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 new file mode 100644 index 000000000..35299216a --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +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.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 597c34310..8d612b32e 100644 --- a/.github/workflows/fdroid.yml +++ b/.github/workflows/fdroid.yml @@ -1,6 +1,17 @@ name: F-Droid on: + pull_request: + paths: + - '.github/workflows/fdroid.yml' + - 'app/org.fcitx.fcitx5.android.yml' + workflow_dispatch: + inputs: + build_number: + description: build number on Jenkins Job fcitx5-android + type: string + required: true + default: lastSuccessfulBuild repository_dispatch: defaults: @@ -9,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: @@ -19,16 +30,14 @@ jobs: - x86 - x86_64 fail-fast: false - env: - APP_ID: org.fcitx.fcitx5.android steps: - name: Fetch fdroiddata - uses: actions/checkout@v3 + uses: actions/checkout@v4 with: repository: f-droid/fdroiddata - name: Fetch fdroidserver - uses: actions/checkout@v3 + uses: actions/checkout@v4 with: repository: f-droid/fdroidserver path: fdroidserver @@ -53,51 +62,62 @@ jobs: chown -R vagrant $GITHUB_WORKSPACE - name: Build + env: + BUILD_NUMBER: ${{ inputs.build_number || 'lastSuccessfulBuild' }} run: | set -x - curl -Lo /usr/bin/yq https://github.com/mikefarah/yq/releases/latest/download/yq_linux_amd64 + # 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/lastSuccessfulBuild/artifact/out/build-metadata.json) + build_metadata=$(curl "https://jenkins.fcitx-im.org/job/android/job/fcitx5-android/$BUILD_NUMBER/artifact/out/build-metadata.json") versionName=$(echo $build_metadata | yq ".versionName") commitHash=$(echo $build_metadata | yq ".commitHash") timestamp=$(echo $build_metadata | yq ".timestamp") - baseVersionCode=$(curl https://raw.githubusercontent.com/fcitx5-android/fcitx5-android/$commitHash/build-logic/convention/src/main/kotlin/Versions.kt | grep "baseVersionCode =" | sed 's/.*= //') + baseVersionCode=$(curl -L "https://github.com/fcitx5-android/fcitx5-android/raw/$commitHash/build-logic/convention/src/main/kotlin/Versions.kt" | grep "baseVersionCode =" | sed 's/.*= //') declare -A abi_list abi_list=([armeabi-v7a]=1 [arm64-v8a]=2 [x86]=3 [x86_64]=4) i=${abi_list[${{ matrix.abi }}]} - versionCode=$(expr $baseVersionCode \* 10 + $i) + versionCode=$(($baseVersionCode * 10 + $i)) source /etc/profile.d/bsenv.sh - metadata="$home_vagrant/metadata/$APP_ID.yml" - curl -Lo $metadata "https://raw.githubusercontent.com/${{ github.repository }}/${{ github.sha }}/app/$APP_ID.yml" + metadata="$home_vagrant/metadata/org.fcitx.fcitx5.android.yml" + curl -Lo $metadata "https://github.com/${{ github.repository }}/raw/${{ github.sha }}/app/org.fcitx.fcitx5.android.yml" + sed -i s/%ts/$timestamp/g $metadata sed -i s/%abi/${{ matrix.abi }}/g $metadata yq -i ".Builds[0] |= (.versionName = \"$versionName\") |= (.versionCode = $versionCode) |= (.commit = \"$commitHash\") " $metadata + prebuiltTreeURL=$(curl -L \ + -H "Accept: application/vnd.github+json" \ + -H "Authorization: Bearer ${{ github.token }}" \ + -H "X-GitHub-Api-Version: 2022-11-28" \ + "https://api.github.com/repos/${{ github.repository }}/contents/lib/fcitx5/src/main/cpp/prebuilt?ref=${{ github.sha }}" \ + | yq .html_url) + prebuilderSHA=$(curl -L "${prebuiltTreeURL/\/tree\//\/raw\/}/toolchain-versions.json" | yq ".prebuilder") + yq -i ".Builds[0].srclibs[0] |= + \"fcitx5-android-prebuilder@${prebuilderSHA}\" + " $metadata yq ".Builds[0]" $metadata cp $metadata $GITHUB_WORKSPACE/metadata - export PATH="$fdroidserver:$PATH" - export PYTHONPATH="$fdroidserver:$fdroidserver/examples" - export PYTHONUNBUFFERED=true - export GRADLE_USER_HOME=$home_vagrant/.gradle - export fdroid="sudo --preserve-env --user vagrant - env PATH=$fdroidserver:$PATH - env PYTHONPATH=$fdroidserver:$fdroidserver/examples - env PYTHONUNBUFFERED=true - env TERM=$TERM + + fdroid="sudo --preserve-env --user vagrant env HOME=$home_vagrant - fdroid" + PYTHONPATH=$fdroidserver:$fdroidserver/examples + PYTHONUNBUFFERED=true + GRADLE_USER_HOME=$home_vagrant/.gradle + $fdroidserver/fdroid" - build="$APP_ID:$versionCode" + build="org.fcitx.fcitx5.android:$versionCode" chown -R vagrant $home_vagrant $fdroid fetchsrclibs $build --verbose cd $home_vagrant $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 e8c76db18..01968ac76 100644 --- a/.github/workflows/nix.yml +++ b/.github/workflows/nix.yml @@ -4,24 +4,24 @@ on: pull_request: push: branches: [master] + 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@v3 + uses: actions/checkout@v4 with: fetch-depth: 0 submodules: recursive - - uses: cachix/install-nix-action@v20 + - uses: cachix/install-nix-action@v31 + with: + github_access_token: ${{ secrets.GITHUB_TOKEN }} + - uses: cachix/cachix-action@v16 with: - nix_path: nixpkgs=channel:nixos-unstable - - name: Build dev shell - run: nix develop .#noAS - - name: Build Debug APK + name: fcitx5-android + authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}" + - 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 new file mode 100644 index 000000000..43438a178 --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,47 @@ +name: Publish + +on: + push: + branches: [master] + paths: + - 'build-logic/**' + - 'lib/**' + - '.github/workflows/publish.yml' + +jobs: + publish: + runs-on: ubuntu-24.04 + steps: + - name: Fetch source code + uses: actions/checkout@v4 + with: + 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@v4 + with: + distribution: "temurin" + java-version: "17" + + - name: Setup Android environment + uses: android-actions/setup-android@v3 + with: + packages: cmake;3.31.6 + + - name: Setup Gradle + 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 7291ccae1..dcc86c2e1 100644 --- a/.github/workflows/pull_request.yml +++ b/.github/workflows/pull_request.yml @@ -10,68 +10,81 @@ on: jobs: build_pull_request: - runs-on: ubuntu-22.04 + runs-on: ${{ matrix.os }} strategy: + fail-fast: false matrix: - abi: - - armeabi-v7a - - arm64-v8a - - x86 - - x86_64 - env: - BUILD_ABI: ${{ matrix.abi }} + os: + - ubuntu-24.04 + - macos-13 + - macos-14 + - windows-2022 steps: - name: Fetch source code - uses: actions/checkout@v3 + uses: actions/checkout@v4 with: fetch-depth: 0 submodules: recursive - 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@v2 + uses: android-actions/setup-android@v3 - name: Install Android NDK run: | - sdkmanager --install "cmake;3.22.1" + sdkmanager --install "cmake;3.31.6" - - name: Install system dependencies + - name: Install system dependencies (Ubuntu) + if: ${{ startsWith(matrix.os, 'ubuntu') }} run: | sudo apt update sudo apt install extra-cmake-modules gettext + - name: Install system dependencies (macOS) + if: ${{ startsWith(matrix.os, 'macos') }} + run: | + brew install extra-cmake-modules + + - name: Install system dependencies (Windows) + 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.abi }} - path: app/build/outputs/apk/debug/ + name: app-${{ matrix.os }} + path: app/build/outputs/apk/release/ - name: Pack plugins + shell: bash run: | mkdir plugins-to-upload for i in $(ls plugin) 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.abi }} + name: plugins-${{ matrix.os }} path: plugins-to-upload diff --git a/.gitignore b/.gitignore index 9ac32bd93..0bd6d8615 100644 --- a/.gitignore +++ b/.gitignore @@ -1,34 +1,20 @@ # 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 -# Module :plugin:anthy +# Plugins # Installed data -/plugin/anthy/src/main/assets/usr/share/fcitx5/addon -/plugin/anthy/src/main/assets/usr/share/fcitx5/anthy -/plugin/anthy/src/main/assets/usr/share/fcitx5/inputmethod -/plugin/anthy/src/main/assets/usr/share/locale +/plugin/*/src/main/assets/usr/ # Generated asset descriptor -/plugin/anthy/src/main/assets/descriptor.json +/plugin/*/src/main/assets/descriptor.json # Intellij .idea/* !.idea/codeStyles/ +!.idea/copyright/ !.idea/dictionaries/ -!.idea/modules/ -.idea/modules/* -# tell Andriod Stuido to exclude prebuilt dir in this file -!.idea/modules/fcitx5-android.iml # below are generated by Android Studio @@ -113,3 +99,6 @@ lint/tmp/ # Android Profiling *.hprof + +### Kotlin ### +.kotlin/ diff --git a/.gitmodules b/.gitmodules index 25c9c6748..2f3d38b6d 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,24 +1,58 @@ [submodule "lib/fcitx5/src/main/cpp/fcitx5"] path = lib/fcitx5/src/main/cpp/fcitx5 - url = git@github.com:fcitx/fcitx5.git -[submodule "app/src/main/cpp/fcitx5-chinese-addons"] - path = app/src/main/cpp/fcitx5-chinese-addons - url = git@github.com:fcitx/fcitx5-chinese-addons.git -[submodule "app/src/main/cpp/libime"] - path = app/src/main/cpp/libime - url = git@github.com:fcitx/libime.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 -[submodule "app/src/main/cpp/fcitx5-lua"] - path = app/src/main/cpp/fcitx5-lua - url = git@github.com:fcitx/fcitx5-lua.git -[submodule "app/src/main/cpp/fcitx5-unikey"] - path = app/src/main/cpp/fcitx5-unikey - url = git@github.com:fcitx/fcitx5-unikey.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 = https://github.com/fcitx/fcitx5-lua.git +[submodule "lib/libime/src/main/cpp/libime"] + path = lib/libime/src/main/cpp/libime + 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 = 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:fcitx5-android/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 = https://github.com/fcitx/fcitx5-unikey.git +[submodule "plugin/rime/src/main/cpp/fcitx5-rime"] + path = plugin/rime/src/main/cpp/fcitx5-rime + 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 = https://github.com/rime/rime-prelude.git +[submodule "plugin/rime/src/main/cpp/rime-essay"] + path = plugin/rime/src/main/cpp/rime-essay + 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 = 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 = https://github.com/rime/rime-stroke.git +[submodule "plugin/hangul/src/main/cpp/fcitx5-hangul"] + path = plugin/hangul/src/main/cpp/fcitx5-hangul + 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 = https://github.com/fcitx/fcitx5-chewing.git +[submodule "plugin/sayura/src/main/cpp/fcitx5-sayura"] + path = plugin/sayura/src/main/cpp/fcitx5-sayura + 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 = 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/.idea/modules/fcitx5-android.iml b/.idea/modules/fcitx5-android.iml deleted file mode 100644 index a3390344e..000000000 --- a/.idea/modules/fcitx5-android.iml +++ /dev/null @@ -1,14 +0,0 @@ - - - - - - - - - - - - - - \ No newline at end of file diff --git a/README.md b/README.md index 30e82190c..85f9bba0f 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # fcitx5-android -An attempt to run fcitx5 on Android. +[Fcitx5](https://github.com/fcitx/fcitx5) input method framework and engines ported to Android. ## Download @@ -12,36 +12,46 @@ Jenkins: [![build status](https://img.shields.io/jenkins/build.svg?jobUrl=https: GitHub: [![release version](https://img.shields.io/github/v/release/fcitx5-android/fcitx5-android)](https://github.com/fcitx5-android/fcitx5-android/releases) -[Get it on F-Droid](https://f-droid.org/packages/org.fcitx.fcitx5.android) - -[Get it on Google Play](https://play.google.com/store/apps/details?id=org.fcitx.fcitx5.android&pcampaignid=pcampaignidMKT-Other-global-all-co-prtnr-py-PartBadge-Mar2515-1) +[Get it on F-Droid](https://f-droid.org/packages/org.fcitx.fcitx5.android) +[Get it on Google Play](https://play.google.com/store/apps/details?id=org.fcitx.fcitx5.android) ## Project status -### Implemented +### Supported Languages + +- English (with spell check) +- Chinese + - Pinyin, Shuangpin, Wubi, Cangjie and custom tables (built-in, powered by [fcitx5-chinese-addons](https://github.com/fcitx/fcitx5-chinese-addons)) + - Zhuyin/Bopomofo (via [Chewing Plugin](./plugin/chewing)) + - Jyutping (via [Jyutping Plugin](./plugin/jyutping/), powered by [libime-jyutping](https://github.com/fcitx/libime-jyutping)) +- Vietnamese (via [UniKey Plugin](./plugin/unikey), supports Telex, VNI and VIQR) +- 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 - 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 +- Plugin System for loading addons from other installed apk -### Work in progress +### Planned Features - Customizable keyboard layout -- More input methods +- More input methods (via plugin) ## Screenshots |拼音, Material Light theme, key border enabled|自然码双拼, Pixel Dark theme, key border disabled| |:-:|:-:| -||| +||| |Emoji picker, Pixel Light theme, key border enabled|Symbol picker, Material Dark theme, key border disabled| |:-:|:-:| @@ -53,33 +63,54 @@ 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.) ### How to set up development environment +
+Prerequisites for Windows + +- Enable [Developer Mode](https://learn.microsoft.com/en-us/windows/apps/get-started/enable-your-device-for-development) so that symlinks can be created without administrator privilege. + +- Enable symlink support for `git`: + + ```shell + git config --global core.symlinks true + ``` + +
+ 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 ``` -Install extra-cmake-modules from your distribution software repository: +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 + # For Debian/Ubuntu sudo apt install extra-cmake-modules gettext + +# For macOS +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 ``` Install Android SDK Platform, Android SDK Build-Tools, Android NDK and cmake via SDK Manager in Android Studio: @@ -87,6 +118,9 @@ Install Android SDK Platform, Android SDK Build-Tools, Android NDK and cmake via
Detailed steps (screenshots) +**Note:** These screenshots are for references and the versions in them may be out of date. +The current recommended versions are recorded in [Versions.kt](build-logic/convention/src/main/kotlin/Versions.kt) file. + ![Open SDK Manager](https://user-images.githubusercontent.com/13914967/202184493-3ee1546b-0a83-4cc9-9e41-d20b0904a0cf.png) ![Install SDK Platform](https://user-images.githubusercontent.com/13914967/202184534-340a9e7c-7c42-49bd-9cf5-1ec9dcafcf32.png) @@ -99,6 +133,16 @@ Install Android SDK Platform, Android SDK Build-Tools, Android NDK and cmake via
+### Trouble-shooting + +- Android Studio indexing takes forever to complete and cosumes a lot of memory. + + 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." 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. + ## Nix Appropriate Android SDK with NDK is available in the development shell. The `gradlew` should work out-of-the-box, so you can install the app to your phone with `./gradlew installDebug` after applying the patch mentioned above. For development, you may want to install the unstable version of Android Studio, and point the project SDK path to `$ANDROID_SDK_ROOT` defined in the shell. Notice that Android Studio may generate wrong `local.properties` which sets the SDK location to `~/Android/SDK` (installed by SDK Manager). In such case, you need specify `sdk.dir` as the project SDK in that file manually, in case Android Studio sticks to the wrong global SDK. diff --git a/app/build.gradle.kts b/app/build.gradle.kts index d75ca7209..1f435a29b 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -1,11 +1,9 @@ -@file:Suppress("UnstableApiUsage") - plugins { - id("android-app-convention") - id("native-app-convention") - id("build-metadata") - id("data-descriptor") - id("fcitx-component") + id("org.fcitx.fcitx5.android.app-convention") + id("org.fcitx.fcitx5.android.native-app-convention") + id("org.fcitx.fcitx5.android.build-metadata") + id("org.fcitx.fcitx5.android.data-descriptor") + id("org.fcitx.fcitx5.android.fcitx-component") alias(libs.plugins.kotlin.parcelize) alias(libs.plugins.kotlin.serialization) alias(libs.plugins.ksp) @@ -18,6 +16,7 @@ android { applicationId = "org.fcitx.fcitx5.android" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + @Suppress("UnstableApiUsage") externalNativeBuild { cmake { targets( @@ -28,18 +27,7 @@ android { // android specific modules "androidfrontend", "androidkeyboard", - // fcitx5-chinese-addons - "pinyin", - "scel2org5", - "table", - "chttrans", - "fullwidth", - "pinyinhelper", - "punctuation", - // fcitx5-lua - "luaaddonloader", - // fcitx5-unikey - "unikey" + "androidnotification" ) } } @@ -66,20 +54,31 @@ android { buildFeatures { viewBinding = true } + + androidResources { + @Suppress("UnstableApiUsage") + generateLocaleConfig = true + } } kotlin { sourceSets.configureEach { - kotlin.srcDir("$buildDir/generated/ksp/$name/kotlin/") + kotlin.srcDir(layout.buildDirectory.dir("generated/ksp/$name/kotlin")) } } -aboutLibraries { - configPath = "app/licenses" -} - fcitxComponent { - installFcitx5Data = true + 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 { @@ -89,6 +88,10 @@ ksp { dependencies { ksp(project(":codegen")) implementation(project(":lib:fcitx5")) + implementation(project(":lib:fcitx5-lua")) + implementation(project(":lib:libime")) + implementation(project(":lib:fcitx5-chinese-addons")) + implementation(project(":lib:common")) implementation(libs.kotlinx.coroutines) implementation(libs.kotlinx.serialization.json) implementation(libs.androidx.activity) @@ -111,10 +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.konbini) implementation(libs.material) - implementation(libs.arrow) + implementation(libs.arrow.core) + implementation(libs.arrow.functions) implementation(libs.imagecropper) implementation(libs.flexbox) implementation(libs.dependency) @@ -135,3 +139,13 @@ dependencies { androidTestImplementation(libs.androidx.lifecycle.testing) androidTestImplementation(libs.junit) } + +configurations { + all { + // remove Baseline Profile Installer or whatever it is... + exclude(group = "androidx.profileinstaller", module = "profileinstaller") + // remove unwanted splitties libraries... + exclude(group = "com.louiscad.splitties", module = "splitties-appctx") + exclude(group = "com.louiscad.splitties", module = "splitties-systemservices") + } +} diff --git a/app/licenses/libraries/boost.json b/app/licenses/libraries/boost.json index 7848e8ef2..cebc09b62 100644 --- a/app/licenses/libraries/boost.json +++ b/app/licenses/libraries/boost.json @@ -1,6 +1,6 @@ { "uniqueId": "boostorg/boost", - "artifactVersion": "1.80.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 f5799b00a..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.0.17", + "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 36e42980a..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.10", + "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 0f51e4eb9..f20f85043 100644 --- a/app/licenses/libraries/fcitx5.json +++ b/app/licenses/libraries/fcitx5.json @@ -1,6 +1,6 @@ { "uniqueId": "fcitx/fcitx5", - "artifactVersion": "5.0.23", + "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 ec190743e..07f2dbdc1 100644 --- a/app/licenses/libraries/fmt.json +++ b/app/licenses/libraries/fmt.json @@ -1,6 +1,6 @@ { "uniqueId": "fmtlib/fmt", - "artifactVersion": "8.1.1", + "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 df66a50f4..18507f632 100644 --- a/app/licenses/libraries/libime.json +++ b/app/licenses/libraries/libime.json @@ -1,6 +1,6 @@ { "uniqueId": "fcitx/libime", - "artifactVersion": "1.0.17", + "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 e75a093c1..11c9ef4c7 100644 --- a/app/licenses/libraries/lua.json +++ b/app/licenses/libraries/lua.json @@ -1,6 +1,6 @@ { "uniqueId": "lua/lua", - "artifactVersion": "5.4.4", + "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 d1d58905d..ed4109ee0 100644 --- a/app/licenses/libraries/opencc.json +++ b/app/licenses/libraries/opencc.json @@ -1,6 +1,6 @@ { "uniqueId": "BYVoid/OpenCC", - "artifactVersion": "1.1.6", + "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 6b7957aff..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++ make cmake xz-utils bzip2 extra-cmake-modules gettext - ghc libghc-shake-dev opencc libghc-js-flot-data haskell-js-dgtable-utils automake - libtool openjdk-17-jdk-headless - - update-java-alternatives -a - - apt-get install -y -t bullseye-backports libime-bin 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,27 +32,24 @@ Builds: rm: - lib/fcitx5/src/main/cpp/prebuilt prebuild: - - sdkmanager 'cmake;3.22.1' 'ndk;25.0.8775105' - - buildTimestamp=$(curl -L https://jenkins.fcitx-im.org/job/android/job/fcitx5-android/lastSuccessfulBuild/artifact/out/build-metadata.json - | sed -n -E 's/.*timestamp.*"(.*)"$/\1/p') - - echo -e "\nbuildTimestamp=$buildTimestamp" >> ../gradle.properties - - sed -i -e '/ImportQualifiedPost/d' -e 's/import \(.*\) qualified as/import - qualified \1 as/g' $$fcitx5-android-prebuilder$$/Main.hs + - 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 + - sed -i -e 's|https://maven.pkg.github.com|https://jitpack.io|g' ../lib/*/build.gradle.kts scanignore: - lib/fcitx5/src/main/cpp/fcitx5/src/modules/unicode/charselectdata scandelete: - build-logic/convention/build build: - pushd $$fcitx5-android-prebuilder$$ - - mkdir build - - cd build - - ABI=%abi ANDROID_NDK_ROOT=$$NDK$$/../25.0.8775105 CMAKE_VERSION=3.22.1 ANDROID_PLATFORM=23 - COMP_SPELL_DICT=/usr/lib/x86_64-linux-gnu/fcitx5/libexec/comp-spell-dict runghc - ../Main.hs -j everything + - 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 AllowedAPKSigningKeys: e4db1e9edff13629d07de4bbf8165fe9bd8557ab55092672da8e40dbe484ecd7 diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro index 4ef03d96c..d154724d5 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -10,6 +10,9 @@ # Keep JNI interface -keep class org.fcitx.fcitx5.android.core.* { *; } +-keep class org.fcitx.fcitx5.android.data.pinyin.customphrase.PinyinCustomPhrase { + public (...); +} # Keep dependency magic -keep class ** extends org.mechdancer.dependency.Component { @@ -17,6 +20,17 @@ boolean equals(java.lang.Object); } +# remove kotlin null checks +-assumenosideeffects class kotlin.jvm.internal.Intrinsics { + 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 # debugging stack traces. -keepattributes SourceFile,LineNumberTable 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/androidTest/java/org/fcitx/fcitx5/android/FcitxTest.kt b/app/src/androidTest/java/org/fcitx/fcitx5/android/FcitxTest.kt index 2defe76f7..14bb9601a 100644 --- a/app/src/androidTest/java/org/fcitx/fcitx5/android/FcitxTest.kt +++ b/app/src/androidTest/java/org/fcitx/fcitx5/android/FcitxTest.kt @@ -1,57 +1,72 @@ +/* + * SPDX-License-Identifier: LGPL-2.1-or-later + * SPDX-FileCopyrightText: Copyright 2021-2023 Fcitx5 for Android Contributors + */ package org.fcitx.fcitx5.android -import android.util.Log -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.testing.TestLifecycleOwner import androidx.test.platform.app.InstrumentationRegistry -import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.MainScope import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.* +import kotlinx.coroutines.flow.firstOrNull +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.mapNotNull +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.runBlocking import org.fcitx.fcitx5.android.core.Fcitx import org.fcitx.fcitx5.android.core.FcitxEvent import org.fcitx.fcitx5.android.core.RawConfig -import org.junit.* +import org.junit.After +import org.junit.AfterClass +import org.junit.Assert +import org.junit.Before +import org.junit.BeforeClass +import org.junit.Test +import timber.log.Timber class FcitxTest { private companion object { lateinit var fcitx: Fcitx - val lifeCycleOwner = TestLifecycleOwner() val fcitxEventChannel = Channel>(capacity = Channel.CONFLATED) - - fun log(str: String) = Log.d("UnitTest", str) + val scope = MainScope() @BeforeClass @JvmStatic fun setup() { val context = InstrumentationRegistry.getInstrumentation().targetContext fcitx = Fcitx(context) - lifeCycleOwner.lifecycle.addObserver(fcitx) - lifeCycleOwner.handleLifecycleEvent(Lifecycle.Event.ON_CREATE) // forward to our channel for point to point consuming fcitx.eventFlow .onEach { fcitxEventChannel.send(it) } - .launchIn(GlobalScope) + .launchIn(scope) + fcitx.start() // wait fcitx started - runBlocking { receiveFirst() } - fcitx.setEnabledIme(arrayOf("pinyin")) - fcitx.globalConfig = RawConfig(arrayOf( - RawConfig("Behavior", arrayOf( - RawConfig("ShowInputMethodInformation", false) - )) - )) + runBlocking { + receiveFirst() + fcitx.setEnabledIme(arrayOf("pinyin")) + fcitx.setGlobalConfig( + RawConfig( + arrayOf( + RawConfig( + "Behavior", arrayOf( + RawConfig("ShowInputMethodInformation", false) + ) + ) + ) + ) + ) + } } @AfterClass @JvmStatic fun cleanup() { - log("cleanup") - lifeCycleOwner.handleLifecycleEvent(Lifecycle.Event.ON_DESTROY) + fcitx.stop() } private suspend fun sendString(str: String) { @@ -61,7 +76,7 @@ class FcitxTest { } } - private suspend inline fun > receiveFirst(): T? = + private suspend inline fun > receiveFirst(): T? = fcitxEventChannel.receiveAsFlow().mapNotNull { it as? T }.firstOrNull() private suspend fun receiveFirstCandidateList() = @@ -80,12 +95,12 @@ class FcitxTest { private var enabledIme: List = listOf() @Before - fun saveEnabledIME() { + fun saveEnabledIME() = runBlocking { enabledIme = fcitx.enabledIme().map { it.uniqueName } } @After - fun restoreEnabledIME() { + fun restoreEnabledIME() = runBlocking { fcitx.setEnabledIme(enabledIme.toTypedArray()) } @@ -96,7 +111,7 @@ class FcitxTest { val expected = "你好" fcitx.select(0) val commitString = receiveFirstCommitString()?.data - log("commitString is $commitString") + Timber.i("commitString is $commitString") Assert.assertEquals(expected, commitString) fcitx.reset() } @@ -108,7 +123,7 @@ class FcitxTest { val expected = "你好世界" fcitx.select(0) val commitString = receiveFirstCommitString()?.data - log("commitString is $commitString") + Timber.i("commitString is $commitString") Assert.assertEquals(expected, commitString) fcitx.reset() } @@ -116,19 +131,19 @@ class FcitxTest { @Test fun testInputPanelStatus(): Unit = runBlocking { fcitx.reset() - log("after first reset: ${fcitx.isEmpty()}") + Timber.i("after first reset: ${fcitx.isEmpty()}") Assert.assertEquals(true, fcitx.isEmpty()) fcitx.sendKey('a') do { val list = receiveFirstCandidateList() - } while (list!!.data.isEmpty()) - log("after sending 'a': ${fcitx.isEmpty()}") + } while (list!!.data.candidates.isNotEmpty()) + Timber.i("after sending 'a': ${fcitx.isEmpty()}") Assert.assertEquals(false, fcitx.isEmpty()) fcitx.reset() do { val list = receiveFirstCandidateList() - } while (list!!.data.isNotEmpty()) - log("after second reset: ${fcitx.isEmpty()}") + } while (list!!.data.candidates.isNotEmpty()) + Timber.i("after second reset: ${fcitx.isEmpty()}") Assert.assertEquals(true, fcitx.isEmpty()) } diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 2e162315b..fcc089b60 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -19,10 +19,23 @@ - + + + + + + + + + + @@ -49,12 +62,9 @@ android:configChanges="orientation|screenSize" android:exported="false" android:label="@string/edit_theme" /> - + android:name=".ui.main.CropImageActivity" + android:exported="false" /> + @@ -89,12 +103,17 @@ + + + + @@ -116,6 +136,16 @@ android:resource="@xml/input_method" /> + + + + + + + + + + + + - + diff --git a/app/src/main/assets/usr/share/fcitx5/pinyin b/app/src/main/assets/usr/share/fcitx5/pinyin deleted file mode 120000 index 3da4da6b0..000000000 --- a/app/src/main/assets/usr/share/fcitx5/pinyin +++ /dev/null @@ -1 +0,0 @@ -../../../../../../../lib/fcitx5/src/main/cpp/prebuilt/chinese-addons-data/pinyin \ No newline at end of file diff --git a/app/src/main/assets/usr/share/fcitx5/pinyinhelper b/app/src/main/assets/usr/share/fcitx5/pinyinhelper deleted file mode 120000 index ff38cad3f..000000000 --- a/app/src/main/assets/usr/share/fcitx5/pinyinhelper +++ /dev/null @@ -1 +0,0 @@ -../../../../../../../lib/fcitx5/src/main/cpp/prebuilt/chinese-addons-data/pinyinhelper \ No newline at end of file diff --git a/app/src/main/assets/usr/share/fcitx5/spell b/app/src/main/assets/usr/share/fcitx5/spell deleted file mode 120000 index b29f79c51..000000000 --- a/app/src/main/assets/usr/share/fcitx5/spell +++ /dev/null @@ -1 +0,0 @@ -../../../../../../../lib/fcitx5/src/main/cpp/prebuilt/spell-dict \ No newline at end of file diff --git a/app/src/main/assets/usr/share/fcitx5/table b/app/src/main/assets/usr/share/fcitx5/table deleted file mode 120000 index cf7158e9a..000000000 --- a/app/src/main/assets/usr/share/fcitx5/table +++ /dev/null @@ -1 +0,0 @@ -../../../../../../../lib/fcitx5/src/main/cpp/prebuilt/libime/table \ No newline at end of file diff --git a/app/src/main/assets/usr/share/libime b/app/src/main/assets/usr/share/libime deleted file mode 120000 index 8b39600e1..000000000 --- a/app/src/main/assets/usr/share/libime +++ /dev/null @@ -1 +0,0 @@ -../../../../../../lib/fcitx5/src/main/cpp/prebuilt/libime/data \ No newline at end of file diff --git a/app/src/main/assets/usr/share/opencc b/app/src/main/assets/usr/share/opencc deleted file mode 120000 index 24bf7f559..000000000 --- a/app/src/main/assets/usr/share/opencc +++ /dev/null @@ -1 +0,0 @@ -../../../../../../lib/fcitx5/src/main/cpp/prebuilt/opencc/data \ No newline at end of file diff --git a/app/src/main/cpp/CMakeLists.txt b/app/src/main/cpp/CMakeLists.txt index 574913a3b..9777b761f 100644 --- a/app/src/main/cpp/CMakeLists.txt +++ b/app/src/main/cpp/CMakeLists.txt @@ -1,8 +1,6 @@ cmake_minimum_required(VERSION 3.18) -project(fcitx5-android VERSION 0.0.6) - -set(CMAKE_CXX_STANDARD 17) +project(fcitx5-android VERSION ${VERSION_NAME}) # For reproducible build add_link_options("LINKER:--hash-style=gnu,--build-id=none") @@ -15,87 +13,78 @@ set(CMAKE_MODULE_PATH ${FCITX5_CMAKE_MODULES} ${CMAKE_MODULE_PATH}) find_package(Fcitx5Core MODULE) find_package(Fcitx5Module MODULE) -# install prefix for addon conf and locale -set(CMAKE_INSTALL_PREFIX /usr) -set(FCITX_INSTALL_PKGDATADIR /usr/share/fcitx5) -set(FCITX_INSTALL_LOCALEDIR /usr/share/locale) +find_package(libime REQUIRED CONFIG) +get_target_property(LIBIME_CMAKE_MODULES libime::cmake INTERFACE_INCLUDE_DIRECTORIES) +set(CMAKE_MODULE_PATH ${LIBIME_CMAKE_MODULES} ${CMAKE_MODULE_PATH}) + +find_package(LibIMECore MODULE) +find_package(LibIMEPinyin MODULE) +find_package(LibIMETable MODULE) + +find_package(fcitx5-lua REQUIRED CONFIG) +find_package(fcitx5-chinese-addons REQUIRED CONFIG) + +include("${FCITX_INSTALL_CMAKECONFIG_DIR}/Fcitx5Utils/Fcitx5CompilerSettings.cmake") add_subdirectory(po) add_subdirectory(androidfrontend) add_subdirectory(androidkeyboard) +add_subdirectory(androidnotification) -# prebuilt dir. at least it works. -set(PREBUILT_DIR "${CMAKE_CURRENT_SOURCE_DIR}/../../../../lib/fcitx5/src/main/cpp/prebuilt") - -# prebuilt fmt -set(fmt_DIR "${PREBUILT_DIR}/fmt/${ANDROID_ABI}/lib/cmake/fmt") -find_package(fmt) - -# prebuilt libevent -set(Libevent_DIR "${PREBUILT_DIR}/libevent/${ANDROID_ABI}/lib/cmake/libevent") -find_package(Libevent) +# prebuilt libuv +set(libuv_DIR "${PREBUILT_DIR}/libuv/${ANDROID_ABI}/lib/cmake/libuv") +find_package(libuv) # prebuilt boost -set(BOOST_VERSION "1.80.0") -set(BOOST_MODULES headers filesystem atomic iostreams regex) -set(BOOST_ROOT "${PREBUILT_DIR}/boost/${ANDROID_ABI}") -set(Boost_DIR "${BOOST_ROOT}/lib/cmake/Boost-${BOOST_VERSION}") -foreach(mod IN LISTS BOOST_MODULES) - set("boost_${mod}_DIR" "${BOOST_ROOT}/lib/cmake/boost_${mod}-${BOOST_VERSION}") -endforeach() - -option(ENABLE_TEST "" OFF) -set(LIBIME_INSTALL_PKGDATADIR table) -add_subdirectory(libime) -# kenlm/util/exception.hh uses __FILE__ macro -target_compile_options(kenlm PRIVATE "-ffile-prefix-map=${CMAKE_CURRENT_SOURCE_DIR}/libime/src/libime/core/kenlm=.") +list(APPEND CMAKE_FIND_ROOT_PATH "${PREBUILT_DIR}/boost/${ANDROID_ABI}/lib/cmake") +find_package(Boost 1.86.0 REQUIRED COMPONENTS headers iostreams CONFIG) -# prebuilt lua -include("${PREBUILT_DIR}/lua/${ANDROID_ABI}/lib/cmake/LuaConfig.cmake") - -# we are using static linking -option(USE_DLOPEN "" OFF) -add_subdirectory(fcitx5-lua) - -# prebuilt opencc -set(OpenCC_DIR "${PREBUILT_DIR}/opencc/${ANDROID_ABI}/lib/cmake/opencc") -find_package(OpenCC) - -option(ENABLE_TEST "" OFF) -option(ENABLE_GUI "" OFF) -option(ENABLE_BROWSER "" OFF) -option(USE_WEBKIT "" OFF) -option(ENABLE_CLOUDPINYIN "" OFF) -# prefer OpenCC_DIR rather than fcitx5-chinese-addons/cmake/FindOpenCC.cmake -set(CMAKE_FIND_PACKAGE_PREFER_CONFIG ON) -add_subdirectory(fcitx5-chinese-addons) -# rename to include executable in apk -set_target_properties(scel2org5 PROPERTIES OUTPUT_NAME libscel2org5.so) - -option(ENABLE_TEST "" OFF) -option(ENABLE_QT "" OFF) -add_subdirectory(fcitx5-unikey) -# suppress "illegal character encoding in character literal" warning in unikey/data.cpp -target_compile_options(unikey-lib PRIVATE "-Wno-invalid-source-encoding") +set(CHINESE_ADDONS_PINYIN_DIR "${CMAKE_CURRENT_SOURCE_DIR}/../../../../lib/fcitx5-chinese-addons/src/main/cpp/fcitx5-chinese-addons/im/pinyin") +add_library(pinyin-customphrase STATIC "${CHINESE_ADDONS_PINYIN_DIR}/customphrase.cpp") +target_include_directories(pinyin-customphrase INTERFACE "${CHINESE_ADDONS_PINYIN_DIR}") +target_link_libraries(pinyin-customphrase PRIVATE Fcitx5::Utils LibIME::Core) add_library(native-lib SHARED native-lib.cpp) target_link_libraries(native-lib log - libevent::core + libuv::uv_a Fcitx5::Utils Fcitx5::Config Fcitx5::Core Fcitx5::Module::QuickPhrase Fcitx5::Module::Unicode Fcitx5::Module::Clipboard + Boost::headers + Boost::iostreams LibIME::Pinyin - LibIME::Table) + LibIME::Table + pinyin-customphrase + ) +# copy module libraries from dependency lib add_custom_target(copy-fcitx5-modules - 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/androidfrontend/androidfrontend.cpp b/app/src/main/cpp/androidfrontend/androidfrontend.cpp index c49064083..620452604 100644 --- a/app/src/main/cpp/androidfrontend/androidfrontend.cpp +++ b/app/src/main/cpp/androidfrontend/androidfrontend.cpp @@ -1,5 +1,10 @@ +/* + * SPDX-License-Identifier: LGPL-2.1-or-later + * SPDX-FileCopyrightText: Copyright 2021-2023 Fcitx5 for Android Contributors + */ #include #include +#include #include #include #include @@ -10,13 +15,13 @@ namespace fcitx { -class AndroidInputContext : public InputContext { +class AndroidInputContext : public InputContextV2 { public: AndroidInputContext(AndroidFrontend *frontend, InputContextManager &inputContextManager, int uid, const std::string &pkgName) - : InputContext(inputContextManager, pkgName), + : InputContextV2(inputContextManager, pkgName), frontend_(frontend), uid_(uid) { created(); @@ -30,7 +35,11 @@ class AndroidInputContext : public InputContext { [[nodiscard]] const char *frontend() const override { return "androidfrontend"; } void commitStringImpl(const std::string &text) override { - frontend_->commitString(text); + frontend_->commitString(text, -1); + } + + void commitStringWithCursorImpl(const std::string &text, size_t cursor) override { + frontend_->commitString(text, static_cast(cursor)); } void forwardKeyImpl(const ForwardKeyEvent &key) override { @@ -38,55 +47,91 @@ class AndroidInputContext : public InputContext { } void deleteSurroundingTextImpl(int offset, unsigned int size) override { - FCITX_INFO() << "DeleteSurrounding: " << offset << " " << size; + 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; + return; + } + frontend_->deleteSurrounding(before, after); } void updatePreeditImpl() override { - checkClientPreeditUpdate(); + frontend_->updateClientPreedit(filterText(inputPanel().clientPreedit())); } 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. - if (!isPreeditEnabled()) { - checkClientPreeditUpdate(); - } - InputPanel &ip = inputPanel(); + const InputPanel &ip = inputPanel(); 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) { size = bulk->totalSize(); // limit candidate count to 16 (for paging) - const int limit = std::min(size, 16); + const int limit = size < 0 ? 16 : std::min(size, 16); for (int i = 0; i < limit; i++) { - 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())); + try { + auto &candidate = bulk->candidateFromAll(i); + // maybe unnecessary; I don't see anywhere using `CandidateWord::setPlaceHolder` + // if (candidate.isPlaceHolder()) continue; + candidates.emplace_back(filterString(candidate.textWithComment())); + } catch (const std::invalid_argument &e) { + size = static_cast(candidates.size()); + break; + } } } 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; @@ -105,42 +150,122 @@ class AndroidInputContext : public InputContext { 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(); if (list) { + const int last = offset + limit; const auto &bulk = list->toBulk(); if (bulk) { - const int _limit = std::min(bulk->totalSize(), offset + limit); - for (int i = offset; i < _limit; i++) { - auto &candidate = bulk->candidateFromAll(i); - candidates.emplace_back(filterString(candidate.text())); + const int totalSize = bulk->totalSize(); + const int end = totalSize < 0 ? last : std::min(totalSize, last); + for (int i = offset; i < end; i++) { + try { + auto &candidate = bulk->candidateFromAll(i); + candidates.emplace_back(filterString(candidate.textWithComment())); + } catch (const std::invalid_argument &e) { + break; + } } } else { - const int _limit = std::min(list->size(), offset + limit); - for (int i = offset; i < _limit; i++) { - candidates.emplace_back(filterString(list->candidate(i).text())); + const int end = std::min(list->size(), last); + for (int i = offset; i < end; i++) { + candidates.emplace_back(filterString(list->candidate(i).textWithComment())); } } } return candidates; } -private: - AndroidFrontend *frontend_; - int uid_; + 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; + } - bool clientPreeditEmpty_ = true; + 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 checkClientPreeditUpdate() { - const auto &clientPreedit = filterText(inputPanel().clientPreedit()); - bool empty = clientPreedit.empty(); - // skip update if new and old clientPreedit are both empty - if (empty && clientPreeditEmpty_) return; - clientPreeditEmpty_ = empty; - frontend_->updateClientPreedit(clientPreedit); + 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_; + inline Text filterText(const Text &orig) { return frontend_->instance()->outputFilter(this, orig); } @@ -156,26 +281,34 @@ AndroidFrontend::AndroidFrontend(Instance *instance) activeIC_(nullptr), icCache_(), eventHandlers_(), - statusAreaDefer_(), - statusAreaUpdated_(false) { + pagingMode_(0) { eventHandlers_.emplace_back(instance_->watchEvent( EventType::InputContextInputMethodActivated, EventWatcherPhase::Default, - [this](Event &event) { imChangeCallback(); } + [this](Event &event) { + FCITX_UNUSED(event); + imChangeCallback(); + } )); eventHandlers_.emplace_back(instance_->watchEvent( - EventType::InputContextUpdateUI, + EventType::InputContextFlushUI, EventWatcherPhase::Default, [this](Event &event) { - auto &e = static_cast(event); + auto &e = static_cast(event); switch (e.component()) { case UserInterfaceComponent::InputPanel: { - auto *ic = dynamic_cast(activeIC_); - if (ic) ic->updateInputPanel(); + if (activeIC_) { + activeIC_->updateInputPanel(); + if (pagingMode_ == 0) { + activeIC_->updateCandidatesBulk(); + } else { + activeIC_->updateCandidatesPaged(); + } + } break; } case UserInterfaceComponent::StatusArea: { - handleStatusAreaUpdate(); + statusAreaUpdateCallback(); break; } } @@ -184,10 +317,9 @@ AndroidFrontend::AndroidFrontend(Instance *instance) } void AndroidFrontend::keyEvent(const Key &key, bool isRelease, const int timestamp) { - auto *ic = activeIC_; - if (!ic) return; - KeyEvent keyEvent(ic, key, isRelease); - ic->keyEvent(keyEvent); + if (!activeIC_) return; + KeyEvent keyEvent(activeIC_, key, isRelease); + activeIC_->keyEvent(keyEvent); if (!keyEvent.accepted()) { auto sym = key.sym(); keyEventCallback(sym, key.states(), Key::keySymToUnicode(sym), isRelease, timestamp); @@ -199,8 +331,8 @@ void AndroidFrontend::forwardKey(const Key &key, bool isRelease) { keyEventCallback(sym, key.states(), Key::keySymToUnicode(sym), isRelease, -1); } -void AndroidFrontend::commitString(const std::string &str) { - commitStringCallback(str); +void AndroidFrontend::commitString(const std::string &str, const int cursor) { + commitStringCallback(str, cursor); } void AndroidFrontend::updateCandidateList(const std::vector &candidates, const int size) { @@ -212,7 +344,7 @@ void AndroidFrontend::updateClientPreedit(const Text &clientPreedit) { } void AndroidFrontend::updateInputPanel(const Text &preedit, const Text &auxUp, const Text &auxDown) { - inputPanelAuxCallback(preedit, auxUp, auxDown); + inputPanelCallback(preedit, auxUp, auxDown); } void AndroidFrontend::releaseInputContext(const int uid) { @@ -220,46 +352,53 @@ void AndroidFrontend::releaseInputContext(const int uid) { } bool AndroidFrontend::selectCandidate(int idx) { - auto *ic = dynamic_cast(focusGroup_.focusedInputContext()); - if (!ic) return false; - return ic->selectCandidate(idx); + if (!activeIC_) return false; + 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() { - auto *ic = focusGroup_.focusedInputContext(); - if (!ic) return true; - return ic->inputPanel().empty(); + if (!activeIC_) return true; + return activeIC_->inputPanel().empty(); } void AndroidFrontend::resetInputContext() { - auto *ic = focusGroup_.focusedInputContext(); - if (!ic) return; - ic->reset(); + if (!activeIC_) return; + activeIC_->reset(); } void AndroidFrontend::repositionCursor(int position) { - auto *ic = focusGroup_.focusedInputContext(); - if (!ic) return; - auto engine = instance_->inputMethodEngine(ic); - InvokeActionEvent event(InvokeActionEvent::Action::LeftClick, position, ic); - engine->invokeAction(*(instance_->inputMethodEntry(ic)), event); + if (!activeIC_) return; + InvokeActionEvent event(InvokeActionEvent::Action::LeftClick, position, activeIC_); + activeIC_->invokeAction(event); } void AndroidFrontend::focusInputContext(bool focus) { + if (!activeIC_) return; if (focus) { - if (!activeIC_) return; activeIC_->focusIn(); } else { - auto *ic = focusGroup_.focusedInputContext(); - if (!ic) return; - ic->focusOut(); + activeIC_->focusOut(); } } void AndroidFrontend::activateInputContext(const int uid, const std::string &pkgName) { auto *ptr = icCache_.find(uid); if (ptr) { - activeIC_ = ptr->get(); + activeIC_ = dynamic_cast(ptr->get()); } else { auto *ic = new AndroidInputContext(this, instance_->inputContextManager(), uid, pkgName); activeIC_ = ic; @@ -289,9 +428,29 @@ void AndroidFrontend::setCandidateListCallback(const CandidateListCallback &call } std::vector AndroidFrontend::getCandidates(const int offset, const int limit) { - auto *ic = dynamic_cast(focusGroup_.focusedInputContext()); - if (!ic) return {}; - return ic->getCandidates(offset, limit); + if (!activeIC_) return {}; + return activeIC_->getCandidates(offset, limit); +} + +void AndroidFrontend::deleteSurrounding(const int before, const int after) { + deleteSurroundingCallback(before, after); +} + +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) { @@ -303,7 +462,7 @@ void AndroidFrontend::setPreeditCallback(const ClientPreeditCallback &callback) } void AndroidFrontend::setInputPanelAuxCallback(const InputPanelCallback &callback) { - inputPanelAuxCallback = callback; + inputPanelCallback = callback; } void AndroidFrontend::setKeyEventCallback(const KeyEventCallback &callback) { @@ -318,15 +477,16 @@ void AndroidFrontend::setStatusAreaUpdateCallback(const StatusAreaUpdateCallback statusAreaUpdateCallback = callback; } -void AndroidFrontend::handleStatusAreaUpdate() { - if (statusAreaUpdated_) return; - statusAreaUpdated_ = true; - statusAreaDefer_ = instance_->eventLoop().addDeferEvent([this](EventSource *) { - statusAreaUpdateCallback(); - statusAreaUpdated_ = false; - statusAreaDefer_ = nullptr; - return true; - }); +void AndroidFrontend::setDeleteSurroundingCallback(const DeleteSurroundingCallback &callback) { + deleteSurroundingCallback = callback; +} + +void AndroidFrontend::setToastCallback(const ToastCallback &callback) { + toastCallback = callback; +} + +void AndroidFrontend::setPagedCandidateCallback(const PagedCandidateCallback &callback) { + pagedCandidateCallback = callback; } class AndroidFrontendFactory : public AddonFactory { diff --git a/app/src/main/cpp/androidfrontend/androidfrontend.h b/app/src/main/cpp/androidfrontend/androidfrontend.h index a8bd64278..9b9db800b 100644 --- a/app/src/main/cpp/androidfrontend/androidfrontend.h +++ b/app/src/main/cpp/androidfrontend/androidfrontend.h @@ -1,9 +1,13 @@ -#ifndef _FCITX5_ANDROID_ANDROIDFRONTEND_H_ -#define _FCITX5_ANDROID_ANDROIDFRONTEND_H_ +/* + * 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 #include #include -#include #include #include "androidfrontend_public.h" @@ -11,17 +15,20 @@ namespace fcitx { +class AndroidInputContext; + class AndroidFrontend : public AddonInstance { public: - AndroidFrontend(Instance *instance); + explicit AndroidFrontend(Instance *instance); Instance *instance() { return instance_; } void updateCandidateList(const std::vector &candidates, const int size); - void commitString(const std::string &str); + void commitString(const std::string &str, const int cursor); 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); @@ -32,9 +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); @@ -42,6 +55,9 @@ class AndroidFrontend : public AddonInstance { void setKeyEventCallback(const KeyEventCallback &callback); void setInputMethodChangeCallback(const InputMethodChangeCallback &callback); 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); @@ -55,6 +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); @@ -62,25 +83,28 @@ class AndroidFrontend : public AddonInstance { FCITX_ADDON_EXPORT_FUNCTION(AndroidFrontend, setKeyEventCallback); FCITX_ADDON_EXPORT_FUNCTION(AndroidFrontend, setInputMethodChangeCallback); 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_; - InputContext *activeIC_; + AndroidInputContext *activeIC_; InputContextCache icCache_; std::vector>> eventHandlers_; - std::unique_ptr statusAreaDefer_; - bool statusAreaUpdated_; - - void handleStatusAreaUpdate(); + int pagingMode_; CandidateListCallback candidateListCallback = [](const std::vector &, const int) {}; - CommitStringCallback commitStringCallback = [](const std::string &) {}; + CommitStringCallback commitStringCallback = [](const std::string &, const int) {}; ClientPreeditCallback preeditCallback = [](const Text &) {}; - InputPanelCallback inputPanelAuxCallback = [](const fcitx::Text &, const fcitx::Text &, const Text &) {}; + InputPanelCallback inputPanelCallback = [](const fcitx::Text &, const fcitx::Text &, const Text &) {}; KeyEventCallback keyEventCallback = [](const int, const uint32_t, const uint32_t, const bool, const int) {}; InputMethodChangeCallback imChangeCallback = [] {}; 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 f75a72711..69bcbbdd5 100644 --- a/app/src/main/cpp/androidfrontend/androidfrontend_public.h +++ b/app/src/main/cpp/androidfrontend/androidfrontend_public.h @@ -1,16 +1,27 @@ -#ifndef _FCITX5_ANDROID_ANDROIDFRONTEND_PUBLIC_H_ -#define _FCITX5_ANDROID_ANDROIDFRONTEND_PUBLIC_H_ +/* + * 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 #include +#include +#include #include +#include "../helper-types.h" + typedef std::function &, const int)> CandidateListCallback; -typedef std::function CommitStringCallback; +typedef std::function CommitStringCallback; typedef std::function ClientPreeditCallback; typedef std::function InputPanelCallback; typedef std::function KeyEventCallback; 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)) @@ -45,6 +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 &)) @@ -66,4 +92,13 @@ FCITX_ADDON_DECLARE_FUNCTION(AndroidFrontend, setInputMethodChangeCallback, FCITX_ADDON_DECLARE_FUNCTION(AndroidFrontend, setStatusAreaUpdateCallback, void(const StatusAreaUpdateCallback &)) -#endif // _FCITX5_ANDROID_ANDROIDFRONTEND_PUBLIC_H_ +FCITX_ADDON_DECLARE_FUNCTION(AndroidFrontend, setDeleteSurroundingCallback, + void(const DeleteSurroundingCallback &)) + +FCITX_ADDON_DECLARE_FUNCTION(AndroidFrontend, setToastCallback, + void(const ToastCallback &)) + +FCITX_ADDON_DECLARE_FUNCTION(AndroidFrontend, setPagedCandidateCallback, + void(const PagedCandidateCallback &)) + +#endif // FCITX5_ANDROID_ANDROIDFRONTEND_PUBLIC_H diff --git a/app/src/main/cpp/androidfrontend/inputcontextcache.h b/app/src/main/cpp/androidfrontend/inputcontextcache.h index 058044dce..637ecf44e 100644 --- a/app/src/main/cpp/androidfrontend/inputcontextcache.h +++ b/app/src/main/cpp/androidfrontend/inputcontextcache.h @@ -1,5 +1,9 @@ -// modified from https://github.com/fcitx/libime/blob/1.0.14/src/libime/core/lrucache.h - +/* + * SPDX-License-Identifier: LGPL-2.1-or-later + * SPDX-FileCopyrightText: Copyright 2017-2017 CSSlayer + * SPDX-FileCopyrightText: Copyright 2021-2023 Fcitx5 for Android Contributors + * SPDX-FileComment: Modified from https://github.com/fcitx/libime/blob/1.0.14/src/libime/core/lrucache.h + */ #ifndef FCITX5_ANDROID_INPUTCONTEXTCACHE_H #define FCITX5_ANDROID_INPUTCONTEXTCACHE_H diff --git a/app/src/main/cpp/androidkeyboard/androidkeyboard.conf.in.in b/app/src/main/cpp/androidkeyboard/androidkeyboard.conf.in.in index 7f83929d9..03d617017 100644 --- a/app/src/main/cpp/androidkeyboard/androidkeyboard.conf.in.in +++ b/app/src/main/cpp/androidkeyboard/androidkeyboard.conf.in.in @@ -8,5 +8,4 @@ Configurable=True [Addon/OptionalDependencies] 0=spell -1=quickphrase -;2=emoji \ No newline at end of file +1=quickphrase \ No newline at end of file diff --git a/app/src/main/cpp/androidkeyboard/androidkeyboard.cpp b/app/src/main/cpp/androidkeyboard/androidkeyboard.cpp index 6c517cc94..a84d55ab7 100644 --- a/app/src/main/cpp/androidkeyboard/androidkeyboard.cpp +++ b/app/src/main/cpp/androidkeyboard/androidkeyboard.cpp @@ -1,3 +1,7 @@ +/* + * SPDX-License-Identifier: LGPL-2.1-or-later + * SPDX-FileCopyrightText: Copyright 2021-2023 Fcitx5 for Android Contributors + */ #include #include #include @@ -10,8 +14,6 @@ #include "androidkeyboard.h" -#define FCITX_KEYBOARD_MAX_BUFFER 20 - namespace fcitx { namespace { @@ -85,7 +87,7 @@ void AndroidKeyboardEngine::keyEvent(const InputMethodEntry &entry, KeyEvent &ev // check if we can select candidate. if (auto candList = inputContext->inputPanel().candidateList()) { - int idx = key.keyListIndex(selectionKeys_); + const int idx = key.keyListIndex(selectionKeys_); if (idx >= 0 && idx < candList->size()) { event.filterAndAccept(); candList->candidate(idx).select(inputContext); @@ -93,9 +95,9 @@ void AndroidKeyboardEngine::keyEvent(const InputMethodEntry &entry, KeyEvent &ev } } - bool validSym = isValidSym(key); + const bool validSym = isValidSym(key); - static KeyList FCITX_HYPHEN_APOS = {Key(FcitxKey_minus), Key(FcitxKey_apostrophe)}; + static const KeyList FCITX_HYPHEN_APOS = {Key(FcitxKey_minus), Key(FcitxKey_apostrophe)}; // check for valid character if (key.isSimple() || validSym) { // prepend space before input next word @@ -106,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(); } } @@ -119,7 +120,7 @@ void AndroidKeyboardEngine::keyEvent(const InputMethodEntry &entry, KeyEvent &ev } return updateCandidate(entry, inputContext); } - } else if (key.check(FcitxKey_Delete)) { + } else if (key.check(FcitxKey_Delete) || key.check(FcitxKey_KP_Delete)) { if (buffer.del()) { event.filterAndAccept(); if (buffer.empty()) { @@ -140,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); } } @@ -173,7 +174,7 @@ std::vector AndroidKeyboardEngine::listInputMethods() { void AndroidKeyboardEngine::reloadConfig() { readAsIni(config_, ConfPath); selectionKeys_.clear(); - KeySym syms[] = { + const std::array syms{ FcitxKey_1, FcitxKey_2, FcitxKey_3, FcitxKey_4, FcitxKey_5, FcitxKey_6, FcitxKey_7, FcitxKey_8, FcitxKey_9, FcitxKey_0, }; @@ -209,6 +210,7 @@ void AndroidKeyboardEngine::setConfig(const RawConfig &config) { } void AndroidKeyboardEngine::activate(const InputMethodEntry &entry, InputContextEvent &event) { + FCITX_UNUSED(entry); auto *inputContext = event.inputContext(); wordHintAction_.setChecked(*config_.enableWordHint); wordHintAction_.update(inputContext); @@ -226,6 +228,7 @@ void AndroidKeyboardEngine::deactivate(const InputMethodEntry &entry, InputConte } void AndroidKeyboardEngine::reset(const InputMethodEntry &entry, InputContextEvent &event) { + FCITX_UNUSED(entry); auto *inputContext = event.inputContext(); resetState(inputContext); inputContext->inputPanel().reset(); @@ -245,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(), - 20); + 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); } @@ -265,45 +276,46 @@ void AndroidKeyboardEngine::updateCandidate(const InputMethodEntry &entry, Input } void AndroidKeyboardEngine::updateUI(InputContext *inputContext) { - auto *state = inputContext->propertyFor(&factory_); - Text preedit(preeditString(inputContext), TextFormatFlag::Underline); - preedit.setCursor(static_cast(state->buffer_.cursorByChar())); - inputContext->inputPanel().setClientPreedit(preedit); - // 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, - CapabilityFlag::Sensitive}; - // 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; } auto &buffer = state->buffer_; - auto preedit = preeditString(inputContext); + auto [preedit, cursor] = preeditWithCursor(inputContext); if (preedit != buffer.userInput()) { buffer.clear(); buffer.type(preedit); } - buffer.type(chr); + buffer.type(Key::keySymToUTF8(event.key().sym())); - if (buffer.size() >= FCITX_KEYBOARD_MAX_BUFFER) { + if (buffer.size() >= MaxBufferSize) { commitBuffer(inputContext); return true; } @@ -313,11 +325,16 @@ bool AndroidKeyboardEngine::updateBuffer(InputContext *inputContext, const std:: } void AndroidKeyboardEngine::commitBuffer(InputContext *inputContext) { - auto preedit = preeditString(inputContext); + auto [preedit, cursor] = preeditWithCursor(inputContext); if (preedit.empty()) { return; } - inputContext->commitString(preedit); + auto characterCount = utf8::length(preedit, 0, cursor); + if (inputContext->capabilityFlags().test(CapabilityFlag::CommitStringWithCursor)) { + inputContext->commitStringWithCursor(preedit, characterCount); + } else { + inputContext->commitString(preedit); + } resetState(inputContext); inputContext->inputPanel().reset(); inputContext->updatePreedit(); @@ -329,22 +346,22 @@ bool AndroidKeyboardEngine::supportHint(const std::string &language) { return hasSpell; } -std::string AndroidKeyboardEngine::preeditString(InputContext *inputContext) { +std::pair AndroidKeyboardEngine::preeditWithCursor(InputContext *inputContext) { auto *state = inputContext->propertyFor(&factory_); - return state->buffer_.userInput(); + return {state->buffer_.userInput(), state->buffer_.cursorByChar()}; } void AndroidKeyboardEngine::invokeActionImpl(const InputMethodEntry &entry, InvokeActionEvent &event) { - size_t cursor = event.cursor(); + const int cursor = event.cursor(); auto inputContext = event.inputContext(); auto *state = inputContext->propertyFor(&factory_); if (event.action() != InvokeActionEvent::Action::LeftClick || cursor < 0 - || cursor > state->buffer_.size()) { + || static_cast(cursor) > state->buffer_.size()) { return InputMethodEngineV3::invokeActionImpl(entry, event); } event.filter(); - state->buffer_.setCursor(event.cursor()); + state->buffer_.setCursor(static_cast(cursor)); updateUI(inputContext); } diff --git a/app/src/main/cpp/androidkeyboard/androidkeyboard.h b/app/src/main/cpp/androidkeyboard/androidkeyboard.h index 847b64fc7..b365ac527 100644 --- a/app/src/main/cpp/androidkeyboard/androidkeyboard.h +++ b/app/src/main/cpp/androidkeyboard/androidkeyboard.h @@ -1,5 +1,9 @@ -#ifndef _FCITX5_ANDROID_ANDROIDKEYBOARD_H_ -#define _FCITX5_ANDROID_ANDROIDKEYBOARD_H_ +/* + * 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 #include #include @@ -26,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 @@ -50,8 +58,10 @@ struct AndroidKeyboardEngineState : public InputContextProperty { class AndroidKeyboardEngine final : public InputMethodEngineV3 { public: - AndroidKeyboardEngine(Instance *instance); - ~AndroidKeyboardEngine() = default; + static int constexpr MaxBufferSize = 20; + static int constexpr SpellCandidateSize = 20; + + explicit AndroidKeyboardEngine(Instance *instance); Instance *instance() { return instance_; } @@ -87,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(). @@ -100,7 +110,10 @@ class AndroidKeyboardEngine final : public InputMethodEngineV3 { private: bool supportHint(const std::string &language); - std::string preeditString(InputContext *inputContext); + /** + * preedit string and byte cursor + */ + std::pair preeditWithCursor(InputContext *inputContext); Instance *instance_; AndroidKeyboardEngineConfig config_; @@ -121,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/CMakeLists.txt b/app/src/main/cpp/androidnotification/CMakeLists.txt new file mode 100644 index 000000000..86d583495 --- /dev/null +++ b/app/src/main/cpp/androidnotification/CMakeLists.txt @@ -0,0 +1,10 @@ +add_definitions(-DFCITX_GETTEXT_DOMAIN=\"fcitx5-android\") + +add_library(androidnotification MODULE androidnotification.cpp) +target_link_libraries(androidnotification Fcitx5::Core Fcitx5::Utils Fcitx5::Module::Notifications) + +configure_file(notifications.conf.in.in notifications.conf.in @ONLY) +fcitx5_translate_desktop_file(${CMAKE_CURRENT_BINARY_DIR}/notifications.conf.in notifications.conf) +install(FILES "${CMAKE_CURRENT_BINARY_DIR}/notifications.conf" + DESTINATION "${FCITX_INSTALL_PKGDATADIR}/addon" + COMPONENT config) diff --git a/app/src/main/cpp/androidnotification/androidnotification.cpp b/app/src/main/cpp/androidnotification/androidnotification.cpp new file mode 100644 index 000000000..b2e06b5e4 --- /dev/null +++ b/app/src/main/cpp/androidnotification/androidnotification.cpp @@ -0,0 +1,100 @@ +/* + * SPDX-License-Identifier: LGPL-2.1-or-later + * SPDX-FileCopyrightText: Copyright 2023 Fcitx5 for Android Contributors + */ +#include + +#include +#include + +#include "../androidfrontend/androidfrontend_public.h" + +#include "androidnotification.h" + +namespace fcitx { + +Notifications::Notifications(Instance *instance) : instance_(instance) { + reloadConfig(); +} + +void Notifications::reloadConfig() { + readAsIni(config_, ConfPath); + updateHiddenNotifications(); +} + +void Notifications::save() { + std::vector values_; + values_.reserve(hiddenNotifications_.size()); + for (const auto &id: hiddenNotifications_) { + values_.push_back(id); + } + config_.hiddenNotifications.setValue(std::move(values_)); + 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, + const std::string &appIcon, + const std::string &summary, + const std::string &body, + const std::vector &actions, + int32_t timeout, + NotificationActionCallback actionCallback, + NotificationClosedCallback closedCallback) { + // TODO implement Notification + FCITX_UNUSED(appName); + FCITX_UNUSED(replaceId); + FCITX_UNUSED(appIcon); + FCITX_UNUSED(summary); + FCITX_UNUSED(body); + FCITX_UNUSED(actions); + FCITX_UNUSED(timeout); + FCITX_UNUSED(actionCallback); + FCITX_UNUSED(closedCallback); + return 0; +} + +void Notifications::showTip( + const std::string &tipId, + const std::string &appName, + const std::string &appIcon, + const std::string &summary, + const std::string &body, + int32_t timeout) { + FCITX_UNUSED(appName); + FCITX_UNUSED(appIcon); + FCITX_UNUSED(timeout); + if (hiddenNotifications_.count(tipId)) { + return; + } + std::string const s = summary + ": " + body; + androidfrontend()->call(s); +} + +void Notifications::closeNotification(uint64_t internalId) { + FCITX_UNUSED(internalId); +} + +class NotificationsModuleFactory : public AddonFactory { + AddonInstance *create(AddonManager *manager) override { + return new Notifications(manager->instance()); + } +}; + +} + +FCITX_ADDON_FACTORY(fcitx::NotificationsModuleFactory) diff --git a/app/src/main/cpp/androidnotification/androidnotification.h b/app/src/main/cpp/androidnotification/androidnotification.h new file mode 100644 index 000000000..242e2857b --- /dev/null +++ b/app/src/main/cpp/androidnotification/androidnotification.h @@ -0,0 +1,76 @@ +/* + * SPDX-License-Identifier: LGPL-2.1-or-later + * SPDX-FileCopyrightText: Copyright 2023 Fcitx5 for Android Contributors + */ +#ifndef FCITX5_ANDROID_ANDROIDNOTIFICATION_H +#define FCITX5_ANDROID_ANDROIDNOTIFICATION_H + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +namespace fcitx { + +FCITX_CONFIGURATION(NotificationsConfig, + fcitx::Option> hiddenNotifications{ + this, "HiddenNotifications", + _("Hidden Notifications")};) + +class Notifications final : public AddonInstance { +public: + explicit Notifications(Instance *instance); + + Instance *instance() { return instance_; } + + void reloadConfig() override; + + void save() override; + + const Configuration *getConfig() const override { return &config_; } + + void setConfig(const RawConfig &config) override; + + FCITX_ADDON_DEPENDENCY_LOADER(androidfrontend, instance_->addonManager()); + + uint32_t sendNotification(const std::string &appName, uint32_t replaceId, + const std::string &appIcon, + const std::string &summary, + const std::string &body, + const std::vector &actions, + int32_t timeout, + NotificationActionCallback actionCallback, + NotificationClosedCallback closedCallback); + + void showTip(const std::string &tipId, const std::string &appName, + const std::string &appIcon, const std::string &summary, + const std::string &body, int32_t timeout); + + void closeNotification(uint64_t internalId); + +private: + FCITX_ADDON_EXPORT_FUNCTION(Notifications, sendNotification); + FCITX_ADDON_EXPORT_FUNCTION(Notifications, showTip); + FCITX_ADDON_EXPORT_FUNCTION(Notifications, closeNotification); + + static const inline char* ConfPath = "conf/androidnotification.conf"; + + NotificationsConfig config_; + Instance *instance_; + + std::unordered_set hiddenNotifications_; + + void updateHiddenNotifications(); + +}; // class Notifications + +} // namespace fcitx + +#endif //FCITX5_ANDROID_ANDROIDNOTIFICATION_H diff --git a/app/src/main/cpp/androidnotification/notifications.conf.in.in b/app/src/main/cpp/androidnotification/notifications.conf.in.in new file mode 100644 index 000000000..453050f16 --- /dev/null +++ b/app/src/main/cpp/androidnotification/notifications.conf.in.in @@ -0,0 +1,11 @@ +[Addon] +Name=Android Toast & Notification +Type=SharedLibrary +Library=libandroidnotification +Category=Module +Version=@PROJECT_VERSION@ +OnDemand=True +Configurable=True + +[Addon/Dependencies] +0=androidfrontend:@PROJECT_VERSION@ diff --git a/app/src/main/cpp/fcitx5-chinese-addons b/app/src/main/cpp/fcitx5-chinese-addons deleted file mode 160000 index 21e416db6..000000000 --- a/app/src/main/cpp/fcitx5-chinese-addons +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 21e416db60485a1871a169285f7a5cbb774d84bf diff --git a/app/src/main/cpp/fcitx5-lua b/app/src/main/cpp/fcitx5-lua deleted file mode 160000 index d8a1319c6..000000000 --- a/app/src/main/cpp/fcitx5-lua +++ /dev/null @@ -1 +0,0 @@ -Subproject commit d8a1319c6583b0055b4bd4ea5bbd0292a8f8023a diff --git a/app/src/main/cpp/fcitx5-unikey b/app/src/main/cpp/fcitx5-unikey deleted file mode 160000 index 0f2867136..000000000 --- a/app/src/main/cpp/fcitx5-unikey +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 0f2867136b130967da183bb21921defd60c0a17c diff --git a/app/src/main/cpp/helper-types.h b/app/src/main/cpp/helper-types.h index fa2e9453e..1f13c9494 100644 --- a/app/src/main/cpp/helper-types.h +++ b/app/src/main/cpp/helper-types.h @@ -1,30 +1,51 @@ +/* + * SPDX-License-Identifier: LGPL-2.1-or-later + * SPDX-FileCopyrightText: Copyright 2021-2023 Fcitx5 for Android Contributors + */ #ifndef FCITX5_ANDROID_HELPER_TYPES_H #define FCITX5_ANDROID_HELPER_TYPES_H #include #include #include +#include +#include +#include +#include + +#include class InputMethodStatus { public: - const fcitx::InputMethodEntry *entry; + // fcitx::InputMethodEntry + std::string uniqueName; + std::string name; + std::string nativeName; + std::string icon; + std::string label; + std::string languageCode; + std::string addon; + bool configurable = false; + // fcitx::InputMethodEngine std::string subMode; std::string subModeLabel; std::string subModeIcon; InputMethodStatus(const fcitx::InputMethodEntry *entry, fcitx::InputMethodEngine *engine, - fcitx::InputContext *ic) - : entry(entry) { - if (engine) { - subMode = engine->subMode(*entry, *ic); - subModeLabel = engine->subModeLabel(*entry, *ic); - subModeIcon = engine->subModeIcon(*entry, *ic); - } + fcitx::InputContext *ic) { + uniqueName = entry->uniqueName(); + name = entry->name(); + nativeName = entry->nativeName(); + icon = entry->icon(); + label = entry->label(); + languageCode = entry->languageCode(); + addon = entry->addon(); + configurable = entry->isConfigurable(); + subMode = engine->subMode(*entry, *ic); + subModeLabel = engine->subModeLabel(*entry, *ic); + subModeIcon = engine->subModeIcon(*entry, *ic); } - - InputMethodStatus(const fcitx::InputMethodEntry *entry) - : entry(entry) {} }; class AddonStatus { @@ -68,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 5ec574728..6807b531d 100644 --- a/app/src/main/cpp/jni-utils.h +++ b/app/src/main/cpp/jni-utils.h @@ -1,3 +1,7 @@ +/* + * SPDX-License-Identifier: LGPL-2.1-or-later + * SPDX-FileCopyrightText: Copyright 2021-2023 Fcitx5 for Android Contributors + */ #ifndef FCITX5_ANDROID_JNI_UTILS_H #define FCITX5_ANDROID_JNI_UTILS_H @@ -73,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); } @@ -102,6 +106,7 @@ class GlobalRefSingleton { jmethodID BooleanInit; jclass Fcitx; + jmethodID ShowToast; jmethodID HandleFcitxEvent; jclass InputMethodEntry; @@ -127,7 +132,19 @@ class GlobalRefSingleton { jclass FormattedText; jmethodID FormattedTextFromByteCursor; - GlobalRefSingleton(JavaVM *jvm_) : jvm(jvm_) { + jclass PinyinCustomPhrase; + jmethodID PinyinCustomPhraseInit; + jfieldID PinyinCustomPhraseKey; + jfieldID PinyinCustomPhraseOrder; + jfieldID PinyinCustomPhraseValue; + + jclass CandidateAction; + jmethodID CandidateActionInit; + + jclass Candidate; + jmethodID CandidateInit; + + explicit GlobalRefSingleton(JavaVM *jvm_) : jvm(jvm_) { JNIEnv *env; jvm->AttachCurrentThread(&env, nullptr); @@ -142,11 +159,12 @@ class GlobalRefSingleton { BooleanInit = env->GetMethodID(Boolean, "", "(Z)V"); Fcitx = reinterpret_cast(env->NewGlobalRef(env->FindClass("org/fcitx/fcitx5/android/core/Fcitx"))); + ShowToast = env->GetStaticMethodID(Fcitx, "showToast", "(Ljava/lang/String;)V"); 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;"); @@ -166,9 +184,21 @@ class GlobalRefSingleton { FormattedText = reinterpret_cast(env->NewGlobalRef(env->FindClass("org/fcitx/fcitx5/android/core/FormattedText"))); FormattedTextFromByteCursor = env->GetStaticMethodID(FormattedText, "fromByteCursor", "([Ljava/lang/String;[II)Lorg/fcitx/fcitx5/android/core/FormattedText;"); + + PinyinCustomPhrase = reinterpret_cast(env->NewGlobalRef(env->FindClass("org/fcitx/fcitx5/android/data/pinyin/customphrase/PinyinCustomPhrase"))); + PinyinCustomPhraseInit = env->GetMethodID(PinyinCustomPhrase, "", "(Ljava/lang/String;ILjava/lang/String;)V"); + 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/libime b/app/src/main/cpp/libime deleted file mode 160000 index ba001dc0a..000000000 --- a/app/src/main/cpp/libime +++ /dev/null @@ -1 +0,0 @@ -Subproject commit ba001dc0a3aeaf7e253fef9b7dd41cc0736e1944 diff --git a/app/src/main/cpp/native-lib.cpp b/app/src/main/cpp/native-lib.cpp index bfb90acd4..bdeb17191 100644 --- a/app/src/main/cpp/native-lib.cpp +++ b/app/src/main/cpp/native-lib.cpp @@ -1,12 +1,18 @@ +/* + * SPDX-License-Identifier: LGPL-2.1-or-later + * SPDX-FileCopyrightText: Copyright 2021-2024 Fcitx5 for Android Contributors + */ #include +#include + #include #include #include #include -#include +#include #include #include @@ -19,7 +25,9 @@ #include #include #include +#include #include +#include #include #include @@ -28,6 +36,10 @@ #include #include +#include +#include +#include "customphrase.h" + #include "androidfrontend/androidfrontend_public.h" #include "jni-utils.h" #include "nativestreambuf.h" @@ -59,19 +71,17 @@ 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(const std::function &setupCallback) { - char arg0[] = ""; - char *argv[] = {arg0}; - p_instance = std::make_unique(FCITX_ARRAY_SIZE(argv), argv); + p_instance = std::make_unique(0, nullptr); p_instance->addonManager().registerDefaultLoader(nullptr); p_dispatcher = std::make_unique(); p_dispatcher->attach(&p_instance->eventLoop()); @@ -136,17 +146,15 @@ class Fcitx { const auto *entry = imMgr.entry(ime.name()); entries.emplace_back(entry); } - return std::move(entries); + return entries; } - InputMethodStatus inputMethodStatus() { + std::unique_ptr inputMethodStatus() { auto *ic = p_frontend->call(); - auto *engine = p_instance->inputMethodEngine(ic); - const auto *entry = p_instance->inputMethodEntry(ic); - if (engine) { - return {entry, engine, ic}; - } - return {entry}; + if (!ic) return nullptr; + auto *entry = p_instance->inputMethodEntry(ic); + auto *engine = static_cast(p_instance->addonManager().addon(entry->addon(), true)); + return std::make_unique(entry, engine, ic); } void setInputMethod(const std::string &ime) { @@ -163,7 +171,7 @@ class Fcitx { entries.emplace_back(&entry); return true; }); - return std::move(entries); + return entries; } void setEnabledInputMethods(std::vector &entries) { @@ -279,9 +287,9 @@ class Fcitx { auto &globalConfig = p_instance->globalConfig(); auto &addonManager = p_instance->addonManager(); const auto &enabledAddons = globalConfig.enabledAddons(); - std::unordered_set enabledSet(enabledAddons.begin(), enabledAddons.end()); + const std::unordered_set enabledSet(enabledAddons.begin(), enabledAddons.end()); const auto &disabledAddons = globalConfig.disabledAddons(); - std::unordered_set + const std::unordered_set disabledSet(disabledAddons.begin(), disabledAddons.end()); std::vector addons; for (const auto category: {fcitx::AddonCategory::InputMethod, @@ -301,7 +309,7 @@ class Fcitx { } else if (enabledSet.count(info->uniqueName())) { enabled = true; } - addons.emplace_back(AddonStatus(info, enabled)); + addons.emplace_back(info, enabled); } } return addons; @@ -354,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) { @@ -387,7 +395,7 @@ class Fcitx { fcitx::StatusGroup::InputMethod, fcitx::StatusGroup::AfterInputMethod}) { for (auto act: ic->statusArea().actions(group)) { - actions.emplace_back(ActionEntity(act, ic)); + actions.emplace_back(act, ic); } } return actions; @@ -405,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. @@ -461,6 +489,9 @@ JNI_OnLoad(JavaVM *jvm, void * /* reserved */) { return JNI_VERSION_1_6; } +#pragma GCC diagnostic push +#pragma GCC diagnostic ignored "-Wunused-parameter" + extern "C" JNIEXPORT void JNICALL Java_org_fcitx_fcitx5_android_core_Fcitx_setupLogStream(JNIEnv *env, jclass clazz, jboolean verbose) { @@ -471,40 +502,47 @@ Java_org_fcitx_fcitx5_android_core_Fcitx_setupLogStream(JNIEnv *env, jclass claz extern "C" JNIEXPORT void JNICALL -Java_org_fcitx_fcitx5_android_core_Fcitx_startupFcitx(JNIEnv *env, jclass clazz, jstring locale, jstring appData, jstring appLib, jstring extData, jstring extCache, jobjectArray extDomains) { +Java_org_fcitx_fcitx5_android_core_Fcitx_startupFcitx( + JNIEnv *env, jclass clazz, + jstring locale, + jstring appData, + jstring appLib, + jstring extData, + jstring extCache, + jobjectArray extDomains) { if (Fcitx::Instance().isRunning()) { FCITX_ERROR() << "Fcitx is already running!"; return; } FCITX_INFO() << "Starting..."; - setenv("SKIP_FCITX_PATH", "true", 1); - auto locale_ = CString(env, locale); auto appData_ = CString(env, appData); auto appLib_ = CString(env, appLib); auto extData_ = CString(env, extData); auto extCache_ = CString(env, extCache); - std::string lang_ = fcitx::stringutils::split(*locale_, ":")[0]; - std::string config_home = fcitx::stringutils::joinPath(*extData_, "config"); - std::string data_home = fcitx::stringutils::joinPath(*extData_, "data"); - std::string usr_share = fcitx::stringutils::joinPath(*appData_, "usr", "share"); - std::string locale_dir = fcitx::stringutils::joinPath(usr_share, "locale"); - std::string libime_data = fcitx::stringutils::joinPath(usr_share, "libime"); - std::string lua_path = fcitx::stringutils::concat( + const std::string lang_ = fcitx::stringutils::split(*locale_, ":")[0]; + const std::string config_home = fcitx::stringutils::joinPath(*extData_, "config"); + const std::string data_home = fcitx::stringutils::joinPath(*extData_, "data"); + const std::string usr_share = fcitx::stringutils::joinPath(*appData_, "usr", "share"); + const std::string locale_dir = fcitx::stringutils::joinPath(usr_share, "locale"); + const std::string libime_data = fcitx::stringutils::joinPath(usr_share, "libime"); + const std::string lua_path = fcitx::stringutils::concat( fcitx::stringutils::joinPath(data_home, "lua", "?.lua"), ";", fcitx::stringutils::joinPath(data_home, "lua", "?", "init.lua"), ";", fcitx::stringutils::joinPath(usr_share, "lua", "5.4", "?.lua"), ";", fcitx::stringutils::joinPath(usr_share, "lua", "5.4", "?", "init.lua"), ";", ";" // double semicolon, for default path defined in luaconf.h ); - std::string lua_cpath = fcitx::stringutils::concat( + const std::string lua_cpath = fcitx::stringutils::concat( fcitx::stringutils::joinPath(data_home, "lua", "?.so"), ";", fcitx::stringutils::joinPath(usr_share, "lua", "5.4", "?.so"), ";", ";" ); + // prevent StandardPath from resolving it's hardcoded installation path + setenv("SKIP_FCITX_PATH", "1", 1); // for fcitx default profile [DefaultInputMethod] setenv("LANG", lang_.c_str(), 1); // for libintl-lite loading gettext .mo translations @@ -535,12 +573,11 @@ Java_org_fcitx_fcitx5_android_core_Fcitx_startupFcitx(JNIEnv *env, jclass clazz, const char *locale_dir_char = locale_dir.c_str(); fcitx::registerDomain("fcitx5", locale_dir_char); - fcitx::registerDomain("fcitx5-chinese-addons", locale_dir_char); fcitx::registerDomain("fcitx5-lua", locale_dir_char); - fcitx::registerDomain("fcitx5-unikey", locale_dir_char); + fcitx::registerDomain("fcitx5-chinese-addons", locale_dir_char); fcitx::registerDomain("fcitx5-android", locale_dir_char); - int extDomainsSize = env->GetArrayLength(extDomains); + const int extDomainsSize = env->GetArrayLength(extDomains); for (int i = 0; i < extDomainsSize; i++) { auto domain = JRef(env, env->GetObjectArrayElement(extDomains, i)); fcitx::registerDomain(CString(env, domain), locale_dir_char); @@ -559,10 +596,12 @@ Java_org_fcitx_fcitx5_android_core_Fcitx_startupFcitx(JNIEnv *env, jclass clazz, env->SetObjectArrayElement(vararg, 1, *candidatesArray); env->CallStaticVoidMethod(GlobalRef->Fcitx, GlobalRef->HandleFcitxEvent, 0, *vararg); }; - auto commitStringCallback = [](const std::string &str) { + auto commitStringCallback = [](const std::string &str, const int cursor) { auto env = GlobalRef->AttachEnv(); - auto vararg = JRef(env, env->NewObjectArray(1, GlobalRef->String, nullptr)); + auto stringCursor = JRef(env, env->NewObject(GlobalRef->Integer, GlobalRef->IntegerInit, cursor)); + auto vararg = JRef(env, env->NewObjectArray(2, GlobalRef->Object, nullptr)); env->SetObjectArrayElement(vararg, 0, JString(env, str)); + env->SetObjectArrayElement(vararg, 1, stringCursor); env->CallStaticVoidMethod(GlobalRef->Fcitx, GlobalRef->HandleFcitxEvent, 1, *vararg); }; auto preeditCallback = [](const fcitx::Text &clientPreedit) { @@ -595,24 +634,71 @@ Java_org_fcitx_fcitx5_android_core_Fcitx_startupFcitx(JNIEnv *env, jclass clazz, env->CallStaticVoidMethod(GlobalRef->Fcitx, GlobalRef->HandleFcitxEvent, 5, *vararg); }; auto imChangeCallback = []() { + std::unique_ptr status = Fcitx::Instance().inputMethodStatus(); + if (!status) return; auto env = GlobalRef->AttachEnv(); auto vararg = JRef(env, env->NewObjectArray(1, GlobalRef->Object, nullptr)); - const auto status = Fcitx::Instance().inputMethodStatus(); - auto obj = JRef(env, fcitxInputMethodStatusToJObject(env, status)); + 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(); - auto vararg = JRef(env, env->NewObjectArray(static_cast(actions.size()), GlobalRef->Action, nullptr)); + auto actionArray = JRef(env, env->NewObjectArray(static_cast(actions.size()), GlobalRef->Action, nullptr)); int i = 0; for (const auto &a: actions) { auto obj = JRef(env, fcitxActionToJObject(env, a)); - env->SetObjectArrayElement(vararg, i++, obj); + env->SetObjectArrayElement(actionArray, i++, obj); } + env->SetObjectArrayElement(vararg, 0, actionArray); + auto statusObj = JRef(env, fcitxInputMethodStatusToJObject(env, *status)); + env->SetObjectArrayElement(vararg, 1, statusObj); env->CallStaticVoidMethod(GlobalRef->Fcitx, GlobalRef->HandleFcitxEvent, 7, *vararg); }; + auto deleteSurroundingCallback = [](const int before, const int after) { + std::array arr{before, after}; + auto env = GlobalRef->AttachEnv(); + auto vararg = JRef(env, env->NewObjectArray(1, GlobalRef->Object, nullptr)); + auto intArray = JRef(env, env->NewIntArray(2)); + env->SetIntArrayRegion(intArray, 0, 2, arr.data()); + 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::StandardPaths::global().syncUmask(); Fcitx::Instance().startup([&](auto *androidfrontend) { FCITX_INFO() << "Setting up callback"; @@ -624,6 +710,9 @@ Java_org_fcitx_fcitx5_android_core_Fcitx_startupFcitx(JNIEnv *env, jclass clazz, androidfrontend->template call(keyEventCallback); androidfrontend->template call(imChangeCallback); androidfrontend->template call(statusAreaUpdateCallback); + androidfrontend->template call(deleteSurroundingCallback); + androidfrontend->template call(pagedCandidateCallback); + androidfrontend->template call(toastCallback); }); FCITX_INFO() << "Finishing startup"; } @@ -658,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 - fcitx::Key parsedKey{fcitx::Key::keySymFromString((const char *) &c), - fcitx::KeyStates(static_cast(state))}; + const fcitx::Key parsedKey{fcitx::Key::keySymFromString(reinterpret_cast(&c)), + 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); } @@ -687,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); } @@ -738,8 +829,9 @@ extern "C" JNIEXPORT jobject JNICALL Java_org_fcitx_fcitx5_android_core_Fcitx_inputMethodStatus(JNIEnv *env, jclass clazz) { RETURN_VALUE_IF_NOT_RUNNING(nullptr) - const auto &status = Fcitx::Instance().inputMethodStatus(); - return fcitxInputMethodStatusToJObject(env, status); + auto status = Fcitx::Instance().inputMethodStatus(); + if (!status) return nullptr; + return fcitxInputMethodStatusToJObject(env, *status); } extern "C" @@ -884,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" @@ -952,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) { @@ -1041,3 +1168,91 @@ Java_org_fcitx_fcitx5_android_data_table_TableManager_checkTableDictFormat(JNIEn } return JNI_TRUE; } + +extern "C" +JNIEXPORT jobjectArray JNICALL +Java_org_fcitx_fcitx5_android_data_pinyin_CustomPhraseManager_load(JNIEnv *env, jclass clazz) { + auto fp = fcitx::StandardPaths::global().open(fcitx::StandardPathsType::PkgData, "pinyin/customphrase"); + if (fp.fd() < 0) { + FCITX_INFO() << "cannot open pinyin/customphrase"; + return nullptr; + } + boost::iostreams::stream_buffer + buffer(fp.fd(), boost::iostreams::file_descriptor_flags::never_close_handle); + std::istream in(&buffer); + fcitx::CustomPhraseDict dict; + dict.load(in, true); + int size = 0; + dict.foreach([&](const std::string &key, std::vector &items) { + FCITX_UNUSED(key); + size += static_cast(items.size()); + }); + int i = 0; + jobjectArray array = env->NewObjectArray(size, GlobalRef->PinyinCustomPhrase, nullptr); + dict.foreach([&](const std::string &key, std::vector &items) { + for (const auto &item: items) { + env->SetObjectArrayElement(array, i++, + JRef(env, env->NewObject(GlobalRef->PinyinCustomPhrase, GlobalRef->PinyinCustomPhraseInit, + *JString(env, key), + item.order(), + *JString(env, item.value()) + ) + ) + ); + } + }); + return array; +} + +extern "C" +JNIEXPORT void JNICALL +Java_org_fcitx_fcitx5_android_data_pinyin_CustomPhraseManager_save(JNIEnv *env, jclass clazz, jobjectArray items) { + fcitx::CustomPhraseDict dict; + const int size = env->GetArrayLength(items); + for (int i = 0; i < size; i++) { + auto phrase = JRef(env, env->GetObjectArrayElement(items, i)); + auto phraseKey = JRef(env, env->GetObjectField(phrase, GlobalRef->PinyinCustomPhraseKey)); + auto phraseOrder = env->GetIntField(phrase, GlobalRef->PinyinCustomPhraseOrder); + auto phraseValue = JRef(env, env->GetObjectField(phrase, GlobalRef->PinyinCustomPhraseValue)); + dict.addPhrase(*CString(env, phraseKey), + *CString(env, phraseValue), + static_cast(phraseOrder)); + } + 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); + std::ostream out(&buffer); + dict.save(out); + return true; + }); +} + +extern "C" +JNIEXPORT jobject JNICALL +Java_org_fcitx_fcitx5_android_utils_Ini_readFromIni(JNIEnv *env, jclass clazz, jstring src) { + fcitx::RawConfig config; + FILE *fp = std::fopen(*CString(env, src), "rb"); + if (!fp) { + return nullptr; + } + fcitx::readFromIni(config, fp); + std::fclose(fp); + return fcitxRawConfigToJObject(env, config); +} + +extern "C" +JNIEXPORT void JNICALL +Java_org_fcitx_fcitx5_android_utils_Ini_writeAsIni(JNIEnv *env, jclass clazz, jstring dest, jobject value) { + FILE *fp = std::fopen(*CString(env, dest), "wb"); + if (!fp) { + throwJavaException(env, "Unable to open file"); + return; + } + auto config = jobjectToRawConfig(env, value); + fcitx::writeAsIni(config, fp); + std::fclose(fp); +} + +#pragma GCC diagnostic pop diff --git a/app/src/main/cpp/nativestreambuf.h b/app/src/main/cpp/nativestreambuf.h index 0d288e627..3d423c503 100644 --- a/app/src/main/cpp/nativestreambuf.h +++ b/app/src/main/cpp/nativestreambuf.h @@ -1,3 +1,7 @@ +/* + * SPDX-License-Identifier: LGPL-2.1-or-later + * SPDX-FileCopyrightText: Copyright 2021-2023 Fcitx5 for Android Contributors + */ #ifndef FCITX5_ANDROID_NATIVESTREAMBUF_H #define FCITX5_ANDROID_NATIVESTREAMBUF_H diff --git a/app/src/main/cpp/object-conversion.h b/app/src/main/cpp/object-conversion.h index a84840fde..aa4155095 100644 --- a/app/src/main/cpp/object-conversion.h +++ b/app/src/main/cpp/object-conversion.h @@ -1,3 +1,7 @@ +/* + * SPDX-License-Identifier: LGPL-2.1-or-later + * SPDX-FileCopyrightText: Copyright 2021-2023 Fcitx5 for Android Contributors + */ #ifndef FCITX5_ANDROID_OBJECT_CONVERSION_H #define FCITX5_ANDROID_OBJECT_CONVERSION_H @@ -16,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() ); } @@ -31,16 +36,15 @@ jobjectArray fcitxInputMethodEntriesToJObjectArray(JNIEnv *env, const std::vecto } jobject fcitxInputMethodStatusToJObject(JNIEnv *env, const InputMethodStatus &status) { - const auto entry = status.entry; - if (status.subMode.empty()) return fcitxInputMethodEntryToJObject(env, entry); return env->NewObject(GlobalRef->InputMethodEntry, GlobalRef->InputMethodEntryInitWithSubMode, - *JString(env, entry->uniqueName()), - *JString(env, entry->name()), - *JString(env, entry->icon()), - *JString(env, entry->nativeName()), - *JString(env, entry->label()), - *JString(env, entry->languageCode()), - entry->isConfigurable(), + *JString(env, status.uniqueName), + *JString(env, status.name), + *JString(env, status.icon), + *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), *JString(env, status.subModeIcon) @@ -157,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 new file mode 100644 index 000000000..d78fede55 --- /dev/null +++ b/app/src/main/cpp/po/es.po @@ -0,0 +1,50 @@ +# +# Translators: +# Adolfo Jayme-Barrientos, 2023 +# +msgid "" +msgstr "" +"Project-Id-Version: fcitx5-android\n" +"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: Adolfo Jayme-Barrientos, 2023\n" +"Language-Team: Spanish (https://app.transifex.com/fcitx/teams/12005/es/)\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Language: es\n" +"Plural-Forms: nplurals=3; plural=n == 1 ? 0 : n != 0 && n % 1000000 == 0 ? 1 : 2;\n" + +msgid "Android Frontend" +msgstr "Interfaz para Android" + +msgid "Android Keyboard" +msgstr "Teclado para 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 "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 73844287a..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 "" @@ -31,3 +37,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/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 f542a8242..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 "单词提示页大小" @@ -37,3 +44,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_TW.po b/app/src/main/cpp/po/zh_TW.po index 3fa1be6b9..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,16 +25,28 @@ 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 "選詞修飾鍵" msgid "Insert space between words" msgstr "在單字間插入空格" + +msgid "Android Toast & Notification" +msgstr "Android 浮動式訊息與通知" + +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 659202e48..e52553b36 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/FcitxApplication.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/FcitxApplication.kt @@ -1,20 +1,35 @@ +/* + * SPDX-License-Identifier: LGPL-2.1-or-later + * SPDX-FileCopyrightText: Copyright 2021-2023 Fcitx5 for Android Contributors + */ package org.fcitx.fcitx5.android +import android.annotation.SuppressLint import android.app.Application +import android.content.BroadcastReceiver +import android.content.Context import android.content.Intent +import android.content.IntentFilter 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 import kotlinx.coroutines.plus +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.data.theme.ThemeManager 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 @@ -22,11 +37,71 @@ class FcitxApplication : Application() { val coroutineScope = MainScope() + CoroutineName("FcitxApplication") + private val shutdownReceiver = object : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + if (intent.action != Intent.ACTION_SHUTDOWN) return + Timber.d("Device shutting down, trying to save fcitx state...") + val fcitx = FcitxDaemon.getFirstConnectionOrNull() + ?: return Timber.d("No active fcitx connection, skipping") + fcitx.runImmediately { save() } + } + } + + private val unlockReceiver = object : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + if (intent.action != Intent.ACTION_USER_UNLOCKED) return + if (!isDirectBootMode) return + Timber.d("Device unlocked, app will exit now and restart to normal mode") + FcitxDaemon.getFirstConnectionOrNull()?.also { + // try to shutdown fcitx gracefully + FcitxDaemon.stopFcitx() + } + AppUtil.exit() + } + } + + 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 + + val directBootAwareContext: Context + @SuppressLint("NewApi") + get() = if (isDirectBootMode) createDeviceProtectedStorageContext() else applicationContext + override fun onCreate() { super.onCreate() + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N && !userManager.isUserUnlocked) { + isDirectBootMode = true + registerReceiver(unlockReceiver, IntentFilter(Intent.ACTION_USER_UNLOCKED)) + } + val ctx = directBootAwareContext + if (!BuildConfig.DEBUG) { Thread.setDefaultUncaughtExceptionHandler { _, e -> - startActivity(Intent(applicationContext, 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 @@ -37,14 +112,14 @@ class FcitxApplication : Application() { it } putExtra(LogActivity.CRASH_STACK_TRACE, truncated) - }) + } exitProcess(10) } } instance = this // we don't have AppPrefs available yet - val sharedPrefs = PreferenceManager.getDefaultSharedPreferences(applicationContext) + val sharedPrefs = PreferenceManager.getDefaultSharedPreferences(ctx) if (BuildConfig.DEBUG || sharedPrefs.getBoolean("verbose_log", false)) { Timber.plant(object : Timber.DebugTree() { override fun log(priority: Int, tag: String?, message: String, t: Throwable?) { @@ -60,6 +135,8 @@ class FcitxApplication : Application() { }) } + Timber.d("isDirectBootMode=$isDirectBootMode") + AppPrefs.init(sharedPrefs) // record last pid for crash logs AppPrefs.getInstance().internal.pid.apply { @@ -68,24 +145,53 @@ class FcitxApplication : Application() { Timber.d("Last pid is $lastPid. Set it to current pid: $currentPid") setValue(currentPid) } - ClipboardManager.init(applicationContext) + ClipboardManager.init(ctx) ThemeManager.init(resources.configuration) Locales.onLocaleChange(resources.configuration) + registerReceiver(shutdownReceiver, IntentFilter(Intent.ACTION_SHUTDOWN)) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N && !isDirectBootMode) { + 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 { private var lastPid: Int? = null private var instance: FcitxApplication? = null fun getInstance() = - instance ?: throw IllegalStateException("Fcitx application is not created!") + instance ?: throw IllegalStateException("FcitxApplication has not been created!") 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 new file mode 100644 index 000000000..282a31f62 --- /dev/null +++ b/app/src/main/java/org/fcitx/fcitx5/android/FcitxRemoteService.kt @@ -0,0 +1,132 @@ +/* + * SPDX-License-Identifier: LGPL-2.1-or-later + * SPDX-FileCopyrightText: Copyright 2021-2023 Fcitx5 for Android Contributors + */ +package org.fcitx.fcitx5.android + +import android.app.Service +import android.content.Intent +import android.os.IBinder +import android.os.Process +import kotlinx.coroutines.CoroutineName +import kotlinx.coroutines.MainScope +import kotlinx.coroutines.cancel +import kotlinx.coroutines.launch +import kotlinx.coroutines.plus +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import org.fcitx.fcitx5.android.common.ipc.IClipboardEntryTransformer +import org.fcitx.fcitx5.android.common.ipc.IFcitxRemoteService +import org.fcitx.fcitx5.android.core.data.DataManager +import org.fcitx.fcitx5.android.core.reloadPinyinDict +import org.fcitx.fcitx5.android.core.reloadQuickPhrase +import org.fcitx.fcitx5.android.daemon.FcitxDaemon +import org.fcitx.fcitx5.android.data.clipboard.ClipboardManager +import org.fcitx.fcitx5.android.utils.Const +import org.fcitx.fcitx5.android.utils.desc +import org.fcitx.fcitx5.android.utils.descEquals +import timber.log.Timber +import java.util.PriorityQueue + +class FcitxRemoteService : Service() { + + private val clipboardTransformerLock = Mutex() + + private val scope = MainScope() + CoroutineName("FcitxRemoteService") + + private val clipboardTransformers = + PriorityQueue(3, compareByDescending { it.priority }) + + private fun transformClipboard(source: String): String { + var result = source + clipboardTransformers.forEach { + try { + result = it.transform(result)!! + } catch (e: Exception) { + Timber.w("Exception while calling clipboard transformer '${it.desc}'") + Timber.w(e) + } + } + return result + } + + private suspend fun updateClipboardManager() = clipboardTransformerLock.withLock { + ClipboardManager.transformer = + if (clipboardTransformers.isEmpty()) null else ::transformClipboard + Timber.d("All clipboard transformers: ${clipboardTransformers.joinToString { it.desc }}") + } + + private val binder = object : IFcitxRemoteService.Stub() { + override fun getVersionName(): String = Const.versionName + + override fun getPid(): Int = Process.myPid() + + override fun getLoadedPlugins(): MutableMap = + DataManager.getLoadedPlugins().map { + it.packageName to it.versionName + }.let { mutableMapOf().apply { putAll(it) } } + + override fun restartFcitx() { + FcitxDaemon.restartFcitx() + } + + override fun registerClipboardEntryTransformer(transformer: IClipboardEntryTransformer) { + Timber.d("registerClipboardEntryTransformer: ${transformer.desc}") + if (transformer.description.isNullOrBlank()) { + Timber.w("Cannot register ClipboardEntryTransformer of null or empty description") + } + if (clipboardTransformers.any { it.descEquals(transformer) }) { + Timber.w("ClipboardEntryTransformer ${transformer.desc} has already been registered") + return + } + scope.launch { + transformer.asBinder().linkToDeath({ + unregisterClipboardEntryTransformer(transformer) + }, 0) + clipboardTransformers.add(transformer) + updateClipboardManager() + } + } + + override fun unregisterClipboardEntryTransformer(transformer: IClipboardEntryTransformer) { + Timber.d("unregisterClipboardEntryTransformer: ${transformer.desc}") + scope.launch { + clipboardTransformers.remove(transformer) + || clipboardTransformers.removeAll { it.descEquals(transformer) } + || return@launch + updateClipboardManager() + } + } + + override fun reloadPinyinDict() { + FcitxDaemon.getFirstConnectionOrNull()?.runIfReady { reloadPinyinDict() } + } + + override fun reloadQuickPhrase() { + FcitxDaemon.getFirstConnectionOrNull()?.runIfReady { reloadQuickPhrase() } + } + } + + override fun onCreate() { + Timber.d("FcitxRemoteService onCreate") + super.onCreate() + } + + override fun onBind(intent: Intent): IBinder { + Timber.d("FcitxRemoteService onBind: $intent") + return binder + } + + override fun onUnbind(intent: Intent): Boolean { + Timber.d("FcitxRemoteService onUnbind: $intent") + return super.onUnbind(intent) + } + + override fun onDestroy() { + Timber.d("FcitxRemoteService onDestroy") + scope.cancel() + clipboardTransformers.clear() + runBlocking { updateClipboardManager() } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/fcitx/fcitx5/android/core/AddonSubconfig.kt b/app/src/main/java/org/fcitx/fcitx5/android/core/AddonSubconfig.kt index 36e7b23b1..0f56aa830 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/core/AddonSubconfig.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/core/AddonSubconfig.kt @@ -1,3 +1,7 @@ +/* + * SPDX-License-Identifier: LGPL-2.1-or-later + * SPDX-FileCopyrightText: Copyright 2021-2023 Fcitx5 for Android Contributors + */ package org.fcitx.fcitx5.android.core suspend fun FcitxAPI.reloadPinyinDict() = setAddonSubConfig("pinyin", "dictmanager") @@ -8,4 +12,6 @@ suspend fun FcitxAPI.getPunctuationConfig(lang: String) = suspend fun FcitxAPI.savePunctuationConfig(lang: String = "zh_CN", config: RawConfig) = setAddonSubConfig("punctuation", "punctuationmap/$lang", config) -suspend fun FcitxAPI.reloadQuickPhrase() = setAddonSubConfig("quickphrase", "editor") \ No newline at end of file +suspend fun FcitxAPI.reloadQuickPhrase() = setAddonSubConfig("quickphrase", "editor") + +suspend fun FcitxAPI.reloadPinyinCustomPhrase() = setAddonSubConfig("pinyin", "customphrase") 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 6f58477a9..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 @@ -1,3 +1,7 @@ +/* + * SPDX-License-Identifier: LGPL-2.1-or-later + * SPDX-FileCopyrightText: Copyright 2021-2023 Fcitx5 for Android Contributors + */ package org.fcitx.fcitx5.android.core import android.text.InputType @@ -6,7 +10,7 @@ import splitties.bitflags.hasFlag /** * translated from - * [fcitx-utils/capabilityflags.h](https://github.com/fcitx/fcitx5/blob/5.0.13/src/lib/fcitx-utils/capabilityflags.h) + * [fcitx-utils/capabilityflags.h](https://github.com/fcitx/fcitx5/blob/5.1.1/src/lib/fcitx-utils/capabilityflags.h) */ @Suppress("unused") enum class CapabilityFlag(val flag: ULong) { @@ -60,6 +64,17 @@ enum class CapabilityFlag(val flag: ULong) { */ ClientSideInputPanel(1UL shl 39), + /** + * Whether client request input method to be disabled. + * Usually this means only allow to type with raw keyboard. + */ + Disable(1UL shl 40), + + /** + * Whether client support commit string with cursor location. + */ + CommitStringWithCursor(1UL shl 41), + PasswordOrSensitive(Password.flag or Sensitive.flag); } @@ -75,7 +90,8 @@ value class CapabilityFlags constructor(val flags: ULong) { val DefaultFlags = CapabilityFlags( CapabilityFlag.Preedit, - CapabilityFlag.ClientUnfocusCommit + CapabilityFlag.ClientUnfocusCommit, + CapabilityFlag.CommitStringWithCursor ) fun fromEditorInfo(info: EditorInfo): CapabilityFlags { @@ -132,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 355ed5716..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,19 +1,28 @@ +/* + * SPDX-License-Identifier: LGPL-2.1-or-later + * 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 import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withContext +import org.fcitx.fcitx5.android.FcitxApplication import org.fcitx.fcitx5.android.R import org.fcitx.fcitx5.android.core.data.DataManager import org.fcitx.fcitx5.android.data.clipboard.ClipboardManager import org.fcitx.fcitx5.android.data.prefs.AppPrefs import org.fcitx.fcitx5.android.utils.ImmutableGraph import org.fcitx.fcitx5.android.utils.Locales +import org.fcitx.fcitx5.android.utils.appContext +import org.fcitx.fcitx5.android.utils.toast import timber.log.Timber /** @@ -64,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() } @@ -134,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) = @@ -154,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!") @@ -165,6 +198,17 @@ class Fcitx(private val context: Context) : FcitxAPI, FcitxLifecycleOwner { private companion object JNI { + /** + * called from native-lib + */ + @Suppress("unused") + @JvmStatic + fun showToast(s: String) { + ContextCompat.getMainExecutor(appContext).execute { + appContext.toast(s) + } + } + private val eventFlow_ = MutableSharedFlow>( extraBufferCapacity = 15, @@ -204,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 @@ -282,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) @@ -305,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() @@ -335,16 +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")?.run { - get("Behavior")["PreeditEnabledByDefault"].value = "False" - setFcitxGlobalConfig(this) - } - getFcitxAddonConfig("pinyin")?.get("cfg")?.run { - get("PreeditInApplication").value = "False" - get("PreeditCursorPositionAtBeginning").value = "False" - get("QuickPhraseKey").value = "" - setFcitxAddonConfig("pinyin", this) - } firstRun = false } @@ -366,31 +418,43 @@ class Fcitx(private val context: Context) : FcitxAPI, FcitxLifecycleOwner { override fun nativeStartup() { DataManager.sync() val locale = Locales.fcitxLocale + val dataDir = DataManager.dataDir.absolutePath val plugins = DataManager.getLoadedPlugins() - val nativeLibDir = buildString { - append(context.applicationInfo.nativeLibraryDir) - plugins.forEach { - append(':') - append(it.nativeLibraryDir) + val nativeLibDir = StringBuilder(context.applicationInfo.nativeLibraryDir) + val extDomains = arrayListOf() + plugins.forEach { + if (it.nativeLibraryDir.isNotBlank()) { + nativeLibDir.append(':') + nativeLibDir.append(it.nativeLibraryDir) + } + it.domain?.let { d -> + extDomains.add(d) } } - val extDomains = plugins.mapNotNull { it.domain }.toTypedArray() - Timber.d(""" + Timber.d( + """ Starting fcitx with: locale=$locale + dataDir=$dataDir nativeLibDir=$nativeLibDir extDomains=${extDomains.joinToString()} - """.trimIndent()) - with(context) { + """.trimIndent() + ) + with(FcitxApplication.getInstance().directBootAwareContext) { startupFcitx( locale, - applicationInfo.dataDir, - nativeLibDir, + dataDir, + nativeLibDir.toString(), (getExternalFilesDir(null) ?: filesDir).absolutePath, (externalCacheDir ?: cacheDir).absolutePath, - extDomains + extDomains.toTypedArray() ) } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + lifecycle.launchWhenReady { + SubtypeManager.syncWith(enabledIme()) + } + } } override fun nativeLoopOnce() { @@ -407,14 +471,14 @@ class Fcitx(private val context: Context) : FcitxAPI, FcitxLifecycleOwner { }) - private suspend fun withFcitxContext(block: suspend () -> T): T = + private suspend inline fun withFcitxContext(crossinline block: suspend () -> T): T = withContext(dispatcher) { block() } @Keep private val onClipboardUpdate = ClipboardManager.OnClipboardUpdateListener { - lifecycle.lifecycleScope.launch { setClipboard(it.text) } + lifecycle.lifecycleScope.launch { setClipboard(it.text, it.sensitive) } } private fun computeAddonGraph() = runBlocking { @@ -431,7 +495,16 @@ class Fcitx(private val context: Context) : FcitxAPI, FcitxLifecycleOwner { when (event) { is FcitxEvent.ReadyEvent -> lifecycleRegistry.postEvent(FcitxLifecycle.Event.ON_READY) is FcitxEvent.IMChangeEvent -> inputMethodEntryCached = event.data - is FcitxEvent.StatusAreaEvent -> statusAreaActionsCached = event.data + is FcitxEvent.StatusAreaEvent -> { + val (actions, im) = event.data + statusAreaActionsCached = actions + // Engine subMode update won't trigger IMChangeEvent, but usually updates StatusArea + if (im != inputMethodEntryCached) { + inputMethodEntryCached = im + // notify downstream consumers that engine subMode has changed + eventFlow_.tryEmit(FcitxEvent.IMChangeEvent(im)) + } + } is FcitxEvent.ClientPreeditEvent -> clientPreeditCached = event.data is FcitxEvent.InputPanelEvent -> inputPanelCached = event.data else -> {} @@ -446,6 +519,9 @@ class Fcitx(private val context: Context) : FcitxAPI, FcitxLifecycleOwner { registerFcitxEventHandler(::handleFcitxEvent) lifecycleRegistry.postEvent(FcitxLifecycle.Event.ON_START) ClipboardManager.addOnUpdateListener(onClipboardUpdate) + DataManager.addOnNextSyncedCallback { + FcitxPluginServices.connectAll() + } dispatcher.start() } @@ -457,6 +533,7 @@ class Fcitx(private val context: Context) : FcitxAPI, FcitxLifecycleOwner { lifecycleRegistry.postEvent(FcitxLifecycle.Event.ON_STOP) Timber.i("Fcitx stop()") ClipboardManager.removeOnUpdateListener(onClipboardUpdate) + FcitxPluginServices.disconnectAll() dispatcher.stop().let { if (it.isNotEmpty()) Timber.w("${it.size} job(s) didn't get a chance to run!") 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 cf12372bb..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 @@ -1,3 +1,7 @@ +/* + * SPDX-License-Identifier: LGPL-2.1-or-later + * SPDX-FileCopyrightText: Copyright 2021-2023 Fcitx5 for Android Contributors + */ package org.fcitx.fcitx5.android.core import kotlinx.coroutines.flow.SharedFlow @@ -10,7 +14,6 @@ import kotlinx.coroutines.flow.SharedFlow */ interface FcitxAPI { - enum class AddonDep { Required, Optional @@ -39,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 @@ -96,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 93b8145df..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,10 +1,13 @@ +/* + * SPDX-License-Identifier: LGPL-2.1-or-later + * SPDX-FileCopyrightText: Copyright 2021-2025 Fcitx5 for Android Contributors + */ package org.fcitx.fcitx5.android.core import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Runnable import kotlinx.coroutines.asCoroutineDispatcher -import kotlinx.coroutines.cancel import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking @@ -18,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 @@ -53,7 +51,6 @@ class FcitxDispatcher(private val controller: FcitxController) : CoroutineDispat fun nativeExit() } - private val nativeLoopLock = Mutex() private val runningLock = Mutex() private val queue = ConcurrentLinkedQueue() @@ -65,17 +62,15 @@ class FcitxDispatcher(private val controller: FcitxController) : CoroutineDispat * This function returns immediately */ fun start() { + Timber.d("FcitxDispatcher start()") internalScope.launch { runningLock.withLock { - Timber.d("FcitxDispatcher start()") if (isRunning.compareAndSet(false, true)) { Timber.d("nativeStartup()") controller.nativeStartup() while (isActive && isRunning.get()) { // blocking... - nativeLoopLock.withLock { - controller.nativeLoopOnce() - } + controller.nativeLoopOnce() // do scheduled jobs while (true) { val block = queue.poll() ?: break @@ -97,7 +92,7 @@ class FcitxDispatcher(private val controller: FcitxController) : CoroutineDispat Timber.i("FcitxDispatcher stop()") return if (isRunning.compareAndSet(true, false)) { runBlocking { - bypass() + controller.nativeScheduleEmpty() runningLock.withLock { val rest = queue.toList() queue.clear() @@ -107,18 +102,14 @@ class FcitxDispatcher(private val controller: FcitxController) : CoroutineDispat } else emptyList() } - // bypass nativeLoopOnce if no code is executing in native dispatcher - private fun bypass() { - if (nativeLoopLock.isLocked) - controller.nativeScheduleEmpty() - } - override fun dispatch(context: CoroutineContext, block: Runnable) { if (!isRunning.get()) { throw IllegalStateException("Dispatcher is not in running state!") } queue.offer(WrappedRunnable(block)) - bypass() + // always call `nativeScheduleEmpty()` to prevent `nativeLoopOnce()` from blocking + // the thread when we have something to run + controller.nativeScheduleEmpty() } companion object { 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 d8acfedc5..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 @@ -1,16 +1,21 @@ +/* + * SPDX-License-Identifier: LGPL-2.1-or-later + * SPDX-FileCopyrightText: Copyright 2021-2023 Fcitx5 for Android Contributors + */ 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) : FcitxEvent(data) { - override val eventType: EventType - get() = EventType.Candidate + 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)}]" @@ -35,24 +40,25 @@ sealed class FcitxEvent(open val data: T) { } } - data class CommitStringEvent(override val data: String) : - FcitxEvent(data) { - override val eventType: EventType - get() = EventType.Commit + data class CommitStringEvent(override val data: Data) : + FcitxEvent(data) { + + override val eventType = EventType.Commit + + data class Data(val text: String, val cursor: Int) } data class ClientPreeditEvent(override val data: FormattedText) : FcitxEvent(data) { - override val eventType: EventType - get() = EventType.ClientPreedit + + override val eventType = EventType.ClientPreedit override fun toString(): String = "ClientPreeditEvent('$data', ${data.cursor})" } - data class InputPanelEvent(override val data: Data) : - FcitxEvent(data) { - override val eventType: EventType - get() = EventType.InputPanel + data class InputPanelEvent(override val data: Data) : FcitxEvent(data) { + + override val eventType = EventType.InputPanel data class Data( val preedit: FormattedText, @@ -64,16 +70,15 @@ sealed class FcitxEvent(open val data: T) { } data class ReadyEvent(override val data: Unit = Unit) : FcitxEvent(data) { - override val eventType: EventType - get() = EventType.Ready + + override val eventType = EventType.Ready override fun toString(): String = "ReadyEvent" } - data class KeyEvent(override val data: Data) : - FcitxEvent(data) { - override val eventType: EventType - get() = EventType.Key + data class KeyEvent(override val data: Data) : FcitxEvent(data) { + + override val eventType = EventType.Key data class Data( val sym: KeySym, @@ -90,31 +95,94 @@ sealed class FcitxEvent(open val data: T) { get() = EventType.Change } - data class StatusAreaEvent(override val data: Array) : - FcitxEvent>(data) { + data class StatusAreaEvent(override val data: Data) : FcitxEvent(data) { - override val eventType: EventType - get() = EventType.StatusArea + override val eventType = EventType.StatusArea - override fun equals(other: Any?): Boolean { - if (this === other) return true - if (javaClass != other?.javaClass) return false + data class Data(val actions: Array, val im: InputMethodEntry) { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false - other as StatusAreaEvent + other as Data - if (!data.contentEquals(other.data)) return false + if (!actions.contentEquals(other.actions)) return false + if (im != other.im) return false - return true + return true + } + + override fun hashCode(): Int { + var result = actions.contentHashCode() + result = 31 * result + im.hashCode() + return result + } } + } - override fun hashCode(): Int { - return data.contentHashCode() + data class DeleteSurroundingEvent(override val data: Data) : + FcitxEvent(data) { + + 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 - get() = EventType.Unknown + + override val eventType = EventType.Unknown override fun equals(other: Any?): Boolean { if (this === other) return true @@ -141,12 +209,14 @@ sealed class FcitxEvent(open val data: T) { Key, 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) = @@ -157,7 +227,12 @@ sealed class FcitxEvent(open val data: T) { params[1] as Array ) ) - EventType.Commit -> CommitStringEvent(params[0] as String) + EventType.Commit -> CommitStringEvent( + CommitStringEvent.Data( + params[0] as String, + params[1] as Int + ) + ) EventType.ClientPreedit -> ClientPreeditEvent(params[0] as FormattedText) EventType.InputPanel -> InputPanelEvent( InputPanelEvent.Data( @@ -177,7 +252,28 @@ sealed class FcitxEvent(open val data: T) { ) ) EventType.Change -> IMChangeEvent(params[0] as InputMethodEntry) - EventType.StatusArea -> StatusAreaEvent(params as Array) + EventType.StatusArea -> StatusAreaEvent( + StatusAreaEvent.Data( + params[0] as Array, + params[1] as InputMethodEntry + ) + ) + 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/FcitxLifecycle.kt b/app/src/main/java/org/fcitx/fcitx5/android/core/FcitxLifecycle.kt index e69fcbaec..de3d2fb19 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/core/FcitxLifecycle.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/core/FcitxLifecycle.kt @@ -1,3 +1,7 @@ +/* + * SPDX-License-Identifier: LGPL-2.1-or-later + * SPDX-FileCopyrightText: Copyright 2021-2023 Fcitx5 for Android Contributors + */ package org.fcitx.fcitx5.android.core import kotlinx.coroutines.CoroutineScope @@ -94,28 +98,25 @@ fun interface FcitxLifecycleObserver { class FcitxLifecycleCoroutineScope( val lifecycle: FcitxLifecycle, override val coroutineContext: CoroutineContext = SupervisorJob() -) : - CoroutineScope, FcitxLifecycleObserver { +) : CoroutineScope, FcitxLifecycleObserver { override fun onStateChanged(event: FcitxLifecycle.Event) { if (lifecycle.currentState >= FcitxLifecycle.State.STOPPING) { coroutineContext.cancelChildren() } } - } suspend fun FcitxLifecycle.whenAtState( state: FcitxLifecycle.State, block: suspend CoroutineScope.() -> T ): T = - if (state == currentState) - block(lifecycleScope) + if (state == currentState) block(lifecycleScope) else AtStateHelper(this, state).run(block) -suspend fun FcitxLifecycle.whenReady(block: suspend CoroutineScope.() -> T) = +suspend inline fun FcitxLifecycle.whenReady(noinline block: suspend CoroutineScope.() -> T) = whenAtState(FcitxLifecycle.State.READY, block) -suspend fun FcitxLifecycle.whenStopped(block: suspend CoroutineScope.() -> T) = +suspend inline fun FcitxLifecycle.whenStopped(noinline block: suspend CoroutineScope.() -> T) = whenAtState(FcitxLifecycle.State.STOPPED, block) fun FcitxLifecycle.launchWhenReady(block: suspend CoroutineScope.() -> T) = diff --git a/app/src/main/java/org/fcitx/fcitx5/android/core/FcitxPluginServices.kt b/app/src/main/java/org/fcitx/fcitx5/android/core/FcitxPluginServices.kt new file mode 100644 index 000000000..6a0618a26 --- /dev/null +++ b/app/src/main/java/org/fcitx/fcitx5/android/core/FcitxPluginServices.kt @@ -0,0 +1,107 @@ +/* + * SPDX-License-Identifier: LGPL-2.1-or-later + * SPDX-FileCopyrightText: Copyright 2021-2023 Fcitx5 for Android Contributors + */ +package org.fcitx.fcitx5.android.core + +import android.content.ComponentName +import android.content.Context +import android.content.Intent +import android.content.ServiceConnection +import android.os.IBinder +import android.os.Message +import android.os.Messenger +import org.fcitx.fcitx5.android.BuildConfig +import org.fcitx.fcitx5.android.core.data.DataManager +import org.fcitx.fcitx5.android.core.data.PluginDescriptor +import org.fcitx.fcitx5.android.utils.appContext +import timber.log.Timber + +object FcitxPluginServices { + + const val PLUGIN_SERVICE_ACTION = "${BuildConfig.APPLICATION_ID}.plugin.SERVICE" + + class PluginServiceConnection( + private val pluginId: String, + private val onDied: () -> Unit + ) : ServiceConnection { + private var messenger: Messenger? = null + + override fun onServiceConnected(name: ComponentName, service: IBinder) { + messenger = Messenger(service) + Timber.d("Plugin connected: $pluginId") + } + + // may re-connect in the future + override fun onServiceDisconnected(name: ComponentName) { + messenger = null + Timber.d("Plugin disconnected: $pluginId") + } + + // will never receive another connection + override fun onBindingDied(name: ComponentName?) { + onDied.invoke() + Timber.d("Plugin binding died: $pluginId") + } + + fun sendMessage(message: Message) { + try { + messenger?.send(message) + } catch (e: Throwable) { + Timber.w("Cannot send message to plugin: $pluginId") + Timber.w(e) + } + } + } + + private val connections = mutableMapOf() + + private fun connectPlugin(descriptor: PluginDescriptor) { + val connection = PluginServiceConnection(descriptor.name) { + disconnectPlugin(descriptor.name) + } + try { + val result = appContext.bindService( + Intent(PLUGIN_SERVICE_ACTION).apply { setPackage(descriptor.packageName) }, + connection, + Context.BIND_AUTO_CREATE + ) + if (!result) throw Exception("Couldn't find service or not enough permission") + connections[descriptor.name] = connection + Timber.d("Bind to plugin: ${descriptor.name}") + } catch (e: Exception) { + appContext.unbindService(connection) + Timber.w("Cannot bind to plugin: ${descriptor.name}") + Timber.w(e) + } + } + + fun connectAll() { + DataManager.getLoadedPlugins().forEach { + if (it.hasService && !connections.containsKey(it.name)) { + connectPlugin(it) + } + } + } + + private fun disconnectPlugin(name: String) { + connections.remove(name)?.also { + appContext.unbindService(it) + Timber.d("Unbound plugin: $name") + } + } + + fun disconnectAll() { + connections.forEach { (name, connection) -> + appContext.unbindService(connection) + Timber.d("Unbound plugin: $name") + } + connections.clear() + } + + fun sendMessage(message: Message) { + connections.forEach { (_, conn) -> + conn.sendMessage(message) + } + } +} \ No newline at end of file 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/FormattedText.kt b/app/src/main/java/org/fcitx/fcitx5/android/core/FormattedText.kt index a0745cbdb..b3b4db761 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/core/FormattedText.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/core/FormattedText.kt @@ -1,3 +1,7 @@ +/* + * SPDX-License-Identifier: LGPL-2.1-or-later + * SPDX-FileCopyrightText: Copyright 2021-2023 Fcitx5 for Android Contributors + */ package org.fcitx.fcitx5.android.core import android.graphics.Typeface.BOLD diff --git a/app/src/main/java/org/fcitx/fcitx5/android/core/Key.kt b/app/src/main/java/org/fcitx/fcitx5/android/core/Key.kt index 8cdfd00a6..d72b78ebe 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/core/Key.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/core/Key.kt @@ -1,3 +1,7 @@ +/* + * SPDX-License-Identifier: LGPL-2.1-or-later + * SPDX-FileCopyrightText: Copyright 2021-2023 Fcitx5 for Android Contributors + */ package org.fcitx.fcitx5.android.core import android.os.Parcelable 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 11f70bd2a..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 @@ -1,3 +1,7 @@ +/* + * SPDX-License-Identifier: LGPL-2.1-or-later + * SPDX-FileCopyrightText: Copyright 2021-2023 Fcitx5 for Android Contributors + */ package org.fcitx.fcitx5.android.core import android.view.KeyEvent @@ -7,7 +11,7 @@ infix fun UInt.or(other: KeyState): UInt = this or other.state /** * translated from - * [fcitx-utils/keysym.h](https://github.com/fcitx/fcitx5/blob/5.0.15/src/lib/fcitx-utils/keysym.h) + * [fcitx-utils/keysym.h](https://github.com/fcitx/fcitx5/blob/0346e58/src/lib/fcitx-utils/keysym.h) */ @Suppress("Unused", "EnumEntryName") enum class KeyState(val state: UInt) { @@ -34,18 +38,14 @@ enum class KeyState(val state: UInt) { Super2(1u shl 26), // Gtk virtual Super Hyper2(1u shl 27), // Gtk virtual Hyper Meta(1u shl 28), + Virtual(1u shl 29), /** * Whether a Key Press is from key repetition. */ Repeat(1u shl 31), UsedMask(0x5c001fffu), - SimpleMask(Ctrl_Alt_Shift or Super or Super2 or Hyper or Meta), - - /** - * Whether a Key Press is from virtual keyboard. **used in `fcitx5-android` only**. - */ - Virtual(1u shl 16); + SimpleMask(Ctrl_Alt_Shift or Super or Super2 or Hyper or Meta); constructor(other: KeyState) : this(other.state) @@ -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/KeySym.kt b/app/src/main/java/org/fcitx/fcitx5/android/core/KeySym.kt index e87e81de7..6bc2c3531 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/core/KeySym.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/core/KeySym.kt @@ -1,3 +1,7 @@ +/* + * SPDX-License-Identifier: LGPL-2.1-or-later + * SPDX-FileCopyrightText: Copyright 2021-2023 Fcitx5 for Android Contributors + */ package org.fcitx.fcitx5.android.core import android.view.KeyEvent 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 74755093f..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,7 +1,12 @@ +/* + * SPDX-License-Identifier: LGPL-2.1-or-later + * 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("", "", "") @@ -14,6 +19,7 @@ data class InputMethodEntry( val nativeName: String, val label: String, val languageCode: String, + val addon: String, val isConfigurable: Boolean, val subMode: InputMethodSubMode ) { @@ -24,6 +30,7 @@ data class InputMethodEntry( nativeName: String, label: String, languageCode: String, + addon: String, isConfigurable: Boolean ) : this( uniqueName, @@ -32,6 +39,7 @@ data class InputMethodEntry( nativeName, label, languageCode, + addon, isConfigurable, InputMethodSubMode() ) @@ -43,6 +51,7 @@ data class InputMethodEntry( nativeName: String, label: String, languageCode: String, + addon: String, isConfigurable: Boolean, subMode: String, subModeLabel: String, @@ -54,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, @@ -83,6 +94,20 @@ data class RawConfig( return subItems?.find { it.name == name } } + fun getOrCreate(name: String): RawConfig { + val items = subItems + return if (items == null) { + RawConfig(name, "", "", null).also { + subItems = arrayOf(it) + } + } else { + items.find { it.name == name } + ?: RawConfig(name, "", "", null).also { + subItems = items + it + } + } + } + /** * generated by Android Studio */ @@ -119,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] } } @@ -136,6 +160,7 @@ data class AddonInfo( val dependencies: Array = arrayOf(), val optionalDependencies: Array = arrayOf(), ) { + @Suppress("UNUSED") // used in JNI constructor( uniqueName: String, name: String, @@ -238,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/DataDescriptor.kt b/app/src/main/java/org/fcitx/fcitx5/android/core/data/DataDescriptor.kt index 787b27dc4..915ff6f9c 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/core/data/DataDescriptor.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/core/data/DataDescriptor.kt @@ -1,3 +1,7 @@ +/* + * SPDX-License-Identifier: LGPL-2.1-or-later + * SPDX-FileCopyrightText: Copyright 2021-2023 Fcitx5 for Android Contributors + */ package org.fcitx.fcitx5.android.core.data import kotlinx.serialization.Serializable @@ -17,5 +21,9 @@ data class DataDescriptor( * path -> sha256 * sha256 will be empty if the path is a directory */ - val files: Map + val files: Map, + /** + * Symbolic links from target -> source + */ + val symlinks: Map = mapOf() ) \ No newline at end of file diff --git a/app/src/main/java/org/fcitx/fcitx5/android/core/data/DataHierarchy.kt b/app/src/main/java/org/fcitx/fcitx5/android/core/data/DataHierarchy.kt index 1acc7af1f..40e96e998 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/core/data/DataHierarchy.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/core/data/DataHierarchy.kt @@ -1,3 +1,7 @@ +/* + * SPDX-License-Identifier: LGPL-2.1-or-later + * SPDX-FileCopyrightText: Copyright 2021-2023 Fcitx5 for Android Contributors + */ package org.fcitx.fcitx5.android.core.data import android.util.Base64 @@ -11,30 +15,43 @@ import java.security.MessageDigest class DataHierarchy { private val files = mutableMapOf>() private val descriptorSHA256 = mutableSetOf() - private val plugins = mutableSetOf() + private val symlinks = mutableMapOf>() - class Conflict(val path: String, val src: FileSource) : Exception() + data class PathConflict(val path: String, val src: FileSource) : Exception() + data class SymlinkConflict(val path: String, val src: FileSource) : Exception() /** * Merge a [DataDescriptor] * - * @throws Conflict if a non-directory path is existing in the file list + * @throws PathConflict if a non-directory path already exists in the hierarchy + * @throws SymlinkConflict if a file or directory already exists when creating symlink */ fun install(descriptor: DataDescriptor, src: FileSource) { val newFiles = descriptor.files.mapValues { (path, sha256) -> files[path]?.also { old -> // path conflict when at least one of them is not a directory (empty sha256) if (old.first.isNotEmpty() || sha256.isNotEmpty()) { - throw Conflict(path, old.second) + throw PathConflict(path, old.second) } } Pair(sha256, src) } // merge new files only when there is no conflict with existing files files.putAll(newFiles) - if (src is FileSource.Plugin) { - plugins += src.descriptor + val newSymlinks = descriptor.symlinks.mapValues { (path, source) -> + // path we try to create is already a file or directory in our hierarchy + files[path]?.let { (_, src) -> + throw SymlinkConflict(path, src) + } + // path we try to create is already a symlink in our hierarchy + // but it refers to a different path + symlinks[path]?.let { (existedSource, src) -> + if (source != existedSource) + throw PathConflict(path, src) + } + Pair(source, src) } + symlinks.putAll(newSymlinks) descriptorSHA256.add(descriptor.sha256) } @@ -42,7 +59,10 @@ class DataHierarchy { * Create a [DataDescriptor] from the file list, discarding other information */ fun downToDataDescriptor() = - DataDescriptor(sha256(this), files.mapValues { it.value.first }) + DataDescriptor( + sha256(this), + files.mapValues { it.value.first }, + symlinks.mapValues { it.value.first }) companion object { private val digest by lazy { MessageDigest.getInstance("SHA-256") } @@ -66,7 +86,7 @@ class DataHierarchy { fun diff(old: DataDescriptor, new: DataHierarchy): List { if (old.sha256 == sha256(new)) return emptyList() - return new.files.mapNotNull { (path, v) -> + val diffFiles = new.files.mapNotNull { (path, v) -> val (sha256, src) = v when { path !in old.files && sha256.isNotBlank() -> @@ -86,6 +106,19 @@ class DataHierarchy { FileAction.DeleteDir(path) }) } + val diffLinks = new.symlinks.mapNotNull { (target, v) -> + val (source, _) = v + if (old.symlinks[target] == source) + // old link will be overwritten + null + else + FileAction.CreateSymlink(target, source) + }.toMutableList().apply { + addAll(old.symlinks.filterKeys { it !in new.symlinks }.map { (target, _) -> + FileAction.DeleteFile(target) + }) + } + return diffFiles + diffLinks } } } \ No newline at end of file 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 7cd09780b..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,3 +1,7 @@ +/* + * SPDX-License-Identifier: LGPL-2.1-or-later + * SPDX-FileCopyrightText: Copyright 2021-2024 Fcitx5 for Android Contributors + */ package org.fcitx.fcitx5.android.core.data import android.annotation.SuppressLint @@ -5,13 +9,13 @@ import android.content.Intent import android.content.pm.PackageManager import android.content.res.AssetManager import android.os.Build -import kotlinx.serialization.decodeFromString 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 @@ -25,7 +29,12 @@ import kotlin.concurrent.withLock */ object DataManager { - const val PLUGIN_INTENT = "org.fcitx.fcitx5.android.plugin.MANIFEST" + data class PluginSet( + val loaded: Set, + val failed: Map + ) + + const val PLUGIN_INTENT = "${BuildConfig.APPLICATION_ID}.plugin.MANIFEST" private val lock = ReentrantLock() @@ -34,24 +43,30 @@ 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) } - val dataDir = File(appContext.applicationInfo.dataDir) - private val destDescriptorFile = File(dataDir, Const.dataDescriptorName) + // If Android version supports direct boot, we put the hierarchy in device encrypted storage + // instead of credential encrypted storage so that data can be accessed before user unlock + val dataDir: File = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + Timber.d("Using device protected storage") + appContext.createDeviceProtectedStorageContext().dataDir + } else { + File(appContext.applicationInfo.dataDir) + } - 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() @@ -59,6 +74,8 @@ object DataManager { fun getLoadedPlugins(): Set = loadedPlugins fun getFailedPlugins(): Map = failedPlugins + fun getSyncedPluginSet() = PluginSet(loadedPlugins, failedPlugins) + /** * Will be cleared after each sync */ @@ -67,26 +84,21 @@ object DataManager { fun addOnNextSyncedCallback(block: () -> Unit) = callbacks.add(block) - @SuppressLint("DiscouragedApi") - fun detectPlugins(): Pair, Map> { + fun detectPlugins(): PluginSet { val toLoad = mutableSetOf() val preloadFailed = mutableMapOf() val pm = appContext.packageManager - val isDebugBuild = Const.buildType == "debug" val pluginPackages = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { pm.queryIntentActivities( Intent(PLUGIN_INTENT), PackageManager.ResolveInfoFlags.of(PackageManager.MATCH_ALL.toLong()) ) } else { - @Suppress("DEPRECATION") pm.queryIntentActivities(Intent(PLUGIN_INTENT), PackageManager.MATCH_ALL) - }.mapNotNull { - // Only consider plugin with the same build variant as app's - val packageName = it.activityInfo.packageName - if (isDebugBuild == packageName.endsWith(".debug")) packageName else null + }.map { + it.activityInfo.packageName } Timber.d("Detected plugin packages: ${pluginPackages.joinToString()}") @@ -94,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") @@ -105,6 +119,7 @@ object DataManager { var domain: String? = null var apiVersion: String? = null var description: String? = null + var hasService = false var text: String? = null while ((eventType != XmlPullParser.END_DOCUMENT)) { when (eventType) { @@ -113,19 +128,20 @@ object DataManager { "apiVersion" -> apiVersion = text "domain" -> domain = text "description" -> description = text + "hasService" -> hasService = text?.lowercase() == "true" } } eventType = parser.next() } - - // 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 + parser.close() + + 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) } } @@ -137,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( @@ -146,8 +161,9 @@ object DataManager { apiVersion, domain, description, - info.versionName, - info.applicationInfo.nativeLibraryDir + hasService, + info.versionName ?: "", + info.applicationInfo?.nativeLibraryDir ?: "" ) ) } else { @@ -159,7 +175,7 @@ object DataManager { preloadFailed[packageName] = PluginLoadFailed.PluginDescriptorParseError } } - return toLoad to preloadFailed + return PluginSet(toLoad, preloadFailed) } fun sync() = lock.withLock { @@ -167,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()) + 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) @@ -203,13 +210,18 @@ object DataManager { val pluginContext = appContext.createPackageContext(plugin.packageName, 0) val assets = pluginContext.assets val descriptor = runCatching { assets.getDataDescriptor() }.onFailure { - it.printStackTrace() - Timber.w("Failed to get and decode the data descriptor of ${plugin.name}") + Timber.w("Failed to get or decode data descriptor of '${plugin.name}'") + Timber.w(it) }.getOrNull() ?: continue try { newHierarchy.install(descriptor, FileSource.Plugin(plugin)) - } catch (e: DataHierarchy.Conflict) { - Timber.w("Path ${e.path} is already created by ${e.src}") + } catch (e: DataHierarchy.PathConflict) { + Timber.w("Path '${e.path}' has already been created by '${e.src}', cannot create file") + failedPlugins[plugin.packageName] = + PluginLoadFailed.PathConflict(plugin, e.path, e.src) + continue + } catch (e: DataHierarchy.SymlinkConflict) { + Timber.w("Path '${e.path}' has already been created by '${e.src}', cannot create symlink") failedPlugins[plugin.packageName] = PluginLoadFailed.PathConflict(plugin, e.path, e.src) continue @@ -233,10 +245,10 @@ object DataManager { assets.copyFile(it.path) } is FileAction.DeleteDir -> { - deleteDir(it.path) + removePath(it.path).getOrThrow() } is FileAction.DeleteFile -> { - deleteFile(it.path) + removePath(it.path).getOrThrow() } is FileAction.UpdateFile -> { val assets = if (it.src is FileSource.Plugin) @@ -244,27 +256,37 @@ object DataManager { else appContext.assets assets.copyFile(it.path) } + is FileAction.CreateSymlink -> { + removePath(it.path).getOrThrow() + symlink(it.src, it.path).getOrThrow() + } } } // 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(BuildConfig.DATA_DESCRIPTOR_NAME) + if (oldDataDescriptor.exists()) { + oldDataDescriptor.delete() + oldDataDir.resolve("README.md").delete() + oldDataDir.resolve("usr").deleteRecursively() + } + } synced = true Timber.d("Synced") } - private fun deleteFile(path: String) { - val file = File(dataDir, path) - if (file.exists() && file.isFile) - file.delete() - } + private fun removePath(path: String) = + FileUtil.removeFile(dataDir.resolve(path)) - private fun deleteDir(path: String) { - val dir = File(dataDir, path) - if (dir.exists() && dir.isDirectory) - dir.deleteRecursively() - } + private fun symlink(source: String, target: String) = + FileUtil.symlink(dataDir.resolve(source), dataDir.resolve(target)) private fun AssetManager.copyFile(filename: String) { open(filename).use { i -> @@ -277,9 +299,11 @@ object DataManager { fun deleteAndSync() { lock.withLock { - dataDir.deleteRecursively() + 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/FileAction.kt b/app/src/main/java/org/fcitx/fcitx5/android/core/data/FileAction.kt index 2be5db1d1..ded187f09 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/core/data/FileAction.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/core/data/FileAction.kt @@ -1,10 +1,14 @@ +/* + * SPDX-License-Identifier: LGPL-2.1-or-later + * SPDX-FileCopyrightText: Copyright 2021-2023 Fcitx5 for Android Contributors + */ package org.fcitx.fcitx5.android.core.data sealed interface FileAction { val path: String /** - * We want to create files first, then update files, and finally delete directories and files. + * We want to create files first, then update files, delete directories and files, and finally create symlinks */ val ordinal: Int @@ -15,6 +19,11 @@ sealed interface FileAction { val src: FileSource } + data class CreateSymlink(override val path: String, val src: String) : FileAction { + override val ordinal: Int + get() = -1 + } + data class CreateFile(override val path: String, override val src: FileSource) : FileAction, Sourced { diff --git a/app/src/main/java/org/fcitx/fcitx5/android/core/data/FileSource.kt b/app/src/main/java/org/fcitx/fcitx5/android/core/data/FileSource.kt index fa1e6648c..21e0e78fd 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/core/data/FileSource.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/core/data/FileSource.kt @@ -1,3 +1,7 @@ +/* + * SPDX-License-Identifier: LGPL-2.1-or-later + * SPDX-FileCopyrightText: Copyright 2021-2023 Fcitx5 for Android Contributors + */ package org.fcitx.fcitx5.android.core.data sealed interface FileSource { 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 3c539787a..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,7 +1,11 @@ +/* + * SPDX-License-Identifier: LGPL-2.1-or-later + * 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` @@ -23,15 +27,18 @@ data class PluginDescriptor( * Can use string resource, e.g. `@string/description` */ val description: String, + /** + * Contains IPC service with action `${mainApplicationId}.plugin.SERVICE`. Default to `false`. + */ + val hasService: Boolean, val versionName: String, 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/core/data/PluginLoadFailed.kt b/app/src/main/java/org/fcitx/fcitx5/android/core/data/PluginLoadFailed.kt index 3048fe7bc..7a5f1a30b 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/core/data/PluginLoadFailed.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/core/data/PluginLoadFailed.kt @@ -1,3 +1,7 @@ +/* + * SPDX-License-Identifier: LGPL-2.1-or-later + * SPDX-FileCopyrightText: Copyright 2021-2023 Fcitx5 for Android Contributors + */ package org.fcitx.fcitx5.android.core.data sealed interface PluginLoadFailed { @@ -7,13 +11,9 @@ sealed interface PluginLoadFailed { val existingSrc: FileSource ) : PluginLoadFailed - object MissingPluginDescriptor : PluginLoadFailed { - override fun toString(): String = "MissingPluginDescriptor" - } + data object MissingPluginDescriptor : PluginLoadFailed - object PluginDescriptorParseError : PluginLoadFailed { - override fun toString(): String = "PluginDescriptorParseError" - } + data object PluginDescriptorParseError : PluginLoadFailed data class MissingDataDescriptor( val plugin: PluginDescriptor diff --git a/app/src/main/java/org/fcitx/fcitx5/android/daemon/FcitxConnection.kt b/app/src/main/java/org/fcitx/fcitx5/android/daemon/FcitxConnection.kt index 0b1ef40dc..d808f7c11 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/daemon/FcitxConnection.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/daemon/FcitxConnection.kt @@ -1,3 +1,7 @@ +/* + * SPDX-License-Identifier: LGPL-2.1-or-later + * SPDX-FileCopyrightText: Copyright 2021-2023 Fcitx5 for Android Contributors + */ package org.fcitx.fcitx5.android.daemon import kotlinx.coroutines.CoroutineScope diff --git a/app/src/main/java/org/fcitx/fcitx5/android/daemon/FcitxDaemon.kt b/app/src/main/java/org/fcitx/fcitx5/android/daemon/FcitxDaemon.kt index d85d286fd..9be86f05e 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/daemon/FcitxDaemon.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/daemon/FcitxDaemon.kt @@ -1,3 +1,7 @@ +/* + * SPDX-License-Identifier: LGPL-2.1-or-later + * SPDX-FileCopyrightText: Copyright 2021-2023 Fcitx5 for Android Contributors + */ package org.fcitx.fcitx5.android.daemon import android.app.NotificationChannel @@ -127,6 +131,25 @@ object FcitxDaemon { } } + /** + * Stop fcitx instance regardless of connected clients. + * Should only be used before importing user configuration files, + * then the App must be restarted as soon as possible. + * + * This method blocks until fully stopped. + */ + fun stopFcitx() { + realFcitx.stop() + } + + /** + * Start fcitx instance. + * Should only be used when it has been stopped **AND** user data importing failed. + */ + fun startFcitx() { + realFcitx.start() + } + init { createNotificationChannel() } @@ -142,6 +165,11 @@ object FcitxDaemon { } } + /** + * Reuse a connection for remote service + */ + fun getFirstConnectionOrNull() = clients.firstNotNullOfOrNull { it.value } + private const val CHANNEL_ID = "fcitx-daemon" private var RESTART_ID = 0 diff --git a/app/src/main/java/org/fcitx/fcitx5/android/daemon/Functions.kt b/app/src/main/java/org/fcitx/fcitx5/android/daemon/Functions.kt index 79078d0aa..2a08c3115 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/daemon/Functions.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/daemon/Functions.kt @@ -1,3 +1,7 @@ +/* + * SPDX-License-Identifier: LGPL-2.1-or-later + * SPDX-FileCopyrightText: Copyright 2021-2023 Fcitx5 for Android Contributors + */ package org.fcitx.fcitx5.android.daemon import kotlinx.coroutines.CoroutineScope 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 new file mode 100644 index 000000000..fef6d17b1 --- /dev/null +++ b/app/src/main/java/org/fcitx/fcitx5/android/data/InputFeedbacks.kt @@ -0,0 +1,133 @@ +/* + * SPDX-License-Identifier: LGPL-2.1-or-later + * SPDX-FileCopyrightText: Copyright 2021-2023 Fcitx5 for Android Contributors + */ +package org.fcitx.fcitx5.android.data + +import android.media.AudioManager +import android.os.Build +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.ManagedPreferenceEnum +import org.fcitx.fcitx5.android.utils.appContext +import org.fcitx.fcitx5.android.utils.audioManager +import org.fcitx.fcitx5.android.utils.getSystemSettings +import org.fcitx.fcitx5.android.utils.vibrator + +object InputFeedbacks { + + 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 = 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 = getSystemSettings(Settings.System.HAPTIC_FEEDBACK_ENABLED) == 1 + } + + 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() + + 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 + if (longPress) { + duration = buttonLongPressVibrationMilliseconds.toLong() + amplitude = buttonLongPressVibrationAmplitude + hfc = HapticFeedbackConstants.LONG_PRESS + } else { + duration = buttonPressVibrationMilliseconds.toLong() + amplitude = buttonPressVibrationAmplitude + hfc = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1 && keyUp) { + HapticFeedbackConstants.KEYBOARD_RELEASE + } else { + HapticFeedbackConstants.KEYBOARD_TAP + } + } + + // 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 { + 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) + } + } + + enum class SoundEffect { + Standard, SpaceBar, Delete, Return + } + + private val audioManager = appContext.audioManager + + fun soundEffect(effect: SoundEffect) { + when (soundOnKeyPress) { + InputFeedbackMode.Enabled -> {} + InputFeedbackMode.Disabled -> return + InputFeedbackMode.FollowingSystem -> if (!systemSoundEffects) return + } + val fx = when (effect) { + SoundEffect.Standard -> AudioManager.FX_KEYPRESS_STANDARD + SoundEffect.SpaceBar -> AudioManager.FX_KEYPRESS_SPACEBAR + SoundEffect.Delete -> AudioManager.FX_KEYPRESS_DELETE + SoundEffect.Return -> AudioManager.FX_KEYPRESS_RETURN + } + val volume = soundOnKeyPressVolume + if (volume == 0) { + audioManager.playSoundEffect(fx, -1f) + } else { + audioManager.playSoundEffect(fx, volume / 100f) + } + } + +} \ No newline at end of file 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 3129596ec..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,38 +1,76 @@ +/* + * SPDX-License-Identifier: LGPL-2.1-or-later + * SPDX-FileCopyrightText: Copyright 2021-2025 Fcitx5 for Android Contributors + */ + package org.fcitx.fcitx5.android.data -import org.fcitx.fcitx5.android.utils.appContext +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 = appContext.filesDir.resolve(DIR_NAME).run { - mkdirs() - resolve(fileName).apply { createNewFile() } + private val sharedPreferences = FcitxApplication.getInstance().directBootAwareContext + .getSharedPreferences(PREFERENCE_NAME, Context.MODE_PRIVATE) + + private val map = LinkedHashMap(limit).apply { + (migrate() ?: load()).forEach { put(it, true) } } - fun load() { - val xs = file.readLines() - xs.forEach { - if (it.isNotBlank()) - put(it, it) + 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() } } - fun save() { - file.writeText(values.joinToString("\n")) + private fun save() { + sharedPreferences.edit { + putString(type, Json.encodeToString>(map.keys.toList())) + } } - override fun removeEldestEntry(eldest: MutableMap.MutableEntry?) = - size > capacity - - 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 new file mode 100644 index 000000000..39a31ab64 --- /dev/null +++ b/app/src/main/java/org/fcitx/fcitx5/android/data/UserDataManager.kt @@ -0,0 +1,111 @@ +/* + * SPDX-License-Identifier: LGPL-2.1-or-later + * SPDX-FileCopyrightText: Copyright 2021-2025 Fcitx5 for Android Contributors + */ +package org.fcitx.fcitx5.android.data + +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.encodeToStream +import org.fcitx.fcitx5.android.BuildConfig +import org.fcitx.fcitx5.android.R +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 +import java.io.InputStream +import java.io.OutputStream +import java.util.zip.ZipEntry +import java.util.zip.ZipInputStream +import java.util.zip.ZipOutputStream + +object UserDataManager { + + private val json = Json { prettyPrint = true } + + @Serializable + data class Metadata( + val packageName: String, + val versionCode: Long, + val versionName: String, + val exportTime: Long + ) + + private fun writeFileTree(srcDir: File, destPrefix: String, dest: ZipOutputStream) { + dest.putNextEntry(ZipEntry("$destPrefix/")) + srcDir.walkTopDown().forEach { f -> + val related = f.relativeTo(srcDir) + if (related.path != "") { + if (f.isDirectory) { + dest.putNextEntry(ZipEntry("$destPrefix/${related.path}/")) + } else if (f.isFile) { + dest.putNextEntry(ZipEntry("$destPrefix/${related.path}")) + f.inputStream().use { it.copyTo(dest) } + } + } + } + } + + private val sharedPrefsDir = File(appContext.applicationInfo.dataDir, "shared_prefs") + private val dataBasesDir = File(appContext.applicationInfo.dataDir, "databases") + private val externalDir = appContext.getExternalFilesDir(null)!! + private val recentlyUsedDir = appContext.filesDir.resolve(RecentlyUsed.DIR_NAME) + + @OptIn(ExperimentalSerializationApi::class) + fun export(dest: OutputStream, timestamp: Long = System.currentTimeMillis()) = runCatching { + ZipOutputStream(dest.buffered()).use { zipStream -> + // shared_prefs + writeFileTree(sharedPrefsDir, "shared_prefs", zipStream) + // databases + writeFileTree(dataBasesDir, "databases", zipStream) + // external + writeFileTree(externalDir, "external", 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( + pkgInfo.packageName, + pkgInfo.versionCodeCompat, + Const.versionName, + timestamp + ) + json.encodeToStream(metadata, zipStream) + zipStream.closeEntry() + } + } + + private fun copyDir(source: File, target: File) { + val exists = source.exists() + val isDir = source.isDirectory + if (exists && isDir) { + source.copyRecursively(target, overwrite = true) + } else { + Timber.w("Cannot import user data: path='${source.path}', exists=$exists, isDir=$isDir") + } + } + + fun import(src: InputStream) = runCatching { + ZipInputStream(src).use { zipStream -> + withTempDir { tempDir -> + val extracted = zipStream.extract(tempDir) + val metadataFile = extracted.find { it.name == "metadata.json" } + ?: errorRuntime(R.string.exception_user_data_metadata) + val metadata = json.decodeFromString(metadataFile.readText()) + if (metadata.packageName != BuildConfig.APPLICATION_ID) + errorRuntime(R.string.exception_user_data_package_name_mismatch) + 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 + } + } + } +} \ No newline at end of file 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 4a3d15f40..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 @@ -1,9 +1,15 @@ +/* + * SPDX-License-Identifier: LGPL-2.1-or-later + * SPDX-FileCopyrightText: Copyright 2021-2023 Fcitx5 for Android Contributors + */ 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 @@ -19,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) { @@ -42,6 +49,8 @@ object ClipboardManager : ClipboardManager.OnPrimaryClipChangedListener, private val onUpdateListeners = WeakHashSet() + var transformer: ((String) -> String)? = null + fun addOnUpdateListener(listener: OnClipboardUpdateListener) { onUpdateListeners.add(listener) } @@ -78,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()) @@ -136,26 +147,50 @@ object ClipboardManager : ClipboardManager.OnPrimaryClipChangedListener, } } + private var lastClipTimestamp = -1L + private var lastClipHash = 0 + override fun onPrimaryClipChanged() { - clipboardManager.primaryClip - ?.let { ClipboardEntry.fromClipData(it) } - ?.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 b7b1b0c90..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 @@ -1,3 +1,7 @@ +/* + * SPDX-License-Identifier: LGPL-2.1-or-later + * SPDX-FileCopyrightText: Copyright 2021-2023 Fcitx5 for Android Contributors + */ package org.fcitx.fcitx5.android.data.clipboard.db import androidx.paging.PagingSource @@ -37,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 85d7f9d65..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 @@ -1,3 +1,7 @@ +/* + * SPDX-License-Identifier: LGPL-2.1-or-later + * SPDX-FileCopyrightText: Copyright 2021-2023 Fcitx5 for Android Contributors + */ package org.fcitx.fcitx5.android.data.clipboard.db import androidx.room.AutoMigration @@ -6,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 0a6f62671..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 @@ -1,10 +1,16 @@ +/* + * SPDX-License-Identifier: LGPL-2.1-or-later + * SPDX-FileCopyrightText: Copyright 2021-2023 Fcitx5 for Android Contributors + */ 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( @@ -18,13 +24,39 @@ 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" - fun fromClipData(clipData: ClipData): ClipboardEntry? { - val str = clipData.getItemAt(0).text?.toString() ?: return null - return ClipboardEntry(text = str, type = clipData.description.getMimeType(0)) + 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 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 = if (transformer != null) transformer(str) else str, + timestamp = clipData.timestamp(), + type = desc.getMimeType(0), + sensitive = sensitive + ) } } } \ No newline at end of file diff --git a/app/src/main/java/org/fcitx/fcitx5/android/data/pinyin/CustomPhraseManager.kt b/app/src/main/java/org/fcitx/fcitx5/android/data/pinyin/CustomPhraseManager.kt new file mode 100644 index 000000000..a0aedaf0f --- /dev/null +++ b/app/src/main/java/org/fcitx/fcitx5/android/data/pinyin/CustomPhraseManager.kt @@ -0,0 +1,15 @@ +/* + * SPDX-License-Identifier: LGPL-2.1-or-later + * SPDX-FileCopyrightText: Copyright 2021-2023 Fcitx5 for Android Contributors + */ +package org.fcitx.fcitx5.android.data.pinyin + +import org.fcitx.fcitx5.android.data.pinyin.customphrase.PinyinCustomPhrase + +object CustomPhraseManager { + @JvmStatic + external fun load(): Array? + + @JvmStatic + external fun save(items: Array) +} diff --git a/app/src/main/java/org/fcitx/fcitx5/android/data/pinyin/PinyinDictManager.kt b/app/src/main/java/org/fcitx/fcitx5/android/data/pinyin/PinyinDictManager.kt index 18e56243b..00211e84f 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/data/pinyin/PinyinDictManager.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/data/pinyin/PinyinDictManager.kt @@ -1,8 +1,14 @@ +/* + * SPDX-License-Identifier: LGPL-2.1-or-later + * SPDX-FileCopyrightText: Copyright 2021-2023 Fcitx5 for Android Contributors + */ package org.fcitx.fcitx5.android.data.pinyin import org.fcitx.fcitx5.android.R -import org.fcitx.fcitx5.android.data.pinyin.dict.Dictionary +import org.fcitx.fcitx5.android.core.data.DataManager +import org.fcitx.fcitx5.android.data.pinyin.dict.BuiltinDictionary import org.fcitx.fcitx5.android.data.pinyin.dict.LibIMEDictionary +import org.fcitx.fcitx5.android.data.pinyin.dict.PinyinDictionary import org.fcitx.fcitx5.android.utils.appContext import org.fcitx.fcitx5.android.utils.errorArg import timber.log.Timber @@ -16,26 +22,33 @@ object PinyinDictManager { appContext.getExternalFilesDir(null)!!, "data/pinyin/dictionaries" ).also { it.mkdirs() } + private val builtinPinyinDictDir = File( + DataManager.dataDir, "usr/share/fcitx5/pinyin/dictionaries" + ) + private val nativeDir = File(appContext.applicationInfo.nativeLibraryDir) private val scel2org5 by lazy { File(nativeDir, scel2org5Name) } - fun dictionaries(): List = pinyinDicDir - .listFiles() - ?.mapNotNull { Dictionary.new(it) } - ?.toList() ?: listOf() - - fun libIMEDictionaries(): List = - dictionaries().mapNotNull { it as? LibIMEDictionary } + fun listDictionaries(): List = + (builtinPinyinDictDir.listFiles()?.mapNotNull { + it.takeIf { it.extension == PinyinDictionary.Type.LibIME.ext } + ?.let(::BuiltinDictionary) + } ?: listOf()) + + (pinyinDicDir + .listFiles() + ?.mapNotNull { PinyinDictionary.new(it)?.takeIf { it is LibIMEDictionary } } + ?.toList() ?: listOf()) fun importFromFile(file: File): Result = runCatching { - val raw = Dictionary.new(file) ?: errorArg(R.string.exception_dict_filename, file.path) + val raw = + PinyinDictionary.new(file) ?: errorArg(R.string.exception_dict_filename, file.path) // convert to libime format in dictionaries dir // preserve original file name val new = raw.toLibIMEDictionary( File( pinyinDicDir, - file.nameWithoutExtension + ".${Dictionary.Type.LibIME.ext}" + file.nameWithoutExtension + ".${PinyinDictionary.Type.LibIME.ext}" ) ) Timber.d("Converted $raw to $new") 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 new file mode 100644 index 000000000..96dd68982 --- /dev/null +++ b/app/src/main/java/org/fcitx/fcitx5/android/data/pinyin/customphrase/PinyinCustomPhrase.kt @@ -0,0 +1,22 @@ +/* + * SPDX-License-Identifier: LGPL-2.1-or-later + * SPDX-FileCopyrightText: Copyright 2021-2023 Fcitx5 for Android Contributors + */ +package org.fcitx.fcitx5.android.data.pinyin.customphrase + +import org.fcitx.fcitx5.android.core.FcitxUtils +import kotlin.math.absoluteValue + +data class PinyinCustomPhrase( + val key: String, + val order: Int, + val value: String +) { + val enabled: Boolean get() = order > 0 + + fun copyEnabled(e: Boolean): PinyinCustomPhrase { + return copy(order = (if (e) 1 else -1) * order.absoluteValue) + } + + fun serialize() = "$key,${order.absoluteValue}=${FcitxUtils.escapeForValue(value)}" +} diff --git a/app/src/main/java/org/fcitx/fcitx5/android/data/pinyin/dict/BuiltinDictionary.kt b/app/src/main/java/org/fcitx/fcitx5/android/data/pinyin/dict/BuiltinDictionary.kt new file mode 100644 index 000000000..ecb710e38 --- /dev/null +++ b/app/src/main/java/org/fcitx/fcitx5/android/data/pinyin/dict/BuiltinDictionary.kt @@ -0,0 +1,19 @@ +/* + * SPDX-License-Identifier: LGPL-2.1-or-later + * SPDX-FileCopyrightText: Copyright 2021-2023 Fcitx5 for Android Contributors + */ +package org.fcitx.fcitx5.android.data.pinyin.dict + +import java.io.File + +class BuiltinDictionary(override val file: File) : PinyinDictionary() { + override val type: Type + get() = Type.LibIME + + private val delegate by lazy { LibIMEDictionary(file) } + + override fun toTextDictionary(dest: File): TextDictionary = delegate.toTextDictionary(dest) + + override fun toLibIMEDictionary(dest: File): LibIMEDictionary = delegate + +} \ No newline at end of file diff --git a/app/src/main/java/org/fcitx/fcitx5/android/data/pinyin/dict/LibIMEDictionary.kt b/app/src/main/java/org/fcitx/fcitx5/android/data/pinyin/dict/LibIMEDictionary.kt index 4d5590944..24d309012 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/data/pinyin/dict/LibIMEDictionary.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/data/pinyin/dict/LibIMEDictionary.kt @@ -1,3 +1,7 @@ +/* + * SPDX-License-Identifier: LGPL-2.1-or-later + * SPDX-FileCopyrightText: Copyright 2021-2023 Fcitx5 for Android Contributors + */ package org.fcitx.fcitx5.android.data.pinyin.dict import org.fcitx.fcitx5.android.R @@ -5,7 +9,7 @@ import org.fcitx.fcitx5.android.data.pinyin.PinyinDictManager import org.fcitx.fcitx5.android.utils.errorArg import java.io.File -class LibIMEDictionary(file: File) : Dictionary() { +class LibIMEDictionary(file: File) : PinyinDictionary() { override var file: File = file private set diff --git a/app/src/main/java/org/fcitx/fcitx5/android/data/pinyin/dict/Dictionary.kt b/app/src/main/java/org/fcitx/fcitx5/android/data/pinyin/dict/PinyinDictionary.kt similarity index 88% rename from app/src/main/java/org/fcitx/fcitx5/android/data/pinyin/dict/Dictionary.kt rename to app/src/main/java/org/fcitx/fcitx5/android/data/pinyin/dict/PinyinDictionary.kt index ee4a7b303..3900a41e2 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/data/pinyin/dict/Dictionary.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/data/pinyin/dict/PinyinDictionary.kt @@ -1,8 +1,12 @@ +/* + * SPDX-License-Identifier: LGPL-2.1-or-later + * SPDX-FileCopyrightText: Copyright 2021-2023 Fcitx5 for Android Contributors + */ package org.fcitx.fcitx5.android.data.pinyin.dict import java.io.File -abstract class Dictionary { +abstract class PinyinDictionary { enum class Type(val ext: String) { LibIME("dict"), Sougou("scel"), Text("txt"); @@ -60,7 +64,7 @@ abstract class Dictionary { override fun toString(): String = "${javaClass.simpleName}[$name -> ${file.path}]" companion object { - fun new(it: File): Dictionary? = when (Type.fromFileName(it.name)) { + fun new(it: File): PinyinDictionary? = when (Type.fromFileName(it.name)) { Type.LibIME -> LibIMEDictionary(it) Type.Sougou -> SougouDictionary(it) Type.Text -> TextDictionary(it) diff --git a/app/src/main/java/org/fcitx/fcitx5/android/data/pinyin/dict/SougouDictionary.kt b/app/src/main/java/org/fcitx/fcitx5/android/data/pinyin/dict/SougouDictionary.kt index 19e6a0bfa..4e945be7e 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/data/pinyin/dict/SougouDictionary.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/data/pinyin/dict/SougouDictionary.kt @@ -1,3 +1,7 @@ +/* + * SPDX-License-Identifier: LGPL-2.1-or-later + * SPDX-FileCopyrightText: Copyright 2021-2023 Fcitx5 for Android Contributors + */ package org.fcitx.fcitx5.android.data.pinyin.dict import org.fcitx.fcitx5.android.R @@ -5,7 +9,7 @@ import org.fcitx.fcitx5.android.data.pinyin.PinyinDictManager import org.fcitx.fcitx5.android.utils.errorArg import java.io.File -class SougouDictionary(file: File) : Dictionary() { +class SougouDictionary(file: File) : PinyinDictionary() { override var file: File = file private set diff --git a/app/src/main/java/org/fcitx/fcitx5/android/data/pinyin/dict/TextDictionary.kt b/app/src/main/java/org/fcitx/fcitx5/android/data/pinyin/dict/TextDictionary.kt index 57d694304..f2d41d207 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/data/pinyin/dict/TextDictionary.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/data/pinyin/dict/TextDictionary.kt @@ -1,3 +1,7 @@ +/* + * SPDX-License-Identifier: LGPL-2.1-or-later + * SPDX-FileCopyrightText: Copyright 2021-2023 Fcitx5 for Android Contributors + */ package org.fcitx.fcitx5.android.data.pinyin.dict import org.fcitx.fcitx5.android.R @@ -5,7 +9,7 @@ import org.fcitx.fcitx5.android.data.pinyin.PinyinDictManager import org.fcitx.fcitx5.android.utils.errorArg import java.io.File -class TextDictionary(file: File) : Dictionary() { +class TextDictionary(file: File) : PinyinDictionary() { override var file: File = file private set 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 ce209f2d9..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,15 +1,28 @@ +/* + * SPDX-License-Identifier: LGPL-2.1-or-later + * 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.input.candidates.HorizontalCandidateMode +import org.fcitx.fcitx5.android.data.InputFeedbacks.InputFeedbackMode 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.getSystemProperty import org.fcitx.fcitx5.android.utils.vibrator class AppPrefs(private val sharedPreferences: SharedPreferences) { @@ -21,22 +34,37 @@ class AppPrefs(private val sharedPreferences: SharedPreferences) { val verboseLog = bool("verbose_log", false) val pid = int("pid", 0) val editorInfoInspector = bool("editor_info_inspector", false) + val needNotifications = bool("need_notifications", true) } 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( R.string.vivo_keypress_workaround, "vivo_keypress_workaround", - getSystemProperty("ro.vivo.os.version").isNotEmpty() + DeviceUtil.isVivoOriginOS + ) + val ignoreSystemWindowInsets = switch( + R.string.ignore_system_window_insets, "ignore_system_window_insets", false ) } - inner class Keyboard : ManagedPreferenceCategory(R.string.keyboard, sharedPreferences) { - val buttonHapticFeedback = - switch(R.string.button_haptic_feedback, "button_haptic_feedback", true) + inner class Keyboard : ManagedPreferenceCategory(R.string.virtual_keyboard, sharedPreferences) { + val hapticOnKeyPress = + enumList( + R.string.button_haptic_feedback, + "haptic_on_keypress", + 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 @@ -53,7 +81,7 @@ class AppPrefs(private val sharedPreferences: SharedPreferences) { 100, "ms", defaultLabel = R.string.system_default - ) { buttonHapticFeedback.getValue() } + ) { hapticOnKeyPress.getValue() != InputFeedbackMode.Disabled } buttonPressVibrationMilliseconds = primary buttonLongPressVibrationMilliseconds = secondary } @@ -74,16 +102,33 @@ class AppPrefs(private val sharedPreferences: SharedPreferences) { 255, defaultLabel = R.string.system_default ) { - buttonHapticFeedback.getValue() - && Build.VERSION.SDK_INT >= Build.VERSION_CODES.O - && appContext.vibrator.hasAmplitudeControl() + (hapticOnKeyPress.getValue() != InputFeedbackMode.Disabled) + // hide this if using default duration + && (buttonPressVibrationMilliseconds.getValue() != 0 || buttonLongPressVibrationMilliseconds.getValue() != 0) + && (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && appContext.vibrator.hasAmplitudeControl()) } buttonPressVibrationAmplitude = primary buttonLongPressVibrationAmplitude = secondary } - val systemTouchSounds = - switch(R.string.system_touch_sounds, "system_touch_sounds", true) + val soundOnKeyPress = enumList( + R.string.button_sound, + "sound_on_keypress", + InputFeedbackMode.FollowingSystem + ) + val soundOnKeyPressVolume = int( + R.string.button_sound_volume, + "button_sound_volume", + 0, + 0, + 100, + "%", + defaultLabel = R.string.system_default + ) { + soundOnKeyPress.getValue() != InputFeedbackMode.Disabled + } + val focusChangeResetKeyboard = + switch(R.string.reset_keyboard_on_focus_change, "reset_keyboard_on_focus_change", true) val expandToolbarByDefault = switch(R.string.expand_toolbar_by_default, "expand_toolbar_by_default", false) val inlineSuggestions = switch(R.string.inline_suggestions, "inline_suggestions", true) @@ -97,21 +142,12 @@ class AppPrefs(private val sharedPreferences: SharedPreferences) { ) val showVoiceInputButton = switch(R.string.show_voice_input_button, "show_voice_input_button", false) - val swipeSymbolDirection = list( + val expandKeypressArea = + switch(R.string.expand_keypress_area, "expand_keypress_area", false) + 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, @@ -122,26 +158,20 @@ 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 = enumList( + R.string.lang_switch_key_behavior, + "lang_switch_key_behavior", + LangSwitchBehavior.Enumerate + ) { showLangSwitchKey.getValue() } val keyboardHeightPercent: ManagedPreference.PInt val keyboardHeightPercentLandscape: ManagedPreference.PInt @@ -176,7 +206,7 @@ class AppPrefs(private val sharedPreferences: SharedPreferences) { "keyboard_side_padding_landscape", 0, 0, - 200, + 300, "dp" ) keyboardSidePadding = primary @@ -203,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 @@ -255,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( @@ -273,6 +337,20 @@ class AppPrefs(private val sharedPreferences: SharedPreferences) { Int.MAX_VALUE, "s" ) { clipboardListening.getValue() && clipboardSuggestion.getValue() } + 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() @@ -291,15 +369,45 @@ 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) + } + } + + @RequiresApi(Build.VERSION_CODES.N) + fun syncToDeviceEncryptedStorage() { + val ctx = appContext.createDeviceProtectedStorageContext() + val sp = PreferenceManager.getDefaultSharedPreferences(ctx) + sp.edit { + listOf( + internal.verboseLog, + internal.editorInfoInspector, + advanced.ignoreSystemCursor, + advanced.disableAnimation, + advanced.vivoKeypressWorkaround + ).forEach { + it.putValueTo(this@edit) + } + listOf( + keyboard, + candidates, + clipboard + ).forEach { category -> + category.managedPreferences.forEach { + it.value.putValueTo(this@edit) + } } } + } companion object { private var instance: AppPrefs? = null 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 729920b93..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 @@ -1,9 +1,12 @@ +/* + * SPDX-License-Identifier: LGPL-2.1-or-later + * SPDX-FileCopyrightText: Copyright 2021-2023 Fcitx5 for Android Contributors + */ package org.fcitx.fcitx5.android.data.prefs import android.content.SharedPreferences import androidx.core.content.edit import org.fcitx.fcitx5.android.utils.WeakHashSet -import timber.log.Timber import kotlin.properties.ReadWriteProperty import kotlin.reflect.KProperty @@ -26,13 +29,13 @@ abstract class ManagedPreference( abstract fun getValue(): T + abstract fun putValueTo(editor: SharedPreferences.Editor) + override fun getValue(thisRef: Any?, property: KProperty<*>): T = getValue() 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! @@ -42,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) } } @@ -62,7 +69,18 @@ abstract class ManagedPreference( sharedPreferences.edit { putBoolean(key, value) } } - override fun getValue(): Boolean = sharedPreferences.getBoolean(key, defaultValue) + override fun getValue(): Boolean { + return try { + sharedPreferences.getBoolean(key, defaultValue) + } catch (e: Exception) { + setValue(defaultValue) + defaultValue + } + } + + override fun putValueTo(editor: SharedPreferences.Editor) { + editor.putBoolean(key, getValue()) + } } class PString(sharedPreferences: SharedPreferences, key: String, defaultValue: String) : @@ -72,7 +90,18 @@ abstract class ManagedPreference( sharedPreferences.edit { putString(key, value) } } - override fun getValue(): String = sharedPreferences.getString(key, defaultValue)!! + override fun getValue(): String { + return try { + sharedPreferences.getString(key, defaultValue)!! + } catch (e: Exception) { + setValue(defaultValue) + defaultValue + } + } + + override fun putValueTo(editor: SharedPreferences.Editor) { + editor.putString(key, getValue()) + } } class PStringLike( @@ -86,12 +115,20 @@ abstract class ManagedPreference( sharedPreferences.edit { putString(key, codec.encode(value)) } } - override fun getValue(): T = - sharedPreferences.getString(key, null).let { raw -> - raw?.runCatching { codec.decode(this) } - ?.onFailure { Timber.w("Failed to decode value '$raw' of preference $key") } - ?.getOrNull() ?: defaultValue + override fun getValue(): T { + return try { + sharedPreferences.getString(key, null)?.let { + codec.decode(it) + } ?: defaultValue + } catch (e: Exception) { + setValue(defaultValue) + defaultValue } + } + + override fun putValueTo(editor: SharedPreferences.Editor) { + editor.putString(key, codec.encode(getValue())) + } } @@ -102,7 +139,18 @@ abstract class ManagedPreference( sharedPreferences.edit { putInt(key, value) } } - override fun getValue(): Int = sharedPreferences.getInt(key, defaultValue) + override fun getValue(): Int { + return try { + sharedPreferences.getInt(key, defaultValue) + } catch (e: Exception) { + setValue(defaultValue) + defaultValue + } + } + + override fun putValueTo(editor: SharedPreferences.Editor) { + editor.putInt(key, getValue()) + } } class PFloat(sharedPreferences: SharedPreferences, key: String, defaultValue: Float) : @@ -111,7 +159,18 @@ abstract class ManagedPreference( sharedPreferences.edit { putFloat(key, value) } } - override fun getValue(): Float = sharedPreferences.getFloat(key, defaultValue) + override fun getValue(): Float { + return try { + sharedPreferences.getFloat(key, defaultValue) + } catch (e: Exception) { + setValue(defaultValue) + defaultValue + } + } + + override fun putValueTo(editor: SharedPreferences.Editor) { + editor.putFloat(key, getValue()) + } } } 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 257020604..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 @@ -1,3 +1,7 @@ +/* + * SPDX-License-Identifier: LGPL-2.1-or-later + * SPDX-FileCopyrightText: Copyright 2021-2023 Fcitx5 for Android Contributors + */ package org.fcitx.fcitx5.android.data.prefs import android.content.SharedPreferences @@ -45,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/ManagedPreferenceFragment.kt b/app/src/main/java/org/fcitx/fcitx5/android/data/prefs/ManagedPreferenceFragment.kt index 957558e72..7d1de009f 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/data/prefs/ManagedPreferenceFragment.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/data/prefs/ManagedPreferenceFragment.kt @@ -1,9 +1,15 @@ +/* + * SPDX-License-Identifier: LGPL-2.1-or-later + * SPDX-FileCopyrightText: Copyright 2021-2023 Fcitx5 for Android Contributors + */ package org.fcitx.fcitx5.android.data.prefs +import android.os.Build import android.os.Bundle import androidx.annotation.CallSuper import androidx.lifecycle.lifecycleScope import androidx.preference.Preference +import androidx.preference.PreferenceScreen import kotlinx.coroutines.launch import org.fcitx.fcitx5.android.ui.common.PaddingPreferenceFragment @@ -18,17 +24,27 @@ abstract class ManagedPreferenceFragment(private val preferenceProvider: Managed } } + open fun onPreferenceUiCreated(screen: PreferenceScreen) {} + @CallSuper override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { evaluator.evaluateVisibility() preferenceScreen = preferenceManager.createPreferenceScreen(preferenceManager.context).also { screen -> preferenceProvider.createUi(screen) + onPreferenceUiCreated(screen) } } + override fun onStop() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + AppPrefs.getInstance().syncToDeviceEncryptedStorage() + } + super.onStop() + } + override fun onDestroy() { - super.onDestroy() evaluator.destroy() + super.onDestroy() } } \ No newline at end of file diff --git a/app/src/main/java/org/fcitx/fcitx5/android/data/prefs/ManagedPreferenceInternal.kt b/app/src/main/java/org/fcitx/fcitx5/android/data/prefs/ManagedPreferenceInternal.kt index fcbe1a771..422368c3f 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/data/prefs/ManagedPreferenceInternal.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/data/prefs/ManagedPreferenceInternal.kt @@ -1,3 +1,7 @@ +/* + * SPDX-License-Identifier: LGPL-2.1-or-later + * SPDX-FileCopyrightText: Copyright 2021-2023 Fcitx5 for Android Contributors + */ package org.fcitx.fcitx5.android.data.prefs import android.content.SharedPreferences 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 2b7ed88a5..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,9 +1,19 @@ +/* + * SPDX-License-Identifier: LGPL-2.1-or-later + * 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() @@ -18,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 57e94fb85..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 @@ -1,3 +1,7 @@ +/* + * SPDX-License-Identifier: LGPL-2.1-or-later + * SPDX-FileCopyrightText: Copyright 2021-2023 Fcitx5 for Android Contributors + */ package org.fcitx.fcitx5.android.data.prefs import android.content.Context @@ -5,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 71ac316d8..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 @@ -1,3 +1,7 @@ +/* + * SPDX-License-Identifier: LGPL-2.1-or-later + * SPDX-FileCopyrightText: Copyright 2021-2023 Fcitx5 for Android Contributors + */ package org.fcitx.fcitx5.android.data.prefs import androidx.annotation.Keep @@ -11,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() { @@ -36,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/punctuation/PunctuationManager.kt b/app/src/main/java/org/fcitx/fcitx5/android/data/punctuation/PunctuationManager.kt index ab73787e8..d4e2c56d5 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/data/punctuation/PunctuationManager.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/data/punctuation/PunctuationManager.kt @@ -1,3 +1,7 @@ +/* + * SPDX-License-Identifier: LGPL-2.1-or-later + * SPDX-FileCopyrightText: Copyright 2021-2023 Fcitx5 for Android Contributors + */ package org.fcitx.fcitx5.android.data.punctuation import org.fcitx.fcitx5.android.core.FcitxAPI @@ -8,9 +12,8 @@ import org.fcitx.fcitx5.android.core.savePunctuationConfig object PunctuationManager { fun parseRawConfig(raw: RawConfig): List { - return raw.findByName("cfg") - ?.run { get(ENTRIES).subItems?.map { PunctuationMapEntry(it) } } - ?: listOf() + val items = raw.findByName("cfg")?.get(ENTRIES)?.subItems ?: return emptyList() + return items.map { PunctuationMapEntry(it) } } suspend fun load(fcitx: FcitxAPI, lang: String): List { diff --git a/app/src/main/java/org/fcitx/fcitx5/android/data/punctuation/PunctuationMapEntry.kt b/app/src/main/java/org/fcitx/fcitx5/android/data/punctuation/PunctuationMapEntry.kt index abf325944..e6205feb0 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/data/punctuation/PunctuationMapEntry.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/data/punctuation/PunctuationMapEntry.kt @@ -1,3 +1,7 @@ +/* + * SPDX-License-Identifier: LGPL-2.1-or-later + * SPDX-FileCopyrightText: Copyright 2021-2023 Fcitx5 for Android Contributors + */ package org.fcitx.fcitx5.android.data.punctuation import org.fcitx.fcitx5.android.core.RawConfig 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 4a8d6c057..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 @@ -1,3 +1,7 @@ +/* + * SPDX-License-Identifier: LGPL-2.1-or-later + * SPDX-FileCopyrightText: Copyright 2021-2023 Fcitx5 for Android Contributors + */ package org.fcitx.fcitx5.android.data.quickphrase import java.io.File @@ -10,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 @@ -31,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() @@ -67,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 61a2b27a2..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 @@ -1,3 +1,7 @@ +/* + * SPDX-License-Identifier: LGPL-2.1-or-later + * SPDX-FileCopyrightText: Copyright 2021-2023 Fcitx5 for Android Contributors + */ package org.fcitx.fcitx5.android.data.quickphrase import org.fcitx.fcitx5.android.R @@ -16,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 baad249ef..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 @@ -1,9 +1,28 @@ +/* + * SPDX-License-Identifier: LGPL-2.1-or-later + * SPDX-FileCopyrightText: Copyright 2021-2023 Fcitx5 for Android Contributors + */ 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 @@ -17,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 108462a0c..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 @@ -1,26 +1,16 @@ +/* + * SPDX-License-Identifier: LGPL-2.1-or-later + * SPDX-FileCopyrightText: Copyright 2021-2023 Fcitx5 for Android Contributors + */ 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 { +class QuickPhraseData(private val data: List) : List by data { fun serialize(): String = joinToString("\n") { it.serialize() } companion object { - fun fromLines(lines: List): Result = - runCatching { - lines - .mapNotNull { - it.trim().takeIf(String::isNotEmpty)?.let { l -> - val key = l.substringBefore(' ') - val value = l.substringAfter(' ') - if (key.isEmpty() || value.isEmpty()) - errorRuntime(R.string.exception_quickphrase_parse, it) - QuickPhraseEntry(key, value) - } - } - }.map { QuickPhraseData(it) } + 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 0034ba30a..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 @@ -1,5 +1,34 @@ +/* + * SPDX-License-Identifier: LGPL-2.1-or-later + * SPDX-FileCopyrightText: Copyright 2021-2023 Fcitx5 for Android Contributors + */ 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" + + 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 b7747b9b5..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 @@ -1,9 +1,14 @@ +/* + * SPDX-License-Identifier: LGPL-2.1-or-later + * SPDX-FileCopyrightText: Copyright 2021-2023 Fcitx5 for Android Contributors + */ 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.errorArg +import org.fcitx.fcitx5.android.utils.errorRuntime +import org.fcitx.fcitx5.android.utils.withTempDir import java.io.File import java.io.InputStream @@ -33,28 +38,30 @@ object QuickPhraseManager { return CustomQuickPhrase(file) } - fun importFromFile(file: File): Result { - if (file.extension != QuickPhrase.EXT) - errorArg(R.string.exception_quickphrase_filename, file.path) - // throw away data, only ensuring the format is correct - return QuickPhraseData.fromLines(file.readLines()).map { + private fun importFromFile(file: File): Result { + 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) } } - fun importFromInputStream(stream: InputStream, name: String): Result { - val tempFile = File(appContext.cacheDir, name) - tempFile.outputStream().use { - stream.copyTo(it) + fun importFromInputStream(stream: InputStream, fileName: String): Result { + return stream.use { i -> + withTempDir { dir -> + val tempFile = dir.resolve(fileName) + tempFile.outputStream().use { o -> i.copyTo(o) } + importFromFile(tempFile) + } } - val new = importFromFile(tempFile) - tempFile.delete() - return new } - private fun listDir( dir: File, block: (File) -> T? 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/table/TableBasedInputMethod.kt b/app/src/main/java/org/fcitx/fcitx5/android/data/table/TableBasedInputMethod.kt index 83f6ba73d..8a57b145a 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/data/table/TableBasedInputMethod.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/data/table/TableBasedInputMethod.kt @@ -1,47 +1,48 @@ +/* + * SPDX-License-Identifier: LGPL-2.1-or-later + * SPDX-FileCopyrightText: Copyright 2021-2023 Fcitx5 for Android Contributors + */ package org.fcitx.fcitx5.android.data.table -import cc.ekblad.konbini.ParserResult import org.fcitx.fcitx5.android.R +import org.fcitx.fcitx5.android.core.RawConfig import org.fcitx.fcitx5.android.data.table.dict.LibIMEDictionary -import org.fcitx.fcitx5.android.utils.IniParser -import org.fcitx.fcitx5.android.utils.IniPrettyPrinter +import org.fcitx.fcitx5.android.utils.Ini import org.fcitx.fcitx5.android.utils.Locales import org.fcitx.fcitx5.android.utils.errorRuntime -import org.fcitx.fcitx5.android.utils.getValue import timber.log.Timber import java.io.File class TableBasedInputMethod(val file: File) { - private var ini = when (val x = IniParser.parse(file.readText())) { - is ParserResult.Error -> errorRuntime(R.string.invalid_im, file.name) - is ParserResult.Ok -> x.result - } + private var ini = Ini.parseIniFromFile(file) ?: errorRuntime(R.string.invalid_im, file.name) var table: LibIMEDictionary? = null val name: String by lazy { - ini.sections[InputMethod]?.let { im -> - val properties = im.data - properties.getValue(NameI18n.format(Locales.languageWithCountry)) - ?: properties.getValue(NameI18n.format(Locales.language)) - ?: properties.getValue(Name) - } ?: errorRuntime(R.string.invalid_im, ERROR_MISSING_INPUT_METHOD_OR_NAME) + ini.get(InputMethod)?.let { + (it.get(NameI18n.format(Locales.languageWithCountry)) + ?: it.get(NameI18n.format(Locales.language)) + ?: it.get(Name))?.value + } ?: errorRuntime( + R.string.invalid_im, + ERROR_MISSING_INPUT_METHOD_OR_NAME + ) } var tableFileName: String - get() = ini.getValue(Table, File) + get() = ini.get(Table, File)?.value ?.substringAfterLast('/') ?: errorRuntime(R.string.invalid_im, ERROR_MISSING_TABLE_OR_FILE) set(value) { - ini.setValue(Table, File, "table/$value") + ini.set(Table, File, str = "table/$value") } val tableFileExists get() = table != null fun save() { - file.writeText(IniPrettyPrinter.pretty(ini)) + Ini.writeIniToFile(ini, file) } fun delete() { diff --git a/app/src/main/java/org/fcitx/fcitx5/android/data/table/TableManager.kt b/app/src/main/java/org/fcitx/fcitx5/android/data/table/TableManager.kt index 286794249..972421d78 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/data/table/TableManager.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/data/table/TableManager.kt @@ -1,3 +1,7 @@ +/* + * SPDX-License-Identifier: LGPL-2.1-or-later + * SPDX-FileCopyrightText: Copyright 2021-2023 Fcitx5 for Android Contributors + */ package org.fcitx.fcitx5.android.data.table import org.fcitx.fcitx5.android.R @@ -22,19 +26,15 @@ object TableManager { ).also { it.mkdirs() } fun inputMethods(): List = - inputMethodDir - .listFiles() - ?.mapNotNull { confFile -> - runCatching { - TableBasedInputMethod.new(confFile).apply { - table = runCatching { - File(tableDicDir, tableFileName) - .takeIf { it.extension == "dict" } - ?.let { LibIMEDictionary(it) } - }.getOrNull() + inputMethodDir.listFiles()?.mapNotNull { confFile -> + runCatching { + TableBasedInputMethod.new(confFile).apply { + runCatching { + table = LibIMEDictionary(File(tableDicDir, tableFileName)) } - }.getOrNull() - } ?: listOf() + } + }.getOrNull() + } ?: emptyList() fun importFromZip(src: InputStream): Result = runCatching { @@ -59,11 +59,11 @@ object TableManager { dictStream: InputStream ): Result = runCatching { withTempDir { tempDir -> - val confFile = File(tempDir, confName).apply { - outputStream().use { o -> confStream.use { i -> i.copyTo(o) } } + val confFile = File(tempDir, confName).also { + it.outputStream().use { o -> confStream.use { i -> i.copyTo(o) } } } - val dictFile = File(tempDir, dictName).apply { - outputStream().use { o -> dictStream.use { i -> i.copyTo(o) } } + val dictFile = File(tempDir, dictName).also { + it.outputStream().use { o -> dictStream.use { i -> i.copyTo(o) } } } importFiles(confFile, dictFile) } @@ -93,6 +93,27 @@ object TableManager { return im } + fun replaceTableDict( + im: TableBasedInputMethod, + dictName: String, + dictStream: InputStream + ): Result = runCatching { + withTempDir { tempDir -> + val dictFile = File(tempDir, dictName).also { + it.outputStream().use { o -> dictStream.use { i -> i.copyTo(o) } } + } + val dict = Dictionary.new(dictFile)!! + runCatching { + dict.toLibIMEDictionary(File(tempDir, im.tableFileName)) + }.onSuccess { + it.file.copyTo(File(tableDicDir, im.tableFileName), overwrite = true) + }.onFailure { + dictFile.delete() + errorRuntime(R.string.invalid_table_dict, it.message) + }.getOrThrow() + } + } + @JvmStatic external fun tableDictConv(src: String, dest: String, mode: Boolean) diff --git a/app/src/main/java/org/fcitx/fcitx5/android/data/table/dict/Dictionary.kt b/app/src/main/java/org/fcitx/fcitx5/android/data/table/dict/Dictionary.kt index acd93673a..0c66fd648 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/data/table/dict/Dictionary.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/data/table/dict/Dictionary.kt @@ -1,3 +1,7 @@ +/* + * SPDX-License-Identifier: LGPL-2.1-or-later + * SPDX-FileCopyrightText: Copyright 2021-2023 Fcitx5 for Android Contributors + */ package org.fcitx.fcitx5.android.data.table.dict import java.io.File diff --git a/app/src/main/java/org/fcitx/fcitx5/android/data/table/dict/LibIMEDictionary.kt b/app/src/main/java/org/fcitx/fcitx5/android/data/table/dict/LibIMEDictionary.kt index b9b9ed04c..38498b3b7 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/data/table/dict/LibIMEDictionary.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/data/table/dict/LibIMEDictionary.kt @@ -1,3 +1,7 @@ +/* + * SPDX-License-Identifier: LGPL-2.1-or-later + * SPDX-FileCopyrightText: Copyright 2021-2023 Fcitx5 for Android Contributors + */ package org.fcitx.fcitx5.android.data.table.dict import org.fcitx.fcitx5.android.R diff --git a/app/src/main/java/org/fcitx/fcitx5/android/data/table/dict/TextDictionary.kt b/app/src/main/java/org/fcitx/fcitx5/android/data/table/dict/TextDictionary.kt index f15758129..691143e94 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/data/table/dict/TextDictionary.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/data/table/dict/TextDictionary.kt @@ -1,3 +1,7 @@ +/* + * SPDX-License-Identifier: LGPL-2.1-or-later + * SPDX-FileCopyrightText: Copyright 2021-2023 Fcitx5 for Android Contributors + */ package org.fcitx.fcitx5.android.data.table.dict import org.fcitx.fcitx5.android.R 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 b8ffea6a9..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 @@ -1,10 +1,18 @@ +/* + * SPDX-License-Identifier: LGPL-2.1-or-later + * SPDX-FileCopyrightText: Copyright 2021-2023 Fcitx5 for Android Contributors + */ 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()) { @@ -31,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) { @@ -66,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/ManagedThemePreference.kt b/app/src/main/java/org/fcitx/fcitx5/android/data/theme/ManagedThemePreference.kt index a110be1c3..aa058c0fb 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/data/theme/ManagedThemePreference.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/data/theme/ManagedThemePreference.kt @@ -1,3 +1,7 @@ +/* + * SPDX-License-Identifier: LGPL-2.1-or-later + * SPDX-FileCopyrightText: Copyright 2021-2023 Fcitx5 for Android Contributors + */ package org.fcitx.fcitx5.android.data.theme import android.content.SharedPreferences @@ -21,4 +25,8 @@ class ManagedThemePreference( ThemeManager.getAllThemes().find { it.name == name } } ?: defaultValue + override fun putValueTo(editor: SharedPreferences.Editor) { + editor.putString(key, getValue().name) + } + } \ No newline at end of file diff --git a/app/src/main/java/org/fcitx/fcitx5/android/data/theme/ManagedThemePreferenceUi.kt b/app/src/main/java/org/fcitx/fcitx5/android/data/theme/ManagedThemePreferenceUi.kt index 3ba726fa4..2e73533f6 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/data/theme/ManagedThemePreferenceUi.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/data/theme/ManagedThemePreferenceUi.kt @@ -1,3 +1,7 @@ +/* + * SPDX-License-Identifier: LGPL-2.1-or-later + * SPDX-FileCopyrightText: Copyright 2021-2023 Fcitx5 for Android Contributors + */ package org.fcitx.fcitx5.android.data.theme import android.content.Context 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 60881f0ad..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 @@ -1,3 +1,7 @@ +/* + * SPDX-License-Identifier: LGPL-2.1-or-later + * SPDX-FileCopyrightText: Copyright 2021-2023 Fcitx5 for Android Contributors + */ package org.fcitx.fcitx5.android.data.theme import android.graphics.BitmapFactory @@ -8,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 @@ -26,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 @@ -63,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, @@ -84,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) } } } @@ -110,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, @@ -135,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, @@ -156,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(), @@ -180,6 +203,9 @@ sealed class Theme : Parcelable { keyboardColor, keyBackgroundColor, keyTextColor, + candidateTextColor, + candidateLabelColor, + candidateCommentColor, altKeyBackgroundColor, altKeyTextColor, accentKeyBackgroundColor, @@ -201,6 +227,7 @@ sealed class Theme : Parcelable { originBackgroundImage: String, brightness: Int = 70, cropBackgroundRect: Rect? = null, + cropBackgroundRotation: Int = 0 ) = Custom( name, isDark, @@ -208,13 +235,17 @@ sealed class Theme : Parcelable { croppedBackgroundImage, originBackgroundImage, brightness, - cropBackgroundRect + cropBackgroundRect, + cropBackgroundRotation ), backgroundColor, barColor, keyboardColor, keyBackgroundColor, keyTextColor, + candidateTextColor, + candidateLabelColor, + candidateCommentColor, altKeyBackgroundColor, altKeyTextColor, accentKeyBackgroundColor, @@ -231,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 5549ebe24..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 @@ -1,25 +1,21 @@ +/* + * SPDX-License-Identifier: LGPL-2.1-or-later + * SPDX-FileCopyrightText: Copyright 2021-2023 Fcitx5 for Android Contributors + */ 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.StringRes -import kotlinx.serialization.json.Json -import org.fcitx.fcitx5.android.R +import androidx.annotation.RequiresApi +import androidx.core.content.edit +import androidx.preference.PreferenceManager 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.utils.* -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 +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.isDarkMode object ThemeManager { @@ -27,354 +23,136 @@ 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) - } - } - } - - class Prefs(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 + var activeTheme: Theme + get() = _activeTheme + private set(value) { + if (_activeTheme == value) return + _activeTheme = value + fireChange() } - val keyBorder = switch(R.string.key_border, "key_border", false) + private var isDarkMode = false - val keyRippleEffect = switch(R.string.key_ripple_effect, "key_ripple_effect", false) + private val onChangeListeners = WeakHashSet() - val keyHorizontalMargin = - int(R.string.key_horizontal_margin, "key_horizontal_margin", 3, 0, 8, "dp") + fun addOnChangedListener(listener: OnThemeChangeListener) { + onChangeListeners.add(listener) + } - val keyVerticalMargin = - int(R.string.key_vertical_margin, "key_vertical_margin", 7, 0, 16, "dp") + fun removeOnChangedListener(listener: OnThemeChangeListener) { + onChangeListeners.remove(listener) + } - val keyRadius = int(R.string.key_radius, "key_radius", 4, 0, 48, "dp") + private fun fireChange() { + onChangeListeners.forEach { it.onThemeChange(_activeTheme) } + } - enum class PunctuationPosition { - Bottom, - TopRight; + val prefs = AppPrefs.getInstance().registerProvider(::ThemePrefs) - companion object : ManagedPreference.StringLikeCodec { - override fun decode(raw: String): PunctuationPosition = valueOf(raw) - } + 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 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) - } + if (activeTheme.name == theme.name) { + activeTheme = theme } - - 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 deleteTheme(name: String) { + customThemes.find { it.name == name }?.also { + ThemeFilesManager.deleteThemeFiles(it) + customThemes.remove(it) + } + if (activeTheme.name == name) { + activeTheme = evaluateActiveTheme() + } } - 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, - ) + 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) + } - @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) + prefs.registerOnChangeListener(onThemePrefsChange) + _activeTheme = evaluateActiveTheme() } - private lateinit var currentTheme: Theme - - private fun fireChange() { - onChangeListeners.forEach { it.onThemeChange(currentTheme) } + 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() } - 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()) + @RequiresApi(Build.VERSION_CODES.N) + fun syncToDeviceEncryptedStorage() { + val ctx = appContext.createDeviceProtectedStorageContext() + val sp = PreferenceManager.getDefaultSharedPreferences(ctx) + sp.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 abf6f1b56..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 @@ -1,3 +1,7 @@ +/* + * SPDX-License-Identifier: LGPL-2.1-or-later + * SPDX-FileCopyrightText: Copyright 2021-2023 Fcitx5 for Android Contributors + */ package org.fcitx.fcitx5.android.data.theme object ThemePreset { @@ -10,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, @@ -34,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, @@ -58,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, @@ -82,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, @@ -106,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, @@ -130,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, @@ -154,6 +176,9 @@ object ThemePreset { keyboardColor = 0xffECEFF4, keyBackgroundColor = 0xffECEFF4, keyTextColor = 0xff2E3440, + candidateTextColor = 0xff2E3440, + candidateLabelColor = 0xff2E3440, + candidateCommentColor = 0xff4C566A, altKeyBackgroundColor = 0xffE5E9F0, altKeyTextColor = 0xff434C5E, accentKeyBackgroundColor = 0xff5E81AC, @@ -177,6 +202,9 @@ object ThemePreset { keyboardColor = 0xff2E3440, keyBackgroundColor = 0xff4C566A, keyTextColor = 0xffECEFF4, + candidateTextColor = 0xffECEFF4, + candidateLabelColor = 0xffECEFF4, + candidateCommentColor = 0xffD8DEE9, altKeyBackgroundColor = 0xff3B4252, altKeyTextColor = 0xffD8DEE9, accentKeyBackgroundColor = 0xff88C0D0, @@ -200,6 +228,9 @@ object ThemePreset { keyboardColor = 0xff272822, keyBackgroundColor = 0xff33342c, keyTextColor = 0xffd6d6d6, + candidateTextColor = 0xffd6d6d6, + candidateLabelColor = 0xffd6d6d6, + candidateCommentColor = 0xff797979, altKeyBackgroundColor = 0xff2d2e27, altKeyTextColor = 0xff797979, accentKeyBackgroundColor = 0xffb05279, @@ -226,6 +257,9 @@ object ThemePreset { keyboardColor = 0x00000000, keyBackgroundColor = 0x4bffffff, keyTextColor = 0xffffffff, + candidateTextColor = 0xffffffff, + candidateLabelColor = 0xffffffff, + candidateCommentColor = 0xc9ffffff, altKeyBackgroundColor = 0x0cffffff, altKeyTextColor = 0xc9ffffff, accentKeyBackgroundColor = 0xff5e97f6, @@ -252,6 +286,9 @@ object ThemePreset { keyboardColor = 0x00000000, keyBackgroundColor = 0x4bffffff, keyTextColor = 0xff000000, + candidateTextColor = 0xff000000, + candidateLabelColor = 0xff000000, + candidateCommentColor = 0xb9000000, altKeyBackgroundColor = 0x0cffffff, altKeyTextColor = 0xb9000000, accentKeyBackgroundColor = 0xff5e97f6, @@ -267,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/AutoScaleTextView.kt b/app/src/main/java/org/fcitx/fcitx5/android/input/AutoScaleTextView.kt new file mode 100644 index 000000000..0fb52735c --- /dev/null +++ b/app/src/main/java/org/fcitx/fcitx5/android/input/AutoScaleTextView.kt @@ -0,0 +1,179 @@ +/* + * SPDX-License-Identifier: LGPL-2.1-or-later + * SPDX-FileCopyrightText: Copyright 2021-2023 Fcitx5 for Android Contributors + */ +package org.fcitx.fcitx5.android.input + +import android.annotation.SuppressLint +import android.content.Context +import android.graphics.Canvas +import android.graphics.Paint +import android.graphics.Rect +import android.util.AttributeSet +import android.view.Gravity +import android.widget.TextView +import androidx.core.graphics.withSave +import kotlin.math.ceil +import kotlin.math.floor +import kotlin.math.max +import kotlin.math.min + +@SuppressLint("AppCompatCustomView") +class AutoScaleTextView @JvmOverloads constructor( + context: Context?, + attributeSet: AttributeSet? = null +) : TextView(context, attributeSet) { + + enum class Mode { + /** + * do not scale or ellipse text, overflow when cannot fit width + */ + None, + /** + * only scale in X axis, makes text looks "condensed" or "slim" + */ + Horizontal, + /** + * scale both in X and Y axis, align center vertically + */ + Proportional + } + + var scaleMode = Mode.None + + private lateinit var text: String + + private var needsMeasureText = true + private val fontMetrics = Paint.FontMetrics() + private val textBounds = Rect() + + private var needsCalculateTransform = true + private var translateY = 0.0f + private var translateX = 0.0f + private var textScaleX = 1.0f + private var textScaleY = 1.0f + + override fun setText(charSequence: CharSequence?, bufferType: BufferType) { + // setText can be called in super constructor + if (!::text.isInitialized || charSequence == null || !text.contentEquals(charSequence)) { + needsMeasureText = true + needsCalculateTransform = true + text = charSequence?.toString() ?: "" + requestLayout() + invalidate() + } + } + + override fun getText(): CharSequence { + return text + } + + override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { + val widthMode = MeasureSpec.getMode(widthMeasureSpec) + val widthSize = MeasureSpec.getSize(widthMeasureSpec) + val heightMode = MeasureSpec.getMode(heightMeasureSpec) + val heightSize = MeasureSpec.getSize(heightMeasureSpec) + val width = measureTextBounds().width() + paddingLeft + paddingRight + val height = ceil(fontMetrics.bottom - fontMetrics.top + paddingTop + paddingBottom).toInt() + val maxHeight = if (maxHeight >= 0) maxHeight else Int.MAX_VALUE + val maxWidth = if (maxWidth >= 0) maxWidth else Int.MAX_VALUE + setMeasuredDimension( + measure(widthMode, widthSize, min(max(width, minimumWidth), maxWidth)), + measure(heightMode, heightSize, min(max(height, minimumHeight), maxHeight)) + ) + } + + private fun measure(specMode: Int, specSize: Int, calculatedSize: Int): Int = when (specMode) { + MeasureSpec.EXACTLY -> specSize + MeasureSpec.AT_MOST -> min(calculatedSize, specSize) + else -> calculatedSize + } + + private fun measureTextBounds(): Rect { + if (needsMeasureText) { + val paint = paint + paint.getFontMetrics(fontMetrics) + val codePointCount = Character.codePointCount(text, 0, text.length) + if (codePointCount == 1) { + // use actual text bounds when there is only one "character", + // eg. full-width punctuation + paint.getTextBounds(text, 0, text.length, textBounds) + } else { + textBounds.set( + /* left = */ 0, + /* top = */ floor(fontMetrics.top).toInt(), + /* right = */ ceil(paint.measureText(text)).toInt(), + /* bottom = */ ceil(fontMetrics.bottom).toInt() + ) + } + needsMeasureText = false + } + return textBounds + } + + override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) { + if (needsCalculateTransform || changed) { + calculateTransform(right - left, bottom - top) + needsCalculateTransform = false + } + } + + private fun calculateTransform(viewWidth: Int, viewHeight: Int) { + val contentWidth = viewWidth - paddingLeft - paddingRight + val contentHeight = viewHeight - paddingTop - paddingBottom + measureTextBounds() + val textWidth = textBounds.width() + val leftAlignOffset = (paddingLeft - textBounds.left).toFloat() + val centerAlignOffset = + paddingLeft.toFloat() + (contentWidth - textWidth) / 2.0f - textBounds.left.toFloat() + + @SuppressLint("RtlHardcoded") + val shouldAlignLeft = gravity and Gravity.HORIZONTAL_GRAVITY_MASK == Gravity.LEFT + if (textWidth >= contentWidth) { + when (scaleMode) { + Mode.None -> { + textScaleX = 1.0f + textScaleY = 1.0f + translateX = if (shouldAlignLeft) leftAlignOffset else centerAlignOffset + } + Mode.Horizontal -> { + textScaleX = contentWidth.toFloat() / textWidth.toFloat() + textScaleY = 1.0f + translateX = leftAlignOffset + } + Mode.Proportional -> { + val textScale = contentWidth.toFloat() / textWidth.toFloat() + textScaleX = textScale + textScaleY = textScale + translateX = leftAlignOffset + } + } + } else { + translateX = if (shouldAlignLeft) leftAlignOffset else centerAlignOffset + textScaleX = 1.0f + textScaleY = 1.0f + } + val fontHeight = (fontMetrics.bottom - fontMetrics.top) * textScaleY + val fontOffsetY = fontMetrics.top * textScaleY + translateY = (contentHeight.toFloat() - fontHeight) / 2.0f - fontOffsetY + paddingTop + } + + override fun onDraw(canvas: Canvas) { + if (needsCalculateTransform) { + calculateTransform(width, height) + needsCalculateTransform = false + } + val paint = paint + paint.color = currentTextColor + canvas.withSave { + translate(scrollX.toFloat(), scrollY.toFloat()) + scale(textScaleX, textScaleY, 0f, translateY) + translate(translateX, translateY) + drawText(text, 0f, 0f, paint) + } + } + + override fun getTextScaleX(): Float { + return textScaleX + } +} 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 229c91fe3..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,6 +1,12 @@ +/* + * SPDX-License-Identifier: LGPL-2.1-or-later + * 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 @@ -11,11 +17,17 @@ import android.os.SystemClock import android.text.InputType import android.util.LruCache import android.util.Size -import android.view.* +import android.view.KeyCharacterMap +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 @@ -27,49 +39,74 @@ import androidx.autofill.inline.common.ViewStyle import androidx.autofill.inline.v1.InlineSuggestionUi import androidx.core.view.updateLayoutParams import androidx.lifecycle.lifecycleScope +import kotlinx.coroutines.CoroutineStart import kotlinx.coroutines.Job +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.channels.consumeEach import kotlinx.coroutines.launch -import kotlinx.coroutines.sync.Mutex -import kotlinx.coroutines.sync.withLock import org.fcitx.fcitx5.android.R -import org.fcitx.fcitx5.android.core.* +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.daemon.launchOnReady +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.inputConnection +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 import splitties.resources.styledColor import timber.log.Timber import kotlin.math.max - class FcitxInputMethodService : LifecycleInputMethodService() { private lateinit var fcitx: FcitxConnection - private var eventHandlerJob: Job? = null + + private var jobs = Channel(capacity = Channel.UNLIMITED) private val cachedKeyEvents = LruCache(78) private var cachedKeyEventIndex = 0 - private val inputContextMutex = Mutex() - 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 - var editorInfo = EmptyEditorInfo - var capabilityFlags = CapabilityFlags.DefaultFlags + private val navbarMgr = NavigationBarManager() + private val inputDeviceMgr = InputDeviceManager onChange@{ + val w = window.window ?: return@onChange + navbarMgr.evaluate(w, useVirtualKeyboard = it) + } - val selection = CursorTracker() - val composing = CursorRange() + private var capabilityFlags = CapabilityFlags.DefaultFlags + + private val selection = CursorTracker() + + val currentInputSelection: CursorRange + get() = selection.latest + + private val composing = CursorRange() private var composingText = FormattedText.Empty private fun resetComposingState() { @@ -81,62 +118,123 @@ 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 inlineSuggestions by AppPrefs.getInstance().keyboard.inlineSuggestions + private val prefs = AppPrefs.getInstance() + private val inlineSuggestions by prefs.keyboard.inlineSuggestions + private val ignoreSystemCursor by prefs.advanced.ignoreSystemCursor + + private val recreateInputViewPrefs: Array> = arrayOf( + prefs.keyboard.expandKeypressArea, + prefs.advanced.disableAnimation, + prefs.advanced.ignoreSystemWindowInsets, + ) + + 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 recreateInputViewListener = ManagedPreference.OnChangeListener { _, _ -> - recreateInputView(ThemeManager.getActiveTheme()) + replaceInputView(ThemeManager.activeTheme) } @Keep - private val onThemeChangeListener = ThemeManager.OnThemeChangeListener { - recreateInputView(it) + private val recreateCandidatesViewListener = ManagedPreferenceProvider.OnChangeListener { + replaceCandidateView(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 onThemeChangeListener = ThemeManager.OnThemeChangeListener { + replaceInputViews(it) + } + + /** + * Post a fcitx operation to [jobs] to be executed + * + * Unlike `fcitx.runOnReady` or `fcitx.launchOnReady` where + * 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): 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) - eventHandlerJob = lifecycleScope.launch { + lifecycleScope.launch { + jobs.consumeEach { it.join() } + } + lifecycleScope.launch { fcitx.runImmediately { eventFlow }.collect { handleFcitxEvent(it) } } pkgNameCache = PackageNameCache(this) - AppPrefs.getInstance().apply { - keyboard.systemTouchSounds.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<*>) { when (event) { is FcitxEvent.CommitStringEvent -> { - commitText(event.data) + commitText(event.data.text, event.data.cursor) } 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) // use cached event if available cachedKeyEvents.remove(it.timestamp)?.let { keyEvent -> - inputConnection?.sendKeyEvent(keyEvent) + currentInputConnection?.sendKeyEvent(keyEvent) return@event } // simulate key event @@ -152,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") } @@ -162,10 +260,35 @@ class FcitxInputMethodService : LifecycleInputMethodService() { is FcitxEvent.ClientPreeditEvent -> { updateComposingText(event.data) } + is FcitxEvent.DeleteSurroundingEvent -> { + 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 -> {} } } + private fun handleDeleteSurrounding(before: Int, after: Int) { + val ic = currentInputConnection ?: return + if (before > 0) { + selection.predictOffset(-before) + } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + ic.deleteSurroundingTextInCodePoints(before, after) + } else { + ic.deleteSurroundingText(before, after) + } + } + private fun handleBackspaceKey() { val lastSelection = selection.latest if (lastSelection.isNotEmpty()) { @@ -173,11 +296,11 @@ class FcitxInputMethodService : LifecycleInputMethodService() { } else if (lastSelection.start > 0) { selection.predictOffset(-1) } - // In practice nobody (apart form us) would set `privateImeOptions` to our + // In practice nobody (apart form ourselves) would set `privateImeOptions` to our // `DeleteSurroundingFlag`, leading to a behavior of simulating backspace key pressing // in almost every EditText. - if (editorInfo.privateImeOptions != DeleteSurroundingFlag || - editorInfo.inputType and InputType.TYPE_MASK_CLASS == InputType.TYPE_NULL + if (currentInputEditorInfo.privateImeOptions != DeleteSurroundingFlag || + currentInputEditorInfo.inputType and InputType.TYPE_MASK_CLASS == InputType.TYPE_NULL ) { sendDownUpKeyEvents(KeyEvent.KEYCODE_DEL) return @@ -188,17 +311,17 @@ class FcitxInputMethodService : LifecycleInputMethodService() { return } if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { - inputConnection?.deleteSurroundingTextInCodePoints(1, 0) + currentInputConnection.deleteSurroundingTextInCodePoints(1, 0) } else { - inputConnection?.deleteSurroundingText(1, 0) + currentInputConnection.deleteSurroundingText(1, 0) } } else { - inputConnection?.commitText("", 0) + currentInputConnection.commitText("", 0) } } private fun handleReturnKey() { - editorInfo.run { + currentInputEditorInfo.run { if (inputType and InputType.TYPE_MASK_CLASS == InputType.TYPE_NULL) { sendDownUpKeyEvents(KeyEvent.KEYCODE_ENTER) return @@ -208,42 +331,52 @@ class FcitxInputMethodService : LifecycleInputMethodService() { return } if (actionLabel?.isNotEmpty() == true && actionId != EditorInfo.IME_ACTION_UNSPECIFIED) { - inputConnection?.performEditorAction(actionId) + currentInputConnection.performEditorAction(actionId) return } when (val action = imeOptions and EditorInfo.IME_MASK_ACTION) { EditorInfo.IME_ACTION_UNSPECIFIED, EditorInfo.IME_ACTION_NONE -> commitText("\n") - - else -> inputConnection?.performEditorAction(action) + else -> currentInputConnection.performEditorAction(action) } } } - fun commitText(text: String) { + fun commitText(text: String, cursor: Int = -1) { + val ic = currentInputConnection ?: return + // when composing text equals commit content, finish composing text as-is if (composing.isNotEmpty() && composingText.toString() == text) { - // when composing text equals commit content, finish composing text as-is - val cursor = composing.end - selection.predict(cursor) + val c = if (cursor == -1) text.length else cursor + val target = composing.start + c resetComposingState() - inputConnection?.apply { - beginBatchEdit() - finishComposingText() - setSelection(cursor, cursor) - endBatchEdit() + ic.withBatchEdit { + if (selection.current.start != target) { + selection.predict(target) + ic.setSelection(target, target) + } + ic.finishComposingText() } return } // committed text should replace composing (if any), replace selected range (if any), // or simply prepend before cursor val start = if (composing.isEmpty()) selection.latest.start else composing.start - selection.predict(start + text.length) resetComposingState() - inputConnection?.commitText(text, 1) + if (cursor == -1) { + selection.predict(start + text.length) + ic.commitText(text, 1) + } else { + val target = start + cursor + selection.predict(target) + ic.withBatchEdit { + commitText(text, 1) + setSelection(target, target) + } + } } private fun sendDownKeyEvent(eventTime: Long, keyEventCode: Int, metaState: Int = 0) { - inputConnection?.sendKeyEvent( + currentInputConnection?.sendKeyEvent( KeyEvent( eventTime, eventTime, @@ -252,14 +385,14 @@ class FcitxInputMethodService : LifecycleInputMethodService() { 0, metaState, KeyCharacterMap.VIRTUAL_KEYBOARD, - 0, + ScancodeMapping.keyCodeToScancode(keyEventCode), KeyEvent.FLAG_SOFT_KEYBOARD or KeyEvent.FLAG_KEEP_TOUCH_MODE ) ) } private fun sendUpKeyEvent(eventTime: Long, keyEventCode: Int, metaState: Int = 0) { - inputConnection?.sendKeyEvent( + currentInputConnection?.sendKeyEvent( KeyEvent( eventTime, SystemClock.uptimeMillis(), @@ -268,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 ) ) @@ -278,7 +411,7 @@ class FcitxInputMethodService : LifecycleInputMethodService() { val lastSelection = selection.latest if (lastSelection.isEmpty()) return selection.predict(lastSelection.start) - inputConnection?.commitText("", 1) + currentInputConnection?.commitText("", 1) } fun sendCombinationKeyEvents( @@ -287,7 +420,6 @@ class FcitxInputMethodService : LifecycleInputMethodService() { ctrl: Boolean = false, shift: Boolean = false ) { - inputConnection?.beginBatchEdit() ?: return var metaState = 0 if (alt) metaState = KeyEvent.META_ALT_ON or KeyEvent.META_ALT_LEFT_ON if (ctrl) metaState = metaState or KeyEvent.META_CTRL_ON or KeyEvent.META_CTRL_LEFT_ON @@ -301,12 +433,11 @@ class FcitxInputMethodService : LifecycleInputMethodService() { if (shift) sendUpKeyEvent(eventTime, KeyEvent.KEYCODE_SHIFT_LEFT) if (ctrl) sendUpKeyEvent(eventTime, KeyEvent.KEYCODE_CTRL_LEFT) if (alt) sendUpKeyEvent(eventTime, KeyEvent.KEYCODE_ALT_LEFT) - inputConnection?.endBatchEdit() } fun applySelectionOffset(offsetStart: Int, offsetEnd: Int = 0) { val lastSelection = selection.latest - inputConnection?.also { + currentInputConnection?.also { val start = max(lastSelection.start + offsetStart, 0) val end = max(lastSelection.end + offsetEnd, 0) if (start > end) return @@ -320,37 +451,45 @@ class FcitxInputMethodService : LifecycleInputMethodService() { if (lastSelection.isEmpty()) return val end = lastSelection.end selection.predict(end) - inputConnection?.setSelection(end, end) + currentInputConnection?.setSelection(end, end) } override fun onConfigurationChanged(newConfig: Configuration) { super.onConfigurationChanged(newConfig) - fcitx.launchOnReady { it.reset() } + postFcitxJob { reset() } } - override fun onCreateInputView(): View { - super.onCreateInputView() - // 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 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? { + 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 } @@ -360,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 { @@ -388,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) { - fcitx.launchOnReady { - it.sendKey(charCode, states.states, up, timestamp) + // 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, s.states, event.scanCode, up, timestamp) } return true } val keySym = KeySym.fromKeyEvent(event) if (keySym != null) { - fcitx.launchOnReady { - it.sendKey(keySym, states, up, timestamp) + postFcitxJob { + sendKey(keySym, states, event.scanCode, up, timestamp) } return true } @@ -406,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) } @@ -413,20 +576,74 @@ 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) Timber.d("onBindInput: uid=$uid pkg=$pkgName") - lifecycleScope.launch { + postFcitxJob { // ensure InputContext has been created before focusing it - inputContextMutex.withLock { - fcitx.runOnReady { - activate(uid, pkgName) + 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) { // update selection as soon as possible // sometimes when restarting input, onUpdateSelection happens before onStartInput, and @@ -435,39 +652,41 @@ class FcitxInputMethodService : LifecycleInputMethodService() { selection.resetTo(attribute.initialSelStart, attribute.initialSelEnd) resetComposingState() val flags = CapabilityFlags.fromEditorInfo(attribute) - editorInfo = attribute capabilityFlags = flags Timber.d("onStartInput: initialSel=${selection.current}, restarting=$restarting") - lifecycleScope.launch { - // wait until InputContext created/activated - inputContextMutex.withLock { - fcitx.runOnReady { - if (restarting) { - // when input restarts in the same editor, focus out to clear previous state - focus(false) - // try focus out before changing CapabilityFlags, - // to avoid confusing state of different text fields - } - // EditorInfo can be different in onStartInput and onStartInputView, - // especially in browsers - setCapFlags(flags) - } + // wait until InputContext created/activated + postFcitxJob { + if (restarting) { + // when input restarts in the same editor, focus out to clear previous state + focus(false) + // try focus out before changing CapabilityFlags, + // to avoid confusing state of different text fields } + // EditorInfo can be different in onStartInput and onStartInputView, + // especially in browsers + setCapFlags(flags) } } override fun onStartInputView(info: EditorInfo, restarting: Boolean) { Timber.d("onStartInputView: restarting=$restarting") - lifecycleScope.launch { - inputContextMutex.withLock { - fcitx.runOnReady { - focus(true) + postFcitxJob { + focus(true) + } + 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() } } - // because onStartInputView will always be called after onStartInput, - // editorInfo and capFlags should be up-to-date - inputView?.startInput(editorInfo, capabilityFlags, restarting) } override fun onUpdateSelection( @@ -479,15 +698,76 @@ class FcitxInputMethodService : LifecycleInputMethodService() { candidatesEnd: Int ) { // onUpdateSelection can left behind when user types quickly enough, eg. long press backspace - // TODO: call InputConnection#beginBatchEdit() before starting key repeat cursorUpdateIndex += 1 - Timber.d("onUpdateSelection: old=[$oldSelStart,$oldSelEnd] new=[$newSelStart,$newSelEnd] cand=[$candidatesStart,$candidatesEnd]") + Timber.d("onUpdateSelection: old=[$oldSelStart,$oldSelEnd] new=[$newSelStart,$newSelEnd]") handleCursorUpdate(newSelStart, newSelEnd, cursorUpdateIndex) 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) { @@ -501,10 +781,10 @@ class FcitxInputMethodService : LifecycleInputMethodService() { if (newSelStart != newSelEnd) return // do reset if composing is empty && input panel is not empty if (composing.isEmpty()) { - fcitx.launchOnReady { - if (!it.isEmpty()) { + postFcitxJob { + if (!isEmpty()) { Timber.d("handleCursorUpdate: reset") - it.reset() + reset() } } return @@ -518,36 +798,47 @@ class FcitxInputMethodService : LifecycleInputMethodService() { if (position != composingText.cursor) { // cursor in InvokeActionEvent counts by "UTF-8 characters" val codePointPosition = composingText.codePointCountUntil(position) - fcitx.launchOnReady { - if (updateIndex != cursorUpdateIndex) return@launchOnReady + postFcitxJob { + if (updateIndex != cursorUpdateIndex) return@postFcitxJob Timber.d("handleCursorUpdate: move fcitx cursor to $codePointPosition") - it.moveCursor(codePointPosition) + moveCursor(codePointPosition) } } } else { Timber.d("handleCursorUpdate: focus out/in") resetComposingState() // cursor outside composing range, finish composing as-is - inputConnection?.finishComposingText() + currentInputConnection?.finishComposingText() // `fcitx.reset()` here would commit preedit after new cursor position // since we have `ClientUnfocusCommit`, focus out and in would do the trick - fcitx.launchOnReady { - it.focus(false) - it.focus(true) + postFcitxJob { + focus(false) + focus(true) } } } // 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 private fun updateComposingText(text: FormattedText) { - val ic = inputConnection ?: return + 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()) { @@ -579,27 +870,29 @@ 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() } + /** + * Finish composing text and leave cursor position as-is. + * Also updates internal composing state of [FcitxInputMethodService]. + */ + fun finishComposing() { + val ic = currentInputConnection ?: return + if (composing.isEmpty()) return + composing.clear() + composingText = FormattedText.Empty + ic.finishComposingText() + } + @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) @@ -644,7 +937,7 @@ class FcitxInputMethodService : LifecycleInputMethodService() { .addStyle(style) .build() val spec = InlinePresentationSpec - .Builder(InlinePresentationSpecMinSize, InlinePresentationSpecMaxSize) + .Builder(Size(0, 0), Size(Int.MAX_VALUE, Int.MAX_VALUE)) .setStyle(styleBundle) .build() return InlineSuggestionsRequest.Builder(listOf(spec)) @@ -654,26 +947,27 @@ class FcitxInputMethodService : LifecycleInputMethodService() { @RequiresApi(Build.VERSION_CODES.R) override fun onInlineSuggestionsResponse(response: InlineSuggestionsResponse): Boolean { - if (!inlineSuggestions) return false - return inputView?.handleInlineSuggestions(response) ?: false + if (!inlineSuggestions || !inputDeviceMgr.isVirtualKeyboard) return false + return inputView?.handleInlineSuggestions(response) == true } override fun onFinishInputView(finishingInput: Boolean) { Timber.d("onFinishInputView: finishingInput=$finishingInput") - inputConnection?.finishComposingText() - lifecycleScope.launch { - inputContextMutex.withLock { - fcitx.runOnReady { - focus(false) - } - } + decorLocationUpdated = false + inputDeviceMgr.onFinishInputView() + currentInputConnection?.apply { + finishComposingText() + monitorCursorAnchor(false) + } + resetComposingState() + postFcitxJob { + focus(false) } - inputView?.finishInput() + showingDialog?.dismiss() } override fun onFinishInput() { Timber.d("onFinishInput") - editorInfo = EmptyEditorInfo capabilityFlags = CapabilityFlags.DefaultFlags } @@ -684,33 +978,45 @@ class FcitxInputMethodService : LifecycleInputMethodService() { // currentInputBinding can be null on some devices under some special Multi-screen mode val uid = currentInputBinding?.uid ?: return Timber.d("onUnbindInput: uid=$uid") - lifecycleScope.launch { - inputContextMutex.withLock { - fcitx.runOnReady { - deactivate(uid) - } - } + postFcitxJob { + deactivate(uid) } } override fun onDestroy() { - AppPrefs.getInstance().apply { - keyboard.systemTouchSounds.unregisterOnChangeListener(recreateInputViewListener) - advanced.disableAnimation.unregisterOnChangeListener(recreateInputViewListener) + recreateInputViewPrefs.forEach { + it.unregisterOnChangeListener(recreateInputViewListener) } + prefs.candidates.unregisterOnChangeListener(recreateCandidatesViewListener) ThemeManager.removeOnChangedListener(onThemeChangeListener) - eventHandlerJob?.cancel() - eventHandlerJob = null 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 { - val EmptyEditorInfo = EditorInfo() const val DeleteSurroundingFlag = "org.fcitx.fcitx5.android.DELETE_SURROUNDING" - private val InlinePresentationSpecMinSize = Size(0, 0) - private val InlinePresentationSpecMaxSize = Size(Int.MAX_VALUE, Int.MAX_VALUE) } - -} \ 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 ef34567b1..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,38 +1,37 @@ +/* + * SPDX-License-Identifier: LGPL-2.1-or-later + * 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.* -import androidx.lifecycle.lifecycleScope -import kotlinx.coroutines.launch +import androidx.core.view.updateLayoutParams 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 @@ -41,28 +40,39 @@ 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 import org.mechdancer.dependency.plusAssign import splitties.dimensions.dp -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.centerVertically +import splitties.views.dsl.constraintlayout.constraintLayout +import splitties.views.dsl.constraintlayout.endOfParent +import splitties.views.dsl.constraintlayout.endToStartOf +import splitties.views.dsl.constraintlayout.lParams +import splitties.views.dsl.constraintlayout.startOfParent +import splitties.views.dsl.constraintlayout.startToEndOf +import splitties.views.dsl.constraintlayout.topOfParent +import splitties.views.dsl.core.add +import splitties.views.dsl.core.imageView +import splitties.views.dsl.core.matchParent +import splitties.views.dsl.core.view +import splitties.views.dsl.core.withTheme +import splitties.views.dsl.core.wrapContent 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 @@ -83,12 +93,6 @@ class InputView( setOnClickListener(placeholderOnClickListener) } - private val eventHandlerJob = service.lifecycleScope.launch { - fcitx.runImmediately { eventFlow }.collect { - handleFcitxEvent(it) - } - } - private val scope = DynamicScope() private val themedContext = context.withTheme(R.style.Theme_InputViewTheme) private val broadcaster = InputBroadcaster() @@ -126,6 +130,9 @@ class InputView( } private val keyboardPrefs = AppPrefs.getInstance().keyboard + + private val focusChangeResetKeyboard by keyboardPrefs.focusChangeResetKeyboard + private val keyboardHeightPercent = keyboardPrefs.keyboardHeightPercent private val keyboardHeightPercentLandscape = keyboardPrefs.keyboardHeightPercentLandscape private val keyboardSidePadding = keyboardPrefs.keyboardSidePadding @@ -170,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 @@ -185,56 +194,16 @@ class InputView( 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 { @@ -288,6 +257,8 @@ class InputView( centerVertically() centerHorizontally() }) + + keyboardPrefs.registerOnChangeListener(onKeyboardSizeChangeListener) } private fun updateKeyboardSize() { @@ -300,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 @@ -309,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 } @@ -324,35 +295,29 @@ 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) - windowManager.attachWindow(KeyboardWindow) + if (focusChangeResetKeyboard || !restarting) { + windowManager.attachWindow(KeyboardWindow) + } } - private fun handleFcitxEvent(it: FcitxEvent<*>) { + override fun handleFcitxEvent(it: FcitxEvent<*>) { when (it) { is FcitxEvent.CandidateListEvent -> { broadcaster.onCandidateUpdate(it.data) @@ -369,8 +334,8 @@ class InputView( broadcaster.onImeUpdate(it.data) } is FcitxEvent.StatusAreaEvent -> { - punctuation.updatePunctuationMapping(it.data) - broadcaster.onStatusAreaUpdate(it.data) + punctuation.updatePunctuationMapping(it.data.actions) + broadcaster.onStatusAreaUpdate(it.data.actions) } else -> {} } @@ -380,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/LifecycleInputMethodService.kt b/app/src/main/java/org/fcitx/fcitx5/android/input/LifecycleInputMethodService.kt index 90113ba4d..7656ff761 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/input/LifecycleInputMethodService.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/input/LifecycleInputMethodService.kt @@ -1,7 +1,10 @@ +/* + * SPDX-License-Identifier: LGPL-2.1-or-later + * SPDX-FileCopyrightText: Copyright 2021-2023 Fcitx5 for Android Contributors + */ package org.fcitx.fcitx5.android.input import android.inputmethodservice.InputMethodService -import android.view.View import androidx.annotation.CallSuper import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleOwner @@ -9,6 +12,7 @@ import androidx.lifecycle.LifecycleRegistry import androidx.lifecycle.setViewTreeLifecycleOwner open class LifecycleInputMethodService : InputMethodService(), LifecycleOwner { + private val lifecycleRegistry by lazy { LifecycleRegistry(this) } override val lifecycle = lifecycleRegistry @@ -16,20 +20,15 @@ open class LifecycleInputMethodService : InputMethodService(), LifecycleOwner { @CallSuper override fun onCreate() { super.onCreate() + window.window!!.decorView.setViewTreeLifecycleOwner(this) lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_CREATE) lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_START) } - @CallSuper - override fun onCreateInputView(): View? { - window.window!!.decorView.setViewTreeLifecycleOwner(this) - return null - } - @CallSuper override fun onDestroy() { super.onDestroy() lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_STOP) lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_DESTROY) } -} \ No newline at end of file +} 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/PackageNameCache.kt b/app/src/main/java/org/fcitx/fcitx5/android/input/PackageNameCache.kt index 91176801e..132fddfd4 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/input/PackageNameCache.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/input/PackageNameCache.kt @@ -1,3 +1,7 @@ +/* + * SPDX-License-Identifier: LGPL-2.1-or-later + * SPDX-FileCopyrightText: Copyright 2021-2023 Fcitx5 for Android Contributors + */ package org.fcitx.fcitx5.android.input import android.content.Context 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 b07814610..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 @@ -1,3 +1,7 @@ +/* + * SPDX-License-Identifier: LGPL-2.1-or-later + * SPDX-FileCopyrightText: Copyright 2021-2023 Fcitx5 for Android Contributors + */ package org.fcitx.fcitx5.android.input.bar import org.fcitx.fcitx5.android.input.bar.ExpandButtonStateMachine.BooleanKey.ExpandedCandidatesEmpty @@ -36,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 142da37b9..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,3 +1,7 @@ +/* + * SPDX-License-Identifier: LGPL-2.1-or-later + * SPDX-FileCopyrightText: Copyright 2021-2025 Fcitx5 for Android Contributors + */ package org.fcitx.fcitx5.android.input.bar import android.graphics.Color @@ -16,27 +20,37 @@ import android.widget.inline.InlineContentView import androidx.annotation.Keep import androidx.annotation.RequiresApi import androidx.lifecycle.lifecycleScope -import kotlinx.coroutines.* +import kotlinx.coroutines.Job +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch import org.fcitx.fcitx5.android.R 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 @@ -44,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 @@ -59,10 +74,10 @@ import splitties.views.backgroundColor import splitties.views.dsl.core.add import splitties.views.dsl.core.lParams import splitties.views.dsl.core.matchParent -import splitties.views.imageResource import java.util.concurrent.Executor import kotlin.coroutines.resume import kotlin.coroutines.suspendCoroutine +import kotlin.math.min class KawaiiBarComponent : UniqueViewComponent(), InputBroadcastReceiver { @@ -75,20 +90,23 @@ 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 - private var isClipboardFresh: Boolean = true - private var isInlineSuggestionEmpty: Boolean = true + private var isClipboardFresh: Boolean = false + private var isInlineSuggestionPresent: Boolean = false private var isCapabilityFlagsPassword: Boolean = false private var isKeyboardLayoutNumber: Boolean = false - private var isToolbarManuallyExpanded: Boolean = false + private var isToolbarManuallyToggled: Boolean = false @Keep private val onClipboardUpdateListener = @@ -96,10 +114,14 @@ class KawaiiBarComponent : UniqueViewComponent( if (!clipboardSuggestion.getValue()) return@OnClipboardUpdateListener service.lifecycleScope.launch { if (it.text.isEmpty()) { - isClipboardFresh = true - } else { - idleUi.clipboardUi.text.text = it.text.take(42) isClipboardFresh = false + } else { + idleUi.clipboardUi.text.text = if (it.sensitive && clipboardMaskSensitive) { + ClipboardEntry.BULLET.repeat(min(42, it.text.length)) + } else { + it.text.take(42) + } + isClipboardFresh = true launchClipboardTimeoutJob() } evalIdleUiState() @@ -110,7 +132,7 @@ class KawaiiBarComponent : UniqueViewComponent( private val onClipboardSuggestionUpdateListener = ManagedPreference.OnChangeListener { _, it -> if (!it) { - isClipboardFresh = true + isClipboardFresh = false evalIdleUiState() clipboardTimeoutJob?.cancel() clipboardTimeoutJob = null @@ -136,18 +158,25 @@ class KawaiiBarComponent : UniqueViewComponent( if (timeout < 0L) return clipboardTimeoutJob = service.lifecycleScope.launch { delay(timeout) - isClipboardFresh = true + isClipboardFresh = false clipboardTimeoutJob = null } } private fun evalIdleUiState(fromUser: Boolean = false) { val newState = when { - !isClipboardFresh -> IdleUi.State.Clipboard - !isInlineSuggestionEmpty -> IdleUi.State.InlineSuggestion + isClipboardFresh -> IdleUi.State.Clipboard + isInlineSuggestionPresent -> IdleUi.State.InlineSuggestion isCapabilityFlagsPassword && !isKeyboardLayoutNumber -> IdleUi.State.NumberRow - expandToolbarByDefault || isToolbarManuallyExpanded -> IdleUi.State.Toolbar - else -> IdleUi.State.Empty + /** + * state matrix: + * expandToolbarByDefault + * | \ | true | false + * isToolbarManuallyToggled | true | Empty | Toolbar + * | false | Toolbar | Empty + */ + expandToolbarByDefault == isToolbarManuallyToggled -> IdleUi.State.Empty + else -> IdleUi.State.Toolbar } if (newState == idleUi.currentState) return idleUi.updateState(newState, fromUser) @@ -157,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 { @@ -167,19 +203,31 @@ class KawaiiBarComponent : UniqueViewComponent( private val idleUi: IdleUi by lazy { IdleUi(context, theme, popup, commonKeyActionListener).apply { menuButton.setOnClickListener { - if (idleUi.currentState == IdleUi.State.Toolbar) { - isToolbarManuallyExpanded = false - evalIdleUiState(fromUser = true) - } else { - isToolbarManuallyExpanded = true - idleUi.updateState(IdleUi.State.Toolbar, fromUser = true) + when (idleUi.currentState) { + IdleUi.State.Empty -> { + isToolbarManuallyToggled = !expandToolbarByDefault + evalIdleUiState(fromUser = true) + } + IdleUi.State.Toolbar -> { + isToolbarManuallyToggled = expandToolbarByDefault + evalIdleUiState(fromUser = true) + } + else -> { + isToolbarManuallyToggled = !expandToolbarByDefault + idleUi.updateState(IdleUi.State.Toolbar, fromUser = true) + } } // reset timeout timer (if present) when user switch layout if (clipboardTimeoutJob != null) { 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) @@ -204,8 +252,8 @@ class KawaiiBarComponent : UniqueViewComponent( } clipboardTimeoutJob?.cancel() clipboardTimeoutJob = null - isClipboardFresh = true - evalIdleUiState(fromUser = true) + isClipboardFresh = false + evalIdleUiState() } setOnLongClickListener { ClipboardManager.lastEntry?.let { @@ -218,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 { @@ -255,7 +309,8 @@ class KawaiiBarComponent : UniqueViewComponent( } ) } - candidateUi.expandButton.image.imageResource = R.drawable.ic_baseline_expand_more_24 + 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 @@ -263,7 +318,8 @@ class KawaiiBarComponent : UniqueViewComponent( candidateUi.expandButton.setOnClickListener { windowManager.attachWindow(KeyboardWindow) } - candidateUi.expandButton.image.imageResource = R.drawable.ic_baseline_expand_less_24 + 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 @@ -312,7 +368,7 @@ class KawaiiBarComponent : UniqueViewComponent( idleUi.privateMode(info.imeOptions.hasFlag(EditorInfo.IME_FLAG_NO_PERSONALIZED_LEARNING)) } isCapabilityFlagsPassword = toolbarNumRowOnPassword && capFlags.has(CapabilityFlag.Password) - isInlineSuggestionEmpty = true + isInlineSuggestionPresent = false if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { idleUi.inlineSuggestionsBar.clear() } @@ -362,14 +418,15 @@ class KawaiiBarComponent : UniqueViewComponent( @RequiresApi(Build.VERSION_CODES.R) fun handleInlineSuggestions(response: InlineSuggestionsResponse): Boolean { - if (response.inlineSuggestions.isEmpty()) { - isInlineSuggestionEmpty = true + val suggestions = response.inlineSuggestions + if (suggestions.isEmpty()) { + isInlineSuggestionPresent = false return true } var pinned: InlineSuggestion? = null val scrollable = mutableListOf() var extraPinnedCount = 0 - response.inlineSuggestions.forEach { + suggestions.forEach { if (it.info.isPinned) { if (pinned == null) { pinned = it @@ -393,14 +450,15 @@ class KawaiiBarComponent : UniqueViewComponent( }.awaitAll() idleUi.inlineSuggestionsBar.setScrollableViews(views) } - isInlineSuggestionEmpty = false + isInlineSuggestionPresent = true evalIdleUiState() return true } @RequiresApi(Build.VERSION_CODES.R) - private suspend fun inflateInlineContentView(suggestion: InlineSuggestion): InlineContentView { + private suspend fun inflateInlineContentView(suggestion: InlineSuggestion): InlineContentView? { return suspendCoroutine { c -> + // callback view might be null suggestion.inflate(context, suggestionSize, directExecutor) { v -> c.resume(v) } 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 c35d26a26..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 @@ -1,8 +1,14 @@ +/* + * SPDX-License-Identifier: LGPL-2.1-or-later + * SPDX-FileCopyrightText: Copyright 2021-2023 Fcitx5 for Android Contributors + */ 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 @@ -43,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/CandidateUi.kt b/app/src/main/java/org/fcitx/fcitx5/android/input/bar/ui/CandidateUi.kt index 82b1614a1..bb756df0c 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/input/bar/ui/CandidateUi.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/input/bar/ui/CandidateUi.kt @@ -1,3 +1,7 @@ +/* + * SPDX-License-Identifier: LGPL-2.1-or-later + * SPDX-FileCopyrightText: Copyright 2021-2023 Fcitx5 for Android Contributors + */ package org.fcitx.fcitx5.android.input.bar.ui import android.content.Context @@ -5,16 +9,19 @@ import android.view.View import org.fcitx.fcitx5.android.R import org.fcitx.fcitx5.android.data.theme.Theme import splitties.dimensions.dp -import splitties.views.dsl.constraintlayout.* +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.constraintlayout.startOfParent import splitties.views.dsl.core.Ui import splitties.views.dsl.core.add -import splitties.views.dsl.core.view class CandidateUi(override val ctx: Context, theme: Theme, private val horizontalView: View) : Ui { - val expandButton = view(::ToolButton, R.id.expand_candidate_btn) { - setIcon(R.drawable.ic_baseline_expand_more_24, theme.altKeyTextColor) - setPressHighlightColor(theme.keyPressHighlightColor) + val expandButton = ToolButton(ctx, R.drawable.ic_baseline_expand_more_24, theme).apply { + id = R.id.expand_candidate_btn visibility = View.INVISIBLE } 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 04c69e003..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,3 +1,7 @@ +/* + * SPDX-License-Identifier: LGPL-2.1-or-later + * SPDX-FileCopyrightText: Copyright 2021-2025 Fcitx5 for Android Contributors + */ package org.fcitx.fcitx5.android.input.bar.ui import android.content.Context @@ -18,7 +22,14 @@ import org.fcitx.fcitx5.android.input.bar.ui.idle.NumberRow import org.fcitx.fcitx5.android.input.keyboard.CommonKeyActionListener import org.fcitx.fcitx5.android.input.popup.PopupComponent import splitties.dimensions.dp -import splitties.views.dsl.constraintlayout.* +import splitties.views.dsl.constraintlayout.after +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.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.lParams @@ -119,6 +130,7 @@ class IdleUi( if (activate == inPrivate) return inPrivate = activate updateMenuButtonIcon() + updateMenuButtonContentDescription() updateMenuButtonRotation(instant = true) } @@ -128,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 { @@ -142,11 +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, - theme.altKeyTextColor - ) + 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) } @@ -163,8 +185,9 @@ class IdleUi( fun updateState(state: State, fromUser: Boolean = false) { Timber.d("Switch idle ui to $state") if ( + !fromUser || disableAnimation || - (state == State.InlineSuggestion || currentState == State.InlineSuggestion) && !fromUser + (state == State.InlineSuggestion || currentState == State.InlineSuggestion) ) { clearAnimation() } else { @@ -194,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 340ce68af..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,3 +1,7 @@ +/* + * SPDX-License-Identifier: LGPL-2.1-or-later + * SPDX-FileCopyrightText: Copyright 2021-2025 Fcitx5 for Android Contributors + */ package org.fcitx.fcitx5.android.input.bar.ui import android.content.Context @@ -7,7 +11,16 @@ import androidx.core.view.isVisible import org.fcitx.fcitx5.android.R import org.fcitx.fcitx5.android.data.theme.Theme import splitties.dimensions.dp -import splitties.views.dsl.constraintlayout.* +import splitties.views.dsl.constraintlayout.after +import splitties.views.dsl.constraintlayout.bottomOfParent +import splitties.views.dsl.constraintlayout.centerHorizontally +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.constraintlayout.topOfParent import splitties.views.dsl.core.Ui import splitties.views.dsl.core.add import splitties.views.dsl.core.textView @@ -17,7 +30,7 @@ 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).apply { - id = R.id.expand_candidate_btn + contentDescription = ctx.getString(R.string.back_to_keyboard) } private val titleText = textView { diff --git a/app/src/main/java/org/fcitx/fcitx5/android/input/bar/ui/ToolButton.kt b/app/src/main/java/org/fcitx/fcitx5/android/input/bar/ui/ToolButton.kt index 5007f3344..be8e2ae40 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/input/bar/ui/ToolButton.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/input/bar/ui/ToolButton.kt @@ -1,8 +1,11 @@ +/* + * SPDX-License-Identifier: LGPL-2.1-or-later + * SPDX-FileCopyrightText: Copyright 2021-2023 Fcitx5 for Android Contributors + */ package org.fcitx.fcitx5.android.input.bar.ui import android.content.Context -import android.graphics.PorterDuff -import android.graphics.PorterDuffColorFilter +import android.content.res.ColorStateList import android.widget.ImageView import androidx.annotation.ColorInt import androidx.annotation.DrawableRes @@ -12,13 +15,12 @@ import org.fcitx.fcitx5.android.input.keyboard.CustomGestureView import org.fcitx.fcitx5.android.utils.borderlessRippleDrawable import org.fcitx.fcitx5.android.utils.circlePressHighlightDrawable import splitties.dimensions.dp -import splitties.resources.drawable import splitties.views.dsl.core.add import splitties.views.dsl.core.imageView import splitties.views.dsl.core.lParams import splitties.views.dsl.core.wrapContent import splitties.views.gravityCenter -import splitties.views.imageDrawable +import splitties.views.imageResource import splitties.views.padding class ToolButton(context: Context) : CustomGestureView(context) { @@ -27,11 +29,6 @@ class ToolButton(context: Context) : CustomGestureView(context) { val disableAnimation by AppPrefs.getInstance().advanced.disableAnimation } - constructor(context: Context, @DrawableRes icon: Int, theme: Theme) : this(context) { - setIcon(icon, theme.altKeyTextColor) - setPressHighlightColor(theme.keyPressHighlightColor) - } - val image = imageView { isClickable = false isFocusable = false @@ -39,13 +36,15 @@ class ToolButton(context: Context) : CustomGestureView(context) { scaleType = ImageView.ScaleType.CENTER_INSIDE } - init { + constructor(context: Context, @DrawableRes icon: Int, theme: Theme) : this(context) { + image.imageTintList = ColorStateList.valueOf(theme.altKeyTextColor) + setIcon(icon) + setPressHighlightColor(theme.keyPressHighlightColor) add(image, lParams(wrapContent, wrapContent, gravityCenter)) } - fun setIcon(@DrawableRes icon: Int, @ColorInt color: Int) { - image.imageDrawable = drawable(icon) - image.colorFilter = PorterDuffColorFilter(color, PorterDuff.Mode.SRC_IN) + fun setIcon(@DrawableRes icon: Int) { + image.imageResource = icon } fun setPressHighlightColor(@ColorInt color: Int) { 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 99d9bf88b..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 @@ -1,3 +1,7 @@ +/* + * SPDX-License-Identifier: LGPL-2.1-or-later + * SPDX-FileCopyrightText: Copyright 2021-2023 Fcitx5 for Android Contributors + */ package org.fcitx.fcitx5.android.input.bar.ui.idle import android.content.Context @@ -24,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/ClipboardSuggestionUi.kt b/app/src/main/java/org/fcitx/fcitx5/android/input/bar/ui/idle/ClipboardSuggestionUi.kt index 8d98d6da2..7b46b33a8 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/input/bar/ui/idle/ClipboardSuggestionUi.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/input/bar/ui/idle/ClipboardSuggestionUi.kt @@ -1,23 +1,42 @@ +/* + * SPDX-License-Identifier: LGPL-2.1-or-later + * SPDX-FileCopyrightText: Copyright 2021-2023 Fcitx5 for Android Contributors + */ package org.fcitx.fcitx5.android.input.bar.ui.idle import android.content.Context -import android.graphics.PorterDuff -import android.graphics.PorterDuffColorFilter import android.text.TextUtils import org.fcitx.fcitx5.android.R import org.fcitx.fcitx5.android.data.theme.Theme import org.fcitx.fcitx5.android.input.keyboard.CustomGestureView import org.fcitx.fcitx5.android.utils.rippleDrawable import splitties.dimensions.dp -import splitties.views.dsl.constraintlayout.* -import splitties.views.dsl.core.* -import splitties.views.imageResource +import splitties.resources.drawable +import splitties.views.dsl.constraintlayout.after +import splitties.views.dsl.constraintlayout.before +import splitties.views.dsl.constraintlayout.centerInParent +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.imageView +import splitties.views.dsl.core.lParams +import splitties.views.dsl.core.matchParent +import splitties.views.dsl.core.textView +import splitties.views.dsl.core.verticalMargin +import splitties.views.dsl.core.wrapContent +import splitties.views.imageDrawable class ClipboardSuggestionUi(override val ctx: Context, private val theme: Theme) : Ui { private val icon = imageView { - imageResource = R.drawable.ic_clipboard - colorFilter = PorterDuffColorFilter(theme.altKeyTextColor, PorterDuff.Mode.SRC_IN) + imageDrawable = drawable(R.drawable.ic_clipboard)!!.apply { + setTint(theme.altKeyTextColor) + } } val text = textView { 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 aeb7868f2..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 @@ -1,3 +1,7 @@ +/* + * SPDX-License-Identifier: LGPL-2.1-or-later + * SPDX-FileCopyrightText: Copyright 2021-2023 Fcitx5 for Android Contributors + */ package org.fcitx.fcitx5.android.input.bar.ui.idle import android.content.Context @@ -13,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 { @@ -84,13 +102,14 @@ class InlineSuggestionsUi(override val ctx: Context) : Ui { } @RequiresApi(Build.VERSION_CODES.R) - fun setScrollableViews(views: List) { + fun setScrollableViews(views: List) { val flexbox = view(::FlexboxLayout) { flexWrap = FlexWrap.NOWRAP justifyContent = JustifyContent.CENTER } val parentSurfaceControl = scrollSurfaceView.surfaceControl views.forEach { + if (it == null) return@forEach scrollableContentViews.add(it) it.setSurfaceControlCallback(object : InlineContentView.SurfaceControlCallback { override fun onCreated(surfaceControl: SurfaceControl) { diff --git a/app/src/main/java/org/fcitx/fcitx5/android/input/bar/ui/idle/NumberRow.kt b/app/src/main/java/org/fcitx/fcitx5/android/input/bar/ui/idle/NumberRow.kt index 95e9b3dc3..0da95932a 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/input/bar/ui/idle/NumberRow.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/input/bar/ui/idle/NumberRow.kt @@ -1,3 +1,7 @@ +/* + * SPDX-License-Identifier: LGPL-2.1-or-later + * SPDX-FileCopyrightText: Copyright 2021-2023 Fcitx5 for Android Contributors + */ package org.fcitx.fcitx5.android.input.bar.ui.idle import android.annotation.SuppressLint diff --git a/app/src/main/java/org/fcitx/fcitx5/android/input/broadcast/InputBroadcastReceiver.kt b/app/src/main/java/org/fcitx/fcitx5/android/input/broadcast/InputBroadcastReceiver.kt index c8b127cee..6f711811d 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/input/broadcast/InputBroadcastReceiver.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/input/broadcast/InputBroadcastReceiver.kt @@ -1,3 +1,7 @@ +/* + * SPDX-License-Identifier: LGPL-2.1-or-later + * SPDX-FileCopyrightText: Copyright 2021-2023 Fcitx5 for Android Contributors + */ package org.fcitx.fcitx5.android.input.broadcast import android.view.inputmethod.EditorInfo diff --git a/app/src/main/java/org/fcitx/fcitx5/android/input/broadcast/InputBroadcaster.kt b/app/src/main/java/org/fcitx/fcitx5/android/input/broadcast/InputBroadcaster.kt index becff3df2..28fbd4bfe 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/input/broadcast/InputBroadcaster.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/input/broadcast/InputBroadcaster.kt @@ -1,3 +1,7 @@ +/* + * SPDX-License-Identifier: LGPL-2.1-or-later + * SPDX-FileCopyrightText: Copyright 2021-2023 Fcitx5 for Android Contributors + */ package org.fcitx.fcitx5.android.input.broadcast import android.view.inputmethod.EditorInfo diff --git a/app/src/main/java/org/fcitx/fcitx5/android/input/broadcast/PreeditEmptyStateComponent.kt b/app/src/main/java/org/fcitx/fcitx5/android/input/broadcast/PreeditEmptyStateComponent.kt index 14e6257ca..3c517b8d5 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/input/broadcast/PreeditEmptyStateComponent.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/input/broadcast/PreeditEmptyStateComponent.kt @@ -1,3 +1,7 @@ +/* + * SPDX-License-Identifier: LGPL-2.1-or-later + * SPDX-FileCopyrightText: Copyright 2021-2023 Fcitx5 for Android Contributors + */ package org.fcitx.fcitx5.android.input.broadcast import org.fcitx.fcitx5.android.core.FormattedText diff --git a/app/src/main/java/org/fcitx/fcitx5/android/input/broadcast/PunctuationComponent.kt b/app/src/main/java/org/fcitx/fcitx5/android/input/broadcast/PunctuationComponent.kt index 867e41fd8..67ad72192 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/input/broadcast/PunctuationComponent.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/input/broadcast/PunctuationComponent.kt @@ -1,3 +1,7 @@ +/* + * SPDX-License-Identifier: LGPL-2.1-or-later + * SPDX-FileCopyrightText: Copyright 2021-2023 Fcitx5 for Android Contributors + */ package org.fcitx.fcitx5.android.input.broadcast import androidx.lifecycle.lifecycleScope @@ -34,8 +38,15 @@ class PunctuationComponent : service.lifecycleScope.launch { mapping = if (enabled) { fcitx.runOnReady { - PunctuationManager.load(this, inputMethodEntryCached.languageCode) - .associate { it.key to it.mapping } + val items = PunctuationManager.load(this, inputMethodEntryCached.languageCode) + val map = HashMap() + items.forEach { + // use first entry as mapping value + if (!map.containsKey(it.key)) { + map[it.key] = it.mapping + } + } + map } } else emptyMap() broadcaster.onPunctuationUpdate(mapping) diff --git a/app/src/main/java/org/fcitx/fcitx5/android/input/broadcast/ReturnKeyDrawableComponent.kt b/app/src/main/java/org/fcitx/fcitx5/android/input/broadcast/ReturnKeyDrawableComponent.kt index 2b104a126..972a4c8af 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/input/broadcast/ReturnKeyDrawableComponent.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/input/broadcast/ReturnKeyDrawableComponent.kt @@ -1,3 +1,7 @@ +/* + * SPDX-License-Identifier: LGPL-2.1-or-later + * SPDX-FileCopyrightText: Copyright 2021-2023 Fcitx5 for Android Contributors + */ package org.fcitx.fcitx5.android.input.broadcast import android.view.inputmethod.EditorInfo 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 cf8429ee1..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 @@ -1,30 +1,41 @@ +/* + * 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 -import org.fcitx.fcitx5.android.data.prefs.AppPrefs import org.fcitx.fcitx5.android.data.theme.Theme +import org.fcitx.fcitx5.android.input.AutoScaleTextView import org.fcitx.fcitx5.android.input.keyboard.CustomGestureView import org.fcitx.fcitx5.android.utils.pressHighlightDrawable -import splitties.views.dsl.core.* +import splitties.views.dsl.core.Ui +import splitties.views.dsl.core.add +import splitties.views.dsl.core.lParams +import splitties.views.dsl.core.matchParent +import splitties.views.dsl.core.view +import splitties.views.dsl.core.wrapContent import splitties.views.gravityCenter class CandidateItemUi(override val ctx: Context, theme: Theme) : Ui { - companion object { - val systemTouchSounds by AppPrefs.getInstance().keyboard.systemTouchSounds - } - - val text = textView { + val text = view(::AutoScaleTextView) { + scaleMode = AutoScaleTextView.Mode.Proportional textSize = 20f // sp isSingleLine = true gravity = gravityCenter - setTextColor(theme.keyTextColor) + setTextColor(theme.candidateTextColor) } override val root = view(::CustomGestureView) { - isSoundEffectsEnabled = systemTouchSounds 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 700bdfa8c..000000000 --- a/app/src/main/java/org/fcitx/fcitx5/android/input/candidates/HorizontalCandidateMode.kt +++ /dev/null @@ -1,13 +0,0 @@ -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/adapter/GridPagingCandidateViewAdapter.kt b/app/src/main/java/org/fcitx/fcitx5/android/input/candidates/adapter/GridPagingCandidateViewAdapter.kt deleted file mode 100644 index 4314f39e7..000000000 --- a/app/src/main/java/org/fcitx/fcitx5/android/input/candidates/adapter/GridPagingCandidateViewAdapter.kt +++ /dev/null @@ -1,36 +0,0 @@ -package org.fcitx.fcitx5.android.input.candidates.adapter - -import android.graphics.Paint -import android.graphics.Rect -import android.util.LruCache -import android.view.ViewGroup -import androidx.recyclerview.widget.GridLayoutManager -import org.fcitx.fcitx5.android.data.theme.Theme -import splitties.dimensions.dp -import splitties.views.dsl.core.matchParent - -abstract class GridPagingCandidateViewAdapter(theme: Theme) : PagingCandidateViewAdapter(theme) { - - // cache measureWidth - private val measuredWidths = LruCache(200) - - 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) } - } - } - - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { - return super.onCreateViewHolder(parent, viewType).apply { - itemView.apply { - layoutParams = GridLayoutManager.LayoutParams(matchParent, dp(40)) - } - } - } -} \ 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 614a756b4..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,3 +1,7 @@ +/* + * SPDX-License-Identifier: LGPL-2.1-or-later + * SPDX-FileCopyrightText: Copyright 2021-2024 Fcitx5 for Android Contributors + */ package org.fcitx.fcitx5.android.input.candidates.expanded import androidx.paging.PagingSource @@ -9,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)") @@ -17,7 +21,11 @@ class CandidatesPagingSource(val fcitx: FcitxConnection, val total: Int, val off getCandidates(startIndex, pageSize) } val prevKey = if (startIndex >= pageSize) startIndex - pageSize else null - val nextKey = if (startIndex + pageSize + 1 >= total) null else startIndex + pageSize + val nextKey = if (total > 0) { + if (startIndex + pageSize + 1 >= total) null else startIndex + pageSize + } else { + if (candidates.size < pageSize) null else startIndex + pageSize + } return LoadResult.Page(candidates.toList(), prevKey, nextKey) } 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 7d7036e36..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,3 +1,7 @@ +/* + * SPDX-License-Identifier: LGPL-2.1-or-later + * SPDX-FileCopyrightText: Copyright 2021-2024 Fcitx5 for Android Contributors + */ package org.fcitx.fcitx5.android.input.candidates.expanded import android.annotation.SuppressLint @@ -6,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 194f71d03..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 @@ -1,12 +1,13 @@ +/* + * 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 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/expanded/GridPagingCandidateViewAdapter.kt b/app/src/main/java/org/fcitx/fcitx5/android/input/candidates/expanded/GridPagingCandidateViewAdapter.kt new file mode 100644 index 000000000..f60ec40f2 --- /dev/null +++ b/app/src/main/java/org/fcitx/fcitx5/android/input/candidates/expanded/GridPagingCandidateViewAdapter.kt @@ -0,0 +1,47 @@ +/* + * SPDX-License-Identifier: LGPL-2.1-or-later + * SPDX-FileCopyrightText: Copyright 2021-2024 Fcitx5 for Android Contributors + */ + +package org.fcitx.fcitx5.android.input.candidates.expanded + +import android.graphics.Paint +import android.graphics.Rect +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 = 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] + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): CandidateViewHolder { + return super.onCreateViewHolder(parent, viewType).apply { + itemView.apply { + layoutParams = GridLayoutManager.LayoutParams(matchParent, dp(40)) + } + } + } +} \ No newline at end of file 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 61% 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 bb8daceed..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,14 +1,19 @@ -package org.fcitx.fcitx5.android.input.candidates.adapter +/* + * SPDX-License-Identifier: LGPL-2.1-or-later + * SPDX-FileCopyrightText: Copyright 2021-2024 Fcitx5 for Android Contributors + */ + +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() { @@ -22,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 @@ -34,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 af5625da2..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 @@ -1,7 +1,11 @@ +/* + * 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/decoration/FlexboxHorizontalDecoration.kt b/app/src/main/java/org/fcitx/fcitx5/android/input/candidates/expanded/decoration/FlexboxHorizontalDecoration.kt index 63088fa90..d12ad7c7f 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/input/candidates/expanded/decoration/FlexboxHorizontalDecoration.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/input/candidates/expanded/decoration/FlexboxHorizontalDecoration.kt @@ -1,3 +1,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.expanded.decoration import android.graphics.Canvas diff --git a/app/src/main/java/org/fcitx/fcitx5/android/input/candidates/expanded/decoration/FlexboxVerticalDecoration.kt b/app/src/main/java/org/fcitx/fcitx5/android/input/candidates/expanded/decoration/FlexboxVerticalDecoration.kt index 54ca6e93a..3d3f4841d 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/input/candidates/expanded/decoration/FlexboxVerticalDecoration.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/input/candidates/expanded/decoration/FlexboxVerticalDecoration.kt @@ -1,3 +1,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.expanded.decoration import android.graphics.Canvas diff --git a/app/src/main/java/org/fcitx/fcitx5/android/input/candidates/expanded/decoration/GridDecoration.kt b/app/src/main/java/org/fcitx/fcitx5/android/input/candidates/expanded/decoration/GridDecoration.kt index 33ab2870b..26eabfd21 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/input/candidates/expanded/decoration/GridDecoration.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/input/candidates/expanded/decoration/GridDecoration.kt @@ -1,3 +1,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.expanded.decoration import android.graphics.Canvas 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 1a54372f3..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,17 +1,21 @@ +/* + * SPDX-License-Identifier: LGPL-2.1-or-later + * 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 @@ -19,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 @@ -50,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 { @@ -91,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 @@ -106,40 +116,42 @@ 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 } } override fun onDetached() { bar.expandButtonStateMachine.push( ExpandedCandidatesDetached, - ExpandedCandidatesEmpty to (horizontalCandidate.adapter.total <= adapter.offset) + ExpandedCandidatesEmpty to (horizontalCandidate.adapter.total == adapter.offset) ) candidatesSubmitJob?.cancel() offsetJob?.cancel() 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 5f7726466..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,3 +1,8 @@ +/* + * SPDX-License-Identifier: LGPL-2.1-or-later + * SPDX-FileCopyrightText: Copyright 2021-2024 Fcitx5 for Android Contributors + */ + package org.fcitx.fcitx5.android.input.candidates.expanded.window import android.util.DisplayMetrics @@ -7,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 @@ -20,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) @@ -31,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) } } } @@ -56,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 c81f9c04c..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,3 +1,8 @@ +/* + * SPDX-License-Identifier: LGPL-2.1-or-later + * SPDX-FileCopyrightText: Copyright 2021-2024 Fcitx5 for Android Contributors + */ + package org.fcitx.fcitx5.android.input.candidates.expanded.window import android.content.res.Configuration @@ -5,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 @@ -26,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) } } } @@ -50,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 68% 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 99d9600e0..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,35 +1,49 @@ -package org.fcitx.fcitx5.android.input.candidates +/* + * 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 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 : @@ -72,15 +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 == 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 @@ -89,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 + } } } } @@ -99,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 } @@ -115,11 +136,7 @@ class HorizontalCandidateComponent : secondLayoutPassNeeded = false } } - refreshExpanded() - bar.expandButtonStateMachine.push( - ExpandedCandidatesUpdated, - ExpandedCandidatesEmpty to (adapter.total <= childCount) - ) + refreshExpanded(cnt) } // no need to override `generate{,Default}LayoutParams`, because HorizontalCandidateViewAdapter // guarantees ViewHolder's layoutParams to be `FlexboxLayoutManager.LayoutParams` @@ -176,5 +193,45 @@ class HorizontalCandidateComponent : } } adapter.updateCandidates(candidates, total) + // not sure why empty candidates won't trigger `FlexboxLayoutManager#onLayoutCompleted()` + if (candidates.isEmpty()) { + 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 69% 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 46790d233..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,4 +1,9 @@ -package org.fcitx.fcitx5.android.input.candidates.adapter +/* + * 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 android.annotation.SuppressLint import android.view.ViewGroup @@ -7,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 @@ -35,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 a62fe2a85..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 @@ -1,24 +1,28 @@ +/* + * SPDX-License-Identifier: LGPL-2.1-or-later + * SPDX-FileCopyrightText: Copyright 2021-2023 Fcitx5 for Android Contributors + */ package org.fcitx.fcitx5.android.input.clipboard -import android.graphics.PorterDuff -import android.graphics.PorterDuffColorFilter 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 import org.fcitx.fcitx5.android.R import org.fcitx.fcitx5.android.data.clipboard.db.ClipboardEntry import org.fcitx.fcitx5.android.data.theme.Theme -import splitties.resources.drawable +import org.fcitx.fcitx5.android.utils.DeviceUtil +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() { @@ -40,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) { @@ -52,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)) + } } } } @@ -67,47 +86,45 @@ 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 { - popupMenu?.dismiss() - val iconColor = ctx.styledColor(android.R.attr.colorControlNormal) - val iconColorFilter = PorterDuffColorFilter(iconColor, PorterDuff.Mode.SRC_IN) 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 { colorFilter = iconColorFilter } - 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) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q && !DeviceUtil.isSamsungOneUI && !DeviceUtil.isFlyme) { popup.setForceShowIcon(true) } - popupMenu = popup popup.setOnDismissListener { if (it === popupMenu) popupMenu = null } + popupMenu?.dismiss() + popupMenu = popup popup.show() true } @@ -118,10 +135,9 @@ abstract class ClipboardAdapter : fun onDetached() { popupMenu?.dismiss() + popupMenu = null } - abstract val theme: Theme - abstract fun onPaste(entry: ClipboardEntry) abstract fun onPin(id: Int) @@ -130,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 d9871dd4a..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 @@ -1,17 +1,21 @@ +/* + * SPDX-License-Identifier: LGPL-2.1-or-later + * SPDX-FileCopyrightText: Copyright 2021-2023 Fcitx5 for Android Contributors + */ package org.fcitx.fcitx5.android.input.clipboard import android.content.Context import android.content.res.ColorStateList import android.graphics.Color -import android.graphics.PorterDuff -import android.graphics.PorterDuffColorFilter import android.graphics.drawable.GradientDrawable import android.graphics.drawable.RippleDrawable 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 import splitties.views.dsl.constraintlayout.centerVertically import splitties.views.dsl.constraintlayout.constraintLayout @@ -20,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.imageResource +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 @@ -38,15 +43,25 @@ class ClipboardEntryUi(override val ctx: Context, private val theme: Theme) : Ui } val pin = imageView { - imageResource = R.drawable.ic_baseline_push_pin_24 - colorFilter = PorterDuffColorFilter(theme.altKeyTextColor, PorterDuff.Mode.SRC_IN) - alpha = 0.3f + imageDrawable = drawable(R.drawable.ic_baseline_push_pin_24)!!.apply { + setTint(theme.altKeyTextColor) + setAlpha(0.3f) + } } - 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 { @@ -58,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/ClipboardInstructionUi.kt b/app/src/main/java/org/fcitx/fcitx5/android/input/clipboard/ClipboardInstructionUi.kt index 0ae8c217d..0d8d9ef2f 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/input/clipboard/ClipboardInstructionUi.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/input/clipboard/ClipboardInstructionUi.kt @@ -1,15 +1,27 @@ +/* + * SPDX-License-Identifier: LGPL-2.1-or-later + * SPDX-FileCopyrightText: Copyright 2021-2023 Fcitx5 for Android Contributors + */ package org.fcitx.fcitx5.android.input.clipboard import android.content.Context -import android.graphics.PorterDuff -import android.graphics.PorterDuffColorFilter import org.fcitx.fcitx5.android.R import org.fcitx.fcitx5.android.data.theme.Theme import splitties.dimensions.dp +import splitties.resources.drawable +import splitties.views.dsl.constraintlayout.below +import splitties.views.dsl.constraintlayout.constraintLayout +import splitties.views.dsl.constraintlayout.endOfParent +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.imageView import splitties.views.dsl.core.styles.AndroidStyles -import splitties.views.dsl.constraintlayout.* -import splitties.views.dsl.core.* -import splitties.views.imageResource +import splitties.views.dsl.core.textView +import splitties.views.dsl.core.wrapContent +import splitties.views.imageDrawable import splitties.views.setPaddingDp sealed class ClipboardInstructionUi(override val ctx: Context, protected val theme: Theme) : Ui { @@ -45,8 +57,9 @@ sealed class ClipboardInstructionUi(override val ctx: Context, protected val the class Empty(ctx: Context, theme: Theme) : ClipboardInstructionUi(ctx, theme) { private val icon = imageView { - imageResource = R.drawable.ic_baseline_content_paste_24 - colorFilter = PorterDuffColorFilter(theme.altKeyTextColor, PorterDuff.Mode.SRC_IN) + imageDrawable = drawable(R.drawable.ic_baseline_content_paste_24)!!.apply { + setTint(theme.altKeyTextColor) + } } private val instructionText = textView { 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 56aeadd9a..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 @@ -1,3 +1,7 @@ +/* + * SPDX-License-Identifier: LGPL-2.1-or-later + * SPDX-FileCopyrightText: Copyright 2021-2023 Fcitx5 for Android Contributors + */ package org.fcitx.fcitx5.android.input.clipboard import org.fcitx.fcitx5.android.input.clipboard.ClipboardStateMachine.BooleanKey.ClipboardDbEmpty @@ -38,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 f43160f54..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 @@ -1,3 +1,7 @@ +/* + * SPDX-License-Identifier: LGPL-2.1-or-later + * SPDX-FileCopyrightText: Copyright 2021-2023 Fcitx5 for Android Contributors + */ package org.fcitx.fcitx5.android.input.clipboard import android.content.Context @@ -14,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 @@ -44,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 b65dc23d1..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 @@ -1,6 +1,11 @@ +/* + * SPDX-License-Identifier: LGPL-2.1-or-later + * SPDX-FileCopyrightText: Copyright 2021-2023 Fcitx5 for Android Contributors + */ 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 @@ -16,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 @@ -25,39 +31,41 @@ 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 import org.fcitx.fcitx5.android.input.dependency.theme +import org.fcitx.fcitx5.android.input.keyboard.KeyboardWindow 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() { private val service: FcitxInputMethodService by manager.inputMethodService() + private val windowManager: InputWindowManager by manager.must() private val theme by manager.theme() 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( @@ -65,7 +73,13 @@ class ClipboardWindow : InputWindow.ExtendedInputWindow() { ) } - private val clipboardEnabledPref = AppPrefs.getInstance().clipboard.clipboardListening + 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() } @@ -73,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) } } @@ -87,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) @@ -96,6 +124,7 @@ class ClipboardWindow : InputWindow.ExtendedInputWindow() { override fun onPaste(entry: ClipboardEntry) { service.commitText(entry.text) + if (clipboardReturnAfterPaste) windowManager.attachWindow(KeyboardWindow) } } } @@ -148,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 { @@ -178,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 { @@ -213,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 { @@ -239,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/clipboard/SpacesItemDecoration.kt b/app/src/main/java/org/fcitx/fcitx5/android/input/clipboard/SpacesItemDecoration.kt index 157ff96b4..4766a5e31 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/input/clipboard/SpacesItemDecoration.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/input/clipboard/SpacesItemDecoration.kt @@ -1,3 +1,7 @@ +/* + * SPDX-License-Identifier: LGPL-2.1-or-later + * SPDX-FileCopyrightText: Copyright 2021-2023 Fcitx5 for Android Contributors + */ package org.fcitx.fcitx5.android.input.clipboard import android.graphics.Rect diff --git a/app/src/main/java/org/fcitx/fcitx5/android/input/cursor/CursorRange.kt b/app/src/main/java/org/fcitx/fcitx5/android/input/cursor/CursorRange.kt index 78038dd5d..fbb1cd647 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/input/cursor/CursorRange.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/input/cursor/CursorRange.kt @@ -1,3 +1,7 @@ +/* + * SPDX-License-Identifier: LGPL-2.1-or-later + * SPDX-FileCopyrightText: Copyright 2021-2023 Fcitx5 for Android Contributors + */ package org.fcitx.fcitx5.android.input.cursor @JvmInline diff --git a/app/src/main/java/org/fcitx/fcitx5/android/input/cursor/CursorTracker.kt b/app/src/main/java/org/fcitx/fcitx5/android/input/cursor/CursorTracker.kt index 238e88f39..5da72015d 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/input/cursor/CursorTracker.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/input/cursor/CursorTracker.kt @@ -1,3 +1,7 @@ +/* + * SPDX-License-Identifier: LGPL-2.1-or-later + * SPDX-FileCopyrightText: Copyright 2021-2023 Fcitx5 for Android Contributors + */ package org.fcitx.fcitx5.android.input.cursor import timber.log.Timber @@ -33,7 +37,6 @@ class CursorTracker { } fun consume(start: Int, end: Int = start): Boolean { - Timber.d("consume [$start,$end]") if (current.rangeEquals(start, end)) { return true } @@ -46,7 +49,7 @@ class CursorTracker { } current.update(start, end) if (!matched) { - Timber.d("unable to consume") + Timber.d("unable to consume [$start,$end]") } return matched } diff --git a/app/src/main/java/org/fcitx/fcitx5/android/input/dependency/Functions.kt b/app/src/main/java/org/fcitx/fcitx5/android/input/dependency/Functions.kt index 912db9e53..3a2be0395 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/input/dependency/Functions.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/input/dependency/Functions.kt @@ -1,3 +1,7 @@ +/* + * SPDX-License-Identifier: LGPL-2.1-or-later + * SPDX-FileCopyrightText: Copyright 2021-2023 Fcitx5 for Android Contributors + */ package org.fcitx.fcitx5.android.input.dependency import android.view.ContextThemeWrapper diff --git a/app/src/main/java/org/fcitx/fcitx5/android/input/dependency/UniqueViewComponent.kt b/app/src/main/java/org/fcitx/fcitx5/android/input/dependency/UniqueViewComponent.kt index 98533cce4..0930d1146 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/input/dependency/UniqueViewComponent.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/input/dependency/UniqueViewComponent.kt @@ -1,3 +1,7 @@ +/* + * SPDX-License-Identifier: LGPL-2.1-or-later + * SPDX-FileCopyrightText: Copyright 2021-2023 Fcitx5 for Android Contributors + */ package org.fcitx.fcitx5.android.input.dependency import android.view.View diff --git a/app/src/main/java/org/fcitx/fcitx5/android/input/dialog/AddMoreInputMethodsPrompt.kt b/app/src/main/java/org/fcitx/fcitx5/android/input/dialog/AddMoreInputMethodsPrompt.kt index 50499496e..c745030ca 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/input/dialog/AddMoreInputMethodsPrompt.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/input/dialog/AddMoreInputMethodsPrompt.kt @@ -1,3 +1,7 @@ +/* + * SPDX-License-Identifier: LGPL-2.1-or-later + * SPDX-FileCopyrightText: Copyright 2021-2023 Fcitx5 for Android Contributors + */ package org.fcitx.fcitx5.android.input.dialog import android.app.AlertDialog diff --git a/app/src/main/java/org/fcitx/fcitx5/android/input/dialog/InputMethodData.kt b/app/src/main/java/org/fcitx/fcitx5/android/input/dialog/InputMethodData.kt index fb2b6c815..7e82ee491 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/input/dialog/InputMethodData.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/input/dialog/InputMethodData.kt @@ -1,3 +1,7 @@ +/* + * SPDX-License-Identifier: LGPL-2.1-or-later + * SPDX-FileCopyrightText: Copyright 2021-2023 Fcitx5 for Android Contributors + */ package org.fcitx.fcitx5.android.input.dialog import android.content.Context diff --git a/app/src/main/java/org/fcitx/fcitx5/android/input/dialog/InputMethodEntryUi.kt b/app/src/main/java/org/fcitx/fcitx5/android/input/dialog/InputMethodEntryUi.kt index b77e6c873..130d67830 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/input/dialog/InputMethodEntryUi.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/input/dialog/InputMethodEntryUi.kt @@ -1,3 +1,7 @@ +/* + * SPDX-License-Identifier: LGPL-2.1-or-later + * SPDX-FileCopyrightText: Copyright 2021-2023 Fcitx5 for Android Contributors + */ package org.fcitx.fcitx5.android.input.dialog import android.content.Context diff --git a/app/src/main/java/org/fcitx/fcitx5/android/input/dialog/InputMethodListAdapter.kt b/app/src/main/java/org/fcitx/fcitx5/android/input/dialog/InputMethodListAdapter.kt index 7a149be63..de80546cd 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/input/dialog/InputMethodListAdapter.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/input/dialog/InputMethodListAdapter.kt @@ -1,3 +1,7 @@ +/* + * SPDX-License-Identifier: LGPL-2.1-or-later + * SPDX-FileCopyrightText: Copyright 2021-2023 Fcitx5 for Android Contributors + */ package org.fcitx.fcitx5.android.input.dialog import android.view.ViewGroup diff --git a/app/src/main/java/org/fcitx/fcitx5/android/input/dialog/InputMethodPickerDialog.kt b/app/src/main/java/org/fcitx/fcitx5/android/input/dialog/InputMethodPickerDialog.kt index cd8e93d91..35878541c 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/input/dialog/InputMethodPickerDialog.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/input/dialog/InputMethodPickerDialog.kt @@ -1,3 +1,7 @@ +/* + * SPDX-License-Identifier: LGPL-2.1-or-later + * SPDX-FileCopyrightText: Copyright 2021-2023 Fcitx5 for Android Contributors + */ package org.fcitx.fcitx5.android.input.dialog import android.app.AlertDialog diff --git a/app/src/main/java/org/fcitx/fcitx5/android/input/dialog/SingleDividerDecoration.kt b/app/src/main/java/org/fcitx/fcitx5/android/input/dialog/SingleDividerDecoration.kt index 1db1742b9..3bf68304d 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/input/dialog/SingleDividerDecoration.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/input/dialog/SingleDividerDecoration.kt @@ -1,3 +1,7 @@ +/* + * SPDX-License-Identifier: LGPL-2.1-or-later + * SPDX-FileCopyrightText: Copyright 2021-2023 Fcitx5 for Android Contributors + */ package org.fcitx.fcitx5.android.input.dialog import android.graphics.Canvas 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 dd897b55a..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,125 +1,93 @@ +/* + * SPDX-License-Identifier: LGPL-2.1-or-later + * 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.PorterDuff -import android.graphics.PorterDuffColorFilter -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.views.dsl.constraintlayout.* -import splitties.views.dsl.core.* -import splitties.views.gravityCenter -import splitties.views.imageResource -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 +import splitties.views.dsl.constraintlayout.above +import splitties.views.dsl.constraintlayout.below +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.leftToRightOf +import splitties.views.dsl.constraintlayout.rightOfParent +import splitties.views.dsl.constraintlayout.rightToLeftOf +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.lParams + +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)) + private fun iconButton(@DrawableRes icon: Int, altStyle: Boolean = false) = + TextEditingButton(ctx, theme, ripple, border, radius, altStyle).apply { + setIcon(icon) } - } - class GImageButton(context: Context) : CustomGestureView(context) { - val image = imageView { - isClickable = false - isFocusable = false - } - - 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.imageResource = icon - image.colorFilter = PorterDuffColorFilter(theme.altKeyTextColor, PorterDuff.Mode.SRC_IN) - 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) + 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 { add(leftButton, lParams { @@ -222,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 112ee6565..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 @@ -1,21 +1,23 @@ +/* + * SPDX-License-Identifier: LGPL-2.1-or-later + * SPDX-FileCopyrightText: Copyright 2021-2023 Fcitx5 for Android Contributors + */ 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 import org.fcitx.fcitx5.android.input.wm.InputWindow import org.fcitx.fcitx5.android.input.wm.InputWindowManager -import org.fcitx.fcitx5.android.utils.inputConnection import org.mechdancer.dependency.manager.must class TextEditingWindow : InputWindow.ExtendedInputWindow(), @@ -23,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 @@ -34,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) } @@ -57,32 +67,30 @@ class TextEditingWindow : InputWindow.ExtendedInputWindow(), service.cancelSelection() } else { userSelection = !userSelection - updateSelection(hasSelection, userSelection) + updateSelection(false, userSelection) } } selectAllButton.setOnClickListener { // activate select button after operation userSelection = true - service.inputConnection?.performContextMenuAction(android.R.id.selectAll) + service.currentInputConnection?.performContextMenuAction(android.R.id.selectAll) } cutButton.setOnClickListener { // deactivate select button after operation userSelection = false - service.inputConnection?.performContextMenuAction(android.R.id.cut) + service.currentInputConnection?.performContextMenuAction(android.R.id.cut) } copyButton.setOnClickListener { userSelection = false - service.inputConnection?.performContextMenuAction(android.R.id.copy) + service.currentInputConnection?.performContextMenuAction(android.R.id.copy) } pasteButton.setOnClickListener { userSelection = false - service.inputConnection?.performContextMenuAction(android.R.id.paste) + service.currentInputConnection?.performContextMenuAction(android.R.id.paste) } backspaceButton.onClickWithRepeating { userSelection = false - fcitx.launchOnReady { - it.sendKey(FcitxKeyMapping.FcitxKey_BackSpace) - } + service.sendDownUpKeyEvents(KeyEvent.KEYCODE_DEL) } clipboardButton.setOnClickListener { windowManager.attachWindow(ClipboardWindow()) @@ -93,8 +101,8 @@ class TextEditingWindow : InputWindow.ExtendedInputWindow(), override fun onCreateView(): View = ui.root override fun onAttached() { - val info = service.selection.latest - onSelectionUpdate(info.start, info.end) + val range = service.currentInputSelection + onSelectionUpdate(range.start, range.end) } override fun onDetached() {} diff --git a/app/src/main/java/org/fcitx/fcitx5/android/input/editorinfo/EditorInfoParser.kt b/app/src/main/java/org/fcitx/fcitx5/android/input/editorinfo/EditorInfoParser.kt index c14b43c36..ce06a89b4 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/input/editorinfo/EditorInfoParser.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/input/editorinfo/EditorInfoParser.kt @@ -1,3 +1,7 @@ +/* + * SPDX-License-Identifier: LGPL-2.1-or-later + * SPDX-FileCopyrightText: Copyright 2021-2023 Fcitx5 for Android Contributors + */ package org.fcitx.fcitx5.android.input.editorinfo import android.os.Build @@ -12,6 +16,8 @@ import java.lang.reflect.Modifier object EditorInfoParser { + private const val NULL = "null" + private fun Field.isStatic() = Modifier.isStatic(modifiers) private val EDITOR_INFO_MEMBER = EditorInfo::class.java.declaredFields @@ -78,7 +84,7 @@ object EditorInfoParser { } private fun parseSurroundingText(st: Any?): String { - if (st == null) return "null" + if (st == null) return NULL if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S || st !is SurroundingText) return st.toString() return st.run { "text=$text\noffset=$offset\nselectionStart=$selectionStart\nselectionEnd=$selectionEnd" @@ -86,20 +92,19 @@ object EditorInfoParser { } private fun parseStringArray(arr: Any?): String { - if (arr == null) return "null" + if (arr == null) return NULL if (arr !is Array<*> || arr[0] !is String) return arr.toString() return arr.joinToString() } private fun parseBundle(bundle: Any?): String { - if (bundle == null) return "null" + if (bundle == null) return NULL if (bundle !is Bundle) return bundle.toString() @Suppress("DEPRECATION") return bundle.keySet().joinToString("\n") { "$it => ${bundle.get(it)}" } } fun parse(info: EditorInfo): Map = EDITOR_INFO_MEMBER - .filter { !Modifier.isStatic(it.modifiers) && it.name != "CREATOR" } .associate { it.isAccessible = true val name = it.name @@ -110,7 +115,7 @@ object EditorInfoParser { "initialCapsMode" -> parseCapsMode(it.getInt(info)) "inputType" -> parseInputType(it.getInt(info)) "mInitialSurroundingText" -> parseSurroundingText(it.get(info)) - else -> it.get(info)?.toString() ?: "null" + else -> it.get(info)?.toString() ?: NULL } } } 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 a18cfe9e4..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 @@ -1,10 +1,18 @@ +/* + * SPDX-License-Identifier: LGPL-2.1-or-later + * SPDX-FileCopyrightText: Copyright 2021-2023 Fcitx5 for Android Contributors + */ package org.fcitx.fcitx5.android.input.editorinfo 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/editorinfo/EditorInfoWindow.kt b/app/src/main/java/org/fcitx/fcitx5/android/input/editorinfo/EditorInfoWindow.kt index 67c73770d..fd13a810c 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/input/editorinfo/EditorInfoWindow.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/input/editorinfo/EditorInfoWindow.kt @@ -1,3 +1,7 @@ +/* + * SPDX-License-Identifier: LGPL-2.1-or-later + * SPDX-FileCopyrightText: Copyright 2021-2023 Fcitx5 for Android Contributors + */ package org.fcitx.fcitx5.android.input.editorinfo import org.fcitx.fcitx5.android.R @@ -22,7 +26,7 @@ class EditorInfoWindow : InputWindow.ExtendedInputWindow() { override fun onCreateView() = ui.root override fun onAttached() { - ui.setValues(EditorInfoParser.parse(service.editorInfo)) + ui.setValues(EditorInfoParser.parse(service.currentInputEditorInfo)) } override fun onDetached() {} 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 e9d3f68df..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,3 +1,7 @@ +/* + * SPDX-License-Identifier: LGPL-2.1-or-later + * SPDX-FileCopyrightText: Copyright 2021-2025 Fcitx5 for Android Contributors + */ package org.fcitx.fcitx5.android.input.keyboard import android.content.Context @@ -8,11 +12,14 @@ import androidx.annotation.CallSuper import androidx.annotation.DrawableRes import androidx.constraintlayout.widget.ConstraintLayout import androidx.core.view.children +import androidx.core.view.updateLayoutParams import org.fcitx.fcitx5.android.core.FcitxKeyMapping import org.fcitx.fcitx5.android.core.InputMethodEntry 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 @@ -44,10 +51,23 @@ abstract class BaseKeyboard( var keyActionListener: KeyActionListener? = null - private val popupOnKeyPress by AppPrefs.getInstance().keyboard.popupOnKeyPress - private val swipeSymbolDirection by AppPrefs.getInstance().keyboard.swipeSymbolDirection + private val prefs = AppPrefs.getInstance() - private val vivoKeypressWorkaround by AppPrefs.getInstance().advanced.vivoKeypressWorkaround + private val popupOnKeyPress by prefs.keyboard.popupOnKeyPress + 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 @@ -70,6 +90,7 @@ abstract class BaseKeyboard( keyRows = keyLayout.map { row -> val keyViews = row.map(::createKeyView) constraintLayout Row@{ + var totalWidth = 0f keyViews.forEachIndexed { index, view -> add(view, lParams { centerVertically() @@ -89,6 +110,25 @@ abstract class BaseKeyboard( val def = row[index] matchConstraintPercentWidth = def.appearance.percentWidth }) + row[index].appearance.percentWidth.let { + // 0f means fill remaining space, thus does not need expanding + totalWidth += if (it != 0f) it else 1f + } + } + if (expandKeypressArea && totalWidth < 1f) { + val free = (1f - totalWidth) / 2f + keyViews.first().apply { + updateLayoutParams { + matchConstraintPercentWidth += free + } + layoutMarginLeft = free / (row.first().appearance.percentWidth + free) + } + keyViews.last().apply { + updateLayoutParams { + matchConstraintPercentWidth += free + } + layoutMarginRight = free / (row.last().appearance.percentWidth + free) + } } } } @@ -101,29 +141,40 @@ abstract class BaseKeyboard( centerHorizontally() }) } + spaceSwipeMoveCursor.registerOnChangeListener(spaceSwipeChangeListener) } private fun createKeyView(def: KeyDef): KeyView { return when (def.appearance) { is KeyDef.Appearance.AltText -> AltTextKeyView(context, theme, def.appearance) + is KeyDef.Appearance.ImageText -> ImageTextKeyView(context, theme, def.appearance) is KeyDef.Appearance.Text -> TextKeyView(context, theme, def.appearance) is KeyDef.Appearance.Image -> ImageKeyView(context, theme, def.appearance) }.apply { + soundEffect = when (def) { + is SpaceKey -> InputFeedbacks.SoundEffect.SpaceBar + is MiniSpaceKey -> InputFeedbacks.SoundEffect.SpaceBar + is BackspaceKey -> InputFeedbacks.SoundEffect.Delete + is ReturnKey -> InputFeedbacks.SoundEffect.Return + 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 } @@ -136,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 } @@ -168,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 2de7cc69c..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,16 +1,21 @@ +/* + * SPDX-License-Identifier: LGPL-2.1-or-later + * 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 @@ -29,7 +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.inputConnection +import org.fcitx.fcitx5.android.utils.switchToNextIME import org.mechdancer.dependency.Dependent import org.mechdancer.dependency.UniqueComponent import org.mechdancer.dependency.manager.ManagedHandler @@ -46,21 +51,28 @@ 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 - private val spaceKeyLongPressBehavior by AppPrefs.getInstance().keyboard.spaceKeyLongPressBehavior + + private val kbdPrefs = AppPrefs.getInstance().keyboard + + private val spaceKeyLongPressBehavior by kbdPrefs.spaceKeyLongPressBehavior + private val langSwitchKeyBehavior by kbdPrefs.langSwitchKeyBehavior 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.inputConnection?.finishComposingText() + service.finishComposing() reset() } else { if (!select(0)) reset() @@ -70,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)) } } } @@ -78,41 +90,55 @@ class CommonKeyActionListener : val listener by lazy { KeyActionListener { action, _ -> when (action) { - is FcitxKeyAction -> fcitx.launchOnReady { - it.sendKey(action.act, KeyState.Virtual.state) + is FcitxKeyAction -> service.postFcitxJob { + sendKey(action.act, action.states.states, action.code) } - is SymAction -> fcitx.launchOnReady { - it.sendKey(action.sym, action.states) + is SymAction -> service.postFcitxJob { + sendKey(action.sym, action.states) } - is CommitAction -> fcitx.launchOnReady { - it.commitAndReset() + is CommitAction -> service.postFcitxJob { + commitAndReset() service.lifecycleScope.launch { service.commitText(action.text) } } - is QuickPhraseAction -> fcitx.launchOnReady { - it.commitAndReset() - it.triggerQuickPhrase() + is QuickPhraseAction -> service.postFcitxJob { + commitAndReset() + triggerQuickPhrase() } - is UnicodeAction -> fcitx.launchOnReady { - it.commitAndReset() - it.triggerUnicode() + is UnicodeAction -> service.postFcitxJob { + commitAndReset() + triggerUnicode() } - is LangSwitchAction -> fcitx.launchOnReady { - if (it.enabledIme().size < 2) { - service.lifecycleScope.launch { - inputView.showDialog(AddMoreInputMethodsPrompt.build(context)) + is LangSwitchAction -> { + when (langSwitchKeyBehavior) { + LangSwitchBehavior.Enumerate -> { + service.postFcitxJob { + if (enabledIme().size < 2) { + service.lifecycleScope.launch { + service.showDialog(AddMoreInputMethodsPrompt.build(context)) + } + } else { + enumerateIme() + } + } + } + LangSwitchBehavior.ToggleActivate -> { + service.postFcitxJob { + toggleIme() + } + } + LangSwitchBehavior.NextInputMethodApp -> { + service.switchToNextIME() } - } else { - it.enumerateIme() } } is ShowInputMethodPickerAction -> showInputMethodPicker() is MoveSelectionAction -> { when (backspaceSwipeState) { Stopped -> { - val preeditEmpty = fcitx.runImmediately { - clientPreeditCached.isEmpty() && inputPanelCached.preedit.isEmpty() - } - backspaceSwipeState = if (preeditEmpty) { + backspaceSwipeState = if ( + preeditState.isEmpty && + horizontalCandidate.adapter.total <= 0 // total is -1 on initialization + ) { service.applySelectionOffset(action.start, action.end) Selection } else { @@ -130,7 +156,7 @@ class CommonKeyActionListener : Stopped -> {} Selection -> service.deleteSelection() Reset -> if (action.totalCnt < 0) { // swipe left - fcitx.launchOnReady { it.reset() } + service.postFcitxJob { reset() } } } backspaceSwipeState = Stopped @@ -147,11 +173,11 @@ class CommonKeyActionListener : is SpaceLongPressAction -> { when (spaceKeyLongPressBehavior) { SpaceLongPressBehavior.None -> {} - SpaceLongPressBehavior.Enumerate -> fcitx.launchOnReady { - it.enumerateIme() + SpaceLongPressBehavior.Enumerate -> service.postFcitxJob { + enumerateIme() } - SpaceLongPressBehavior.ToggleActivate -> fcitx.launchOnReady { - it.toggleIme() + SpaceLongPressBehavior.ToggleActivate -> service.postFcitxJob { + toggleIme() } SpaceLongPressBehavior.ShowPicker -> showInputMethodPicker() } 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 74ac000d6..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 @@ -1,11 +1,12 @@ +/* + * SPDX-License-Identifier: LGPL-2.1-or-later + * SPDX-FileCopyrightText: Copyright 2021-2023 Fcitx5 for Android Contributors + */ package org.fcitx.fcitx5.android.input.keyboard import android.annotation.SuppressLint import android.content.Context -import android.os.Build import android.os.SystemClock -import android.os.VibrationEffect -import android.view.HapticFeedbackConstants import android.view.MotionEvent import android.view.View import android.view.ViewConfiguration @@ -16,9 +17,9 @@ import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.isActive import kotlinx.coroutines.launch +import org.fcitx.fcitx5.android.data.InputFeedbacks import org.fcitx.fcitx5.android.data.prefs.AppPrefs import org.fcitx.fcitx5.android.input.keyboard.CustomGestureView.OnGestureListener -import org.fcitx.fcitx5.android.utils.vibrator open class CustomGestureView(ctx: Context) : FrameLayout(ctx) { @@ -57,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 @@ -84,10 +88,14 @@ open class CustomGestureView(ctx: Context) : FrameLayout(ctx) { var onRepeatListener: ((View) -> Unit)? = null var onGestureListener: OnGestureListener? = null + var soundEffect: InputFeedbacks.SoundEffect = InputFeedbacks.SoundEffect.Standard + private val touchSlop: Float = ViewConfiguration.get(ctx).scaledTouchSlop.toFloat() init { - isSoundEffectsEnabled = systemTouchSounds + // disable system sound effect and haptic feedback + isSoundEffectsEnabled = false + isHapticFeedbackEnabled = false } override fun setEnabled(enabled: Boolean) { @@ -97,34 +105,6 @@ open class CustomGestureView(ctx: Context) : FrameLayout(ctx) { } } - private fun hapticFeedback(feedback: Int = HapticFeedbackConstants.KEYBOARD_TAP) { - if (!buttonHapticFeedback) return - val ms = when (feedback) { - HapticFeedbackConstants.KEYBOARD_TAP -> buttonPressVibrationMilliseconds - HapticFeedbackConstants.LONG_PRESS -> buttonLongPressVibrationMilliseconds - else -> return - }.toLong() - if (ms == 0L) { - performHapticFeedback(feedback, HapticFeedbackFlags) - return - } - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - val amp = if (vibrator.hasAmplitudeControl()) { - when (feedback) { - HapticFeedbackConstants.KEYBOARD_TAP -> buttonPressVibrationAmplitude - HapticFeedbackConstants.LONG_PRESS -> buttonLongPressVibrationAmplitude - else -> return - }.let { if (it == 0) VibrationEffect.DEFAULT_AMPLITUDE else it } - } else { - VibrationEffect.DEFAULT_AMPLITUDE - } - vibrator.vibrate(VibrationEffect.createOneShot(ms, amp)) - } else { - @Suppress("DEPRECATION") - vibrator.vibrate(ms) - } - } - private fun pointInView(x: Float, y: Float): Boolean { return -touchSlop <= x && -touchSlop <= y && @@ -166,13 +146,16 @@ open class CustomGestureView(ctx: Context) : FrameLayout(ctx) { if (!isEnabled) return false drawableHotspotChanged(x, y) isPressed = true - hapticFeedback() + InputFeedbacks.hapticFeedback(this) + InputFeedbacks.soundEffect(soundEffect) dispatchGestureEvent(GestureType.Down, x, y) if (longPressEnabled) { longPressJob?.cancel() longPressJob = lifecycleScope.launch { delay(longPressDelay.toLong()) - hapticFeedback(HapticFeedbackConstants.LONG_PRESS) + if (longPressFeedbackEnabled) { + InputFeedbacks.hapticFeedback(this@CustomGestureView, true) + } longPressTriggered = performLongClick() } } @@ -197,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 || @@ -323,20 +307,6 @@ open class CustomGestureView(ctx: Context) : FrameLayout(ctx) { companion object { val longPressDelay by AppPrefs.getInstance().keyboard.longPressDelay - val systemTouchSounds by AppPrefs.getInstance().keyboard.systemTouchSounds - val buttonHapticFeedback by AppPrefs.getInstance().keyboard.buttonHapticFeedback - val buttonPressVibrationMilliseconds by AppPrefs.getInstance().keyboard.buttonPressVibrationMilliseconds - val buttonLongPressVibrationMilliseconds by AppPrefs.getInstance().keyboard.buttonLongPressVibrationMilliseconds - val buttonPressVibrationAmplitude by AppPrefs.getInstance().keyboard.buttonPressVibrationAmplitude - val buttonLongPressVibrationAmplitude by AppPrefs.getInstance().keyboard.buttonLongPressVibrationAmplitude - const val RepeatInterval = 50L - - val HapticFeedbackFlags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - HapticFeedbackConstants.FLAG_IGNORE_VIEW_SETTING - } else { - @Suppress("DEPRECATION") - HapticFeedbackConstants.FLAG_IGNORE_GLOBAL_SETTING or HapticFeedbackConstants.FLAG_IGNORE_VIEW_SETTING - } } } 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 936df4827..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 @@ -1,31 +1,35 @@ +/* + * SPDX-License-Identifier: LGPL-2.1-or-later + * SPDX-FileCopyrightText: Copyright 2021-2023 Fcitx5 for Android Contributors + */ 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() - object QuickPhraseAction : KeyAction() + data object QuickPhraseAction : KeyAction() - object UnicodeAction : KeyAction() + data object UnicodeAction : KeyAction() - object LangSwitchAction : KeyAction() + data object LangSwitchAction : KeyAction() - object ShowInputMethodPickerAction : KeyAction() + data object ShowInputMethodPickerAction : KeyAction() data class LayoutSwitchAction(val act: String = "") : KeyAction() @@ -35,5 +39,5 @@ sealed class KeyAction { data class PickerSwitchAction(val key: PickerWindow.Key? = null) : KeyAction() - object SpaceLongPressAction: KeyAction() + data object SpaceLongPressAction : KeyAction() } diff --git a/app/src/main/java/org/fcitx/fcitx5/android/input/keyboard/KeyActionListener.kt b/app/src/main/java/org/fcitx/fcitx5/android/input/keyboard/KeyActionListener.kt index 72921482a..863defefa 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/input/keyboard/KeyActionListener.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/input/keyboard/KeyActionListener.kt @@ -1,3 +1,7 @@ +/* + * SPDX-License-Identifier: LGPL-2.1-or-later + * SPDX-FileCopyrightText: Copyright 2021-2023 Fcitx5 for Android Contributors + */ package org.fcitx.fcitx5.android.input.keyboard fun interface KeyActionListener { diff --git a/app/src/main/java/org/fcitx/fcitx5/android/input/keyboard/KeyDef.kt b/app/src/main/java/org/fcitx/fcitx5/android/input/keyboard/KeyDef.kt index f2667e134..211a2b37f 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/input/keyboard/KeyDef.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/input/keyboard/KeyDef.kt @@ -1,7 +1,12 @@ +/* + * SPDX-License-Identifier: LGPL-2.1-or-later + * SPDX-FileCopyrightText: Copyright 2021-2023 Fcitx5 for Android Contributors + */ package org.fcitx.fcitx5.android.input.keyboard import android.graphics.Typeface import androidx.annotation.DrawableRes +import org.fcitx.fcitx5.android.data.InputFeedbacks open class KeyDef( val appearance: Appearance, @@ -14,6 +19,7 @@ open class KeyDef( val border: Border, val margin: Boolean, val viewId: Int, + val soundEffect: InputFeedbacks.SoundEffect ) { enum class Variant { Normal, AltForeground, Alternative, Accent @@ -35,8 +41,9 @@ open class KeyDef( variant: Variant = Variant.Normal, border: Border = Border.Default, margin: Boolean = true, - viewId: Int = -1 - ) : Appearance(percentWidth, variant, border, margin, viewId) + viewId: Int = -1, + soundEffect: InputFeedbacks.SoundEffect = InputFeedbacks.SoundEffect.Standard + ) : Appearance(percentWidth, variant, border, margin, viewId, soundEffect) class AltText( displayText: String, @@ -51,10 +58,28 @@ open class KeyDef( variant: Variant = Variant.Normal, border: Border = Border.Default, margin: Boolean = true, - viewId: Int = -1 + viewId: Int = -1, ) : Text(displayText, textSize, textStyle, percentWidth, variant, border, margin, viewId) class Image( + @DrawableRes + val src: Int, + percentWidth: Float = 0.1f, + variant: Variant = Variant.Normal, + border: Border = Border.Default, + margin: Boolean = true, + viewId: Int = -1, + soundEffect: InputFeedbacks.SoundEffect = InputFeedbacks.SoundEffect.Standard + ) : Appearance(percentWidth, variant, border, margin, viewId, soundEffect) + + class ImageText( + displayText: String, + textSize: Float, + /** + * `Int` constants in [Typeface]. + * Can be `NORMAL`(default), `BOLD`, `ITALIC` or `BOLD_ITALIC` + */ + textStyle: Int = Typeface.NORMAL, @DrawableRes val src: Int, percentWidth: Float = 0.1f, @@ -62,7 +87,7 @@ open class KeyDef( border: Border = Border.Default, margin: Boolean = true, viewId: Int = -1 - ) : Appearance(percentWidth, variant, border, margin, viewId) + ) : Text(displayText, textSize, textStyle, percentWidth, variant, border, margin, viewId) } sealed class Behavior { diff --git a/app/src/main/java/org/fcitx/fcitx5/android/input/keyboard/KeyDefPreset.kt b/app/src/main/java/org/fcitx/fcitx5/android/input/keyboard/KeyDefPreset.kt index bb79a0fba..96b1ba94b 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/input/keyboard/KeyDefPreset.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/input/keyboard/KeyDefPreset.kt @@ -1,3 +1,7 @@ +/* + * SPDX-License-Identifier: LGPL-2.1-or-later + * SPDX-FileCopyrightText: Copyright 2021-2023 Fcitx5 for Android Contributors + */ package org.fcitx.fcitx5.android.input.keyboard import android.graphics.Typeface @@ -7,6 +11,7 @@ import org.fcitx.fcitx5.android.core.FcitxKeyMapping 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.data.InputFeedbacks 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.picker.PickerWindow @@ -128,7 +133,8 @@ class BackspaceKey( src = R.drawable.ic_baseline_backspace_24, percentWidth = percentWidth, variant = variant, - viewId = R.id.button_backspace + viewId = R.id.button_backspace, + soundEffect = InputFeedbacks.SoundEffect.Delete ), setOf( Behavior.Press(KeyAction.SymAction(KeySym(FcitxKeyMapping.FcitxKey_BackSpace))), @@ -152,11 +158,12 @@ class CommaKey( percentWidth: Float, variant: Variant, ) : KeyDef( - Appearance.Text( + Appearance.ImageText( displayText = ",", textSize = 23f, percentWidth = percentWidth, - variant = variant + variant = variant, + src = R.drawable.ic_baseline_tag_faces_24 ), setOf( Behavior.Press(KeyAction.FcitxKeyAction(",")) @@ -203,7 +210,8 @@ class SpaceKey : KeyDef( textSize = 13f, percentWidth = 0f, border = Border.Special, - viewId = R.id.button_space + viewId = R.id.button_space, + soundEffect = InputFeedbacks.SoundEffect.SpaceBar ), setOf( Behavior.Press(KeyAction.SymAction(KeySym(FcitxKeyMapping.FcitxKey_space))), @@ -217,7 +225,8 @@ class ReturnKey(percentWidth: Float = 0.15f) : KeyDef( percentWidth = percentWidth, variant = Variant.Accent, border = Border.Special, - viewId = R.id.button_return + viewId = R.id.button_return, + soundEffect = InputFeedbacks.SoundEffect.Return ), setOf( Behavior.Press(KeyAction.SymAction(KeySym(FcitxKeyMapping.FcitxKey_Return))) 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 4e15cd28f..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 @@ -1,34 +1,55 @@ +/* + * SPDX-License-Identifier: LGPL-2.1-or-later + * SPDX-FileCopyrightText: Copyright 2021-2023 Fcitx5 for Android Contributors + */ package org.fcitx.fcitx5.android.input.keyboard import android.annotation.SuppressLint import android.content.Context import android.content.res.ColorStateList import android.content.res.Configuration -import android.graphics.* -import android.graphics.drawable.* +import android.graphics.Color +import android.graphics.Rect +import android.graphics.Typeface +import android.graphics.drawable.ColorDrawable +import android.graphics.drawable.Drawable +import android.graphics.drawable.InsetDrawable +import android.graphics.drawable.RippleDrawable +import android.graphics.drawable.StateListDrawable +import android.util.TypedValue import android.view.View +import android.widget.ImageView import androidx.annotation.ColorInt +import androidx.annotation.DrawableRes +import androidx.annotation.FloatRange import androidx.constraintlayout.widget.ConstraintLayout 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 import org.fcitx.fcitx5.android.utils.styledFloat import org.fcitx.fcitx5.android.utils.unset import splitties.dimensions.dp -import splitties.resources.drawable +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.parentId -import splitties.views.dsl.core.* +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.view +import splitties.views.dsl.core.wrapContent import splitties.views.existingOrNewId -import splitties.views.imageDrawable +import splitties.views.imageResource import splitties.views.padding import kotlin.math.min +import kotlin.math.roundToInt abstract class KeyView(ctx: Context, val theme: Theme, val def: KeyDef.Appearance) : CustomGestureView(ctx) { @@ -44,8 +65,13 @@ abstract class KeyView(ctx: Context, val theme: Theme, val def: KeyDef.Appearanc bordered = prefs.keyBorder.getValue() rippled = prefs.keyRippleEffect.getValue() radius = dp(prefs.keyRadius.getValue().toFloat()) - hMargin = if (def.margin) dp(prefs.keyHorizontalMargin.getValue()) else 0 - vMargin = if (def.margin) dp(prefs.keyVerticalMargin.getValue()) else 0 + val landscape = resources.configuration.orientation == Configuration.ORIENTATION_LANDSCAPE + val hMarginPref = + if (landscape) prefs.keyHorizontalMarginLandscape else prefs.keyHorizontalMargin + val vMarginPref = + if (landscape) prefs.keyVerticalMarginLandscape else prefs.keyVerticalMargin + hMargin = if (def.margin) dp(hMarginPref.getValue()) else 0 + vMargin = if (def.margin) dp(vMarginPref.getValue()) else 0 } private val cachedLocation = intArrayOf(0, 0) @@ -56,7 +82,27 @@ abstract class KeyView(ctx: Context, val theme: Theme, val def: KeyDef.Appearanc if (!boundsValid) updateBounds() } - val layout = constraintLayout { + /** + * KeyView content left margin, in percentage of parent width + */ + @FloatRange(0.0, 1.0) + var layoutMarginLeft = 0f + + /** + * KeyView content right margin, in percentage of parent width + */ + @FloatRange(0.0, 1.0) + var layoutMarginRight = 0f + + /** + * [KeyView] contains 2 parts: `TouchEventView` and `AppearanceView`. + * + * `TouchEventView` is the outer [CustomGestureView] that handles touch events. + * + * `AppearanceView` in the inner [ConstraintLayout], it can be smaller than its parent, + * and holds the [bounds] for popup. + */ + protected val appearanceView = constraintLayout { // sync any state from parent isDuplicateParentStateEnabled = true } @@ -71,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 - 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 { @@ -103,17 +137,17 @@ abstract class KeyView(ctx: Context, val theme: Theme, val def: KeyDef.Appearanc setupPressHighlight() } } - add(layout, lParams(matchParent, matchParent)) + add(appearanceView, lParams(matchParent, matchParent)) } private fun setupPressHighlight(mask: Drawable? = null) { - 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), @@ -121,30 +155,42 @@ 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) - layout.alpha = if (enabled) 1f else styledFloat(android.R.attr.disabledAlpha) + appearanceView.alpha = if (enabled) 1f else styledFloat(android.R.attr.disabledAlpha) } fun updateBounds() { - val (x, y) = cachedLocation.also { getLocationInWindow(it) } - cachedBounds.set(x, y, x + width, y + height) + val (x, y) = cachedLocation.also { appearanceView.getLocationInWindow(it) } + cachedBounds.set(x, y, x + appearanceView.width, y + appearanceView.height) boundsValid = true } override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) { boundsValid = false + if (layoutMarginLeft != 0f || layoutMarginRight != 0f) { + val w = right - left + val h = bottom - top + val layoutWidth = (w * (1f - layoutMarginLeft - layoutMarginRight)).roundToInt() + appearanceView.updateLayoutParams { + leftMargin = (w * layoutMarginLeft).roundToInt() + rightMargin = (w * layoutMarginRight).roundToInt() + } + // sets `measuredWidth` and `measuredHeight` of `AppearanceView` + // https://developer.android.com/guide/topics/ui/how-android-draws#measure + appearanceView.measure( + MeasureSpec.makeMeasureSpec(layoutWidth, MeasureSpec.EXACTLY), + MeasureSpec.makeMeasureSpec(h, MeasureSpec.EXACTLY) + ) + } super.onLayout(changed, left, top, right, bottom) } @@ -156,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)) - 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 - padding = 0 + 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 ) ) } @@ -180,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 - background = InsetDrawable( - GradientDrawable().apply { - shape = GradientDrawable.OVAL - setColor(theme.accentKeyBackgroundColor) - }, - hInset, vInset, hInset, vInset + appearanceView.background = insetOvalDrawable( + hInset, vInset, theme.accentKeyBackgroundColor ) - padding = 0 + 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 ) ) } @@ -205,12 +236,12 @@ abstract class KeyView(ctx: Context, val theme: Theme, val def: KeyDef.Appearanc @SuppressLint("ViewConstructor") open class TextKeyView(ctx: Context, theme: Theme, def: KeyDef.Appearance.Text) : KeyView(ctx, theme, def) { - val mainText = textView { + val mainText = view(::AutoScaleTextView) { isClickable = false isFocusable = false background = null text = def.displayText - textSize = def.textSize + setTextSize(TypedValue.COMPLEX_UNIT_DIP, def.textSize) textDirection = View.TEXT_DIRECTION_FIRST_STRONG_LTR // keep original typeface, apply textStyle only setTypeface(typeface, def.textStyle) @@ -224,7 +255,7 @@ open class TextKeyView(ctx: Context, theme: Theme, def: KeyDef.Appearance.Text) } init { - layout.apply { + appearanceView.apply { add(mainText, lParams(wrapContent, wrapContent) { centerInParent() }) @@ -235,15 +266,14 @@ open class TextKeyView(ctx: Context, theme: Theme, def: KeyDef.Appearance.Text) @SuppressLint("ViewConstructor") class AltTextKeyView(ctx: Context, theme: Theme, def: KeyDef.Appearance.AltText) : TextKeyView(ctx, theme, def) { - val altText = textView { + val altText = view(::AutoScaleTextView) { isClickable = false isFocusable = false // TODO hardcoded alt text size - textSize = 10.7f + setTextSize(TypedValue.COMPLEX_UNIT_DIP, 10.666667f) setTypeface(typeface, Typeface.BOLD) text = def.altText textDirection = View.TEXT_DIRECTION_FIRST_STRONG_LTR - // TODO darken altText color setTextColor( when (def.variant) { Variant.Normal, Variant.AltForeground, Variant.Alternative -> theme.altKeyTextColor @@ -253,7 +283,9 @@ class AltTextKeyView(ctx: Context, theme: Theme, def: KeyDef.Appearance.AltText) } init { - layout.apply { add(altText, lParams(wrapContent, wrapContent)) } + appearanceView.apply { + add(altText, lParams(wrapContent, wrapContent)) + } applyLayout(resources.configuration.orientation) } @@ -266,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 @@ -284,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 @@ -295,14 +329,26 @@ 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) { - Configuration.ORIENTATION_PORTRAIT when (ThemeManager.prefs.punctuationPosition.getValue()) { PunctuationPosition.Bottom -> when (orientation) { Configuration.ORIENTATION_LANDSCAPE -> applyTopRightAltTextPosition() else -> applyBottomAltTextPosition() } PunctuationPosition.TopRight -> applyTopRightAltTextPosition() + PunctuationPosition.None -> applyNoAltTextPosition() } } @@ -317,21 +363,76 @@ class AltTextKeyView(ctx: Context, theme: Theme, def: KeyDef.Appearance.AltText) @SuppressLint("ViewConstructor") class ImageKeyView(ctx: Context, theme: Theme, def: KeyDef.Appearance.Image) : KeyView(ctx, theme, def) { + val img = imageView { configure(theme, def.src, def.variant) } + + init { + appearanceView.apply { + add(img, lParams(wrapContent, wrapContent) { + centerInParent() + }) + } + } +} + +private fun ImageView.configure(theme: Theme, @DrawableRes src: Int, variant: Variant) = apply { + isClickable = false + isFocusable = false + imageTintList = ColorStateList.valueOf( + when (variant) { + Variant.Normal -> theme.keyTextColor + Variant.AltForeground, Variant.Alternative -> theme.altKeyTextColor + Variant.Accent -> theme.accentKeyTextColor + } + ) + imageResource = src +} + +@SuppressLint("ViewConstructor") +class ImageTextKeyView(ctx: Context, theme: Theme, def: KeyDef.Appearance.ImageText) : + TextKeyView(ctx, theme, def) { val img = imageView { - isClickable = false - isFocusable = false - imageDrawable = drawable(def.src) - colorFilter = PorterDuffColorFilter( - when (def.variant) { - Variant.Normal -> theme.keyTextColor - Variant.AltForeground, Variant.Alternative -> theme.altKeyTextColor - Variant.Accent -> theme.accentKeyTextColor - }, - PorterDuff.Mode.SRC_IN - ) + configure(theme, def.src, def.variant) } init { - layout.apply { add(img, lParams(wrapContent, wrapContent) { centerInParent() }) } + appearanceView.apply { + add(img, lParams(dp(13), dp(13))) + } + mainText.updateLayoutParams { + centerHorizontally() + bottomToBottom = parentId + bottomMargin = vMargin + dp(4) + topToTop = unset + } + img.updateLayoutParams { + centerHorizontally() + topToTop = parentId + } + updateMargins(resources.configuration.orientation) + } + + private fun updateMargins(orientation: Int) { + when (orientation) { + Configuration.ORIENTATION_LANDSCAPE -> { + mainText.updateLayoutParams { + bottomMargin = vMargin + dp(2) + } + img.updateLayoutParams { + topMargin = vMargin + dp(4) + } + } + else -> { + mainText.updateLayoutParams { + bottomMargin = vMargin + dp(4) + } + img.updateLayoutParams { + topMargin = vMargin + dp(8) + } + } + } + } + + override fun onConfigurationChanged(newConfig: Configuration) { + updateMargins(newConfig.orientation) } } 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 ad4001ddd..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 @@ -1,3 +1,7 @@ +/* + * SPDX-License-Identifier: LGPL-2.1-or-later + * SPDX-FileCopyrightText: Copyright 2021-2023 Fcitx5 for Android Contributors + */ package org.fcitx.fcitx5.android.input.keyboard import android.text.InputType @@ -113,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 new file mode 100644 index 000000000..d4b905471 --- /dev/null +++ b/app/src/main/java/org/fcitx/fcitx5/android/input/keyboard/LangSwitchBehavior.kt @@ -0,0 +1,14 @@ +/* + * SPDX-License-Identifier: LGPL-2.1-or-later + * SPDX-FileCopyrightText: Copyright 2023 Fcitx5 for Android Contributors + */ +package org.fcitx.fcitx5.android.input.keyboard + +import org.fcitx.fcitx5.android.R +import org.fcitx.fcitx5.android.data.prefs.ManagedPreferenceEnum + +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 468910ea9..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 @@ -1,3 +1,7 @@ +/* + * SPDX-License-Identifier: LGPL-2.1-or-later + * SPDX-FileCopyrightText: Copyright 2021-2023 Fcitx5 for Android Contributors + */ package org.fcitx.fcitx5.android.input.keyboard import android.annotation.SuppressLint @@ -5,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") @@ -40,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() ) @@ -58,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 2bb42109b..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 @@ -1,14 +1,15 @@ +/* + * SPDX-License-Identifier: LGPL-2.1-or-later + * SPDX-FileCopyrightText: Copyright 2021-2023 Fcitx5 for Android Contributors + */ 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 d97c503de..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 @@ -1,16 +1,17 @@ +/* + * SPDX-License-Identifier: LGPL-2.1-or-later + * SPDX-FileCopyrightText: Copyright 2021-2023 Fcitx5 for Android Contributors + */ 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 3e00022d7..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 @@ -1,3 +1,7 @@ +/* + * SPDX-License-Identifier: LGPL-2.1-or-later + * SPDX-FileCopyrightText: Copyright 2021-2023 Fcitx5 for Android Contributors + */ package org.fcitx.fcitx5.android.input.keyboard import android.annotation.SuppressLint @@ -7,12 +11,12 @@ 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 import org.fcitx.fcitx5.android.input.popup.PopupAction -import splitties.resources.drawable -import splitties.views.imageDrawable import splitties.views.imageResource @SuppressLint("ViewConstructor") @@ -28,16 +32,16 @@ class TextKeyboard( val Layout: List> = listOf( listOf( - AlphabetDigitKey("Q", 1), - AlphabetDigitKey("W", 2), - AlphabetDigitKey("E", 3), - AlphabetDigitKey("R", 4), - AlphabetDigitKey("T", 5), - AlphabetDigitKey("Y", 6), - AlphabetDigitKey("U", 7), - AlphabetDigitKey("I", 8), - AlphabetDigitKey("O", 9), - AlphabetDigitKey("P", 0) + AlphabetKey("Q", "1"), + AlphabetKey("W", "2"), + AlphabetKey("E", "3"), + AlphabetKey("R", "4"), + AlphabetKey("T", "5"), + AlphabetKey("Y", "6"), + AlphabetKey("U", "7"), + AlphabetKey("I", "8"), + AlphabetKey("O", "9"), + AlphabetKey("P", "0") ), listOf( AlphabetKey("A", "@"), @@ -109,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() { @@ -154,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()) @@ -172,26 +196,29 @@ 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() } private fun updateCapsButtonIcon() { caps.img.apply { - imageDrawable = drawable( - when (capsState) { - CapsState.None -> R.drawable.ic_capslock_none - CapsState.Once -> R.drawable.ic_capslock_once - CapsState.Lock -> R.drawable.ic_capslock_lock - } - ) + imageResource = when (capsState) { + CapsState.None -> R.drawable.ic_capslock_none + CapsState.Once -> R.drawable.ic_capslock_once + CapsState.Lock -> R.drawable.ic_capslock_lock + } } } 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 aa8d27e6f..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,10 +1,14 @@ +/* + * SPDX-License-Identifier: LGPL-2.1-or-later + * SPDX-FileCopyrightText: Copyright 2021-2025 Fcitx5 for Android Contributors + */ package org.fcitx.fcitx5.android.input.picker 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) @@ -15,7 +19,7 @@ object PickerData { "'", "\"", "=", "_", "`", ":", ";", "?", "~", "|", "+", "-", "\\", "/", "[", "]", "{", "}", "<", ">", "“", "”", "·", "‘", "’", "¡", "¿", "¥", - "€", "£", "¢", "©", "®", "™", "℃", "℉", + "€", "£", "¢", "©", "®", "℗", "™", "℠", "°", "§", "№", "†", "‡", "‥", "…", "‰", "※", "‾", "⁄", "‼", "⁇", "⁈", "⁉", "√", "π", "±", "×", "÷", "¶", "∆", "¤", "µ", "‹", "›", "«", "»" @@ -93,19 +97,19 @@ object PickerData { "😀", "😃", "😄", "😁", "😆", "😅", "🤣", "😂", "🙂", "🙃", "🫠", "😉", "😊", "😇", "🥰", "😍", "🤩", "😘", "😗", "☺️", "😚", "😙", "🥲", "😋", "😛", "😜", "🤪", "😝", "🤑", "🤗", "🤭", "🫢", "🫣", "🤫", "🤔", "🫡", "🤐", "🤨", "😐", - "😑", "😶", "🫥", "😶‍🌫️", "😏", "😒", "🙄", "😬", "😮‍💨", "🤥", "😌", "😔", "😪", - "🤤", "😴", "😷", "🤒", "🤕", "🤢", "🤮", "🤧", "🥵", "🥶", "🥴", "😵", "😵‍💫", + "😑", "😶", "🫥", "😶‍🌫️", "😏", "😒", "🙄", "😬", "😮‍💨", "🤥", "😌", "😔", "🙂‍↕️", "🙂‍↔️", "😪", + "🤤", "😴", "😷", "🤒", "🤕", "🤢", "🤮", "🤧", "🥵", "🥶", "🥴", "😵", "😵‍💫", "🫨", "🫩", "🤯", "🤠", "🥳", "🥸", "😎", "🤓", "🧐", "😕", "🫤", "😟", "🙁", "☹️", "😮", "😯", "😲", "😳", "🥺", "🥹", "😦", "😧", "😨", "😰", "😥", "😢", "😭", "😱", "😖", "😣", "😞", "😓", "😩", "😫", "🥱", "😤", "😡", "😠", "🤬", "😈", "👿", "💀", "☠️", "💩", "🤡", "👹", "👺", "👻", "👽", "👾", "🤖", "😺", "😸", "😹", "😻", "😼", "😽", "🙀", "😿", "😾", "🙈", "🙉", "🙊", "💋", "💌", "💘", "💝", "💖", "💗", "💓", "💞", "💕", "💟", "❣️", "💔", "❤️‍🔥", "❤️‍🩹", "❤️", "🧡", "💛", - "💚", "💙", "💜", "🤎", "🖤", "🤍", "💯", "💢", "💥", "💫", "💦", "💨", "🕳️", + "💚", "💙", "💜", "🤎", "🖤", "🤍", "🩷", "🩵", "🩶", "💯", "💢", "💥", "💫", "💦", "💨", "🕳️", "💣", "💬", "👁️‍🗨️", "🗨️", "🗯️", "💭", "💤", ), Category("🧑", R.drawable.ic_baseline_emoji_people_24) to arrayOf( - "👋", "🤚", "🖐️", "✋", "🖖", "🫱", "🫲", "🫳", "🫴", "👌", "🤌", "🤏", "✌️", + "👋", "🤚", "🖐️", "✋", "🖖", "🫱", "🫲", "🫸", "🫷", "🫳", "🫴", "👌", "🤌", "🤏", "✌️", "🤞", "🫰", "🤟", "🤘", "🤙", "👈", "👉", "👆", "🖕", "👇", "☝️", "🫵", "👍", "👎", "✊", "👊", "🤛", "🤜", "👏", "🙌", "🫶", "👐", "🤲", "🤝", "🙏", "✍️", "💅", "🤳", "💪", "🦾", "🦿", "🦵", "🦶", "👂", "🦻", "👃", "🧠", "🫀", "🫁", @@ -123,8 +127,8 @@ object PickerData { "🤱", "👩‍🍼", "👨‍🍼", "🧑‍🍼", "👼", "🎅", "🤶", "🧑‍🎄", "🦸", "🦸‍♂️", "🦸‍♀️", "🦹", "🦹‍♂️", "🦹‍♀️", "🧙", "🧙‍♂️", "🧙‍♀️", "🧚", "🧚‍♂️", "🧚‍♀️", "🧛", "🧛‍♂️", "🧛‍♀️", "🧜", "🧜‍♂️", "🧜‍♀️", "🧝", "🧝‍♂️", "🧝‍♀️", "🧞", "🧞‍♂️", "🧞‍♀️", "🧟", "🧟‍♂️", "🧟‍♀️", "🧌", "💆", "💆‍♂️", "💆‍♀️", - "💇", "💇‍♂️", "💇‍♀️", "🚶", "🚶‍♂️", "🚶‍♀️", "🧍", "🧍‍♂️", "🧍‍♀️", "🧎", "🧎‍♂️", "🧎‍♀️", "🧑‍🦯", - "👨‍🦯", "👩‍🦯", "🧑‍🦼", "👨‍🦼", "👩‍🦼", "🧑‍🦽", "👨‍🦽", "👩‍🦽", "🏃", "🏃‍♂️", "🏃‍♀️", "💃", "🕺", + "💇", "💇‍♂️", "💇‍♀️", "🚶", "🚶‍♂️", "🚶‍♀️", "🚶‍➡️", "🚶‍♀️‍➡️", "🚶‍♂️‍➡️", "🧍", "🧍‍♂️", "🧍‍♀️", "🧎", "🧎‍♂️", "🧎‍♀️", "🧎‍➡️", "🧎‍♀️‍➡️", "🧎‍♂️‍➡️", "🧑‍🦯", + "👨‍🦯", "👩‍🦯", "🧑‍🦯‍➡️", "👨‍🦯‍➡️", "👩‍🦯‍➡️", "🧑‍🦽", "👨‍🦽", "👩‍🦽", "🧑‍🦽‍➡️", "👨‍🦽‍➡️", "👩‍🦽‍➡️", "🧑‍🦼", "👨‍🦼", "👩‍🦼", "🧑‍🦼‍➡️", "👨‍🦼‍➡️", "👩‍🦼‍➡️", "🏃", "🏃‍♂️", "🏃‍♀️", "🏃‍➡️", "🏃‍♂️‍➡️", "🏃‍♀️‍➡️", "💃", "🕺", "🕴️", "👯", "👯‍♂️", "👯‍♀️", "🧖", "🧖‍♂️", "🧖‍♀️", "🧗", "🧗‍♂️", "🧗‍♀️", "🤺", "🏇", "⛷️", "🏂", "🏌️", "🏌️‍♂️", "🏌️‍♀️", "🏄", "🏄‍♂️", "🏄‍♀️", "🚣", "🚣‍♂️", "🚣‍♀️", "🏊", "🏊‍♂️", "🏊‍♀️", "⛹️", "⛹️‍♂️", "⛹️‍♀️", "🏋️", "🏋️‍♂️", "🏋️‍♀️", "🚴", "🚴‍♂️", "🚴‍♀️", "🚵", "🚵‍♂️", "🚵‍♀️", "🤸", @@ -132,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( + "🍇", "🍈", "🍉", "🍊", "🍋", "🍋‍🟩", "🍌", "🍍", "🥭", "🍎", "🍏", "🍐", "🍑", "🍒", + "🍓", "🫐", "🥝", "🍅", "🫒", "🥥", "🥑", "🍆", "🥔", "🥕", "🌽", "🌶️", "🫑", "🫛", + "🥒", "🥬", "🥦", "🧄", "🧅", "🫚", "🫜", "🍄", "🍄‍🟫", "🥜", "🫘", "🌰", "🍞", "🥐", "🥖", "🫓", "🥨", "🥯", "🥞", "🧇", "🧀", "🍖", "🍗", "🥩", "🥓", "🍔", "🍟", "🍕", "🌭", "🥪", "🌮", "🌯", "🫔", "🥙", "🧆", "🥚", "🍳", "🥘", "🍲", "🫕", "🥣", "🥗", "🍿", "🧈", "🧂", "🥫", "🍱", "🍘", "🍙", "🍚", "🍛", "🍜", "🍝", "🍠", "🍢", @@ -161,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( "🌍", "🌎", "🌏", "🌐", "🗺️", "🗾", "🧭", "🏔️", "⛰️", "🌋", "🗻", "🏕️", "🏖️", "🏜️", "🏝️", "🏞️", "🏟️", "🏛️", "🏗️", "🧱", "🪨", "🪵", "🛖", "🏘️", "🏚️", "🏠", "🏡", "🏢", "🏣", "🏤", "🏥", "🏦", "🏨", "🏩", "🏪", "🏫", "🏬", "🏭", "🏯", @@ -180,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( "🎃", "🎄", "🎆", "🎇", "🧨", "✨", "🎈", "🎉", "🎊", "🎋", "🎍", "🎎", "🎏", "🎐", "🎑", "🧧", "🎀", "🎁", "🎗️", "🎟️", "🎫", "🎖️", "🏆", "🏅", "🥇", "🥈", "🥉", "⚽", "⚾", "🥎", "🏀", "🏐", "🏈", "🏉", "🎾", "🥏", "🎳", "🏏", "🏑", @@ -189,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( "👓", "🕶️", "🥽", "🥼", "🦺", "👔", "👕", "👖", "🧣", "🧤", "🧥", "🧦", "👗", "👘", "🥻", "🩱", "🩲", "🩳", "👙", "👚", "👛", "👜", "👝", "🛍️", "🎒", "🩴", "👞", "👟", "🥾", "🥿", "👠", "👡", "🩰", "👢", "👑", "👒", "🎩", "🎓", "🧢", - "🪖", "⛑️", "📿", "💄", "💍", "💎", "🔇", "🔈", "🔉", "🔊", "📢", "📣", "📯", + "🪖", "⛑️", "📿", "💄", "🪭", "💍", "💎", "🔇", "🔈", "🔉", "🔊", "📢", "📣", "📯", "🔔", "🔕", "🎼", "🎵", "🎶", "🎙️", "🎚️", "🎛️", "🎤", "🎧", "📻", "🎷", "🪗", - "🎸", "🎹", "🎺", "🎻", "🪕", "🥁", "🪘", "📱", "📲", "☎️", "📞", "📟", "📠", + "🎸", "🎹", "🎺", "🎻", "🪕", "🪉", "🥁", "🪘", "🪇", "🪈", "📱", "📲", "☎️", "📞", "📟", "📠", "🔋", "🪫", "🔌", "💻", "🖥️", "🖨️", "⌨️", "🖱️", "🖲️", "💽", "💾", "💿", "📀", "🧮", "🎥", "🎞️", "📽️", "🎬", "📺", "📷", "📸", "📹", "📼", "🔍", "🔎", "🕯️", "💡", "🔦", "🏮", "🪔", "📔", "📕", "📖", "📗", "📘", "📙", "📚", "📓", "📒", @@ -205,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️⃣", @@ -235,7 +239,7 @@ object PickerData { "🇦🇮", "🇦🇱", "🇦🇲", "🇦🇴", "🇦🇶", "🇦🇷", "🇦🇸", "🇦🇹", "🇦🇺", "🇦🇼", "🇦🇽", "🇦🇿", "🇧🇦", "🇧🇧", "🇧🇩", "🇧🇪", "🇧🇫", "🇧🇬", "🇧🇭", "🇧🇮", "🇧🇯", "🇧🇱", "🇧🇲", "🇧🇳", "🇧🇴", "🇧🇶", "🇧🇷", "🇧🇸", "🇧🇹", "🇧🇻", "🇧🇼", "🇧🇾", "🇧🇿", "🇨🇦", "🇨🇨", "🇨🇩", "🇨🇫", "🇨🇬", "🇨🇭", - "🇨🇮", "🇨🇰", "🇨🇱", "🇨🇲", "🇨🇳", "🇨🇴", "🇨🇵", "🇨🇷", "🇨🇺", "🇨🇻", "🇨🇼", "🇨🇽", "🇨🇾", + "🇨🇮", "🇨🇰", "🇨🇱", "🇨🇲", "🇨🇳", "🇨🇴", "🇨🇵", "🇨🇶", "🇨🇷", "🇨🇺", "🇨🇻", "🇨🇼", "🇨🇽", "🇨🇾", "🇨🇿", "🇩🇪", "🇩🇬", "🇩🇯", "🇩🇰", "🇩🇲", "🇩🇴", "🇩🇿", "🇪🇦", "🇪🇨", "🇪🇪", "🇪🇬", "🇪🇭", "🇪🇷", "🇪🇸", "🇪🇹", "🇪🇺", "🇫🇮", "🇫🇯", "🇫🇰", "🇫🇲", "🇫🇴", "🇫🇷", "🇬🇦", "🇬🇧", "🇬🇩", "🇬🇪", "🇬🇫", "🇬🇬", "🇬🇭", "🇬🇮", "🇬🇱", "🇬🇲", "🇬🇳", "🇬🇵", "🇬🇶", "🇬🇷", "🇬🇸", "🇬🇹", @@ -258,7 +262,7 @@ object PickerData { val Emoticon: List>> = listOf( Category("(^_^)") to arrayOf( ":D", ":)", ";)", ":-)", - "^_^", "(^^)", "(^^♪", "•ٹ•ʔ", + "^_^", "(^^)", "(^^♪", "ʕ•ٹ•ʔ", "(^o^)", "(^^)/", "(^^)v", "(^_^)", "(・∀・)", "(^_-)", "(≧▽≦)", "(^。^)", "(^・^)", "(^ム^)", "(•ө•)♡", "(ㆁωㆁ*)", @@ -331,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 5fa81b204..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 @@ -1,3 +1,7 @@ +/* + * SPDX-License-Identifier: LGPL-2.1-or-later + * SPDX-FileCopyrightText: Copyright 2021-2023 Fcitx5 for Android Contributors + */ package org.fcitx.fcitx5.android.input.picker import android.annotation.SuppressLint @@ -8,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 15bcc2aaf..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,15 +1,20 @@ +/* + * SPDX-License-Identifier: LGPL-2.1-or-later + * SPDX-FileCopyrightText: Copyright 2021-2025 Fcitx5 for Android Contributors + */ package org.fcitx.fcitx5.android.input.picker import android.content.Context -import android.util.TypedValue +import android.view.View import android.view.ViewGroup +import androidx.constraintlayout.widget.ConstraintLayout import androidx.core.view.updateLayoutParams -import androidx.core.widget.TextViewCompat import org.fcitx.fcitx5.android.R import org.fcitx.fcitx5.android.core.FcitxKeyMapping import org.fcitx.fcitx5.android.core.KeySym import org.fcitx.fcitx5.android.data.prefs.AppPrefs import org.fcitx.fcitx5.android.data.theme.Theme +import org.fcitx.fcitx5.android.input.AutoScaleTextView import org.fcitx.fcitx5.android.input.keyboard.CustomGestureView import org.fcitx.fcitx5.android.input.keyboard.CustomGestureView.OnGestureListener import org.fcitx.fcitx5.android.input.keyboard.ImageKeyView @@ -24,54 +29,51 @@ 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 import splitties.views.dsl.constraintlayout.bottomOfParent import splitties.views.dsl.constraintlayout.bottomToTopOf import splitties.views.dsl.constraintlayout.constraintLayout -import splitties.views.dsl.constraintlayout.endOfParent -import splitties.views.dsl.constraintlayout.endToStartOf import splitties.views.dsl.constraintlayout.lParams -import splitties.views.dsl.constraintlayout.startOfParent -import splitties.views.dsl.constraintlayout.startToEndOf +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.constraintlayout.topToBottomOf import splitties.views.dsl.core.Ui import splitties.views.dsl.core.add import splitties.views.dsl.core.matchParent -import splitties.views.gravityCenter -import splitties.views.lines -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,112 +84,73 @@ 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 { - lines = 1 - gravity = gravityCenter - updateLayoutParams { - width = matchParent - height = matchParent - } + scaleMode = AutoScaleTextView.Mode.Proportional + setPadding(hMargin, vMargin, hMargin, vMargin) } - TextViewCompat.setAutoSizeTextTypeUniformWithConfiguration( - mainText, 4, density.textSize.toInt(), 1, TypedValue.COMPLEX_UNIT_SP - ) } } } - 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_constraintEnd_to - if (i == keyViews.size - 1) { - // last key (likely not last column), align end to start of backspace button - endToStartOf(backspaceKey) - } else if (column == columnCount - 1) { - // last column, align end to end of parent - endOfParent() - } else { - // neither, align end to start of next view - endToStartOf(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_constraintStart_to - if (column == 0) { - // first column, align start to start of parent - startOfParent() - } else { - // not first column, align start to end of last column - startToEndOf(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 { @@ -195,9 +158,19 @@ class PickerPageUi(override val ctx: Context, val theme: Theme, private val dens below(keyViews[(rowCount - 2) * columnCount]) // bottom/right corner bottomOfParent() - endOfParent() + 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) } @@ -206,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) { @@ -218,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 @@ -234,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 ) ) @@ -252,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 6f39e6b52..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,5 +1,10 @@ +/* + * SPDX-License-Identifier: LGPL-2.1-or-later + * 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 @@ -12,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) { @@ -118,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 b68c0e386..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,3 +1,7 @@ +/* + * SPDX-License-Identifier: LGPL-2.1-or-later + * SPDX-FileCopyrightText: Copyright 2021-2025 Fcitx5 for Android Contributors + */ package org.fcitx.fcitx5.android.input.picker import android.content.Context @@ -7,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 { @@ -47,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/PickerTabsUi.kt b/app/src/main/java/org/fcitx/fcitx5/android/input/picker/PickerTabsUi.kt index e3bbf133d..957634b5f 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/input/picker/PickerTabsUi.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/input/picker/PickerTabsUi.kt @@ -1,8 +1,10 @@ +/* + * SPDX-License-Identifier: LGPL-2.1-or-later + * SPDX-FileCopyrightText: Copyright 2021-2023 Fcitx5 for Android Contributors + */ package org.fcitx.fcitx5.android.input.picker import android.content.Context -import android.graphics.PorterDuff -import android.graphics.PorterDuffColorFilter import android.graphics.Typeface import androidx.annotation.DrawableRes import androidx.core.view.isVisible @@ -13,8 +15,19 @@ import org.fcitx.fcitx5.android.utils.alpha import org.fcitx.fcitx5.android.utils.pressHighlightDrawable import org.fcitx.fcitx5.android.utils.rippleDrawable import splitties.resources.drawable -import splitties.views.dsl.constraintlayout.* -import splitties.views.dsl.core.* +import splitties.views.dsl.constraintlayout.after +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.constraintlayout.startOfParent +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.textView +import splitties.views.dsl.core.view import splitties.views.gravityCenter import splitties.views.imageDrawable @@ -25,8 +38,7 @@ class PickerTabsUi(override val ctx: Context, val theme: Theme) : Ui { } inner class TabUi : Ui { - override val ctx: Context - get() = this@PickerTabsUi.ctx + override val ctx = this@PickerTabsUi.ctx var position: Int = -1 @@ -36,9 +48,7 @@ class PickerTabsUi(override val ctx: Context, val theme: Theme) : Ui { setTextColor(theme.keyTextColor) } - val icon = imageView { - colorFilter = PorterDuffColorFilter(theme.keyTextColor, PorterDuff.Mode.SRC_IN) - } + val icon = imageView() override val root = view(::CustomGestureView) { add(label, lParams { @@ -64,7 +74,9 @@ class PickerTabsUi(override val ctx: Context, val theme: Theme) : Ui { } fun setIcon(@DrawableRes src: Int) { - icon.imageDrawable = ctx.drawable(src) + icon.imageDrawable = ctx.drawable(src)!!.apply { + setTint(theme.keyTextColor.alpha(0.5f)) + } label.isVisible = false icon.isVisible = true } @@ -72,7 +84,7 @@ class PickerTabsUi(override val ctx: Context, val theme: Theme) : Ui { fun setActive(active: Boolean) { val color = theme.keyTextColor.alpha(if (active) 1f else 0.5f) label.setTextColor(color) - icon.colorFilter = PorterDuffColorFilter(color, PorterDuff.Mode.SRC_IN) + icon.imageDrawable?.setTint(color) } } 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 32e818f21..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,13 +1,26 @@ +/* + * SPDX-License-Identifier: LGPL-2.1-or-later + * 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.transition.Slide import androidx.transition.Transition import androidx.viewpager2.widget.ViewPager2 +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 -import org.fcitx.fcitx5.android.input.keyboard.* +import org.fcitx.fcitx5.android.input.keyboard.CommonKeyActionListener +import org.fcitx.fcitx5.android.input.keyboard.KeyAction +import org.fcitx.fcitx5.android.input.keyboard.KeyActionListener +import org.fcitx.fcitx5.android.input.keyboard.KeyDef +import org.fcitx.fcitx5.android.input.keyboard.KeyboardWindow import org.fcitx.fcitx5.android.input.popup.PopupAction import org.fcitx.fcitx5.android.input.popup.PopupActionListener import org.fcitx.fcitx5.android.input.popup.PopupComponent @@ -18,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 { @@ -30,12 +44,15 @@ class PickerWindow( Emoticon } + private val service by manager.inputMethodService() private val theme by manager.theme() private val windowManager: InputWindowManager by manager.must() private val commonKeyActionListener: CommonKeyActionListener by manager.must() 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 @@ -103,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, @@ -130,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() } }) @@ -147,17 +162,29 @@ 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 - pickerPagesAdapter.saveRecent() + if (key === Key.Emoji) { + skinTonePreference.unregisterOnChangeListener(refreshPagesListener) + } } override val showTitle = false 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 706467dfd..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 @@ -1,3 +1,7 @@ +/* + * SPDX-License-Identifier: LGPL-2.1-or-later + * SPDX-FileCopyrightText: Copyright 2021-2023 Fcitx5 for Android Contributors + */ package org.fcitx.fcitx5.android.input.picker import org.fcitx.fcitx5.android.R @@ -17,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/PopupAction.kt b/app/src/main/java/org/fcitx/fcitx5/android/input/popup/PopupAction.kt index e4a6c36f0..f6e076f33 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/input/popup/PopupAction.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/input/popup/PopupAction.kt @@ -1,3 +1,7 @@ +/* + * SPDX-License-Identifier: LGPL-2.1-or-later + * SPDX-FileCopyrightText: Copyright 2021-2023 Fcitx5 for Android Contributors + */ package org.fcitx.fcitx5.android.input.popup import android.graphics.Rect diff --git a/app/src/main/java/org/fcitx/fcitx5/android/input/popup/PopupActionListener.kt b/app/src/main/java/org/fcitx/fcitx5/android/input/popup/PopupActionListener.kt index a60ed162d..1e3d7901e 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/input/popup/PopupActionListener.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/input/popup/PopupActionListener.kt @@ -1,3 +1,7 @@ +/* + * SPDX-License-Identifier: LGPL-2.1-or-later + * SPDX-FileCopyrightText: Copyright 2021-2023 Fcitx5 for Android Contributors + */ package org.fcitx.fcitx5.android.input.popup fun interface PopupActionListener { 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 5aa378332..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,3 +1,7 @@ +/* + * SPDX-License-Identifier: LGPL-2.1-or-later + * SPDX-FileCopyrightText: Copyright 2021-2025 Fcitx5 for Android Contributors + */ package org.fcitx.fcitx5.android.input.popup import android.graphics.Rect @@ -55,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) + } } } @@ -93,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) @@ -106,6 +122,7 @@ class PopupComponent : val keyboardUi = PopupKeyboardUi( context, theme, + rootBounds, bounds, { dismissPopup(viewId) }, popupRadius, @@ -116,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) { @@ -132,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 ee6924dfe..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 @@ -1,3 +1,7 @@ +/* + * SPDX-License-Identifier: LGPL-2.1-or-later + * SPDX-FileCopyrightText: Copyright 2021-2023 Fcitx5 for Android Contributors + */ package org.fcitx.fcitx5.android.input.popup import android.content.Context @@ -11,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 { @@ -21,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/PopupEntryUi.kt b/app/src/main/java/org/fcitx/fcitx5/android/input/popup/PopupEntryUi.kt index a652be83a..ed6a05f84 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/input/popup/PopupEntryUi.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/input/popup/PopupEntryUi.kt @@ -1,9 +1,15 @@ +/* + * SPDX-License-Identifier: LGPL-2.1-or-later + * SPDX-FileCopyrightText: Copyright 2021-2023 Fcitx5 for Android Contributors + */ package org.fcitx.fcitx5.android.input.popup import android.content.Context import android.graphics.drawable.GradientDrawable import android.view.ViewOutlineProvider +import android.widget.TextView import org.fcitx.fcitx5.android.data.theme.Theme +import org.fcitx.fcitx5.android.input.AutoScaleTextView import splitties.dimensions.dp import splitties.views.dsl.constraintlayout.centerHorizontally import splitties.views.dsl.constraintlayout.constraintLayout @@ -12,14 +18,14 @@ 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.view import splitties.views.gravityCenter class PopupEntryUi(override val ctx: Context, theme: Theme, keyHeight: Int, radius: Float) : Ui { var lastShowTime = -1L - val textView = textView { + val textView = view(::AutoScaleTextView) { textSize = 23f gravity = gravityCenter setTextColor(theme.popupTextColor) 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 faa7b14d3..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 @@ -1,12 +1,15 @@ +/* + * SPDX-License-Identifier: LGPL-2.1-or-later + * SPDX-FileCopyrightText: Copyright 2021-2023 Fcitx5 for Android Contributors + */ package org.fcitx.fcitx5.android.input.popup import android.content.Context import android.graphics.Rect import android.graphics.drawable.GradientDrawable import android.view.ViewOutlineProvider -import org.fcitx.fcitx5.android.core.FcitxKeyMapping -import org.fcitx.fcitx5.android.core.KeySym import org.fcitx.fcitx5.android.data.theme.Theme +import org.fcitx.fcitx5.android.input.AutoScaleTextView import org.fcitx.fcitx5.android.input.keyboard.KeyAction import splitties.dimensions.dp import splitties.views.dsl.core.Ui @@ -15,8 +18,8 @@ import splitties.views.dsl.core.frameLayout import splitties.views.dsl.core.horizontalLayout import splitties.views.dsl.core.lParams import splitties.views.dsl.core.matchParent -import splitties.views.dsl.core.textView import splitties.views.dsl.core.verticalLayout +import splitties.views.dsl.core.view import splitties.views.gravityCenter import splitties.views.gravityEnd import splitties.views.gravityStart @@ -27,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 @@ -41,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, @@ -49,14 +54,14 @@ 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 { - val textView = textView { + val textView = view(::AutoScaleTextView) { text = this@PopupKeyUi.text + scaleMode = AutoScaleTextView.Mode.Proportional textSize = 23f - isSingleLine = true setTextColor(theme.keyTextColor) } @@ -90,7 +95,7 @@ class PopupKeyboardUi( columnCount = (keyCount / rowCount).roundToInt() focusRow = 0 - focusColumn = calcInitialFocusedColumn(columnCount, keyWidth, bounds) + focusColumn = calcInitialFocusedColumn(columnCount, keyWidth, outerBounds, triggerBounds) } /** @@ -122,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) @@ -214,14 +219,7 @@ class PopupKeyboardUi( override fun onTrigger(): KeyAction? { val key = keys.getOrNull(focusedIndex) ?: return null - /** - * send `KP_*` KeySym for numeric characters in popup - * see also [org.fcitx.fcitx5.android.input.keyboard.AlphabetDigitKey] - */ - return if (key.length == 1 && key[0].isDigit()) - KeyAction.SymAction(KeySym(FcitxKeyMapping.FcitxKey_KP_0 + key[0].digitToInt())) - else - KeyAction.FcitxKeyAction(key) + return KeyAction.FcitxKeyAction(key) } } 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 6e1cecade..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 @@ -1,8 +1,10 @@ +/* + * SPDX-License-Identifier: LGPL-2.1-or-later + * SPDX-FileCopyrightText: Copyright 2021-2023 Fcitx5 for Android Contributors + */ package org.fcitx.fcitx5.android.input.popup import android.content.Context -import android.graphics.PorterDuff -import android.graphics.PorterDuffColorFilter import android.graphics.Rect import android.graphics.drawable.InsetDrawable import android.graphics.drawable.ShapeDrawable @@ -12,20 +14,22 @@ import androidx.core.graphics.ColorUtils import org.fcitx.fcitx5.android.data.theme.Theme import org.fcitx.fcitx5.android.input.keyboard.KeyDef import splitties.dimensions.dp +import splitties.resources.drawable 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.imageResource +import splitties.views.imageDrawable 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) @@ -47,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) @@ -60,8 +65,9 @@ class PopupMenuUi( imageView { background = inactiveBackground scaleType = ImageView.ScaleType.CENTER_INSIDE - colorFilter = PorterDuffColorFilter(theme.accentKeyTextColor, PorterDuff.Mode.SRC_IN) - imageResource = it.icon + imageDrawable = drawable(it.icon)!!.apply { + setTint(theme.accentKeyTextColor) + } } } 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 93d8860c8..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 @@ -1,3 +1,7 @@ +/* + * SPDX-License-Identifier: LGPL-2.1-or-later + * SPDX-FileCopyrightText: Copyright 2021-2023 Fcitx5 for Android Contributors + */ package org.fcitx.fcitx5.android.input.popup /** @@ -25,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", "ç", "ć", "č"), @@ -140,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 f1786efd3..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 @@ -1,7 +1,13 @@ +/* + * SPDX-License-Identifier: LGPL-2.1-or-later + * SPDX-FileCopyrightText: Copyright 2021-2023 Fcitx5 for Android Contributors + */ 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 @@ -9,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() { @@ -16,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 901bcdd41..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,3 +1,7 @@ +/* + * SPDX-License-Identifier: LGPL-2.1-or-later + * SPDX-FileCopyrightText: Copyright 2021-2025 Fcitx5 for Android Contributors + */ package org.fcitx.fcitx5.android.input.preedit import android.content.Context @@ -8,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() { @@ -36,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() @@ -58,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/StatusAreaAdapter.kt b/app/src/main/java/org/fcitx/fcitx5/android/input/status/StatusAreaAdapter.kt index a1ca2e65a..9297dfa70 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/input/status/StatusAreaAdapter.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/input/status/StatusAreaAdapter.kt @@ -1,3 +1,7 @@ +/* + * SPDX-License-Identifier: LGPL-2.1-or-later + * SPDX-FileCopyrightText: Copyright 2021-2023 Fcitx5 for Android Contributors + */ package org.fcitx.fcitx5.android.input.status import android.annotation.SuppressLint diff --git a/app/src/main/java/org/fcitx/fcitx5/android/input/status/StatusAreaEntry.kt b/app/src/main/java/org/fcitx/fcitx5/android/input/status/StatusAreaEntry.kt index fd100bad7..464cde9a5 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/input/status/StatusAreaEntry.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/input/status/StatusAreaEntry.kt @@ -1,3 +1,7 @@ +/* + * SPDX-License-Identifier: LGPL-2.1-or-later + * SPDX-FileCopyrightText: Copyright 2021-2023 Fcitx5 for Android Contributors + */ package org.fcitx.fcitx5.android.input.status import androidx.annotation.DrawableRes @@ -24,9 +28,10 @@ sealed class StatusAreaEntry( StatusAreaEntry(label, icon, active) companion object { - private fun Action.isActive() = icon.endsWith("-active") || isChecked - - private fun drawableRes(icon: String, active: Boolean = false) = when (icon) { + private fun drawableFromIconName(icon: String) = when (icon) { + // androidkeyboard + "tools-check-spelling" -> R.drawable.ic_baseline_spellcheck_24 + // fcitx5-chinese-addons "fcitx-chttrans-active" -> R.drawable.ic_fcitx_status_chttrans_trad "fcitx-chttrans-inactive" -> R.drawable.ic_fcitx_status_chttrans_simp "fcitx-punc-active" -> R.drawable.ic_fcitx_status_punc_active @@ -35,13 +40,24 @@ sealed class StatusAreaEntry( "fcitx-fullwidth-inactive" -> R.drawable.ic_fcitx_status_fullwidth_inactive "fcitx-remind-active" -> R.drawable.ic_fcitx_status_prediction_active "fcitx-remind-inactive" -> R.drawable.ic_fcitx_status_prediction_inactive - "tools-check-spelling" -> R.drawable.ic_baseline_spellcheck_24 - else -> if (active) R.drawable.ic_baseline_code_24 else R.drawable.ic_baseline_code_off_24 + // fcitx5-unikey + "document-edit" -> R.drawable.ic_baseline_edit_24 + "character-set" -> R.drawable.ic_baseline_text_format_24 + "edit-find" -> R.drawable.ic_baseline_search_24 + // fallback + "" -> 0 + else -> { + if (icon.endsWith("-inactive")) { + R.drawable.ic_baseline_code_off_24 + } else { + R.drawable.ic_baseline_code_24 + } + } } fun fromAction(it: Action): Fcitx { - val active = it.isActive() - return Fcitx(it, it.shortText, drawableRes(it.icon, active), active) + val active = it.icon.endsWith("-active") || it.isChecked + return Fcitx(it, it.shortText, drawableFromIconName(it.icon), active) } } } \ No newline at end of file diff --git a/app/src/main/java/org/fcitx/fcitx5/android/input/status/StatusAreaEntryUi.kt b/app/src/main/java/org/fcitx/fcitx5/android/input/status/StatusAreaEntryUi.kt index 72500dfa3..2c0bd71d4 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/input/status/StatusAreaEntryUi.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/input/status/StatusAreaEntryUi.kt @@ -1,18 +1,40 @@ +/* + * SPDX-License-Identifier: LGPL-2.1-or-later + * SPDX-FileCopyrightText: Copyright 2021-2023 Fcitx5 for Android Contributors + */ package org.fcitx.fcitx5.android.input.status import android.content.Context -import android.graphics.PorterDuff -import android.graphics.PorterDuffColorFilter +import android.graphics.Typeface import android.graphics.drawable.ShapeDrawable import android.graphics.drawable.shapes.OvalShape +import android.icu.text.BreakIterator +import android.os.Build +import android.util.TypedValue +import android.view.View import android.view.ViewGroup import android.widget.ImageView import org.fcitx.fcitx5.android.data.theme.Theme +import org.fcitx.fcitx5.android.input.AutoScaleTextView import org.fcitx.fcitx5.android.input.keyboard.CustomGestureView import splitties.dimensions.dp import splitties.resources.drawable -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.centerHorizontally +import splitties.views.dsl.constraintlayout.centerOn +import splitties.views.dsl.constraintlayout.constraintLayout +import splitties.views.dsl.constraintlayout.lParams +import splitties.views.dsl.constraintlayout.topOfParent +import splitties.views.dsl.core.Ui +import splitties.views.dsl.core.add +import splitties.views.dsl.core.frameLayout +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.view +import splitties.views.dsl.core.wrapContent import splitties.views.gravityCenter import splitties.views.imageDrawable @@ -20,11 +42,25 @@ class StatusAreaEntryUi(override val ctx: Context, private val theme: Theme) : U private val bkgDrawable = ShapeDrawable(OvalShape()) - val icon = imageView { + val bkg = frameLayout { background = bkgDrawable + } + + val icon = imageView { scaleType = ImageView.ScaleType.CENTER_INSIDE } + val textIcon = view(::AutoScaleTextView) { + setTextSize(TypedValue.COMPLEX_UNIT_DIP, 20f) + // keep original typeface, apply textStyle only + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + // 600 = Semi Bold, 700 = Bold which is too heavy + typeface = Typeface.create(typeface, 600, false) + } else { + setTypeface(typeface, Typeface.BOLD) + } + } + val label = textView { textSize = 12f gravity = gravityCenter @@ -33,16 +69,20 @@ class StatusAreaEntryUi(override val ctx: Context, private val theme: Theme) : U override val root = object : CustomGestureView(ctx) { val content = constraintLayout { - add(icon, lParams(dp(48), dp(48)) { + add(bkg, lParams(dp(48), dp(48)) { topOfParent(dp(4)) - startOfParent() - endOfParent() + centerHorizontally() above(label) }) + add(icon, lParams { + centerOn(bkg) + }) + add(textIcon, lParams(wrapContent, wrapContent) { + centerOn(bkg) + }) add(label, lParams(wrapContent, wrapContent) { - below(icon, dp(6)) - startOfParent() - endOfParent() + below(bkg, dp(6)) + centerHorizontally() }) } @@ -53,13 +93,33 @@ class StatusAreaEntryUi(override val ctx: Context, private val theme: Theme) : U } fun setEntry(entry: StatusAreaEntry) { - icon.imageDrawable = ctx.drawable(entry.icon) - icon.colorFilter = PorterDuffColorFilter( - if (entry.active) theme.genericActiveForegroundColor else theme.keyTextColor, - PorterDuff.Mode.SRC_IN - ) + val contentColor = + if (entry.active) theme.genericActiveForegroundColor else theme.keyTextColor + if (entry.icon != 0) { + icon.visibility = View.VISIBLE + textIcon.visibility = View.GONE + icon.imageDrawable = ctx.drawable(entry.icon)!!.apply { + setTint(contentColor) + } + } else { + icon.visibility = View.GONE + textIcon.visibility = View.VISIBLE + textIcon.text = getFirstCharacter(entry.label) + textIcon.setTextColor(contentColor) + } bkgDrawable.paint.color = if (entry.active) theme.genericActiveBackgroundColor else theme.keyBackgroundColor label.text = entry.label } + + private fun getFirstCharacter(s: String): String { + if (s.isEmpty()) return "" + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + val iterator = BreakIterator.getCharacterInstance() + iterator.setText(s) + s.substring(iterator.first(), iterator.next()) + } else { + s.substring(0, s.offsetByCodePoints(0, 1)) + } + } } 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 4aedfeeba..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,16 +1,23 @@ +/* + * SPDX-License-Identifier: LGPL-2.1-or-later + * SPDX-FileCopyrightText: Copyright 2021-2025 Fcitx5 for Android Contributors + */ package org.fcitx.fcitx5.android.input.status +import android.os.Build import android.view.View import android.widget.PopupMenu import android.widget.Toast +import androidx.core.text.buildSpannedString +import androidx.core.text.color 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 -import org.fcitx.fcitx5.android.data.theme.Theme import org.fcitx.fcitx5.android.data.theme.ThemeManager import org.fcitx.fcitx5.android.input.FcitxInputMethodService import org.fcitx.fcitx5.android.input.bar.ui.ToolButton @@ -26,8 +33,11 @@ import org.fcitx.fcitx5.android.input.status.StatusAreaEntry.Android.Type.ThemeL 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.DeviceUtil +import org.fcitx.fcitx5.android.utils.alpha import org.mechdancer.dependency.manager.must import splitties.dimensions.dp +import splitties.resources.styledColor import splitties.views.backgroundColor import splitties.views.dsl.core.add import splitties.views.dsl.core.horizontalLayout @@ -63,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 ) @@ -71,31 +81,59 @@ class StatusAreaWindow : InputWindow.ExtendedInputWindow(), } private fun activateAction(action: Action) { - fcitx.launchOnReady { f -> - f.activateAction(action.id) + fcitx.launchOnReady { + it.activateAction(action.id) } } + var popupMenu: PopupMenu? = null + private val adapter: StatusAreaAdapter by lazy { object : StatusAreaAdapter() { override fun onItemClick(view: View, entry: StatusAreaEntry) { when (entry) { is StatusAreaEntry.Fcitx -> { - val menu = entry.action.menu - if (menu != null && menu.isNotEmpty()) { - val popup = PopupMenu(context, view) - menu.forEach { action -> - popup.menu.add(action.shortText).apply { - setOnMenuItemClickListener { - activateAction(action) + val actions = entry.action.menu + if (actions.isNullOrEmpty()) { + activateAction(entry.action) + return + } + val popup = PopupMenu(context, view) + val menu = popup.menu + val hasDivider = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P && !DeviceUtil.isHMOS && !DeviceUtil.isHonorMagicOS) { + menu.setGroupDividerEnabled(true) + true + } else { + false + } + var groupId = 0 // Menu.NONE; ungrouped + actions.forEach { + if (it.isSeparator) { + if (hasDivider) { + groupId++ + } else { + val dividerString = buildSpannedString { + color(context.styledColor(android.R.attr.colorForeground).alpha(0.4f)) { + append("──────────") + } + } + menu.add(groupId, 0, 0, dividerString).apply { + isEnabled = false + } + } + } else { + menu.add(groupId, 0, 0, it.shortText).apply { + setOnMenuItemClickListener { _ -> + activateAction(it) true } } } - popup.show() - } else { - activateAction(entry.action) } + popupMenu?.dismiss() + popupMenu = popup + popup.show() } is StatusAreaEntry.Android -> when (entry.type) { InputMethod -> fcitx.runImmediately { inputMethodEntryCached }.let { @@ -105,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() } @@ -115,8 +156,7 @@ class StatusAreaWindow : InputWindow.ExtendedInputWindow(), } } - override val theme: Theme - get() = this@StatusAreaWindow.theme + override val theme = this@StatusAreaWindow.theme } } @@ -135,7 +175,7 @@ class StatusAreaWindow : InputWindow.ExtendedInputWindow(), override fun onStatusAreaUpdate(actions: Array) { adapter.entries = arrayOf( *staticEntries, - *actions.map { StatusAreaEntry.fromAction(it) }.toTypedArray() + *Array(actions.size) { StatusAreaEntry.fromAction(actions[it]) } ) } @@ -143,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) } } } @@ -174,5 +216,7 @@ class StatusAreaWindow : InputWindow.ExtendedInputWindow(), } override fun onDetached() { + popupMenu?.dismiss() + popupMenu = null } } diff --git a/app/src/main/java/org/fcitx/fcitx5/android/input/wm/EssentialWindow.kt b/app/src/main/java/org/fcitx/fcitx5/android/input/wm/EssentialWindow.kt index b8df5bcc5..35855aacb 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/input/wm/EssentialWindow.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/input/wm/EssentialWindow.kt @@ -1,3 +1,7 @@ +/* + * SPDX-License-Identifier: LGPL-2.1-or-later + * SPDX-FileCopyrightText: Copyright 2021-2023 Fcitx5 for Android Contributors + */ package org.fcitx.fcitx5.android.input.wm /** diff --git a/app/src/main/java/org/fcitx/fcitx5/android/input/wm/InputWindow.kt b/app/src/main/java/org/fcitx/fcitx5/android/input/wm/InputWindow.kt index 9ad23c07f..7f3d92d2a 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/input/wm/InputWindow.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/input/wm/InputWindow.kt @@ -1,3 +1,7 @@ +/* + * SPDX-License-Identifier: LGPL-2.1-or-later + * SPDX-FileCopyrightText: Copyright 2021-2023 Fcitx5 for Android Contributors + */ package org.fcitx.fcitx5.android.input.wm import android.view.Gravity 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 86c67b4ec..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 @@ -1,3 +1,7 @@ +/* + * SPDX-License-Identifier: LGPL-2.1-or-later + * SPDX-FileCopyrightText: Copyright 2021-2023 Fcitx5 for Android Contributors + */ package org.fcitx.fcitx5.android.input.wm import android.view.View @@ -11,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 @@ -63,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") @@ -86,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") @@ -112,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) { @@ -164,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 af3c3f14c..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 @@ -1,3 +1,7 @@ +/* + * SPDX-License-Identifier: LGPL-2.1-or-later + * SPDX-FileCopyrightText: Copyright 2021-2023 Fcitx5 for Android Contributors + */ package org.fcitx.fcitx5.android.provider import android.content.res.AssetFileDescriptor @@ -25,7 +29,9 @@ class FcitxDataProvider : DocumentsProvider() { private val TEXT_EXTENSIONS = arrayOf( "conf", "mb", - "lua" + "lua", + "yml", + "yaml" ) // path relative to baseDir that should be recognize as text files @@ -72,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 @@ -84,7 +90,7 @@ class FcitxDataProvider : DocumentsProvider() { add(Root.COLUMN_ROOT_ID, baseDir.docId) add( Root.COLUMN_FLAGS, - Root.FLAG_SUPPORTS_CREATE or Root.FLAG_SUPPORTS_SEARCH or Root.FLAG_SUPPORTS_IS_CHILD + Root.FLAG_SUPPORTS_CREATE or Root.FLAG_LOCAL_ONLY or Root.FLAG_SUPPORTS_SEARCH or Root.FLAG_SUPPORTS_IS_CHILD ) add(Root.COLUMN_ICON, R.mipmap.app_icon) add(Root.COLUMN_TITLE, context!!.getString(R.string.app_name)) 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 8e3a11632..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 @@ -1,16 +1,23 @@ +/* + * SPDX-License-Identifier: LGPL-2.1-or-later + * SPDX-FileCopyrightText: Copyright 2021-2023 Fcitx5 for Android Contributors + */ package org.fcitx.fcitx5.android.ui.common +import android.annotation.SuppressLint +import android.app.AlertDialog import android.content.Context -import android.graphics.PorterDuff -import android.graphics.PorterDuffColorFilter 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 @@ -19,17 +26,31 @@ 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.imageResource +import splitties.views.imageDrawable import splitties.views.recyclerview.verticalLayoutManager import kotlin.math.min @@ -53,10 +74,9 @@ abstract class BaseDynamicListUi( protected var shouldShowFab = false protected val fab = view(::FloatingActionButton) { - imageResource = R.drawable.ic_baseline_plus_24 - colorFilter = PorterDuffColorFilter( - styledColor(android.R.attr.colorForegroundInverse), PorterDuff.Mode.SRC_IN - ) + imageDrawable = drawable(R.drawable.ic_baseline_plus_24)!!.apply { + setTint(styledColor(android.R.attr.colorForegroundInverse)) + } } sealed class Mode { @@ -238,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 { @@ -292,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 0d7ce41af..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 @@ -1,3 +1,7 @@ +/* + * SPDX-License-Identifier: LGPL-2.1-or-later + * SPDX-FileCopyrightText: Copyright 2021-2023 Fcitx5 for Android Contributors + */ package org.fcitx.fcitx5.android.ui.common import android.annotation.SuppressLint @@ -11,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/DynamicListEntryUi.kt b/app/src/main/java/org/fcitx/fcitx5/android/ui/common/DynamicListEntryUi.kt index 4b8ae5325..075991490 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/ui/common/DynamicListEntryUi.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/ui/common/DynamicListEntryUi.kt @@ -1,29 +1,46 @@ +/* + * SPDX-License-Identifier: LGPL-2.1-or-later + * SPDX-FileCopyrightText: Copyright 2021-2023 Fcitx5 for Android Contributors + */ package org.fcitx.fcitx5.android.ui.common import android.content.Context -import android.graphics.PorterDuff -import android.graphics.PorterDuffColorFilter import android.view.View import android.view.ViewGroup import org.fcitx.fcitx5.android.R import splitties.dimensions.dp +import splitties.resources.drawable import splitties.resources.resolveThemeAttribute import splitties.resources.styledColor import splitties.resources.styledDimenPxSize import splitties.resources.styledDrawable import splitties.views.backgroundColor -import splitties.views.dsl.constraintlayout.* -import splitties.views.dsl.core.* -import splitties.views.imageResource +import splitties.views.dsl.constraintlayout.after +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.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.checkBox +import splitties.views.dsl.core.imageButton +import splitties.views.dsl.core.imageView +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 import splitties.views.textAppearance class DynamicListEntryUi(override val ctx: Context) : Ui { val handleImage = imageView { - imageResource = R.drawable.ic_baseline_drag_handle_24 - colorFilter = - PorterDuffColorFilter(styledColor(android.R.attr.colorAccent), PorterDuff.Mode.SRC_IN) + imageDrawable = drawable(R.drawable.ic_baseline_drag_handle_24)!!.apply { + setTint(styledColor(android.R.attr.colorAccent)) + } setPaddingDp(3, 0, 3, 0) } @@ -38,12 +55,16 @@ class DynamicListEntryUi(override val ctx: Context) : Ui { val editButton = imageButton { background = styledDrawable(android.R.attr.selectableItemBackground) - imageResource = R.drawable.ic_baseline_edit_24 + imageDrawable = drawable(R.drawable.ic_baseline_edit_24)!!.apply { + setTint(styledColor(android.R.attr.colorControlNormal)) + } } val settingsButton = imageButton { background = styledDrawable(android.R.attr.selectableItemBackground) - imageResource = R.drawable.ic_baseline_settings_24 + imageDrawable = drawable(R.drawable.ic_baseline_settings_24)!!.apply { + setTint(styledColor(android.R.attr.colorControlNormal)) + } } override val root: View = constraintLayout { 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/DynamicListTouchCallback.kt b/app/src/main/java/org/fcitx/fcitx5/android/ui/common/DynamicListTouchCallback.kt index 3a5e623c7..33891bd68 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/ui/common/DynamicListTouchCallback.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/ui/common/DynamicListTouchCallback.kt @@ -1,10 +1,12 @@ +/* + * SPDX-License-Identifier: LGPL-2.1-or-later + * SPDX-FileCopyrightText: Copyright 2021-2023 Fcitx5 for Android Contributors + */ package org.fcitx.fcitx5.android.ui.common import android.content.Context +import android.graphics.Bitmap import android.graphics.Canvas -import android.graphics.Paint -import android.graphics.PorterDuff -import android.graphics.PorterDuffColorFilter import android.graphics.Rect import android.graphics.drawable.ColorDrawable import androidx.core.graphics.drawable.toBitmap @@ -28,23 +30,16 @@ open class DynamicListTouchCallback( private var selected = true private var reset = false - private val deleteBackground by lazy { + private val deleteBackground: ColorDrawable by lazy { ColorDrawable().apply { color = ctx.color(R.color.red_400) } } - private val deleteIcon by lazy { - ctx.drawable(R.drawable.ic_baseline_delete_24)!!.toBitmap() - } - - private val deleteIconPaint by lazy { - Paint().apply { - colorFilter = PorterDuffColorFilter( - ctx.styledColor(android.R.attr.colorBackground), - PorterDuff.Mode.SRC_IN - ) - } + private val deleteIcon: Bitmap by lazy { + ctx.drawable(R.drawable.ic_baseline_delete_24)!!.apply { + setTint(ctx.styledColor(android.R.attr.colorBackground)) + }.toBitmap() } // manually call start drag at the on long click listener @@ -94,26 +89,24 @@ open class DynamicListTouchCallback( deleteBackground.apply { bounds = itemView.run { Rect(canvasLeft, top, right, bottom) } }.draw(c) - deleteIcon.also { - val iconMargin = (itemView.height - it.height) / 2 - val revealed = (dX.absoluteValue - iconMargin).toInt() - c.drawBitmap( - it, - /* src = */ Rect( - /* left = */ if (revealed > it.width) 0 else it.width - revealed, - /* top = */ 0, - /* right = */ it.width, - /* bottom = */ it.height - ), - /* dst = */ Rect( - /* left = */ if (revealed > it.width) itemView.right - iconMargin - it.width else canvasLeft, - /* top = */ itemView.top + iconMargin, - /* right = */ itemView.right - iconMargin, - /* bottom = */ itemView.top + iconMargin + it.height - ), - deleteIconPaint - ) - } + val iconMargin = (itemView.height - deleteIcon.height) / 2 + val revealed = (dX.absoluteValue - iconMargin).toInt() + c.drawBitmap( + deleteIcon, + /* src = */ Rect( + /* left = */ if (revealed > deleteIcon.width) 0 else deleteIcon.width - revealed, + /* top = */ 0, + /* right = */ deleteIcon.width, + /* bottom = */ deleteIcon.height + ), + /* dst = */ Rect( + /* left = */ if (revealed > deleteIcon.width) itemView.right - iconMargin - deleteIcon.width else canvasLeft, + /* top = */ itemView.top + iconMargin, + /* right = */ itemView.right - iconMargin, + /* bottom = */ itemView.top + iconMargin + deleteIcon.height + ), + null + ) } ItemTouchHelper.ACTION_STATE_DRAG -> { } diff --git a/app/src/main/java/org/fcitx/fcitx5/android/ui/common/OnItemChangedListener.kt b/app/src/main/java/org/fcitx/fcitx5/android/ui/common/OnItemChangedListener.kt index 54437c3b1..bd2a9ed17 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/ui/common/OnItemChangedListener.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/ui/common/OnItemChangedListener.kt @@ -1,3 +1,7 @@ +/* + * SPDX-License-Identifier: LGPL-2.1-or-later + * SPDX-FileCopyrightText: Copyright 2021-2023 Fcitx5 for Android Contributors + */ package org.fcitx.fcitx5.android.ui.common /** diff --git a/app/src/main/java/org/fcitx/fcitx5/android/ui/common/PaddingPreferenceFragment.kt b/app/src/main/java/org/fcitx/fcitx5/android/ui/common/PaddingPreferenceFragment.kt index f85e9d89e..5f58f2105 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/ui/common/PaddingPreferenceFragment.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/ui/common/PaddingPreferenceFragment.kt @@ -1,3 +1,7 @@ +/* + * SPDX-License-Identifier: LGPL-2.1-or-later + * SPDX-FileCopyrightText: Copyright 2021-2023 Fcitx5 for Android Contributors + */ package org.fcitx.fcitx5.android.ui.common import android.os.Bundle 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 50% 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 6ca4b62c1..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,9 +1,14 @@ +/* + * 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.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 @@ -11,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 { @@ -60,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/common/ThemeSelectPreference.kt b/app/src/main/java/org/fcitx/fcitx5/android/ui/common/ThemeSelectPreference.kt index 8c3a6cd58..b08ed3c94 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/ui/common/ThemeSelectPreference.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/ui/common/ThemeSelectPreference.kt @@ -1,3 +1,7 @@ +/* + * SPDX-License-Identifier: LGPL-2.1-or-later + * SPDX-FileCopyrightText: Copyright 2021-2023 Fcitx5 for Android Contributors + */ package org.fcitx.fcitx5.android.ui.common import android.content.Context 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 29ffba3c7..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,18 +1,26 @@ +/* + * SPDX-License-Identifier: LGPL-2.1-or-later + * 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) { @@ -22,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))) @@ -33,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/ClipboardEditActivity.kt b/app/src/main/java/org/fcitx/fcitx5/android/ui/main/ClipboardEditActivity.kt index 70d297de6..a84dd4570 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/ui/main/ClipboardEditActivity.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/ui/main/ClipboardEditActivity.kt @@ -1,3 +1,7 @@ +/* + * SPDX-License-Identifier: LGPL-2.1-or-later + * SPDX-FileCopyrightText: Copyright 2021-2023 Fcitx5 for Android Contributors + */ package org.fcitx.fcitx5.android.ui.main import android.app.Activity @@ -64,18 +68,18 @@ class ClipboardEditActivity : Activity() { private fun processIntent(intent: Intent) { scope.launch { - intent.extras?.run { - if (getBoolean(LAST_ENTRY)) { + intent.run { + if (getBooleanExtra(LAST_ENTRY, false)) { ClipboardManager.lastEntry } else { - ClipboardManager.get(getInt(ENTRY_ID)) + ClipboardManager.get(getIntExtra(ENTRY_ID, -1)) } }?.let { setEntry(it) } } } - override fun onPause() { - super.onPause() + override fun onStop() { + super.onStop() finish() } 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 deb9bc4ad..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 @@ -1,18 +1,23 @@ +/* + * SPDX-License-Identifier: LGPL-2.1-or-later + * SPDX-FileCopyrightText: Copyright 2021-2023 Fcitx5 for Android Contributors + */ package org.fcitx.fcitx5.android.ui.main import android.os.Bundle import android.os.Debug -import android.widget.Toast import androidx.activity.result.ActivityResultLauncher import androidx.activity.result.contract.ActivityResultContracts.CreateDocument import androidx.appcompat.app.AlertDialog import androidx.lifecycle.lifecycleScope -import androidx.preference.SwitchPreference 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 @@ -20,6 +25,7 @@ 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 @@ -30,14 +36,18 @@ class DeveloperFragment : PaddingPreferenceFragment() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - val ctx = requireContext() launcher = registerForActivityResult(CreateDocument("application/octet-stream")) { uri -> + if (uri == null) { + hprofFile.delete() + return@registerForActivityResult + } + val ctx = requireContext() lifecycleScope.launch(NonCancellable + Dispatchers.IO) { - uri?.runCatching { - ctx.contentResolver.openOutputStream(uri)?.use { o -> + runCatching { + ctx.contentResolver.openOutputStream(uri)!!.use { o -> hprofFile.inputStream().use { i -> i.copyTo(o) } } - }?.toast(ctx) + }.let { ctx.toast(it) } hprofFile.delete() } } @@ -46,7 +56,7 @@ class DeveloperFragment : PaddingPreferenceFragment() { override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { preferenceScreen = preferenceManager.createPreferenceScreen(requireContext()).apply { addPreference(R.string.real_time_logs) { - AppUtil.launchLog(context) + startActivity() } addPreference(MySwitchPreference(context).apply { key = AppPrefs.getInstance().internal.verboseLog.key @@ -55,20 +65,60 @@ 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 + setTitle(R.string.editor_info_inspector) + setDefaultValue(false) + 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() - launch(Dispatchers.Main) { - Toast.makeText( - context, - getString(R.string.synced), - Toast.LENGTH_SHORT - ).show() + withContext(Dispatchers.Main) { + context.toast(R.string.synced) } } } @@ -76,10 +126,19 @@ class DeveloperFragment : PaddingPreferenceFragment() { .show() } addPreference(R.string.clear_clb_db) { - lifecycleScope.launch { - ClipboardManager.nukeTable() - Toast.makeText(context, getString(R.string.done), Toast.LENGTH_SHORT).show() - } + AlertDialog.Builder(context) + .setTitle(R.string.clear_clb_db) + .setMessage(R.string.clear_clp_db_confirm) + .setPositiveButton(android.R.string.ok) { _, _ -> + lifecycleScope.launch(NonCancellable + Dispatchers.IO) { + ClipboardManager.nukeTable() + withContext(Dispatchers.Main) { + context.toast(R.string.done) + } + } + } + .setNegativeButton(android.R.string.cancel, null) + .show() } addPreference(R.string.capture_heap_dump) { val fileName = "${context.packageName}_${iso8601UTCDateTime()}.hprof" diff --git a/app/src/main/java/org/fcitx/fcitx5/android/ui/main/LicensesFragment.kt b/app/src/main/java/org/fcitx/fcitx5/android/ui/main/LicensesFragment.kt index 9b3071b14..02c7fca29 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/ui/main/LicensesFragment.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/ui/main/LicensesFragment.kt @@ -1,3 +1,7 @@ +/* + * SPDX-License-Identifier: LGPL-2.1-or-later + * SPDX-FileCopyrightText: Copyright 2021-2023 Fcitx5 for Android Contributors + */ package org.fcitx.fcitx5.android.ui.main import android.app.AlertDialog 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 5a3eab3e6..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 @@ -1,9 +1,13 @@ +/* + * SPDX-License-Identifier: LGPL-2.1-or-later + * SPDX-FileCopyrightText: Copyright 2021-2023 Fcitx5 for Android Contributors + */ 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 @@ -21,9 +25,10 @@ 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.styledColor import splitties.views.topPadding class LogActivity : AppCompatActivity() { @@ -35,22 +40,23 @@ class LogActivity : AppCompatActivity() { private fun registerLauncher() { launcher = registerForActivityResult(CreateDocument("text/plain")) { uri -> + if (uri == null) return@registerForActivityResult lifecycleScope.launch(NonCancellable + Dispatchers.IO) { - uri?.runCatching { - contentResolver.openOutputStream(this)?.use { stream -> + runCatching { + contentResolver.openOutputStream(uri)!!.use { stream -> stream.bufferedWriter().use { writer -> writer.write(DeviceInfo.get(this@LogActivity)) 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()) @@ -91,23 +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 { - setIcon(R.drawable.ic_baseline_delete_24) - 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 { - setIcon(R.drawable.ic_baseline_save_24) - 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 } @@ -116,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 8ebdbcd34..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,37 +1,44 @@ +/* + * SPDX-License-Identifier: LGPL-2.1-or-later + * 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.result.contract.ActivityResultContracts +import androidx.activity.enableEdgeToEdge import androidx.activity.viewModels import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AppCompatActivity -import androidx.core.content.ContextCompat -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.styledColor import splitties.views.topPadding -import timber.log.Timber class MainActivity : AppCompatActivity() { @@ -41,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()) @@ -54,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()) { @@ -77,145 +86,132 @@ class MainActivity : AppCompatActivity() { binding.toolbar.elevation = dp(if (it) 4f else 0f) } navController.addOnDestinationChangedListener { _, dest, _ -> - when (dest.id) { - R.id.themeListFragment -> viewModel.disableToolbarShadow() - else -> viewModel.enableToolbarShadow() + dest.label?.let { viewModel.setToolbarTitle(it.toString()) } + if (dest.hasRoute()) { + viewModel.disableToolbarShadow() + } else { + viewModel.enableToolbarShadow() } } - if (SetupActivity.shouldShowUp() && intent.action == Intent.ACTION_MAIN) - startActivity(Intent(this, SetupActivity::class.java)) processIntent(intent) - requestNotificationPermission() + checkNotificationPermission() } - override fun onNewIntent(intent: Intent?) { - processIntent(intent) + override fun onNewIntent(intent: Intent) { super.onNewIntent(intent) - navController.handleDeepLink(intent) + processIntent(intent) } private fun processIntent(intent: Intent?) { - listOf(::processAddDictIntent).firstOrNull { it(intent) } - } - - private fun processAddDictIntent(intent: Intent?): Boolean { - if (intent != null && 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(R.string.import_) { _, _ -> - navController.navigateFromMain( - R.id.action_mainFragment_to_pinyinDictionaryFragment, - bundleOf(PinyinDictionaryFragment.INTENT_DATA_URI to it) - ) + .setPositiveButton(android.R.string.ok) { _, _ -> + navController.popBackStack(SettingsRoute.Index, false) + navController.navigateWithAnim(SettingsRoute.PinyinDict(it)) } .show() } - return true + Intent.ACTION_RUN -> { + val route = intent.parcelable(EXTRA_SETTINGS_ROUTE) ?: return + navController.popBackStack(SettingsRoute.Index, false) + navController.navigateWithAnim(route) + } } - return false } - override fun onCreateOptionsMenu(menu: Menu): Boolean = menu.run { - add(R.string.save).apply { - setIcon(R.drawable.ic_baseline_save_24) - 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 - } + 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 aboutMenus = mutableListOf() - add(R.string.faq).apply { - aboutMenus.add(this) - setOnMenuItemClickListener { + val aboutMenuItems = listOf( + menu.item(R.string.faq) { + @SuppressLint("UseKtx") startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(Const.faqUrl))) - true + }, + menu.item(R.string.developer) { + navController.navigateWithAnim(SettingsRoute.Developer) + }, + menu.item(R.string.about) { + navController.navigateWithAnim(SettingsRoute.About) } + ) + viewModel.aboutButton.observe(this@MainActivity) { enabled -> + aboutMenuItems.forEach { menu -> menu.isVisible = enabled } } - add(R.string.developer).apply { - aboutMenus.add(this) - setOnMenuItemClickListener { - navController.navigate(R.id.action_mainFragment_to_developerFragment) - true - } + 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 } } - add(R.string.about).apply { - aboutMenus.add(this) - setOnMenuItemClickListener { - navController.navigate(R.id.action_mainFragment_to_aboutFragment) - true - } - } - viewModel.aboutButton.apply { - observe(this@MainActivity) { enabled -> - aboutMenus.forEach { menu -> menu.isVisible = enabled } - } - setValue(value) - } - - add(R.string.edit).apply { - setIcon(R.drawable.ic_baseline_edit_24) - setShowAsAction(MenuItem.SHOW_AS_ACTION_IF_ROOM) - viewModel.toolbarEditButtonVisible.apply { - observe(this@MainActivity) { isVisible = it } - setValue(value) - } - setOnMenuItemClickListener { - viewModel.toolbarEditButtonOnClickListener.value?.invoke() - true - } + 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 } } - - add(R.string.delete).apply { - setIcon(R.drawable.ic_baseline_delete_24) - 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 + // all menus should be invisible and enabled on demand + menu.forEach { it.isVisible = false } } - private val requestNotificationPermissionLauncher = - registerForActivityResult(ActivityResultContracts.RequestPermission()) { - if (!it) { - AlertDialog.Builder(this) - .setNeutralButton(android.R.string.ok, null) - .setTitle(R.string.notification_permission_title) - .setMessage(R.string.notification_permission_message) - .setIcon(R.drawable.ic_baseline_info_24) - .show() - } - } + private var needNotifications by AppPrefs.getInstance().internal.needNotifications - private fun requestNotificationPermission() { + private fun checkNotificationPermission() { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - when { - ContextCompat.checkSelfPermission( - this, - Manifest.permission.POST_NOTIFICATIONS - ) == PackageManager.PERMISSION_GRANTED -> { - } - shouldShowRequestPermissionRationale(Manifest.permission.POST_NOTIFICATIONS) -> { - Timber.d("No notification permission") + if (checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS) == PackageManager.PERMISSION_GRANTED) { + needNotifications = true + return + } + // do not ask again if user denied the request + if (!needNotifications) return + // always show a dialog to explain why we need notification permission, + // regardless of `shouldShowRequestPermissionRationale(...)` + AlertDialog.Builder(this) + .setIconAttribute(android.R.attr.alertDialogIcon) + .setTitle(R.string.notification_permission_title) + .setMessage(R.string.notification_permission_message) + .setNegativeButton(R.string.i_do_not_need_it) { _, _ -> + // do not ask again if user denied the request + needNotifications = false } - else -> { - requestNotificationPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS) + .setPositiveButton(R.string.grant_permission) { _, _ -> + requestPermissions(arrayOf(Manifest.permission.POST_NOTIFICATIONS), 0) } - } + .show() + } + } + + override fun onRequestPermissionsResult( + requestCode: Int, + permissions: Array, + grantResults: IntArray + ) { + super.onRequestPermissionsResult(requestCode, permissions, grantResults) + if (requestCode != 0) return + // do not ask again if user denied the request + needNotifications = grantResults.getOrNull(0) == PackageManager.PERMISSION_GRANTED + } + + override fun onStop() { + viewModel.fcitx.runIfReady { + save() } + super.onStop() + } + + companion object { + const val EXTRA_SETTINGS_ROUTE = "${BuildConfig.APPLICATION_ID}.EXTRA_SETTINGS_ROUTE" } -} \ No newline at end of file +} 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 73081b303..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,16 +1,20 @@ +/* + * SPDX-License-Identifier: LGPL-2.1-or-later + * 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() { @@ -29,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) } } @@ -42,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_themeListFragment + 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/MainViewModel.kt b/app/src/main/java/org/fcitx/fcitx5/android/ui/main/MainViewModel.kt index cbcd73569..00f1ffb4d 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/ui/main/MainViewModel.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/ui/main/MainViewModel.kt @@ -1,3 +1,7 @@ +/* + * SPDX-License-Identifier: LGPL-2.1-or-later + * SPDX-FileCopyrightText: Copyright 2021-2023 Fcitx5 for Android Contributors + */ package org.fcitx.fcitx5.android.ui.main import androidx.lifecycle.MutableLiveData 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 b77e24371..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 @@ -1,8 +1,15 @@ +/* + * SPDX-License-Identifier: LGPL-2.1-or-later + * SPDX-FileCopyrightText: Copyright 2021-2023 Fcitx5 for Android Contributors + */ 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 @@ -23,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) { @@ -63,7 +127,7 @@ class PluginFragment : PaddingPreferenceFragment() { return@apply } if (loaded.isNotEmpty()) { - addCategory(R.string.loaded) { + addCategory(R.string.plugins_loaded) { isIconSpaceReserved = false loaded.forEach { addPreference(it.name, "${it.versionName}\n${it.description}") { @@ -73,7 +137,7 @@ class PluginFragment : PaddingPreferenceFragment() { } } if (failed.isNotEmpty()) { - addCategory(R.string.failed) { + addCategory(R.string.plugins_failed) { isIconSpaceReserved = false failed.forEach { (packageName, reason) -> val summary = when (reason) { @@ -87,14 +151,11 @@ class PluginFragment : PaddingPreferenceFragment() { getString(R.string.missing_plugin_descriptor) } is PluginLoadFailed.PathConflict -> { - getString( - R.string.path_conflict, - reason.path, - when (val src = reason.existingSrc) { - FileSource.Main -> R.string.main_program - is FileSource.Plugin -> src.descriptor.name - } - ) + val owner = when (reason.existingSrc) { + FileSource.Main -> getString(R.string.main_program) + is FileSource.Plugin -> reason.existingSrc.descriptor.name + } + getString(R.string.path_conflict, reason.path, owner) } is PluginLoadFailed.PluginAPIIncompatible -> { getString(R.string.incompatible_api, reason.api) @@ -111,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 @@ -129,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 @@ -148,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 cfa33c077..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 @@ -1,3 +1,7 @@ +/* + * SPDX-License-Identifier: LGPL-2.1-or-later + * SPDX-FileCopyrightText: Copyright 2021-2023 Fcitx5 for Android Contributors + */ package org.fcitx.fcitx5.android.ui.main.log import android.graphics.Typeface @@ -8,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 @@ -41,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/log/LogView.kt b/app/src/main/java/org/fcitx/fcitx5/android/ui/main/log/LogView.kt index da9981fa2..eee3b7896 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/ui/main/log/LogView.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/ui/main/log/LogView.kt @@ -1,3 +1,7 @@ +/* + * SPDX-License-Identifier: LGPL-2.1-or-later + * SPDX-FileCopyrightText: Copyright 2021-2023 Fcitx5 for Android Contributors + */ package org.fcitx.fcitx5.android.ui.main.log import android.content.Context 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 a147f0e81..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 @@ -1,3 +1,7 @@ +/* + * SPDX-License-Identifier: LGPL-2.1-or-later + * SPDX-FileCopyrightText: Copyright 2021-2023 Fcitx5 for Android Contributors + */ package org.fcitx.fcitx5.android.ui.main.modified import android.view.View @@ -21,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/modified/MyEditTextPreferenceDialogFragment.kt b/app/src/main/java/org/fcitx/fcitx5/android/ui/main/modified/MyEditTextPreferenceDialogFragment.kt index 239dedc71..58cd827d2 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/ui/main/modified/MyEditTextPreferenceDialogFragment.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/ui/main/modified/MyEditTextPreferenceDialogFragment.kt @@ -1,3 +1,7 @@ +/* + * SPDX-License-Identifier: LGPL-2.1-or-later + * SPDX-FileCopyrightText: Copyright 2021-2023 Fcitx5 for Android Contributors + */ package org.fcitx.fcitx5.android.ui.main.modified import android.os.Bundle diff --git a/app/src/main/java/org/fcitx/fcitx5/android/ui/main/modified/MyListPreferenceDialogFragment.kt b/app/src/main/java/org/fcitx/fcitx5/android/ui/main/modified/MyListPreferenceDialogFragment.kt index d1609a30b..31320552c 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/ui/main/modified/MyListPreferenceDialogFragment.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/ui/main/modified/MyListPreferenceDialogFragment.kt @@ -1,3 +1,7 @@ +/* + * SPDX-License-Identifier: LGPL-2.1-or-later + * SPDX-FileCopyrightText: Copyright 2021-2023 Fcitx5 for Android Contributors + */ package org.fcitx.fcitx5.android.ui.main.modified import android.os.Bundle diff --git a/app/src/main/java/org/fcitx/fcitx5/android/ui/main/modified/MyPreferenceFragment.kt b/app/src/main/java/org/fcitx/fcitx5/android/ui/main/modified/MyPreferenceFragment.kt index 734161cf1..f44eea0be 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/ui/main/modified/MyPreferenceFragment.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/ui/main/modified/MyPreferenceFragment.kt @@ -1,3 +1,7 @@ +/* + * SPDX-License-Identifier: LGPL-2.1-or-later + * SPDX-FileCopyrightText: Copyright 2021-2023 Fcitx5 for Android Contributors + */ package org.fcitx.fcitx5.android.ui.main.modified import android.annotation.SuppressLint diff --git a/app/src/main/java/org/fcitx/fcitx5/android/ui/main/modified/MySwitchPreference.kt b/app/src/main/java/org/fcitx/fcitx5/android/ui/main/modified/MySwitchPreference.kt index e5b2e8508..6571a3eff 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/ui/main/modified/MySwitchPreference.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/ui/main/modified/MySwitchPreference.kt @@ -1,3 +1,7 @@ +/* + * SPDX-License-Identifier: LGPL-2.1-or-later + * SPDX-FileCopyrightText: Copyright 2021-2023 Fcitx5 for Android Contributors + */ package org.fcitx.fcitx5.android.ui.main.modified import android.content.Context 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 404479b30..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 @@ -1,3 +1,7 @@ +/* + * SPDX-License-Identifier: LGPL-2.1-or-later + * SPDX-FileCopyrightText: Copyright 2021-2023 Fcitx5 for Android Contributors + */ package org.fcitx.fcitx5.android.ui.main.settings import android.content.Context @@ -9,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 @@ -36,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 { @@ -63,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() { @@ -119,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 175bdfbdd..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 @@ -1,3 +1,7 @@ +/* + * SPDX-License-Identifier: LGPL-2.1-or-later + * SPDX-FileCopyrightText: Copyright 2021-2023 Fcitx5 for Android Contributors + */ package org.fcitx.fcitx5.android.ui.main.settings import android.content.Context @@ -15,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) @@ -55,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/FcitxKeyPreference.kt b/app/src/main/java/org/fcitx/fcitx5/android/ui/main/settings/FcitxKeyPreference.kt index b40afcdd2..1efc8d975 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/ui/main/settings/FcitxKeyPreference.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/ui/main/settings/FcitxKeyPreference.kt @@ -1,3 +1,7 @@ +/* + * SPDX-License-Identifier: LGPL-2.1-or-later + * SPDX-FileCopyrightText: Copyright 2021-2023 Fcitx5 for Android Contributors + */ package org.fcitx.fcitx5.android.ui.main.settings import android.content.Context 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 76c9deda6..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,3 +1,7 @@ +/* + * SPDX-License-Identifier: LGPL-2.1-or-later + * SPDX-FileCopyrightText: Copyright 2021-2025 Fcitx5 for Android Contributors + */ package org.fcitx.fcitx5.android.ui.main.settings import android.os.Bundle @@ -9,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 @@ -35,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"]) + } } } @@ -53,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/FcitxRawConfigStore.kt b/app/src/main/java/org/fcitx/fcitx5/android/ui/main/settings/FcitxRawConfigStore.kt index 0f0d3eed0..86cc1da4c 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/ui/main/settings/FcitxRawConfigStore.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/ui/main/settings/FcitxRawConfigStore.kt @@ -1,3 +1,7 @@ +/* + * SPDX-License-Identifier: LGPL-2.1-or-later + * SPDX-FileCopyrightText: Copyright 2021-2023 Fcitx5 for Android Contributors + */ package org.fcitx.fcitx5.android.ui.main.settings import androidx.preference.PreferenceDataStore diff --git a/app/src/main/java/org/fcitx/fcitx5/android/ui/main/settings/KeyPreferenceUi.kt b/app/src/main/java/org/fcitx/fcitx5/android/ui/main/settings/KeyPreferenceUi.kt index e6c8c64e3..08b39782a 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/ui/main/settings/KeyPreferenceUi.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/ui/main/settings/KeyPreferenceUi.kt @@ -1,3 +1,7 @@ +/* + * SPDX-License-Identifier: LGPL-2.1-or-later + * SPDX-FileCopyrightText: Copyright 2021-2023 Fcitx5 for Android Contributors + */ package org.fcitx.fcitx5.android.ui.main.settings import android.content.Context @@ -11,13 +15,30 @@ import org.fcitx.fcitx5.android.core.KeyStates import org.fcitx.fcitx5.android.core.KeySym import org.fcitx.fcitx5.android.input.FcitxInputMethodService import splitties.dimensions.dp +import splitties.resources.drawable import splitties.resources.styledColor import splitties.resources.styledColorSL 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.after +import splitties.views.dsl.constraintlayout.before +import splitties.views.dsl.constraintlayout.below +import splitties.views.dsl.constraintlayout.bottomOfParent +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.constraintlayout.topOfParent +import splitties.views.dsl.core.Ui +import splitties.views.dsl.core.add +import splitties.views.dsl.core.button +import splitties.views.dsl.core.editText +import splitties.views.dsl.core.imageButton +import splitties.views.dsl.core.textView +import splitties.views.dsl.core.wrapContent import splitties.views.gravityCenter -import splitties.views.imageResource +import splitties.views.imageDrawable class KeyPreferenceUi(override val ctx: Context) : Ui { @@ -26,8 +47,7 @@ class KeyPreferenceUi(override val ctx: Context) : Ui { } private inner class ModifierButton(label: String, val modifier: KeyState) : Ui { - override val ctx: Context - get() = this@KeyPreferenceUi.ctx + override val ctx = this@KeyPreferenceUi.ctx override val root = button { text = label @@ -96,7 +116,9 @@ class KeyPreferenceUi(override val ctx: Context) : Ui { private val clearButton = imageButton { background = styledDrawable(android.R.attr.actionBarItemBackground) - imageResource = R.drawable.ic_baseline_delete_24 + imageDrawable = drawable(R.drawable.ic_baseline_delete_24)!!.apply { + setTint(styledColor(android.R.attr.colorControlNormal)) + } setOnClickListener { setKey(Key.None) } 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 e4a98f400..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,3 +1,7 @@ +/* + * SPDX-License-Identifier: LGPL-2.1-or-later + * SPDX-FileCopyrightText: Copyright 2021-2025 Fcitx5 for Android Contributors + */ package org.fcitx.fcitx5.android.ui.main.settings import android.app.AlertDialog @@ -15,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() @@ -46,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 -> { @@ -154,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 new file mode 100644 index 000000000..7778e316d --- /dev/null +++ b/app/src/main/java/org/fcitx/fcitx5/android/ui/main/settings/PinyinCustomPhraseFragment.kt @@ -0,0 +1,242 @@ +/* + * SPDX-License-Identifier: LGPL-2.1-or-later + * SPDX-FileCopyrightText: Copyright 2021-2023 Fcitx5 for Android Contributors + */ +package org.fcitx.fcitx5.android.ui.main.settings + +import android.app.AlertDialog +import android.os.Bundle +import android.text.InputFilter +import android.text.InputType +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.view.inputmethod.EditorInfo +import androidx.fragment.app.Fragment +import androidx.fragment.app.activityViewModels +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 +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.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 +import splitties.views.dsl.core.matchParent +import splitties.views.dsl.core.verticalLayout +import splitties.views.setPaddingDp +import kotlin.math.absoluteValue +import kotlin.math.min + +class PinyinCustomPhraseFragment : Fragment(), OnItemChangedListener { + + private val viewModel: MainViewModel by activityViewModels() + + private lateinit var ui: BaseDynamicListUi + + private val dustman = NaiveDustman() + + private val initialItems = CustomPhraseManager.load() ?: emptyArray() + + private var keyLabel = KEY + private var orderLabel = ORDER + private var phraseLabel = PHRASE + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + lifecycleScope.launch { + viewModel.fcitx.runOnReady { + keyLabel = translate(KEY, CHINESE_ADDONS_DOMAIN) + orderLabel = translate(ORDER, CHINESE_ADDONS_DOMAIN) + phraseLabel = translate(PHRASE, CHINESE_ADDONS_DOMAIN) + } + } + val initialEntries = initialItems.toList() + ui = object : BaseDynamicListUi( + requireContext(), + Mode.FreeAdd("", converter = { PinyinCustomPhrase("", 1, "") }), + initialItems.toList(), + enableOrder = true, + initCheckBox = { entry -> + setOnCheckedChangeListener(null) + isChecked = entry.enabled + setOnCheckedChangeListener { _, checked -> + ui.updateItem(ui.indexItem(entry), entry.copyEnabled(checked)) + } + } + ) { + override fun showEntry(x: PinyinCustomPhrase): String { + val s = x.serialize() + val firstLF = s.indexOf('\n') + val endIndex = min(if (firstLF > 0) firstLF else s.length, 20) + return if (endIndex == s.length) { + s + } else { + s.substring(0, endIndex) + "…" + } + } + + override fun showEditDialog( + title: String, + entry: PinyinCustomPhrase?, + block: (PinyinCustomPhrase) -> Unit + ) { + val (keyLayout, keyField) = materialTextInput { + hint = keyLabel + } + keyField.apply { + isSingleLine = true + filters = arrayOf( + InputFilter { source, _, _, _, _, _ -> + source.filter { it.code in 'A'.code..'Z'.code || it.code in 'a'.code..'z'.code } + } + ) + imeOptions = EditorInfo.IME_ACTION_NEXT + inputType = + InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_NORMAL or InputType.TYPE_TEXT_FLAG_NO_SUGGESTIONS + } + val (orderLayout, orderField) = materialTextInput { + hint = orderLabel + } + orderField.apply { + isSingleLine = true + inputType = + InputType.TYPE_CLASS_NUMBER or InputType.TYPE_NUMBER_VARIATION_NORMAL or InputType.TYPE_NUMBER_FLAG_SIGNED + imeOptions = EditorInfo.IME_ACTION_NEXT + } + val (phraseLayout, phraseField) = materialTextInput { + hint = phraseLabel + } + phraseField.apply { + isSingleLine = false + maxLines = 8 + } + entry?.apply { + keyField.setText(key) + orderField.setText(order.absoluteValue.toString(10)) + phraseField.setText(value) + } + val layout = verticalLayout { + setPaddingDp(20, 10, 20, 0) + add(keyLayout, lParams(matchParent)) + add(orderLayout, lParams(matchParent)) + add(phraseLayout, lParams(matchParent)) + } + AlertDialog.Builder(context) + .setTitle(title) + .setView(layout) + .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) + ui.addTouchCallback() + resetDustman() + ui.setViewModel(viewModel) + viewModel.enableToolbarEditButton(initialEntries.isNotEmpty()) { + ui.enterMultiSelect(requireActivity().onBackPressedDispatcher) + } + return ui.root + } + + override fun onItemAdded(idx: Int, item: PinyinCustomPhrase) { + dustman.addOrUpdate(item.serialize(), item) + } + + override fun onItemRemoved(idx: Int, item: PinyinCustomPhrase) { + dustman.remove(item.serialize()) + } + + override fun onItemRemovedBatch(indexed: List>) { + batchRemove(indexed) + } + + override fun onItemUpdated(idx: Int, old: PinyinCustomPhrase, new: PinyinCustomPhrase) { + dustman.remove(old.serialize()) + dustman.addOrUpdate(new.serialize(), new) + } + + private fun saveConfig() { + if (!dustman.dirty) return + resetDustman() + lifecycleScope.launch(NonCancellable + Dispatchers.IO) { + CustomPhraseManager.save(ui.entries.toTypedArray()) + viewModel.fcitx.runOnReady { + reloadPinyinCustomPhrase() + } + } + } + + private fun resetDustman() { + dustman.reset(ui.entries.associateBy { it.serialize() }) + } + + override fun onStart() { + super.onStart() + lifecycleScope.launch { + val title = viewModel.fcitx.runOnReady { + translate(MANAGE_CUSTOM_PHRASE, CHINESE_ADDONS_DOMAIN) + } + viewModel.setToolbarTitle(title) + } + viewModel.enableToolbarEditButton(ui.entries.isNotEmpty()) { + ui.enterMultiSelect(requireActivity().onBackPressedDispatcher) + } + } + + override fun onStop() { + saveConfig() + ui.exitMultiSelect() + viewModel.disableToolbarEditButton() + super.onStop() + } + + override fun onDestroy() { + ui.removeItemChangedListener() + super.onDestroy() + } + + companion object { + const val CHINESE_ADDONS_DOMAIN = "fcitx5-chinese-addons" + const val KEY = "Key" + const val ORDER = "Order" + const val PHRASE = "Phrase" + const val MANAGE_CUSTOM_PHRASE = "Manage Custom Phrase" + } + +} 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 f56e78619..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,8 +1,12 @@ +/* + * SPDX-License-Identifier: LGPL-2.1-or-later + * 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.content.ContentResolver import android.net.Uri import android.os.Build import android.os.Bundle @@ -15,34 +19,31 @@ import androidx.core.app.NotificationCompat import androidx.fragment.app.Fragment import androidx.fragment.app.activityViewModels import androidx.lifecycle.lifecycleScope -import arrow.core.None -import arrow.core.Option -import arrow.core.continuations.option import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import org.fcitx.fcitx5.android.R import org.fcitx.fcitx5.android.core.reloadPinyinDict import org.fcitx.fcitx5.android.data.pinyin.PinyinDictManager -import org.fcitx.fcitx5.android.data.pinyin.dict.Dictionary +import org.fcitx.fcitx5.android.data.pinyin.dict.BuiltinDictionary import org.fcitx.fcitx5.android.data.pinyin.dict.LibIMEDictionary +import org.fcitx.fcitx5.android.data.pinyin.dict.PinyinDictionary 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.io.File import java.util.concurrent.atomic.AtomicBoolean -class PinyinDictionaryFragment : Fragment(), OnItemChangedListener { +class PinyinDictionaryFragment : Fragment(), OnItemChangedListener { - private val viewModel: MainViewModel by activityViewModels() + private val args by lazyRoute() - private val contentResolver: ContentResolver - get() = requireContext().contentResolver + private val viewModel: MainViewModel by activityViewModels() private lateinit var launcher: ActivityResultLauncher @@ -52,17 +53,22 @@ class PinyinDictionaryFragment : Fragment(), OnItemChangedListener by lazy { - object : BaseDynamicListUi( + private val ui: BaseDynamicListUi by lazy { + object : BaseDynamicListUi( requireContext(), Mode.Custom(), - PinyinDictManager.libIMEDictionaries(), + PinyinDictManager.listDictionaries(), initCheckBox = { entry -> - setOnCheckedChangeListener(null) - isChecked = entry.isEnabled - setOnCheckedChangeListener { _, isChecked -> - if (isChecked) entry.enable() else entry.disable() - ui.updateItem(ui.indexItem(entry), entry) + if (entry is LibIMEDictionary) { + setOnCheckedChangeListener(null) + isChecked = entry.isEnabled + setOnCheckedChangeListener { _, isChecked -> + if (isChecked) entry.enable() else entry.disable() + ui.updateItem(ui.indexItem(entry), entry) + } + } else { + isChecked = true + isEnabled = false } } ) { @@ -76,13 +82,14 @@ class PinyinDictionaryFragment : Fragment(), OnItemChangedListener e !is BuiltinDictionary } } override fun updateFAB() { // do nothing } - override fun showEntry(x: LibIMEDictionary): String = x.name + override fun showEntry(x: PinyinDictionary): String = x.name }.also { uiInitialized = true } @@ -101,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) } @@ -113,7 +120,7 @@ class PinyinDictionaryFragment : Fragment(), OnItemChangedListener { - importErrorDialog(getString(R.string.dict_already_exists)) - shift(None) - } - Dictionary.Type.fromFileName(file.name) == null -> { - importErrorDialog(getString(R.string.invalid_dict)) - shift(None) - } - else -> Unit - } - NotificationCompat.Builder(requireContext(), CHANNEL_ID) - .setSmallIcon(R.drawable.ic_baseline_library_books_24) - .setContentTitle(getString(R.string.pinyin_dict)) - .setContentText("${getString(R.string.importing)} ${file.nameWithoutExtension}") - .setOngoing(true) - .setProgress(100, 0, true) - .setPriority(NotificationCompat.PRIORITY_HIGH) - .build().let { nm.notify(id, it) } - val inputStream = Option - .catch { contentResolver.openInputStream(uri) } - .mapNotNull { it } - .bind() - runCatching { - inputStream.use { i -> - PinyinDictManager.importFromInputStream(i, file.name).getOrThrow() - } - }.onFailure { - importErrorDialog(it.localizedMessage ?: it.stackTraceToString()) - }.onSuccess { - launch(Dispatchers.Main) { - ui.addItem(item = it) - } + val fileName = cr.queryFileName(uri) ?: return@launch + if (PinyinDictionary.Type.fromFileName(fileName) == null) { + ctx.importErrorDialog(R.string.invalid_dict) + return@launch + } + val entryName = fileName.substringBeforeLast('.') + if (ui.entries.any { it.name == entryName }) { + ctx.importErrorDialog(R.string.dict_already_exists) + return@launch + } + NotificationCompat.Builder(ctx, CHANNEL_ID) + .setSmallIcon(R.drawable.ic_baseline_library_books_24) + .setContentTitle(getString(R.string.pinyin_dict)) + .setContentText("${getString(R.string.importing)} $entryName") + .setOngoing(true) + .setProgress(100, 0, true) + .setPriority(NotificationCompat.PRIORITY_HIGH) + .build().let { nm.notify(id, it) } + try { + val inputStream = cr.openInputStream(uri)!! + val imported = PinyinDictManager.importFromInputStream(inputStream, fileName) + .getOrThrow() + withContext(Dispatchers.Main) { + ui.addItem(item = imported) } + } catch (e: Exception) { + ctx.importErrorDialog(e) } nm.cancel(id) } } - private suspend fun importErrorDialog(message: String) { - errorDialog(requireContext(), getString(R.string.import_error), message) - } - private fun reloadDict() { if (!dustman.dirty) return resetDustman() // Save the reference to NotificationManager, because reloadDict() could be called // right before the Fragment detached from Activity, and at the time reload completes, // Fragment is no longer attached to a Context, thus unable to cancel the notification. - val nm = notificationManager + val nm = requireContext().notificationManager lifecycleScope.launch(NonCancellable + Dispatchers.IO) { if (busy.compareAndSet(false, true)) { val id = RELOAD_ID++ @@ -201,23 +197,27 @@ class PinyinDictionaryFragment : Fragment(), OnItemChangedListener>) { + override fun onItemRemovedBatch(indexed: List>) { batchRemove(indexed) } - override fun onItemUpdated(idx: Int, old: LibIMEDictionary, new: LibIMEDictionary) { + override fun onItemUpdated(idx: Int, old: PinyinDictionary, new: PinyinDictionary) { + new as LibIMEDictionary dustman.addOrUpdate(new.name, new.isEnabled) } @@ -250,6 +250,5 @@ 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, @@ -59,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) @@ -69,84 +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) + navigate(SettingsRoute.TableInputMethods) + } + } + + fun pinyinCustomPhrase() = Preference(context).apply { + setOnPreferenceClickListener { + navigate(SettingsRoute.PinyinCustomPhrase) + } + } + + fun rimeUserDataDir(title: String): Preference = LongClickPreference(context).apply { + setOnPreferenceClickListener { + 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")) + } catch (e: Exception) { + context.toast(e) + } + } + } } 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() } @@ -155,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 } } @@ -205,12 +216,16 @@ object PreferenceScreenFactory { ConfigExternal.ETy.Chttrans -> addonConfigPreference("chttrans") ConfigExternal.ETy.TableGlobal -> addonConfigPreference("table") ConfigExternal.ETy.AndroidTable -> tableInputMethod() + ConfigExternal.ETy.PinyinCustomPhrase -> pinyinCustomPhrase() + 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) } @@ -230,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 { @@ -250,7 +265,11 @@ object PreferenceScreenFactory { dialogMessage = descriptor.tooltip } setOnPreferenceChangeListener { _, _ -> - save() + // setOnPreferenceChangeListener runs before preferenceDataStore was updated, + // post to save() to make sure store has been updated (hopefully) + ContextCompat.getMainExecutor(context).execute { + save() + } true } screen.addPreference(this) @@ -260,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 @@ -273,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/ProgressFragment.kt b/app/src/main/java/org/fcitx/fcitx5/android/ui/main/settings/ProgressFragment.kt index 37c678344..d01638cf1 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/ui/main/settings/ProgressFragment.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/ui/main/settings/ProgressFragment.kt @@ -1,3 +1,7 @@ +/* + * SPDX-License-Identifier: LGPL-2.1-or-later + * SPDX-FileCopyrightText: Copyright 2021-2023 Fcitx5 for Android Contributors + */ package org.fcitx.fcitx5.android.ui.main.settings import android.os.Bundle 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 d9b0147bd..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,7 +1,12 @@ +/* + * SPDX-License-Identifier: LGPL-2.1-or-later + * 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 @@ -10,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 @@ -20,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 @@ -54,14 +63,15 @@ class PunctuationEditorFragment : ProgressFragment(), OnItemChangedListener( requireContext(), Mode.FreeAdd(hint = "", converter = { PunctuationMapEntry(it, "", "") }), - initialEntries + initialEntries, + enableOrder = true ) { init { addTouchCallback() @@ -101,13 +111,30 @@ 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() @@ -133,9 +160,13 @@ 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(), Mode.FreeAdd("", converter = { QuickPhraseEntry("", "") }), initialEntries, ) { + override fun showEntry(x: QuickPhraseEntry): String = x.run { + "$keyword → ${phrase.replace("\n", "\\n")}" + } + override fun showEditDialog( title: String, entry: QuickPhraseEntry?, @@ -50,6 +63,10 @@ class QuickPhraseEditFragment : ProgressFragment(), OnItemChangedListener - block(QuickPhraseEntry(keywordField.str, phraseField.str)) - } + .setPositiveButton(android.R.string.ok, null) .setNegativeButton(android.R.string.cancel, null) .show() - } - - override fun showEntry(x: QuickPhraseEntry): String = x.run { - "$keyword\u2003→\u2003$phrase" + .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) @@ -148,7 +188,6 @@ class QuickPhraseEditFragment : ProgressFragment(), OnItemChangedListener { private val viewModel: MainViewModel by activityViewModels() - private val contentResolver: ContentResolver - get() = requireContext().contentResolver - private lateinit var launcher: ActivityResultLauncher private val busy: AtomicBoolean = AtomicBoolean(false) @@ -80,41 +81,38 @@ class QuickPhraseListFragment : Fragment(), 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 -> { if (entry.override != null) { - imageResource = R.drawable.ic_baseline_expand_more_24 + 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() } } } else { - imageResource = R.drawable.ic_baseline_edit_24 + icon = R.drawable.ic_baseline_edit_24 setOnClickListener { edit() } @@ -122,13 +120,15 @@ class QuickPhraseListFragment : Fragment(), OnItemChangedListener { } is CustomQuickPhrase -> { - imageResource = R.drawable.ic_baseline_edit_24 + icon = R.drawable.ic_baseline_edit_24 setOnClickListener { edit() } } } - + imageDrawable = drawable(icon)!!.apply { + setTint(styledColor(android.R.attr.colorControlNormal)) + } } ) { init { @@ -136,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 @@ -177,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 } @@ -195,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) } } @@ -207,67 +221,50 @@ class QuickPhraseListFragment : Fragment(), OnItemChangedListener { } private fun importFromUri(uri: Uri) { - val nm = notificationManager + val ctx = requireContext() + val cr = ctx.contentResolver + val nm = ctx.notificationManager lifecycleScope.launch(NonCancellable + Dispatchers.IO) { val id = IMPORT_ID++ - option { - val file = uri.queryFileName(contentResolver).bind().let { File(it) } - when { - file.nameWithoutExtension in ui.entries.map { it.name } -> { - errorDialog(getString(R.string.quickphrase_already_exists)) - shift(None) - } - file.extension != QuickPhrase.EXT -> { - errorDialog(getString(R.string.invalid_quickphrase)) - shift(None) - } - else -> Unit - } - - NotificationCompat.Builder(requireContext(), CHANNEL_ID) - .setSmallIcon(R.drawable.ic_baseline_format_quote_24) - .setContentTitle(getString(R.string.quickphrase_editor)) - .setContentText("${getString(R.string.importing)} ${file.nameWithoutExtension}") - .setOngoing(true) - .setProgress(100, 0, true) - .setPriority(NotificationCompat.PRIORITY_HIGH) - .build().let { nm.notify(id, it) } - val inputStream = Option.catch { contentResolver.openInputStream(uri) } - .mapNotNull { it } - .bind() - runCatching { - inputStream.use { i -> - QuickPhraseManager.importFromInputStream(i, file.name).getOrThrow() - } - }.onFailure { - errorDialog(it.localizedMessage ?: it.stackTraceToString()) - }.onSuccess { - launch(Dispatchers.Main) { - ui.addItem(item = it) - } + val fileName = cr.queryFileName(uri) ?: return@launch + val extName = fileName.substringAfterLast('.') + if (extName != QuickPhrase.EXT) { + ctx.importErrorDialog(R.string.exception_quickphrase_filename, fileName) + return@launch + } + val entryName = fileName.substringBeforeLast('.') + if (ui.entries.any { it.name == entryName }) { + ctx.importErrorDialog(R.string.quickphrase_already_exists) + return@launch + } + NotificationCompat.Builder(ctx, CHANNEL_ID) + .setSmallIcon(R.drawable.ic_baseline_format_quote_24) + .setContentTitle(getString(R.string.quickphrase_editor)) + .setContentText("${getString(R.string.importing)} $entryName") + .setOngoing(true) + .setProgress(100, 0, true) + .setPriority(NotificationCompat.PRIORITY_HIGH) + .build().let { nm.notify(id, it) } + try { + val inputStream = cr.openInputStream(uri)!! + val imported = QuickPhraseManager.importFromInputStream(inputStream, fileName) + .getOrThrow() + withContext(Dispatchers.Main) { + ui.addItem(item = imported) } + } catch (e: Exception) { + ctx.importErrorDialog(e) } nm.cancel(id) } } - private fun errorDialog(message: String) { - lifecycleScope.launch { - AlertDialog.Builder(requireContext()) - .setTitle(R.string.import_error) - .setMessage(message) - .setPositiveButton(android.R.string.ok) { _, _ -> } - .setIconAttribute(android.R.attr.alertDialogIcon) - .show() - } - } - 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 ad049428b..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 @@ -1,3 +1,7 @@ +/* + * SPDX-License-Identifier: LGPL-2.1-or-later + * SPDX-FileCopyrightText: Copyright 2021-2023 Fcitx5 for Android Contributors + */ package org.fcitx.fcitx5.android.ui.main.settings import android.content.Context @@ -9,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 d88173da8..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 @@ -1,8 +1,12 @@ +/* + * SPDX-License-Identifier: LGPL-2.1-or-later + * SPDX-FileCopyrightText: Copyright 2021-2023 Fcitx5 for Android Contributors + */ package org.fcitx.fcitx5.android.ui.main.settings +import android.app.AlertDialog import android.app.NotificationChannel import android.app.NotificationManager -import android.content.ContentResolver import android.net.Uri import android.os.Build import android.os.Bundle @@ -11,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 @@ -24,15 +27,17 @@ import org.fcitx.fcitx5.android.R import org.fcitx.fcitx5.android.daemon.FcitxDaemon import org.fcitx.fcitx5.android.data.table.TableBasedInputMethod import org.fcitx.fcitx5.android.data.table.TableManager +import org.fcitx.fcitx5.android.data.table.dict.Dictionary 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.bindOnNotNull -import org.fcitx.fcitx5.android.utils.errorArg -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 import splitties.views.imageDrawable @@ -40,16 +45,15 @@ class TableInputMethodFragment : Fragment(), OnItemChangedListener private lateinit var confLauncher: ActivityResultLauncher private lateinit var dictLauncher: ActivityResultLauncher + private lateinit var replaceLauncher: ActivityResultLauncher private var confUri: Uri? = null private var dictUri: Uri? = null private var filesSelectionDialog: AlertDialog? = null + private var tableToReplace: TableBasedInputMethod? = null private val dustman = NaiveDustman() @@ -61,16 +65,18 @@ class TableInputMethodFragment : Fragment(), OnItemChangedListener - if (it.tableFileExists) return@setOnClickListener + visibility = View.VISIBLE + imageDrawable = + if (it.tableFileExists) drawable(R.drawable.ic_baseline_edit_24) + else styledDrawable(android.R.attr.alertDialogIcon) + setOnClickListener { _ -> + tableToReplace = it lifecycleScope.launch { - errorDialog( - requireContext(), - getString(R.string.table_file_does_not_exist_title), - getString(R.string.table_file_does_not_exist_message, it.tableFileName) - ) + if (it.tableFileExists) { + showReplaceTableDialog(it) + } else { + showMissingTableDictDialog(it) + } } } } @@ -129,7 +135,7 @@ class TableInputMethodFragment : Fragment(), OnItemChangedListener if (uri != null) prepareDictFromUri(uri) } + replaceLauncher = registerForActivityResult(ActivityResultContracts.GetContent()) { uri -> + if (uri != null) replaceDictFromUri(uri) + } } private fun showImportDialog() { @@ -176,20 +185,20 @@ class TableInputMethodFragment : Fragment(), OnItemChangedListener - launch(Dispatchers.Main) { + val imported = + TableManager.importFromConfAndDict(confName, confStream, dictName, dictStream) + .getOrThrow() + withContext(Dispatchers.Main) { dismissFilesSelectionDialog() - // add or update - ui.entries - .indexOfFirst { it.name == new.name } - .takeIf { it != -1 } - ?.let { ui.updateItem(it, new) } - ?: run { ui.addItem(item = new) } + ui.addItem(item = imported) } + } catch (e: Exception) { + ctx.importErrorDialog(e) } nm.cancel(importId) withContext(Dispatchers.Main) { - filesSelectionDialog?.apply { - getButton(AlertDialog.BUTTON_POSITIVE).isEnabled = true + updateFilesSelectionDialogButton(importing = false) + } + } + } + + private fun replaceDictFromUri(uri: Uri) { + val ctx = requireContext() + val cr = ctx.contentResolver + val nm = ctx.notificationManager + val im = tableToReplace ?: return + tableToReplace = null + lifecycleScope.launch(NonCancellable + Dispatchers.IO) { + val importId = IMPORT_ID++ + val dictName = cr.queryFileName(uri) ?: return@launch + if (Dictionary.Type.fromFileName(dictName) == null) { + ctx.importErrorDialog(R.string.exception_table_dict_filename, dictName) + return@launch + } + NotificationCompat.Builder(ctx, CHANNEL_ID) + .setSmallIcon(R.drawable.ic_baseline_library_books_24) + .setContentTitle(getString(R.string.table_im)) + .setContentText("${getString(R.string.importing)} $dictName") + .setOngoing(true) + .setProgress(100, 0, true) + .setPriority(NotificationCompat.PRIORITY_HIGH) + .build().let { nm.notify(importId, it) } + try { + val dictStream = cr.openInputStream(uri)!! + im.table = TableManager.replaceTableDict(im, dictName, dictStream).getOrThrow() + withContext(Dispatchers.Main) { + ui.updateItem(ui.indexItem(im), im) } + dustman.forceDirty() + } catch (e: Exception) { + ctx.importErrorDialog(e) } + nm.cancel(importId) } } - private suspend fun importErrorDialog(message: String) { - errorDialog(requireContext(), getString(R.string.import_error), message) + private fun showReplaceTableDialog(im: TableBasedInputMethod) { + AlertDialog.Builder(requireContext()) + .setTitle(R.string.update_table) + .setMessage(getString(R.string.table_dict_replace_message, im.tableFileName)) + .setNeutralButton(R.string.table_file_placeholder) { _, _ -> + replaceLauncher.launch("*/*") + } + .setNegativeButton(android.R.string.cancel, null) + .show() + } + + private fun showMissingTableDictDialog(im: TableBasedInputMethod) { + AlertDialog.Builder(requireContext()) + .setIconAttribute(android.R.attr.alertDialogIcon) + .setTitle(R.string.table_file_does_not_exist_title) + .setMessage(getString(R.string.table_file_does_not_exist_message, im.tableFileName)) + .setPositiveButton(android.R.string.ok, null) + .setNeutralButton(R.string.table_file_placeholder) { _, _ -> + replaceLauncher.launch("*/*") + } + .show() } private fun reloadConfig() { diff --git a/app/src/main/java/org/fcitx/fcitx5/android/ui/main/settings/TwinSeekBarPreference.kt b/app/src/main/java/org/fcitx/fcitx5/android/ui/main/settings/TwinSeekBarPreference.kt index e9fd2ad6e..b699fec72 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/ui/main/settings/TwinSeekBarPreference.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/ui/main/settings/TwinSeekBarPreference.kt @@ -1,3 +1,7 @@ +/* + * SPDX-License-Identifier: LGPL-2.1-or-later + * SPDX-FileCopyrightText: Copyright 2021-2023 Fcitx5 for Android Contributors + */ package org.fcitx.fcitx5.android.ui.main.settings import android.content.Context @@ -13,8 +17,20 @@ 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.constraintlayout.* -import splitties.views.dsl.core.* +import splitties.views.dsl.constraintlayout.below +import splitties.views.dsl.constraintlayout.centerHorizontally +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.constraintlayout.topOfParent +import splitties.views.dsl.core.add +import splitties.views.dsl.core.horizontalMargin +import splitties.views.dsl.core.seekBar +import splitties.views.dsl.core.textView +import splitties.views.dsl.core.verticalMargin +import splitties.views.dsl.core.wrapContent import splitties.views.textAppearance class TwinSeekBarPreference @JvmOverloads constructor( @@ -31,8 +47,8 @@ class TwinSeekBarPreference @JvmOverloads constructor( var secondaryKey: String = "" var secondaryLabel: String = "" - var default: Int? = null - var secondaryDefault: Int? = null + var default: Int = 0 + var secondaryDefault: Int = 0 var defaultLabel: String? = null var value = 0 @@ -42,19 +58,22 @@ class TwinSeekBarPreference @JvmOverloads constructor( override fun onSetInitialValue(defaultValue: Any?) { preferenceDataStore?.apply { - value = getInt(key, 0) - secondaryValue = getInt(secondaryKey, 0) + value = getInt(key, default) + secondaryValue = getInt(secondaryKey, secondaryDefault) } ?: sharedPreferences?.apply { - value = getInt(key, 0) - secondaryValue = getInt(secondaryKey, 0) + value = getInt(key, default) + secondaryValue = getInt(secondaryKey, secondaryDefault) } } + /** + * @param defaultValue should be `Pair` + */ 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) { @@ -138,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 77041e514..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,36 +1,39 @@ +/* + * SPDX-License-Identifier: LGPL-2.1-or-later + * SPDX-FileCopyrightText: Copyright 2021-2025 Fcitx5 for Android Contributors + */ package org.fcitx.fcitx5.android.ui.main.settings.addon 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 970bd15e1..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,10 +1,12 @@ +/* + * SPDX-License-Identifier: LGPL-2.1-or-later + * 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 @@ -13,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 { @@ -116,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 fcf854a34..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,6 +1,145 @@ +/* + * SPDX-License-Identifier: LGPL-2.1-or-later + * SPDX-FileCopyrightText: Copyright 2021-2024 Fcitx5 for Android Contributors + */ package org.fcitx.fcitx5.android.ui.main.settings.behavior +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.fragment.app.activityViewModels +import androidx.lifecycle.lifecycleScope +import androidx.preference.PreferenceScreen +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.daemon.FcitxDaemon +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.MainViewModel +import org.fcitx.fcitx5.android.utils.AppUtil +import org.fcitx.fcitx5.android.utils.addPreference +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.queryFileName +import org.fcitx.fcitx5.android.utils.toast -class AdvancedSettingsFragment: ManagedPreferenceFragment(AppPrefs.getInstance().advanced) +class AdvancedSettingsFragment : ManagedPreferenceFragment(AppPrefs.getInstance().advanced) { + + private val viewModel: MainViewModel by activityViewModels() + + private var exportTimestamp = System.currentTimeMillis() + + private lateinit var exportLauncher: ActivityResultLauncher + + private lateinit var importLauncher: ActivityResultLauncher + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + importLauncher = + registerForActivityResult(ActivityResultContracts.GetContent()) { uri -> + if (uri == null) return@registerForActivityResult + val ctx = requireContext() + val cr = ctx.contentResolver + lifecycleScope.withLoadingDialog(ctx) { + withContext(NonCancellable + Dispatchers.IO) { + val name = cr.queryFileName(uri) ?: return@withContext + if (!name.endsWith(".zip")) { + ctx.importErrorDialog(R.string.exception_user_data_filename, name) + return@withContext + } + try { + // stop fcitx before overwriting files + FcitxDaemon.stopFcitx() + val inputStream = cr.openInputStream(uri)!! + val metadata = UserDataManager.import(inputStream).getOrThrow() + lifecycleScope.launch(NonCancellable + Dispatchers.Main) { + delay(400L) + AppUtil.exit() + } + withContext(Dispatchers.Main) { + 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() + ctx.importErrorDialog(e) + } + } + } + } + exportLauncher = + registerForActivityResult(ActivityResultContracts.CreateDocument("application/zip")) { uri -> + if (uri == null) return@registerForActivityResult + val ctx = requireContext() + lifecycleScope.withLoadingDialog(requireContext()) { + withContext(NonCancellable + Dispatchers.IO) { + try { + val outputStream = ctx.contentResolver.openOutputStream(uri)!! + UserDataManager.export(outputStream, exportTimestamp).getOrThrow() + } catch (e: Exception) { + e.printStackTrace() + withContext(Dispatchers.Main) { + ctx.toast(e) + } + } + } + } + } + } + + 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) { + lifecycleScope.launch { + lifecycleScope.withLoadingDialog(ctx) { + viewModel.fcitx.runOnReady { + save() + } + } + exportTimestamp = System.currentTimeMillis() + exportLauncher.launch("fcitx5-android_${iso8601UTCDateTime(exportTimestamp)}.zip") + } + } + screen.addPreference(R.string.import_user_data) { + AlertDialog.Builder(ctx) + .setIconAttribute(android.R.attr.alertDialogIcon) + .setTitle(R.string.import_user_data) + .setMessage(R.string.confirm_import_user_data) + .setPositiveButton(android.R.string.ok) { _, _ -> + importLauncher.launch("application/zip") + } + .setNegativeButton(android.R.string.cancel, null) + .show() + } + } +} 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/ClipboardSettingsFragment.kt b/app/src/main/java/org/fcitx/fcitx5/android/ui/main/settings/behavior/ClipboardSettingsFragment.kt index a4c1d919d..fe540fbee 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/ui/main/settings/behavior/ClipboardSettingsFragment.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/ui/main/settings/behavior/ClipboardSettingsFragment.kt @@ -1,3 +1,7 @@ +/* + * SPDX-License-Identifier: LGPL-2.1-or-later + * SPDX-FileCopyrightText: Copyright 2021-2023 Fcitx5 for Android Contributors + */ package org.fcitx.fcitx5.android.ui.main.settings.behavior import org.fcitx.fcitx5.android.data.prefs.AppPrefs diff --git a/app/src/main/java/org/fcitx/fcitx5/android/ui/main/settings/behavior/KeyboardSettingsFragment.kt b/app/src/main/java/org/fcitx/fcitx5/android/ui/main/settings/behavior/KeyboardSettingsFragment.kt index 420f68577..f8f2778e9 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/ui/main/settings/behavior/KeyboardSettingsFragment.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/ui/main/settings/behavior/KeyboardSettingsFragment.kt @@ -1,3 +1,7 @@ +/* + * SPDX-License-Identifier: LGPL-2.1-or-later + * SPDX-FileCopyrightText: Copyright 2021-2023 Fcitx5 for Android Contributors + */ package org.fcitx.fcitx5.android.ui.main.settings.behavior import org.fcitx.fcitx5.android.data.prefs.AppPrefs 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/global/GlobalConfigFragment.kt b/app/src/main/java/org/fcitx/fcitx5/android/ui/main/settings/global/GlobalConfigFragment.kt index 11ee0ae61..592f12e14 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/ui/main/settings/global/GlobalConfigFragment.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/ui/main/settings/global/GlobalConfigFragment.kt @@ -1,3 +1,7 @@ +/* + * SPDX-License-Identifier: LGPL-2.1-or-later + * SPDX-FileCopyrightText: Copyright 2021-2023 Fcitx5 for Android Contributors + */ package org.fcitx.fcitx5.android.ui.main.settings.global import org.fcitx.fcitx5.android.R 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 39d5d359a..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,22 +1,25 @@ +/* + * SPDX-License-Identifier: LGPL-2.1-or-later + * 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 de872fa67..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,15 +1,20 @@ +/* + * SPDX-License-Identifier: LGPL-2.1-or-later + * 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 { @@ -17,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()) + } } } } @@ -34,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 29e468141..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 @@ -1,17 +1,18 @@ +/* + * SPDX-License-Identifier: LGPL-2.1-or-later + * SPDX-FileCopyrightText: Copyright 2021-2023 Fcitx5 for Android Contributors + */ 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.PorterDuff -import android.graphics.PorterDuffColorFilter 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 @@ -21,41 +22,62 @@ 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.core.net.toUri +import androidx.appcompat.widget.Toolbar 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 -import splitties.views.* +import splitties.views.backgroundColor +import splitties.views.bottomPadding import splitties.views.dsl.appcompat.switch -import splitties.views.dsl.appcompat.toolbar -import splitties.views.dsl.constraintlayout.* -import splitties.views.dsl.core.* +import splitties.views.dsl.constraintlayout.above +import splitties.views.dsl.constraintlayout.before +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.endOfParent +import splitties.views.dsl.constraintlayout.lParams +import splitties.views.dsl.constraintlayout.matchConstraints +import splitties.views.dsl.constraintlayout.packed +import splitties.views.dsl.constraintlayout.startOfParent +import splitties.views.dsl.constraintlayout.topOfParent +import splitties.views.dsl.constraintlayout.topToTopOf +import splitties.views.dsl.core.add +import splitties.views.dsl.core.matchParent +import splitties.views.dsl.core.seekBar +import splitties.views.dsl.core.textView +import splitties.views.dsl.core.view +import splitties.views.dsl.core.wrapContent +import splitties.views.dsl.core.wrapInScrollView +import splitties.views.gravityVerticalCenter +import splitties.views.horizontalPadding +import splitties.views.textAppearance +import splitties.views.topPadding import java.io.File class CustomThemeActivity : AppCompatActivity() { @@ -78,11 +100,11 @@ class CustomThemeActivity : AppCompatActivity() { } override fun parseResult(resultCode: Int, intent: Intent?): BackgroundResult? = - intent?.extras?.parcelable(RESULT) + intent?.parcelable(RESULT) } private val toolbar by lazy { - toolbar { + view(::Toolbar) { backgroundColor = styledColor(android.R.attr.colorPrimary) elevation = dp(4f) } @@ -106,7 +128,10 @@ class CustomThemeActivity : AppCompatActivity() { createTextView(R.string.dark_keys, ripple = true) } private val variantSwitch by lazy { - switch { } + switch { + // Use dark keys by default + isChecked = false + } } private val brightnessLabel by lazy { @@ -190,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 @@ -214,33 +239,28 @@ 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) } override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) // recover from bundle - val originTheme = intent?.extras?.parcelable(ORIGIN_THEME)?.also { t -> + val originTheme = intent?.parcelable(ORIGIN_THEME)?.also { t -> theme = t whenHasBackground { croppedImageFile = File(it.croppedFilePath) srcImageFile = File(it.srcFilePath) cropRect = it.cropRect + cropRotation = it.cropRotation croppedBitmap = BitmapFactory.decodeFile(it.croppedFilePath) filteredDrawable = BitmapDrawable(resources, croppedBitmap) } @@ -248,36 +268,15 @@ 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 } - theme = - (if (variantSwitch.isChecked) ThemePreset.TransparentLight else ThemePreset.TransparentDark) - .deriveCustomBackground(n, c.path, s.path) + // Use dark keys by default + theme = ThemePreset.TransparentDark.deriveCustomBackground(n, c.path, s.path) } previewUi = KeyboardPreviewUi(this, theme) - whenHasBackground { - cropLabel.setOnClickListener { - launchCrop(previewUi.intrinsicWidth, previewUi.intrinsicHeight) - } - variantLabel.setOnClickListener { - variantSwitch.isChecked = !variantSwitch.isChecked - } - variantSwitch.setOnCheckedChangeListener { _, isChecked -> - setKeyVariant(it, darkKeys = isChecked) - } - brightnessSeekBar.setOnSeekBarChangeListener(object : - SeekBar.OnSeekBarChangeListener { - override fun onStartTrackingTouch(bar: SeekBar) {} - override fun onStopTrackingTouch(bar: SeekBar) {} - - override fun onProgressChanged(bar: SeekBar, progress: Int, fromUser: Boolean) { - if (fromUser) updateState() - } - }) - } if (theme.backgroundImage == null) { brightnessLabel.visibility = View.GONE cropLabel.visibility = View.GONE @@ -285,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()) @@ -305,31 +304,48 @@ 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 { + launchCrop(previewUi.intrinsicWidth, previewUi.intrinsicHeight) + } + variantLabel.setOnClickListener { + variantSwitch.isChecked = !variantSwitch.isChecked + } + // attach OnCheckedChangeListener after calling setChecked (isChecked in kotlin) + variantSwitch.setOnCheckedChangeListener { _, isChecked -> + setKeyVariant(background, darkKeys = isChecked) + } + brightnessSeekBar.setOnSeekBarChangeListener(object : + SeekBar.OnSeekBarChangeListener { + override fun onStartTrackingTouch(bar: SeekBar) {} + override fun onStopTrackingTouch(bar: SeekBar) {} + + override fun onProgressChanged(bar: SeekBar, progress: Int, fromUser: Boolean) { + if (fromUser) updateState() + } + }) } if (newCreated) { @@ -351,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() @@ -407,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) @@ -426,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 ) ) } @@ -451,7 +446,7 @@ class CustomThemeActivity : AppCompatActivity() { private fun delete() { setResult( - Activity.RESULT_OK, + RESULT_OK, Intent().apply { putExtra(RESULT, BackgroundResult.Deleted(theme.name)) } @@ -472,25 +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 { - colorFilter = - PorterDuffColorFilter(color(R.color.red_400), PorterDuff.Mode.SRC_IN) - } - 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 { - setIcon(R.drawable.ic_baseline_done_24) - 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 } @@ -507,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 7fb7d698c..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,6 +1,10 @@ +/* + * SPDX-License-Identifier: LGPL-2.1-or-later + * 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 @@ -9,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 { @@ -55,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 } @@ -87,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 @@ -108,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 @@ -139,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/NewThemeEntryUi.kt b/app/src/main/java/org/fcitx/fcitx5/android/ui/main/settings/theme/NewThemeEntryUi.kt index 430444579..42c3ed9bd 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/ui/main/settings/theme/NewThemeEntryUi.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/ui/main/settings/theme/NewThemeEntryUi.kt @@ -1,17 +1,29 @@ +/* + * SPDX-License-Identifier: LGPL-2.1-or-later + * SPDX-FileCopyrightText: Copyright 2021-2023 Fcitx5 for Android Contributors + */ package org.fcitx.fcitx5.android.ui.main.settings.theme import android.content.Context import android.graphics.Color -import android.graphics.PorterDuff -import android.graphics.PorterDuffColorFilter import android.view.ViewOutlineProvider import androidx.constraintlayout.widget.ConstraintLayout import org.fcitx.fcitx5.android.R import splitties.dimensions.dp import splitties.resources.drawable 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.topOfParent +import splitties.views.dsl.core.Ui +import splitties.views.dsl.core.add +import splitties.views.dsl.core.imageView +import splitties.views.dsl.core.textView +import splitties.views.dsl.core.wrapContent import splitties.views.imageDrawable class NewThemeEntryUi(override val ctx: Context) : Ui { @@ -21,8 +33,9 @@ class NewThemeEntryUi(override val ctx: Context) : Ui { } val icon = imageView { - imageDrawable = ctx.drawable(R.drawable.ic_baseline_plus_24) - colorFilter = PorterDuffColorFilter(Color.WHITE, PorterDuff.Mode.SRC_IN) + imageDrawable = ctx.drawable(R.drawable.ic_baseline_plus_24)!!.apply { + setTint(Color.WHITE) + } } override val root = constraintLayout { diff --git a/app/src/main/java/org/fcitx/fcitx5/android/ui/main/settings/theme/ResponsiveThemeListView.kt b/app/src/main/java/org/fcitx/fcitx5/android/ui/main/settings/theme/ResponsiveThemeListView.kt index f3f0397e0..8b61092dc 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/ui/main/settings/theme/ResponsiveThemeListView.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/ui/main/settings/theme/ResponsiveThemeListView.kt @@ -1,3 +1,7 @@ +/* + * SPDX-License-Identifier: LGPL-2.1-or-later + * SPDX-FileCopyrightText: Copyright 2021-2023 Fcitx5 for Android Contributors + */ package org.fcitx.fcitx5.android.ui.main.settings.theme import android.content.Context diff --git a/app/src/main/java/org/fcitx/fcitx5/android/ui/main/settings/theme/SimpleThemeListAdapter.kt b/app/src/main/java/org/fcitx/fcitx5/android/ui/main/settings/theme/SimpleThemeListAdapter.kt index 35a2cfd36..56bcedcb7 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/ui/main/settings/theme/SimpleThemeListAdapter.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/ui/main/settings/theme/SimpleThemeListAdapter.kt @@ -1,3 +1,7 @@ +/* + * SPDX-License-Identifier: LGPL-2.1-or-later + * SPDX-FileCopyrightText: Copyright 2021-2023 Fcitx5 for Android Contributors + */ package org.fcitx.fcitx5.android.ui.main.settings.theme import android.view.View 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 new file mode 100644 index 000000000..dd1ed6afd --- /dev/null +++ b/app/src/main/java/org/fcitx/fcitx5/android/ui/main/settings/theme/ThemeFragment.kt @@ -0,0 +1,128 @@ +/* + * SPDX-License-Identifier: LGPL-2.1-or-later + * SPDX-FileCopyrightText: Copyright 2021-2023 Fcitx5 for Android Contributors + */ +package org.fcitx.fcitx5.android.ui.main.settings.theme + +import android.os.Build +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.view.ViewOutlineProvider +import androidx.annotation.Keep +import androidx.fragment.app.Fragment +import androidx.lifecycle.lifecycleScope +import androidx.viewpager2.adapter.FragmentStateAdapter +import androidx.viewpager2.widget.ViewPager2 +import com.google.android.material.tabs.TabLayout +import com.google.android.material.tabs.TabLayoutMediator +import kotlinx.coroutines.launch +import org.fcitx.fcitx5.android.R +import org.fcitx.fcitx5.android.data.theme.ThemeManager +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.endOfParent +import splitties.views.dsl.constraintlayout.lParams +import splitties.views.dsl.constraintlayout.startOfParent +import splitties.views.dsl.constraintlayout.topOfParent +import splitties.views.dsl.core.add +import splitties.views.dsl.core.matchParent +import splitties.views.dsl.core.wrapContent + +class ThemeFragment : Fragment() { + + private lateinit var previewUi: KeyboardPreviewUi + + private lateinit var tabLayout: TabLayout + + private lateinit var viewPager: ViewPager2 + + @Keep + private val onThemeChangeListener = ThemeManager.OnThemeChangeListener { + lifecycleScope.launch { + previewUi.setTheme(it) + } + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View = with(requireContext()) { + previewUi = KeyboardPreviewUi(this, ThemeManager.activeTheme) + ThemeManager.addOnChangedListener(onThemeChangeListener) + val preview = previewUi.root.apply { + scaleX = 0.5f + scaleY = 0.5f + outlineProvider = ViewOutlineProvider.BOUNDS + elevation = dp(4f) + } + + tabLayout = TabLayout(this) + + viewPager = ViewPager2(this).apply { + adapter = object : FragmentStateAdapter(this@ThemeFragment) { + override fun getItemCount() = 2 + override fun createFragment(position: Int): Fragment = when (position) { + 0 -> ThemeListFragment() + else -> ThemeSettingsFragment() + } + } + } + + TabLayoutMediator(tabLayout, viewPager) { tab, position -> + tab.text = getString( + when (position) { + 0 -> R.string.theme + else -> R.string.configure + } + ) + }.attach() + + val previewWrapper = constraintLayout { + add(preview, lParams(wrapContent, wrapContent) { + topOfParent(dp(-52)) + startOfParent() + endOfParent() + }) + add(tabLayout, lParams(matchParent, wrapContent) { + centerHorizontally() + bottomOfParent() + }) + backgroundColor = styledColor(android.R.attr.colorPrimary) + elevation = dp(4f) + } + + constraintLayout { + add(previewWrapper, lParams(height = wrapContent) { + topOfParent() + startOfParent() + endOfParent() + }) + add(viewPager, lParams { + below(previewWrapper) + startOfParent() + endOfParent() + bottomOfParent() + }) + } + } + + override fun onStop() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + ThemeManager.syncToDeviceEncryptedStorage() + } + super.onStop() + } + + override fun onDestroy() { + ThemeManager.removeOnChangedListener(onThemeChangeListener) + super.onDestroy() + } +} 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 ef6aaf55f..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 @@ -1,66 +1,100 @@ +/* + * SPDX-License-Identifier: LGPL-2.1-or-later + * SPDX-FileCopyrightText: Copyright 2021-2023 Fcitx5 for Android Contributors + */ package org.fcitx.fcitx5.android.ui.main.settings.theme import android.view.ViewGroup import androidx.recyclerview.widget.RecyclerView import org.fcitx.fcitx5.android.data.theme.Theme import splitties.views.dsl.core.Ui +import kotlin.math.sign abstract class ThemeListAdapter : RecyclerView.Adapter() { class ViewHolder(val ui: Ui) : RecyclerView.ViewHolder(ui.root) val entries = mutableListOf() - private var checkedIndex = -1 + private var activeIndex = -1 + private var lightIndex = -1 + private var darkIndex = -1 private fun entryAt(position: Int) = entries.getOrNull(position - OFFSET) - private fun positionOf(theme: Theme) = entries.indexOfFirst { it.name == theme.name } + OFFSET + private fun positionOf(theme: Theme? = null): Int { + if (theme == null) return -1 + return entries.indexOfFirst { it.name == theme.name } + OFFSET + } - fun setThemes(themes: List, active: Theme) { + fun setThemes(themes: List) { entries.clear() entries.addAll(themes) - checkedIndex = entries.indexOf(active) + OFFSET notifyItemRangeInserted(OFFSET, themes.size) } - fun setCheckedTheme(theme: Theme) { - val oldChecked = entryAt(checkedIndex) - if (oldChecked == theme) return - notifyItemChanged(checkedIndex) - checkedIndex = positionOf(theme) - notifyItemChanged(checkedIndex) + fun setSelectedThemes(active: Theme, light: Theme? = null, dark: Theme? = null) { + val oldActive = entryAt(activeIndex) + if (oldActive != active) { + notifyItemChanged(activeIndex) + activeIndex = positionOf(active) + notifyItemChanged(activeIndex) + } + val oldLight = entryAt(lightIndex) + if (oldLight != light) { + notifyItemChanged(lightIndex) + lightIndex = positionOf(light) + if (lightIndex >= OFFSET) { + notifyItemChanged(lightIndex) + } + } + val oldDark = entryAt(darkIndex) + if (oldDark != dark) { + notifyItemChanged(darkIndex) + darkIndex = positionOf(dark) + if (darkIndex >= OFFSET) { + notifyItemChanged(darkIndex) + } + } + } + + private fun prependOffset(index: Int): Int { + return if (index == -1) 0 else 1 } fun prependTheme(it: Theme) { entries.add(0, it) - checkedIndex += 1 + activeIndex += prependOffset(activeIndex) + lightIndex += prependOffset(lightIndex) + darkIndex += prependOffset(darkIndex) notifyItemInserted(OFFSET) } - fun replaceTheme(theme: Theme) { - val index = entries.indexOfFirst { it.name == theme.name } - entries[index] = theme - notifyItemChanged(index + OFFSET) + private fun removedOffset(removedIndex: Int, index: Int): Int { + return if (index == -1) 0 else (removedIndex - OFFSET - index).sign } fun removeTheme(name: String) { val index = entries.indexOfFirst { it.name == name } entries.removeAt(index) notifyItemRemoved(index + OFFSET) - val cmp = (index - OFFSET).compareTo(checkedIndex) - when { - cmp > 0 -> { - // Do nothing - } - cmp == 0 -> { - // Reset - checkedIndex = -1 - } - cmp < 0 -> { - // Fix - checkedIndex -= 1 - } - } + activeIndex += removedOffset(index, activeIndex) + lightIndex += removedOffset(index, lightIndex) + darkIndex += removedOffset(index, darkIndex) + } + + private fun replaceIndex(replacedIndex: Int, index: Int): Int { + return if (replacedIndex + OFFSET == index) OFFSET else index + } + + fun replaceTheme(theme: Theme) { + val index = entries.indexOfFirst { it.name == theme.name } + entries.removeAt(index) + entries.add(0, theme) + activeIndex = replaceIndex(index, activeIndex) + lightIndex = replaceIndex(index, lightIndex) + darkIndex = replaceIndex(index, darkIndex) + notifyItemMoved(index + OFFSET, OFFSET) + notifyItemChanged(OFFSET) } override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = @@ -77,8 +111,15 @@ abstract class ThemeListAdapter : RecyclerView.Adapter holder.ui.root.setOnClickListener { onAddNewTheme() } THEME -> (holder.ui as ThemeThumbnailUi).apply { val theme = entryAt(position)!! - val isActive = position == checkedIndex - setTheme(theme, isActive) + setTheme(theme) + setChecked( + when (position) { + darkIndex -> ThemeThumbnailUi.State.DarkMode + lightIndex -> ThemeThumbnailUi.State.LightMode + activeIndex -> ThemeThumbnailUi.State.Selected + else -> ThemeThumbnailUi.State.Normal + } + ) root.setOnClickListener { onSelectTheme(theme) } @@ -86,6 +127,9 @@ abstract class ThemeListAdapter : RecyclerView.Adapter - private lateinit var previewUi: KeyboardPreviewUi + private lateinit var themeListAdapter: ThemeListAdapter - private lateinit var adapter: ThemeListAdapter - - private lateinit var themeList: RecyclerView + private var followSystemDayNightTheme by ThemeManager.prefs.followSystemDayNightTheme private var beingExported: Theme.Custom? = null @Keep private val onThemeChangeListener = ThemeManager.OnThemeChangeListener { lifecycleScope.launch { - previewUi.setTheme(it) - adapter.setCheckedTheme(it) + updateSelectedThemes(it) } } override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) + imageLauncher = registerForActivityResult(CustomThemeActivity.Contract()) { result -> - if (result != null) { - when (result) { - is CustomThemeActivity.BackgroundResult.Created -> { - val theme = result.theme - adapter.prependTheme(theme) - ThemeManager.saveTheme(theme) - 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 - adapter.removeTheme(name) - ThemeManager.deleteTheme(name) - } - is CustomThemeActivity.BackgroundResult.Updated -> { - val theme = result.theme - adapter.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 = registerForActivityResult(ActivityResultContracts.GetContent()) { uri -> - lifecycleScope.withLoadingDialog(requireContext()) { + if (uri == null) return@registerForActivityResult + val ctx = requireContext() + val cr = ctx.contentResolver + lifecycleScope.withLoadingDialog(ctx) { withContext(NonCancellable + Dispatchers.IO) { - runCatching { - uri?.let { - it.queryFileName(requireContext().contentResolver) - .orNull() - ?.let { name -> - name.endsWith(".zip") - .takeIf(Boolean::identity) - ?: errorArg(R.string.exception_theme_filename, name) - } - requireContext().contentResolver.openInputStream(it) - } - }.bindOnNotNull { - ThemeManager.importTheme(it) - }?.onSuccess { (newCreated, theme, migrated) -> + val name = cr.queryFileName(uri) ?: return@withContext + 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) = + ThemeFilesManager.importTheme(inputStream).getOrThrow() + ThemeManager.refreshThemes() withContext(Dispatchers.Main) { - if (newCreated) - adapter.prependTheme(theme) - else - adapter.replaceTheme(theme) - if (migrated) - Toast.makeText( - requireContext(), - getString(R.string.theme_migrated), - Toast.LENGTH_SHORT - ).show() + if (newCreated) { + themeListAdapter.prependTheme(theme) + } else { + themeListAdapter.replaceTheme(theme) + } + if (migrated) { + ctx.toast(R.string.theme_migrated) + } } - }?.onFailure { - errorDialog( - requireContext(), - getString(R.string.import_error), - it.localizedMessage ?: it.stackTraceToString() - ) + } catch (e: Exception) { + ctx.importErrorDialog(e) } } } } exportLauncher = registerForActivityResult(ActivityResultContracts.CreateDocument("application/zip")) { uri -> + if (uri == null) return@registerForActivityResult + val ctx = requireContext() + val exported = beingExported ?: return@registerForActivityResult + beingExported = null lifecycleScope.withLoadingDialog(requireContext()) { withContext(NonCancellable + Dispatchers.IO) { - runCatching { - uri?.let { requireContext().contentResolver.openOutputStream(it) } - }.bindOnNotNull { - requireNotNull(beingExported) - ThemeManager.exportTheme(beingExported!!, it).also { - beingExported = null + try { + val outputStream = ctx.contentResolver.openOutputStream(uri)!! + ThemeFilesManager.exportTheme(exported, outputStream).getOrThrow() + } catch (e: Exception) { + withContext(Dispatchers.Main) { + ctx.toast(e) } - }?.toast(requireContext()) + } } } } @@ -162,76 +136,32 @@ class ThemeListFragment : Fragment() { inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? - ): View = with(requireContext()) { - val activeTheme = ThemeManager.getActiveTheme() - - previewUi = KeyboardPreviewUi(this, activeTheme) - val preview = previewUi.root.apply { - scaleX = 0.5f - scaleY = 0.5f - outlineProvider = ViewOutlineProvider.BOUNDS - elevation = dp(4f) - } - - val settingsText = textView { - setText(R.string.configure_theme) - textAppearance = resolveThemeAttribute(android.R.attr.textAppearanceListItem) - gravity = gravityVerticalCenter + ): View { + themeListAdapter = object : ThemeListAdapter() { + override fun onAddNewTheme() = addTheme() + override fun onSelectTheme(theme: Theme) = selectTheme(theme) + override fun onEditTheme(theme: Theme.Custom) = editTheme(theme) + override fun onExportTheme(theme: Theme.Custom) = exportTheme(theme) } - val settingsButton = imageButton { - imageDrawable = drawable(R.drawable.ic_baseline_settings_24) - background = styledDrawable(android.R.attr.actionBarItemBackground) - setOnClickListener { - findNavController().navigate(R.id.action_themeListFragment_to_themeSettingsFragment) - } - } - - val previewWrapper = constraintLayout { - add(preview, lParams(wrapContent, wrapContent) { - topOfParent(dp(-52)) - startOfParent() - endOfParent() - }) - add(settingsText, lParams(wrapContent, dp(48)) { - startOfParent(dp(64)) - bottomOfParent(dp(4)) - }) - add(settingsButton, lParams(dp(48), dp(48)) { - endOfParent(dp(64)) - bottomOfParent(dp(4)) - }) - backgroundColor = styledColor(android.R.attr.colorPrimary) - elevation = dp(4f) - } - - themeList = ResponsiveThemeListView(this).apply { - this@ThemeListFragment.adapter = object : ThemeListAdapter() { - override fun onAddNewTheme() = addTheme() - override fun onSelectTheme(theme: Theme) = selectTheme(theme) - override fun onEditTheme(theme: Theme.Custom) = editTheme(theme) - override fun onExportTheme(theme: Theme.Custom) = exportTheme(theme) - }.apply { - setThemes(ThemeManager.getAllThemes(), activeTheme) - } - adapter = this@ThemeListFragment.adapter + ThemeManager.refreshThemes() + themeListAdapter.setThemes(ThemeManager.getAllThemes()) + updateSelectedThemes() + ThemeManager.addOnChangedListener(onThemeChangeListener) + return ResponsiveThemeListView(requireContext()).apply { + adapter = themeListAdapter applyNavBarInsetsBottomPadding() } + } - ThemeManager.addOnChangedListener(onThemeChangeListener) - - constraintLayout { - add(previewWrapper, lParams(height = wrapContent) { - topOfParent() - startOfParent() - endOfParent() - }) - add(themeList, lParams { - below(previewWrapper) - startOfParent() - endOfParent() - bottomOfParent() - }) + private fun updateSelectedThemes(activeTheme: Theme? = null) { + val active = activeTheme ?: ThemeManager.activeTheme + var light: Theme? = null + var dark: Theme? = null + if (followSystemDayNightTheme) { + light = ThemeManager.prefs.lightModeTheme.getValue() + dark = ThemeManager.prefs.darkModeTheme.getValue() } + themeListAdapter.setSelectedThemes(active, light, dark) } private fun addTheme() { @@ -259,11 +189,11 @@ 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()) - adapter.prependTheme(newTheme) + themeListAdapter.prependTheme(newTheme) ThemeManager.saveTheme(newTheme) dialog.dismiss() } @@ -276,7 +206,24 @@ class ThemeListFragment : Fragment() { } private fun selectTheme(theme: Theme) { - ThemeManager.switchTheme(theme) + if (followSystemDayNightTheme) { + val ctx = requireContext() + AlertDialog.Builder(ctx) + .setIcon(ctx.styledDrawable(android.R.attr.alertDialogIcon)) + .setTitle(R.string.configure) + .setMessage(R.string.theme_message_follow_system_day_night_mode_enabled) + .setPositiveButton(android.R.string.ok, null) + .setNegativeButton(R.string.disable_it) { _, _ -> + followSystemDayNightTheme = false + lifecycleScope.launch { + ThemeManager.setNormalModeTheme(theme) + updateSelectedThemes() + } + } + .show() + return + } + ThemeManager.setNormalModeTheme(theme) } private fun editTheme(theme: Theme.Custom) { diff --git a/app/src/main/java/org/fcitx/fcitx5/android/ui/main/settings/theme/ThemeListItemDecoration.kt b/app/src/main/java/org/fcitx/fcitx5/android/ui/main/settings/theme/ThemeListItemDecoration.kt index bf1109a4d..37dc20cbe 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/ui/main/settings/theme/ThemeListItemDecoration.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/ui/main/settings/theme/ThemeListItemDecoration.kt @@ -1,8 +1,13 @@ +/* + * SPDX-License-Identifier: LGPL-2.1-or-later + * SPDX-FileCopyrightText: Copyright 2021-2023 Fcitx5 for Android Contributors + */ package org.fcitx.fcitx5.android.ui.main.settings.theme import android.graphics.Rect import android.view.View import androidx.recyclerview.widget.RecyclerView +import kotlin.math.ceil class ThemeListItemDecoration(val itemWidth: Int, val spanCount: Int) : RecyclerView.ItemDecoration() { @@ -16,7 +21,7 @@ class ThemeListItemDecoration(val itemWidth: Int, val spanCount: Int) : val offset = (parent.width - itemWidth * spanCount) / (spanCount + 1) val halfOffset = offset / 2 val position = parent.getChildAdapterPosition(view) - val rowCount = parent.adapter?.run { itemCount / spanCount } ?: -1 + val rowCount = parent.adapter?.run { ceil(itemCount / spanCount.toFloat()).toInt() } ?: -1 val n = position % spanCount when (parent.layoutDirection) { 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 2bc5042dd..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 @@ -1,6 +1,51 @@ +/* + * SPDX-License-Identifier: LGPL-2.1-or-later + * SPDX-FileCopyrightText: Copyright 2021-2023 Fcitx5 for Android Contributors + */ 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 b7211aa53..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,26 +1,46 @@ +/* + * SPDX-License-Identifier: LGPL-2.1-or-later + * SPDX-FileCopyrightText: Copyright 2021-2025 Fcitx5 for Android Contributors + */ package org.fcitx.fcitx5.android.ui.main.settings.theme import android.content.Context -import android.graphics.PorterDuff -import android.graphics.PorterDuffColorFilter +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 +import androidx.core.view.isVisible import org.fcitx.fcitx5.android.R import org.fcitx.fcitx5.android.data.theme.Theme import org.fcitx.fcitx5.android.utils.rippleDrawable import splitties.dimensions.dp -import splitties.resources.drawable import splitties.views.backgroundColor -import splitties.views.dsl.constraintlayout.* -import splitties.views.dsl.core.* +import splitties.views.dsl.constraintlayout.bottomOfParent +import splitties.views.dsl.constraintlayout.centerHorizontally +import splitties.views.dsl.constraintlayout.centerInParent +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 +import splitties.views.dsl.core.imageView +import splitties.views.dsl.core.matchParent +import splitties.views.dsl.core.view import splitties.views.imageDrawable +import splitties.views.imageResource import splitties.views.setPaddingDp class ThemeThumbnailUi(override val ctx: Context) : Ui { + + enum class State { Normal, Selected, LightMode, DarkMode } + val bkg = imageView { scaleType = ImageView.ScaleType.CENTER_CROP } @@ -33,13 +53,18 @@ class ThemeThumbnailUi(override val ctx: Context) : Ui { val checkMark = imageView { scaleType = ImageView.ScaleType.FIT_CENTER - imageDrawable = ctx.drawable(R.drawable.ic_baseline_check_24) } val editButton = imageView { setPaddingDp(16, 4, 4, 16) scaleType = ImageView.ScaleType.FIT_CENTER - imageDrawable = ctx.drawable(R.drawable.ic_baseline_edit_24) + 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 { @@ -63,9 +88,13 @@ class ThemeThumbnailUi(override val ctx: Context) : Ui { topOfParent() endOfParent() }) + add(dynamicIcon, lParams(dp(32), dp(32)) { + topOfParent() + startOfParent() + }) } - fun setTheme(theme: Theme, checked: Boolean = false) { + fun setTheme(theme: Theme) { root.apply { foreground = rippleDrawable(theme.keyPressHighlightColor) } @@ -79,17 +108,32 @@ class ThemeThumbnailUi(override val ctx: Context) : Ui { returnKey.background = ShapeDrawable(OvalShape()).apply { paint.color = theme.accentKeyBackgroundColor } - val foreground = PorterDuffColorFilter(theme.altKeyTextColor, PorterDuff.Mode.SRC_IN) + val foregroundTint = ColorStateList.valueOf(theme.altKeyTextColor) editButton.apply { visibility = if (theme is Theme.Custom) View.VISIBLE else View.GONE - colorFilter = foreground 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 } - setChecked(checked) - checkMark.colorFilter = foreground + checkMark.imageTintList = foregroundTint } fun setChecked(checked: Boolean) { - checkMark.visibility = if (checked) View.VISIBLE else View.GONE + checkMark.isVisible = checked + checkMark.imageResource = R.drawable.ic_baseline_check_24 + } + + fun setChecked(state: State) { + checkMark.isVisible = state != State.Normal + checkMark.imageResource = when (state) { + State.Normal -> 0 + State.Selected -> R.drawable.ic_baseline_check_24 + State.LightMode -> R.drawable.ic_baseline_light_mode_24 + State.DarkMode -> R.drawable.ic_baseline_dark_mode_24 + } } } \ No newline at end of file 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 ed75c5f60..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 @@ -1,3 +1,7 @@ +/* + * SPDX-License-Identifier: LGPL-2.1-or-later + * SPDX-FileCopyrightText: Copyright 2021-2023 Fcitx5 for Android Contributors + */ package org.fcitx.fcitx5.android.ui.setup import android.app.NotificationChannel @@ -8,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 @@ -21,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() { @@ -37,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()) @@ -55,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() } @@ -93,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() { @@ -134,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 { @@ -148,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/SetupFragment.kt b/app/src/main/java/org/fcitx/fcitx5/android/ui/setup/SetupFragment.kt index ce3a1f125..70f6433ba 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/ui/setup/SetupFragment.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/ui/setup/SetupFragment.kt @@ -1,3 +1,7 @@ +/* + * SPDX-License-Identifier: LGPL-2.1-or-later + * SPDX-FileCopyrightText: Copyright 2021-2023 Fcitx5 for Android Contributors + */ package org.fcitx.fcitx5.android.ui.setup import android.os.Bundle 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 1af335320..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 @@ -1,3 +1,7 @@ +/* + * SPDX-License-Identifier: LGPL-2.1-or-later + * SPDX-FileCopyrightText: Copyright 2021-2023 Fcitx5 for Android Contributors + */ package org.fcitx.fcitx5.android.ui.setup import android.content.Context @@ -33,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/ui/setup/SetupViewModel.kt b/app/src/main/java/org/fcitx/fcitx5/android/ui/setup/SetupViewModel.kt index 3bcc77e06..6761a1cf2 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/ui/setup/SetupViewModel.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/ui/setup/SetupViewModel.kt @@ -1,3 +1,7 @@ +/* + * SPDX-License-Identifier: LGPL-2.1-or-later + * SPDX-FileCopyrightText: Copyright 2021-2023 Fcitx5 for Android Contributors + */ package org.fcitx.fcitx5.android.ui.setup import androidx.lifecycle.MutableLiveData 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 d7b1dc8b6..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,68 +1,94 @@ +/* + * SPDX-License-Identifier: LGPL-2.1-or-later + * 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.LogActivity 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) - } - ) + context.startActivity { + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_EXCLUDE_FROM_RECENTS) + } } - fun launchLog(context: Context, initIntent: Intent.() -> Unit = {}) { - context.startActivity( - Intent(context, LogActivity::class.java).apply { - initIntent.invoke(this) - } - ) - } - - private fun launchMainToDest(context: Context, @IdRes dest: Int, arguments: Bundle? = null) { - NavDeepLinkBuilder(context) - .setGraph(R.navigation.settings_nav) - .addDestination(dest, arguments) - .createPendingIntent() - .send() + 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.themeListFragment) + 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) + } } -} \ No newline at end of file + fun exit() { + exitProcess(0) + } + + 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 new file mode 100644 index 000000000..a6d63087f --- /dev/null +++ b/app/src/main/java/org/fcitx/fcitx5/android/utils/Bundle.kt @@ -0,0 +1,39 @@ +/* + * SPDX-License-Identifier: LGPL-2.1-or-later + * SPDX-FileCopyrightText: Copyright 2023 Fcitx5 for Android Contributors + */ + +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? { + // 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? { + 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? { + 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 new file mode 100644 index 000000000..bafea28a9 --- /dev/null +++ b/app/src/main/java/org/fcitx/fcitx5/android/utils/ClipboardEntryTransformer.kt @@ -0,0 +1,20 @@ +/* + * SPDX-License-Identifier: LGPL-2.1-or-later + * SPDX-FileCopyrightText: Copyright 2021-2023 Fcitx5 for Android Contributors + */ +package org.fcitx.fcitx5.android.utils + +import org.fcitx.fcitx5.android.common.ipc.IClipboardEntryTransformer + +private const val FALLBACK_DESC = "" + +val IClipboardEntryTransformer.desc: String + get() = runCatching { description }.getOrElse { FALLBACK_DESC } + +fun IClipboardEntryTransformer.descEquals(other: IClipboardEntryTransformer): Boolean { + return try { + description!! == other.description!! + } catch (e: Exception) { + false + } +} 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 442437796..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 @@ -1,16 +1,16 @@ +/* + * SPDX-License-Identifier: LGPL-2.1-or-later + * SPDX-FileCopyrightText: Copyright 2021-2023 Fcitx5 for Android Contributors + */ 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 dcf235ae8..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 @@ -1,7 +1,12 @@ +/* + * 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.content.Context import android.content.res.Configuration +import android.graphics.Point import android.os.Build import org.fcitx.fcitx5.android.BuildConfig @@ -16,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: ${ @@ -29,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 new file mode 100644 index 000000000..3c894744f --- /dev/null +++ b/app/src/main/java/org/fcitx/fcitx5/android/utils/DeviceUtil.kt @@ -0,0 +1,49 @@ +/* + * SPDX-License-Identifier: LGPL-2.1-or-later + * SPDX-FileCopyrightText: Copyright 2023 Fcitx5 for Android Contributors + */ + +package org.fcitx.fcitx5.android.utils + +import android.os.Build + +object DeviceUtil { + + val isMIUI: Boolean by lazy { + getSystemProperty("ro.miui.ui.version.name").isNotEmpty() + } + + /** + * https://www.cnblogs.com/qixingchao/p/15899405.html + */ + val isHMOS: Boolean by lazy { + getSystemProperty("hw_sc.build.platform.version").isNotEmpty() + } + + /** + * https://stackoverflow.com/questions/60122037/how-can-i-detect-samsung-one-ui + */ + val isSamsungOneUI: Boolean by lazy { + try { + val semPlatformInt = Build.VERSION::class.java + .getDeclaredField("SEM_PLATFORM_INT") + .getInt(null) + semPlatformInt > 90000 + } catch (e: Exception) { + false + } + } + + val isVivoOriginOS: Boolean by lazy { + 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 ac7121413..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 @@ -1,24 +1,41 @@ +/* + * 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.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 } @@ -29,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 ece48abd6..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 @@ -1,3 +1,7 @@ +/* + * SPDX-License-Identifier: LGPL-2.1-or-later + * SPDX-FileCopyrightText: Copyright 2021-2023 Fcitx5 for Android Contributors + */ package org.fcitx.fcitx5.android.utils import org.fcitx.fcitx5.android.data.prefs.AppPrefs @@ -5,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/Exception.kt b/app/src/main/java/org/fcitx/fcitx5/android/utils/Exception.kt index 8cafe9bb3..6b0c0278f 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/utils/Exception.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/utils/Exception.kt @@ -1,3 +1,7 @@ +/* + * SPDX-License-Identifier: LGPL-2.1-or-later + * SPDX-FileCopyrightText: Copyright 2021-2023 Fcitx5 for Android Contributors + */ package org.fcitx.fcitx5.android.utils import androidx.annotation.StringRes diff --git a/app/src/main/java/org/fcitx/fcitx5/android/utils/FileUtil.kt b/app/src/main/java/org/fcitx/fcitx5/android/utils/FileUtil.kt new file mode 100644 index 000000000..efd76900d --- /dev/null +++ b/app/src/main/java/org/fcitx/fcitx5/android/utils/FileUtil.kt @@ -0,0 +1,51 @@ +/* + * 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.system.Os +import android.system.OsConstants +import java.io.File +import java.io.IOException + +object FileUtil { + + private fun File.isSymlink(): Boolean = OsConstants.S_ISLNK(Os.lstat(path).st_mode) + + /** + * Delete a [File]. + * If it's a directory, delete its contents first. + * If it's a symlink, don't follow. + */ + fun removeFile(file: File) = runCatching { + if (!file.exists()) + return Result.success(Unit) + val result = if (file.isSymlink()) { + file.delete() + } else if (file.isDirectory) { + file.walkBottomUp() + .onEnter { + // delete symlink (to directory) instead of entering it + if (it.isSymlink()) { + it.delete() + false + } else { + true + } + } + .fold(true) { acc, it -> + if (!it.exists()) acc else it.delete() + } + } else { + file.delete() + } + if (!result) + throw IOException("Cannot delete '${file.path}'") + } + + fun symlink(source: File, target: File) = runCatching { + target.parentFile?.mkdirs() + Os.symlink(source.path, target.path) + } +} \ No newline at end of file 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/ImmutableGraph.kt b/app/src/main/java/org/fcitx/fcitx5/android/utils/ImmutableGraph.kt index 957f813fe..08b7583fc 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/utils/ImmutableGraph.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/utils/ImmutableGraph.kt @@ -1,3 +1,7 @@ +/* + * SPDX-License-Identifier: LGPL-2.1-or-later + * SPDX-FileCopyrightText: Copyright 2021-2023 Fcitx5 for Android Contributors + */ package org.fcitx.fcitx5.android.utils import java.util.LinkedList diff --git a/app/src/main/java/org/fcitx/fcitx5/android/utils/Ini.kt b/app/src/main/java/org/fcitx/fcitx5/android/utils/Ini.kt index c7b7914ee..0c283ee31 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/utils/Ini.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/utils/Ini.kt @@ -1,177 +1,58 @@ +/* + * SPDX-License-Identifier: LGPL-2.1-or-later + * SPDX-FileCopyrightText: Copyright 2021-2023 Fcitx5 for Android Contributors + */ package org.fcitx.fcitx5.android.utils -import cc.ekblad.konbini.* -import org.intellij.lang.annotations.Language +import org.fcitx.fcitx5.android.core.RawConfig +import java.io.File -typealias IniProperties = MutableList> +@JvmInline +value class Ini(val core: RawConfig) { -fun IniProperties.getValue(key: String): String? { - val prop = find { it.data.name == key } ?: return null - return prop.data.value -} + val value: String + get() = core.value -fun IniProperties.setValue(key: String, newValue: String) { - val idx = indexOfFirst { it.data.name == key } - val prop = Ini.Property(key, newValue) - if (idx >= 0) { - set(idx, Ini.Annotated(get(idx).comments, prop)) - } else { - add(Ini.Annotated(prop)) - } -} - -data class Ini( - val properties: IniProperties, - val sections: MutableMap>, - val trailingComments: MutableList -) { - - data class Property(var name: String, var value: String) - - data class Annotated( - val comments: MutableList, - val data: T - ) { - constructor(data: T) : this(mutableListOf(), data) - } - - fun getValue(key: String) = properties.getValue(key) - - fun getValue(section: String, key: String) = sections[section]?.data?.getValue(key) - - fun setValue(key: String, newValue: String) = properties.setValue(key, newValue) - - fun setValue(section: String, key: String, newValue: String) { - val s = sections[section] ?: Annotated(mutableListOf>()).also { - sections[section] = it + fun get(vararg keys: String): Ini? { + if (keys.isEmpty()) return null + var current = core + keys.forEach { + current = current.findByName(it) ?: return null } - s.data.setValue(key, newValue) + return Ini(current) } -} - -object IniPrettyPrinter { - data class PrettyOptions( - val separator: Separator, - val commentStart: CommentStart, - val spaceAroundSeparator: Boolean - ) { - enum class Separator { - Colon, Equal + fun set(vararg keys: String, raw: RawConfig) { + var current = core + keys.forEach { + current = current.getOrCreate(it) } - - enum class CommentStart { - Semicolon, Pound + current.getOrCreate(raw.name).apply { + // RawConfig's comment is immutable; is fine. + value = raw.value + subItems = raw.subItems } } - private val defaultPrettyOptions = PrettyOptions( - PrettyOptions.Separator.Equal, - PrettyOptions.CommentStart.Semicolon, - false - ) - - fun pretty(ini: Ini, prettyOptions: PrettyOptions = defaultPrettyOptions): String { - val sb = StringBuilder() - fun prettyProperty(property: Ini.Property) { - sb.append(property.name) - if (prettyOptions.spaceAroundSeparator) - sb.append(' ') - when (prettyOptions.separator) { - PrettyOptions.Separator.Colon -> sb.append(':') - PrettyOptions.Separator.Equal -> sb.append('=') - } - if (prettyOptions.spaceAroundSeparator) - - sb.append(' ') - sb.appendLine(property.value) + fun set(vararg keys: String, str: String) { + if (keys.isEmpty()) return + var current = core + keys.forEach { + current = current.getOrCreate(it) } - - fun prettyComments(comments: List) { - comments.forEach { - when (prettyOptions.commentStart) { - PrettyOptions.CommentStart.Semicolon -> sb.append(';') - PrettyOptions.CommentStart.Pound -> sb.append('#') - } - sb.appendLine(it) - } - } - ini.properties.forEach { - prettyComments(it.comments) - prettyProperty(it.data) - } - ini.sections.forEach { (n, properties) -> - prettyComments(properties.comments) - sb.appendLine("[$n]") - properties.data.forEach { - prettyComments(it.comments) - prettyProperty(it.data) - } - } - prettyComments(ini.trailingComments) - return sb.toString() - } -} - -object IniParser { - private inline fun lexeme(crossinline parser: Parser) = parser { - val data = parser() - whitespace() - data + current.value = str } - @Language("RegExp") - private val name = lexeme(regex("[a-zA-Z0-9._/\\[\\]]+")) + companion object { + @JvmStatic + private external fun readFromIni(src: String): RawConfig? - @Language("RegExp") - private val sectionName = lexeme(regex("[a-zA-Z0-9._/]+")) + @JvmStatic + private external fun writeAsIni(dest: String, value: RawConfig) - @Language("RegExp") - private val value = lexeme(regex(".+")) + fun parseIniFromFile(file: File) = readFromIni(file.path)?.let { Ini(it) } - @Language("RegExp") - private val comment = lexeme(regex("[;#].*")) - private val colon = lexeme(char(':')) - private val equal = lexeme(char('=')) - private val bOpen = lexeme(char('[')) - private val bClose = lexeme(char(']')) - - private inline fun annotated(crossinline parser: Parser) = parser { - val comments = many(comment).map { it.drop(1) } - val data = parser() - Ini.Annotated(comments.toMutableList(), data) - } - - private val property = annotated(parser { - val n = name() - oneOf(colon, equal) - val v = value() - Ini.Property(n, v) - }) - - private val section = annotated(parser { - val n = bracket(bOpen, bClose, sectionName) - val properties = many(property) - n to properties - }) - - private val ini = parser { - val globals = mutableListOf>() - val sections = - mutableMapOf>>>() - whitespace() - many { - oneOf( - section.map { - sections[it.data.first] = - Ini.Annotated(it.comments, it.data.second.toMutableList()) - }, - property.map { globals.add(it) } - ) - } - val trailingComments = annotated(whitespace).map { it.comments }() - Ini(globals, sections, trailingComments) + fun writeIniToFile(ini: Ini, file: File) = writeAsIni(file.path, ini.core) } - fun parse(text: String) = ini.parseToEnd(text) } \ No newline at end of file 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 56d6bc0b6..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 @@ -1,3 +1,7 @@ +/* + * 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.content.ComponentName @@ -6,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 new file mode 100644 index 000000000..d1b41167c --- /dev/null +++ b/app/src/main/java/org/fcitx/fcitx5/android/utils/Intent.kt @@ -0,0 +1,52 @@ +/* + * SPDX-License-Identifier: LGPL-2.1-or-later + * SPDX-FileCopyrightText: Copyright 2023 Fcitx5 for Android Contributors + */ + +package org.fcitx.fcitx5.android.utils + +import android.content.Intent +import android.net.Uri +import android.os.Build +import android.os.Parcelable +import android.provider.DocumentsContract +import androidx.annotation.RequiresApi +import org.fcitx.fcitx5.android.BuildConfig + +inline fun Intent.parcelable(key: String): T? { + // https://issuetracker.google.com/issues/240585930#comment6 + 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? { + 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) +fun buildPrimaryStorageIntent(path: String = ""): Intent { + val initialUri = appContext.storageManager.primaryStorageVolume.createOpenDocumentTreeIntent() + .parcelable(DocumentsContract.EXTRA_INITIAL_URI)!! + val uri = Uri.Builder() + .scheme(initialUri.scheme) + .authority(initialUri.authority) + .encodedPath( + initialUri.path!!.replaceFirst("/root/", "/document/") + + Uri.encode(":Android/data/${BuildConfig.APPLICATION_ID}/files/$path") + ).build() + return Intent(Intent.ACTION_VIEW, uri) +} + +fun buildDocumentsProviderIntent(): Intent { + val uri = DocumentsContract.buildRootUri("${BuildConfig.APPLICATION_ID}.provider", "files") + return Intent(Intent.ACTION_VIEW, uri) +} 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/NaiveDustman.kt b/app/src/main/java/org/fcitx/fcitx5/android/utils/NaiveDustman.kt index f8f43f427..cdf057aae 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/utils/NaiveDustman.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/utils/NaiveDustman.kt @@ -1,3 +1,7 @@ +/* + * SPDX-License-Identifier: LGPL-2.1-or-later + * SPDX-FileCopyrightText: Copyright 2021-2023 Fcitx5 for Android Contributors + */ package org.fcitx.fcitx5.android.utils import kotlin.properties.Delegates 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/NostalgicSerializer.kt b/app/src/main/java/org/fcitx/fcitx5/android/utils/NostalgicSerializer.kt index 65fd98533..d159eab11 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/utils/NostalgicSerializer.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/utils/NostalgicSerializer.kt @@ -1,3 +1,7 @@ +/* + * SPDX-License-Identifier: LGPL-2.1-or-later + * SPDX-FileCopyrightText: Copyright 2021-2023 Fcitx5 for Android Contributors + */ package org.fcitx.fcitx5.android.utils import kotlinx.serialization.KSerializer 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 cde25801c..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,11 +1,19 @@ +/* + * SPDX-License-Identifier: LGPL-2.1-or-later + * 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 fun PreferenceScreen.addCategory(title: String, block: PreferenceCategory.() -> Unit) { val category = PreferenceCategory(context).apply { @@ -16,32 +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(icon) - } - 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( @@ -50,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( @@ -60,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 01db44aa9..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 @@ -1,3 +1,7 @@ +/* + * 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.graphics.Rect @@ -5,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 743230fb4..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 @@ -1,28 +1,40 @@ +/* + * 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.app.NotificationManager import android.content.ClipboardManager import android.content.Context +import android.media.AudioManager +import android.os.UserManager import android.os.Vibrator -import android.view.View +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()!! val Context.clipboardManager get() = getSystemService()!! -val Context.vibrator - get() = getSystemService()!! - -val View.vibrator - get() = context.vibrator - val Context.inputMethodManager get() = getSystemService()!! 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/TextinputLayout.kt b/app/src/main/java/org/fcitx/fcitx5/android/utils/TextinputLayout.kt index b006f73fe..dc3a03142 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/utils/TextinputLayout.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/utils/TextinputLayout.kt @@ -1,3 +1,7 @@ +/* + * 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.content.Context 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 5ca4a8a22..000000000 --- a/app/src/main/java/org/fcitx/fcitx5/android/utils/Utils.kt +++ /dev/null @@ -1,267 +0,0 @@ -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.inputmethodservice.InputMethodService -import android.net.Uri -import android.os.Build -import android.os.Bundle -import android.os.Looper -import android.os.Parcelable -import android.provider.OpenableColumns -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.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 arrow.core.toOption -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.io.Serializable -import java.text.SimpleDateFormat -import java.util.* -import java.util.zip.ZipInputStream -import kotlin.contracts.ExperimentalContracts -import kotlin.contracts.InvocationKind -import kotlin.contracts.contract -import kotlin.math.roundToInt - -val InputMethodService.inputConnection: InputConnection? - get() = currentInputConnection - -fun ViewPager2.getCurrentFragment(fragmentManager: FragmentManager): Fragment? = - fragmentManager.findFragmentByTag("f$currentItem") - -val appContext: Context - get() = FcitxApplication.getInstance().applicationContext - -fun Uri.queryFileName(contentResolver: ContentResolver) = - contentResolver.query( - this, - null, null, null, null - )?.use { - val index = it.getColumnIndex(OpenableColumns.DISPLAY_NAME) - it.moveToFirst() - it.getString(index) - }.toOption() - -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.US).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() = - when (uiMode.and(Configuration.UI_MODE_NIGHT_MASK)) { - Configuration.UI_MODE_NIGHT_YES -> true - else -> false - } - -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 - } -} - -@OptIn(ExperimentalContracts::class) -inline fun Result.bindOnNotNull(block: (T) -> Result): Result? { - contract { - callsInPlace(block, InvocationKind.AT_MOST_ONCE) - } - return when { - isSuccess && getOrThrow() != null -> block(getOrThrow()!!) - isSuccess && getOrThrow() == null -> null - else -> Result.failure(exceptionOrNull()!!) - } -} - -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() - } -} - -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 -// } -} - -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 -// } -} - -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 -// } -} - -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 Class.forName("android.os.SystemProperties") - .getMethod("get", String::class.java) - .invoke(null, key) as String -} - -fun ZipInputStream.extract(destDir: File): List { - val extracted = mutableListOf() - var entry = nextEntry - val canonicalDest = destDir.canonicalPath - while (entry != null && !entry.isDirectory) { - val file = File(destDir, entry.name) - if (!file.canonicalPath.startsWith(canonicalDest)) - throw SecurityException() - copyTo(file.outputStream()) - extracted.add(file) - entry = nextEntry - } - return extracted -} - -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*+") \ No newline at end of file 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 091c77a38..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,21 +1,30 @@ +/* + * SPDX-License-Identifier: LGPL-2.1-or-later + * SPDX-FileCopyrightText: Copyright 2021-2025 Fcitx5 for Android Contributors + */ package org.fcitx.fcitx5.android.utils.config import android.os.Parcelable import arrow.core.Either -import arrow.core.continuations.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>, @@ -23,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, @@ -37,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, @@ -85,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 @@ -104,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, @@ -128,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, @@ -146,12 +182,14 @@ sealed class ConfigDescriptor : Parcelable { QuickPhrase, Chttrans, TableGlobal, + PinyinCustomPhrase, + RimeUserDataDir, // manually added on Android side for TableManager AndroidTable } - override val type: ConfigType + override val ty: ConfigType get() = ConfigType.TyExternal override val defaultValue: Nothing? get() = null @@ -160,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 @@ -190,7 +234,7 @@ sealed class ConfigDescriptor : Parcelable { override fun parse(raw: RawConfig): Either> = ((raw.type?.mapLeft { ParseException.TypeNoParse(it) }) ?: (Either.Left(ParseException.NoTypeExist(raw)))).flatMap { - either.eager { + either { when (it) { ConfigType.TyBool -> ConfigBool( @@ -204,7 +248,7 @@ sealed class ConfigDescriptor : Parcelable { raw.description ) ConfigType.TyEnum -> { - val entries = raw.enum ?: shift(ParseException.NoEnumFound(raw)) + val entries = raw.enum ?: raise(ParseException.NoEnumFound(raw)) ConfigEnum( raw.name, raw.description, @@ -222,10 +266,14 @@ 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 ?: shift(ParseException.NoEnumFound(raw)) + val entries = raw.enum ?: raise(ParseException.NoEnumFound(raw)) ConfigEnumList( raw.name, raw.description, @@ -242,12 +290,20 @@ 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 -> shift(ParseException.BadFormList(it)) + else -> raise(ParseException.BadFormList(it)) } } ) @@ -267,6 +323,8 @@ sealed class ConfigDescriptor : Parcelable { "QuickPhrase", "Editor" -> ConfigExternal.ETy.QuickPhrase "Chttrans" -> ConfigExternal.ETy.Chttrans "TableGlobal" -> ConfigExternal.ETy.TableGlobal + "CustomPhrase" -> ConfigExternal.ETy.PinyinCustomPhrase + "UserDataDir" -> ConfigExternal.ETy.RimeUserDataDir "AndroidTable" -> ConfigExternal.ETy.AndroidTable else -> null } @@ -278,8 +336,8 @@ sealed class ConfigDescriptor : Parcelable { fun parseTopLevel(raw: RawConfig): Either = - either.eager { - val topLevel = raw.subItems?.get(0) ?: shift(ParseException.BadFormDesc(raw)) + either { + val topLevel = raw.subItems?.get(0) ?: raise(ParseException.BadFormDesc(raw)) val customTypeDef = raw.subItems?.drop(1)?.mapNotNull { it.subItems?.map { ele -> parse(ele).bind() } ?.let { parsed -> ConfigCustomTypeDef(it.name, parsed) } @@ -288,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/ConfigParser.kt b/app/src/main/java/org/fcitx/fcitx5/android/utils/config/ConfigParser.kt index baee7c916..2762b7cb6 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/utils/config/ConfigParser.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/utils/config/ConfigParser.kt @@ -1,3 +1,7 @@ +/* + * SPDX-License-Identifier: LGPL-2.1-or-later + * SPDX-FileCopyrightText: Copyright 2021-2023 Fcitx5 for Android Contributors + */ package org.fcitx.fcitx5.android.utils.config import arrow.core.Either 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 3e7a80191..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 @@ -1,45 +1,47 @@ +/* + * SPDX-License-Identifier: LGPL-2.1-or-later + * SPDX-FileCopyrightText: Copyright 2021-2023 Fcitx5 for Android Contributors + */ package org.fcitx.fcitx5.android.utils.config import android.os.Parcelable import arrow.core.Either -import arrow.core.continuations.either +import arrow.core.raise.either import kotlinx.parcelize.Parcelize +import kotlinx.serialization.Serializable +@Serializable sealed class ConfigType : Parcelable { @Parcelize - object TyInt : ConfigType() { - override fun toString(): String = javaClass.simpleName - } + @Serializable + data object TyInt : ConfigType() @Parcelize - object TyString : ConfigType() { - override fun toString(): String = javaClass.simpleName - } + @Serializable + data object TyString : ConfigType() @Parcelize - object TyBool : ConfigType() { - override fun toString(): String = javaClass.simpleName - } + @Serializable + data object TyBool : ConfigType() @Parcelize - object TyKey : ConfigType() { - override fun toString(): String = javaClass.simpleName - } + @Serializable + data object TyKey : ConfigType() @Parcelize - object TyEnum : ConfigType() { - override fun toString(): String = javaClass.simpleName - } + @Serializable + data object TyEnum : ConfigType() @Parcelize - object TyExternal : ConfigType() { - override fun toString(): String = javaClass.simpleName - } + @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> { @@ -47,7 +49,7 @@ sealed class ConfigType : Parcelable { data class UnknownConfigTypeException(val type: String) : Exception() override fun parse(raw: String): Either> = - either.eager { + either { when (raw) { "Integer" -> TyInt "String" -> TyString @@ -59,7 +61,7 @@ sealed class ConfigType : Parcelable { when { raw.startsWith("List|") -> parse(raw.drop(5)).map(::TyList).bind() raw.contains("$") -> TyCustom(raw) - else -> shift(UnknownConfigTypeException(raw)) + else -> raise(UnknownConfigTypeException(raw)) } } } 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/app/src/main/play/listings/en-US/full-description.txt b/app/src/main/play/listings/en-US/full-description.txt new file mode 100644 index 000000000..6a8f5ca18 --- /dev/null +++ b/app/src/main/play/listings/en-US/full-description.txt @@ -0,0 +1,27 @@ +Supported languages +
    +
  • English (with spellcheck)
  • +
  • Chinese +
      +
    • Pinyin, Shuangpin, Wubi, Cangjie and custom tables (built-in)
    • +
    • Zhuyin/Bopomofo (via Chewing Plugin)
    • +
    • Jyutping (via Jyutping Plugin)
    • +
    +
  • +
  • Vietnamese (via on UniKey Plugin, supports Telex, VNI and VIQR)
  • +
  • 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 +
    +
  • Virtual Keyboard (layout not customizable yet)
  • +
  • Expandable candidate view
  • +
  • Clipboard management (plain text only)
  • +
  • Theming (custom color scheme and background image)
  • +
  • Popup preview on key press
  • +
  • Long press popup keyboard for convenient symbol input
  • +
  • Symbol and Emoji picker
  • +
\ 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/app/src/main/play/listings/zh-CN/full-description.txt b/app/src/main/play/listings/zh-CN/full-description.txt new file mode 100644 index 000000000..e75c4d1c8 --- /dev/null +++ b/app/src/main/play/listings/zh-CN/full-description.txt @@ -0,0 +1,27 @@ +支持的语言 +
    +
  • 英文(支持拼写检查)
  • +
  • 中文 +
      +
    • 拼音、双拼、五笔、仓颉 及 自定义码表(内置)
    • +
    • 注音(新酷音插件)
    • +
    • 粤语拼音(粤拼插件)
    • +
    +
  • +
  • 越南语(UniKey 插件,支持 Telex VNI VIQR 等方案)
  • +
  • 日语(Anthy 插件)
  • +
  • 韩语(Hangul 插件)
  • +
  • 僧伽罗语(Sayura 插件)
  • +
  • 泰语(Thai 插件)
  • +
  • 通用(中州韵插件,支持导入自定义方案)
  • +
+已实现的功能 +
    +
  • 虚拟键盘(暂不支持自定义布局)
  • +
  • 可翻页的展开候选词列表
  • +
  • 剪贴板管理(仅纯文本)
  • +
  • 主题(自定义颜色方案和背景图片)
  • +
  • 按键弹出预览
  • +
  • 长按弹出键盘以快速输入标点符号
  • +
  • 标点符号、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/app/src/main/play/release-notes/en-US/54.txt b/app/src/main/play/release-notes/en-US/54.txt new file mode 100644 index 000000000..5e53b86b6 --- /dev/null +++ b/app/src/main/play/release-notes/en-US/54.txt @@ -0,0 +1,39 @@ +# 0.0.7 - Plugin Service and User Data Export/Import + +## Highlights + +- Plugin Service: plugins can have their own service and communicate with the input method. Currently "Clipboard Filter Plugin" is available for applying ClearURLs rules. +- User Data Export/Import: export Android preference and Fcitx configuration as .zip archive, then import it later or on other devices. + +### libime 1.1.1 + +- Updated language model, which may "improve the experience on Pinyin in a significant way" + +### fcitx5-chinese-addons 5.1.0 + +- New custom phrase in Pinyin, Learn more at https://github.com/fcitx/fcitx5-chinese-addons/pull/138 + +## New features + +- Show a smile face icon on comma key to indicate it's long press action +- Scale candidate text if it's too long +- Keypress sound effect settings +- Plugins can have their own service +- Option for switch back to keyboard after paste in clipboard UI +- Don't follow system font scale settings in keyboard buttons +- Save fcitx state before device shutdown +- Option for switch back to keyboard on focus change +- User data export and import +- Exclude Activity launched by keyboard from recents +- Prompt before clear clipboard database in developer settings +- Option to reset cursor position after commit text + +## Bug fixes + +- Missing character in some emoticon +- Missing EditorInfo inspector preference UI +- Potential crash in InlineSuggestionsUi +- Import table configuration file with empty value +- Edit another clipboard entry while in clipboard editing dialog +- Potential crash when showing the keyboard for the first time +- Changes to fcitx configuration via settings UI cannot be saved sometimes diff --git a/app/src/main/play/release-notes/en-US/64.txt b/app/src/main/play/release-notes/en-US/64.txt new file mode 100644 index 000000000..5e00f5a0c --- /dev/null +++ b/app/src/main/play/release-notes/en-US/64.txt @@ -0,0 +1,44 @@ +# 0.0.8 - More Languages Supported via Plugins + +## Highlights + +- More Languages Supported, and Many Many New Plugins +- Pinyin `customphrase` Editor UI: Manage pinyin customphrase within settings +- Direct Boot Mode Support: Fcitx5 for Android can be used to input password for unlocking device the first time after rebooting + +### New plugins + +- RIME, Hangul (Korean), Chewing (Zhuyin/Bopomofo), Sayura (Sinhala), Jyutping (Cantonese) +- Unikey (Vietnamese) has been split into plugin as well +- All plugins now have their own icon + +### Build process improvements + +- Now you can build fcitx5-android on Windows (requires MSYS2) and macOS (requires Homebrew). + +## New features + +- Direct boot mode support +- Support more icons and text labels in StatusArea +- Implement `fcitx::INotifications::showTip` as Android Toast +- Set GlobalConfig/ShareInputState to "All" on first run +- Swipe/LongPress now inputs normal number keys rather than KP_* ones +- Pinyin customphrase editor UI +- Option to "Expand keypress area" (i.e. Expanding tapping area of A and L keys) +- Key margin can be configured separately for portrait and landscape mode +- Change theme settings and preview theme in one page +- Replace table dictionary without deleting and importing again +- Option to change language switch key behavior + +## Bug fixes + +- QuickPhrase .mb files cannot be imported +- Keyboard theme out of sync when importing theme with "Follow system dark mode" enabled +- PickerWindow layout mirrors in RTL layout direction +- Potential crash when showing keyboard for the first time +- Toolbar cannot be collapsed when "expand by default" enabled +- Label on space key won't change when input method subMode changes +- Save/Read QuickPhrase with newline character +- Occasionally event disorder / dead lock when handling fcitx events +- Workaround some manufacturer os specific issues +- ExpandedCandidateWindow cannot be detached when dismissing predict candidates 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-v24/ic_launcher_foreground.xml b/app/src/main/res/drawable-v24/ic_launcher_foreground.xml index 915902837..eca078f4f 100644 --- a/app/src/main/res/drawable-v24/ic_launcher_foreground.xml +++ b/app/src/main/res/drawable-v24/ic_launcher_foreground.xml @@ -12,10 +12,10 @@ android:strokeLineJoin="miter"> + android:strokeWidth="3.78" /> + android:strokeWidth="3.78" /> diff --git a/app/src/main/res/drawable-v24/ic_launcher_foreground_debug.xml b/app/src/main/res/drawable-v24/ic_launcher_foreground_debug.xml index 81d694b98..91c490b9c 100644 --- a/app/src/main/res/drawable-v24/ic_launcher_foreground_debug.xml +++ b/app/src/main/res/drawable-v24/ic_launcher_foreground_debug.xml @@ -12,10 +12,10 @@ android:strokeLineJoin="miter"> + android:strokeWidth="3.78" /> + android:strokeWidth="3.78" /> + android:strokeWidth="1" /> + android:strokeWidth="1" /> + android:strokeWidth="1" /> + android:strokeWidth="1" /> + android:strokeWidth="1" /> + android:strokeWidth="1" /> diff --git a/app/src/main/res/drawable/ic_baseline_access_time_24.xml b/app/src/main/res/drawable/ic_baseline_access_time_24.xml index d168353d1..da839ddc8 100644 --- a/app/src/main/res/drawable/ic_baseline_access_time_24.xml +++ b/app/src/main/res/drawable/ic_baseline_access_time_24.xml @@ -2,12 +2,11 @@ android:width="24dp" android:height="24dp" android:viewportWidth="24" - android:viewportHeight="24" - android:tint="?attr/colorControlNormal"> + android:viewportHeight="24"> diff --git a/app/src/main/res/drawable/ic_baseline_android_24.xml b/app/src/main/res/drawable/ic_baseline_android_24.xml index e5cce70f1..7abb6157a 100644 --- a/app/src/main/res/drawable/ic_baseline_android_24.xml +++ b/app/src/main/res/drawable/ic_baseline_android_24.xml @@ -1,11 +1,9 @@ diff --git a/app/src/main/res/drawable/ic_baseline_arrow_back_24.xml b/app/src/main/res/drawable/ic_baseline_arrow_back_24.xml index 2a31b2ef3..327df952d 100644 --- a/app/src/main/res/drawable/ic_baseline_arrow_back_24.xml +++ b/app/src/main/res/drawable/ic_baseline_arrow_back_24.xml @@ -1,11 +1,10 @@ - + android:viewportHeight="24"> + diff --git a/app/src/main/res/drawable/ic_baseline_arrow_downward_24.xml b/app/src/main/res/drawable/ic_baseline_arrow_downward_24.xml index 8f9be44a7..447527468 100644 --- a/app/src/main/res/drawable/ic_baseline_arrow_downward_24.xml +++ b/app/src/main/res/drawable/ic_baseline_arrow_downward_24.xml @@ -2,9 +2,8 @@ android:width="24dp" android:height="24dp" android:viewportWidth="24" - android:viewportHeight="24" - android:tint="?attr/colorControlNormal"> - + android:viewportHeight="24"> + diff --git a/app/src/main/res/drawable/ic_baseline_arrow_drop_down_24.xml b/app/src/main/res/drawable/ic_baseline_arrow_drop_down_24.xml index ce583469c..5e72babb0 100644 --- a/app/src/main/res/drawable/ic_baseline_arrow_drop_down_24.xml +++ b/app/src/main/res/drawable/ic_baseline_arrow_drop_down_24.xml @@ -2,9 +2,8 @@ android:width="24dp" android:height="24dp" android:viewportWidth="24" - android:viewportHeight="24" - android:tint="?attr/colorControlNormal"> - + android:viewportHeight="24"> + diff --git a/app/src/main/res/drawable/ic_baseline_arrow_forward_24.xml b/app/src/main/res/drawable/ic_baseline_arrow_forward_24.xml index d5e3ac8b2..0ca99223d 100644 --- a/app/src/main/res/drawable/ic_baseline_arrow_forward_24.xml +++ b/app/src/main/res/drawable/ic_baseline_arrow_forward_24.xml @@ -1,11 +1,10 @@ - + android:viewportHeight="24"> + 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_arrow_upward_24.xml b/app/src/main/res/drawable/ic_baseline_arrow_upward_24.xml index 07b7a8366..c03f6f515 100644 --- a/app/src/main/res/drawable/ic_baseline_arrow_upward_24.xml +++ b/app/src/main/res/drawable/ic_baseline_arrow_upward_24.xml @@ -2,9 +2,8 @@ android:width="24dp" android:height="24dp" android:viewportWidth="24" - android:viewportHeight="24" - android:tint="?attr/colorControlNormal"> - + android:viewportHeight="24"> + 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_backspace_24.xml b/app/src/main/res/drawable/ic_baseline_backspace_24.xml index 1cfa0d73c..1bb49cc0c 100644 --- a/app/src/main/res/drawable/ic_baseline_backspace_24.xml +++ b/app/src/main/res/drawable/ic_baseline_backspace_24.xml @@ -1,10 +1,10 @@ - + android:viewportHeight="24"> + diff --git a/app/src/main/res/drawable/ic_baseline_cake_24.xml b/app/src/main/res/drawable/ic_baseline_cake_24.xml index 7e38e1e32..73823e43a 100644 --- a/app/src/main/res/drawable/ic_baseline_cake_24.xml +++ b/app/src/main/res/drawable/ic_baseline_cake_24.xml @@ -2,9 +2,8 @@ android:width="24dp" android:height="24dp" android:viewportWidth="24" - android:viewportHeight="24" - android:tint="?attr/colorControlNormal"> + android:viewportHeight="24"> diff --git a/app/src/main/res/drawable/ic_baseline_check_24.xml b/app/src/main/res/drawable/ic_baseline_check_24.xml index 0432fa69b..ee9b144b9 100644 --- a/app/src/main/res/drawable/ic_baseline_check_24.xml +++ b/app/src/main/res/drawable/ic_baseline_check_24.xml @@ -2,9 +2,8 @@ android:width="24dp" android:height="24dp" android:viewportWidth="24" - android:viewportHeight="24" - android:tint="?attr/colorControlNormal"> - + android:viewportHeight="24"> + diff --git a/app/src/main/res/drawable/ic_baseline_check_circle_24.xml b/app/src/main/res/drawable/ic_baseline_check_circle_24.xml index 5e111ca7d..82c21d850 100644 --- a/app/src/main/res/drawable/ic_baseline_check_circle_24.xml +++ b/app/src/main/res/drawable/ic_baseline_check_circle_24.xml @@ -2,9 +2,8 @@ android:width="24dp" android:height="24dp" android:viewportWidth="24" - android:viewportHeight="24" - android:tint="?attr/colorControlNormal"> - + android:viewportHeight="24"> + diff --git a/app/src/main/res/drawable/ic_baseline_code_24.xml b/app/src/main/res/drawable/ic_baseline_code_24.xml index b97ee53fa..123e24c3f 100644 --- a/app/src/main/res/drawable/ic_baseline_code_24.xml +++ b/app/src/main/res/drawable/ic_baseline_code_24.xml @@ -2,9 +2,8 @@ android:width="24dp" android:height="24dp" android:viewportWidth="24" - android:viewportHeight="24" - android:tint="?attr/colorControlNormal"> - + android:viewportHeight="24"> + diff --git a/app/src/main/res/drawable/ic_baseline_code_off_24.xml b/app/src/main/res/drawable/ic_baseline_code_off_24.xml index d2cf6333a..0c5f79308 100644 --- a/app/src/main/res/drawable/ic_baseline_code_off_24.xml +++ b/app/src/main/res/drawable/ic_baseline_code_off_24.xml @@ -2,9 +2,8 @@ android:width="24dp" android:height="24dp" android:viewportWidth="24" - android:viewportHeight="24" - android:tint="?attr/colorControlNormal"> - + android:viewportHeight="24"> + diff --git a/app/src/main/res/drawable/ic_baseline_content_paste_24.xml b/app/src/main/res/drawable/ic_baseline_content_paste_24.xml index 2d8f09a1d..fc37de99a 100644 --- a/app/src/main/res/drawable/ic_baseline_content_paste_24.xml +++ b/app/src/main/res/drawable/ic_baseline_content_paste_24.xml @@ -2,9 +2,8 @@ android:width="24dp" android:height="24dp" android:viewportWidth="24" - android:viewportHeight="24" - android:tint="?attr/colorControlNormal"> - + android:viewportHeight="24"> + diff --git a/app/src/main/res/drawable/ic_baseline_dark_mode_24.xml b/app/src/main/res/drawable/ic_baseline_dark_mode_24.xml new file mode 100644 index 000000000..10cccb646 --- /dev/null +++ b/app/src/main/res/drawable/ic_baseline_dark_mode_24.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_baseline_delete_24.xml b/app/src/main/res/drawable/ic_baseline_delete_24.xml index 3c4030b03..7fe15c703 100644 --- a/app/src/main/res/drawable/ic_baseline_delete_24.xml +++ b/app/src/main/res/drawable/ic_baseline_delete_24.xml @@ -2,9 +2,8 @@ android:width="24dp" android:height="24dp" android:viewportWidth="24" - android:viewportHeight="24" - android:tint="?attr/colorControlNormal"> - + android:viewportHeight="24"> + diff --git a/app/src/main/res/drawable/ic_baseline_delete_sweep_24.xml b/app/src/main/res/drawable/ic_baseline_delete_sweep_24.xml index 22560a4f9..d6bb636f3 100644 --- a/app/src/main/res/drawable/ic_baseline_delete_sweep_24.xml +++ b/app/src/main/res/drawable/ic_baseline_delete_sweep_24.xml @@ -2,9 +2,8 @@ android:width="24dp" android:height="24dp" android:viewportWidth="24" - android:viewportHeight="24" - android:tint="?attr/colorControlNormal"> - + android:viewportHeight="24"> + diff --git a/app/src/main/res/drawable/ic_baseline_developer_mode_24.xml b/app/src/main/res/drawable/ic_baseline_developer_mode_24.xml index ded6b0358..dbc260a20 100644 --- a/app/src/main/res/drawable/ic_baseline_developer_mode_24.xml +++ b/app/src/main/res/drawable/ic_baseline_developer_mode_24.xml @@ -2,9 +2,8 @@ android:width="24dp" android:height="24dp" android:viewportWidth="24" - android:viewportHeight="24" - android:tint="?attr/colorControlNormal"> - + android:viewportHeight="24"> + diff --git a/app/src/main/res/drawable/ic_baseline_directions_car_24.xml b/app/src/main/res/drawable/ic_baseline_directions_car_24.xml index dc2afc1f6..ea76ed0b6 100644 --- a/app/src/main/res/drawable/ic_baseline_directions_car_24.xml +++ b/app/src/main/res/drawable/ic_baseline_directions_car_24.xml @@ -2,9 +2,8 @@ android:width="24dp" android:height="24dp" android:viewportWidth="24" - android:viewportHeight="24" - android:tint="?attr/colorControlNormal"> + android:viewportHeight="24"> diff --git a/app/src/main/res/drawable/ic_baseline_done_24.xml b/app/src/main/res/drawable/ic_baseline_done_24.xml index 899cbb684..6245292c3 100644 --- a/app/src/main/res/drawable/ic_baseline_done_24.xml +++ b/app/src/main/res/drawable/ic_baseline_done_24.xml @@ -2,9 +2,8 @@ android:width="24dp" android:height="24dp" android:viewportWidth="24" - android:viewportHeight="24" - android:tint="?attr/colorControlNormal"> - + android:viewportHeight="24"> + diff --git a/app/src/main/res/drawable/ic_baseline_drag_handle_24.xml b/app/src/main/res/drawable/ic_baseline_drag_handle_24.xml index 88fe5b407..672cd70ee 100644 --- a/app/src/main/res/drawable/ic_baseline_drag_handle_24.xml +++ b/app/src/main/res/drawable/ic_baseline_drag_handle_24.xml @@ -2,9 +2,8 @@ android:width="24dp" android:height="24dp" android:viewportWidth="24" - android:viewportHeight="24" - android:tint="?attr/colorControlNormal"> - + android:viewportHeight="24"> + diff --git a/app/src/main/res/drawable/ic_baseline_edit_24.xml b/app/src/main/res/drawable/ic_baseline_edit_24.xml index 2844bafeb..d93cd57f7 100644 --- a/app/src/main/res/drawable/ic_baseline_edit_24.xml +++ b/app/src/main/res/drawable/ic_baseline_edit_24.xml @@ -2,9 +2,8 @@ android:width="24dp" android:height="24dp" android:viewportWidth="24" - android:viewportHeight="24" - android:tint="?attr/colorControlNormal"> - + android:viewportHeight="24"> + diff --git a/app/src/main/res/drawable/ic_baseline_emoji_objects_24.xml b/app/src/main/res/drawable/ic_baseline_emoji_objects_24.xml index c0f24afb3..96efb706a 100644 --- a/app/src/main/res/drawable/ic_baseline_emoji_objects_24.xml +++ b/app/src/main/res/drawable/ic_baseline_emoji_objects_24.xml @@ -2,9 +2,8 @@ android:width="24dp" android:height="24dp" android:viewportWidth="24" - android:viewportHeight="24" - android:tint="?attr/colorControlNormal"> + android:viewportHeight="24"> diff --git a/app/src/main/res/drawable/ic_baseline_emoji_people_24.xml b/app/src/main/res/drawable/ic_baseline_emoji_people_24.xml index c6f8a1b6a..cb83947e4 100644 --- a/app/src/main/res/drawable/ic_baseline_emoji_people_24.xml +++ b/app/src/main/res/drawable/ic_baseline_emoji_people_24.xml @@ -2,12 +2,11 @@ android:width="24dp" android:height="24dp" android:viewportWidth="24" - android:viewportHeight="24" - android:tint="?attr/colorControlNormal"> + android:viewportHeight="24"> diff --git a/app/src/main/res/drawable/ic_baseline_emoji_symbols_24.xml b/app/src/main/res/drawable/ic_baseline_emoji_symbols_24.xml index ac32e8164..ec67a52ca 100644 --- a/app/src/main/res/drawable/ic_baseline_emoji_symbols_24.xml +++ b/app/src/main/res/drawable/ic_baseline_emoji_symbols_24.xml @@ -2,27 +2,26 @@ android:width="24dp" android:height="24dp" android:viewportWidth="24" - android:viewportHeight="24" - android:tint="?attr/colorControlNormal"> + android:viewportHeight="24"> diff --git a/app/src/main/res/drawable/ic_baseline_expand_less_24.xml b/app/src/main/res/drawable/ic_baseline_expand_less_24.xml index 15501459e..10de30eb5 100644 --- a/app/src/main/res/drawable/ic_baseline_expand_less_24.xml +++ b/app/src/main/res/drawable/ic_baseline_expand_less_24.xml @@ -2,9 +2,8 @@ android:width="24dp" android:height="24dp" android:viewportWidth="24" - android:viewportHeight="24" - android:tint="?attr/colorControlNormal"> - + android:viewportHeight="24"> + diff --git a/app/src/main/res/drawable/ic_baseline_expand_more_24.xml b/app/src/main/res/drawable/ic_baseline_expand_more_24.xml index adc215c43..a39aaf016 100644 --- a/app/src/main/res/drawable/ic_baseline_expand_more_24.xml +++ b/app/src/main/res/drawable/ic_baseline_expand_more_24.xml @@ -2,9 +2,8 @@ android:width="24dp" android:height="24dp" android:viewportWidth="24" - android:viewportHeight="24" - android:tint="?attr/colorControlNormal"> - + android:viewportHeight="24"> + diff --git a/app/src/main/res/drawable/ic_baseline_extension_24.xml b/app/src/main/res/drawable/ic_baseline_extension_24.xml index 94055ba42..a186d13f7 100644 --- a/app/src/main/res/drawable/ic_baseline_extension_24.xml +++ b/app/src/main/res/drawable/ic_baseline_extension_24.xml @@ -2,9 +2,8 @@ android:width="24dp" android:height="24dp" android:viewportWidth="24" - android:viewportHeight="24" - android:tint="?attr/colorControlNormal"> - + android:viewportHeight="24"> + diff --git a/app/src/main/res/drawable/ic_baseline_first_page_24.xml b/app/src/main/res/drawable/ic_baseline_first_page_24.xml index 31f30ffe4..a07004188 100644 --- a/app/src/main/res/drawable/ic_baseline_first_page_24.xml +++ b/app/src/main/res/drawable/ic_baseline_first_page_24.xml @@ -2,9 +2,8 @@ android:width="24dp" android:height="24dp" android:viewportWidth="24" - android:viewportHeight="24" - android:tint="?attr/colorControlNormal"> - + android:viewportHeight="24"> + diff --git a/app/src/main/res/drawable/ic_baseline_flag_24.xml b/app/src/main/res/drawable/ic_baseline_flag_24.xml index 150ee8916..c06bf4077 100644 --- a/app/src/main/res/drawable/ic_baseline_flag_24.xml +++ b/app/src/main/res/drawable/ic_baseline_flag_24.xml @@ -2,9 +2,8 @@ android:width="24dp" android:height="24dp" android:viewportWidth="24" - android:viewportHeight="24" - android:tint="?attr/colorControlNormal"> + android:viewportHeight="24"> 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_flower_24.xml b/app/src/main/res/drawable/ic_baseline_flower_24.xml index 1f843b95a..d6c9eb19a 100644 --- a/app/src/main/res/drawable/ic_baseline_flower_24.xml +++ b/app/src/main/res/drawable/ic_baseline_flower_24.xml @@ -2,9 +2,8 @@ android:width="24dp" android:height="24dp" android:viewportWidth="24" - android:viewportHeight="24" - android:tint="?attr/colorControlNormal"> + android:viewportHeight="24"> diff --git a/app/src/main/res/drawable/ic_baseline_format_quote_24.xml b/app/src/main/res/drawable/ic_baseline_format_quote_24.xml index 705269282..eab1bfe51 100644 --- a/app/src/main/res/drawable/ic_baseline_format_quote_24.xml +++ b/app/src/main/res/drawable/ic_baseline_format_quote_24.xml @@ -2,9 +2,8 @@ android:width="24dp" android:height="24dp" android:viewportWidth="24" - android:viewportHeight="24" - android:tint="?attr/colorControlNormal"> - + android:viewportHeight="24"> + diff --git a/app/src/main/res/drawable/ic_baseline_info_24.xml b/app/src/main/res/drawable/ic_baseline_info_24.xml index 82f78a2d6..a6888b2d3 100644 --- a/app/src/main/res/drawable/ic_baseline_info_24.xml +++ b/app/src/main/res/drawable/ic_baseline_info_24.xml @@ -2,9 +2,8 @@ android:width="24dp" android:height="24dp" android:viewportWidth="24" - android:viewportHeight="24" - android:tint="?attr/colorControlNormal"> + android:viewportHeight="24"> diff --git a/app/src/main/res/drawable/ic_baseline_keyboard_24.xml b/app/src/main/res/drawable/ic_baseline_keyboard_24.xml index 533fc1562..7fd3a9cd6 100644 --- a/app/src/main/res/drawable/ic_baseline_keyboard_24.xml +++ b/app/src/main/res/drawable/ic_baseline_keyboard_24.xml @@ -2,9 +2,8 @@ android:width="24dp" android:height="24dp" android:viewportWidth="24" - android:viewportHeight="24" - android:tint="?attr/colorControlNormal"> - + android:viewportHeight="24"> + diff --git a/app/src/main/res/drawable/ic_baseline_keyboard_arrow_down_24.xml b/app/src/main/res/drawable/ic_baseline_keyboard_arrow_down_24.xml index 884bee144..f31ef83e4 100644 --- a/app/src/main/res/drawable/ic_baseline_keyboard_arrow_down_24.xml +++ b/app/src/main/res/drawable/ic_baseline_keyboard_arrow_down_24.xml @@ -2,9 +2,8 @@ android:width="24dp" android:height="24dp" android:viewportWidth="24" - android:viewportHeight="24" - android:tint="?attr/colorControlNormal"> - + android:viewportHeight="24"> + diff --git a/app/src/main/res/drawable/ic_baseline_keyboard_arrow_left_24.xml b/app/src/main/res/drawable/ic_baseline_keyboard_arrow_left_24.xml index 8bf031f64..173606f2c 100644 --- a/app/src/main/res/drawable/ic_baseline_keyboard_arrow_left_24.xml +++ b/app/src/main/res/drawable/ic_baseline_keyboard_arrow_left_24.xml @@ -2,9 +2,8 @@ android:width="24dp" android:height="24dp" android:viewportWidth="24" - android:viewportHeight="24" - android:tint="?attr/colorControlNormal"> - + android:viewportHeight="24"> + diff --git a/app/src/main/res/drawable/ic_baseline_keyboard_arrow_right_24.xml b/app/src/main/res/drawable/ic_baseline_keyboard_arrow_right_24.xml index d3d62595e..0727c0a1e 100644 --- a/app/src/main/res/drawable/ic_baseline_keyboard_arrow_right_24.xml +++ b/app/src/main/res/drawable/ic_baseline_keyboard_arrow_right_24.xml @@ -2,9 +2,8 @@ android:width="24dp" android:height="24dp" android:viewportWidth="24" - android:viewportHeight="24" - android:tint="?attr/colorControlNormal"> - + android:viewportHeight="24"> + diff --git a/app/src/main/res/drawable/ic_baseline_keyboard_arrow_up_24.xml b/app/src/main/res/drawable/ic_baseline_keyboard_arrow_up_24.xml index 9b15755e5..5c480efce 100644 --- a/app/src/main/res/drawable/ic_baseline_keyboard_arrow_up_24.xml +++ b/app/src/main/res/drawable/ic_baseline_keyboard_arrow_up_24.xml @@ -2,9 +2,8 @@ android:width="24dp" android:height="24dp" android:viewportWidth="24" - android:viewportHeight="24" - android:tint="?attr/colorControlNormal"> - + android:viewportHeight="24"> + diff --git a/app/src/main/res/drawable/ic_baseline_keyboard_return_24.xml b/app/src/main/res/drawable/ic_baseline_keyboard_return_24.xml index 509cf0064..194dfbf2c 100644 --- a/app/src/main/res/drawable/ic_baseline_keyboard_return_24.xml +++ b/app/src/main/res/drawable/ic_baseline_keyboard_return_24.xml @@ -2,9 +2,8 @@ android:width="24dp" android:height="24dp" android:viewportWidth="24" - android:viewportHeight="24" - android:tint="?android:attr/colorControlNormal"> - + android:viewportHeight="24"> + diff --git a/app/src/main/res/drawable/ic_baseline_keyboard_tab_24.xml b/app/src/main/res/drawable/ic_baseline_keyboard_tab_24.xml index 1441707b7..f1ffc9806 100644 --- a/app/src/main/res/drawable/ic_baseline_keyboard_tab_24.xml +++ b/app/src/main/res/drawable/ic_baseline_keyboard_tab_24.xml @@ -2,9 +2,8 @@ android:width="24dp" android:height="24dp" android:viewportWidth="24" - android:viewportHeight="24" - android:tint="?attr/colorControlNormal"> - + android:viewportHeight="24"> + diff --git a/app/src/main/res/drawable/ic_baseline_keyboard_tab_reverse_24.xml b/app/src/main/res/drawable/ic_baseline_keyboard_tab_reverse_24.xml index 7af886cef..5d346f613 100644 --- a/app/src/main/res/drawable/ic_baseline_keyboard_tab_reverse_24.xml +++ b/app/src/main/res/drawable/ic_baseline_keyboard_tab_reverse_24.xml @@ -2,9 +2,8 @@ android:width="24dp" android:height="24dp" android:viewportWidth="24" - android:viewportHeight="24" - android:tint="?attr/colorControlNormal"> - + android:viewportHeight="24"> + diff --git a/app/src/main/res/drawable/ic_baseline_keyboard_voice_24.xml b/app/src/main/res/drawable/ic_baseline_keyboard_voice_24.xml index 022f31cab..267f2654b 100644 --- a/app/src/main/res/drawable/ic_baseline_keyboard_voice_24.xml +++ b/app/src/main/res/drawable/ic_baseline_keyboard_voice_24.xml @@ -1,10 +1,9 @@ diff --git a/app/src/main/res/drawable/ic_baseline_language_24.xml b/app/src/main/res/drawable/ic_baseline_language_24.xml index 3f70646ba..5e28f4931 100644 --- a/app/src/main/res/drawable/ic_baseline_language_24.xml +++ b/app/src/main/res/drawable/ic_baseline_language_24.xml @@ -2,9 +2,8 @@ android:width="24dp" android:height="24dp" android:viewportWidth="24" - android:viewportHeight="24" - android:tint="?attr/colorControlNormal"> - + android:viewportHeight="24"> + diff --git a/app/src/main/res/drawable/ic_baseline_last_page_24.xml b/app/src/main/res/drawable/ic_baseline_last_page_24.xml index 32b69adb1..4d9d657c3 100644 --- a/app/src/main/res/drawable/ic_baseline_last_page_24.xml +++ b/app/src/main/res/drawable/ic_baseline_last_page_24.xml @@ -2,9 +2,8 @@ android:width="24dp" android:height="24dp" android:viewportWidth="24" - android:viewportHeight="24" - android:tint="?attr/colorControlNormal"> - + android:viewportHeight="24"> + diff --git a/app/src/main/res/drawable/ic_baseline_library_books_24.xml b/app/src/main/res/drawable/ic_baseline_library_books_24.xml index acebe2be2..b376b73f5 100644 --- a/app/src/main/res/drawable/ic_baseline_library_books_24.xml +++ b/app/src/main/res/drawable/ic_baseline_library_books_24.xml @@ -2,10 +2,8 @@ android:width="24dp" android:height="24dp" android:viewportWidth="24" - android:viewportHeight="24" - android:tint="?attr/colorControlNormal" - android:autoMirrored="true"> - + android:viewportHeight="24"> + 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 new file mode 100644 index 000000000..210ccaba0 --- /dev/null +++ b/app/src/main/res/drawable/ic_baseline_light_mode_24.xml @@ -0,0 +1,9 @@ + + + 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_more_horiz_24.xml b/app/src/main/res/drawable/ic_baseline_more_horiz_24.xml index 6439bcc7c..3ee36a60d 100644 --- a/app/src/main/res/drawable/ic_baseline_more_horiz_24.xml +++ b/app/src/main/res/drawable/ic_baseline_more_horiz_24.xml @@ -2,9 +2,8 @@ android:width="24dp" android:height="24dp" android:viewportWidth="24" - android:viewportHeight="24" - android:tint="?attr/colorControlNormal"> - + android:viewportHeight="24"> + diff --git a/app/src/main/res/drawable/ic_baseline_palette_24.xml b/app/src/main/res/drawable/ic_baseline_palette_24.xml index 0d97f0315..ad803bbdb 100644 --- a/app/src/main/res/drawable/ic_baseline_palette_24.xml +++ b/app/src/main/res/drawable/ic_baseline_palette_24.xml @@ -2,9 +2,8 @@ android:width="24dp" android:height="24dp" android:viewportWidth="24" - android:viewportHeight="24" - android:tint="?attr/colorControlNormal"> - + android:viewportHeight="24"> + diff --git a/app/src/main/res/drawable/ic_baseline_plus_24.xml b/app/src/main/res/drawable/ic_baseline_plus_24.xml index 5387bd227..44d5b5065 100644 --- a/app/src/main/res/drawable/ic_baseline_plus_24.xml +++ b/app/src/main/res/drawable/ic_baseline_plus_24.xml @@ -1,10 +1,9 @@ \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_baseline_push_pin_24.xml b/app/src/main/res/drawable/ic_baseline_push_pin_24.xml index f1e14a868..7ac28eca3 100644 --- a/app/src/main/res/drawable/ic_baseline_push_pin_24.xml +++ b/app/src/main/res/drawable/ic_baseline_push_pin_24.xml @@ -2,10 +2,8 @@ android:width="24dp" android:height="24dp" android:viewportWidth="24" - android:viewportHeight="24" - android:tint="?attr/colorControlNormal"> - + android:viewportHeight="24"> + diff --git a/app/src/main/res/drawable/ic_baseline_redo_24.xml b/app/src/main/res/drawable/ic_baseline_redo_24.xml index 4e218168e..ff18616d3 100644 --- a/app/src/main/res/drawable/ic_baseline_redo_24.xml +++ b/app/src/main/res/drawable/ic_baseline_redo_24.xml @@ -1,11 +1,10 @@ - + android:viewportHeight="24"> + 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_save_24.xml b/app/src/main/res/drawable/ic_baseline_save_24.xml index 1a8d86d20..7db25d84d 100644 --- a/app/src/main/res/drawable/ic_baseline_save_24.xml +++ b/app/src/main/res/drawable/ic_baseline_save_24.xml @@ -2,9 +2,8 @@ android:width="24dp" android:height="24dp" android:viewportWidth="24" - android:viewportHeight="24" - android:tint="?attr/colorControlNormal"> - + android:viewportHeight="24"> + diff --git a/app/src/main/res/drawable/ic_baseline_search_24.xml b/app/src/main/res/drawable/ic_baseline_search_24.xml index 07b76d627..c75a5e7a3 100644 --- a/app/src/main/res/drawable/ic_baseline_search_24.xml +++ b/app/src/main/res/drawable/ic_baseline_search_24.xml @@ -2,9 +2,8 @@ android:width="24dp" android:height="24dp" android:viewportWidth="24" - android:viewportHeight="24" - android:tint="?attr/colorControlNormal"> - + android:viewportHeight="24"> + diff --git a/app/src/main/res/drawable/ic_baseline_send_24.xml b/app/src/main/res/drawable/ic_baseline_send_24.xml index f0d63e179..54f9dac6c 100644 --- a/app/src/main/res/drawable/ic_baseline_send_24.xml +++ b/app/src/main/res/drawable/ic_baseline_send_24.xml @@ -2,10 +2,8 @@ android:width="24dp" android:height="24dp" android:viewportWidth="24" - android:viewportHeight="24" - android:tint="?attr/colorControlNormal" - android:autoMirrored="true"> - + android:viewportHeight="24"> + diff --git a/app/src/main/res/drawable/ic_baseline_settings_24.xml b/app/src/main/res/drawable/ic_baseline_settings_24.xml index 41a82ede8..8cc3d19a0 100644 --- a/app/src/main/res/drawable/ic_baseline_settings_24.xml +++ b/app/src/main/res/drawable/ic_baseline_settings_24.xml @@ -2,9 +2,8 @@ android:width="24dp" android:height="24dp" android:viewportWidth="24" - android:viewportHeight="24" - android:tint="?attr/colorControlNormal"> - + android:viewportHeight="24"> + diff --git a/app/src/main/res/drawable/ic_baseline_settings_backup_restore_24.xml b/app/src/main/res/drawable/ic_baseline_settings_backup_restore_24.xml index 1772ed506..d2aab60e5 100644 --- a/app/src/main/res/drawable/ic_baseline_settings_backup_restore_24.xml +++ b/app/src/main/res/drawable/ic_baseline_settings_backup_restore_24.xml @@ -2,9 +2,8 @@ android:width="24dp" android:height="24dp" android:viewportWidth="24" - android:viewportHeight="24" - android:tint="?attr/colorControlNormal"> - + android:viewportHeight="24"> + 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/drawable/ic_baseline_space_bar_24.xml b/app/src/main/res/drawable/ic_baseline_space_bar_24.xml index 9dc510a38..e77442136 100644 --- a/app/src/main/res/drawable/ic_baseline_space_bar_24.xml +++ b/app/src/main/res/drawable/ic_baseline_space_bar_24.xml @@ -2,9 +2,8 @@ android:width="24dp" android:height="24dp" android:viewportWidth="24" - android:viewportHeight="24" - android:tint="?attr/colorControlNormal"> - + android:viewportHeight="24"> + diff --git a/app/src/main/res/drawable/ic_baseline_spellcheck_24.xml b/app/src/main/res/drawable/ic_baseline_spellcheck_24.xml index 7da40f83a..db67ac8b6 100644 --- a/app/src/main/res/drawable/ic_baseline_spellcheck_24.xml +++ b/app/src/main/res/drawable/ic_baseline_spellcheck_24.xml @@ -2,9 +2,8 @@ android:width="24dp" android:height="24dp" android:viewportWidth="24" - android:viewportHeight="24" - android:tint="?android:attr/colorControlNormal"> + android:viewportHeight="24"> diff --git a/app/src/main/res/drawable/ic_baseline_sports_basketball_24.xml b/app/src/main/res/drawable/ic_baseline_sports_basketball_24.xml index e50e8b640..b22f6c655 100644 --- a/app/src/main/res/drawable/ic_baseline_sports_basketball_24.xml +++ b/app/src/main/res/drawable/ic_baseline_sports_basketball_24.xml @@ -2,30 +2,29 @@ android:width="24dp" android:height="24dp" android:viewportWidth="24" - android:viewportHeight="24" - android:tint="?attr/colorControlNormal"> + android:viewportHeight="24"> diff --git a/app/src/main/res/drawable/ic_baseline_sync_24.xml b/app/src/main/res/drawable/ic_baseline_sync_24.xml index c2f773a17..7ab03e8f9 100644 --- a/app/src/main/res/drawable/ic_baseline_sync_24.xml +++ b/app/src/main/res/drawable/ic_baseline_sync_24.xml @@ -2,9 +2,8 @@ android:width="24dp" android:height="24dp" android:viewportWidth="24" - android:viewportHeight="24" - android:tint="?attr/colorControlNormal"> - + android:viewportHeight="24"> + diff --git a/app/src/main/res/drawable/ic_baseline_tag_faces_24.xml b/app/src/main/res/drawable/ic_baseline_tag_faces_24.xml index 4797e384e..e6a19d030 100644 --- a/app/src/main/res/drawable/ic_baseline_tag_faces_24.xml +++ b/app/src/main/res/drawable/ic_baseline_tag_faces_24.xml @@ -2,9 +2,8 @@ android:width="24dp" android:height="24dp" android:viewportWidth="24" - android:viewportHeight="24" - android:tint="?attr/colorControlNormal"> + android:viewportHeight="24"> diff --git a/app/src/main/res/drawable/ic_baseline_text_format_24.xml b/app/src/main/res/drawable/ic_baseline_text_format_24.xml new file mode 100644 index 000000000..fea0bb4b8 --- /dev/null +++ b/app/src/main/res/drawable/ic_baseline_text_format_24.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_baseline_tune_24.xml b/app/src/main/res/drawable/ic_baseline_tune_24.xml index e8d34f142..f413b3ab5 100644 --- a/app/src/main/res/drawable/ic_baseline_tune_24.xml +++ b/app/src/main/res/drawable/ic_baseline_tune_24.xml @@ -2,9 +2,8 @@ android:width="24dp" android:height="24dp" android:viewportWidth="24" - android:viewportHeight="24" - android:tint="?attr/colorControlNormal"> - + android:viewportHeight="24"> + diff --git a/app/src/main/res/drawable/ic_baseline_undo_24.xml b/app/src/main/res/drawable/ic_baseline_undo_24.xml index 4ce410880..4e89155a4 100644 --- a/app/src/main/res/drawable/ic_baseline_undo_24.xml +++ b/app/src/main/res/drawable/ic_baseline_undo_24.xml @@ -1,11 +1,10 @@ - + android:viewportHeight="24"> + diff --git a/app/src/main/res/drawable/ic_capslock_lock.xml b/app/src/main/res/drawable/ic_capslock_lock.xml index a0445b41c..9a4a8d631 100644 --- a/app/src/main/res/drawable/ic_capslock_lock.xml +++ b/app/src/main/res/drawable/ic_capslock_lock.xml @@ -2,12 +2,11 @@ android:width="24dp" android:height="24dp" android:viewportWidth="24" - android:viewportHeight="24" - android:tint="?attr/colorControlNormal"> - - + android:viewportHeight="24"> + + diff --git a/app/src/main/res/drawable/ic_capslock_none.xml b/app/src/main/res/drawable/ic_capslock_none.xml index 9eacda5c2..e217bb4c6 100644 --- a/app/src/main/res/drawable/ic_capslock_none.xml +++ b/app/src/main/res/drawable/ic_capslock_none.xml @@ -2,9 +2,8 @@ android:width="24dp" android:height="24dp" android:viewportWidth="24" - android:viewportHeight="24" - android:tint="?attr/colorControlNormal"> - + android:viewportHeight="24"> + diff --git a/app/src/main/res/drawable/ic_capslock_once.xml b/app/src/main/res/drawable/ic_capslock_once.xml index b7ece0166..8f0494315 100644 --- a/app/src/main/res/drawable/ic_capslock_once.xml +++ b/app/src/main/res/drawable/ic_capslock_once.xml @@ -2,9 +2,8 @@ android:width="24dp" android:height="24dp" android:viewportWidth="24" - android:viewportHeight="24" - android:tint="?attr/colorControlNormal"> - + android:viewportHeight="24"> + diff --git a/app/src/main/res/drawable/ic_clipboard.xml b/app/src/main/res/drawable/ic_clipboard.xml index bf34c073d..4b98b511b 100644 --- a/app/src/main/res/drawable/ic_clipboard.xml +++ b/app/src/main/res/drawable/ic_clipboard.xml @@ -2,9 +2,8 @@ android:width="24dp" android:height="24dp" android:viewportWidth="24" - android:viewportHeight="24" - android:tint="?attr/colorControlNormal"> - + android:viewportHeight="24"> + diff --git a/app/src/main/res/drawable/ic_cursor_move.xml b/app/src/main/res/drawable/ic_cursor_move.xml index bbe47e080..8a1a6aa2d 100644 --- a/app/src/main/res/drawable/ic_cursor_move.xml +++ b/app/src/main/res/drawable/ic_cursor_move.xml @@ -2,15 +2,14 @@ android:width="24dp" android:height="24dp" android:viewportWidth="24" - android:viewportHeight="24" - android:tint="?attr/colorControlNormal"> - - - + android:viewportHeight="24"> + + + diff --git a/app/src/main/res/drawable/ic_fcitx_status_chttrans_simp.xml b/app/src/main/res/drawable/ic_fcitx_status_chttrans_simp.xml index 331561354..95c6ed5e9 100644 --- a/app/src/main/res/drawable/ic_fcitx_status_chttrans_simp.xml +++ b/app/src/main/res/drawable/ic_fcitx_status_chttrans_simp.xml @@ -2,9 +2,8 @@ android:width="24dp" android:height="24dp" android:viewportWidth="24" - android:viewportHeight="24" - android:tint="?attr/colorControlNormal"> - + android:viewportHeight="24"> + diff --git a/app/src/main/res/drawable/ic_fcitx_status_chttrans_trad.xml b/app/src/main/res/drawable/ic_fcitx_status_chttrans_trad.xml index fc0f64b26..8e4e8d7d6 100644 --- a/app/src/main/res/drawable/ic_fcitx_status_chttrans_trad.xml +++ b/app/src/main/res/drawable/ic_fcitx_status_chttrans_trad.xml @@ -2,9 +2,8 @@ android:width="24dp" android:height="24dp" android:viewportWidth="24" - android:viewportHeight="24" - android:tint="?attr/colorControlNormal"> - + android:viewportHeight="24"> + diff --git a/app/src/main/res/drawable/ic_fcitx_status_fullwidth_active.xml b/app/src/main/res/drawable/ic_fcitx_status_fullwidth_active.xml index fd2756c20..f82da83ff 100644 --- a/app/src/main/res/drawable/ic_fcitx_status_fullwidth_active.xml +++ b/app/src/main/res/drawable/ic_fcitx_status_fullwidth_active.xml @@ -2,9 +2,8 @@ android:width="24dp" android:height="24dp" android:viewportWidth="24" - android:viewportHeight="24" - android:tint="?attr/colorControlNormal"> - + android:viewportHeight="24"> + diff --git a/app/src/main/res/drawable/ic_fcitx_status_fullwidth_inactive.xml b/app/src/main/res/drawable/ic_fcitx_status_fullwidth_inactive.xml index ebd4c0a8b..d71737741 100644 --- a/app/src/main/res/drawable/ic_fcitx_status_fullwidth_inactive.xml +++ b/app/src/main/res/drawable/ic_fcitx_status_fullwidth_inactive.xml @@ -2,9 +2,8 @@ android:width="24dp" android:height="24dp" android:viewportWidth="24" - android:viewportHeight="24" - android:tint="?attr/colorControlNormal"> - + android:viewportHeight="24"> + diff --git a/app/src/main/res/drawable/ic_fcitx_status_prediction_active.xml b/app/src/main/res/drawable/ic_fcitx_status_prediction_active.xml index 8b7f1e243..0070f5f65 100644 --- a/app/src/main/res/drawable/ic_fcitx_status_prediction_active.xml +++ b/app/src/main/res/drawable/ic_fcitx_status_prediction_active.xml @@ -2,9 +2,8 @@ android:width="24dp" android:height="24dp" android:viewportWidth="24" - android:viewportHeight="24" - android:tint="?attr/colorControlNormal"> - + android:viewportHeight="24"> + diff --git a/app/src/main/res/drawable/ic_fcitx_status_prediction_inactive.xml b/app/src/main/res/drawable/ic_fcitx_status_prediction_inactive.xml index e934defbf..73dbf418a 100644 --- a/app/src/main/res/drawable/ic_fcitx_status_prediction_inactive.xml +++ b/app/src/main/res/drawable/ic_fcitx_status_prediction_inactive.xml @@ -2,9 +2,8 @@ android:width="24dp" android:height="24dp" android:viewportWidth="24" - android:viewportHeight="24" - android:tint="?attr/colorControlNormal"> - + android:viewportHeight="24"> + diff --git a/app/src/main/res/drawable/ic_fcitx_status_punc_active.xml b/app/src/main/res/drawable/ic_fcitx_status_punc_active.xml index ab7b0c30d..47bdc20b4 100644 --- a/app/src/main/res/drawable/ic_fcitx_status_punc_active.xml +++ b/app/src/main/res/drawable/ic_fcitx_status_punc_active.xml @@ -2,9 +2,8 @@ android:width="24dp" android:height="24dp" android:viewportWidth="24" - android:viewportHeight="24" - android:tint="?attr/colorControlNormal"> - + android:viewportHeight="24"> + diff --git a/app/src/main/res/drawable/ic_fcitx_status_punc_inactive.xml b/app/src/main/res/drawable/ic_fcitx_status_punc_inactive.xml index 7d37388a0..fc2e05b02 100644 --- a/app/src/main/res/drawable/ic_fcitx_status_punc_inactive.xml +++ b/app/src/main/res/drawable/ic_fcitx_status_punc_inactive.xml @@ -2,9 +2,8 @@ android:width="24dp" android:height="24dp" android:viewportWidth="24" - android:viewportHeight="24" - android:tint="?attr/colorControlNormal"> - + android:viewportHeight="24"> + diff --git a/app/src/main/res/drawable/ic_launcher_background.xml b/app/src/main/res/drawable/ic_launcher_background.xml index 385ec3f1a..73657a076 100644 --- a/app/src/main/res/drawable/ic_launcher_background.xml +++ b/app/src/main/res/drawable/ic_launcher_background.xml @@ -4,45 +4,46 @@ android:height="108dp" android:viewportWidth="108" android:viewportHeight="108"> - - - - - - - - - - - - - - + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_logo_unicode.xml b/app/src/main/res/drawable/ic_logo_unicode.xml index c69e4d3a5..7297c072a 100644 --- a/app/src/main/res/drawable/ic_logo_unicode.xml +++ b/app/src/main/res/drawable/ic_logo_unicode.xml @@ -2,9 +2,8 @@ android:width="24dp" android:height="24dp" android:viewportWidth="24" - android:viewportHeight="24" - android:tint="?attr/colorControlNormal"> - + android:viewportHeight="24"> + diff --git a/app/src/main/res/drawable/ic_number_pad.xml b/app/src/main/res/drawable/ic_number_pad.xml index 9ca81aa24..c2d7c9d98 100644 --- a/app/src/main/res/drawable/ic_number_pad.xml +++ b/app/src/main/res/drawable/ic_number_pad.xml @@ -2,18 +2,17 @@ android:width="24dp" android:height="24dp" android:viewportWidth="24" - android:viewportHeight="24" - android:tint="?attr/colorControlNormal"> - - - - + android:viewportHeight="24"> + + + + diff --git a/app/src/main/res/drawable/ic_outline_push_pin_24.xml b/app/src/main/res/drawable/ic_outline_push_pin_24.xml index b1075975b..9f6f2b6f1 100644 --- a/app/src/main/res/drawable/ic_outline_push_pin_24.xml +++ b/app/src/main/res/drawable/ic_outline_push_pin_24.xml @@ -2,9 +2,8 @@ android:width="24dp" android:height="24dp" android:viewportWidth="24" - android:viewportHeight="24" - android:tint="?attr/colorControlNormal"> - + android:viewportHeight="24"> + diff --git a/app/src/main/res/drawable/ic_view_private.xml b/app/src/main/res/drawable/ic_view_private.xml index c730ea363..1fff3a27d 100644 --- a/app/src/main/res/drawable/ic_view_private.xml +++ b/app/src/main/res/drawable/ic_view_private.xml @@ -2,12 +2,11 @@ android:width="24dp" android:height="24dp" android:viewportWidth="24" - android:viewportHeight="24" - android:tint="?attr/colorControlNormal"> - - + android:viewportHeight="24"> + + diff --git a/app/src/main/res/drawable/symbol_arrow.xml b/app/src/main/res/drawable/symbol_arrow.xml index 27e3d430f..ec2ba08bd 100644 --- a/app/src/main/res/drawable/symbol_arrow.xml +++ b/app/src/main/res/drawable/symbol_arrow.xml @@ -2,9 +2,8 @@ android:width="24dp" android:height="24dp" android:viewportWidth="24" - android:viewportHeight="24" - android:tint="?attr/colorControlNormal"> + android:viewportHeight="24"> diff --git a/app/src/main/res/drawable/symbol_bracket.xml b/app/src/main/res/drawable/symbol_bracket.xml index ea21982c4..0e8b7485a 100644 --- a/app/src/main/res/drawable/symbol_bracket.xml +++ b/app/src/main/res/drawable/symbol_bracket.xml @@ -2,12 +2,11 @@ android:width="24dp" android:height="24dp" android:viewportWidth="24" - android:viewportHeight="24" - android:tint="?attr/colorControlNormal"> - - + android:viewportHeight="24"> + + diff --git a/app/src/main/res/drawable/symbol_fullwidth.xml b/app/src/main/res/drawable/symbol_fullwidth.xml index 9e0432278..e240fb6a0 100644 --- a/app/src/main/res/drawable/symbol_fullwidth.xml +++ b/app/src/main/res/drawable/symbol_fullwidth.xml @@ -2,15 +2,14 @@ android:width="24dp" android:height="24dp" android:viewportWidth="24" - android:viewportHeight="24" - android:tint="?attr/colorControlNormal"> - - - + android:viewportHeight="24"> + + + diff --git a/app/src/main/res/drawable/symbol_math.xml b/app/src/main/res/drawable/symbol_math.xml index ffbce9679..798e87446 100644 --- a/app/src/main/res/drawable/symbol_math.xml +++ b/app/src/main/res/drawable/symbol_math.xml @@ -2,18 +2,17 @@ android:width="24dp" android:height="24dp" android:viewportWidth="24" - android:viewportHeight="24" - android:tint="?attr/colorControlNormal"> + android:viewportHeight="24"> diff --git a/app/src/main/res/drawable/symbol_number_punctuation.xml b/app/src/main/res/drawable/symbol_number_punctuation.xml index 96087558f..1d7e080bc 100644 --- a/app/src/main/res/drawable/symbol_number_punctuation.xml +++ b/app/src/main/res/drawable/symbol_number_punctuation.xml @@ -2,18 +2,17 @@ android:width="24dp" android:height="24dp" android:viewportWidth="24" - android:viewportHeight="24" - android:tint="?attr/colorControlNormal"> - - - - + android:viewportHeight="24"> + + + + diff --git a/app/src/main/res/drawable/symbol_ordinal.xml b/app/src/main/res/drawable/symbol_ordinal.xml index 33842201e..198b6c05f 100644 --- a/app/src/main/res/drawable/symbol_ordinal.xml +++ b/app/src/main/res/drawable/symbol_ordinal.xml @@ -2,12 +2,11 @@ android:width="24dp" android:height="24dp" android:viewportWidth="24" - android:viewportHeight="24" - android:tint="?attr/colorControlNormal"> - - + android:viewportHeight="24"> + + diff --git a/app/src/main/res/drawable/symbol_other.xml b/app/src/main/res/drawable/symbol_other.xml index a2f8b066b..2b83930e7 100644 --- a/app/src/main/res/drawable/symbol_other.xml +++ b/app/src/main/res/drawable/symbol_other.xml @@ -2,9 +2,8 @@ android:width="24dp" android:height="24dp" android:viewportWidth="24" - android:viewportHeight="24" - android:tint="?attr/colorControlNormal"> + android:viewportHeight="24"> 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/layout/activity_setup.xml b/app/src/main/res/layout/activity_setup.xml index 654961078..4c8eee7aa 100644 --- a/app/src/main/res/layout/activity_setup.xml +++ b/app/src/main/res/layout/activity_setup.xml @@ -5,34 +5,38 @@ android:layout_width="match_parent" android:layout_height="match_parent" tools:context=".ui.setup.SetupActivity"> + + app:layout_constraintTop_toTopOf="parent" /> +