diff --git a/.github/ISSUE_TEMPLATE/bug-report.md b/.github/ISSUE_TEMPLATE/bug-report.md deleted file mode 100644 index c31cdb8f6..000000000 --- a/.github/ISSUE_TEMPLATE/bug-report.md +++ /dev/null @@ -1,50 +0,0 @@ ---- -name: 问题报告 / Bug report -about: 创建问题报告以帮助我们改进 / Create a report to help us improve -title: '' -labels: bug -assignees: '' - ---- - -**描述问题 / Describe the bug** - - -**重现步骤 / To Reproduce** - - -**预期的行为 / Expected behavior** - - -**日志 / Log** - - -**截图 / Screenshots** - - -**设备信息 / Device Infomation** - -- 操作系统 / OS: [e.g. Android 13 (MIUI 14)] -- 应用版本 / App Version: [e.g. 0.0.6-g024241cf-release] - -**附加信息 / Additional context** - diff --git a/.github/ISSUE_TEMPLATE/bug_report.yaml b/.github/ISSUE_TEMPLATE/bug_report.yaml new file mode 100644 index 000000000..9a79cd21b --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yaml @@ -0,0 +1,83 @@ +name: 问题报告 / Bug Report +description: 创建问题报告以帮助我们改进 / Create a bug report to help us improve +labels: + - bug + +body: + - type: textarea + id: summary + attributes: + label: 摘要 / Summary + description: 简要描述遇到的问题。 / Briefly describe the bug. + validations: + required: true + + - type: textarea + id: step_to_reproduce + attributes: + label: 重现步骤 / Steps to Reproduce + description: 如何重现该问题。 / How to reproduce the bug. + placeholder: | + 1. 打开某界面 / Open page ... + 2. 点击某菜单 / Click menu ... + 3. 某处出问题 / Something went wrong ... + validations: + required: true + + - type: textarea + id: expected_behavior + attributes: + label: 预期行为 / Expected Behavior + description: 完成上述步骤后应该发生什么。 / What is expected to happen after the steps above. + validations: + required: true + + - type: textarea + id: log + attributes: + label: 日志 / Log + description: 附上日志以帮助定位问题。 / Attach log to help locate the bug. + validations: + required: false + + - type: textarea + id: screenshot + attributes: + label: 截图 / Screenshot + description: 附上截图以帮助解释问题。 / Attach screenshots to help explain the bug. + validations: + required: false + + - type: textarea + id: additional_context + attributes: + label: 附加信息 / Additional Context + description: 与此问题相关的上下文信息,比如在问题出现前做了什么。 / Additional context about the bug, eg. what did you do before the bug appears. + validations: + required: false + + - type: markdown + attributes: + value: | + ### 设备信息 / Device Infomation + + - type: input + id: os_version + attributes: + label: 系统版本 / OS Version + validations: + required: true + + - type: input + id: app_version + attributes: + label: 应用版本 / App Version + validations: + required: true + + - type: input + id: plugins_version + attributes: + label: 插件版本 / Plugins Version + validations: + required: false diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml index 99d680b0a..35299216a 100644 --- a/.github/ISSUE_TEMPLATE/config.yml +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -1,2 +1,14 @@ blank_issues_enabled: false +contact_links: + - name: GitHub 讨论区 / GitHub Discussions + url: https://github.com/fcitx5-android/fcitx5-android/discussions + about: 请在这里提出有关如何使用本输入法的疑问。 / Please ask questions about how to use the input method here. + + - name: Telegram 群组 / Telegram Group + url: https://t.me/fcitx5_android_group + about: 也可以群组中提问或讨论新功能。 / You may also ask questions or discuss new features in the group. + + - name: Matrix 房间 / Matrix Room + url: https://matrix.to/#/#fcitx5-android:mozilla.org + about: Matrix 房间与 Telegram 群组通过桥接机器人互通。 / Matrix Room and Telegram Group are connected through bridge bot. diff --git a/.github/ISSUE_TEMPLATE/feature-request.md b/.github/ISSUE_TEMPLATE/feature-request.md deleted file mode 100644 index ffc3e2496..000000000 --- a/.github/ISSUE_TEMPLATE/feature-request.md +++ /dev/null @@ -1,31 +0,0 @@ ---- -name: 功能请求 / Feature request -about: 为本项目提供建议和意见 / Suggest an idea for this project -title: '' -labels: enhancement -assignees: '' - ---- - -**描述你请求的功能是否和问题有关 / Is your feature request related to a problem? Please describe.** - - -**描述解决方案 / Describe the solution you'd like** - - -**描述替代方案 / Describe alternatives you've considered** - - -**附加信息 / Additional context** - diff --git a/.github/ISSUE_TEMPLATE/feature_request.yaml b/.github/ISSUE_TEMPLATE/feature_request.yaml new file mode 100644 index 000000000..30f66b8bf --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yaml @@ -0,0 +1,55 @@ +name: 功能请求 / Feature Request +description: 为本项目提供新功能建议 / Suggest a new feature for this project +labels: + - enhancement + +body: + - type: textarea + id: summary + attributes: + label: 摘要 / Summary + description: 新功能应该做什么。 / What the new feature should do. + validations: + required: true + + - type: textarea + id: alternative + attributes: + label: 替代方案 / Alternative Solution + description: 其它可能的解决方案(如果有)。 / Other possible solutions, if any. + validations: + required: false + + - type: textarea + id: additional_context + attributes: + label: 附加信息 / Additional Context + description: 与此功能请求有关的上下文信息或截图。 / Additional context or screenshots about the feature request. + validations: + required: false + + - type: markdown + attributes: + value: | + ### 设备信息 / Device Infomation + + - type: input + id: os_version + attributes: + label: 系统版本 / OS Version + validations: + required: true + + - type: input + id: app_version + attributes: + label: 应用版本 / App Version + validations: + required: true + + - type: input + id: plugins_version + attributes: + label: 插件版本 / Plugins Version + validations: + required: false diff --git a/.github/workflows/fdroid.yml b/.github/workflows/fdroid.yml index eb5d194ea..8d612b32e 100644 --- a/.github/workflows/fdroid.yml +++ b/.github/workflows/fdroid.yml @@ -20,8 +20,8 @@ defaults: jobs: fdroid-build: - runs-on: ubuntu-latest - container: registry.gitlab.com/fdroid/fdroidserver:buildserver-bullseye + runs-on: ubuntu-24.04 + container: registry.gitlab.com/fdroid/fdroidserver:buildserver-bookworm strategy: matrix: abi: @@ -66,6 +66,8 @@ jobs: BUILD_NUMBER: ${{ inputs.build_number || 'lastSuccessfulBuild' }} run: | set -x + # prevent prebuilder from writing to build summary file + unset GITHUB_ACTIONS GITHUB_STEP_SUMMARY curl -Lo /usr/bin/yq "https://github.com/mikefarah/yq/releases/latest/download/yq_linux_amd64" chmod +x /usr/bin/yq build_metadata=$(curl "https://jenkins.fcitx-im.org/job/android/job/fcitx5-android/$BUILD_NUMBER/artifact/out/build-metadata.json") @@ -115,7 +117,7 @@ jobs: $fdroid build --verbose --test --scan-binary --on-server --no-tarball $build - name: Upload Artifact - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 if: ${{ success() || failure() }} with: name: fdroid-${{ matrix.abi }} diff --git a/.github/workflows/nix.yml b/.github/workflows/nix.yml index 22ae1a897..01968ac76 100644 --- a/.github/workflows/nix.yml +++ b/.github/workflows/nix.yml @@ -7,24 +7,21 @@ on: jobs: develop: - strategy: - matrix: - os: [ubuntu-latest, macOS-latest] - runs-on: ${{ matrix.os }} + runs-on: ubuntu-24.04 steps: - name: Fetch source code uses: actions/checkout@v4 with: fetch-depth: 0 submodules: recursive - - uses: cachix/install-nix-action@v23 + - uses: cachix/install-nix-action@v31 with: github_access_token: ${{ secrets.GITHUB_TOKEN }} - - uses: cachix/cachix-action@v12 + - uses: cachix/cachix-action@v16 with: name: fcitx5-android authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}" - - name: Build Debug APK + - name: Build Release APK run: | - nix develop .#noAS --command ./gradlew :app:assembleDebug - nix develop .#noAS --command ./gradlew :assembleDebugPlugins + nix develop .#noAS --command ./gradlew :app:assembleRelease + nix develop .#noAS --command ./gradlew :assembleReleasePlugins diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index c49702b34..43438a178 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -10,10 +10,7 @@ on: jobs: publish: - runs-on: ubuntu-22.04 - env: - GITHUB_TOKEN: ${{ secrets.PACKAGE_TOKEN }} - GITHUB_ACTOR: android-fcitx5 + runs-on: ubuntu-24.04 steps: - name: Fetch source code uses: actions/checkout@v4 @@ -21,30 +18,30 @@ jobs: fetch-depth: 0 submodules: recursive + - name: Install system dependencies + run: | + sudo apt update + sudo apt install extra-cmake-modules gettext + - name: Setup Java - uses: actions/setup-java@v3 + uses: actions/setup-java@v4 with: distribution: "temurin" java-version: "17" - name: Setup Android environment uses: android-actions/setup-android@v3 - - - name: Install Android NDK - run: | - sdkmanager --install "cmake;3.22.1" - - - name: Install system dependencies - run: | - sudo apt update - sudo apt install extra-cmake-modules gettext + with: + packages: cmake;3.31.6 - name: Setup Gradle - uses: gradle/gradle-build-action@v2 + uses: gradle/actions/setup-gradle@v4 - name: Publish build convention and libs + env: + GITHUB_TOKEN: ${{ secrets.PACKAGE_TOKEN }} + GITHUB_ACTOR: fcitx5-android-bot run: | ./gradlew :build-logic:convention:publish ./gradlew :lib:common:publish ./gradlew :lib:plugin-base:publish - diff --git a/.github/workflows/pull_request.yml b/.github/workflows/pull_request.yml index d527bbc3d..dcc86c2e1 100644 --- a/.github/workflows/pull_request.yml +++ b/.github/workflows/pull_request.yml @@ -12,18 +12,13 @@ jobs: build_pull_request: runs-on: ${{ matrix.os }} strategy: + fail-fast: false matrix: os: - - ubuntu-22.04 + - ubuntu-24.04 - macos-13 + - macos-14 - windows-2022 - abi: - - armeabi-v7a - - arm64-v8a - - x86 - - x86_64 - env: - BUILD_ABI: ${{ matrix.abi }} steps: - name: Fetch source code uses: actions/checkout@v4 @@ -31,14 +26,8 @@ jobs: fetch-depth: 0 submodules: recursive - - name: Regenerate symlinks pointing to submodule (Windows) - if: ${{ matrix.os == 'windows-2022' }} - run: | - Remove-Item -Recurse app/src/main/assets/usr/share, plugin/hangul/src/main/assets/usr/share/libhangul, plugin/chewing/src/main/assets/usr/share/libchewing, plugin/jyutping/src/main/assets/usr/share/libime - git checkout -- . - - name: Setup Java - uses: actions/setup-java@v3 + uses: actions/setup-java@v4 with: distribution: "temurin" java-version: "17" @@ -48,38 +37,39 @@ jobs: - name: Install Android NDK run: | - sdkmanager --install "cmake;3.22.1" + sdkmanager --install "cmake;3.31.6" - name: Install system dependencies (Ubuntu) - if: ${{ matrix.os == 'ubuntu-22.04' }} + if: ${{ startsWith(matrix.os, 'ubuntu') }} run: | sudo apt update sudo apt install extra-cmake-modules gettext - name: Install system dependencies (macOS) - if: ${{ matrix.os == 'macos-13' }} + if: ${{ startsWith(matrix.os, 'macos') }} run: | brew install extra-cmake-modules - name: Install system dependencies (Windows) - if: ${{ matrix.os == 'windows-2022' }} + if: ${{ startsWith(matrix.os, 'windows') }} run: | + C:/msys64/usr/bin/pacman -Syu --noconfirm C:/msys64/usr/bin/pacman -S --noconfirm mingw-w64-ucrt-x86_64-gettext mingw-w64-ucrt-x86_64-extra-cmake-modules Add-Content $env:GITHUB_PATH "C:/msys64/ucrt64/bin" - name: Setup Gradle - uses: gradle/gradle-build-action@v2 + uses: gradle/actions/setup-gradle@v4 - - name: Build Debug APK + - name: Build Release APK run: | - ./gradlew :app:assembleDebug - ./gradlew :assembleDebugPlugins + ./gradlew :app:assembleRelease + ./gradlew :assembleReleasePlugins - name: Upload app - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 with: - name: app-${{ matrix.os }}-${{ matrix.abi }} - path: app/build/outputs/apk/debug/ + name: app-${{ matrix.os }} + path: app/build/outputs/apk/release/ - name: Pack plugins shell: bash @@ -89,12 +79,12 @@ jobs: do if [ -d "plugin/${i}" ] then - mv "plugin/${i}/build/outputs/apk/debug" "plugins-to-upload/${i}" + mv "plugin/${i}/build/outputs/apk/release" "plugins-to-upload/${i}" fi done - name: Upload plugins - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 with: - name: plugins-${{ matrix.os }}-${{ matrix.abi }} + name: plugins-${{ matrix.os }} path: plugins-to-upload diff --git a/.gitignore b/.gitignore index 7538326e1..0bd6d8615 100644 --- a/.gitignore +++ b/.gitignore @@ -1,21 +1,12 @@ # Module :app # Installed data -/app/src/main/assets/usr/share/fcitx5/addon -/app/src/main/assets/usr/share/fcitx5/chttrans -/app/src/main/assets/usr/share/fcitx5/data -/app/src/main/assets/usr/share/fcitx5/default -/app/src/main/assets/usr/share/fcitx5/inputmethod -/app/src/main/assets/usr/share/fcitx5/lua -/app/src/main/assets/usr/share/fcitx5/punctuation -/app/src/main/assets/usr/share/fcitx5/unicode -/app/src/main/assets/usr/share/locale +/app/src/main/assets/usr/ # Generated asset descriptor /app/src/main/assets/descriptor.json # Plugins # Installed data -/plugin/*/src/main/assets/usr/share/fcitx5 -/plugin/*/src/main/assets/usr/share/locale +/plugin/*/src/main/assets/usr/ # Generated asset descriptor /plugin/*/src/main/assets/descriptor.json @@ -108,3 +99,6 @@ lint/tmp/ # Android Profiling *.hprof + +### Kotlin ### +.kotlin/ diff --git a/.gitmodules b/.gitmodules index 3bd559222..2f3d38b6d 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,51 +1,58 @@ [submodule "lib/fcitx5/src/main/cpp/fcitx5"] path = lib/fcitx5/src/main/cpp/fcitx5 - url = git@github.com:fcitx/fcitx5.git + url = https://github.com/fcitx/fcitx5.git [submodule "lib/fcitx5/src/main/cpp/prebuilt"] path = lib/fcitx5/src/main/cpp/prebuilt - url = git@github.com:fcitx5-android/prebuilt.git + url = https://github.com/fcitx5-android/prebuilt.git + shallow = true [submodule "lib/fcitx5-lua/src/main/cpp/fcitx5-lua"] path = lib/fcitx5-lua/src/main/cpp/fcitx5-lua - url = git@github.com:fcitx/fcitx5-lua.git + url = https://github.com/fcitx/fcitx5-lua.git [submodule "lib/libime/src/main/cpp/libime"] path = lib/libime/src/main/cpp/libime - url = git@github.com:fcitx/libime.git + url = https://github.com/fcitx/libime.git [submodule "lib/fcitx5-chinese-addons/src/main/cpp/fcitx5-chinese-addons"] path = lib/fcitx5-chinese-addons/src/main/cpp/fcitx5-chinese-addons - url = git@github.com:fcitx/fcitx5-chinese-addons.git + url = https://github.com/fcitx/fcitx5-chinese-addons.git [submodule "plugin/anthy/src/main/cpp/anthy-cmake"] path = plugin/anthy/src/main/cpp/anthy-cmake - url = git@github.com:fcitx5-android/anthy-cmake.git + url = https://github.com/fcitx5-android/anthy-cmake.git [submodule "plugin/anthy/src/main/cpp/fcitx5-anthy"] path = plugin/anthy/src/main/cpp/fcitx5-anthy - url = git@github.com:fcitx/fcitx5-anthy.git + url = https://github.com/fcitx/fcitx5-anthy.git [submodule "plugin/unikey/src/main/cpp/fcitx5-unikey"] path = plugin/unikey/src/main/cpp/fcitx5-unikey - url = git@github.com:fcitx/fcitx5-unikey.git + url = https://github.com/fcitx/fcitx5-unikey.git [submodule "plugin/rime/src/main/cpp/fcitx5-rime"] path = plugin/rime/src/main/cpp/fcitx5-rime - url = git@github.com:fcitx/fcitx5-rime.git + url = https://github.com/fcitx/fcitx5-rime.git [submodule "plugin/rime/src/main/cpp/rime-prelude"] path = plugin/rime/src/main/cpp/rime-prelude - url = git@github.com:rime/rime-prelude.git + url = https://github.com/rime/rime-prelude.git [submodule "plugin/rime/src/main/cpp/rime-essay"] path = plugin/rime/src/main/cpp/rime-essay - url = git@github.com:rime/rime-essay.git + url = https://github.com/rime/rime-essay.git [submodule "plugin/rime/src/main/cpp/rime-luna-pinyin"] path = plugin/rime/src/main/cpp/rime-luna-pinyin - url = git@github.com:rime/rime-luna-pinyin.git + url = https://github.com/rime/rime-luna-pinyin.git [submodule "plugin/rime/src/main/cpp/rime-stroke"] path = plugin/rime/src/main/cpp/rime-stroke - url = git@github.com:rime/rime-stroke.git + url = https://github.com/rime/rime-stroke.git [submodule "plugin/hangul/src/main/cpp/fcitx5-hangul"] path = plugin/hangul/src/main/cpp/fcitx5-hangul - url = git@github.com:fcitx/fcitx5-hangul.git + url = https://github.com/fcitx/fcitx5-hangul.git [submodule "plugin/chewing/src/main/cpp/fcitx5-chewing"] path = plugin/chewing/src/main/cpp/fcitx5-chewing - url = git@github.com:fcitx/fcitx5-chewing.git + url = https://github.com/fcitx/fcitx5-chewing.git [submodule "plugin/sayura/src/main/cpp/fcitx5-sayura"] path = plugin/sayura/src/main/cpp/fcitx5-sayura - url = git@github.com:fcitx/fcitx5-sayura.git + url = https://github.com/fcitx/fcitx5-sayura.git [submodule "plugin/jyutping/src/main/cpp/libime-jyutping"] path = plugin/jyutping/src/main/cpp/libime-jyutping - url = git@github.com:fcitx/libime-jyutping.git + url = https://github.com/fcitx/libime-jyutping.git +[submodule "plugin/clipboard-filter/ClearURLsRules"] + path = plugin/clipboard-filter/ClearURLsRules + url = https://github.com/ClearURLs/Rules.git +[submodule "plugin/thai/src/main/cpp/fcitx5-libthai"] + path = plugin/thai/src/main/cpp/fcitx5-libthai + url = https://github.com/fcitx/fcitx5-libthai diff --git a/.idea/codeStyles/Project.xml b/.idea/codeStyles/Project.xml index b38416157..cf0517db7 100644 --- a/.idea/codeStyles/Project.xml +++ b/.idea/codeStyles/Project.xml @@ -1,5 +1,40 @@ + + + \ No newline at end of file diff --git a/README.md b/README.md index eaca2a140..85f9bba0f 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,7 @@ GitHub: [![release version](https://img.shields.io/github/v/release/fcitx5-andro - Japanese (via [Anthy Plugin](./plugin/anthy)) - Korean (via [Hangul Plugin](./plugin/hangul)) - Sinhala (via [Sayura Plugin](./plugin/sayura)) +- Thai (via [Thai Plugin](./plugin/thai)) - Generic (via [RIME Plugin](./plugin/rime), supports importing custom schemas) ### Implemented Features @@ -35,7 +36,7 @@ GitHub: [![release version](https://img.shields.io/github/v/release/fcitx5-andro - Virtual Keyboard (layout not customizable yet) - Expandable candidate view - Clipboard management (plain text only) -- Theming (custom color scheme and background image) +- Theming (custom color scheme, background image and dynamic color aka monet color after Android 12) - Popup preview on key press - Long press popup keyboard for convenient symbol input - Symbol and Emoji picker @@ -62,14 +63,14 @@ Trello kanban: https://trello.com/b/gftk6ZdV/kanban Matrix Room: https://matrix.to/#/#fcitx5-android:mozilla.org -Discuss on Telegram: https://t.me/+hci-DrFVWUM3NTUx ([@fcitx5_android](https://t.me/fcitx5_android) originally) +Discuss on Telegram: [@fcitx5_android_group](https://t.me/fcitx5_android_group) ([@fcitx5_android](https://t.me/fcitx5_android) originally) ## Build ### Dependencies -- Android SDK Platform & Build-Tools 33. -- Android NDK (Side by side) 25 & CMake 3.22.1, they can be installed using SDK Manager in Android Studio or `sdkmanager` command line. **Note:** NDK 21 & 22 are confirmed not working with this project. +- Android SDK Platform & Build-Tools 35. +- Android NDK (Side by side) 25 & CMake 3.22.1, they can be installed using SDK Manager in Android Studio or `sdkmanager` command line. - [KDE/extra-cmake-modules](https://github.com/KDE/extra-cmake-modules) - GNU Gettext >= 0.20 (for `msgfmt` binary; or install `appstream` if you really have to use gettext <= 0.19.) @@ -82,7 +83,7 @@ Discuss on Telegram: https://t.me/+hci-DrFVWUM3NTUx ([@fcitx5_android](https://t - Enable symlink support for `git`: - ```powershell + ```shell git config --global core.symlinks true ``` @@ -90,24 +91,14 @@ Discuss on Telegram: https://t.me/+hci-DrFVWUM3NTUx ([@fcitx5_android](https://t First, clone this repository and fetch all submodules: -```sh +```shell git clone git@github.com:fcitx5-android/fcitx5-android.git git submodule update --init --recursive ``` -
-On Windows, you may need to regenerate symlinks to submodules. - -```powershell -Remove-Item -Recurse app/src/main/assets/usr/share, plugin/hangul/src/main/assets/usr/share/libhangul, plugin/chewing/src/main/assets/usr/share/libchewing, plugin/jyutping/src/main/assets/usr/share/libime -git checkout -- . -``` - -
- Install `extra-cmake-modules` and `gettext` with your system package manager: -```sh +```shell # For Arch Linux (Arch has gettext in it's base meta package) sudo pacman -S extra-cmake-modules @@ -119,7 +110,7 @@ brew install extra-cmake-modules gettext # For Windows, install MSYS2 and execute in its shell (UCRT64) pacman -S mingw-w64-ucrt-x86_64-extra-cmake-modules mingw-w64-ucrt-x86_64-gettext -# then add C:/msys64/ucrt64/bin to PATH +# then add C:\msys64\ucrt64\bin to PATH ``` Install Android SDK Platform, Android SDK Build-Tools, Android NDK and cmake via SDK Manager in Android Studio: @@ -148,7 +139,7 @@ The current recommended versions are recorded in [Versions.kt](build-logic/conve Switch to "Project" view in the "Project" tool window (namely the file tree side bar), right click `lib/fcitx5/src/main/cpp/prebuilt` directory, then select "Mark Directory as > Excluded". You may also need to restart the IDE to interrupt ongoing indexing process. -- Gradle error: "No variants found for ':app'. Check build files to ensure at least one variant exists." +- Gradle error: "No variants found for ':app'. Check build files to ensure at least one variant exists." or "[CXX1210] /CMakeLists.txt debug|arm64-v8a : No compatible library found" Examine if there are environment variables set such as `_JAVA_OPTIONS` or `JAVA_TOOL_OPTIONS`. You might want to clear them (maybe in the startup script `studio.sh` of Android Studio), as some gradle plugin treats anything in stderr as errors and aborts. diff --git a/app/build.gradle.kts b/app/build.gradle.kts index fc6c1364d..1f435a29b 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -1,5 +1,3 @@ -@file:Suppress("UnstableApiUsage") - plugins { id("org.fcitx.fcitx5.android.app-convention") id("org.fcitx.fcitx5.android.native-app-convention") @@ -18,6 +16,7 @@ android { applicationId = "org.fcitx.fcitx5.android" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + @Suppress("UnstableApiUsage") externalNativeBuild { cmake { targets( @@ -64,21 +63,22 @@ android { kotlin { sourceSets.configureEach { - kotlin.srcDir("$buildDir/generated/ksp/$name/kotlin/") + kotlin.srcDir(layout.buildDirectory.dir("generated/ksp/$name/kotlin")) } } -aboutLibraries { - configPath = "app/licenses" -} - fcitxComponent { - installLibraries = listOf( + includeLibs = listOf( "fcitx5", "fcitx5-lua", "libime", "fcitx5-chinese-addons" ) + // exclude (delete immediately after install) tables that nobody would use + excludeFiles = listOf("cangjie", "erbi", "qxm", "wanfeng").map { + "usr/share/fcitx5/inputmethod/$it.conf" + } + installPrebuiltAssets = true } ksp { @@ -114,9 +114,11 @@ dependencies { implementation(libs.androidx.room.runtime) implementation(libs.androidx.room.ktx) implementation(libs.androidx.room.paging) + implementation(libs.androidx.startup) implementation(libs.androidx.viewpager2) implementation(libs.material) - implementation(libs.arrow) + implementation(libs.arrow.core) + implementation(libs.arrow.functions) implementation(libs.imagecropper) implementation(libs.flexbox) implementation(libs.dependency) diff --git a/app/licenses/libraries/boost.json b/app/licenses/libraries/boost.json index 7a2285fb6..cebc09b62 100644 --- a/app/licenses/libraries/boost.json +++ b/app/licenses/libraries/boost.json @@ -1,6 +1,6 @@ { "uniqueId": "boostorg/boost", - "artifactVersion": "1.83.0", + "artifactVersion": "1.86.0", "description": "Free peer-reviewed portable C++ source libraries", "name": "boostorg/boost", "website": "https://www.boost.org/", diff --git a/app/licenses/libraries/fcitx5-chinese-addons.json b/app/licenses/libraries/fcitx5-chinese-addons.json index f1edc7feb..8200bcbed 100644 --- a/app/licenses/libraries/fcitx5-chinese-addons.json +++ b/app/licenses/libraries/fcitx5-chinese-addons.json @@ -1,6 +1,6 @@ { "uniqueId": "fcitx/fcitx5-chinese-addons", - "artifactVersion": "5.1.2", + "artifactVersion": "5.1.7", "description": "Chinese related addon for fcitx5", "name": "fcitx/fcitx5-chinese-addons", "website": "https://github.com/fcitx/fcitx5-chinese-addons", diff --git a/app/licenses/libraries/fcitx5-lua.json b/app/licenses/libraries/fcitx5-lua.json index 1d3e279ad..e3ff2ca38 100644 --- a/app/licenses/libraries/fcitx5-lua.json +++ b/app/licenses/libraries/fcitx5-lua.json @@ -1,6 +1,6 @@ { "uniqueId": "fcitx/fcitx5-lua", - "artifactVersion": "5.0.11", + "artifactVersion": "5.0.14", "description": "Lua support for fcitx5", "name": "fcitx/fcitx5-lua", "website": "https://github.com/fcitx/fcitx5-lua", diff --git a/app/licenses/libraries/fcitx5.json b/app/licenses/libraries/fcitx5.json index 0683c6262..f20f85043 100644 --- a/app/licenses/libraries/fcitx5.json +++ b/app/licenses/libraries/fcitx5.json @@ -1,6 +1,6 @@ { "uniqueId": "fcitx/fcitx5", - "artifactVersion": "5.1.5", + "artifactVersion": "5.1.12", "description": "Next generation of fcitx", "name": "fcitx/fcitx5", "website": "https://github.com/fcitx/fcitx5", diff --git a/app/licenses/libraries/fmt.json b/app/licenses/libraries/fmt.json index b69becddf..07f2dbdc1 100644 --- a/app/licenses/libraries/fmt.json +++ b/app/licenses/libraries/fmt.json @@ -1,6 +1,6 @@ { "uniqueId": "fmtlib/fmt", - "artifactVersion": "9.1.0", + "artifactVersion": "11.0.2", "description": "Open-source formatting library for C++", "name": "fmtlib/fmt", "website": "https://fmt.dev", diff --git a/app/licenses/libraries/libevent.json b/app/licenses/libraries/libevent.json deleted file mode 100644 index b7ed73770..000000000 --- a/app/licenses/libraries/libevent.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "uniqueId": "libevent/libevent", - "artifactVersion": "release-2.1.12-stable", - "description": "Event notification library", - "name": "libevent/libevent", - "website": "https://libevent.org/", - "tag": "native", - "licenses": [ - "BSD-3-Clause" - ] -} diff --git a/app/licenses/libraries/libime.json b/app/licenses/libraries/libime.json index 899f13945..18507f632 100644 --- a/app/licenses/libraries/libime.json +++ b/app/licenses/libraries/libime.json @@ -1,6 +1,6 @@ { "uniqueId": "fcitx/libime", - "artifactVersion": "1.1.3", + "artifactVersion": "1.1.10", "description": "library to support generic input method implementation", "name": "fcitx/libime", "website": "https://github.com/fcitx/libime", diff --git a/app/licenses/libraries/libintl-lite.json b/app/licenses/libraries/libintl-lite.json index 86daf4cce..e08b58baf 100644 --- a/app/licenses/libraries/libintl-lite.json +++ b/app/licenses/libraries/libintl-lite.json @@ -1,6 +1,6 @@ { "uniqueId": "j-jorge/libintl-lite", - "artifactVersion": "5750d92", + "artifactVersion": "ba15146", "description": "simple (but less powerful) GNU gettext libintl replacement", "name": "j-jorge/libintl-lite", "website": "https://github.com/j-jorge/libintl-lite", diff --git a/app/licenses/libraries/libuv.json b/app/licenses/libraries/libuv.json new file mode 100644 index 000000000..65d992b93 --- /dev/null +++ b/app/licenses/libraries/libuv.json @@ -0,0 +1,11 @@ +{ + "uniqueId": "libuv/libuv", + "artifactVersion": "1.49.2", + "description": "Cross-platform asynchronous I/O", + "name": "libuv/libuv", + "website": "https://libuv.org/", + "tag": "native", + "licenses": [ + "MIT" + ] +} diff --git a/app/licenses/libraries/lua.json b/app/licenses/libraries/lua.json index 386077cda..11c9ef4c7 100644 --- a/app/licenses/libraries/lua.json +++ b/app/licenses/libraries/lua.json @@ -1,6 +1,6 @@ { "uniqueId": "lua/lua", - "artifactVersion": "5.4.6", + "artifactVersion": "5.4.7", "description": "Powerful lightweight programming language designed for extending applications", "name": "lua/lua", "website": "https://www.lua.org/", diff --git a/app/licenses/libraries/opencc.json b/app/licenses/libraries/opencc.json index 5aa3ea77b..ed4109ee0 100644 --- a/app/licenses/libraries/opencc.json +++ b/app/licenses/libraries/opencc.json @@ -1,6 +1,6 @@ { "uniqueId": "BYVoid/OpenCC", - "artifactVersion": "1.1.7", + "artifactVersion": "1.1.9", "description": "opensource project for conversions between Traditional Chinese, Simplified Chinese and Japanese Kanji (Shinjitai).", "name": "BYVoid/OpenCC", "website": "https://opencc.byvoid.com/", diff --git a/app/org.fcitx.fcitx5.android.yml b/app/org.fcitx.fcitx5.android.yml index 62fdba70e..7c2ffc2eb 100644 --- a/app/org.fcitx.fcitx5.android.yml +++ b/app/org.fcitx.fcitx5.android.yml @@ -20,11 +20,10 @@ Builds: submodules: true sudo: - apt-get update - - apt-get install -y g++ libtool make automake gettext bzip2 xz-utils pkg-config - cmake extra-cmake-modules ninja-build libfmt-dev libboost-all-dev libfcitx5utils-dev opencc openjdk-17-jdk-headless - ghc cabal-install libghc-shake-dev libghc-aeson-pretty-dev libghc-js-flot-data haskell-js-dgtable-utils - - update-java-alternatives -a - - apt-get install -y -t bullseye-backports fcitx5-modules + - apt-get install -y g++ libtool make automake gettext bzip2 xz-utils zstd pkg-config + cmake extra-cmake-modules ninja-build libfmt-dev libsystemd-dev libboost-all-dev + ghc cabal-install libghc-shake-dev libghc-aeson-pretty-dev libghc-js-flot-data haskell-js-dgtable-utils + python-is-python3 opencc gradle: - yes binary: https://jenkins.fcitx-im.org/job/android/job/fcitx5-android/lastSuccessfulBuild/artifact/out/org.fcitx.fcitx5.android-%v-%abi-release.apk @@ -33,7 +32,7 @@ Builds: rm: - lib/fcitx5/src/main/cpp/prebuilt prebuild: - - sdkmanager 'cmake;3.22.1' + - sdkmanager 'cmake;3.31.6' - sed -i -e '/ImportQualifiedPost/d' $$fcitx5-android-prebuilder$$/src/Main.hs - sed -i -e 's/import \(.*\) qualified as/import qualified \1 as/g' $$fcitx5-android-prebuilder$$/src/*.hs - sed -i -e 's|https://maven.pkg.github.com|https://jitpack.io|g' ../build-logic/convention/build.gradle.kts @@ -44,13 +43,11 @@ Builds: - build-logic/convention/build build: - pushd $$fcitx5-android-prebuilder$$ - - cabal configure --disable-library-vanilla --enable-shared --enable-executable-dynamic --ghc-options=-dynamic - - ABI=%abi ANDROID_NDK_ROOT=$$NDK$$ CMAKE_VERSION=3.22.1 ANDROID_PLATFORM=23 - COMP_SPELL_DICT=/usr/lib/x86_64-linux-gnu/fcitx5/libexec/comp-spell-dict - ./build-cabal -j spell-dict fmt libevent libintl-lite boost marisa opencc libime lua chinese-addons-data zstd + - ABI=%abi ANDROID_NDK_ROOT=$$NDK$$ CMAKE_VERSION=3.31.6 ANDROID_PLATFORM=23 + ./build-cabal -j app - popd - mv $$fcitx5-android-prebuilder$$/build ../lib/fcitx5/src/main/cpp/prebuilt - ndk: 25.2.9519653 + ndk: 28.0.13004108 gradleprops: - buildABI=%abi - buildTimestamp=%ts diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro index a853cde43..d154724d5 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -22,8 +22,13 @@ # remove kotlin null checks -assumenosideeffects class kotlin.jvm.internal.Intrinsics { - static void checkParameterIsNotNull(java.lang.Object, java.lang.String); - static void checkNotNullParameter(java.lang.Object, java.lang.String); + static void checkNotNull(...); + static void checkExpressionValueIsNotNull(...); + static void checkNotNullExpressionValue(...); + static void checkReturnedValueIsNotNull(...); + static void checkFieldIsNotNull(...); + static void checkParameterIsNotNull(...); + static void checkNotNullParameter(...); } # Uncomment this to preserve the line number information for diff --git a/app/schemas/org.fcitx.fcitx5.android.data.clipboard.db.ClipboardDatabase/4.json b/app/schemas/org.fcitx.fcitx5.android.data.clipboard.db.ClipboardDatabase/4.json new file mode 100644 index 000000000..14363cb60 --- /dev/null +++ b/app/schemas/org.fcitx.fcitx5.android.data.clipboard.db.ClipboardDatabase/4.json @@ -0,0 +1,74 @@ +{ + "formatVersion": 1, + "database": { + "version": 4, + "identityHash": "b0fe6cdac09e0d7deaff17d8b45fe565", + "entities": [ + { + "tableName": "clipboard", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `text` TEXT NOT NULL, `pinned` INTEGER NOT NULL, `timestamp` INTEGER NOT NULL DEFAULT -1, `type` TEXT NOT NULL DEFAULT 'text/plain', `deleted` INTEGER NOT NULL DEFAULT 0, `sensitive` INTEGER NOT NULL DEFAULT 0)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "text", + "columnName": "text", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pinned", + "columnName": "pinned", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "-1" + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'text/plain'" + }, + { + "fieldPath": "deleted", + "columnName": "deleted", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "sensitive", + "columnName": "sensitive", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'b0fe6cdac09e0d7deaff17d8b45fe565')" + ] + } +} \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 7c16296c4..fcc089b60 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -21,6 +21,9 @@ + + + - + android:name=".ui.main.CropImageActivity" + android:exported="false" /> + @@ -99,6 +103,10 @@ + + + + @@ -116,7 +124,7 @@ @@ -131,7 +139,8 @@ + android:permission="${applicationId}.permission.IPC" + tools:ignore="SystemPermissionTypo"> @@ -148,18 +157,23 @@ + + + + + + - - ${CMAKE_LIBRARY_OUTPUT_DIRECTORY} - COMMAND ${CMAKE_COMMAND} -E copy $ ${CMAKE_LIBRARY_OUTPUT_DIRECTORY} - COMMAND ${CMAKE_COMMAND} -E copy $ ${CMAKE_LIBRARY_OUTPUT_DIRECTORY} - COMMAND ${CMAKE_COMMAND} -E copy $ ${CMAKE_LIBRARY_OUTPUT_DIRECTORY} - COMMAND ${CMAKE_COMMAND} -E copy $ ${CMAKE_LIBRARY_OUTPUT_DIRECTORY} - # fcitx5-lua - COMMAND ${CMAKE_COMMAND} -E copy $ ${CMAKE_LIBRARY_OUTPUT_DIRECTORY} - # fcitx5-chinese-addons - COMMAND ${CMAKE_COMMAND} -E copy $ ${CMAKE_LIBRARY_OUTPUT_DIRECTORY} - COMMAND ${CMAKE_COMMAND} -E copy $ ${CMAKE_LIBRARY_OUTPUT_DIRECTORY} - COMMAND ${CMAKE_COMMAND} -E copy $ ${CMAKE_LIBRARY_OUTPUT_DIRECTORY} - COMMAND ${CMAKE_COMMAND} -E copy $ ${CMAKE_LIBRARY_OUTPUT_DIRECTORY} - COMMAND ${CMAKE_COMMAND} -E copy $ ${CMAKE_LIBRARY_OUTPUT_DIRECTORY} - COMMAND ${CMAKE_COMMAND} -E copy $ ${CMAKE_LIBRARY_OUTPUT_DIRECTORY} - COMMAND ${CMAKE_COMMAND} -E copy $ ${CMAKE_LIBRARY_OUTPUT_DIRECTORY} + COMMAND ${CMAKE_COMMAND} -E copy_if_different + $ + $ + $ + $ + $ + $ + $ + $ + $ + $ + $ + $ + $ + ${CMAKE_LIBRARY_OUTPUT_DIRECTORY} COMMENT "Copying fcitx5 module libraries to :app" ) + +# install prebuilt assets +install(FILES "${PREBUILT_DIR}/spell-dict/en_dict.fscd" DESTINATION "${FCITX_INSTALL_PKGDATADIR}/spell" COMPONENT prebuilt-assets) +install(FILES "${PREBUILT_DIR}/chinese-addons-data/pinyin/chaizi.dict" DESTINATION "${FCITX_INSTALL_PKGDATADIR}/pinyin" COMPONENT prebuilt-assets) +install(DIRECTORY "${PREBUILT_DIR}/chinese-addons-data/pinyinhelper" DESTINATION "${FCITX_INSTALL_PKGDATADIR}" COMPONENT prebuilt-assets) +install(DIRECTORY "${PREBUILT_DIR}/libime/table" DESTINATION "${FCITX_INSTALL_PKGDATADIR}" COMPONENT prebuilt-assets) +install(DIRECTORY "${PREBUILT_DIR}/libime/data/" DESTINATION "${FCITX_INSTALL_DATADIR}/libime" COMPONENT prebuilt-assets) +install(DIRECTORY "${PREBUILT_DIR}/opencc/data/" DESTINATION "${FCITX_INSTALL_DATADIR}/opencc" COMPONENT prebuilt-assets) diff --git a/app/src/main/cpp/androidaddonloader/androidaddonloader.cpp b/app/src/main/cpp/androidaddonloader/androidaddonloader.cpp deleted file mode 100644 index 26ae10387..000000000 --- a/app/src/main/cpp/androidaddonloader/androidaddonloader.cpp +++ /dev/null @@ -1,91 +0,0 @@ -/* - * SPDX-License-Identifier: LGPL-2.1-or-later - * SPDX-FileCopyrightText: Copyright 2016-2016 CSSlayer - * SPDX-FileCopyrightText: Copyright 2023 Fcitx5 for Android Contributors - * SPDX-FileComment: Modified from https://github.com/fcitx/fcitx5/blob/5.1.1/src/lib/fcitx/addonloader.cpp - */ -#include "androidaddonloader.h" - -#define FCITX_LIBRARY_SUFFIX ".so" - -namespace fcitx { - -AndroidSharedLibraryLoader::AndroidSharedLibraryLoader(AndroidLibraryDependency dependency) - : dependency_(std::move(dependency)) {} - -AddonInstance *AndroidSharedLibraryLoader::load(const AddonInfo &info, - AddonManager *manager) { - auto iter = registry_.find(info.uniqueName()); - if (iter == registry_.end()) { - std::string libname = info.library(); - Flags flag = LibraryLoadHint::DefaultHint; - if (stringutils::startsWith(libname, "export:")) { - libname = libname.substr(7); - flag |= LibraryLoadHint::ExportExternalSymbolsHint; - } - auto file = libname + FCITX_LIBRARY_SUFFIX; - auto libs = standardPath_.locateAll(StandardPath::Type::Addon, file); - if (libs.empty()) { - FCITX_ERROR() << "Could not locate library " << file - << " for addon " << info.uniqueName() << "."; - } - // ========== Android specific start ========== // - auto deps = dependency_.find(libname); - if (deps != dependency_.end()) { - for (const auto &dep: deps->second) { - auto depFile = dep + FCITX_LIBRARY_SUFFIX; - auto depPaths = standardPath_.locateAll(StandardPath::Type::Addon, depFile); - if (depPaths.empty()) { - FCITX_ERROR() << "Could not locate dependency " << depFile - << " for library " << file << "."; - } else { - for (const auto &depPath: depPaths) { - Library depLib(depPath); - if (!depLib.load()) { - FCITX_ERROR() << "Failed to load dependency " << depPath - << " for library " << file << "."; - } else { - FCITX_INFO() << "Loaded dependency " << depFile - << " for library " << file << "."; - break; - } - } - } - } - } - // ========== Android specific end ========== // - for (const auto &libraryPath: libs) { - Library lib(libraryPath); - if (!lib.load(flag)) { - FCITX_ERROR() - << "Failed to load library for addon " << info.uniqueName() - << " on " << libraryPath << ". Error: " << lib.error(); - continue; - } - try { - registry_.emplace( - info.uniqueName(), - std::make_unique(std::move(lib))); - } catch (const std::exception &e) { - } - break; - } - iter = registry_.find(info.uniqueName()); - } - - if (iter == registry_.end()) { - return nullptr; - } - - try { - return iter->second->factory()->create(manager); - } catch (const std::exception &e) { - FCITX_ERROR() << "Failed to create addon: " << info.uniqueName() << " " - << e.what(); - } catch (...) { - FCITX_ERROR() << "Failed to create addon: " << info.uniqueName(); - } - return nullptr; -} - -} diff --git a/app/src/main/cpp/androidaddonloader/androidaddonloader.h b/app/src/main/cpp/androidaddonloader/androidaddonloader.h deleted file mode 100644 index ff3a444b9..000000000 --- a/app/src/main/cpp/androidaddonloader/androidaddonloader.h +++ /dev/null @@ -1,60 +0,0 @@ -/* - * SPDX-License-Identifier: LGPL-2.1-or-later - * SPDX-FileCopyrightText: Copyright 2016-2016 CSSlayer - * SPDX-FileCopyrightText: Copyright 2023 Fcitx5 for Android Contributors - * SPDX-FileComment: Modified from https://github.com/fcitx/fcitx5/blob/5.1.1/src/lib/fcitx/addonloader_p.h - */ -#ifndef FCITX5_ANDROID_ANDROIDADDONLOADER_H -#define FCITX5_ANDROID_ANDROIDADDONLOADER_H - -#include -#include -#include -#include -#include -#include -#include -#include - -namespace fcitx { - -class AndroidSharedLibraryFactory { -public: - AndroidSharedLibraryFactory(Library lib) : library_(std::move(lib)) { - auto *funcPtr = library_.resolve("fcitx_addon_factory_instance"); - if (!funcPtr) { - throw std::runtime_error(library_.error()); - } - auto func = Library::toFunction(funcPtr); - factory_ = func(); - if (!factory_) { - throw std::runtime_error("Failed to get a factory"); - } - } - - AddonFactory *factory() { return factory_; } - -private: - Library library_; - AddonFactory *factory_; -}; - -typedef std::unordered_map> AndroidLibraryDependency; - -class AndroidSharedLibraryLoader : public AddonLoader { -public: - AndroidSharedLibraryLoader(AndroidLibraryDependency dependency); - ~AndroidSharedLibraryLoader() = default; - std::string type() const override { return "SharedLibrary"; } - - AddonInstance *load(const AddonInfo &info, AddonManager *manager) override; - -private: - StandardPath standardPath_; - std::unordered_map> registry_; - AndroidLibraryDependency dependency_; -}; - -} - -#endif //FCITX5_ANDROID_ANDROIDADDONLOADER_H diff --git a/app/src/main/cpp/androidfrontend/androidfrontend.cpp b/app/src/main/cpp/androidfrontend/androidfrontend.cpp index 60ce1ae36..620452604 100644 --- a/app/src/main/cpp/androidfrontend/androidfrontend.cpp +++ b/app/src/main/cpp/androidfrontend/androidfrontend.cpp @@ -4,6 +4,7 @@ */ #include #include +#include #include #include #include @@ -49,7 +50,8 @@ class AndroidInputContext : public InputContextV2 { const int before = -offset; const int after = offset + static_cast(size); if (before < 0 || after < 0) { - FCITX_WARN() << "Invalid deleteSurrounding request: offset=" << offset << ", size=" << size; + FCITX_WARN() << "Invalid deleteSurrounding request: offset=" << offset << ", size=" + << size; return; } frontend_->deleteSurrounding(before, after); @@ -60,24 +62,18 @@ class AndroidInputContext : public InputContextV2 { } void updateInputPanel() { - // Normally input method engine should check CapabilityFlag::Preedit before update clientPreedit, - // and fcitx5 won't trigger UpdatePreeditEvent when that flag is not present, in which case - // InputContext::updatePreeditImpl() won't be called. - // However on Android, androidkeyboard uses clientPreedit unconditionally in order to provide - // a more integrated experience, so we need to check clientPreedit update manually even if - // clientPreedit is not enabled. const InputPanel &ip = inputPanel(); - if (!isPreeditEnabled() && frontend_->instance()->inputMethod(this) == "keyboard-us") { - frontend_->updateClientPreedit(filterText(ip.clientPreedit())); - } frontend_->updateInputPanel( filterText(ip.preedit()), filterText(ip.auxUp()), filterText(ip.auxDown()) ); + } + + void updateCandidatesBulk() { std::vector candidates; int size = 0; - const auto &list = ip.candidateList(); + const auto &list = inputPanel().candidateList(); if (list) { const auto &bulk = list->toBulk(); if (bulk) { @@ -89,7 +85,7 @@ class AndroidInputContext : public InputContextV2 { auto &candidate = bulk->candidateFromAll(i); // maybe unnecessary; I don't see anywhere using `CandidateWord::setPlaceHolder` // if (candidate.isPlaceHolder()) continue; - candidates.emplace_back(filterString(candidate.text())); + candidates.emplace_back(filterString(candidate.textWithComment())); } catch (const std::invalid_argument &e) { size = static_cast(candidates.size()); break; @@ -98,14 +94,44 @@ class AndroidInputContext : public InputContextV2 { } else { size = list->size(); for (int i = 0; i < size; i++) { - candidates.emplace_back(filterString(list->candidate(i).text())); + candidates.emplace_back(filterString(list->candidate(i).textWithComment())); } } } frontend_->updateCandidateList(candidates, size); } - bool selectCandidate(int idx) { + void updateCandidatesPaged() { + const auto &list = inputPanel().candidateList(); + if (!list) { + frontend_->updatePagedCandidate(PagedCandidateEntity::Empty); + return; + } + int cursorIndex = list->cursorIndex(); + CandidateLayoutHint layoutHint = list->layoutHint(); + bool hasPrev = false; + bool hasNext = false; + const auto &pageable = list->toPageable(); + if (pageable) { + hasPrev = pageable->hasPrev(); + hasNext = pageable->hasNext(); + } + int size = list->size(); + std::vector candidates; + candidates.reserve(size); + for (int i = 0; i < size; i++) { + const auto &c = list->candidate(i); + candidates.emplace_back( + filterString(list->label(i)), + filterString(c.text()), + filterString(c.comment()) + ); + } + PagedCandidateEntity paged(candidates, cursorIndex, layoutHint, hasPrev, hasNext); + frontend_->updatePagedCandidate(paged); + } + + bool selectCandidateBulk(int idx) { const auto &list = inputPanel().candidateList(); if (!list) { return false; @@ -124,6 +150,20 @@ class AndroidInputContext : public InputContextV2 { return true; } + bool selectCandidatePaged(int idx) { + const auto &list = inputPanel().candidateList(); + if (!list) { + return false; + } + try { + list->candidate(idx).select(this); + } catch (const std::invalid_argument &e) { + FCITX_WARN() << "selectCandidate index out of range"; + return false; + } + return true; + } + std::vector getCandidates(const int offset, const int limit) { std::vector candidates; const auto &list = inputPanel().candidateList(); @@ -136,7 +176,7 @@ class AndroidInputContext : public InputContextV2 { for (int i = offset; i < end; i++) { try { auto &candidate = bulk->candidateFromAll(i); - candidates.emplace_back(filterString(candidate.text())); + candidates.emplace_back(filterString(candidate.textWithComment())); } catch (const std::invalid_argument &e) { break; } @@ -144,13 +184,84 @@ class AndroidInputContext : public InputContextV2 { } else { const int end = std::min(list->size(), last); for (int i = offset; i < end; i++) { - candidates.emplace_back(filterString(list->candidate(i).text())); + candidates.emplace_back(filterString(list->candidate(i).textWithComment())); } } } return candidates; } + std::vector getCandidateAction(const int idx) { + std::vector actions; + const auto &list = inputPanel().candidateList(); + if (list) { + const auto &actionable = list->toActionable(); + if (actionable) { + if (idx >= list->size()) { + const auto &bulk = list->toBulk(); + if (bulk) { + try { + const auto &c = bulk->candidateFromAll(idx); + for (const auto &a: actionable->candidateActions(c)) { + actions.emplace_back(a); + } + } catch (const std::exception &e) { + FCITX_WARN() << "getCandidateAction(" << idx << ") failed:" << e.what(); + } + } + } else { + const auto &c = list->candidate(idx); + for (const auto &a: actionable->candidateActions(c)) { + actions.emplace_back(a); + } + } + } + } + return actions; + } + + void triggerCandidateAction(const int idx, const int actionIdx) { + const auto &list = inputPanel().candidateList(); + if (!list) return; + const auto &actionable = list->toActionable(); + if (!actionable) return; + if (idx >= list->size()) { + const auto &bulk = list->toBulk(); + if (bulk) { + try { + const auto &c = bulk->candidateFromAll(idx); + actionable->triggerAction(c, actionIdx); + } catch (const std::exception &e) { + FCITX_WARN() << "triggerCandidateAction(" << idx << ") failed:" << e.what(); + } + } + } else { + const auto &c = list->candidate(idx); + actionable->triggerAction(c, actionIdx); + } + } + + void offsetCandidatePage(int delta) { + if (delta == 0) { + return; + } + const auto &list = inputPanel().candidateList(); + if (!list) { + return; + } + const auto &pageable = list->toPageable(); + if (!pageable) { + return; + } + if (delta > 0 && pageable->hasNext()) { + pageable->next(); + updateUserInterface(UserInterfaceComponent::InputPanel); + } else if (delta < 0 && pageable->hasPrev()) { + pageable->prev(); + updateUserInterface(UserInterfaceComponent::InputPanel); + } + } + private: AndroidFrontend *frontend_; int uid_; @@ -169,7 +280,8 @@ AndroidFrontend::AndroidFrontend(Instance *instance) focusGroup_("android", instance->inputContextManager()), activeIC_(nullptr), icCache_(), - eventHandlers_() { + eventHandlers_(), + pagingMode_(0) { eventHandlers_.emplace_back(instance_->watchEvent( EventType::InputContextInputMethodActivated, EventWatcherPhase::Default, @@ -185,7 +297,14 @@ AndroidFrontend::AndroidFrontend(Instance *instance) auto &e = static_cast(event); switch (e.component()) { case UserInterfaceComponent::InputPanel: { - if (activeIC_) activeIC_->updateInputPanel(); + if (activeIC_) { + activeIC_->updateInputPanel(); + if (pagingMode_ == 0) { + activeIC_->updateCandidatesBulk(); + } else { + activeIC_->updateCandidatesPaged(); + } + } break; } case UserInterfaceComponent::StatusArea: { @@ -234,7 +353,21 @@ void AndroidFrontend::releaseInputContext(const int uid) { bool AndroidFrontend::selectCandidate(int idx) { if (!activeIC_) return false; - return activeIC_->selectCandidate(idx); + if (pagingMode_) { + return activeIC_->selectCandidatePaged(idx); + } else { + return activeIC_->selectCandidateBulk(idx); + } +} + +std::vector AndroidFrontend::getCandidateActions(const int idx) { + if (!activeIC_) return {}; + return activeIC_->getCandidateAction(idx); +} + +void AndroidFrontend::triggerCandidateAction(const int idx, const int actionIdx) { + if (!activeIC_) return; + activeIC_->triggerCandidateAction(idx, actionIdx); } bool AndroidFrontend::isInputPanelEmpty() { @@ -307,6 +440,19 @@ void AndroidFrontend::showToast(const std::string &s) { toastCallback(s); } +void AndroidFrontend::setCandidatePagingMode(const int mode) { + pagingMode_ = mode; +} + +void AndroidFrontend::updatePagedCandidate(const PagedCandidateEntity &paged) { + pagedCandidateCallback(paged); +} + +void AndroidFrontend::offsetCandidatePage(int delta) { + if (!activeIC_) return; + activeIC_->offsetCandidatePage(delta); +} + void AndroidFrontend::setCommitStringCallback(const CommitStringCallback &callback) { commitStringCallback = callback; } @@ -339,6 +485,10 @@ void AndroidFrontend::setToastCallback(const ToastCallback &callback) { toastCallback = callback; } +void AndroidFrontend::setPagedCandidateCallback(const PagedCandidateCallback &callback) { + pagedCandidateCallback = callback; +} + class AndroidFrontendFactory : public AddonFactory { public: AddonInstance *create(AddonManager *manager) override { diff --git a/app/src/main/cpp/androidfrontend/androidfrontend.h b/app/src/main/cpp/androidfrontend/androidfrontend.h index 9dc662cfe..9b9db800b 100644 --- a/app/src/main/cpp/androidfrontend/androidfrontend.h +++ b/app/src/main/cpp/androidfrontend/androidfrontend.h @@ -2,8 +2,9 @@ * SPDX-License-Identifier: LGPL-2.1-or-later * SPDX-FileCopyrightText: Copyright 2021-2023 Fcitx5 for Android Contributors */ -#ifndef _FCITX5_ANDROID_ANDROIDFRONTEND_H_ -#define _FCITX5_ANDROID_ANDROIDFRONTEND_H_ + +#ifndef FCITX5_ANDROID_ANDROIDFRONTEND_H +#define FCITX5_ANDROID_ANDROIDFRONTEND_H #include #include @@ -18,7 +19,7 @@ class AndroidInputContext; class AndroidFrontend : public AddonInstance { public: - AndroidFrontend(Instance *instance); + explicit AndroidFrontend(Instance *instance); Instance *instance() { return instance_; } @@ -27,6 +28,7 @@ class AndroidFrontend : public AddonInstance { void updateClientPreedit(const Text &clientPreedit); void updateInputPanel(const Text &preedit, const Text &auxUp, const Text &auxDown); void releaseInputContext(const int uid); + void updatePagedCandidate(const PagedCandidateEntity &paged); void keyEvent(const Key &key, bool isRelease, const int timestamp); void forwardKey(const Key &key, bool isRelease); @@ -37,11 +39,15 @@ class AndroidFrontend : public AddonInstance { void focusInputContext(bool focus); void activateInputContext(const int uid, const std::string &pkgName); void deactivateInputContext(const int uid); - InputContext *activeInputContext() const; + [[nodiscard]] InputContext *activeInputContext() const; void setCapabilityFlags(uint64_t flag); std::vector getCandidates(const int offset, const int limit); + std::vector getCandidateActions(const int idx); + void triggerCandidateAction(const int idx, const int actionIdx); void deleteSurrounding(const int before, const int after); void showToast(const std::string &s); + void setCandidatePagingMode(const int mode); + void offsetCandidatePage(int delta); void setCandidateListCallback(const CandidateListCallback &callback); void setCommitStringCallback(const CommitStringCallback &callback); void setPreeditCallback(const ClientPreeditCallback &callback); @@ -51,6 +57,7 @@ class AndroidFrontend : public AddonInstance { void setStatusAreaUpdateCallback(const StatusAreaUpdateCallback &callback); void setDeleteSurroundingCallback(const DeleteSurroundingCallback &callback); void setToastCallback(const ToastCallback &callback); + void setPagedCandidateCallback(const PagedCandidateCallback &callback); private: FCITX_ADDON_EXPORT_FUNCTION(AndroidFrontend, keyEvent); @@ -64,7 +71,11 @@ class AndroidFrontend : public AddonInstance { FCITX_ADDON_EXPORT_FUNCTION(AndroidFrontend, deactivateInputContext); FCITX_ADDON_EXPORT_FUNCTION(AndroidFrontend, setCapabilityFlags); FCITX_ADDON_EXPORT_FUNCTION(AndroidFrontend, getCandidates); + FCITX_ADDON_EXPORT_FUNCTION(AndroidFrontend, getCandidateActions); + FCITX_ADDON_EXPORT_FUNCTION(AndroidFrontend, triggerCandidateAction); FCITX_ADDON_EXPORT_FUNCTION(AndroidFrontend, showToast); + FCITX_ADDON_EXPORT_FUNCTION(AndroidFrontend, setCandidatePagingMode); + FCITX_ADDON_EXPORT_FUNCTION(AndroidFrontend, offsetCandidatePage); FCITX_ADDON_EXPORT_FUNCTION(AndroidFrontend, setCandidateListCallback); FCITX_ADDON_EXPORT_FUNCTION(AndroidFrontend, setCommitStringCallback); FCITX_ADDON_EXPORT_FUNCTION(AndroidFrontend, setPreeditCallback); @@ -74,12 +85,14 @@ class AndroidFrontend : public AddonInstance { FCITX_ADDON_EXPORT_FUNCTION(AndroidFrontend, setStatusAreaUpdateCallback); FCITX_ADDON_EXPORT_FUNCTION(AndroidFrontend, setDeleteSurroundingCallback); FCITX_ADDON_EXPORT_FUNCTION(AndroidFrontend, setToastCallback); + FCITX_ADDON_EXPORT_FUNCTION(AndroidFrontend, setPagedCandidateCallback); Instance *instance_; FocusGroup focusGroup_; AndroidInputContext *activeIC_; InputContextCache icCache_; std::vector>> eventHandlers_; + int pagingMode_; CandidateListCallback candidateListCallback = [](const std::vector &, const int) {}; CommitStringCallback commitStringCallback = [](const std::string &, const int) {}; @@ -90,7 +103,8 @@ class AndroidFrontend : public AddonInstance { StatusAreaUpdateCallback statusAreaUpdateCallback = [] {}; DeleteSurroundingCallback deleteSurroundingCallback = [](const int, const int) {}; ToastCallback toastCallback = [](const std::string &) {}; + PagedCandidateCallback pagedCandidateCallback = [](const PagedCandidateEntity &) {}; }; } // namespace fcitx -#endif //_FCITX5_ANDROID_ANDROIDFRONTEND_H_ +#endif //FCITX5_ANDROID_ANDROIDFRONTEND_H diff --git a/app/src/main/cpp/androidfrontend/androidfrontend_public.h b/app/src/main/cpp/androidfrontend/androidfrontend_public.h index 878309093..69bcbbdd5 100644 --- a/app/src/main/cpp/androidfrontend/androidfrontend_public.h +++ b/app/src/main/cpp/androidfrontend/androidfrontend_public.h @@ -2,12 +2,16 @@ * SPDX-License-Identifier: LGPL-2.1-or-later * SPDX-FileCopyrightText: Copyright 2021-2023 Fcitx5 for Android Contributors */ -#ifndef _FCITX5_ANDROID_ANDROIDFRONTEND_PUBLIC_H_ -#define _FCITX5_ANDROID_ANDROIDFRONTEND_PUBLIC_H_ +#ifndef FCITX5_ANDROID_ANDROIDFRONTEND_PUBLIC_H +#define FCITX5_ANDROID_ANDROIDFRONTEND_PUBLIC_H +#include #include +#include #include +#include "../helper-types.h" + typedef std::function &, const int)> CandidateListCallback; typedef std::function CommitStringCallback; typedef std::function ClientPreeditCallback; @@ -17,6 +21,7 @@ typedef std::function InputMethodChangeCallback; typedef std::function StatusAreaUpdateCallback; typedef std::function DeleteSurroundingCallback; typedef std::function ToastCallback; +typedef std::function PagedCandidateCallback; FCITX_ADDON_DECLARE_FUNCTION(AndroidFrontend, keyEvent, void(const fcitx::Key &, bool isRelease, const int timestamp)) @@ -51,9 +56,21 @@ FCITX_ADDON_DECLARE_FUNCTION(AndroidFrontend, setCapabilityFlags, FCITX_ADDON_DECLARE_FUNCTION(AndroidFrontend, getCandidates, std::vector(const int, const int)) +FCITX_ADDON_DECLARE_FUNCTION(AndroidFrontend, getCandidateActions, + std::vector(const int)) + +FCITX_ADDON_DECLARE_FUNCTION(AndroidFrontend, triggerCandidateAction, + void(const int, const int)) + FCITX_ADDON_DECLARE_FUNCTION(AndroidFrontend, showToast, void(const std::string &)) +FCITX_ADDON_DECLARE_FUNCTION(AndroidFrontend, setCandidatePagingMode, + void(const int)) + +FCITX_ADDON_DECLARE_FUNCTION(AndroidFrontend, offsetCandidatePage, + void(int)) + FCITX_ADDON_DECLARE_FUNCTION(AndroidFrontend, setCandidateListCallback, void(const CandidateListCallback &)) @@ -81,4 +98,7 @@ FCITX_ADDON_DECLARE_FUNCTION(AndroidFrontend, setDeleteSurroundingCallback, FCITX_ADDON_DECLARE_FUNCTION(AndroidFrontend, setToastCallback, void(const ToastCallback &)) -#endif // _FCITX5_ANDROID_ANDROIDFRONTEND_PUBLIC_H_ +FCITX_ADDON_DECLARE_FUNCTION(AndroidFrontend, setPagedCandidateCallback, + void(const PagedCandidateCallback &)) + +#endif // FCITX5_ANDROID_ANDROIDFRONTEND_PUBLIC_H diff --git a/app/src/main/cpp/androidkeyboard/androidkeyboard.cpp b/app/src/main/cpp/androidkeyboard/androidkeyboard.cpp index 414f85a4a..a84d55ab7 100644 --- a/app/src/main/cpp/androidkeyboard/androidkeyboard.cpp +++ b/app/src/main/cpp/androidkeyboard/androidkeyboard.cpp @@ -108,8 +108,7 @@ void AndroidKeyboardEngine::keyEvent(const InputMethodEntry &entry, KeyEvent &ev } if (key.isLAZ() || key.isUAZ() || validSym || (!buffer.empty() && key.checkKeyList(FCITX_HYPHEN_APOS))) { - auto text = Key::keySymToUTF8(key.sym()); - if (updateBuffer(inputContext, text)) { + if (updateBuffer(inputContext, event)) { return event.filterAndAccept(); } } @@ -142,16 +141,16 @@ void AndroidKeyboardEngine::keyEvent(const InputMethodEntry &entry, KeyEvent &ev auto cursor = buffer.cursor(); if (cursor > 0) { buffer.setCursor(cursor - 1); + event.filterAndAccept(); + return updateCandidate(entry, inputContext); } - event.filterAndAccept(); - return updateCandidate(entry, inputContext); } else if (key.check(FcitxKey_Right) || key.check(FcitxKey_KP_Right)) { auto cursor = buffer.cursor(); if (cursor < buffer.size()) { buffer.setCursor(buffer.cursor() + 1); + event.filterAndAccept(); + return updateCandidate(entry, inputContext); } - event.filterAndAccept(); - return updateCandidate(entry, inputContext); } } @@ -249,14 +248,22 @@ void AndroidKeyboardEngine::resetState(InputContext *inputContext, bool fromCand void AndroidKeyboardEngine::updateCandidate(const InputMethodEntry &entry, InputContext *inputContext) { inputContext->inputPanel().reset(); auto *state = inputContext->propertyFor(&factory_); + const auto userInput = state->buffer_.userInput(); std::vector> results; if (spell()) { results = spell()->call(entry.languageCode(), SpellProvider::Default, - state->buffer_.userInput(), + userInput, SpellCandidateSize); } auto candidateList = std::make_unique(); + if (results.empty() || results.front().second != userInput) { + // TODO: comply with fcitx5 spell module's delim " _-,./?!%" + // it's fine in androidkeyboard because only "-" won't commit buffer + const auto segments = stringutils::split(userInput, "-"); + const auto label = segments.size() > 1 ? segments.back() : userInput; + candidateList->append(this, Text(label), userInput); + } for (const auto &result: results) { candidateList->append(this, Text(result.first), result.second); } @@ -269,30 +276,32 @@ void AndroidKeyboardEngine::updateCandidate(const InputMethodEntry &entry, Input } void AndroidKeyboardEngine::updateUI(InputContext *inputContext) { - auto [preedit, cursor] = preeditWithCursor(inputContext); - Text clientPreedit(preedit, TextFormatFlag::Underline); - clientPreedit.setCursor(static_cast(cursor)); - inputContext->inputPanel().setClientPreedit(clientPreedit); - // we don't want preedit here ... -// if (!inputContext->capabilityFlags().test(CapabilityFlag::Preedit)) { -// inputContext->inputPanel().setPreedit(preedit); -// } - inputContext->updatePreedit(); + auto [text, cursor] = preeditWithCursor(inputContext); + if (inputContext->capabilityFlags().test(CapabilityFlag::Preedit)) { + Text clientPreedit(text, TextFormatFlag::Underline); + clientPreedit.setCursor(static_cast(cursor)); + inputContext->inputPanel().setClientPreedit(clientPreedit); + inputContext->updatePreedit(); + } else { + Text preedit(text); + preedit.setCursor(static_cast(cursor)); + inputContext->inputPanel().setPreedit(preedit); + } inputContext->updateUserInterface(UserInterfaceComponent::InputPanel); } -bool AndroidKeyboardEngine::updateBuffer(InputContext *inputContext, const std::string &chr) { +bool AndroidKeyboardEngine::updateBuffer(InputContext *inputContext, const KeyEvent& event) { auto *entry = instance_->inputMethodEntry(inputContext); if (!entry) { return false; } auto *state = inputContext->propertyFor(&factory_); - const CapabilityFlags noPredictFlag{CapabilityFlag::Password, - CapabilityFlag::NoSpellCheck}; - // no spell hint enabled or no supported dictionary + // word hint is disabled, input is password, or language not supported if (!*config_.enableWordHint || - inputContext->capabilityFlags().testAny(noPredictFlag) || + (!*config_.hintOnPhysicalKeyboard && !event.isVirtual()) || + (*config_.editorControlledWordHint && inputContext->capabilityFlags().test(CapabilityFlag::NoSpellCheck)) || + inputContext->capabilityFlags().test(CapabilityFlag::Password) || !supportHint(entry->languageCode())) { return false; } @@ -304,7 +313,7 @@ bool AndroidKeyboardEngine::updateBuffer(InputContext *inputContext, const std:: buffer.type(preedit); } - buffer.type(chr); + buffer.type(Key::keySymToUTF8(event.key().sym())); if (buffer.size() >= MaxBufferSize) { commitBuffer(inputContext); @@ -320,8 +329,9 @@ void AndroidKeyboardEngine::commitBuffer(InputContext *inputContext) { if (preedit.empty()) { return; } + auto characterCount = utf8::length(preedit, 0, cursor); if (inputContext->capabilityFlags().test(CapabilityFlag::CommitStringWithCursor)) { - inputContext->commitStringWithCursor(preedit, cursor); + inputContext->commitStringWithCursor(preedit, characterCount); } else { inputContext->commitString(preedit); } diff --git a/app/src/main/cpp/androidkeyboard/androidkeyboard.h b/app/src/main/cpp/androidkeyboard/androidkeyboard.h index 377ed2de8..b365ac527 100644 --- a/app/src/main/cpp/androidkeyboard/androidkeyboard.h +++ b/app/src/main/cpp/androidkeyboard/androidkeyboard.h @@ -2,8 +2,8 @@ * SPDX-License-Identifier: LGPL-2.1-or-later * SPDX-FileCopyrightText: Copyright 2021-2023 Fcitx5 for Android Contributors */ -#ifndef _FCITX5_ANDROID_ANDROIDKEYBOARD_H_ -#define _FCITX5_ANDROID_ANDROIDKEYBOARD_H_ +#ifndef FCITX5_ANDROID_ANDROIDKEYBOARD_H +#define FCITX5_ANDROID_ANDROIDKEYBOARD_H #include #include @@ -30,6 +30,10 @@ FCITX_CONFIGURATION( AndroidKeyboardEngineConfig, Option enableWordHint{this, "EnableWordHint", _("Enable word hint"), true}; + Option + hintOnPhysicalKeyboard{this, "WordHintOnPhysicalKeyboard", _("Enable word hint when using physical keyboard"), false}; + Option + editorControlledWordHint{this, "EditorControlledWordHint", _("Disable word hint based on editor attributes"), true}; Option pageSize{this, "PageSize", _("Word hint page size"), 5, IntConstrain(3, 10)}; OptionWithAnnotation @@ -57,8 +61,7 @@ class AndroidKeyboardEngine final : public InputMethodEngineV3 { static int constexpr MaxBufferSize = 20; static int constexpr SpellCandidateSize = 20; - AndroidKeyboardEngine(Instance *instance); - ~AndroidKeyboardEngine() = default; + explicit AndroidKeyboardEngine(Instance *instance); Instance *instance() { return instance_; } @@ -94,10 +97,10 @@ class AndroidKeyboardEngine final : public InputMethodEngineV3 { auto factory() { return &factory_; } - // Return true if chr is pushed to buffer. - // Return false if chr will be skipped by buffer, usually this means caller - // need to call commit buffer and forward chr manually. - bool updateBuffer(InputContext *inputContext, const std::string &chr); + // Return true if event is pushed to buffer. + // Return false if event will be skipped by buffer, usually this means caller + // need to call commit buffer and forward event manually. + bool updateBuffer(InputContext *inputContext, const KeyEvent& event); // Commit current buffer, also reset the state. // See also preeditString(). @@ -107,6 +110,9 @@ class AndroidKeyboardEngine final : public InputMethodEngineV3 { private: bool supportHint(const std::string &language); + /** + * preedit string and byte cursor + */ std::pair preeditWithCursor(InputContext *inputContext); Instance *instance_; @@ -128,4 +134,4 @@ class AndroidKeyboardEngineFactory : public AddonFactory { } -#endif //_FCITX5_ANDROID_ANDROIDKEYBOARD_H_ +#endif //FCITX5_ANDROID_ANDROIDKEYBOARD_H diff --git a/app/src/main/cpp/androidnotification/androidnotification.cpp b/app/src/main/cpp/androidnotification/androidnotification.cpp index dae404865..b2e06b5e4 100644 --- a/app/src/main/cpp/androidnotification/androidnotification.cpp +++ b/app/src/main/cpp/androidnotification/androidnotification.cpp @@ -13,20 +13,18 @@ namespace fcitx { -void Notifications::updateConfig() { - hiddenNotifications_.clear(); - for (const auto &id: config_.hiddenNotifications.value()) { - hiddenNotifications_.insert(id); - } +Notifications::Notifications(Instance *instance) : instance_(instance) { + reloadConfig(); } void Notifications::reloadConfig() { readAsIni(config_, ConfPath); - updateConfig(); + updateHiddenNotifications(); } void Notifications::save() { std::vector values_; + values_.reserve(hiddenNotifications_.size()); for (const auto &id: hiddenNotifications_) { values_.push_back(id); } @@ -34,6 +32,19 @@ void Notifications::save() { safeSaveAsIni(config_, ConfPath); } +void Notifications::setConfig(const fcitx::RawConfig &config) { + config_.load(config, true); + safeSaveAsIni(config_, ConfPath); + updateHiddenNotifications(); +} + +void Notifications::updateHiddenNotifications() { + hiddenNotifications_.clear(); + for (const auto &id: config_.hiddenNotifications.value()) { + hiddenNotifications_.insert(id); + } +} + uint32_t Notifications::sendNotification( const std::string &appName, uint32_t replaceId, diff --git a/app/src/main/cpp/androidnotification/androidnotification.h b/app/src/main/cpp/androidnotification/androidnotification.h index 3733b6c68..242e2857b 100644 --- a/app/src/main/cpp/androidnotification/androidnotification.h +++ b/app/src/main/cpp/androidnotification/androidnotification.h @@ -22,26 +22,21 @@ namespace fcitx { FCITX_CONFIGURATION(NotificationsConfig, fcitx::Option> hiddenNotifications{ this, "HiddenNotifications", - _("Hidden Notifications")};); + _("Hidden Notifications")};) class Notifications final : public AddonInstance { public: - Notifications(Instance *instance) : instance_(instance) {}; - ~Notifications() = default; + explicit Notifications(Instance *instance); Instance *instance() { return instance_; } - void updateConfig(); void reloadConfig() override; + void save() override; const Configuration *getConfig() const override { return &config_; } - void setConfig(const RawConfig &config) override { - config_.load(config, true); - safeSaveAsIni(config_, ConfPath); - updateConfig(); - } + void setConfig(const RawConfig &config) override; FCITX_ADDON_DEPENDENCY_LOADER(androidfrontend, instance_->addonManager()); @@ -65,13 +60,15 @@ class Notifications final : public AddonInstance { FCITX_ADDON_EXPORT_FUNCTION(Notifications, showTip); FCITX_ADDON_EXPORT_FUNCTION(Notifications, closeNotification); - static const inline std::string ConfPath = "conf/androidnotification.conf"; + static const inline char* ConfPath = "conf/androidnotification.conf"; NotificationsConfig config_; Instance *instance_; std::unordered_set hiddenNotifications_; + void updateHiddenNotifications(); + }; // class Notifications } // namespace fcitx diff --git a/app/src/main/cpp/helper-types.h b/app/src/main/cpp/helper-types.h index a0be956c9..1f13c9494 100644 --- a/app/src/main/cpp/helper-types.h +++ b/app/src/main/cpp/helper-types.h @@ -8,6 +8,12 @@ #include #include #include +#include +#include +#include +#include + +#include class InputMethodStatus { public: @@ -83,4 +89,63 @@ class ActionEntity { } }; +class CandidateActionEntity { +public: + int id; + std::string text; + bool isSeparator; + std::string icon; + bool isCheckable; + bool isChecked; + + explicit CandidateActionEntity(const fcitx::CandidateAction &act) : + id(act.id()), + text(act.text()), + isSeparator(act.isSeparator()), + icon(act.icon()), + isCheckable(act.isCheckable()), + isChecked(act.isChecked()) {} +}; + +class CandidateEntity { +public: + std::string label; + std::string text; + std::string comment; + + explicit CandidateEntity(std::string label, std::string text, std::string comment) : + label(std::move(label)), + text(std::move(text)), + comment(std::move(comment)) {} +}; + +class PagedCandidateEntity { +public: + std::vector candidates; + int cursorIndex; + fcitx::CandidateLayoutHint layoutHint; + bool hasPrev; + bool hasNext; + + explicit PagedCandidateEntity(std::vector candidates, + int cursorIndex, + fcitx::CandidateLayoutHint layoutHint, + bool hasPrev, + bool hasNext) : + candidates(std::move(candidates)), + cursorIndex(cursorIndex), + layoutHint(layoutHint), + hasPrev(hasPrev), + hasNext(hasNext) {} + + static PagedCandidateEntity Empty; + +private: + PagedCandidateEntity() : + candidates({}), cursorIndex(-1), layoutHint(fcitx::CandidateLayoutHint::NotSet), + hasPrev(false), hasNext(false) {} +}; + +PagedCandidateEntity PagedCandidateEntity::Empty = PagedCandidateEntity(); + #endif //FCITX5_ANDROID_HELPER_TYPES_H diff --git a/app/src/main/cpp/jni-utils.h b/app/src/main/cpp/jni-utils.h index 6a7d30b0a..6807b531d 100644 --- a/app/src/main/cpp/jni-utils.h +++ b/app/src/main/cpp/jni-utils.h @@ -77,10 +77,10 @@ class JString { class JEnv { private: - JNIEnv *env; + JNIEnv *env = nullptr; public: - JEnv(JavaVM *jvm) { + explicit JEnv(JavaVM *jvm) { if (jvm->GetEnv(reinterpret_cast(&env), JNI_VERSION_1_6) == JNI_EDETACHED) { jvm->AttachCurrentThread(&env, nullptr); } @@ -138,7 +138,13 @@ class GlobalRefSingleton { jfieldID PinyinCustomPhraseOrder; jfieldID PinyinCustomPhraseValue; - GlobalRefSingleton(JavaVM *jvm_) : jvm(jvm_) { + jclass CandidateAction; + jmethodID CandidateActionInit; + + jclass Candidate; + jmethodID CandidateInit; + + explicit GlobalRefSingleton(JavaVM *jvm_) : jvm(jvm_) { JNIEnv *env; jvm->AttachCurrentThread(&env, nullptr); @@ -157,8 +163,8 @@ class GlobalRefSingleton { HandleFcitxEvent = env->GetStaticMethodID(Fcitx, "handleFcitxEvent", "(I[Ljava/lang/Object;)V"); InputMethodEntry = reinterpret_cast(env->NewGlobalRef(env->FindClass("org/fcitx/fcitx5/android/core/InputMethodEntry"))); - InputMethodEntryInit = env->GetMethodID(InputMethodEntry, "", "(Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Z)V"); - InputMethodEntryInitWithSubMode = env->GetMethodID(InputMethodEntry, "", "(Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;ZLjava/lang/String;Ljava/lang/String;Ljava/lang/String;)V"); + InputMethodEntryInit = env->GetMethodID(InputMethodEntry, "", "(Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Z)V"); + InputMethodEntryInitWithSubMode = env->GetMethodID(InputMethodEntry, "", "(Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;ZLjava/lang/String;Ljava/lang/String;Ljava/lang/String;)V"); RawConfig = reinterpret_cast(env->NewGlobalRef(env->FindClass("org/fcitx/fcitx5/android/core/RawConfig"))); RawConfigName = env->GetFieldID(RawConfig, "name", "Ljava/lang/String;"); @@ -184,9 +190,15 @@ class GlobalRefSingleton { PinyinCustomPhraseKey = env->GetFieldID(PinyinCustomPhrase, "key", "Ljava/lang/String;"); PinyinCustomPhraseOrder = env->GetFieldID(PinyinCustomPhrase, "order", "I"); PinyinCustomPhraseValue = env->GetFieldID(PinyinCustomPhrase, "value", "Ljava/lang/String;"); + + CandidateAction = reinterpret_cast(env->NewGlobalRef(env->FindClass("org/fcitx/fcitx5/android/core/CandidateAction"))); + CandidateActionInit = env->GetMethodID(CandidateAction, "", "(ILjava/lang/String;ZLjava/lang/String;ZZ)V"); + + Candidate = reinterpret_cast(env->NewGlobalRef(env->FindClass("org/fcitx/fcitx5/android/core/FcitxEvent$Candidate"))); + CandidateInit = env->GetMethodID(Candidate, "", "(Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)V"); } - const JEnv AttachEnv() const { return JEnv(jvm); } + [[nodiscard]] JEnv AttachEnv() const { return JEnv(jvm); } }; extern GlobalRefSingleton *GlobalRef; diff --git a/app/src/main/cpp/native-lib.cpp b/app/src/main/cpp/native-lib.cpp index 1ed0cd934..bdeb17191 100644 --- a/app/src/main/cpp/native-lib.cpp +++ b/app/src/main/cpp/native-lib.cpp @@ -1,6 +1,6 @@ /* * SPDX-License-Identifier: LGPL-2.1-or-later - * SPDX-FileCopyrightText: Copyright 2021-2023 Fcitx5 for Android Contributors + * SPDX-FileCopyrightText: Copyright 2021-2024 Fcitx5 for Android Contributors */ #include @@ -12,7 +12,7 @@ #include -#include +#include #include #include @@ -41,7 +41,6 @@ #include "customphrase.h" #include "androidfrontend/androidfrontend_public.h" -#include "androidaddonloader/androidaddonloader.h" #include "jni-utils.h" #include "nativestreambuf.h" #include "helper-types.h" @@ -72,19 +71,18 @@ class Fcitx { return p_instance != nullptr && p_dispatcher != nullptr && p_frontend != nullptr; } - event_base *get_event_base() { + uv_loop_t *get_event_base() { fcitx::EventLoop &event_loop = p_instance->eventLoop(); - return static_cast(event_loop.nativeHandle()); + return static_cast(event_loop.nativeHandle()); } int loopOnce() { - return event_base_loop(get_event_base(), EVLOOP_ONCE); + return uv_run(get_event_base(), UV_RUN_ONCE); } - void startup(fcitx::AndroidLibraryDependency dependency, - const std::function &setupCallback) { + void startup(const std::function &setupCallback) { p_instance = std::make_unique(0, nullptr); - p_instance->addonManager().registerLoader(std::make_unique(dependency)); + p_instance->addonManager().registerDefaultLoader(nullptr); p_dispatcher = std::make_unique(); p_dispatcher->attach(&p_instance->eventLoop()); p_instance->initialize(); @@ -364,9 +362,9 @@ class Fcitx { p_unicode->call(ic); } - void setClipboard(const std::string &string) { + void setClipboard(const std::string &string, bool password) { if (!p_clipboard) return; - p_clipboard->call("", string); + p_clipboard->call("", string, password); } void focusInputContext(bool focus) { @@ -415,13 +413,33 @@ class Fcitx { return p_frontend->call(offset, limit); } + std::vector getCandidateActions(int idx) { + auto actions = std::vector(); + for (const auto &a: p_frontend->call(idx)) { + actions.emplace_back(a); + } + return actions; + } + + void triggerCandidateAction(int idx, int actionIdx) { + return p_frontend->call(idx, actionIdx); + } + + void setCandidatePagingMode(int mode) { + return p_frontend->call(mode); + } + + void offsetCandidatePage(int delta) { + return p_frontend->call(delta); + } + void save() { p_instance->save(); } void exit() { // Make sure that the exec doesn't get blocked - event_base_loopexit(get_event_base(), nullptr); + uv_stop(get_event_base()); // Normally, we would use exec to drive the event loop. // Since we are calling loopOnce in JVM repeatedly, we shouldn't have used this function. // However, exit events would lose chance to be called in this case. @@ -491,9 +509,7 @@ Java_org_fcitx_fcitx5_android_core_Fcitx_startupFcitx( jstring appLib, jstring extData, jstring extCache, - jobjectArray extDomains, - jobjectArray libraryNames, - jobjectArray libraryDependencies) { + jobjectArray extDomains) { if (Fcitx::Instance().isRunning()) { FCITX_ERROR() << "Fcitx is already running!"; return; @@ -567,21 +583,6 @@ Java_org_fcitx_fcitx5_android_core_Fcitx_startupFcitx( fcitx::registerDomain(CString(env, domain), locale_dir_char); } - std::unordered_map> depsMap; - const int librarySize = env->GetArrayLength(libraryNames); - for (int i = 0; i < librarySize; i++) { - auto jstringName = JRef(env, env->GetObjectArrayElement(libraryNames, i)); - auto lib = CString(env, jstringName); - auto jobjectArrayDeps = JRef(env, env->GetObjectArrayElement(libraryDependencies, i)); - const int depSize = env->GetArrayLength(jobjectArrayDeps); - std::unordered_set depSet(depSize); - for (int j = 0; j < depSize; j++) { - auto jstringDepName = JRef(env, env->GetObjectArrayElement(jobjectArrayDeps, j)); - depSet.emplace(CString(env, jstringDepName)); - } - depsMap.emplace(lib, depSet); - } - auto candidateListCallback = [](const std::vector &candidates, const int size) { auto env = GlobalRef->AttachEnv(); auto candidatesArray = JRef(env, env->NewObjectArray(static_cast(candidates.size()), GlobalRef->String, nullptr)); @@ -633,15 +634,17 @@ Java_org_fcitx_fcitx5_android_core_Fcitx_startupFcitx( env->CallStaticVoidMethod(GlobalRef->Fcitx, GlobalRef->HandleFcitxEvent, 5, *vararg); }; auto imChangeCallback = []() { - auto env = GlobalRef->AttachEnv(); - auto vararg = JRef(env, env->NewObjectArray(1, GlobalRef->Object, nullptr)); std::unique_ptr status = Fcitx::Instance().inputMethodStatus(); if (!status) return; + auto env = GlobalRef->AttachEnv(); + auto vararg = JRef(env, env->NewObjectArray(1, GlobalRef->Object, nullptr)); auto obj = JRef(env, fcitxInputMethodStatusToJObject(env, *status)); env->SetObjectArrayElement(vararg, 0, obj); env->CallStaticVoidMethod(GlobalRef->Fcitx, GlobalRef->HandleFcitxEvent, 6, *vararg); }; auto statusAreaUpdateCallback = []() { + std::unique_ptr status = Fcitx::Instance().inputMethodStatus(); + if (!status) return; auto env = GlobalRef->AttachEnv(); auto vararg = JRef(env, env->NewObjectArray(static_cast(2), GlobalRef->Object, nullptr)); const auto actions = Fcitx::Instance().statusAreaActions(); @@ -652,7 +655,6 @@ Java_org_fcitx_fcitx5_android_core_Fcitx_startupFcitx( env->SetObjectArrayElement(actionArray, i++, obj); } env->SetObjectArrayElement(vararg, 0, actionArray); - std::unique_ptr status = Fcitx::Instance().inputMethodStatus(); auto statusObj = JRef(env, fcitxInputMethodStatusToJObject(env, *status)); env->SetObjectArrayElement(vararg, 1, statusObj); env->CallStaticVoidMethod(GlobalRef->Fcitx, GlobalRef->HandleFcitxEvent, 7, *vararg); @@ -666,15 +668,39 @@ Java_org_fcitx_fcitx5_android_core_Fcitx_startupFcitx( env->SetObjectArrayElement(vararg, 0, intArray); env->CallStaticVoidMethod(GlobalRef->Fcitx, GlobalRef->HandleFcitxEvent, 8, *vararg); }; + auto pagedCandidateCallback = [](const PagedCandidateEntity &paged) { + auto env = GlobalRef->AttachEnv(); + const int size = static_cast(paged.candidates.size()); + if (size == 0) { + auto vararg = JRef(env, env->NewObjectArray(0, GlobalRef->Object, nullptr)); + env->CallStaticVoidMethod(GlobalRef->Fcitx, GlobalRef->HandleFcitxEvent, 9, *vararg); + return; + } + auto candidatesArray = JRef(env, env->NewObjectArray(size, GlobalRef->Candidate, nullptr)); + for (int i = 0; i < size; ++i) { + env->SetObjectArrayElement(candidatesArray, i, candidateEntityToObject(env, paged.candidates[i])); + } + auto cursorIndex = JRef(env, env->NewObject(GlobalRef->Integer, GlobalRef->IntegerInit, paged.cursorIndex)); + auto layoutHint = JRef(env, env->NewObject(GlobalRef->Integer, GlobalRef->IntegerInit, static_cast(paged.layoutHint))); + auto hasPrev = JRef(env, env->NewObject(GlobalRef->Boolean, GlobalRef->BooleanInit, paged.hasPrev)); + auto hasNext = JRef(env, env->NewObject(GlobalRef->Boolean, GlobalRef->BooleanInit, paged.hasNext)); + auto vararg = JRef(env, env->NewObjectArray(5, GlobalRef->Object, nullptr)); + env->SetObjectArrayElement(vararg, 0, candidatesArray); + env->SetObjectArrayElement(vararg, 1, cursorIndex); + env->SetObjectArrayElement(vararg, 2, layoutHint); + env->SetObjectArrayElement(vararg, 3, hasPrev); + env->SetObjectArrayElement(vararg, 4, hasNext); + env->CallStaticVoidMethod(GlobalRef->Fcitx, GlobalRef->HandleFcitxEvent, 9, *vararg); + }; auto toastCallback = [](const std::string &s) { auto env = GlobalRef->AttachEnv(); env->CallStaticVoidMethod(GlobalRef->Fcitx, GlobalRef->ShowToast, *JString(env, s)); }; umask(007); - fcitx::StandardPath::global().syncUmask(); + fcitx::StandardPaths::global().syncUmask(); - Fcitx::Instance().startup(depsMap, [&](auto *androidfrontend) { + Fcitx::Instance().startup([&](auto *androidfrontend) { FCITX_INFO() << "Setting up callback"; readyCallback(); androidfrontend->template call(candidateListCallback); @@ -685,6 +711,7 @@ Java_org_fcitx_fcitx5_android_core_Fcitx_startupFcitx( androidfrontend->template call(imChangeCallback); androidfrontend->template call(statusAreaUpdateCallback); androidfrontend->template call(deleteSurroundingCallback); + androidfrontend->template call(pagedCandidateCallback); androidfrontend->template call(toastCallback); }); FCITX_INFO() << "Finishing startup"; @@ -720,28 +747,31 @@ Java_org_fcitx_fcitx5_android_core_Fcitx_reloadFcitxConfig(JNIEnv *env, jclass c extern "C" JNIEXPORT void JNICALL -Java_org_fcitx_fcitx5_android_core_Fcitx_sendKeyToFcitxString(JNIEnv *env, jclass clazz, jstring key, jint state, jboolean up, jint timestamp) { +Java_org_fcitx_fcitx5_android_core_Fcitx_sendKeyToFcitxString(JNIEnv *env, jclass clazz, jstring key, jint state, jint code, jboolean up, jint timestamp) { RETURN_IF_NOT_RUNNING fcitx::Key parsedKey{fcitx::Key::keySymFromString(CString(env, key)), - fcitx::KeyStates(static_cast(state))}; + fcitx::KeyStates(static_cast(state)), + code + /* evdev offset */ 8}; Fcitx::Instance().sendKey(parsedKey, up, timestamp); } extern "C" JNIEXPORT void JNICALL -Java_org_fcitx_fcitx5_android_core_Fcitx_sendKeyToFcitxChar(JNIEnv *env, jclass clazz, jchar c, jint state, jboolean up, jint timestamp) { +Java_org_fcitx_fcitx5_android_core_Fcitx_sendKeyToFcitxChar(JNIEnv *env, jclass clazz, jchar c, jint state, jint code, jboolean up, jint timestamp) { RETURN_IF_NOT_RUNNING const fcitx::Key parsedKey{fcitx::Key::keySymFromString(reinterpret_cast(&c)), - fcitx::KeyStates(static_cast(state))}; + fcitx::KeyStates(static_cast(state)), + code + /* evdev offset */ 8}; Fcitx::Instance().sendKey(parsedKey, up, timestamp); } extern "C" JNIEXPORT void JNICALL -Java_org_fcitx_fcitx5_android_core_Fcitx_sendKeySymToFcitx(JNIEnv *env, jclass clazz, jint sym, jint state, jboolean up, jint timestamp) { +Java_org_fcitx_fcitx5_android_core_Fcitx_sendKeySymToFcitx(JNIEnv *env, jclass clazz, jint sym, jint state, jint code, jboolean up, jint timestamp) { RETURN_IF_NOT_RUNNING fcitx::Key key{fcitx::KeySym(static_cast(sym)), - fcitx::KeyStates(static_cast(state))}; + fcitx::KeyStates(static_cast(state)), + code + /* evdev offset */ 8}; Fcitx::Instance().sendKey(key, up, timestamp); } @@ -749,7 +779,6 @@ extern "C" JNIEXPORT jboolean JNICALL Java_org_fcitx_fcitx5_android_core_Fcitx_selectCandidate(JNIEnv *env, jclass clazz, jint idx) { RETURN_VALUE_IF_NOT_RUNNING(false) - FCITX_DEBUG() << "selectCandidate: #" << idx; return Fcitx::Instance().select(idx); } @@ -947,9 +976,9 @@ Java_org_fcitx_fcitx5_android_core_Fcitx_triggerUnicodeInput(JNIEnv *env, jclass extern "C" JNIEXPORT void JNICALL -Java_org_fcitx_fcitx5_android_core_Fcitx_setFcitxClipboard(JNIEnv *env, jclass clazz, jstring string) { +Java_org_fcitx_fcitx5_android_core_Fcitx_setFcitxClipboard(JNIEnv *env, jclass clazz, jstring string, jboolean password) { RETURN_IF_NOT_RUNNING - Fcitx::Instance().setClipboard(CString(env, string)); + Fcitx::Instance().setClipboard(CString(env, string), password == JNI_TRUE); } extern "C" @@ -1015,6 +1044,41 @@ Java_org_fcitx_fcitx5_android_core_Fcitx_getFcitxCandidates(JNIEnv *env, jclass return array; } +extern "C" +JNIEXPORT jobjectArray JNICALL +Java_org_fcitx_fcitx5_android_core_Fcitx_getFcitxCandidateActions(JNIEnv *env, jclass clazz, jint idx) { + RETURN_VALUE_IF_NOT_RUNNING(nullptr) + auto actions = Fcitx::Instance().getCandidateActions(idx); + int size = static_cast(actions.size()); + jobjectArray array = env->NewObjectArray(size, GlobalRef->CandidateAction, nullptr); + for (int i = 0; i < size; i++) { + auto obj = JRef(env, fcitxCandidateActionToObject(env, actions[i])); + env->SetObjectArrayElement(array, i, obj); + } + return array; +} + +extern "C" +JNIEXPORT void JNICALL +Java_org_fcitx_fcitx5_android_core_Fcitx_triggerFcitxCandidateAction(JNIEnv *env, jclass clazz, jint idx, jint action_idx) { + RETURN_IF_NOT_RUNNING + Fcitx::Instance().triggerCandidateAction(idx, action_idx); +} + +extern "C" +JNIEXPORT void JNICALL +Java_org_fcitx_fcitx5_android_core_Fcitx_setFcitxCandidatePagingMode(JNIEnv *env, jclass clazz, jint mode) { + RETURN_IF_NOT_RUNNING + Fcitx::Instance().setCandidatePagingMode(mode); +} + +extern "C" +JNIEXPORT void JNICALL +Java_org_fcitx_fcitx5_android_core_Fcitx_offsetFcitxCandidatePage(JNIEnv *env, jclass clazz, jint delta) { + RETURN_IF_NOT_RUNNING + Fcitx::Instance().offsetCandidatePage(delta); +} + extern "C" JNIEXPORT void JNICALL Java_org_fcitx_fcitx5_android_core_Fcitx_loopOnce(JNIEnv *env, jclass clazz) { @@ -1108,7 +1172,7 @@ Java_org_fcitx_fcitx5_android_data_table_TableManager_checkTableDictFormat(JNIEn extern "C" JNIEXPORT jobjectArray JNICALL Java_org_fcitx_fcitx5_android_data_pinyin_CustomPhraseManager_load(JNIEnv *env, jclass clazz) { - auto fp = fcitx::StandardPath::global().open(fcitx::StandardPath::Type::PkgData, "pinyin/customphrase", O_RDONLY); + auto fp = fcitx::StandardPaths::global().open(fcitx::StandardPathsType::PkgData, "pinyin/customphrase"); if (fp.fd() < 0) { FCITX_INFO() << "cannot open pinyin/customphrase"; return nullptr; @@ -1154,8 +1218,8 @@ Java_org_fcitx_fcitx5_android_data_pinyin_CustomPhraseManager_save(JNIEnv *env, *CString(env, phraseValue), static_cast(phraseOrder)); } - fcitx::StandardPath::global().safeSave( - fcitx::StandardPath::Type::PkgData, "pinyin/customphrase", + fcitx::StandardPaths::global().safeSave( + fcitx::StandardPathsType::PkgData, "pinyin/customphrase", [&](int fd) { boost::iostreams::stream_buffer buffer(fd, boost::iostreams::file_descriptor_flags::never_close_handle); diff --git a/app/src/main/cpp/object-conversion.h b/app/src/main/cpp/object-conversion.h index 1472e3c52..aa4155095 100644 --- a/app/src/main/cpp/object-conversion.h +++ b/app/src/main/cpp/object-conversion.h @@ -20,6 +20,7 @@ jobject fcitxInputMethodEntryToJObject(JNIEnv *env, const fcitx::InputMethodEntr *JString(env, entry->nativeName()), *JString(env, entry->label()), *JString(env, entry->languageCode()), + *JString(env, entry->addon()), entry->isConfigurable() ); } @@ -42,6 +43,7 @@ jobject fcitxInputMethodStatusToJObject(JNIEnv *env, const InputMethodStatus &st *JString(env, status.nativeName), *JString(env, status.label), *JString(env, status.languageCode), + *JString(env, status.addon), status.configurable, *JString(env, status.subMode), *JString(env, status.subModeLabel), @@ -159,4 +161,25 @@ jobject fcitxTextToJObject(JNIEnv *env, const fcitx::Text &text) { return obj; } +jobject fcitxCandidateActionToObject(JNIEnv *env, const CandidateActionEntity &act) { + auto obj = env->NewObject(GlobalRef->CandidateAction, GlobalRef->CandidateActionInit, + act.id, + *JString(env, act.text), + act.isSeparator, + *JString(env, act.icon), + act.isCheckable, + act.isChecked + ); + return obj; +} + +jobject candidateEntityToObject(JNIEnv *env, const CandidateEntity &c) { + auto obj = env->NewObject(GlobalRef->Candidate, GlobalRef->CandidateInit, + *JString(env, c.label), + *JString(env, c.text), + *JString(env, c.comment) + ); + return obj; +} + #endif //FCITX5_ANDROID_OBJECT_CONVERSION_H diff --git a/app/src/main/cpp/po/de.po b/app/src/main/cpp/po/de.po index b04353cf8..e9737c8d4 100644 --- a/app/src/main/cpp/po/de.po +++ b/app/src/main/cpp/po/de.po @@ -9,7 +9,7 @@ msgstr "" "POT-Creation-Date: 2022-02-14 18:51+0800\n" "PO-Revision-Date: 2022-03-18 22:18+0000\n" "Last-Translator: Ettore Atalan , 2022\n" -"Language-Team: German (https://www.transifex.com/fcitx/teams/12005/de/)\n" +"Language-Team: German (https://app.transifex.com/fcitx/teams/12005/de/)\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" @@ -28,6 +28,12 @@ msgstr "Worthinweis" msgid "Enable word hint" msgstr "Worthinweis aktivieren" +msgid "Enable word hint when using physical keyboard" +msgstr "" + +msgid "Disable word hint based on editor attributes" +msgstr "" + msgid "Word hint page size" msgstr "Seitengröße des Worthinweises" @@ -36,3 +42,9 @@ msgstr "" msgid "Insert space between words" msgstr "" + +msgid "Android Toast & Notification" +msgstr "" + +msgid "Hidden Notifications" +msgstr "" diff --git a/app/src/main/cpp/po/es.po b/app/src/main/cpp/po/es.po index 71d03f71f..d78fede55 100644 --- a/app/src/main/cpp/po/es.po +++ b/app/src/main/cpp/po/es.po @@ -28,6 +28,12 @@ msgstr "" msgid "Enable word hint" msgstr "" +msgid "Enable word hint when using physical keyboard" +msgstr "" + +msgid "Disable word hint based on editor attributes" +msgstr "" + msgid "Word hint page size" msgstr "" @@ -36,3 +42,9 @@ msgstr "" msgid "Insert space between words" msgstr "Insertar espacio entre palabras" + +msgid "Android Toast & Notification" +msgstr "" + +msgid "Hidden Notifications" +msgstr "" diff --git a/app/src/main/cpp/po/fcitx5-android.pot b/app/src/main/cpp/po/fcitx5-android.pot index a1582b1d7..0efb63065 100644 --- a/app/src/main/cpp/po/fcitx5-android.pot +++ b/app/src/main/cpp/po/fcitx5-android.pot @@ -23,6 +23,12 @@ msgstr "" msgid "Enable word hint" msgstr "" +msgid "Enable word hint when using physical keyboard" +msgstr "" + +msgid "Disable word hint based on editor attributes" +msgstr "" + msgid "Word hint page size" msgstr "" diff --git a/app/src/main/cpp/po/ja.po b/app/src/main/cpp/po/ja.po index defa4355e..d612fdf36 100644 --- a/app/src/main/cpp/po/ja.po +++ b/app/src/main/cpp/po/ja.po @@ -1,6 +1,7 @@ # # Translators: # Takuro Onoue , 2022 +# NPL, 2024 # msgid "" msgstr "" @@ -8,8 +9,8 @@ msgstr "" "Report-Msgid-Bugs-To: https://github.com/fcitx5-android/fcitx5-android/issues\n" "POT-Creation-Date: 2022-02-14 18:51+0800\n" "PO-Revision-Date: 2022-03-18 22:18+0000\n" -"Last-Translator: Takuro Onoue , 2022\n" -"Language-Team: Japanese (https://www.transifex.com/fcitx/teams/12005/ja/)\n" +"Last-Translator: NPL, 2024\n" +"Language-Team: Japanese (https://app.transifex.com/fcitx/teams/12005/ja/)\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" @@ -21,3 +22,30 @@ msgstr "Android フロントエンド" msgid "Android Keyboard" msgstr "Android キーボード" + +msgid "Word hint" +msgstr "単語ヒント" + +msgid "Enable word hint" +msgstr "単語ヒントを有効にする" + +msgid "Enable word hint when using physical keyboard" +msgstr "" + +msgid "Disable word hint based on editor attributes" +msgstr "" + +msgid "Word hint page size" +msgstr "単語ヒントのページサイズ" + +msgid "Choose key modifier" +msgstr "修飾キーを選択" + +msgid "Insert space between words" +msgstr "単語間に空白を入力する" + +msgid "Android Toast & Notification" +msgstr "Android トーストと通知" + +msgid "Hidden Notifications" +msgstr "通知を非表示にする" diff --git a/app/src/main/cpp/po/ru.po b/app/src/main/cpp/po/ru.po index 20040eec1..9af294da3 100644 --- a/app/src/main/cpp/po/ru.po +++ b/app/src/main/cpp/po/ru.po @@ -1,7 +1,7 @@ # # Translators: # Potato Hatsue, 2022 -# Dmitry , 2022 +# Dmitry , 2024 # msgid "" msgstr "" @@ -9,8 +9,8 @@ msgstr "" "Report-Msgid-Bugs-To: https://github.com/fcitx5-android/fcitx5-android/issues\n" "POT-Creation-Date: 2022-02-14 18:51+0800\n" "PO-Revision-Date: 2022-03-18 22:18+0000\n" -"Last-Translator: Dmitry , 2022\n" -"Language-Team: Russian (https://www.transifex.com/fcitx/teams/12005/ru/)\n" +"Last-Translator: Dmitry , 2024\n" +"Language-Team: Russian (https://app.transifex.com/fcitx/teams/12005/ru/)\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" @@ -29,6 +29,12 @@ msgstr "Подсказка слова" msgid "Enable word hint" msgstr "Включить подсказку слова" +msgid "Enable word hint when using physical keyboard" +msgstr "Включить подсказки слов при использовании физической клавиатуры" + +msgid "Disable word hint based on editor attributes" +msgstr "Отключить подсказки слов в зависимости от свойств редактора" + msgid "Word hint page size" msgstr "Размер страницы подсказки слов" @@ -37,3 +43,9 @@ msgstr "Выберите клавишу-модификатор" msgid "Insert space between words" msgstr "Вставить пробел между словами" + +msgid "Android Toast & Notification" +msgstr "Всплывающие подсказки и уведомления Android" + +msgid "Hidden Notifications" +msgstr "Скрытые уведомления" diff --git a/app/src/main/cpp/po/zh_CN.po b/app/src/main/cpp/po/zh_CN.po index 1bab9bbd2..5be82e30c 100644 --- a/app/src/main/cpp/po/zh_CN.po +++ b/app/src/main/cpp/po/zh_CN.po @@ -1,7 +1,8 @@ # # Translators: # Potato Hatsue, 2022 -# rocka, 2022 +# rocka, 2024 +# Yiyu Liu, 2024 # msgid "" msgstr "" @@ -9,8 +10,8 @@ msgstr "" "Report-Msgid-Bugs-To: https://github.com/fcitx5-android/fcitx5-android/issues\n" "POT-Creation-Date: 2022-02-14 18:51+0800\n" "PO-Revision-Date: 2022-03-18 22:18+0000\n" -"Last-Translator: rocka, 2022\n" -"Language-Team: Chinese (China) (https://www.transifex.com/fcitx/teams/12005/zh_CN/)\n" +"Last-Translator: Yiyu Liu, 2024\n" +"Language-Team: Chinese (China) (https://app.transifex.com/fcitx/teams/12005/zh_CN/)\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" @@ -29,6 +30,12 @@ msgstr "单词提示" msgid "Enable word hint" msgstr "启用单词提示" +msgid "Enable word hint when using physical keyboard" +msgstr "在使用物理键盘时启用单词提示" + +msgid "Disable word hint based on editor attributes" +msgstr "根据编辑器属性禁用单词提示" + msgid "Word hint page size" msgstr "单词提示页大小" @@ -38,8 +45,8 @@ msgstr "选词修饰键" msgid "Insert space between words" msgstr "在单词间插入空格" -msgid "Android 消息框与通知" -msgstr "" +msgid "Android Toast & Notification" +msgstr "Android 弹出提示与通知" -msgid "隐藏的通知" -msgstr "" +msgid "Hidden Notifications" +msgstr "隐藏的通知" diff --git a/app/src/main/cpp/po/zh_TW.po b/app/src/main/cpp/po/zh_TW.po index c5ace5e70..36cd22166 100644 --- a/app/src/main/cpp/po/zh_TW.po +++ b/app/src/main/cpp/po/zh_TW.po @@ -1,8 +1,8 @@ # # Translators: # 黃柏諺 , 2022 -# Zhang Jia-Bin , 2022 -# rocka, 2022 +# Jia-Bin, 2022 +# Yiyu Liu, 2024 # msgid "" msgstr "" @@ -10,8 +10,8 @@ msgstr "" "Report-Msgid-Bugs-To: https://github.com/fcitx5-android/fcitx5-android/issues\n" "POT-Creation-Date: 2022-02-14 18:51+0800\n" "PO-Revision-Date: 2022-03-18 22:18+0000\n" -"Last-Translator: rocka, 2022\n" -"Language-Team: Chinese (Taiwan) (https://www.transifex.com/fcitx/teams/12005/zh_TW/)\n" +"Last-Translator: Yiyu Liu, 2024\n" +"Language-Team: Chinese (Taiwan) (https://app.transifex.com/fcitx/teams/12005/zh_TW/)\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" @@ -25,13 +25,19 @@ msgid "Android Keyboard" msgstr "Android 鍵盤" msgid "Word hint" -msgstr "單字提示" +msgstr "字詞提示" msgid "Enable word hint" -msgstr "啟用單字提示" +msgstr "啟用字詞提示" + +msgid "Enable word hint when using physical keyboard" +msgstr "在使用物理鍵盤時啟用字詞提示" + +msgid "Disable word hint based on editor attributes" +msgstr "依據編輯器屬性禁用字詞提示" msgid "Word hint page size" -msgstr "單字提示頁大小" +msgstr "字詞提示頁大小" msgid "Choose key modifier" msgstr "選詞修飾鍵" @@ -39,8 +45,8 @@ msgstr "選詞修飾鍵" msgid "Insert space between words" msgstr "在單字間插入空格" -msgid "Android 浮動式訊息与通知" -msgstr "" +msgid "Android Toast & Notification" +msgstr "Android 浮動式訊息與通知" -msgid "隐藏的通知" -msgstr "" +msgid "Hidden Notifications" +msgstr "隱藏的通知" diff --git a/app/src/main/java/org/fcitx/fcitx5/android/FcitxApplication.kt b/app/src/main/java/org/fcitx/fcitx5/android/FcitxApplication.kt index 3e68edc13..e52553b36 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/FcitxApplication.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/FcitxApplication.kt @@ -14,6 +14,8 @@ import android.content.res.Configuration import android.os.Build import android.os.Process import android.util.Log +import androidx.core.content.ContextCompat +import androidx.core.content.edit import androidx.preference.PreferenceManager import kotlinx.coroutines.CoroutineName import kotlinx.coroutines.MainScope @@ -26,6 +28,7 @@ import org.fcitx.fcitx5.android.ui.main.LogActivity import org.fcitx.fcitx5.android.utils.AppUtil import org.fcitx.fcitx5.android.utils.Locales import org.fcitx.fcitx5.android.utils.isDarkMode +import org.fcitx.fcitx5.android.utils.startActivity import org.fcitx.fcitx5.android.utils.userManager import timber.log.Timber import kotlin.system.exitProcess @@ -57,6 +60,18 @@ class FcitxApplication : Application() { } } + private val restartFcitxInstanceReceiver = object : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + if (intent.action != ACTION_RESTART_FCITX_INSTANCE) return + if (FcitxDaemon.getFirstConnectionOrNull() != null) { + Timber.i("Received broadcast '${intent.action}', try to restart fcitx instance ...") + FcitxDaemon.restartFcitx() + } else { + Timber.i("Received broadcast '${intent.action}', but there's no fcitx instance") + } + } + } + var isDirectBootMode = false private set @@ -74,7 +89,19 @@ class FcitxApplication : Application() { if (!BuildConfig.DEBUG) { Thread.setDefaultUncaughtExceptionHandler { _, e -> - startActivity(Intent(ctx, LogActivity::class.java).apply { + val crashTime = System.currentTimeMillis() + val sharedPreferences = PreferenceManager.getDefaultSharedPreferences(ctx) + val lastCrashTimePrefKey = "last_crash_time" + val lastCrashTime = sharedPreferences.getLong(lastCrashTimePrefKey, -1L) + // make sure it was written to persistent storage + sharedPreferences.edit(commit = true) { + putLong(lastCrashTimePrefKey, crashTime) + } + if (crashTime - lastCrashTime <= 10_000L) { + // continuous crashes within 10 seconds, maybe in a crash loop. just bail + exitProcess(10) + } + startActivity { flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK putExtra(LogActivity.FROM_CRASH, true) // avoid transaction overflow @@ -85,7 +112,7 @@ class FcitxApplication : Application() { it } putExtra(LogActivity.CRASH_STACK_TRACE, truncated) - }) + } exitProcess(10) } } @@ -126,12 +153,20 @@ class FcitxApplication : Application() { AppPrefs.getInstance().syncToDeviceEncryptedStorage() ThemeManager.syncToDeviceEncryptedStorage() } + ContextCompat.registerReceiver( + this, + restartFcitxInstanceReceiver, + IntentFilter(ACTION_RESTART_FCITX_INSTANCE), + PERMISSION_TEST_INPUT_METHOD, + null, + ContextCompat.RECEIVER_EXPORTED + ) } override fun onConfigurationChanged(newConfig: Configuration) { super.onConfigurationChanged(newConfig) - ThemeManager.onSystemDarkModeChange(newConfig.isDarkMode()) - Locales.onLocaleChange(resources.configuration) + ThemeManager.onSystemPlatteChange(newConfig) + Locales.onLocaleChange(newConfig) } companion object { @@ -142,5 +177,21 @@ class FcitxApplication : Application() { fun getLastPid() = lastPid private const val MAX_STACKTRACE_SIZE = 128000 + + const val ACTION_RESTART_FCITX_INSTANCE = + "${BuildConfig.APPLICATION_ID}.action.RESTART_FCITX_INSTANCE" + + /** + * This permission is requested by com.android.shell, makes it possible to restart + * fcitx instance from `adb shell am` command: + * ```sh + * adb shell am broadcast -a org.fcitx.fcitx5.android.action.RESTART_FCITX_INSTANCE + * ``` + * https://android.googlesource.com/platform/frameworks/base/+/refs/tags/android-7.0.0_r1/packages/Shell/AndroidManifest.xml#67 + * + * other candidate: android.permission.TEST_INPUT_METHOD requires Android 14 + * https://android.googlesource.com/platform/frameworks/base/+/refs/tags/android-14.0.0_r1/packages/Shell/AndroidManifest.xml#628 + */ + const val PERMISSION_TEST_INPUT_METHOD = "android.permission.READ_INPUT_STATE" } } \ No newline at end of file diff --git a/app/src/main/java/org/fcitx/fcitx5/android/FcitxRemoteService.kt b/app/src/main/java/org/fcitx/fcitx5/android/FcitxRemoteService.kt index 4762ed92c..282a31f62 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/FcitxRemoteService.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/FcitxRemoteService.kt @@ -39,7 +39,6 @@ class FcitxRemoteService : Service() { PriorityQueue(3, compareByDescending { it.priority }) private fun transformClipboard(source: String): String { - MainScope() var result = source clipboardTransformers.forEach { try { @@ -74,11 +73,8 @@ class FcitxRemoteService : Service() { override fun registerClipboardEntryTransformer(transformer: IClipboardEntryTransformer) { Timber.d("registerClipboardEntryTransformer: ${transformer.desc}") - try { - transformer.description!!.isNotEmpty() || throw Exception() - } catch (e: Exception) { + if (transformer.description.isNullOrBlank()) { Timber.w("Cannot register ClipboardEntryTransformer of null or empty description") - return } if (clipboardTransformers.any { it.descEquals(transformer) }) { Timber.w("ClipboardEntryTransformer ${transformer.desc} has already been registered") @@ -97,7 +93,7 @@ class FcitxRemoteService : Service() { Timber.d("unregisterClipboardEntryTransformer: ${transformer.desc}") scope.launch { clipboardTransformers.remove(transformer) - || clipboardTransformers.removeIf { it.descEquals(transformer) } + || clipboardTransformers.removeAll { it.descEquals(transformer) } || return@launch updateClipboardManager() } diff --git a/app/src/main/java/org/fcitx/fcitx5/android/core/CapabilityFlag.kt b/app/src/main/java/org/fcitx/fcitx5/android/core/CapabilityFlag.kt index 800ede315..098f34c06 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/core/CapabilityFlag.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/core/CapabilityFlag.kt @@ -148,6 +148,7 @@ value class CapabilityFlags constructor(val flags: ULong) { } if (equals(InputType.TYPE_TEXT_VARIATION_VISIBLE_PASSWORD)) { flags += CapabilityFlag.Sensitive + flags += CapabilityFlag.NoSpellCheck } if (equals(InputType.TYPE_TEXT_VARIATION_URI)) { flags += CapabilityFlag.Url diff --git a/app/src/main/java/org/fcitx/fcitx5/android/core/Fcitx.kt b/app/src/main/java/org/fcitx/fcitx5/android/core/Fcitx.kt index d47361431..54aa7a084 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/core/Fcitx.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/core/Fcitx.kt @@ -1,10 +1,11 @@ /* * SPDX-License-Identifier: LGPL-2.1-or-later - * SPDX-FileCopyrightText: Copyright 2021-2023 Fcitx5 for Android Contributors + * SPDX-FileCopyrightText: Copyright 2021-2024 Fcitx5 for Android Contributors */ package org.fcitx.fcitx5.android.core import android.content.Context +import android.os.Build import androidx.annotation.Keep import androidx.core.content.ContextCompat import kotlinx.coroutines.channels.BufferOverflow @@ -72,17 +73,29 @@ class Fcitx(private val context: Context) : FcitxAPI, FcitxLifecycleOwner { override suspend fun save() = withFcitxContext { saveFcitxState() } override suspend fun reloadConfig() = withFcitxContext { reloadFcitxConfig() } - override suspend fun sendKey(key: String, states: UInt, up: Boolean, timestamp: Int) = - withFcitxContext { sendKeyToFcitxString(key, states.toInt(), up, timestamp) } - - override suspend fun sendKey(c: Char, states: UInt, up: Boolean, timestamp: Int) = - withFcitxContext { sendKeyToFcitxChar(c, states.toInt(), up, timestamp) } - - override suspend fun sendKey(sym: Int, states: UInt, up: Boolean, timestamp: Int) = - withFcitxContext { sendKeySymToFcitx(sym, states.toInt(), up, timestamp) } - - override suspend fun sendKey(sym: KeySym, states: KeyStates, up: Boolean, timestamp: Int) = - withFcitxContext { sendKeySymToFcitx(sym.sym, states.toInt(), up, timestamp) } + override suspend fun sendKey( + key: String, + states: UInt, + code: Int, + up: Boolean, + timestamp: Int + ) = + withFcitxContext { sendKeyToFcitxString(key, states.toInt(), code, up, timestamp) } + + override suspend fun sendKey(c: Char, states: UInt, code: Int, up: Boolean, timestamp: Int) = + withFcitxContext { sendKeyToFcitxChar(c, states.toInt(), code, up, timestamp) } + + override suspend fun sendKey(sym: Int, states: UInt, code: Int, up: Boolean, timestamp: Int) = + withFcitxContext { sendKeySymToFcitx(sym, states.toInt(), code, up, timestamp) } + + override suspend fun sendKey( + sym: KeySym, + states: KeyStates, + code: Int, + up: Boolean, + timestamp: Int + ) = + withFcitxContext { sendKeySymToFcitx(sym.sym, states.toInt(), code, up, timestamp) } override suspend fun select(idx: Int): Boolean = withFcitxContext { selectCandidate(idx) } override suspend fun isEmpty(): Boolean = withFcitxContext { isInputPanelEmpty() } @@ -142,8 +155,8 @@ class Fcitx(private val context: Context) : FcitxAPI, FcitxLifecycleOwner { override suspend fun triggerQuickPhrase() = withFcitxContext { triggerQuickPhraseInput() } override suspend fun triggerUnicode() = withFcitxContext { triggerUnicodeInput() } - private suspend fun setClipboard(string: String) = - withFcitxContext { setFcitxClipboard(string) } + private suspend fun setClipboard(string: String, password: Boolean = false) = + withFcitxContext { setFcitxClipboard(string, password) } override suspend fun focus(focus: Boolean) = withFcitxContext { focusInputContext(focus) } override suspend fun activate(uid: Int, pkgName: String) = @@ -162,6 +175,18 @@ class Fcitx(private val context: Context) : FcitxAPI, FcitxLifecycleOwner { override suspend fun getCandidates(offset: Int, limit: Int): Array = withFcitxContext { getFcitxCandidates(offset, limit) ?: emptyArray() } + override suspend fun getCandidateActions(idx: Int): Array = + withFcitxContext { getFcitxCandidateActions(idx) ?: emptyArray() } + + override suspend fun triggerCandidateAction(idx: Int, actionIdx: Int) = + withFcitxContext { triggerFcitxCandidateAction(idx, actionIdx) } + + override suspend fun setCandidatePagingMode(mode: Int) = + withFcitxContext { setFcitxCandidatePagingMode(mode) } + + override suspend fun offsetCandidatePage(delta: Int) = + withFcitxContext { offsetFcitxCandidatePage(delta) } + init { if (lifecycle.currentState != FcitxLifecycle.State.STOPPED) throw IllegalAccessException("Fcitx5 has already been created!") @@ -207,9 +232,7 @@ class Fcitx(private val context: Context) : FcitxAPI, FcitxLifecycleOwner { appLib: String, extData: String, extCache: String, - extDomains: Array, - libraryNames: Array, - libraryDependencies: Array> + extDomains: Array ) @JvmStatic @@ -225,13 +248,19 @@ class Fcitx(private val context: Context) : FcitxAPI, FcitxLifecycleOwner { external fun reloadFcitxConfig() @JvmStatic - external fun sendKeyToFcitxString(key: String, state: Int, up: Boolean, timestamp: Int) + external fun sendKeyToFcitxString( + key: String, + state: Int, + code: Int, + up: Boolean, + timestamp: Int + ) @JvmStatic - external fun sendKeyToFcitxChar(c: Char, state: Int, up: Boolean, timestamp: Int) + external fun sendKeyToFcitxChar(c: Char, state: Int, code: Int, up: Boolean, timestamp: Int) @JvmStatic - external fun sendKeySymToFcitx(sym: Int, state: Int, up: Boolean, timestamp: Int) + external fun sendKeySymToFcitx(sym: Int, state: Int, code: Int, up: Boolean, timestamp: Int) @JvmStatic external fun selectCandidate(idx: Int): Boolean @@ -303,7 +332,7 @@ class Fcitx(private val context: Context) : FcitxAPI, FcitxLifecycleOwner { external fun triggerUnicodeInput() @JvmStatic - external fun setFcitxClipboard(string: String) + external fun setFcitxClipboard(string: String, password: Boolean) @JvmStatic external fun focusInputContext(focus: Boolean) @@ -326,6 +355,18 @@ class Fcitx(private val context: Context) : FcitxAPI, FcitxLifecycleOwner { @JvmStatic external fun getFcitxCandidates(offset: Int, limit: Int): Array? + @JvmStatic + external fun getFcitxCandidateActions(idx: Int): Array? + + @JvmStatic + external fun triggerFcitxCandidateAction(idx: Int, actionIdx: Int) + + @JvmStatic + external fun setFcitxCandidatePagingMode(mode: Int) + + @JvmStatic + external fun offsetFcitxCandidatePage(delta: Int) + @JvmStatic external fun loopOnce() @@ -356,19 +397,6 @@ class Fcitx(private val context: Context) : FcitxAPI, FcitxLifecycleOwner { // will be called in fcitx main thread private fun onFirstRun() { Timber.i("onFirstRun") - getFcitxGlobalConfig()?.get("cfg")?.apply { - get("Behavior").apply { - get("ShareInputState").value = "All" - get("PreeditEnabledByDefault").value = "False" - } - setFcitxGlobalConfig(this) - } - getFcitxAddonConfig("pinyin")?.get("cfg")?.apply { - get("PreeditInApplication").value = "False" - get("PreeditCursorPositionAtBeginning").value = "False" - get("QuickPhraseKey").value = "" - setFcitxAddonConfig("pinyin", this) - } firstRun = false } @@ -394,18 +422,14 @@ class Fcitx(private val context: Context) : FcitxAPI, FcitxLifecycleOwner { val plugins = DataManager.getLoadedPlugins() val nativeLibDir = StringBuilder(context.applicationInfo.nativeLibraryDir) val extDomains = arrayListOf() - val libraryNames = arrayListOf() - val libraryDependency = arrayListOf>() plugins.forEach { - nativeLibDir.append(':') - nativeLibDir.append(it.nativeLibraryDir) + if (it.nativeLibraryDir.isNotBlank()) { + nativeLibDir.append(':') + nativeLibDir.append(it.nativeLibraryDir) + } it.domain?.let { d -> extDomains.add(d) } - it.libraryDependency.forEach { (lib, dep) -> - libraryNames.add(lib) - libraryDependency.add(dep.toTypedArray()) - } } Timber.d( """ @@ -423,11 +447,14 @@ class Fcitx(private val context: Context) : FcitxAPI, FcitxLifecycleOwner { nativeLibDir.toString(), (getExternalFilesDir(null) ?: filesDir).absolutePath, (externalCacheDir ?: cacheDir).absolutePath, - extDomains.toTypedArray(), - libraryNames.toTypedArray(), - libraryDependency.toTypedArray() + extDomains.toTypedArray() ) } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + lifecycle.launchWhenReady { + SubtypeManager.syncWith(enabledIme()) + } + } } override fun nativeLoopOnce() { @@ -451,7 +478,7 @@ class Fcitx(private val context: Context) : FcitxAPI, FcitxLifecycleOwner { @Keep private val onClipboardUpdate = ClipboardManager.OnClipboardUpdateListener { - lifecycle.lifecycleScope.launch { setClipboard(it.text) } + lifecycle.lifecycleScope.launch { setClipboard(it.text, it.sensitive) } } private fun computeAddonGraph() = runBlocking { diff --git a/app/src/main/java/org/fcitx/fcitx5/android/core/FcitxAPI.kt b/app/src/main/java/org/fcitx/fcitx5/android/core/FcitxAPI.kt index c8d218ce4..b6fd8ce8e 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/core/FcitxAPI.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/core/FcitxAPI.kt @@ -14,7 +14,6 @@ import kotlinx.coroutines.flow.SharedFlow */ interface FcitxAPI { - enum class AddonDep { Required, Optional @@ -43,13 +42,13 @@ interface FcitxAPI { suspend fun reloadConfig() - suspend fun sendKey(key: String, states: UInt = 0u, up: Boolean = false, timestamp: Int = -1) + suspend fun sendKey(key: String, states: UInt = 0u, code: Int = 0, up: Boolean = false, timestamp: Int = -1) - suspend fun sendKey(c: Char, states: UInt = 0u, up: Boolean = false, timestamp: Int = -1) + suspend fun sendKey(c: Char, states: UInt = 0u, code: Int = 0, up: Boolean = false, timestamp: Int = -1) - suspend fun sendKey(sym: Int, states: UInt = 0u, up: Boolean = false, timestamp: Int = -1) + suspend fun sendKey(sym: Int, states: UInt = 0u, code: Int = 0, up: Boolean = false, timestamp: Int = -1) - suspend fun sendKey(sym: KeySym, states: KeyStates, up: Boolean = false, timestamp: Int = -1) + suspend fun sendKey(sym: KeySym, states: KeyStates, code: Int = 0, up: Boolean = false, timestamp: Int = -1) suspend fun select(idx: Int): Boolean suspend fun isEmpty(): Boolean @@ -100,4 +99,10 @@ interface FcitxAPI { suspend fun getCandidates(offset: Int, limit: Int): Array + suspend fun getCandidateActions(idx: Int): Array + suspend fun triggerCandidateAction(idx: Int, actionIdx: Int) + + suspend fun setCandidatePagingMode(mode: Int) + suspend fun offsetCandidatePage(delta: Int) + } \ No newline at end of file diff --git a/app/src/main/java/org/fcitx/fcitx5/android/core/FcitxDispatcher.kt b/app/src/main/java/org/fcitx/fcitx5/android/core/FcitxDispatcher.kt index 33e0025e4..c596c8117 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/core/FcitxDispatcher.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/core/FcitxDispatcher.kt @@ -1,6 +1,6 @@ /* * SPDX-License-Identifier: LGPL-2.1-or-later - * SPDX-FileCopyrightText: Copyright 2021-2023 Fcitx5 for Android Contributors + * SPDX-FileCopyrightText: Copyright 2021-2025 Fcitx5 for Android Contributors */ package org.fcitx.fcitx5.android.core @@ -21,23 +21,18 @@ import kotlin.coroutines.CoroutineContext class FcitxDispatcher(private val controller: FcitxController) : CoroutineDispatcher() { - class WrappedRunnable(private val runnable: Runnable, private val name: String? = null) : - Runnable by runnable { + class WrappedRunnable(private val runnable: Runnable) : Runnable by runnable { private val time = System.currentTimeMillis() - var started = false - private set - - private val delta - get() = System.currentTimeMillis() - time override fun run() { - if (delta > JOB_WAITING_LIMIT) - Timber.w("${toString()} has waited $delta ms to get run since created!") - started = true + val delta = System.currentTimeMillis() - time + if (delta > JOB_WAITING_LIMIT) { + Timber.w("$this has waited $delta ms to get run since created!") + } runnable.run() } - override fun toString(): String = "WrappedRunnable[${name ?: hashCode()}]" + override fun toString(): String = "WrappedRunnable[${hashCode()}]" } // this is fcitx main thread diff --git a/app/src/main/java/org/fcitx/fcitx5/android/core/FcitxEvent.kt b/app/src/main/java/org/fcitx/fcitx5/android/core/FcitxEvent.kt index 3172ba6f1..1c119fab2 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/core/FcitxEvent.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/core/FcitxEvent.kt @@ -6,6 +6,8 @@ package org.fcitx.fcitx5.android.core sealed class FcitxEvent(open val data: T) { + data class Candidate(val label: String, val text: String, val comment: String) + abstract val eventType: EventType data class CandidateListEvent(override val data: Data) : @@ -13,7 +15,7 @@ sealed class FcitxEvent(open val data: T) { override val eventType = EventType.Candidate - data class Data(val total: Int, val candidates: Array) { + data class Data(val total: Int = -1, val candidates: Array = emptyArray()) { override fun toString(): String = "total=$total, candidates=[${candidates.joinToString(limit = 5)}]" @@ -121,11 +123,63 @@ sealed class FcitxEvent(open val data: T) { data class DeleteSurroundingEvent(override val data: Data) : FcitxEvent(data) { - override val eventType = EventType.DeleteSurrounding + override val eventType = EventType.DeleteSurrounding data class Data(val before: Int, val after: Int) } + data class PagedCandidateEvent(override val data: Data) : + FcitxEvent(data) { + + override val eventType = EventType.PagedCandidate + + enum class LayoutHint(value: Int) { + NotSet(0), Vertical(1), Horizontal(2); + + companion object { + private val Types = entries.toTypedArray() + fun of(value: Int) = Types[value] + } + } + + data class Data( + val candidates: Array, + val cursorIndex: Int, + val layoutHint: LayoutHint, + val hasPrev: Boolean, + val hasNext: Boolean + ) { + companion object { + @Suppress("BooleanLiteralArgument") + val Empty = Data(emptyArray(), -1, LayoutHint.NotSet, false, false) + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as Data + + if (!candidates.contentEquals(other.candidates)) return false + if (cursorIndex != other.cursorIndex) return false + if (layoutHint != other.layoutHint) return false + if (hasPrev != other.hasPrev) return false + if (hasNext != other.hasNext) return false + + return true + } + + override fun hashCode(): Int { + var result = candidates.contentHashCode() + result = 31 * result + cursorIndex + result = 31 * result + layoutHint.hashCode() + result = 31 * result + hasPrev.hashCode() + result = 31 * result + hasNext.hashCode() + return result + } + } + } + data class UnknownEvent(override val data: Array) : FcitxEvent>(data) { override val eventType = EventType.Unknown @@ -156,12 +210,13 @@ sealed class FcitxEvent(open val data: T) { Change, StatusArea, DeleteSurrounding, + PagedCandidate, Unknown } companion object { - private val Types = EventType.values() + private val Types = EventType.entries.toTypedArray() @Suppress("UNCHECKED_CAST") fun create(type: Int, params: Array) = @@ -206,6 +261,19 @@ sealed class FcitxEvent(open val data: T) { EventType.DeleteSurrounding -> (params[0] as IntArray).let { DeleteSurroundingEvent(DeleteSurroundingEvent.Data(it[0], it[1])) } + EventType.PagedCandidate -> if (params.isEmpty()) { + PagedCandidateEvent(PagedCandidateEvent.Data.Empty) + } else { + PagedCandidateEvent( + PagedCandidateEvent.Data( + params[0] as Array, + params[1] as Int, + PagedCandidateEvent.LayoutHint.of(params[2] as Int), + params[3] as Boolean, + params[4] as Boolean + ) + ) + } else -> UnknownEvent(params) } } diff --git a/app/src/main/java/org/fcitx/fcitx5/android/core/FcitxUtils.kt b/app/src/main/java/org/fcitx/fcitx5/android/core/FcitxUtils.kt new file mode 100644 index 000000000..374788e3e --- /dev/null +++ b/app/src/main/java/org/fcitx/fcitx5/android/core/FcitxUtils.kt @@ -0,0 +1,64 @@ +/* + * SPDX-License-Identifier: LGPL-2.1-or-later + * SPDX-FileCopyrightText: Copyright 2024 Fcitx5 for Android Contributors + */ + +package org.fcitx.fcitx5.android.core + +object FcitxUtils { + + // https://github.com/fcitx/fcitx5/blob/5.1.8/src/lib/fcitx-utils/stringutils.cpp#L323 + // https://github.com/fcitx/fcitx5/blob/5.1.8/src/lib/fcitx-utils/stringutils.cpp#L362 + fun unescapeForValue(str: String): String { + val quoted = str.length >= 2 && str.first() == '"' && str.last() == '"' + val s = if (quoted) str.substring(1, str.length - 1) else str + if (s.isEmpty()) return s + var escape = false + return buildString { + s.forEach { c -> + when (escape) { + false -> { + if (c == '\\') { + escape = true + } else { + append(c) + } + } + true -> { + if (c == '\\') { + append('\\') + } else if (c == 'n') { + append('\n') + } else if (c == '"' && quoted) { + append('"') + } else { + throw IllegalStateException("Unexpected escape sequence '\\${c}' when unescaping string '${str}'.") + } + escape = false + } + } + } + } + } + + private val QuotedChars = charArrayOf(' ', '"', '\t', '\r', '\u000b', '\u000c') + + // https://github.com/fcitx/fcitx5/blob/5.1.8/src/lib/fcitx-utils/stringutils.cpp#L380 + fun escapeForValue(str: String): String { + val needsQuote = str.lastIndexOfAny(QuotedChars) >= 0 + return buildString { + if (needsQuote) append('"') + str.forEach { c -> + append( + when (c) { + '\\' -> "\\\\" + '\n' -> "\\n" + '"' -> "\\\"" + else -> c + } + ) + } + if (needsQuote) append('"') + } + } +} diff --git a/app/src/main/java/org/fcitx/fcitx5/android/core/KeyState.kt b/app/src/main/java/org/fcitx/fcitx5/android/core/KeyState.kt index 4d4f2da3b..19ab51286 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/core/KeyState.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/core/KeyState.kt @@ -92,6 +92,8 @@ value class KeyStates(val states: UInt) { companion object { val Empty = KeyStates(0u) + val Virtual = KeyStates(KeyState.Virtual) + fun of(v: Int) = KeyStates(v.toUInt()) fun fromKeyEvent(event: KeyEvent): KeyStates { diff --git a/app/src/main/java/org/fcitx/fcitx5/android/core/SubtypeManager.kt b/app/src/main/java/org/fcitx/fcitx5/android/core/SubtypeManager.kt new file mode 100644 index 000000000..a07d60cbe --- /dev/null +++ b/app/src/main/java/org/fcitx/fcitx5/android/core/SubtypeManager.kt @@ -0,0 +1,59 @@ +/* + * SPDX-License-Identifier: LGPL-2.1-or-later + * SPDX-FileCopyrightText: Copyright 2023 Fcitx5 for Android Contributors + */ + +package org.fcitx.fcitx5.android.core + +import android.os.Build +import android.view.inputmethod.InputMethodSubtype +import android.view.inputmethod.InputMethodSubtype.InputMethodSubtypeBuilder +import androidx.annotation.RequiresApi +import org.fcitx.fcitx5.android.utils.InputMethodUtil +import org.fcitx.fcitx5.android.utils.appContext +import org.fcitx.fcitx5.android.utils.inputMethodManager + +object SubtypeManager { + + private const val MODE_KEYBOARD = "keyboard" + + private const val IM_KEYBOARD = "keyboard-us" + + private val knownSubtypes: HashMap = hashMapOf() + + fun subtypeOf(inputMethod: String): InputMethodSubtype? { + return knownSubtypes[inputMethod] + } + + fun inputMethodOf(subtype: InputMethodSubtype): String { + return subtype.extraValue.ifEmpty { IM_KEYBOARD } + } + + @RequiresApi(Build.VERSION_CODES.UPSIDE_DOWN_CAKE) + fun syncWith(inputMethods: Array) { + knownSubtypes.clear() + val size = inputMethods.size + val subtypes = arrayOfNulls(size) + val hashCodes = IntArray(size) + inputMethods.forEachIndexed { i, im -> + val subtype = InputMethodSubtypeBuilder() + .setSubtypeId(im.uniqueName.hashCode()) + .setSubtypeExtraValue(im.uniqueName) + .setSubtypeNameOverride(im.displayName) + .setSubtypeMode(MODE_KEYBOARD) + .setIsAsciiCapable(im.uniqueName == IM_KEYBOARD) + .build() + val hashCode = subtype.hashCode() + subtypes[i] = subtype + hashCodes[i] = hashCode + knownSubtypes[im.uniqueName] = subtype + } + val imm = appContext.inputMethodManager + val imiId = InputMethodUtil.componentName + // although this method has been marked as deprecated, + // dynamic subtypes have to be "registered" before they can be "enabled" + @Suppress("DEPRECATION") + imm.setAdditionalInputMethodSubtypes(imiId, subtypes) + imm.setExplicitlyEnabledInputMethodSubtypes(imiId, hashCodes) + } +} diff --git a/app/src/main/java/org/fcitx/fcitx5/android/core/Types.kt b/app/src/main/java/org/fcitx/fcitx5/android/core/Types.kt index e2e868fd7..12911945b 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/core/Types.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/core/Types.kt @@ -1,11 +1,12 @@ /* * SPDX-License-Identifier: LGPL-2.1-or-later - * SPDX-FileCopyrightText: Copyright 2021-2023 Fcitx5 for Android Contributors + * SPDX-FileCopyrightText: Copyright 2021-2025 Fcitx5 for Android Contributors */ package org.fcitx.fcitx5.android.core import android.os.Parcelable import kotlinx.parcelize.Parcelize +import kotlinx.serialization.Serializable data class InputMethodSubMode(val name: String, val label: String, val icon: String) { constructor() : this("", "", "") @@ -18,6 +19,7 @@ data class InputMethodEntry( val nativeName: String, val label: String, val languageCode: String, + val addon: String, val isConfigurable: Boolean, val subMode: InputMethodSubMode ) { @@ -28,6 +30,7 @@ data class InputMethodEntry( nativeName: String, label: String, languageCode: String, + addon: String, isConfigurable: Boolean ) : this( uniqueName, @@ -36,6 +39,7 @@ data class InputMethodEntry( nativeName, label, languageCode, + addon, isConfigurable, InputMethodSubMode() ) @@ -47,6 +51,7 @@ data class InputMethodEntry( nativeName: String, label: String, languageCode: String, + addon: String, isConfigurable: Boolean, subMode: String, subModeLabel: String, @@ -58,17 +63,19 @@ data class InputMethodEntry( nativeName, label, languageCode, + addon, isConfigurable, InputMethodSubMode(subMode, subModeLabel, subModeIcon) ) - constructor(name: String) : this("", name, "", "", "×", "", false) + constructor(name: String) : this("", name, "", "", "×", "", "", false) val displayName: String get() = name.ifEmpty { uniqueName } } @Parcelize +@Serializable data class RawConfig( val name: String, val comment: String, @@ -137,8 +144,7 @@ enum class AddonCategory { InputMethod, Frontend, Loader, Module, UI; companion object { - private val Values = values() - fun fromInt(i: Int) = Values[i] + fun fromInt(i: Int) = entries[i] } } @@ -154,6 +160,7 @@ data class AddonInfo( val dependencies: Array = arrayOf(), val optionalDependencies: Array = arrayOf(), ) { + @Suppress("UNUSED") // used in JNI constructor( uniqueName: String, name: String, @@ -256,3 +263,12 @@ data class Action( return result } } + +data class CandidateAction( + val id: Int, + val text: String, + val isSeparator: Boolean, + val icon: String, + val isCheckable: Boolean, + val isChecked: Boolean +) diff --git a/app/src/main/java/org/fcitx/fcitx5/android/core/data/DataManager.kt b/app/src/main/java/org/fcitx/fcitx5/android/core/data/DataManager.kt index a458e2212..f134cc767 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/core/data/DataManager.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/core/data/DataManager.kt @@ -1,6 +1,6 @@ /* * SPDX-License-Identifier: LGPL-2.1-or-later - * SPDX-FileCopyrightText: Copyright 2021-2023 Fcitx5 for Android Contributors + * SPDX-FileCopyrightText: Copyright 2021-2024 Fcitx5 for Android Contributors */ package org.fcitx.fcitx5.android.core.data @@ -13,10 +13,9 @@ import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json import org.fcitx.fcitx5.android.BuildConfig import org.fcitx.fcitx5.android.core.data.DataManager.dataDir -import org.fcitx.fcitx5.android.utils.Const import org.fcitx.fcitx5.android.utils.FileUtil import org.fcitx.fcitx5.android.utils.appContext -import org.fcitx.fcitx5.android.utils.javaIdRegex +import org.fcitx.fcitx5.android.utils.isJavaIdentifier import org.xmlpull.v1.XmlPullParser import timber.log.Timber import java.io.File @@ -30,6 +29,11 @@ import kotlin.concurrent.withLock */ object DataManager { + data class PluginSet( + val loaded: Set, + val failed: Map + ) + const val PLUGIN_INTENT = "${BuildConfig.APPLICATION_ID}.plugin.MANIFEST" private val lock = ReentrantLock() @@ -39,13 +43,13 @@ object DataManager { var synced = false private set - // should be consistent with the deserialization in build.gradle.kts (:app) - private fun deserializeDataDescriptor(raw: String) = runCatching { - json.decodeFromString(raw) + // should be consistent with the deserialization in DataDescriptorPlugin (:build-logic) + private fun deserializeDataDescriptor(raw: String): DataDescriptor { + return json.decodeFromString(raw) } - private fun serializeDataDescriptor(descriptor: DataDescriptor) = runCatching { - json.encodeToString(descriptor) + private fun serializeDataDescriptor(descriptor: DataDescriptor): String { + return json.encodeToString(descriptor) } // If Android version supports direct boot, we put the hierarchy in device encrypted storage @@ -57,14 +61,12 @@ object DataManager { File(appContext.applicationInfo.dataDir) } - private val destDescriptorFile = File(dataDir, Const.dataDescriptorName) - - private fun AssetManager.getDataDescriptor() = - open(Const.dataDescriptorName) + private fun AssetManager.getDataDescriptor(): DataDescriptor { + return open(BuildConfig.DATA_DESCRIPTOR_NAME) .bufferedReader() .use { it.readText() } .let { deserializeDataDescriptor(it) } - .getOrThrow() + } private val loadedPlugins = mutableSetOf() private val failedPlugins = mutableMapOf() @@ -72,6 +74,8 @@ object DataManager { fun getLoadedPlugins(): Set = loadedPlugins fun getFailedPlugins(): Map = failedPlugins + fun getSyncedPluginSet() = PluginSet(loadedPlugins, failedPlugins) + /** * Will be cleared after each sync */ @@ -80,8 +84,7 @@ object DataManager { fun addOnNextSyncedCallback(block: () -> Unit) = callbacks.add(block) - @SuppressLint("DiscouragedApi") - fun detectPlugins(): Pair, Map> { + fun detectPlugins(): PluginSet { val toLoad = mutableSetOf() val preloadFailed = mutableMapOf() @@ -93,7 +96,6 @@ object DataManager { PackageManager.ResolveInfoFlags.of(PackageManager.MATCH_ALL.toLong()) ) } else { - @Suppress("DEPRECATION") pm.queryIntentActivities(Intent(PLUGIN_INTENT), PackageManager.MATCH_ALL) }.map { it.activityInfo.packageName @@ -104,6 +106,8 @@ object DataManager { // Parse plugin.xml for (packageName in pluginPackages) { val res = pm.getResourcesForApplication(packageName) + + @SuppressLint("DiscouragedApi") val resId = res.getIdentifier("plugin", "xml", packageName) if (resId == 0) { Timber.w("Failed to get the plugin descriptor of $packageName") @@ -116,50 +120,28 @@ object DataManager { var apiVersion: String? = null var description: String? = null var hasService = false - val libraryDependency = mutableMapOf>() - var library: String? = null - var dependency: ArrayList? = null var text: String? = null while ((eventType != XmlPullParser.END_DOCUMENT)) { when (eventType) { XmlPullParser.TEXT -> text = parser.text - XmlPullParser.START_TAG -> when (parser.name) { - "library" -> { - dependency = arrayListOf() - for (i in 0.. when (parser.name) { "apiVersion" -> apiVersion = text "domain" -> domain = text "description" -> description = text "hasService" -> hasService = text?.lowercase() == "true" - "dependency" -> dependency?.add(text!!) - "library" -> { - if (library != null && dependency != null) { - libraryDependency[library] = dependency - library = null - dependency = null - } - } } } eventType = parser.next() } parser.close() - // Replace @string/ with string resource - description = description?.let { d -> - d.removePrefix("@string/").let { s -> - if (s.matches(javaIdRegex)) { - res.getIdentifier(s, "string", packageName).let { id -> - if (id != 0) res.getString(id) else d - } - } else d + if (description?.startsWith("@string/") == true) { + // Replace "@string/" with string resource + val s = description.substring(8) + if (s.isJavaIdentifier()) { + @SuppressLint("DiscouragedApi") + val id = res.getIdentifier(s, "string", packageName) + if (id != 0) description = res.getString(id) } } @@ -171,7 +153,6 @@ object DataManager { PackageManager.PackageInfoFlags.of(PackageManager.GET_META_DATA.toLong()) ) } else { - @Suppress("DEPRECATION") pm.getPackageInfo(packageName, PackageManager.GET_META_DATA) } toLoad.add( @@ -181,9 +162,8 @@ object DataManager { domain, description, hasService, - info.versionName, - info.applicationInfo.nativeLibraryDir, - libraryDependency + info.versionName ?: "", + info.applicationInfo?.nativeLibraryDir ?: "" ) ) } else { @@ -195,7 +175,7 @@ object DataManager { preloadFailed[packageName] = PluginLoadFailed.PluginDescriptorParseError } } - return toLoad to preloadFailed + return PluginSet(toLoad, preloadFailed) } fun sync() = lock.withLock { @@ -203,24 +183,15 @@ object DataManager { loadedPlugins.clear() failedPlugins.clear() + val destDescriptorFile = File(dataDir, BuildConfig.DATA_DESCRIPTOR_NAME) + // load last run's data descriptor - val oldDescriptor = - destDescriptorFile - .takeIf { it.exists() && it.isFile } - ?.runCatching { readText() } - ?.getOrNull() - ?.let { deserializeDataDescriptor(it) } - ?.getOrNull() - ?: DataDescriptor("", mapOf(), mapOf()) + val oldDescriptor = destDescriptorFile + .runCatching { deserializeDataDescriptor(bufferedReader().use { it.readText() }) } + .getOrElse { DataDescriptor("", emptyMap(), emptyMap()) } // load app's data descriptor - val mainDescriptor = - appContext.assets - .open(Const.dataDescriptorName) - .bufferedReader() - .use { it.readText() } - .let { deserializeDataDescriptor(it) } - .getOrThrow() + val mainDescriptor = appContext.assets.getDataDescriptor() val (parsedDescriptors, failed) = detectPlugins() failedPlugins.putAll(failed) @@ -292,13 +263,15 @@ object DataManager { } } // save the new hierarchy as the data descriptor to be used in the next run - destDescriptorFile.writeText(serializeDataDescriptor(newHierarchy.downToDataDescriptor()).getOrThrow()) + destDescriptorFile.bufferedWriter().use { + it.write(serializeDataDescriptor(newHierarchy.downToDataDescriptor())) + } callbacks.forEach { it() } callbacks.clear() if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { // remove old assets from credential encrypted storage val oldDataDir = appContext.dataDir - val oldDataDescriptor = oldDataDir.resolve(Const.dataDescriptorName) + val oldDataDescriptor = oldDataDir.resolve(BuildConfig.DATA_DESCRIPTOR_NAME) if (oldDataDescriptor.exists()) { oldDataDescriptor.delete() oldDataDir.resolve("README.md").delete() @@ -326,11 +299,11 @@ object DataManager { fun deleteAndSync() { lock.withLock { - dataDir.resolve(Const.dataDescriptorName).delete() + dataDir.resolve(BuildConfig.DATA_DESCRIPTOR_NAME).delete() dataDir.resolve("README.md").delete() dataDir.resolve("usr").deleteRecursively() } sync() } -} \ No newline at end of file +} diff --git a/app/src/main/java/org/fcitx/fcitx5/android/core/data/PluginDescriptor.kt b/app/src/main/java/org/fcitx/fcitx5/android/core/data/PluginDescriptor.kt index 52671a9bc..cec9078ec 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/core/data/PluginDescriptor.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/core/data/PluginDescriptor.kt @@ -1,11 +1,11 @@ /* * SPDX-License-Identifier: LGPL-2.1-or-later - * SPDX-FileCopyrightText: Copyright 2021-2023 Fcitx5 for Android Contributors + * SPDX-FileCopyrightText: Copyright 2021-2024 Fcitx5 for Android Contributors */ package org.fcitx.fcitx5.android.core.data +import org.fcitx.fcitx5.android.BuildConfig import org.fcitx.fcitx5.android.core.data.PluginDescriptor.Companion.pluginPackagePrefix -import org.fcitx.fcitx5.android.utils.Const /** * Metadata of a plugin, at `res/xml/plugin.xml` @@ -32,15 +32,13 @@ data class PluginDescriptor( */ val hasService: Boolean, val versionName: String, - val nativeLibraryDir: String, - val libraryDependency: Map> + val nativeLibraryDir: String ) { - val name by lazy { - packageName.removePrefix("$pluginPackagePrefix.").removeSuffix(".${Const.buildType}") - } + val name = packageName.removePrefix(pluginPackagePrefix).removeSuffix(pluginPackageSuffix) companion object { - const val pluginPackagePrefix = "org.fcitx.fcitx5.android.plugin" + const val pluginPackagePrefix = "org.fcitx.fcitx5.android.plugin." + const val pluginPackageSuffix = ".${BuildConfig.BUILD_TYPE}" const val pluginAPI = "0.1" } } \ No newline at end of file diff --git a/app/src/main/java/org/fcitx/fcitx5/android/data/InputFeedbacks.kt b/app/src/main/java/org/fcitx/fcitx5/android/data/InputFeedbacks.kt index f9d71acc6..fef6d17b1 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/data/InputFeedbacks.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/data/InputFeedbacks.kt @@ -10,56 +10,56 @@ import android.os.VibrationEffect import android.provider.Settings import android.view.HapticFeedbackConstants import android.view.View +import org.fcitx.fcitx5.android.R import org.fcitx.fcitx5.android.data.prefs.AppPrefs -import org.fcitx.fcitx5.android.data.prefs.ManagedPreference +import org.fcitx.fcitx5.android.data.prefs.ManagedPreferenceEnum import org.fcitx.fcitx5.android.utils.appContext import org.fcitx.fcitx5.android.utils.audioManager -import org.fcitx.fcitx5.android.utils.isSystemSettingEnabled +import org.fcitx.fcitx5.android.utils.getSystemSettings import org.fcitx.fcitx5.android.utils.vibrator object InputFeedbacks { - enum class InputFeedbackMode { - Enabled, Disabled, FollowingSystem; - - companion object : ManagedPreference.StringLikeCodec { - override fun decode(raw: String) = InputFeedbackMode.valueOf(raw) - } + enum class InputFeedbackMode(override val stringRes: Int) : ManagedPreferenceEnum { + FollowingSystem(R.string.following_system_settings), + Enabled(R.string.enabled), + Disabled(R.string.disabled); } private var systemSoundEffects = false private var systemHapticFeedback = false fun syncSystemPrefs() { - systemSoundEffects = isSystemSettingEnabled(Settings.System.SOUND_EFFECTS_ENABLED) + systemSoundEffects = getSystemSettings(Settings.System.SOUND_EFFECTS_ENABLED) == 1 // it says "Replaced by using android.os.VibrationAttributes.USAGE_TOUCH" // but gives no clue about how to use it, and this one still works @Suppress("DEPRECATION") - systemHapticFeedback = isSystemSettingEnabled(Settings.System.HAPTIC_FEEDBACK_ENABLED) + systemHapticFeedback = getSystemSettings(Settings.System.HAPTIC_FEEDBACK_ENABLED) == 1 } - private val soundOnKeyPress by AppPrefs.getInstance().keyboard.soundOnKeyPress - private val soundOnKeyPressVolume by AppPrefs.getInstance().keyboard.soundOnKeyPressVolume - private val hapticOnKeyPress by AppPrefs.getInstance().keyboard.hapticOnKeyPress - private val buttonPressVibrationMilliseconds by AppPrefs.getInstance().keyboard.buttonPressVibrationMilliseconds - private val buttonLongPressVibrationMilliseconds by AppPrefs.getInstance().keyboard.buttonLongPressVibrationMilliseconds - private val buttonPressVibrationAmplitude by AppPrefs.getInstance().keyboard.buttonPressVibrationAmplitude - private val buttonLongPressVibrationAmplitude by AppPrefs.getInstance().keyboard.buttonLongPressVibrationAmplitude + private val keyboardPrefs = AppPrefs.getInstance().keyboard + + private val soundOnKeyPress by keyboardPrefs.soundOnKeyPress + private val soundOnKeyPressVolume by keyboardPrefs.soundOnKeyPressVolume + private val hapticOnKeyPress by keyboardPrefs.hapticOnKeyPress + private val hapticOnKeyUp by keyboardPrefs.hapticOnKeyUp + private val buttonPressVibrationMilliseconds by keyboardPrefs.buttonPressVibrationMilliseconds + private val buttonLongPressVibrationMilliseconds by keyboardPrefs.buttonLongPressVibrationMilliseconds + private val buttonPressVibrationAmplitude by keyboardPrefs.buttonPressVibrationAmplitude + private val buttonLongPressVibrationAmplitude by keyboardPrefs.buttonLongPressVibrationAmplitude private val vibrator = appContext.vibrator private val hasAmplitudeControl = (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) && vibrator.hasAmplitudeControl() - private val audioManager = appContext.audioManager - - fun hapticFeedback(view: View, longPress: Boolean = false) { + fun hapticFeedback(view: View, longPress: Boolean = false, keyUp: Boolean = false) { when (hapticOnKeyPress) { InputFeedbackMode.Enabled -> {} InputFeedbackMode.Disabled -> return InputFeedbackMode.FollowingSystem -> if (!systemHapticFeedback) return } - + if (keyUp && !hapticOnKeyUp) return val duration: Long val amplitude: Int val hfc: Int @@ -70,25 +70,36 @@ object InputFeedbacks { } else { duration = buttonPressVibrationMilliseconds.toLong() amplitude = buttonPressVibrationAmplitude - hfc = HapticFeedbackConstants.KEYBOARD_TAP + hfc = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1 && keyUp) { + HapticFeedbackConstants.KEYBOARD_RELEASE + } else { + HapticFeedbackConstants.KEYBOARD_TAP + } } - val useVibrator = duration != 0L - if (useVibrator) { + // there is `VibrationEffect.DEFAULT_AMPLITUDE` but no default duration; + // also `VibrationEffect.createOneShot()` only accepts positive duration. + // so changing amplitude without changing duration makes no sense + if (duration != 0L) { // on Android 13, if system haptic feedback was disabled, `vibrator.vibrate()` won't work // but `view.performHapticFeedback()` with `FLAG_IGNORE_GLOBAL_SETTING` still works if (hasAmplitudeControl && amplitude != 0) { vibrator.vibrate(VibrationEffect.createOneShot(duration, amplitude)) + } else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val ve = VibrationEffect.createOneShot(duration, VibrationEffect.DEFAULT_AMPLITUDE) + vibrator.vibrate(ve) } else { @Suppress("DEPRECATION") vibrator.vibrate(duration) } } else { - // it says "Starting TIRAMISU only privileged apps can ignore user settings for touch feedback" - // but we still seem to be able to use `FLAG_IGNORE_GLOBAL_SETTING` - @Suppress("DEPRECATION") - val flags = - HapticFeedbackConstants.FLAG_IGNORE_VIEW_SETTING or HapticFeedbackConstants.FLAG_IGNORE_GLOBAL_SETTING + var flags = HapticFeedbackConstants.FLAG_IGNORE_VIEW_SETTING + if (hapticOnKeyPress == InputFeedbackMode.Enabled) { + // it says "Starting TIRAMISU only privileged apps can ignore user settings for touch feedback" + // but we still seem to be able to use `FLAG_IGNORE_GLOBAL_SETTING` + @Suppress("DEPRECATION") + flags = flags or HapticFeedbackConstants.FLAG_IGNORE_GLOBAL_SETTING + } view.performHapticFeedback(hfc, flags) } } @@ -97,6 +108,8 @@ object InputFeedbacks { Standard, SpaceBar, Delete, Return } + private val audioManager = appContext.audioManager + fun soundEffect(effect: SoundEffect) { when (soundOnKeyPress) { InputFeedbackMode.Enabled -> {} diff --git a/app/src/main/java/org/fcitx/fcitx5/android/data/RecentlyUsed.kt b/app/src/main/java/org/fcitx/fcitx5/android/data/RecentlyUsed.kt index 9afc698aa..deeadf0db 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/data/RecentlyUsed.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/data/RecentlyUsed.kt @@ -1,43 +1,76 @@ /* * SPDX-License-Identifier: LGPL-2.1-or-later - * SPDX-FileCopyrightText: Copyright 2021-2023 Fcitx5 for Android Contributors + * SPDX-FileCopyrightText: Copyright 2021-2025 Fcitx5 for Android Contributors */ + package org.fcitx.fcitx5.android.data +import android.content.Context +import androidx.core.content.edit +import kotlinx.serialization.json.Json import org.fcitx.fcitx5.android.FcitxApplication +import timber.log.Timber -// Not thread-safe -class RecentlyUsed( - val fileName: String, - val capacity: Int -) : LinkedHashMap(0, .75f, true) { +class RecentlyUsed(val type: String, val limit: Int) { companion object { + // for backwords compatibility only const val DIR_NAME = "recently_used" + const val PREFERENCE_NAME = "picker_recently_used" } - private val file = - FcitxApplication.getInstance().directBootAwareContext.filesDir.resolve(DIR_NAME).run { - mkdirs() - resolve(fileName).apply { createNewFile() } - } + private val sharedPreferences = FcitxApplication.getInstance().directBootAwareContext + .getSharedPreferences(PREFERENCE_NAME, Context.MODE_PRIVATE) - fun load() { - val xs = file.readLines() - xs.forEach { - if (it.isNotBlank()) - put(it, it) - } + private val map = LinkedHashMap(limit).apply { + (migrate() ?: load()).forEach { put(it, true) } } - fun save() { - file.writeText(values.joinToString("\n")) + val items: List get() = map.keys.reversed() + + private fun load(): List { + val rawValue = sharedPreferences.getString(type, "") ?: "" + if (rawValue.isEmpty()) { + return emptyList() + } + return try { + Json.decodeFromString>(rawValue) + } catch (_: Exception) { + sharedPreferences.edit { + remove(type) + } + emptyList() + } } - override fun removeEldestEntry(eldest: MutableMap.MutableEntry?) = - size > capacity + private fun save() { + sharedPreferences.edit { + putString(type, Json.encodeToString>(map.keys.toList())) + } + } - fun insert(s: String) = put(s, s) + fun insert(item: String) { + map.put(item, true) + save() + } - fun toOrderedList() = values.toList().reversed() + fun migrate(): List? { + val dir = FcitxApplication.getInstance().directBootAwareContext.filesDir.resolve(DIR_NAME) + val file = dir.resolve(type) + if (file.exists()) { + try { + val lines = file.readLines() + file.delete() + if (dir.list()?.isEmpty() == true) { + dir.delete() + } + return lines + } catch (e: Exception) { + Timber.w("Failed to migrate RecentlyUsed(type=$type)") + Timber.w(e) + return null + } + } + return null + } } \ No newline at end of file diff --git a/app/src/main/java/org/fcitx/fcitx5/android/data/UserDataManager.kt b/app/src/main/java/org/fcitx/fcitx5/android/data/UserDataManager.kt index c0c65ec39..39a31ab64 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/data/UserDataManager.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/data/UserDataManager.kt @@ -1,6 +1,6 @@ /* * SPDX-License-Identifier: LGPL-2.1-or-later - * SPDX-FileCopyrightText: Copyright 2021-2023 Fcitx5 for Android Contributors + * SPDX-FileCopyrightText: Copyright 2021-2025 Fcitx5 for Android Contributors */ package org.fcitx.fcitx5.android.data @@ -14,6 +14,7 @@ import org.fcitx.fcitx5.android.utils.Const import org.fcitx.fcitx5.android.utils.appContext import org.fcitx.fcitx5.android.utils.errorRuntime import org.fcitx.fcitx5.android.utils.extract +import org.fcitx.fcitx5.android.utils.versionCodeCompat import org.fcitx.fcitx5.android.utils.withTempDir import timber.log.Timber import java.io.File @@ -30,7 +31,7 @@ object UserDataManager { @Serializable data class Metadata( val packageName: String, - val versionCode: Int, + val versionCode: Long, val versionName: String, val exportTime: Long ) @@ -64,13 +65,13 @@ object UserDataManager { writeFileTree(dataBasesDir, "databases", zipStream) // external writeFileTree(externalDir, "external", zipStream) - // recently_used - writeFileTree(recentlyUsedDir, "recently_used", zipStream) + // recently_used moved to SharedPreference and shoud not be exported // metadata zipStream.putNextEntry(ZipEntry("metadata.json")) + val pkgInfo = appContext.packageManager.getPackageInfo(appContext.packageName, 0) val metadata = Metadata( - BuildConfig.APPLICATION_ID, - BuildConfig.VERSION_CODE, + pkgInfo.packageName, + pkgInfo.versionCodeCompat, Const.versionName, timestamp ) @@ -85,7 +86,6 @@ object UserDataManager { if (exists && isDir) { source.copyRecursively(target, overwrite = true) } else { - source.toString() Timber.w("Cannot import user data: path='${source.path}', exists=$exists, isDir=$isDir") } } @@ -102,6 +102,7 @@ object UserDataManager { copyDir(File(tempDir, "shared_prefs"), sharedPrefsDir) copyDir(File(tempDir, "databases"), dataBasesDir) copyDir(File(tempDir, "external"), externalDir) + // keep importing recently_used for backwords compatibility copyDir(File(tempDir, "recently_used"), recentlyUsedDir) metadata } diff --git a/app/src/main/java/org/fcitx/fcitx5/android/data/clipboard/ClipboardManager.kt b/app/src/main/java/org/fcitx/fcitx5/android/data/clipboard/ClipboardManager.kt index 6e93b933d..92bc85285 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/data/clipboard/ClipboardManager.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/data/clipboard/ClipboardManager.kt @@ -6,8 +6,10 @@ package org.fcitx.fcitx5.android.data.clipboard import android.content.ClipboardManager import android.content.Context +import android.os.Build import androidx.annotation.Keep import androidx.room.Room +import androidx.room.withTransaction import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob @@ -23,6 +25,7 @@ import org.fcitx.fcitx5.android.data.prefs.ManagedPreference import org.fcitx.fcitx5.android.utils.WeakHashSet import org.fcitx.fcitx5.android.utils.appContext import org.fcitx.fcitx5.android.utils.clipboardManager +import timber.log.Timber object ClipboardManager : ClipboardManager.OnPrimaryClipChangedListener, CoroutineScope by CoroutineScope(SupervisorJob() + Dispatchers.Default) { @@ -84,6 +87,8 @@ object ClipboardManager : ClipboardManager.OnPrimaryClipChangedListener, fun init(context: Context) { clbDb = Room .databaseBuilder(context, ClipboardDatabase::class.java, "clbdb") + // allow wipe the database instead of crashing when downgrade + .fallbackToDestructiveMigrationOnDowngrade(dropAllTables = true) .build() clbDao = clbDb.clipboardDao() enabledListener.onChange(enabledPref.key, enabledPref.getValue()) @@ -142,26 +147,50 @@ object ClipboardManager : ClipboardManager.OnPrimaryClipChangedListener, } } + private var lastClipTimestamp = -1L + private var lastClipHash = 0 + override fun onPrimaryClipChanged() { - clipboardManager.primaryClip - ?.let { ClipboardEntry.fromClipData(it, transformer) } - ?.takeIf { it.text.isNotBlank() } - ?.let { e -> - launch { - mutex.withLock { - clbDao.find(e.text)?.let { - updateLastEntry(it.copy(timestamp = e.timestamp)) - clbDao.updateTime(it.id, e.timestamp) - return@launch - } - val rowId = clbDao.insert(e) + val clip = clipboardManager.primaryClip ?: return + /** + * skip duplicate ClipData + * https://developer.android.com/reference/android/content/ClipboardManager.OnPrimaryClipChangedListener#onPrimaryClipChanged() + */ + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val timestamp = clip.description.timestamp + if (timestamp == lastClipTimestamp) return + lastClipTimestamp = timestamp + } else { + val timestamp = System.currentTimeMillis() + val hash = clip.hashCode() + if (timestamp - lastClipTimestamp < 100L && hash == lastClipHash) return + lastClipTimestamp = timestamp + lastClipHash = hash + } + launch { + mutex.withLock { + val entry = ClipboardEntry.fromClipData(clip, transformer) ?: return@withLock + if (entry.text.isBlank()) return@withLock + try { + clbDao.find(entry.text, entry.sensitive)?.let { + updateLastEntry(it.copy(timestamp = entry.timestamp)) + clbDao.updateTime(it.id, entry.timestamp) + return@withLock + } + val insertedEntry = clbDb.withTransaction { + val rowId = clbDao.insert(entry) removeOutdated() - updateItemCount() // new entry can be deleted immediately if clipboard limit == 0 - updateLastEntry(clbDao.get(rowId) ?: e) + clbDao.get(rowId) ?: entry } + updateLastEntry(insertedEntry) + updateItemCount() + } catch (exception: Exception) { + Timber.w("Failed to update clipboard database: $exception") + updateLastEntry(entry) } } + } } private suspend fun removeOutdated() { diff --git a/app/src/main/java/org/fcitx/fcitx5/android/data/clipboard/db/ClipboardDao.kt b/app/src/main/java/org/fcitx/fcitx5/android/data/clipboard/db/ClipboardDao.kt index fe8dd6700..cf55dad10 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/data/clipboard/db/ClipboardDao.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/data/clipboard/db/ClipboardDao.kt @@ -41,8 +41,8 @@ interface ClipboardDao { @Query("SELECT * FROM ${ClipboardEntry.TABLE_NAME} WHERE deleted=0 ORDER BY pinned DESC, timestamp DESC") fun allEntries(): PagingSource - @Query("SELECT * FROM ${ClipboardEntry.TABLE_NAME} WHERE text=:text AND deleted=0 LIMIT 1") - suspend fun find(text: String): ClipboardEntry? + @Query("SELECT * FROM ${ClipboardEntry.TABLE_NAME} WHERE text=:text AND sensitive=:sensitive AND deleted=0 LIMIT 1") + suspend fun find(text: String, sensitive: Boolean = false): ClipboardEntry? @Query("SELECT id FROM ${ClipboardEntry.TABLE_NAME} WHERE deleted=0") suspend fun findAllIds(): IntArray diff --git a/app/src/main/java/org/fcitx/fcitx5/android/data/clipboard/db/ClipboardDatabase.kt b/app/src/main/java/org/fcitx/fcitx5/android/data/clipboard/db/ClipboardDatabase.kt index 8d5b69f01..1d7113877 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/data/clipboard/db/ClipboardDatabase.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/data/clipboard/db/ClipboardDatabase.kt @@ -10,10 +10,11 @@ import androidx.room.RoomDatabase @Database( entities = [ClipboardEntry::class], - version = 3, + version = 4, autoMigrations = [ AutoMigration(from = 1, to = 2), - AutoMigration(from = 2, to = 3) + AutoMigration(from = 2, to = 3), + AutoMigration(from = 3, to = 4) ] ) abstract class ClipboardDatabase : RoomDatabase() { diff --git a/app/src/main/java/org/fcitx/fcitx5/android/data/clipboard/db/ClipboardEntry.kt b/app/src/main/java/org/fcitx/fcitx5/android/data/clipboard/db/ClipboardEntry.kt index e7ed17075..c98252b75 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/data/clipboard/db/ClipboardEntry.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/data/clipboard/db/ClipboardEntry.kt @@ -6,9 +6,11 @@ package org.fcitx.fcitx5.android.data.clipboard.db import android.content.ClipData import android.content.ClipDescription +import android.os.Build import androidx.room.ColumnInfo import androidx.room.Entity import androidx.room.PrimaryKey +import org.fcitx.fcitx5.android.utils.timestamp @Entity(tableName = ClipboardEntry.TABLE_NAME) data class ClipboardEntry( @@ -22,18 +24,38 @@ data class ClipboardEntry( val type: String = ClipDescription.MIMETYPE_TEXT_PLAIN, @ColumnInfo(defaultValue = "0") val deleted: Boolean = false, + @ColumnInfo(defaultValue = "0") + val sensitive: Boolean = false ) { companion object { + const val BULLET = "•" + const val TABLE_NAME = "clipboard" + private val IS_SENSITIVE = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + ClipDescription.EXTRA_IS_SENSITIVE + } else { + "android.content.extra.IS_SENSITIVE" + } + fun fromClipData( clipData: ClipData, transformer: ((String) -> String)? = null ): ClipboardEntry? { - val str = clipData.getItemAt(0).text?.toString() ?: return null + val desc = clipData.description + // TODO: handle multiple items (when does this happen?) + val item = clipData.getItemAt(0) ?: return null + val str = item.text?.toString() ?: return null + val sensitive = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + desc.extras?.getBoolean(IS_SENSITIVE) ?: false + } else { + false + } return ClipboardEntry( - text = transformer?.let { it(str) } ?: str, - type = clipData.description.getMimeType(0) + text = if (transformer != null) transformer(str) else str, + timestamp = clipData.timestamp(), + type = desc.getMimeType(0), + sensitive = sensitive ) } } diff --git a/app/src/main/java/org/fcitx/fcitx5/android/data/pinyin/customphrase/PinyinCustomPhrase.kt b/app/src/main/java/org/fcitx/fcitx5/android/data/pinyin/customphrase/PinyinCustomPhrase.kt index a6edd8ec7..96dd68982 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/data/pinyin/customphrase/PinyinCustomPhrase.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/data/pinyin/customphrase/PinyinCustomPhrase.kt @@ -4,6 +4,7 @@ */ package org.fcitx.fcitx5.android.data.pinyin.customphrase +import org.fcitx.fcitx5.android.core.FcitxUtils import kotlin.math.absoluteValue data class PinyinCustomPhrase( @@ -17,5 +18,5 @@ data class PinyinCustomPhrase( return copy(order = (if (e) 1 else -1) * order.absoluteValue) } - fun serialize() = "$key,${order.absoluteValue}=$value" + fun serialize() = "$key,${order.absoluteValue}=${FcitxUtils.escapeForValue(value)}" } diff --git a/app/src/main/java/org/fcitx/fcitx5/android/data/prefs/AppPrefs.kt b/app/src/main/java/org/fcitx/fcitx5/android/data/prefs/AppPrefs.kt index 0f03a18f5..d84a0215b 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/data/prefs/AppPrefs.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/data/prefs/AppPrefs.kt @@ -1,22 +1,26 @@ /* * SPDX-License-Identifier: LGPL-2.1-or-later - * SPDX-FileCopyrightText: Copyright 2021-2023 Fcitx5 for Android Contributors + * SPDX-FileCopyrightText: Copyright 2021-2024 Fcitx5 for Android Contributors */ package org.fcitx.fcitx5.android.data.prefs import android.content.SharedPreferences import android.os.Build +import androidx.annotation.Keep import androidx.annotation.RequiresApi import androidx.core.content.edit import androidx.preference.PreferenceManager import org.fcitx.fcitx5.android.R import org.fcitx.fcitx5.android.data.InputFeedbacks.InputFeedbackMode -import org.fcitx.fcitx5.android.input.candidates.HorizontalCandidateMode import org.fcitx.fcitx5.android.input.candidates.expanded.ExpandedCandidateStyle +import org.fcitx.fcitx5.android.input.candidates.floating.FloatingCandidatesMode +import org.fcitx.fcitx5.android.input.candidates.floating.FloatingCandidatesOrientation +import org.fcitx.fcitx5.android.input.candidates.horizontal.HorizontalCandidateMode import org.fcitx.fcitx5.android.input.keyboard.LangSwitchBehavior import org.fcitx.fcitx5.android.input.keyboard.SpaceLongPressBehavior import org.fcitx.fcitx5.android.input.keyboard.SwipeSymbolDirection import org.fcitx.fcitx5.android.input.picker.PickerWindow +import org.fcitx.fcitx5.android.input.popup.EmojiModifier import org.fcitx.fcitx5.android.utils.DeviceUtil import org.fcitx.fcitx5.android.utils.appContext import org.fcitx.fcitx5.android.utils.vibrator @@ -34,7 +38,7 @@ class AppPrefs(private val sharedPreferences: SharedPreferences) { } inner class Advanced : ManagedPreferenceCategory(R.string.advanced, sharedPreferences) { - val ignoreSystemCursor = switch(R.string.ignore_sys_cursor, "ignore_system_cursor", true) + val ignoreSystemCursor = switch(R.string.ignore_sys_cursor, "ignore_system_cursor", false) val hideKeyConfig = switch(R.string.hide_key_config, "hide_key_config", true) val disableAnimation = switch(R.string.disable_animation, "disable_animation", false) val vivoKeypressWorkaround = switch( @@ -42,26 +46,25 @@ class AppPrefs(private val sharedPreferences: SharedPreferences) { "vivo_keypress_workaround", DeviceUtil.isVivoOriginOS ) + val ignoreSystemWindowInsets = switch( + R.string.ignore_system_window_insets, "ignore_system_window_insets", false + ) } - inner class Keyboard : ManagedPreferenceCategory(R.string.keyboard, sharedPreferences) { + inner class Keyboard : ManagedPreferenceCategory(R.string.virtual_keyboard, sharedPreferences) { val hapticOnKeyPress = - list( + enumList( R.string.button_haptic_feedback, "haptic_on_keypress", - InputFeedbackMode.FollowingSystem, - InputFeedbackMode, - listOf( - InputFeedbackMode.FollowingSystem, - InputFeedbackMode.Enabled, - InputFeedbackMode.Disabled - ), - listOf( - R.string.following_system_settings, - R.string.enabled, - R.string.disabled - ) + InputFeedbackMode.FollowingSystem ) + val hapticOnKeyUp = switch( + R.string.button_up_haptic_feedback, + "haptic_on_keyup", + false + ) { hapticOnKeyPress.getValue() != InputFeedbackMode.Disabled } + val hapticOnRepeat = switch(R.string.haptic_on_repeat, "haptic_on_repeat", false) + val buttonPressVibrationMilliseconds: ManagedPreference.PInt val buttonLongPressVibrationMilliseconds: ManagedPreference.PInt @@ -108,21 +111,10 @@ class AppPrefs(private val sharedPreferences: SharedPreferences) { buttonLongPressVibrationAmplitude = secondary } - val soundOnKeyPress = list( + val soundOnKeyPress = enumList( R.string.button_sound, "sound_on_keypress", - InputFeedbackMode.FollowingSystem, - InputFeedbackMode, - listOf( - InputFeedbackMode.FollowingSystem, - InputFeedbackMode.Enabled, - InputFeedbackMode.Disabled - ), - listOf( - R.string.following_system_settings, - R.string.enabled, - R.string.disabled - ) + InputFeedbackMode.FollowingSystem ) val soundOnKeyPressVolume = int( R.string.button_sound_volume, @@ -152,21 +144,10 @@ class AppPrefs(private val sharedPreferences: SharedPreferences) { switch(R.string.show_voice_input_button, "show_voice_input_button", false) val expandKeypressArea = switch(R.string.expand_keypress_area, "expand_keypress_area", false) - val swipeSymbolDirection = list( + val swipeSymbolDirection = enumList( R.string.swipe_symbol_behavior, "swipe_symbol_behavior", - SwipeSymbolDirection.Down, - SwipeSymbolDirection, - listOf( - SwipeSymbolDirection.Up, - SwipeSymbolDirection.Down, - SwipeSymbolDirection.Disabled - ), - listOf( - R.string.swipe_up, - R.string.swipe_down, - R.string.disabled - ) + SwipeSymbolDirection.Down ) val longPressDelay = int( R.string.keyboard_long_press_delay, @@ -177,41 +158,19 @@ class AppPrefs(private val sharedPreferences: SharedPreferences) { "ms", 10 ) - val spaceKeyLongPressBehavior = list( + val spaceKeyLongPressBehavior = enumList( R.string.space_long_press_behavior, "space_long_press_behavior", - SpaceLongPressBehavior.None, - SpaceLongPressBehavior, - listOf( - SpaceLongPressBehavior.None, - SpaceLongPressBehavior.Enumerate, - SpaceLongPressBehavior.ToggleActivate, - SpaceLongPressBehavior.ShowPicker - ), - listOf( - R.string.space_behavior_none, - R.string.space_behavior_enumerate, - R.string.space_behavior_activate, - R.string.space_behavior_picker - ) + SpaceLongPressBehavior.None ) + val spaceSwipeMoveCursor = + switch(R.string.space_swipe_move_cursor, "space_swipe_move_cursor", true) val showLangSwitchKey = switch(R.string.show_lang_switch_key, "show_lang_switch_key", true) - val langSwitchKeyBehavior = list( + val langSwitchKeyBehavior = enumList( R.string.lang_switch_key_behavior, "lang_switch_key_behavior", - LangSwitchBehavior.Enumerate, - LangSwitchBehavior, - listOf( - LangSwitchBehavior.Enumerate, - LangSwitchBehavior.ToggleActivate, - LangSwitchBehavior.NextInputMethodApp - ), - listOf( - R.string.space_behavior_enumerate, - R.string.space_behavior_activate, - R.string.lang_switch_behavior_next_ime_app - ) + LangSwitchBehavior.Enumerate ) { showLangSwitchKey.getValue() } val keyboardHeightPercent: ManagedPreference.PInt @@ -247,7 +206,7 @@ class AppPrefs(private val sharedPreferences: SharedPreferences) { "keyboard_side_padding_landscape", 0, 0, - 200, + 300, "dp" ) keyboardSidePadding = primary @@ -274,35 +233,15 @@ class AppPrefs(private val sharedPreferences: SharedPreferences) { keyboardBottomPaddingLandscape = secondary } - val horizontalCandidateStyle = list( + val horizontalCandidateStyle = enumList( R.string.horizontal_candidate_style, "horizontal_candidate_style", - HorizontalCandidateMode.AutoFillWidth, - HorizontalCandidateMode, - listOf( - HorizontalCandidateMode.NeverFillWidth, - HorizontalCandidateMode.AutoFillWidth, - HorizontalCandidateMode.AlwaysFillWidth, - ), - listOf( - R.string.horizontal_candidate_never_fill, - R.string.horizontal_candidate_auto_fill, - R.string.horizontal_candidate_always_fill - ) + HorizontalCandidateMode.AutoFillWidth ) - val expandedCandidateStyle = list( + val expandedCandidateStyle = enumList( R.string.expanded_candidate_style, "expanded_candidate_style", - ExpandedCandidateStyle.Grid, - ExpandedCandidateStyle, - listOf( - ExpandedCandidateStyle.Grid, - ExpandedCandidateStyle.Flexbox - ), - listOf( - R.string.expanded_candidate_style_grid, - R.string.expanded_candidate_style_flexbox - ) + ExpandedCandidateStyle.Grid ) val expandedCandidateGridSpanCount: ManagedPreference.PInt @@ -326,6 +265,60 @@ class AppPrefs(private val sharedPreferences: SharedPreferences) { } + inner class Candidates : + ManagedPreferenceCategory(R.string.candidates_window, sharedPreferences) { + val mode = enumList( + R.string.show_candidates_window, + "show_candidates_window", + FloatingCandidatesMode.InputDevice + ) + + val orientation = enumList( + R.string.candidates_orientation, + "candidates_window_orientation", + FloatingCandidatesOrientation.Automatic + ) + + val windowMinWidth = int( + R.string.candidates_window_min_width, + "candidates_window_min_width", + 0, + 0, + 640, + "dp", + 10 + ) + + val windowPadding = + int(R.string.candidates_window_padding, "candidates_window_padding", 4, 0, 32, "dp") + + val fontSize = + int(R.string.candidates_font_size, "candidates_window_font_size", 20, 4, 64, "sp") + + val windowRadius = + int(R.string.candidates_window_radius, "candidates_window_radius", 0, 0, 48, "dp") + + val itemPaddingVertical: ManagedPreference.PInt + val itemPaddingHorizontal: ManagedPreference.PInt + + init { + val (primary, secondary) = twinInt( + R.string.candidates_padding, + R.string.vertical, + "candidates_item_padding_vertical", + 2, + R.string.horizontal, + "candidates_item_padding_horizontal", + 4, + 0, + 64, + "dp" + ) + itemPaddingVertical = primary + itemPaddingHorizontal = secondary + } + } + inner class Clipboard : ManagedPreferenceCategory(R.string.clipboard, sharedPreferences) { val clipboardListening = switch(R.string.clipboard_listening, "clipboard_enable", true) val clipboardHistoryLimit = int( @@ -347,6 +340,17 @@ class AppPrefs(private val sharedPreferences: SharedPreferences) { val clipboardReturnAfterPaste = switch( R.string.clipboard_return_after_paste, "clipboard_return_after_paste", false ) { clipboardListening.getValue() } + val clipboardMaskSensitive = switch( + R.string.clipboard_mask_sensitive, "clipboard_mask_sensitive", true + ) { clipboardListening.getValue() } + } + + inner class Symbols : ManagedPreferenceCategory(R.string.emoji_and_symbols, sharedPreferences) { + val defaultEmojiSkinTone = enumList( + R.string.default_emoji_skin_tone, + "default_emoji_skin_tone", + EmojiModifier.SkinTone.Default, + ) } private val providers = mutableListOf() @@ -365,13 +369,17 @@ class AppPrefs(private val sharedPreferences: SharedPreferences) { val internal = Internal().register() val keyboard = Keyboard().register() + val candidates = Candidates().register() val clipboard = Clipboard().register() + val symbols = Symbols().register() val advanced = Advanced().register() + @Keep private val onSharedPreferenceChangeListener = SharedPreferences.OnSharedPreferenceChangeListener { _, key -> + if (key == null) return@OnSharedPreferenceChangeListener providers.forEach { - it.managedPreferences[key]?.fireChange() + it.fireChange(key) } } @@ -389,11 +397,14 @@ class AppPrefs(private val sharedPreferences: SharedPreferences) { ).forEach { it.putValueTo(this@edit) } - keyboard.managedPreferences.forEach { - it.value.putValueTo(this@edit) - } - clipboard.managedPreferences.forEach { - it.value.putValueTo(this@edit) + listOf( + keyboard, + candidates, + clipboard + ).forEach { category -> + category.managedPreferences.forEach { + it.value.putValueTo(this@edit) + } } } } diff --git a/app/src/main/java/org/fcitx/fcitx5/android/data/prefs/ManagedPreference.kt b/app/src/main/java/org/fcitx/fcitx5/android/data/prefs/ManagedPreference.kt index 6bc725a7e..ca8367b0c 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/data/prefs/ManagedPreference.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/data/prefs/ManagedPreference.kt @@ -35,9 +35,7 @@ abstract class ManagedPreference( override fun setValue(thisRef: Any?, property: KProperty<*>, value: T) = setValue(value) - private val listeners by lazy { - WeakHashSet>() - } + private lateinit var listeners: MutableSet> /** * **WARN:** No anonymous listeners, please **KEEP** the reference! @@ -47,15 +45,19 @@ abstract class ManagedPreference( * or simply mark the listener with [@Keep][androidx.annotation.Keep] . */ fun registerOnChangeListener(listener: OnChangeListener) { + if (!::listeners.isInitialized) { + listeners = WeakHashSet() + } listeners.add(listener) } fun unregisterOnChangeListener(listener: OnChangeListener) { + if (!::listeners.isInitialized || listeners.isEmpty()) return listeners.remove(listener) } fun fireChange() { - if (listeners.isEmpty()) return + if (!::listeners.isInitialized || listeners.isEmpty()) return val newValue = getValue() listeners.forEach { it.onChange(key, newValue) } } diff --git a/app/src/main/java/org/fcitx/fcitx5/android/data/prefs/ManagedPreferenceCategory.kt b/app/src/main/java/org/fcitx/fcitx5/android/data/prefs/ManagedPreferenceCategory.kt index 8cd5fdaa2..1c8a84994 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/data/prefs/ManagedPreferenceCategory.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/data/prefs/ManagedPreferenceCategory.kt @@ -49,6 +49,21 @@ abstract class ManagedPreferenceCategory( return pref } + protected inline fun enumList( + @StringRes + title: Int, + key: String, + defaultValue: T, + noinline enableUiOn: (() -> Boolean)? = null + ): ManagedPreference.PStringLike where T : Enum, T : ManagedPreferenceEnum { + val codec = object : ManagedPreference.StringLikeCodec { + override fun decode(raw: String): T = enumValueOf(raw) + } + val entryValues = enumValues().toList() + val entryLabels = entryValues.map { it.stringRes } + return list(title, key, defaultValue, codec, entryValues, entryLabels, enableUiOn) + } + protected fun int( @StringRes title: Int, diff --git a/app/src/main/java/org/fcitx/fcitx5/android/data/prefs/ManagedPreferenceEnum.kt b/app/src/main/java/org/fcitx/fcitx5/android/data/prefs/ManagedPreferenceEnum.kt new file mode 100644 index 000000000..a67e8407a --- /dev/null +++ b/app/src/main/java/org/fcitx/fcitx5/android/data/prefs/ManagedPreferenceEnum.kt @@ -0,0 +1,13 @@ +/* + * SPDX-License-Identifier: LGPL-2.1-or-later + * SPDX-FileCopyrightText: Copyright 2024 Fcitx5 for Android Contributors + */ + +package org.fcitx.fcitx5.android.data.prefs + +import androidx.annotation.StringRes + +interface ManagedPreferenceEnum { + @get:StringRes + val stringRes: Int +} diff --git a/app/src/main/java/org/fcitx/fcitx5/android/data/prefs/ManagedPreferenceProvider.kt b/app/src/main/java/org/fcitx/fcitx5/android/data/prefs/ManagedPreferenceProvider.kt index 1cf246375..449bdc785 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/data/prefs/ManagedPreferenceProvider.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/data/prefs/ManagedPreferenceProvider.kt @@ -1,13 +1,19 @@ /* * SPDX-License-Identifier: LGPL-2.1-or-later - * SPDX-FileCopyrightText: Copyright 2021-2023 Fcitx5 for Android Contributors + * SPDX-FileCopyrightText: Copyright 2021-2024 Fcitx5 for Android Contributors */ + package org.fcitx.fcitx5.android.data.prefs import androidx.preference.PreferenceScreen +import org.fcitx.fcitx5.android.utils.WeakHashSet abstract class ManagedPreferenceProvider { + fun interface OnChangeListener { + fun onChange(key: String) + } + private val _managedPreferences: MutableMap> = mutableMapOf() private val _managedPreferencesUi: MutableList> = mutableListOf() @@ -22,6 +28,22 @@ abstract class ManagedPreferenceProvider { } + private val onChangeListeners = WeakHashSet() + + fun registerOnChangeListener(listener: OnChangeListener) { + onChangeListeners.add(listener) + } + + fun unregisterOnChangeListener(listener: OnChangeListener) { + onChangeListeners.remove(listener) + } + + fun fireChange(key: String) { + val preference = _managedPreferences[key] ?: return + onChangeListeners.forEach { it.onChange(key) } + preference.fireChange() + } + fun ManagedPreferenceUi<*>.registerUi() { _managedPreferencesUi.add(this) } diff --git a/app/src/main/java/org/fcitx/fcitx5/android/data/prefs/ManagedPreferenceUi.kt b/app/src/main/java/org/fcitx/fcitx5/android/data/prefs/ManagedPreferenceUi.kt index ba9d83ff5..f18c56ae5 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/data/prefs/ManagedPreferenceUi.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/data/prefs/ManagedPreferenceUi.kt @@ -9,7 +9,6 @@ import androidx.annotation.StringRes import androidx.preference.EditTextPreference import androidx.preference.ListPreference import androidx.preference.Preference -import androidx.preference.SwitchPreference import org.fcitx.fcitx5.android.ui.main.modified.MySwitchPreference import org.fcitx.fcitx5.android.ui.main.settings.DialogSeekBarPreference import org.fcitx.fcitx5.android.ui.main.settings.EditTextIntPreference diff --git a/app/src/main/java/org/fcitx/fcitx5/android/data/prefs/ManagedPreferenceVisibilityEvaluator.kt b/app/src/main/java/org/fcitx/fcitx5/android/data/prefs/ManagedPreferenceVisibilityEvaluator.kt index bd62f129d..a4d2bbe03 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/data/prefs/ManagedPreferenceVisibilityEvaluator.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/data/prefs/ManagedPreferenceVisibilityEvaluator.kt @@ -15,14 +15,12 @@ class ManagedPreferenceVisibilityEvaluator( // it would be better to declare the dependency relationship, rather than reevaluating on each value changed @Keep - private val onValueChangeListener = ManagedPreference.OnChangeListener { _, _ -> + private val onValueChangeListener = ManagedPreferenceProvider.OnChangeListener { evaluateVisibility() } init { - provider.managedPreferences.forEach { (_, pref) -> - pref.registerOnChangeListener(onValueChangeListener) - } + provider.registerOnChangeListener(onValueChangeListener) } fun evaluateVisibility() { @@ -40,9 +38,7 @@ class ManagedPreferenceVisibilityEvaluator( } fun destroy() { - provider.managedPreferences.forEach { (_, pref) -> - pref.unregisterOnChangeListener(onValueChangeListener) - } + provider.unregisterOnChangeListener(onValueChangeListener) } } \ No newline at end of file diff --git a/app/src/main/java/org/fcitx/fcitx5/android/data/quickphrase/BuiltinQuickPhrase.kt b/app/src/main/java/org/fcitx/fcitx5/android/data/quickphrase/BuiltinQuickPhrase.kt index 8b61190b0..b3cd68eea 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/data/quickphrase/BuiltinQuickPhrase.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/data/quickphrase/BuiltinQuickPhrase.kt @@ -14,18 +14,13 @@ class BuiltinQuickPhrase( init { ensureFileExists() + evaluateOverride() } - var override: CustomQuickPhrase? = - if (overrideFile.exists()) - CustomQuickPhrase(overrideFile) - else { - val disabledOverride = File(overrideFile.path + ".$DISABLE") - if (disabledOverride.exists()) - CustomQuickPhrase(disabledOverride) - else - null - } + val overrideFilePath: String + get() = overrideFile.absolutePath + + var override: CustomQuickPhrase? = null private set override val isEnabled: Boolean @@ -35,17 +30,13 @@ class BuiltinQuickPhrase( if (override != null) return file.copyTo(overrideFile, overwrite = true) + // Update override override = CustomQuickPhrase(overrideFile) } private fun loadBuiltinData() = QuickPhraseData.fromLines(file.readLines()) - override fun loadData(): Result = - if (override == null) - loadBuiltinData() - else - override!!.loadData() - + override fun loadData() = override?.loadData() ?: loadBuiltinData() override fun saveData(data: QuickPhraseData) { createOverrideIfNotExist() @@ -71,6 +62,21 @@ class BuiltinQuickPhrase( override = null } + /** + * Make sure [override] is set correctly. + */ + fun evaluateOverride() { + override = if (overrideFile.exists()) + CustomQuickPhrase(overrideFile) + else { + val disabledOverride = File(overrideFile.path + ".$DISABLE") + if (disabledOverride.exists()) + CustomQuickPhrase(disabledOverride) + else + null + } + } + override fun toString(): String { return "BuiltinQuickPhrase(file=$file, overrideFile=$overrideFile, override=$override, isEnabled=$isEnabled)" } diff --git a/app/src/main/java/org/fcitx/fcitx5/android/data/quickphrase/CustomQuickPhrase.kt b/app/src/main/java/org/fcitx/fcitx5/android/data/quickphrase/CustomQuickPhrase.kt index 11c77cc6c..8d1156aec 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/data/quickphrase/CustomQuickPhrase.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/data/quickphrase/CustomQuickPhrase.kt @@ -20,7 +20,7 @@ class CustomQuickPhrase(file: File) : QuickPhrase() { get() = if (isEnabled) super.name else file.name.substringBefore(".$EXT.$DISABLE") - override fun loadData(): Result = QuickPhraseData.fromLines(file.readLines()) + override fun loadData() = QuickPhraseData.fromLines(file.readLines()) init { ensureFileExists() diff --git a/app/src/main/java/org/fcitx/fcitx5/android/data/quickphrase/QuickPhrase.kt b/app/src/main/java/org/fcitx/fcitx5/android/data/quickphrase/QuickPhrase.kt index 4d5125560..97aa42631 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/data/quickphrase/QuickPhrase.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/data/quickphrase/QuickPhrase.kt @@ -4,10 +4,25 @@ */ package org.fcitx.fcitx5.android.data.quickphrase +import android.os.Parcel +import android.os.Parcelable +import kotlinx.serialization.Serializable import java.io.File -import java.io.Serializable -abstract class QuickPhrase : Serializable { +@Serializable(QuickPhraseSerializer::class) +abstract class QuickPhrase : Parcelable { + + override fun describeContents(): Int = 0 + + override fun writeToParcel(dest: Parcel, flags: Int) { + dest.writeString(file.absolutePath) + dest.writeByte(if (this is BuiltinQuickPhrase) 1 else 0) + if (this is BuiltinQuickPhrase) { + dest.writeString(overrideFilePath) + } else { + dest.writeString(null) + } + } abstract val file: File @@ -21,7 +36,7 @@ abstract class QuickPhrase : Serializable { throw IllegalStateException("File ${file.absolutePath} does not exist") } - abstract fun loadData(): Result + abstract fun loadData(): QuickPhraseData abstract fun saveData(data: QuickPhraseData) diff --git a/app/src/main/java/org/fcitx/fcitx5/android/data/quickphrase/QuickPhraseData.kt b/app/src/main/java/org/fcitx/fcitx5/android/data/quickphrase/QuickPhraseData.kt index 885144643..54c3f708d 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/data/quickphrase/QuickPhraseData.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/data/quickphrase/QuickPhraseData.kt @@ -4,27 +4,13 @@ */ package org.fcitx.fcitx5.android.data.quickphrase -import org.fcitx.fcitx5.android.R -import org.fcitx.fcitx5.android.utils.errorRuntime - class QuickPhraseData(private val data: List) : List by data { fun serialize(): String = joinToString("\n") { it.serialize() } companion object { - fun fromLines(lines: List): Result { - return runCatching { - val list = mutableListOf() - lines.forEach { - if (it.isBlank()) return@forEach - val s = it.trim() - val sep = s.indexOf(' ') - if (sep < 0) - errorRuntime(R.string.exception_quickphrase_parse, it) - list.add(QuickPhraseEntry(s.substring(0, sep), s.substring(sep + 1))) - } - QuickPhraseData(list) - } + fun fromLines(lines: List): QuickPhraseData { + return QuickPhraseData(lines.mapNotNull { QuickPhraseEntry.fromLine(it) }) } } } \ No newline at end of file diff --git a/app/src/main/java/org/fcitx/fcitx5/android/data/quickphrase/QuickPhraseEntry.kt b/app/src/main/java/org/fcitx/fcitx5/android/data/quickphrase/QuickPhraseEntry.kt index 7408c25e7..b415d5ffe 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/data/quickphrase/QuickPhraseEntry.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/data/quickphrase/QuickPhraseEntry.kt @@ -4,6 +4,31 @@ */ package org.fcitx.fcitx5.android.data.quickphrase +import org.fcitx.fcitx5.android.core.FcitxUtils + data class QuickPhraseEntry(val keyword: String, val phrase: String) { - fun serialize() = "$keyword ${phrase.replace("\n", "\\n")}" + + fun serialize() = "$keyword ${FcitxUtils.escapeForValue(phrase)}" + + companion object { + // https://github.com/fcitx/fcitx5/blob/5.1.5/src/lib/fcitx-utils/macros.h#L46 + private val WhiteSpaces = charArrayOf(' ', '\t', '\r', '\n', '\u000b', '\u000c') + + // https://github.com/fcitx/fcitx5/blob/5.1.5/src/modules/quickphrase/quickphraseprovider.cpp#L67 + fun fromLine(line: String): QuickPhraseEntry? { + val text = line.trim() + if (text.isEmpty()) return null + val pos = text.indexOfAny(WhiteSpaces) + if (pos < 0) return null + val word = text.substring(pos).indexOfFirst { c -> !WhiteSpaces.contains(c) } + if (word < 0) return null + return try { + val wordString = FcitxUtils.unescapeForValue(text.substring(pos + word)) + val key = text.substring(0, pos) + QuickPhraseEntry(key, wordString) + } catch (e: Exception) { + null + } + } + } } \ No newline at end of file diff --git a/app/src/main/java/org/fcitx/fcitx5/android/data/quickphrase/QuickPhraseManager.kt b/app/src/main/java/org/fcitx/fcitx5/android/data/quickphrase/QuickPhraseManager.kt index 143231e26..595e9ceb3 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/data/quickphrase/QuickPhraseManager.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/data/quickphrase/QuickPhraseManager.kt @@ -4,8 +4,10 @@ */ package org.fcitx.fcitx5.android.data.quickphrase +import org.fcitx.fcitx5.android.R import org.fcitx.fcitx5.android.core.data.DataManager import org.fcitx.fcitx5.android.utils.appContext +import org.fcitx.fcitx5.android.utils.errorRuntime import org.fcitx.fcitx5.android.utils.withTempDir import java.io.File import java.io.InputStream @@ -37,8 +39,13 @@ object QuickPhraseManager { } private fun importFromFile(file: File): Result { - // throw away data, only ensuring the format is correct - return QuickPhraseData.fromLines(file.readLines()).map { + return runCatching { + // check quickphrase format of each line + file.readLines().forEachIndexed { idx, line -> + if (line.isNotBlank() && QuickPhraseEntry.fromLine(line) == null) { + errorRuntime(R.string.exception_quickphrase_parse, "\n(${idx + 1}) $line") + } + } val dest = File(customQuickPhraseDir, file.name) file.copyTo(dest) CustomQuickPhrase(dest) diff --git a/app/src/main/java/org/fcitx/fcitx5/android/data/quickphrase/QuickPhraseSerializer.kt b/app/src/main/java/org/fcitx/fcitx5/android/data/quickphrase/QuickPhraseSerializer.kt new file mode 100644 index 000000000..5a5690e1e --- /dev/null +++ b/app/src/main/java/org/fcitx/fcitx5/android/data/quickphrase/QuickPhraseSerializer.kt @@ -0,0 +1,73 @@ +/* + * SPDX-License-Identifier: LGPL-2.1-or-later + * SPDX-FileCopyrightText: Copyright 2025 Fcitx5 for Android Contributors + */ + +package org.fcitx.fcitx5.android.data.quickphrase + +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.KSerializer +import kotlinx.serialization.builtins.serializer +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.descriptors.buildClassSerialDescriptor +import kotlinx.serialization.descriptors.element +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder +import kotlinx.serialization.encoding.decodeStructure +import kotlinx.serialization.encoding.encodeStructure +import java.io.File + +object QuickPhraseSerializer : KSerializer { + override val descriptor: SerialDescriptor = buildClassSerialDescriptor("quickphrase") { + element("path") + element("isBuiltin") + element("override") + } + + @OptIn(ExperimentalSerializationApi::class) + override fun serialize( + encoder: Encoder, + value: QuickPhrase + ) = encoder.encodeStructure(descriptor) { + encodeStringElement(descriptor, 0, value.file.absolutePath) + encodeBooleanElement(descriptor, 1, value is BuiltinQuickPhrase) + encodeNullableSerializableElement( + descriptor, + 2, + String.serializer(), + value.let { it as? BuiltinQuickPhrase }?.overrideFilePath + ) + + } + + @OptIn(ExperimentalSerializationApi::class) + override fun deserialize(decoder: Decoder): QuickPhrase = + decoder.decodeStructure(descriptor) { + var path: String? = null + var isBuiltin = false + var overridePath: String? = null + + while (true) { + when (decodeElementIndex(descriptor)) { + 0 -> path = decodeStringElement(descriptor, 0) + 1 -> isBuiltin = decodeBooleanElement(descriptor, 1) + 2 -> overridePath = decodeNullableSerializableElement( + descriptor, 2, String.serializer() + ) + else -> break + } + } + + val file = File(path ?: throw IllegalStateException("Path cannot be null")) + if (isBuiltin) { + BuiltinQuickPhrase( + file, + File( + overridePath ?: throw IllegalStateException("Override path cannot be null") + ) + ) + } else { + CustomQuickPhrase(file) + } + } +} diff --git a/app/src/main/java/org/fcitx/fcitx5/android/data/theme/CustomThemeSerializer.kt b/app/src/main/java/org/fcitx/fcitx5/android/data/theme/CustomThemeSerializer.kt index 0413cd708..ebadfeb16 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/data/theme/CustomThemeSerializer.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/data/theme/CustomThemeSerializer.kt @@ -5,10 +5,14 @@ package org.fcitx.fcitx5.android.data.theme import arrow.core.compose -import kotlinx.serialization.json.* +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.JsonTransformingSerializer +import kotlinx.serialization.json.boolean +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive import org.fcitx.fcitx5.android.utils.NostalgicSerializer -import org.fcitx.fcitx5.android.utils.identity -import org.fcitx.fcitx5.android.utils.upcast object CustomThemeSerializer : JsonTransformingSerializer(Theme.Custom.serializer()) { @@ -35,21 +39,29 @@ object CustomThemeSerializer : JsonTransformingSerializer(Theme.Cu private fun JsonObject.removeVersion() = JsonObject(this - VERSION) + private val EmptyTransform: (JsonObject) -> JsonObject = { it } private fun applyStrategy(oldVersion: String, obj: JsonObject) = strategies .takeWhile { it.version != oldVersion } - .foldRight(JsonObject::identity.upcast()) { f, acc -> f compose acc } + .foldRight(EmptyTransform) { it, acc -> it.transformation compose acc } .invoke(obj) data class MigrationStrategy( val version: String, val transformation: (JsonObject) -> JsonObject - ) : (JsonObject) -> JsonObject by transformation + ) private val strategies: List = // Add migrations here listOf( + MigrationStrategy("2.1") { + JsonObject(it.toMutableMap().apply { + put("candidateTextColor", getValue("keyTextColor")) + put("candidateLabelColor", getValue("keyTextColor")) + put("candidateCommentColor", getValue("altKeyTextColor")) + }) + }, MigrationStrategy("2.0") { JsonObject(it.toMutableMap().apply { if (get("backgroundImage") != null) { @@ -70,12 +82,12 @@ object CustomThemeSerializer : JsonTransformingSerializer(Theme.Cu } }) }, - MigrationStrategy("1.0", JsonObject::identity), + MigrationStrategy("1.0", EmptyTransform) ) private const val VERSION = "version" - private const val CURRENT_VERSION = "2.0" + private const val CURRENT_VERSION = "2.1" private const val FALLBACK_VERSION = "1.0" private val knownVersions = strategies.map { it.version } diff --git a/app/src/main/java/org/fcitx/fcitx5/android/data/theme/Theme.kt b/app/src/main/java/org/fcitx/fcitx5/android/data/theme/Theme.kt index 28f7b14d7..d7f2791b2 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/data/theme/Theme.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/data/theme/Theme.kt @@ -12,9 +12,10 @@ import android.graphics.drawable.Drawable import android.os.Parcelable import kotlinx.parcelize.Parcelize import kotlinx.serialization.Serializable +import org.fcitx.fcitx5.android.utils.DarkenColorFilter import org.fcitx.fcitx5.android.utils.RectSerializer +import org.fcitx.fcitx5.android.utils.alpha import org.fcitx.fcitx5.android.utils.appContext -import org.fcitx.fcitx5.android.utils.darkenColorFilter import java.io.File @Serializable @@ -30,6 +31,11 @@ sealed class Theme : Parcelable { abstract val keyBackgroundColor: Int abstract val keyTextColor: Int + // Color of candidate text + abstract val candidateTextColor: Int + abstract val candidateLabelColor: Int + abstract val candidateCommentColor: Int + abstract val altKeyBackgroundColor: Int abstract val altKeyTextColor: Int @@ -67,6 +73,9 @@ sealed class Theme : Parcelable { override val keyboardColor: Int, override val keyBackgroundColor: Int, override val keyTextColor: Int, + override val candidateTextColor: Int, + override val candidateLabelColor: Int, + override val candidateCommentColor: Int, override val altKeyBackgroundColor: Int, override val altKeyTextColor: Int, override val accentKeyBackgroundColor: Int, @@ -88,13 +97,14 @@ sealed class Theme : Parcelable { val srcFilePath: String, val brightness: Int = 70, val cropRect: @Serializable(RectSerializer::class) Rect?, + val cropRotation: Int = 0 ) : Parcelable { fun toDrawable(): Drawable? { val cropped = File(croppedFilePath) if (!cropped.exists()) return null val bitmap = BitmapFactory.decodeStream(cropped.inputStream()) ?: return null return BitmapDrawable(appContext.resources, bitmap).apply { - colorFilter = darkenColorFilter(100 - brightness) + colorFilter = DarkenColorFilter(100 - brightness) } } } @@ -114,6 +124,9 @@ sealed class Theme : Parcelable { override val keyboardColor: Int, override val keyBackgroundColor: Int, override val keyTextColor: Int, + override val candidateTextColor: Int, + override val candidateLabelColor: Int, + override val candidateCommentColor: Int, override val altKeyBackgroundColor: Int, override val altKeyTextColor: Int, override val accentKeyBackgroundColor: Int, @@ -139,6 +152,9 @@ sealed class Theme : Parcelable { keyboardColor: Number, keyBackgroundColor: Number, keyTextColor: Number, + candidateTextColor: Number, + candidateLabelColor: Number, + candidateCommentColor: Number, altKeyBackgroundColor: Number, altKeyTextColor: Number, accentKeyBackgroundColor: Number, @@ -160,6 +176,9 @@ sealed class Theme : Parcelable { keyboardColor.toInt(), keyBackgroundColor.toInt(), keyTextColor.toInt(), + candidateTextColor.toInt(), + candidateLabelColor.toInt(), + candidateCommentColor.toInt(), altKeyBackgroundColor.toInt(), altKeyTextColor.toInt(), accentKeyBackgroundColor.toInt(), @@ -184,6 +203,9 @@ sealed class Theme : Parcelable { keyboardColor, keyBackgroundColor, keyTextColor, + candidateTextColor, + candidateLabelColor, + candidateCommentColor, altKeyBackgroundColor, altKeyTextColor, accentKeyBackgroundColor, @@ -205,6 +227,7 @@ sealed class Theme : Parcelable { originBackgroundImage: String, brightness: Int = 70, cropBackgroundRect: Rect? = null, + cropBackgroundRotation: Int = 0 ) = Custom( name, isDark, @@ -212,13 +235,17 @@ sealed class Theme : Parcelable { croppedBackgroundImage, originBackgroundImage, brightness, - cropBackgroundRect + cropBackgroundRect, + cropBackgroundRotation ), backgroundColor, barColor, keyboardColor, keyBackgroundColor, keyTextColor, + candidateTextColor, + candidateLabelColor, + candidateCommentColor, altKeyBackgroundColor, altKeyTextColor, accentKeyBackgroundColor, @@ -235,4 +262,94 @@ sealed class Theme : Parcelable { ) } -} \ No newline at end of file + @Parcelize + data class Monet( + override val name: String, + override val isDark: Boolean, + override val backgroundColor: Int, + override val barColor: Int, + override val keyboardColor: Int, + override val keyBackgroundColor: Int, + override val keyTextColor: Int, + override val candidateTextColor: Int, + override val candidateLabelColor: Int, + override val candidateCommentColor: Int, + override val altKeyBackgroundColor: Int, + override val altKeyTextColor: Int, + override val accentKeyBackgroundColor: Int, + override val accentKeyTextColor: Int, + override val keyPressHighlightColor: Int, + override val keyShadowColor: Int, + override val popupBackgroundColor: Int, + override val popupTextColor: Int, + override val spaceBarColor: Int, + override val dividerColor: Int, + override val clipboardEntryColor: Int, + override val genericActiveBackgroundColor: Int, + override val genericActiveForegroundColor: Int + ) : Theme() { + constructor( + isDark: Boolean, + surfaceContainer: Int, + surfaceContainerHigh: Int, + surfaceBright: Int, + onSurface: Int, + primary: Int, + onPrimary: Int, + secondaryContainer: Int, + onSurfaceVariant: Int, + ) : this( + name = "Monet" + if (isDark) "Dark" else "Light", + isDark = isDark, + backgroundColor = surfaceContainer, + barColor = surfaceContainerHigh, + keyboardColor = surfaceContainer, + keyBackgroundColor = surfaceBright, + keyTextColor = onSurface, + candidateTextColor = onSurface, + candidateLabelColor = onSurface, + candidateCommentColor = onSurfaceVariant, + altKeyBackgroundColor = secondaryContainer, + altKeyTextColor = onSurfaceVariant, + accentKeyBackgroundColor = primary, + accentKeyTextColor = onPrimary, + keyPressHighlightColor = onSurface.alpha(if (isDark) 0.2f else 0.12f), + keyShadowColor = 0x000000, + popupBackgroundColor = surfaceContainer, + popupTextColor = onSurface, + spaceBarColor = surfaceBright, + dividerColor = surfaceBright, + clipboardEntryColor = surfaceBright, + genericActiveBackgroundColor = primary, + genericActiveForegroundColor = onPrimary + ) + + @OptIn(ExperimentalStdlibApi::class) + fun toCustom() = Custom( + name = name + "#" + this.accentKeyBackgroundColor.toHexString(), // Use primary color as identifier + isDark = isDark, + backgroundImage = null, + backgroundColor = backgroundColor, + barColor = barColor, + keyboardColor = keyboardColor, + keyBackgroundColor = keyBackgroundColor, + keyTextColor = keyTextColor, + candidateTextColor = candidateTextColor, + candidateLabelColor = candidateLabelColor, + candidateCommentColor = candidateCommentColor, + altKeyBackgroundColor = altKeyBackgroundColor, + altKeyTextColor = altKeyTextColor, + accentKeyBackgroundColor = accentKeyBackgroundColor, + accentKeyTextColor = accentKeyTextColor, + keyPressHighlightColor = keyPressHighlightColor, + keyShadowColor = keyShadowColor, + popupBackgroundColor = popupBackgroundColor, + popupTextColor = popupTextColor, + spaceBarColor = spaceBarColor, + dividerColor = dividerColor, + clipboardEntryColor = clipboardEntryColor, + genericActiveBackgroundColor = genericActiveBackgroundColor, + genericActiveForegroundColor = genericActiveForegroundColor + ) + } +} diff --git a/app/src/main/java/org/fcitx/fcitx5/android/data/theme/ThemeFilesManager.kt b/app/src/main/java/org/fcitx/fcitx5/android/data/theme/ThemeFilesManager.kt new file mode 100644 index 000000000..434a0e944 --- /dev/null +++ b/app/src/main/java/org/fcitx/fcitx5/android/data/theme/ThemeFilesManager.kt @@ -0,0 +1,164 @@ +package org.fcitx.fcitx5.android.data.theme + +import kotlinx.serialization.json.Json +import org.fcitx.fcitx5.android.R +import org.fcitx.fcitx5.android.utils.appContext +import org.fcitx.fcitx5.android.utils.errorRuntime +import org.fcitx.fcitx5.android.utils.extract +import org.fcitx.fcitx5.android.utils.withTempDir +import timber.log.Timber +import java.io.File +import java.io.FileFilter +import java.io.InputStream +import java.io.OutputStream +import java.util.UUID +import java.util.zip.ZipEntry +import java.util.zip.ZipInputStream +import java.util.zip.ZipOutputStream + +object ThemeFilesManager { + + private val dir = File(appContext.getExternalFilesDir(null), "theme").also { it.mkdirs() } + + private fun themeFile(theme: Theme.Custom) = File(dir, theme.name + ".json") + + fun newCustomBackgroundImages(): Triple { + val themeName = UUID.randomUUID().toString() + val croppedImageFile = File(dir, "$themeName-cropped.png") + val srcImageFile = File(dir, "$themeName-src") + return Triple(themeName, croppedImageFile, srcImageFile) + } + + fun saveThemeFiles(theme: Theme.Custom) { + themeFile(theme).writeText(Json.encodeToString(CustomThemeSerializer, theme)) + } + + fun deleteThemeFiles(theme: Theme.Custom) { + themeFile(theme).delete() + theme.backgroundImage?.let { + File(it.croppedFilePath).delete() + File(it.srcFilePath).delete() + } + } + + fun listThemes(): MutableList { + val files = dir.listFiles(FileFilter { it.extension == "json" }) ?: return mutableListOf() + return files + .sortedByDescending { it.lastModified() } // newest first + .mapNotNull decode@{ + val (theme, migrated) = runCatching { + Json.decodeFromString(CustomThemeSerializer.WithMigrationStatus, it.readText()) + }.getOrElse { e -> + Timber.w("Failed to decode theme file ${it.absolutePath}: ${e.message}") + return@decode null + } + if (theme.backgroundImage != null) { + if (!File(theme.backgroundImage.croppedFilePath).exists() || + !File(theme.backgroundImage.srcFilePath).exists() + ) { + Timber.w("Cannot find background image file for theme ${theme.name}") + return@decode null + } + } + // Update the saved file if migration happens + if (migrated) { + saveThemeFiles(theme) + } + return@decode theme + }.toMutableList() + } + + /** + * [dest] will be closed on finished + */ + fun exportTheme(theme: Theme.Custom, dest: OutputStream) = + runCatching { + ZipOutputStream(dest.buffered()).use { zipStream -> + // we don't export the internal path of images + val tweakedTheme = theme.backgroundImage?.let { + theme.copy( + backgroundImage = theme.backgroundImage.copy( + croppedFilePath = theme.backgroundImage.croppedFilePath + .substringAfterLast('/'), + srcFilePath = theme.backgroundImage.srcFilePath + .substringAfterLast('/'), + ) + ) + } ?: theme + if (tweakedTheme.backgroundImage != null) { + requireNotNull(theme.backgroundImage) + // write cropped image + zipStream.putNextEntry(ZipEntry(tweakedTheme.backgroundImage.croppedFilePath)) + File(theme.backgroundImage.croppedFilePath).inputStream() + .use { it.copyTo(zipStream) } + // write src image + zipStream.putNextEntry(ZipEntry(tweakedTheme.backgroundImage.srcFilePath)) + File(theme.backgroundImage.srcFilePath).inputStream() + .use { it.copyTo(zipStream) } + } + // write json + zipStream.putNextEntry(ZipEntry("${tweakedTheme.name}.json")) + zipStream.write( + Json.encodeToString(CustomThemeSerializer, tweakedTheme) + .encodeToByteArray() + ) + // done + zipStream.closeEntry() + } + } + + /** + * @return (newCreated, theme, migrated) + */ + fun importTheme(src: InputStream): Result> = + runCatching { + ZipInputStream(src).use { zipStream -> + withTempDir { tempDir -> + val extracted = zipStream.extract(tempDir) + val jsonFile = extracted.find { it.extension == "json" } + ?: errorRuntime(R.string.exception_theme_json) + val (decoded, migrated) = Json.decodeFromString( + CustomThemeSerializer.WithMigrationStatus, + jsonFile.readText() + ) + if (ThemeManager.BuiltinThemes.find { it.name == decoded.name } != null) + errorRuntime(R.string.exception_theme_name_clash) + val oldTheme = ThemeManager.getTheme(decoded.name) as? Theme.Custom + val newCreated = oldTheme == null + val newTheme = if (decoded.backgroundImage != null) { + val srcFile = File(dir, decoded.backgroundImage.srcFilePath) + val oldSrcFile = oldTheme?.backgroundImage?.srcFilePath?.let { File(it) } + val srcFileNameMatches = oldSrcFile?.name == srcFile.name + extracted.find { it.name == srcFile.name } + // allow overwriting background image files when theme and file names all are same + ?.copyTo(srcFile, overwrite = srcFileNameMatches) + ?: errorRuntime(R.string.exception_theme_src_image) + val croppedFile = File(dir, decoded.backgroundImage.croppedFilePath) + val oldCroppedFile = + oldTheme?.backgroundImage?.croppedFilePath?.let { File(it) } + val croppedFileNameMatches = oldCroppedFile?.name == croppedFile.name + extracted.find { it.name == croppedFile.name } + ?.copyTo(croppedFile, overwrite = croppedFileNameMatches) + ?: errorRuntime(R.string.exception_theme_cropped_image) + if (!srcFileNameMatches) { + oldSrcFile?.delete() + } + if (!croppedFileNameMatches) { + oldCroppedFile?.delete() + } + decoded.copy( + backgroundImage = decoded.backgroundImage.copy( + croppedFilePath = croppedFile.path, + srcFilePath = srcFile.path + ) + ) + } else { + decoded + } + saveThemeFiles(newTheme) + Triple(newCreated, newTheme, migrated) + } + } + } + +} diff --git a/app/src/main/java/org/fcitx/fcitx5/android/data/theme/ThemeManager.kt b/app/src/main/java/org/fcitx/fcitx5/android/data/theme/ThemeManager.kt index 9d20b5472..93ba4d47f 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/data/theme/ThemeManager.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/data/theme/ThemeManager.kt @@ -4,37 +4,18 @@ */ package org.fcitx.fcitx5.android.data.theme -import android.content.SharedPreferences import android.content.res.Configuration import android.os.Build import androidx.annotation.Keep import androidx.annotation.RequiresApi -import androidx.annotation.StringRes import androidx.core.content.edit import androidx.preference.PreferenceManager -import kotlinx.serialization.json.Json -import org.fcitx.fcitx5.android.R import org.fcitx.fcitx5.android.data.prefs.AppPrefs -import org.fcitx.fcitx5.android.data.prefs.ManagedPreference -import org.fcitx.fcitx5.android.data.prefs.ManagedPreferenceCategory -import org.fcitx.fcitx5.android.data.prefs.ManagedPreferenceInternal +import org.fcitx.fcitx5.android.data.prefs.ManagedPreferenceProvider +import org.fcitx.fcitx5.android.data.theme.ThemeManager.activeTheme import org.fcitx.fcitx5.android.utils.WeakHashSet import org.fcitx.fcitx5.android.utils.appContext -import org.fcitx.fcitx5.android.utils.errorArg -import org.fcitx.fcitx5.android.utils.errorRuntime -import org.fcitx.fcitx5.android.utils.errorState -import org.fcitx.fcitx5.android.utils.extract import org.fcitx.fcitx5.android.utils.isDarkMode -import org.fcitx.fcitx5.android.utils.withTempDir -import timber.log.Timber -import java.io.File -import java.io.FileFilter -import java.io.InputStream -import java.io.OutputStream -import java.util.UUID -import java.util.zip.ZipEntry -import java.util.zip.ZipInputStream -import java.util.zip.ZipOutputStream object ThemeManager { @@ -42,389 +23,126 @@ object ThemeManager { fun onThemeChange(theme: Theme) } - private val dir = File(appContext.getExternalFilesDir(null), "theme").also { it.mkdirs() } - - private val onChangeListeners = WeakHashSet() - - fun addOnChangedListener(listener: OnThemeChangeListener) { - onChangeListeners.add(listener) - } - - fun removeOnChangedListener(listener: OnThemeChangeListener) { - onChangeListeners.remove(listener) - } + val BuiltinThemes = listOf( + ThemePreset.MaterialLight, + ThemePreset.MaterialDark, + ThemePreset.PixelLight, + ThemePreset.PixelDark, + ThemePreset.NordLight, + ThemePreset.NordDark, + ThemePreset.DeepBlue, + ThemePreset.Monokai, + ThemePreset.AMOLEDBlack, + ) - fun newCustomBackgroundImages(): Triple { - val themeName = UUID.randomUUID().toString() - val croppedImageFile = File(dir, "$themeName-cropped.png") - val srcImageFile = File(dir, "$themeName-src") - return Triple(themeName, croppedImageFile, srcImageFile) - } + val DefaultTheme = ThemePreset.PixelDark - private fun getTheme(name: String) = - customThemes.find { it.name == name } ?: builtinThemes.find { it.name == name } + private var monetThemes = listOf(ThemeMonet.getLight(), ThemeMonet.getDark()) - private fun themeFile(theme: Theme.Custom) = File(dir, theme.name + ".json") + private val customThemes: MutableList = ThemeFilesManager.listThemes() - fun saveTheme(theme: Theme.Custom) { - themeFile(theme).writeText(Json.encodeToString(CustomThemeSerializer, theme)) - customThemes.indexOfFirst { it.name == theme.name }.let { - if (it >= 0) customThemes[it] = theme - else customThemes.add(0, theme) - } - if (getActiveTheme().name == theme.name) { - currentTheme = theme - fireChange() - } - } + fun getTheme(name: String) = + customThemes.find { it.name == name } ?: BuiltinThemes.find { it.name == name } - fun deleteTheme(name: String) { - if (currentTheme.name == name) - switchTheme(defaultTheme) - val theme = customThemes.find { it.name == name } - ?: errorArg(R.string.exception_theme_unknown, name) - themeFile(theme).delete() - theme.backgroundImage?.let { - File(it.croppedFilePath).delete() - File(it.srcFilePath).delete() - } - customThemes.remove(theme) - } + fun getAllThemes() = customThemes + monetThemes + BuiltinThemes - fun switchTheme(theme: Theme) { - if (getTheme(theme.name) == null) - errorArg(R.string.exception_theme_unknown, theme.name) - internalPrefs.activeThemeName.setValue(theme.name) + fun refreshThemes() { + customThemes.clear() + customThemes.addAll(ThemeFilesManager.listThemes()) + activeTheme = evaluateActiveTheme() } - private fun listThemes(): MutableList = - dir.listFiles(FileFilter { it.extension == "json" }) - ?.sortedByDescending { it.lastModified() } // newest first - ?.mapNotNull decode@{ - val (theme, migrated) = runCatching { - Json.decodeFromString(CustomThemeSerializer.WithMigrationStatus, it.readText()) - }.getOrElse { e -> - Timber.w("Failed to decode theme file ${it.absolutePath}: ${e.message}") - return@decode null - } - if (theme.backgroundImage != null) { - if (!File(theme.backgroundImage.croppedFilePath).exists() || - !File(theme.backgroundImage.srcFilePath).exists() - ) { - Timber.w("Cannot find background image file for theme ${theme.name}") - return@decode null - } - } - // Update the saved file if migration happens - if (migrated) { - // We can't use saveTheme here, since customThemes might have not been initialized - themeFile(theme).writeText(Json.encodeToString(CustomThemeSerializer, theme)) - } - return@decode theme - }?.toMutableList() ?: mutableListOf() - - /** - * [dest] will be closed on finished + * [backing property](https://kotlinlang.org/docs/properties.html#backing-properties) + * of [activeTheme]; holds the [Theme] object currently in use */ - fun exportTheme(theme: Theme.Custom, dest: OutputStream) = - runCatching { - ZipOutputStream(dest.buffered()).use { zipStream -> - // we don't export the internal path of images - val tweakedTheme = theme.backgroundImage?.let { - theme.copy( - backgroundImage = theme.backgroundImage.copy( - croppedFilePath = theme.backgroundImage.croppedFilePath - .substringAfterLast('/'), - srcFilePath = theme.backgroundImage.srcFilePath - .substringAfterLast('/'), - ) - ) - } ?: theme - if (tweakedTheme.backgroundImage != null) { - requireNotNull(theme.backgroundImage) - // write cropped image - zipStream.putNextEntry(ZipEntry(tweakedTheme.backgroundImage.croppedFilePath)) - File(theme.backgroundImage.croppedFilePath).inputStream() - .use { it.copyTo(zipStream) } - // write src image - zipStream.putNextEntry(ZipEntry(tweakedTheme.backgroundImage.srcFilePath)) - File(theme.backgroundImage.srcFilePath).inputStream() - .use { it.copyTo(zipStream) } - } - // write json - zipStream.putNextEntry(ZipEntry("${tweakedTheme.name}.json")) - zipStream.write( - Json.encodeToString(CustomThemeSerializer, tweakedTheme) - .encodeToByteArray() - ) - // done - zipStream.closeEntry() - } - } + private lateinit var _activeTheme: Theme - /** - * @return (newCreated, theme, migrated) - */ - fun importTheme(src: InputStream): Result> = - runCatching { - ZipInputStream(src).use { zipStream -> - withTempDir { tempDir -> - val extracted = zipStream.extract(tempDir) - val jsonFile = extracted.find { it.extension == "json" } - ?: errorRuntime(R.string.exception_theme_json) - val (decoded, migrated) = Json.decodeFromString( - CustomThemeSerializer.WithMigrationStatus, - jsonFile.readText() - ) - if (builtinThemes.find { it.name == decoded.name } != null) - errorRuntime(R.string.exception_theme_name_clash) - val exists = customThemes.find { it.name == decoded.name } != null - val newTheme = if (decoded.backgroundImage != null) { - val srcFile = File(dir, decoded.backgroundImage.srcFilePath) - val croppedFile = File(dir, decoded.backgroundImage.croppedFilePath) - extracted.find { it.name == srcFile.name }?.copyTo(srcFile) - ?: errorRuntime(R.string.exception_theme_src_image) - extracted.find { it.name == croppedFile.name }?.copyTo(croppedFile) - ?: errorRuntime(R.string.exception_theme_cropped_image) - decoded.copy( - backgroundImage = decoded.backgroundImage.copy( - croppedFilePath = croppedFile.path, - srcFilePath = srcFile.path - ) - ) - } else { - decoded - } - saveTheme(newTheme) - Triple(!exists, newTheme, migrated) - } - } + var activeTheme: Theme + get() = _activeTheme + private set(value) { + if (_activeTheme == value) return + _activeTheme = value + fireChange() } - class Prefs(sharedPreferences: SharedPreferences) : - ManagedPreferenceCategory(R.string.theme, sharedPreferences) { + private var isDarkMode = false - private fun themePreference( - @StringRes - title: Int, - key: String, - defaultValue: Theme, - @StringRes - summary: Int? = null, - enableUiOn: (() -> Boolean)? = null - ): ManagedThemePreference { - val pref = ManagedThemePreference(sharedPreferences, key, defaultValue) - val ui = ManagedThemePreferenceUi(title, key, defaultValue, summary, enableUiOn) - pref.register() - ui.registerUi() - return pref - } - - val keyBorder = switch(R.string.key_border, "key_border", false) + private val onChangeListeners = WeakHashSet() - val keyRippleEffect = switch(R.string.key_ripple_effect, "key_ripple_effect", false) + fun addOnChangedListener(listener: OnThemeChangeListener) { + onChangeListeners.add(listener) + } - val keyHorizontalMargin: ManagedPreference.PInt - val keyHorizontalMarginLandscape: ManagedPreference.PInt + fun removeOnChangedListener(listener: OnThemeChangeListener) { + onChangeListeners.remove(listener) + } - init { - val (primary, secondary) = twinInt( - R.string.key_horizontal_margin, - R.string.portrait, - "key_horizontal_margin", - 3, - R.string.landscape, - "key_horizontal_margin_landscape", - 3, - 0, - 24, - "dp" - ) - keyHorizontalMargin = primary - keyHorizontalMarginLandscape = secondary - } + private fun fireChange() { + onChangeListeners.forEach { it.onThemeChange(_activeTheme) } + } - val keyVerticalMargin: ManagedPreference.PInt - val keyVerticalMarginLandscape: ManagedPreference.PInt + val prefs = AppPrefs.getInstance().registerProvider(::ThemePrefs) - init { - val (primary, secondary) = twinInt( - R.string.key_vertical_margin, - R.string.portrait, - "key_vertical_margin", - 7, - R.string.landscape, - "key_vertical_margin_landscape", - 4, - 0, - 24, - "dp" - ) - keyVerticalMargin = primary - keyVerticalMarginLandscape = secondary + fun saveTheme(theme: Theme.Custom) { + ThemeFilesManager.saveThemeFiles(theme) + customThemes.indexOfFirst { it.name == theme.name }.also { + if (it >= 0) customThemes[it] = theme else customThemes.add(0, theme) } - - val keyRadius = int(R.string.key_radius, "key_radius", 4, 0, 48, "dp") - - enum class PunctuationPosition { - Bottom, - TopRight; - - companion object : ManagedPreference.StringLikeCodec { - override fun decode(raw: String): PunctuationPosition = valueOf(raw) - } + if (activeTheme.name == theme.name) { + activeTheme = theme } + } - val punctuationPosition = list( - R.string.punctuation_position, - "punctuation_position", - PunctuationPosition.Bottom, - PunctuationPosition, - listOf( - PunctuationPosition.Bottom, - PunctuationPosition.TopRight - ), - listOf( - R.string.punctuation_pos_bottom, - R.string.punctuation_pos_top_right - ) - ) - - enum class NavbarBackground { - None, - ColorOnly, - Full; - - companion object : ManagedPreference.StringLikeCodec { - override fun decode(raw: String): NavbarBackground = valueOf(raw) - } + fun deleteTheme(name: String) { + customThemes.find { it.name == name }?.also { + ThemeFilesManager.deleteThemeFiles(it) + customThemes.remove(it) + } + if (activeTheme.name == name) { + activeTheme = evaluateActiveTheme() } - - val navbarBackground = list( - R.string.navbar_background, - "navbar_background", - NavbarBackground.Full, - NavbarBackground, - listOf( - NavbarBackground.None, - NavbarBackground.ColorOnly, - NavbarBackground.Full - ), - listOf( - R.string.navbar_bkg_none, - R.string.navbar_bkg_color_only, - R.string.navbar_bkg_full - ) - ) - - val followSystemDayNightTheme = switch( - R.string.follow_system_day_night_theme, - "follow_system_dark_mode", - false, - summary = R.string.follow_system_day_night_theme_summary - ) - - val lightModeTheme = themePreference( - R.string.light_mode_theme, - "light_mode_theme", - ThemePreset.PixelLight, - enableUiOn = { - followSystemDayNightTheme.getValue() - }) - - val darkModeTheme = themePreference( - R.string.dark_mode_theme, - "dark_mode_theme", - ThemePreset.PixelDark, - enableUiOn = { - followSystemDayNightTheme.getValue() - }) - - val dayNightModePrefNames = setOf( - followSystemDayNightTheme.key, - lightModeTheme.key, - darkModeTheme.key - ) - } - class InternalPrefs(sharedPreferences: SharedPreferences) : - ManagedPreferenceInternal(sharedPreferences) { - val activeThemeName = string("active_theme_name", defaultTheme.name) + fun setNormalModeTheme(theme: Theme) { + // `normalModeTheme.setValue(theme)` would trigger `onThemePrefsChange` listener, + // which calls `fireChange()`. + // `activateTheme`'s setter would also trigger `fireChange()` when theme actually changes. + // write to backing property directly to avoid unnecessary `fireChange()` + _activeTheme = theme + prefs.normalModeTheme.setValue(theme) } - private val defaultTheme = ThemePreset.PixelDark - - val prefs = AppPrefs.getInstance().registerProvider(::Prefs) - - private val internalPrefs = AppPrefs.getInstance().registerProvider(providerF = ::InternalPrefs) - - private val customThemes = listThemes() - - val builtinThemes = listOf( - ThemePreset.MaterialLight, - ThemePreset.MaterialDark, - ThemePreset.PixelLight, - ThemePreset.PixelDark, - ThemePreset.NordLight, - ThemePreset.NordDark, - ThemePreset.DeepBlue, - ThemePreset.Monokai, - ThemePreset.AMOLEDBlack, - ) - - @Keep - private val onActiveThemeNameChange = ManagedPreference.OnChangeListener { _, it -> - currentTheme = getTheme(internalPrefs.activeThemeName.getValue()) - ?: errorState(R.string.exception_theme_unknown, it) - fireChange() + private fun evaluateActiveTheme(): Theme { + return if (prefs.followSystemDayNightTheme.getValue()) { + if (isDarkMode) prefs.darkModeTheme else prefs.lightModeTheme + } else { + prefs.normalModeTheme + }.getValue() } @Keep - private val onThemePrefsChange = ManagedPreference.OnChangeListener { key, _ -> - fireChange() + private val onThemePrefsChange = ManagedPreferenceProvider.OnChangeListener { key -> if (prefs.dayNightModePrefNames.contains(key)) { - onSystemDarkModeChange() + activeTheme = evaluateActiveTheme() + } else { + fireChange() } } fun init(configuration: Configuration) { - isCurrentDark = configuration.isDarkMode() + isDarkMode = configuration.isDarkMode() // fire all `OnThemeChangedListener`s on theme preferences change - prefs.managedPreferences.values.forEach { - it.registerOnChangeListener(onThemePrefsChange) - } - currentTheme = if (prefs.followSystemDayNightTheme.getValue()) { - (if (isCurrentDark) prefs.darkModeTheme else prefs.lightModeTheme).getValue() - } else { - val activeThemeName = internalPrefs.activeThemeName.getValue() - // fallback to default theme if active theme not found - getTheme(activeThemeName) ?: defaultTheme.also { - Timber.w("Cannot find active theme '$activeThemeName', fallback to ${it.name}") - internalPrefs.activeThemeName.setValue(it.name) - } - } - internalPrefs.activeThemeName.registerOnChangeListener(onActiveThemeNameChange) - } - - private lateinit var currentTheme: Theme - - private fun fireChange() { - onChangeListeners.forEach { it.onThemeChange(currentTheme) } + prefs.registerOnChangeListener(onThemePrefsChange) + _activeTheme = evaluateActiveTheme() } - fun getAllThemes() = customThemes + builtinThemes - - fun getActiveTheme() = currentTheme - - private var isCurrentDark = false - - fun onSystemDarkModeChange(isDark: Boolean = isCurrentDark) { - isCurrentDark = isDark - if (prefs.followSystemDayNightTheme.getValue()) { - switchTheme((if (isDark) prefs.darkModeTheme else prefs.lightModeTheme).getValue()) - } + fun onSystemPlatteChange(newConfig: Configuration) { + isDarkMode = newConfig.isDarkMode() + monetThemes = listOf(ThemeMonet.getLight(), ThemeMonet.getDark()) + // `ManagedThemePreference` finds a theme with same name in `getAllThemes()` + // thus `evaluateActiveTheme()` should be called after updating `monetThemes` + activeTheme = evaluateActiveTheme() } @RequiresApi(Build.VERSION_CODES.N) @@ -432,9 +150,6 @@ object ThemeManager { val ctx = appContext.createDeviceProtectedStorageContext() val sp = PreferenceManager.getDefaultSharedPreferences(ctx) sp.edit { - internalPrefs.managedPreferences.forEach { - it.value.putValueTo(this@edit) - } prefs.managedPreferences.forEach { it.value.putValueTo(this@edit) } diff --git a/app/src/main/java/org/fcitx/fcitx5/android/data/theme/ThemeMonet.kt b/app/src/main/java/org/fcitx/fcitx5/android/data/theme/ThemeMonet.kt new file mode 100644 index 000000000..ef9e32433 --- /dev/null +++ b/app/src/main/java/org/fcitx/fcitx5/android/data/theme/ThemeMonet.kt @@ -0,0 +1,95 @@ +/* + * SPDX-License-Identifier: LGPL-2.1-or-later + * SPDX-FileCopyrightText: Copyright 2025 Fcitx5 for Android Contributors + */ +package org.fcitx.fcitx5.android.data.theme + +import android.os.Build +import org.fcitx.fcitx5.android.utils.appContext + +// Ref: +// https://github.com/material-components/material-components-android/blob/master/docs/theming/Color.md +// https://www.figma.com/community/file/809865700885504168/material-3-android-15 +// https://material-foundation.github.io/material-theme-builder/ + +// FIXME: SDK < 34 can only have approximate color values, maybe we can implement our own color algorithm. +// See: https://github.com/XayahSuSuSu/Android-DataBackup/blob/e8b087fb55519c659bebdc46c0217731fe80a0d7/source/core/ui/src/main/kotlin/com/xayah/core/ui/material3/DynamicTonalPalette.kt#L185 + +object ThemeMonet { + fun getLight(): Theme.Monet = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) // Real Monet colors + Theme.Monet( + isDark = false, + surfaceContainer = appContext.getColor(android.R.color.system_surface_container_light), + surfaceContainerHigh = appContext.getColor(android.R.color.system_surface_container_highest_light), + surfaceBright = appContext.getColor(android.R.color.system_surface_bright_light), + onSurface = appContext.getColor(android.R.color.system_on_surface_light), + primary = appContext.getColor(android.R.color.system_primary_light), + onPrimary = appContext.getColor(android.R.color.system_on_primary_light), + secondaryContainer = appContext.getColor(android.R.color.system_secondary_container_light), + onSurfaceVariant = appContext.getColor(android.R.color.system_on_surface_variant_light) + ) + else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) // Approximate color values + Theme.Monet( + isDark = false, + surfaceContainer = appContext.getColor(android.R.color.system_neutral1_50), // neutral94 + surfaceContainerHigh = appContext.getColor(android.R.color.system_neutral2_100), // neutral92 + surfaceBright = appContext.getColor(android.R.color.system_neutral1_10), // neutral98 + onSurface = appContext.getColor(android.R.color.system_neutral1_900), + primary = appContext.getColor(android.R.color.system_accent1_600), + onPrimary = appContext.getColor(android.R.color.system_accent1_0), + secondaryContainer = appContext.getColor(android.R.color.system_accent2_100), + onSurfaceVariant = appContext.getColor(android.R.color.system_accent2_700) + ) + else // Static MD3 colors, based on #769CDF + Theme.Monet( + isDark = false, + surfaceContainer = 0xffededf4.toInt(), + surfaceContainerHigh = 0xffe7e8ee.toInt(), + surfaceBright = 0xfff9f9ff.toInt(), + onSurface = 0xff191c20.toInt(), + primary = 0xff415f91.toInt(), + onPrimary = 0xffffffff.toInt(), + secondaryContainer = 0xffdae2f9.toInt(), + onSurfaceVariant = 0xff44474e.toInt(), + ) + + fun getDark(): Theme.Monet = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) // Real Monet colors + Theme.Monet( + isDark = true, + surfaceContainer = appContext.getColor(android.R.color.system_surface_container_dark), + surfaceContainerHigh = appContext.getColor(android.R.color.system_surface_container_high_dark), + surfaceBright = appContext.getColor(android.R.color.system_surface_bright_dark), + onSurface = appContext.getColor(android.R.color.system_on_surface_dark), + primary = appContext.getColor(android.R.color.system_primary_dark), + onPrimary = appContext.getColor(android.R.color.system_on_primary_dark), + secondaryContainer = appContext.getColor(android.R.color.system_secondary_container_dark), + onSurfaceVariant = appContext.getColor(android.R.color.system_on_surface_variant_dark) + ) + else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) // Approximate color values + Theme.Monet( + isDark = true, + surfaceContainer = appContext.getColor(android.R.color.system_neutral1_900), // neutral12 + surfaceContainerHigh = appContext.getColor(android.R.color.system_neutral2_1000), // neutral17 + surfaceBright = appContext.getColor(android.R.color.system_neutral1_800), // neutral24 + onSurface = appContext.getColor(android.R.color.system_neutral1_100), + primary = appContext.getColor(android.R.color.system_accent1_200), + onPrimary = appContext.getColor(android.R.color.system_accent1_800), + secondaryContainer = appContext.getColor(android.R.color.system_accent2_700), + onSurfaceVariant = appContext.getColor(android.R.color.system_accent2_200) + ) + else // Static MD3 colors, based on #769CDF + Theme.Monet( + isDark = true, + surfaceContainer = 0xff1d2024.toInt(), + surfaceContainerHigh = 0xff282a2f.toInt(), + surfaceBright = 0xff37393e.toInt(), + onSurface = 0xffe2e2e9.toInt(), + primary = 0xffaac7ff.toInt(), + onPrimary = 0xff0a305f.toInt(), + secondaryContainer = 0xff3e4759.toInt(), + onSurfaceVariant = 0xffc4c6d0.toInt(), + ) + +} \ No newline at end of file diff --git a/app/src/main/java/org/fcitx/fcitx5/android/data/theme/ThemePrefs.kt b/app/src/main/java/org/fcitx/fcitx5/android/data/theme/ThemePrefs.kt new file mode 100644 index 000000000..a42ef8d52 --- /dev/null +++ b/app/src/main/java/org/fcitx/fcitx5/android/data/theme/ThemePrefs.kt @@ -0,0 +1,159 @@ +/* + * SPDX-License-Identifier: LGPL-2.1-or-later + * SPDX-FileCopyrightText: Copyright 2023 Fcitx5 for Android Contributors + */ + +package org.fcitx.fcitx5.android.data.theme + +import android.content.SharedPreferences +import android.os.Build +import androidx.annotation.StringRes +import androidx.core.content.edit +import org.fcitx.fcitx5.android.BuildConfig +import org.fcitx.fcitx5.android.R +import org.fcitx.fcitx5.android.data.prefs.ManagedPreference +import org.fcitx.fcitx5.android.data.prefs.ManagedPreferenceCategory +import org.fcitx.fcitx5.android.data.prefs.ManagedPreferenceEnum + +class ThemePrefs(sharedPreferences: SharedPreferences) : + ManagedPreferenceCategory(R.string.theme, sharedPreferences) { + + private fun themePreference( + @StringRes + title: Int, + key: String, + defaultValue: Theme, + @StringRes + summary: Int? = null, + enableUiOn: (() -> Boolean)? = null + ): ManagedThemePreference { + val pref = ManagedThemePreference(sharedPreferences, key, defaultValue) + val ui = ManagedThemePreferenceUi(title, key, defaultValue, summary, enableUiOn) + pref.register() + ui.registerUi() + return pref + } + + val keyBorder = switch(R.string.key_border, "key_border", false) + + val keyRippleEffect = switch(R.string.key_ripple_effect, "key_ripple_effect", false) + + val keyHorizontalMargin: ManagedPreference.PInt + val keyHorizontalMarginLandscape: ManagedPreference.PInt + + init { + val (primary, secondary) = twinInt( + R.string.key_horizontal_margin, + R.string.portrait, + "key_horizontal_margin", + 3, + R.string.landscape, + "key_horizontal_margin_landscape", + 3, + 0, + 24, + "dp" + ) + keyHorizontalMargin = primary + keyHorizontalMarginLandscape = secondary + } + + val keyVerticalMargin: ManagedPreference.PInt + val keyVerticalMarginLandscape: ManagedPreference.PInt + + init { + val (primary, secondary) = twinInt( + R.string.key_vertical_margin, + R.string.portrait, + "key_vertical_margin", + 7, + R.string.landscape, + "key_vertical_margin_landscape", + 4, + 0, + 24, + "dp" + ) + keyVerticalMargin = primary + keyVerticalMarginLandscape = secondary + } + + val keyRadius = int(R.string.key_radius, "key_radius", 4, 0, 48, "dp") + + val textEditingButtonRadius = + int(R.string.text_editing_button_radius, "text_editing_button_radius", 8, 0, 48, "dp") + + val clipboardEntryRadius = + int(R.string.clipboard_entry_radius, "clipboard_entry_radius", 2, 0, 48, "dp") + + enum class PunctuationPosition(override val stringRes: Int) : ManagedPreferenceEnum { + None(R.string.punctuation_pos_none), + Bottom(R.string.punctuation_pos_bottom), + TopRight(R.string.punctuation_pos_top_right); + } + + val punctuationPosition = enumList( + R.string.punctuation_position, + "punctuation_position", + PunctuationPosition.Bottom + ) + + enum class NavbarBackground(override val stringRes: Int) : ManagedPreferenceEnum { + None(R.string.navbar_bkg_none), + ColorOnly(R.string.navbar_bkg_color_only), + Full(R.string.navbar_bkg_full); + } + + val navbarBackground = enumList( + R.string.navbar_background, + "navbar_background", + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1) NavbarBackground.Full else NavbarBackground.ColorOnly, + // 35+ forces edge to edge + enableUiOn = { Build.VERSION.SDK_INT < Build.VERSION_CODES.VANILLA_ICE_CREAM } + ).apply { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.VANILLA_ICE_CREAM) { + sharedPreferences.edit { + remove(this@apply.key) + } + } + } + + /** + * When [followSystemDayNightTheme] is disabled, this theme is used. + * This is effectively an internal preference which does not need UI. + */ + val normalModeTheme = ManagedThemePreference( + sharedPreferences, "normal_mode_theme", ThemeManager.DefaultTheme + ).also { + it.register() + } + + val followSystemDayNightTheme = switch( + R.string.follow_system_day_night_theme, + "follow_system_dark_mode", + true, + summary = R.string.follow_system_day_night_theme_summary + ) + + val lightModeTheme = themePreference( + R.string.light_mode_theme, + "light_mode_theme", + if (BuildConfig.DEBUG) ThemePreset.MaterialLight else ThemePreset.PixelLight, + enableUiOn = { + followSystemDayNightTheme.getValue() + }) + + val darkModeTheme = themePreference( + R.string.dark_mode_theme, + "dark_mode_theme", + if (BuildConfig.DEBUG) ThemePreset.MaterialDark else ThemePreset.PixelDark, + enableUiOn = { + followSystemDayNightTheme.getValue() + }) + + val dayNightModePrefNames = setOf( + followSystemDayNightTheme.key, + lightModeTheme.key, + darkModeTheme.key + ) +} diff --git a/app/src/main/java/org/fcitx/fcitx5/android/data/theme/ThemePreset.kt b/app/src/main/java/org/fcitx/fcitx5/android/data/theme/ThemePreset.kt index 429270372..239e22cb8 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/data/theme/ThemePreset.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/data/theme/ThemePreset.kt @@ -14,6 +14,9 @@ object ThemePreset { keyboardColor = 0xffeceff1, keyBackgroundColor = 0xfffbfbfc, keyTextColor = 0xff37474f, + candidateTextColor = 0xff37474f, + candidateLabelColor = 0xff37474f, + candidateCommentColor = 0xff7a858a, altKeyBackgroundColor = 0xffdfe2e4, // Google Pinyin's symbol color on alphabet key: #727d82 altKeyTextColor = 0xff7a858a, @@ -38,6 +41,9 @@ object ThemePreset { keyboardColor = 0xff263238, keyBackgroundColor = 0xff404a50, keyTextColor = 0xffd9dbdc, + candidateTextColor = 0xffd9dbdc, + candidateLabelColor = 0xffd9dbdc, + candidateCommentColor = 0xffadb1b3, altKeyBackgroundColor = 0xff313c42, // Google Pinyin's symbol color on alphabet key: #b3b7b9 altKeyTextColor = 0xffadb1b3, @@ -62,6 +68,9 @@ object ThemePreset { keyboardColor = 0xfffafafa, keyBackgroundColor = 0xffffffff, keyTextColor = 0xff212121, + candidateTextColor = 0xff212121, + candidateLabelColor = 0xff212121, + candidateCommentColor = 0xff6e6e6e, altKeyBackgroundColor = 0xffe1e1e1, // Google Pinyin's symbol color on alphabet key: #4e4e4e altKeyTextColor = 0xff6e6e6e, @@ -86,6 +95,9 @@ object ThemePreset { keyboardColor = 0xff2d2d2d, keyBackgroundColor = 0xff464646, keyTextColor = 0xfffafafa, + candidateTextColor = 0xfffafafa, + candidateLabelColor = 0xfffafafa, + candidateCommentColor = 0xffacacac, altKeyBackgroundColor = 0xff373737, // Google Pinyin's symbol color on alphabet key: #d6d6d6 altKeyTextColor = 0xffacacac, @@ -110,6 +122,9 @@ object ThemePreset { keyboardColor = 0xff1565c0, keyBackgroundColor = 0xff3f80cb, keyTextColor = 0xffffffff, + candidateTextColor = 0xffffffff, + candidateLabelColor = 0xffffffff, + candidateCommentColor = 0xffa9c6e7, altKeyBackgroundColor = 0xff2771c4, // Google Pinyin's symbol color on alphabet key: #d6d6d6 altKeyTextColor = 0xffa9c6e7, @@ -134,6 +149,9 @@ object ThemePreset { keyboardColor = 0xff000000, keyBackgroundColor = 0xff2e2e2e, keyTextColor = 0xffffffff, + candidateTextColor = 0xffffffff, + candidateLabelColor = 0xffffffff, + candidateCommentColor = 0xffa1a1a1, altKeyBackgroundColor = 0xff141414, // Google Pinyin's symbol color on alphabet key: #d9e6f5 altKeyTextColor = 0xffa1a1a1, @@ -158,6 +176,9 @@ object ThemePreset { keyboardColor = 0xffECEFF4, keyBackgroundColor = 0xffECEFF4, keyTextColor = 0xff2E3440, + candidateTextColor = 0xff2E3440, + candidateLabelColor = 0xff2E3440, + candidateCommentColor = 0xff4C566A, altKeyBackgroundColor = 0xffE5E9F0, altKeyTextColor = 0xff434C5E, accentKeyBackgroundColor = 0xff5E81AC, @@ -181,6 +202,9 @@ object ThemePreset { keyboardColor = 0xff2E3440, keyBackgroundColor = 0xff4C566A, keyTextColor = 0xffECEFF4, + candidateTextColor = 0xffECEFF4, + candidateLabelColor = 0xffECEFF4, + candidateCommentColor = 0xffD8DEE9, altKeyBackgroundColor = 0xff3B4252, altKeyTextColor = 0xffD8DEE9, accentKeyBackgroundColor = 0xff88C0D0, @@ -204,6 +228,9 @@ object ThemePreset { keyboardColor = 0xff272822, keyBackgroundColor = 0xff33342c, keyTextColor = 0xffd6d6d6, + candidateTextColor = 0xffd6d6d6, + candidateLabelColor = 0xffd6d6d6, + candidateCommentColor = 0xff797979, altKeyBackgroundColor = 0xff2d2e27, altKeyTextColor = 0xff797979, accentKeyBackgroundColor = 0xffb05279, @@ -230,6 +257,9 @@ object ThemePreset { keyboardColor = 0x00000000, keyBackgroundColor = 0x4bffffff, keyTextColor = 0xffffffff, + candidateTextColor = 0xffffffff, + candidateLabelColor = 0xffffffff, + candidateCommentColor = 0xc9ffffff, altKeyBackgroundColor = 0x0cffffff, altKeyTextColor = 0xc9ffffff, accentKeyBackgroundColor = 0xff5e97f6, @@ -256,6 +286,9 @@ object ThemePreset { keyboardColor = 0x00000000, keyBackgroundColor = 0x4bffffff, keyTextColor = 0xff000000, + candidateTextColor = 0xff000000, + candidateLabelColor = 0xff000000, + candidateCommentColor = 0xb9000000, altKeyBackgroundColor = 0x0cffffff, altKeyTextColor = 0xb9000000, accentKeyBackgroundColor = 0xff5e97f6, @@ -271,4 +304,4 @@ object ThemePreset { genericActiveForegroundColor = 0xffffffff ) -} \ No newline at end of file +} diff --git a/app/src/main/java/org/fcitx/fcitx5/android/input/BaseInputView.kt b/app/src/main/java/org/fcitx/fcitx5/android/input/BaseInputView.kt new file mode 100644 index 000000000..c43731d39 --- /dev/null +++ b/app/src/main/java/org/fcitx/fcitx5/android/input/BaseInputView.kt @@ -0,0 +1,80 @@ +/* + * SPDX-License-Identifier: LGPL-2.1-or-later + * SPDX-FileCopyrightText: Copyright 2024 Fcitx5 for Android Contributors + */ + +package org.fcitx.fcitx5.android.input + +import android.view.WindowInsets +import androidx.constraintlayout.widget.ConstraintLayout +import androidx.core.view.WindowInsetsCompat +import androidx.lifecycle.lifecycleScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.launch +import org.fcitx.fcitx5.android.core.FcitxEvent +import org.fcitx.fcitx5.android.daemon.FcitxConnection +import org.fcitx.fcitx5.android.data.theme.Theme +import org.fcitx.fcitx5.android.data.theme.ThemeManager +import org.fcitx.fcitx5.android.data.theme.ThemePrefs +import org.fcitx.fcitx5.android.utils.navbarFrameHeight +import kotlin.math.max + +abstract class BaseInputView( + val service: FcitxInputMethodService, + val fcitx: FcitxConnection, + val theme: Theme +) : ConstraintLayout(service) { + + protected abstract fun handleFcitxEvent(it: FcitxEvent<*>) + + private var eventHandlerJob: Job? = null + + private fun setupFcitxEventHandler() { + eventHandlerJob = service.lifecycleScope.launch { + fcitx.runImmediately { eventFlow }.collect { + handleFcitxEvent(it) + } + } + } + + var handleEvents = false + set(value) { + field = value + if (field) { + if (eventHandlerJob == null) { + setupFcitxEventHandler() + } + } else { + eventHandlerJob?.cancel() + eventHandlerJob = null + } + } + + private val navbarBackground by ThemeManager.prefs.navbarBackground + + protected fun getNavBarBottomInset(windowInsets: WindowInsets): Int { + if (navbarBackground != ThemePrefs.NavbarBackground.Full) { + return 0 + } + val insets = WindowInsetsCompat.toWindowInsetsCompat(windowInsets) + // use navigation bar insets when available + val navBars = insets.getInsets(WindowInsetsCompat.Type.navigationBars()) + // in case navigation bar insets goes wrong (eg. on LineageOS 21+ with gesture navigation) + // use mandatory system gesture insets + val mandatory = insets.getInsets(WindowInsetsCompat.Type.mandatorySystemGestures()) + var insetsBottom = max(navBars.bottom, mandatory.bottom) + if (insetsBottom <= 0) { + // check system gesture insets and fallback to navigation_bar_frame_height just in case + val gesturesBottom = insets.getInsets(WindowInsetsCompat.Type.systemGestures()).bottom + if (gesturesBottom > 0) { + insetsBottom = max(gesturesBottom, context.navbarFrameHeight()) + } + } + return insetsBottom + } + + override fun onDetachedFromWindow() { + handleEvents = false + super.onDetachedFromWindow() + } +} diff --git a/app/src/main/java/org/fcitx/fcitx5/android/input/CandidatesView.kt b/app/src/main/java/org/fcitx/fcitx5/android/input/CandidatesView.kt new file mode 100644 index 000000000..c03eb61ff --- /dev/null +++ b/app/src/main/java/org/fcitx/fcitx5/android/input/CandidatesView.kt @@ -0,0 +1,238 @@ +/* + * SPDX-License-Identifier: LGPL-2.1-or-later + * SPDX-FileCopyrightText: Copyright 2024-2025 Fcitx5 for Android Contributors + */ + +package org.fcitx.fcitx5.android.input + +import android.annotation.SuppressLint +import android.graphics.drawable.GradientDrawable +import android.os.Build +import android.view.ViewGroup +import android.view.ViewOutlineProvider +import android.view.ViewTreeObserver.OnGlobalLayoutListener +import android.view.ViewTreeObserver.OnPreDrawListener +import android.view.WindowInsets +import android.widget.TextView +import androidx.annotation.Size +import org.fcitx.fcitx5.android.R +import org.fcitx.fcitx5.android.core.FcitxEvent +import org.fcitx.fcitx5.android.daemon.FcitxConnection +import org.fcitx.fcitx5.android.daemon.launchOnReady +import org.fcitx.fcitx5.android.data.prefs.AppPrefs +import org.fcitx.fcitx5.android.data.theme.Theme +import org.fcitx.fcitx5.android.input.candidates.floating.PagedCandidatesUi +import org.fcitx.fcitx5.android.input.preedit.PreeditUi +import splitties.dimensions.dp +import splitties.views.dsl.constraintlayout.below +import splitties.views.dsl.constraintlayout.bottomOfParent +import splitties.views.dsl.constraintlayout.centerHorizontally +import splitties.views.dsl.constraintlayout.lParams +import splitties.views.dsl.constraintlayout.matchConstraints +import splitties.views.dsl.constraintlayout.startOfParent +import splitties.views.dsl.constraintlayout.topOfParent +import splitties.views.dsl.core.add +import splitties.views.dsl.core.withTheme +import splitties.views.dsl.core.wrapContent +import splitties.views.padding +import kotlin.math.roundToInt + +@SuppressLint("ViewConstructor") +class CandidatesView( + service: FcitxInputMethodService, + fcitx: FcitxConnection, + theme: Theme +) : BaseInputView(service, fcitx, theme) { + + private val ctx = context.withTheme(R.style.Theme_InputViewTheme) + + private val candidatesPrefs = AppPrefs.getInstance().candidates + private val orientation by candidatesPrefs.orientation + private val windowMinWidth by candidatesPrefs.windowMinWidth + private val windowPadding by candidatesPrefs.windowPadding + private val windowRadius by candidatesPrefs.windowRadius + private val fontSize by candidatesPrefs.fontSize + private val itemPaddingVertical by candidatesPrefs.itemPaddingVertical + private val itemPaddingHorizontal by candidatesPrefs.itemPaddingHorizontal + + private var inputPanel = FcitxEvent.InputPanelEvent.Data() + private var paged = FcitxEvent.PagedCandidateEvent.Data.Empty + + /** + * horizontal, bottom, top + */ + private val anchorPosition = floatArrayOf(0f, 0f, 0f) + private val parentSize = floatArrayOf(0f, 0f) + + private var shouldUpdatePosition = false + + /** + * layout update may or may not cause [CandidatesView]'s size [onSizeChanged], + * in either case, we should reposition it + */ + private val layoutListener = OnGlobalLayoutListener { + shouldUpdatePosition = true + } + + /** + * [CandidatesView]'s position is calculated based on it's size, + * so we need to recalculate the position after layout, + * and before any actual drawing to avoid flicker + */ + private val preDrawListener = OnPreDrawListener { + if (shouldUpdatePosition) { + updatePosition() + } + true + } + + private val touchEventReceiverWindow = TouchEventReceiverWindow(this) + + private val setupTextView: TextView.() -> Unit = { + textSize = fontSize.toFloat() + val v = dp(itemPaddingVertical) + val h = dp(itemPaddingHorizontal) + setPadding(h, v, h, v) + } + + private val preeditUi = PreeditUi(ctx, theme, setupTextView) + + private val candidatesUi = PagedCandidatesUi( + ctx, theme, setupTextView, + onCandidateClick = { index -> fcitx.launchOnReady { it.select(index) } }, + onPrevPage = { fcitx.launchOnReady { it.offsetCandidatePage(-1) } }, + onNextPage = { fcitx.launchOnReady { it.offsetCandidatePage(1) } } + ) + + private var bottomInsets = 0 + + override fun handleFcitxEvent(it: FcitxEvent<*>) { + when (it) { + is FcitxEvent.InputPanelEvent -> { + inputPanel = it.data + updateUi() + } + is FcitxEvent.PagedCandidateEvent -> { + paged = it.data + updateUi() + } + else -> {} + } + } + + private fun evaluateVisibility(): Boolean { + return inputPanel.preedit.isNotEmpty() || + paged.candidates.isNotEmpty() || + inputPanel.auxUp.isNotEmpty() || + inputPanel.auxDown.isNotEmpty() + } + + private fun updateUi() { + preeditUi.update(inputPanel) + preeditUi.root.visibility = if (preeditUi.visible) VISIBLE else GONE + candidatesUi.update(paged, orientation) + if (evaluateVisibility()) { + visibility = VISIBLE + } else { + // RecyclerView won't update its items when ancestor view is GONE + visibility = INVISIBLE + touchEventReceiverWindow.dismiss() + } + } + + private fun updatePosition() { + if (visibility != VISIBLE) { + // skip unnecessary updates + return + } + val (parentWidth, parentHeight) = parentSize + if (parentWidth <= 0 || parentHeight <= 0) { + // panic, bail + translationX = 0f + translationY = 0f + return + } + val (horizontal, bottom, top) = anchorPosition + val w: Int = width + val h: Int = height + val selfWidth = w.toFloat() + val selfHeight = h.toFloat() + val tX: Float = if (layoutDirection == LAYOUT_DIRECTION_RTL) { + val rtlOffset = parentWidth - horizontal + if (rtlOffset + selfWidth > parentWidth) selfWidth - parentWidth else -rtlOffset + } else { + if (horizontal + selfWidth > parentWidth) parentWidth - selfWidth else horizontal + } + val bottomLimit = parentHeight - bottomInsets + val bottomSpace = bottomLimit - bottom + // move CandidatesView above cursor anchor, only when + val tY: Float = if ( + bottom + selfHeight > bottomLimit // bottom space is not enough + && top > bottomSpace // top space is larger than bottom + ) top - selfHeight else bottom + translationX = tX + translationY = tY + // update touchEventReceiverWindow's position after CandidatesView's + touchEventReceiverWindow.showAt(tX.roundToInt(), tY.roundToInt(), w, h) + shouldUpdatePosition = false + } + + fun updateCursorAnchor(@Size(4) anchor: FloatArray, @Size(2) parent: FloatArray) { + val (horizontal, bottom, _, top) = anchor + val (parentWidth, parentHeight) = parent + anchorPosition[0] = horizontal + anchorPosition[1] = bottom + anchorPosition[2] = top + parentSize[0] = parentWidth + parentSize[1] = parentHeight + updatePosition() + } + + init { + // invisible by default + visibility = INVISIBLE + + minWidth = dp(windowMinWidth) + padding = dp(windowPadding) + background = GradientDrawable().apply { + setColor(theme.backgroundColor) + shape = GradientDrawable.RECTANGLE + cornerRadius = dp(windowRadius).toFloat() + } + clipToOutline = true + outlineProvider = ViewOutlineProvider.BACKGROUND + add(preeditUi.root, lParams(wrapContent, wrapContent) { + topOfParent() + startOfParent() + }) + add(candidatesUi.root, lParams(matchConstraints, wrapContent) { + matchConstraintMinWidth = wrapContent + below(preeditUi.root) + centerHorizontally() + bottomOfParent() + }) + + isFocusable = false + layoutParams = ViewGroup.LayoutParams(wrapContent, wrapContent) + } + + override fun onApplyWindowInsets(insets: WindowInsets): WindowInsets { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.VANILLA_ICE_CREAM) { + bottomInsets = getNavBarBottomInset(insets) + } + return insets + } + + override fun onAttachedToWindow() { + super.onAttachedToWindow() + candidatesUi.root.viewTreeObserver.addOnGlobalLayoutListener(layoutListener) + viewTreeObserver.addOnPreDrawListener(preDrawListener) + } + + override fun onDetachedFromWindow() { + viewTreeObserver.removeOnPreDrawListener(preDrawListener) + candidatesUi.root.viewTreeObserver.removeOnGlobalLayoutListener(layoutListener) + touchEventReceiverWindow.dismiss() + super.onDetachedFromWindow() + } +} diff --git a/app/src/main/java/org/fcitx/fcitx5/android/input/FcitxInputMethodService.kt b/app/src/main/java/org/fcitx/fcitx5/android/input/FcitxInputMethodService.kt index 3925aae39..b417f5a64 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/input/FcitxInputMethodService.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/input/FcitxInputMethodService.kt @@ -1,10 +1,12 @@ /* * SPDX-License-Identifier: LGPL-2.1-or-later - * SPDX-FileCopyrightText: Copyright 2021-2023 Fcitx5 for Android Contributors + * SPDX-FileCopyrightText: Copyright 2021-2025 Fcitx5 for Android Contributors */ + package org.fcitx.fcitx5.android.input import android.annotation.SuppressLint +import android.app.Dialog import android.content.res.ColorStateList import android.content.res.Configuration import android.graphics.Color @@ -20,10 +22,12 @@ import android.view.KeyEvent import android.view.View import android.view.ViewGroup import android.view.Window +import android.view.WindowManager import android.view.inputmethod.CursorAnchorInfo import android.view.inputmethod.EditorInfo import android.view.inputmethod.InlineSuggestionsRequest import android.view.inputmethod.InlineSuggestionsResponse +import android.view.inputmethod.InputMethodSubtype import android.widget.FrameLayout import android.widget.inline.InlinePresentationSpec import androidx.annotation.Keep @@ -35,7 +39,6 @@ import androidx.autofill.inline.common.ViewStyle import androidx.autofill.inline.v1.InlineSuggestionUi import androidx.core.view.updateLayoutParams import androidx.lifecycle.lifecycleScope -import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineStart import kotlinx.coroutines.Job import kotlinx.coroutines.channels.Channel @@ -45,20 +48,28 @@ import org.fcitx.fcitx5.android.R import org.fcitx.fcitx5.android.core.CapabilityFlags import org.fcitx.fcitx5.android.core.FcitxAPI import org.fcitx.fcitx5.android.core.FcitxEvent +import org.fcitx.fcitx5.android.core.FcitxKeyMapping import org.fcitx.fcitx5.android.core.FormattedText import org.fcitx.fcitx5.android.core.KeyStates import org.fcitx.fcitx5.android.core.KeySym +import org.fcitx.fcitx5.android.core.ScancodeMapping +import org.fcitx.fcitx5.android.core.SubtypeManager import org.fcitx.fcitx5.android.daemon.FcitxConnection import org.fcitx.fcitx5.android.daemon.FcitxDaemon import org.fcitx.fcitx5.android.data.InputFeedbacks import org.fcitx.fcitx5.android.data.prefs.AppPrefs import org.fcitx.fcitx5.android.data.prefs.ManagedPreference +import org.fcitx.fcitx5.android.data.prefs.ManagedPreferenceProvider import org.fcitx.fcitx5.android.data.theme.Theme import org.fcitx.fcitx5.android.data.theme.ThemeManager import org.fcitx.fcitx5.android.input.cursor.CursorRange import org.fcitx.fcitx5.android.input.cursor.CursorTracker +import org.fcitx.fcitx5.android.utils.InputMethodUtil import org.fcitx.fcitx5.android.utils.alpha +import org.fcitx.fcitx5.android.utils.forceShowSelf import org.fcitx.fcitx5.android.utils.inputMethodManager +import org.fcitx.fcitx5.android.utils.monitorCursorAnchor +import org.fcitx.fcitx5.android.utils.styledFloat import org.fcitx.fcitx5.android.utils.withBatchEdit import splitties.bitflags.hasFlag import splitties.dimensions.dp @@ -66,7 +77,6 @@ import splitties.resources.styledColor import timber.log.Timber import kotlin.math.max - class FcitxInputMethodService : LifecycleInputMethodService() { private lateinit var fcitx: FcitxConnection @@ -78,7 +88,16 @@ class FcitxInputMethodService : LifecycleInputMethodService() { private lateinit var pkgNameCache: PackageNameCache + private lateinit var decorView: View + private lateinit var contentView: FrameLayout private var inputView: InputView? = null + private var candidatesView: CandidatesView? = null + + private val navbarMgr = NavigationBarManager() + private val inputDeviceMgr = InputDeviceManager onChange@{ + val w = window.window ?: return@onChange + navbarMgr.evaluate(w, useVirtualKeyboard = it) + } private var capabilityFlags = CapabilityFlags.DefaultFlags @@ -99,33 +118,56 @@ class FcitxInputMethodService : LifecycleInputMethodService() { private var highlightColor: Int = 0x66008577 // material_deep_teal_500 with alpha 0.4 - private val ignoreSystemCursor by AppPrefs.getInstance().advanced.ignoreSystemCursor + private val prefs = AppPrefs.getInstance() + private val inlineSuggestions by prefs.keyboard.inlineSuggestions + private val ignoreSystemCursor by prefs.advanced.ignoreSystemCursor - private val inlineSuggestions by AppPrefs.getInstance().keyboard.inlineSuggestions + private val recreateInputViewPrefs: Array> = arrayOf( + prefs.keyboard.expandKeypressArea, + prefs.advanced.disableAnimation, + prefs.advanced.ignoreSystemWindowInsets, + ) - @Keep - private val recreateInputViewListener = ManagedPreference.OnChangeListener { _, _ -> - recreateInputView(ThemeManager.getActiveTheme()) + private fun replaceInputView(theme: Theme): InputView { + val newInputView = InputView(this, fcitx, theme) + setInputView(newInputView) + inputDeviceMgr.setInputView(newInputView) + navbarMgr.setupInputView(newInputView) + inputView = newInputView + return newInputView + } + + private fun replaceCandidateView(theme: Theme): CandidatesView { + val newCandidatesView = CandidatesView(this, fcitx, theme) + // replace CandidatesView manually + contentView.removeView(candidatesView) + // put CandidatesView directly under content view + contentView.addView(newCandidatesView) + inputDeviceMgr.setCandidatesView(newCandidatesView) + navbarMgr.setupInputView(newCandidatesView) + candidatesView = newCandidatesView + return newCandidatesView + } + + private fun replaceInputViews(theme: Theme) { + navbarMgr.evaluate(window.window!!) + replaceInputView(theme) + replaceCandidateView(theme) } @Keep - private val onThemeChangeListener = ThemeManager.OnThemeChangeListener { - recreateInputView(it) + private val recreateInputViewListener = ManagedPreference.OnChangeListener { _, _ -> + replaceInputView(ThemeManager.activeTheme) } - private fun recreateInputView(theme: Theme) { - // InputView should be created first in onCreateInputView - // setInputView should be used to 'replace' current InputView only - InputView(this, fcitx, theme).also { - inputView = it - setInputView(it) - } + @Keep + private val recreateCandidatesViewListener = ManagedPreferenceProvider.OnChangeListener { + replaceCandidateView(ThemeManager.activeTheme) } - private fun postJob(scope: CoroutineScope, block: suspend () -> Unit): Job { - val job = scope.launch(start = CoroutineStart.LAZY) { block() } - jobs.trySend(job) - return job + @Keep + private val onThemeChangeListener = ThemeManager.OnThemeChangeListener { + replaceInputViews(it) } /** @@ -135,8 +177,13 @@ class FcitxInputMethodService : LifecycleInputMethodService() { * subsequent operations can start if the prior operation is not finished (suspended), * [postFcitxJob] ensures that operations are executed sequentially. */ - fun postFcitxJob(block: suspend FcitxAPI.() -> Unit) = - postJob(fcitx.lifecycleScope) { fcitx.runOnReady(block) } + fun postFcitxJob(block: suspend FcitxAPI.() -> Unit): Job { + val job = fcitx.lifecycleScope.launch(start = CoroutineStart.LAZY) { + fcitx.runOnReady(block) + } + jobs.trySend(job) + return job + } override fun onCreate() { fcitx = FcitxDaemon.connect(javaClass.name) @@ -149,12 +196,19 @@ class FcitxInputMethodService : LifecycleInputMethodService() { } } pkgNameCache = PackageNameCache(this) - AppPrefs.getInstance().apply { - keyboard.expandKeypressArea.registerOnChangeListener(recreateInputViewListener) - advanced.disableAnimation.registerOnChangeListener(recreateInputViewListener) + recreateInputViewPrefs.forEach { + it.registerOnChangeListener(recreateInputViewListener) } + prefs.candidates.registerOnChangeListener(recreateCandidatesViewListener) ThemeManager.addOnChangedListener(onThemeChangeListener) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + postFcitxJob { + SubtypeManager.syncWith(enabledIme()) + } + } super.onCreate() + decorView = window.window!!.decorView + contentView = decorView.findViewById(android.R.id.content) } private fun handleFcitxEvent(event: FcitxEvent<*>) { @@ -165,10 +219,16 @@ class FcitxInputMethodService : LifecycleInputMethodService() { is FcitxEvent.KeyEvent -> event.data.let event@{ if (it.states.virtual) { // KeyEvent from virtual keyboard - when (it.unicode) { - '\b'.code -> handleBackspaceKey() - '\r'.code -> handleReturnKey() - else -> commitText(Char(it.unicode).toString()) + when (it.sym.sym) { + FcitxKeyMapping.FcitxKey_BackSpace -> handleBackspaceKey() + FcitxKeyMapping.FcitxKey_Return -> handleReturnKey() + FcitxKeyMapping.FcitxKey_Left -> sendDownUpKeyEvents(KeyEvent.KEYCODE_DPAD_LEFT) + FcitxKeyMapping.FcitxKey_Right -> sendDownUpKeyEvents(KeyEvent.KEYCODE_DPAD_RIGHT) + else -> if (it.unicode > 0) { + commitText(Character.toString(it.unicode)) + } else { + Timber.w("Unhandled Virtual KeyEvent: $it") + } } } else { // KeyEvent from physical keyboard (or input method engine forwardKey) @@ -190,7 +250,7 @@ class FcitxInputMethodService : LifecycleInputMethodService() { } else { // no matching keyCode, commit character once on key down if (!it.up && it.unicode > 0) { - commitText(Char(it.unicode).toString()) + commitText(Character.toString(it.unicode)) } else { Timber.w("Unhandled Fcitx KeyEvent: $it") } @@ -204,6 +264,15 @@ class FcitxInputMethodService : LifecycleInputMethodService() { val (before, after) = event.data handleDeleteSurrounding(before, after) } + is FcitxEvent.IMChangeEvent -> { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + val im = event.data.uniqueName + val subtype = SubtypeManager.subtypeOf(im) ?: return + skipNextSubtypeChange = im + // [^1]: notify system that input method subtype has changed + switchInputMethod(InputMethodUtil.componentName, subtype) + } + } else -> {} } } @@ -316,7 +385,7 @@ class FcitxInputMethodService : LifecycleInputMethodService() { 0, metaState, KeyCharacterMap.VIRTUAL_KEYBOARD, - 0, + ScancodeMapping.keyCodeToScancode(keyEventCode), KeyEvent.FLAG_SOFT_KEYBOARD or KeyEvent.FLAG_KEEP_TOUCH_MODE ) ) @@ -332,7 +401,7 @@ class FcitxInputMethodService : LifecycleInputMethodService() { 0, metaState, KeyCharacterMap.VIRTUAL_KEYBOARD, - 0, + ScancodeMapping.keyCodeToScancode(keyEventCode), KeyEvent.FLAG_SOFT_KEYBOARD or KeyEvent.FLAG_KEEP_TOUCH_MODE ) ) @@ -392,31 +461,35 @@ class FcitxInputMethodService : LifecycleInputMethodService() { override fun onWindowShown() { super.onWindowShown() + try { + highlightColor = styledColor(android.R.attr.colorAccent).alpha(0.4f) + } catch (_: Exception) { + Timber.w("Device does not support android.R.attr.colorAccent which it should have.") + } InputFeedbacks.syncSystemPrefs() + // navbar foreground/background color would reset every time window shows + navbarMgr.update(window.window!!) } - override fun onCreateInputView(): View { - // onCreateInputView will be called once, when the input area is first displayed, - // during each onConfigurationChanged period. - // That is, onCreateInputView would be called again, after system dark mode changes, - // or screen orientation changes. - return InputView(this, fcitx, ThemeManager.getActiveTheme()).also { - inputView = it - } + override fun onCreateInputView(): View? { + replaceInputViews(ThemeManager.activeTheme) + // We will call `setInputView` by ourselves. This is fine. + return null } override fun setInputView(view: View) { - try { - highlightColor = view.styledColor(android.R.attr.colorAccent).alpha(0.4f) - } catch (e: Exception) { - Timber.w("Device does not support android.R.attr.colorAccent which it should have.") - } - window.window!!.decorView - .findViewById(android.R.id.inputArea) + super.setInputView(view) + // input method layout has not changed in 11 years: + // https://android.googlesource.com/platform/frameworks/base/+/ae3349e1c34f7aceddc526cd11d9ac44951e97b6/core/res/res/layout/input_method.xml + // expand inputArea to fullscreen + contentView.findViewById(android.R.id.inputArea) .updateLayoutParams { height = ViewGroup.LayoutParams.MATCH_PARENT } - super.setInputView(view) + /** + * expand InputView to fullscreen, since [android.inputmethodservice.InputMethodService.setInputView] + * would set InputView's height to [ViewGroup.LayoutParams.WRAP_CONTENT] + */ view.updateLayoutParams { height = ViewGroup.LayoutParams.MATCH_PARENT } @@ -426,21 +499,33 @@ class FcitxInputMethodService : LifecycleInputMethodService() { win.setLayout(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT) } + private var inputViewLocation = intArrayOf(0, 0) + override fun onComputeInsets(outInsets: Insets) { - val (_, y) = intArrayOf(0, 0).also { inputView?.keyboardView?.getLocationInWindow(it) } - outInsets.apply { - contentTopInsets = y - touchableInsets = Insets.TOUCHABLE_INSETS_CONTENT - touchableRegion.setEmpty() - visibleTopInsets = y + if (inputDeviceMgr.isVirtualKeyboard) { + inputView?.keyboardView?.getLocationInWindow(inputViewLocation) + outInsets.apply { + contentTopInsets = inputViewLocation[1] + visibleTopInsets = inputViewLocation[1] + touchableInsets = Insets.TOUCHABLE_INSETS_VISIBLE + } + } else { + val n = decorView.findViewById(android.R.id.navigationBarBackground)?.height ?: 0 + val h = decorView.height - n + outInsets.apply { + contentTopInsets = h + visibleTopInsets = h + touchableInsets = Insets.TOUCHABLE_INSETS_VISIBLE + } } } - // TODO: candidate view for physical keyboard input - // always show InputView since we do not support physical keyboard input without it yet + // always show InputView since we delegate CandidatesView's visibility to it @SuppressLint("MissingSuperCall") override fun onEvaluateInputViewShown() = true + fun superEvaluateInputViewShown() = super.onEvaluateInputViewShown() + override fun onEvaluateFullscreenMode() = false private fun forwardKeyEvent(event: KeyEvent): Boolean { @@ -454,16 +539,21 @@ class FcitxInputMethodService : LifecycleInputMethodService() { // try send charCode first, allow upper case and lower case character generating different KeySym // skip \t, because it's charCode is different from KeySym // skip \n, because fcitx wants \r for return - if (charCode > 0 && charCode != '\t'.code && charCode != '\n'.code) { + // skip ' ', because it would produce same KeySym regardless of the modifier + if (charCode > 0 && charCode != '\t'.code && charCode != '\n'.code && charCode != ' '.code) { + // drop modifier state when using combination keys to input number/symbol on some phones + // because fcitx doesn't recognize selection key with modifiers (eg. Alt+Q for 1) + // in which case event.getNumber().toInt() == event.getUnicodeChar() + val s = if (event.number.code == charCode) KeyStates.Empty else states postFcitxJob { - sendKey(charCode, states.states, up, timestamp) + sendKey(charCode, s.states, event.scanCode, up, timestamp) } return true } val keySym = KeySym.fromKeyEvent(event) if (keySym != null) { postFcitxJob { - sendKey(keySym, states, up, timestamp) + sendKey(keySym, states, event.scanCode, up, timestamp) } return true } @@ -472,6 +562,13 @@ class FcitxInputMethodService : LifecycleInputMethodService() { } override fun onKeyDown(keyCode: Int, event: KeyEvent): Boolean { + // request to show floating CandidatesView when pressing physical keyboard + if (inputDeviceMgr.evaluateOnKeyDown(event, this)) { + postFcitxJob { + focus(true) + } + forceShowSelf() + } return forwardKeyEvent(event) || super.onKeyDown(keyCode, event) } @@ -479,6 +576,23 @@ class FcitxInputMethodService : LifecycleInputMethodService() { return forwardKeyEvent(event) || super.onKeyUp(keyCode, event) } + // Added in API level 14, deprecated in 29 + @Suppress("DEPRECATION", "OVERRIDE_DEPRECATION") + override fun onViewClicked(focusChanged: Boolean) { + super.onViewClicked(focusChanged) + if (Build.VERSION.SDK_INT < 34) { + inputDeviceMgr.evaluateOnViewClicked(this) + } + } + + @RequiresApi(34) + override fun onUpdateEditorToolType(toolType: Int) { + super.onUpdateEditorToolType(toolType) + inputDeviceMgr.evaluateOnUpdateEditorToolType(toolType, this) + } + + private var firstBindInput = true + override fun onBindInput() { val uid = currentInputBinding.uid val pkgName = pkgNameCache.forUid(uid) @@ -487,6 +601,47 @@ class FcitxInputMethodService : LifecycleInputMethodService() { // ensure InputContext has been created before focusing it activate(uid, pkgName) } + if (firstBindInput) { + firstBindInput = false + // only use input method from subtype for the first `onBindInput`, because + // 1. fcitx has `ShareInputState` option, thus reading input method from subtype + // everytime would ruin `ShareInputState=Program` + // 2. im from subtype should be read once, when user changes input method from other + // app to a subtype of ours via system input method picker (on 34+) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + val subtype = inputMethodManager.currentInputMethodSubtype ?: return + val im = SubtypeManager.inputMethodOf(subtype) + postFcitxJob { + activateIme(im) + } + } + } + } + + /** + * When input method changes internally (eg. via language switch key or keyboard shortcut), + * we want to notify system that subtype has changed (see [^1]), then ignore the incoming + * [onCurrentInputMethodSubtypeChanged] callback. + * Input method should only be changed when user changes subtype in system input method picker + * manually. + */ + private var skipNextSubtypeChange: String? = null + + override fun onCurrentInputMethodSubtypeChanged(newSubtype: InputMethodSubtype) { + super.onCurrentInputMethodSubtypeChanged(newSubtype) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + val im = SubtypeManager.inputMethodOf(newSubtype) + Timber.d("onCurrentInputMethodSubtypeChanged: im=$im") + // don't change input method if this "subtype change" was our notify to system + // see [^1] + if (skipNextSubtypeChange == im) { + skipNextSubtypeChange = null + return + } + postFcitxJob { + activateIme(im) + } + } } override fun onStartInput(attribute: EditorInfo, restarting: Boolean) { @@ -518,9 +673,20 @@ class FcitxInputMethodService : LifecycleInputMethodService() { postFcitxJob { focus(true) } - // because onStartInputView will always be called after onStartInput, - // editorInfo and capFlags should be up-to-date - inputView?.startInput(info, capabilityFlags, restarting) + if (inputDeviceMgr.evaluateOnStartInputView(info, this)) { + // because onStartInputView will always be called after onStartInput, + // editorInfo and capFlags should be up-to-date + inputView?.startInput(info, capabilityFlags, restarting) + } else { + if (currentInputConnection?.monitorCursorAnchor() != true) { + if (!decorLocationUpdated) { + updateDecorLocation() + } + // anchor CandidatesView to bottom-left corner in case InputConnection does not + // support monitoring CursorAnchorInfo + workaroundNullCursorAnchorInfo() + } + } } override fun onUpdateSelection( @@ -538,8 +704,70 @@ class FcitxInputMethodService : LifecycleInputMethodService() { inputView?.updateSelection(newSelStart, newSelEnd) } + private val contentSize = floatArrayOf(0f, 0f) + private val decorLocation = floatArrayOf(0f, 0f) + private val decorLocationInt = intArrayOf(0, 0) + private var decorLocationUpdated = false + + private fun updateDecorLocation() { + contentSize[0] = contentView.width.toFloat() + contentSize[1] = contentView.height.toFloat() + decorView.getLocationOnScreen(decorLocationInt) + decorLocation[0] = decorLocationInt[0].toFloat() + decorLocation[1] = decorLocationInt[1].toFloat() + // contentSize and decorLocation can be completely wrong, + // when measuring right after the very first onStartInputView() of an IMS' lifecycle + if (contentSize[0] > 0 && contentSize[1] > 0) { + decorLocationUpdated = true + } + } + + private val anchorPosition = floatArrayOf(0f, 0f, 0f, 0f) + + /** + * anchor candidates view to bottom-left corner, only works if [decorLocationUpdated] + */ + private fun workaroundNullCursorAnchorInfo() { + anchorPosition[0] = 0f + anchorPosition[1] = contentSize[1] + anchorPosition[2] = 0f + anchorPosition[3] = contentSize[1] + candidatesView?.updateCursorAnchor(anchorPosition, contentSize) + } + override fun onUpdateCursorAnchorInfo(info: CursorAnchorInfo) { - // CursorAnchorInfo focus more on screen coordinates rather than selection + val bounds = info.getCharacterBounds(0) + if (bounds != null) { + // anchor to start of composing span instead of insertion mark if available + val horizontal = + if (candidatesView?.layoutDirection == View.LAYOUT_DIRECTION_RTL) bounds.right else bounds.left + anchorPosition[0] = horizontal + anchorPosition[1] = bounds.bottom + anchorPosition[2] = horizontal + anchorPosition[3] = bounds.top + } else { + anchorPosition[0] = info.insertionMarkerHorizontal + anchorPosition[1] = info.insertionMarkerBottom + anchorPosition[2] = info.insertionMarkerHorizontal + anchorPosition[3] = info.insertionMarkerTop + } + // avoid calling `decorView.getLocationOnScreen` repeatedly + if (!decorLocationUpdated) { + updateDecorLocation() + } + if (anchorPosition.any(Float::isNaN)) { + // anchor candidates view to bottom-left corner in case CursorAnchorInfo is invalid + workaroundNullCursorAnchorInfo() + return + } + // params of `Matrix.mapPoints` must be [x0, y0, x1, y1] + info.matrix.mapPoints(anchorPosition) + val (xOffset, yOffset) = decorLocation + anchorPosition[0] -= xOffset + anchorPosition[1] -= yOffset + anchorPosition[2] -= xOffset + anchorPosition[3] -= yOffset + candidatesView?.updateCursorAnchor(anchorPosition, contentSize) } private fun handleCursorUpdate(newSelStart: Int, newSelEnd: Int, updateIndex: Int) { @@ -591,7 +819,7 @@ class FcitxInputMethodService : LifecycleInputMethodService() { } // because setComposingText(text, cursor) can only put cursor at end of composing, - // sometimes onUpdateCursorAnchorInfo/onUpdateSelection would receive event with wrong cursor position. + // sometimes onUpdateSelection would receive event with wrong cursor position. // those events need to be filtered. // because of https://android.googlesource.com/platform/frameworks/base.git/+/refs/tags/android-11.0.0_r45/core/java/android/view/inputmethod/BaseInputConnection.java#851 // it's not possible to set cursor inside composing text @@ -599,7 +827,18 @@ class FcitxInputMethodService : LifecycleInputMethodService() { val ic = currentInputConnection ?: return val lastSelection = selection.latest ic.beginBatchEdit() - if (!composingText.spanEquals(text)) { + if (composingText.spanEquals(text)) { + // composing text content is up-to-date + // update cursor only when it's not empty AND cursor position is valid + if (text.length > 0 && text.cursor >= 0) { + val p = text.cursor + composing.start + if (p != lastSelection.start) { + Timber.d("updateComposingText: set Android selection ($p, $p)") + ic.setSelection(p, p) + selection.predict(p) + } + } + } else { // composing text content changed Timber.d("updateComposingText: '$text' lastSelection=$lastSelection") if (text.isEmpty()) { @@ -631,17 +870,6 @@ class FcitxInputMethodService : LifecycleInputMethodService() { } } Timber.d("updateComposingText: composing=$composing") - } else { - // composing text content is up-to-date - // update cursor only when it's not empty AND cursor position is valid - if (text.length > 0 && text.cursor >= 0) { - val p = text.cursor + composing.start - if (p != lastSelection.start) { - Timber.d("updateComposingText: set Android selection ($p, $p)") - ic.setSelection(p, p) - selection.predict(p) - } - } } composingText = text ic.endBatchEdit() @@ -662,8 +890,9 @@ class FcitxInputMethodService : LifecycleInputMethodService() { @SuppressLint("RestrictedApi") @RequiresApi(Build.VERSION_CODES.R) override fun onCreateInlineSuggestionsRequest(uiExtras: Bundle): InlineSuggestionsRequest? { - if (!inlineSuggestions) return null - val theme = ThemeManager.getActiveTheme() + // ignore inline suggestion when disabled by user || using physical keyboard with floating candidates view + if (!inlineSuggestions || !inputDeviceMgr.isVirtualKeyboard) return null + val theme = ThemeManager.activeTheme val chipDrawable = if (theme.isDark) R.drawable.bkg_inline_suggestion_dark else R.drawable.bkg_inline_suggestion_light val chipBg = Icon.createWithResource(this, chipDrawable).setTint(theme.keyTextColor) @@ -718,26 +947,23 @@ class FcitxInputMethodService : LifecycleInputMethodService() { @RequiresApi(Build.VERSION_CODES.R) override fun onInlineSuggestionsResponse(response: InlineSuggestionsResponse): Boolean { - if (!inlineSuggestions) return false - return inputView?.handleInlineSuggestions(response) ?: false - } - - fun nextInputMethodApp() { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { - switchToNextInputMethod(false) - } else { - @Suppress("DEPRECATION") - inputMethodManager.switchToNextInputMethod(window.window!!.attributes.token, false) - } + if (!inlineSuggestions || !inputDeviceMgr.isVirtualKeyboard) return false + return inputView?.handleInlineSuggestions(response) == true } override fun onFinishInputView(finishingInput: Boolean) { Timber.d("onFinishInputView: finishingInput=$finishingInput") - currentInputConnection?.finishComposingText() + decorLocationUpdated = false + inputDeviceMgr.onFinishInputView() + currentInputConnection?.apply { + finishComposingText() + monitorCursorAnchor(false) + } + resetComposingState() postFcitxJob { focus(false) } - inputView?.finishInput() + showingDialog?.dismiss() } override fun onFinishInput() { @@ -758,18 +984,39 @@ class FcitxInputMethodService : LifecycleInputMethodService() { } override fun onDestroy() { - AppPrefs.getInstance().apply { - keyboard.expandKeypressArea.unregisterOnChangeListener(recreateInputViewListener) - advanced.disableAnimation.unregisterOnChangeListener(recreateInputViewListener) + recreateInputViewPrefs.forEach { + it.unregisterOnChangeListener(recreateInputViewListener) } + prefs.candidates.unregisterOnChangeListener(recreateCandidatesViewListener) ThemeManager.removeOnChangedListener(onThemeChangeListener) super.onDestroy() // Fcitx might be used in super.onDestroy() FcitxDaemon.disconnect(javaClass.name) } + private var showingDialog: Dialog? = null + + fun showDialog(dialog: Dialog) { + showingDialog?.dismiss() + dialog.window?.also { + it.attributes.apply { + token = decorView.windowToken + type = WindowManager.LayoutParams.TYPE_APPLICATION_ATTACHED_DIALOG + } + it.addFlags( + WindowManager.LayoutParams.FLAG_ALT_FOCUSABLE_IM or WindowManager.LayoutParams.FLAG_DIM_BEHIND + ) + it.setDimAmount(styledFloat(android.R.attr.backgroundDimAmount)) + } + dialog.setOnDismissListener { + showingDialog = null + } + dialog.show() + showingDialog = dialog + } + + @Suppress("ConstPropertyName") companion object { const val DeleteSurroundingFlag = "org.fcitx.fcitx5.android.DELETE_SURROUNDING" } - -} \ No newline at end of file +} diff --git a/app/src/main/java/org/fcitx/fcitx5/android/input/InputDeviceManager.kt b/app/src/main/java/org/fcitx/fcitx5/android/input/InputDeviceManager.kt new file mode 100644 index 000000000..656f40dfd --- /dev/null +++ b/app/src/main/java/org/fcitx/fcitx5/android/input/InputDeviceManager.kt @@ -0,0 +1,146 @@ +/* + * SPDX-License-Identifier: LGPL-2.1-or-later + * SPDX-FileCopyrightText: Copyright 2024 Fcitx5 for Android Contributors + */ + +package org.fcitx.fcitx5.android.input + +import android.text.InputType +import android.view.KeyEvent +import android.view.MotionEvent +import android.view.View +import android.view.inputmethod.EditorInfo +import org.fcitx.fcitx5.android.data.prefs.AppPrefs +import org.fcitx.fcitx5.android.input.candidates.floating.FloatingCandidatesMode +import org.fcitx.fcitx5.android.utils.monitorCursorAnchor + +class InputDeviceManager(private val onChange: (Boolean) -> Unit) { + + private var inputView: InputView? = null + private var candidatesView: CandidatesView? = null + + private fun setupInputViewEvents(isVirtual: Boolean) { + inputView?.handleEvents = isVirtual + inputView?.visibility = if (isVirtual) View.VISIBLE else View.GONE + } + + private fun setupCandidatesViewEvents(isVirtual: Boolean) { + candidatesView?.handleEvents = !isVirtual + // hide CandidatesView when entering virtual keyboard mode, + // but preserve the visibility when entering physical keyboard mode (in case it's empty) + if (isVirtual) { + candidatesView?.visibility = View.GONE + } + } + + private fun setupViewEvents(isVirtual: Boolean) { + setupInputViewEvents(isVirtual) + setupCandidatesViewEvents(isVirtual) + } + + var isVirtualKeyboard = true + private set(value) { + field = value + setupViewEvents(value) + } + + fun setInputView(inputView: InputView) { + this.inputView = inputView + setupInputViewEvents(this.isVirtualKeyboard) + } + + fun setCandidatesView(candidatesView: CandidatesView) { + this.candidatesView = candidatesView + setupCandidatesViewEvents(this.isVirtualKeyboard) + } + + private fun applyMode(service: FcitxInputMethodService, useVirtualKeyboard: Boolean) { + if (useVirtualKeyboard == isVirtualKeyboard) { + return + } + // monitor CursorAnchorInfo when switching to CandidatesView + service.currentInputConnection.monitorCursorAnchor(!useVirtualKeyboard) + service.postFcitxJob { + setCandidatePagingMode(if (useVirtualKeyboard) 0 else 1) + } + isVirtualKeyboard = useVirtualKeyboard + onChange(isVirtualKeyboard) + } + + private var startedInputView = false + private var isNullInputType = true + + private var candidatesViewMode by AppPrefs.getInstance().candidates.mode + + /** + * @return should use virtual keyboard + */ + fun evaluateOnStartInputView(info: EditorInfo, service: FcitxInputMethodService): Boolean { + startedInputView = true + isNullInputType = info.inputType and InputType.TYPE_MASK_CLASS == InputType.TYPE_NULL + val useVirtualKeyboard = when (candidatesViewMode) { + FloatingCandidatesMode.SystemDefault -> service.superEvaluateInputViewShown() + FloatingCandidatesMode.InputDevice -> isVirtualKeyboard + FloatingCandidatesMode.Disabled -> true + } + applyMode(service, useVirtualKeyboard) + return useVirtualKeyboard + } + + /** + * @return should force show input views + */ + fun evaluateOnKeyDown(e: KeyEvent, service: FcitxInputMethodService): Boolean { + if (startedInputView) { + // filter out back/home/volume buttons and combination keys + if (e.isPrintingKey && e.hasNoModifiers()) { + // evaluate virtual keyboard visibility when pressing physical keyboard while InputView visible + evaluateOnKeyDownInner(service) + } + // no need to force show InputView since it's already visible + return false + } else { + // force show InputView when focusing on text input (likely inputType is not TYPE_NULL) + // and pressing any digit/letter/punctuation key on physical keyboard + val showInputView = !isNullInputType && e.isPrintingKey && e.hasNoModifiers() + if (showInputView) { + evaluateOnKeyDownInner(service) + } + return showInputView + } + } + + private fun evaluateOnKeyDownInner(service: FcitxInputMethodService) { + val useVirtualKeyboard = when (candidatesViewMode) { + FloatingCandidatesMode.SystemDefault -> service.superEvaluateInputViewShown() + FloatingCandidatesMode.InputDevice -> false + FloatingCandidatesMode.Disabled -> true + } + applyMode(service, useVirtualKeyboard) + } + + fun evaluateOnViewClicked(service: FcitxInputMethodService) { + if (!startedInputView) return + val useVirtualKeyboard = when (candidatesViewMode) { + FloatingCandidatesMode.SystemDefault -> service.superEvaluateInputViewShown() + else -> true + } + applyMode(service, useVirtualKeyboard) + } + + fun evaluateOnUpdateEditorToolType(toolType: Int, service: FcitxInputMethodService) { + if (!startedInputView) return + val useVirtualKeyboard = when (candidatesViewMode) { + FloatingCandidatesMode.SystemDefault -> service.superEvaluateInputViewShown() + FloatingCandidatesMode.InputDevice -> + // switch to virtual keyboard on touch screen events, otherwise preserve current mode + if (toolType == MotionEvent.TOOL_TYPE_FINGER || toolType == MotionEvent.TOOL_TYPE_STYLUS) true else isVirtualKeyboard + FloatingCandidatesMode.Disabled -> true + } + applyMode(service, useVirtualKeyboard) + } + + fun onFinishInputView() { + startedInputView = false + } +} diff --git a/app/src/main/java/org/fcitx/fcitx5/android/input/InputView.kt b/app/src/main/java/org/fcitx/fcitx5/android/input/InputView.kt index 82adb517e..52680f473 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/input/InputView.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/input/InputView.kt @@ -1,46 +1,37 @@ /* * SPDX-License-Identifier: LGPL-2.1-or-later - * SPDX-FileCopyrightText: Copyright 2021-2023 Fcitx5 for Android Contributors + * SPDX-FileCopyrightText: Copyright 2021-2024 Fcitx5 for Android Contributors */ + package org.fcitx.fcitx5.android.input import android.annotation.SuppressLint -import android.app.Dialog import android.content.res.Configuration -import android.graphics.Color import android.os.Build import android.view.View import android.view.View.OnClickListener -import android.view.WindowManager +import android.view.WindowInsets import android.view.inputmethod.EditorInfo import android.view.inputmethod.InlineSuggestionsResponse import android.widget.ImageView import androidx.annotation.Keep import androidx.annotation.RequiresApi -import androidx.constraintlayout.widget.ConstraintLayout -import androidx.core.view.ViewCompat -import androidx.core.view.WindowCompat -import androidx.core.view.WindowInsetsCompat import androidx.core.view.updateLayoutParams -import androidx.lifecycle.lifecycleScope -import kotlinx.coroutines.Job -import kotlinx.coroutines.launch import org.fcitx.fcitx5.android.R import org.fcitx.fcitx5.android.core.CapabilityFlags import org.fcitx.fcitx5.android.core.FcitxEvent import org.fcitx.fcitx5.android.daemon.FcitxConnection import org.fcitx.fcitx5.android.daemon.launchOnReady import org.fcitx.fcitx5.android.data.prefs.AppPrefs -import org.fcitx.fcitx5.android.data.prefs.ManagedPreference +import org.fcitx.fcitx5.android.data.prefs.ManagedPreferenceProvider import org.fcitx.fcitx5.android.data.theme.Theme import org.fcitx.fcitx5.android.data.theme.ThemeManager -import org.fcitx.fcitx5.android.data.theme.ThemeManager.Prefs.NavbarBackground import org.fcitx.fcitx5.android.input.bar.KawaiiBarComponent import org.fcitx.fcitx5.android.input.broadcast.InputBroadcaster import org.fcitx.fcitx5.android.input.broadcast.PreeditEmptyStateComponent import org.fcitx.fcitx5.android.input.broadcast.PunctuationComponent import org.fcitx.fcitx5.android.input.broadcast.ReturnKeyDrawableComponent -import org.fcitx.fcitx5.android.input.candidates.HorizontalCandidateComponent +import org.fcitx.fcitx5.android.input.candidates.horizontal.HorizontalCandidateComponent import org.fcitx.fcitx5.android.input.keyboard.CommonKeyActionListener import org.fcitx.fcitx5.android.input.keyboard.KeyboardWindow import org.fcitx.fcitx5.android.input.picker.emojiPicker @@ -49,7 +40,6 @@ import org.fcitx.fcitx5.android.input.picker.symbolPicker import org.fcitx.fcitx5.android.input.popup.PopupComponent import org.fcitx.fcitx5.android.input.preedit.PreeditComponent import org.fcitx.fcitx5.android.input.wm.InputWindowManager -import org.fcitx.fcitx5.android.utils.styledFloat import org.fcitx.fcitx5.android.utils.unset import org.mechdancer.dependency.DynamicScope import org.mechdancer.dependency.manager.wrapToUniqueComponent @@ -77,16 +67,12 @@ import splitties.views.imageDrawable @SuppressLint("ViewConstructor") class InputView( - val service: FcitxInputMethodService, - val fcitx: FcitxConnection, - val theme: Theme -) : ConstraintLayout(service) { - - private var shouldUpdateNavbarForeground = false - private var shouldUpdateNavbarBackground = false + service: FcitxInputMethodService, + fcitx: FcitxConnection, + theme: Theme +) : BaseInputView(service, fcitx, theme) { private val keyBorder by ThemeManager.prefs.keyBorder - private val navbarBackground by ThemeManager.prefs.navbarBackground private val customBackground = imageView { scaleType = ImageView.ScaleType.CENTER_CROP @@ -107,8 +93,6 @@ class InputView( setOnClickListener(placeholderOnClickListener) } - private val eventHandlerJob: Job - private val scope = DynamicScope() private val themedContext = context.withTheme(R.style.Theme_InputViewTheme) private val broadcaster = InputBroadcaster() @@ -193,8 +177,10 @@ class InputView( } @Keep - private val onKeyboardSizeChangeListener = ManagedPreference.OnChangeListener { _, _ -> - updateKeyboardSize() + private val onKeyboardSizeChangeListener = ManagedPreferenceProvider.OnChangeListener { key -> + if (keyboardSizePrefs.any { it.key == key }) { + updateKeyboardSize() + } } val keyboardView: View @@ -203,67 +189,21 @@ class InputView( // MUST call before any operation setupScope() - eventHandlerJob = service.lifecycleScope.launch { - fcitx.runImmediately { eventFlow }.collect { - handleFcitxEvent(it) - } - } - // restore punctuation mapping in case of InputView recreation fcitx.launchOnReady { punctuation.updatePunctuationMapping(it.statusAreaActionsCached) } - keyboardSizePrefs.forEach { - it.registerOnChangeListener(onKeyboardSizeChangeListener) - } - // make sure KeyboardWindow's view has been created before it receives any broadcast windowManager.addEssentialWindow(keyboardWindow, createView = true) windowManager.addEssentialWindow(symbolPicker) windowManager.addEssentialWindow(emojiPicker) windowManager.addEssentialWindow(emoticonPicker) + // show KeyboardWindow by default + windowManager.attachWindow(KeyboardWindow) broadcaster.onImeUpdate(fcitx.runImmediately { inputMethodEntryCached }) - service.window.window!!.also { - when (navbarBackground) { - NavbarBackground.None -> { - WindowCompat.setDecorFitsSystemWindows(it, true) - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - it.isNavigationBarContrastEnforced = true - } - } - NavbarBackground.ColorOnly -> { - shouldUpdateNavbarForeground = true - shouldUpdateNavbarBackground = true - WindowCompat.setDecorFitsSystemWindows(it, true) - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - it.isNavigationBarContrastEnforced = false - } - } - NavbarBackground.Full -> { - shouldUpdateNavbarForeground = true - // allow draw behind navigation bar - WindowCompat.setDecorFitsSystemWindows(it, false) - // transparent navigation bar - it.navigationBarColor = Color.TRANSPARENT - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - // don't apply scrim to transparent navigation bar - it.isNavigationBarContrastEnforced = false - } - ViewCompat.setOnApplyWindowInsetsListener(this) { _, insets -> - insets.getInsets(WindowInsetsCompat.Type.navigationBars()).let { - bottomPaddingSpace.updateLayoutParams { - bottomMargin = it.bottom - } - } - WindowInsetsCompat.CONSUMED - } - } - } - } - customBackground.imageDrawable = theme.backgroundDrawable(keyBorder) keyboardView = constraintLayout { @@ -317,6 +257,8 @@ class InputView( centerVertically() centerHorizontally() }) + + keyboardPrefs.registerOnChangeListener(onKeyboardSizeChangeListener) } private fun updateKeyboardSize() { @@ -329,8 +271,8 @@ class InputView( val sidePadding = keyboardSidePaddingPx if (sidePadding == 0) { // hide side padding space views when unnecessary - leftPaddingSpace.visibility = View.GONE - rightPaddingSpace.visibility = View.GONE + leftPaddingSpace.visibility = GONE + rightPaddingSpace.visibility = GONE windowManager.view.updateLayoutParams { startToEnd = unset endToStart = unset @@ -338,8 +280,8 @@ class InputView( endOfParent() } } else { - leftPaddingSpace.visibility = View.VISIBLE - rightPaddingSpace.visibility = View.VISIBLE + leftPaddingSpace.visibility = VISIBLE + rightPaddingSpace.visibility = VISIBLE leftPaddingSpace.updateLayoutParams { width = sidePadding } @@ -353,29 +295,21 @@ class InputView( endToStartOf(rightPaddingSpace) } } + preedit.ui.root.setPadding(sidePadding, 0, sidePadding, 0) kawaiiBar.view.setPadding(sidePadding, 0, sidePadding, 0) } + override fun onApplyWindowInsets(insets: WindowInsets): WindowInsets { + bottomPaddingSpace.updateLayoutParams { + bottomMargin = getNavBarBottomInset(insets) + } + return insets + } + /** * called when [InputView] is about to show, or restart */ fun startInput(info: EditorInfo, capFlags: CapabilityFlags, restarting: Boolean = false) { - if (!restarting) { - if (shouldUpdateNavbarForeground || shouldUpdateNavbarBackground) { - service.window.window!!.also { - if (shouldUpdateNavbarForeground) { - WindowCompat.getInsetsController(it, it.decorView) - .isAppearanceLightNavigationBars = !theme.isDark - } - if (shouldUpdateNavbarBackground) { - it.navigationBarColor = when (theme) { - is Theme.Builtin -> if (keyBorder) theme.backgroundColor else theme.keyboardColor - is Theme.Custom -> theme.backgroundColor - } - } - } - } - } broadcaster.onStartInput(info, capFlags) returnKeyDrawable.updateDrawableOnEditorInfo(info) if (focusChangeResetKeyboard || !restarting) { @@ -383,7 +317,7 @@ class InputView( } } - private fun handleFcitxEvent(it: FcitxEvent<*>) { + override fun handleFcitxEvent(it: FcitxEvent<*>) { when (it) { is FcitxEvent.CandidateListEvent -> { broadcaster.onCandidateUpdate(it.data) @@ -411,49 +345,14 @@ class InputView( broadcaster.onSelectionUpdate(start, end) } - private var showingDialog: Dialog? = null - - fun showDialog(dialog: Dialog) { - showingDialog?.dismiss() - val windowToken = windowToken - check(windowToken != null) { "InputView Token is null." } - val window = dialog.window!! - window.attributes.apply { - token = windowToken - type = WindowManager.LayoutParams.TYPE_APPLICATION_ATTACHED_DIALOG - } - window.addFlags( - WindowManager.LayoutParams.FLAG_ALT_FOCUSABLE_IM or - WindowManager.LayoutParams.FLAG_DIM_BEHIND - ) - window.setDimAmount(themedContext.styledFloat(android.R.attr.backgroundDimAmount)) - showingDialog = dialog.apply { - setOnDismissListener { this@InputView.showingDialog = null } - show() - } - } - - /** - * called when [InputView] is being hidden - */ - fun finishInput() { - showingDialog?.dismiss() - } - @RequiresApi(Build.VERSION_CODES.R) fun handleInlineSuggestions(response: InlineSuggestionsResponse): Boolean { return kawaiiBar.handleInlineSuggestions(response) } override fun onDetachedFromWindow() { - keyboardSizePrefs.forEach { - it.unregisterOnChangeListener(onKeyboardSizeChangeListener) - } - ViewCompat.setOnApplyWindowInsetsListener(this, null) - showingDialog?.dismiss() - // cancel eventHandlerJob and then clear DynamicScope, - // implies that InputView should not be attached again after detached. - eventHandlerJob.cancel() + keyboardPrefs.unregisterOnChangeListener(onKeyboardSizeChangeListener) + // clear DynamicScope, implies that InputView should not be attached again after detached. scope.clear() super.onDetachedFromWindow() } diff --git a/app/src/main/java/org/fcitx/fcitx5/android/input/NavigationBarManager.kt b/app/src/main/java/org/fcitx/fcitx5/android/input/NavigationBarManager.kt new file mode 100644 index 000000000..6361670be --- /dev/null +++ b/app/src/main/java/org/fcitx/fcitx5/android/input/NavigationBarManager.kt @@ -0,0 +1,121 @@ +/* + * SPDX-License-Identifier: LGPL-2.1-or-later + * SPDX-FileCopyrightText: Copyright 2024 Fcitx5 for Android Contributors + */ + +package org.fcitx.fcitx5.android.input + +import android.graphics.Color +import android.os.Build +import android.view.View +import android.view.Window +import androidx.annotation.ColorInt +import androidx.core.view.WindowCompat +import org.fcitx.fcitx5.android.data.prefs.AppPrefs +import org.fcitx.fcitx5.android.data.theme.Theme +import org.fcitx.fcitx5.android.data.theme.ThemeManager +import org.fcitx.fcitx5.android.data.theme.ThemePrefs.NavbarBackground +import org.fcitx.fcitx5.android.utils.DeviceUtil + +class NavigationBarManager { + + private val keyBorder by ThemeManager.prefs.keyBorder + private val navbarBackground by ThemeManager.prefs.navbarBackground + + private var shouldUpdateNavbarForeground = false + private var shouldUpdateNavbarBackground = false + + private fun Window.useSystemNavbarBackground(enabled: Boolean) { + // 35+ enforces edge to edge and we must draw behind navbar + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.VANILLA_ICE_CREAM) { + WindowCompat.setDecorFitsSystemWindows(this, enabled) + } + } + + private fun Window.setNavbarBackgroundColor(@ColorInt color: Int) { + /** + * Why on earth does it deprecated? It says + * https://android.googlesource.com/platform/frameworks/base/+/refs/tags/android-15.0.0_r3/core/java/android/view/Window.java#2720 + * "If the app targets VANILLA_ICE_CREAM or above, the color will be transparent and cannot be changed" + * but it only takes effect on API 35+ devices. Older devices still needs this. + */ + @Suppress("DEPRECATION") + navigationBarColor = color + } + + private fun Window.enforceNavbarContrast(enforced: Boolean) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + isNavigationBarContrastEnforced = enforced + } + } + + fun evaluate(window: Window) { + when (navbarBackground) { + NavbarBackground.None -> { + shouldUpdateNavbarForeground = false + shouldUpdateNavbarBackground = false + window.useSystemNavbarBackground(true) + window.enforceNavbarContrast(true) + } + NavbarBackground.ColorOnly -> { + shouldUpdateNavbarForeground = true + shouldUpdateNavbarBackground = true + window.useSystemNavbarBackground(true) + window.enforceNavbarContrast(false) + } + NavbarBackground.Full -> { + shouldUpdateNavbarForeground = true + shouldUpdateNavbarBackground = false + window.useSystemNavbarBackground(false) + window.setNavbarBackgroundColor(Color.TRANSPARENT) + window.enforceNavbarContrast(false) + // it seems One UI 7.0 (Android 15) does not allow drawing behind navbar + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.VANILLA_ICE_CREAM && DeviceUtil.isSamsungOneUI) { + shouldUpdateNavbarBackground = true + } + } + } + } + + fun evaluate(window: Window, useVirtualKeyboard: Boolean) { + if (useVirtualKeyboard) { + evaluate(window) + } else { + shouldUpdateNavbarForeground = true + shouldUpdateNavbarBackground = true + window.useSystemNavbarBackground(true) + window.enforceNavbarContrast(false) + } + update(window) + } + + fun update(window: Window) { + val theme = ThemeManager.activeTheme + if (shouldUpdateNavbarForeground) { + WindowCompat.getInsetsController(window, window.decorView) + .isAppearanceLightNavigationBars = !theme.isDark + } + if (shouldUpdateNavbarBackground) { + window.setNavbarBackgroundColor( + if (!keyBorder && theme is Theme.Builtin) theme.keyboardColor else theme.backgroundColor + ) + } + } + + private val ignoreSystemWindowInsets by AppPrefs.getInstance().advanced.ignoreSystemWindowInsets + + private val emptyOnApplyWindowInsetsListener = View.OnApplyWindowInsetsListener { _, insets -> + insets + } + + fun setupInputView(v: BaseInputView) { + if (ignoreSystemWindowInsets) { + // suppress the view's own onApplyWindowInsets + v.setOnApplyWindowInsetsListener(emptyOnApplyWindowInsetsListener) + } else { + // on API 35+, we must call requestApplyInsets() manually after replacing views, + // otherwise View#onApplyWindowInsets won't be called. ¯\_(ツ)_/¯ + v.requestApplyInsets() + } + } +} diff --git a/app/src/main/java/org/fcitx/fcitx5/android/input/TouchEventReceiverWindow.kt b/app/src/main/java/org/fcitx/fcitx5/android/input/TouchEventReceiverWindow.kt new file mode 100644 index 000000000..58aa1fe56 --- /dev/null +++ b/app/src/main/java/org/fcitx/fcitx5/android/input/TouchEventReceiverWindow.kt @@ -0,0 +1,57 @@ +/* + * SPDX-License-Identifier: LGPL-2.1-or-later + * SPDX-FileCopyrightText: Copyright 2025 Fcitx5 for Android Contributors + */ + +package org.fcitx.fcitx5.android.input + +import android.annotation.SuppressLint +import android.view.Gravity +import android.view.MotionEvent +import android.view.View +import android.widget.PopupWindow + +class TouchEventReceiverWindow( + private val contentView: View +) { + private val ctx = contentView.context + + private val window = PopupWindow(object : View(ctx) { + @SuppressLint("ClickableViewAccessibility") + override fun onTouchEvent(event: MotionEvent?): Boolean { + return contentView.dispatchTouchEvent(event) + } + }).apply { + // disable animation + animationStyle = 0 + } + + private var isWindowShowing = false + + fun showAt(x: Int, y: Int, w: Int, h: Int) { + isWindowShowing = true + if (window.isShowing) { + window.update(x, y, w, h) + } else { + window.width = w + window.height = h + window.showAtLocation(contentView, Gravity.TOP or Gravity.START, x, y) + } + } + + private val cachedLocation = intArrayOf(0, 0) + + fun show() { + val (x, y) = cachedLocation.also { contentView.getLocationInWindow(it) } + val width = contentView.width + val height = contentView.height + showAt(x, y, width, height) + } + + fun dismiss() { + if (isWindowShowing) { + isWindowShowing = false + window.dismiss() + } + } +} diff --git a/app/src/main/java/org/fcitx/fcitx5/android/input/bar/ExpandButtonStateMachine.kt b/app/src/main/java/org/fcitx/fcitx5/android/input/bar/ExpandButtonStateMachine.kt index 7263f3849..3319ef324 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/input/bar/ExpandButtonStateMachine.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/input/bar/ExpandButtonStateMachine.kt @@ -40,7 +40,12 @@ object ExpandButtonStateMachine { } fun new(block: (State) -> Unit) = - EventStateMachine(Hidden).apply { + EventStateMachine( + initialState = Hidden, + externalBooleanStates = mutableMapOf( + ExpandedCandidatesEmpty to true + ) + ).apply { onNewStateListener = block } } diff --git a/app/src/main/java/org/fcitx/fcitx5/android/input/bar/KawaiiBarComponent.kt b/app/src/main/java/org/fcitx/fcitx5/android/input/bar/KawaiiBarComponent.kt index d8b48c167..04d717186 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/input/bar/KawaiiBarComponent.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/input/bar/KawaiiBarComponent.kt @@ -1,6 +1,6 @@ /* * SPDX-License-Identifier: LGPL-2.1-or-later - * SPDX-FileCopyrightText: Copyright 2021-2023 Fcitx5 for Android Contributors + * SPDX-FileCopyrightText: Copyright 2021-2025 Fcitx5 for Android Contributors */ package org.fcitx.fcitx5.android.input.bar @@ -30,21 +30,27 @@ import org.fcitx.fcitx5.android.core.CapabilityFlag import org.fcitx.fcitx5.android.core.CapabilityFlags import org.fcitx.fcitx5.android.core.FcitxEvent.CandidateListEvent import org.fcitx.fcitx5.android.data.clipboard.ClipboardManager +import org.fcitx.fcitx5.android.data.clipboard.db.ClipboardEntry import org.fcitx.fcitx5.android.data.prefs.AppPrefs import org.fcitx.fcitx5.android.data.prefs.ManagedPreference import org.fcitx.fcitx5.android.data.theme.ThemeManager -import org.fcitx.fcitx5.android.input.bar.ExpandButtonStateMachine.State.* +import org.fcitx.fcitx5.android.input.bar.ExpandButtonStateMachine.State.ClickToAttachWindow +import org.fcitx.fcitx5.android.input.bar.ExpandButtonStateMachine.State.ClickToDetachWindow +import org.fcitx.fcitx5.android.input.bar.ExpandButtonStateMachine.State.Hidden import org.fcitx.fcitx5.android.input.bar.KawaiiBarStateMachine.BooleanKey.CandidateEmpty import org.fcitx.fcitx5.android.input.bar.KawaiiBarStateMachine.BooleanKey.PreeditEmpty -import org.fcitx.fcitx5.android.input.bar.KawaiiBarStateMachine.TransitionEvent.* +import org.fcitx.fcitx5.android.input.bar.KawaiiBarStateMachine.TransitionEvent.CandidatesUpdated +import org.fcitx.fcitx5.android.input.bar.KawaiiBarStateMachine.TransitionEvent.ExtendedWindowAttached +import org.fcitx.fcitx5.android.input.bar.KawaiiBarStateMachine.TransitionEvent.PreeditUpdated +import org.fcitx.fcitx5.android.input.bar.KawaiiBarStateMachine.TransitionEvent.WindowDetached import org.fcitx.fcitx5.android.input.bar.ui.CandidateUi import org.fcitx.fcitx5.android.input.bar.ui.IdleUi import org.fcitx.fcitx5.android.input.bar.ui.TitleUi import org.fcitx.fcitx5.android.input.broadcast.InputBroadcastReceiver -import org.fcitx.fcitx5.android.input.candidates.HorizontalCandidateComponent import org.fcitx.fcitx5.android.input.candidates.expanded.ExpandedCandidateStyle import org.fcitx.fcitx5.android.input.candidates.expanded.window.FlexboxExpandedCandidateWindow import org.fcitx.fcitx5.android.input.candidates.expanded.window.GridExpandedCandidateWindow +import org.fcitx.fcitx5.android.input.candidates.horizontal.HorizontalCandidateComponent import org.fcitx.fcitx5.android.input.clipboard.ClipboardWindow import org.fcitx.fcitx5.android.input.dependency.UniqueViewComponent import org.fcitx.fcitx5.android.input.dependency.context @@ -52,6 +58,7 @@ import org.fcitx.fcitx5.android.input.dependency.inputMethodService import org.fcitx.fcitx5.android.input.dependency.theme import org.fcitx.fcitx5.android.input.editing.TextEditingWindow import org.fcitx.fcitx5.android.input.keyboard.CommonKeyActionListener +import org.fcitx.fcitx5.android.input.keyboard.CustomGestureView import org.fcitx.fcitx5.android.input.keyboard.KeyboardWindow import org.fcitx.fcitx5.android.input.popup.PopupComponent import org.fcitx.fcitx5.android.input.status.StatusAreaWindow @@ -70,6 +77,7 @@ import splitties.views.dsl.core.matchParent import java.util.concurrent.Executor import kotlin.coroutines.resume import kotlin.coroutines.suspendCoroutine +import kotlin.math.min class KawaiiBarComponent : UniqueViewComponent(), InputBroadcastReceiver { @@ -82,12 +90,15 @@ class KawaiiBarComponent : UniqueViewComponent( private val commonKeyActionListener: CommonKeyActionListener by manager.must() private val popup: PopupComponent by manager.must() - private val clipboardSuggestion = AppPrefs.getInstance().clipboard.clipboardSuggestion - private val clipboardItemTimeout = AppPrefs.getInstance().clipboard.clipboardItemTimeout - private val expandedCandidateStyle by AppPrefs.getInstance().keyboard.expandedCandidateStyle - private val expandToolbarByDefault by AppPrefs.getInstance().keyboard.expandToolbarByDefault - private val toolbarNumRowOnPassword by AppPrefs.getInstance().keyboard.toolbarNumRowOnPassword - private val showVoiceInputButton by AppPrefs.getInstance().keyboard.showVoiceInputButton + private val prefs = AppPrefs.getInstance() + + private val clipboardSuggestion = prefs.clipboard.clipboardSuggestion + private val clipboardItemTimeout = prefs.clipboard.clipboardItemTimeout + private val clipboardMaskSensitive by prefs.clipboard.clipboardMaskSensitive + private val expandedCandidateStyle by prefs.keyboard.expandedCandidateStyle + private val expandToolbarByDefault by prefs.keyboard.expandToolbarByDefault + private val toolbarNumRowOnPassword by prefs.keyboard.toolbarNumRowOnPassword + private val showVoiceInputButton by prefs.keyboard.showVoiceInputButton private var clipboardTimeoutJob: Job? = null @@ -105,7 +116,11 @@ class KawaiiBarComponent : UniqueViewComponent( if (it.text.isEmpty()) { isClipboardFresh = false } else { - idleUi.clipboardUi.text.text = it.text.take(42) + idleUi.clipboardUi.text.text = if (it.sensitive && clipboardMaskSensitive) { + ClipboardEntry.BULLET.repeat(min(42, it.text.length)) + } else { + it.text.take(42) + } isClipboardFresh = true launchClipboardTimeoutJob() } @@ -171,6 +186,13 @@ class KawaiiBarComponent : UniqueViewComponent( service.requestHideSelf(0) } + private val swipeDownHideKeyboardCallback = CustomGestureView.OnGestureListener { _, e -> + if (e.type == CustomGestureView.GestureType.Up && e.totalY > 0) { + service.requestHideSelf(0) + true + } else false + } + private var voiceInputSubtype: Pair? = null private val switchToVoiceInputCallback = View.OnClickListener { @@ -200,7 +222,12 @@ class KawaiiBarComponent : UniqueViewComponent( launchClipboardTimeoutJob() } } - hideKeyboardButton.setOnClickListener(hideKeyboardCallback) + hideKeyboardButton.apply { + setOnClickListener(hideKeyboardCallback) + swipeEnabled = true + swipeThresholdY = dp(HEIGHT.toFloat()) + onGestureListener = swipeDownHideKeyboardCallback + } buttonsUi.apply { undoButton.setOnClickListener { service.sendCombinationKeyEvents(KeyEvent.KEYCODE_Z, ctrl = true) @@ -239,7 +266,13 @@ class KawaiiBarComponent : UniqueViewComponent( } private val candidateUi by lazy { - CandidateUi(context, theme, horizontalCandidate.view) + CandidateUi(context, theme, horizontalCandidate.view).apply { + expandButton.apply { + swipeEnabled = true + swipeThresholdY = dp(HEIGHT.toFloat()) + onGestureListener = swipeDownHideKeyboardCallback + } + } } private val titleUi by lazy { @@ -277,6 +310,7 @@ class KawaiiBarComponent : UniqueViewComponent( ) } candidateUi.expandButton.setIcon(R.drawable.ic_baseline_expand_more_24) + candidateUi.expandButton.contentDescription = context.getString(R.string.expand_candidates_list) } // set expand candidate button to close expand candidate @@ -285,6 +319,7 @@ class KawaiiBarComponent : UniqueViewComponent( windowManager.attachWindow(KeyboardWindow) } candidateUi.expandButton.setIcon(R.drawable.ic_baseline_expand_less_24) + candidateUi.expandButton.contentDescription = context.getString(R.string.hide_candidates_list) } // should be used with setExpandButtonToAttach or setExpandButtonToDetach diff --git a/app/src/main/java/org/fcitx/fcitx5/android/input/bar/KawaiiBarStateMachine.kt b/app/src/main/java/org/fcitx/fcitx5/android/input/bar/KawaiiBarStateMachine.kt index fc1db5cbf..2deed8025 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/input/bar/KawaiiBarStateMachine.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/input/bar/KawaiiBarStateMachine.kt @@ -6,7 +6,9 @@ package org.fcitx.fcitx5.android.input.bar import org.fcitx.fcitx5.android.input.bar.KawaiiBarStateMachine.BooleanKey.CandidateEmpty import org.fcitx.fcitx5.android.input.bar.KawaiiBarStateMachine.BooleanKey.PreeditEmpty -import org.fcitx.fcitx5.android.input.bar.KawaiiBarStateMachine.State.* +import org.fcitx.fcitx5.android.input.bar.KawaiiBarStateMachine.State.Candidate +import org.fcitx.fcitx5.android.input.bar.KawaiiBarStateMachine.State.Idle +import org.fcitx.fcitx5.android.input.bar.KawaiiBarStateMachine.State.Title import org.fcitx.fcitx5.android.utils.BuildTransitionEvent import org.fcitx.fcitx5.android.utils.EventStateMachine import org.fcitx.fcitx5.android.utils.TransitionBuildBlock @@ -47,7 +49,11 @@ object KawaiiBarStateMachine { fun new(block: (State) -> Unit) = EventStateMachine( - Idle + initialState = Idle, + externalBooleanStates = mutableMapOf( + PreeditEmpty to true, + CandidateEmpty to true + ) ).apply { onNewStateListener = block } diff --git a/app/src/main/java/org/fcitx/fcitx5/android/input/bar/ui/IdleUi.kt b/app/src/main/java/org/fcitx/fcitx5/android/input/bar/ui/IdleUi.kt index 957bd83f6..c6cc8ab8d 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/input/bar/ui/IdleUi.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/input/bar/ui/IdleUi.kt @@ -1,6 +1,6 @@ /* * SPDX-License-Identifier: LGPL-2.1-or-later - * SPDX-FileCopyrightText: Copyright 2021-2023 Fcitx5 for Android Contributors + * SPDX-FileCopyrightText: Copyright 2021-2025 Fcitx5 for Android Contributors */ package org.fcitx.fcitx5.android.input.bar.ui @@ -130,6 +130,7 @@ class IdleUi( if (activate == inPrivate) return inPrivate = activate updateMenuButtonIcon() + updateMenuButtonContentDescription() updateMenuButtonRotation(instant = true) } @@ -139,6 +140,14 @@ class IdleUi( else R.drawable.ic_baseline_expand_more_24 } + private fun updateMenuButtonContentDescription() { + menuButton.contentDescription = when { + inPrivate -> ctx.getString(R.string.private_mode) + currentState == State.Toolbar -> ctx.getString(R.string.hide_toolbar) + else -> ctx.getString(R.string.expand_toolbar) + } + } + private fun updateMenuButtonRotation(instant: Boolean = false) { val targetRotation = menuButtonRotation menuButton.apply { @@ -153,10 +162,13 @@ class IdleUi( } fun setHideKeyboardIsVoiceInput(isVoiceInput: Boolean, callback: View.OnClickListener) { - hideKeyboardButton.setIcon( - if (isVoiceInput) R.drawable.ic_baseline_keyboard_voice_24 - else R.drawable.ic_baseline_arrow_drop_down_24 - ) + if (isVoiceInput) { + hideKeyboardButton.setIcon(R.drawable.ic_baseline_keyboard_voice_24) + hideKeyboardButton.contentDescription = ctx.getString(R.string.switch_to_voice_input) + } else { + hideKeyboardButton.setIcon(R.drawable.ic_baseline_arrow_drop_down_24) + hideKeyboardButton.contentDescription = ctx.getString(R.string.hide_keyboard) + } hideKeyboardButton.setOnClickListener(callback) } @@ -205,6 +217,7 @@ class IdleUi( popup.dismissAll() } currentState = state + updateMenuButtonContentDescription() updateMenuButtonRotation(instant = !fromUser) } } diff --git a/app/src/main/java/org/fcitx/fcitx5/android/input/bar/ui/TitleUi.kt b/app/src/main/java/org/fcitx/fcitx5/android/input/bar/ui/TitleUi.kt index 903f59cce..9f746dba6 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/input/bar/ui/TitleUi.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/input/bar/ui/TitleUi.kt @@ -1,6 +1,6 @@ /* * SPDX-License-Identifier: LGPL-2.1-or-later - * SPDX-FileCopyrightText: Copyright 2021-2023 Fcitx5 for Android Contributors + * SPDX-FileCopyrightText: Copyright 2021-2025 Fcitx5 for Android Contributors */ package org.fcitx.fcitx5.android.input.bar.ui @@ -29,7 +29,9 @@ import splitties.views.gravityVerticalCenter class TitleUi(override val ctx: Context, theme: Theme) : Ui { - private val backButton = ToolButton(ctx, R.drawable.ic_baseline_arrow_back_24, theme) + private val backButton = ToolButton(ctx, R.drawable.ic_baseline_arrow_back_24, theme).apply { + contentDescription = ctx.getString(R.string.back_to_keyboard) + } private val titleText = textView { typeface = Typeface.defaultFromStyle(Typeface.BOLD) diff --git a/app/src/main/java/org/fcitx/fcitx5/android/input/bar/ui/idle/ButtonsBarUi.kt b/app/src/main/java/org/fcitx/fcitx5/android/input/bar/ui/idle/ButtonsBarUi.kt index 87772f216..3170536fa 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/input/bar/ui/idle/ButtonsBarUi.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/input/bar/ui/idle/ButtonsBarUi.kt @@ -28,14 +28,24 @@ class ButtonsBarUi(override val ctx: Context, private val theme: Theme) : Ui { root.addView(it, FlexboxLayout.LayoutParams(size, size)) } - val undoButton = toolButton(R.drawable.ic_baseline_undo_24) + val undoButton = toolButton(R.drawable.ic_baseline_undo_24).apply { + contentDescription = ctx.getString(R.string.undo) + } - val redoButton = toolButton(R.drawable.ic_baseline_redo_24) + val redoButton = toolButton(R.drawable.ic_baseline_redo_24).apply { + contentDescription = ctx.getString(R.string.redo) + } - val cursorMoveButton = toolButton(R.drawable.ic_cursor_move) + val cursorMoveButton = toolButton(R.drawable.ic_cursor_move).apply { + contentDescription = ctx.getString(R.string.text_editing) + } - val clipboardButton = toolButton(R.drawable.ic_clipboard) + val clipboardButton = toolButton(R.drawable.ic_clipboard).apply { + contentDescription = ctx.getString(R.string.clipboard) + } - val moreButton = toolButton(R.drawable.ic_baseline_more_horiz_24) + val moreButton = toolButton(R.drawable.ic_baseline_more_horiz_24).apply { + contentDescription = ctx.getString(R.string.status_area) + } } diff --git a/app/src/main/java/org/fcitx/fcitx5/android/input/bar/ui/idle/InlineSuggestionsUi.kt b/app/src/main/java/org/fcitx/fcitx5/android/input/bar/ui/idle/InlineSuggestionsUi.kt index c9ee4ef23..afcacc5dd 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/input/bar/ui/idle/InlineSuggestionsUi.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/input/bar/ui/idle/InlineSuggestionsUi.kt @@ -17,8 +17,22 @@ import com.google.android.flexbox.FlexWrap import com.google.android.flexbox.FlexboxLayout import com.google.android.flexbox.JustifyContent import splitties.dimensions.dp -import splitties.views.dsl.constraintlayout.* -import splitties.views.dsl.core.* +import splitties.views.dsl.constraintlayout.before +import splitties.views.dsl.constraintlayout.centerOn +import splitties.views.dsl.constraintlayout.centerVertically +import splitties.views.dsl.constraintlayout.constraintLayout +import splitties.views.dsl.constraintlayout.endOfParent +import splitties.views.dsl.constraintlayout.lParams +import splitties.views.dsl.constraintlayout.matchConstraints +import splitties.views.dsl.constraintlayout.startOfParent +import splitties.views.dsl.core.Ui +import splitties.views.dsl.core.add +import splitties.views.dsl.core.frameLayout +import splitties.views.dsl.core.horizontalMargin +import splitties.views.dsl.core.lParams +import splitties.views.dsl.core.matchParent +import splitties.views.dsl.core.view +import splitties.views.dsl.core.wrapContent class InlineSuggestionsUi(override val ctx: Context) : Ui { diff --git a/app/src/main/java/org/fcitx/fcitx5/android/input/candidates/CandidateItemUi.kt b/app/src/main/java/org/fcitx/fcitx5/android/input/candidates/CandidateItemUi.kt index 3ecd3cc83..e927b15fb 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/input/candidates/CandidateItemUi.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/input/candidates/CandidateItemUi.kt @@ -2,6 +2,7 @@ * SPDX-License-Identifier: LGPL-2.1-or-later * SPDX-FileCopyrightText: Copyright 2021-2023 Fcitx5 for Android Contributors */ + package org.fcitx.fcitx5.android.input.candidates import android.content.Context @@ -24,12 +25,17 @@ class CandidateItemUi(override val ctx: Context, theme: Theme) : Ui { textSize = 20f // sp isSingleLine = true gravity = gravityCenter - setTextColor(theme.keyTextColor) + setTextColor(theme.candidateTextColor) } override val root = view(::CustomGestureView) { background = pressHighlightDrawable(theme.keyPressHighlightColor) + /** + * candidate long press feedback is handled by [org.fcitx.fcitx5.android.input.candidates.horizontal.HorizontalCandidateComponent.showCandidateActionMenu] + */ + longPressFeedbackEnabled = false + add(text, lParams(wrapContent, matchParent) { gravity = gravityCenter }) diff --git a/app/src/main/java/org/fcitx/fcitx5/android/input/candidates/CandidateViewHolder.kt b/app/src/main/java/org/fcitx/fcitx5/android/input/candidates/CandidateViewHolder.kt new file mode 100644 index 000000000..1bde332c6 --- /dev/null +++ b/app/src/main/java/org/fcitx/fcitx5/android/input/candidates/CandidateViewHolder.kt @@ -0,0 +1,13 @@ +/* + * SPDX-License-Identifier: LGPL-2.1-or-later + * SPDX-FileCopyrightText: Copyright 2024 Fcitx5 for Android Contributors + */ + +package org.fcitx.fcitx5.android.input.candidates + +import androidx.recyclerview.widget.RecyclerView + +class CandidateViewHolder(val ui: CandidateItemUi) : RecyclerView.ViewHolder(ui.root) { + var idx = -1 + var text = "" +} diff --git a/app/src/main/java/org/fcitx/fcitx5/android/input/candidates/HorizontalCandidateMode.kt b/app/src/main/java/org/fcitx/fcitx5/android/input/candidates/HorizontalCandidateMode.kt deleted file mode 100644 index cd17a3308..000000000 --- a/app/src/main/java/org/fcitx/fcitx5/android/input/candidates/HorizontalCandidateMode.kt +++ /dev/null @@ -1,17 +0,0 @@ -/* - * SPDX-License-Identifier: LGPL-2.1-or-later - * SPDX-FileCopyrightText: Copyright 2021-2023 Fcitx5 for Android Contributors - */ -package org.fcitx.fcitx5.android.input.candidates - -import org.fcitx.fcitx5.android.data.prefs.ManagedPreference - -enum class HorizontalCandidateMode { - NeverFillWidth, - AutoFillWidth, - AlwaysFillWidth; - - companion object : ManagedPreference.StringLikeCodec { - override fun decode(raw: String): HorizontalCandidateMode = valueOf(raw) - } -} \ No newline at end of file diff --git a/app/src/main/java/org/fcitx/fcitx5/android/input/candidates/expanded/CandidatesPagingSource.kt b/app/src/main/java/org/fcitx/fcitx5/android/input/candidates/expanded/CandidatesPagingSource.kt index 87610a583..2b5f7c05c 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/input/candidates/expanded/CandidatesPagingSource.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/input/candidates/expanded/CandidatesPagingSource.kt @@ -1,6 +1,6 @@ /* * SPDX-License-Identifier: LGPL-2.1-or-later - * SPDX-FileCopyrightText: Copyright 2021-2023 Fcitx5 for Android Contributors + * SPDX-FileCopyrightText: Copyright 2021-2024 Fcitx5 for Android Contributors */ package org.fcitx.fcitx5.android.input.candidates.expanded @@ -13,7 +13,7 @@ class CandidatesPagingSource(val fcitx: FcitxConnection, val total: Int, val off PagingSource() { override suspend fun load(params: LoadParams): LoadResult { - // use candidate index for key, null means load from beginning (including offset) + // use candidate index for key, null means load from beginning (with offset) val startIndex = params.key ?: offset val pageSize = params.loadSize Timber.d("getCandidates(offset=$startIndex, limit=$pageSize)") diff --git a/app/src/main/java/org/fcitx/fcitx5/android/input/candidates/expanded/ExpandedCandidateLayout.kt b/app/src/main/java/org/fcitx/fcitx5/android/input/candidates/expanded/ExpandedCandidateLayout.kt index cec2d18f5..a4151e09f 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/input/candidates/expanded/ExpandedCandidateLayout.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/input/candidates/expanded/ExpandedCandidateLayout.kt @@ -1,6 +1,6 @@ /* * SPDX-License-Identifier: LGPL-2.1-or-later - * SPDX-FileCopyrightText: Copyright 2021-2023 Fcitx5 for Android Contributors + * SPDX-FileCopyrightText: Copyright 2021-2024 Fcitx5 for Android Contributors */ package org.fcitx.fcitx5.android.input.candidates.expanded @@ -10,9 +10,20 @@ import androidx.constraintlayout.widget.ConstraintLayout import org.fcitx.fcitx5.android.R import org.fcitx.fcitx5.android.data.theme.Theme import org.fcitx.fcitx5.android.data.theme.ThemeManager -import org.fcitx.fcitx5.android.input.keyboard.* +import org.fcitx.fcitx5.android.input.keyboard.BackspaceKey +import org.fcitx.fcitx5.android.input.keyboard.BaseKeyboard +import org.fcitx.fcitx5.android.input.keyboard.ImageKeyView +import org.fcitx.fcitx5.android.input.keyboard.ImageLayoutSwitchKey +import org.fcitx.fcitx5.android.input.keyboard.KeyDef +import org.fcitx.fcitx5.android.input.keyboard.ReturnKey import splitties.views.backgroundColor -import splitties.views.dsl.constraintlayout.* +import splitties.views.dsl.constraintlayout.bottomOfParent +import splitties.views.dsl.constraintlayout.lParams +import splitties.views.dsl.constraintlayout.leftOfParent +import splitties.views.dsl.constraintlayout.leftToRightOf +import splitties.views.dsl.constraintlayout.rightOfParent +import splitties.views.dsl.constraintlayout.rightToLeftOf +import splitties.views.dsl.constraintlayout.topOfParent import splitties.views.dsl.core.add import splitties.views.dsl.recyclerview.recyclerView import splitties.views.imageResource diff --git a/app/src/main/java/org/fcitx/fcitx5/android/input/candidates/expanded/ExpandedCandidateStyle.kt b/app/src/main/java/org/fcitx/fcitx5/android/input/candidates/expanded/ExpandedCandidateStyle.kt index cb125c2f2..65e6cd2e5 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/input/candidates/expanded/ExpandedCandidateStyle.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/input/candidates/expanded/ExpandedCandidateStyle.kt @@ -4,13 +4,10 @@ */ package org.fcitx.fcitx5.android.input.candidates.expanded -import org.fcitx.fcitx5.android.data.prefs.ManagedPreference +import org.fcitx.fcitx5.android.R +import org.fcitx.fcitx5.android.data.prefs.ManagedPreferenceEnum -enum class ExpandedCandidateStyle { - Grid, - Flexbox; - - companion object : ManagedPreference.StringLikeCodec { - override fun decode(raw: String): ExpandedCandidateStyle = valueOf(raw) - } -} \ No newline at end of file +enum class ExpandedCandidateStyle(override val stringRes: Int) : ManagedPreferenceEnum { + Grid(R.string.expanded_candidate_style_grid), + Flexbox(R.string.expanded_candidate_style_flexbox); +} diff --git a/app/src/main/java/org/fcitx/fcitx5/android/input/candidates/adapter/GridPagingCandidateViewAdapter.kt b/app/src/main/java/org/fcitx/fcitx5/android/input/candidates/expanded/GridPagingCandidateViewAdapter.kt similarity index 53% rename from app/src/main/java/org/fcitx/fcitx5/android/input/candidates/adapter/GridPagingCandidateViewAdapter.kt rename to app/src/main/java/org/fcitx/fcitx5/android/input/candidates/expanded/GridPagingCandidateViewAdapter.kt index 6472c2bdb..f60ec40f2 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/input/candidates/adapter/GridPagingCandidateViewAdapter.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/input/candidates/expanded/GridPagingCandidateViewAdapter.kt @@ -1,8 +1,9 @@ /* * SPDX-License-Identifier: LGPL-2.1-or-later - * SPDX-FileCopyrightText: Copyright 2021-2023 Fcitx5 for Android Contributors + * SPDX-FileCopyrightText: Copyright 2021-2024 Fcitx5 for Android Contributors */ -package org.fcitx.fcitx5.android.input.candidates.adapter + +package org.fcitx.fcitx5.android.input.candidates.expanded import android.graphics.Paint import android.graphics.Rect @@ -10,27 +11,33 @@ import android.util.LruCache import android.view.ViewGroup import androidx.recyclerview.widget.GridLayoutManager import org.fcitx.fcitx5.android.data.theme.Theme +import org.fcitx.fcitx5.android.input.candidates.CandidateViewHolder import splitties.dimensions.dp import splitties.views.dsl.core.matchParent abstract class GridPagingCandidateViewAdapter(theme: Theme) : PagingCandidateViewAdapter(theme) { + companion object { + // 20f here is chosen randomly, since we only care about the ratio + private const val TEXT_SIZE = 20f + } + // cache measureWidth - private val measuredWidths = LruCache(200) + private val measuredWidths = object : LruCache(200) { + private val cachedPaint = Paint().apply { textSize = TEXT_SIZE } + private val cachedRect = Rect() + override fun create(key: String): Float { + cachedPaint.getTextBounds(key, 0, key.length, cachedRect) + return cachedRect.width() / TEXT_SIZE + } + } fun measureWidth(position: Int): Float { val candidate = getItem(position) ?: return 0f - return measuredWidths[candidate] ?: run { - val paint = Paint() - val bounds = Rect() - // 20f here is chosen randomly, since we only care about the ratio - paint.textSize = 20f - paint.getTextBounds(candidate, 0, candidate.length, bounds) - (bounds.width() / 20f).also { measuredWidths.put(candidate, it) } - } + return measuredWidths[candidate] } - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): CandidateViewHolder { return super.onCreateViewHolder(parent, viewType).apply { itemView.apply { layoutParams = GridLayoutManager.LayoutParams(matchParent, dp(40)) diff --git a/app/src/main/java/org/fcitx/fcitx5/android/input/candidates/adapter/PagingCandidateViewAdapter.kt b/app/src/main/java/org/fcitx/fcitx5/android/input/candidates/expanded/PagingCandidateViewAdapter.kt similarity index 64% rename from app/src/main/java/org/fcitx/fcitx5/android/input/candidates/adapter/PagingCandidateViewAdapter.kt rename to app/src/main/java/org/fcitx/fcitx5/android/input/candidates/expanded/PagingCandidateViewAdapter.kt index e3615e878..c7d4d556d 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/input/candidates/adapter/PagingCandidateViewAdapter.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/input/candidates/expanded/PagingCandidateViewAdapter.kt @@ -1,18 +1,19 @@ /* * SPDX-License-Identifier: LGPL-2.1-or-later - * SPDX-FileCopyrightText: Copyright 2021-2023 Fcitx5 for Android Contributors + * SPDX-FileCopyrightText: Copyright 2021-2024 Fcitx5 for Android Contributors */ -package org.fcitx.fcitx5.android.input.candidates.adapter + +package org.fcitx.fcitx5.android.input.candidates.expanded import android.view.ViewGroup import androidx.paging.PagingDataAdapter import androidx.recyclerview.widget.DiffUtil -import androidx.recyclerview.widget.RecyclerView import org.fcitx.fcitx5.android.data.theme.Theme import org.fcitx.fcitx5.android.input.candidates.CandidateItemUi +import org.fcitx.fcitx5.android.input.candidates.CandidateViewHolder open class PagingCandidateViewAdapter(val theme: Theme) : - PagingDataAdapter(diffCallback) { + PagingDataAdapter(diffCallback) { companion object { private val diffCallback = object : DiffUtil.ItemCallback() { @@ -26,10 +27,6 @@ open class PagingCandidateViewAdapter(val theme: Theme) : } } - class ViewHolder(val ui: CandidateItemUi) : RecyclerView.ViewHolder(ui.root) { - var idx = -1 - } - var offset = 0 private set @@ -38,12 +35,14 @@ open class PagingCandidateViewAdapter(val theme: Theme) : refresh() } - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { - return ViewHolder(CandidateItemUi(parent.context, theme)) + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): CandidateViewHolder { + return CandidateViewHolder(CandidateItemUi(parent.context, theme)) } - override fun onBindViewHolder(holder: ViewHolder, position: Int) { - holder.ui.text.text = getItem(position)!! + override fun onBindViewHolder(holder: CandidateViewHolder, position: Int) { + val text = getItem(position)!! + holder.ui.text.text = text + holder.text = text holder.idx = position + offset } } diff --git a/app/src/main/java/org/fcitx/fcitx5/android/input/candidates/expanded/SpanHelper.kt b/app/src/main/java/org/fcitx/fcitx5/android/input/candidates/expanded/SpanHelper.kt index 2997b4acb..c739139ea 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/input/candidates/expanded/SpanHelper.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/input/candidates/expanded/SpanHelper.kt @@ -2,10 +2,10 @@ * SPDX-License-Identifier: LGPL-2.1-or-later * SPDX-FileCopyrightText: Copyright 2021-2023 Fcitx5 for Android Contributors */ + package org.fcitx.fcitx5.android.input.candidates.expanded import androidx.recyclerview.widget.GridLayoutManager -import org.fcitx.fcitx5.android.input.candidates.adapter.GridPagingCandidateViewAdapter import kotlin.math.ceil import kotlin.math.max import kotlin.math.min diff --git a/app/src/main/java/org/fcitx/fcitx5/android/input/candidates/expanded/window/BaseExpandedCandidateWindow.kt b/app/src/main/java/org/fcitx/fcitx5/android/input/candidates/expanded/window/BaseExpandedCandidateWindow.kt index 14bb5de97..8b87ab58c 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/input/candidates/expanded/window/BaseExpandedCandidateWindow.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/input/candidates/expanded/window/BaseExpandedCandidateWindow.kt @@ -1,21 +1,21 @@ /* * SPDX-License-Identifier: LGPL-2.1-or-later - * SPDX-FileCopyrightText: Copyright 2021-2023 Fcitx5 for Android Contributors + * SPDX-FileCopyrightText: Copyright 2021-2024 Fcitx5 for Android Contributors */ + package org.fcitx.fcitx5.android.input.candidates.expanded.window import android.graphics.drawable.ShapeDrawable import android.graphics.drawable.shapes.RectShape import android.view.View -import androidx.lifecycle.LifecycleCoroutineScope -import androidx.lifecycle.findViewTreeLifecycleOwner import androidx.lifecycle.lifecycleScope import androidx.paging.Pager import androidx.paging.PagingConfig import androidx.recyclerview.widget.RecyclerView -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch +import org.fcitx.fcitx5.android.daemon.launchOnReady import org.fcitx.fcitx5.android.data.prefs.AppPrefs import org.fcitx.fcitx5.android.input.bar.ExpandButtonStateMachine.BooleanKey.ExpandedCandidatesEmpty import org.fcitx.fcitx5.android.input.bar.ExpandButtonStateMachine.TransitionEvent.ExpandedCandidatesAttached @@ -23,10 +23,11 @@ import org.fcitx.fcitx5.android.input.bar.ExpandButtonStateMachine.TransitionEve import org.fcitx.fcitx5.android.input.bar.KawaiiBarComponent import org.fcitx.fcitx5.android.input.broadcast.InputBroadcastReceiver import org.fcitx.fcitx5.android.input.broadcast.ReturnKeyDrawableComponent -import org.fcitx.fcitx5.android.input.candidates.HorizontalCandidateComponent -import org.fcitx.fcitx5.android.input.candidates.adapter.PagingCandidateViewAdapter +import org.fcitx.fcitx5.android.input.candidates.CandidateViewHolder import org.fcitx.fcitx5.android.input.candidates.expanded.CandidatesPagingSource import org.fcitx.fcitx5.android.input.candidates.expanded.ExpandedCandidateLayout +import org.fcitx.fcitx5.android.input.candidates.expanded.PagingCandidateViewAdapter +import org.fcitx.fcitx5.android.input.candidates.horizontal.HorizontalCandidateComponent import org.fcitx.fcitx5.android.input.dependency.fcitx import org.fcitx.fcitx5.android.input.dependency.inputMethodService import org.fcitx.fcitx5.android.input.dependency.theme @@ -54,7 +55,6 @@ abstract class BaseExpandedCandidateWindow> : protected val disableAnimation by AppPrefs.getInstance().advanced.disableAnimation - private lateinit var lifecycleCoroutineScope: LifecycleCoroutineScope private lateinit var candidateLayout: ExpandedCandidateLayout protected val dividerDrawable by lazy { @@ -95,13 +95,19 @@ abstract class BaseExpandedCandidateWindow> : private var offsetJob: Job? = null private val candidatesPager by lazy { - Pager(PagingConfig(pageSize = 48)) { - CandidatesPagingSource( - fcitx, - total = horizontalCandidate.adapter.total, - offset = adapter.offset - ) - } + Pager( + config = PagingConfig( + pageSize = 48, + enablePlaceholders = false + ), + pagingSourceFactory = { + CandidatesPagingSource( + fcitx, + total = horizontalCandidate.adapter.total, + offset = adapter.offset + ) + } + ) } private var candidatesSubmitJob: Job? = null @@ -110,33 +116,35 @@ abstract class BaseExpandedCandidateWindow> : abstract fun nextPage() override fun onAttached() { - lifecycleCoroutineScope = candidateLayout.findViewTreeLifecycleOwner()!!.lifecycleScope bar.expandButtonStateMachine.push(ExpandedCandidatesAttached) candidateLayout.embeddedKeyboard.also { it.onReturnDrawableUpdate(returnKeyDrawable.resourceId) it.keyActionListener = keyActionListener } - offsetJob = lifecycleCoroutineScope.launch { + offsetJob = service.lifecycleScope.launch { horizontalCandidate.expandedCandidateOffset.collect { - updateCandidatesWithOffset(it) + if (it <= 0) { + windowManager.attachWindow(KeyboardWindow) + } else { + candidateLayout.resetPosition() + adapter.refreshWithOffset(it) + } } } - candidatesSubmitJob = lifecycleCoroutineScope.launch { - candidatesPager.flow.collect { + candidatesSubmitJob = service.lifecycleScope.launch { + candidatesPager.flow.collectLatest { adapter.submitData(it) } } } - private fun updateCandidatesWithOffset(offset: Int) { - val candidates = horizontalCandidate.adapter.candidates - if (candidates.isEmpty()) { - windowManager.attachWindow(KeyboardWindow) - } else { - adapter.refreshWithOffset(offset) - lifecycleCoroutineScope.launch(Dispatchers.Main) { - candidateLayout.resetPosition() - } + fun bindCandidateUiViewHolder(holder: CandidateViewHolder) { + holder.itemView.setOnClickListener { + fcitx.launchOnReady { it.select(holder.idx) } + } + holder.itemView.setOnLongClickListener { + horizontalCandidate.showCandidateActionMenu(holder.idx, holder.text, holder.ui) + true } } diff --git a/app/src/main/java/org/fcitx/fcitx5/android/input/candidates/expanded/window/FlexboxExpandedCandidateWindow.kt b/app/src/main/java/org/fcitx/fcitx5/android/input/candidates/expanded/window/FlexboxExpandedCandidateWindow.kt index c74157b56..b63d4b0cb 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/input/candidates/expanded/window/FlexboxExpandedCandidateWindow.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/input/candidates/expanded/window/FlexboxExpandedCandidateWindow.kt @@ -1,7 +1,8 @@ /* * SPDX-License-Identifier: LGPL-2.1-or-later - * SPDX-FileCopyrightText: Copyright 2021-2023 Fcitx5 for Android Contributors + * SPDX-FileCopyrightText: Copyright 2021-2024 Fcitx5 for Android Contributors */ + package org.fcitx.fcitx5.android.input.candidates.expanded.window import android.util.DisplayMetrics @@ -11,9 +12,9 @@ import androidx.recyclerview.widget.RecyclerView import com.google.android.flexbox.AlignItems import com.google.android.flexbox.FlexboxLayoutManager import com.google.android.flexbox.JustifyContent -import org.fcitx.fcitx5.android.daemon.launchOnReady -import org.fcitx.fcitx5.android.input.candidates.adapter.PagingCandidateViewAdapter +import org.fcitx.fcitx5.android.input.candidates.CandidateViewHolder import org.fcitx.fcitx5.android.input.candidates.expanded.ExpandedCandidateLayout +import org.fcitx.fcitx5.android.input.candidates.expanded.PagingCandidateViewAdapter import org.fcitx.fcitx5.android.input.candidates.expanded.decoration.FlexboxHorizontalDecoration import splitties.dimensions.dp import splitties.views.dsl.core.wrapContent @@ -24,7 +25,7 @@ class FlexboxExpandedCandidateWindow : override val adapter by lazy { object : PagingCandidateViewAdapter(theme) { - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): CandidateViewHolder { return super.onCreateViewHolder(parent, viewType).apply { itemView.apply { minimumWidth = dp(40) @@ -35,11 +36,9 @@ class FlexboxExpandedCandidateWindow : } } - override fun onBindViewHolder(holder: ViewHolder, position: Int) { + override fun onBindViewHolder(holder: CandidateViewHolder, position: Int) { super.onBindViewHolder(holder, position) - holder.itemView.setOnClickListener { - fcitx.launchOnReady { it.select(holder.idx) } - } + bindCandidateUiViewHolder(holder) } } } @@ -60,9 +59,9 @@ class FlexboxExpandedCandidateWindow : addOnScrollListener(object : RecyclerView.OnScrollListener() { override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) { this@FlexboxExpandedCandidateWindow.layoutManager.apply { - pageUpBtn.isEnabled = findFirstCompletelyVisibleItemPosition() != 0 + pageUpBtn.isEnabled = findFirstCompletelyVisibleItemPosition() > 0 pageDnBtn.isEnabled = - findLastCompletelyVisibleItemPosition() != itemCount - 1 + findLastCompletelyVisibleItemPosition() < itemCount - 1 } } }) diff --git a/app/src/main/java/org/fcitx/fcitx5/android/input/candidates/expanded/window/GridExpandedCandidateWindow.kt b/app/src/main/java/org/fcitx/fcitx5/android/input/candidates/expanded/window/GridExpandedCandidateWindow.kt index 6f29667d7..dc365a5bf 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/input/candidates/expanded/window/GridExpandedCandidateWindow.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/input/candidates/expanded/window/GridExpandedCandidateWindow.kt @@ -1,7 +1,8 @@ /* * SPDX-License-Identifier: LGPL-2.1-or-later - * SPDX-FileCopyrightText: Copyright 2021-2023 Fcitx5 for Android Contributors + * SPDX-FileCopyrightText: Copyright 2021-2024 Fcitx5 for Android Contributors */ + package org.fcitx.fcitx5.android.input.candidates.expanded.window import android.content.res.Configuration @@ -9,10 +10,10 @@ import android.util.DisplayMetrics import androidx.recyclerview.widget.GridLayoutManager import androidx.recyclerview.widget.LinearSmoothScroller import androidx.recyclerview.widget.RecyclerView -import org.fcitx.fcitx5.android.daemon.launchOnReady import org.fcitx.fcitx5.android.data.prefs.AppPrefs -import org.fcitx.fcitx5.android.input.candidates.adapter.GridPagingCandidateViewAdapter +import org.fcitx.fcitx5.android.input.candidates.CandidateViewHolder import org.fcitx.fcitx5.android.input.candidates.expanded.ExpandedCandidateLayout +import org.fcitx.fcitx5.android.input.candidates.expanded.GridPagingCandidateViewAdapter import org.fcitx.fcitx5.android.input.candidates.expanded.SpanHelper import org.fcitx.fcitx5.android.input.candidates.expanded.decoration.GridDecoration @@ -30,11 +31,9 @@ class GridExpandedCandidateWindow : override val adapter by lazy { object : GridPagingCandidateViewAdapter(theme) { - override fun onBindViewHolder(holder: ViewHolder, position: Int) { + override fun onBindViewHolder(holder: CandidateViewHolder, position: Int) { super.onBindViewHolder(holder, position) - holder.itemView.setOnClickListener { - fcitx.launchOnReady { it.select(holder.idx) } - } + bindCandidateUiViewHolder(holder) } } } @@ -54,9 +53,9 @@ class GridExpandedCandidateWindow : addOnScrollListener(object : RecyclerView.OnScrollListener() { override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) { (recyclerView.layoutManager as GridLayoutManager).apply { - pageUpBtn.isEnabled = findFirstCompletelyVisibleItemPosition() != 0 + pageUpBtn.isEnabled = findFirstCompletelyVisibleItemPosition() > 0 pageDnBtn.isEnabled = - findLastCompletelyVisibleItemPosition() != itemCount - 1 + findLastCompletelyVisibleItemPosition() < itemCount - 1 } } }) diff --git a/app/src/main/java/org/fcitx/fcitx5/android/input/candidates/floating/FloatingCandidatesMode.kt b/app/src/main/java/org/fcitx/fcitx5/android/input/candidates/floating/FloatingCandidatesMode.kt new file mode 100644 index 000000000..aeae453e5 --- /dev/null +++ b/app/src/main/java/org/fcitx/fcitx5/android/input/candidates/floating/FloatingCandidatesMode.kt @@ -0,0 +1,15 @@ +/* + * SPDX-License-Identifier: LGPL-2.1-or-later + * SPDX-FileCopyrightText: Copyright 2024 Fcitx5 for Android Contributors + */ + +package org.fcitx.fcitx5.android.input.candidates.floating + +import org.fcitx.fcitx5.android.R +import org.fcitx.fcitx5.android.data.prefs.ManagedPreferenceEnum + +enum class FloatingCandidatesMode(override val stringRes: Int) : ManagedPreferenceEnum { + SystemDefault(R.string.system_default), + InputDevice(R.string.follow_input_device), + Disabled(R.string.disabled) +} diff --git a/app/src/main/java/org/fcitx/fcitx5/android/input/candidates/floating/FloatingCandidatesOrientation.kt b/app/src/main/java/org/fcitx/fcitx5/android/input/candidates/floating/FloatingCandidatesOrientation.kt new file mode 100644 index 000000000..0107b999d --- /dev/null +++ b/app/src/main/java/org/fcitx/fcitx5/android/input/candidates/floating/FloatingCandidatesOrientation.kt @@ -0,0 +1,15 @@ +/* + * SPDX-License-Identifier: LGPL-2.1-or-later + * SPDX-FileCopyrightText: Copyright 2024 Fcitx5 for Android Contributors + */ + +package org.fcitx.fcitx5.android.input.candidates.floating + +import org.fcitx.fcitx5.android.R +import org.fcitx.fcitx5.android.data.prefs.ManagedPreferenceEnum + +enum class FloatingCandidatesOrientation(override val stringRes: Int): ManagedPreferenceEnum { + Automatic(R.string.automatic), + Horizontal(R.string.horizontal), + Vertical(R.string.vertical) +} diff --git a/app/src/main/java/org/fcitx/fcitx5/android/input/candidates/floating/LabeledCandidateItemUi.kt b/app/src/main/java/org/fcitx/fcitx5/android/input/candidates/floating/LabeledCandidateItemUi.kt new file mode 100644 index 000000000..613473e0f --- /dev/null +++ b/app/src/main/java/org/fcitx/fcitx5/android/input/candidates/floating/LabeledCandidateItemUi.kt @@ -0,0 +1,44 @@ +/* + * SPDX-License-Identifier: LGPL-2.1-or-later + * SPDX-FileCopyrightText: Copyright 2024 Fcitx5 for Android Contributors + */ + +package org.fcitx.fcitx5.android.input.candidates.floating + +import android.content.Context +import android.graphics.Color +import android.widget.TextView +import androidx.core.text.buildSpannedString +import androidx.core.text.color +import org.fcitx.fcitx5.android.core.FcitxEvent +import org.fcitx.fcitx5.android.data.theme.Theme +import splitties.views.backgroundColor +import splitties.views.dsl.core.Ui +import splitties.views.dsl.core.textView + +class LabeledCandidateItemUi( + override val ctx: Context, + val theme: Theme, + setupTextView: TextView.() -> Unit +) : Ui { + + override val root = textView { + setupTextView(this) + } + + fun update(candidate: FcitxEvent.Candidate, active: Boolean) { + val labelFg = if (active) theme.genericActiveForegroundColor else theme.candidateLabelColor + val fg = if (active) theme.genericActiveForegroundColor else theme.candidateTextColor + val altFg = if (active) theme.genericActiveForegroundColor else theme.candidateCommentColor + root.text = buildSpannedString { + color(labelFg) { append(candidate.label) } + color(fg) { append(candidate.text) } + if (candidate.comment.isNotBlank()) { + append(" ") + color(altFg) { append(candidate.comment) } + } + } + val bg = if (active) theme.genericActiveBackgroundColor else Color.TRANSPARENT + root.backgroundColor = bg + } +} diff --git a/app/src/main/java/org/fcitx/fcitx5/android/input/candidates/floating/PagedCandidatesUi.kt b/app/src/main/java/org/fcitx/fcitx5/android/input/candidates/floating/PagedCandidatesUi.kt new file mode 100644 index 000000000..f0684d18b --- /dev/null +++ b/app/src/main/java/org/fcitx/fcitx5/android/input/candidates/floating/PagedCandidatesUi.kt @@ -0,0 +1,124 @@ +/* + * SPDX-License-Identifier: LGPL-2.1-or-later + * SPDX-FileCopyrightText: Copyright 2024 Fcitx5 for Android Contributors + */ + +package org.fcitx.fcitx5.android.input.candidates.floating + +import android.annotation.SuppressLint +import android.content.Context +import android.view.View +import android.view.ViewGroup +import android.view.ViewGroup.LayoutParams.MATCH_PARENT +import android.view.ViewGroup.LayoutParams.WRAP_CONTENT +import android.widget.TextView +import androidx.core.view.updateLayoutParams +import androidx.recyclerview.widget.RecyclerView +import com.google.android.flexbox.AlignItems +import com.google.android.flexbox.FlexDirection +import com.google.android.flexbox.FlexWrap +import com.google.android.flexbox.FlexboxLayoutManager +import org.fcitx.fcitx5.android.core.FcitxEvent +import org.fcitx.fcitx5.android.core.FcitxEvent.PagedCandidateEvent.LayoutHint +import org.fcitx.fcitx5.android.data.theme.Theme +import splitties.views.dsl.core.Ui +import splitties.views.dsl.recyclerview.recyclerView + +class PagedCandidatesUi( + override val ctx: Context, + val theme: Theme, + private val setupTextView: TextView.() -> Unit, + private val onCandidateClick: (Int) -> Unit, + private val onPrevPage: () -> Unit, + private val onNextPage: () -> Unit +) : Ui { + + private var data = FcitxEvent.PagedCandidateEvent.Data.Empty + + private var isVertical = false + + sealed class UiHolder(open val ui: Ui) : RecyclerView.ViewHolder(ui.root) { + class Candidate(override val ui: LabeledCandidateItemUi) : UiHolder(ui) + class Pagination(override val ui: PaginationUi) : UiHolder(ui) + } + + private val candidatesAdapter = object : RecyclerView.Adapter() { + override fun getItemCount() = + data.candidates.size + (if (data.hasPrev || data.hasNext) 1 else 0) + + override fun getItemViewType(position: Int) = if (position < data.candidates.size) 0 else 1 + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): UiHolder { + return when (viewType) { + 0 -> UiHolder.Candidate(LabeledCandidateItemUi(ctx, theme, setupTextView)) + else -> UiHolder.Pagination(PaginationUi(ctx, theme)).apply { + ui.prevIcon.setOnClickListener { + onPrevPage.invoke() + } + ui.nextIcon.setOnClickListener { + onNextPage.invoke() + } + } + }.apply { + // assign default LayoutParams, otherwise updateLayoutParams won't work + ui.root.layoutParams = FlexboxLayoutManager.LayoutParams(WRAP_CONTENT, WRAP_CONTENT) + } + } + + override fun onBindViewHolder(holder: UiHolder, position: Int) { + when (holder) { + is UiHolder.Candidate -> { + val candidate = data.candidates[position] + holder.ui.update(candidate, active = position == data.cursorIndex) + holder.ui.root.setOnClickListener { + onCandidateClick.invoke(position) + } + holder.ui.root.updateLayoutParams { + width = if (isVertical) MATCH_PARENT else WRAP_CONTENT + } + } + is UiHolder.Pagination -> { + holder.ui.update(data) + holder.ui.root.updateLayoutParams { + flexGrow = 1f + width = if (isVertical) MATCH_PARENT else WRAP_CONTENT + alignSelf = if (isVertical) AlignItems.STRETCH else AlignItems.CENTER + } + } + } + } + } + + private val candidatesLayoutManager = FlexboxLayoutManager(ctx).apply { + flexWrap = FlexWrap.WRAP + } + + override val root = recyclerView { + isFocusable = false + adapter = candidatesAdapter + layoutManager = candidatesLayoutManager + overScrollMode = View.OVER_SCROLL_NEVER + } + + @SuppressLint("NotifyDataSetChanged") + fun update( + data: FcitxEvent.PagedCandidateEvent.Data, + orientation: FloatingCandidatesOrientation + ) { + this.data = data + this.isVertical = when (orientation) { + FloatingCandidatesOrientation.Automatic -> data.layoutHint == LayoutHint.Vertical + else -> orientation == FloatingCandidatesOrientation.Vertical + } + candidatesLayoutManager.apply { + if (isVertical) { + flexDirection = FlexDirection.COLUMN + alignItems = AlignItems.STRETCH + } else { + flexDirection = FlexDirection.ROW + alignItems = AlignItems.BASELINE + } + } + candidatesAdapter.notifyDataSetChanged() + } +} diff --git a/app/src/main/java/org/fcitx/fcitx5/android/input/candidates/floating/PaginationUi.kt b/app/src/main/java/org/fcitx/fcitx5/android/input/candidates/floating/PaginationUi.kt new file mode 100644 index 000000000..44bc9b7ce --- /dev/null +++ b/app/src/main/java/org/fcitx/fcitx5/android/input/candidates/floating/PaginationUi.kt @@ -0,0 +1,59 @@ +/* + * SPDX-License-Identifier: LGPL-2.1-or-later + * SPDX-FileCopyrightText: Copyright 2024 Fcitx5 for Android Contributors + */ + +package org.fcitx.fcitx5.android.input.candidates.floating + +import android.content.Context +import android.content.res.ColorStateList +import android.widget.ImageView +import androidx.annotation.DrawableRes +import org.fcitx.fcitx5.android.R +import org.fcitx.fcitx5.android.core.FcitxEvent +import org.fcitx.fcitx5.android.data.theme.Theme +import org.fcitx.fcitx5.android.utils.styledFloat +import splitties.dimensions.dp +import splitties.resources.drawable +import splitties.views.dsl.constraintlayout.before +import splitties.views.dsl.constraintlayout.centerVertically +import splitties.views.dsl.constraintlayout.constraintLayout +import splitties.views.dsl.constraintlayout.endOfParent +import splitties.views.dsl.constraintlayout.lParams +import splitties.views.dsl.core.Ui +import splitties.views.dsl.core.add +import splitties.views.dsl.core.imageView +import splitties.views.imageDrawable + +class PaginationUi(override val ctx: Context, val theme: Theme) : Ui { + + private fun createIcon(@DrawableRes icon: Int) = imageView { + imageTintList = ColorStateList.valueOf(theme.keyTextColor) + imageDrawable = drawable(icon) + scaleType = ImageView.ScaleType.CENTER_CROP + isClickable = true + } + + val prevIcon = createIcon(R.drawable.ic_baseline_arrow_prev_24) + val nextIcon = createIcon(R.drawable.ic_baseline_arrow_next_24) + + private val disabledAlpha = styledFloat(android.R.attr.disabledAlpha) + + override val root = constraintLayout { + val w = dp(10) + val h = dp(20) + add(nextIcon, lParams(w, h) { + centerVertically() + endOfParent() + }) + add(prevIcon, lParams(w, h) { + centerVertically() + before(nextIcon) + }) + } + + fun update(data: FcitxEvent.PagedCandidateEvent.Data) { + prevIcon.alpha = if (data.hasPrev) 1f else disabledAlpha + nextIcon.alpha = if (data.hasNext) 1f else disabledAlpha + } +} diff --git a/app/src/main/java/org/fcitx/fcitx5/android/input/candidates/HorizontalCandidateComponent.kt b/app/src/main/java/org/fcitx/fcitx5/android/input/candidates/horizontal/HorizontalCandidateComponent.kt similarity index 70% rename from app/src/main/java/org/fcitx/fcitx5/android/input/candidates/HorizontalCandidateComponent.kt rename to app/src/main/java/org/fcitx/fcitx5/android/input/candidates/horizontal/HorizontalCandidateComponent.kt index cb43ba2ff..8ee143705 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/input/candidates/HorizontalCandidateComponent.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/input/candidates/horizontal/HorizontalCandidateComponent.kt @@ -1,43 +1,55 @@ /* * SPDX-License-Identifier: LGPL-2.1-or-later - * SPDX-FileCopyrightText: Copyright 2021-2023 Fcitx5 for Android Contributors + * SPDX-FileCopyrightText: Copyright 2021-2024 Fcitx5 for Android Contributors */ -package org.fcitx.fcitx5.android.input.candidates + +package org.fcitx.fcitx5.android.input.candidates.horizontal import android.content.res.Configuration import android.graphics.drawable.ShapeDrawable import android.graphics.drawable.shapes.RectShape +import android.widget.PopupMenu +import androidx.core.text.bold +import androidx.core.text.buildSpannedString +import androidx.core.text.color import androidx.core.view.updateLayoutParams +import androidx.lifecycle.lifecycleScope import androidx.recyclerview.widget.RecyclerView import com.google.android.flexbox.FlexboxLayoutManager import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.asSharedFlow -import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.launch import org.fcitx.fcitx5.android.R import org.fcitx.fcitx5.android.core.FcitxEvent import org.fcitx.fcitx5.android.daemon.launchOnReady +import org.fcitx.fcitx5.android.data.InputFeedbacks import org.fcitx.fcitx5.android.data.prefs.AppPrefs import org.fcitx.fcitx5.android.input.bar.ExpandButtonStateMachine.BooleanKey.ExpandedCandidatesEmpty import org.fcitx.fcitx5.android.input.bar.ExpandButtonStateMachine.TransitionEvent.ExpandedCandidatesUpdated import org.fcitx.fcitx5.android.input.bar.KawaiiBarComponent import org.fcitx.fcitx5.android.input.broadcast.InputBroadcastReceiver -import org.fcitx.fcitx5.android.input.candidates.HorizontalCandidateMode.AlwaysFillWidth -import org.fcitx.fcitx5.android.input.candidates.HorizontalCandidateMode.AutoFillWidth -import org.fcitx.fcitx5.android.input.candidates.HorizontalCandidateMode.NeverFillWidth -import org.fcitx.fcitx5.android.input.candidates.adapter.HorizontalCandidateViewAdapter +import org.fcitx.fcitx5.android.input.candidates.CandidateItemUi +import org.fcitx.fcitx5.android.input.candidates.CandidateViewHolder import org.fcitx.fcitx5.android.input.candidates.expanded.decoration.FlexboxVerticalDecoration +import org.fcitx.fcitx5.android.input.candidates.horizontal.HorizontalCandidateMode.AlwaysFillWidth +import org.fcitx.fcitx5.android.input.candidates.horizontal.HorizontalCandidateMode.AutoFillWidth +import org.fcitx.fcitx5.android.input.candidates.horizontal.HorizontalCandidateMode.NeverFillWidth import org.fcitx.fcitx5.android.input.dependency.UniqueViewComponent import org.fcitx.fcitx5.android.input.dependency.context import org.fcitx.fcitx5.android.input.dependency.fcitx +import org.fcitx.fcitx5.android.input.dependency.inputMethodService import org.fcitx.fcitx5.android.input.dependency.theme +import org.fcitx.fcitx5.android.utils.item import org.mechdancer.dependency.manager.must import splitties.dimensions.dp +import splitties.resources.styledColor import kotlin.math.max class HorizontalCandidateComponent : UniqueViewComponent(), InputBroadcastReceiver { + private val service by manager.inputMethodService() private val context by manager.context() private val fcitx by manager.fcitx() private val theme by manager.theme() @@ -74,19 +86,17 @@ class HorizontalCandidateComponent : val expandedCandidateOffset = _expandedCandidateOffset.asSharedFlow() - private fun refreshExpanded() { - runBlocking { - _expandedCandidateOffset.emit(view.childCount) - } + private fun refreshExpanded(childCount: Int) { + _expandedCandidateOffset.tryEmit(childCount) bar.expandButtonStateMachine.push( ExpandedCandidatesUpdated, - ExpandedCandidatesEmpty to (adapter.total == layoutManager.childCount) + ExpandedCandidatesEmpty to (adapter.total == childCount) ) } val adapter: HorizontalCandidateViewAdapter by lazy { object : HorizontalCandidateViewAdapter(theme) { - override fun onBindViewHolder(holder: ViewHolder, position: Int) { + override fun onBindViewHolder(holder: CandidateViewHolder, position: Int) { super.onBindViewHolder(holder, position) holder.itemView.updateLayoutParams { minWidth = layoutMinWidth @@ -95,6 +105,10 @@ class HorizontalCandidateComponent : holder.itemView.setOnClickListener { fcitx.launchOnReady { it.select(holder.idx) } } + holder.itemView.setOnLongClickListener { + showCandidateActionMenu(holder.idx, candidates[position], holder.ui) + true + } } } } @@ -105,14 +119,15 @@ class HorizontalCandidateComponent : override fun canScrollHorizontally() = false override fun onLayoutCompleted(state: RecyclerView.State) { super.onLayoutCompleted(state) + val cnt = this.childCount if (secondLayoutPassNeeded) { - if (childCount < adapter.candidates.size) { + if (cnt < adapter.candidates.size) { // [^2] RecyclerView can't display all candidates // update LayoutParams in onLayoutCompleted would trigger another // onLayoutCompleted, skip the second one to avoid infinite loop if (secondLayoutPassDone) return secondLayoutPassDone = true - for (i in 0 until childCount) { + for (i in 0 until cnt) { getChildAt(i)!!.updateLayoutParams { flexGrow = 1f } @@ -121,7 +136,7 @@ class HorizontalCandidateComponent : secondLayoutPassNeeded = false } } - refreshExpanded() + refreshExpanded(cnt) } // no need to override `generate{,Default}LayoutParams`, because HorizontalCandidateViewAdapter // guarantees ViewHolder's layoutParams to be `FlexboxLayoutManager.LayoutParams` @@ -180,7 +195,43 @@ class HorizontalCandidateComponent : adapter.updateCandidates(candidates, total) // not sure why empty candidates won't trigger `FlexboxLayoutManager#onLayoutCompleted()` if (candidates.isEmpty()) { - refreshExpanded() + refreshExpanded(0) + } + } + + private fun triggerCandidateAction(idx: Int, actionIdx: Int) { + fcitx.runIfReady { triggerCandidateAction(idx, actionIdx) } + } + + private var candidateActionMenu: PopupMenu? = null + + fun showCandidateActionMenu(idx: Int, text: String, ui: CandidateItemUi) { + candidateActionMenu?.dismiss() + candidateActionMenu = null + service.lifecycleScope.launch { + val actions = fcitx.runOnReady { getCandidateActions(idx) } + if (actions.isEmpty()) return@launch + InputFeedbacks.hapticFeedback(ui.root, longPress = true) + candidateActionMenu = PopupMenu(context, ui.root).apply { + menu.add(buildSpannedString { + bold { + color(context.styledColor(android.R.attr.colorAccent)) { + append(text) + } + } + }).apply { + isEnabled = false + } + actions.forEach { action -> + menu.item(action.text) { + triggerCandidateAction(idx, action.id) + } + } + setOnDismissListener { + candidateActionMenu = null + } + show() + } } } } \ No newline at end of file diff --git a/app/src/main/java/org/fcitx/fcitx5/android/input/candidates/horizontal/HorizontalCandidateMode.kt b/app/src/main/java/org/fcitx/fcitx5/android/input/candidates/horizontal/HorizontalCandidateMode.kt new file mode 100644 index 000000000..1be621824 --- /dev/null +++ b/app/src/main/java/org/fcitx/fcitx5/android/input/candidates/horizontal/HorizontalCandidateMode.kt @@ -0,0 +1,15 @@ +/* + * SPDX-License-Identifier: LGPL-2.1-or-later + * SPDX-FileCopyrightText: Copyright 2021-2024 Fcitx5 for Android Contributors + */ + +package org.fcitx.fcitx5.android.input.candidates.horizontal + +import org.fcitx.fcitx5.android.R +import org.fcitx.fcitx5.android.data.prefs.ManagedPreferenceEnum + +enum class HorizontalCandidateMode(override val stringRes: Int) : ManagedPreferenceEnum { + NeverFillWidth(R.string.horizontal_candidate_never_fill), + AutoFillWidth(R.string.horizontal_candidate_auto_fill), + AlwaysFillWidth(R.string.horizontal_candidate_always_fill); +} diff --git a/app/src/main/java/org/fcitx/fcitx5/android/input/candidates/adapter/HorizontalCandidateViewAdapter.kt b/app/src/main/java/org/fcitx/fcitx5/android/input/candidates/horizontal/HorizontalCandidateViewAdapter.kt similarity index 73% rename from app/src/main/java/org/fcitx/fcitx5/android/input/candidates/adapter/HorizontalCandidateViewAdapter.kt rename to app/src/main/java/org/fcitx/fcitx5/android/input/candidates/horizontal/HorizontalCandidateViewAdapter.kt index abd7b8ee3..f2d4c78b0 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/input/candidates/adapter/HorizontalCandidateViewAdapter.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/input/candidates/horizontal/HorizontalCandidateViewAdapter.kt @@ -1,8 +1,9 @@ /* * SPDX-License-Identifier: LGPL-2.1-or-later - * SPDX-FileCopyrightText: Copyright 2021-2023 Fcitx5 for Android Contributors + * SPDX-FileCopyrightText: Copyright 2021-2024 Fcitx5 for Android Contributors */ -package org.fcitx.fcitx5.android.input.candidates.adapter + +package org.fcitx.fcitx5.android.input.candidates.horizontal import android.annotation.SuppressLint import android.view.ViewGroup @@ -11,17 +12,14 @@ import androidx.recyclerview.widget.RecyclerView import com.google.android.flexbox.FlexboxLayoutManager import org.fcitx.fcitx5.android.data.theme.Theme import org.fcitx.fcitx5.android.input.candidates.CandidateItemUi +import org.fcitx.fcitx5.android.input.candidates.CandidateViewHolder import splitties.dimensions.dp import splitties.views.dsl.core.matchParent import splitties.views.dsl.core.wrapContent import splitties.views.setPaddingDp open class HorizontalCandidateViewAdapter(val theme: Theme) : - RecyclerView.Adapter() { - - inner class ViewHolder(val ui: CandidateItemUi) : RecyclerView.ViewHolder(ui.root) { - var idx = -1 - } + RecyclerView.Adapter() { var candidates: Array = arrayOf() private set @@ -39,19 +37,21 @@ open class HorizontalCandidateViewAdapter(val theme: Theme) : override fun getItemCount() = candidates.size @CallSuper - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): CandidateViewHolder { val ui = CandidateItemUi(parent.context, theme) ui.root.apply { minimumWidth = dp(40) setPaddingDp(10, 0, 10, 0) layoutParams = FlexboxLayoutManager.LayoutParams(wrapContent, matchParent) } - return ViewHolder(ui) + return CandidateViewHolder(ui) } @CallSuper - override fun onBindViewHolder(holder: ViewHolder, position: Int) { - holder.ui.text.text = candidates[position] + override fun onBindViewHolder(holder: CandidateViewHolder, position: Int) { + val text = candidates[position] + holder.ui.text.text = text + holder.text = text holder.idx = position } diff --git a/app/src/main/java/org/fcitx/fcitx5/android/input/clipboard/ClipboardAdapter.kt b/app/src/main/java/org/fcitx/fcitx5/android/input/clipboard/ClipboardAdapter.kt index d3ea3d905..bd7c7fa3e 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/input/clipboard/ClipboardAdapter.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/input/clipboard/ClipboardAdapter.kt @@ -7,8 +7,6 @@ package org.fcitx.fcitx5.android.input.clipboard import android.os.Build import android.view.ViewGroup import android.widget.PopupMenu -import androidx.annotation.DrawableRes -import androidx.annotation.StringRes import androidx.paging.PagingDataAdapter import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.RecyclerView @@ -16,12 +14,15 @@ import org.fcitx.fcitx5.android.R import org.fcitx.fcitx5.android.data.clipboard.db.ClipboardEntry import org.fcitx.fcitx5.android.data.theme.Theme import org.fcitx.fcitx5.android.utils.DeviceUtil -import splitties.resources.drawable +import org.fcitx.fcitx5.android.utils.item import splitties.resources.styledColor import kotlin.math.min -abstract class ClipboardAdapter : - PagingDataAdapter(diffCallback) { +abstract class ClipboardAdapter( + private val theme: Theme, + private val entryRadius: Float, + private val maskSensitive: Boolean +) : PagingDataAdapter(diffCallback) { companion object { private val diffCallback = object : DiffUtil.ItemCallback() { @@ -43,10 +44,16 @@ abstract class ClipboardAdapter : /** * excerpt text to show on ClipboardEntryUi, to reduce render time of very long text * @param str text to excerpt + * @param mask mask text content with "•" * @param lines max output lines * @param chars max chars per output line */ - fun excerptText(str: String, lines: Int = 4, chars: Int = 128) = buildString { + fun excerptText( + str: String, + mask: Boolean = false, + lines: Int = 4, + chars: Int = 128 + ): String = buildString { val length = str.length var lineBreak = -1 for (i in 1..lines) { @@ -55,11 +62,20 @@ abstract class ClipboardAdapter : lineBreak = str.indexOf('\n', start) if (lineBreak < 0) { // no line breaks remaining, substring to end of text - append(str.substring(start, excerptEnd)) + if (mask) { + append(ClipboardEntry.BULLET.repeat(excerptEnd - start)) + } else { + append(str.substring(start, excerptEnd)) + } break } else { + val end = min(excerptEnd, lineBreak) // append one line exactly - appendLine(str.substring(start, min(excerptEnd, lineBreak))) + if (mask) { + append(ClipboardEntry.BULLET.repeat(end - start)) + } else { + appendLine(str.substring(start, end)) + } } } } @@ -70,39 +86,38 @@ abstract class ClipboardAdapter : class ViewHolder(val entryUi: ClipboardEntryUi) : RecyclerView.ViewHolder(entryUi.root) override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder = - ViewHolder(ClipboardEntryUi(parent.context, theme)) + ViewHolder(ClipboardEntryUi(parent.context, theme, entryRadius)) override fun onBindViewHolder(holder: ViewHolder, position: Int) { val entry = getItem(position) ?: return with(holder.entryUi) { - setEntry(excerptText(entry.text), entry.pinned) + setEntry(excerptText(entry.text, entry.sensitive && maskSensitive), entry.pinned) root.setOnClickListener { onPaste(entry) } root.setOnLongClickListener { - val iconColor = ctx.styledColor(android.R.attr.colorControlNormal) val popup = PopupMenu(ctx, root) - fun menuItem(@StringRes title: Int, @DrawableRes ic: Int, callback: () -> Unit) { - popup.menu.add(title).apply { - icon = ctx.drawable(ic)?.apply { setTint(iconColor) } - setOnMenuItemClickListener { - callback() - true - } + val menu = popup.menu + val iconTint = ctx.styledColor(android.R.attr.colorControlNormal) + if (entry.pinned) { + menu.item(R.string.unpin, R.drawable.ic_outline_push_pin_24, iconTint) { + onUnpin(entry.id) + } + } else { + menu.item(R.string.pin, R.drawable.ic_baseline_push_pin_24, iconTint) { + onPin(entry.id) } } - if (entry.pinned) menuItem(R.string.unpin, R.drawable.ic_outline_push_pin_24) { - onUnpin(entry.id) - } else menuItem(R.string.pin, R.drawable.ic_baseline_push_pin_24) { - onPin(entry.id) - } - menuItem(R.string.edit, R.drawable.ic_baseline_edit_24) { + menu.item(R.string.edit, R.drawable.ic_baseline_edit_24, iconTint) { onEdit(entry.id) } - menuItem(R.string.delete, R.drawable.ic_baseline_delete_24) { + menu.item(R.string.share, R.drawable.ic_baseline_share_24, iconTint) { + onShare(entry) + } + menu.item(R.string.delete, R.drawable.ic_baseline_delete_24, iconTint) { onDelete(entry.id) } - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q && !DeviceUtil.isSamsungOneUI) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q && !DeviceUtil.isSamsungOneUI && !DeviceUtil.isFlyme) { popup.setForceShowIcon(true) } popup.setOnDismissListener { @@ -123,8 +138,6 @@ abstract class ClipboardAdapter : popupMenu = null } - abstract val theme: Theme - abstract fun onPaste(entry: ClipboardEntry) abstract fun onPin(id: Int) @@ -133,6 +146,8 @@ abstract class ClipboardAdapter : abstract fun onEdit(id: Int) + abstract fun onShare(entry: ClipboardEntry) + abstract fun onDelete(id: Int) } \ No newline at end of file diff --git a/app/src/main/java/org/fcitx/fcitx5/android/input/clipboard/ClipboardEntryUi.kt b/app/src/main/java/org/fcitx/fcitx5/android/input/clipboard/ClipboardEntryUi.kt index 38794f305..6d6eb53ec 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/input/clipboard/ClipboardEntryUi.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/input/clipboard/ClipboardEntryUi.kt @@ -13,6 +13,7 @@ import android.text.TextUtils import android.view.View import org.fcitx.fcitx5.android.R import org.fcitx.fcitx5.android.data.theme.Theme +import org.fcitx.fcitx5.android.input.keyboard.CustomGestureView import splitties.dimensions.dp import splitties.resources.drawable import splitties.views.dsl.constraintlayout.bottomOfParent @@ -23,13 +24,14 @@ import splitties.views.dsl.constraintlayout.lParams import splitties.views.dsl.core.Ui import splitties.views.dsl.core.add import splitties.views.dsl.core.imageView +import splitties.views.dsl.core.lParams import splitties.views.dsl.core.matchParent import splitties.views.dsl.core.textView import splitties.views.dsl.core.wrapContent import splitties.views.imageDrawable import splitties.views.setPaddingDp -class ClipboardEntryUi(override val ctx: Context, private val theme: Theme) : Ui { +class ClipboardEntryUi(override val ctx: Context, private val theme: Theme, radius: Float) : Ui { val textView = textView { minLines = 1 @@ -47,10 +49,19 @@ class ClipboardEntryUi(override val ctx: Context, private val theme: Theme) : Ui } } - override val root = constraintLayout { + val layout = constraintLayout { + add(textView, lParams(matchParent, wrapContent) { + centerVertically() + }) + add(pin, lParams(dp(12), dp(12)) { + bottomOfParent(dp(2)) + endOfParent(dp(2)) + }) + } + + override val root = CustomGestureView(ctx).apply { isClickable = true minimumHeight = dp(30) - val radius = dp(2f) foreground = RippleDrawable( ColorStateList.valueOf(theme.keyPressHighlightColor), null, GradientDrawable().apply { @@ -62,13 +73,7 @@ class ClipboardEntryUi(override val ctx: Context, private val theme: Theme) : Ui cornerRadius = radius setColor(theme.clipboardEntryColor) } - add(textView, lParams(matchParent, wrapContent) { - centerVertically() - }) - add(pin, lParams(dp(12), dp(12)) { - bottomOfParent(dp(2)) - endOfParent(dp(2)) - }) + add(layout, lParams(matchParent, matchParent)) } fun setEntry(text: String, pinned: Boolean) { diff --git a/app/src/main/java/org/fcitx/fcitx5/android/input/clipboard/ClipboardStateMachine.kt b/app/src/main/java/org/fcitx/fcitx5/android/input/clipboard/ClipboardStateMachine.kt index adc70d806..aa5e705d0 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/input/clipboard/ClipboardStateMachine.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/input/clipboard/ClipboardStateMachine.kt @@ -42,8 +42,14 @@ object ClipboardStateMachine { }) } - fun new(initialState: State, block: (State) -> Unit) = - EventStateMachine(initialState).apply { + fun new(initial: State, empty: Boolean, listening: Boolean, block: (State) -> Unit) = + EventStateMachine( + initialState = initial, + externalBooleanStates = mutableMapOf( + ClipboardDbEmpty to empty, + ClipboardListeningEnabled to listening + ) + ).apply { onNewStateListener = block } diff --git a/app/src/main/java/org/fcitx/fcitx5/android/input/clipboard/ClipboardUi.kt b/app/src/main/java/org/fcitx/fcitx5/android/input/clipboard/ClipboardUi.kt index e8a0456de..fef73bf5d 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/input/clipboard/ClipboardUi.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/input/clipboard/ClipboardUi.kt @@ -18,7 +18,12 @@ import splitties.dimensions.dp import splitties.views.backgroundColor import splitties.views.dsl.coordinatorlayout.coordinatorLayout import splitties.views.dsl.coordinatorlayout.defaultLParams -import splitties.views.dsl.core.* +import splitties.views.dsl.core.Ui +import splitties.views.dsl.core.add +import splitties.views.dsl.core.horizontalLayout +import splitties.views.dsl.core.lParams +import splitties.views.dsl.core.matchParent +import splitties.views.dsl.core.view import splitties.views.dsl.recyclerview.recyclerView import timber.log.Timber @@ -48,7 +53,9 @@ class ClipboardUi(override val ctx: Context, private val theme: Theme) : Ui { add(viewAnimator, defaultLParams(matchParent, matchParent)) } - val deleteAllButton = ToolButton(ctx, R.drawable.ic_baseline_delete_sweep_24, theme) + val deleteAllButton = ToolButton(ctx, R.drawable.ic_baseline_delete_sweep_24, theme).apply { + contentDescription = ctx.getString(R.string.delete_all) + } val extension = horizontalLayout { add(deleteAllButton, lParams(dp(40), dp(40))) diff --git a/app/src/main/java/org/fcitx/fcitx5/android/input/clipboard/ClipboardWindow.kt b/app/src/main/java/org/fcitx/fcitx5/android/input/clipboard/ClipboardWindow.kt index 864a1ee97..6b6fac783 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/input/clipboard/ClipboardWindow.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/input/clipboard/ClipboardWindow.kt @@ -5,6 +5,7 @@ package org.fcitx.fcitx5.android.input.clipboard import android.annotation.SuppressLint +import android.content.Intent import android.view.View import android.view.ViewGroup import android.widget.FrameLayout @@ -20,6 +21,7 @@ import androidx.paging.PagingConfig import androidx.recyclerview.widget.ItemTouchHelper import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.StaggeredGridLayoutManager +import com.google.android.material.snackbar.BaseTransientBottomBar.BaseCallback import com.google.android.material.snackbar.Snackbar import com.google.android.material.snackbar.SnackbarContentLayout import kotlinx.coroutines.Job @@ -29,10 +31,13 @@ import org.fcitx.fcitx5.android.data.clipboard.ClipboardManager import org.fcitx.fcitx5.android.data.clipboard.db.ClipboardEntry import org.fcitx.fcitx5.android.data.prefs.AppPrefs import org.fcitx.fcitx5.android.data.prefs.ManagedPreference +import org.fcitx.fcitx5.android.data.theme.ThemeManager import org.fcitx.fcitx5.android.input.FcitxInputMethodService import org.fcitx.fcitx5.android.input.clipboard.ClipboardStateMachine.BooleanKey.ClipboardDbEmpty import org.fcitx.fcitx5.android.input.clipboard.ClipboardStateMachine.BooleanKey.ClipboardListeningEnabled -import org.fcitx.fcitx5.android.input.clipboard.ClipboardStateMachine.State.* +import org.fcitx.fcitx5.android.input.clipboard.ClipboardStateMachine.State.AddMore +import org.fcitx.fcitx5.android.input.clipboard.ClipboardStateMachine.State.EnableListening +import org.fcitx.fcitx5.android.input.clipboard.ClipboardStateMachine.State.Normal import org.fcitx.fcitx5.android.input.clipboard.ClipboardStateMachine.TransitionEvent.ClipboardDbUpdated import org.fcitx.fcitx5.android.input.clipboard.ClipboardStateMachine.TransitionEvent.ClipboardListeningUpdated import org.fcitx.fcitx5.android.input.dependency.inputMethodService @@ -42,11 +47,11 @@ import org.fcitx.fcitx5.android.input.wm.InputWindow import org.fcitx.fcitx5.android.input.wm.InputWindowManager import org.fcitx.fcitx5.android.utils.AppUtil import org.fcitx.fcitx5.android.utils.EventStateMachine +import org.fcitx.fcitx5.android.utils.item import org.mechdancer.dependency.manager.must import splitties.dimensions.dp import splitties.resources.styledColor import splitties.views.dsl.core.withTheme -import kotlin.properties.Delegates class ClipboardWindow : InputWindow.ExtendedInputWindow() { @@ -57,15 +62,10 @@ class ClipboardWindow : InputWindow.ExtendedInputWindow() { private val snackbarCtx by lazy { context.withTheme(R.style.InputViewSnackbarTheme) } + private var snackbarInstance: Snackbar? = null private lateinit var stateMachine: EventStateMachine - private var isClipboardDbEmpty by Delegates.observable(ClipboardManager.itemCount == 0) { _, _, new -> - stateMachine.push( - ClipboardDbUpdated, ClipboardDbEmpty to new - ) - } - @Keep private val clipboardEnabledListener = ManagedPreference.OnChangeListener { _, it -> stateMachine.push( @@ -73,8 +73,13 @@ class ClipboardWindow : InputWindow.ExtendedInputWindow() { ) } - private val clipboardEnabledPref = AppPrefs.getInstance().clipboard.clipboardListening - private val clipboardReturnAfterPaste by AppPrefs.getInstance().clipboard.clipboardReturnAfterPaste + private val prefs = AppPrefs.getInstance().clipboard + + private val clipboardEnabledPref = prefs.clipboardListening + private val clipboardReturnAfterPaste by prefs.clipboardReturnAfterPaste + private val clipboardMaskSensitive by prefs.clipboardMaskSensitive + + private val clipboardEntryRadius by ThemeManager.prefs.clipboardEntryRadius private val clipboardEntriesPager by lazy { Pager(PagingConfig(pageSize = 16)) { ClipboardManager.allEntries() } @@ -82,8 +87,11 @@ class ClipboardWindow : InputWindow.ExtendedInputWindow() { private var adapterSubmitJob: Job? = null private val adapter: ClipboardAdapter by lazy { - object : ClipboardAdapter() { - override val theme = this@ClipboardWindow.theme + object : ClipboardAdapter( + theme, + context.dp(clipboardEntryRadius.toFloat()), + clipboardMaskSensitive + ) { override fun onPin(id: Int) { service.lifecycleScope.launch { ClipboardManager.pin(id) } } @@ -96,6 +104,17 @@ class ClipboardWindow : InputWindow.ExtendedInputWindow() { AppUtil.launchClipboardEdit(context, id) } + override fun onShare(entry: ClipboardEntry) { + val target = Intent(Intent.ACTION_SEND).apply { + type = "text/plain" + putExtra(Intent.EXTRA_TEXT, entry.text) + } + val chooser = Intent.createChooser(target, null).apply { + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } + service.startActivity(chooser) + } + override fun onDelete(id: Int) { service.lifecycleScope.launch { ClipboardManager.delete(id) @@ -158,27 +177,18 @@ class ClipboardWindow : InputWindow.ExtendedInputWindow() { private fun promptDeleteAll(skipPinned: Boolean) { promptMenu?.dismiss() promptMenu = PopupMenu(context, ui.deleteAllButton).apply { - menu.apply { - add(buildSpannedString { - bold { - color(context.styledColor(android.R.attr.colorAccent)) { - append(context.getString(if (skipPinned) R.string.delete_all_except_pinned else R.string.delete_all_pinned_items)) - } + menu.add(buildSpannedString { + bold { + color(context.styledColor(android.R.attr.colorAccent)) { + append(context.getString(if (skipPinned) R.string.delete_all_except_pinned else R.string.delete_all_pinned_items)) } - }).apply { - isEnabled = false - } - add(android.R.string.cancel).apply { - setOnMenuItemClickListener { true } } - add(android.R.string.ok).apply { - setOnMenuItemClickListener { - service.lifecycleScope.launch { - val ids = ClipboardManager.deleteAll(skipPinned) - showUndoSnackbar(*ids) - } - true - } + }).isEnabled = false + menu.add(android.R.string.cancel) + menu.item(android.R.string.ok) { + service.lifecycleScope.launch { + val ids = ClipboardManager.deleteAll(skipPinned) + showUndoSnackbar(*ids) } } setOnDismissListener { @@ -188,22 +198,40 @@ class ClipboardWindow : InputWindow.ExtendedInputWindow() { } } + private val pendingDeleteIds = arrayListOf() + @SuppressLint("RestrictedApi") private fun showUndoSnackbar(vararg id: Int) { - val str = context.resources.getString(R.string.num_items_deleted, id.size) - Snackbar.make(snackbarCtx, ui.root, str, Snackbar.LENGTH_LONG) - .setBackgroundTint(theme.keyBackgroundColor) - .setTextColor(theme.keyTextColor) + id.forEach { pendingDeleteIds.add(it) } + val str = context.resources.getString(R.string.num_items_deleted, pendingDeleteIds.size) + snackbarInstance = Snackbar.make(snackbarCtx, ui.root, str, Snackbar.LENGTH_LONG) + .setBackgroundTint(theme.popupBackgroundColor) + .setTextColor(theme.popupTextColor) .setActionTextColor(theme.genericActiveBackgroundColor) .setAction(R.string.undo) { service.lifecycleScope.launch { - ClipboardManager.undoDelete(*id) + ClipboardManager.undoDelete(*pendingDeleteIds.toIntArray()) + pendingDeleteIds.clear() } } .addCallback(object : Snackbar.Callback() { override fun onDismissed(transientBottomBar: Snackbar, event: Int) { - service.lifecycleScope.launch { - ClipboardManager.realDelete() + if (snackbarInstance === transientBottomBar) { + snackbarInstance = null + } + when (event) { + BaseCallback.DISMISS_EVENT_SWIPE, + BaseCallback.DISMISS_EVENT_MANUAL, + BaseCallback.DISMISS_EVENT_TIMEOUT -> { + service.lifecycleScope.launch { + ClipboardManager.realDelete() + pendingDeleteIds.clear() + } + } + BaseCallback.DISMISS_EVENT_ACTION, + BaseCallback.DISMISS_EVENT_CONSECUTIVE -> { + // user clicked "undo" or deleted more items which makes a new snackbar + } } } }).apply { @@ -223,18 +251,21 @@ class ClipboardWindow : InputWindow.ExtendedInputWindow() { } override fun onAttached() { + val isEmpty = ClipboardManager.itemCount == 0 + val isListening = clipboardEnabledPref.getValue() val initialState = when { - !clipboardEnabledPref.getValue() -> EnableListening - isClipboardDbEmpty -> AddMore + !isListening -> EnableListening + isEmpty -> AddMore else -> Normal } - stateMachine = ClipboardStateMachine.new(initialState) { + stateMachine = ClipboardStateMachine.new(initialState, isEmpty, isListening) { ui.switchUiByState(it) } // manually switch to initial ui ui.switchUiByState(initialState) adapter.addLoadStateListener { - isClipboardDbEmpty = it.append.endOfPaginationReached && adapter.itemCount < 1 + val empty = it.append.endOfPaginationReached && adapter.itemCount < 1 + stateMachine.push(ClipboardDbUpdated, ClipboardDbEmpty to empty) } adapterSubmitJob = service.lifecycleScope.launch { clipboardEntriesPager.flow.collect { @@ -249,6 +280,7 @@ class ClipboardWindow : InputWindow.ExtendedInputWindow() { adapter.onDetached() adapterSubmitJob?.cancel() promptMenu?.dismiss() + snackbarInstance?.dismiss() } override val title: String by lazy { diff --git a/app/src/main/java/org/fcitx/fcitx5/android/input/editing/TextEditingButton.kt b/app/src/main/java/org/fcitx/fcitx5/android/input/editing/TextEditingButton.kt new file mode 100644 index 000000000..356e7709c --- /dev/null +++ b/app/src/main/java/org/fcitx/fcitx5/android/input/editing/TextEditingButton.kt @@ -0,0 +1,159 @@ +/* + * SPDX-License-Identifier: LGPL-2.1-or-later + * SPDX-FileCopyrightText: Copyright 2024 Fcitx5 for Android Contributors + */ + +package org.fcitx.fcitx5.android.input.editing + +import android.annotation.SuppressLint +import android.content.Context +import android.content.res.ColorStateList +import android.graphics.drawable.RippleDrawable +import android.graphics.drawable.StateListDrawable +import androidx.annotation.DrawableRes +import org.fcitx.fcitx5.android.data.theme.Theme +import org.fcitx.fcitx5.android.input.keyboard.CustomGestureView +import org.fcitx.fcitx5.android.input.keyboard.borderedKeyBackgroundDrawable +import org.fcitx.fcitx5.android.input.keyboard.insetRadiusDrawable +import org.fcitx.fcitx5.android.utils.borderDrawable +import org.fcitx.fcitx5.android.utils.pressHighlightDrawable +import org.fcitx.fcitx5.android.utils.rippleDrawable +import splitties.dimensions.dp +import splitties.views.dsl.core.add +import splitties.views.dsl.core.imageView +import splitties.views.dsl.core.lParams +import splitties.views.dsl.core.textView +import splitties.views.dsl.core.wrapContent +import splitties.views.gravityCenter +import splitties.views.imageResource +import kotlin.math.max + +@SuppressLint("ViewConstructor") +class TextEditingButton( + ctx: Context, + private val theme: Theme, + private val rippled: Boolean, + private val bordered: Boolean, + private val radius: Float, + private val altStyle: Boolean = false +) : CustomGestureView(ctx) { + + // bordered + private val shadowWidth = dp(1) + private val hInset = dp(4) + private val vInset = dp(4) + + // !bordered + private val lineWidth = max(1, dp(1) / 2) + + init { + if (bordered) { + val bkgColor = if (altStyle) theme.altKeyBackgroundColor else theme.keyBackgroundColor + background = borderedKeyBackgroundDrawable( + bkgColor, theme.keyShadowColor, + radius, shadowWidth, hInset, vInset + ) + foreground = if (rippled) { + RippleDrawable( + ColorStateList.valueOf(theme.keyPressHighlightColor), null, + insetRadiusDrawable(hInset, vInset, radius) + ) + } else { + StateListDrawable().apply { + addState( + intArrayOf(android.R.attr.state_pressed), + insetRadiusDrawable(hInset, vInset, radius, theme.keyPressHighlightColor) + ) + } + } + } else { + background = borderDrawable(lineWidth, theme.dividerColor) + foreground = + if (rippled) rippleDrawable(theme.keyPressHighlightColor) + else pressHighlightDrawable(theme.keyPressHighlightColor) + } + } + + val textView = textView { + isClickable = false + isFocusable = false + background = null + setTextColor(if (altStyle) theme.altKeyTextColor else theme.keyTextColor) + } + + val imageView = imageView { + isClickable = false + isFocusable = false + imageTintList = ColorStateList.valueOf(theme.altKeyTextColor) + } + + fun setText(id: Int) { + textView.setText(id) + removeView(imageView) + add(textView, lParams(wrapContent, wrapContent, gravityCenter)) + } + + fun setIcon(@DrawableRes icon: Int) { + imageView.imageResource = icon + removeView(textView) + add(imageView, lParams(wrapContent, wrapContent, gravityCenter)) + } + + fun enableActivatedState() { + textView.setTextColor( + ColorStateList( + arrayOf( + intArrayOf(android.R.attr.state_activated), + intArrayOf(android.R.attr.state_enabled) + ), + intArrayOf( + theme.genericActiveForegroundColor, + if (altStyle) theme.altKeyTextColor else theme.keyTextColor + ) + ) + ) + imageView.imageTintList = ColorStateList( + arrayOf( + intArrayOf(android.R.attr.state_activated), + intArrayOf(android.R.attr.state_enabled) + ), + intArrayOf( + theme.genericActiveForegroundColor, + theme.altKeyTextColor + ) + ) + background = if (bordered) { + StateListDrawable().apply { + addState( + intArrayOf(android.R.attr.state_activated), + borderedKeyBackgroundDrawable( + theme.genericActiveBackgroundColor, theme.keyShadowColor, + radius, shadowWidth, hInset, vInset + ) + ) + addState( + intArrayOf(android.R.attr.state_enabled), + borderedKeyBackgroundDrawable( + theme.keyBackgroundColor, theme.keyShadowColor, + radius, shadowWidth, hInset, vInset + ) + ) + } + } else { + StateListDrawable().apply { + addState( + intArrayOf(android.R.attr.state_activated), + borderDrawable( + lineWidth, + theme.dividerColor, + theme.genericActiveBackgroundColor + ) + ) + addState( + intArrayOf(android.R.attr.state_enabled), + borderDrawable(lineWidth, theme.dividerColor) + ) + } + } + } +} diff --git a/app/src/main/java/org/fcitx/fcitx5/android/input/editing/TextEditingUi.kt b/app/src/main/java/org/fcitx/fcitx5/android/input/editing/TextEditingUi.kt index 546189ba8..327f92958 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/input/editing/TextEditingUi.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/input/editing/TextEditingUi.kt @@ -1,26 +1,18 @@ /* * SPDX-License-Identifier: LGPL-2.1-or-later - * SPDX-FileCopyrightText: Copyright 2021-2023 Fcitx5 for Android Contributors + * SPDX-FileCopyrightText: Copyright 2021-2025 Fcitx5 for Android Contributors */ package org.fcitx.fcitx5.android.input.editing import android.content.Context -import android.content.res.ColorStateList -import android.graphics.drawable.StateListDrawable import android.view.View import androidx.annotation.DrawableRes import androidx.annotation.StringRes import org.fcitx.fcitx5.android.R import org.fcitx.fcitx5.android.data.InputFeedbacks import org.fcitx.fcitx5.android.data.theme.Theme -import org.fcitx.fcitx5.android.data.theme.ThemeManager import org.fcitx.fcitx5.android.input.bar.ui.ToolButton -import org.fcitx.fcitx5.android.input.keyboard.CustomGestureView -import org.fcitx.fcitx5.android.utils.borderDrawable -import org.fcitx.fcitx5.android.utils.pressHighlightDrawable -import org.fcitx.fcitx5.android.utils.rippleDrawable import splitties.dimensions.dp -import splitties.resources.drawable import splitties.views.dsl.constraintlayout.above import splitties.views.dsl.constraintlayout.below import splitties.views.dsl.constraintlayout.bottomOfParent @@ -34,113 +26,67 @@ import splitties.views.dsl.constraintlayout.topOfParent import splitties.views.dsl.core.Ui import splitties.views.dsl.core.add import splitties.views.dsl.core.horizontalLayout -import splitties.views.dsl.core.imageView import splitties.views.dsl.core.lParams -import splitties.views.dsl.core.textView -import splitties.views.dsl.core.wrapContent -import splitties.views.gravityCenter -import splitties.views.imageDrawable -import splitties.views.padding -class TextEditingUi(override val ctx: Context, private val theme: Theme) : Ui { - - private val keyRippleEffect by ThemeManager.prefs.keyRippleEffect - - private val borderWidth = ctx.dp(1) / 2 - - private fun View.applyBorderedBackground() { - background = borderDrawable(borderWidth, theme.dividerColor) - foreground = - if (keyRippleEffect) rippleDrawable(theme.keyPressHighlightColor) - else pressHighlightDrawable(theme.keyPressHighlightColor) - } - - class GTextButton(context: Context) : CustomGestureView(context) { - val text = textView { - isClickable = false - isFocusable = false - background = null +class TextEditingUi( + override val ctx: Context, + private val theme: Theme, + private val ripple: Boolean, + private val border: Boolean, + private val radius: Float +) : Ui { + + private fun textButton(@StringRes id: Int, altStyle: Boolean = false) = + TextEditingButton(ctx, theme, ripple, border, radius, altStyle).apply { + setText(id) } - init { - add(text, lParams(wrapContent, wrapContent, gravityCenter)) - } - } - - class GImageButton(context: Context) : CustomGestureView(context) { - val image = imageView { - isClickable = false - isFocusable = false + private fun iconButton(@DrawableRes icon: Int, altStyle: Boolean = false) = + TextEditingButton(ctx, theme, ripple, border, radius, altStyle).apply { + setIcon(icon) } - init { - add(image, lParams(wrapContent, wrapContent, gravityCenter)) - } + val upButton = iconButton(R.drawable.ic_baseline_keyboard_arrow_up_24).apply { + contentDescription = ctx.getString(R.string.move_cursor_up) } - private fun textButton(@StringRes id: Int) = GTextButton(ctx).apply { - text.setText(id) - text.setTextColor(theme.keyTextColor) - stateListAnimator = null - applyBorderedBackground() + val rightButton = iconButton(R.drawable.ic_baseline_keyboard_arrow_right_24).apply { + contentDescription = ctx.getString(R.string.move_cursor_right) } - private fun iconButton(@DrawableRes icon: Int) = GImageButton(ctx).apply { - image.imageDrawable = drawable(icon)!!.apply { - setTint(theme.altKeyTextColor) - } - padding = dp(10) - applyBorderedBackground() + val downButton = iconButton(R.drawable.ic_baseline_keyboard_arrow_down_24).apply { + contentDescription = ctx.getString(R.string.move_cursor_down) } - val upButton = iconButton(R.drawable.ic_baseline_keyboard_arrow_up_24) - - val rightButton = iconButton(R.drawable.ic_baseline_keyboard_arrow_right_24) - - val downButton = iconButton(R.drawable.ic_baseline_keyboard_arrow_down_24) - - val leftButton = iconButton(R.drawable.ic_baseline_keyboard_arrow_left_24) + val leftButton = iconButton(R.drawable.ic_baseline_keyboard_arrow_left_24).apply { + contentDescription = ctx.getString(R.string.move_cursor_left) + } val selectButton = textButton(R.string.select).apply { - text.setTextColor( - ColorStateList( - arrayOf( - intArrayOf(android.R.attr.state_activated), - intArrayOf(android.R.attr.state_enabled) - ), - intArrayOf(theme.genericActiveForegroundColor, theme.keyTextColor) - ) - ) - background = StateListDrawable().apply { - addState( - intArrayOf(android.R.attr.state_activated), - borderDrawable( - borderWidth, - theme.dividerColor, - theme.genericActiveBackgroundColor - ) - ) - addState( - intArrayOf(android.R.attr.state_enabled), - borderDrawable(borderWidth, theme.dividerColor) - ) - } + enableActivatedState() } - val homeButton = iconButton(R.drawable.ic_baseline_first_page_24) + val homeButton = iconButton(R.drawable.ic_baseline_first_page_24).apply { + contentDescription = ctx.getString(R.string.move_cursor_to_start) + } - val endButton = iconButton(R.drawable.ic_baseline_last_page_24) + val endButton = iconButton(R.drawable.ic_baseline_last_page_24).apply { + contentDescription = ctx.getString(R.string.move_cursor_to_end) + } - val selectAllButton = textButton(android.R.string.selectAll) + val selectAllButton = textButton(android.R.string.selectAll, altStyle = true) - val cutButton = textButton(android.R.string.cut).apply { visibility = View.GONE } + val cutButton = textButton(android.R.string.cut, altStyle = true).apply { + visibility = View.GONE + } - val copyButton = textButton(android.R.string.copy) + val copyButton = textButton(android.R.string.copy, altStyle = true) - val pasteButton = textButton(android.R.string.paste) + val pasteButton = textButton(android.R.string.paste, altStyle = true) - val backspaceButton = iconButton(R.drawable.ic_baseline_backspace_24).apply { + val backspaceButton = iconButton(R.drawable.ic_baseline_backspace_24, altStyle = true).apply { soundEffect = InputFeedbacks.SoundEffect.Delete + contentDescription = ctx.getString(R.string.backspace) } override val root = constraintLayout { @@ -244,7 +190,9 @@ class TextEditingUi(override val ctx: Context, private val theme: Theme) : Ui { } } - val clipboardButton = ToolButton(ctx, R.drawable.ic_clipboard, theme) + val clipboardButton = ToolButton(ctx, R.drawable.ic_clipboard, theme).apply { + contentDescription = ctx.getString(R.string.clipboard) + } val extension = horizontalLayout { add(clipboardButton, lParams(dp(40), dp(40))) diff --git a/app/src/main/java/org/fcitx/fcitx5/android/input/editing/TextEditingWindow.kt b/app/src/main/java/org/fcitx/fcitx5/android/input/editing/TextEditingWindow.kt index 886af27ec..78e1725b6 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/input/editing/TextEditingWindow.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/input/editing/TextEditingWindow.kt @@ -7,13 +7,12 @@ package org.fcitx.fcitx5.android.input.editing import android.view.KeyEvent import android.view.View import org.fcitx.fcitx5.android.R -import org.fcitx.fcitx5.android.core.FcitxKeyMapping -import org.fcitx.fcitx5.android.daemon.FcitxConnection -import org.fcitx.fcitx5.android.daemon.launchOnReady +import org.fcitx.fcitx5.android.data.InputFeedbacks +import org.fcitx.fcitx5.android.data.prefs.AppPrefs +import org.fcitx.fcitx5.android.data.theme.ThemeManager import org.fcitx.fcitx5.android.input.FcitxInputMethodService import org.fcitx.fcitx5.android.input.broadcast.InputBroadcastReceiver import org.fcitx.fcitx5.android.input.clipboard.ClipboardWindow -import org.fcitx.fcitx5.android.input.dependency.fcitx import org.fcitx.fcitx5.android.input.dependency.inputMethodService import org.fcitx.fcitx5.android.input.dependency.theme import org.fcitx.fcitx5.android.input.keyboard.CustomGestureView @@ -26,9 +25,14 @@ class TextEditingWindow : InputWindow.ExtendedInputWindow(), private val service: FcitxInputMethodService by manager.inputMethodService() private val windowManager: InputWindowManager by manager.must() - private val fcitx: FcitxConnection by manager.fcitx() private val theme by manager.theme() + private val hapticOnRepeat by AppPrefs.getInstance().keyboard.hapticOnRepeat + + private val buttonRipple by ThemeManager.prefs.keyRippleEffect + private val buttonBorder by ThemeManager.prefs.keyBorder + private val buttonRadius by ThemeManager.prefs.textEditingButtonRadius + private var hasSelection = false private var userSelection = false @@ -37,11 +41,14 @@ class TextEditingWindow : InputWindow.ExtendedInputWindow(), } private val ui by lazy { - TextEditingUi(context, theme).apply { + TextEditingUi(context, theme, buttonRipple, buttonBorder, buttonRadius.toFloat()).apply { fun CustomGestureView.onClickWithRepeating(block: () -> Unit) { setOnClickListener { block() } repeatEnabled = true - onRepeatListener = { block() } + onRepeatListener = { + block() + if (hapticOnRepeat) InputFeedbacks.hapticFeedback(this) + } } leftButton.onClickWithRepeating { sendDirectionKey(KeyEvent.KEYCODE_DPAD_LEFT) } @@ -83,9 +90,7 @@ class TextEditingWindow : InputWindow.ExtendedInputWindow(), } backspaceButton.onClickWithRepeating { userSelection = false - fcitx.launchOnReady { - it.sendKey(FcitxKeyMapping.FcitxKey_BackSpace) - } + service.sendDownUpKeyEvents(KeyEvent.KEYCODE_DEL) } clipboardButton.setOnClickListener { windowManager.attachWindow(ClipboardWindow()) diff --git a/app/src/main/java/org/fcitx/fcitx5/android/input/editorinfo/EditorInfoUi.kt b/app/src/main/java/org/fcitx/fcitx5/android/input/editorinfo/EditorInfoUi.kt index 2e9a6faa1..a1ed7d5ca 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/input/editorinfo/EditorInfoUi.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/input/editorinfo/EditorInfoUi.kt @@ -8,7 +8,11 @@ import android.content.Context import android.widget.TableLayout import android.widget.TableRow import org.fcitx.fcitx5.android.data.theme.Theme -import splitties.views.dsl.core.* +import splitties.views.dsl.core.Ui +import splitties.views.dsl.core.textView +import splitties.views.dsl.core.view +import splitties.views.dsl.core.wrapInHorizontalScrollView +import splitties.views.dsl.core.wrapInScrollView import splitties.views.setPaddingDp class EditorInfoUi(override val ctx: Context, private val theme: Theme) : Ui { diff --git a/app/src/main/java/org/fcitx/fcitx5/android/input/keyboard/BaseKeyboard.kt b/app/src/main/java/org/fcitx/fcitx5/android/input/keyboard/BaseKeyboard.kt index 8e8871225..84a21447c 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/input/keyboard/BaseKeyboard.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/input/keyboard/BaseKeyboard.kt @@ -1,6 +1,6 @@ /* * SPDX-License-Identifier: LGPL-2.1-or-later - * SPDX-FileCopyrightText: Copyright 2021-2023 Fcitx5 for Android Contributors + * SPDX-FileCopyrightText: Copyright 2021-2025 Fcitx5 for Android Contributors */ package org.fcitx.fcitx5.android.input.keyboard @@ -19,6 +19,7 @@ import org.fcitx.fcitx5.android.core.KeyStates import org.fcitx.fcitx5.android.core.KeySym import org.fcitx.fcitx5.android.data.InputFeedbacks import org.fcitx.fcitx5.android.data.prefs.AppPrefs +import org.fcitx.fcitx5.android.data.prefs.ManagedPreference import org.fcitx.fcitx5.android.data.theme.Theme import org.fcitx.fcitx5.android.input.keyboard.CustomGestureView.GestureType import org.fcitx.fcitx5.android.input.keyboard.CustomGestureView.OnGestureListener @@ -56,8 +57,18 @@ abstract class BaseKeyboard( private val expandKeypressArea by prefs.keyboard.expandKeypressArea private val swipeSymbolDirection by prefs.keyboard.swipeSymbolDirection + private val spaceSwipeMoveCursor = prefs.keyboard.spaceSwipeMoveCursor + private val spaceKeys = mutableListOf() + private val spaceSwipeChangeListener = ManagedPreference.OnChangeListener { _, v -> + spaceKeys.forEach { + it.swipeEnabled = v + } + } + private val vivoKeypressWorkaround by prefs.advanced.vivoKeypressWorkaround + private val hapticOnRepeat by prefs.keyboard.hapticOnRepeat + var popupActionListener: PopupActionListener? = null private val selectionSwipeThreshold = dp(10f) @@ -130,6 +141,7 @@ abstract class BaseKeyboard( centerHorizontally() }) } + spaceSwipeMoveCursor.registerOnChangeListener(spaceSwipeChangeListener) } private fun createKeyView(def: KeyDef): KeyView { @@ -147,20 +159,22 @@ abstract class BaseKeyboard( else -> InputFeedbacks.SoundEffect.Standard } if (def is SpaceKey) { - swipeEnabled = true + spaceKeys.add(this) + swipeEnabled = spaceSwipeMoveCursor.getValue() swipeRepeatEnabled = true swipeThresholdX = selectionSwipeThreshold swipeThresholdY = disabledSwipeThreshold - onGestureListener = OnGestureListener { _, event -> + onGestureListener = OnGestureListener { view, event -> when (event.type) { GestureType.Move -> when (val count = event.countX) { 0 -> false else -> { val sym = if (count > 0) FcitxKeyMapping.FcitxKey_Right else FcitxKeyMapping.FcitxKey_Left - val action = KeyAction.SymAction(KeySym(sym), KeyStates.Empty) + val action = KeyAction.SymAction(KeySym(sym), KeyStates.Virtual) repeat(count.absoluteValue) { onAction(action) + if (hapticOnRepeat) InputFeedbacks.hapticFeedback(view) } true } @@ -173,12 +187,13 @@ abstract class BaseKeyboard( swipeRepeatEnabled = true swipeThresholdX = selectionSwipeThreshold swipeThresholdY = disabledSwipeThreshold - onGestureListener = OnGestureListener { _, event -> + onGestureListener = OnGestureListener { view, event -> when (event.type) { GestureType.Move -> { val count = event.countX if (count != 0) { onAction(KeyAction.MoveSelectionAction(count)) + if (hapticOnRepeat) InputFeedbacks.hapticFeedback(view) true } else false } @@ -205,8 +220,9 @@ abstract class BaseKeyboard( } is KeyDef.Behavior.Repeat -> { repeatEnabled = true - onRepeatListener = { _ -> + onRepeatListener = { view -> onAction(it.action) + if (hapticOnRepeat) InputFeedbacks.hapticFeedback(view) } } is KeyDef.Behavior.Swipe -> { diff --git a/app/src/main/java/org/fcitx/fcitx5/android/input/keyboard/CommonKeyActionListener.kt b/app/src/main/java/org/fcitx/fcitx5/android/input/keyboard/CommonKeyActionListener.kt index edb7fb199..a4a8bb9d7 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/input/keyboard/CommonKeyActionListener.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/input/keyboard/CommonKeyActionListener.kt @@ -1,21 +1,21 @@ /* * SPDX-License-Identifier: LGPL-2.1-or-later - * SPDX-FileCopyrightText: Copyright 2021-2023 Fcitx5 for Android Contributors + * SPDX-FileCopyrightText: Copyright 2021-2024 Fcitx5 for Android Contributors */ + package org.fcitx.fcitx5.android.input.keyboard import androidx.core.content.ContextCompat import androidx.lifecycle.lifecycleScope import kotlinx.coroutines.launch import org.fcitx.fcitx5.android.core.FcitxAPI -import org.fcitx.fcitx5.android.core.KeyState import org.fcitx.fcitx5.android.daemon.launchOnReady import org.fcitx.fcitx5.android.data.prefs.AppPrefs import org.fcitx.fcitx5.android.input.broadcast.PreeditEmptyStateComponent +import org.fcitx.fcitx5.android.input.candidates.horizontal.HorizontalCandidateComponent import org.fcitx.fcitx5.android.input.dependency.context import org.fcitx.fcitx5.android.input.dependency.fcitx import org.fcitx.fcitx5.android.input.dependency.inputMethodService -import org.fcitx.fcitx5.android.input.dependency.inputView import org.fcitx.fcitx5.android.input.dialog.AddMoreInputMethodsPrompt import org.fcitx.fcitx5.android.input.dialog.InputMethodPickerDialog import org.fcitx.fcitx5.android.input.keyboard.CommonKeyActionListener.BackspaceSwipeState.Reset @@ -34,6 +34,7 @@ import org.fcitx.fcitx5.android.input.keyboard.KeyAction.SymAction import org.fcitx.fcitx5.android.input.keyboard.KeyAction.UnicodeAction import org.fcitx.fcitx5.android.input.picker.PickerWindow import org.fcitx.fcitx5.android.input.wm.InputWindowManager +import org.fcitx.fcitx5.android.utils.switchToNextIME import org.mechdancer.dependency.Dependent import org.mechdancer.dependency.UniqueComponent import org.mechdancer.dependency.manager.ManagedHandler @@ -50,8 +51,8 @@ class CommonKeyActionListener : private val context by manager.context() private val fcitx by manager.fcitx() private val service by manager.inputMethodService() - private val inputView by manager.inputView() private val preeditState: PreeditEmptyStateComponent by manager.must() + private val horizontalCandidate: HorizontalCandidateComponent by manager.must() private val windowManager: InputWindowManager by manager.must() private var lastPickerType by AppPrefs.getInstance().internal.lastPickerType @@ -63,11 +64,13 @@ class CommonKeyActionListener : private var backspaceSwipeState = Stopped + private val keepComposingIMs = arrayOf("keyboard-us", "unikey") + private suspend fun FcitxAPI.commitAndReset() { if (clientPreeditCached.isEmpty() && inputPanelCached.preedit.isEmpty()) { // preedit is empty, there can be prediction candidates reset() - } else if (inputMethodEntryCached.uniqueName.let { it == "keyboard-us" || it == "unikey" }) { + } else if (inputMethodEntryCached.uniqueName in keepComposingIMs) { // androidkeyboard clears composing on reset, but we want to commit it as-is service.finishComposing() reset() @@ -79,7 +82,7 @@ class CommonKeyActionListener : private fun showInputMethodPicker() { fcitx.launchOnReady { service.lifecycleScope.launch { - inputView.showDialog(InputMethodPickerDialog.build(it, service, context)) + service.showDialog(InputMethodPickerDialog.build(it, service, context)) } } } @@ -88,7 +91,7 @@ class CommonKeyActionListener : KeyActionListener { action, _ -> when (action) { is FcitxKeyAction -> service.postFcitxJob { - sendKey(action.act, KeyState.Virtual.state) + sendKey(action.act, action.states.states, action.code) } is SymAction -> service.postFcitxJob { sendKey(action.sym, action.states) @@ -111,7 +114,7 @@ class CommonKeyActionListener : service.postFcitxJob { if (enabledIme().size < 2) { service.lifecycleScope.launch { - inputView.showDialog(AddMoreInputMethodsPrompt.build(context)) + service.showDialog(AddMoreInputMethodsPrompt.build(context)) } } else { enumerateIme() @@ -124,7 +127,7 @@ class CommonKeyActionListener : } } LangSwitchBehavior.NextInputMethodApp -> { - service.nextInputMethodApp() + service.switchToNextIME() } } } @@ -132,7 +135,10 @@ class CommonKeyActionListener : is MoveSelectionAction -> { when (backspaceSwipeState) { Stopped -> { - backspaceSwipeState = if (preeditState.isEmpty) { + backspaceSwipeState = if ( + preeditState.isEmpty && + horizontalCandidate.adapter.total <= 0 // total is -1 on initialization + ) { service.applySelectionOffset(action.start, action.end) Selection } else { diff --git a/app/src/main/java/org/fcitx/fcitx5/android/input/keyboard/CustomGestureView.kt b/app/src/main/java/org/fcitx/fcitx5/android/input/keyboard/CustomGestureView.kt index ba7b92b91..b76804469 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/input/keyboard/CustomGestureView.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/input/keyboard/CustomGestureView.kt @@ -58,6 +58,9 @@ open class CustomGestureView(ctx: Context) : FrameLayout(ctx) { var longPressEnabled = false private var longPressJob: Job? = null + @Volatile + var longPressFeedbackEnabled = true + @Volatile private var repeatStarted = false var repeatEnabled = false @@ -90,7 +93,9 @@ open class CustomGestureView(ctx: Context) : FrameLayout(ctx) { private val touchSlop: Float = ViewConfiguration.get(ctx).scaledTouchSlop.toFloat() init { + // disable system sound effect and haptic feedback isSoundEffectsEnabled = false + isHapticFeedbackEnabled = false } override fun setEnabled(enabled: Boolean) { @@ -148,7 +153,9 @@ open class CustomGestureView(ctx: Context) : FrameLayout(ctx) { longPressJob?.cancel() longPressJob = lifecycleScope.launch { delay(longPressDelay.toLong()) - InputFeedbacks.hapticFeedback(this@CustomGestureView, true) + if (longPressFeedbackEnabled) { + InputFeedbacks.hapticFeedback(this@CustomGestureView, true) + } longPressTriggered = performLongClick() } } @@ -173,6 +180,7 @@ open class CustomGestureView(ctx: Context) : FrameLayout(ctx) { } MotionEvent.ACTION_UP -> { isPressed = false + InputFeedbacks.hapticFeedback(this, longPress = true, keyUp = true) dispatchGestureEvent(GestureType.Up, event.x, event.y) val shouldPerformClick = !(touchMovedOutside || longPressTriggered || diff --git a/app/src/main/java/org/fcitx/fcitx5/android/input/keyboard/KeyAction.kt b/app/src/main/java/org/fcitx/fcitx5/android/input/keyboard/KeyAction.kt index 4182bb38f..192cfbadb 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/input/keyboard/KeyAction.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/input/keyboard/KeyAction.kt @@ -4,22 +4,22 @@ */ package org.fcitx.fcitx5.android.input.keyboard -import org.fcitx.fcitx5.android.core.KeyState import org.fcitx.fcitx5.android.core.KeyStates import org.fcitx.fcitx5.android.core.KeySym +import org.fcitx.fcitx5.android.core.ScancodeMapping import org.fcitx.fcitx5.android.input.picker.PickerWindow sealed class KeyAction { - data class FcitxKeyAction(var act: String) : KeyAction() + data class FcitxKeyAction( + val act: String, + val code: Int = ScancodeMapping.charToScancode(act[0]), + val states: KeyStates = KeyStates.Virtual + ) : KeyAction() - data class SymAction(val sym: KeySym, val states: KeyStates = VirtualState) : KeyAction() { - companion object { - val VirtualState = KeyStates(KeyState.Virtual) - } - } + data class SymAction(val sym: KeySym, val states: KeyStates = KeyStates.Virtual) : KeyAction() - data class CommitAction(var text: String) : KeyAction() + data class CommitAction(val text: String) : KeyAction() data class CapsAction(val lock: Boolean) : KeyAction() @@ -39,5 +39,5 @@ sealed class KeyAction { data class PickerSwitchAction(val key: PickerWindow.Key? = null) : KeyAction() - data object SpaceLongPressAction: KeyAction() + data object SpaceLongPressAction : KeyAction() } diff --git a/app/src/main/java/org/fcitx/fcitx5/android/input/keyboard/KeyDrawable.kt b/app/src/main/java/org/fcitx/fcitx5/android/input/keyboard/KeyDrawable.kt new file mode 100644 index 000000000..f5ea1e1b5 --- /dev/null +++ b/app/src/main/java/org/fcitx/fcitx5/android/input/keyboard/KeyDrawable.kt @@ -0,0 +1,60 @@ +/* + * SPDX-License-Identifier: LGPL-2.1-or-later + * SPDX-FileCopyrightText: Copyright 2024 Fcitx5 for Android Contributors + */ + +package org.fcitx.fcitx5.android.input.keyboard + +import android.graphics.Color +import android.graphics.drawable.Drawable +import android.graphics.drawable.GradientDrawable +import android.graphics.drawable.InsetDrawable +import android.graphics.drawable.LayerDrawable +import androidx.annotation.ColorInt + +fun radiusDrawable( + r: Float, @ColorInt + color: Int = Color.WHITE +): Drawable = GradientDrawable().apply { + setColor(color) + cornerRadius = r +} + +fun insetRadiusDrawable( + hInset: Int, + vInset: Int, + r: Float = 0f, + @ColorInt color: Int = Color.WHITE +): Drawable = InsetDrawable( + radiusDrawable(r, color), + hInset, vInset, hInset, vInset +) + +fun insetOvalDrawable( + hInset: Int, + vInset: Int, + @ColorInt color: Int = Color.WHITE +): Drawable = InsetDrawable( + GradientDrawable().apply { + shape = GradientDrawable.OVAL + setColor(color) + }, + hInset, vInset, hInset, vInset +) + +fun borderedKeyBackgroundDrawable( + @ColorInt bkgColor: Int, + @ColorInt shadowColor: Int, + radius: Float, + shadowWidth: Int, + hMargin: Int, + vMargin: Int +): Drawable = LayerDrawable( + arrayOf( + radiusDrawable(radius, shadowColor), + radiusDrawable(radius, bkgColor), + ) +).apply { + setLayerInset(0, hMargin, vMargin, hMargin, vMargin - shadowWidth) + setLayerInset(1, hMargin, vMargin, hMargin, vMargin) +} diff --git a/app/src/main/java/org/fcitx/fcitx5/android/input/keyboard/KeyView.kt b/app/src/main/java/org/fcitx/fcitx5/android/input/keyboard/KeyView.kt index 5a19310e3..2252d7459 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/input/keyboard/KeyView.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/input/keyboard/KeyView.kt @@ -13,9 +13,7 @@ import android.graphics.Rect import android.graphics.Typeface import android.graphics.drawable.ColorDrawable import android.graphics.drawable.Drawable -import android.graphics.drawable.GradientDrawable import android.graphics.drawable.InsetDrawable -import android.graphics.drawable.LayerDrawable import android.graphics.drawable.RippleDrawable import android.graphics.drawable.StateListDrawable import android.util.TypedValue @@ -29,7 +27,7 @@ import androidx.core.view.updateLayoutParams import org.fcitx.fcitx5.android.R import org.fcitx.fcitx5.android.data.theme.Theme import org.fcitx.fcitx5.android.data.theme.ThemeManager -import org.fcitx.fcitx5.android.data.theme.ThemeManager.Prefs.PunctuationPosition +import org.fcitx.fcitx5.android.data.theme.ThemePrefs.PunctuationPosition import org.fcitx.fcitx5.android.input.AutoScaleTextView import org.fcitx.fcitx5.android.input.keyboard.KeyDef.Appearance.Border import org.fcitx.fcitx5.android.input.keyboard.KeyDef.Appearance.Variant @@ -119,29 +117,17 @@ abstract class KeyView(ctx: Context, val theme: Theme, val def: KeyDef.Appearanc } // key border if ((bordered && def.border != Border.Off) || def.border == Border.On) { - // background: key border - appearanceView.background = LayerDrawable( - arrayOf( - GradientDrawable().apply { - cornerRadius = radius - setColor(theme.keyShadowColor) - }, - GradientDrawable().apply { - cornerRadius = radius - setColor( - when (def.variant) { - Variant.Normal, Variant.AltForeground -> theme.keyBackgroundColor - Variant.Alternative -> theme.altKeyBackgroundColor - Variant.Accent -> theme.accentKeyBackgroundColor - } - ) - } - ) - ).apply { - val shadowWidth = dp(1) - setLayerInset(0, hMargin, vMargin, hMargin, vMargin - shadowWidth) - setLayerInset(1, hMargin, vMargin, hMargin, vMargin) + val bkgColor = when (def.variant) { + Variant.Normal, Variant.AltForeground -> theme.keyBackgroundColor + Variant.Alternative -> theme.altKeyBackgroundColor + Variant.Accent -> theme.accentKeyBackgroundColor } + val shadowWidth = dp(1) + // background: key border + appearanceView.background = borderedKeyBackgroundDrawable( + bkgColor, theme.keyShadowColor, + radius, shadowWidth, hMargin, vMargin + ) // foreground: press highlight or ripple setupPressHighlight() } else { @@ -155,13 +141,13 @@ abstract class KeyView(ctx: Context, val theme: Theme, val def: KeyDef.Appearanc } private fun setupPressHighlight(mask: Drawable? = null) { - appearanceView.foreground = if (rippled) + appearanceView.foreground = if (rippled) { RippleDrawable( ColorStateList.valueOf(theme.keyPressHighlightColor), null, // ripple should be masked with an opaque color mask ?: highlightMaskDrawable(Color.WHITE) ) - else + } else { StateListDrawable().apply { addState( intArrayOf(android.R.attr.state_pressed), @@ -169,16 +155,13 @@ abstract class KeyView(ctx: Context, val theme: Theme, val def: KeyDef.Appearanc mask ?: highlightMaskDrawable(theme.keyPressHighlightColor) ) } + } } - private fun highlightMaskDrawable(@ColorInt color: Int) = - InsetDrawable( - if (bordered) GradientDrawable().apply { - cornerRadius = radius - setColor(color) - } else ColorDrawable(color), - hMargin, vMargin, hMargin, vMargin - ) + private fun highlightMaskDrawable(@ColorInt color: Int): Drawable { + return if (bordered) insetRadiusDrawable(hMargin, vMargin, radius, color) + else InsetDrawable(ColorDrawable(color), hMargin, vMargin, hMargin, vMargin) + } override fun setEnabled(enabled: Boolean) { super.setEnabled(enabled) @@ -219,23 +202,16 @@ abstract class KeyView(ctx: Context, val theme: Theme, val def: KeyDef.Appearanc val minHeight = dp(26) val hInset = dp(10) val vInset = if (h < minHeight) 0 else min((h - minHeight) / 2, dp(16)) - appearanceView.background = InsetDrawable( - GradientDrawable().apply { - cornerRadius = bkgRadius - setColor(theme.spaceBarColor) - }, - hInset, vInset, hInset, vInset + appearanceView.background = insetRadiusDrawable( + hInset, vInset, bkgRadius, theme.spaceBarColor ) // InsetDrawable sets padding to container view; remove padding to prevent text from bing clipped appearanceView.padding = 0 // apply press highlight for background area setupPressHighlight( - InsetDrawable( - GradientDrawable().apply { - cornerRadius = bkgRadius - setColor(if (rippled) Color.WHITE else theme.keyPressHighlightColor) - }, - hInset, vInset, hInset, vInset + insetRadiusDrawable( + hInset, vInset, bkgRadius, + if (rippled) Color.WHITE else theme.keyPressHighlightColor ) ) } @@ -243,21 +219,13 @@ abstract class KeyView(ctx: Context, val theme: Theme, val def: KeyDef.Appearanc val drawableSize = min(min(w, h), dp(35)) val hInset = (w - drawableSize) / 2 val vInset = (h - drawableSize) / 2 - appearanceView.background = InsetDrawable( - GradientDrawable().apply { - shape = GradientDrawable.OVAL - setColor(theme.accentKeyBackgroundColor) - }, - hInset, vInset, hInset, vInset + appearanceView.background = insetOvalDrawable( + hInset, vInset, theme.accentKeyBackgroundColor ) appearanceView.padding = 0 setupPressHighlight( - InsetDrawable( - GradientDrawable().apply { - shape = GradientDrawable.OVAL - setColor(if (rippled) Color.WHITE else theme.keyPressHighlightColor) - }, - hInset, vInset, hInset, vInset + insetOvalDrawable( + hInset, vInset, if (rippled) Color.WHITE else theme.keyPressHighlightColor ) ) } @@ -330,6 +298,7 @@ class AltTextKeyView(ctx: Context, theme: Theme, def: KeyDef.Appearance.AltText) topToTop = parentId bottomToBottom = parentId } + altText.visibility = View.VISIBLE altText.updateLayoutParams { // reset bottomToBottom = unset; bottomMargin = 0 @@ -348,6 +317,7 @@ class AltTextKeyView(ctx: Context, theme: Theme, def: KeyDef.Appearance.AltText) topToTop = parentId; topMargin = vMargin bottomToTop = altText.existingOrNewId } + altText.visibility = View.VISIBLE altText.updateLayoutParams { // reset topToTop = unset; topMargin = 0 @@ -359,6 +329,18 @@ class AltTextKeyView(ctx: Context, theme: Theme, def: KeyDef.Appearance.AltText) } } + private fun applyNoAltTextPosition() { + mainText.updateLayoutParams { + // reset + topMargin = 0 + bottomToTop = unset + // set + topToTop = parentId + bottomToBottom = parentId + } + altText.visibility = View.GONE + } + private fun applyLayout(orientation: Int) { when (ThemeManager.prefs.punctuationPosition.getValue()) { PunctuationPosition.Bottom -> when (orientation) { @@ -366,6 +348,7 @@ class AltTextKeyView(ctx: Context, theme: Theme, def: KeyDef.Appearance.AltText) else -> applyBottomAltTextPosition() } PunctuationPosition.TopRight -> applyTopRightAltTextPosition() + PunctuationPosition.None -> applyNoAltTextPosition() } } diff --git a/app/src/main/java/org/fcitx/fcitx5/android/input/keyboard/KeyboardWindow.kt b/app/src/main/java/org/fcitx/fcitx5/android/input/keyboard/KeyboardWindow.kt index f54e1b22b..26dcded01 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/input/keyboard/KeyboardWindow.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/input/keyboard/KeyboardWindow.kt @@ -117,17 +117,18 @@ class KeyboardWindow : InputWindow.SimpleInputWindow(), Essentia } fun switchLayout(to: String, remember: Boolean = true) { - if (to == currentKeyboardName) return val target = to.ifEmpty { lastSymbolType } ContextCompat.getMainExecutor(service).execute { if (keyboards.containsKey(target)) { if (remember && target != TextKeyboard.Name) { lastSymbolType = target } + if (target == currentKeyboardName) return@execute detachCurrentLayout() attachLayout(target) - if (windowManager.isAttached(this)) + if (windowManager.isAttached(this)) { notifyBarLayoutChanged() + } } else { if (remember) { lastSymbolType = PickerWindow.Key.Symbol.name diff --git a/app/src/main/java/org/fcitx/fcitx5/android/input/keyboard/LangSwitchBehavior.kt b/app/src/main/java/org/fcitx/fcitx5/android/input/keyboard/LangSwitchBehavior.kt index ef743669a..d4b905471 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/input/keyboard/LangSwitchBehavior.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/input/keyboard/LangSwitchBehavior.kt @@ -4,14 +4,11 @@ */ package org.fcitx.fcitx5.android.input.keyboard -import org.fcitx.fcitx5.android.data.prefs.ManagedPreference +import org.fcitx.fcitx5.android.R +import org.fcitx.fcitx5.android.data.prefs.ManagedPreferenceEnum -enum class LangSwitchBehavior { - Enumerate, - ToggleActivate, - NextInputMethodApp; - - companion object : ManagedPreference.StringLikeCodec { - override fun decode(raw: String): LangSwitchBehavior = valueOf(raw) - } +enum class LangSwitchBehavior(override val stringRes: Int) : ManagedPreferenceEnum { + Enumerate(R.string.space_behavior_enumerate), + ToggleActivate(R.string.space_behavior_activate), + NextInputMethodApp(R.string.lang_switch_behavior_next_ime_app); } diff --git a/app/src/main/java/org/fcitx/fcitx5/android/input/keyboard/NumberKeyboard.kt b/app/src/main/java/org/fcitx/fcitx5/android/input/keyboard/NumberKeyboard.kt index 226bbaaec..89dff000a 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/input/keyboard/NumberKeyboard.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/input/keyboard/NumberKeyboard.kt @@ -9,6 +9,7 @@ import android.content.Context import org.fcitx.fcitx5.android.R import org.fcitx.fcitx5.android.data.theme.Theme import org.fcitx.fcitx5.android.input.picker.PickerWindow +import org.fcitx.fcitx5.android.input.popup.PopupAction import splitties.views.imageResource @SuppressLint("ViewConstructor") @@ -44,10 +45,10 @@ class NumberKeyboard( ), listOf( LayoutSwitchKey("ABC", TextKeyboard.Name), - SymbolKey(",", variant = KeyDef.Appearance.Variant.Alternative), + NumPadKey(",", 0xffac, 23f, 0.1f, KeyDef.Appearance.Variant.Alternative), LayoutSwitchKey("!?#", PickerWindow.Key.Symbol.name, 0.13333f, KeyDef.Appearance.Variant.AltForeground), NumPadKey("0", 0xffb0, 30f, 0.23334f), - SymbolKey("=", 0.13333f), + NumPadKey("=", 0xffbd, 23f, 0.13333f, KeyDef.Appearance.Variant.AltForeground), NumPadKey(".", 0xffae, 23f, 0.1f, KeyDef.Appearance.Variant.Alternative), ReturnKey() ) @@ -62,4 +63,9 @@ class NumberKeyboard( `return`.img.imageResource = returnDrawable } + @SuppressLint("MissingSuperCall") + override fun onPopupAction(action: PopupAction) { + // leave empty on purpose to disable popup in NumberKeyboard + } + } \ No newline at end of file diff --git a/app/src/main/java/org/fcitx/fcitx5/android/input/keyboard/SpaceLongPressBehavior.kt b/app/src/main/java/org/fcitx/fcitx5/android/input/keyboard/SpaceLongPressBehavior.kt index 116053a59..adea1fce6 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/input/keyboard/SpaceLongPressBehavior.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/input/keyboard/SpaceLongPressBehavior.kt @@ -4,15 +4,12 @@ */ package org.fcitx.fcitx5.android.input.keyboard -import org.fcitx.fcitx5.android.data.prefs.ManagedPreference +import org.fcitx.fcitx5.android.R +import org.fcitx.fcitx5.android.data.prefs.ManagedPreferenceEnum -enum class SpaceLongPressBehavior { - None, - Enumerate, - ToggleActivate, - ShowPicker; - - companion object : ManagedPreference.StringLikeCodec { - override fun decode(raw: String): SpaceLongPressBehavior = valueOf(raw) - } +enum class SpaceLongPressBehavior(override val stringRes: Int) : ManagedPreferenceEnum { + None(R.string.space_behavior_none), + Enumerate(R.string.space_behavior_enumerate), + ToggleActivate(R.string.space_behavior_activate), + ShowPicker(R.string.space_behavior_picker); } diff --git a/app/src/main/java/org/fcitx/fcitx5/android/input/keyboard/SwipeSymbolDirection.kt b/app/src/main/java/org/fcitx/fcitx5/android/input/keyboard/SwipeSymbolDirection.kt index ed22ba4f7..7ccf2315f 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/input/keyboard/SwipeSymbolDirection.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/input/keyboard/SwipeSymbolDirection.kt @@ -4,17 +4,14 @@ */ package org.fcitx.fcitx5.android.input.keyboard -import org.fcitx.fcitx5.android.data.prefs.ManagedPreference +import org.fcitx.fcitx5.android.R +import org.fcitx.fcitx5.android.data.prefs.ManagedPreferenceEnum -enum class SwipeSymbolDirection { - Up, - Down, - Disabled; +enum class SwipeSymbolDirection(override val stringRes: Int): ManagedPreferenceEnum { + Up(R.string.swipe_up), + Down(R.string.swipe_down), + Disabled(R.string.disabled); fun checkY(totalY: Int): Boolean = (this != Disabled) && (totalY != 0) && ((totalY > 0) == (this == Down)) - - companion object : ManagedPreference.StringLikeCodec { - override fun decode(raw: String): SwipeSymbolDirection = valueOf(raw) - } } diff --git a/app/src/main/java/org/fcitx/fcitx5/android/input/keyboard/TextKeyboard.kt b/app/src/main/java/org/fcitx/fcitx5/android/input/keyboard/TextKeyboard.kt index dc987c415..f3476aafb 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/input/keyboard/TextKeyboard.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/input/keyboard/TextKeyboard.kt @@ -11,6 +11,8 @@ import androidx.annotation.Keep import androidx.core.view.allViews import org.fcitx.fcitx5.android.R import org.fcitx.fcitx5.android.core.InputMethodEntry +import org.fcitx.fcitx5.android.core.KeyState +import org.fcitx.fcitx5.android.core.KeyStates import org.fcitx.fcitx5.android.data.prefs.AppPrefs import org.fcitx.fcitx5.android.data.prefs.ManagedPreference import org.fcitx.fcitx5.android.data.theme.Theme @@ -111,29 +113,40 @@ class TextKeyboard( private var punctuationMapping: Map = mapOf() private fun transformPunctuation(p: String) = punctuationMapping.getOrDefault(p, p) - private fun transformInputString(c: String): String { - if (c.length != 1) return c - if (c[0].isLetter()) return transformAlphabet(c) - return transformPunctuation(c) - } - override fun onAction(action: KeyAction, source: KeyActionListener.Source) { + var transformed = action when (action) { - is KeyAction.FcitxKeyAction -> if (source == KeyActionListener.Source.Keyboard) { - transformKeyAction(action) + is KeyAction.FcitxKeyAction -> when (source) { + KeyActionListener.Source.Keyboard -> { + when (capsState) { + CapsState.None -> { + transformed = action.copy(act = action.act.lowercase()) + } + CapsState.Once -> { + transformed = action.copy( + act = action.act.uppercase(), + states = KeyStates(KeyState.Virtual, KeyState.Shift) + ) + switchCapsState() + } + CapsState.Lock -> { + transformed = action.copy( + act = action.act.uppercase(), + states = KeyStates(KeyState.Virtual, KeyState.CapsLock) + ) + } + } + } + KeyActionListener.Source.Popup -> { + if (capsState == CapsState.Once) { + switchCapsState() + } + } } is KeyAction.CapsAction -> switchCapsState(action.lock) else -> {} } - super.onAction(action, source) - } - - private fun transformKeyAction(action: KeyAction.FcitxKeyAction) { - if (action.act.length > 1) { - return - } - action.act = transformAlphabet(action.act) - if (capsState == CapsState.Once) switchCapsState() + super.onAction(transformed, source) } override fun onAttach() { @@ -156,12 +169,21 @@ class TextKeyboard( append(ime.displayName) ime.subMode.run { label.ifEmpty { name.ifEmpty { null } } }?.let { append(" ($it)") } } + if (capsState != CapsState.None) { + switchCapsState() + } + } + + private fun transformPopupPreview(c: String): String { + if (c.length != 1) return c + if (c[0].isLetter()) return transformAlphabet(c) + return transformPunctuation(c) } override fun onPopupAction(action: PopupAction) { val newAction = when (action) { - is PopupAction.PreviewAction -> action.copy(content = transformInputString(action.content)) - is PopupAction.PreviewUpdateAction -> action.copy(content = transformInputString(action.content)) + is PopupAction.PreviewAction -> action.copy(content = transformPopupPreview(action.content)) + is PopupAction.PreviewUpdateAction -> action.copy(content = transformPopupPreview(action.content)) is PopupAction.ShowKeyboardAction -> { val label = action.keyboard.label if (label.length == 1 && label[0].isLetter()) @@ -174,13 +196,18 @@ class TextKeyboard( } private fun switchCapsState(lock: Boolean = false) { - capsState = if (lock) when (capsState) { - CapsState.Lock -> CapsState.None - else -> CapsState.Lock - } else when (capsState) { - CapsState.None -> CapsState.Once - else -> CapsState.None - } + capsState = + if (lock) { + when (capsState) { + CapsState.Lock -> CapsState.None + else -> CapsState.Lock + } + } else { + when (capsState) { + CapsState.None -> CapsState.Once + else -> CapsState.None + } + } updateCapsButtonIcon() updateAlphabetKeys() } diff --git a/app/src/main/java/org/fcitx/fcitx5/android/input/picker/PickerData.kt b/app/src/main/java/org/fcitx/fcitx5/android/input/picker/PickerData.kt index 9818ed4ba..67545fe64 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/input/picker/PickerData.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/input/picker/PickerData.kt @@ -1,6 +1,6 @@ /* * SPDX-License-Identifier: LGPL-2.1-or-later - * SPDX-FileCopyrightText: Copyright 2021-2023 Fcitx5 for Android Contributors + * SPDX-FileCopyrightText: Copyright 2021-2025 Fcitx5 for Android Contributors */ package org.fcitx.fcitx5.android.input.picker @@ -8,7 +8,7 @@ import org.fcitx.fcitx5.android.R object PickerData { - class Category(val label: String = "", val icon: Int = 0) + data class Category(val label: String = "", val icon: Int = 0) val RecentlyUsedCategory = Category("⟳", R.drawable.ic_baseline_access_time_24) @@ -19,7 +19,7 @@ object PickerData { "'", "\"", "=", "_", "`", ":", ";", "?", "~", "|", "+", "-", "\\", "/", "[", "]", "{", "}", "<", ">", "“", "”", "·", "‘", "’", "¡", "¿", "¥", - "€", "£", "¢", "©", "®", "™", "℃", "℉", + "€", "£", "¢", "©", "®", "℗", "™", "℠", "°", "§", "№", "†", "‡", "‥", "…", "‰", "※", "‾", "⁄", "‼", "⁇", "⁈", "⁉", "√", "π", "±", "×", "÷", "¶", "∆", "¤", "µ", "‹", "›", "«", "»" @@ -97,19 +97,19 @@ object PickerData { "😀", "😃", "😄", "😁", "😆", "😅", "🤣", "😂", "🙂", "🙃", "🫠", "😉", "😊", "😇", "🥰", "😍", "🤩", "😘", "😗", "☺️", "😚", "😙", "🥲", "😋", "😛", "😜", "🤪", "😝", "🤑", "🤗", "🤭", "🫢", "🫣", "🤫", "🤔", "🫡", "🤐", "🤨", "😐", - "😑", "😶", "🫥", "😶‍🌫️", "😏", "😒", "🙄", "😬", "😮‍💨", "🤥", "😌", "😔", "😪", - "🤤", "😴", "😷", "🤒", "🤕", "🤢", "🤮", "🤧", "🥵", "🥶", "🥴", "😵", "😵‍💫", + "😑", "😶", "🫥", "😶‍🌫️", "😏", "😒", "🙄", "😬", "😮‍💨", "🤥", "😌", "😔", "🙂‍↕️", "🙂‍↔️", "😪", + "🤤", "😴", "😷", "🤒", "🤕", "🤢", "🤮", "🤧", "🥵", "🥶", "🥴", "😵", "😵‍💫", "🫨", "🫩", "🤯", "🤠", "🥳", "🥸", "😎", "🤓", "🧐", "😕", "🫤", "😟", "🙁", "☹️", "😮", "😯", "😲", "😳", "🥺", "🥹", "😦", "😧", "😨", "😰", "😥", "😢", "😭", "😱", "😖", "😣", "😞", "😓", "😩", "😫", "🥱", "😤", "😡", "😠", "🤬", "😈", "👿", "💀", "☠️", "💩", "🤡", "👹", "👺", "👻", "👽", "👾", "🤖", "😺", "😸", "😹", "😻", "😼", "😽", "🙀", "😿", "😾", "🙈", "🙉", "🙊", "💋", "💌", "💘", "💝", "💖", "💗", "💓", "💞", "💕", "💟", "❣️", "💔", "❤️‍🔥", "❤️‍🩹", "❤️", "🧡", "💛", - "💚", "💙", "💜", "🤎", "🖤", "🤍", "💯", "💢", "💥", "💫", "💦", "💨", "🕳️", + "💚", "💙", "💜", "🤎", "🖤", "🤍", "🩷", "🩵", "🩶", "💯", "💢", "💥", "💫", "💦", "💨", "🕳️", "💣", "💬", "👁️‍🗨️", "🗨️", "🗯️", "💭", "💤", ), Category("🧑", R.drawable.ic_baseline_emoji_people_24) to arrayOf( - "👋", "🤚", "🖐️", "✋", "🖖", "🫱", "🫲", "🫳", "🫴", "👌", "🤌", "🤏", "✌️", + "👋", "🤚", "🖐️", "✋", "🖖", "🫱", "🫲", "🫸", "🫷", "🫳", "🫴", "👌", "🤌", "🤏", "✌️", "🤞", "🫰", "🤟", "🤘", "🤙", "👈", "👉", "👆", "🖕", "👇", "☝️", "🫵", "👍", "👎", "✊", "👊", "🤛", "🤜", "👏", "🙌", "🫶", "👐", "🤲", "🤝", "🙏", "✍️", "💅", "🤳", "💪", "🦾", "🦿", "🦵", "🦶", "👂", "🦻", "👃", "🧠", "🫀", "🫁", @@ -127,8 +127,8 @@ object PickerData { "🤱", "👩‍🍼", "👨‍🍼", "🧑‍🍼", "👼", "🎅", "🤶", "🧑‍🎄", "🦸", "🦸‍♂️", "🦸‍♀️", "🦹", "🦹‍♂️", "🦹‍♀️", "🧙", "🧙‍♂️", "🧙‍♀️", "🧚", "🧚‍♂️", "🧚‍♀️", "🧛", "🧛‍♂️", "🧛‍♀️", "🧜", "🧜‍♂️", "🧜‍♀️", "🧝", "🧝‍♂️", "🧝‍♀️", "🧞", "🧞‍♂️", "🧞‍♀️", "🧟", "🧟‍♂️", "🧟‍♀️", "🧌", "💆", "💆‍♂️", "💆‍♀️", - "💇", "💇‍♂️", "💇‍♀️", "🚶", "🚶‍♂️", "🚶‍♀️", "🧍", "🧍‍♂️", "🧍‍♀️", "🧎", "🧎‍♂️", "🧎‍♀️", "🧑‍🦯", - "👨‍🦯", "👩‍🦯", "🧑‍🦼", "👨‍🦼", "👩‍🦼", "🧑‍🦽", "👨‍🦽", "👩‍🦽", "🏃", "🏃‍♂️", "🏃‍♀️", "💃", "🕺", + "💇", "💇‍♂️", "💇‍♀️", "🚶", "🚶‍♂️", "🚶‍♀️", "🚶‍➡️", "🚶‍♀️‍➡️", "🚶‍♂️‍➡️", "🧍", "🧍‍♂️", "🧍‍♀️", "🧎", "🧎‍♂️", "🧎‍♀️", "🧎‍➡️", "🧎‍♀️‍➡️", "🧎‍♂️‍➡️", "🧑‍🦯", + "👨‍🦯", "👩‍🦯", "🧑‍🦯‍➡️", "👨‍🦯‍➡️", "👩‍🦯‍➡️", "🧑‍🦽", "👨‍🦽", "👩‍🦽", "🧑‍🦽‍➡️", "👨‍🦽‍➡️", "👩‍🦽‍➡️", "🧑‍🦼", "👨‍🦼", "👩‍🦼", "🧑‍🦼‍➡️", "👨‍🦼‍➡️", "👩‍🦼‍➡️", "🏃", "🏃‍♂️", "🏃‍♀️", "🏃‍➡️", "🏃‍♂️‍➡️", "🏃‍♀️‍➡️", "💃", "🕺", "🕴️", "👯", "👯‍♂️", "👯‍♀️", "🧖", "🧖‍♂️", "🧖‍♀️", "🧗", "🧗‍♂️", "🧗‍♀️", "🤺", "🏇", "⛷️", "🏂", "🏌️", "🏌️‍♂️", "🏌️‍♀️", "🏄", "🏄‍♂️", "🏄‍♀️", "🚣", "🚣‍♂️", "🚣‍♀️", "🏊", "🏊‍♂️", "🏊‍♀️", "⛹️", "⛹️‍♂️", "⛹️‍♀️", "🏋️", "🏋️‍♂️", "🏋️‍♀️", "🚴", "🚴‍♂️", "🚴‍♀️", "🚵", "🚵‍♂️", "🚵‍♀️", "🤸", @@ -136,26 +136,26 @@ object PickerData { "🤹‍♀️", "🧘", "🧘‍♂️", "🧘‍♀️", "🛀", "🛌", "🧑‍🤝‍🧑", "👭", "👫", "👬", "💏", "👩‍❤️‍💋‍👨", "👨‍❤️‍💋‍👨", "👩‍❤️‍💋‍👩", "💑", "👩‍❤️‍👨", "👨‍❤️‍👨", "👩‍❤️‍👩", "👪", "👨‍👩‍👦", "👨‍👩‍👧", "👨‍👩‍👧‍👦", "👨‍👩‍👦‍👦", "👨‍👩‍👧‍👧", "👨‍👨‍👦", "👨‍👨‍👧", "👨‍👨‍👧‍👦", "👨‍👨‍👦‍👦", "👨‍👨‍👧‍👧", "👩‍👩‍👦", "👩‍👩‍👧", "👩‍👩‍👧‍👦", "👩‍👩‍👦‍👦", "👩‍👩‍👧‍👧", "👨‍👦", "👨‍👦‍👦", "👨‍👧", "👨‍👧‍👦", "👨‍👧‍👧", - "👩‍👦", "👩‍👦‍👦", "👩‍👧", "👩‍👧‍👦", "👩‍👧‍👧", "🗣️", "👤", "👥", "🫂", "👣" + "👩‍👦", "👩‍👦‍👦", "👩‍👧", "👩‍👧‍👦", "👩‍👧‍👧", "🧑‍🧑‍🧒", "🧑‍🧑‍🧒‍🧒", "🧑‍🧒", "🧑‍🧒‍🧒", "🗣️", "👤", "👥", "🫂", "👣" ), Category("🌸", R.drawable.ic_baseline_flower_24) to arrayOf( "🐵", "🐒", "🦍", "🦧", "🐶", "🐕", "🦮", "🐕‍🦺", "🐩", "🐺", "🦊", "🦝", "🐱", "🐈", "🐈‍⬛", "🦁", "🐯", "🐅", "🐆", "🐴", "🐎", "🦄", "🦓", "🦌", "🦬", "🐮", "🐂", "🐃", "🐄", "🐷", "🐖", "🐗", "🐽", "🐏", "🐑", "🐐", "🐪", "🐫", "🦙", "🦒", "🐘", "🦣", "🦏", "🦛", "🐭", "🐁", "🐀", "🐹", "🐰", "🐇", "🐿️", "🦫", - "🦔", "🦇", "🐻", "🐻‍❄️", "🐨", "🐼", "🦥", "🦦", "🦨", "🦘", "🦡", "🐾", "🦃", - "🐔", "🐓", "🐣", "🐤", "🐥", "🐦", "🐧", "🕊️", "🦅", "🦆", "🦢", "🦉", "🦤", - "🪶", "🦩", "🦚", "🦜", "🐸", "🐊", "🐢", "🦎", "🐍", "🐲", "🐉", "🦕", "🦖", - "🐳", "🐋", "🐬", "🦭", "🐟", "🐠", "🐡", "🦈", "🐙", "🐚", "🪸", "🐌", "🦋", + "🦔", "🦇", "🐻", "🐻‍❄️", "🐨", "🐼", "🫎", "🫏", "🦥", "🦦", "🦨", "🦘", "🦡", "🐾", "🦃", + "🐔", "🐓", "🐣", "🐤", "🐥", "🐦", "🐦‍⬛", "🐧", "🕊️", "🦅", "🦆", "🦢", "🦉", "🦤", "🪿", + "🪽", "🪶", "🦩", "🦚", "🐦‍🔥", "🦜", "🐸", "🐊", "🐢", "🦎", "🐍", "🐲", "🐉", "🦕", "🦖", + "🐳", "🐋", "🐬", "🦭", "🐟", "🐠", "🐡", "🦈", "🐙", "🪼", "🐚", "🪸", "🐌", "🦋", "🐛", "🐜", "🐝", "🪲", "🐞", "🦗", "🪳", "🕷️", "🕸️", "🦂", "🦟", "🪰", "🪱", - "🦠", "💐", "🌸", "💮", "🪷", "🏵️", "🌹", "🥀", "🌺", "🌻", "🌼", "🌷", "🌱", - "🪴", "🌲", "🌳", "🌴", "🌵", "🌾", "🌿", "☘️", "🍀", "🍁", "🍂", "🍃", "🪹", + "🦠", "💐", "🌸", "💮", "🪷", "🏵️", "🌹", "🥀", "🌺", "🌻", "🌼", "🌷", "🪻", "🌱", + "🪴", "🌲", "🌳", "🌴", "🪾", "🌵", "🌾", "🌿", "☘️", "🍀", "🍁", "🍂", "🍃", "🪹", "🪺" ), - Category("🎂", R.drawable.ic_baseline_cake_24) to arrayOf ( - "🍇", "🍈", "🍉", "🍊", "🍋", "🍌", "🍍", "🥭", "🍎", "🍏", "🍐", "🍑", "🍒", - "🍓", "🫐", "🥝", "🍅", "🫒", "🥥", "🥑", "🍆", "🥔", "🥕", "🌽", "🌶️", "🫑", - "🥒", "🥬", "🥦", "🧄", "🧅", "🍄", "🥜", "🫘", "🌰", "🍞", "🥐", "🥖", "🫓", + Category("🎂", R.drawable.ic_baseline_cake_24) to arrayOf( + "🍇", "🍈", "🍉", "🍊", "🍋", "🍋‍🟩", "🍌", "🍍", "🥭", "🍎", "🍏", "🍐", "🍑", "🍒", + "🍓", "🫐", "🥝", "🍅", "🫒", "🥥", "🥑", "🍆", "🥔", "🥕", "🌽", "🌶️", "🫑", "🫛", + "🥒", "🥬", "🥦", "🧄", "🧅", "🫚", "🫜", "🍄", "🍄‍🟫", "🥜", "🫘", "🌰", "🍞", "🥐", "🥖", "🫓", "🥨", "🥯", "🥞", "🧇", "🧀", "🍖", "🍗", "🥩", "🥓", "🍔", "🍟", "🍕", "🌭", "🥪", "🌮", "🌯", "🫔", "🥙", "🧆", "🥚", "🍳", "🥘", "🍲", "🫕", "🥣", "🥗", "🍿", "🧈", "🧂", "🥫", "🍱", "🍘", "🍙", "🍚", "🍛", "🍜", "🍝", "🍠", "🍢", @@ -165,7 +165,7 @@ object PickerData { "🥂", "🥃", "🫗", "🥤", "🧋", "🧃", "🧉", "🧊", "🥢", "🍽️", "🍴", "🥄", "🔪", "🫙", "🏺" ), - Category("🚘", R.drawable.ic_baseline_directions_car_24) to arrayOf ( + Category("🚘", R.drawable.ic_baseline_directions_car_24) to arrayOf( "🌍", "🌎", "🌏", "🌐", "🗺️", "🗾", "🧭", "🏔️", "⛰️", "🌋", "🗻", "🏕️", "🏖️", "🏜️", "🏝️", "🏞️", "🏟️", "🏛️", "🏗️", "🧱", "🪨", "🪵", "🛖", "🏘️", "🏚️", "🏠", "🏡", "🏢", "🏣", "🏤", "🏥", "🏦", "🏨", "🏩", "🏪", "🏫", "🏬", "🏭", "🏯", @@ -184,7 +184,7 @@ object PickerData { "🌤️", "🌥️", "🌦️", "🌧️", "🌨️", "🌩️", "🌪️", "🌫️", "🌬️", "🌀", "🌈", "🌂", "☂️", "☔", "⛱️", "⚡", "❄️", "☃️", "⛄", "☄️", "🔥", "💧", "🌊" ), - Category("⚽", R.drawable.ic_baseline_sports_basketball_24) to arrayOf ( + Category("⚽", R.drawable.ic_baseline_sports_basketball_24) to arrayOf( "🎃", "🎄", "🎆", "🎇", "🧨", "✨", "🎈", "🎉", "🎊", "🎋", "🎍", "🎎", "🎏", "🎐", "🎑", "🧧", "🎀", "🎁", "🎗️", "🎟️", "🎫", "🎖️", "🏆", "🏅", "🥇", "🥈", "🥉", "⚽", "⚾", "🥎", "🏀", "🏐", "🏈", "🏉", "🎾", "🥏", "🎳", "🏏", "🏑", @@ -193,13 +193,13 @@ object PickerData { "🎲", "🧩", "🧸", "🪅", "🪩", "🪆", "♠️", "♥️", "♦️", "♣️", "♟️", "🃏", "🀄", "🎴", "🎭", "🖼️", "🎨", "🧵", "🪡", "🧶", "🪢" ), - Category("💡", R.drawable.ic_baseline_emoji_objects_24) to arrayOf ( + Category("💡", R.drawable.ic_baseline_emoji_objects_24) to arrayOf( "👓", "🕶️", "🥽", "🥼", "🦺", "👔", "👕", "👖", "🧣", "🧤", "🧥", "🧦", "👗", "👘", "🥻", "🩱", "🩲", "🩳", "👙", "👚", "👛", "👜", "👝", "🛍️", "🎒", "🩴", "👞", "👟", "🥾", "🥿", "👠", "👡", "🩰", "👢", "👑", "👒", "🎩", "🎓", "🧢", - "🪖", "⛑️", "📿", "💄", "💍", "💎", "🔇", "🔈", "🔉", "🔊", "📢", "📣", "📯", + "🪖", "⛑️", "📿", "💄", "🪭", "💍", "💎", "🔇", "🔈", "🔉", "🔊", "📢", "📣", "📯", "🔔", "🔕", "🎼", "🎵", "🎶", "🎙️", "🎚️", "🎛️", "🎤", "🎧", "📻", "🎷", "🪗", - "🎸", "🎹", "🎺", "🎻", "🪕", "🥁", "🪘", "📱", "📲", "☎️", "📞", "📟", "📠", + "🎸", "🎹", "🎺", "🎻", "🪕", "🪉", "🥁", "🪘", "🪇", "🪈", "📱", "📲", "☎️", "📞", "📟", "📠", "🔋", "🪫", "🔌", "💻", "🖥️", "🖨️", "⌨️", "🖱️", "🖲️", "💽", "💾", "💿", "📀", "🧮", "🎥", "🎞️", "📽️", "🎬", "📺", "📷", "📸", "📹", "📼", "🔍", "🔎", "🕯️", "💡", "🔦", "🏮", "🪔", "📔", "📕", "📖", "📗", "📘", "📙", "📚", "📓", "📒", @@ -209,21 +209,21 @@ object PickerData { "📁", "📂", "🗂️", "📅", "📆", "🗒️", "🗓️", "📇", "📈", "📉", "📊", "📋", "📌", "📍", "📎", "🖇️", "📏", "📐", "✂️", "🗃️", "🗄️", "🗑️", "🔒", "🔓", "🔏", "🔐", "🔑", "🗝️", "🔨", "🪓", "⛏️", "⚒️", "🛠️", "🗡️", "⚔️", "🔫", "🪃", "🏹", "🛡️", - "🪚", "🔧", "🪛", "🔩", "⚙️", "🗜️", "⚖️", "🦯", "🔗", "⛓️", "🪝", "🧰", "🧲", + "🪚", "🔧", "🪛", "🔩", "⚙️", "🗜️", "⚖️", "🦯", "🔗", "⛓️", "⛓️‍💥", "🪝", "🧰", "🧲", "🪜", "⚗️", "🧪", "🧫", "🧬", "🔬", "🔭", "📡", "💉", "🩸", "💊", "🩹", "🩼", "🩺", "🩻", "🚪", "🛗", "🪞", "🪟", "🛏️", "🛋️", "🪑", "🚽", "🪠", "🚿", "🛁", - "🪤", "🪒", "🧴", "🧷", "🧹", "🧺", "🧻", "🪣", "🧼", "🫧", "🪥", "🧽", "🧯", + "🪤", "🪒", "🧴", "🧷", "🧹", "🪏", "🧺", "🧻", "🪣", "🧼", "🫧", "🫟", "🪥", "🪮", "🧽", "🧯", "🛒", "🚬", "⚰️", "🪦", "⚱️", "🗿", "🪧", "🪪" ), - Category("🔣", R.drawable.ic_baseline_emoji_symbols_24) to arrayOf( + Category("🔣", R.drawable.ic_baseline_emoji_symbols_24) to arrayOf( "🏧", "🚮", "🚰", "♿", "🚹", "🚺", "🚻", "🚼", "🚾", "🛂", "🛃", "🛄", "🛅", "⚠️", "🚸", "⛔", "🚫", "🚳", "🚭", "🚯", "🚱", "🚷", "📵", "🔞", "☢️", "☣️", "⬆️", "↗️", "➡️", "↘️", "⬇️", "↙️", "⬅️", "↖️", "↕️", "↔️", "↩️", "↪️", "⤴️", "⤵️", "🔃", "🔄", "🔙", "🔚", "🔛", "🔜", "🔝", "🛐", "⚛️", "🕉️", "✡️", "☸️", "☯️", - "✝️", "☦️", "☪️", "☮️", "🕎", "🔯", "♈", "♉", "♊", "♋", "♌", "♍", "♎", + "✝️", "☦️", "☪️", "☮️", "🕎", "🪯", "🔯", "♈", "♉", "♊", "♋", "♌", "♍", "♎", "♏", "♐", "♑", "♒", "♓", "⛎", "🔀", "🔁", "🔂", "▶️", "⏩", "⏭️", "⏯️", "◀️", "⏪", "⏮️", "🔼", "⏫", "🔽", "⏬", "⏸️", "⏹️", "⏺️", "⏏️", "🎦", "🔅", - "🔆", "📶", "📳", "📴", "♀️", "♂️", "⚧️", "✖️", "➕", "➖", "➗", "🟰", "♾️", + "🔆", "📶", "📳", "📴", "🛜", "🫆", "♀️", "♂️", "⚧️", "✖️", "➕", "➖", "➗", "🟰", "♾️", "‼️", "⁉️", "❓", "❔", "❕", "❗", "〰️", "💱", "💲", "⚕️", "♻️", "⚜️", "🔱", "📛", "🔰", "⭕", "✅", "☑️", "✔️", "❌", "❎", "➰", "➿", "〽️", "✳️", "✴️", "❇️", "©️", "®️", "™️", "#️⃣", "*️⃣", "0️⃣", "1️⃣", "2️⃣", "3️⃣", "4️⃣", "5️⃣", "6️⃣", @@ -239,7 +239,7 @@ object PickerData { "🇦🇮", "🇦🇱", "🇦🇲", "🇦🇴", "🇦🇶", "🇦🇷", "🇦🇸", "🇦🇹", "🇦🇺", "🇦🇼", "🇦🇽", "🇦🇿", "🇧🇦", "🇧🇧", "🇧🇩", "🇧🇪", "🇧🇫", "🇧🇬", "🇧🇭", "🇧🇮", "🇧🇯", "🇧🇱", "🇧🇲", "🇧🇳", "🇧🇴", "🇧🇶", "🇧🇷", "🇧🇸", "🇧🇹", "🇧🇻", "🇧🇼", "🇧🇾", "🇧🇿", "🇨🇦", "🇨🇨", "🇨🇩", "🇨🇫", "🇨🇬", "🇨🇭", - "🇨🇮", "🇨🇰", "🇨🇱", "🇨🇲", "🇨🇳", "🇨🇴", "🇨🇵", "🇨🇷", "🇨🇺", "🇨🇻", "🇨🇼", "🇨🇽", "🇨🇾", + "🇨🇮", "🇨🇰", "🇨🇱", "🇨🇲", "🇨🇳", "🇨🇴", "🇨🇵", "🇨🇶", "🇨🇷", "🇨🇺", "🇨🇻", "🇨🇼", "🇨🇽", "🇨🇾", "🇨🇿", "🇩🇪", "🇩🇬", "🇩🇯", "🇩🇰", "🇩🇲", "🇩🇴", "🇩🇿", "🇪🇦", "🇪🇨", "🇪🇪", "🇪🇬", "🇪🇭", "🇪🇷", "🇪🇸", "🇪🇹", "🇪🇺", "🇫🇮", "🇫🇯", "🇫🇰", "🇫🇲", "🇫🇴", "🇫🇷", "🇬🇦", "🇬🇧", "🇬🇩", "🇬🇪", "🇬🇫", "🇬🇬", "🇬🇭", "🇬🇮", "🇬🇱", "🇬🇲", "🇬🇳", "🇬🇵", "🇬🇶", "🇬🇷", "🇬🇸", "🇬🇹", @@ -335,7 +335,7 @@ object PickerData { "(● ̄(エ) ̄●)", "ε=ε=(ノ≧∇≦)ノ", "(´・_・`)", "(-_-#)", "( ̄へ ̄)", "( ̄ε(# ̄) Σ", "ヽ(`Д´)ノ", "(#-_-)┯━┯", "(╯°口°)╯(┴—┴", "←◡←", "( ♥д♥)", "Σ>―(〃°ω°〃)♡→", - "⁄(⁄ ⁄•⁄ω⁄•⁄ ⁄)⁄", "(╬゚д゚)▄︻┻┳═一", "・*・:≡( ε:)" + "⁄(⁄ ⁄•⁄ω⁄•⁄ ⁄)⁄", "(╬゚д゚)▄︻┻┳═一", "・*・:≡( ε:)", "¯\\_(ツ)_/¯" ) ) } \ No newline at end of file diff --git a/app/src/main/java/org/fcitx/fcitx5/android/input/picker/PickerLayout.kt b/app/src/main/java/org/fcitx/fcitx5/android/input/picker/PickerLayout.kt index 59fb256e6..248fbb13b 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/input/picker/PickerLayout.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/input/picker/PickerLayout.kt @@ -12,7 +12,13 @@ import org.fcitx.fcitx5.android.R import org.fcitx.fcitx5.android.data.theme.Theme import org.fcitx.fcitx5.android.input.keyboard.* import splitties.dimensions.dp -import splitties.views.dsl.constraintlayout.* +import splitties.views.dsl.constraintlayout.above +import splitties.views.dsl.constraintlayout.below +import splitties.views.dsl.constraintlayout.bottomOfParent +import splitties.views.dsl.constraintlayout.centerHorizontally +import splitties.views.dsl.constraintlayout.lParams +import splitties.views.dsl.constraintlayout.matchConstraints +import splitties.views.dsl.constraintlayout.topOfParent import splitties.views.dsl.core.add import splitties.views.dsl.core.view import splitties.views.imageResource diff --git a/app/src/main/java/org/fcitx/fcitx5/android/input/picker/PickerPageUi.kt b/app/src/main/java/org/fcitx/fcitx5/android/input/picker/PickerPageUi.kt index 422c7c834..d36086d97 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/input/picker/PickerPageUi.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/input/picker/PickerPageUi.kt @@ -1,11 +1,14 @@ /* * SPDX-License-Identifier: LGPL-2.1-or-later - * SPDX-FileCopyrightText: Copyright 2021-2023 Fcitx5 for Android Contributors + * SPDX-FileCopyrightText: Copyright 2021-2025 Fcitx5 for Android Contributors */ package org.fcitx.fcitx5.android.input.picker import android.content.Context +import android.view.View import android.view.ViewGroup +import androidx.constraintlayout.widget.ConstraintLayout +import androidx.core.view.updateLayoutParams import org.fcitx.fcitx5.android.R import org.fcitx.fcitx5.android.core.FcitxKeyMapping import org.fcitx.fcitx5.android.core.KeySym @@ -26,6 +29,7 @@ import org.fcitx.fcitx5.android.input.keyboard.KeyDef.Appearance.Border import org.fcitx.fcitx5.android.input.keyboard.KeyDef.Appearance.Variant import org.fcitx.fcitx5.android.input.keyboard.KeyView import org.fcitx.fcitx5.android.input.keyboard.TextKeyView +import org.fcitx.fcitx5.android.input.popup.EmojiModifier import org.fcitx.fcitx5.android.input.popup.PopupAction import org.fcitx.fcitx5.android.input.popup.PopupActionListener import splitties.views.dsl.constraintlayout.below @@ -43,35 +47,33 @@ import splitties.views.dsl.core.Ui import splitties.views.dsl.core.add import splitties.views.dsl.core.matchParent -class PickerPageUi(override val ctx: Context, val theme: Theme, private val density: Density) : Ui { +class PickerPageUi( + override val ctx: Context, + theme: Theme, + density: Density, + bordered: Boolean = false +) : Ui { enum class Density( val pageSize: Int, val columnCount: Int, val rowCount: Int, val textSize: Float, + val autoScale: Boolean, val showBackspace: Boolean ) { // symbol: 10/10/8, backspace on bottom right - High(28, 10, 3, 19f, true), + High(28, 10, 3, 19f, false, true), // emoji: 7/7/6, backspace on bottom right - Medium(20, 7, 3, 23.7f, true), + Medium(20, 7, 3, 23.7f, false, true), // emoticon: 4/4/4, no backspace - Low(12, 4, 3, 19f, false) + Low(12, 4, 3, 19f, true, false) } companion object { - val BackspaceAppearance = Appearance.Image( - src = R.drawable.ic_baseline_backspace_24, - variant = Variant.Alternative, - border = Border.Off, - viewId = R.id.button_backspace - ) - val BackspaceAction = SymAction(KeySym(FcitxKeyMapping.FcitxKey_BackSpace)) - private var popupOnKeyPress by AppPrefs.getInstance().keyboard.popupOnKeyPress } @@ -82,12 +84,12 @@ class PickerPageUi(override val ctx: Context, val theme: Theme, private val dens displayText = "", textSize = density.textSize, variant = Variant.Normal, - border = Border.Off + border = if (bordered) Border.On else Border.Off ) private val keyViews = Array(density.pageSize) { TextKeyView(ctx, theme, keyAppearance).apply { - if (density == Density.Low) { + if (density.autoScale) { mainText.apply { scaleMode = AutoScaleTextView.Mode.Proportional setPadding(hMargin, vMargin, hMargin, vMargin) @@ -96,91 +98,59 @@ class PickerPageUi(override val ctx: Context, val theme: Theme, private val dens } } - private val backspaceKey = ImageKeyView(ctx, theme, BackspaceAppearance).apply { - setOnClickListener { onBackspaceClick() } - repeatEnabled = true - onRepeatListener = { onBackspaceClick() } - } + private val backspaceAppearance = Appearance.Image( + src = R.drawable.ic_baseline_backspace_24, + variant = Variant.Alternative, + border = if (bordered) Border.On else Border.Off, + viewId = R.id.button_backspace + ) - private fun onBackspaceClick() { - keyActionListener?.onKeyAction(BackspaceAction, Source.Keyboard) + private val backspaceKey by lazy { + val action: (View) -> Unit = { + keyActionListener?.onKeyAction(BackspaceAction, Source.Keyboard) + } + val listener = View.OnClickListener { action.invoke(it) } + ImageKeyView(ctx, theme, backspaceAppearance).apply { + setOnClickListener(listener) + repeatEnabled = true + onRepeatListener = action + } } override val root = constraintLayout { val columnCount = density.columnCount val rowCount = density.rowCount val keyWidth = 1f / columnCount - when (density) { - Density.High -> { - keyViews.forEachIndexed { i, keyView -> - val row = i / columnCount - val column = i % columnCount - add(keyView, lParams { - // layout_constraintTop_to - if (row == 0) { - // first row, align top to top of parent - topOfParent() - } else { - // not first row, align top to bottom of first view in last row - topToBottomOf(keyViews[(row - 1) * columnCount]) - } - // layout_constraintBottom_to - if (row == rowCount - 1) { - // last row, align bottom to bottom of parent - bottomOfParent() - } else { - // not last row, align bottom to top of first view in next row - bottomToTopOf(keyViews[(row + 1) * columnCount]) - } - // layout_constraintRight_to - if (i == keyViews.size - 1) { - // last key (likely not last column), align end to start of backspace button - rightToLeftOf(backspaceKey) - } else if (column == columnCount - 1) { - // last column, align end to end of parent - rightOfParent() - } else { - // neither, align end to start of next view - rightToLeftOf(keyViews[i + 1]) - } - matchConstraintPercentWidth = keyWidth - }) + keyViews.forEachIndexed { i, keyView -> + val row = i / columnCount + val column = i % columnCount + add(keyView, lParams { + // layout_constraintTop_to + if (row == 0) { + // first row, align top to top of parent + topOfParent() + } else { + // not first row, align top to bottom of first view in last row + topToBottomOf(keyViews[(row - 1) * columnCount]) } - } - - Density.Medium, Density.Low -> { - keyViews.forEachIndexed { i, keyView -> - val row = i / columnCount - val column = i % columnCount - add(keyView, lParams { - // layout_constraintTop_to - if (row == 0) { - // first row, align top to top of parent - topOfParent() - } else { - // not first row, align top to bottom of first view in last row - topToBottomOf(keyViews[(row - 1) * columnCount]) - } - // layout_constraintBottom_to - if (row == rowCount - 1) { - // last row, align bottom to bottom of parent - bottomOfParent() - } else { - // not last row, align bottom to top of first view in next row - bottomToTopOf(keyViews[(row + 1) * columnCount]) - } - // layout_constraintLeft_to - if (column == 0) { - // first column, align start to start of parent - leftOfParent() - } else { - // not first column, align start to end of last column - leftToRightOf(keyViews[i - 1]) - } - matchConstraintPercentWidth = keyWidth - }) + // layout_constraintBottom_to + if (row == rowCount - 1) { + // last row, align bottom to bottom of parent + bottomOfParent() + } else { + // not last row, align bottom to top of first view in next row + bottomToTopOf(keyViews[(row + 1) * columnCount]) } - } + // layout_constraintLeft_to + if (column == 0) { + // first column, align start to start of parent + leftOfParent() + } else { + // not first column, align start to end of last column + leftToRightOf(keyViews[i - 1]) + } + matchConstraintPercentWidth = keyWidth + }) } if (density.showBackspace) { add(backspaceKey, lParams { @@ -191,6 +161,16 @@ class PickerPageUi(override val ctx: Context, val theme: Theme, private val dens rightOfParent() matchConstraintPercentWidth = 0.15f }) + keyViews.last().updateLayoutParams { + // align right of last key to left of backspace + rightToLeftOf(backspaceKey) + } + keyViews[(rowCount - 1) * columnCount].updateLayoutParams { + // first key of last row, align its right to the left of its next sibling + rightToLeftOf(keyViews[(rowCount - 1) * columnCount + 1]) + // pack the entire last row together, towards the backspace + horizontalChainStyle = ConstraintLayout.LayoutParams.CHAIN_PACKED + } } layoutParams = ViewGroup.LayoutParams(matchParent, matchParent) } @@ -199,7 +179,7 @@ class PickerPageUi(override val ctx: Context, val theme: Theme, private val dens keyActionListener?.onKeyAction(CommitAction(str), Source.Keyboard) } - fun setItems(items: Array) { + fun setItems(items: List, withSkinTone: Boolean = false) { keyViews.forEachIndexed { i, keyView -> keyView.apply { if (i >= items.size) { @@ -211,10 +191,12 @@ class PickerPageUi(override val ctx: Context, val theme: Theme, private val dens onGestureListener = null } else { isEnabled = true - val text = items[i] - mainText.text = text + val label = items[i] + val commitString = + if (withSkinTone) EmojiModifier.getPreferredTone(label) else label + mainText.text = commitString setOnClickListener { - onSymbolClick(text) + onSymbolClick(commitString) } setOnLongClickListener { view -> view as KeyView @@ -227,7 +209,7 @@ class PickerPageUi(override val ctx: Context, val theme: Theme, private val dens onPopupAction( PopupAction.ShowKeyboardAction( view.id, - KeyDef.Popup.Keyboard(text), + KeyDef.Popup.Keyboard(label), bounds ) ) @@ -245,7 +227,7 @@ class PickerPageUi(override val ctx: Context, val theme: Theme, private val dens // so update bounds when it's pressed view.updateBounds() onPopupAction( - PopupAction.PreviewAction(view.id, text, view.bounds) + PopupAction.PreviewAction(view.id, label, view.bounds) ) } false diff --git a/app/src/main/java/org/fcitx/fcitx5/android/input/picker/PickerPagesAdapter.kt b/app/src/main/java/org/fcitx/fcitx5/android/input/picker/PickerPagesAdapter.kt index 539788b20..13e9f40b7 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/input/picker/PickerPagesAdapter.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/input/picker/PickerPagesAdapter.kt @@ -1,9 +1,10 @@ /* * SPDX-License-Identifier: LGPL-2.1-or-later - * SPDX-FileCopyrightText: Copyright 2021-2023 Fcitx5 for Android Contributors + * SPDX-FileCopyrightText: Copyright 2021-2025 Fcitx5 for Android Contributors */ package org.fcitx.fcitx5.android.input.picker +import android.text.TextPaint import android.view.ViewGroup import androidx.recyclerview.widget.RecyclerView import org.fcitx.fcitx5.android.data.RecentlyUsed @@ -16,105 +17,67 @@ class PickerPagesAdapter( private val keyActionListener: KeyActionListener, private val popupActionListener: PopupActionListener, data: List>>, - val density: PickerPageUi.Density, - recentlyUsedFileName: String + private val density: PickerPageUi.Density, + recentlyUsedFileName: String, + private val bordered: Boolean = false, + private val isEmoji: Boolean = false ) : RecyclerView.Adapter() { class ViewHolder(val ui: PickerPageUi) : RecyclerView.ViewHolder(ui.root) /** - * calculated layout of data in the form of - * [(start page of the category, # of pages)] - * Note: unlike [ranges], [pages], and [categories], - * this does not include recently used category. + * list<`Category` to `[start, end]`>, starting with empty "RecentlyUsed" category */ - private val cats: Array> - - private val ranges: List - - private val pages: ArrayList> + private val categories: MutableList> = mutableListOf( + PickerData.RecentlyUsedCategory to IntRange(0, 0) + ) /** - * INVARIANT: The recently used category only takes one page - * It does not interleave the layout calculation for data. - * See the note on [cats] for details + * list, starting with empty "RecentlyUsed" page */ - private val recentlyUsed = RecentlyUsed(recentlyUsedFileName, density.pageSize) + private val pages: MutableList> = mutableListOf(listOf()) - val categories: List + fun getCategoryList(): List { + return categories.map { it.first } + } init { - // Add recently used category - categories = listOf(PickerData.RecentlyUsedCategory) + data.map { it.first } - val concat = data.flatMap { it.second.toList() } - // shift the start page of each category in data by one - var start = 1 - var p = 0 - pages = ArrayList() - // Add a placeholder for the recently used page - // We will update it in [updateRecent] - pages.add(arrayOf()) - cats = Array(data.size) { i -> - val v = data[i].second - val filled = v.size / density.pageSize - val rest = v.size % density.pageSize - val pageNum = filled + if (rest != 0) 1 else 0 - for (j in start until start + filled) { - pages.add(j, (p until p + density.pageSize).map { - concat[it] - }.toTypedArray()) - p += density.pageSize - } - if (rest != 0) { - pages.add(start + pageNum - 1, (p until p + rest).map { - concat[it] - }.toTypedArray()) - p += rest - } - (start to pageNum).also { start += pageNum } - } - // Add recently used page - ranges = listOf(0..0) + cats.map { (start, pageNum) -> - start until start + pageNum + val textPaint = TextPaint() + data.forEach { (cat, arr) -> + val chunks = arr.filter { if (isEmoji) textPaint.hasGlyph(it) else true } + .chunked(density.pageSize) + categories.add(cat to IntRange(pages.size, pages.size + chunks.size - 1)) + pages.addAll(chunks) } - recentlyUsed.load() } + private val recentlyUsed = RecentlyUsed(recentlyUsedFileName, density.pageSize) + fun insertRecent(text: String) { if (text.length == 1 && text[0].code.let { it in Digit || it in FullWidthDigit }) return recentlyUsed.insert(text) } - private fun updateRecent() { - pages[0] = recentlyUsed.toOrderedList().toTypedArray() + fun getCategoryIndexOfPage(page: Int): Int { + return categories.indexOfFirst { page in it.second } } - fun saveRecent() { - recentlyUsed.save() + fun getCategoryRangeOfPage(page: Int): IntRange { + return categories.find { page in it.second }?.second ?: IntRange(0, 0) } - fun getCategoryOfPage(page: Int) = - ranges.indexOfFirst { page in it } - - fun getCategoryRangeOfPage(page: Int) = - ranges.find { page in it } ?: (0..0) - - fun getStartPageOfCategory(cat: Int) = - // Recently used category only has one page which must be the first page - if (cat == 0) - 0 - // Otherwise, we need offset it by one - else - cats[cat - 1].first + fun getRangeOfCategoryIndex(cat: Int): IntRange { + return categories[cat].second + } override fun getItemCount() = pages.size override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { - return ViewHolder(PickerPageUi(parent.context, theme, density)) + return ViewHolder(PickerPageUi(parent.context, theme, density, bordered)) } override fun onBindViewHolder(holder: ViewHolder, position: Int) { - holder.ui.setItems(pages[position]) + holder.ui.setItems(pages[position], isEmoji) } override fun onViewAttachedToWindow(holder: ViewHolder) { @@ -122,9 +85,8 @@ class PickerPagesAdapter( if (holder.bindingAdapterPosition == 0) { // prevent popup on RecentlyUsed page holder.ui.popupActionListener = null - // update RecentlyUsed when it's page attached - updateRecent() - holder.ui.setItems(pages[0]) + // RecentlyUsed content are already modified with skin tones + holder.ui.setItems(recentlyUsed.items, withSkinTone = false) } else { holder.ui.popupActionListener = popupActionListener } diff --git a/app/src/main/java/org/fcitx/fcitx5/android/input/picker/PickerPaginationUi.kt b/app/src/main/java/org/fcitx/fcitx5/android/input/picker/PickerPaginationUi.kt index 7ead60c77..00f6700c2 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/input/picker/PickerPaginationUi.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/input/picker/PickerPaginationUi.kt @@ -1,6 +1,6 @@ /* * SPDX-License-Identifier: LGPL-2.1-or-later - * SPDX-FileCopyrightText: Copyright 2021-2023 Fcitx5 for Android Contributors + * SPDX-FileCopyrightText: Copyright 2021-2025 Fcitx5 for Android Contributors */ package org.fcitx.fcitx5.android.input.picker @@ -11,8 +11,15 @@ import androidx.core.view.updateLayoutParams import org.fcitx.fcitx5.android.data.theme.Theme import org.fcitx.fcitx5.android.utils.alpha import splitties.views.backgroundColor -import splitties.views.dsl.constraintlayout.* -import splitties.views.dsl.core.* +import splitties.views.dsl.constraintlayout.centerVertically +import splitties.views.dsl.constraintlayout.constraintLayout +import splitties.views.dsl.constraintlayout.lParams +import splitties.views.dsl.constraintlayout.matchConstraints +import splitties.views.dsl.constraintlayout.startOfParent +import splitties.views.dsl.core.Ui +import splitties.views.dsl.core.add +import splitties.views.dsl.core.matchParent +import splitties.views.dsl.core.view import kotlin.math.roundToInt class PickerPaginationUi(override val ctx: Context, val theme: Theme) : Ui { @@ -51,8 +58,11 @@ class PickerPaginationUi(override val ctx: Context, val theme: Theme) : Ui { } fun updateScrollProgress(current: Int, progress: Float) { + if (pageCount <= 1) { + return + } highlight.updateLayoutParams { - startMargin = ((current + progress) * highlight.width).roundToInt() + marginStart = ((current + progress) * highlight.width).roundToInt() } } } diff --git a/app/src/main/java/org/fcitx/fcitx5/android/input/picker/PickerWindow.kt b/app/src/main/java/org/fcitx/fcitx5/android/input/picker/PickerWindow.kt index 3c4b2d954..115e455be 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/input/picker/PickerWindow.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/input/picker/PickerWindow.kt @@ -1,17 +1,18 @@ /* * SPDX-License-Identifier: LGPL-2.1-or-later - * SPDX-FileCopyrightText: Copyright 2021-2023 Fcitx5 for Android Contributors + * SPDX-FileCopyrightText: Copyright 2021-2025 Fcitx5 for Android Contributors */ package org.fcitx.fcitx5.android.input.picker +import android.annotation.SuppressLint import android.view.Gravity import androidx.core.content.ContextCompat -import androidx.lifecycle.lifecycleScope import androidx.transition.Slide import androidx.transition.Transition import androidx.viewpager2.widget.ViewPager2 -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch +import org.fcitx.fcitx5.android.data.prefs.AppPrefs +import org.fcitx.fcitx5.android.data.prefs.ManagedPreference +import org.fcitx.fcitx5.android.data.theme.ThemeManager import org.fcitx.fcitx5.android.input.broadcast.ReturnKeyDrawableComponent import org.fcitx.fcitx5.android.input.dependency.inputMethodService import org.fcitx.fcitx5.android.input.dependency.theme @@ -30,10 +31,11 @@ import org.mechdancer.dependency.manager.must class PickerWindow( override val key: Key, - val data: List>>, - val density: PickerPageUi.Density, + private val data: List>>, + private val density: PickerPageUi.Density, private val switchKey: KeyDef, - val popupPreview: Boolean = true + private val popupPreview: Boolean = true, + private val followKeyBorder: Boolean = true ) : InputWindow.ExtendedInputWindow(), EssentialWindow { enum class Key : EssentialWindow.Key { @@ -49,6 +51,8 @@ class PickerWindow( private val popup: PopupComponent by manager.must() private val returnKeyDrawable: ReturnKeyDrawableComponent by manager.must() + private val keyBorder by ThemeManager.prefs.keyBorder + private lateinit var pickerLayout: PickerLayout private lateinit var pickerPagesAdapter: PickerPagesAdapter @@ -116,26 +120,27 @@ class PickerWindow( override fun onCreateView() = PickerLayout(context, theme, switchKey).apply { pickerLayout = this + val bordered = followKeyBorder && keyBorder + val isEmoji = key === Key.Emoji pickerPagesAdapter = PickerPagesAdapter( - theme, keyActionListener, popupActionListener, data, density, key.name + theme, keyActionListener, popupActionListener, data, + density, key.name, bordered, isEmoji ) tabsUi.apply { - setTabs(pickerPagesAdapter.categories) + setTabs(pickerPagesAdapter.getCategoryList()) setOnTabClickListener { i -> - pager.setCurrentItem(pickerPagesAdapter.getStartPageOfCategory(i), false) + pager.setCurrentItem(pickerPagesAdapter.getRangeOfCategoryIndex(i).first, false) } } pager.apply { adapter = pickerPagesAdapter // show first symbol category by default, rather than recently used - val initialPage = pickerPagesAdapter.getStartPageOfCategory(1) - setCurrentItem(initialPage, false) + val range = pickerPagesAdapter.getRangeOfCategoryIndex(1) + setCurrentItem(range.first, false) // update initial tab and page manually to avoid // "Adding or removing callbacks during dispatch to callbacks" tabsUi.activateTab(1) - paginationUi.updatePageCount( - pickerPagesAdapter.getCategoryRangeOfPage(initialPage).run { last - first + 1 } - ) + paginationUi.updatePageCount(range.run { last - first + 1 }) registerOnPageChangeCallback(object : ViewPager2.OnPageChangeCallback() { override fun onPageScrolled( position: Int, @@ -143,15 +148,12 @@ class PickerWindow( positionOffsetPixels: Int ) { val range = pickerPagesAdapter.getCategoryRangeOfPage(position) - val start = range.first - val total = range.last - start + 1 - val current = position - start - paginationUi.updatePageCount(total) - paginationUi.updateScrollProgress(current, positionOffset) + paginationUi.updatePageCount(range.run { last - first + 1 }) + paginationUi.updateScrollProgress(position - range.first, positionOffset) } override fun onPageSelected(position: Int) { - tabsUi.activateTab(pickerPagesAdapter.getCategoryOfPage(position)) + tabsUi.activateTab(pickerPagesAdapter.getCategoryIndexOfPage(position)) popup.dismissAll() } }) @@ -160,18 +162,28 @@ class PickerWindow( override fun onCreateBarExtension() = pickerLayout.tabsUi.root + private val skinTonePreference = AppPrefs.getInstance().symbols.defaultEmojiSkinTone + + @SuppressLint("NotifyDataSetChanged") + private val refreshPagesListener = ManagedPreference.OnChangeListener { _, _ -> + pickerPagesAdapter.notifyDataSetChanged() + } + override fun onAttached() { pickerLayout.embeddedKeyboard.also { it.onReturnDrawableUpdate(returnKeyDrawable.resourceId) it.keyActionListener = keyActionListener } + if (key === Key.Emoji) { + skinTonePreference.registerOnChangeListener(refreshPagesListener) + } } override fun onDetached() { popup.dismissAll() pickerLayout.embeddedKeyboard.keyActionListener = null - service.lifecycleScope.launch(Dispatchers.IO) { - pickerPagesAdapter.saveRecent() + if (key === Key.Emoji) { + skinTonePreference.unregisterOnChangeListener(refreshPagesListener) } } diff --git a/app/src/main/java/org/fcitx/fcitx5/android/input/picker/PickerWindowPreset.kt b/app/src/main/java/org/fcitx/fcitx5/android/input/picker/PickerWindowPreset.kt index 5d6bd6d95..5fae0d885 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/input/picker/PickerWindowPreset.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/input/picker/PickerWindowPreset.kt @@ -21,14 +21,16 @@ fun emojiPicker(): PickerWindow = PickerWindow( key = PickerWindow.Key.Emoji, data = PickerData.Emoji, density = PickerPageUi.Density.Medium, - popupPreview = false, switchKey = TextPickerSwitchKey(":-)", PickerWindow.Key.Emoticon), + popupPreview = false, + followKeyBorder = false ) fun emoticonPicker(): PickerWindow = PickerWindow( key = PickerWindow.Key.Emoticon, data = PickerData.Emoticon, density = PickerPageUi.Density.Low, + switchKey = ImagePickerSwitchKey(R.drawable.ic_baseline_tag_faces_24, PickerWindow.Key.Emoji), popupPreview = false, - switchKey = ImagePickerSwitchKey(R.drawable.ic_baseline_tag_faces_24, PickerWindow.Key.Emoji) + followKeyBorder = false ) diff --git a/app/src/main/java/org/fcitx/fcitx5/android/input/popup/EmojiModifier.kt b/app/src/main/java/org/fcitx/fcitx5/android/input/popup/EmojiModifier.kt new file mode 100644 index 000000000..600095d09 --- /dev/null +++ b/app/src/main/java/org/fcitx/fcitx5/android/input/popup/EmojiModifier.kt @@ -0,0 +1,114 @@ +/* + * SPDX-License-Identifier: LGPL-2.1-or-later + * SPDX-FileCopyrightText: Copyright 2025 Fcitx5 for Android Contributors + */ + +package org.fcitx.fcitx5.android.input.popup + +import android.icu.lang.UCharacter +import android.icu.lang.UProperty +import android.os.Build +import android.text.TextPaint +import androidx.annotation.RequiresApi +import org.fcitx.fcitx5.android.R +import org.fcitx.fcitx5.android.data.prefs.AppPrefs +import org.fcitx.fcitx5.android.data.prefs.ManagedPreferenceEnum +import org.fcitx.fcitx5.android.utils.includes + +object EmojiModifier { + + enum class SkinTone(val value: String, override val stringRes: Int) : ManagedPreferenceEnum { + Default("", R.string.emoji_skin_tone_none), + Type_1_2("🏻", R.string.emoji_skin_tone_type_1_2), + Type_3("🏼", R.string.emoji_skin_tone_type_3), + Type_4("🏽", R.string.emoji_skin_tone_type_4), + Type_5("🏾", R.string.emoji_skin_tone_type_5), + Type_6("🏿", R.string.emoji_skin_tone_type_6) + } + + /** + * **Special Case 1:** Drop `U+FE0F` (Variation Selector-16) when combining with skin tone + */ + private val SpecialCase1 = intArrayOf( + 0x261D, // ☝️ + 0x26F9, // ⛹️ + 0x270C, // ✌️ + 0x1F3CB, // 🏋️ + 0x1F3CC, // 🏌️ + 0x1F574, // 🕴️ + 0x1F575, // 🕵️ + 0x1F590, // 🖐️ + ) + private const val VariationSelector16 = 0xFE0F + + /** + * **Special Case 2:** Make `U+1F91D`(🤝 Handshake) in 🧑‍🤝‍🧑 not modifiable + */ + private val SpecialCase2 = intArrayOf( + 0x1F9D1, 0x200D, 0x1F91D, 0x200D, 0x1F9D1, + ) + + private val defaultSkinTone by AppPrefs.getInstance().symbols.defaultEmojiSkinTone + + fun isSupported(): Boolean { + // UProperty.EMOJI_MODIFIER_BASE requires API 28 + return Build.VERSION.SDK_INT >= Build.VERSION_CODES.P + } + + private fun isModifiable(modifiable: BooleanArray): Boolean { + val sum = modifiable.sumOf { if (it) 1 else 0 } + // bail if too crowded + // eg. https://emojipedia.org/family-man-medium-light-skin-tone-woman-medium-light-skin-tone-girl-medium-light-skin-tone-boy-medium-light-skin-tone + return sum == 1 || sum == 2 + } + + @RequiresApi(Build.VERSION_CODES.P) + private fun getCodePoints(emoji: String): Pair { + val codePoints = emoji.codePoints().toArray() + val modifiable = BooleanArray(codePoints.size) { + UCharacter.hasBinaryProperty(codePoints[it], UProperty.EMOJI_MODIFIER_BASE) + } + // make U+1F91D not modifiable if the whole sequence is special + if (codePoints contentEquals SpecialCase2) { + modifiable[2] = false + } + return codePoints to modifiable + } + + private fun buildEmoji(codePoints: IntArray, modifiable: BooleanArray, tone: SkinTone): String { + return buildString { + for (i in 0.. 0 && codePoints[i] == VariationSelector16 && + SpecialCase1.includes(codePoints[i - 1]) && + tone != SkinTone.Default + ) continue + appendCodePoint(codePoints[i]) + if (modifiable[i]) { + append(tone.value) + } + } + } + } + + private val DefaultTextPaint = TextPaint() + + fun getPreferredTone(emoji: String): String { + if (!isSupported()) return emoji + val (codePoints, modifiable) = getCodePoints(emoji) + if (!isModifiable(modifiable)) return emoji + val candidate = buildEmoji(codePoints, modifiable, defaultSkinTone) + return if (DefaultTextPaint.hasGlyph(candidate)) candidate else emoji + } + + fun produceSkinTones(emoji: String): Array? { + if (!isSupported()) return null + val (codePoints, modifiable) = getCodePoints(emoji) + if (!isModifiable(modifiable)) return null + val candidates = SkinTone.entries + .filter { it != defaultSkinTone } + .map { buildEmoji(codePoints, modifiable, it) } + .filter { DefaultTextPaint.hasGlyph(it) } + return if (candidates.isEmpty()) null else candidates.toTypedArray() + } +} diff --git a/app/src/main/java/org/fcitx/fcitx5/android/input/popup/PopupComponent.kt b/app/src/main/java/org/fcitx/fcitx5/android/input/popup/PopupComponent.kt index be0174211..1d8ffe161 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/input/popup/PopupComponent.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/input/popup/PopupComponent.kt @@ -1,6 +1,6 @@ /* * SPDX-License-Identifier: LGPL-2.1-or-later - * SPDX-FileCopyrightText: Copyright 2021-2023 Fcitx5 for Android Contributors + * SPDX-FileCopyrightText: Copyright 2021-2025 Fcitx5 for Android Contributors */ package org.fcitx.fcitx5.android.input.popup @@ -59,12 +59,22 @@ class PopupComponent : } private val hideThreshold = 100L + private val rootLocation = intArrayOf(0, 0) + private val rootBounds: Rect = Rect() + val root by lazy { context.frameLayout { // we want (0, 0) at top left layoutDirection = View.LAYOUT_DIRECTION_LTR isClickable = false isFocusable = false + + addOnLayoutChangeListener { v, left, top, right, bottom, _, _, _, _ -> + val (x, y) = rootLocation.also { v.getLocationInWindow(it) } + val width = right - left + val height = bottom - top + rootBounds.set(x, y, x + width, y + height) + } } } @@ -97,7 +107,9 @@ class PopupComponent : } private fun showKeyboard(viewId: Int, keyboard: KeyDef.Popup.Keyboard, bounds: Rect) { - val keys = PopupPreset[keyboard.label] ?: return + val keys = PopupPreset[keyboard.label] + ?: EmojiModifier.produceSkinTones(keyboard.label) + ?: return // clear popup preview text OR create empty popup preview showingEntryUi[viewId]?.setText("") ?: showPopup(viewId, "", bounds) reallyShowKeyboard(viewId, keys, bounds) @@ -110,6 +122,7 @@ class PopupComponent : val keyboardUi = PopupKeyboardUi( context, theme, + rootBounds, bounds, { dismissPopup(viewId) }, popupRadius, @@ -120,13 +133,7 @@ class PopupComponent : keys, labels ) - root.apply { - add(keyboardUi.root, lParams { - leftMargin = bounds.left + keyboardUi.offsetX - topMargin = bounds.top + keyboardUi.offsetY - }) - } - showingContainerUi[viewId] = keyboardUi + showPopupContainer(viewId, keyboardUi) } private fun showMenu(viewId: Int, menu: KeyDef.Popup.Menu, bounds: Rect) { @@ -136,17 +143,22 @@ class PopupComponent : val menuUi = PopupMenuUi( context, theme, + rootBounds, bounds, { dismissPopup(viewId) }, menu.items, ) + showPopupContainer(viewId, menuUi) + } + + private fun showPopupContainer(viewId: Int, ui: PopupContainerUi) { root.apply { - add(menuUi.root, lParams { - leftMargin = bounds.left + menuUi.offsetX - topMargin = bounds.top + menuUi.offsetY + add(ui.root, lParams { + leftMargin = ui.triggerBounds.left + ui.offsetX - rootBounds.left + topMargin = ui.triggerBounds.top + ui.offsetY - rootBounds.top }) } - showingContainerUi[viewId] = menuUi + showingContainerUi[viewId] = ui } private fun changeFocus(viewId: Int, x: Float, y: Float): Boolean { diff --git a/app/src/main/java/org/fcitx/fcitx5/android/input/popup/PopupContainerUi.kt b/app/src/main/java/org/fcitx/fcitx5/android/input/popup/PopupContainerUi.kt index ab51cffc1..8b54eee24 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/input/popup/PopupContainerUi.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/input/popup/PopupContainerUi.kt @@ -15,7 +15,8 @@ import kotlin.math.roundToInt abstract class PopupContainerUi( override val ctx: Context, val theme: Theme, - val bounds: Rect, + val outerBounds: Rect, + val triggerBounds: Rect, val onDismissSelf: PopupContainerUi.() -> Unit ) : Ui { @@ -25,22 +26,28 @@ abstract class PopupContainerUi( abstract override val root: View /** - * Offset on X axis to put this [PopupKeyboardUi] relative to popup trigger view [bounds] + * Offset on X axis to put this [PopupKeyboardUi] relative to popup trigger view [triggerBounds] */ abstract val offsetX: Int /** - * Offset on Y axis to put this [PopupKeyboardUi] relative to popup trigger view [bounds] + * Offset on Y axis to put this [PopupKeyboardUi] relative to popup trigger view [triggerBounds] */ abstract val offsetY: Int - fun calcInitialFocusedColumn(columnCount: Int, columnWidth: Int, bounds: Rect): Int { - val leftSpace = bounds.left - val rightSpace = ctx.resources.displayMetrics.widthPixels - bounds.right + fun calcInitialFocusedColumn( + columnCount: Int, + columnWidth: Int, + outerBounds: Rect, + triggerBounds: Rect + ): Int { + // assume trigger bounds inside outer bounds + val leftSpace = triggerBounds.left - outerBounds.left + val rightSpace = outerBounds.right - triggerBounds.right var col = (columnCount - 1) / 2 while (columnWidth * col > leftSpace) col-- while (columnWidth * (columnCount - col - 1) > rightSpace) col++ - return col + return col.coerceIn(0, columnCount - 1) } /** diff --git a/app/src/main/java/org/fcitx/fcitx5/android/input/popup/PopupKeyboardUi.kt b/app/src/main/java/org/fcitx/fcitx5/android/input/popup/PopupKeyboardUi.kt index 96c1cbb51..f7ba9c4d3 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/input/popup/PopupKeyboardUi.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/input/popup/PopupKeyboardUi.kt @@ -30,7 +30,8 @@ import kotlin.math.roundToInt /** * @param ctx [Context] * @param theme [Theme] - * @param bounds bound [Rect] of popup trigger view. Used to calculate free space of both sides and + * @param outerBounds bound [Rect] of [PopupComponent] root view. + * @param triggerBounds bound [Rect] of popup trigger view. Used to calculate free space of both sides and * determine column order. See [focusColumn] and [columnOrder]. * @param onDismissSelf callback when popup keyboard wants to close * @param radius popup keyboard and key radius @@ -44,7 +45,8 @@ import kotlin.math.roundToInt class PopupKeyboardUi( override val ctx: Context, theme: Theme, - bounds: Rect, + outerBounds: Rect, + triggerBounds: Rect, onDismissSelf: PopupContainerUi.() -> Unit = {}, private val radius: Float, private val keyWidth: Int, @@ -52,7 +54,7 @@ class PopupKeyboardUi( private val popupHeight: Int, private val keys: Array, private val labels: Array -) : PopupContainerUi(ctx, theme, bounds, onDismissSelf) { +) : PopupContainerUi(ctx, theme, outerBounds, triggerBounds, onDismissSelf) { class PopupKeyUi(override val ctx: Context, val theme: Theme, val text: String) : Ui { @@ -93,7 +95,7 @@ class PopupKeyboardUi( columnCount = (keyCount / rowCount).roundToInt() focusRow = 0 - focusColumn = calcInitialFocusedColumn(columnCount, keyWidth, bounds) + focusColumn = calcInitialFocusedColumn(columnCount, keyWidth, outerBounds, triggerBounds) } /** @@ -125,8 +127,8 @@ class PopupKeyboardUi( * Applying only `1.` parts of both X and Y offset, the origin should transform from `o` to `p`. * `2.` parts of both offset transform it from `p` to `c`. */ - override val offsetX = ((bounds.width() - keyWidth) / 2) - (keyWidth * focusColumn) - override val offsetY = (bounds.height() - popupHeight) - (keyHeight * (rowCount - 1)) + override val offsetX = ((triggerBounds.width() - keyWidth) / 2) - (keyWidth * focusColumn) + override val offsetY = (triggerBounds.height() - popupHeight) - (keyHeight * (rowCount - 1)) private val columnOrder = createColumnOrder(columnCount, focusColumn) diff --git a/app/src/main/java/org/fcitx/fcitx5/android/input/popup/PopupMenuUi.kt b/app/src/main/java/org/fcitx/fcitx5/android/input/popup/PopupMenuUi.kt index 9bc9badf5..86a41075c 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/input/popup/PopupMenuUi.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/input/popup/PopupMenuUi.kt @@ -25,10 +25,11 @@ import kotlin.math.floor class PopupMenuUi( override val ctx: Context, theme: Theme, - bounds: Rect, + outerBounds: Rect, + triggerBounds: Rect, onDismissSelf: PopupContainerUi.() -> Unit = {}, private val items: Array -) : PopupContainerUi(ctx, theme, bounds, onDismissSelf) { +) : PopupContainerUi(ctx, theme, outerBounds, triggerBounds, onDismissSelf) { private val keySize = ctx.dp(48) @@ -50,9 +51,10 @@ class PopupMenuUi( ) private val columnCount = items.size - private val focusColumn = calcInitialFocusedColumn(columnCount, keySize, bounds) + private val focusColumn = + calcInitialFocusedColumn(columnCount, keySize, outerBounds, triggerBounds) - override val offsetX = ((bounds.width() - keySize) / 2) - (keySize * focusColumn) + override val offsetX = ((triggerBounds.width() - keySize) / 2) - (keySize * focusColumn) override val offsetY = ctx.dp(-52) private val columnOrder = createColumnOrder(columnCount, focusColumn) diff --git a/app/src/main/java/org/fcitx/fcitx5/android/input/popup/PopupPreset.kt b/app/src/main/java/org/fcitx/fcitx5/android/input/popup/PopupPreset.kt index 8056587f0..11905d2f1 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/input/popup/PopupPreset.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/input/popup/PopupPreset.kt @@ -29,8 +29,8 @@ val PopupPreset: Map> = hashMapOf( "g" to arrayOf("=", "G", "ğ"), "h" to arrayOf("/", "H"), "j" to arrayOf("#", "J"), - "k" to arrayOf("(", "K"), - "l" to arrayOf(")", "L", "ł"), + "k" to arrayOf("(", "[", "{", "K"), + "l" to arrayOf(")", "]", "}", "L", "ł"), "z" to arrayOf("'", "Z", "`", "ž", "ź", "ż"), "x" to arrayOf(":", "X", "×"), "c" to arrayOf("\"", "C", "ç", "ć", "č"), @@ -144,9 +144,10 @@ val PopupPreset: Map> = hashMapOf( "%" to arrayOf("‰", "℅"), "^" to arrayOf("↑", "↓", "←", "→"), "+" to arrayOf("±"), - "<" to arrayOf("«", "≤", "‹", "⟨"), + "<" to arrayOf("≤", "«", "‹", "⟨"), "=" to arrayOf("∞", "≠", "≈"), - ">" to arrayOf("⟩", "»", "≥", "›"), + ">" to arrayOf("≥", "»", "›", "⟩"), + "°" to arrayOf("′", "″", "‴"), // // Currency // diff --git a/app/src/main/java/org/fcitx/fcitx5/android/input/preedit/PreeditComponent.kt b/app/src/main/java/org/fcitx/fcitx5/android/input/preedit/PreeditComponent.kt index 1e7cf2322..0f2c4a59a 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/input/preedit/PreeditComponent.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/input/preedit/PreeditComponent.kt @@ -6,6 +6,8 @@ package org.fcitx.fcitx5.android.input.preedit import android.view.View import org.fcitx.fcitx5.android.core.FcitxEvent +import org.fcitx.fcitx5.android.data.theme.Theme +import org.fcitx.fcitx5.android.data.theme.ThemeManager import org.fcitx.fcitx5.android.input.broadcast.InputBroadcastReceiver import org.fcitx.fcitx5.android.input.dependency.context import org.fcitx.fcitx5.android.input.dependency.theme @@ -13,6 +15,9 @@ import org.mechdancer.dependency.Dependent import org.mechdancer.dependency.UniqueComponent import org.mechdancer.dependency.manager.ManagedHandler import org.mechdancer.dependency.manager.managedHandler +import splitties.dimensions.dp +import splitties.views.backgroundColor +import splitties.views.horizontalPadding class PreeditComponent : UniqueComponent(), Dependent, InputBroadcastReceiver, ManagedHandler by managedHandler() { @@ -20,7 +25,19 @@ class PreeditComponent : UniqueComponent(), Dependent, InputBr private val context by manager.context() private val theme by manager.theme() - val ui by lazy { PreeditUi(context, theme) } + val ui by lazy { + val keyBorder = ThemeManager.prefs.keyBorder.getValue() + val bkgColor = + if (!keyBorder && theme is Theme.Builtin) theme.barColor else theme.backgroundColor + PreeditUi(context, theme, setupTextView = { + backgroundColor = bkgColor + horizontalPadding = dp(8) + }).apply { + // TODO make it customizable + root.alpha = 0.8f + root.visibility = View.INVISIBLE + } + } override fun onInputPanelUpdate(data: FcitxEvent.InputPanelEvent.Data) { ui.update(data) diff --git a/app/src/main/java/org/fcitx/fcitx5/android/input/preedit/PreeditUi.kt b/app/src/main/java/org/fcitx/fcitx5/android/input/preedit/PreeditUi.kt index 978a0215d..7c7a8b476 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/input/preedit/PreeditUi.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/input/preedit/PreeditUi.kt @@ -1,6 +1,6 @@ /* * SPDX-License-Identifier: LGPL-2.1-or-later - * SPDX-FileCopyrightText: Copyright 2021-2023 Fcitx5 for Android Contributors + * SPDX-FileCopyrightText: Copyright 2021-2025 Fcitx5 for Android Contributors */ package org.fcitx.fcitx5.android.input.preedit @@ -12,19 +12,23 @@ import android.text.Spanned import android.text.SpannedString import android.text.style.DynamicDrawableSpan import android.view.View -import android.view.View.* import android.widget.TextView import androidx.annotation.ColorInt import androidx.core.text.buildSpannedString import org.fcitx.fcitx5.android.core.FcitxEvent import org.fcitx.fcitx5.android.data.theme.Theme -import org.fcitx.fcitx5.android.data.theme.ThemeManager import splitties.dimensions.dp -import splitties.views.backgroundColor -import splitties.views.dsl.core.* -import splitties.views.horizontalPadding +import splitties.views.dsl.core.Ui +import splitties.views.dsl.core.add +import splitties.views.dsl.core.lParams +import splitties.views.dsl.core.textView +import splitties.views.dsl.core.verticalLayout -class PreeditUi(override val ctx: Context, private val theme: Theme) : Ui { +open class PreeditUi( + override val ctx: Context, + private val theme: Theme, + private val setupTextView: (TextView.() -> Unit)? = null +) : Ui { class CursorSpan(ctx: Context, @ColorInt color: Int, metrics: Paint.FontMetricsInt) : DynamicDrawableSpan() { @@ -40,18 +44,10 @@ class PreeditUi(override val ctx: Context, private val theme: Theme) : Ui { CursorSpan(ctx, theme.keyTextColor, upView.paint.fontMetricsInt) } - private val keyBorder by ThemeManager.prefs.keyBorder - - private val barBackground = when (theme) { - is Theme.Builtin -> if (keyBorder) theme.backgroundColor else theme.barColor - is Theme.Custom -> theme.backgroundColor - } - private fun createTextView() = textView { - backgroundColor = barBackground - horizontalPadding = dp(8) setTextColor(theme.keyTextColor) textSize = 16f + setupTextView?.invoke(this) } private val upView = createTextView() @@ -62,43 +58,41 @@ class PreeditUi(override val ctx: Context, private val theme: Theme) : Ui { private set override val root: View = verticalLayout { - alpha = 0.8f - visibility = INVISIBLE add(upView, lParams()) add(downView, lParams()) } - private fun updateTextView(view: TextView, str: CharSequence, visible: Boolean) = view.run { - if (visible) { - text = str - if (visibility == GONE) visibility = VISIBLE - } else if (visibility != GONE) { - visibility = GONE - } + private fun updateTextView(view: TextView, str: CharSequence, visible: Boolean) { + view.text = str + view.visibility = if (visible) View.VISIBLE else View.GONE } fun update(inputPanel: FcitxEvent.InputPanelEvent.Data) { - val bkgColor = theme.genericActiveBackgroundColor + val activeBkg = theme.genericActiveBackgroundColor val upString: SpannedString val upCursor: Int if (inputPanel.auxUp.isEmpty()) { - upString = inputPanel.preedit.toSpannedString(bkgColor) + upString = inputPanel.preedit.toSpannedString(activeBkg) upCursor = inputPanel.preedit.cursor } else { upString = buildSpannedString { - append(inputPanel.auxUp.toSpannedString(bkgColor)) - append(inputPanel.preedit.toSpannedString(bkgColor)) + append(inputPanel.auxUp.toSpannedString(activeBkg)) + append(inputPanel.preedit.toSpannedString(activeBkg)) } upCursor = inputPanel.preedit.cursor.let { if (it < 0) it else inputPanel.auxUp.length + it } } - val downString = inputPanel.auxDown.toSpannedString(bkgColor) + val downString = inputPanel.auxDown.toSpannedString(activeBkg) val hasUp = upString.isNotEmpty() val hasDown = downString.isNotEmpty() visible = hasUp || hasDown - if (!visible) return + if (!visible) { + updateTextView(upView, "", false) + updateTextView(downView, "", false) + return + } val upStringWithCursor = if (upCursor < 0 || upCursor == upString.length) { upString } else buildSpannedString { diff --git a/app/src/main/java/org/fcitx/fcitx5/android/input/status/StatusAreaWindow.kt b/app/src/main/java/org/fcitx/fcitx5/android/input/status/StatusAreaWindow.kt index 6afb4b408..5e0c37204 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/input/status/StatusAreaWindow.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/input/status/StatusAreaWindow.kt @@ -1,6 +1,6 @@ /* * SPDX-License-Identifier: LGPL-2.1-or-later - * SPDX-FileCopyrightText: Copyright 2021-2023 Fcitx5 for Android Contributors + * SPDX-FileCopyrightText: Copyright 2021-2025 Fcitx5 for Android Contributors */ package org.fcitx.fcitx5.android.input.status @@ -14,6 +14,7 @@ import androidx.lifecycle.lifecycleScope import kotlinx.coroutines.launch import org.fcitx.fcitx5.android.R import org.fcitx.fcitx5.android.core.Action +import org.fcitx.fcitx5.android.core.SubtypeManager import org.fcitx.fcitx5.android.daemon.FcitxConnection import org.fcitx.fcitx5.android.daemon.launchOnReady import org.fcitx.fcitx5.android.data.prefs.AppPrefs @@ -34,7 +35,6 @@ import org.fcitx.fcitx5.android.input.wm.InputWindowManager import org.fcitx.fcitx5.android.utils.AppUtil import org.fcitx.fcitx5.android.utils.DeviceUtil import org.fcitx.fcitx5.android.utils.alpha -import org.fcitx.fcitx5.android.utils.styledFloat import org.mechdancer.dependency.manager.must import splitties.dimensions.dp import splitties.resources.styledColor @@ -73,7 +73,7 @@ class StatusAreaWindow : InputWindow.ExtendedInputWindow(), ReloadConfig ), StatusAreaEntry.Android( - context.getString(R.string.keyboard), + context.getString(R.string.virtual_keyboard), R.drawable.ic_baseline_keyboard_24, Keyboard ) @@ -101,7 +101,7 @@ class StatusAreaWindow : InputWindow.ExtendedInputWindow(), val popup = PopupMenu(context, view) val menu = popup.menu val hasDivider = - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P && !DeviceUtil.isHMOS) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P && !DeviceUtil.isHMOS && !DeviceUtil.isHonorMagicOS) { menu.setGroupDividerEnabled(true) true } else { @@ -143,6 +143,9 @@ class StatusAreaWindow : InputWindow.ExtendedInputWindow(), } ReloadConfig -> fcitx.launchOnReady { f -> f.reloadConfig() + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + SubtypeManager.syncWith(f.enabledIme()) + } service.lifecycleScope.launch { Toast.makeText(service, R.string.done, Toast.LENGTH_SHORT).show() } @@ -180,12 +183,14 @@ class StatusAreaWindow : InputWindow.ExtendedInputWindow(), private val editorInfoButton by lazy { ToolButton(context, R.drawable.ic_baseline_info_24, theme).apply { + contentDescription = context.getString(R.string.editor_info_inspector) setOnClickListener { windowManager.attachWindow(EditorInfoWindow()) } } } private val settingsButton by lazy { ToolButton(context, R.drawable.ic_baseline_settings_24, theme).apply { + contentDescription = context.getString(R.string.open_input_method_settings) setOnClickListener { AppUtil.launchMain(context) } } } diff --git a/app/src/main/java/org/fcitx/fcitx5/android/input/wm/InputWindowManager.kt b/app/src/main/java/org/fcitx/fcitx5/android/input/wm/InputWindowManager.kt index c80eb1b07..17b1c3866 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/input/wm/InputWindowManager.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/input/wm/InputWindowManager.kt @@ -15,7 +15,6 @@ import org.fcitx.fcitx5.android.input.broadcast.InputBroadcastReceiver import org.fcitx.fcitx5.android.input.broadcast.InputBroadcaster import org.fcitx.fcitx5.android.input.dependency.UniqueViewComponent import org.fcitx.fcitx5.android.input.dependency.context -import org.fcitx.fcitx5.android.utils.isUiThread import org.mechdancer.dependency.DynamicScope import org.mechdancer.dependency.manager.must import org.mechdancer.dependency.minusAssign @@ -67,7 +66,6 @@ class InputWindowManager : UniqueViewComponent( window: R, createView: Boolean = false ) where R : W, R : E { - ensureThread() if (window.key in essentialWindows) { if (essentialWindows[window.key]!!.first === window) Timber.d("Skip adding essential window $window") @@ -90,7 +88,6 @@ class InputWindowManager : UniqueViewComponent( * Moreover, [attachWindow] can also add the essential window with key. */ fun attachWindow(windowKey: EssentialWindow.Key) { - ensureThread() essentialWindows[windowKey]?.let { (window, _) -> attachWindow(window) } ?: throw IllegalStateException("$windowKey is not a known essential window key") @@ -116,7 +113,6 @@ class InputWindowManager : UniqueViewComponent( * [attachWindow] includes the operation done by [addEssentialWindow]. */ fun attachWindow(window: InputWindow) { - ensureThread() if (window === currentWindow) Timber.d("Skip attaching $window") val newView = if (window is EssentialWindow) { @@ -168,10 +164,5 @@ class InputWindowManager : UniqueViewComponent( this.scope = scope } - private fun ensureThread() { - if (!isUiThread()) - throw IllegalThreadStateException("Window manager must be operated in main thread!") - } - fun isAttached(window: InputWindow) = currentWindow === window } \ No newline at end of file diff --git a/app/src/main/java/org/fcitx/fcitx5/android/provider/FcitxDataProvider.kt b/app/src/main/java/org/fcitx/fcitx5/android/provider/FcitxDataProvider.kt index aacef75d6..ab4127cf7 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/provider/FcitxDataProvider.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/provider/FcitxDataProvider.kt @@ -78,7 +78,7 @@ class FcitxDataProvider : DocumentsProvider() { private fun fileFromDocId(docId: String) = File(docIdPrefix, docId) override fun onCreate(): Boolean { - baseDir = context!!.getExternalFilesDir(null)!! + baseDir = context!!.getExternalFilesDir(null) ?: return false docIdPrefix = "${baseDir.parent}${File.separator}" textFilePaths = Array(TEXT_FILES.size) { baseDir.resolve(TEXT_FILES[it]).absolutePath } return true diff --git a/app/src/main/java/org/fcitx/fcitx5/android/ui/common/BaseDynamicListUi.kt b/app/src/main/java/org/fcitx/fcitx5/android/ui/common/BaseDynamicListUi.kt index 70d22b6bf..4f0d89fe9 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/ui/common/BaseDynamicListUi.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/ui/common/BaseDynamicListUi.kt @@ -4,15 +4,20 @@ */ package org.fcitx.fcitx5.android.ui.common +import android.annotation.SuppressLint +import android.app.AlertDialog import android.content.Context import android.view.View import android.view.ViewGroup import android.widget.CheckBox import android.widget.ImageButton import androidx.activity.OnBackPressedDispatcher -import androidx.appcompat.app.AlertDialog import androidx.coordinatorlayout.widget.CoordinatorLayout -import androidx.core.view.* +import androidx.core.view.ViewCompat +import androidx.core.view.WindowInsetsCompat +import androidx.core.view.doOnAttach +import androidx.core.view.isVisible +import androidx.core.view.updateLayoutParams import androidx.core.widget.addTextChangedListener import androidx.recyclerview.widget.ItemTouchHelper import arrow.core.identity @@ -21,15 +26,28 @@ import com.google.android.material.floatingactionbutton.FloatingActionButton import com.google.android.material.snackbar.BaseTransientBottomBar import com.google.android.material.snackbar.Snackbar import org.fcitx.fcitx5.android.R +import org.fcitx.fcitx5.android.utils.onPositiveButtonClick +import org.fcitx.fcitx5.android.utils.str import splitties.dimensions.dp import splitties.resources.drawable import splitties.resources.styledColor import splitties.views.backgroundColor import splitties.views.bottomPadding -import splitties.views.dsl.constraintlayout.* +import splitties.views.dsl.constraintlayout.bottomOfParent +import splitties.views.dsl.constraintlayout.constraintLayout +import splitties.views.dsl.constraintlayout.lParams +import splitties.views.dsl.constraintlayout.leftOfParent +import splitties.views.dsl.constraintlayout.rightOfParent +import splitties.views.dsl.constraintlayout.topOfParent import splitties.views.dsl.coordinatorlayout.coordinatorLayout import splitties.views.dsl.coordinatorlayout.defaultLParams -import splitties.views.dsl.core.* +import splitties.views.dsl.core.Ui +import splitties.views.dsl.core.add +import splitties.views.dsl.core.editText +import splitties.views.dsl.core.margin +import splitties.views.dsl.core.matchParent +import splitties.views.dsl.core.view +import splitties.views.dsl.core.wrapContent import splitties.views.dsl.recyclerview.recyclerView import splitties.views.gravityEndBottom import splitties.views.imageDrawable @@ -240,25 +258,24 @@ abstract class BaseDynamicListUi( rightOfParent(dp(20)) }) } - val dialog = AlertDialog.Builder(ctx) + AlertDialog.Builder(ctx) .setTitle(title) .setView(layout) - .setPositiveButton(android.R.string.ok) { _, _ -> - } - .setNegativeButton(android.R.string.cancel) { dialog, _ -> - dialog.cancel() - } + .setPositiveButton(android.R.string.ok, null) + .setNegativeButton(android.R.string.cancel, null) .show() - editText.requestFocus() - dialog.getButton(AlertDialog.BUTTON_POSITIVE).setOnClickListener { - val t = editText.editableText.toString() - if (mode.validator(t)) { - block(mode.converter(t)) - dialog.dismiss() - } else { - editText.error = ctx.getString(R.string.invalid_value) + .onPositiveButtonClick { + val str = editText.str + if (mode.validator(str)) { + editText.error = null + block(mode.converter(str)) + true + } else { + editText.error = ctx.getString(R.string.invalid_value) + false + } } - } + editText.requestFocus() } protected val recyclerView = recyclerView { @@ -294,6 +311,7 @@ abstract class BaseDynamicListUi( gravity = gravityEndBottom margin = dp(16) behavior = object : HideBottomViewOnScrollBehavior() { + @SuppressLint("RestrictedApi") override fun layoutDependsOn( parent: CoordinatorLayout, child: FloatingActionButton, diff --git a/app/src/main/java/org/fcitx/fcitx5/android/ui/common/DynamicListAdapter.kt b/app/src/main/java/org/fcitx/fcitx5/android/ui/common/DynamicListAdapter.kt index 778446b8f..5b1fd080c 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/ui/common/DynamicListAdapter.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/ui/common/DynamicListAdapter.kt @@ -15,7 +15,7 @@ import androidx.annotation.CallSuper import androidx.recyclerview.widget.ItemTouchHelper import androidx.recyclerview.widget.RecyclerView import org.fcitx.fcitx5.android.ui.main.MainViewModel -import java.util.* +import java.util.Collections abstract class DynamicListAdapter( initialEntries: List, diff --git a/app/src/main/java/org/fcitx/fcitx5/android/ui/common/DynamicListPreset.kt b/app/src/main/java/org/fcitx/fcitx5/android/ui/common/DynamicListPreset.kt new file mode 100644 index 000000000..779a0b8e8 --- /dev/null +++ b/app/src/main/java/org/fcitx/fcitx5/android/ui/common/DynamicListPreset.kt @@ -0,0 +1,50 @@ +/* + * SPDX-License-Identifier: LGPL-2.1-or-later + * SPDX-FileCopyrightText: Copyright 2021-2024 Fcitx5 for Android Contributors + */ + +package org.fcitx.fcitx5.android.ui.common + +import android.content.Context +import android.view.View +import android.widget.CheckBox +import android.widget.ImageButton + +@Suppress("FunctionName") +fun Context.DynamicListUi( + mode: BaseDynamicListUi.Mode, + initialEntries: List, + enableOrder: Boolean = false, + initCheckBox: (CheckBox.(T) -> Unit) = { visibility = View.GONE }, + initSettingsButton: (ImageButton.(T) -> Unit) = { visibility = View.GONE }, + show: (T) -> String +): BaseDynamicListUi = object : + BaseDynamicListUi( + this, + mode, + initialEntries, + enableOrder, + initCheckBox, + initSettingsButton + ) { + init { + addTouchCallback() + } + + override fun showEntry(x: T): String = show(x) +} + +@Suppress("FunctionName") +fun Context.CheckBoxListUi( + initialEntries: List, + initCheckBox: (CheckBox.(T) -> Unit), + initSettingsButton: (ImageButton.(T) -> Unit), + show: (T) -> String +) = DynamicListUi( + BaseDynamicListUi.Mode.Immutable(), + initialEntries, + false, + initCheckBox, + initSettingsButton, + show +) diff --git a/app/src/main/java/org/fcitx/fcitx5/android/ui/common/Preset.kt b/app/src/main/java/org/fcitx/fcitx5/android/ui/common/ProgressBarDialogIndeterminate.kt similarity index 53% rename from app/src/main/java/org/fcitx/fcitx5/android/ui/common/Preset.kt rename to app/src/main/java/org/fcitx/fcitx5/android/ui/common/ProgressBarDialogIndeterminate.kt index d71a8b309..373857cd6 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/ui/common/Preset.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/ui/common/ProgressBarDialogIndeterminate.kt @@ -1,13 +1,14 @@ /* * SPDX-License-Identifier: LGPL-2.1-or-later - * SPDX-FileCopyrightText: Copyright 2021-2023 Fcitx5 for Android Contributors + * SPDX-FileCopyrightText: Copyright 2021-2024 Fcitx5 for Android Contributors */ + package org.fcitx.fcitx5.android.ui.common +import android.animation.ValueAnimator import android.content.Context -import android.view.View -import android.widget.CheckBox -import android.widget.ImageButton +import android.os.Build +import android.provider.Settings import androidx.annotation.StringRes import androidx.appcompat.app.AlertDialog import androidx.lifecycle.LifecycleCoroutineScope @@ -15,48 +16,18 @@ import kotlinx.coroutines.cancelAndJoin import kotlinx.coroutines.delay import kotlinx.coroutines.launch import org.fcitx.fcitx5.android.R +import org.fcitx.fcitx5.android.utils.getGlobalSettings import splitties.dimensions.dp -import splitties.views.dsl.core.* +import splitties.resources.resolveThemeAttribute +import splitties.views.dsl.core.add +import splitties.views.dsl.core.horizontalMargin +import splitties.views.dsl.core.lParams +import splitties.views.dsl.core.matchParent import splitties.views.dsl.core.styles.AndroidStyles - -@Suppress("FunctionName") -fun Context.DynamicListUi( - mode: BaseDynamicListUi.Mode, - initialEntries: List, - enableOrder: Boolean = false, - initCheckBox: (CheckBox.(T) -> Unit) = { visibility = View.GONE }, - initSettingsButton: (ImageButton.(T) -> Unit) = { visibility = View.GONE }, - show: (T) -> String -): BaseDynamicListUi = object : - BaseDynamicListUi( - this, - mode, - initialEntries, - enableOrder, - initCheckBox, - initSettingsButton - ) { - init { - addTouchCallback() - } - - override fun showEntry(x: T): String = show(x) -} - -@Suppress("FunctionName") -fun Context.CheckBoxListUi( - initialEntries: List, - initCheckBox: (CheckBox.(T) -> Unit), - initSettingsButton: (ImageButton.(T) -> Unit), - show: (T) -> String -) = DynamicListUi( - BaseDynamicListUi.Mode.Immutable(), - initialEntries, - false, - initCheckBox, - initSettingsButton, - show -) +import splitties.views.dsl.core.textView +import splitties.views.dsl.core.verticalLayout +import splitties.views.dsl.core.verticalMargin +import splitties.views.textAppearance @Suppress("FunctionName") fun Context.ProgressBarDialogIndeterminate(@StringRes title: Int): AlertDialog.Builder { @@ -64,8 +35,20 @@ fun Context.ProgressBarDialogIndeterminate(@StringRes title: Int): AlertDialog.B return AlertDialog.Builder(this) .setTitle(title) .setView(verticalLayout { - add(androidStyles.progressBar.horizontal { - isIndeterminate = true + val shouldAnimate = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + ValueAnimator.areAnimatorsEnabled() + } else { + getGlobalSettings(Settings.Global.ANIMATOR_DURATION_SCALE) > 0f + } + add(if (shouldAnimate) { + androidStyles.progressBar.horizontal { + isIndeterminate = true + } + } else { + textView { + setText(R.string.please_wait) + textAppearance = resolveThemeAttribute(android.R.attr.textAppearanceListItem) + } }, lParams { width = matchParent verticalMargin = dp(20) diff --git a/app/src/main/java/org/fcitx/fcitx5/android/ui/main/AboutFragment.kt b/app/src/main/java/org/fcitx/fcitx5/android/ui/main/AboutFragment.kt index ce810ebce..0cced1194 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/ui/main/AboutFragment.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/ui/main/AboutFragment.kt @@ -1,22 +1,26 @@ /* * SPDX-License-Identifier: LGPL-2.1-or-later - * SPDX-FileCopyrightText: Copyright 2021-2023 Fcitx5 for Android Contributors + * SPDX-FileCopyrightText: Copyright 2021-2025 Fcitx5 for Android Contributors */ package org.fcitx.fcitx5.android.ui.main +import android.annotation.SuppressLint import android.content.Intent import android.net.Uri import android.os.Bundle -import androidx.navigation.fragment.findNavController +import org.fcitx.fcitx5.android.BuildConfig import org.fcitx.fcitx5.android.R import org.fcitx.fcitx5.android.ui.common.PaddingPreferenceFragment +import org.fcitx.fcitx5.android.ui.main.settings.SettingsRoute import org.fcitx.fcitx5.android.utils.Const import org.fcitx.fcitx5.android.utils.addCategory import org.fcitx.fcitx5.android.utils.addPreference import org.fcitx.fcitx5.android.utils.formatDateTime +import org.fcitx.fcitx5.android.utils.navigateWithAnim class AboutFragment : PaddingPreferenceFragment() { + @SuppressLint("UseKtx") override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { preferenceScreen = preferenceManager.createPreferenceScreen(requireContext()).apply { addPreference(R.string.privacy_policy) { @@ -26,7 +30,7 @@ class AboutFragment : PaddingPreferenceFragment() { R.string.open_source_licenses, R.string.licenses_of_third_party_libraries ) { - findNavController().navigate(R.id.action_aboutFragment_to_licensesFragment) + navigateWithAnim(SettingsRoute.License) } addPreference(R.string.source_code, R.string.github_repo) { startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(Const.githubRepo))) @@ -37,12 +41,12 @@ class AboutFragment : PaddingPreferenceFragment() { addCategory(R.string.version) { isIconSpaceReserved = false addPreference(R.string.current_version, Const.versionName) - addPreference(R.string.build_git_hash, Const.buildGitHash) { - val commit = Const.buildGitHash.substringBefore('-') + addPreference(R.string.build_git_hash, BuildConfig.BUILD_GIT_HASH) { + val commit = BuildConfig.BUILD_GIT_HASH.substringBefore('-') val uri = Uri.parse("${Const.githubRepo}/commit/${commit}") startActivity(Intent(Intent.ACTION_VIEW, uri)) } - addPreference(R.string.build_time, formatDateTime(Const.buildTime)) + addPreference(R.string.build_time, formatDateTime(BuildConfig.BUILD_TIME)) } } } diff --git a/app/src/main/java/org/fcitx/fcitx5/android/ui/main/CropImageActivity.kt b/app/src/main/java/org/fcitx/fcitx5/android/ui/main/CropImageActivity.kt new file mode 100644 index 000000000..3651e4382 --- /dev/null +++ b/app/src/main/java/org/fcitx/fcitx5/android/ui/main/CropImageActivity.kt @@ -0,0 +1,261 @@ +/* + * SPDX-License-Identifier: LGPL-2.1-or-later + * SPDX-FileCopyrightText: Copyright 2024 Fcitx5 for Android Contributors + */ + +package org.fcitx.fcitx5.android.ui.main + +import android.content.Context +import android.content.Intent +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.graphics.Rect +import android.net.Uri +import android.os.Bundle +import android.os.Parcelable +import android.view.Menu +import android.view.ViewGroup +import androidx.activity.addCallback +import androidx.activity.enableEdgeToEdge +import androidx.activity.result.contract.ActivityResultContract +import androidx.activity.result.contract.ActivityResultContracts +import androidx.appcompat.app.AppCompatActivity +import androidx.appcompat.graphics.drawable.DrawerArrowDrawable +import androidx.appcompat.widget.Toolbar +import androidx.constraintlayout.widget.ConstraintLayout +import androidx.core.view.ViewCompat +import androidx.core.view.WindowInsetsCompat +import androidx.core.view.updateLayoutParams +import com.canhub.cropper.CropImageOptions +import com.canhub.cropper.CropImageView +import kotlinx.parcelize.IgnoredOnParcel +import kotlinx.parcelize.Parcelize +import org.fcitx.fcitx5.android.R +import org.fcitx.fcitx5.android.utils.item +import org.fcitx.fcitx5.android.utils.parcelable +import org.fcitx.fcitx5.android.utils.subMenu +import org.fcitx.fcitx5.android.utils.toast +import splitties.dimensions.dp +import splitties.resources.styledColor +import splitties.views.backgroundColor +import splitties.views.dsl.constraintlayout.below +import splitties.views.dsl.constraintlayout.bottomOfParent +import splitties.views.dsl.constraintlayout.centerHorizontally +import splitties.views.dsl.constraintlayout.constraintLayout +import splitties.views.dsl.constraintlayout.lParams +import splitties.views.dsl.constraintlayout.topOfParent +import splitties.views.dsl.core.add +import splitties.views.dsl.core.matchParent +import splitties.views.dsl.core.view +import splitties.views.dsl.core.wrapContent +import splitties.views.topPadding +import timber.log.Timber +import java.io.File + +class CropImageActivity : AppCompatActivity(), CropImageView.OnCropImageCompleteListener { + + companion object { + const val CROP_OPTIONS = "crop_options" + const val CROP_RESULT = "crop_result" + } + + sealed class CropOption() : Parcelable { + abstract val width: Int + abstract val height: Int + + @Parcelize + data class New(override val width: Int, override val height: Int) : CropOption() + + @Parcelize + data class Edit( + override val width: Int, + override val height: Int, + val sourceUri: Uri, + val initialRect: Rect? = null, + val initialRotation: Int = 0 + ) : CropOption() + } + + sealed class CropResult : Parcelable { + @Parcelize + data object Fail : CropResult() + + @Parcelize + data class Success( + val rect: Rect, + val rotation: Int, + val file: File, + val srcUri: Uri + ) : CropResult() { + @IgnoredOnParcel + private var _bitmap: Bitmap? = null + val bitmap: Bitmap + get() { + _bitmap?.let { return it } + return BitmapFactory.decodeFile(file.path).also { + _bitmap = it + file.delete() + } + } + } + } + + class CropContract : ActivityResultContract() { + override fun createIntent(context: Context, input: CropOption): Intent { + return Intent(context, CropImageActivity::class.java).putExtra(CROP_OPTIONS, input) + } + + override fun parseResult(resultCode: Int, intent: Intent?): CropResult { + val result = intent?.parcelable(CROP_RESULT) + if (resultCode != RESULT_OK || result == null) { + return CropResult.Fail + } + return result + } + } + + private lateinit var cropOption: CropOption + + private lateinit var root: ConstraintLayout + private lateinit var toolbar: Toolbar + private lateinit var cropView: CropImageView + + private fun getDefaultCropImageOptions() = CropImageOptions( + // CropImageView + snapRadius = 0f, + guidelines = CropImageView.Guidelines.ON_TOUCH, + showProgressBar = true, + progressBarColor = styledColor(android.R.attr.colorAccent), + // CropOverlayView + borderLineThickness = dp(1f), + borderCornerOffset = 0f, + ) + + private var selectedImageUri: Uri? = null + + private val launcher = registerForActivityResult(ActivityResultContracts.GetContent()) { uri -> + if (uri == null) { + setResult(RESULT_CANCELED) + finish() + } else { + selectedImageUri = uri + cropView.setImageUriAsync(uri) + } + } + + private lateinit var tempOutFile: File + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + cropOption = intent.parcelable(CROP_OPTIONS) ?: CropOption.New(1, 1) + enableEdgeToEdge() + setupRootView() + setContentView(root) + setupCropView(cropOption) + onBackPressedDispatcher.addCallback { + setResult(RESULT_CANCELED) + finish() + } + toolbar.setNavigationOnClickListener { + onBackPressedDispatcher.onBackPressed() + } + } + + private fun setupRootView() { + toolbar = view(::Toolbar) { + backgroundColor = styledColor(android.R.attr.colorPrimary) + elevation = dp(4f) + navigationIcon = DrawerArrowDrawable(context).apply { progress = 1f } + setupToolbarMenu(menu) + } + cropView = CropImageView(this).apply { + setOnCropImageCompleteListener(this@CropImageActivity) + setImageCropOptions(getDefaultCropImageOptions()) + } + root = constraintLayout { + add(toolbar, lParams(matchParent, wrapContent) { + topOfParent() + centerHorizontally() + }) + add(cropView, lParams(matchParent) { + below(toolbar) + centerHorizontally() + bottomOfParent() + }) + } + ViewCompat.setOnApplyWindowInsetsListener(root) { _, windowInsets -> + val statusBars = windowInsets.getInsets(WindowInsetsCompat.Type.statusBars()) + val navBars = windowInsets.getInsets(WindowInsetsCompat.Type.navigationBars()) + root.updateLayoutParams { + leftMargin = navBars.left + rightMargin = navBars.right + bottomMargin = navBars.bottom + } + toolbar.topPadding = statusBars.top + windowInsets + } + } + + private fun setupToolbarMenu(menu: Menu) { + val iconTint = styledColor(android.R.attr.colorControlNormal) + menu.item(R.string.rotate, R.drawable.ic_baseline_rotate_right_24, iconTint, true) { + cropView.rotateImage(90) + } + menu.subMenu(R.string.flip, R.drawable.ic_baseline_flip_24, iconTint, true) { + item(R.string.flip_vertically) { + cropView.flipImageVertically() + } + item(R.string.flip_horizontally) { + cropView.flipImageHorizontally() + } + } + menu.item(R.string.crop, R.drawable.ic_baseline_check_24, iconTint, true) { + onCropImage() + } + } + + private fun setupCropView(option: CropOption) { + cropView.setAspectRatio(option.width, option.height) + when (option) { + is CropOption.New -> { + launcher.launch("image/*") + } + is CropOption.Edit -> { + cropView.setOnSetImageUriCompleteListener { view, uri, e -> + view.cropRect = option.initialRect + view.rotatedDegrees = option.initialRotation + } + cropView.setImageUriAsync(option.sourceUri) + } + } + } + + private fun onCropImage() { + tempOutFile = File.createTempFile("cropped", ".png", cacheDir) + cropView.croppedImageAsync( + saveCompressFormat = Bitmap.CompressFormat.PNG, + reqWidth = cropOption.width, + reqHeight = cropOption.height, + options = CropImageView.RequestSizeOptions.RESIZE_INSIDE, + customOutputUri = Uri.fromFile(tempOutFile) + ) + } + + override fun onCropImageComplete(view: CropImageView, result: CropImageView.CropResult) { + try { + result + val success = CropResult.Success( + result.cropRect!!, + result.rotation, + tempOutFile, + (cropOption as? CropOption.Edit)?.sourceUri ?: selectedImageUri!! + ) + setResult(RESULT_OK, Intent().putExtra(CROP_RESULT, success)) + } catch (e: Exception) { + Timber.e("Exception when cropping image: $e") + toast(e) + setResult(RESULT_CANCELED) + } + finish() + } +} diff --git a/app/src/main/java/org/fcitx/fcitx5/android/ui/main/DeveloperFragment.kt b/app/src/main/java/org/fcitx/fcitx5/android/ui/main/DeveloperFragment.kt index c4b5f00a7..d02f4881a 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/ui/main/DeveloperFragment.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/ui/main/DeveloperFragment.kt @@ -4,7 +4,6 @@ */ package org.fcitx.fcitx5.android.ui.main -import android.content.Intent import android.os.Bundle import android.os.Debug import androidx.activity.result.ActivityResultLauncher @@ -13,16 +12,20 @@ import androidx.appcompat.app.AlertDialog import androidx.lifecycle.lifecycleScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.delay import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.fcitx.fcitx5.android.R import org.fcitx.fcitx5.android.core.data.DataManager +import org.fcitx.fcitx5.android.daemon.FcitxDaemon import org.fcitx.fcitx5.android.data.clipboard.ClipboardManager import org.fcitx.fcitx5.android.data.prefs.AppPrefs import org.fcitx.fcitx5.android.ui.common.PaddingPreferenceFragment import org.fcitx.fcitx5.android.ui.main.modified.MySwitchPreference +import org.fcitx.fcitx5.android.utils.AppUtil import org.fcitx.fcitx5.android.utils.addPreference import org.fcitx.fcitx5.android.utils.iso8601UTCDateTime +import org.fcitx.fcitx5.android.utils.startActivity import org.fcitx.fcitx5.android.utils.toast import java.io.File @@ -34,14 +37,17 @@ class DeveloperFragment : PaddingPreferenceFragment() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) launcher = registerForActivityResult(CreateDocument("application/octet-stream")) { uri -> - if (uri == null) return@registerForActivityResult + if (uri == null) { + hprofFile.delete() + return@registerForActivityResult + } val ctx = requireContext() lifecycleScope.launch(NonCancellable + Dispatchers.IO) { runCatching { ctx.contentResolver.openOutputStream(uri)!!.use { o -> hprofFile.inputStream().use { i -> i.copyTo(o) } } - }.toast(ctx) + }.let { ctx.toast(it) } hprofFile.delete() } } @@ -50,7 +56,7 @@ class DeveloperFragment : PaddingPreferenceFragment() { override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { preferenceScreen = preferenceManager.createPreferenceScreen(requireContext()).apply { addPreference(R.string.real_time_logs) { - context.startActivity(Intent(context, LogActivity::class.java)) + startActivity() } addPreference(MySwitchPreference(context).apply { key = AppPrefs.getInstance().internal.verboseLog.key @@ -59,6 +65,26 @@ class DeveloperFragment : PaddingPreferenceFragment() { setDefaultValue(false) isIconSpaceReserved = false isSingleLineTitle = false + setOnPreferenceChangeListener { _, _ -> + AlertDialog.Builder(context) + .setTitle(R.string.verbose_log) + .setMessage(R.string.restart_to_apply_settings) + .setNegativeButton(android.R.string.cancel, null) + .setPositiveButton(android.R.string.ok) { _, _ -> + lifecycleScope.launch { + withContext(NonCancellable + Dispatchers.IO) { + FcitxDaemon.stopFcitx() + } + lifecycleScope.launch(NonCancellable + Dispatchers.Main) { + delay(400L) + AppUtil.exit() + } + AppUtil.showRestartNotification(context) + } + } + .show() + true + } }) addPreference(MySwitchPreference(context).apply { key = AppPrefs.getInstance().internal.editorInfoInspector.key @@ -67,12 +93,29 @@ class DeveloperFragment : PaddingPreferenceFragment() { isIconSpaceReserved = false isSingleLineTitle = false }) + addPreference(R.string.restart_fcitx_instance) { + AlertDialog.Builder(context) + .setTitle(R.string.restart_fcitx_instance) + .setMessage(R.string.restart_fcitx_instance_confirm) + .setNegativeButton(android.R.string.cancel, null) + .setPositiveButton(android.R.string.ok) { _, _ -> + lifecycleScope.launch { + withContext(NonCancellable + Dispatchers.IO) { + FcitxDaemon.stopFcitx() + withContext(Dispatchers.Main) { + context.toast(R.string.done) + } + } + } + } + .show() + } addPreference(R.string.delete_and_sync_data) { AlertDialog.Builder(context) .setTitle(R.string.delete_and_sync_data) .setMessage(R.string.delete_and_sync_data_message) .setPositiveButton(android.R.string.ok) { _, _ -> - lifecycleScope.launch(Dispatchers.IO) { + lifecycleScope.launch(NonCancellable + Dispatchers.IO) { DataManager.deleteAndSync() withContext(Dispatchers.Main) { context.toast(R.string.synced) @@ -87,7 +130,7 @@ class DeveloperFragment : PaddingPreferenceFragment() { .setTitle(R.string.clear_clb_db) .setMessage(R.string.clear_clp_db_confirm) .setPositiveButton(android.R.string.ok) { _, _ -> - lifecycleScope.launch(Dispatchers.IO) { + lifecycleScope.launch(NonCancellable + Dispatchers.IO) { ClipboardManager.nukeTable() withContext(Dispatchers.Main) { context.toast(R.string.done) diff --git a/app/src/main/java/org/fcitx/fcitx5/android/ui/main/LogActivity.kt b/app/src/main/java/org/fcitx/fcitx5/android/ui/main/LogActivity.kt index 187fa8d3b..937998890 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/ui/main/LogActivity.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/ui/main/LogActivity.kt @@ -6,8 +6,8 @@ package org.fcitx.fcitx5.android.ui.main import android.os.Bundle import android.view.Menu -import android.view.MenuItem import android.view.ViewGroup +import androidx.activity.enableEdgeToEdge import androidx.activity.result.ActivityResultLauncher import androidx.activity.result.contract.ActivityResultContracts.CreateDocument import androidx.appcompat.app.AlertDialog @@ -25,10 +25,9 @@ import org.fcitx.fcitx5.android.databinding.ActivityLogBinding import org.fcitx.fcitx5.android.ui.main.log.LogView import org.fcitx.fcitx5.android.utils.DeviceInfo import org.fcitx.fcitx5.android.utils.Logcat -import org.fcitx.fcitx5.android.utils.applyTranslucentSystemBars import org.fcitx.fcitx5.android.utils.iso8601UTCDateTime +import org.fcitx.fcitx5.android.utils.item import org.fcitx.fcitx5.android.utils.toast -import splitties.resources.drawable import splitties.resources.styledColor import splitties.views.topPadding @@ -50,14 +49,14 @@ class LogActivity : AppCompatActivity() { writer.write(logView.currentLog) } } - }.toast(this@LogActivity) + }.let { toast(it) } } } } override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - applyTranslucentSystemBars() + enableEdgeToEdge() val binding = ActivityLogBinding.inflate(layoutInflater) ViewCompat.setOnApplyWindowInsetsListener(binding.root) { _, windowInsets -> val statusBars = windowInsets.getInsets(WindowInsetsCompat.Type.statusBars()) @@ -98,27 +97,14 @@ class LogActivity : AppCompatActivity() { } override fun onCreateOptionsMenu(menu: Menu): Boolean { + val iconTint = styledColor(android.R.attr.colorControlNormal) if (!fromCrash) { - menu.add(R.string.clear).apply { - icon = drawable(R.drawable.ic_baseline_delete_24)!!.apply { - setTint(styledColor(android.R.attr.colorControlNormal)) - } - setShowAsAction(MenuItem.SHOW_AS_ACTION_IF_ROOM) - setOnMenuItemClickListener { - logView.clear() - true - } + menu.item(R.string.clear, R.drawable.ic_baseline_delete_24, iconTint, true) { + logView.clear() } } - menu.add(R.string.export).apply { - icon = drawable(R.drawable.ic_baseline_save_24)!!.apply { - setTint(styledColor(android.R.attr.colorControlNormal)) - } - setShowAsAction(MenuItem.SHOW_AS_ACTION_IF_ROOM) - setOnMenuItemClickListener { - launcher.launch("$packageName-${iso8601UTCDateTime()}.txt") - true - } + menu.item(R.string.export, R.drawable.ic_baseline_save_24, iconTint, true) { + launcher.launch("$packageName-${iso8601UTCDateTime()}.txt") } return true } @@ -127,4 +113,4 @@ class LogActivity : AppCompatActivity() { const val FROM_CRASH = "from_crash" const val CRASH_STACK_TRACE = "crash_stack_trace" } -} \ No newline at end of file +} diff --git a/app/src/main/java/org/fcitx/fcitx5/android/ui/main/MainActivity.kt b/app/src/main/java/org/fcitx/fcitx5/android/ui/main/MainActivity.kt index f13b12dbd..20fdf18ea 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/ui/main/MainActivity.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/ui/main/MainActivity.kt @@ -1,39 +1,42 @@ /* * SPDX-License-Identifier: LGPL-2.1-or-later - * SPDX-FileCopyrightText: Copyright 2021-2023 Fcitx5 for Android Contributors + * SPDX-FileCopyrightText: Copyright 2021-2025 Fcitx5 for Android Contributors */ package org.fcitx.fcitx5.android.ui.main import android.Manifest +import android.annotation.SuppressLint import android.content.Intent import android.content.pm.PackageManager import android.net.Uri import android.os.Build import android.os.Bundle import android.view.Menu -import android.view.MenuItem import android.view.ViewGroup +import androidx.activity.enableEdgeToEdge import androidx.activity.viewModels import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AppCompatActivity -import androidx.core.os.bundleOf +import androidx.appcompat.graphics.drawable.DrawerArrowDrawable import androidx.core.view.ViewCompat import androidx.core.view.WindowInsetsCompat +import androidx.core.view.forEach import androidx.core.view.updateLayoutParams import androidx.navigation.NavController +import androidx.navigation.NavDestination.Companion.hasRoute import androidx.navigation.fragment.NavHostFragment -import androidx.navigation.ui.AppBarConfiguration -import androidx.navigation.ui.setupWithNavController +import org.fcitx.fcitx5.android.BuildConfig import org.fcitx.fcitx5.android.R import org.fcitx.fcitx5.android.data.prefs.AppPrefs import org.fcitx.fcitx5.android.databinding.ActivityMainBinding -import org.fcitx.fcitx5.android.ui.main.settings.PinyinDictionaryFragment +import org.fcitx.fcitx5.android.ui.main.settings.SettingsRoute import org.fcitx.fcitx5.android.ui.setup.SetupActivity import org.fcitx.fcitx5.android.utils.Const -import org.fcitx.fcitx5.android.utils.applyTranslucentSystemBars -import org.fcitx.fcitx5.android.utils.navigateFromMain +import org.fcitx.fcitx5.android.utils.item +import org.fcitx.fcitx5.android.utils.navigateWithAnim +import org.fcitx.fcitx5.android.utils.parcelable +import org.fcitx.fcitx5.android.utils.startActivity import splitties.dimensions.dp -import splitties.resources.drawable import splitties.resources.styledColor import splitties.views.topPadding @@ -45,7 +48,7 @@ class MainActivity : AppCompatActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - applyTranslucentSystemBars() + enableEdgeToEdge() val binding = ActivityMainBinding.inflate(layoutInflater) ViewCompat.setOnApplyWindowInsetsListener(binding.root) { _, windowInsets -> val statusBars = windowInsets.getInsets(WindowInsetsCompat.Type.statusBars()) @@ -58,13 +61,15 @@ class MainActivity : AppCompatActivity() { windowInsets } setContentView(binding.root) - setSupportActionBar(binding.toolbar) - val appBarConfiguration = AppBarConfiguration( - // always show back icon regardless of `navController.currentDestination` - topLevelDestinationIds = setOf() - ) + // always show toolbar back arrow icon + // https://android.googlesource.com/platform/frameworks/support/+/32e643112d0217619237a0d7101b50919c6caf51/navigation/navigation-ui/src/main/java/androidx/navigation/ui/AbstractAppBarOnDestinationChangedListener.kt#80 + binding.toolbar.navigationIcon = DrawerArrowDrawable(this).apply { progress = 1f } + // show menu icon and other action icons on toolbar + // don't use `setSupportActionBar(binding.toolbar)` here, + // because navController would change toolbar title, we need to control it by ourselves + setupToolbarMenu(binding.toolbar.menu) navController = binding.navHostFragment.getFragment().navController - binding.toolbar.setupWithNavController(navController, appBarConfiguration) + navController.graph = SettingsRoute.createGraph(navController) binding.toolbar.setNavigationOnClickListener { // prevent navigate up when child fragment has enabled `OnBackPressedCallback` if (onBackPressedDispatcher.hasEnabledCallbacks()) { @@ -81,114 +86,83 @@ class MainActivity : AppCompatActivity() { binding.toolbar.elevation = dp(if (it) 4f else 0f) } navController.addOnDestinationChangedListener { _, dest, _ -> - when (dest.id) { - R.id.themeFragment -> viewModel.disableToolbarShadow() - else -> viewModel.enableToolbarShadow() + dest.label?.let { viewModel.setToolbarTitle(it.toString()) } + if (dest.hasRoute()) { + viewModel.disableToolbarShadow() + } else { + viewModel.enableToolbarShadow() } } - if (intent?.action == Intent.ACTION_MAIN && SetupActivity.shouldShowUp()) { - startActivity(Intent(this, SetupActivity::class.java)) - } else { - processIntent(intent) - } - addOnNewIntentListener { - processIntent(it) - } + processIntent(intent) checkNotificationPermission() } + override fun onNewIntent(intent: Intent) { + super.onNewIntent(intent) + processIntent(intent) + } + private fun processIntent(intent: Intent?) { - if (intent?.action == Intent.ACTION_VIEW) { - intent.data?.let { + val action = intent?.action ?: return + when (action) { + Intent.ACTION_MAIN -> if (SetupActivity.shouldShowUp()) { + startActivity() + } + Intent.ACTION_VIEW -> intent.data?.let { AlertDialog.Builder(this) .setTitle(R.string.pinyin_dict) .setMessage(R.string.whether_import_dict) .setNegativeButton(android.R.string.cancel) { _, _ -> } .setPositiveButton(android.R.string.ok) { _, _ -> - navController.navigateFromMain( - R.id.action_mainFragment_to_pinyinDictionaryFragment, - bundleOf(PinyinDictionaryFragment.INTENT_DATA_URI to it) - ) + navController.popBackStack(SettingsRoute.Index, false) + navController.navigateWithAnim(SettingsRoute.PinyinDict(it)) } .show() } + Intent.ACTION_RUN -> { + val route = intent.parcelable(EXTRA_SETTINGS_ROUTE) ?: return + navController.popBackStack(SettingsRoute.Index, false) + navController.navigateWithAnim(route) + } } } - override fun onCreateOptionsMenu(menu: Menu): Boolean = menu.run { - add(R.string.save).apply { - icon = drawable(R.drawable.ic_baseline_save_24)!!.apply { - setTint(styledColor(android.R.attr.colorControlNormal)) - } - setShowAsAction(MenuItem.SHOW_AS_ACTION_IF_ROOM) - viewModel.toolbarSaveButtonOnClickListener.apply { - observe(this@MainActivity) { listener -> isVisible = listener != null } - setValue(value) - } - setOnMenuItemClickListener { - viewModel.toolbarSaveButtonOnClickListener.value?.invoke() - true - } - } - val aboutMenus = mutableListOf() - add(R.string.faq).apply { - aboutMenus.add(this) - setOnMenuItemClickListener { + private fun setupToolbarMenu(menu: Menu) { + val iconTint = styledColor(android.R.attr.colorControlNormal) + menu.item(R.string.save, R.drawable.ic_baseline_save_24, iconTint, true) { + viewModel.toolbarSaveButtonOnClickListener.value?.invoke() + }.apply { + viewModel.toolbarSaveButtonOnClickListener + .observe(this@MainActivity) { listener -> isVisible = listener != null } + } + val aboutMenuItems = listOf( + menu.item(R.string.faq) { + @SuppressLint("UseKtx") startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(Const.faqUrl))) - true - } - } - add(R.string.developer).apply { - aboutMenus.add(this) - setOnMenuItemClickListener { - navController.navigate(R.id.action_mainFragment_to_developerFragment) - true - } - } - add(R.string.about).apply { - aboutMenus.add(this) - setOnMenuItemClickListener { - navController.navigate(R.id.action_mainFragment_to_aboutFragment) - true + }, + menu.item(R.string.developer) { + navController.navigateWithAnim(SettingsRoute.Developer) + }, + menu.item(R.string.about) { + navController.navigateWithAnim(SettingsRoute.About) } - } - viewModel.aboutButton.apply { - observe(this@MainActivity) { enabled -> - aboutMenus.forEach { menu -> menu.isVisible = enabled } - } - setValue(value) - } - - add(R.string.edit).apply { - icon = drawable(R.drawable.ic_baseline_edit_24)!!.apply { - setTint(styledColor(android.R.attr.colorControlNormal)) - } - setShowAsAction(MenuItem.SHOW_AS_ACTION_IF_ROOM) - viewModel.toolbarEditButtonVisible.apply { - observe(this@MainActivity) { isVisible = it } - setValue(value) - } - setOnMenuItemClickListener { - viewModel.toolbarEditButtonOnClickListener.value?.invoke() - true - } - } - - add(R.string.delete).apply { - icon = drawable(R.drawable.ic_baseline_delete_24)!!.apply { - setTint(styledColor(android.R.attr.colorControlNormal)) - } - setShowAsAction(MenuItem.SHOW_AS_ACTION_IF_ROOM) - viewModel.toolbarDeleteButtonOnClickListener.apply { - observe(this@MainActivity) { listener -> isVisible = listener != null } - setValue(value) - } - setOnMenuItemClickListener { - viewModel.toolbarDeleteButtonOnClickListener.value?.invoke() - true - } - } - true + ) + viewModel.aboutButton.observe(this@MainActivity) { enabled -> + aboutMenuItems.forEach { menu -> menu.isVisible = enabled } + } + menu.item(R.string.edit, R.drawable.ic_baseline_edit_24, iconTint, true) { + viewModel.toolbarEditButtonOnClickListener.value?.invoke() + }.apply { + viewModel.toolbarEditButtonVisible.observe(this@MainActivity) { isVisible = it } + } + menu.item(R.string.delete, R.drawable.ic_baseline_delete_24, iconTint, true) { + viewModel.toolbarDeleteButtonOnClickListener.value?.invoke() + }.apply { + viewModel.toolbarDeleteButtonOnClickListener + .observe(this@MainActivity) { listener -> isVisible = listener != null } + } + // all menus should be invisible and enabled on demand + menu.forEach { it.isVisible = false } } private var needNotifications by AppPrefs.getInstance().internal.needNotifications @@ -236,4 +210,8 @@ class MainActivity : AppCompatActivity() { super.onStop() } -} \ No newline at end of file + companion object { + const val EXTRA_SETTINGS_ROUTE = "${BuildConfig.APPLICATION_ID}.EXTRA_SETTINGS_ROUTE" + } + +} diff --git a/app/src/main/java/org/fcitx/fcitx5/android/ui/main/MainFragment.kt b/app/src/main/java/org/fcitx/fcitx5/android/ui/main/MainFragment.kt index 647a0b5a0..20334a125 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/ui/main/MainFragment.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/ui/main/MainFragment.kt @@ -1,20 +1,20 @@ /* * SPDX-License-Identifier: LGPL-2.1-or-later - * SPDX-FileCopyrightText: Copyright 2021-2023 Fcitx5 for Android Contributors + * SPDX-FileCopyrightText: Copyright 2021-2025 Fcitx5 for Android Contributors */ package org.fcitx.fcitx5.android.ui.main import android.os.Bundle import androidx.annotation.DrawableRes -import androidx.annotation.IdRes import androidx.annotation.StringRes import androidx.fragment.app.activityViewModels -import androidx.navigation.fragment.findNavController import androidx.preference.PreferenceCategory import org.fcitx.fcitx5.android.R import org.fcitx.fcitx5.android.ui.common.PaddingPreferenceFragment +import org.fcitx.fcitx5.android.ui.main.settings.SettingsRoute import org.fcitx.fcitx5.android.utils.addCategory import org.fcitx.fcitx5.android.utils.addPreference +import org.fcitx.fcitx5.android.utils.navigateWithAnim class MainFragment : PaddingPreferenceFragment() { @@ -33,10 +33,10 @@ class MainFragment : PaddingPreferenceFragment() { private fun PreferenceCategory.addDestinationPreference( @StringRes title: Int, @DrawableRes icon: Int, - @IdRes destination: Int + route: SettingsRoute ) { addPreference(title, icon = icon) { - findNavController().navigate(destination) + navigateWithAnim(route) } } @@ -46,44 +46,54 @@ class MainFragment : PaddingPreferenceFragment() { addDestinationPreference( R.string.global_options, R.drawable.ic_baseline_tune_24, - R.id.action_mainFragment_to_globalConfigFragment + SettingsRoute.GlobalConfig ) addDestinationPreference( R.string.input_methods, R.drawable.ic_baseline_language_24, - R.id.action_mainFragment_to_imListFragment + SettingsRoute.InputMethodList ) addDestinationPreference( R.string.addons, R.drawable.ic_baseline_extension_24, - R.id.action_mainFragment_to_addonListFragment + SettingsRoute.AddonList ) } addCategory("Android") { addDestinationPreference( - R.string.keyboard, + R.string.theme, + R.drawable.ic_baseline_palette_24, + SettingsRoute.Theme + ) + addDestinationPreference( + R.string.virtual_keyboard, R.drawable.ic_baseline_keyboard_24, - R.id.action_mainFragment_to_keyboardSettingsFragment + SettingsRoute.VirtualKeyboard ) addDestinationPreference( - R.string.theme, - R.drawable.ic_baseline_palette_24, - R.id.action_mainFragment_to_themeFragment + R.string.candidates_window, + R.drawable.ic_baseline_list_alt_24, + SettingsRoute.CandidatesWindow ) addDestinationPreference( R.string.clipboard, R.drawable.ic_clipboard, - R.id.action_mainFragment_to_clipboardSettingsFragment + SettingsRoute.Clipboard + ) + addDestinationPreference( + R.string.emoji_and_symbols, + R.drawable.ic_baseline_emoji_symbols_24, + SettingsRoute.Symbol ) addDestinationPreference( R.string.plugins, R.drawable.ic_baseline_android_24, - R.id.action_mainFragment_to_pluginFragment + SettingsRoute.Plugin ) addDestinationPreference( R.string.advanced, R.drawable.ic_baseline_more_horiz_24, - R.id.action_mainFragment_to_advancedSettingsFragment + SettingsRoute.Advanced ) } } diff --git a/app/src/main/java/org/fcitx/fcitx5/android/ui/main/PluginFragment.kt b/app/src/main/java/org/fcitx/fcitx5/android/ui/main/PluginFragment.kt index e9a2e37d4..c5a03a16f 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/ui/main/PluginFragment.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/ui/main/PluginFragment.kt @@ -5,8 +5,11 @@ package org.fcitx.fcitx5.android.ui.main import android.annotation.SuppressLint +import android.content.BroadcastReceiver import android.content.ComponentName +import android.content.Context import android.content.Intent +import android.content.IntentFilter import android.content.pm.PackageManager import android.net.Uri import android.os.Build @@ -27,34 +30,91 @@ import org.fcitx.fcitx5.android.utils.addPreference class PluginFragment : PaddingPreferenceFragment() { - override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { + private var firstRun = true + + private lateinit var synced: DataManager.PluginSet + private lateinit var detected: DataManager.PluginSet + + private val packageChangeReceiver = object : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + refreshPreferencesWhenNeeded() + } + } + + private fun DataManager.whenSynced(block: () -> Unit) { lifecycleScope.launch { - val needsReload = if (DataManager.synced) { - val (newPluginsToLoad, _) = DataManager.detectPlugins() - newPluginsToLoad != DataManager.getLoadedPlugins() - } else { - DataManager.waitSynced() - false + if (!synced) { + suspendCancellableCoroutine { + if (synced) { + it.resumeWith(Result.success(Unit)) + } else { + addOnNextSyncedCallback { + it.resumeWith(Result.success(Unit)) + } + } + } } - preferenceScreen = createPreferenceScreen(needsReload) + block.invoke() + } + } + + override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { + DataManager.whenSynced { + synced = DataManager.getSyncedPluginSet() + detected = DataManager.detectPlugins() + preferenceScreen = createPreferenceScreen() } } - private fun createPreferenceScreen(needsReload: Boolean): PreferenceScreen = + private fun refreshPreferencesWhenNeeded() { + DataManager.whenSynced { + val newDetected = DataManager.detectPlugins() + if (detected != newDetected) { + detected = newDetected + preferenceScreen = createPreferenceScreen() + } + } + } + + override fun onResume() { + super.onResume() + requireContext().registerReceiver(packageChangeReceiver, IntentFilter().apply { + addAction(Intent.ACTION_PACKAGE_ADDED) + addAction(Intent.ACTION_PACKAGE_REMOVED) + addAction(Intent.ACTION_PACKAGE_REPLACED) + addDataScheme("package") + }) + /** + * [onResume] got called after [onCreatePreferences] when the fragment is created and + * shown for the first time + */ + if (firstRun) { + firstRun = false + return + } + // try refresh plugin list when the user navigate back from other apps + refreshPreferencesWhenNeeded() + } + + override fun onPause() { + super.onPause() + requireContext().unregisterReceiver(packageChangeReceiver) + } + + private fun createPreferenceScreen(): PreferenceScreen = preferenceManager.createPreferenceScreen(requireContext()).apply { - if (needsReload) { + if (synced != detected) { addPreference(R.string.plugin_needs_reload, icon = R.drawable.ic_baseline_info_24) { DataManager.addOnNextSyncedCallback { - // recreate the plugin list - // we only check new plugins on the first creation - preferenceScreen = createPreferenceScreen(false) + synced = DataManager.getSyncedPluginSet() + detected = DataManager.detectPlugins() + preferenceScreen = createPreferenceScreen() } // DataManager.sync and and restart fcitx FcitxDaemon.restartFcitx() } } - val loaded = DataManager.getLoadedPlugins() - val failed = DataManager.getFailedPlugins() + val (loaded, failed) = synced if (loaded.isEmpty() && failed.isEmpty()) { // use PreferenceCategory to show a divider below the "reload" preference addCategory(R.string.no_plugins) { @@ -112,15 +172,6 @@ class PluginFragment : PaddingPreferenceFragment() { } } - private suspend fun DataManager.waitSynced() = suspendCancellableCoroutine { - if (synced) - it.resumeWith(Result.success(Unit)) - else - addOnNextSyncedCallback { - it.resumeWith(Result.success(Unit)) - } - } - private fun startPluginAboutActivity(pkg: String): Boolean { val ctx = requireContext() val pm = ctx.packageManager @@ -130,7 +181,6 @@ class PluginFragment : PaddingPreferenceFragment() { PackageManager.ResolveInfoFlags.of(PackageManager.MATCH_ALL.toLong()) ) } else { - @Suppress("DEPRECATION") pm.queryIntentActivities(Intent(DataManager.PLUGIN_INTENT), PackageManager.MATCH_ALL) }.firstOrNull { it.activityInfo.packageName == pkg @@ -149,4 +199,4 @@ class PluginFragment : PaddingPreferenceFragment() { return true } -} \ No newline at end of file +} diff --git a/app/src/main/java/org/fcitx/fcitx5/android/ui/main/log/LogAdapter.kt b/app/src/main/java/org/fcitx/fcitx5/android/ui/main/log/LogAdapter.kt index e4a770f23..02366c1bf 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/ui/main/log/LogAdapter.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/ui/main/log/LogAdapter.kt @@ -12,8 +12,6 @@ import android.view.textclassifier.TextClassifier import android.widget.TextView import androidx.recyclerview.widget.RecyclerView import splitties.dimensions.dp -import splitties.views.dsl.core.endMargin -import splitties.views.dsl.core.startMargin import splitties.views.dsl.core.textView import splitties.views.dsl.core.wrapContent @@ -45,8 +43,8 @@ class LogAdapter(private val entries: ArrayList = ArrayList()) : setTextClassifier(TextClassifier.NO_OP) } layoutParams = MarginLayoutParams(wrapContent, wrapContent).apply { - startMargin = dp(4) - endMargin = dp(4) + marginStart = dp(4) + marginEnd = dp(4) } } ) diff --git a/app/src/main/java/org/fcitx/fcitx5/android/ui/main/modified/Functions.kt b/app/src/main/java/org/fcitx/fcitx5/android/ui/main/modified/Functions.kt index 9470d442e..faea7129a 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/ui/main/modified/Functions.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/ui/main/modified/Functions.kt @@ -25,15 +25,29 @@ private fun T.def() = mDefault.get(this) fun T.restore() { - def()?.let { text = it.toString() } + // must `callChangeListener` before `setText` + // https://android.googlesource.com/platform/frameworks/support/+/872b66efac82f0b0a3fac4bb14a789464ab19f96/preference/preference/src/main/java/androidx/preference/EditTextPreferenceDialogFragmentCompat.java#146 + (def() as? String)?.let { + if (callChangeListener(it)) { + text = it + } + } } fun T.restore() { - def()?.let { it as? String }?.let { value = it } + (def() as? String)?.let { + if (callChangeListener(it)) { + value = it + } + } } fun T.restore() { - def()?.let { it as? Boolean }?.let { isChecked = it } + (def() as? Boolean)?.let { + if (callChangeListener(it)) { + isChecked = it + } + } } fun PreferenceDialogFragmentCompat.fixDialogMargin(contentView: View) { diff --git a/app/src/main/java/org/fcitx/fcitx5/android/ui/main/settings/DialogSeekBarPreference.kt b/app/src/main/java/org/fcitx/fcitx5/android/ui/main/settings/DialogSeekBarPreference.kt index d30313018..893cf5155 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/ui/main/settings/DialogSeekBarPreference.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/ui/main/settings/DialogSeekBarPreference.kt @@ -13,7 +13,14 @@ import org.fcitx.fcitx5.android.R import org.fcitx.fcitx5.android.utils.setOnChangeListener import splitties.dimensions.dp import splitties.resources.resolveThemeAttribute -import splitties.views.dsl.core.* +import splitties.views.dsl.core.add +import splitties.views.dsl.core.horizontalMargin +import splitties.views.dsl.core.lParams +import splitties.views.dsl.core.matchParent +import splitties.views.dsl.core.seekBar +import splitties.views.dsl.core.textView +import splitties.views.dsl.core.verticalLayout +import splitties.views.dsl.core.verticalMargin import splitties.views.gravityHorizontalCenter import splitties.views.textAppearance @@ -40,7 +47,7 @@ class DialogSeekBarPreference @JvmOverloads constructor( var step: Int var unit: String - var default: Int? = null + var default: Int = 0 var defaultLabel: String? = null init { @@ -67,15 +74,15 @@ class DialogSeekBarPreference @JvmOverloads constructor( override fun setDefaultValue(defaultValue: Any?) { super.setDefaultValue(defaultValue) - (defaultValue as? Int)?.let { default = it } + default = defaultValue as? Int ?: 0 } override fun onGetDefaultValue(a: TypedArray, index: Int): Any { - return a.getInteger(index, 0) + return a.getInteger(index, default) } override fun onSetInitialValue(defaultValue: Any?) { - value = getPersistedInt(defaultValue as? Int ?: 0) + value = getPersistedInt(defaultValue as? Int ?: default) } override fun onClick() { @@ -123,9 +130,7 @@ class DialogSeekBarPreference @JvmOverloads constructor( setValue(value) } .setNeutralButton(R.string.default_) { _, _ -> - default?.let { - setValue(it) - } + setValue(default) } .setNegativeButton(android.R.string.cancel, null) .show() diff --git a/app/src/main/java/org/fcitx/fcitx5/android/ui/main/settings/EditTextIntPreference.kt b/app/src/main/java/org/fcitx/fcitx5/android/ui/main/settings/EditTextIntPreference.kt index d1f23fc9a..ef3c1e4d2 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/ui/main/settings/EditTextIntPreference.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/ui/main/settings/EditTextIntPreference.kt @@ -19,20 +19,37 @@ class EditTextIntPreference(context: Context) : EditTextPreference(context) { var max: Int = Int.MAX_VALUE var unit: String = "" - private val currentValue: Int - get() = getPersistedInt(value) + private var default: Int = 0 + + override fun persistInt(value: Int): Boolean { + return super.persistInt(value).also { + if (it) this@EditTextIntPreference.value = value + } + } + + // it appears as an "Int" Preference to the user, we want to accept Int for defaultValue + override fun setDefaultValue(defaultValue: Any?) { + val value = defaultValue as? Int ?: return + default = value + // the underlying Preference is an "EditText", we must use String for it's defaultValue + super.setDefaultValue(value.toString()) + } override fun onGetDefaultValue(a: TypedArray, index: Int): Any { - return a.getInteger(index, 0) + return a.getInteger(index, default) } override fun onSetInitialValue(defaultValue: Any?) { - value = defaultValue as? Int ?: getPersistedInt(0) + value = getPersistedInt(default) + } + + private fun textForValue(): String { + return getPersistedInt(value).toString() } init { setOnBindEditTextListener { - it.setText(currentValue.toString()) + it.setText(textForValue()) it.inputType = InputType.TYPE_CLASS_NUMBER or InputType.TYPE_NUMBER_FLAG_DECIMAL it.keyListener = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { DigitsKeyListener.getInstance(Locale.ROOT, min < 0, false) @@ -59,7 +76,7 @@ class EditTextIntPreference(context: Context) : EditTextPreference(context) { object SimpleSummaryProvider : SummaryProvider { override fun provideSummary(preference: EditTextIntPreference): CharSequence { - return preference.run { "$currentValue $unit" } + return preference.run { "${textForValue()} $unit" } } } } diff --git a/app/src/main/java/org/fcitx/fcitx5/android/ui/main/settings/FcitxPreferenceFragment.kt b/app/src/main/java/org/fcitx/fcitx5/android/ui/main/settings/FcitxPreferenceFragment.kt index debe663f7..c15898d70 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/ui/main/settings/FcitxPreferenceFragment.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/ui/main/settings/FcitxPreferenceFragment.kt @@ -1,6 +1,6 @@ /* * SPDX-License-Identifier: LGPL-2.1-or-later - * SPDX-FileCopyrightText: Copyright 2021-2023 Fcitx5 for Android Contributors + * SPDX-FileCopyrightText: Copyright 2021-2025 Fcitx5 for Android Contributors */ package org.fcitx.fcitx5.android.ui.main.settings @@ -13,11 +13,11 @@ import androidx.preference.isEmpty import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancel +import kotlinx.coroutines.launch import org.fcitx.fcitx5.android.R import org.fcitx.fcitx5.android.core.FcitxAPI import org.fcitx.fcitx5.android.core.RawConfig import org.fcitx.fcitx5.android.daemon.FcitxConnection -import org.fcitx.fcitx5.android.daemon.launchOnReady import org.fcitx.fcitx5.android.ui.common.PaddingPreferenceFragment import org.fcitx.fcitx5.android.ui.common.withLoadingDialog import org.fcitx.fcitx5.android.ui.main.MainViewModel @@ -39,14 +39,13 @@ abstract class FcitxPreferenceFragment : PaddingPreferenceFragment() { private val fcitx: FcitxConnection get() = viewModel.fcitx - fun requireStringArg(key: String) = - requireArguments().getString(key) - ?: throw IllegalStateException("No $key found in bundle") - private fun save() { if (!configLoaded) return - fcitx.launchOnReady { - saveConfig(it, raw["cfg"]) + // launch "saveConfig" job under supervisorJob scope + scope.launch { + fcitx.runOnReady { + saveConfig(this, raw["cfg"]) + } } } @@ -57,7 +56,7 @@ abstract class FcitxPreferenceFragment : PaddingPreferenceFragment() { // prevent "back" from navigating away from this Fragment when it's still saving override fun handleOnBackPressed() { lifecycleScope.withLoadingDialog(requireContext(), R.string.saving) { - // complete the parent job and wait for all children + // complete the parent job and wait all "saveConfig" jobs to finish supervisorJob.complete() supervisorJob.join() scope.cancel() diff --git a/app/src/main/java/org/fcitx/fcitx5/android/ui/main/settings/ListFragment.kt b/app/src/main/java/org/fcitx/fcitx5/android/ui/main/settings/ListFragment.kt index 3283d0853..6ce291c4c 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/ui/main/settings/ListFragment.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/ui/main/settings/ListFragment.kt @@ -1,6 +1,6 @@ /* * SPDX-License-Identifier: LGPL-2.1-or-later - * SPDX-FileCopyrightText: Copyright 2021-2023 Fcitx5 for Android Contributors + * SPDX-FileCopyrightText: Copyright 2021-2025 Fcitx5 for Android Contributors */ package org.fcitx.fcitx5.android.ui.main.settings @@ -19,15 +19,18 @@ import org.fcitx.fcitx5.android.ui.common.DynamicListUi import org.fcitx.fcitx5.android.ui.main.MainViewModel import org.fcitx.fcitx5.android.utils.config.ConfigDescriptor import org.fcitx.fcitx5.android.utils.config.ConfigType -import org.fcitx.fcitx5.android.utils.parcelable +import org.fcitx.fcitx5.android.utils.lazyRoute class ListFragment : Fragment() { - private val descriptor: ConfigDescriptor<*, out List<*>> by lazy { - requireArguments().parcelable(ARG_DESC)!! + val args by lazyRoute() + + private val descriptor: ConfigDescriptor<*, *> by lazy { + args.desc } + private val cfg: RawConfig by lazy { - requireArguments().parcelable(ARG_CFG)!! + args.cfg } private val viewModel: MainViewModel by activityViewModels() @@ -50,7 +53,7 @@ class ListFragment : Fragment() { ) } is ConfigDescriptor.ConfigList -> { - val ty = descriptor.type as ConfigType.TyList + val ty = descriptor.ty as ConfigType.TyList when (ty.subtype) { // does a list of booleans make sense? ConfigType.TyBool -> { @@ -158,8 +161,6 @@ class ListFragment : Fragment() { } companion object { - const val ARG_DESC = "desc" - const val ARG_CFG = "cfg" val supportedSubtypes = listOf( ConfigType.TyEnum, ConfigType.TyString, diff --git a/app/src/main/java/org/fcitx/fcitx5/android/ui/main/settings/PinyinCustomPhraseFragment.kt b/app/src/main/java/org/fcitx/fcitx5/android/ui/main/settings/PinyinCustomPhraseFragment.kt index 96792990e..7778e316d 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/ui/main/settings/PinyinCustomPhraseFragment.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/ui/main/settings/PinyinCustomPhraseFragment.kt @@ -18,6 +18,7 @@ import androidx.lifecycle.lifecycleScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.launch +import org.fcitx.fcitx5.android.R import org.fcitx.fcitx5.android.core.reloadPinyinCustomPhrase import org.fcitx.fcitx5.android.data.pinyin.CustomPhraseManager import org.fcitx.fcitx5.android.data.pinyin.customphrase.PinyinCustomPhrase @@ -26,6 +27,7 @@ import org.fcitx.fcitx5.android.ui.common.OnItemChangedListener import org.fcitx.fcitx5.android.ui.main.MainViewModel import org.fcitx.fcitx5.android.utils.NaiveDustman import org.fcitx.fcitx5.android.utils.materialTextInput +import org.fcitx.fcitx5.android.utils.onPositiveButtonClick import org.fcitx.fcitx5.android.utils.str import splitties.views.dsl.core.add import splitties.views.dsl.core.lParams @@ -109,6 +111,7 @@ class PinyinCustomPhraseFragment : Fragment(), OnItemChangedListener - block( - PinyinCustomPhrase( - keyField.str, - orderField.str.toIntOrNull() ?: 1, - phraseField.str - ) - ) - } + .setPositiveButton(android.R.string.ok, null) .setNegativeButton(android.R.string.cancel, null) .show() + .onPositiveButtonClick onClick@{ + val key = keyField.str + if (key.isBlank()) { + keyField.error = getString(R.string._cannot_be_empty, keyLabel) + keyField.requestFocus() + return@onClick false + } else { + keyField.error = null + } + val order = orderField.str.toIntOrNull() ?: 1 + val phrase = phraseField.str + if (phrase.isEmpty()) { + phraseField.error = getString(R.string._cannot_be_empty, phraseLabel) + phraseField.requestFocus() + return@onClick false + } else { + phraseField.error = null + } + block(PinyinCustomPhrase(key, order, phrase)) + return@onClick true + } + .setCanceledOnTouchOutside(false) } } ui.addOnItemChangedListener(this) diff --git a/app/src/main/java/org/fcitx/fcitx5/android/ui/main/settings/PinyinDictionaryFragment.kt b/app/src/main/java/org/fcitx/fcitx5/android/ui/main/settings/PinyinDictionaryFragment.kt index d38c65404..4d60266ad 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/ui/main/settings/PinyinDictionaryFragment.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/ui/main/settings/PinyinDictionaryFragment.kt @@ -1,9 +1,10 @@ /* * SPDX-License-Identifier: LGPL-2.1-or-later - * SPDX-FileCopyrightText: Copyright 2021-2023 Fcitx5 for Android Contributors + * SPDX-FileCopyrightText: Copyright 2021-2025 Fcitx5 for Android Contributors */ package org.fcitx.fcitx5.android.ui.main.settings +import android.annotation.SuppressLint import android.app.NotificationChannel import android.app.NotificationManager import android.net.Uri @@ -32,14 +33,16 @@ import org.fcitx.fcitx5.android.ui.common.BaseDynamicListUi import org.fcitx.fcitx5.android.ui.common.OnItemChangedListener import org.fcitx.fcitx5.android.ui.main.MainViewModel import org.fcitx.fcitx5.android.utils.NaiveDustman -import org.fcitx.fcitx5.android.utils.errorDialog +import org.fcitx.fcitx5.android.utils.importErrorDialog +import org.fcitx.fcitx5.android.utils.lazyRoute import org.fcitx.fcitx5.android.utils.notificationManager -import org.fcitx.fcitx5.android.utils.parcelable import org.fcitx.fcitx5.android.utils.queryFileName import java.util.concurrent.atomic.AtomicBoolean class PinyinDictionaryFragment : Fragment(), OnItemChangedListener { + private val args by lazyRoute() + private val viewModel: MainViewModel by activityViewModels() private lateinit var launcher: ActivityResultLauncher @@ -105,8 +108,8 @@ class PinyinDictionaryFragment : Fragment(), OnItemChangedListener(INTENT_DATA_URI) - ?.let { importFromUri(it) } + @SuppressLint("UseKtx") + args.uri?.let { importFromUri(Uri.parse(it)) } super.onViewCreated(view, savedInstanceState) } @@ -117,7 +120,7 @@ class PinyinDictionaryFragment : Fragment(), OnItemChangedListener - general(context, fragmentManager, cfg, screen, d, store, save) - } - } - + // TODO: needs some error handling + val topLevelDesc = ConfigDescriptor.parseTopLevel(desc).getOrElse { throw it } + screen.title = topLevelDesc.name + topLevelDesc.values.forEach { + general(context, fragmentManager, cfg.findByName(it.name), screen, it, store, save) + } return screen } private fun general( context: Context, fragmentManager: FragmentManager, - cfg: RawConfig, + cfg: RawConfig?, screen: PreferenceScreen, descriptor: ConfigDescriptor<*, *>, store: PreferenceDataStore, @@ -84,8 +78,9 @@ object PreferenceScreenFactory { ) { // Hide key related configs - if (hideKeyConfig && ConfigType.pretty(descriptor.type).contains("Key")) + if (hideKeyConfig && ConfigType.pretty(descriptor.ty).contains("Key")) { return + } if (descriptor is ConfigCustom) { custom(context, fragmentManager, cfg, screen, descriptor, save) @@ -94,116 +89,86 @@ object PreferenceScreenFactory { fun stubPreference() = Preference(context).apply { summary = - "${context.getString(R.string.unimplemented_type)} '${ConfigType.pretty(descriptor.type)}'" + "${context.getString(R.string.unimplemented_type)} '${ConfigType.pretty(descriptor.ty)}'" + } + + fun navigate(route: T): Boolean { + return try { + fragmentManager.primaryNavigationFragment!!.navigateWithAnim(route) + true + } catch (e: Exception) { + Timber.w("Unable to navigate(route=$route): $e") + false + } } fun pinyinDictionary() = Preference(context).apply { setOnPreferenceClickListener { - val currentFragment = fragmentManager.findFragmentById(R.id.nav_host_fragment)!! - val action = when (currentFragment) { - is AddonConfigFragment -> R.id.action_addonConfigFragment_to_pinyinDictionaryFragment - is InputMethodConfigFragment -> R.id.action_imConfigFragment_to_pinyinDictionaryFragment - else -> throw IllegalStateException("Can not navigate to pinyin dictionary from current fragment") - } - currentFragment.findNavController().navigate(action) - true + navigate(SettingsRoute.PinyinDict("")) } } fun punctuationEditor(title: String, lang: String?) = Preference(context).apply { setOnPreferenceClickListener { - val currentFragment = fragmentManager.findFragmentById(R.id.nav_host_fragment)!! - val action = when (currentFragment) { - is AddonConfigFragment -> R.id.action_addonConfigFragment_to_punctuationEditorFragment - is InputMethodConfigFragment -> R.id.action_imConfigFragment_to_punctuationEditorFragment - else -> throw IllegalStateException("Can not navigate to punctuation editor from current fragment") - } - currentFragment.findNavController().navigate( - action, - bundleOf( - PunctuationEditorFragment.TITLE to title, - PunctuationEditorFragment.LANG to lang - ) - ) - true + navigate(SettingsRoute.Punctuation(title, lang)) } } fun quickPhraseEditor() = Preference(context).apply { setOnPreferenceClickListener { - val currentFragment = fragmentManager.findFragmentById(R.id.nav_host_fragment)!! - val action = when (currentFragment) { - is AddonConfigFragment -> R.id.action_addonConfigFragment_to_quickPhraseListFragment - is InputMethodConfigFragment -> R.id.action_imConfigFragment_to_quickPhraseListFragment - else -> throw IllegalStateException("Can not navigate to quick phrase editor from current fragment") - } - currentFragment.findNavController().navigate(action) - true + navigate(SettingsRoute.QuickPhraseList) } } fun tableInputMethod() = Preference(context).apply { setOnPreferenceClickListener { - val currentFragment = fragmentManager.findFragmentById(R.id.nav_host_fragment)!! - currentFragment.findNavController() - .navigate(R.id.action_addonConfigFragment_to_tableInputMethodFragment) - true + navigate(SettingsRoute.TableInputMethods) } } fun pinyinCustomPhrase() = Preference(context).apply { setOnPreferenceClickListener { - val currentFragment = fragmentManager.findFragmentById(R.id.nav_host_fragment)!! - val action = when (currentFragment) { - is AddonConfigFragment -> R.id.action_addonConfigFragment_to_pinyinCustomPhraseFragment - is InputMethodConfigFragment -> R.id.action_imConfigFragment_to_pinyinCustomPhraseFragment - else -> throw IllegalStateException("Can not navigate to custom phrase editor from current fragment") - } - currentFragment.findNavController().navigate(action) - true + navigate(SettingsRoute.PinyinCustomPhrase) } } - fun rimeUserDataDir() = Preference(context).apply { + fun rimeUserDataDir(title: String): Preference = LongClickPreference(context).apply { setOnPreferenceClickListener { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + AlertDialog.Builder(context) + .setTitle(title) + .setMessage(R.string.open_rime_user_data_dir) + .setNegativeButton(android.R.string.cancel, null) + .setPositiveButton(android.R.string.ok) { _, _ -> + try { + context.startActivity(buildDocumentsProviderIntent()) + } catch (e: Exception) { + context.toast(e) + } + } + .show() + true + } + + // make it a hidden option, because of compatibility issues + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + setOnPreferenceLongClickListener { try { context.startActivity(buildPrimaryStorageIntent("data/rime")) - return@setOnPreferenceClickListener true } catch (e: Exception) { - context.toast(e.localizedMessage ?: e.stackTraceToString()) + context.toast(e) } } - try { - context.startActivity(buildDocumentsProviderIntent()) - } catch (e: Exception) { - context.toast(e.localizedMessage ?: e.stackTraceToString()) - } - true } } fun listPreference(subtype: ConfigType<*>): Preference = object : Preference(context) { override fun onClick() { - val currentFragment = fragmentManager.findFragmentById(R.id.nav_host_fragment)!! - val action = when (currentFragment) { - is GlobalConfigFragment -> R.id.action_globalConfigFragment_to_listFragment - is InputMethodConfigFragment -> R.id.action_imConfigFragment_to_listFragment - is AddonConfigFragment -> R.id.action_addonConfigFragment_to_listFragment - else -> throw IllegalStateException("Can not navigate to listFragment from current fragment") - } - currentFragment.findNavController().navigate( - action, - bundleOf( - ListFragment.ARG_CFG to cfg[descriptor.name], - ListFragment.ARG_DESC to descriptor, - ) - ) + navigate(SettingsRoute.ListConfig(cfg ?: RawConfig(), descriptor)) fragmentManager.setFragmentResultListener( descriptor.name, - currentFragment + fragmentManager.primaryNavigationFragment!! ) { _, v -> - cfg[descriptor.name].subItems = v.parcelableArray(descriptor.name) + cfg?.subItems = v.parcelableArray(descriptor.name) if (callChangeListener(null)) { notifyChanged() } @@ -212,30 +177,19 @@ object PreferenceScreenFactory { }.apply { if (subtype == ConfigType.TyKey) { summaryProvider = SummaryProvider { - val str = cfg[descriptor.name].subItems?.joinToString("\n") { + val keys = cfg?.subItems?.joinToString("\n") { Key.parse(it.value).localizedString - } ?: "" - str.ifEmpty { context.getString(R.string.none) } + } + if (keys.isNullOrEmpty()) context.getString(R.string.none) else keys } } } fun addonConfigPreference(addon: String) = Preference(context).apply { setOnPreferenceClickListener { - val currentFragment = fragmentManager.findFragmentById(R.id.nav_host_fragment)!! - val action = when (currentFragment) { - is AddonConfigFragment -> R.id.action_addonConfigFragment_self - is InputMethodConfigFragment -> R.id.action_imConfigFragment_to_addonConfigFragment - else -> throw IllegalStateException("Can not navigate to addonConfigFragment from current fragment") - } - currentFragment.findNavController().navigate( - action, - bundleOf( - AddonConfigFragment.ARG_UNIQUE_NAME to addon, - AddonConfigFragment.ARG_NAME to (descriptor.description ?: descriptor.name) - ) + navigate( + SettingsRoute.AddonConfig(descriptor.description ?: descriptor.name, addon) ) - true } } @@ -263,13 +217,15 @@ object PreferenceScreenFactory { ConfigExternal.ETy.TableGlobal -> addonConfigPreference("table") ConfigExternal.ETy.AndroidTable -> tableInputMethod() ConfigExternal.ETy.PinyinCustomPhrase -> pinyinCustomPhrase() - ConfigExternal.ETy.RimeUserDataDir -> rimeUserDataDir() + ConfigExternal.ETy.RimeUserDataDir -> rimeUserDataDir( + descriptor.description ?: descriptor.name + ) else -> stubPreference() } is ConfigInt -> { val min = descriptor.intMin val max = descriptor.intMax - if (min != null && max != null) { + if (min != null && max != null && max - min <= 100) { DialogSeekBarPreference(context).apply { summaryProvider = DialogSeekBarPreference.SimpleSummaryProvider descriptor.defaultValue?.let { setDefaultValue(it) } @@ -289,8 +245,8 @@ object PreferenceScreenFactory { summaryProvider = FcitxKeyPreference.SimpleSummaryProvider descriptor.defaultValue?.let { setDefaultValue(it) } } - is ConfigList -> if (descriptor.type.subtype in ListFragment.supportedSubtypes) - listPreference(descriptor.type.subtype) + is ConfigList -> if (descriptor.ty.subtype in ListFragment.supportedSubtypes) + listPreference(descriptor.ty.subtype) else stubPreference() is ConfigString -> EditTextPreference(context).apply { @@ -323,12 +279,12 @@ object PreferenceScreenFactory { private fun custom( context: Context, fragmentManager: FragmentManager, - cfg: RawConfig, + cfg: RawConfig?, screen: PreferenceScreen, descriptor: ConfigCustom, save: () -> Unit ) { - val subStore = FcitxRawConfigStore(cfg[descriptor.name]) + val subStore = FcitxRawConfigStore(cfg ?: RawConfig()) val subPref = PreferenceCategory(context).apply { key = descriptor.name title = descriptor.description ?: descriptor.name @@ -336,16 +292,8 @@ object PreferenceScreenFactory { isIconSpaceReserved = false } screen.addPreference(subPref) - descriptor.customTypeDef!!.values.forEach { - general( - context, - fragmentManager, - cfg[descriptor.name], - screen, - it, - subStore, - save - ) + descriptor.customTypeDef?.values?.forEach { + general(context, fragmentManager, cfg?.findByName(it.name), screen, it, subStore, save) } } diff --git a/app/src/main/java/org/fcitx/fcitx5/android/ui/main/settings/PunctuationEditorFragment.kt b/app/src/main/java/org/fcitx/fcitx5/android/ui/main/settings/PunctuationEditorFragment.kt index baed2c58f..3b0149d3c 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/ui/main/settings/PunctuationEditorFragment.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/ui/main/settings/PunctuationEditorFragment.kt @@ -1,11 +1,12 @@ /* * SPDX-License-Identifier: LGPL-2.1-or-later - * SPDX-FileCopyrightText: Copyright 2021-2023 Fcitx5 for Android Contributors + * SPDX-FileCopyrightText: Copyright 2021-2025 Fcitx5 for Android Contributors */ package org.fcitx.fcitx5.android.ui.main.settings import android.app.AlertDialog import android.view.View +import org.fcitx.fcitx5.android.R import org.fcitx.fcitx5.android.core.RawConfig import org.fcitx.fcitx5.android.core.getPunctuationConfig import org.fcitx.fcitx5.android.daemon.launchOnReady @@ -14,7 +15,9 @@ import org.fcitx.fcitx5.android.data.punctuation.PunctuationMapEntry import org.fcitx.fcitx5.android.ui.common.BaseDynamicListUi import org.fcitx.fcitx5.android.ui.common.OnItemChangedListener import org.fcitx.fcitx5.android.utils.NaiveDustman +import org.fcitx.fcitx5.android.utils.lazyRoute import org.fcitx.fcitx5.android.utils.materialTextInput +import org.fcitx.fcitx5.android.utils.onPositiveButtonClick import org.fcitx.fcitx5.android.utils.str import splitties.views.dsl.core.add import splitties.views.dsl.core.lParams @@ -24,6 +27,8 @@ import splitties.views.setPaddingDp class PunctuationEditorFragment : ProgressFragment(), OnItemChangedListener { + private val args by lazyRoute() + private lateinit var lang: String private lateinit var keyDesc: String private lateinit var mappingDesc: String @@ -58,7 +63,7 @@ class PunctuationEditorFragment : ProgressFragment(), OnItemChangedListener - block( - PunctuationMapEntry(keyField.str, mappingField.str, altMappingField.str) - ) - } + .setPositiveButton(android.R.string.ok, null) .setNegativeButton(android.R.string.cancel, null) .show() + .onPositiveButtonClick onClick@{ + val key = keyField.str.trim() + if (key.isBlank()) { + keyField.error = getString(R.string._cannot_be_empty, keyDesc) + keyField.requestFocus() + return@onClick false + } else { + keyField.error = null + } + val mapping = mappingField.str + if (mapping.isBlank()) { + mappingField.error = getString(R.string._cannot_be_empty, mappingDesc) + mappingField.requestFocus() + return@onClick false + } else { + mappingField.error = null + } + block(PunctuationMapEntry(key, mapping, altMappingField.str)) + return@onClick true + } + .setCanceledOnTouchOutside(false) } } resetDustman() @@ -144,7 +166,7 @@ class PunctuationEditorFragment : ProgressFragment(), OnItemChangedListener { + private val args by lazyRoute() - private lateinit var ui: BaseDynamicListUi + private val quickPhrase: QuickPhrase by lazy { + args.param.quickPhrase + } - private lateinit var quickPhrase: QuickPhrase + private lateinit var ui: BaseDynamicListUi private val dustman = NaiveDustman() override suspend fun initialize(): View { - quickPhrase = requireArguments().serializable(ARG)!! val initialEntries = withContext(Dispatchers.IO) { - quickPhrase.loadData().getOrThrow() + quickPhrase.loadData() } ui = object : BaseDynamicListUi( requireContext(), @@ -78,11 +82,38 @@ class QuickPhraseEditFragment : ProgressFragment(), OnItemChangedListener - block(QuickPhraseEntry(keywordField.str, phraseField.str)) - } + .setPositiveButton(android.R.string.ok, null) .setNegativeButton(android.R.string.cancel, null) .show() + .onPositiveButtonClick onClick@{ + val keyword = keywordField.str.trim() + // "keyword" cannot contain any black characters + if (keyword.isBlank()) { + keywordField.error = getString( + R.string._cannot_be_empty, + getString(R.string.quickphrase_keyword) + ) + keywordField.requestFocus() + return@onClick false + } else { + keywordField.error = null + } + // "phrase" may contain blank characters + val phrase = phraseField.str + if (phrase.isEmpty()) { + phraseField.error = getString( + R.string._cannot_be_empty, + getString(R.string.quickphrase_phrase) + ) + phraseField.requestFocus() + return@onClick false + } else { + phraseField.error = null + } + block(QuickPhraseEntry(keyword, phraseField.str)) + return@onClick true + } + .setCanceledOnTouchOutside(false) } } ui.addOnItemChangedListener(this) @@ -157,7 +188,6 @@ class QuickPhraseEditFragment : ProgressFragment(), OnItemChangedListener { initSettingsButton = { entry -> visibility = if (!entry.isEnabled) View.GONE else View.VISIBLE fun edit() { - findNavController().navigate( - R.id.action_quickPhraseListFragment_to_quickPhraseEditFragment, - bundleOf(QuickPhraseEditFragment.ARG to entry) - ) + navigateWithAnim(SettingsRoute.QuickPhraseEdit(entry)) parentFragmentManager.setFragmentResultListener( QuickPhraseEditFragment.RESULT, this@QuickPhraseListFragment ) { _, _ -> + if (entry is BuiltinQuickPhrase) + entry.evaluateOverride() ui.updateItem(ui.indexItem(entry), entry) // editor changed file content dustman.forceDirty() } } + var icon = R.drawable.ic_baseline_settings_24 when (entry) { is BuiltinQuickPhrase -> { @@ -100,16 +101,12 @@ class QuickPhraseListFragment : Fragment(), OnItemChangedListener { icon = R.drawable.ic_baseline_expand_more_24 setOnClickListener { PopupMenu(requireContext(), this).apply { - menu.add(getString(R.string.edit)).setOnMenuItemClickListener { - edit() - true - } - menu.add(getString(R.string.reset)).setOnMenuItemClickListener { + menu.item(R.string.edit) { edit() } + menu.item(R.string.reset) { entry.deleteOverride() ui.updateItem(ui.indexItem(entry), entry) // not sure if the content changes dustman.forceDirty() - true } show() } @@ -139,39 +136,7 @@ class QuickPhraseListFragment : Fragment(), OnItemChangedListener { shouldShowFab = true fab.setOnClickListener { // TODO use expandable fab instead - val actions = arrayOf( - getString(R.string.import_from_file), - getString(R.string.create_new) - ) - AlertDialog.Builder(requireContext()) - .setTitle(R.string.quickphrase_editor) - .setItems(actions) { _, i -> - when (i) { - 0 -> { - launcher.launch("*/*") - } - 1 -> { - val (inputLayout, editText) = materialTextInput { - setHint(R.string.name) - } - val layout = verticalLayout { - setPaddingDp(20, 10, 20, 0) - add(inputLayout, lParams(matchParent)) - } - AlertDialog.Builder(requireContext()) - .setTitle(R.string.create_new) - .setView(layout) - .setPositiveButton(android.R.string.ok) { _, _ -> - ui.addItem(item = QuickPhraseManager.newEmpty(editText.str)) - } - .setNegativeButton(android.R.string.cancel) { _, _ -> } - .show() - } - else -> {} - } - } - .setNegativeButton(android.R.string.cancel, null) - .show() + showImportOrCreateDialog() } setViewModel(viewModel) // Builtin quick phrase shouldn't be removed @@ -180,6 +145,52 @@ class QuickPhraseListFragment : Fragment(), OnItemChangedListener { addTouchCallback() } + private fun showImportOrCreateDialog() { + val actions = arrayOf( + getString(R.string.import_from_file), + getString(R.string.create_new) + ) + AlertDialog.Builder(requireContext()) + .setTitle(R.string.quickphrase_editor) + .setItems(actions) { _, i -> + when (i) { + 0 -> launcher.launch("*/*") + 1 -> showCreateQuickPhraseDialog() + } + } + .setNegativeButton(android.R.string.cancel, null) + .show() + } + + private fun showCreateQuickPhraseDialog() { + val (inputLayout, editText) = materialTextInput { + setHint(R.string.name) + } + val layout = verticalLayout { + setPaddingDp(20, 10, 20, 0) + add(inputLayout, lParams(matchParent)) + } + AlertDialog.Builder(requireContext()) + .setTitle(R.string.create_new) + .setView(layout) + .setPositiveButton(android.R.string.ok, null) + .setNegativeButton(android.R.string.cancel, null) + .show() + .onPositiveButtonClick onClick@{ + val name = editText.str.trim() + if (name.isBlank()) { + editText.error = + getString(R.string._cannot_be_empty, getString(R.string.name)) + editText.requestFocus() + return@onClick false + } else { + editText.error = null + } + ui.addItem(item = QuickPhraseManager.newEmpty(name)) + return@onClick true + } + } + override fun updateFAB() { // do nothing } @@ -198,7 +209,7 @@ class QuickPhraseListFragment : Fragment(), OnItemChangedListener { getText(R.string.quickphrase_editor), NotificationManager.IMPORTANCE_HIGH ).apply { description = CHANNEL_ID } - notificationManager.createNotificationChannel(channel) + requireContext().notificationManager.createNotificationChannel(channel) } } @@ -218,12 +229,12 @@ class QuickPhraseListFragment : Fragment(), OnItemChangedListener { val fileName = cr.queryFileName(uri) ?: return@launch val extName = fileName.substringAfterLast('.') if (extName != QuickPhrase.EXT) { - importErrorDialog(getString(R.string.exception_quickphrase_filename, fileName)) + ctx.importErrorDialog(R.string.exception_quickphrase_filename, fileName) return@launch } val entryName = fileName.substringBeforeLast('.') if (ui.entries.any { it.name == entryName }) { - importErrorDialog(getString(R.string.quickphrase_already_exists)) + ctx.importErrorDialog(R.string.quickphrase_already_exists) return@launch } NotificationCompat.Builder(ctx, CHANNEL_ID) @@ -242,22 +253,18 @@ class QuickPhraseListFragment : Fragment(), OnItemChangedListener { ui.addItem(item = imported) } } catch (e: Exception) { - importErrorDialog(e.localizedMessage ?: e.stackTraceToString()) + ctx.importErrorDialog(e) } nm.cancel(id) } } - private suspend fun importErrorDialog(message: String) { - errorDialog(requireContext(), getString(R.string.import_error), message) - } - private fun reloadQuickPhrase() { if (!dustman.dirty) return resetDustman() // save the reference to NotificationManager, in case we need to cancel notification // after Fragment detached - val nm = notificationManager + val nm = requireContext().notificationManager lifecycleScope.launch(NonCancellable + Dispatchers.IO) { if (busy.compareAndSet(false, true)) { val id = RELOAD_ID++ diff --git a/app/src/main/java/org/fcitx/fcitx5/android/ui/main/settings/SettingsRoute.kt b/app/src/main/java/org/fcitx/fcitx5/android/ui/main/settings/SettingsRoute.kt new file mode 100644 index 000000000..ffc4db630 --- /dev/null +++ b/app/src/main/java/org/fcitx/fcitx5/android/ui/main/settings/SettingsRoute.kt @@ -0,0 +1,261 @@ +/* + * SPDX-License-Identifier: LGPL-2.1-or-later + * SPDX-FileCopyrightText: Copyright 2025 Fcitx5 for Android Contributors + */ + +package org.fcitx.fcitx5.android.ui.main.settings + +import android.net.Uri +import android.os.Parcelable +import androidx.navigation.NavController +import androidx.navigation.NavType +import androidx.navigation.createGraph +import androidx.navigation.fragment.fragment +import androidx.savedstate.SavedState +import kotlinx.parcelize.Parcelize +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json +import org.fcitx.fcitx5.android.R +import org.fcitx.fcitx5.android.core.RawConfig +import org.fcitx.fcitx5.android.data.quickphrase.QuickPhrase +import org.fcitx.fcitx5.android.ui.main.AboutFragment +import org.fcitx.fcitx5.android.ui.main.DeveloperFragment +import org.fcitx.fcitx5.android.ui.main.LicensesFragment +import org.fcitx.fcitx5.android.ui.main.MainFragment +import org.fcitx.fcitx5.android.ui.main.PluginFragment +import org.fcitx.fcitx5.android.ui.main.settings.addon.AddonConfigFragment +import org.fcitx.fcitx5.android.ui.main.settings.addon.AddonListFragment +import org.fcitx.fcitx5.android.ui.main.settings.behavior.AdvancedSettingsFragment +import org.fcitx.fcitx5.android.ui.main.settings.behavior.CandidatesSettingsFragment +import org.fcitx.fcitx5.android.ui.main.settings.behavior.ClipboardSettingsFragment +import org.fcitx.fcitx5.android.ui.main.settings.behavior.KeyboardSettingsFragment +import org.fcitx.fcitx5.android.ui.main.settings.behavior.SymbolSettingsFragment +import org.fcitx.fcitx5.android.ui.main.settings.global.GlobalConfigFragment +import org.fcitx.fcitx5.android.ui.main.settings.im.InputMethodConfigFragment +import org.fcitx.fcitx5.android.ui.main.settings.im.InputMethodListFragment +import org.fcitx.fcitx5.android.ui.main.settings.theme.ThemeFragment +import org.fcitx.fcitx5.android.utils.config.ConfigDescriptor +import org.fcitx.fcitx5.android.utils.parcelable +import kotlin.reflect.typeOf + +@Parcelize +sealed class SettingsRoute : Parcelable { + + /* ========== Index ========== */ + + @Serializable + data object Index : SettingsRoute() + + /* ========== Fcitx ========== */ + + @Serializable + data object GlobalConfig : SettingsRoute() + + @Serializable + data object InputMethodList : SettingsRoute() + + @Serializable + data class InputMethodConfig(val name: String, val uniqueName: String) : SettingsRoute() + + @Serializable + data object AddonList : SettingsRoute() + + @Serializable + data class AddonConfig(val name: String, val uniqueName: String) : SettingsRoute() + + /* ========== Android ========== */ + + @Serializable + data object Theme : SettingsRoute() + + @Serializable + data object VirtualKeyboard : SettingsRoute() + + @Serializable + data object CandidatesWindow : SettingsRoute() + + @Serializable + data object Clipboard : SettingsRoute() + + @Serializable + data object Symbol : SettingsRoute() + + @Serializable + data object Plugin : SettingsRoute() + + @Serializable + data object Advanced : SettingsRoute() + + @Serializable + data object Developer : SettingsRoute() + + @Serializable + data object License : SettingsRoute() + + @Serializable + data object About : SettingsRoute() + + /* ========== External ========== */ + + @Serializable + data class ListConfig(val params: Params) : SettingsRoute() { + @Parcelize + @Serializable + data class Params(val cfg: RawConfig, val desc: ConfigDescriptor<*, *>) : Parcelable { + companion object { + // https://developer.android.com/guide/navigation/design/kotlin-dsl#custom-types + val NavType = object : NavType(isNullableAllowed = false) { + override fun put(bundle: SavedState, key: String, value: Params) { + bundle.putParcelable(key, value) + } + + override fun get(bundle: SavedState, key: String): Params? { + return bundle.parcelable(key) + } + + override fun serializeAsValue(value: Params): String { + // Serialized values must always be Uri encoded + return Uri.encode(Json.encodeToString(value)) + } + + override fun parseValue(value: String): Params { + // Navigation decodes the string before passing it to parseValue() + return Json.decodeFromString(value) + } + } + } + } + + constructor(cfg: RawConfig, desc: ConfigDescriptor<*, *>) : this(Params(cfg, desc)) + + val desc: ConfigDescriptor<*, *> + get() = params.desc + val cfg: RawConfig + get() = params.cfg + } + + @Serializable + data class PinyinDict(val uri: String? = null) : SettingsRoute() { + constructor(uri: Uri) : this(uri.toString()) + } + + @Serializable + data class Punctuation(val title: String, val lang: String? = null) : SettingsRoute() + + @Serializable + data object QuickPhraseList : SettingsRoute() + + @Serializable + data class QuickPhraseEdit(val param: Param) : SettingsRoute() { + constructor(quickPhrase: QuickPhrase) : this(Param(quickPhrase)) + + @Serializable + @Parcelize + data class Param( + val quickPhrase: QuickPhrase + ) : Parcelable { + companion object { + val NavType = object : NavType(isNullableAllowed = false) { + override fun put(bundle: SavedState, key: String, value: Param) { + bundle.putParcelable(key, value) + } + + override fun get(bundle: SavedState, key: String): Param? { + return bundle.parcelable(key) + } + + override fun serializeAsValue(value: Param): String { + return Uri.encode(Json.encodeToString(value)) + } + + override fun parseValue(value: String): Param { + return Json.decodeFromString(value) + } + } + } + } + } + + @Serializable + data object TableInputMethods : SettingsRoute() + + @Serializable + data object PinyinCustomPhrase : SettingsRoute() + + companion object { + fun createGraph(controller: NavController) = controller.createGraph(Index) { + val ctx = controller.context + + /* ========== Index ========== */ + + fragment { + label = ctx.getString(R.string.app_name) + } + + /* ========== Fcitx ========== */ + + fragment() + fragment { + label = ctx.getString(R.string.input_methods) + } + fragment() + fragment { + label = ctx.getString(R.string.addons) + } + fragment() + + /* ========== Android ========== */ + + fragment { + label = ctx.getString(R.string.theme) + } + fragment { + label = ctx.getString(R.string.virtual_keyboard) + } + fragment { + label = ctx.getString(R.string.candidates_window) + } + fragment { + label = ctx.getString(R.string.clipboard) + } + fragment { + label = ctx.getString(R.string.emoji_and_symbols) + } + fragment { + label = ctx.getString(R.string.plugins) + } + fragment { + label = ctx.getString(R.string.advanced) + } + fragment { + label = ctx.getString(R.string.developer) + } + fragment { + label = ctx.getString(R.string.license) + } + fragment { + label = ctx.getString(R.string.about) + } + + /* ========== External ========== */ + + fragment( + typeMap = mapOf(typeOf() to ListConfig.Params.NavType) + ) + fragment { + label = ctx.getString(R.string.pinyin_dict) + } + fragment() + fragment { + label = ctx.getString(R.string.quickphrase_editor) + } + fragment( + typeMap = mapOf(typeOf() to QuickPhraseEdit.Param.NavType) + ) + fragment { + label = ctx.getString(R.string.table_im) + } + fragment() + } + } +} diff --git a/app/src/main/java/org/fcitx/fcitx5/android/ui/main/settings/TableFilesSelectionUi.kt b/app/src/main/java/org/fcitx/fcitx5/android/ui/main/settings/TableFilesSelectionUi.kt index 7929f426a..d57140e09 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/ui/main/settings/TableFilesSelectionUi.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/ui/main/settings/TableFilesSelectionUi.kt @@ -13,8 +13,19 @@ import splitties.resources.resolveThemeAttribute import splitties.resources.styledColor import splitties.resources.styledDimenPxSize import splitties.resources.styledDrawable -import splitties.views.dsl.constraintlayout.* -import splitties.views.dsl.core.* +import splitties.views.dsl.constraintlayout.above +import splitties.views.dsl.constraintlayout.below +import splitties.views.dsl.constraintlayout.bottomOfParent +import splitties.views.dsl.constraintlayout.centerHorizontally +import splitties.views.dsl.constraintlayout.constraintLayout +import splitties.views.dsl.constraintlayout.lParams +import splitties.views.dsl.constraintlayout.startOfParent +import splitties.views.dsl.constraintlayout.topOfParent +import splitties.views.dsl.core.Ui +import splitties.views.dsl.core.add +import splitties.views.dsl.core.matchParent +import splitties.views.dsl.core.textView +import splitties.views.dsl.core.wrapContent import splitties.views.textAppearance import splitties.views.topPadding diff --git a/app/src/main/java/org/fcitx/fcitx5/android/ui/main/settings/TableInputMethodFragment.kt b/app/src/main/java/org/fcitx/fcitx5/android/ui/main/settings/TableInputMethodFragment.kt index 254611671..505952ffb 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/ui/main/settings/TableInputMethodFragment.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/ui/main/settings/TableInputMethodFragment.kt @@ -4,6 +4,7 @@ */ package org.fcitx.fcitx5.android.ui.main.settings +import android.app.AlertDialog import android.app.NotificationChannel import android.app.NotificationManager import android.net.Uri @@ -14,7 +15,6 @@ import android.view.View import android.view.ViewGroup import androidx.activity.result.ActivityResultLauncher import androidx.activity.result.contract.ActivityResultContracts -import androidx.appcompat.app.AlertDialog import androidx.core.app.NotificationCompat import androidx.fragment.app.Fragment import androidx.fragment.app.activityViewModels @@ -32,8 +32,10 @@ import org.fcitx.fcitx5.android.ui.common.BaseDynamicListUi import org.fcitx.fcitx5.android.ui.common.OnItemChangedListener import org.fcitx.fcitx5.android.ui.main.MainViewModel import org.fcitx.fcitx5.android.utils.NaiveDustman -import org.fcitx.fcitx5.android.utils.errorDialog +import org.fcitx.fcitx5.android.utils.importErrorDialog import org.fcitx.fcitx5.android.utils.notificationManager +import org.fcitx.fcitx5.android.utils.onPositiveButtonClick +import org.fcitx.fcitx5.android.utils.positiveButton import org.fcitx.fcitx5.android.utils.queryFileName import splitties.resources.drawable import splitties.resources.styledDrawable @@ -133,7 +135,7 @@ class TableInputMethodFragment : Fragment(), OnItemChangedListener` + */ override fun setDefaultValue(defaultValue: Any?) { - (defaultValue as? Pair<*, *>)?.apply { - (first as? Int)?.let { value = it; default = it } - (second as? Int)?.let { secondaryValue = it; secondaryDefault = it } - } + super.setDefaultValue(defaultValue) + val (first, second) = defaultValue as? Pair<*, *> ?: return + default = first as? Int ?: 0 + secondaryDefault = second as? Int ?: 0 } private fun persistValues(primary: Int, secondary: Int) { @@ -142,11 +157,7 @@ class TwinSeekBarPreference @JvmOverloads constructor( setValue(primary, secondary) } .setNeutralButton(R.string.default_) { _, _ -> - default?.let { p -> - secondaryDefault?.let { s -> - setValue(p, s) - } - } + setValue(default, secondaryDefault) } .setNegativeButton(android.R.string.cancel, null) .show() diff --git a/app/src/main/java/org/fcitx/fcitx5/android/ui/main/settings/addon/AddonConfigFragment.kt b/app/src/main/java/org/fcitx/fcitx5/android/ui/main/settings/addon/AddonConfigFragment.kt index 88a209d38..f8e93511c 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/ui/main/settings/addon/AddonConfigFragment.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/ui/main/settings/addon/AddonConfigFragment.kt @@ -1,6 +1,6 @@ /* * SPDX-License-Identifier: LGPL-2.1-or-later - * SPDX-FileCopyrightText: Copyright 2021-2023 Fcitx5 for Android Contributors + * SPDX-FileCopyrightText: Copyright 2021-2025 Fcitx5 for Android Contributors */ package org.fcitx.fcitx5.android.ui.main.settings.addon @@ -8,33 +8,32 @@ import org.fcitx.fcitx5.android.R import org.fcitx.fcitx5.android.core.FcitxAPI import org.fcitx.fcitx5.android.core.RawConfig import org.fcitx.fcitx5.android.ui.main.settings.FcitxPreferenceFragment +import org.fcitx.fcitx5.android.ui.main.settings.SettingsRoute +import org.fcitx.fcitx5.android.utils.lazyRoute class AddonConfigFragment : FcitxPreferenceFragment() { - override fun getPageTitle(): String = requireStringArg(ARG_NAME) + private val args by lazyRoute() + + override fun getPageTitle(): String = args.name override suspend fun obtainConfig(fcitx: FcitxAPI): RawConfig { - val addon = requireStringArg(ARG_UNIQUE_NAME) + val addon = args.uniqueName val raw = fcitx.getAddonConfig(addon) if (addon == "table") { - val desc = raw["desc"]["TableGlobalConfig"] - val androidTable = RawConfig( - "AndroidTable", subItems = arrayOf( - RawConfig("Type", "External"), - RawConfig("Description", getString(R.string.manage_table_im)) + // append android specific "Manage Table Input Methods" to config of table addon + raw.findByName("desc")?.findByName("TableGlobalConfig")?.let { + it.subItems = (it.subItems ?: emptyArray()) + RawConfig( + "AndroidTable", subItems = arrayOf( + RawConfig("Type", "External"), + RawConfig("Description", getString(R.string.manage_table_im)) + ) ) - ) - desc.subItems = (desc.subItems ?: arrayOf()) + androidTable + } } return raw } override suspend fun saveConfig(fcitx: FcitxAPI, newConfig: RawConfig) { - fcitx.setAddonConfig(requireStringArg(ARG_UNIQUE_NAME), newConfig) - } - - companion object { - const val ARG_UNIQUE_NAME = "addon" - const val ARG_NAME = "addon_" + fcitx.setAddonConfig(args.uniqueName, newConfig) } - -} \ No newline at end of file +} diff --git a/app/src/main/java/org/fcitx/fcitx5/android/ui/main/settings/addon/AddonListFragment.kt b/app/src/main/java/org/fcitx/fcitx5/android/ui/main/settings/addon/AddonListFragment.kt index f17ddc539..88b6bc91a 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/ui/main/settings/addon/AddonListFragment.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/ui/main/settings/addon/AddonListFragment.kt @@ -1,14 +1,12 @@ /* * SPDX-License-Identifier: LGPL-2.1-or-later - * SPDX-FileCopyrightText: Copyright 2021-2023 Fcitx5 for Android Contributors + * SPDX-FileCopyrightText: Copyright 2021-2025 Fcitx5 for Android Contributors */ package org.fcitx.fcitx5.android.ui.main.settings.addon import android.view.View import androidx.annotation.StringRes import androidx.appcompat.app.AlertDialog -import androidx.core.os.bundleOf -import androidx.navigation.findNavController import org.fcitx.fcitx5.android.R import org.fcitx.fcitx5.android.core.AddonInfo import org.fcitx.fcitx5.android.core.FcitxAPI @@ -17,6 +15,8 @@ import org.fcitx.fcitx5.android.ui.common.BaseDynamicListUi import org.fcitx.fcitx5.android.ui.common.CheckBoxListUi import org.fcitx.fcitx5.android.ui.common.OnItemChangedListener import org.fcitx.fcitx5.android.ui.main.settings.ProgressFragment +import org.fcitx.fcitx5.android.ui.main.settings.SettingsRoute +import org.fcitx.fcitx5.android.utils.navigateWithAnim class AddonListFragment : ProgressFragment(), OnItemChangedListener { @@ -120,12 +120,8 @@ class AddonListFragment : ProgressFragment(), OnItemChangedListener { entry.uniqueName != "clipboard" ) View.VISIBLE else View.INVISIBLE setOnClickListener { - it.findNavController().navigate( - R.id.action_addonListFragment_to_addonConfigFragment, - bundleOf( - AddonConfigFragment.ARG_UNIQUE_NAME to entry.uniqueName, - AddonConfigFragment.ARG_NAME to entry.displayName - ) + navigateWithAnim( + SettingsRoute.AddonConfig(entry.displayName, entry.uniqueName) ) } }, diff --git a/app/src/main/java/org/fcitx/fcitx5/android/ui/main/settings/behavior/AdvancedSettingsFragment.kt b/app/src/main/java/org/fcitx/fcitx5/android/ui/main/settings/behavior/AdvancedSettingsFragment.kt index 985d37003..acedfa2e7 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/ui/main/settings/behavior/AdvancedSettingsFragment.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/ui/main/settings/behavior/AdvancedSettingsFragment.kt @@ -1,19 +1,14 @@ /* * SPDX-License-Identifier: LGPL-2.1-or-later - * SPDX-FileCopyrightText: Copyright 2021-2023 Fcitx5 for Android Contributors + * SPDX-FileCopyrightText: Copyright 2021-2024 Fcitx5 for Android Contributors */ package org.fcitx.fcitx5.android.ui.main.settings.behavior -import android.app.NotificationChannel -import android.app.NotificationManager -import android.app.PendingIntent -import android.content.Intent import android.os.Build import android.os.Bundle import androidx.activity.result.ActivityResultLauncher import androidx.activity.result.contract.ActivityResultContracts import androidx.appcompat.app.AlertDialog -import androidx.core.app.NotificationCompat import androidx.fragment.app.activityViewModels import androidx.lifecycle.lifecycleScope import androidx.preference.PreferenceScreen @@ -28,13 +23,14 @@ import org.fcitx.fcitx5.android.data.UserDataManager import org.fcitx.fcitx5.android.data.prefs.AppPrefs import org.fcitx.fcitx5.android.data.prefs.ManagedPreferenceFragment import org.fcitx.fcitx5.android.ui.common.withLoadingDialog -import org.fcitx.fcitx5.android.ui.main.MainActivity import org.fcitx.fcitx5.android.ui.main.MainViewModel import org.fcitx.fcitx5.android.utils.AppUtil import org.fcitx.fcitx5.android.utils.addPreference -import org.fcitx.fcitx5.android.utils.errorDialog +import org.fcitx.fcitx5.android.utils.buildDocumentsProviderIntent +import org.fcitx.fcitx5.android.utils.buildPrimaryStorageIntent +import org.fcitx.fcitx5.android.utils.formatDateTime +import org.fcitx.fcitx5.android.utils.importErrorDialog import org.fcitx.fcitx5.android.utils.iso8601UTCDateTime -import org.fcitx.fcitx5.android.utils.notificationManager import org.fcitx.fcitx5.android.utils.queryFileName import org.fcitx.fcitx5.android.utils.toast @@ -59,9 +55,7 @@ class AdvancedSettingsFragment : ManagedPreferenceFragment(AppPrefs.getInstance( withContext(NonCancellable + Dispatchers.IO) { val name = cr.queryFileName(uri) ?: return@withContext if (!name.endsWith(".zip")) { - importErrorDialog( - getString(R.string.exception_user_data_filename, name) - ) + ctx.importErrorDialog(R.string.exception_user_data_filename, name) return@withContext } try { @@ -74,33 +68,14 @@ class AdvancedSettingsFragment : ManagedPreferenceFragment(AppPrefs.getInstance( AppUtil.exit() } withContext(Dispatchers.Main) { - NotificationCompat.Builder(ctx, CHANNEL_ID) - .setSmallIcon(R.drawable.ic_baseline_sync_24) - .setContentTitle(getText(R.string.app_name)) - .setContentText(getText(R.string.restart_notify_msg)) - .setPriority(NotificationCompat.PRIORITY_HIGH) - .setContentIntent( - PendingIntent.getActivity( - ctx, - 0, - Intent(ctx, MainActivity::class.java), - PendingIntent.FLAG_IMMUTABLE - ) - ) - .setAutoCancel(true) - .build() - .let { ctx.notificationManager.notify(NOTIFY_ID, it) } - ctx.toast( - getString( - R.string.user_data_imported, - iso8601UTCDateTime(metadata.exportTime) - ) - ) + AppUtil.showRestartNotification(ctx) + val exportTime = formatDateTime(metadata.exportTime) + ctx.toast(getString(R.string.user_data_imported, exportTime)) } } catch (e: Exception) { // re-start fcitx in case importing failed FcitxDaemon.startFcitx() - importErrorDialog(e.localizedMessage ?: e.stackTraceToString()) + ctx.importErrorDialog(e) } } } @@ -117,18 +92,34 @@ class AdvancedSettingsFragment : ManagedPreferenceFragment(AppPrefs.getInstance( } catch (e: Exception) { e.printStackTrace() withContext(Dispatchers.Main) { - ctx.toast(e.localizedMessage ?: e.stackTraceToString()) + ctx.toast(e) } } } } } - createNotificationChannel() } override fun onPreferenceUiCreated(screen: PreferenceScreen) { + val ctx = requireContext() + screen.addPreference( + R.string.browse_user_data_dir, + onClick = { + try { + ctx.startActivity(buildDocumentsProviderIntent()) + } catch (e: Exception) { + ctx.toast(e) + } + }, + onLongClick = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) ({ + try { + ctx.startActivity(buildPrimaryStorageIntent()) + } catch (e: Exception) { + ctx.toast(e) + } + }) else null + ) screen.addPreference(R.string.export_user_data) { - val ctx = requireContext() lifecycleScope.launch { lifecycleScope.withLoadingDialog(ctx) { viewModel.fcitx.runOnReady { @@ -140,7 +131,6 @@ class AdvancedSettingsFragment : ManagedPreferenceFragment(AppPrefs.getInstance( } } screen.addPreference(R.string.import_user_data) { - val ctx = requireContext() AlertDialog.Builder(ctx) .setIconAttribute(android.R.attr.alertDialogIcon) .setTitle(R.string.import_user_data) @@ -152,23 +142,4 @@ class AdvancedSettingsFragment : ManagedPreferenceFragment(AppPrefs.getInstance( .show() } } - - private suspend fun importErrorDialog(message: String) { - errorDialog(requireContext(), getString(R.string.import_error), message) - } - - private val CHANNEL_ID = "app-restart" - - private val NOTIFY_ID = 0xdead - - private fun createNotificationChannel() { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - val channel = NotificationChannel( - CHANNEL_ID, - getText(R.string.restart_channel), - NotificationManager.IMPORTANCE_HIGH - ).apply { description = CHANNEL_ID } - notificationManager.createNotificationChannel(channel) - } - } } diff --git a/app/src/main/java/org/fcitx/fcitx5/android/ui/main/settings/behavior/CandidatesSettingsFragment.kt b/app/src/main/java/org/fcitx/fcitx5/android/ui/main/settings/behavior/CandidatesSettingsFragment.kt new file mode 100644 index 000000000..c3ddc0c79 --- /dev/null +++ b/app/src/main/java/org/fcitx/fcitx5/android/ui/main/settings/behavior/CandidatesSettingsFragment.kt @@ -0,0 +1,11 @@ +/* + * SPDX-License-Identifier: LGPL-2.1-or-later + * SPDX-FileCopyrightText: Copyright 2024 Fcitx5 for Android Contributors + */ + +package org.fcitx.fcitx5.android.ui.main.settings.behavior + +import org.fcitx.fcitx5.android.data.prefs.AppPrefs +import org.fcitx.fcitx5.android.data.prefs.ManagedPreferenceFragment + +class CandidatesSettingsFragment : ManagedPreferenceFragment(AppPrefs.getInstance().candidates) diff --git a/app/src/main/java/org/fcitx/fcitx5/android/ui/main/settings/behavior/SymbolSettingsFragment.kt b/app/src/main/java/org/fcitx/fcitx5/android/ui/main/settings/behavior/SymbolSettingsFragment.kt new file mode 100644 index 000000000..3c7a0ff40 --- /dev/null +++ b/app/src/main/java/org/fcitx/fcitx5/android/ui/main/settings/behavior/SymbolSettingsFragment.kt @@ -0,0 +1,11 @@ +/* + * SPDX-License-Identifier: LGPL-2.1-or-later + * SPDX-FileCopyrightText: Copyright 2025 Fcitx5 for Android Contributors + */ + +package org.fcitx.fcitx5.android.ui.main.settings.behavior + +import org.fcitx.fcitx5.android.data.prefs.AppPrefs +import org.fcitx.fcitx5.android.data.prefs.ManagedPreferenceFragment + +class SymbolSettingsFragment : ManagedPreferenceFragment(AppPrefs.getInstance().symbols) diff --git a/app/src/main/java/org/fcitx/fcitx5/android/ui/main/settings/im/InputMethodConfigFragment.kt b/app/src/main/java/org/fcitx/fcitx5/android/ui/main/settings/im/InputMethodConfigFragment.kt index 0e8b7e571..d211f3e5d 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/ui/main/settings/im/InputMethodConfigFragment.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/ui/main/settings/im/InputMethodConfigFragment.kt @@ -1,26 +1,25 @@ /* * SPDX-License-Identifier: LGPL-2.1-or-later - * SPDX-FileCopyrightText: Copyright 2021-2023 Fcitx5 for Android Contributors + * SPDX-FileCopyrightText: Copyright 2021-2025 Fcitx5 for Android Contributors */ package org.fcitx.fcitx5.android.ui.main.settings.im import org.fcitx.fcitx5.android.core.FcitxAPI import org.fcitx.fcitx5.android.core.RawConfig import org.fcitx.fcitx5.android.ui.main.settings.FcitxPreferenceFragment +import org.fcitx.fcitx5.android.ui.main.settings.SettingsRoute +import org.fcitx.fcitx5.android.utils.lazyRoute class InputMethodConfigFragment : FcitxPreferenceFragment() { - override fun getPageTitle(): String = requireStringArg(ARG_NAME) + val args by lazyRoute() + + override fun getPageTitle(): String = args.name override suspend fun obtainConfig(fcitx: FcitxAPI): RawConfig { - return fcitx.getImConfig(requireStringArg(ARG_UNIQUE_NAME)) + return fcitx.getImConfig(args.uniqueName) } override suspend fun saveConfig(fcitx: FcitxAPI, newConfig: RawConfig) { - fcitx.setImConfig(requireStringArg(ARG_UNIQUE_NAME), newConfig) - } - - companion object { - const val ARG_UNIQUE_NAME = "im" - const val ARG_NAME = "im_" + fcitx.setImConfig(args.uniqueName, newConfig) } } \ No newline at end of file diff --git a/app/src/main/java/org/fcitx/fcitx5/android/ui/main/settings/im/InputMethodListFragment.kt b/app/src/main/java/org/fcitx/fcitx5/android/ui/main/settings/im/InputMethodListFragment.kt index b774437f5..922510962 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/ui/main/settings/im/InputMethodListFragment.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/ui/main/settings/im/InputMethodListFragment.kt @@ -1,19 +1,20 @@ /* * SPDX-License-Identifier: LGPL-2.1-or-later - * SPDX-FileCopyrightText: Copyright 2021-2023 Fcitx5 for Android Contributors + * SPDX-FileCopyrightText: Copyright 2021-2025 Fcitx5 for Android Contributors */ package org.fcitx.fcitx5.android.ui.main.settings.im +import android.os.Build import android.view.View -import androidx.core.os.bundleOf -import androidx.navigation.findNavController -import org.fcitx.fcitx5.android.R import org.fcitx.fcitx5.android.core.InputMethodEntry +import org.fcitx.fcitx5.android.core.SubtypeManager import org.fcitx.fcitx5.android.daemon.launchOnReady import org.fcitx.fcitx5.android.ui.common.BaseDynamicListUi import org.fcitx.fcitx5.android.ui.common.DynamicListUi import org.fcitx.fcitx5.android.ui.common.OnItemChangedListener import org.fcitx.fcitx5.android.ui.main.settings.ProgressFragment +import org.fcitx.fcitx5.android.ui.main.settings.SettingsRoute +import org.fcitx.fcitx5.android.utils.navigateWithAnim class InputMethodListFragment : ProgressFragment(), OnItemChangedListener { @@ -21,6 +22,9 @@ class InputMethodListFragment : ProgressFragment(), OnItemChangedListener f.setEnabledIme(ui.entries.map { it.uniqueName }.toTypedArray()) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + SubtypeManager.syncWith(f.enabledIme()) + } } } } @@ -38,12 +42,8 @@ class InputMethodListFragment : ProgressFragment(), OnItemChangedListener setOnClickListener { - it.findNavController().navigate( - R.id.action_imListFragment_to_imConfigFragment, - bundleOf( - InputMethodConfigFragment.ARG_UNIQUE_NAME to entry.uniqueName, - InputMethodConfigFragment.ARG_NAME to entry.displayName - ) + navigateWithAnim( + SettingsRoute.InputMethodConfig(entry.displayName, entry.uniqueName), ) } }, diff --git a/app/src/main/java/org/fcitx/fcitx5/android/ui/main/settings/theme/CustomThemeActivity.kt b/app/src/main/java/org/fcitx/fcitx5/android/ui/main/settings/theme/CustomThemeActivity.kt index fb6c5567f..c056b8547 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/ui/main/settings/theme/CustomThemeActivity.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/ui/main/settings/theme/CustomThemeActivity.kt @@ -5,15 +5,14 @@ package org.fcitx.fcitx5.android.ui.main.settings.theme import android.annotation.SuppressLint -import android.app.Activity import android.app.AlertDialog import android.content.Context import android.content.Intent import android.graphics.Bitmap import android.graphics.BitmapFactory -import android.graphics.Color import android.graphics.Rect import android.graphics.drawable.BitmapDrawable +import android.net.Uri import android.os.Bundle import android.os.Parcelable import android.view.Menu @@ -23,34 +22,32 @@ import android.view.ViewGroup import android.webkit.MimeTypeMap import android.widget.SeekBar import androidx.activity.addCallback +import androidx.activity.enableEdgeToEdge import androidx.activity.result.ActivityResultLauncher import androidx.activity.result.contract.ActivityResultContract import androidx.annotation.StringRes import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.widget.Toolbar -import androidx.core.net.toUri import androidx.core.view.ViewCompat import androidx.core.view.WindowInsetsCompat import androidx.core.view.updateLayoutParams import androidx.lifecycle.lifecycleScope -import com.canhub.cropper.CropImageContract -import com.canhub.cropper.CropImageContractOptions -import com.canhub.cropper.CropImageOptions -import com.canhub.cropper.CropImageView import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import kotlinx.parcelize.Parcelize import org.fcitx.fcitx5.android.R import org.fcitx.fcitx5.android.data.theme.Theme -import org.fcitx.fcitx5.android.data.theme.ThemeManager +import org.fcitx.fcitx5.android.data.theme.ThemeFilesManager import org.fcitx.fcitx5.android.data.theme.ThemePreset import org.fcitx.fcitx5.android.ui.common.withLoadingDialog -import org.fcitx.fcitx5.android.utils.applyTranslucentSystemBars -import org.fcitx.fcitx5.android.utils.darkenColorFilter +import org.fcitx.fcitx5.android.ui.main.CropImageActivity.CropContract +import org.fcitx.fcitx5.android.ui.main.CropImageActivity.CropOption +import org.fcitx.fcitx5.android.ui.main.CropImageActivity.CropResult +import org.fcitx.fcitx5.android.utils.DarkenColorFilter +import org.fcitx.fcitx5.android.utils.item import org.fcitx.fcitx5.android.utils.parcelable import splitties.dimensions.dp import splitties.resources.color -import splitties.resources.drawable import splitties.resources.resolveThemeAttribute import splitties.resources.styledColor import splitties.resources.styledDrawable @@ -218,11 +215,11 @@ class CustomThemeActivity : AppCompatActivity() { private lateinit var theme: Theme.Custom private class BackgroundStates { - lateinit var launcher: ActivityResultLauncher + lateinit var launcher: ActivityResultLauncher var srcImageExtension: String? = null var srcImageBuffer: ByteArray? = null - var tempImageFile: File? = null var cropRect: Rect? = null + var cropRotation: Int = 0 lateinit var croppedBitmap: Bitmap lateinit var filteredDrawable: BitmapDrawable lateinit var srcImageFile: File @@ -242,21 +239,15 @@ class CustomThemeActivity : AppCompatActivity() { background: Theme.Custom.CustomBackground, darkKeys: Boolean ) { - theme = if (darkKeys) - ThemePreset.TransparentLight.deriveCustomBackground( - theme.name, - background.croppedFilePath, - background.srcFilePath, - brightnessSeekBar.progress, - background.cropRect - ) else - ThemePreset.TransparentDark.deriveCustomBackground( - theme.name, - background.croppedFilePath, - background.srcFilePath, - brightnessSeekBar.progress, - background.cropRect - ) + val template = if (darkKeys) ThemePreset.TransparentLight else ThemePreset.TransparentDark + theme = template.deriveCustomBackground( + theme.name, + background.croppedFilePath, + background.srcFilePath, + brightnessSeekBar.progress, + background.cropRect, + background.cropRotation + ) previewUi.setTheme(theme, filteredDrawable) } @@ -269,6 +260,7 @@ class CustomThemeActivity : AppCompatActivity() { croppedImageFile = File(it.croppedFilePath) srcImageFile = File(it.srcFilePath) cropRect = it.cropRect + cropRotation = it.cropRotation croppedBitmap = BitmapFactory.decodeFile(it.croppedFilePath) filteredDrawable = BitmapDrawable(resources, croppedBitmap) } @@ -276,7 +268,7 @@ class CustomThemeActivity : AppCompatActivity() { } // create new if (originTheme == null) { - val (n, c, s) = ThemeManager.newCustomBackgroundImages() + val (n, c, s) = ThemeFilesManager.newCustomBackgroundImages() backgroundStates.apply { croppedImageFile = c srcImageFile = s @@ -292,7 +284,7 @@ class CustomThemeActivity : AppCompatActivity() { variantSwitch.visibility = View.GONE brightnessSeekBar.visibility = View.GONE } - applyTranslucentSystemBars() + enableEdgeToEdge() ViewCompat.setOnApplyWindowInsetsListener(ui) { _, windowInsets -> val statusBars = windowInsets.getInsets(WindowInsetsCompat.Type.statusBars()) val navBars = windowInsets.getInsets(WindowInsetsCompat.Type.navigationBars()) @@ -312,29 +304,27 @@ class CustomThemeActivity : AppCompatActivity() { whenHasBackground { background -> brightnessSeekBar.progress = background.brightness variantSwitch.isChecked = !theme.isDark - launcher = registerForActivityResult(CropImageContract()) { - if (!it.isSuccessful) { - if (newCreated) - cancel() - else - return@registerForActivityResult - } else { - if (newCreated) { - srcImageExtension = MimeTypeMap.getSingleton() - .getExtensionFromMimeType(contentResolver.getType(it.originalUri!!)) - srcImageBuffer = - contentResolver.openInputStream(it.originalUri!!)!! - .use { x -> x.readBytes() } + launcher = registerForActivityResult(CropContract()) { + when (it) { + CropResult.Fail -> { + if (newCreated) { + cancel() + } + } + is CropResult.Success -> { + if (newCreated) { + srcImageExtension = MimeTypeMap.getSingleton() + .getExtensionFromMimeType(contentResolver.getType(it.srcUri)) + srcImageBuffer = + contentResolver.openInputStream(it.srcUri)!! + .use { x -> x.readBytes() } + } + cropRect = it.rect + cropRotation = it.rotation + croppedBitmap = it.bitmap + filteredDrawable = BitmapDrawable(resources, croppedBitmap) + updateState() } - cropRect = it.cropRect!! - croppedBitmap = Bitmap.createScaledBitmap( - it.getBitmap(this@CustomThemeActivity)!!, - previewUi.intrinsicWidth, - previewUi.intrinsicHeight, - true - ) - filteredDrawable = BitmapDrawable(resources, croppedBitmap) - updateState() } } cropLabel.setOnClickListener { @@ -377,53 +367,32 @@ class CustomThemeActivity : AppCompatActivity() { } private fun BackgroundStates.launchCrop(w: Int, h: Int) { - if (tempImageFile == null || tempImageFile?.exists() != true) { - tempImageFile = File.createTempFile("cropped", ".png", cacheDir) - } - launcher.launch( - CropImageContractOptions( - uri = srcImageFile.takeIf { it.exists() }?.toUri(), - CropImageOptions( - initialCropWindowRectangle = cropRect, - guidelines = CropImageView.Guidelines.ON_TOUCH, - borderLineColor = Color.WHITE, - borderLineThickness = dp(1f), - borderCornerColor = Color.WHITE, - borderCornerOffset = 0f, - imageSourceIncludeGallery = true, - imageSourceIncludeCamera = false, - aspectRatioX = w, - aspectRatioY = h, - fixAspectRatio = true, - customOutputUri = tempImageFile!!.toUri(), - outputCompressFormat = Bitmap.CompressFormat.PNG, - cropMenuCropButtonIcon = R.drawable.ic_baseline_done_24, - showProgressBar = true, - progressBarColor = styledColor(android.R.attr.colorAccent), - activityMenuIconColor = styledColor(android.R.attr.colorControlNormal), - activityMenuTextColor = styledColor(android.R.attr.colorForeground), - activityBackgroundColor = styledColor(android.R.attr.colorBackground), - toolbarColor = styledColor(android.R.attr.colorPrimary), - toolbarBackButtonColor = styledColor(android.R.attr.colorControlNormal) + if (newCreated) { + launcher.launch(CropOption.New(w, h)) + } else { + launcher.launch( + CropOption.Edit( + width = w, + height = h, + Uri.fromFile(srcImageFile), + initialRect = cropRect, + initialRotation = cropRotation ) ) - ) + } } @SuppressLint("SetTextI18n") private fun BackgroundStates.updateState() { val progress = brightnessSeekBar.progress brightnessValue.text = "$progress%" - filteredDrawable.colorFilter = darkenColorFilter(100 - progress) + filteredDrawable.colorFilter = DarkenColorFilter(100 - progress) previewUi.setBackground(filteredDrawable) } private fun cancel() { - whenHasBackground { - tempImageFile?.delete() - } setResult( - Activity.RESULT_CANCELED, + RESULT_CANCELED, Intent().apply { putExtra(RESULT, null as BackgroundResult?) } ) finish() @@ -433,7 +402,6 @@ class CustomThemeActivity : AppCompatActivity() { lifecycleScope.withLoadingDialog(this) { whenHasBackground { withContext(Dispatchers.IO) { - tempImageFile?.delete() croppedImageFile.delete() croppedImageFile.outputStream().use { croppedBitmap.compress(Bitmap.CompressFormat.PNG, 100, it) @@ -452,14 +420,15 @@ class CustomThemeActivity : AppCompatActivity() { } } setResult( - Activity.RESULT_OK, + RESULT_OK, Intent().apply { var newTheme = theme whenHasBackground { newTheme = theme.copy( backgroundImage = it.copy( brightness = brightnessSeekBar.progress, - cropRect = cropRect + cropRect = cropRect, + cropRotation = cropRotation ) ) } @@ -477,7 +446,7 @@ class CustomThemeActivity : AppCompatActivity() { private fun delete() { setResult( - Activity.RESULT_OK, + RESULT_OK, Intent().apply { putExtra(RESULT, BackgroundResult.Deleted(theme.name)) } @@ -498,26 +467,14 @@ class CustomThemeActivity : AppCompatActivity() { override fun onCreateOptionsMenu(menu: Menu): Boolean { if (!newCreated) { - menu.add(R.string.delete).apply { - icon = drawable(R.drawable.ic_baseline_delete_24)!!.apply { - setTint(color(R.color.red_400)) - } - setShowAsAction(MenuItem.SHOW_AS_ACTION_IF_ROOM) - setOnMenuItemClickListener { - promptDelete() - true - } + val iconTint = color(R.color.red_400) + menu.item(R.string.save, R.drawable.ic_baseline_delete_24, iconTint, true) { + promptDelete() } } - menu.add(R.string.save).apply { - icon = drawable(R.drawable.ic_baseline_done_24)!!.apply { - setTint(styledColor(android.R.attr.colorControlNormal)) - } - setShowAsAction(MenuItem.SHOW_AS_ACTION_IF_ROOM) - setOnMenuItemClickListener { - done() - true - } + val iconTint = styledColor(android.R.attr.colorControlNormal) + menu.item(R.string.save, R.drawable.ic_baseline_check_24, iconTint, true) { + done() } return true } @@ -534,4 +491,4 @@ class CustomThemeActivity : AppCompatActivity() { const val RESULT = "result" const val ORIGIN_THEME = "origin_theme" } -} \ No newline at end of file +} diff --git a/app/src/main/java/org/fcitx/fcitx5/android/ui/main/settings/theme/KeyboardPreviewUi.kt b/app/src/main/java/org/fcitx/fcitx5/android/ui/main/settings/theme/KeyboardPreviewUi.kt index 1a2be9171..b8c048caf 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/ui/main/settings/theme/KeyboardPreviewUi.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/ui/main/settings/theme/KeyboardPreviewUi.kt @@ -1,10 +1,10 @@ /* * SPDX-License-Identifier: LGPL-2.1-or-later - * SPDX-FileCopyrightText: Copyright 2021-2023 Fcitx5 for Android Contributors + * SPDX-FileCopyrightText: Copyright 2021-2024 Fcitx5 for Android Contributors */ + package org.fcitx.fcitx5.android.ui.main.settings.theme -import android.annotation.SuppressLint import android.content.Context import android.content.res.Configuration import android.graphics.Color @@ -13,16 +13,30 @@ import android.view.View import android.widget.FrameLayout import android.widget.ImageView import androidx.constraintlayout.widget.ConstraintLayout -import androidx.core.view.* +import androidx.core.view.ViewCompat +import androidx.core.view.WindowInsetsCompat +import androidx.core.view.updateLayoutParams import org.fcitx.fcitx5.android.data.prefs.AppPrefs +import org.fcitx.fcitx5.android.data.prefs.ManagedPreference import org.fcitx.fcitx5.android.data.theme.Theme import org.fcitx.fcitx5.android.data.theme.ThemeManager -import org.fcitx.fcitx5.android.data.theme.ThemeManager.Prefs.NavbarBackground +import org.fcitx.fcitx5.android.data.theme.ThemePrefs.NavbarBackground import org.fcitx.fcitx5.android.input.keyboard.TextKeyboard +import org.fcitx.fcitx5.android.utils.navbarFrameHeight import splitties.dimensions.dp import splitties.views.backgroundColor -import splitties.views.dsl.constraintlayout.* -import splitties.views.dsl.core.* +import splitties.views.dsl.constraintlayout.below +import splitties.views.dsl.constraintlayout.centerHorizontally +import splitties.views.dsl.constraintlayout.centerInParent +import splitties.views.dsl.constraintlayout.constraintLayout +import splitties.views.dsl.constraintlayout.lParams +import splitties.views.dsl.constraintlayout.matchConstraints +import splitties.views.dsl.core.Ui +import splitties.views.dsl.core.add +import splitties.views.dsl.core.horizontalMargin +import splitties.views.dsl.core.imageView +import splitties.views.dsl.core.lParams +import splitties.views.dsl.core.view import splitties.views.imageDrawable class KeyboardPreviewUi(override val ctx: Context, val theme: Theme) : Ui { @@ -59,9 +73,13 @@ class KeyboardPreviewUi(override val ctx: Context, val theme: Theme) : Ui { return ctx.dp(value) } - private val navbarBackground by ThemeManager.prefs.navbarBackground + private val navbarBackground = ThemeManager.prefs.navbarBackground private val keyBorder by ThemeManager.prefs.keyBorder + private val navbarBkgChangeListener = ManagedPreference.OnChangeListener { _, _ -> + recalculateSize() + } + private val bkg = imageView { scaleType = ImageView.ScaleType.CENTER_CROP } @@ -91,11 +109,17 @@ class KeyboardPreviewUi(override val ctx: Context, val theme: Theme) : Ui { super.onAttachedToWindow() recalculateSize() onSizeMeasured?.invoke(intrinsicWidth, intrinsicHeight) + navbarBackground.registerOnChangeListener(navbarBkgChangeListener) } override fun onConfigurationChanged(newConfig: Configuration?) { recalculateSize() } + + override fun onDetachedFromWindow() { + navbarBackground.unregisterOnChangeListener(navbarBkgChangeListener) + super.onDetachedFromWindow() + } } var onSizeMeasured: ((Int, Int) -> Unit)? = null @@ -112,16 +136,6 @@ class KeyboardPreviewUi(override val ctx: Context, val theme: Theme) : Ui { return w to (h * hPercent / 100) } - /** - * https://android.googlesource.com/platform/frameworks/base/+/refs/tags/android-11.0.0_r48/services/core/java/com/android/server/wm/DisplayPolicy.java#3221 - * https://android.googlesource.com/platform/frameworks/base/+/refs/tags/android-11.0.0_r48/services/core/java/com/android/server/wm/DisplayPolicy.java#3059 - */ - private fun navbarHeight() = ctx.resources.run { - @SuppressLint("DiscouragedApi") - val id = getIdentifier("navigation_bar_frame_height", "dimen", "android") - if (id > 0) getDimensionPixelSize(id) else 0 - } - init { val (w, h) = keyboardWindowAspectRatio() keyboardWidth = w @@ -143,15 +157,15 @@ class KeyboardPreviewUi(override val ctx: Context, val theme: Theme) : Ui { // extra bottom padding intrinsicHeight += keyboardBottomPaddingPx // windowInsets navbar padding - if (navbarBackground == NavbarBackground.Full) { + if (navbarBackground.getValue() == NavbarBackground.Full) { ViewCompat.getRootWindowInsets(root)?.also { // IME window has different navbar height when system navigation in "gesture navigation" mode // thus the inset from Activity root window is unreliable if (it.getInsets(WindowInsetsCompat.Type.navigationBars()).bottom > 0 || // in case navigation hint was hidden ... - it.getInsets(WindowInsetsCompat.Type.systemGestures()).bottom > 0 + it.getInsets(WindowInsetsCompat.Type.mandatorySystemGestures()).bottom > 0 ) { - intrinsicHeight += navbarHeight() + intrinsicHeight += ctx.navbarFrameHeight() } } } diff --git a/app/src/main/java/org/fcitx/fcitx5/android/ui/main/settings/theme/ThemeFragment.kt b/app/src/main/java/org/fcitx/fcitx5/android/ui/main/settings/theme/ThemeFragment.kt index 24cc14d4a..dd1ed6afd 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/ui/main/settings/theme/ThemeFragment.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/ui/main/settings/theme/ThemeFragment.kt @@ -55,9 +55,7 @@ class ThemeFragment : Fragment() { container: ViewGroup?, savedInstanceState: Bundle? ): View = with(requireContext()) { - val activeTheme = ThemeManager.getActiveTheme() - - previewUi = KeyboardPreviewUi(this, activeTheme) + previewUi = KeyboardPreviewUi(this, ThemeManager.activeTheme) ThemeManager.addOnChangedListener(onThemeChangeListener) val preview = previewUi.root.apply { scaleX = 0.5f @@ -69,7 +67,7 @@ class ThemeFragment : Fragment() { tabLayout = TabLayout(this) viewPager = ViewPager2(this).apply { - adapter = object : FragmentStateAdapter(parentFragmentManager, lifecycle) { + adapter = object : FragmentStateAdapter(this@ThemeFragment) { override fun getItemCount() = 2 override fun createFragment(position: Int): Fragment = when (position) { 0 -> ThemeListFragment() diff --git a/app/src/main/java/org/fcitx/fcitx5/android/ui/main/settings/theme/ThemeListAdapter.kt b/app/src/main/java/org/fcitx/fcitx5/android/ui/main/settings/theme/ThemeListAdapter.kt index e4036cc5b..c8702b3eb 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/ui/main/settings/theme/ThemeListAdapter.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/ui/main/settings/theme/ThemeListAdapter.kt @@ -57,23 +57,20 @@ abstract class ThemeListAdapter : RecyclerView.Adapter - if (result != null) { - when (result) { - is CustomThemeActivity.BackgroundResult.Created -> { - val theme = result.theme - themeListAdapter.prependTheme(theme) - ThemeManager.saveTheme(theme) - if (!followSystemDayNightTheme) { - ThemeManager.switchTheme(theme) - } - } - is CustomThemeActivity.BackgroundResult.Deleted -> { - val name = result.name - // Update the list first, as we rely on theme changed listener - // in the case that the deleted theme was active - themeListAdapter.removeTheme(name) - ThemeManager.deleteTheme(name) - } - is CustomThemeActivity.BackgroundResult.Updated -> { - val theme = result.theme - themeListAdapter.replaceTheme(theme) - ThemeManager.saveTheme(theme) + if (result == null) return@registerForActivityResult + when (result) { + is CustomThemeActivity.BackgroundResult.Created -> { + val theme = result.theme + themeListAdapter.prependTheme(theme) + ThemeManager.saveTheme(theme) + if (!followSystemDayNightTheme) { + ThemeManager.setNormalModeTheme(theme) } } + is CustomThemeActivity.BackgroundResult.Deleted -> { + val name = result.name + themeListAdapter.removeTheme(name) + ThemeManager.deleteTheme(name) + } + is CustomThemeActivity.BackgroundResult.Updated -> { + val theme = result.theme + themeListAdapter.replaceTheme(theme) + ThemeManager.saveTheme(theme) + } } } importLauncher = @@ -91,14 +85,16 @@ class ThemeListFragment : Fragment() { lifecycleScope.withLoadingDialog(ctx) { withContext(NonCancellable + Dispatchers.IO) { val name = cr.queryFileName(uri) ?: return@withContext - if (!name.endsWith(".zip")) { - importErrorDialog(getString(R.string.exception_theme_filename)) + val ext = name.substringAfterLast('.') + if (ext != "zip") { + ctx.importErrorDialog(R.string.exception_theme_filename, ext) return@withContext } try { val inputStream = cr.openInputStream(uri)!! - val (newCreated, theme, migrated) = ThemeManager.importTheme(inputStream) - .getOrThrow() + val (newCreated, theme, migrated) = + ThemeFilesManager.importTheme(inputStream).getOrThrow() + ThemeManager.refreshThemes() withContext(Dispatchers.Main) { if (newCreated) { themeListAdapter.prependTheme(theme) @@ -110,7 +106,7 @@ class ThemeListFragment : Fragment() { } } } catch (e: Exception) { - importErrorDialog(e.localizedMessage ?: e.stackTraceToString()) + ctx.importErrorDialog(e) } } } @@ -125,10 +121,10 @@ class ThemeListFragment : Fragment() { withContext(NonCancellable + Dispatchers.IO) { try { val outputStream = ctx.contentResolver.openOutputStream(uri)!! - ThemeManager.exportTheme(exported, outputStream).getOrThrow() + ThemeFilesManager.exportTheme(exported, outputStream).getOrThrow() } catch (e: Exception) { withContext(Dispatchers.Main) { - ctx.toast(e.localizedMessage ?: e.stackTraceToString()) + ctx.toast(e) } } } @@ -147,6 +143,7 @@ class ThemeListFragment : Fragment() { override fun onEditTheme(theme: Theme.Custom) = editTheme(theme) override fun onExportTheme(theme: Theme.Custom) = exportTheme(theme) } + ThemeManager.refreshThemes() themeListAdapter.setThemes(ThemeManager.getAllThemes()) updateSelectedThemes() ThemeManager.addOnChangedListener(onThemeChangeListener) @@ -157,7 +154,7 @@ class ThemeListFragment : Fragment() { } private fun updateSelectedThemes(activeTheme: Theme? = null) { - val active = activeTheme ?: ThemeManager.getActiveTheme() + val active = activeTheme ?: ThemeManager.activeTheme var light: Theme? = null var dark: Theme? = null if (followSystemDayNightTheme) { @@ -192,7 +189,7 @@ class ThemeListFragment : Fragment() { .setView(view) .create() view.adapter = object : - SimpleThemeListAdapter(ThemeManager.builtinThemes) { + SimpleThemeListAdapter(ThemeManager.BuiltinThemes) { override fun onClick(theme: Theme.Builtin) { val newTheme = theme.deriveCustomNoBackground(UUID.randomUUID().toString()) @@ -219,13 +216,14 @@ class ThemeListFragment : Fragment() { .setNegativeButton(R.string.disable_it) { _, _ -> followSystemDayNightTheme = false lifecycleScope.launch { + ThemeManager.setNormalModeTheme(theme) updateSelectedThemes() } } .show() return } - ThemeManager.switchTheme(theme) + ThemeManager.setNormalModeTheme(theme) } private fun editTheme(theme: Theme.Custom) { diff --git a/app/src/main/java/org/fcitx/fcitx5/android/ui/main/settings/theme/ThemeSettingsFragment.kt b/app/src/main/java/org/fcitx/fcitx5/android/ui/main/settings/theme/ThemeSettingsFragment.kt index 3852ede11..73858d443 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/ui/main/settings/theme/ThemeSettingsFragment.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/ui/main/settings/theme/ThemeSettingsFragment.kt @@ -4,7 +4,48 @@ */ package org.fcitx.fcitx5.android.ui.main.settings.theme +import android.os.Bundle +import androidx.preference.SwitchPreference +import org.fcitx.fcitx5.android.data.prefs.ManagedPreference import org.fcitx.fcitx5.android.data.prefs.ManagedPreferenceFragment import org.fcitx.fcitx5.android.data.theme.ThemeManager -class ThemeSettingsFragment : ManagedPreferenceFragment(ThemeManager.prefs) +class ThemeSettingsFragment : ManagedPreferenceFragment(ThemeManager.prefs) { + + private val followSystemDayNightTheme = ThemeManager.prefs.followSystemDayNightTheme + + private var resumed = false + + private lateinit var switchPreference: SwitchPreference + + // sync SwitchPreference's state when `followSystemDayNightTheme` changed in ThemeListFragment + private val listener = ManagedPreference.OnChangeListener { _, v -> + if (resumed) return@OnChangeListener + switchPreference.isChecked = v + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + followSystemDayNightTheme.registerOnChangeListener(listener) + } + + override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { + super.onCreatePreferences(savedInstanceState, rootKey) + switchPreference = findPreference(followSystemDayNightTheme.key)!! + } + + override fun onResume() { + super.onResume() + resumed = true + } + + override fun onPause() { + super.onPause() + resumed = false + } + + override fun onDestroy() { + followSystemDayNightTheme.unregisterOnChangeListener(listener) + super.onDestroy() + } +} diff --git a/app/src/main/java/org/fcitx/fcitx5/android/ui/main/settings/theme/ThemeThumbnailUi.kt b/app/src/main/java/org/fcitx/fcitx5/android/ui/main/settings/theme/ThemeThumbnailUi.kt index 75891cca8..db8964561 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/ui/main/settings/theme/ThemeThumbnailUi.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/ui/main/settings/theme/ThemeThumbnailUi.kt @@ -1,6 +1,6 @@ /* * SPDX-License-Identifier: LGPL-2.1-or-later - * SPDX-FileCopyrightText: Copyright 2021-2023 Fcitx5 for Android Contributors + * SPDX-FileCopyrightText: Copyright 2021-2025 Fcitx5 for Android Contributors */ package org.fcitx.fcitx5.android.ui.main.settings.theme @@ -9,6 +9,7 @@ import android.content.res.ColorStateList import android.graphics.drawable.GradientDrawable import android.graphics.drawable.ShapeDrawable import android.graphics.drawable.shapes.OvalShape +import android.os.Build import android.view.View import android.view.ViewOutlineProvider import android.widget.ImageView @@ -25,6 +26,7 @@ import splitties.views.dsl.constraintlayout.constraintLayout import splitties.views.dsl.constraintlayout.endOfParent import splitties.views.dsl.constraintlayout.lParams import splitties.views.dsl.constraintlayout.rightOfParent +import splitties.views.dsl.constraintlayout.startOfParent import splitties.views.dsl.constraintlayout.topOfParent import splitties.views.dsl.core.Ui import splitties.views.dsl.core.add @@ -59,6 +61,12 @@ class ThemeThumbnailUi(override val ctx: Context) : Ui { imageResource = R.drawable.ic_baseline_edit_24 } + val dynamicIcon = imageView { + setPaddingDp(5, 5, 5, 5) + scaleType = ImageView.ScaleType.FIT_CENTER + imageResource = R.drawable.ic_baseline_auto_awesome_24 + } + override val root = constraintLayout { outlineProvider = ViewOutlineProvider.BOUNDS elevation = dp(2f) @@ -80,6 +88,10 @@ class ThemeThumbnailUi(override val ctx: Context) : Ui { topOfParent() endOfParent() }) + add(dynamicIcon, lParams(dp(32), dp(32)) { + topOfParent() + startOfParent() + }) } fun setTheme(theme: Theme) { @@ -102,6 +114,11 @@ class ThemeThumbnailUi(override val ctx: Context) : Ui { background = rippleDrawable(theme.keyPressHighlightColor) imageTintList = foregroundTint } + dynamicIcon.apply { + visibility = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && theme is Theme.Monet) View.VISIBLE else View.GONE + imageTintList = foregroundTint + } checkMark.imageTintList = foregroundTint } diff --git a/app/src/main/java/org/fcitx/fcitx5/android/ui/setup/SetupActivity.kt b/app/src/main/java/org/fcitx/fcitx5/android/ui/setup/SetupActivity.kt index 7d5d49ba2..00d99770a 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/ui/setup/SetupActivity.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/ui/setup/SetupActivity.kt @@ -12,6 +12,7 @@ import android.os.Build import android.os.Bundle import android.view.View import android.widget.Button +import androidx.activity.enableEdgeToEdge import androidx.activity.viewModels import androidx.core.app.NotificationCompat import androidx.core.os.bundleOf @@ -25,8 +26,6 @@ import org.fcitx.fcitx5.android.R import org.fcitx.fcitx5.android.databinding.ActivitySetupBinding import org.fcitx.fcitx5.android.ui.setup.SetupPage.Companion.firstUndonePage import org.fcitx.fcitx5.android.ui.setup.SetupPage.Companion.isLastPage -import org.fcitx.fcitx5.android.utils.applyTranslucentSystemBars -import org.fcitx.fcitx5.android.utils.getCurrentFragment import org.fcitx.fcitx5.android.utils.notificationManager class SetupActivity : FragmentActivity() { @@ -41,7 +40,7 @@ class SetupActivity : FragmentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - applyTranslucentSystemBars() + enableEdgeToEdge() val binding = ActivitySetupBinding.inflate(layoutInflater) ViewCompat.setOnApplyWindowInsetsListener(binding.root) { _, windowInsets -> val sysBars = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars()) @@ -59,7 +58,7 @@ class SetupActivity : FragmentActivity() { } nextButton = binding.nextButton.apply { setOnClickListener { - if (viewPager.currentItem != SetupPage.values().size - 1) + if (viewPager.currentItem != SetupPage.entries.size - 1) viewPager.currentItem = viewPager.currentItem + 1 else finish() } @@ -97,7 +96,9 @@ class SetupActivity : FragmentActivity() { override fun onWindowFocusChanged(hasFocus: Boolean) { super.onWindowFocusChanged(hasFocus) - (viewPager.getCurrentFragment(supportFragmentManager) as SetupFragment).sync() + supportFragmentManager.fragments.forEach { + if (it.isVisible) (it as SetupFragment).sync() + } } private fun createNotificationChannel() { @@ -138,7 +139,7 @@ class SetupActivity : FragmentActivity() { } private inner class Adapter : FragmentStateAdapter(this) { - override fun getItemCount(): Int = SetupPage.values().size + override fun getItemCount(): Int = SetupPage.entries.size override fun createFragment(position: Int): Fragment = SetupFragment().apply { @@ -152,4 +153,4 @@ class SetupActivity : FragmentActivity() { private const val NOTIFY_ID = 233 fun shouldShowUp() = !shown && SetupPage.hasUndonePage() } -} \ No newline at end of file +} diff --git a/app/src/main/java/org/fcitx/fcitx5/android/ui/setup/SetupPage.kt b/app/src/main/java/org/fcitx/fcitx5/android/ui/setup/SetupPage.kt index 3dee55775..f79a75255 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/ui/setup/SetupPage.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/ui/setup/SetupPage.kt @@ -37,11 +37,10 @@ enum class SetupPage { } companion object { - private val values = values() - fun valueOf(value: Int) = values[value] - fun SetupPage.isLastPage() = this == values.last() - fun Int.isLastPage() = this == values.size - 1 - fun hasUndonePage() = values.any { !it.isDone() } - fun firstUndonePage() = values.firstOrNull { !it.isDone() } + fun valueOf(value: Int) = entries[value] + fun SetupPage.isLastPage() = this == entries.last() + fun Int.isLastPage() = this == entries.size - 1 + fun hasUndonePage() = entries.any { !it.isDone() } + fun firstUndonePage() = entries.firstOrNull { !it.isDone() } } } \ No newline at end of file diff --git a/app/src/main/java/org/fcitx/fcitx5/android/utils/AlertDialog.kt b/app/src/main/java/org/fcitx/fcitx5/android/utils/AlertDialog.kt new file mode 100644 index 000000000..17e305900 --- /dev/null +++ b/app/src/main/java/org/fcitx/fcitx5/android/utils/AlertDialog.kt @@ -0,0 +1,54 @@ +/* + * SPDX-License-Identifier: LGPL-2.1-or-later + * SPDX-FileCopyrightText: Copyright 2024 Fcitx5 for Android Contributors + */ + +package org.fcitx.fcitx5.android.utils + +import android.app.AlertDialog +import android.widget.Button + +val AlertDialog.positiveButton: Button + get() = getButton(AlertDialog.BUTTON_POSITIVE) + +val AlertDialog.negativeButton: Button + get() = getButton(AlertDialog.BUTTON_NEGATIVE) + +val AlertDialog.neutralButton: Button + get() = getButton(AlertDialog.BUTTON_NEUTRAL) + +/** + * Change positive button listener **AFTER** [AlertDialog.show] has been called. + * + * In the listener: `true` to dismiss the dialog; `false` to keep the dialog open. + */ +fun AlertDialog.onPositiveButtonClick(l: AlertDialog.() -> Boolean?): AlertDialog { + positiveButton.setOnClickListener { + if (l.invoke(this) == true) dismiss() + } + return this +} + +/** + * Change negative button listener **AFTER** [AlertDialog.show] has been called. + * + * In the listener: `true` to dismiss the dialog; `false` to keep the dialog open. + */ +fun AlertDialog.onNegativeButtonClick(l: AlertDialog.() -> Boolean): AlertDialog { + negativeButton.setOnClickListener { + if (l.invoke(this)) dismiss() + } + return this +} + +/** + * Change neutral button listener **AFTER** [AlertDialog.show] has been called. + * + * In the listener: `true` to dismiss the dialog; `false` to keep the dialog open. + */ +fun AlertDialog.onNeutralButtonClick(l: AlertDialog.() -> Boolean): AlertDialog { + neutralButton.setOnClickListener { + if (l.invoke(this)) dismiss() + } + return this +} diff --git a/app/src/main/java/org/fcitx/fcitx5/android/utils/AppContext.kt b/app/src/main/java/org/fcitx/fcitx5/android/utils/AppContext.kt new file mode 100644 index 000000000..fbdf16366 --- /dev/null +++ b/app/src/main/java/org/fcitx/fcitx5/android/utils/AppContext.kt @@ -0,0 +1,12 @@ +/* + * SPDX-License-Identifier: LGPL-2.1-or-later + * SPDX-FileCopyrightText: Copyright 2024 Fcitx5 for Android Contributors + */ + +package org.fcitx.fcitx5.android.utils + +import android.content.Context +import org.fcitx.fcitx5.android.FcitxApplication + +val appContext: Context + get() = FcitxApplication.getInstance().applicationContext diff --git a/app/src/main/java/org/fcitx/fcitx5/android/utils/AppUtil.kt b/app/src/main/java/org/fcitx/fcitx5/android/utils/AppUtil.kt index 9ba136c92..a425df01c 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/utils/AppUtil.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/utils/AppUtil.kt @@ -1,72 +1,94 @@ /* * SPDX-License-Identifier: LGPL-2.1-or-later - * SPDX-FileCopyrightText: Copyright 2021-2023 Fcitx5 for Android Contributors + * SPDX-FileCopyrightText: Copyright 2021-2025 Fcitx5 for Android Contributors */ package org.fcitx.fcitx5.android.utils +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent import android.content.Context import android.content.Intent -import android.os.Bundle -import androidx.annotation.IdRes -import androidx.core.os.bundleOf -import androidx.navigation.NavDeepLinkBuilder +import android.os.Build +import androidx.core.app.NotificationCompat import org.fcitx.fcitx5.android.R import org.fcitx.fcitx5.android.ui.main.ClipboardEditActivity import org.fcitx.fcitx5.android.ui.main.MainActivity -import org.fcitx.fcitx5.android.ui.main.settings.im.InputMethodConfigFragment +import org.fcitx.fcitx5.android.ui.main.settings.SettingsRoute import kotlin.system.exitProcess object AppUtil { fun launchMain(context: Context) { - context.startActivity( - Intent(context, MainActivity::class.java).apply { - addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) - addFlags(Intent.FLAG_ACTIVITY_EXCLUDE_FROM_RECENTS) - } - ) + context.startActivity { + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_EXCLUDE_FROM_RECENTS) + } } - private fun launchMainToDest(context: Context, @IdRes dest: Int, arguments: Bundle? = null) { - NavDeepLinkBuilder(context) - .setComponentName(MainActivity::class.java) - .setGraph(R.navigation.settings_nav) - .addDestination(dest, arguments) - .createPendingIntent() - .send(context, 0, Intent().apply { - addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) - addFlags(Intent.FLAG_ACTIVITY_EXCLUDE_FROM_RECENTS) - }) + private fun launchMainToDest(context: Context, route: SettingsRoute) { + context.startActivity { + action = Intent.ACTION_RUN + putExtra(MainActivity.EXTRA_SETTINGS_ROUTE, route) + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_EXCLUDE_FROM_RECENTS) + } } fun launchMainToKeyboard(context: Context) = - launchMainToDest(context, R.id.keyboardSettingsFragment) + launchMainToDest(context, SettingsRoute.VirtualKeyboard) fun launchMainToInputMethodList(context: Context) = - launchMainToDest(context, R.id.imListFragment) + launchMainToDest(context, SettingsRoute.InputMethodList) fun launchMainToThemeList(context: Context) = - launchMainToDest(context, R.id.themeFragment) + launchMainToDest(context, SettingsRoute.Theme) fun launchMainToInputMethodConfig(context: Context, uniqueName: String, displayName: String) = - launchMainToDest( - context, R.id.imConfigFragment, bundleOf( - InputMethodConfigFragment.ARG_NAME to displayName, - InputMethodConfigFragment.ARG_UNIQUE_NAME to uniqueName - ) - ) + launchMainToDest(context, SettingsRoute.InputMethodConfig(displayName, uniqueName)) fun launchClipboardEdit(context: Context, id: Int, lastEntry: Boolean = false) { - context.startActivity( - Intent(context, ClipboardEditActivity::class.java).apply { - addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) - putExtra(ClipboardEditActivity.ENTRY_ID, id) - putExtra(ClipboardEditActivity.LAST_ENTRY, lastEntry) - } - ) + context.startActivity { + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + putExtra(ClipboardEditActivity.ENTRY_ID, id) + putExtra(ClipboardEditActivity.LAST_ENTRY, lastEntry) + } } fun exit() { exitProcess(0) } -} \ No newline at end of file + + private const val RESTART_CHANNEL_ID = "app-restart" + + private const val RESTART_NOTIFY_ID = 0xdead + + private fun createRestartNotificationChannel(ctx: Context) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val channel = NotificationChannel( + RESTART_CHANNEL_ID, + ctx.getText(R.string.restart_channel), + NotificationManager.IMPORTANCE_HIGH + ).apply { description = RESTART_CHANNEL_ID } + ctx.notificationManager.createNotificationChannel(channel) + } + } + + fun showRestartNotification(ctx: Context) { + createRestartNotificationChannel(ctx) + NotificationCompat.Builder(ctx, RESTART_CHANNEL_ID) + .setSmallIcon(R.drawable.ic_baseline_sync_24) + .setContentTitle(ctx.getText(R.string.app_name)) + .setContentText(ctx.getText(R.string.restart_notify_msg)) + .setPriority(NotificationCompat.PRIORITY_HIGH) + .setContentIntent( + PendingIntent.getActivity( + ctx, + 0, + Intent(ctx, MainActivity::class.java), + PendingIntent.FLAG_IMMUTABLE + ) + ) + .setAutoCancel(true) + .build() + .let { ctx.notificationManager.notify(RESTART_NOTIFY_ID, it) } + } +} diff --git a/app/src/main/java/org/fcitx/fcitx5/android/utils/Array.kt b/app/src/main/java/org/fcitx/fcitx5/android/utils/Array.kt new file mode 100644 index 000000000..7a675e2a4 --- /dev/null +++ b/app/src/main/java/org/fcitx/fcitx5/android/utils/Array.kt @@ -0,0 +1,14 @@ +/* + * SPDX-License-Identifier: LGPL-2.1-or-later + * SPDX-FileCopyrightText: Copyright 2025 Fcitx5 for Android Contributors + */ + +package org.fcitx.fcitx5.android.utils + +fun Array.includes(element: T?): Boolean { + return indexOf(element) >= 0 +} + +fun IntArray.includes(element: Int): Boolean { + return indexOf(element) >= 0 +} diff --git a/app/src/main/java/org/fcitx/fcitx5/android/utils/Bundle.kt b/app/src/main/java/org/fcitx/fcitx5/android/utils/Bundle.kt index bde19478a..a6d63087f 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/utils/Bundle.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/utils/Bundle.kt @@ -5,40 +5,35 @@ package org.fcitx.fcitx5.android.utils +import android.os.Build import android.os.Bundle import android.os.Parcelable import java.io.Serializable inline fun Bundle.serializable(key: String): T? { - @Suppress("DEPRECATION") - return getSerializable(key) as? T -// return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { -// getSerializable(key, T::class.java) -// } else { -// @Suppress("DEPRECATION") -// getSerializable(key) as? T -// } + // https://issuetracker.google.com/issues/240585930#comment6 + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + getSerializable(key, T::class.java) + } else { + @Suppress("DEPRECATION") + getSerializable(key) as? T + } } inline fun Bundle.parcelable(key: String): T? { - // https://issuetracker.google.com/issues/240585930#comment6 - @Suppress("DEPRECATION") - return getParcelable(key) as? T -// return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { -// getParcelable(key, T::class.java) -// } else { -// @Suppress("DEPRECATION") -// getParcelable(key) as? T -// } + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + getParcelable(key, T::class.java) + } else { + @Suppress("DEPRECATION") + getParcelable(key) as? T + } } inline fun Bundle.parcelableArray(key: String): Array? { - @Suppress("DEPRECATION", "UNCHECKED_CAST") - return getParcelableArray(key) as? Array -// return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { -// getParcelableArray(key, T::class.java) -// } else { -// @Suppress("DEPRECATION", "UNCHECKED_CAST") -// getParcelableArray(key) as? Array -// } + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + getParcelableArray(key, T::class.java) + } else { + @Suppress("DEPRECATION", "UNCHECKED_CAST") + getParcelableArray(key) as? Array + } } diff --git a/app/src/main/java/org/fcitx/fcitx5/android/utils/ClipData.kt b/app/src/main/java/org/fcitx/fcitx5/android/utils/ClipData.kt new file mode 100644 index 000000000..2bc89546f --- /dev/null +++ b/app/src/main/java/org/fcitx/fcitx5/android/utils/ClipData.kt @@ -0,0 +1,15 @@ +/* + * SPDX-License-Identifier: LGPL-2.1-or-later + * SPDX-FileCopyrightText: Copyright 2024 Fcitx5 for Android Contributors + */ + +package org.fcitx.fcitx5.android.utils + +import android.content.ClipData +import android.os.Build + +fun ClipData.timestamp() = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + description.timestamp +} else { + System.currentTimeMillis() +} diff --git a/app/src/main/java/org/fcitx/fcitx5/android/utils/ClipboardEntryTransformer.kt b/app/src/main/java/org/fcitx/fcitx5/android/utils/ClipboardEntryTransformer.kt index 9a0cbfbc9..bafea28a9 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/utils/ClipboardEntryTransformer.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/utils/ClipboardEntryTransformer.kt @@ -8,7 +8,7 @@ import org.fcitx.fcitx5.android.common.ipc.IClipboardEntryTransformer private const val FALLBACK_DESC = "" -val IClipboardEntryTransformer.desc +val IClipboardEntryTransformer.desc: String get() = runCatching { description }.getOrElse { FALLBACK_DESC } fun IClipboardEntryTransformer.descEquals(other: IClipboardEntryTransformer): Boolean { diff --git a/app/src/main/java/org/fcitx/fcitx5/android/utils/ColorFilter.kt b/app/src/main/java/org/fcitx/fcitx5/android/utils/ColorFilter.kt new file mode 100644 index 000000000..68b9e447a --- /dev/null +++ b/app/src/main/java/org/fcitx/fcitx5/android/utils/ColorFilter.kt @@ -0,0 +1,17 @@ +/* + * SPDX-License-Identifier: LGPL-2.1-or-later + * SPDX-FileCopyrightText: Copyright 2024 Fcitx5 for Android Contributors + */ + +package org.fcitx.fcitx5.android.utils + +import android.graphics.Color +import android.graphics.ColorFilter +import android.graphics.PorterDuff +import android.graphics.PorterDuffColorFilter + +@Suppress("FunctionName") +fun DarkenColorFilter(percent: Int): ColorFilter { + val value = percent * 255 / 100 + return PorterDuffColorFilter(Color.argb(value, 0, 0, 0), PorterDuff.Mode.SRC_OVER) +} diff --git a/app/src/main/java/org/fcitx/fcitx5/android/utils/Configuration.kt b/app/src/main/java/org/fcitx/fcitx5/android/utils/Configuration.kt new file mode 100644 index 000000000..62192998f --- /dev/null +++ b/app/src/main/java/org/fcitx/fcitx5/android/utils/Configuration.kt @@ -0,0 +1,11 @@ +/* + * SPDX-License-Identifier: LGPL-2.1-or-later + * SPDX-FileCopyrightText: Copyright 2024 Fcitx5 for Android Contributors + */ + +package org.fcitx.fcitx5.android.utils + +import android.content.res.Configuration + +fun Configuration.isDarkMode(): Boolean = + uiMode and Configuration.UI_MODE_NIGHT_MASK == Configuration.UI_MODE_NIGHT_YES diff --git a/app/src/main/java/org/fcitx/fcitx5/android/utils/Const.kt b/app/src/main/java/org/fcitx/fcitx5/android/utils/Const.kt index 98e4ea5ba..d596e1740 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/utils/Const.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/utils/Const.kt @@ -7,14 +7,10 @@ package org.fcitx.fcitx5.android.utils import org.fcitx.fcitx5.android.BuildConfig object Const { - const val buildTime = BuildConfig.BUILD_TIME - const val buildGitHash = BuildConfig.BUILD_GIT_HASH const val versionName = "${BuildConfig.VERSION_NAME}-${BuildConfig.BUILD_TYPE}" - const val dataDescriptorName = BuildConfig.DATA_DESCRIPTOR_NAME const val githubRepo = "https://github.com/fcitx5-android/fcitx5-android" const val licenseSpdxId = "LGPL-2.1-or-later" const val licenseUrl = "https://www.gnu.org/licenses/old-licenses/lgpl-2.1" const val privacyPolicyUrl = "https://fcitx5-android.github.io/privacy/" const val faqUrl = "https://fcitx5-android.github.io/faq/" - const val buildType = BuildConfig.BUILD_TYPE } \ No newline at end of file diff --git a/app/src/main/java/org/fcitx/fcitx5/android/utils/ContentResolver.kt b/app/src/main/java/org/fcitx/fcitx5/android/utils/ContentResolver.kt new file mode 100644 index 000000000..3904ff248 --- /dev/null +++ b/app/src/main/java/org/fcitx/fcitx5/android/utils/ContentResolver.kt @@ -0,0 +1,16 @@ +/* + * SPDX-License-Identifier: LGPL-2.1-or-later + * SPDX-FileCopyrightText: Copyright 2024 Fcitx5 for Android Contributors + */ + +package org.fcitx.fcitx5.android.utils + +import android.content.ContentResolver +import android.net.Uri +import android.provider.OpenableColumns + +fun ContentResolver.queryFileName(uri: Uri): String? = query(uri, null, null, null, null)?.use { + val index = it.getColumnIndex(OpenableColumns.DISPLAY_NAME) + it.moveToFirst() + it.getString(index) +} diff --git a/app/src/main/java/org/fcitx/fcitx5/android/utils/DateTime.kt b/app/src/main/java/org/fcitx/fcitx5/android/utils/DateTime.kt new file mode 100644 index 000000000..1af1fbf15 --- /dev/null +++ b/app/src/main/java/org/fcitx/fcitx5/android/utils/DateTime.kt @@ -0,0 +1,25 @@ +/* + * SPDX-License-Identifier: LGPL-2.1-or-later + * SPDX-FileCopyrightText: Copyright 2024 Fcitx5 for Android Contributors + */ + +package org.fcitx.fcitx5.android.utils + +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale +import java.util.TimeZone + +fun formatDateTime(timeMillis: Long? = null): String { + return SimpleDateFormat.getDateTimeInstance().format(timeMillis?.let { Date(it) } ?: Date()) +} + +private val ISO8601DateFormat by lazy { + SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'", Locale.ROOT).apply { + timeZone = TimeZone.getTimeZone("UTC") + } +} + +fun iso8601UTCDateTime(timeMillis: Long? = null): String { + return ISO8601DateFormat.format(timeMillis?.let { Date(it) } ?: Date()) +} diff --git a/app/src/main/java/org/fcitx/fcitx5/android/utils/DeviceInfo.kt b/app/src/main/java/org/fcitx/fcitx5/android/utils/DeviceInfo.kt index ad43253b5..83c25a2e9 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/utils/DeviceInfo.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/utils/DeviceInfo.kt @@ -6,6 +6,7 @@ package org.fcitx.fcitx5.android.utils import android.content.Context import android.content.res.Configuration +import android.graphics.Point import android.os.Build import org.fcitx.fcitx5.android.BuildConfig @@ -20,8 +21,16 @@ object DeviceInfo { appendLine("Model (product): ${Build.MODEL} (${Build.PRODUCT})") appendLine("Manufacturer: ${Build.MANUFACTURER}") appendLine("Tags: ${Build.TAGS}") + @Suppress("DEPRECATION") // we really want the physical display size + val size = Point().also { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + context.display!! + } else { + context.windowManager.defaultDisplay + }.getRealSize(it) + } + appendLine("Screen Size: ${size.x} x ${size.y}") val metrics = context.resources.displayMetrics - appendLine("Screen Size: ${metrics.widthPixels} x ${metrics.heightPixels}") appendLine("Screen Density: ${metrics.density}") appendLine( "Screen orientation: ${ @@ -33,11 +42,14 @@ object DeviceInfo { } }" ) + appendLine("--------- Package Info") + val pkgInfo = context.packageManager.getPackageInfo(context.packageName, 0) + appendLine("Package Name: ${pkgInfo.packageName}") + appendLine("Version Code: ${pkgInfo.versionCodeCompat}") + appendLine("Version Name: ${pkgInfo.versionName}") appendLine("--------- Build Info") - appendLine("Package Name: ${BuildConfig.APPLICATION_ID}") - appendLine("Version Code: ${BuildConfig.VERSION_CODE}") - appendLine("Version Name: ${Const.versionName}") - appendLine("Build Time: ${iso8601UTCDateTime(Const.buildTime)}") - appendLine("Build Git Hash: ${Const.buildGitHash}") + appendLine("Build Type: ${BuildConfig.BUILD_TYPE}") + appendLine("Build Time: ${iso8601UTCDateTime(BuildConfig.BUILD_TIME)}") + appendLine("Build Git Hash: ${BuildConfig.BUILD_GIT_HASH}") } } \ No newline at end of file diff --git a/app/src/main/java/org/fcitx/fcitx5/android/utils/DeviceUtil.kt b/app/src/main/java/org/fcitx/fcitx5/android/utils/DeviceUtil.kt index 2175f8b47..3c894744f 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/utils/DeviceUtil.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/utils/DeviceUtil.kt @@ -38,4 +38,12 @@ object DeviceUtil { getSystemProperty("ro.vivo.os.version").isNotEmpty() } + val isHonorMagicOS: Boolean by lazy { + getSystemProperty("ro.magic.systemversion").isNotEmpty() + } + + val isFlyme: Boolean by lazy { + Build.DISPLAY.lowercase().contains("flyme") + } + } diff --git a/app/src/main/java/org/fcitx/fcitx5/android/utils/Drawable.kt b/app/src/main/java/org/fcitx/fcitx5/android/utils/Drawable.kt index 9b61c2364..f5368506b 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/utils/Drawable.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/utils/Drawable.kt @@ -6,23 +6,36 @@ package org.fcitx.fcitx5.android.utils import android.content.res.ColorStateList import android.graphics.Color -import android.graphics.drawable.* +import android.graphics.drawable.ColorDrawable +import android.graphics.drawable.Drawable +import android.graphics.drawable.GradientDrawable +import android.graphics.drawable.RippleDrawable +import android.graphics.drawable.ShapeDrawable +import android.graphics.drawable.StateListDrawable import android.graphics.drawable.shapes.OvalShape import androidx.annotation.ColorInt -fun rippleDrawable(@ColorInt color: Int) = - RippleDrawable(ColorStateList.valueOf(color), null, ColorDrawable(Color.WHITE)) +fun rippleDrawable( + @ColorInt color: Int, + mask: Drawable = ColorDrawable(Color.WHITE) +): Drawable = RippleDrawable(ColorStateList.valueOf(color), null, mask) -fun borderlessRippleDrawable(@ColorInt color: Int, r: Int = RippleDrawable.RADIUS_AUTO) = - RippleDrawable(ColorStateList.valueOf(color), null, null).apply { - radius = r - } +fun borderlessRippleDrawable( + @ColorInt color: Int, + r: Int = RippleDrawable.RADIUS_AUTO +): Drawable = RippleDrawable(ColorStateList.valueOf(color), null, null).apply { + radius = r +} -fun pressHighlightDrawable(@ColorInt color: Int) = StateListDrawable().apply { +fun pressHighlightDrawable( + @ColorInt color: Int +): Drawable = StateListDrawable().apply { addState(intArrayOf(android.R.attr.state_pressed), ColorDrawable(color)) } -fun circlePressHighlightDrawable(@ColorInt color: Int) = StateListDrawable().apply { +fun circlePressHighlightDrawable( + @ColorInt color: Int +): Drawable = StateListDrawable().apply { addState( intArrayOf(android.R.attr.state_pressed), ShapeDrawable(OvalShape()).apply { paint.color = color } @@ -33,7 +46,7 @@ fun borderDrawable( width: Int, @ColorInt stroke: Int, @ColorInt background: Int = Color.TRANSPARENT -) = GradientDrawable().apply { +): Drawable = GradientDrawable().apply { setStroke(width, stroke) setColor(background) } diff --git a/app/src/main/java/org/fcitx/fcitx5/android/utils/EditText.kt b/app/src/main/java/org/fcitx/fcitx5/android/utils/EditText.kt new file mode 100644 index 000000000..89e3a5995 --- /dev/null +++ b/app/src/main/java/org/fcitx/fcitx5/android/utils/EditText.kt @@ -0,0 +1,11 @@ +/* + * SPDX-License-Identifier: LGPL-2.1-or-later + * SPDX-FileCopyrightText: Copyright 2024 Fcitx5 for Android Contributors + */ + +package org.fcitx.fcitx5.android.utils + +import android.widget.EditText + +inline val EditText.str: String + get() = editableText.toString() diff --git a/app/src/main/java/org/fcitx/fcitx5/android/utils/ErrorDialog.kt b/app/src/main/java/org/fcitx/fcitx5/android/utils/ErrorDialog.kt new file mode 100644 index 000000000..69804f5de --- /dev/null +++ b/app/src/main/java/org/fcitx/fcitx5/android/utils/ErrorDialog.kt @@ -0,0 +1,32 @@ +/* + * SPDX-License-Identifier: LGPL-2.1-or-later + * SPDX-FileCopyrightText: Copyright 2024 Fcitx5 for Android Contributors + */ + +package org.fcitx.fcitx5.android.utils + +import android.content.Context +import androidx.annotation.StringRes +import androidx.appcompat.app.AlertDialog +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import org.fcitx.fcitx5.android.R + +suspend fun Context.importErrorDialog(message: String) { + withContext(Dispatchers.Main.immediate) { + AlertDialog.Builder(this@importErrorDialog) + .setTitle(R.string.import_error) + .setMessage(message) + .setPositiveButton(android.R.string.ok, null) + .setIconAttribute(android.R.attr.alertDialogIcon) + .show() + } +} + +suspend fun Context.importErrorDialog(t: Throwable) { + importErrorDialog(t.localizedMessage ?: t.stackTraceToString()) +} + +suspend fun Context.importErrorDialog(@StringRes resId: Int, vararg formatArgs: Any?) { + importErrorDialog(getString(resId, formatArgs)) +} diff --git a/app/src/main/java/org/fcitx/fcitx5/android/utils/EventStateMachine.kt b/app/src/main/java/org/fcitx/fcitx5/android/utils/EventStateMachine.kt index 8a55716f8..508e316eb 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/utils/EventStateMachine.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/utils/EventStateMachine.kt @@ -9,7 +9,7 @@ import timber.log.Timber class EventStateMachine, B : EventStateMachine.BooleanStateKey>( private val initialState: State, - private val externalBooleanStates: MutableMap = mutableMapOf() + private val externalBooleanStates: MutableMap ) { interface BooleanStateKey { diff --git a/app/src/main/java/org/fcitx/fcitx5/android/utils/Fragment.kt b/app/src/main/java/org/fcitx/fcitx5/android/utils/Fragment.kt new file mode 100644 index 000000000..f4d1a84ec --- /dev/null +++ b/app/src/main/java/org/fcitx/fcitx5/android/utils/Fragment.kt @@ -0,0 +1,18 @@ +/* + * SPDX-License-Identifier: LGPL-2.1-or-later + * SPDX-FileCopyrightText: Copyright 2025 Fcitx5 for Android Contributors + */ + +package org.fcitx.fcitx5.android.utils + +import androidx.fragment.app.Fragment +import androidx.navigation.fragment.findNavController +import androidx.navigation.toRoute + +fun Fragment.navigateWithAnim(route: T) { + findNavController().navigateWithAnim(route) +} + +inline fun Fragment.lazyRoute() = lazy { + findNavController().getBackStackEntry().toRoute() +} diff --git a/app/src/main/java/org/fcitx/fcitx5/android/utils/InputConnection.kt b/app/src/main/java/org/fcitx/fcitx5/android/utils/InputConnection.kt new file mode 100644 index 000000000..f2ec6ae3c --- /dev/null +++ b/app/src/main/java/org/fcitx/fcitx5/android/utils/InputConnection.kt @@ -0,0 +1,33 @@ +/* + * SPDX-License-Identifier: LGPL-2.1-or-later + * SPDX-FileCopyrightText: Copyright 2024 Fcitx5 for Android Contributors + */ + +package org.fcitx.fcitx5.android.utils + +import android.os.Build +import android.view.inputmethod.InputConnection + +fun InputConnection.withBatchEdit(block: InputConnection.() -> Unit) { + beginBatchEdit() + block.invoke(this) + endBatchEdit() +} + +fun InputConnection.monitorCursorAnchor(enable: Boolean = true): Boolean { + if (!enable) { + requestCursorUpdates(0) + return false + } + var scheduled = false + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + scheduled = requestCursorUpdates( + InputConnection.CURSOR_UPDATE_MONITOR, + InputConnection.CURSOR_UPDATE_FILTER_CHARACTER_BOUNDS or InputConnection.CURSOR_UPDATE_FILTER_INSERTION_MARKER + ) + } + if (!scheduled) { + scheduled = requestCursorUpdates(InputConnection.CURSOR_UPDATE_MONITOR) + } + return scheduled +} diff --git a/app/src/main/java/org/fcitx/fcitx5/android/utils/InputMethodService.kt b/app/src/main/java/org/fcitx/fcitx5/android/utils/InputMethodService.kt new file mode 100644 index 000000000..d5d2a9eda --- /dev/null +++ b/app/src/main/java/org/fcitx/fcitx5/android/utils/InputMethodService.kt @@ -0,0 +1,31 @@ +/* + * SPDX-License-Identifier: LGPL-2.1-or-later + * SPDX-FileCopyrightText: Copyright 2024 Fcitx5 for Android Contributors + */ + +@file:Suppress("DEPRECATION") + +package org.fcitx.fcitx5.android.utils + +import android.inputmethodservice.InputMethodService +import android.os.Build +import android.view.inputmethod.InputMethodManager + +fun InputMethodService.forceShowSelf() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + requestShowSelf(InputMethodManager.SHOW_FORCED) + } else { + inputMethodManager.showSoftInputFromInputMethod( + window.window!!.attributes.token, + InputMethodManager.SHOW_FORCED + ) + } +} + +fun InputMethodService.switchToNextIME() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + switchToNextInputMethod(false) + } else { + inputMethodManager.switchToNextInputMethod(window.window!!.attributes.token, false) + } +} diff --git a/app/src/main/java/org/fcitx/fcitx5/android/utils/InputMethodUtil.kt b/app/src/main/java/org/fcitx/fcitx5/android/utils/InputMethodUtil.kt index 6909cb68b..3f3f9d305 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/utils/InputMethodUtil.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/utils/InputMethodUtil.kt @@ -10,24 +10,33 @@ import android.content.Intent import android.os.Build import android.provider.Settings import android.view.inputmethod.InputMethodSubtype +import org.fcitx.fcitx5.android.BuildConfig import org.fcitx.fcitx5.android.input.FcitxInputMethodService -import timber.log.Timber -import java.util.TimeZone object InputMethodUtil { - private val serviceName = - ComponentName(appContext, FcitxInputMethodService::class.java).flattenToShortString() - private fun getSecureSettings(name: String) = - Settings.Secure.getString(appContext.contentResolver, name) + @JvmField + val serviceName: String = FcitxInputMethodService::class.java.name + + @JvmField + val componentName: String = + ComponentName(appContext, FcitxInputMethodService::class.java).flattenToShortString() - fun isEnabled(): Boolean = - getSecureSettings(Settings.Secure.ENABLED_INPUT_METHODS) - ?.split(":")?.contains(serviceName) - ?: false + fun isEnabled(): Boolean { + return appContext.inputMethodManager.enabledInputMethodList.any { + it.packageName == BuildConfig.APPLICATION_ID && it.serviceName == serviceName + } + } - fun isSelected(): Boolean = - getSecureSettings(Settings.Secure.DEFAULT_INPUT_METHOD) == serviceName + fun isSelected(): Boolean { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + appContext.inputMethodManager.currentInputMethodInfo?.let { + it.packageName == BuildConfig.APPLICATION_ID && it.serviceName == serviceName + } ?: false + } else { + getSecureSettings(Settings.Secure.DEFAULT_INPUT_METHOD) == componentName + } + } fun startSettingsActivity(context: Context) = context.startActivity(Intent(Settings.ACTION_INPUT_METHOD_SETTINGS).apply { diff --git a/app/src/main/java/org/fcitx/fcitx5/android/utils/Intent.kt b/app/src/main/java/org/fcitx/fcitx5/android/utils/Intent.kt index 75f4609f7..d1b41167c 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/utils/Intent.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/utils/Intent.kt @@ -15,25 +15,21 @@ import org.fcitx.fcitx5.android.BuildConfig inline fun Intent.parcelable(key: String): T? { // https://issuetracker.google.com/issues/240585930#comment6 - @Suppress("DEPRECATION") - return getParcelableExtra(key) as? T -// return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { -// getParcelableExtra(key, T::class.java) -// } else { -// @Suppress("DEPRECATION") -// getParcelableExtra(key) as? T -// } + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + getParcelableExtra(key, T::class.java) + } else { + @Suppress("DEPRECATION") + getParcelableExtra(key) as? T + } } inline fun Intent.parcelableArray(key: String): Array? { - @Suppress("DEPRECATION", "UNCHECKED_CAST") - return getParcelableArrayExtra(key) as? Array -// return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { -// getParcelableArrayExtra(key, T::class.java) -// } else { -// @Suppress("DEPRECATION", "UNCHECKED_CAST") -// getParcelableArrayExtra(key) as? Array -// } + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + getParcelableArrayExtra(key, T::class.java) + } else { + @Suppress("DEPRECATION", "UNCHECKED_CAST") + getParcelableArrayExtra(key) as? Array + } } @RequiresApi(Build.VERSION_CODES.Q) diff --git a/app/src/main/java/org/fcitx/fcitx5/android/utils/IsJavaIdentifier.kt b/app/src/main/java/org/fcitx/fcitx5/android/utils/IsJavaIdentifier.kt new file mode 100644 index 000000000..0c57df0b2 --- /dev/null +++ b/app/src/main/java/org/fcitx/fcitx5/android/utils/IsJavaIdentifier.kt @@ -0,0 +1,15 @@ +/* + * SPDX-License-Identifier: LGPL-2.1-or-later + * SPDX-FileCopyrightText: Copyright 2024 Fcitx5 for Android Contributors + */ + +package org.fcitx.fcitx5.android.utils + +fun String.isJavaIdentifier(): Boolean { + if (this.isEmpty()) return false + if (!this[0].isJavaIdentifierStart()) return false + for (i in 1..? +): MenuItem { + if (icon != 0 && iconTint != 0) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + iconTintList = ColorStateList.valueOf(iconTint) + setIcon(icon) + } else { + setIcon(appContext.drawable(icon)?.apply { setTint(iconTint) }) + } + } + if (showAsAction) { + setShowAsAction(MenuItem.SHOW_AS_ACTION_IF_ROOM) + } + if (onClick != null) { + setOnMenuItemClickListener { + // return false only when the actual callback returns false + onClick.invoke() != false + } + } + return this +} + +fun Menu.item( + @StringRes title: Int, + @DrawableRes icon: Int = 0, + @ColorInt iconTint: Int = 0, + showAsAction: Boolean = false, + onClick: Function0? = null +): MenuItem { + val item = add(title).setup(icon, iconTint, showAsAction, onClick) + return item +} + +fun Menu.item( + title: CharSequence, + @DrawableRes icon: Int = 0, + @ColorInt iconTint: Int = 0, + showAsAction: Boolean = false, + onClick: Function0? = null +): MenuItem { + val item = add(title).setup(icon, iconTint, showAsAction, onClick) + return item +} + +fun Menu.subMenu( + @StringRes title: Int, + @DrawableRes icon: Int, + @ColorInt iconTint: Int, + showAsAction: Boolean = false, + initSubMenu: SubMenu.() -> Unit +): SubMenu { + val sub = addSubMenu(title) + sub.item.setup(icon, iconTint, showAsAction, null) + initSubMenu.invoke(sub) + return sub +} diff --git a/app/src/main/java/org/fcitx/fcitx5/android/utils/NavController.kt b/app/src/main/java/org/fcitx/fcitx5/android/utils/NavController.kt new file mode 100644 index 000000000..eacfb41da --- /dev/null +++ b/app/src/main/java/org/fcitx/fcitx5/android/utils/NavController.kt @@ -0,0 +1,21 @@ +/* + * SPDX-License-Identifier: LGPL-2.1-or-later + * SPDX-FileCopyrightText: Copyright 2025 Fcitx5 for Android Contributors + */ + +package org.fcitx.fcitx5.android.utils + +import androidx.navigation.NavController +import androidx.navigation.navOptions +import androidx.navigation.ui.R + +fun NavController.navigateWithAnim(route: T) { + navigate(route, navOptions { + anim { + enter = R.animator.nav_default_enter_anim + exit = R.animator.nav_default_exit_anim + popEnter = R.animator.nav_default_pop_enter_anim + popExit = R.animator.nav_default_pop_exit_anim + } + }) +} diff --git a/app/src/main/java/org/fcitx/fcitx5/android/utils/NavigationBarHeight.kt b/app/src/main/java/org/fcitx/fcitx5/android/utils/NavigationBarHeight.kt new file mode 100644 index 000000000..704de2491 --- /dev/null +++ b/app/src/main/java/org/fcitx/fcitx5/android/utils/NavigationBarHeight.kt @@ -0,0 +1,28 @@ +/* + * SPDX-License-Identifier: LGPL-2.1-or-later + * SPDX-FileCopyrightText: Copyright 2024 Fcitx5 for Android Contributors + */ + +package org.fcitx.fcitx5.android.utils + +import android.annotation.SuppressLint +import android.content.Context +import android.content.res.Resources +import splitties.dimensions.dp + +private const val FALLBACK_NAVBAR_HEIGHT = 48 + +/** + * android.R.dimen.navigation_bar_frame_height + * https://android.googlesource.com/platform/frameworks/base/+/refs/tags/android-11.0.0_r48/services/core/java/com/android/server/wm/DisplayPolicy.java#3221 + * https://android.googlesource.com/platform/frameworks/base/+/refs/tags/android-11.0.0_r48/services/core/java/com/android/server/wm/DisplayPolicy.java#3059 + */ +fun Context.navbarFrameHeight(): Int { + @SuppressLint("DiscouragedApi") + val resId = resources.getIdentifier("navigation_bar_frame_height", "dimen", "android") + return try { + resources.getDimensionPixelSize(resId) + } catch (e: Resources.NotFoundException) { + dp(FALLBACK_NAVBAR_HEIGHT) + } +} diff --git a/app/src/main/java/org/fcitx/fcitx5/android/utils/PackageInfo.kt b/app/src/main/java/org/fcitx/fcitx5/android/utils/PackageInfo.kt new file mode 100644 index 000000000..4ec6c4e93 --- /dev/null +++ b/app/src/main/java/org/fcitx/fcitx5/android/utils/PackageInfo.kt @@ -0,0 +1,17 @@ +/* + * SPDX-License-Identifier: LGPL-2.1-or-later + * SPDX-FileCopyrightText: Copyright 2024 Fcitx5 for Android Contributors + */ + +package org.fcitx.fcitx5.android.utils + +import android.content.pm.PackageInfo +import android.os.Build + +val PackageInfo.versionCodeCompat: Long + get() = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + longVersionCode + } else { + @Suppress("DEPRECATION") + versionCode.toLong() + } diff --git a/app/src/main/java/org/fcitx/fcitx5/android/utils/PreferenceScreen.kt b/app/src/main/java/org/fcitx/fcitx5/android/utils/PreferenceScreen.kt index 171223d5f..7349edccf 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/utils/PreferenceScreen.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/utils/PreferenceScreen.kt @@ -1,15 +1,17 @@ /* * SPDX-License-Identifier: LGPL-2.1-or-later - * SPDX-FileCopyrightText: Copyright 2021-2023 Fcitx5 for Android Contributors + * SPDX-FileCopyrightText: Copyright 2021-2024 Fcitx5 for Android Contributors */ package org.fcitx.fcitx5.android.utils +import android.content.Context import androidx.annotation.DrawableRes import androidx.annotation.StringRes import androidx.preference.Preference import androidx.preference.PreferenceCategory import androidx.preference.PreferenceGroup import androidx.preference.PreferenceScreen +import androidx.preference.PreferenceViewHolder import splitties.resources.drawable import splitties.resources.styledColor @@ -22,34 +24,40 @@ fun PreferenceScreen.addCategory(title: String, block: PreferenceCategory.() -> } fun PreferenceScreen.addCategory(@StringRes title: Int, block: PreferenceCategory.() -> Unit) { - val ctx = context - addCategory(ctx.getString(title), block) + addCategory(context.getString(title), block) } -fun PreferenceGroup.addPreference( +fun Preference.setup( title: String, summary: String? = null, @DrawableRes icon: Int? = null, onClick: (() -> Unit)? = null ) { - addPreference(Preference(context).apply { - isSingleLineTitle = false - setTitle(title) - setSummary(summary) - if (icon == null) { - isIconSpaceReserved = false - } else { - setIcon(context.drawable(icon)?.apply { - setTint(context.styledColor(android.R.attr.colorControlNormal)) - }) - } - onClick?.also { - setOnPreferenceClickListener { _ -> - it.invoke() - true - } + isSingleLineTitle = false + setTitle(title) + setSummary(summary) + if (icon == null) { + isIconSpaceReserved = false + } else { + setIcon(context.drawable(icon)?.apply { + setTint(context.styledColor(android.R.attr.colorControlNormal)) + }) + } + onClick?.also { + setOnPreferenceClickListener { _ -> + it.invoke() + true } - }) + } +} + +fun PreferenceGroup.addPreference( + title: String, + summary: String? = null, + @DrawableRes icon: Int? = null, + onClick: (() -> Unit)? = null +) { + addPreference(Preference(context).apply { setup(title, summary, icon, onClick) }) } fun PreferenceGroup.addPreference( @@ -58,8 +66,7 @@ fun PreferenceGroup.addPreference( @DrawableRes icon: Int? = null, onClick: (() -> Unit)? = null ) { - val ctx = context - addPreference(ctx.getString(title), summary, icon, onClick) + addPreference(context.getString(title), summary, icon, onClick) } fun PreferenceGroup.addPreference( @@ -68,6 +75,34 @@ fun PreferenceGroup.addPreference( @DrawableRes icon: Int? = null, onClick: (() -> Unit)? = null ) { - val ctx = context - addPreference(ctx.getString(title), summary?.let(ctx::getString), icon, onClick) + addPreference(context.getString(title), summary?.let(context::getString), icon, onClick) +} + +class LongClickPreference(context: Context) : Preference(context) { + private var onLongClick: (() -> Unit)? = null + + fun setOnPreferenceLongClickListener(callback: (() -> Unit)? = null) { + onLongClick = callback + } + + override fun onBindViewHolder(holder: PreferenceViewHolder) { + super.onBindViewHolder(holder) + holder.itemView.setOnLongClickListener { + onLongClick?.invoke() + true + } + } +} + +fun PreferenceGroup.addPreference( + @StringRes title: Int, + @StringRes summary: Int? = null, + @DrawableRes icon: Int? = null, + onClick: (() -> Unit)? = null, + onLongClick: (() -> Unit)? = null, +) { + addPreference(LongClickPreference(context).apply { + setup(context.getString(title), summary?.let { context.getString(it) }, icon, onClick) + setOnPreferenceLongClickListener(onLongClick) + }) } diff --git a/app/src/main/java/org/fcitx/fcitx5/android/utils/Primitive.kt b/app/src/main/java/org/fcitx/fcitx5/android/utils/Primitive.kt new file mode 100644 index 000000000..5998ccaa2 --- /dev/null +++ b/app/src/main/java/org/fcitx/fcitx5/android/utils/Primitive.kt @@ -0,0 +1,11 @@ +/* + * SPDX-License-Identifier: LGPL-2.1-or-later + * SPDX-FileCopyrightText: Copyright 2024 Fcitx5 for Android Contributors + */ + +package org.fcitx.fcitx5.android.utils + +import androidx.core.graphics.ColorUtils +import kotlin.math.roundToInt + +fun Int.alpha(a: Float) = ColorUtils.setAlphaComponent(this, (a * 0xff).roundToInt()) diff --git a/app/src/main/java/org/fcitx/fcitx5/android/utils/RectSerializer.kt b/app/src/main/java/org/fcitx/fcitx5/android/utils/RectSerializer.kt index 9d41028e9..3b9455d52 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/utils/RectSerializer.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/utils/RectSerializer.kt @@ -9,7 +9,11 @@ import kotlinx.serialization.KSerializer import kotlinx.serialization.descriptors.SerialDescriptor import kotlinx.serialization.descriptors.buildClassSerialDescriptor import kotlinx.serialization.descriptors.element -import kotlinx.serialization.encoding.* +import kotlinx.serialization.encoding.CompositeDecoder +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder +import kotlinx.serialization.encoding.decodeStructure +import kotlinx.serialization.encoding.encodeStructure object RectSerializer : KSerializer { diff --git a/app/src/main/java/org/fcitx/fcitx5/android/utils/RecyclerView.kt b/app/src/main/java/org/fcitx/fcitx5/android/utils/RecyclerView.kt new file mode 100644 index 000000000..ecddddcce --- /dev/null +++ b/app/src/main/java/org/fcitx/fcitx5/android/utils/RecyclerView.kt @@ -0,0 +1,21 @@ +/* + * SPDX-License-Identifier: LGPL-2.1-or-later + * SPDX-FileCopyrightText: Copyright 2024 Fcitx5 for Android Contributors + */ + +package org.fcitx.fcitx5.android.utils + +import androidx.core.view.ViewCompat +import androidx.core.view.WindowInsetsCompat +import androidx.recyclerview.widget.RecyclerView +import splitties.views.bottomPadding + +fun RecyclerView.applyNavBarInsetsBottomPadding() { + clipToPadding = false + ViewCompat.setOnApplyWindowInsetsListener(this) { _, windowInsets -> + windowInsets.getInsets(WindowInsetsCompat.Type.navigationBars()).also { + bottomPadding = it.bottom + } + windowInsets + } +} diff --git a/app/src/main/java/org/fcitx/fcitx5/android/utils/SeekBar.kt b/app/src/main/java/org/fcitx/fcitx5/android/utils/SeekBar.kt new file mode 100644 index 000000000..0081ea801 --- /dev/null +++ b/app/src/main/java/org/fcitx/fcitx5/android/utils/SeekBar.kt @@ -0,0 +1,18 @@ +/* + * SPDX-License-Identifier: LGPL-2.1-or-later + * SPDX-FileCopyrightText: Copyright 2024 Fcitx5 for Android Contributors + */ + +package org.fcitx.fcitx5.android.utils + +import android.widget.SeekBar + +fun SeekBar.setOnChangeListener(listener: SeekBar.(progress: Int) -> Unit) { + setOnSeekBarChangeListener(object : SeekBar.OnSeekBarChangeListener { + override fun onStartTrackingTouch(seekBar: SeekBar) {} + override fun onStopTrackingTouch(seekBar: SeekBar) {} + override fun onProgressChanged(seekBar: SeekBar, progress: Int, fromUser: Boolean) { + listener.invoke(seekBar, progress) + } + }) +} diff --git a/app/src/main/java/org/fcitx/fcitx5/android/utils/Settings.kt b/app/src/main/java/org/fcitx/fcitx5/android/utils/Settings.kt new file mode 100644 index 000000000..f315f334a --- /dev/null +++ b/app/src/main/java/org/fcitx/fcitx5/android/utils/Settings.kt @@ -0,0 +1,42 @@ +/* + * SPDX-License-Identifier: LGPL-2.1-or-later + * SPDX-FileCopyrightText: Copyright 2024 Fcitx5 for Android Contributors + */ + +package org.fcitx.fcitx5.android.utils + +import android.provider.Settings + +fun errInvalidType(cls: Class<*>): Nothing { + throw IllegalArgumentException("Invalid settings type ${cls.name}") +} + +inline fun getGlobalSettings(name: String): T { + return when (T::class.java) { + String::class.java -> Settings.Global.getString(appContext.contentResolver, name) + Float::class.javaObjectType -> Settings.Global.getFloat(appContext.contentResolver, name, 0f) + Long::class.javaObjectType -> Settings.Global.getLong(appContext.contentResolver, name, 0L) + Int::class.javaObjectType -> Settings.Global.getInt(appContext.contentResolver, name, 0) + else -> errInvalidType(T::class.java) + } as T +} + +inline fun getSecureSettings(name: String): T { + return when (T::class.java) { + String::class.java -> Settings.Secure.getString(appContext.contentResolver, name) + Float::class.javaObjectType -> Settings.Secure.getFloat(appContext.contentResolver, name, 0f) + Long::class.javaObjectType -> Settings.Secure.getLong(appContext.contentResolver, name, 0L) + Int::class.javaObjectType -> Settings.Secure.getInt(appContext.contentResolver, name, 0) + else -> errInvalidType(T::class.java) + } as T +} + +inline fun getSystemSettings(name: String): T { + return when (T::class.java) { + String::class.java -> Settings.System.getString(appContext.contentResolver, name) + Float::class.javaObjectType -> Settings.System.getFloat(appContext.contentResolver, name, 0f) + Long::class.javaObjectType -> Settings.System.getLong(appContext.contentResolver, name, 0L) + Int::class.javaObjectType -> Settings.System.getInt(appContext.contentResolver, name, 0) + else -> errInvalidType(T::class.java) + } as T +} diff --git a/app/src/main/java/org/fcitx/fcitx5/android/utils/Splitties.kt b/app/src/main/java/org/fcitx/fcitx5/android/utils/Splitties.kt new file mode 100644 index 000000000..32d8baf24 --- /dev/null +++ b/app/src/main/java/org/fcitx/fcitx5/android/utils/Splitties.kt @@ -0,0 +1,32 @@ +/* + * SPDX-License-Identifier: LGPL-2.1-or-later + * SPDX-FileCopyrightText: Copyright 2024 Fcitx5 for Android Contributors + */ + +package org.fcitx.fcitx5.android.utils + +import android.content.Context +import android.util.TypedValue +import android.view.View +import androidx.annotation.AttrRes +import androidx.constraintlayout.widget.ConstraintLayout +import splitties.experimental.InternalSplittiesApi +import splitties.resources.withResolvedThemeAttribute +import splitties.views.dsl.core.Ui + +@OptIn(InternalSplittiesApi::class) +fun Context.styledFloat(@AttrRes attrRes: Int) = withResolvedThemeAttribute(attrRes) { + when (type) { + TypedValue.TYPE_FLOAT -> float + else -> throw IllegalArgumentException("float attribute expected") + } +} + +@Suppress("NOTHING_TO_INLINE") +inline fun View.styledFloat(@AttrRes attrRes: Int) = context.styledFloat(attrRes) + +@Suppress("NOTHING_TO_INLINE") +inline fun Ui.styledFloat(@AttrRes attrRes: Int) = ctx.styledFloat(attrRes) + +inline val ConstraintLayout.LayoutParams.unset + get() = ConstraintLayout.LayoutParams.UNSET diff --git a/app/src/main/java/org/fcitx/fcitx5/android/utils/StartActivity.kt b/app/src/main/java/org/fcitx/fcitx5/android/utils/StartActivity.kt new file mode 100644 index 000000000..aba997002 --- /dev/null +++ b/app/src/main/java/org/fcitx/fcitx5/android/utils/StartActivity.kt @@ -0,0 +1,19 @@ +/* + * SPDX-License-Identifier: LGPL-2.1-or-later + * SPDX-FileCopyrightText: Copyright 2024 Fcitx5 for Android Contributors + */ + +package org.fcitx.fcitx5.android.utils + +import android.app.Activity +import android.content.Context +import android.content.Intent +import androidx.fragment.app.Fragment + +inline fun Context.startActivity(setupIntent: Intent.() -> Unit = {}) { + startActivity(Intent(this, T::class.java).apply(setupIntent)) +} + +inline fun Fragment.startActivity(setupIntent: Intent.() -> Unit = {}) { + requireContext().startActivity(setupIntent) +} diff --git a/app/src/main/java/org/fcitx/fcitx5/android/utils/SystemProperty.kt b/app/src/main/java/org/fcitx/fcitx5/android/utils/SystemProperty.kt new file mode 100644 index 000000000..4605c20b0 --- /dev/null +++ b/app/src/main/java/org/fcitx/fcitx5/android/utils/SystemProperty.kt @@ -0,0 +1,19 @@ +/* + * SPDX-License-Identifier: LGPL-2.1-or-later + * SPDX-FileCopyrightText: Copyright 2024 Fcitx5 for Android Contributors + */ + +package org.fcitx.fcitx5.android.utils + +import android.annotation.SuppressLint + +@SuppressLint("PrivateApi") +fun getSystemProperty(key: String): String { + return try { + Class.forName("android.os.SystemProperties") + .getMethod("get", String::class.java) + .invoke(null, key) as String + } catch (e: Exception) { + "" + } +} diff --git a/app/src/main/java/org/fcitx/fcitx5/android/utils/SystemService.kt b/app/src/main/java/org/fcitx/fcitx5/android/utils/SystemService.kt index bcf02cc5b..2dfb02b24 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/utils/SystemService.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/utils/SystemService.kt @@ -11,9 +11,9 @@ import android.media.AudioManager import android.os.UserManager import android.os.Vibrator import android.os.storage.StorageManager +import android.view.WindowManager import android.view.inputmethod.InputMethodManager import androidx.core.content.getSystemService -import androidx.fragment.app.Fragment val Context.audioManager get() = getSystemService()!! @@ -27,14 +27,14 @@ val Context.inputMethodManager val Context.notificationManager get() = getSystemService()!! -val Fragment.notificationManager - get() = requireContext().notificationManager - val Context.storageManager get() = getSystemService()!! val Context.vibrator get() = getSystemService()!! +val Context.windowManager + get() = getSystemService()!! + val Context.userManager get() = getSystemService()!! diff --git a/app/src/main/java/org/fcitx/fcitx5/android/utils/TempDir.kt b/app/src/main/java/org/fcitx/fcitx5/android/utils/TempDir.kt new file mode 100644 index 000000000..1f3e747f1 --- /dev/null +++ b/app/src/main/java/org/fcitx/fcitx5/android/utils/TempDir.kt @@ -0,0 +1,19 @@ +/* + * SPDX-License-Identifier: LGPL-2.1-or-later + * SPDX-FileCopyrightText: Copyright 2024 Fcitx5 for Android Contributors + */ + +package org.fcitx.fcitx5.android.utils + +import java.io.File + +inline fun withTempDir(block: (File) -> T): T { + val dir = appContext.cacheDir.resolve(System.currentTimeMillis().toString()).also { + it.mkdirs() + } + try { + return block(dir) + } finally { + dir.deleteRecursively() + } +} diff --git a/app/src/main/java/org/fcitx/fcitx5/android/utils/Toast.kt b/app/src/main/java/org/fcitx/fcitx5/android/utils/Toast.kt new file mode 100644 index 000000000..a215d21b9 --- /dev/null +++ b/app/src/main/java/org/fcitx/fcitx5/android/utils/Toast.kt @@ -0,0 +1,33 @@ +/* + * SPDX-License-Identifier: LGPL-2.1-or-later + * SPDX-FileCopyrightText: Copyright 2024 Fcitx5 for Android Contributors + */ + +package org.fcitx.fcitx5.android.utils + +import android.content.Context +import android.widget.Toast +import androidx.annotation.StringRes +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import org.fcitx.fcitx5.android.R + +fun Context.toast(string: String, duration: Int = Toast.LENGTH_SHORT) { + Toast.makeText(this, string, duration).show() +} + +fun Context.toast(@StringRes resId: Int, duration: Int = Toast.LENGTH_SHORT) { + Toast.makeText(this, resId, duration).show() +} + +fun Context.toast(t: Throwable, duration: Int = Toast.LENGTH_SHORT) { + toast(t.localizedMessage ?: t.stackTraceToString(), duration) +} + +suspend fun Context.toast(result: Result, duration: Int = Toast.LENGTH_SHORT) { + withContext(Dispatchers.Main.immediate) { + result + .onSuccess { toast(R.string.done, duration) } + .onFailure { toast(it, duration) } + } +} diff --git a/app/src/main/java/org/fcitx/fcitx5/android/utils/Utils.kt b/app/src/main/java/org/fcitx/fcitx5/android/utils/Utils.kt deleted file mode 100644 index 7cfc9c547..000000000 --- a/app/src/main/java/org/fcitx/fcitx5/android/utils/Utils.kt +++ /dev/null @@ -1,247 +0,0 @@ -/* - * SPDX-License-Identifier: LGPL-2.1-or-later - * SPDX-FileCopyrightText: Copyright 2021-2023 Fcitx5 for Android Contributors - */ -package org.fcitx.fcitx5.android.utils - -import android.annotation.SuppressLint -import android.app.Activity -import android.content.ContentResolver -import android.content.Context -import android.content.res.Configuration -import android.graphics.Color -import android.graphics.ColorFilter -import android.graphics.PorterDuff -import android.graphics.PorterDuffColorFilter -import android.net.Uri -import android.os.Build -import android.os.Bundle -import android.os.Looper -import android.provider.OpenableColumns -import android.provider.Settings -import android.util.TypedValue -import android.view.View -import android.view.inputmethod.InputConnection -import android.widget.EditText -import android.widget.SeekBar -import android.widget.Toast -import androidx.annotation.AttrRes -import androidx.annotation.IdRes -import androidx.annotation.StringRes -import androidx.appcompat.app.AlertDialog -import androidx.constraintlayout.widget.ConstraintLayout -import androidx.core.graphics.ColorUtils -import androidx.core.view.ViewCompat -import androidx.core.view.WindowCompat -import androidx.core.view.WindowInsetsCompat -import androidx.fragment.app.Fragment -import androidx.fragment.app.FragmentManager -import androidx.navigation.NavController -import androidx.recyclerview.widget.RecyclerView -import androidx.viewpager2.widget.ViewPager2 -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext -import org.fcitx.fcitx5.android.FcitxApplication -import org.fcitx.fcitx5.android.R -import splitties.experimental.InternalSplittiesApi -import splitties.resources.withResolvedThemeAttribute -import splitties.views.bottomPadding -import java.io.File -import java.text.SimpleDateFormat -import java.util.Collections -import java.util.Date -import java.util.Locale -import java.util.TimeZone -import java.util.WeakHashMap -import java.util.zip.ZipInputStream -import kotlin.math.roundToInt - -fun ViewPager2.getCurrentFragment(fragmentManager: FragmentManager): Fragment? = - fragmentManager.findFragmentByTag("f$currentItem") - -val appContext: Context - get() = FcitxApplication.getInstance().applicationContext - -fun Context.toast(string: String, duration: Int = Toast.LENGTH_SHORT) { - Toast.makeText(this, string, duration).show() -} - -fun Context.toast(@StringRes resId: Int, duration: Int = Toast.LENGTH_SHORT) { - toast(getString(resId), duration) -} - -fun ContentResolver.queryFileName(uri: Uri) = - query(uri, null, null, null, null)?.use { - val index = it.getColumnIndex(OpenableColumns.DISPLAY_NAME) - it.moveToFirst() - it.getString(index) - } - -val EditText.str: String get() = editableText.toString() - -@OptIn(InternalSplittiesApi::class) -fun Context.styledFloat(@AttrRes attrRes: Int) = withResolvedThemeAttribute(attrRes) { - when (type) { - TypedValue.TYPE_FLOAT -> float - else -> throw IllegalArgumentException("float attribute expected") - } -} - -@Suppress("NOTHING_TO_INLINE") -inline fun View.styledFloat(@AttrRes attrRes: Int) = context.styledFloat(attrRes) - -@Suppress("NOTHING_TO_INLINE") -inline fun Fragment.styledFloat(@AttrRes attrRes: Int) = context!!.styledFloat(attrRes) - -fun isUiThread() = Looper.getMainLooper().isCurrentThread - -fun formatDateTime(timeMillis: Long? = null): String = - SimpleDateFormat.getDateTimeInstance().format(timeMillis?.let { Date(it) } ?: Date()) - -private val iso8601DateFormat by lazy { - SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'", Locale.ROOT).apply { - timeZone = TimeZone.getTimeZone("UTC") - } -} - -fun iso8601UTCDateTime(timeMillis: Long? = null): String = - iso8601DateFormat.format(timeMillis?.let { Date(it) } ?: Date()) - -fun NavController.navigateFromMain(@IdRes dest: Int, bundle: Bundle? = null) { - popBackStack(R.id.mainFragment, false) - navigate(dest, bundle) -} - -fun darkenColorFilter(percent: Int): ColorFilter { - val value = percent * 255 / 100 - return PorterDuffColorFilter(Color.argb(value, 0, 0, 0), PorterDuff.Mode.SRC_ATOP) -} - -@Suppress("unused") -inline val ConstraintLayout.LayoutParams.unset - get() = ConstraintLayout.LayoutParams.UNSET - -@Suppress("NOTHING_TO_INLINE") -inline fun kotlin.reflect.KFunction1.upcast(): (T) -> U = this - -@Suppress("NOTHING_TO_INLINE") -inline fun T.identity() = arrow.core.identity(this) - -fun Configuration.isDarkMode() = - uiMode and Configuration.UI_MODE_NIGHT_MASK == Configuration.UI_MODE_NIGHT_YES - -fun Activity.applyTranslucentSystemBars() { - WindowCompat.setDecorFitsSystemWindows(window, false) - // windowLightNavigationBar is available for 27+ - window.navigationBarColor = - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1) { - Color.TRANSPARENT - } else { - // com.android.internal.R.color.system_bar_background_semi_transparent - 0x66000000 - } -} - -fun RecyclerView.applyNavBarInsetsBottomPadding() { - clipToPadding = false - ViewCompat.setOnApplyWindowInsetsListener(this) { _, windowInsets -> - windowInsets.getInsets(WindowInsetsCompat.Type.navigationBars()).also { - bottomPadding = it.bottom - } - windowInsets - } -} - -suspend fun Result.toast(context: Context) = withContext(Dispatchers.Main.immediate) { - onSuccess { - Toast.makeText(context, R.string.done, Toast.LENGTH_SHORT).show() - } - onFailure { - Toast.makeText(context, it.message, Toast.LENGTH_SHORT).show() - } -} - -suspend fun errorDialog(context: Context, title: String, message: String) { - withContext(Dispatchers.Main.immediate) { - AlertDialog.Builder(context) - .setTitle(title) - .setMessage(message) - .setPositiveButton(android.R.string.ok, null) - .setIconAttribute(android.R.attr.alertDialogIcon) - .show() - } -} - -fun Int.alpha(a: Float) = ColorUtils.setAlphaComponent(this, (a * 0xff).roundToInt()) - -fun SeekBar.setOnChangeListener(listener: SeekBar.(progress: Int) -> Unit) { - setOnSeekBarChangeListener(object : SeekBar.OnSeekBarChangeListener { - override fun onStartTrackingTouch(seekBar: SeekBar) {} - override fun onStopTrackingTouch(seekBar: SeekBar) {} - override fun onProgressChanged(seekBar: SeekBar, progress: Int, fromUser: Boolean) { - listener.invoke(seekBar, progress) - } - }) -} - -@SuppressLint("PrivateApi") -fun getSystemProperty(key: String): String { - return try { - Class.forName("android.os.SystemProperties") - .getMethod("get", String::class.java) - .invoke(null, key) as String - } catch (e: Exception) { - "" - } -} - -fun isSystemSettingEnabled(key: String): Boolean { - return try { - Settings.System.getInt(appContext.contentResolver, key) == 1 - } catch (e: Exception) { - false - } -} - -/** - * @return top-level files in zip file - */ -fun ZipInputStream.extract(destDir: File): List { - var entry = nextEntry - val canonicalDest = destDir.canonicalPath - while (entry != null) { - if (!entry.isDirectory) { - val file = File(destDir, entry.name) - if (!file.canonicalPath.startsWith(canonicalDest)) - throw SecurityException() - copyTo(file.outputStream()) - } else { - val dir = File(destDir, entry.name) - dir.mkdir() - } - entry = nextEntry - } - return destDir.listFiles()?.toList() ?: emptyList() -} - -inline fun withTempDir(block: (File) -> T): T { - val dir = appContext.cacheDir.resolve(System.currentTimeMillis().toString()).also { - it.mkdirs() - } - try { - return block(dir) - } finally { - dir.deleteRecursively() - } -} - -@Suppress("FunctionName") -fun WeakHashSet(): MutableSet = Collections.newSetFromMap(WeakHashMap()) - -val javaIdRegex = Regex("(?:\\b[_a-zA-Z]|\\B\\$)\\w*+") - -fun InputConnection.withBatchEdit(block: InputConnection.() -> Unit) { - beginBatchEdit() - block.invoke(this) - endBatchEdit() -} diff --git a/app/src/main/java/org/fcitx/fcitx5/android/utils/WeakHashSet.kt b/app/src/main/java/org/fcitx/fcitx5/android/utils/WeakHashSet.kt new file mode 100644 index 000000000..0e358afe3 --- /dev/null +++ b/app/src/main/java/org/fcitx/fcitx5/android/utils/WeakHashSet.kt @@ -0,0 +1,12 @@ +/* + * SPDX-License-Identifier: LGPL-2.1-or-later + * SPDX-FileCopyrightText: Copyright 2024 Fcitx5 for Android Contributors + */ + +package org.fcitx.fcitx5.android.utils + +import java.util.Collections +import java.util.WeakHashMap + +@Suppress("FunctionName") +fun WeakHashSet(): MutableSet = Collections.newSetFromMap(WeakHashMap()) diff --git a/app/src/main/java/org/fcitx/fcitx5/android/utils/ZipStream.kt b/app/src/main/java/org/fcitx/fcitx5/android/utils/ZipStream.kt new file mode 100644 index 000000000..10008e7cb --- /dev/null +++ b/app/src/main/java/org/fcitx/fcitx5/android/utils/ZipStream.kt @@ -0,0 +1,29 @@ +/* + * SPDX-License-Identifier: LGPL-2.1-or-later + * SPDX-FileCopyrightText: Copyright 2024 Fcitx5 for Android Contributors + */ + +package org.fcitx.fcitx5.android.utils + +import java.io.File +import java.util.zip.ZipInputStream + +/** + * @return top-level files in zip file + */ +fun ZipInputStream.extract(destDir: File): List { + var entry = nextEntry + val canonicalDest = destDir.canonicalPath + while (entry != null) { + if (!entry.isDirectory) { + val file = File(destDir, entry.name) + if (!file.canonicalPath.startsWith(canonicalDest)) throw SecurityException() + copyTo(file.outputStream()) + } else { + val dir = File(destDir, entry.name) + dir.mkdir() + } + entry = nextEntry + } + return destDir.listFiles()?.toList() ?: emptyList() +} diff --git a/app/src/main/java/org/fcitx/fcitx5/android/utils/config/ConfigDescriptor.kt b/app/src/main/java/org/fcitx/fcitx5/android/utils/config/ConfigDescriptor.kt index cb08d9a2d..521358ef1 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/utils/config/ConfigDescriptor.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/utils/config/ConfigDescriptor.kt @@ -1,6 +1,6 @@ /* * SPDX-License-Identifier: LGPL-2.1-or-later - * SPDX-FileCopyrightText: Copyright 2021-2023 Fcitx5 for Android Contributors + * SPDX-FileCopyrightText: Copyright 2021-2025 Fcitx5 for Android Contributors */ package org.fcitx.fcitx5.android.utils.config @@ -9,17 +9,22 @@ import arrow.core.Either import arrow.core.flatMap import arrow.core.raise.either import kotlinx.parcelize.Parcelize -import kotlinx.parcelize.RawValue +import kotlinx.serialization.Serializable import org.fcitx.fcitx5.android.core.RawConfig +import org.fcitx.fcitx5.android.utils.config.ConfigDescriptor.Companion.parse +import org.fcitx.fcitx5.android.utils.config.ConfigDescriptor.ConfigList.ConfigListValue +@Serializable sealed class ConfigDescriptor : Parcelable { abstract val name: String - abstract val type: ConfigType + // `type` is reserved in JSON serialization, so we use `ty` instead + abstract val ty: ConfigType abstract val description: String? abstract val defaultValue: U? abstract val tooltip: String? @Parcelize + @Serializable data class ConfigTopLevelDef( val name: String, val values: List>, @@ -27,12 +32,14 @@ sealed class ConfigDescriptor : Parcelable { ) : Parcelable @Parcelize + @Serializable data class ConfigCustomTypeDef( val name: String, val values: List> ) : Parcelable @Parcelize + @Serializable data class ConfigInt( override val name: String, override val description: String? = null, @@ -41,46 +48,50 @@ sealed class ConfigDescriptor : Parcelable { val intMax: Int?, val intMin: Int?, ) : ConfigDescriptor() { - override val type: ConfigType + override val ty: ConfigType get() = ConfigType.TyInt } @Parcelize + @Serializable data class ConfigString( override val name: String, override val description: String? = null, override val defaultValue: String? = null, override val tooltip: String? = null, ) : ConfigDescriptor() { - override val type: ConfigType + override val ty: ConfigType get() = ConfigType.TyString } @Parcelize + @Serializable data class ConfigBool( override val name: String, override val description: String? = null, override val defaultValue: Boolean? = null, override val tooltip: String? = null, ) : ConfigDescriptor() { - override val type: ConfigType + override val ty: ConfigType get() = ConfigType.TyBool } @Parcelize + @Serializable data class ConfigKey( override val name: String, override val description: String? = null, override val defaultValue: String? = null, override val tooltip: String? = null, ) : ConfigDescriptor() { - override val type: ConfigType + override val ty: ConfigType get() = ConfigType.TyKey } @Parcelize + @Serializable data class ConfigEnum( override val name: String, override val description: String? = null, @@ -89,15 +100,16 @@ sealed class ConfigDescriptor : Parcelable { val entries: List, val entriesI18n: List? ) : ConfigDescriptor() { - override val type: ConfigType + override val ty: ConfigType get() = ConfigType.TyEnum } @Parcelize + @Serializable data class ConfigCustom( override val name: String, - override val type: ConfigType.TyCustom, + override val ty: ConfigType.TyCustom, override val description: String? = null, override val tooltip: String? = null, // will be filled in parseTopLevel @@ -108,21 +120,40 @@ sealed class ConfigDescriptor : Parcelable { } @Parcelize + @Serializable data class ConfigList( override val name: String, - override val type: ConfigType.TyList, + override val ty: ConfigType.TyList, override val description: String? = null, override val tooltip: String? = null, /** - * [Any?] is used for a union type. See [parse] for details. + * [ConfigListValue] is used for a union type. See [parse] for details. */ - override val defaultValue: @RawValue List? = null, - ) : ConfigDescriptor>() + override val defaultValue: List? = null, + ) : ConfigDescriptor>() { + @Serializable + @Parcelize + sealed interface ConfigListValue : Parcelable { + @Serializable + @Parcelize + data class BoolValue(val value: Boolean) : ConfigListValue + @Serializable + @Parcelize + data class IntValue(val value: Int) : ConfigListValue + @Serializable + @Parcelize + data class KeyValue(val value: String) : ConfigListValue + @Serializable + @Parcelize + data class StringValue(val value: String) : ConfigListValue + } + } /** * Specialized [ConfigList] for enum */ @Parcelize + @Serializable data class ConfigEnumList( override val name: String, override val description: String? = null, @@ -132,11 +163,12 @@ sealed class ConfigDescriptor : Parcelable { val entriesI18n: List? ) : ConfigDescriptor>() { - override val type: ConfigType + override val ty: ConfigType get() = ConfigType.TyList(ConfigType.TyEnum) } @Parcelize + @Serializable data class ConfigExternal( override val name: String, override val description: String? = null, @@ -157,7 +189,7 @@ sealed class ConfigDescriptor : Parcelable { AndroidTable } - override val type: ConfigType + override val ty: ConfigType get() = ConfigType.TyExternal override val defaultValue: Nothing? get() = null @@ -166,8 +198,14 @@ sealed class ConfigDescriptor : Parcelable { companion object : ConfigParser, Companion.ParseException> { - private val RawConfig.type - get() = findByName("Type")?.value?.let { ConfigType.parse(it) } + private val RawConfig.type: Either>? + get() { + val type = findByName("Type")?.value + if (type == "String" && findByName("IsEnum")?.value == "True") { + return Either.Right(ConfigType.TyEnum) + } + return type?.let { ConfigType.parse(it) } + } private val RawConfig.description get() = findByName("Description")?.value private val RawConfig.defaultValue @@ -228,7 +266,11 @@ sealed class ConfigDescriptor : Parcelable { raw.intMax, raw.intMin ) - ConfigType.TyKey -> ConfigKey(raw.name, raw.description, raw.defaultValue) + ConfigType.TyKey -> ConfigKey( + raw.name, + raw.description, + raw.defaultValue + ) is ConfigType.TyList -> if (it.subtype == ConfigType.TyEnum) { val entries = raw.enum ?: raise(ParseException.NoEnumFound(raw)) @@ -248,10 +290,18 @@ sealed class ConfigDescriptor : Parcelable { raw.tooltip, raw.findByName("DefaultValue")?.subItems?.map { ele -> when (it.subtype) { - ConfigType.TyBool -> ele.value.toBoolean() - ConfigType.TyInt -> ele.value.toInt() - ConfigType.TyKey -> ele.value - ConfigType.TyString -> ele.value + ConfigType.TyBool -> ConfigListValue.BoolValue( + ele.value.toBoolean() + ) + ConfigType.TyInt -> ConfigListValue.IntValue( + ele.value.toInt() + ) + ConfigType.TyKey -> ConfigListValue.KeyValue( + ele.value + ) + ConfigType.TyString -> ConfigListValue.StringValue( + ele.value + ) ConfigType.TyEnum -> error("Impossible!") else -> raise(ParseException.BadFormList(it)) } @@ -296,7 +346,7 @@ sealed class ConfigDescriptor : Parcelable { val parsed = parse(it).bind() if (parsed is ConfigCustom) parsed.customTypeDef = customTypeDef.find { cTy -> - cTy.name == parsed.type.typeName + cTy.name == parsed.ty.typeName } parsed } ?: listOf() diff --git a/app/src/main/java/org/fcitx/fcitx5/android/utils/config/ConfigType.kt b/app/src/main/java/org/fcitx/fcitx5/android/utils/config/ConfigType.kt index c7503f77a..aaf23b3a6 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/utils/config/ConfigType.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/utils/config/ConfigType.kt @@ -8,30 +8,40 @@ import android.os.Parcelable import arrow.core.Either import arrow.core.raise.either import kotlinx.parcelize.Parcelize +import kotlinx.serialization.Serializable +@Serializable sealed class ConfigType : Parcelable { @Parcelize + @Serializable data object TyInt : ConfigType() @Parcelize + @Serializable data object TyString : ConfigType() @Parcelize + @Serializable data object TyBool : ConfigType() @Parcelize + @Serializable data object TyKey : ConfigType() @Parcelize + @Serializable data object TyEnum : ConfigType() @Parcelize + @Serializable data object TyExternal : ConfigType() @Parcelize + @Serializable data class TyCustom(val typeName: String) : ConfigType() @Parcelize + @Serializable data class TyList(val subtype: ConfigType<*>) : ConfigType() companion object : ConfigParser, Companion.UnknownConfigTypeException> { diff --git a/app/src/main/play/contact-email.txt b/app/src/main/play/contact-email.txt new file mode 100644 index 000000000..9d663e3ab --- /dev/null +++ b/app/src/main/play/contact-email.txt @@ -0,0 +1 @@ +fcitx5-android@googlegroups.com \ No newline at end of file diff --git a/app/src/main/play/contact-website.txt b/app/src/main/play/contact-website.txt new file mode 100644 index 000000000..925e50655 --- /dev/null +++ b/app/src/main/play/contact-website.txt @@ -0,0 +1 @@ +https://fcitx5-android.github.io \ No newline at end of file diff --git a/fastlane/metadata/android/en-US/full_description.txt b/app/src/main/play/listings/en-US/full-description.txt similarity index 89% rename from fastlane/metadata/android/en-US/full_description.txt rename to app/src/main/play/listings/en-US/full-description.txt index 7c8c26922..6a8f5ca18 100644 --- a/fastlane/metadata/android/en-US/full_description.txt +++ b/app/src/main/play/listings/en-US/full-description.txt @@ -12,6 +12,7 @@
  • Japanese (via Anthy Plugin)
  • Korean (via Hangul Plugin)
  • Sinhala (via Sayura Plugin)
  • +
  • Thai (via Thai Plugin)
  • Generic (via RIME Plugin, supports importing custom schemas)
  • Features @@ -23,9 +24,4 @@
  • Popup preview on key press
  • Long press popup keyboard for convenient symbol input
  • Symbol and Emoji picker
  • - -Work in progress -
      -
    • Customizable keyboard layout
    • -
    • More input methods
    \ No newline at end of file diff --git a/app/src/main/play/listings/en-US/graphics/icon/icon.png b/app/src/main/play/listings/en-US/graphics/icon/icon.png new file mode 120000 index 000000000..5fce37599 --- /dev/null +++ b/app/src/main/play/listings/en-US/graphics/icon/icon.png @@ -0,0 +1 @@ +../../../../../res/mipmap-xxxhdpi/ic_launcher.png \ No newline at end of file diff --git a/fastlane/metadata/android/en-US/short_description.txt b/app/src/main/play/listings/en-US/short-description.txt similarity index 100% rename from fastlane/metadata/android/en-US/short_description.txt rename to app/src/main/play/listings/en-US/short-description.txt diff --git a/app/src/main/play/listings/en-US/title.txt b/app/src/main/play/listings/en-US/title.txt new file mode 100644 index 000000000..3b1ab936c --- /dev/null +++ b/app/src/main/play/listings/en-US/title.txt @@ -0,0 +1 @@ +Fcitx5 \ No newline at end of file diff --git a/app/src/main/play/listings/ru/full-description.txt b/app/src/main/play/listings/ru/full-description.txt new file mode 100644 index 000000000..73e86f4e2 --- /dev/null +++ b/app/src/main/play/listings/ru/full-description.txt @@ -0,0 +1,27 @@ +Поддерживаемые языки +
      +
    • Английский (с проверкой орфографии)
    • +
    • Китайский +
        +
      • Pinyin, Shuangpin, Wubi, Cangjie и пользовательские таблицы (встроенные)
      • +
      • Zhuyin/Bopomofo (через плагин Chewing)
      • +
      • Jyutping (через плагин Jyutping)
      • +
      +
    • +
    • Вьетнамский (через плагин UniKey, поддерживает Telex, VNI и VIQR)
    • +
    • Японский (через плагин Anthy)
    • +
    • Корейский (через плагин Hangul)
    • +
    • Сингальский (через плагин Sayura)
    • +
    • Тайский (через плагин Thai)
    • +
    • Универсальный (плагин RIME, поддерживает импорт пользовательских решений)
    • +
    +Функции +
      +
    • Виртуальная клавиатура (раскладка пока не настраивается)
    • +
    • Расширяемый обзор кандидатов
    • +
    • Управление буфером обмена (только обычный текст)
    • +
    • Темы (пользовательские цветовые схемы и фоновые изображения)
    • +
    • Всплывающее окно предварительного просмотра при нажатии клавиши
    • +
    • Длительное нажатие вызывает клавиатуру для быстрого ввода знаков препинания.
    • +
    • Выбор символов и эмодзи
    • +
    diff --git a/fastlane/metadata/android/ru/short_description.txt b/app/src/main/play/listings/ru/short-description.txt similarity index 100% rename from fastlane/metadata/android/ru/short_description.txt rename to app/src/main/play/listings/ru/short-description.txt diff --git a/app/src/main/play/listings/ru/title.txt b/app/src/main/play/listings/ru/title.txt new file mode 100644 index 000000000..3b1ab936c --- /dev/null +++ b/app/src/main/play/listings/ru/title.txt @@ -0,0 +1 @@ +Fcitx5 \ No newline at end of file diff --git a/fastlane/metadata/android/zh-CN/full_description.txt b/app/src/main/play/listings/zh-CN/full-description.txt similarity index 89% rename from fastlane/metadata/android/zh-CN/full_description.txt rename to app/src/main/play/listings/zh-CN/full-description.txt index 3386615c1..e75c4d1c8 100644 --- a/fastlane/metadata/android/zh-CN/full_description.txt +++ b/app/src/main/play/listings/zh-CN/full-description.txt @@ -12,6 +12,7 @@
  • 日语(Anthy 插件)
  • 韩语(Hangul 插件)
  • 僧伽罗语(Sayura 插件)
  • +
  • 泰语(Thai 插件)
  • 通用(中州韵插件,支持导入自定义方案)
  • 已实现的功能 @@ -23,9 +24,4 @@
  • 按键弹出预览
  • 长按弹出键盘以快速输入标点符号
  • 标点符号、Emoji 表情和颜文字选择器
  • - -开发中的功能 -
      -
    • 自定义键盘布局
    • -
    • 更多输入法引擎
    \ No newline at end of file diff --git a/fastlane/metadata/android/zh-CN/short_description.txt b/app/src/main/play/listings/zh-CN/short-description.txt similarity index 100% rename from fastlane/metadata/android/zh-CN/short_description.txt rename to app/src/main/play/listings/zh-CN/short-description.txt diff --git a/app/src/main/play/listings/zh-CN/title.txt b/app/src/main/play/listings/zh-CN/title.txt new file mode 100644 index 000000000..21a4d87d7 --- /dev/null +++ b/app/src/main/play/listings/zh-CN/title.txt @@ -0,0 +1 @@ +小企鹅输入法 \ No newline at end of file diff --git a/fastlane/metadata/android/en-US/changelogs/21.txt b/app/src/main/play/release-notes/en-US/21.txt similarity index 100% rename from fastlane/metadata/android/en-US/changelogs/21.txt rename to app/src/main/play/release-notes/en-US/21.txt diff --git a/fastlane/metadata/android/en-US/changelogs/31.txt b/app/src/main/play/release-notes/en-US/31.txt similarity index 100% rename from fastlane/metadata/android/en-US/changelogs/31.txt rename to app/src/main/play/release-notes/en-US/31.txt diff --git a/fastlane/metadata/android/en-US/changelogs/42.txt b/app/src/main/play/release-notes/en-US/42.txt similarity index 100% rename from fastlane/metadata/android/en-US/changelogs/42.txt rename to app/src/main/play/release-notes/en-US/42.txt diff --git a/fastlane/metadata/android/en-US/changelogs/54.txt b/app/src/main/play/release-notes/en-US/54.txt similarity index 100% rename from fastlane/metadata/android/en-US/changelogs/54.txt rename to app/src/main/play/release-notes/en-US/54.txt diff --git a/fastlane/metadata/android/en-US/changelogs/64.txt b/app/src/main/play/release-notes/en-US/64.txt similarity index 100% rename from fastlane/metadata/android/en-US/changelogs/64.txt rename to app/src/main/play/release-notes/en-US/64.txt diff --git a/app/src/main/play/release-notes/en-US/74.txt b/app/src/main/play/release-notes/en-US/74.txt new file mode 100644 index 000000000..e39d5c27a --- /dev/null +++ b/app/src/main/play/release-notes/en-US/74.txt @@ -0,0 +1,53 @@ +# 0.0.9 - Basic Pinyin Correction and Forget Words + +## Highlights + +- The application now targets Android API 34, and exposes input methods as subtypes to system input method picker +- Pinyin input method from fcitx5-chinese-addons gains basic error correction support for adjacent keys in the same row +- Pinyin/Shuangpin and Table input methods from fcitx5-chinese-addons are able to "forget word" by long pressing the candidates +- RIME Plugin includes rime-predict for next-word prediction + +### Notable changes + +- "Global Options - Show preedit in application" has been enabled by default, you may need to enable it manually after upgrading from old versions. +For those who don't like composing text (aka client preedit) in applications, pinyin and rime engine now include a "Preedit Mode" option to disable it. +- As many users requested, English input method now provides an option "Disable word hint based on editor attributes". +Turning it off would make the input method ignore InputType flags from editor and provide word hint regardless. + +### Build process improvements + +- descriptor.json generated on Windows actually works, by replacing backslash ("\\") in path with forward slash ("/") + +## New features + +- Allow client preedit in global options and pinyin engine by default +- Expose input method as subtypes to system input method picker on Android 14+ +- Handle subtype switching via system input method picker +- Raise keyboard side padding limit to 300dp +- Add shrug emoticon ¯\_(ツ)_/¯ +- Swipe left backspace key to clear predict candidates +- Apply keyboard side padding to preedit +- Use KP_Separator/KP_Equal for comma/dot in NumberKeyboard +- Trigger "forget word" by long pressing on candidates from pinyin and table engine +- Option to mask sensitive data (such as password) in clipboard UI +- Option to show word hint regardless of InputType flags +- Prompt to restart after changing verbose logging preference +- Option to disable swipe gesture on space key +- OpenCC config shows a list of available profiles + +## Bug fixes + +- Toolbar above keyboard sometimes become blank when switching input methods +- Unable to detect other input method apps on Android 14 +- Fix crash in androidkeyboard when commit characters with byte length > 1 +- Last symbol state cannot be remembered when switching between SymbolPicker/NumberKeyboard for multiple times +- Keyboard theme won't follow system dark mode when dark mode changes while app is not running +- Transparent area in keyboard background images won't get darkened +- "Restore default" in FcitxPreferenceFragment does not work +- Disallow empty user input in various editor fragments +- Fix importing/creating quickphrase with backslash +- Fix crash when saving logs on some devices +- Fix default value display in various custom Preferences +- Change default navigation bar background to follow keyboard background color to avoid problems on some Android 14 devices +- "Hidden Notifications" config in "Android Toast & Notifications" cannot be persisted +- Prevent crash loop when uncaught exception occurs during Application instantiation diff --git a/app/src/main/play/release-notes/en-US/84.txt b/app/src/main/play/release-notes/en-US/84.txt new file mode 100644 index 000000000..f2438b8ee --- /dev/null +++ b/app/src/main/play/release-notes/en-US/84.txt @@ -0,0 +1,60 @@ +# 0.1.0 - Candidates Window for Physical Keyboard + +## Highlights + +- The input method will show a floating candidates window and hide virtual keyboard when you start typing with a physical keyboard +- Adopt fcitx5 "candidate action" API, to pin candidates in Pinyin/Shuangpin and forget words in RIME +- The application now targets Android API 35, and handles navigation bar or system gesture insets more reliably +- The application has been renamed to "小企鹅输入法" in Chinese, and "Fcitx5" for non-Chinese languages + +### New plugins + +- Thai, this is finally possible since scancode is sent to fcitx along with the keysym + +### Notable changes + +- Removed some bundled table input methods that nobody would use: 晚风、冰蝉全息、仓颉(简体中文) +Some actually useful ones can be found in our F-Droid repo: https://f5a.torus.icu/fdroid/repo/ , or updater: https://github.com/fcitx5-android/fcitx5-android-updater +- "Advanced - Ignore system cursor position" has been disabled by default, it should be stable enough +- "Theme - Navigation bar background" now defaults to "Keyboard background image" on Oreo+ devices + +### Build process improvements + +- Removed many unnecessary files in APK, eg. baseline.prof, vcsInfo, dependenciesInfo, kotlin-tooling-metadata.json ... +- Make use of AGP's splits.abi and signingConfig feature, and make prefab related tasks run more reliably + +## New features + +- Add mapping to other brace characters on top of current "(" & ")" +- Adopt fcitx5 candidate action API +- Show text instead of indeterminate progress bar when animation disabled +- Swipe down voice input / expand candidate button to hide keyboard +- Add option to perform haptic feedback on keyup +- Configurable clipboard entry radius +- Apply keyBorder prefs to Text Editing and Symbol Picker +- Allow cursor to move out of preedit in androidkeyboard +- Send keycode/scancode to fcitx +- Reset caps lock state after switching input method +- Allow uninstalling plugin from AboutActivity +- Refresh PluginFragment on resume/package change +- Show floating CandidatesView for hardware keyboard +- Disable word hint for physical keyboard by default +- Option to show CandidatesView by input device + +## Bug fixes + +- Toolbar would became blank when trigger and exit unicode addon right after changing theme +- Remove discouraged degree celsius/fahrenheit symbols in symbol picker +- Only perform long press haptic feedback when the pressed key has long press action +- Fix composing state tracking when interrupting input +- Fix toolbar title reset when rotating screen +- Fix undoing consecutive deletions in ClipboardWindow +- Fix first backspace swipe after initialization +- Write physical display size instead of some random size without navbar when exporting logs +- Fix crash when opening table addon config while it's not loaded +- Disable "CanceledOnTouchOutside" for complex dialogs to avoid it being dismissed by accident +- Disable menu group divider on Honor MagicOS devices +- Hide PopupMenu icon on Flyme because of layout issues +- Hopefully fixes crash on some devices when longpress "P" in landscape mode +- Fix some English strings +- Fix navbar insets detection on some devices diff --git a/app/src/main/play/release-notes/en-US/94.txt b/app/src/main/play/release-notes/en-US/94.txt new file mode 100644 index 000000000..e60f4a843 --- /dev/null +++ b/app/src/main/play/release-notes/en-US/94.txt @@ -0,0 +1,43 @@ +# 0.1.1 - Bug Fixes and Improvements + +This is a rather small release focusing on improving stability, so don't expect many exciting new features ... + +## Highlights + +- New option to enable "Haptic feedback on key repeat" +- New theme properties for candidate label/text/comment color +- Click to turn pages/select candidates when using CandidatesView for physical keyboard + +### Notable changes + +- "Follow system day/night theme" has been enabled by default, which should be stable enough + +### Build process improvements + +- Removed all symlinks across git submodules to simplify the build process on Windows + +## New features + +- Register a BroadcastReceiver to restart fcitx instance externally +- Add a shortcut in developer settings to restart fcitx instance +- New option to ignore system WindowInsets +- Enable "follow_system_dark_mode" by default +- Make ClearURLs compliant with the JavaScript implementation +- Add option to open DocumentsUI and browse user data dir +- Add option "Haptic feedback on key repeat" +- Add theme properties for candidate label/text/comment color +- Make CandidatesView touchable +- Add content description to buttons on toolbar and TextEditing window +- Always prepend user input as androidkeyboard candidate + +## Bug fixes + +- Fix edge-to-edge in plugin's AboutActivity +- Fix ExpandedCandidateWindow self-detach on predict candidates +- Fix potential null pointer dereference +- Workaround duplicated onPrimaryClipChanged callback +- Try follow system "Vibration & haptics" settings +- Apply fcitx input filter to paged candidates +- Improve CandidatesView positioning when monitoring cursor anchor fails +- Send key with KeyStates.Virtual on space swipe +- Workaround Samsung One UI 7.0 navbar coloring diff --git a/app/src/main/play/release-notes/en-US/default.txt b/app/src/main/play/release-notes/en-US/default.txt new file mode 120000 index 000000000..5f5cca6e1 --- /dev/null +++ b/app/src/main/play/release-notes/en-US/default.txt @@ -0,0 +1 @@ +84.txt \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_baseline_arrow_next_24.xml b/app/src/main/res/drawable/ic_baseline_arrow_next_24.xml new file mode 100644 index 000000000..6416cf711 --- /dev/null +++ b/app/src/main/res/drawable/ic_baseline_arrow_next_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_baseline_arrow_prev_24.xml b/app/src/main/res/drawable/ic_baseline_arrow_prev_24.xml new file mode 100644 index 000000000..522f84e25 --- /dev/null +++ b/app/src/main/res/drawable/ic_baseline_arrow_prev_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_baseline_auto_awesome_24.xml b/app/src/main/res/drawable/ic_baseline_auto_awesome_24.xml new file mode 100644 index 000000000..3fa1ea95d --- /dev/null +++ b/app/src/main/res/drawable/ic_baseline_auto_awesome_24.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_baseline_flip_24.xml b/app/src/main/res/drawable/ic_baseline_flip_24.xml new file mode 100644 index 000000000..8dbbb325f --- /dev/null +++ b/app/src/main/res/drawable/ic_baseline_flip_24.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_baseline_light_mode_24.xml b/app/src/main/res/drawable/ic_baseline_light_mode_24.xml index 5a386cc6b..210ccaba0 100644 --- a/app/src/main/res/drawable/ic_baseline_light_mode_24.xml +++ b/app/src/main/res/drawable/ic_baseline_light_mode_24.xml @@ -5,5 +5,5 @@ android:viewportHeight="24"> + android:pathData="M12,7c-2.76,0 -5,2.24 -5,5s2.24,5 5,5s5,-2.24 5,-5S14.76,7 12,7L12,7zM11,1v4h2V1L11,1zM11,19v4h2v-4L11,19zM23,11l-4,0v2l4,0V11zM5,11l-4,0l0,2l4,0L5,11zM16.24,17.66l2.47,2.47l1.41,-1.41l-2.47,-2.47L16.24,17.66zM3.87,5.28l2.47,2.47l1.41,-1.41L5.28,3.87L3.87,5.28zM6.34,16.24l-2.47,2.47l1.41,1.41l2.47,-2.47L6.34,16.24zM18.72,3.87l-2.47,2.47l1.41,1.41l2.47,-2.47L18.72,3.87z" /> diff --git a/app/src/main/res/drawable/ic_baseline_list_alt_24.xml b/app/src/main/res/drawable/ic_baseline_list_alt_24.xml new file mode 100644 index 000000000..fd5d86e2a --- /dev/null +++ b/app/src/main/res/drawable/ic_baseline_list_alt_24.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_baseline_rotate_right_24.xml b/app/src/main/res/drawable/ic_baseline_rotate_right_24.xml new file mode 100644 index 000000000..116c1ddc9 --- /dev/null +++ b/app/src/main/res/drawable/ic_baseline_rotate_right_24.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_baseline_share_24.xml b/app/src/main/res/drawable/ic_baseline_share_24.xml new file mode 100644 index 000000000..d38ab1c98 --- /dev/null +++ b/app/src/main/res/drawable/ic_baseline_share_24.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml index 2eeac4d25..8abb7224c 100644 --- a/app/src/main/res/layout/activity_main.xml +++ b/app/src/main/res/layout/activity_main.xml @@ -25,7 +25,6 @@ app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" - app:layout_constraintTop_toBottomOf="@id/toolbar" - app:navGraph="@navigation/settings_nav" /> + app:layout_constraintTop_toBottomOf="@id/toolbar" /> \ No newline at end of file diff --git a/app/src/main/res/navigation/settings_nav.xml b/app/src/main/res/navigation/settings_nav.xml deleted file mode 100644 index f376adb65..000000000 --- a/app/src/main/res/navigation/settings_nav.xml +++ /dev/null @@ -1,308 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 7ceb460df..c225eeb5b 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -1,13 +1,11 @@ - Fcitx5 für Android - Fcitx5 für Android (Debug) + Fcitx5 + Fcitx5 (Debug) Speichern Eingabemethoden Eingabemethode hinzufügen - Eingabemethoden konfigurieren Addons - Addons konfigurieren Konfiguration Globale Optionen Verhalten @@ -58,7 +56,7 @@ Löschen Zwischenablage Tastaturhöhe - Tastatur + Tastatur Version Git Hash erzeugen Quelltext @@ -96,7 +94,6 @@ Tastenradius Horizontaler Tastenrand Vertikaler Tastenrand - Konfigurieren Bild wählen ... Dunkle Tasten Bild neu zuschneiden @@ -140,7 +137,6 @@ Die Erweiterung von \"%1$s\" ist keine Tabelleneingabemethode (.zip) Konfigurationsdatei der Tabelleneingabemethode \"%1$s\" existiert schon Tabelle nicht auffindbar - Das Wörterbuch der Tabelleneingabe \"%1$s\" ist nicht vorhanden. Bitte checken Sie die dict-Datei reimportieren Sie sie. Tabelle importieren Aus ZIP-Datei... Aus getrennten Dateien ... @@ -165,5 +161,4 @@ Wie Systemeinstellungen Ton bei Tastendruck Ton Tastendruck Lautstärke - Nach dem Einfügen zurück - \ No newline at end of file + diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index e70c7bdcf..39b18a1f3 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -1,13 +1,11 @@ - Fcitx5 para Android - Fcitx5 para Android (depuración) + Fcitx5 + Fcitx5 (depuración) Guardar Métodos de entrada Añadir método de entrada - Configurar métodos de entrada Complementos - Configurar complementos Configuración Opciones globales Comportamiento @@ -44,7 +42,7 @@ Eliminar Portapapeles Altura de teclado - Teclado + Teclado Versión Código fuente Normativa de privacidad @@ -92,7 +90,6 @@ Contenido del portapapeles Vertical Horizontal - Mostrar sugerencia del portapapeles ¿Confirma que quiere desactivarlo? Desactivado Predeterminado del sistema @@ -127,4 +124,4 @@ No lo necesito Otorgar permiso Actualizar tabla - \ No newline at end of file + diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml index b2b3b4a11..21b9d6001 100644 --- a/app/src/main/res/values-ja/strings.xml +++ b/app/src/main/res/values-ja/strings.xml @@ -1,22 +1,26 @@ - アンドロイド用 Fcitx5 - アンドロイド用 Fcitx5l (デバッグ) + Fcitx5 + Fcitx5 (デバッグ) 保存 インプットメソッド インプットメソッドの追加 - インプットメソッドの設定 アドオン - アドオンの設定 設定 全般オプション ふるまい + 高度な設定 システムカーソル位置を無視する ホットキー設定を隠す (利用不可) + キー押下時の触覚フィードバック + キー押下時の振動の長さ + 押下 + 長押し + キー押下時の振動の大きさ 追加 編集 - アンドゥ + 元に戻す ランタイムデータを削除して同期する 現在のデータディレクトリを削除し、アプリにバンドルされているアセットから同期する インプットメソッドを有効にする @@ -46,17 +50,19 @@ フレックスボックス 切り替えるインプットメソッドがもうありません インプットメソッドをさらに追加しますか? - About + このアプリについて + FAQ カレントバージョン リアルタイムログ デベロッパー クリップボードデータベースをクリア - Pin - Unpin + 固定する + 固定を解除 削除 クリップボード - キーボード高さ - キーボード + キーボードの高さ + ソフトウェアキーボード + クリップボード候補のタイムアウト バージョン ビルド時間 ビルド Git ハッシュ @@ -78,4 +84,174 @@ アプリケーションがクラッシュしました 申し訳ありませんが、調査のためにいくつかのログをお届けします。 クラッシュログ - \ No newline at end of file + 詳細な記録 + 再起動が必要です + %1$s を削除しました + %1$s を追加しました + %1$s を編集しました + 無効な値 + キーの長押しの期間 + クイックフレーズエディター + クイックフレーズはすでに存在します + 無効な値のクイックフレーズ + 新規作成 + 名前 + リセット + キーワード + フレーズ + 設定の再読み込み + インプットメソッドのオプション + テーマ + キーの波紋効果を有効にする + キーの枠を有効にする + 明るさ + キーの角半径 + キーの上下間隔 + キーの左右間隔 + 設定 + 画像を選択 + 黒字のキー + 画像を切り取り直す + ナビゲーションバーの背景 + 背景なし + キーボードの色に従う + キーボードの背景画像 + 記号の位置 + ボタン(縦置きのみ) + 右上 + デフォルトでツールバーを展開する + キー押下時にポップアップする + アニメーションを無効にする + ファイルからインポート… + ビルトインテーマを複製… + Logcatプロセスはすでに作成されています + クイックフレーズの行の解析に失敗しました: %1$s + %1$s のファイル拡張子はクイックフレーズを示していません + %1$s のファイル拡張子はテーマを示していません + テーマ %1$s が不明です + %1$s のファイル拡張子はlibime辞書を示していません + %1$s のファイル拡張子はテキスト辞書を示していません + %1$s のファイル拡張子はsogou辞書を示していません + %1$s のファイル拡張子は辞書を示していません + テーマのJSONが見つかりません + テーマ名がビルトインテーマと衝突します + 選択した画像をテーマの背景として保存できません + 切り取った画像をテーマの背景として保存できません + システムのテーマに従う + 選択したテーマに手動で上書きする + ライトモードテーマ + ダークモードテーマ + 新規テーマ + スキップ + 残りの幅に合わせるため水平候補を引き伸ばす + なし + テーマは現在のバージョンに以降されました + キーボードの文字を大文字にする + インプットメゾッドを選択 + クリップボードを編集 + クリップボードのコンテンツ + 利用可能な設定オプションがありません。もしかしたらこのアドオンはfcitxの起動時に無効になっているかもしれません + 縦画面時 + 横画面時 + キーボードの下部余白 + キーボードの側面余白 + アドオンを無効にする + %1$s を無効にする場合 : + %1$s を無効にする + %1$s のいくつかの機能が無効になります + 本当に無効にしますか? + 句読点や数字をスワイプして入力する + 下にスワイプ + 上にスワイプ + 無効 + システムのデフォルト + エディター情報の調査 + デフォルト + 保存する + 固定されたすべてのアイテムを削除しますか? + vivo OriginOSにおけるキー押下時の回避策 + 無効なインプットメゾッド設定ファイル:\n%1$s + 無効な辞書:\n%1$s + Tableインプットメソッドの設定ファイル(.confもしくは.conf.in)が見つかりません + Tableの辞書(.dictもしくは.txt)が見つかりません + Tableインプットメソッド + Tableインプットメソッドの管理 + \"%1$s\"の拡張子はTableインプットメソッドパッケージのもの(.zip)ではありません + Tableインプットメソッドの設定ファイル\"%1$s\"は既に存在します + Tableは利用できません + Tableの辞書\"%1$s\"が存在しません。辞書ファイルを確認するか、再度インポートしてください。 + Tableにインポート + Zipファイルから… + 分割されたファイルから… + ファイルを選択… + インプットメソッドの設定ファイル(.confもしくは.conf.in) + Tableの辞書(.dictもしくは.txt) + \"%1$s\"の拡張子はTableインプットメソッドの設定ファイルのもの(.confもしくは.conf.in)ではありません + \"%1$s\"の拡張子はTableインプットメソッドの辞書のもの(.dictもしくは.txt)ではありません + インポートする前に上記の両方のファイルを選択してください + 通知の許可がない場合、時間のかかる操作が行われたときに通知することができません。 + 通知の権限がありません + パスワード入力時に数字をツールバーに横並びで表示する + スペースキー長押し時の動作 + なし + インプットメゾッド一覧を表示する + インプットメゾッドのアクティブな状態を切り替える + インプットメゾッド選択ダイアログを表示する + 言語切り替えキーを表示する + 水平候補スタイル + 幅を調節しない + 幅の調節を強制する + 常に幅を調節する + 自動入力候補を表示する + 固定されていないすべてのアイテムを削除しますか? + %1$d 個のアイテムを削除しました + テーマを削除 + 本当に\"%1$s\"テーマを削除しますか? + ヒープダンプを取得する + プラグイン + 読み込みました + 読み込みに失敗しました + プラグインの記述語がありません + プラグインの記述語が無効です + データの記述語がありません + データの記述語が無効です + 互換性のないAPI: %1$s + パス\"%1$s\"はすでに %2$s に使用されています + メインプログラム + テーマを編集 + 音声入力ボタンを表示する + Fcitxデーモン + Fcitxを再起動中 + プラグインの変更を除去しました。ここをクリックして再読み込みします + 利用可能なプラグインはありません + %1$s 個のアイテムを削除しました + この設定をデフォルトの設定にリセットしますか? + 有効 + システム設定に従う + キー押下時の音 + キーの音量 + 「システムのダークモードに従う」設定が有効な場合、テーマを手動で切り替えることができません + 無効にする + ユーザーデータをエクスポートする + ユーザーデータをインポートする + %1$s のファイル拡張子はfcitx5-androidのユーザーデータを示していません + ユーザーデータのメタデータが見つかりません + ユーザーデータが現在のアプリに適合しません + %1$s のユーザーデータをインポートしました。再起動します… + アプリの再起動 + アプリが再起動されました。ここをクリックして開きます + 今はしない + 権限の許可 + キー押下範囲を端まで拡張する + Tableの更新 + Tableの辞書\"%1$s\"は存在します。新しい辞書をインポートして置き換えることができます。 + 言語切り替えキーの動作 + 次のインプットメゾッドアプリに切り替える + 空にできません + %1$s を空にできません + 右回転 + 反転 + 左右反転 + 上下反転 + 切り取り + diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index 1b4385d51..ca797e48d 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -1,13 +1,11 @@ - 안드로이드용 Fcitx5 - 안드로이드용 Fcitx5(디버그) + Fcitx5 + Fcitx5 (디버그) 저장 입력기 입력기 추가 - 입력기 구성하기 애드온 - 애드온 구성하기 구성 전역 옵션 동작 @@ -57,7 +55,7 @@ 삭제 클립보드 키보드 높이 - 키보드 + 키보드 버전 빌드 시간 Git 해시 빌드 @@ -93,4 +91,4 @@ 구문 구성 새로고침 입력기 옵션 - \ No newline at end of file + diff --git a/app/src/main/res/values-night/themes.xml b/app/src/main/res/values-night/themes.xml index f62751d57..e616379fa 100644 --- a/app/src/main/res/values-night/themes.xml +++ b/app/src/main/res/values-night/themes.xml @@ -8,18 +8,6 @@ @color/blue_500 @color/grey_400 @color/grey_400 - - @android:color/transparent - false - @android:color/transparent - false - - - - -