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/build.yml b/.github/workflows/build.yml deleted file mode 100644 index 8bc77bcbd..000000000 --- a/.github/workflows/build.yml +++ /dev/null @@ -1,47 +0,0 @@ -name: Build - -on: - pull_request: - -jobs: - Release: - runs-on: ubuntu-22.04 - strategy: - matrix: - abi: - - armeabi-v7a - - arm64-v8a - - x86 - - x86_64 - env: - ABI: ${{ matrix.abi }} - steps: - - name: Fetch source code - uses: actions/checkout@v3 - with: - fetch-depth: 0 - submodules: recursive - - - name: Setup Java - uses: actions/setup-java@v3 - with: - distribution: "temurin" - java-version: "17" - - - name: Setup Android Environment - uses: android-actions/setup-android@v2 - - - name: Install Android NDK - run: | - sdkmanager --install "cmake;3.22.1" - - - name: Install system dependencies - run: | - sudo apt update - sudo apt install extra-cmake-modules gettext - - - name: Setup Gradle - uses: gradle/gradle-build-action@v2 - - - name: Build Release APK - run: ./gradlew assembleRelease --no-daemon diff --git a/.github/workflows/fdroid.yml b/.github/workflows/fdroid.yml new file mode 100644 index 000000000..8d612b32e --- /dev/null +++ b/.github/workflows/fdroid.yml @@ -0,0 +1,124 @@ +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: + run: + shell: bash + +jobs: + fdroid-build: + runs-on: ubuntu-24.04 + container: registry.gitlab.com/fdroid/fdroidserver:buildserver-bookworm + strategy: + matrix: + abi: + - armeabi-v7a + - arm64-v8a + - x86 + - x86_64 + fail-fast: false + steps: + - name: Fetch fdroiddata + uses: actions/checkout@v4 + with: + repository: f-droid/fdroiddata + + - name: Fetch fdroidserver + uses: actions/checkout@v4 + with: + repository: f-droid/fdroidserver + path: fdroidserver + + - name: Setup fdroidserver + run: | + source /etc/profile.d/bsenv.sh + rm -rf $fdroidserver + mv $GITHUB_WORKSPACE/fdroidserver $fdroidserver + + rm -rf $ANDROID_HOME/tools + sdkmanager "tools" "platform-tools" + for d in logs tmp unsigned $home_vagrant/metadata $home_vagrant/.android $home_vagrant/.gradle $ANDROID_HOME; do + test -d $d || mkdir $d; + chown -R vagrant $d; + done + ln -s $home_vagrant/.gradle $GITHUB_WORKSPACE/.gradle + ln -s $GITHUB_WORKSPACE/tmp $home_vagrant/tmp + ln -s $GITHUB_WORKSPACE/srclibs $home_vagrant/srclibs + mv $GITHUB_WORKSPACE/build $home_vagrant/build + ln -s $home_vagrant/build $GITHUB_WORKSPACE/build + chown -R vagrant $GITHUB_WORKSPACE + + - name: Build + env: + BUILD_NUMBER: ${{ inputs.build_number || 'lastSuccessfulBuild' }} + run: | + set -x + # prevent prebuilder from writing to build summary file + unset GITHUB_ACTIONS GITHUB_STEP_SUMMARY + curl -Lo /usr/bin/yq "https://github.com/mikefarah/yq/releases/latest/download/yq_linux_amd64" + chmod +x /usr/bin/yq + build_metadata=$(curl "https://jenkins.fcitx-im.org/job/android/job/fcitx5-android/$BUILD_NUMBER/artifact/out/build-metadata.json") + versionName=$(echo $build_metadata | yq ".versionName") + commitHash=$(echo $build_metadata | yq ".commitHash") + timestamp=$(echo $build_metadata | yq ".timestamp") + 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=$(($baseVersionCode * 10 + $i)) + + source /etc/profile.d/bsenv.sh + 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 + + fdroid="sudo --preserve-env --user vagrant + env HOME=$home_vagrant + PYTHONPATH=$fdroidserver:$fdroidserver/examples + PYTHONUNBUFFERED=true + GRADLE_USER_HOME=$home_vagrant/.gradle + $fdroidserver/fdroid" + + 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@v4 + if: ${{ success() || failure() }} + with: + name: fdroid-${{ matrix.abi }} + path: tmp/ diff --git a/.github/workflows/nix.yml b/.github/workflows/nix.yml new file mode 100644 index 000000000..01968ac76 --- /dev/null +++ b/.github/workflows/nix.yml @@ -0,0 +1,27 @@ +name: Nix + +on: + pull_request: + push: + branches: [master] + +jobs: + develop: + runs-on: ubuntu-24.04 + steps: + - name: Fetch source code + uses: actions/checkout@v4 + with: + fetch-depth: 0 + submodules: recursive + - uses: cachix/install-nix-action@v31 + with: + github_access_token: ${{ secrets.GITHUB_TOKEN }} + - uses: cachix/cachix-action@v16 + with: + name: fcitx5-android + authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}" + - name: Build Release APK + run: | + 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 new file mode 100644 index 000000000..dcc86c2e1 --- /dev/null +++ b/.github/workflows/pull_request.yml @@ -0,0 +1,90 @@ +name: Pull Request + +on: + pull_request: + types: + - opened + - reopened + # pull request's head branch was updated + - synchronize + +jobs: + build_pull_request: + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: + - ubuntu-24.04 + - macos-13 + - macos-14 + - windows-2022 + steps: + - name: Fetch source code + uses: actions/checkout@v4 + with: + fetch-depth: 0 + submodules: recursive + + - name: Setup Java + uses: actions/setup-java@v4 + with: + distribution: "temurin" + java-version: "17" + + - name: Setup Android environment + uses: android-actions/setup-android@v3 + + - name: Install Android NDK + run: | + sdkmanager --install "cmake;3.31.6" + + - 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/actions/setup-gradle@v4 + + - name: Build Release APK + run: | + ./gradlew :app:assembleRelease + ./gradlew :assembleReleasePlugins + + - name: Upload app + uses: actions/upload-artifact@v4 + with: + 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/release" "plugins-to-upload/${i}" + fi + done + + - name: Upload plugins + uses: actions/upload-artifact@v4 + with: + name: plugins-${{ matrix.os }} + path: plugins-to-upload diff --git a/.gitignore b/.gitignore index 41db9b36b..0bd6d8615 100644 --- a/.gitignore +++ b/.gitignore @@ -1,26 +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 -# Generated license json -/app/src/main/assets/licenses.json + +# Plugins +# Installed data +/plugin/*/src/main/assets/usr/ +# Generated asset descriptor +/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 @@ -105,3 +99,6 @@ lint/tmp/ # Android Profiling *.hprof + +### Kotlin ### +.kotlin/ diff --git a/.gitmodules b/.gitmodules index d4b9412ca..2f3d38b6d 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,18 +1,58 @@ -[submodule "app/src/main/cpp/fcitx5"] - path = app/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 -[submodule "app/src/main/cpp/prebuilt"] - path = app/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 +[submodule "lib/fcitx5/src/main/cpp/fcitx5"] + path = lib/fcitx5/src/main/cpp/fcitx5 + url = https://github.com/fcitx/fcitx5.git +[submodule "lib/fcitx5/src/main/cpp/prebuilt"] + path = lib/fcitx5/src/main/cpp/prebuilt + 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 = 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 = 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 c31322978..cf0517db7 100644 --- a/.idea/codeStyles/Project.xml +++ b/.idea/codeStyles/Project.xml @@ -1,6 +1,42 @@ + + + + diff --git a/.idea/copyright/fcitx5_android.xml b/.idea/copyright/fcitx5_android.xml new file mode 100644 index 000000000..cdcc660a6 --- /dev/null +++ b/.idea/copyright/fcitx5_android.xml @@ -0,0 +1,7 @@ + + + + \ No newline at end of file diff --git a/.idea/copyright/profiles_settings.xml b/.idea/copyright/profiles_settings.xml new file mode 100644 index 000000000..08f4594e4 --- /dev/null +++ b/.idea/copyright/profiles_settings.xml @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/.idea/dictionaries/project.xml b/.idea/dictionaries/project.xml index 88418956f..fa009839e 100644 --- a/.idea/dictionaries/project.xml +++ b/.idea/dictionaries/project.xml @@ -3,18 +3,26 @@ androidfrontend androidkeyboard + androidnotification berberman + bopomofo constraintlayout cout + customphrase endl fcitx fmtlib icuuid + inputmethodservice + iostreams iter jbytes jnicall jobject jstring + jyutping + kawaii + keypress lgpl libevent libime @@ -25,13 +33,19 @@ pkgdatadir preedit quickphrase + sayura sdkmanager setenv shijienihao shuangpin + sinhala + snackbar spdx stringutils unfocus + unikey + zhuyin + zlib \ 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 9b777b35b..000000000 --- a/.idea/modules/fcitx5-android.iml +++ /dev/null @@ -1,13 +0,0 @@ - - - - - - - - - - - - - \ No newline at end of file diff --git a/Jenkinsfile b/Jenkinsfile deleted file mode 100644 index 36c3d7494..000000000 --- a/Jenkinsfile +++ /dev/null @@ -1,146 +0,0 @@ -import groovy.transform.Field - -@Field -def abiList = ['armeabi-v7a', 'arm64-v8a', 'x86', 'x86_64'] - -@Field -def commitSha = "" - -def setBuildStatus(String message, String state, String ctx, String commitSha) { - withCredentials([string(credentialsId: 'github-commit-status-token', variable: 'token')]) { - def body = """{ - "state": "$state", - "description": "$message", - "context": "$ctx", - "target_url": "$BUILD_URL" - } - """.toString() - httpRequest consoleLogResponseBody: true, - contentType: 'APPLICATION_JSON', - httpMode: 'POST', - requestBody: body, - url: "https://api.github.com/repos/fcitx5-android/fcitx5-android/statuses/$commitSha".toString(), - validResponseCodes: '201', - customHeaders: [[name: 'Authorization', value: "token " + token]] - } -} - -def sendMessageToTelegramGroup(String message) { - withCredentials([string(credentialsId: 'fcitx5-android-telegram-group', variable: 'chatId'), - string(credentialsId: 'fcitx5-android-telegram-bot', variable: 'token')]) { - def body = """{ - "chat_id": $chatId, - "text": "${message.replace("-", "\\\\-")}", - "parse_mode": "MarkdownV2", - "disable_web_page_preview": true - } - """.toString() - httpRequest consoleLogResponseBody: true, - contentType: 'APPLICATION_JSON', - httpMode: 'POST', - requestBody: body, - url: "https://api.telegram.org/bot$token/sendMessage".toString(), - validResponseCodes: '200' - } -} - -def withBuildStatus(String name, Closure closure) { - def ctx = "Jenkins Build / $name" - stage(name) { - try { - setBuildStatus("...", "pending", ctx, commitSha) - def start = System.currentTimeMillis() - closure() - def end = System.currentTimeMillis() - setBuildStatus("Successful in ${(end - start) / 1000} seconds", "success", ctx, commitSha) - } catch (Exception e) { - setBuildStatus("Failed", "failure", ctx, commitSha) - throw e - } - } -} - -def forEachABI(String name, Closure closure) { - abiList.each { String abi -> - withBuildStatus("$name ($abi)") { - closure(abi) - } - } -} - - -node("android") { - catchError { - timestamps { - try { - def buildTimestamp = System.currentTimeMillis().toString() - - stage("Fetching sources") { - checkout([$class : 'GitSCM', - branches : scm.branches, - doGenerateSubmoduleConfigurations: false, - extensions : [[$class : 'SubmoduleOption', - disableSubmodules : false, - parentCredentials : true, - recursiveSubmodules: true, - reference : '', - trackingSubmodules : false]], - submoduleCfg : [], - userRemoteConfigs : scm.userRemoteConfigs]) - sh "git config --get remote.origin.url > .git/remote-url" - repoUrl = readFile(".git/remote-url").trim() - sh "git rev-parse HEAD > .git/current-commit" - commitSha = readFile(".git/current-commit").trim() - setBuildStatus("...", "pending", "Jenkins Build", commitSha) - sh 'rm -rf out' - sh 'mkdir out' - } - - withBuildStatus("Clean build intermediates") { - sh './gradlew clean' - } - - withBuildStatus("Compile release kotlin") { - sh './gradlew compileReleaseKotlin' - } - - withBuildStatus("Run release unit test") { - sh './gradlew testReleaseUnitTest' - } - - forEachABI("Assemble release") { String abi -> - withEnv(["BUILD_ABI=$abi", "BUILD_TIMESTAMP=$buildTimestamp"]) { - sh './gradlew assembleRelease' - sh 'mv app/build/outputs/apk/release/*.apk out/' - } - sh 'mv app/build/outputs/apk/build-metadata.json out/' - } - - withBuildStatus("Sign and archive apks") { - signAndroidApks( - keyStoreId: 'fcitx5-android-sign-key', - apksToSign: 'out/*-unsigned.apk', - archiveSignedApks: true, - ) - archiveArtifacts( - artifacts: 'out/build-metadata.json', - fingerprint: true - ) - } - - stage("Post build (success)") { - setBuildStatus("Successful", "success", "Jenkins Build", commitSha) - sendMessageToTelegramGroup("[${JOB_NAME}-${BUILD_NUMBER}](${BUILD_URL}) succeeded") - } - - } catch (Exception e) { - stage("Post build (failure)") { - setBuildStatus("Failed", "failure", "Jenkins Build", commitSha) - sendMessageToTelegramGroup("[${JOB_NAME}-${BUILD_NUMBER}](${BUILD_URL}) failed") - throw e - } - } - - } - } -} diff --git a/README.md b/README.md index 6ad710e44..85f9bba0f 100644 --- a/README.md +++ b/README.md @@ -1,31 +1,57 @@ # fcitx5-android -An attempt to run fcitx5 on Android. +[Fcitx5](https://github.com/fcitx/fcitx5) input method framework and engines ported to Android. -[![Jenkins Build Status](https://img.shields.io/jenkins/s/https/jenkins.fcitx-im.org/job/android/job/fcitx5-android.svg)](https://jenkins.fcitx-im.org/job/android/job/fcitx5-android/) +## Download + +### Latest CI builds + +Jenkins: [![build status](https://img.shields.io/jenkins/build.svg?jobUrl=https://jenkins.fcitx-im.org/job/android/job/fcitx5-android/)](https://jenkins.fcitx-im.org/job/android/job/fcitx5-android/) + +### Tagged releases + +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) ## 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| |:-:|:-:| @@ -37,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: @@ -71,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) @@ -83,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 8abe71d59..1f435a29b 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -1,107 +1,40 @@ -@file:Suppress("UnstableApiUsage") - -import android.databinding.tool.ext.capitalizeUS -import com.android.build.gradle.internal.tasks.factory.dependsOn -import com.google.common.hash.Hashing -import com.google.common.io.Files -import groovy.json.JsonOutput -import groovy.json.JsonSlurper -import java.io.ByteArrayOutputStream -import java.nio.charset.Charset - -fun exec(cmd: String): String = ByteArrayOutputStream().use { - project.exec { - commandLine = cmd.split(" ") - standardOutput = it - } - it.toString().trim() -} - plugins { - id("com.android.application") - kotlin("android") - id("com.google.devtools.ksp") version "1.8.0-1.0.8" - id("com.cookpad.android.plugin.license-tools") version "1.2.8" - kotlin("plugin.serialization") version "1.8.0" - kotlin("plugin.parcelize") -} - -val dataDescriptorName = "descriptor.json" - -// NOTE: increase this value to bump version code -val baseVersionCode = 2 - -fun calculateVersionCode(abi: String): Int { - val abiId = when (abi) { - "armeabi-v7a" -> 1 - "arm64-v8a" -> 2 - "x86" -> 3 - "x86_64" -> 4 - else -> 0 - } - return baseVersionCode * 10 + abiId -} - -fun envOrDefault(env: String, default: () -> String): String { - val v = System.getenv(env) - return if (v.isNullOrBlank()) default() else v -} - -fun propertyOrDefault(prop: String, default: () -> String): String { - return try { - project.property(prop)!!.toString() - } catch (e: Exception) { - default() - } -} - -val buildABI = envOrDefault("BUILD_ABI") { - propertyOrDefault("buildABI") { - "arm64-v8a" - } -} - -val buildVersionName = envOrDefault("BUILD_VERSION_NAME") { - propertyOrDefault("buildVersionName") { - exec("git describe --tags --long --always") - } -} - -val buildCommitHash = envOrDefault("BUILD_COMMIT_HASH") { - propertyOrDefault("buildCommitHash") { - exec("git rev-parse HEAD") - } -} - -val buildTimestamp = envOrDefault("BUILD_TIMESTAMP") { - propertyOrDefault("buildTimestamp") { - System.currentTimeMillis().toString() - } + 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) } android { namespace = "org.fcitx.fcitx5.android" - compileSdk = 33 - buildToolsVersion = "33.0.0" - ndkVersion = System.getenv("NDK_VERSION") ?: "25.0.8775105" defaultConfig { applicationId = "org.fcitx.fcitx5.android" - minSdk = 23 - targetSdk = 33 - versionCode = calculateVersionCode(buildABI) - versionName = buildVersionName - setProperty("archivesBaseName", "$applicationId-$buildVersionName") - buildConfigField("String", "BUILD_GIT_HASH", "\"${buildCommitHash}\"") - buildConfigField("long", "BUILD_TIME", buildTimestamp) - buildConfigField("String", "DATA_DESCRIPTOR_NAME", "\"${dataDescriptorName}\"") testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + + @Suppress("UnstableApiUsage") + externalNativeBuild { + cmake { + targets( + // jni + "native-lib", + // copy fcitx5 built-in addon libraries + "copy-fcitx5-modules", + // android specific modules + "androidfrontend", + "androidkeyboard", + "androidnotification" + ) + } + } } buildTypes { - getByName("release") { - isMinifyEnabled = true - isShrinkResources = true + release { proguardFiles( getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro" @@ -111,313 +44,108 @@ android { resValue("mipmap", "app_icon_round", "@mipmap/ic_launcher_round") resValue("string", "app_name", "@string/app_name_release") } - getByName("debug") { - applicationIdSuffix = ".debug" - + debug { resValue("mipmap", "app_icon", "@mipmap/ic_launcher_debug") resValue("mipmap", "app_icon_round", "@mipmap/ic_launcher_round_debug") resValue("string", "app_name", "@string/app_name_debug") } } - splits { - abi { - isEnable = true - reset() - include(buildABI) - isUniversalApk = false - } - } - - externalNativeBuild { - cmake { - version = "3.22.1" - path("src/main/cpp/CMakeLists.txt") - } - } - buildFeatures { viewBinding = true } - compileOptions { - isCoreLibraryDesugaringEnabled = true - sourceCompatibility = JavaVersion.VERSION_11 - targetCompatibility = JavaVersion.VERSION_11 - } - - kotlinOptions { - jvmTarget = "11" - } - - packagingOptions { - jniLibs { - useLegacyPackaging = 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")) } } -kotlin.sourceSets.all { - languageSettings.optIn("kotlin.RequiresOptIn") -} - -val generateBuildMetadata by tasks.register("generateBuildMetadata") { - doLast { - val outputDir = file("build/outputs/apk") - outputDir.mkdirs() - val metadataFile = outputDir.resolve("build-metadata.json") - metadataFile.writeText( - JsonOutput.prettyPrint( - JsonOutput.toJson( - mapOf( - "versionName" to buildVersionName, - "commitHash" to buildCommitHash, - "timestamp" to buildTimestamp - ) - ) - ) - ) - } - - dependsOn(tasks.find { it.name == "compileDebugKotlin" || it.name == "compileReleaseKotlin" }) -} - -// This task should have depended on buildCMakeABITask -val installFcitxComponent by tasks.register("installFcitxComponent") - -val generateDataDescriptor by tasks.register("generateDataDescriptor") { - inputDir.set(file("src/main/assets")) - outputFile.set(file("src/main/assets/${dataDescriptorName}")) - dependsOn(installFcitxComponent) - dependsOn(tasks.findByName("generateLicenseJson")) -} -/** - * Note *Graph* - * Tasks registered by [installFcitxComponent] implicitly depend .cxx dir to install generated files. - * Since the native task `buildCMake$Variant$ABI` depend on the current variant and ABI, - * we should have registered [installFcitxComponent] tasks for the cartesian product of $Variant and $ABI. - * However, this would be way more tedious, as the build variant and ABI do not affect components we are going to install. - * The essential cause of this situation is that it's impossible for gradle to handle dynamic dependencies, - * where once the build graph was evaluated, no dependencies can be changed. So a trick is used here: when the task graph - * is evaluated, we look into it to find out the name of the native task which will be executed, and then store its output - * path in global variable. Tasks in [installFcitxComponent] are using the output path of the native task WITHOUT explicitly - * depending on it. - */ -project.gradle.taskGraph.whenReady { - val buildCMakeABITask = allTasks - .find { it.name.startsWith("buildCMakeDebug[") || it.name.startsWith("buildCMakeRelWithDebInfo[") } - if (buildCMakeABITask != null) { - val cmakeDir = buildCMakeABITask.outputs.files.first().parentFile - ext.set("cmakeDir", cmakeDir) - } -} - -android.applicationVariants.all { - val variantName = name.capitalizeUS() - tasks.findByName("merge${variantName}Assets")?.dependsOn(generateDataDescriptor) - tasks.findByName("assemble${variantName}")?.dependsOn(generateBuildMetadata) -} - -/** - * DO NOT run these tasks manually. See Note *Graph* for details. - */ -fun installFcitxComponent(targetName: String, componentName: String, destDir: File) { - // Deliberately use doLast to wait ext be set - val build by tasks.register("buildFcitx${componentName.capitalizeUS()}") { - doLast { - try { - exec { - workingDir = ext.get("cmakeDir") as File - commandLine("cmake", "--build", ".", "--target", targetName) - } - } catch (e: Exception) { - logger.log(LogLevel.ERROR, "Failed to build target $targetName: ${e.message}") - logger.log(LogLevel.ERROR, "Did you run this task independently?") - throw e - } - } - - // make sure that this task runs after than the native task - mustRunAfter("buildCMakeDebug[$buildABI]") - mustRunAfter("buildCMakeRelWithDebInfo[$buildABI]") - } - - val install by tasks.register("installFcitx${componentName.capitalizeUS()}") { - doLast { - try { - exec { - environment("DESTDIR", destDir.absolutePath) - workingDir = ext.get("cmakeDir") as File - commandLine("cmake", "--install", ".", "--component", componentName) - } - } catch (e: Exception) { - logger.log( - LogLevel.ERROR, - "Failed to install component $componentName: ${e.message}" - ) - logger.log(LogLevel.ERROR, "Did you run this task independently?") - throw e - } - } - - dependsOn(build) +fcitxComponent { + 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" } - - installFcitxComponent.dependsOn(install) + installPrebuiltAssets = true } -installFcitxComponent("generate-desktop-file", "config", file("src/main/assets")) -installFcitxComponent("translation-file", "translation", file("src/main/assets")) - ksp { arg("room.schemaLocation", "$projectDir/schemas") } -tasks.register("cleanGeneratedAssets") { - delete(file("src/main/assets/usr/share/locale")) - // delete all non symlink dirs - delete(file("src/main/assets/usr/share/fcitx5").listFiles()?.filter { - // https://stackoverflow.com/questions/813710/java-1-6-determine-symbolic-links - File(it.parentFile.canonicalFile, it.name).let { s -> - s.canonicalFile == s.absoluteFile - } - }) - delete(file("src/main/assets/${dataDescriptorName}")) - delete(file("src/main/assets/licenses.json")) -}.also { tasks.clean.dependsOn(it) } - -tasks.register("cleanCxxIntermediates") { - delete(file(".cxx")) -}.also { tasks.clean.dependsOn(it) } - dependencies { - implementation("org.ini4j:ini4j:0.5.4") ksp(project(":codegen")) - coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.0.0") - implementation("io.arrow-kt:arrow-core:1.1.5") - implementation("androidx.activity:activity-ktx:1.6.1") - implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.4.1") - implementation("com.github.CanHub:Android-Image-Cropper:4.2.1") - implementation("cat.ereza:customactivityoncrash:2.4.0") - implementation("com.google.android.flexbox:flexbox:3.0.0") - implementation("org.mechdancer:dependency:0.1.2") - val roomVersion = "2.5.0" - implementation("androidx.room:room-runtime:$roomVersion") - ksp("androidx.room:room-compiler:$roomVersion") - implementation("androidx.room:room-ktx:$roomVersion") - implementation("net.java.dev.jna:jna:5.13.0@aar") - implementation("com.jakewharton.timber:timber:5.0.1") - implementation("androidx.core:core-ktx:1.9.0") - implementation("androidx.appcompat:appcompat:1.6.0") - implementation("androidx.constraintlayout:constraintlayout:2.1.4") - val lifecycleVersion = "2.5.1" - implementation("androidx.lifecycle:lifecycle-runtime-ktx:$lifecycleVersion") - implementation("androidx.lifecycle:lifecycle-service:$lifecycleVersion") - implementation("androidx.lifecycle:lifecycle-common-java8:$lifecycleVersion") - implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.6.4") - implementation("androidx.preference:preference-ktx:1.2.0") - implementation("androidx.recyclerview:recyclerview:1.2.1") - implementation("androidx.viewpager2:viewpager2:1.0.0") - val navVersion = "2.5.3" - implementation("androidx.navigation:navigation-fragment-ktx:$navVersion") - implementation("androidx.navigation:navigation-ui-ktx:$navVersion") - val splittiesVersion = "3.0.0" - implementation("com.louiscad.splitties:splitties-bitflags:$splittiesVersion") - implementation("com.louiscad.splitties:splitties-systemservices:$splittiesVersion") - implementation("com.louiscad.splitties:splitties-views-dsl:$splittiesVersion") - implementation("com.louiscad.splitties:splitties-views-dsl-constraintlayout:$splittiesVersion") - implementation("com.louiscad.splitties:splitties-views-dsl-recyclerview:$splittiesVersion") - implementation("com.louiscad.splitties:splitties-views-recyclerview:$splittiesVersion") - implementation("com.louiscad.splitties:splitties-views-dsl-material:$splittiesVersion") - testImplementation("junit:junit:4.13.2") - androidTestImplementation("androidx.test:runner:1.5.2") - androidTestImplementation("androidx.test:rules:1.5.0") - androidTestImplementation("androidx.lifecycle:lifecycle-runtime-testing:2.5.1") - androidTestImplementation("junit:junit:4.13.2") -} - -abstract class DataDescriptorTask : DefaultTask() { - @get:Incremental - @get:PathSensitive(PathSensitivity.NAME_ONLY) - @get:InputDirectory - abstract val inputDir: DirectoryProperty - - @get:OutputFile - abstract val outputFile: RegularFileProperty - - private val file by lazy { outputFile.get().asFile } - - private fun serialize(map: Map) { - file.deleteOnExit() - file.writeText( - JsonOutput.prettyPrint( - JsonOutput.toJson( - mapOf( - "sha256" to Hashing.sha256() - .hashString( - map.entries.joinToString { it.key + it.value }, - Charset.defaultCharset() - ) - .toString(), - "files" to map - ) - ) - ) - ) - } - - @Suppress("UNCHECKED_CAST") - private fun deserialize(): Map = - ((JsonSlurper().parseText(file.readText()) as Map))["files"] as Map - - companion object { - fun sha256(file: File): String = - Files.asByteSource(file).hash(Hashing.sha256()).toString() - } - - @TaskAction - fun execute(inputChanges: InputChanges) { - val map = - file.exists() - .takeIf { it } - ?.runCatching { - deserialize() - // remove all old dirs - .filterValues { it.isNotBlank() } - .toMutableMap() - } - ?.getOrNull() - ?: mutableMapOf() - - fun File.allParents(): List = - if (parentFile == null || parentFile.path in map) - listOf() - else - listOf(parentFile) + parentFile.allParents() - inputChanges.getFileChanges(inputDir).forEach { change -> - if (change.file.name == file.name) - return@forEach - logger.log(LogLevel.DEBUG, "${change.changeType}: ${change.normalizedPath}") - val relativeFile = change.file.relativeTo(file.parentFile) - val key = relativeFile.path - if (change.changeType == ChangeType.REMOVED) { - map.remove(key) - } else { - map[key] = sha256(change.file) - } - } - // calculate dirs - inputDir.asFileTree.forEach { - it.relativeTo(file.parentFile).allParents().forEach { p -> - map[p.path] = "" - } - } - serialize(map.toSortedMap()) + 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) + implementation(libs.androidx.appcompat) + implementation(libs.androidx.autofill) + implementation(libs.androidx.constraintlayout) + implementation(libs.androidx.coordinatorlayout) + implementation(libs.androidx.core.ktx) + implementation(libs.androidx.lifecycle.viewmodel) + implementation(libs.androidx.lifecycle.livedata) + implementation(libs.androidx.lifecycle.runtime) + implementation(libs.androidx.lifecycle.common) + implementation(libs.androidx.lifecycle.service) + implementation(libs.androidx.navigation.fragment) + implementation(libs.androidx.navigation.ui) + implementation(libs.androidx.paging) + implementation(libs.androidx.preference) + implementation(libs.androidx.recyclerview) + ksp(libs.androidx.room.compiler) + implementation(libs.androidx.room.runtime) + implementation(libs.androidx.room.ktx) + implementation(libs.androidx.room.paging) + implementation(libs.androidx.startup) + implementation(libs.androidx.viewpager2) + implementation(libs.material) + implementation(libs.arrow.core) + implementation(libs.arrow.functions) + implementation(libs.imagecropper) + implementation(libs.flexbox) + implementation(libs.dependency) + implementation(libs.timber) + implementation(libs.splitties.bitflags) + implementation(libs.splitties.dimensions) + implementation(libs.splitties.resources) + implementation(libs.splitties.views.dsl) + implementation(libs.splitties.views.dsl.appcompat) + implementation(libs.splitties.views.dsl.constraintlayout) + implementation(libs.splitties.views.dsl.coordinatorlayout) + implementation(libs.splitties.views.dsl.recyclerview) + implementation(libs.splitties.views.recyclerview) + implementation(libs.aboutlibraries.core) + testImplementation(libs.junit) + androidTestImplementation(libs.androidx.test.runner) + androidTestImplementation(libs.androidx.test.rules) + 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.yml b/app/licenses.yml deleted file mode 100644 index 65f19729b..000000000 --- a/app/licenses.yml +++ /dev/null @@ -1,585 +0,0 @@ -- artifact: androidx.activity:activity-ktx:+ - name: activity-ktx - copyrightHolder: #COPYRIGHT_HOLDER# - license: The Apache Software License, Version 2.0 - licenseUrl: http://www.apache.org/licenses/LICENSE-2.0.txt - url: https://developer.android.com/jetpack/androidx/releases/activity#1.5.1 -- artifact: androidx.activity:activity:+ - name: activity - copyrightHolder: #COPYRIGHT_HOLDER# - license: The Apache Software License, Version 2.0 - licenseUrl: http://www.apache.org/licenses/LICENSE-2.0.txt - url: https://developer.android.com/jetpack/androidx/releases/activity#1.5.1 -- artifact: androidx.annotation:annotation-experimental:+ - name: annotation-experimental - copyrightHolder: #COPYRIGHT_HOLDER# - license: The Apache Software License, Version 2.0 - licenseUrl: http://www.apache.org/licenses/LICENSE-2.0.txt - url: https://developer.android.com/jetpack/androidx/releases/annotation#1.1.0 -- artifact: androidx.annotation:annotation:+ - name: annotation - copyrightHolder: #COPYRIGHT_HOLDER# - license: The Apache Software License, Version 2.0 - licenseUrl: http://www.apache.org/licenses/LICENSE-2.0.txt - url: http://developer.android.com/tools/extras/support-library.html -- artifact: androidx.appcompat:appcompat-resources:+ - name: appcompat-resources - copyrightHolder: #COPYRIGHT_HOLDER# - license: The Apache Software License, Version 2.0 - licenseUrl: http://www.apache.org/licenses/LICENSE-2.0.txt - url: https://developer.android.com/jetpack/androidx/releases/appcompat#1.4.2 -- artifact: androidx.appcompat:appcompat:+ - name: appcompat - copyrightHolder: #COPYRIGHT_HOLDER# - license: The Apache Software License, Version 2.0 - licenseUrl: http://www.apache.org/licenses/LICENSE-2.0.txt - url: https://developer.android.com/jetpack/androidx/releases/appcompat#1.4.2 -- artifact: androidx.arch.core:core-common:+ - name: core-common - copyrightHolder: #COPYRIGHT_HOLDER# - license: The Apache Software License, Version 2.0 - licenseUrl: http://www.apache.org/licenses/LICENSE-2.0.txt - url: https://developer.android.com/topic/libraries/architecture/index.html -- artifact: androidx.arch.core:core-runtime:+ - name: core-runtime - copyrightHolder: #COPYRIGHT_HOLDER# - license: The Apache Software License, Version 2.0 - licenseUrl: http://www.apache.org/licenses/LICENSE-2.0.txt - url: https://developer.android.com/topic/libraries/architecture/index.html -- artifact: androidx.cardview:cardview:+ - name: cardview - copyrightHolder: #COPYRIGHT_HOLDER# - license: The Apache Software License, Version 2.0 - licenseUrl: http://www.apache.org/licenses/LICENSE-2.0.txt - url: http://developer.android.com/tools/extras/support-library.html -- artifact: androidx.collection:collection-ktx:+ - name: collection-ktx - copyrightHolder: #COPYRIGHT_HOLDER# - license: The Apache Software License, Version 2.0 - licenseUrl: http://www.apache.org/licenses/LICENSE-2.0.txt - url: http://developer.android.com/tools/extras/support-library.html -- artifact: androidx.collection:collection:+ - name: collection - copyrightHolder: #COPYRIGHT_HOLDER# - license: The Apache Software License, Version 2.0 - licenseUrl: http://www.apache.org/licenses/LICENSE-2.0.txt - url: http://developer.android.com/tools/extras/support-library.html -- artifact: androidx.concurrent:concurrent-futures:+ - name: concurrent-futures - copyrightHolder: #COPYRIGHT_HOLDER# - license: The Apache Software License, Version 2.0 - licenseUrl: http://www.apache.org/licenses/LICENSE-2.0.txt - url: https://developer.android.com/topic/libraries/architecture/index.html -- artifact: androidx.constraintlayout:constraintlayout-core:+ - name: constraintlayout-core - copyrightHolder: #COPYRIGHT_HOLDER# - license: The Apache Software License, Version 2.0 - licenseUrl: http://www.apache.org/licenses/LICENSE-2.0.txt - url: http://tools.android.com -- artifact: androidx.constraintlayout:constraintlayout:+ - name: constraintlayout - copyrightHolder: #COPYRIGHT_HOLDER# - license: The Apache Software License, Version 2.0 - licenseUrl: http://www.apache.org/licenses/LICENSE-2.0.txt - url: http://tools.android.com -- artifact: androidx.coordinatorlayout:coordinatorlayout:+ - name: coordinatorlayout - copyrightHolder: #COPYRIGHT_HOLDER# - license: The Apache Software License, Version 2.0 - licenseUrl: http://www.apache.org/licenses/LICENSE-2.0.txt - url: https://developer.android.com/jetpack/androidx -- artifact: androidx.core:core-ktx:+ - name: core-ktx - copyrightHolder: #COPYRIGHT_HOLDER# - license: The Apache Software License, Version 2.0 - licenseUrl: http://www.apache.org/licenses/LICENSE-2.0.txt - url: https://developer.android.com/jetpack/androidx/releases/core#1.8.0 -- artifact: androidx.core:core:+ - name: core - copyrightHolder: #COPYRIGHT_HOLDER# - license: The Apache Software License, Version 2.0 - licenseUrl: http://www.apache.org/licenses/LICENSE-2.0.txt - url: https://developer.android.com/jetpack/androidx/releases/core#1.8.0 -- artifact: androidx.cursoradapter:cursoradapter:+ - name: cursoradapter - copyrightHolder: #COPYRIGHT_HOLDER# - license: The Apache Software License, Version 2.0 - licenseUrl: http://www.apache.org/licenses/LICENSE-2.0.txt - url: http://developer.android.com/tools/extras/support-library.html -- artifact: androidx.customview:customview:+ - name: customview - copyrightHolder: #COPYRIGHT_HOLDER# - license: The Apache Software License, Version 2.0 - licenseUrl: http://www.apache.org/licenses/LICENSE-2.0.txt - url: https://developer.android.com/jetpack/androidx -- artifact: androidx.databinding:viewbinding:+ - name: viewbinding - copyrightHolder: #COPYRIGHT_HOLDER# - license: The Apache Software License, Version 2.0 - licenseUrl: http://www.apache.org/licenses/LICENSE-2.0.txt -- artifact: androidx.documentfile:documentfile:+ - name: documentfile - copyrightHolder: #COPYRIGHT_HOLDER# - license: The Apache Software License, Version 2.0 - licenseUrl: http://www.apache.org/licenses/LICENSE-2.0.txt - url: http://developer.android.com/tools/extras/support-library.html -- artifact: androidx.drawerlayout:drawerlayout:+ - name: drawerlayout - copyrightHolder: #COPYRIGHT_HOLDER# - license: The Apache Software License, Version 2.0 - licenseUrl: http://www.apache.org/licenses/LICENSE-2.0.txt - url: https://developer.android.com/jetpack/androidx -- artifact: androidx.dynamicanimation:dynamicanimation:+ - name: dynamicanimation - copyrightHolder: #COPYRIGHT_HOLDER# - license: The Apache Software License, Version 2.0 - licenseUrl: http://www.apache.org/licenses/LICENSE-2.0.txt - url: http://developer.android.com/tools/extras/support-library.html -- artifact: androidx.emoji2:emoji2-views-helper:+ - name: emoji2-views-helper - copyrightHolder: #COPYRIGHT_HOLDER# - license: The Apache Software License, Version 2.0 - licenseUrl: http://www.apache.org/licenses/LICENSE-2.0.txt - url: https://developer.android.com/jetpack/androidx/releases/emoji2#1.0.0 -- artifact: androidx.emoji2:emoji2:+ - name: emoji2 - copyrightHolder: #COPYRIGHT_HOLDER# - license: The Apache Software License, Version 2.0 - licenseUrl: http://www.apache.org/licenses/LICENSE-2.0.txt - url: https://developer.android.com/jetpack/androidx/releases/emoji2#1.0.0 -- artifact: androidx.exifinterface:exifinterface:+ - name: exifinterface - copyrightHolder: #COPYRIGHT_HOLDER# - license: The Apache Software License, Version 2.0 - licenseUrl: http://www.apache.org/licenses/LICENSE-2.0.txt - url: https://developer.android.com/jetpack/androidx/releases/exifinterface#1.3.3 -- artifact: androidx.fragment:fragment-ktx:+ - name: fragment-ktx - copyrightHolder: #COPYRIGHT_HOLDER# - license: The Apache Software License, Version 2.0 - licenseUrl: http://www.apache.org/licenses/LICENSE-2.0.txt - url: https://developer.android.com/jetpack/androidx/releases/fragment#1.5.1 -- artifact: androidx.fragment:fragment:+ - name: fragment - copyrightHolder: #COPYRIGHT_HOLDER# - license: The Apache Software License, Version 2.0 - licenseUrl: http://www.apache.org/licenses/LICENSE-2.0.txt - url: https://developer.android.com/jetpack/androidx/releases/fragment#1.5.1 -- artifact: androidx.interpolator:interpolator:+ - name: interpolator - copyrightHolder: #COPYRIGHT_HOLDER# - license: The Apache Software License, Version 2.0 - licenseUrl: http://www.apache.org/licenses/LICENSE-2.0.txt - url: http://developer.android.com/tools/extras/support-library.html -- artifact: androidx.legacy:legacy-support-core-utils:+ - name: legacy-support-core-utils - copyrightHolder: #COPYRIGHT_HOLDER# - license: The Apache Software License, Version 2.0 - licenseUrl: http://www.apache.org/licenses/LICENSE-2.0.txt - url: http://developer.android.com/tools/extras/support-library.html -- artifact: androidx.lifecycle:lifecycle-common-java8:+ - name: lifecycle-common-java8 - copyrightHolder: #COPYRIGHT_HOLDER# - license: The Apache Software License, Version 2.0 - licenseUrl: http://www.apache.org/licenses/LICENSE-2.0.txt - url: https://developer.android.com/jetpack/androidx/releases/lifecycle#2.5.1 -- artifact: androidx.lifecycle:lifecycle-common:+ - name: lifecycle-common - copyrightHolder: #COPYRIGHT_HOLDER# - license: The Apache Software License, Version 2.0 - licenseUrl: http://www.apache.org/licenses/LICENSE-2.0.txt - url: https://developer.android.com/jetpack/androidx/releases/lifecycle#2.5.1 -- artifact: androidx.lifecycle:lifecycle-livedata-core-ktx:+ - name: lifecycle-livedata-core-ktx - copyrightHolder: #COPYRIGHT_HOLDER# - license: The Apache Software License, Version 2.0 - licenseUrl: http://www.apache.org/licenses/LICENSE-2.0.txt - url: https://developer.android.com/jetpack/androidx/releases/lifecycle#2.5.1 -- artifact: androidx.lifecycle:lifecycle-livedata-core:+ - name: lifecycle-livedata-core - copyrightHolder: #COPYRIGHT_HOLDER# - license: The Apache Software License, Version 2.0 - licenseUrl: http://www.apache.org/licenses/LICENSE-2.0.txt - url: https://developer.android.com/jetpack/androidx/releases/lifecycle#2.5.1 -- artifact: androidx.lifecycle:lifecycle-livedata:+ - name: lifecycle-livedata - copyrightHolder: #COPYRIGHT_HOLDER# - license: The Apache Software License, Version 2.0 - licenseUrl: http://www.apache.org/licenses/LICENSE-2.0.txt - url: https://developer.android.com/topic/libraries/architecture/index.html -- artifact: androidx.lifecycle:lifecycle-process:+ - name: lifecycle-process - copyrightHolder: #COPYRIGHT_HOLDER# - license: The Apache Software License, Version 2.0 - licenseUrl: http://www.apache.org/licenses/LICENSE-2.0.txt - url: https://developer.android.com/jetpack/androidx/releases/lifecycle#2.4.0 -- artifact: androidx.lifecycle:lifecycle-runtime-ktx:+ - name: lifecycle-runtime-ktx - copyrightHolder: #COPYRIGHT_HOLDER# - license: The Apache Software License, Version 2.0 - licenseUrl: http://www.apache.org/licenses/LICENSE-2.0.txt - url: https://developer.android.com/jetpack/androidx/releases/lifecycle#2.5.1 -- artifact: androidx.lifecycle:lifecycle-runtime:+ - name: lifecycle-runtime - copyrightHolder: #COPYRIGHT_HOLDER# - license: The Apache Software License, Version 2.0 - licenseUrl: http://www.apache.org/licenses/LICENSE-2.0.txt - url: https://developer.android.com/jetpack/androidx/releases/lifecycle#2.5.1 -- artifact: androidx.lifecycle:lifecycle-service:+ - name: lifecycle-service - copyrightHolder: #COPYRIGHT_HOLDER# - license: The Apache Software License, Version 2.0 - licenseUrl: http://www.apache.org/licenses/LICENSE-2.0.txt - url: https://developer.android.com/jetpack/androidx/releases/lifecycle#2.5.1 -- artifact: androidx.lifecycle:lifecycle-viewmodel-ktx:+ - name: lifecycle-viewmodel-ktx - copyrightHolder: #COPYRIGHT_HOLDER# - license: The Apache Software License, Version 2.0 - licenseUrl: http://www.apache.org/licenses/LICENSE-2.0.txt - url: https://developer.android.com/jetpack/androidx/releases/lifecycle#2.5.1 -- artifact: androidx.lifecycle:lifecycle-viewmodel-savedstate:+ - name: lifecycle-viewmodel-savedstate - copyrightHolder: #COPYRIGHT_HOLDER# - license: The Apache Software License, Version 2.0 - licenseUrl: http://www.apache.org/licenses/LICENSE-2.0.txt - url: https://developer.android.com/jetpack/androidx/releases/lifecycle#2.5.1 -- artifact: androidx.lifecycle:lifecycle-viewmodel:+ - name: lifecycle-viewmodel - copyrightHolder: #COPYRIGHT_HOLDER# - license: The Apache Software License, Version 2.0 - licenseUrl: http://www.apache.org/licenses/LICENSE-2.0.txt - url: https://developer.android.com/jetpack/androidx/releases/lifecycle#2.5.1 -- artifact: androidx.loader:loader:+ - name: loader - copyrightHolder: #COPYRIGHT_HOLDER# - license: The Apache Software License, Version 2.0 - licenseUrl: http://www.apache.org/licenses/LICENSE-2.0.txt - url: http://developer.android.com/tools/extras/support-library.html -- artifact: androidx.localbroadcastmanager:localbroadcastmanager:+ - name: localbroadcastmanager - copyrightHolder: #COPYRIGHT_HOLDER# - license: The Apache Software License, Version 2.0 - licenseUrl: http://www.apache.org/licenses/LICENSE-2.0.txt - url: http://developer.android.com/tools/extras/support-library.html -- artifact: androidx.navigation:navigation-common-ktx:+ - name: navigation-common-ktx - copyrightHolder: #COPYRIGHT_HOLDER# - license: The Apache Software License, Version 2.0 - licenseUrl: http://www.apache.org/licenses/LICENSE-2.0.txt - url: https://developer.android.com/jetpack/androidx/releases/navigation#2.5.1 -- artifact: androidx.navigation:navigation-common:+ - name: navigation-common - copyrightHolder: #COPYRIGHT_HOLDER# - license: The Apache Software License, Version 2.0 - licenseUrl: http://www.apache.org/licenses/LICENSE-2.0.txt - url: https://developer.android.com/jetpack/androidx/releases/navigation#2.5.1 -- artifact: androidx.navigation:navigation-fragment-ktx:+ - name: navigation-fragment-ktx - copyrightHolder: #COPYRIGHT_HOLDER# - license: The Apache Software License, Version 2.0 - licenseUrl: http://www.apache.org/licenses/LICENSE-2.0.txt - url: https://developer.android.com/jetpack/androidx/releases/navigation#2.5.1 -- artifact: androidx.navigation:navigation-fragment:+ - name: navigation-fragment - copyrightHolder: #COPYRIGHT_HOLDER# - license: The Apache Software License, Version 2.0 - licenseUrl: http://www.apache.org/licenses/LICENSE-2.0.txt - url: https://developer.android.com/jetpack/androidx/releases/navigation#2.5.1 -- artifact: androidx.navigation:navigation-runtime-ktx:+ - name: navigation-runtime-ktx - copyrightHolder: #COPYRIGHT_HOLDER# - license: The Apache Software License, Version 2.0 - licenseUrl: http://www.apache.org/licenses/LICENSE-2.0.txt - url: https://developer.android.com/jetpack/androidx/releases/navigation#2.5.1 -- artifact: androidx.navigation:navigation-runtime:+ - name: navigation-runtime - copyrightHolder: #COPYRIGHT_HOLDER# - license: The Apache Software License, Version 2.0 - licenseUrl: http://www.apache.org/licenses/LICENSE-2.0.txt - url: https://developer.android.com/jetpack/androidx/releases/navigation#2.5.1 -- artifact: androidx.navigation:navigation-ui-ktx:+ - name: navigation-ui-ktx - copyrightHolder: #COPYRIGHT_HOLDER# - license: The Apache Software License, Version 2.0 - licenseUrl: http://www.apache.org/licenses/LICENSE-2.0.txt - url: https://developer.android.com/jetpack/androidx/releases/navigation#2.5.1 -- artifact: androidx.navigation:navigation-ui:+ - name: navigation-ui - copyrightHolder: #COPYRIGHT_HOLDER# - license: The Apache Software License, Version 2.0 - licenseUrl: http://www.apache.org/licenses/LICENSE-2.0.txt - url: https://developer.android.com/jetpack/androidx/releases/navigation#2.5.1 -- artifact: androidx.preference:preference-ktx:+ - name: preference-ktx - copyrightHolder: #COPYRIGHT_HOLDER# - license: The Apache Software License, Version 2.0 - licenseUrl: http://www.apache.org/licenses/LICENSE-2.0.txt - url: https://developer.android.com/jetpack/androidx/releases/preference#1.2.0 -- artifact: androidx.preference:preference:+ - name: preference - copyrightHolder: #COPYRIGHT_HOLDER# - license: The Apache Software License, Version 2.0 - licenseUrl: http://www.apache.org/licenses/LICENSE-2.0.txt - url: https://developer.android.com/jetpack/androidx/releases/preference#1.2.0 -- artifact: androidx.print:print:+ - name: print - copyrightHolder: #COPYRIGHT_HOLDER# - license: The Apache Software License, Version 2.0 - licenseUrl: http://www.apache.org/licenses/LICENSE-2.0.txt - url: http://developer.android.com/tools/extras/support-library.html -- artifact: androidx.recyclerview:recyclerview:+ - name: recyclerview - copyrightHolder: #COPYRIGHT_HOLDER# - license: The Apache Software License, Version 2.0 - licenseUrl: http://www.apache.org/licenses/LICENSE-2.0.txt - url: https://developer.android.com/jetpack/androidx/releases/recyclerview#1.2.1 -- artifact: androidx.resourceinspection:resourceinspection-annotation:+ - name: resourceinspection-annotation - copyrightHolder: #COPYRIGHT_HOLDER# - license: The Apache Software License, Version 2.0 - licenseUrl: http://www.apache.org/licenses/LICENSE-2.0.txt - url: https://developer.android.com/jetpack/androidx/releases/resourceinspection#1.0.0 -- artifact: androidx.room:room-common:+ - name: room-common - copyrightHolder: #COPYRIGHT_HOLDER# - license: The Apache Software License, Version 2.0 - licenseUrl: http://www.apache.org/licenses/LICENSE-2.0.txt - url: https://developer.android.com/jetpack/androidx/releases/room#2.4.3 -- artifact: androidx.room:room-ktx:+ - name: room-ktx - copyrightHolder: #COPYRIGHT_HOLDER# - license: The Apache Software License, Version 2.0 - licenseUrl: http://www.apache.org/licenses/LICENSE-2.0.txt - url: https://developer.android.com/jetpack/androidx/releases/room#2.4.3 -- artifact: androidx.room:room-runtime:+ - name: room-runtime - copyrightHolder: #COPYRIGHT_HOLDER# - license: The Apache Software License, Version 2.0 - licenseUrl: http://www.apache.org/licenses/LICENSE-2.0.txt - url: https://developer.android.com/jetpack/androidx/releases/room#2.4.3 -- artifact: androidx.savedstate:savedstate-ktx:+ - name: savedstate-ktx - copyrightHolder: #COPYRIGHT_HOLDER# - license: The Apache Software License, Version 2.0 - licenseUrl: http://www.apache.org/licenses/LICENSE-2.0.txt - url: https://developer.android.com/jetpack/androidx/releases/savedstate#1.2.0 -- artifact: androidx.savedstate:savedstate:+ - name: savedstate - copyrightHolder: #COPYRIGHT_HOLDER# - license: The Apache Software License, Version 2.0 - licenseUrl: http://www.apache.org/licenses/LICENSE-2.0.txt - url: https://developer.android.com/jetpack/androidx/releases/savedstate#1.2.0 -- artifact: androidx.slidingpanelayout:slidingpanelayout:+ - name: slidingpanelayout - copyrightHolder: #COPYRIGHT_HOLDER# - license: The Apache Software License, Version 2.0 - licenseUrl: http://www.apache.org/licenses/LICENSE-2.0.txt - url: https://developer.android.com/jetpack/androidx/releases/slidingpanelayout#1.2.0 -- artifact: androidx.sqlite:sqlite-framework:+ - name: sqlite-framework - copyrightHolder: #COPYRIGHT_HOLDER# - license: The Apache Software License, Version 2.0 - licenseUrl: http://www.apache.org/licenses/LICENSE-2.0.txt - url: https://developer.android.com/jetpack/androidx/releases/sqlite#2.2.0 -- artifact: androidx.sqlite:sqlite:+ - name: sqlite - copyrightHolder: #COPYRIGHT_HOLDER# - license: The Apache Software License, Version 2.0 - licenseUrl: http://www.apache.org/licenses/LICENSE-2.0.txt - url: https://developer.android.com/jetpack/androidx/releases/sqlite#2.2.0 -- artifact: androidx.startup:startup-runtime:+ - name: startup-runtime - copyrightHolder: #COPYRIGHT_HOLDER# - license: The Apache Software License, Version 2.0 - licenseUrl: http://www.apache.org/licenses/LICENSE-2.0.txt - url: https://developer.android.com/jetpack/androidx/releases/startup#1.0.0 -- artifact: androidx.tracing:tracing:+ - name: tracing - copyrightHolder: #COPYRIGHT_HOLDER# - license: The Apache Software License, Version 2.0 - licenseUrl: http://www.apache.org/licenses/LICENSE-2.0.txt - url: https://developer.android.com/jetpack/androidx/releases/tracing#1.0.0 -- artifact: androidx.transition:transition:+ - name: transition - copyrightHolder: #COPYRIGHT_HOLDER# - license: The Apache Software License, Version 2.0 - licenseUrl: http://www.apache.org/licenses/LICENSE-2.0.txt - url: https://developer.android.com/jetpack/androidx/releases/transition#1.4.1 -- artifact: androidx.vectordrawable:vectordrawable-animated:+ - name: vectordrawable-animated - copyrightHolder: #COPYRIGHT_HOLDER# - license: The Apache Software License, Version 2.0 - licenseUrl: http://www.apache.org/licenses/LICENSE-2.0.txt - url: https://developer.android.com/jetpack/androidx -- artifact: androidx.vectordrawable:vectordrawable:+ - name: vectordrawable - copyrightHolder: #COPYRIGHT_HOLDER# - license: The Apache Software License, Version 2.0 - licenseUrl: http://www.apache.org/licenses/LICENSE-2.0.txt - url: https://developer.android.com/jetpack/androidx -- artifact: androidx.versionedparcelable:versionedparcelable:+ - name: versionedparcelable - copyrightHolder: #COPYRIGHT_HOLDER# - license: The Apache Software License, Version 2.0 - licenseUrl: http://www.apache.org/licenses/LICENSE-2.0.txt - url: http://developer.android.com/tools/extras/support-library.html -- artifact: androidx.viewpager2:viewpager2:+ - name: viewpager2 - copyrightHolder: #COPYRIGHT_HOLDER# - license: The Apache Software License, Version 2.0 - licenseUrl: http://www.apache.org/licenses/LICENSE-2.0.txt - url: https://developer.android.com/jetpack/androidx -- artifact: androidx.viewpager:viewpager:+ - name: viewpager - copyrightHolder: #COPYRIGHT_HOLDER# - license: The Apache Software License, Version 2.0 - licenseUrl: http://www.apache.org/licenses/LICENSE-2.0.txt - url: http://developer.android.com/tools/extras/support-library.html -- artifact: androidx.window:window:+ - name: window - copyrightHolder: #COPYRIGHT_HOLDER# - license: The Apache Software License, Version 2.0 - licenseUrl: http://www.apache.org/licenses/LICENSE-2.0.txt - url: https://developer.android.com/jetpack/androidx/releases/window#1.0.0 -- artifact: cat.ereza:customactivityoncrash:+ - name: customactivityoncrash - copyrightHolder: #COPYRIGHT_HOLDER# - license: The Apache Software License, Version 2.0 - licenseUrl: http://www.apache.org/licenses/LICENSE-2.0.txt - url: https://github.com/Ereza/CustomActivityOnCrash -- artifact: com.github.CanHub:Android-Image-Cropper:+ - name: Android-Image-Cropper - copyrightHolder: #COPYRIGHT_HOLDER# - license: Apache License 2.0 - licenseUrl: https://api.github.com/licenses/apache-2.0 - url: https://canhub.github.io/ -- artifact: com.google.android.flexbox:flexbox:+ - name: flexbox - copyrightHolder: #COPYRIGHT_HOLDER# - license: The Apache Software License, Version 2.0 - licenseUrl: http://www.apache.org/licenses/LICENSE-2.0.txt -- artifact: com.google.android.material:material:+ - name: material - copyrightHolder: #COPYRIGHT_HOLDER# - license: The Apache Software License, Version 2.0 - licenseUrl: http://www.apache.org/licenses/LICENSE-2.0.txt - url: https://github.com/material-components/material-components-android -- artifact: com.google.guava:listenablefuture:+ - name: listenablefuture - copyrightHolder: #COPYRIGHT_HOLDER# - license: #LICENSE# -- artifact: com.jakewharton.timber:timber:+ - name: timber - copyrightHolder: #COPYRIGHT_HOLDER# - license: The Apache Software License, Version 2.0 - licenseUrl: http://www.apache.org/licenses/LICENSE-2.0.txt - url: https://github.com/JakeWharton/timber -- artifact: com.louiscad.splitties:splitties-bitflags-jvm:+ - name: splitties-bitflags-jvm - copyrightHolder: #COPYRIGHT_HOLDER# - license: The Apache Software License, Version 2.0 - licenseUrl: https://www.apache.org/licenses/LICENSE-2.0.txt - url: https://github.com/LouisCAD/Splitties -- artifact: io.arrow-kt:arrow-annotations-jvm:+ - name: arrow-annotations-jvm - copyrightHolder: #COPYRIGHT_HOLDER# - license: https://www.apache.org/licenses/LICENSE-2.0.txt - url: https://github.com/arrow-kt/arrow/ -- artifact: io.arrow-kt:arrow-continuations-jvm:+ - name: arrow-continuations-jvm - copyrightHolder: #COPYRIGHT_HOLDER# - license: https://www.apache.org/licenses/LICENSE-2.0.txt - url: https://github.com/arrow-kt/arrow/ -- artifact: io.arrow-kt:arrow-core-jvm:+ - name: arrow-core-jvm - copyrightHolder: #COPYRIGHT_HOLDER# - license: https://www.apache.org/licenses/LICENSE-2.0.txt - url: https://github.com/arrow-kt/arrow/ -- artifact: net.java.dev.jna:jna:+ - name: jna - copyrightHolder: #COPYRIGHT_HOLDER# - license: LGPL-2.1-or-later - licenseUrl: https://www.gnu.org/licenses/old-licenses/lgpl-2.1 - url: https://github.com/java-native-access/jna -- artifact: org.codehaus.mojo:animal-sniffer-annotations:+ - name: animal-sniffer-annotations - copyrightHolder: #COPYRIGHT_HOLDER# - license: #LICENSE# -- artifact: org.jetbrains.kotlin:kotlin-android-extensions-runtime:+ - name: kotlin-android-extensions-runtime - copyrightHolder: #COPYRIGHT_HOLDER# - license: The Apache License, Version 2.0 - licenseUrl: http://www.apache.org/licenses/LICENSE-2.0.txt - url: https://kotlinlang.org/ -- artifact: org.jetbrains.kotlin:kotlin-parcelize-runtime:+ - name: kotlin-parcelize-runtime - copyrightHolder: #COPYRIGHT_HOLDER# - license: The Apache License, Version 2.0 - licenseUrl: http://www.apache.org/licenses/LICENSE-2.0.txt - url: https://kotlinlang.org/ -- artifact: org.jetbrains.kotlin:kotlin-reflect:+ - name: kotlin-reflect - copyrightHolder: #COPYRIGHT_HOLDER# - license: The Apache License, Version 2.0 - licenseUrl: http://www.apache.org/licenses/LICENSE-2.0.txt - url: https://kotlinlang.org/ -- artifact: org.jetbrains.kotlin:kotlin-stdlib-common:+ - name: kotlin-stdlib-common - copyrightHolder: #COPYRIGHT_HOLDER# - license: The Apache License, Version 2.0 - licenseUrl: http://www.apache.org/licenses/LICENSE-2.0.txt - url: https://kotlinlang.org/ -- artifact: org.jetbrains.kotlin:kotlin-stdlib-jdk7:+ - name: kotlin-stdlib-jdk7 - copyrightHolder: #COPYRIGHT_HOLDER# - license: The Apache License, Version 2.0 - licenseUrl: http://www.apache.org/licenses/LICENSE-2.0.txt - url: https://kotlinlang.org/ -- artifact: org.jetbrains.kotlin:kotlin-stdlib-jdk8:+ - name: kotlin-stdlib-jdk8 - copyrightHolder: #COPYRIGHT_HOLDER# - license: The Apache License, Version 2.0 - licenseUrl: http://www.apache.org/licenses/LICENSE-2.0.txt - url: https://kotlinlang.org/ -- artifact: org.jetbrains.kotlin:kotlin-stdlib:+ - name: kotlin-stdlib - copyrightHolder: #COPYRIGHT_HOLDER# - license: The Apache License, Version 2.0 - licenseUrl: http://www.apache.org/licenses/LICENSE-2.0.txt - url: https://kotlinlang.org/ -- artifact: org.jetbrains.kotlinx:kotlinx-coroutines-android:+ - name: kotlinx-coroutines-android - copyrightHolder: #COPYRIGHT_HOLDER# - license: The Apache Software License, Version 2.0 - licenseUrl: https://www.apache.org/licenses/LICENSE-2.0.txt - url: https://github.com/Kotlin/kotlinx.coroutines -- artifact: org.jetbrains.kotlinx:kotlinx-coroutines-core-jvm:+ - name: kotlinx-coroutines-core-jvm - copyrightHolder: #COPYRIGHT_HOLDER# - license: The Apache Software License, Version 2.0 - licenseUrl: https://www.apache.org/licenses/LICENSE-2.0.txt - url: https://github.com/Kotlin/kotlinx.coroutines -- artifact: org.jetbrains.kotlinx:kotlinx-serialization-core-jvm:+ - name: kotlinx-serialization-core-jvm - copyrightHolder: #COPYRIGHT_HOLDER# - license: The Apache Software License, Version 2.0 - licenseUrl: https://www.apache.org/licenses/LICENSE-2.0.txt - url: https://github.com/Kotlin/kotlinx.serialization -- artifact: org.jetbrains.kotlinx:kotlinx-serialization-json-jvm:+ - name: kotlinx-serialization-json-jvm - copyrightHolder: #COPYRIGHT_HOLDER# - license: The Apache Software License, Version 2.0 - licenseUrl: https://www.apache.org/licenses/LICENSE-2.0.txt - url: https://github.com/Kotlin/kotlinx.serialization -- artifact: org.jetbrains:annotations:+ - name: annotations - copyrightHolder: #COPYRIGHT_HOLDER# - license: The Apache Software License, Version 2.0 - licenseUrl: http://www.apache.org/licenses/LICENSE-2.0.txt - url: http://www.jetbrains.org -- artifact: org.mechdancer:dependency:+ - name: dependency - copyrightHolder: #COPYRIGHT_HOLDER# - license: WTFPL - licenseUrl: http://www.wtfpl.net/txt/copying/ - url: https://github.com/MechDancer/dependency \ No newline at end of file diff --git a/app/licenses/libraries/boost.json b/app/licenses/libraries/boost.json new file mode 100644 index 000000000..cebc09b62 --- /dev/null +++ b/app/licenses/libraries/boost.json @@ -0,0 +1,11 @@ +{ + "uniqueId": "boostorg/boost", + "artifactVersion": "1.86.0", + "description": "Free peer-reviewed portable C++ source libraries", + "name": "boostorg/boost", + "website": "https://www.boost.org/", + "tag": "native", + "licenses": [ + "BSL-1.0" + ] +} diff --git a/app/licenses/libraries/fcitx5-chinese-addons.json b/app/licenses/libraries/fcitx5-chinese-addons.json new file mode 100644 index 000000000..8200bcbed --- /dev/null +++ b/app/licenses/libraries/fcitx5-chinese-addons.json @@ -0,0 +1,12 @@ +{ + "uniqueId": "fcitx/fcitx5-chinese-addons", + "artifactVersion": "5.1.7", + "description": "Chinese related addon for fcitx5", + "name": "fcitx/fcitx5-chinese-addons", + "website": "https://github.com/fcitx/fcitx5-chinese-addons", + "tag": "native", + "licenses": [ + "GPL-2.0-or-later", + "LGPL-2.1-or-later" + ] +} diff --git a/app/licenses/libraries/fcitx5-lua.json b/app/licenses/libraries/fcitx5-lua.json new file mode 100644 index 000000000..e3ff2ca38 --- /dev/null +++ b/app/licenses/libraries/fcitx5-lua.json @@ -0,0 +1,11 @@ +{ + "uniqueId": "fcitx/fcitx5-lua", + "artifactVersion": "5.0.14", + "description": "Lua support for fcitx5", + "name": "fcitx/fcitx5-lua", + "website": "https://github.com/fcitx/fcitx5-lua", + "tag": "native", + "licenses": [ + "LGPL-2.1-or-later" + ] +} diff --git a/app/licenses/libraries/fcitx5.json b/app/licenses/libraries/fcitx5.json new file mode 100644 index 000000000..f20f85043 --- /dev/null +++ b/app/licenses/libraries/fcitx5.json @@ -0,0 +1,11 @@ +{ + "uniqueId": "fcitx/fcitx5", + "artifactVersion": "5.1.12", + "description": "Next generation of fcitx", + "name": "fcitx/fcitx5", + "website": "https://github.com/fcitx/fcitx5", + "tag": "native", + "licenses": [ + "LGPL-2.1-or-later" + ] +} diff --git a/app/licenses/libraries/fmt.json b/app/licenses/libraries/fmt.json new file mode 100644 index 000000000..07f2dbdc1 --- /dev/null +++ b/app/licenses/libraries/fmt.json @@ -0,0 +1,11 @@ +{ + "uniqueId": "fmtlib/fmt", + "artifactVersion": "11.0.2", + "description": "Open-source formatting library for C++", + "name": "fmtlib/fmt", + "website": "https://fmt.dev", + "tag": "native", + "licenses": [ + "MIT" + ] +} diff --git a/app/licenses/libraries/libime.json b/app/licenses/libraries/libime.json new file mode 100644 index 000000000..18507f632 --- /dev/null +++ b/app/licenses/libraries/libime.json @@ -0,0 +1,11 @@ +{ + "uniqueId": "fcitx/libime", + "artifactVersion": "1.1.10", + "description": "library to support generic input method implementation", + "name": "fcitx/libime", + "website": "https://github.com/fcitx/libime", + "tag": "native", + "licenses": [ + "LGPL-2.1-or-later" + ] +} diff --git a/app/licenses/libraries/libintl-lite.json b/app/licenses/libraries/libintl-lite.json new file mode 100644 index 000000000..e08b58baf --- /dev/null +++ b/app/licenses/libraries/libintl-lite.json @@ -0,0 +1,11 @@ +{ + "uniqueId": "j-jorge/libintl-lite", + "artifactVersion": "ba15146", + "description": "simple (but less powerful) GNU gettext libintl replacement", + "name": "j-jorge/libintl-lite", + "website": "https://github.com/j-jorge/libintl-lite", + "tag": "native", + "licenses": [ + "BSL-1.0" + ] +} 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 new file mode 100644 index 000000000..11c9ef4c7 --- /dev/null +++ b/app/licenses/libraries/lua.json @@ -0,0 +1,11 @@ +{ + "uniqueId": "lua/lua", + "artifactVersion": "5.4.7", + "description": "Powerful lightweight programming language designed for extending applications", + "name": "lua/lua", + "website": "https://www.lua.org/", + "tag": "native", + "licenses": [ + "MIT" + ] +} diff --git a/app/licenses/libraries/opencc.json b/app/licenses/libraries/opencc.json new file mode 100644 index 000000000..ed4109ee0 --- /dev/null +++ b/app/licenses/libraries/opencc.json @@ -0,0 +1,11 @@ +{ + "uniqueId": "BYVoid/OpenCC", + "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/", + "tag": "native", + "licenses": [ + "Apache-2.0" + ] +} diff --git a/app/org.fcitx.fcitx5.android.yml b/app/org.fcitx.fcitx5.android.yml new file mode 100644 index 000000000..7c2ffc2eb --- /dev/null +++ b/app/org.fcitx.fcitx5.android.yml @@ -0,0 +1,55 @@ +Categories: + - System +License: LGPL-2.1-only +AuthorName: Fcitx5 for Android Contributors +WebSite: https://fcitx5-android.github.io +SourceCode: https://github.com/fcitx5-android/fcitx5-android +IssueTracker: https://github.com/fcitx5-android/fcitx5-android/issues +Translation: https://explore.transifex.com/fcitx/fcitx5-android + +AutoName: Fcitx5 for Android + +RepoType: git +Repo: https://github.com/fcitx5-android/fcitx5-android + +Builds: + - versionName: placeholder + versionCode: placeholder + commit: placeholder + subdir: app + submodules: true + sudo: + - apt-get update + - 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 + srclibs: + - fcitx5-android-prebuilder@master + rm: + - lib/fcitx5/src/main/cpp/prebuilt + prebuild: + - 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$$ + - 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: 28.0.13004108 + gradleprops: + - buildABI=%abi + - buildTimestamp=%ts + +AllowedAPKSigningKeys: e4db1e9edff13629d07de4bbf8165fe9bd8557ab55092672da8e40dbe484ecd7 diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro index fe9fa07b3..d154724d5 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -10,60 +10,31 @@ # 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 { + int hashCode(); + boolean equals(java.lang.Object); +} -# If your project uses WebView with JS, uncomment the following -# and specify the fully qualified class name to the JavaScript interface -# class: -#-keepclassmembers class fqcn.of.javascript.interface.for.webview { -# public *; -#} +# 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 +-keepattributes SourceFile,LineNumberTable # If you keep the line number information, uncomment this to # hide the original source file name. #-renamesourcefileattribute SourceFile --dontwarn java.awt.* --keep class com.sun.jna.* { *; } --keepclassmembers class * extends com.sun.jna.* { public *; } -# Keep `Companion` object fields of serializable classes. -# This avoids serializer lookup through `getDeclaredClasses` as done for named companion objects. --if @kotlinx.serialization.Serializable class ** --keepclassmembers class <1> { - static <1>$Companion Companion; -} - -# Keep `serializer()` on companion objects (both default and named) of serializable classes. --if @kotlinx.serialization.Serializable class ** { - static **$* *; -} --keepclassmembers class <2>$<3> { - kotlinx.serialization.KSerializer serializer(...); -} - -# Keep `INSTANCE.serializer()` of serializable objects. --if @kotlinx.serialization.Serializable class ** { - public static ** INSTANCE; -} --keepclassmembers class <1> { - public static <1> INSTANCE; - kotlinx.serialization.KSerializer serializer(...); -} - -# @Serializable and @Polymorphic are used at runtime for polymorphic serialization. --keepattributes RuntimeVisibleAnnotations,AnnotationDefault - -# Serializer for classes with named companion objects are retrieved using `getDeclaredClasses`. -# If you have any, uncomment and replace classes with those containing named companion objects. -#-keepattributes InnerClasses # Needed for `getDeclaredClasses`. -#-if @kotlinx.serialization.Serializable class -#com.example.myapplication.HasNamedCompanion, # <-- List serializable classes with named companions. -#com.example.myapplication.HasNamedCompanion2 -#{ -# static **$* *; -#} -#-keepnames class <1>$$serializer { # -keepnames suffices; class is kept when serializer() is kept. -# static <1>$$serializer INSTANCE; -#} \ No newline at end of file diff --git a/app/schemas/org.fcitx.fcitx5.android.data.clipboard.db.ClipboardDatabase/3.json b/app/schemas/org.fcitx.fcitx5.android.data.clipboard.db.ClipboardDatabase/3.json new file mode 100644 index 000000000..4808fb978 --- /dev/null +++ b/app/schemas/org.fcitx.fcitx5.android.data.clipboard.db.ClipboardDatabase/3.json @@ -0,0 +1,67 @@ +{ + "formatVersion": 1, + "database": { + "version": 3, + "identityHash": "3046f390f998a6787882456a529ac924", + "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)", + "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" + } + ], + "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, '3046f390f998a6787882456a529ac924')" + ] + } +} \ No newline at end of file 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 6988562d0..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() = @@ -70,22 +85,22 @@ class FcitxTest { private suspend fun receiveFirstCommitString() = receiveFirst() - private suspend fun receiveFirstPreedit() = receiveFirst() + private suspend fun receiveFirstPreedit() = receiveFirst() private suspend fun receiveFirstInputPanelAux() = - receiveFirst() + receiveFirst() } 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 7f5ce7c64..fcc089b60 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -2,9 +2,40 @@ + + + + + + + + + + + + + + + + + + + + + + + + android:exported="false" + android:label="@string/edit_theme" /> + android:name=".ui.main.CropImageActivity" + android:exported="false" /> + @@ -67,12 +103,17 @@ + + + + @@ -94,6 +136,16 @@ android:resource="@xml/input_method" /> + + + + + + + + + + + + + + + + + \ No newline at end of file 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 28bb57a4b..000000000 --- a/app/src/main/assets/usr/share/fcitx5/pinyin +++ /dev/null @@ -1 +0,0 @@ -../../../../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 6cf9ad428..000000000 --- a/app/src/main/assets/usr/share/fcitx5/pinyinhelper +++ /dev/null @@ -1 +0,0 @@ -../../../../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 d89038d69..000000000 --- a/app/src/main/assets/usr/share/fcitx5/spell +++ /dev/null @@ -1 +0,0 @@ -../../../../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 78abf2508..000000000 --- a/app/src/main/assets/usr/share/fcitx5/table +++ /dev/null @@ -1 +0,0 @@ -../../../../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 6680b3d89..000000000 --- a/app/src/main/assets/usr/share/libime +++ /dev/null @@ -1 +0,0 @@ -../../../cpp/prebuilt/libime/data \ No newline at end of file diff --git a/app/src/main/cpp/CMakeLists.txt b/app/src/main/cpp/CMakeLists.txt index 37c4c808f..9777b761f 100644 --- a/app/src/main/cpp/CMakeLists.txt +++ b/app/src/main/cpp/CMakeLists.txt @@ -1,114 +1,90 @@ cmake_minimum_required(VERSION 3.18) -project(fcitx5-android VERSION 0.0.4) - -set(CMAKE_CXX_STANDARD 17) +project(fcitx5-android VERSION ${VERSION_NAME}) # For reproducible build add_link_options("LINKER:--hash-style=gnu,--build-id=none") -set(CMAKE_INSTALL_PREFIX /usr) -# fcitx-utils/standardpath.cpp uses FCITX_INSTALL_LIBDATADIR, -# which is CMAKE_INSTALL_LIBDATADIR's absolute path -set(CMAKE_INSTALL_LIBDATADIR /usr/lib) -set(FCITX_INSTALL_PKGDATADIR /usr/share/fcitx5) -set(FCITX_INSTALL_LOCALEDIR /usr/share/locale) -set(LIBIME_INSTALL_PKGDATADIR table) - -set(CMAKE_MODULE_PATH "${CMAKE_CURRENT_SOURCE_DIR}/cmake" ${CMAKE_MODULE_PATH}) -# extra-cmake-modules -if (DEFINED ENV{ECM_DIR}) - set(ECM_DIR $ENV{ECM_DIR}) -else () - set(ECM_DIR /usr/share/ECM/cmake) -endif () +# prefab dependency +find_package(fcitx5 REQUIRED CONFIG) +get_target_property(FCITX5_CMAKE_MODULES fcitx5::cmake INTERFACE_INCLUDE_DIRECTORIES) +set(CMAKE_MODULE_PATH ${FCITX5_CMAKE_MODULES} ${CMAKE_MODULE_PATH}) -set(PREBUILT_DIR "${CMAKE_CURRENT_SOURCE_DIR}/prebuilt") +find_package(Fcitx5Core MODULE) +find_package(Fcitx5Module MODULE) -# prebuilt fmt -set(fmt_DIR "${PREBUILT_DIR}/fmt/${ANDROID_ABI}/lib/cmake/fmt") +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}) -# prebuilt libintl-lite -set(LibIntl_DIR "${PREBUILT_DIR}/libintl-lite/${ANDROID_ABI}/lib/cmake") -find_package(LibIntl) +find_package(LibIMECore MODULE) +find_package(LibIMEPinyin MODULE) +find_package(LibIMETable MODULE) -# prebuilt libevent -set(Libevent_DIR "${PREBUILT_DIR}/libevent/${ANDROID_ABI}/lib/cmake/libevent") -set(LIBEVENT_STATIC_LINK ON) -find_package(Libevent) +find_package(fcitx5-lua REQUIRED CONFIG) +find_package(fcitx5-chinese-addons REQUIRED CONFIG) -option(ENABLE_TEST "" OFF) -option(ENABLE_COVERAGE "" OFF) -option(ENABLE_ENCHANT "" OFF) -option(ENABLE_X11 "" OFF) -option(ENABLE_WAYLAND "" OFF) -option(ENABLE_DBUS "" OFF) -option(ENABLE_DOC "" OFF) -option(ENABLE_SERVER "" OFF) -option(ENABLE_KEYBOARD "" OFF) -option(USE_SYSTEMD "" OFF) -option(ENABLE_XDGAUTOSTART "" OFF) -option(ENABLE_EMOJI "" OFF) -option(ENABLE_LIBUUID "" OFF) -add_subdirectory(fcitx5) +include("${FCITX_INSTALL_CMAKECONFIG_DIR}/Fcitx5Utils/Fcitx5CompilerSettings.cmake") -include(fcitx5/src/lib/fcitx-utils/Fcitx5Macros.cmake) add_subdirectory(po) add_subdirectory(androidfrontend) add_subdirectory(androidkeyboard) +add_subdirectory(androidnotification) -# 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(_Fcitx5Macro_SELF_DIR ${CMAKE_CURRENT_SOURCE_DIR}/fcitx5/src/lib/fcitx-utils) -# kenlm/util/exception.hh uses __FILE__ macro -add_compile_options(-ffile-prefix-map=${CMAKE_CURRENT_SOURCE_DIR}=.) -add_subdirectory(libime) - -# prebuilt lua -include("${PREBUILT_DIR}/lua/${ANDROID_ABI}/lib/cmake/LuaConfig.cmake") +# prebuilt libuv +set(libuv_DIR "${PREBUILT_DIR}/libuv/${ANDROID_ABI}/lib/cmake/libuv") +find_package(libuv) -# we are using static linking -option(USE_DLOPEN "" OFF) -add_subdirectory(fcitx5-lua) - -option(ENABLE_TEST "" OFF) -option(ENABLE_OPENCC "" OFF) -option(ENABLE_GUI "" OFF) -option(ENABLE_BROWSER "" OFF) -option(USE_WEBKIT "" OFF) -option(ENABLE_CLOUDPINYIN "" OFF) -add_subdirectory(fcitx5-chinese-addons) -# rename to include executable in apk -set_target_properties(scel2org5 PROPERTIES OUTPUT_NAME libscel2org5.so) +# prebuilt boost +list(APPEND CMAKE_FIND_ROOT_PATH "${PREBUILT_DIR}/boost/${ANDROID_ABI}/lib/cmake") +find_package(Boost 1.86.0 REQUIRED COMPONENTS headers iostreams CONFIG) -option(ENABLE_TEST "" OFF) -option(ENABLE_QT "" OFF) -add_subdirectory(fcitx5-unikey) +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) - -add_library(pinyindictionaryutils SHARED pinyindictionaryutils.cpp) -target_link_libraries(pinyindictionaryutils LibIME::Pinyin) - -add_library(utf8utils SHARED utf8utils.cpp) -target_link_libraries(utf8utils Fcitx5::Utils) - -add_library(tabledictionaryutils SHARED tabledictionaryutils.cpp) -target_link_libraries(tabledictionaryutils LibIME::Table) + Fcitx5::Module::Clipboard + Boost::headers + Boost::iostreams + LibIME::Pinyin + LibIME::Table + pinyin-customphrase + ) + +# copy module libraries from dependency lib +add_custom_target(copy-fcitx5-modules + 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 4cfc31bf4..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,41 +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 { - // if PreeditInApplication is disabled, this function is not called - // moved to `updateClientSideUIImpl` + frontend_->updateClientPreedit(filterText(inputPanel().clientPreedit())); } - void updateClientSideUIImpl() override { - InputPanel &ip = inputPanel(); - frontend_->updatePreedit(filterText(ip.preedit()), filterText(ip.clientPreedit())); - frontend_->updateInputPanelAux(filterText(ip.auxUp()), filterText(ip.auxDown())); + void updateInputPanel() { + const InputPanel &ip = inputPanel(); + frontend_->updateInputPanel( + filterText(ip.preedit()), + filterText(ip.auxUp()), + filterText(ip.auxDown()) + ); + } + + void updateCandidatesBulk() { std::vector candidates; - const auto &list = ip.candidateList(); + int size = 0; + const auto &list = inputPanel().candidateList(); if (list) { const auto &bulk = list->toBulk(); if (bulk) { - const int size = bulk->totalSize(); - for (int i = 0; i < size; 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())); + size = bulk->totalSize(); + // limit candidate count to 16 (for paging) + const int limit = size < 0 ? 16 : std::min(size, 16); + for (int i = 0; i < limit; i++) { + 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 { - const int size = list->size(); + 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); + 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; @@ -91,15 +150,127 @@ 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 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 end = std::min(list->size(), last); + for (int i = offset; i < end; i++) { + candidates.emplace_back(filterString(list->candidate(i).textWithComment())); + } + } + } + return candidates; + } + + std::vector getCandidateAction(const int idx) { + std::vector actions; + const auto &list = inputPanel().candidateList(); + if (list) { + const auto &actionable = list->toActionable(); + if (actionable) { + if (idx >= list->size()) { + const auto &bulk = list->toBulk(); + if (bulk) { + try { + const auto &c = bulk->candidateFromAll(idx); + for (const auto &a: actionable->candidateActions(c)) { + actions.emplace_back(a); + } + } catch (const std::exception &e) { + FCITX_WARN() << "getCandidateAction(" << idx << ") failed:" << e.what(); + } + } + } else { + const auto &c = list->candidate(idx); + for (const auto &a: actionable->candidateActions(c)) { + actions.emplace_back(a); + } + } + } + } + return actions; + } + + void triggerCandidateAction(const int idx, const int actionIdx) { + const auto &list = inputPanel().candidateList(); + if (!list) return; + const auto &actionable = list->toActionable(); + if (!actionable) return; + if (idx >= list->size()) { + const auto &bulk = list->toBulk(); + if (bulk) { + try { + const auto &c = bulk->candidateFromAll(idx); + actionable->triggerAction(c, actionIdx); + } catch (const std::exception &e) { + FCITX_WARN() << "triggerCandidateAction(" << idx << ") failed:" << e.what(); + } + } + } else { + const auto &c = list->candidate(idx); + actionable->triggerAction(c, actionIdx); + } + } + + void offsetCandidatePage(int delta) { + if (delta == 0) { + return; + } + const auto &list = inputPanel().candidateList(); + if (!list) { + return; + } + const auto &pageable = list->toPageable(); + if (!pageable) { + return; + } + if (delta > 0 && pageable->hasNext()) { + pageable->next(); + updateUserInterface(UserInterfaceComponent::InputPanel); + } else if (delta < 0 && pageable->hasPrev()) { + pageable->prev(); + updateUserInterface(UserInterfaceComponent::InputPanel); + } + } + private: AndroidFrontend *frontend_; int uid_; - Text filterText(const Text &orig) { + inline Text filterText(const Text &orig) { return frontend_->instance()->outputFilter(this, orig); } - std::string filterString(const Text &orig) { + inline std::string filterString(const Text &orig) { return filterText(orig).toString(); } }; @@ -110,30 +281,45 @@ 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); - if (e.component() == UserInterfaceComponent::StatusArea) { - handleStatusAreaUpdate(); + auto &e = static_cast(event); + switch (e.component()) { + case UserInterfaceComponent::InputPanel: { + if (activeIC_) { + activeIC_->updateInputPanel(); + if (pagingMode_ == 0) { + activeIC_->updateCandidatesBulk(); + } else { + activeIC_->updateCandidatesPaged(); + } + } + break; + } + case UserInterfaceComponent::StatusArea: { + statusAreaUpdateCallback(); + break; + } } } )); } 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); @@ -145,20 +331,20 @@ 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) { - candidateListCallback(candidates); +void AndroidFrontend::updateCandidateList(const std::vector &candidates, const int size) { + candidateListCallback(candidates, size); } -void AndroidFrontend::updatePreedit(const Text &preedit, const Text &clientPreedit) { - preeditCallback(preedit, clientPreedit); +void AndroidFrontend::updateClientPreedit(const Text &clientPreedit) { + preeditCallback(clientPreedit); } -void AndroidFrontend::updateInputPanelAux(const Text &auxUp, const Text &auxDown) { - inputPanelAuxCallback(auxUp, auxDown); +void AndroidFrontend::updateInputPanel(const Text &preedit, const Text &auxUp, const Text &auxDown) { + inputPanelCallback(preedit, auxUp, auxDown); } void AndroidFrontend::releaseInputContext(const int uid) { @@ -166,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; @@ -234,16 +427,42 @@ void AndroidFrontend::setCandidateListCallback(const CandidateListCallback &call candidateListCallback = callback; } +std::vector AndroidFrontend::getCandidates(const int offset, const int 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) { commitStringCallback = callback; } -void AndroidFrontend::setPreeditCallback(const PreeditCallback &callback) { +void AndroidFrontend::setPreeditCallback(const ClientPreeditCallback &callback) { preeditCallback = callback; } -void AndroidFrontend::setInputPanelAuxCallback(const InputPanelAuxCallback &callback) { - inputPanelAuxCallback = callback; +void AndroidFrontend::setInputPanelAuxCallback(const InputPanelCallback &callback) { + inputPanelCallback = callback; } void AndroidFrontend::setKeyEventCallback(const KeyEventCallback &callback) { @@ -258,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 false; - }); +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 147b55572..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); - void commitString(const std::string &str); - void updatePreedit(const Text &preedit, const Text &clientPreedit); - void updateInputPanelAux(const Text &auxUp, const Text &auxDown); + void updateCandidateList(const std::vector &candidates, const int size); + 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,15 +39,25 @@ 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 PreeditCallback &callback); - void setInputPanelAuxCallback(const InputPanelAuxCallback &callback); + void setPreeditCallback(const ClientPreeditCallback &callback); + void setInputPanelAuxCallback(const InputPanelCallback &callback); 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); @@ -53,6 +70,12 @@ class AndroidFrontend : public AddonInstance { FCITX_ADDON_EXPORT_FUNCTION(AndroidFrontend, activeInputContext); 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); @@ -60,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 &) {}; - CommitStringCallback commitStringCallback = [](const std::string &) {}; - PreeditCallback preeditCallback = [](const Text &, const Text &) {}; - InputPanelAuxCallback inputPanelAuxCallback = [](const fcitx::Text &, const fcitx::Text &) {}; + CandidateListCallback candidateListCallback = [](const std::vector &, const int) {}; + CommitStringCallback commitStringCallback = [](const std::string &, const int) {}; + ClientPreeditCallback preeditCallback = [](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 3ca994934..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 -typedef std::function &)> CandidateListCallback; -typedef std::function CommitStringCallback; -typedef std::function PreeditCallback; -typedef std::function InputPanelAuxCallback; +#include "../helper-types.h" + +typedef std::function &, const int)> CandidateListCallback; +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)) @@ -42,6 +53,24 @@ FCITX_ADDON_DECLARE_FUNCTION(AndroidFrontend, deactivateInputContext, FCITX_ADDON_DECLARE_FUNCTION(AndroidFrontend, setCapabilityFlags, void(uint64_t)) +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 &)) @@ -49,10 +78,10 @@ FCITX_ADDON_DECLARE_FUNCTION(AndroidFrontend, setCommitStringCallback, void(const CommitStringCallback &)) FCITX_ADDON_DECLARE_FUNCTION(AndroidFrontend, setPreeditCallback, - void(const PreeditCallback &)) + void(const ClientPreeditCallback &)) FCITX_ADDON_DECLARE_FUNCTION(AndroidFrontend, setInputPanelAuxCallback, - void(const InputPanelAuxCallback &)) + void(const InputPanelCallback &)) FCITX_ADDON_DECLARE_FUNCTION(AndroidFrontend, setKeyEventCallback, void(const KeyEventCallback &)) @@ -63,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/CMakeLists.txt b/app/src/main/cpp/androidkeyboard/CMakeLists.txt index db54ff396..d1eda2cfc 100644 --- a/app/src/main/cpp/androidkeyboard/CMakeLists.txt +++ b/app/src/main/cpp/androidkeyboard/CMakeLists.txt @@ -1,7 +1,7 @@ add_definitions(-DFCITX_GETTEXT_DOMAIN=\"fcitx5-android\") add_library(androidkeyboard MODULE androidkeyboard.cpp) -target_link_libraries(androidkeyboard Fcitx5::Core Fcitx5::Utils) +target_link_libraries(androidkeyboard Fcitx5::Core Fcitx5::Utils Fcitx5::Module::Spell) configure_file(androidkeyboard.conf.in.in androidkeyboard.conf.in @ONLY) fcitx5_translate_desktop_file(${CMAKE_CURRENT_BINARY_DIR}/androidkeyboard.conf.in androidkeyboard.conf) 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 5d41ee197..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 @@ -5,13 +9,11 @@ #include #include -#include "../fcitx5/src/modules/spell/spell_public.h" -#include "../fcitx5/src/im/keyboard/chardata.h" +#include "spell_public.h" +#include "../../../../../lib/fcitx5/src/main/cpp/fcitx5/src/im/keyboard/chardata.h" // dirty but works #include "androidkeyboard.h" -#define FCITX_KEYBOARD_MAX_BUFFER 20 - namespace fcitx { namespace { @@ -23,10 +25,10 @@ class AndroidKeyboardCandidateWord : public CandidateWord { commit_(std::move(commit)) {} void select(InputContext *inputContext) const override { + inputContext->commitString(commit_); inputContext->inputPanel().reset(); inputContext->updatePreedit(); inputContext->updateUserInterface(UserInterfaceComponent::InputPanel); - inputContext->commitString(commit_); engine_->resetState(inputContext, true); } @@ -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(), - *config_.pageSize); + 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/cmake/Fcitx5Utils/Fcitx5CompilerSettings.cmake b/app/src/main/cpp/cmake/Fcitx5Utils/Fcitx5CompilerSettings.cmake deleted file mode 120000 index 1421520cc..000000000 --- a/app/src/main/cpp/cmake/Fcitx5Utils/Fcitx5CompilerSettings.cmake +++ /dev/null @@ -1 +0,0 @@ -../../../cpp/fcitx5/cmake/Fcitx5CompilerSettings.cmake \ No newline at end of file diff --git a/app/src/main/cpp/cmake/FindFcitx5Core.cmake b/app/src/main/cpp/cmake/FindFcitx5Core.cmake deleted file mode 100644 index 7e28125bb..000000000 --- a/app/src/main/cpp/cmake/FindFcitx5Core.cmake +++ /dev/null @@ -1,11 +0,0 @@ -set(Fcitx5Core_FOUND TRUE) - -find_package(Fcitx5Utils REQUIRED) - -include(FindPackageHandleStandardArgs) -find_package_handle_standard_args(Fcitx5Core - FOUND_VAR - Fcitx5Core_FOUND - REQUIRED_VARS - Fcitx5Core_FOUND -) diff --git a/app/src/main/cpp/cmake/FindFcitx5Module.cmake b/app/src/main/cpp/cmake/FindFcitx5Module.cmake deleted file mode 100644 index cdf55e3bb..000000000 --- a/app/src/main/cpp/cmake/FindFcitx5Module.cmake +++ /dev/null @@ -1,9 +0,0 @@ -set(Fcitx5Module_FOUND TRUE) - -include(FindPackageHandleStandardArgs) -find_package_handle_standard_args(Fcitx5Module - FOUND_VAR - Fcitx5Module_FOUND - REQUIRED_VARS - Fcitx5Module_FOUND -) diff --git a/app/src/main/cpp/cmake/FindFcitx5Utils.cmake b/app/src/main/cpp/cmake/FindFcitx5Utils.cmake deleted file mode 100644 index ba497e42f..000000000 --- a/app/src/main/cpp/cmake/FindFcitx5Utils.cmake +++ /dev/null @@ -1,10 +0,0 @@ -set(Fcitx5Utils_FOUND TRUE) -set(FCITX_INSTALL_CMAKECONFIG_DIR "${CMAKE_SOURCE_DIR}/cmake") - -include(FindPackageHandleStandardArgs) -find_package_handle_standard_args(Fcitx5Utils - FOUND_VAR - Fcitx5Utils_FOUND - REQUIRED_VARS - FCITX_INSTALL_CMAKECONFIG_DIR -) diff --git a/app/src/main/cpp/cmake/FindLibIMEPinyin.cmake b/app/src/main/cpp/cmake/FindLibIMEPinyin.cmake deleted file mode 100644 index 1f7685afe..000000000 --- a/app/src/main/cpp/cmake/FindLibIMEPinyin.cmake +++ /dev/null @@ -1,9 +0,0 @@ -set(LibIMEPinyin_FOUND TRUE) - -include(FindPackageHandleStandardArgs) -find_package_handle_standard_args(LibIMEPinyin - FOUND_VAR - LibIMEPinyin_FOUND - REQUIRED_VARS - LibIMEPinyin_FOUND -) diff --git a/app/src/main/cpp/cmake/FindLibIMETable.cmake b/app/src/main/cpp/cmake/FindLibIMETable.cmake deleted file mode 100644 index 9a202119f..000000000 --- a/app/src/main/cpp/cmake/FindLibIMETable.cmake +++ /dev/null @@ -1,9 +0,0 @@ -set(LibIMETable_FOUND TRUE) - -include(FindPackageHandleStandardArgs) -find_package_handle_standard_args(LibIMETable - FOUND_VAR - LibIMETable_FOUND - REQUIRED_VARS - LibIMETable_FOUND -) diff --git a/app/src/main/cpp/fcitx5 b/app/src/main/cpp/fcitx5 deleted file mode 160000 index c8518ff74..000000000 --- a/app/src/main/cpp/fcitx5 +++ /dev/null @@ -1 +0,0 @@ -Subproject commit c8518ff74cb55424a64f666e935b83b0c9a2d199 diff --git a/app/src/main/cpp/fcitx5-chinese-addons b/app/src/main/cpp/fcitx5-chinese-addons deleted file mode 160000 index df605315d..000000000 --- a/app/src/main/cpp/fcitx5-chinese-addons +++ /dev/null @@ -1 +0,0 @@ -Subproject commit df605315d305f2713797bca6d9d48b7c038ac94b 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 3155d51c7..000000000 --- a/app/src/main/cpp/fcitx5-unikey +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 3155d51c7e8924e4e756ebd0688b40966a3221f0 diff --git a/app/src/main/cpp/helper-types.h b/app/src/main/cpp/helper-types.h index 0850d94c1..1f13c9494 100644 --- a/app/src/main/cpp/helper-types.h +++ b/app/src/main/cpp/helper-types.h @@ -1,9 +1,62 @@ +/* + * 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: + // 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) { + 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); + } +}; + +class AddonStatus { +public: + const fcitx::AddonInfo *info; + bool enabled; + + AddonStatus(const fcitx::AddonInfo *info, bool enabled) : + info(info), + enabled(enabled) {} +}; class ActionEntity { public: @@ -36,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 6d8dcecef..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); } @@ -98,13 +102,11 @@ class GlobalRefSingleton { jclass Integer; jmethodID IntegerInit; - jclass Long; - jmethodID LongInit; - jclass Boolean; jmethodID BooleanInit; jclass Fcitx; + jmethodID ShowToast; jmethodID HandleFcitxEvent; jclass InputMethodEntry; @@ -130,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); @@ -145,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;"); @@ -169,9 +184,23 @@ 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; + #endif //FCITX5_ANDROID_JNI_UTILS_H diff --git a/app/src/main/cpp/libime b/app/src/main/cpp/libime deleted file mode 160000 index 803b35595..000000000 --- a/app/src/main/cpp/libime +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 803b355954a08325db06b34bc9832f2f1fa235f8 diff --git a/app/src/main/cpp/native-lib.cpp b/app/src/main/cpp/native-lib.cpp index b0e2d42c2..bdeb17191 100644 --- a/app/src/main/cpp/native-lib.cpp +++ b/app/src/main/cpp/native-lib.cpp @@ -1,11 +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 @@ -18,16 +25,26 @@ #include #include #include +#include #include +#include #include #include #include +#include +#include + +#include +#include +#include "customphrase.h" + #include "androidfrontend/androidfrontend_public.h" #include "jni-utils.h" #include "nativestreambuf.h" #include "helper-types.h" +#include "object-conversion.h" class Fcitx { @@ -54,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()); @@ -131,28 +146,23 @@ class Fcitx { const auto *entry = imMgr.entry(ime.name()); entries.emplace_back(entry); } - return std::move(entries); + return entries; } - typedef std::tuple> IMStatus; - - IMStatus 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) { - auto subMode = engine->subMode(*entry, *ic); - auto subModeLabel = engine->subModeLabel(*entry, *ic); - auto subModeIcon = engine->subModeIcon(*entry, *ic); - return std::make_tuple(entry, std::vector{subMode, subModeLabel, subModeIcon}); - } else if (entry) { - return std::make_tuple(entry, std::vector{}); - } - return std::make_tuple(nullptr, std::vector{}); + 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) { - p_instance->setCurrentInputMethod(ime); + auto *ic = p_frontend->call(); + if (!ic) return; + // this method remembers input method for each InputContext, + // while Instance::setCurrentInputMethod(std::string) doesn't + p_instance->setCurrentInputMethod(ic, ime, true); } std::vector availableInputMethods() { @@ -161,7 +171,7 @@ class Fcitx { entries.emplace_back(&entry); return true; }); - return std::move(entries); + return entries; } void setEnabledInputMethods(std::vector &entries) { @@ -273,14 +283,15 @@ class Fcitx { engine->setConfigForInputMethod(*entry, config); } - std::map getAddons() { + std::vector getAddons() { 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 disabledSet(disabledAddons.begin(), disabledAddons.end()); - std::map addons; + const std::unordered_set + disabledSet(disabledAddons.begin(), disabledAddons.end()); + std::vector addons; for (const auto category: {fcitx::AddonCategory::InputMethod, fcitx::AddonCategory::Frontend, fcitx::AddonCategory::Loader, @@ -298,7 +309,7 @@ class Fcitx { } else if (enabledSet.count(info->uniqueName())) { enabled = true; } - addons.insert({info, enabled}); + addons.emplace_back(info, enabled); } } return addons; @@ -337,7 +348,7 @@ class Fcitx { void triggerQuickPhrase() { if (!p_quickphrase) return; - auto *ic = p_instance->inputContextManager().lastFocusedInputContext(); + auto *ic = p_frontend->call(); if (!ic) return; p_quickphrase->call( ic, "", "", "", "", fcitx::Key{FcitxKey_None} @@ -346,14 +357,14 @@ class Fcitx { void triggerUnicode() { if (!p_unicode) return; - auto *ic = p_instance->inputContextManager().lastFocusedInputContext(); + auto *ic = p_frontend->call(); if (!ic) return; 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) { @@ -384,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; @@ -398,13 +409,37 @@ class Fcitx { action->activate(ic); } + std::vector getCandidates(int offset, int limit) { + 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. @@ -437,7 +472,6 @@ class Fcitx { } }; - #define DO_IF_NOT_RUNNING(expr) \ if (!Fcitx::Instance().isRunning()) { \ FCITX_WARN() << "Fcitx is not running!"; \ @@ -446,7 +480,7 @@ class Fcitx { #define RETURN_IF_NOT_RUNNING DO_IF_NOT_RUNNING(return) #define RETURN_VALUE_IF_NOT_RUNNING(v) DO_IF_NOT_RUNNING(return (v)) -static GlobalRefSingleton *GlobalRef; +GlobalRefSingleton *GlobalRef; JNIEXPORT jint JNICALL JNI_OnLoad(JavaVM *jvm, void * /* reserved */) { @@ -455,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) { @@ -463,88 +500,49 @@ Java_org_fcitx_fcitx5_android_core_Fcitx_setupLogStream(JNIEnv *env, jclass claz Fcitx::setLogStream(stream, verbose); } -jobject fcitxInputMethodEntryWithSubModeToJObject(JNIEnv *env, const fcitx::InputMethodEntry *entry, const std::vector &subMode); - -jobject fcitxActionToJObject(JNIEnv *env, const ActionEntity &act) { - jobjectArray menu = nullptr; - if (act.menu) { - const int size = static_cast(act.menu->size()); - menu = env->NewObjectArray(size, GlobalRef->Action, nullptr); - for (int i = 0; i < size; i++) { - env->SetObjectArrayElement(menu, i, fcitxActionToJObject(env, act.menu->at(i))); - } - } - auto obj = env->NewObject(GlobalRef->Action, GlobalRef->ActionInit, - act.id, - act.isSeparator, - act.isCheckable, - act.isChecked, - *JString(env, act.name), - *JString(env, act.icon), - *JString(env, act.shortText), - *JString(env, act.longText), - menu - ); - if (menu) { - env->DeleteLocalRef(menu); - } - return obj; -} - -jobject fcitxTextToJObject(JNIEnv *env, const fcitx::Text &text) { - const int size = static_cast(text.size()); - auto str = JRef(env, env->NewObjectArray(size, GlobalRef->String, nullptr)); - auto fmt = JRef(env, env->NewIntArray(size)); - int flag = static_cast(fcitx::TextFormatFlag::NoFlag); - for (int i = 0; i < size; i++) { - env->SetObjectArrayElement(str, i, *JString(env, text.stringAt(i))); - flag = text.formatAt(i).toInteger(); - env->SetIntArrayRegion(fmt, i, 1, &flag); - } - auto obj = env->CallStaticObjectMethod(GlobalRef->FormattedText, GlobalRef->FormattedTextFromByteCursor, - *str, - *fmt, - text.cursor() - ); - return obj; -} - 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) { +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 @@ -575,38 +573,49 @@ 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); - auto candidateListCallback = [](const std::vector &candidateList) { + 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); + } + + auto candidateListCallback = [](const std::vector &candidates, const int size) { auto env = GlobalRef->AttachEnv(); - auto vararg = JRef(env, env->NewObjectArray(static_cast(candidateList.size()), GlobalRef->String, nullptr)); + auto candidatesArray = JRef(env, env->NewObjectArray(static_cast(candidates.size()), GlobalRef->String, nullptr)); int i = 0; - for (const auto &s: candidateList) { - env->SetObjectArrayElement(vararg, i++, JString(env, s)); + for (const auto &s: candidates) { + env->SetObjectArrayElement(candidatesArray, i++, JString(env, s)); } + auto vararg = JRef(env, env->NewObjectArray(2, GlobalRef->Object, nullptr)); + auto candidatesCount = JRef(env, env->NewObject(GlobalRef->Integer, GlobalRef->IntegerInit, size)); + env->SetObjectArrayElement(vararg, 0, *candidatesCount); + 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 &preedit, const fcitx::Text &clientPreedit) { + auto preeditCallback = [](const fcitx::Text &clientPreedit) { auto env = GlobalRef->AttachEnv(); - auto vararg = JRef(env, env->NewObjectArray(2, GlobalRef->FormattedText, nullptr)); - env->SetObjectArrayElement(vararg, 0, fcitxTextToJObject(env, preedit)); - env->SetObjectArrayElement(vararg, 1, fcitxTextToJObject(env, clientPreedit)); + auto vararg = JRef(env, env->NewObjectArray(1, GlobalRef->FormattedText, nullptr)); + env->SetObjectArrayElement(vararg, 0, fcitxTextToJObject(env, clientPreedit)); env->CallStaticVoidMethod(GlobalRef->Fcitx, GlobalRef->HandleFcitxEvent, 2, *vararg); }; - auto inputPanelAuxCallback = [](const fcitx::Text &auxUp, const fcitx::Text &auxDown) { + auto inputPanelAuxCallback = [](const fcitx::Text &preedit, const fcitx::Text &auxUp, const fcitx::Text &auxDown) { auto env = GlobalRef->AttachEnv(); - auto vararg = JRef(env, env->NewObjectArray(2, GlobalRef->FormattedText, nullptr)); - env->SetObjectArrayElement(vararg, 0, fcitxTextToJObject(env, auxUp)); - env->SetObjectArrayElement(vararg, 1, fcitxTextToJObject(env, auxDown)); + auto vararg = JRef(env, env->NewObjectArray(3, GlobalRef->FormattedText, nullptr)); + env->SetObjectArrayElement(vararg, 0, fcitxTextToJObject(env, preedit)); + env->SetObjectArrayElement(vararg, 1, fcitxTextToJObject(env, auxUp)); + env->SetObjectArrayElement(vararg, 2, fcitxTextToJObject(env, auxDown)); env->CallStaticVoidMethod(GlobalRef->Fcitx, GlobalRef->HandleFcitxEvent, 3, *vararg); }; auto readyCallback = []() { @@ -625,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, fcitxInputMethodEntryWithSubModeToJObject(env, std::get<0>(status), std::get<1>(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"; @@ -654,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"; } @@ -688,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); } @@ -717,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); } @@ -756,28 +817,6 @@ Java_org_fcitx_fcitx5_android_core_Fcitx_nextInputMethod(JNIEnv *env, jclass cla Fcitx::Instance().nextInputMethod(forward == JNI_TRUE); } -jobject fcitxInputMethodEntryToJObject(JNIEnv *env, const fcitx::InputMethodEntry *entry) { - return env->NewObject(GlobalRef->InputMethodEntry, GlobalRef->InputMethodEntryInit, - *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() - ); -} - -jobjectArray fcitxInputMethodEntriesToJObjectArray(JNIEnv *env, const std::vector &entries) { - jobjectArray array = env->NewObjectArray(static_cast(entries.size()), GlobalRef->InputMethodEntry, nullptr); - int i = 0; - for (const auto &entry: entries) { - auto obj = JRef(env, fcitxInputMethodEntryToJObject(env, entry)); - env->SetObjectArrayElement(array, i++, obj); - } - return array; -} - extern "C" JNIEXPORT jobjectArray JNICALL Java_org_fcitx_fcitx5_android_core_Fcitx_listInputMethods(JNIEnv *env, jclass clazz) { @@ -786,29 +825,13 @@ Java_org_fcitx_fcitx5_android_core_Fcitx_listInputMethods(JNIEnv *env, jclass cl return fcitxInputMethodEntriesToJObjectArray(env, entries); } -jobject fcitxInputMethodEntryWithSubModeToJObject(JNIEnv *env, const fcitx::InputMethodEntry *entry, const std::vector &subMode) { - if (!entry) return nullptr; - if (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, subMode[0]), - *JString(env, subMode[1]), - *JString(env, subMode[2]) - ); -} - 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 fcitxInputMethodEntryWithSubModeToJObject(env, std::get<0>(status), std::get<1>(status)); + auto status = Fcitx::Instance().inputMethodStatus(); + if (!status) return nullptr; + return fcitxInputMethodStatusToJObject(env, *status); } extern "C" @@ -839,25 +862,6 @@ Java_org_fcitx_fcitx5_android_core_Fcitx_setEnabledInputMethods(JNIEnv *env, jcl Fcitx::Instance().setEnabledInputMethods(entries); } -jobject fcitxRawConfigToJObject(JNIEnv *env, const fcitx::RawConfig &cfg) { - jobject obj = env->NewObject(GlobalRef->RawConfig, GlobalRef->RawConfigInit, - *JString(env, cfg.name()), - *JString(env, cfg.comment()), - *JString(env, cfg.value()), - nullptr); - if (!cfg.hasSubItems()) { - return obj; - } - auto array = JRef(env, env->NewObjectArray(static_cast(cfg.subItemsSize()), GlobalRef->RawConfig, nullptr)); - int i = 0; - for (const auto &item: cfg.subItems()) { - auto jItem = JRef(env, fcitxRawConfigToJObject(env, *cfg.get(item))); - env->SetObjectArrayElement(array, i++, jItem); - } - env->CallVoidMethod(obj, GlobalRef->RawConfigSetSubItems, *array); - return obj; -} - extern "C" JNIEXPORT jobject JNICALL Java_org_fcitx_fcitx5_android_core_Fcitx_getFcitxGlobalConfig(JNIEnv *env, jclass clazz) { @@ -890,28 +894,6 @@ Java_org_fcitx_fcitx5_android_core_Fcitx_getFcitxInputMethodConfig(JNIEnv *env, return result ? fcitxRawConfigToJObject(env, *result) : nullptr; } -void jobjectFillRawConfig(JNIEnv *env, jobject jConfig, fcitx::RawConfig &config) { - auto subItems = JRef(env, env->GetObjectField(jConfig, GlobalRef->RawConfigSubItems)); - if (*subItems == nullptr) { - auto value = JRef(env, env->GetObjectField(jConfig, GlobalRef->RawConfigValue)); - config = CString(env, value); - } else { - int size = env->GetArrayLength(subItems); - for (int i = 0; i < size; i++) { - auto item = JRef(env, env->GetObjectArrayElement(subItems, i)); - auto name = JRef(env, env->GetObjectField(item, GlobalRef->RawConfigName)); - auto subConfig = config.get(CString(env, name), true); - jobjectFillRawConfig(env, item, *subConfig); - } - } -} - -fcitx::RawConfig jobjectToRawConfig(JNIEnv *env, jobject jConfig) { - fcitx::RawConfig config; - jobjectFillRawConfig(env, jConfig, config); - return config; -} - extern "C" JNIEXPORT void JNICALL Java_org_fcitx_fcitx5_android_core_Fcitx_setFcitxGlobalConfig(JNIEnv *env, jclass clazz, jobject config) { @@ -944,15 +926,6 @@ Java_org_fcitx_fcitx5_android_core_Fcitx_setFcitxInputMethodConfig(JNIEnv *env, Fcitx::Instance().setInputMethodConfig(CString(env, im), rawConfig); } -jobjectArray stringVectorToJStringArray(JNIEnv *env, const std::vector &strings) { - jobjectArray array = env->NewObjectArray(static_cast(strings.size()), GlobalRef->String, nullptr); - int i = 0; - for (const auto &s: strings) { - env->SetObjectArrayElement(array, i++, JString(env, s)); - } - return array; -} - extern "C" JNIEXPORT jobjectArray JNICALL Java_org_fcitx_fcitx5_android_core_Fcitx_getFcitxAddons(JNIEnv *env, jclass clazz) { @@ -961,19 +934,7 @@ Java_org_fcitx_fcitx5_android_core_Fcitx_getFcitxAddons(JNIEnv *env, jclass claz jobjectArray array = env->NewObjectArray(static_cast(addons.size()), GlobalRef->AddonInfo, nullptr); int i = 0; for (const auto addon: addons) { - const auto *info = addon.first; - auto obj = JRef(env, env->NewObject(GlobalRef->AddonInfo, GlobalRef->AddonInfoInit, - *JString(env, info->uniqueName()), - *JString(env, info->name().match()), - *JString(env, info->comment().match()), - static_cast(info->category()), - info->isConfigurable(), - addon.second, - info->isDefaultEnabled(), - info->onDemand(), - stringVectorToJStringArray(env, info->dependencies()), - stringVectorToJStringArray(env, info->optionalDependencies()) - )); + auto obj = JRef(env, fcitxAddonStatusToJObject(env, addon)); env->SetObjectArrayElement(array, i++, obj); } return array; @@ -1002,6 +963,7 @@ Java_org_fcitx_fcitx5_android_core_Fcitx_setFcitxAddonState(JNIEnv *env, jclass extern "C" JNIEXPORT void JNICALL Java_org_fcitx_fcitx5_android_core_Fcitx_triggerQuickPhraseInput(JNIEnv *env, jclass clazz) { + RETURN_IF_NOT_RUNNING Fcitx::Instance().triggerQuickPhrase(); } @@ -1014,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" @@ -1052,8 +1014,9 @@ JNIEXPORT jobjectArray JNICALL Java_org_fcitx_fcitx5_android_core_Fcitx_getFcitxStatusAreaActions(JNIEnv *env, jclass clazz) { RETURN_VALUE_IF_NOT_RUNNING(nullptr) const auto actions = Fcitx::Instance().statusAreaActions(); - jobjectArray array = env->NewObjectArray(static_cast(actions.size()), GlobalRef->Action, nullptr); - for (int i = 0; i < actions.size(); i++) { + int size = static_cast(actions.size()); + jobjectArray array = env->NewObjectArray(size, GlobalRef->Action, nullptr); + for (int i = 0; i < size; i++) { auto obj = JRef(env, fcitxActionToJObject(env, actions[i])); env->SetObjectArrayElement(array, i, obj); } @@ -1067,6 +1030,55 @@ Java_org_fcitx_fcitx5_android_core_Fcitx_activateUserInterfaceAction(JNIEnv *env Fcitx::Instance().activateAction(static_cast(id)); } +extern "C" +JNIEXPORT jobjectArray JNICALL +Java_org_fcitx_fcitx5_android_core_Fcitx_getFcitxCandidates(JNIEnv *env, jclass clazz, jint offset, jint limit) { + RETURN_VALUE_IF_NOT_RUNNING(nullptr) + auto candidates = Fcitx::Instance().getCandidates(static_cast(offset), static_cast(limit)); + int size = static_cast(candidates.size()); + jobjectArray array = env->NewObjectArray(size, GlobalRef->String, nullptr); + for (int i = 0; i < size; i++) { + auto str = JString(env, candidates[i]); + env->SetObjectArrayElement(array, i, str); + } + 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) { @@ -1107,3 +1119,140 @@ Java_org_fcitx_fcitx5_android_core_Key_create(JNIEnv *env, jclass clazz, jint sy *JString(env, key.toString(fcitx::KeyStringFormat::Localized)) ); } + +extern "C" +JNIEXPORT void JNICALL +Java_org_fcitx_fcitx5_android_data_pinyin_PinyinDictManager_pinyinDictConv(JNIEnv *env, jclass clazz, jstring src, jstring dest, jboolean mode) { + using namespace libime; + PinyinDictionary dict; + try { + dict.load(PinyinDictionary::SystemDict, *CString(env, src), + mode == JNI_TRUE ? PinyinDictFormat::Binary : PinyinDictFormat::Text); + std::ofstream out; + out.open(*CString(env, dest), std::ios::out | std::ios::binary); + dict.save(PinyinDictionary::SystemDict, out, + mode == JNI_TRUE ? PinyinDictFormat::Text : PinyinDictFormat::Binary); + } catch (const std::exception &e) { + throwJavaException(env, e.what()); + } +} + +extern "C" +JNIEXPORT void JNICALL +Java_org_fcitx_fcitx5_android_data_table_TableManager_tableDictConv(JNIEnv *env, jclass clazz, jstring src, jstring dest, jboolean mode) { + using namespace libime; + TableBasedDictionary dict; + try { + dict.load(*CString(env, src), mode == JNI_TRUE ? TableFormat::Binary : TableFormat::Text); + std::ofstream out; + out.open(*CString(env, dest), std::ios::out | std::ios::binary); + dict.save(out, mode == JNI_TRUE ? TableFormat::Text : TableFormat::Binary); + } catch (const std::exception &e) { + throwJavaException(env, e.what()); + } +} + +extern "C" +JNIEXPORT jboolean JNICALL +Java_org_fcitx_fcitx5_android_data_table_TableManager_checkTableDictFormat(JNIEnv *env, jclass clazz, jstring src, jboolean user) { + using namespace libime; + TableBasedDictionary dict; + try { + if (user == JNI_TRUE) { + dict.loadUser(CString(env, src), TableFormat::Binary); + } else { + dict.load(*CString(env, src), TableFormat::Binary); + } + } catch (const std::exception &e) { + throwJavaException(env, e.what()); + } + 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 new file mode 100644 index 000000000..aa4155095 --- /dev/null +++ b/app/src/main/cpp/object-conversion.h @@ -0,0 +1,185 @@ +/* + * 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 + +#include + +#include + +#include "jni-utils.h" +#include "helper-types.h" + +jobject fcitxInputMethodEntryToJObject(JNIEnv *env, const fcitx::InputMethodEntry *entry) { + return env->NewObject(GlobalRef->InputMethodEntry, GlobalRef->InputMethodEntryInit, + *JString(env, entry->uniqueName()), + *JString(env, entry->name()), + *JString(env, entry->icon()), + *JString(env, entry->nativeName()), + *JString(env, entry->label()), + *JString(env, entry->languageCode()), + *JString(env, entry->addon()), + entry->isConfigurable() + ); +} + +jobjectArray fcitxInputMethodEntriesToJObjectArray(JNIEnv *env, const std::vector &entries) { + jobjectArray array = env->NewObjectArray(static_cast(entries.size()), GlobalRef->InputMethodEntry, nullptr); + int i = 0; + for (const auto &entry: entries) { + auto obj = JRef(env, fcitxInputMethodEntryToJObject(env, entry)); + env->SetObjectArrayElement(array, i++, obj); + } + return array; +} + +jobject fcitxInputMethodStatusToJObject(JNIEnv *env, const InputMethodStatus &status) { + return env->NewObject(GlobalRef->InputMethodEntry, GlobalRef->InputMethodEntryInitWithSubMode, + *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) + ); +} + +jobject fcitxRawConfigToJObject(JNIEnv *env, const fcitx::RawConfig &cfg) { + jobject obj = env->NewObject(GlobalRef->RawConfig, GlobalRef->RawConfigInit, + *JString(env, cfg.name()), + *JString(env, cfg.comment()), + *JString(env, cfg.value()), + nullptr); + if (!cfg.hasSubItems()) { + return obj; + } + auto array = JRef(env, env->NewObjectArray(static_cast(cfg.subItemsSize()), GlobalRef->RawConfig, nullptr)); + int i = 0; + for (const auto &item: cfg.subItems()) { + auto jItem = JRef(env, fcitxRawConfigToJObject(env, *cfg.get(item))); + env->SetObjectArrayElement(array, i++, jItem); + } + env->CallVoidMethod(obj, GlobalRef->RawConfigSetSubItems, *array); + return obj; +} + +void jobjectFillRawConfig(JNIEnv *env, jobject jConfig, fcitx::RawConfig &config) { + auto subItems = JRef(env, env->GetObjectField(jConfig, GlobalRef->RawConfigSubItems)); + if (*subItems == nullptr) { + auto value = JRef(env, env->GetObjectField(jConfig, GlobalRef->RawConfigValue)); + config = CString(env, value); + } else { + int size = env->GetArrayLength(subItems); + for (int i = 0; i < size; i++) { + auto item = JRef(env, env->GetObjectArrayElement(subItems, i)); + auto name = JRef(env, env->GetObjectField(item, GlobalRef->RawConfigName)); + auto subConfig = config.get(CString(env, name), true); + jobjectFillRawConfig(env, item, *subConfig); + } + } +} + +fcitx::RawConfig jobjectToRawConfig(JNIEnv *env, jobject jConfig) { + fcitx::RawConfig config; + jobjectFillRawConfig(env, jConfig, config); + return config; +} + +jobjectArray stringVectorToJStringArray(JNIEnv *env, const std::vector &strings) { + jobjectArray array = env->NewObjectArray(static_cast(strings.size()), GlobalRef->String, nullptr); + int i = 0; + for (const auto &s: strings) { + env->SetObjectArrayElement(array, i++, JString(env, s)); + } + return array; +} + +jobject fcitxAddonStatusToJObject(JNIEnv *env, const AddonStatus &status) { + const auto info = status.info; + return env->NewObject(GlobalRef->AddonInfo, GlobalRef->AddonInfoInit, + *JString(env, info->uniqueName()), + *JString(env, info->name().match()), + *JString(env, info->comment().match()), + static_cast(info->category()), + info->isConfigurable(), + status.enabled, + info->isDefaultEnabled(), + info->onDemand(), + stringVectorToJStringArray(env, info->dependencies()), + stringVectorToJStringArray(env, info->optionalDependencies()) + ); +} + +jobject fcitxActionToJObject(JNIEnv *env, const ActionEntity &act) { + jobjectArray menu = nullptr; + if (act.menu) { + const int size = static_cast(act.menu->size()); + menu = env->NewObjectArray(size, GlobalRef->Action, nullptr); + for (int i = 0; i < size; i++) { + env->SetObjectArrayElement(menu, i, fcitxActionToJObject(env, act.menu->at(i))); + } + } + auto obj = env->NewObject(GlobalRef->Action, GlobalRef->ActionInit, + act.id, + act.isSeparator, + act.isCheckable, + act.isChecked, + *JString(env, act.name), + *JString(env, act.icon), + *JString(env, act.shortText), + *JString(env, act.longText), + menu + ); + if (menu) { + env->DeleteLocalRef(menu); + } + return obj; +} + +jobject fcitxTextToJObject(JNIEnv *env, const fcitx::Text &text) { + const int size = static_cast(text.size()); + auto str = JRef(env, env->NewObjectArray(size, GlobalRef->String, nullptr)); + auto fmt = JRef(env, env->NewIntArray(size)); + int flag = static_cast(fcitx::TextFormatFlag::NoFlag); + for (int i = 0; i < size; i++) { + env->SetObjectArrayElement(str, i, *JString(env, text.stringAt(i))); + flag = text.formatAt(i).toInteger(); + env->SetIntArrayRegion(fmt, i, 1, &flag); + } + auto obj = env->CallStaticObjectMethod(GlobalRef->FormattedText, GlobalRef->FormattedTextFromByteCursor, + *str, + *fmt, + text.cursor() + ); + 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/pinyindictionaryutils.cpp b/app/src/main/cpp/pinyindictionaryutils.cpp deleted file mode 100644 index b2beb5109..000000000 --- a/app/src/main/cpp/pinyindictionaryutils.cpp +++ /dev/null @@ -1,23 +0,0 @@ -#include -#include - -#include "jni-utils.h" - -#include "libime/pinyin/pinyindictionary.h" - -extern "C" -JNIEXPORT void JNICALL -Java_org_fcitx_fcitx5_android_data_pinyin_PinyinDictManager_pinyinDictConv(JNIEnv *env, jclass clazz, jstring src, jstring dest, jboolean mode) { - using namespace libime; - PinyinDictionary dict; - try { - dict.load(PinyinDictionary::SystemDict, *CString(env, src), - mode == JNI_TRUE ? PinyinDictFormat::Binary : PinyinDictFormat::Text); - std::ofstream out; - out.open(*CString(env, dest), std::ios::out | std::ios::binary); - dict.save(PinyinDictionary::SystemDict, out, - mode == JNI_TRUE ? PinyinDictFormat::Text : PinyinDictFormat::Binary); - } catch (const std::exception &e) { - throwJavaException(env, e.what()); - } -} \ No newline at end of file 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/cpp/prebuilt b/app/src/main/cpp/prebuilt deleted file mode 160000 index fb5695039..000000000 --- a/app/src/main/cpp/prebuilt +++ /dev/null @@ -1 +0,0 @@ -Subproject commit fb569503911087b00a052e8a87507bd56a8a2adf diff --git a/app/src/main/cpp/tabledictionaryutils.cpp b/app/src/main/cpp/tabledictionaryutils.cpp deleted file mode 100644 index ed2be1095..000000000 --- a/app/src/main/cpp/tabledictionaryutils.cpp +++ /dev/null @@ -1,38 +0,0 @@ -#include -#include - -#include "jni-utils.h" - -#include "libime/table/tablebaseddictionary.h" - -extern "C" -JNIEXPORT void JNICALL -Java_org_fcitx_fcitx5_android_data_table_TableManager_tableDictConv(JNIEnv *env, jclass clazz, jstring src, jstring dest, jboolean mode) { - using namespace libime; - TableBasedDictionary dict; - try { - dict.load(*CString(env, src), mode == JNI_TRUE ? TableFormat::Binary : TableFormat::Text); - std::ofstream out; - out.open(*CString(env, dest), std::ios::out | std::ios::binary); - dict.save(out, mode == JNI_TRUE ? TableFormat::Text : TableFormat::Binary); - } catch (const std::exception &e) { - throwJavaException(env, e.what()); - } -} - -extern "C" -JNIEXPORT jboolean JNICALL -Java_org_fcitx_fcitx5_android_data_table_TableManager_checkTableDictFormat(JNIEnv *env, jclass clazz, jstring src, jboolean user) { - using namespace libime; - TableBasedDictionary dict; - try { - if (user == JNI_TRUE) { - dict.loadUser(CString(env, src), TableFormat::Binary); - } else { - dict.load(*CString(env, src), TableFormat::Binary); - } - } catch (const std::exception &e) { - throwJavaException(env, e.what()); - } - return JNI_TRUE; -} diff --git a/app/src/main/cpp/utf8utils.cpp b/app/src/main/cpp/utf8utils.cpp deleted file mode 100644 index 596c57b98..000000000 --- a/app/src/main/cpp/utf8utils.cpp +++ /dev/null @@ -1,6 +0,0 @@ -#include "fcitx-utils/utf8.h" - -extern "C" bool validateUTF8(const char *s) { - std::string str = s; - return fcitx::utf8::validate(str); -} \ No newline at end of file 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 d1d4d4d83..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,29 +1,125 @@ +/* + * 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 cat.ereza.customactivityoncrash.config.CaocConfig +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 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() - CaocConfig.Builder.create() - .enabled(!BuildConfig.DEBUG) - .errorActivity(LogActivity::class.java) - .apply() + 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 -> + 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 + val truncated = e.stackTraceToString().let { + if (it.length > MAX_STACKTRACE_SIZE) + it.take(MAX_STACKTRACE_SIZE) + "" + else + 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?) { @@ -39,6 +135,8 @@ class FcitxApplication : Application() { }) } + Timber.d("isDirectBootMode=$isDirectBootMode") + AppPrefs.init(sharedPrefs) // record last pid for crash logs AppPrefs.getInstance().internal.pid.apply { @@ -47,23 +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.onSystemDarkModeChanged(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 5397663eb..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); } @@ -73,15 +88,15 @@ value class CapabilityFlags constructor(val flags: ULong) { fun mergeFlags(arr: Array): ULong = arr.fold(CapabilityFlag.NoFlag.flag) { acc, it -> acc or it.flag } - private val DefaultFlags = CapabilityFlags( - CapabilityFlag.Preedit.flag or - CapabilityFlag.ClientUnfocusCommit.flag or - CapabilityFlag.ClientSideInputPanel.flag + val DefaultFlags = CapabilityFlags( + CapabilityFlag.Preedit, + CapabilityFlag.ClientUnfocusCommit, + CapabilityFlag.CommitStringWithCursor ) - fun fromEditorInfo(info: EditorInfo?): CapabilityFlags { + fun fromEditorInfo(info: EditorInfo): CapabilityFlags { var flags = DefaultFlags.flags - info?.imeOptions?.let { + info.imeOptions.let { if (it.hasFlag(EditorInfo.IME_FLAG_FORCE_ASCII)) { flags += CapabilityFlag.Alpha } @@ -89,7 +104,7 @@ value class CapabilityFlags constructor(val flags: ULong) { flags += CapabilityFlag.Sensitive } } - info?.inputType?.let { + info.inputType.let { when (it and InputType.TYPE_MASK_CLASS) { InputType.TYPE_NULL -> { flags -= CapabilityFlag.Preedit @@ -127,11 +142,14 @@ value class CapabilityFlags constructor(val flags: ULong) { flags += CapabilityFlag.Email } if (equals(InputType.TYPE_TEXT_VARIATION_PASSWORD) || - equals(InputType.TYPE_TEXT_VARIATION_VISIBLE_PASSWORD) || equals(InputType.TYPE_TEXT_VARIATION_WEB_PASSWORD) ) { flags += CapabilityFlag.Password } + if (equals(InputType.TYPE_TEXT_VARIATION_VISIBLE_PASSWORD)) { + flags += CapabilityFlag.Sensitive + flags += CapabilityFlag.NoSpellCheck + } if (equals(InputType.TYPE_TEXT_VARIATION_URI)) { flags += CapabilityFlag.Url } @@ -164,5 +182,9 @@ value class CapabilityFlags constructor(val flags: ULong) { constructor(vararg flags: CapabilityFlag) : this(mergeFlags(flags)) + fun has(flag: ULong) = flags.hasFlag(flag) + + fun has(flag: CapabilityFlag) = flags.hasFlag(flag.flag) + fun toLong() = flags.toLong() } 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 5fb1fcc12..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,20 +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.flow.launchIn -import kotlinx.coroutines.flow.onEach 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.data.DataManager +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 /** @@ -33,13 +41,13 @@ class Fcitx(private val context: Context) : FcitxAPI, FcitxLifecycleOwner { InputMethodEntry(context.getString(R.string._not_available_)) private set - override var statusAreaActionsCached: Array = arrayOf() + override var statusAreaActionsCached: Array = emptyArray() private set - override var preeditCached = FcitxEvent.PreeditEvent.Data() + override var clientPreeditCached = FormattedText.Empty private set - override var panelAuxCached = FcitxEvent.InputPanelAuxEvent.Data() + override var inputPanelCached = FcitxEvent.InputPanelEvent.Data() private set // the computation is delayed to the first call of [getAddonReverseDependencies] @@ -51,7 +59,13 @@ class Fcitx(private val context: Context) : FcitxAPI, FcitxLifecycleOwner { override fun getAddonReverseDependencies(addon: String) = (addonGraph ?: run { computeAddonGraph().also { addonGraph = it } }).let { graph -> addonReverseDependencies.computeIfAbsent(addon) - { graph.bfs(it) } + { + graph.bfs(it) { level, _, dep -> + // stop when the direct child is an optional dependency + dep == FcitxAPI.AddonDep.Required + || (level == 1 && dep == FcitxAPI.AddonDep.Optional) + } + } } override fun translate(str: String, domain: String) = getFcitxTranslation(domain, str) @@ -59,27 +73,39 @@ 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() } override suspend fun reset() = withFcitxContext { resetInputContext() } override suspend fun moveCursor(position: Int) = withFcitxContext { repositionCursor(position) } override suspend fun availableIme() = - withFcitxContext { availableInputMethods() ?: arrayOf() } + withFcitxContext { availableInputMethods() ?: emptyArray() } override suspend fun enabledIme() = - withFcitxContext { listInputMethods() ?: arrayOf() } + withFcitxContext { listInputMethods() ?: emptyArray() } override suspend fun setEnabledIme(array: Array) = withFcitxContext { setEnabledInputMethods(array) } @@ -123,14 +149,14 @@ class Fcitx(private val context: Context) : FcitxAPI, FcitxLifecycleOwner { setFcitxInputMethodConfig(key, config) } - override suspend fun addons() = withFcitxContext { getFcitxAddons() ?: arrayOf() } + override suspend fun addons() = withFcitxContext { getFcitxAddons() ?: emptyArray() } override suspend fun setAddonState(name: Array, state: BooleanArray) = withFcitxContext { setFcitxAddonState(name, state) } 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) = @@ -141,11 +167,26 @@ class Fcitx(private val context: Context) : FcitxAPI, FcitxLifecycleOwner { withFcitxContext { setCapabilityFlags(flags.toLong()) } override suspend fun statusArea(): Array = - withFcitxContext { getFcitxStatusAreaActions() ?: arrayOf() } + withFcitxContext { getFcitxStatusAreaActions() ?: emptyArray() } override suspend fun activateAction(id: Int) = withFcitxContext { activateUserInterfaceAction(id) } + 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!") @@ -157,12 +198,25 @@ 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, onBufferOverflow = BufferOverflow.DROP_OLDEST ) + private val fcitxEventHandlers = ArrayList<(FcitxEvent<*>) -> Unit>() + init { System.loadLibrary("native-lib") setupLogStream(AppPrefs.getInstance().internal.verboseLog.getValue()) @@ -177,7 +231,8 @@ class Fcitx(private val context: Context) : FcitxAPI, FcitxLifecycleOwner { appData: String, appLib: String, extData: String, - extCache: String + extCache: String, + extDomains: Array ) @JvmStatic @@ -193,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 @@ -271,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) @@ -291,6 +352,21 @@ class Fcitx(private val context: Context) : FcitxAPI, FcitxLifecycleOwner { @JvmStatic external fun activateUserInterfaceAction(id: Int) + @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() @@ -314,42 +390,71 @@ class Fcitx(private val context: Context) : FcitxAPI, FcitxLifecycleOwner { onFirstRun() } } + fcitxEventHandlers.forEach { it.invoke(event) } eventFlow_.tryEmit(event) } // 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 } + /** + * register a [FcitxEvent] handler that will fire before events go into [eventFlow_] + */ + private fun registerFcitxEventHandler(handler: (FcitxEvent<*>) -> Unit) { + if (fcitxEventHandlers.contains(handler)) return + fcitxEventHandlers.add(handler) + } + + private fun unregisterFcitxEventHandler(handler: (FcitxEvent<*>) -> Unit) { + fcitxEventHandlers.remove(handler) + } + } private val dispatcher = FcitxDispatcher(object : FcitxDispatcher.FcitxController { override fun nativeStartup() { - with(context) { - DataManager.sync() - val locale = Locales.fcitxLocale - Timber.i("Current locale is $locale") - val externalFilesDir = getExternalFilesDir(null)!! + DataManager.sync() + val locale = Locales.fcitxLocale + val dataDir = DataManager.dataDir.absolutePath + val plugins = DataManager.getLoadedPlugins() + 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) + } + } + Timber.d( + """ + Starting fcitx with: + locale=$locale + dataDir=$dataDir + nativeLibDir=$nativeLibDir + extDomains=${extDomains.joinToString()} + """.trimIndent() + ) + with(FcitxApplication.getInstance().directBootAwareContext) { startupFcitx( locale, - applicationInfo.dataDir, - applicationInfo.nativeLibraryDir, - externalFilesDir.absolutePath, - (externalCacheDir ?: cacheDir).absolutePath + dataDir, + nativeLibDir.toString(), + (getExternalFilesDir(null) ?: filesDir).absolutePath, + (externalCacheDir ?: cacheDir).absolutePath, + extDomains.toTypedArray() ) } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + lifecycle.launchWhenReady { + SubtypeManager.syncWith(enabledIme()) + } + } } override fun nativeLoopOnce() { @@ -366,13 +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 { @@ -385,25 +491,37 @@ class Fcitx(private val context: Context) : FcitxAPI, FcitxLifecycleOwner { }.let { ImmutableGraph(it) } } + private fun handleFcitxEvent(event: FcitxEvent<*>) { + when (event) { + is FcitxEvent.ReadyEvent -> lifecycleRegistry.postEvent(FcitxLifecycle.Event.ON_READY) + is FcitxEvent.IMChangeEvent -> inputMethodEntryCached = 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 -> {} + } + } + fun start() { if (lifecycle.currentState != FcitxLifecycle.State.STOPPED) { Timber.w("Skip starting fcitx: not at stopped state!") return } - // launch before dispatcher started to update internal states in this class - // the job gets cancelled automatically on stop - eventFlow.onEach { - when (it) { - is FcitxEvent.ReadyEvent -> lifecycleRegistry.postEvent(FcitxLifecycle.Event.ON_READY) - is FcitxEvent.IMChangeEvent -> inputMethodEntryCached = it.data - is FcitxEvent.StatusAreaEvent -> statusAreaActionsCached = it.data - is FcitxEvent.PreeditEvent -> preeditCached = it.data - is FcitxEvent.InputPanelAuxEvent -> panelAuxCached = it.data - else -> {} - } - }.launchIn(lifeCycleScope) + registerFcitxEventHandler(::handleFcitxEvent) lifecycleRegistry.postEvent(FcitxLifecycle.Event.ON_START) ClipboardManager.addOnUpdateListener(onClipboardUpdate) + DataManager.addOnNextSyncedCallback { + FcitxPluginServices.connectAll() + } dispatcher.start() } @@ -415,11 +533,13 @@ 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!") } lifecycleRegistry.postEvent(FcitxLifecycle.Event.ON_STOPPED) + unregisterFcitxEventHandler(::handleFcitxEvent) // clear addon graph addonGraph = null addonReverseDependencies.clear() 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 2345f0a2e..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 @@ -27,9 +30,9 @@ interface FcitxAPI { val statusAreaActionsCached: Array - val preeditCached: FcitxEvent.PreeditEvent.Data + val clientPreeditCached: FormattedText - val panelAuxCached: FcitxEvent.InputPanelAuxEvent.Data + val inputPanelCached: FcitxEvent.InputPanelEvent.Data fun getAddonReverseDependencies(addon: String): List> @@ -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 @@ -94,4 +97,12 @@ interface FcitxAPI { suspend fun activateAction(id: Int) + 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 c72278df2..c596c8117 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/core/FcitxDispatcher.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/core/FcitxDispatcher.kt @@ -1,6 +1,16 @@ +/* + * 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.* +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Runnable +import kotlinx.coroutines.asCoroutineDispatcher +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import timber.log.Timber @@ -11,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 @@ -46,7 +51,6 @@ class FcitxDispatcher(private val controller: FcitxController) : CoroutineDispat fun nativeExit() } - private val nativeLoopLock = Mutex() private val runningLock = Mutex() private val queue = ConcurrentLinkedQueue() @@ -58,24 +62,22 @@ class FcitxDispatcher(private val controller: FcitxController) : CoroutineDispat * This function returns immediately */ fun start() { + Timber.d("FcitxDispatcher start()") internalScope.launch { runningLock.withLock { - Timber.i("Start") if (isRunning.compareAndSet(false, true)) { - Timber.d("Calling native startup") + 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 block.run() } } - Timber.i("Calling native exit") + Timber.i("nativeExit()") controller.nativeExit() } } @@ -90,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() @@ -100,23 +102,14 @@ class FcitxDispatcher(private val controller: FcitxController) : CoroutineDispat } else emptyList() } - private fun dispatchInternal(block: Runnable, name: String? = null): WrappedRunnable { - if (!isRunning.get()) - throw IllegalStateException("Dispatcher is not in running state!") - val wrapped = WrappedRunnable(block, name) - queue.offer(wrapped) - bypass() - return wrapped - } - - // 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) { - this@FcitxDispatcher.dispatchInternal(block) + if (!isRunning.get()) { + throw IllegalStateException("Dispatcher is not in running state!") + } + queue.offer(WrappedRunnable(block)) + // 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 d4056fe3f..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,75 +1,84 @@ +/* + * 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: Array) : - FcitxEvent>(data) { - override val eventType: EventType - get() = EventType.Candidate + data class CandidateListEvent(override val data: Data) : + FcitxEvent(data) { - override fun equals(other: Any?): Boolean { - if (this === other) return true - if (javaClass != other?.javaClass) return false + override val eventType = EventType.Candidate - other as CandidateListEvent + data class Data(val total: Int = -1, val candidates: Array = emptyArray()) { - if (!data.contentEquals(other.data)) return false + override fun toString(): String = + "total=$total, candidates=[${candidates.joinToString(limit = 5)}]" - return true - } + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false - override fun hashCode(): Int { - return data.contentHashCode() - } + other as Data + + if (total != other.total) return false + if (!candidates.contentEquals(other.candidates)) return false + + return true + } - override fun toString(): String = "CandidateListEvent(data=[${ - data.take(5).joinToString() - }${if (data.size > 5) ", ..." else ""}])" + override fun hashCode(): Int { + var result = total + result = 31 * result + candidates.contentHashCode() + return result + } + } } - 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 PreeditEvent(override val data: Data) : - FcitxEvent(data) { - override val eventType: EventType - get() = EventType.Preedit + data class ClientPreeditEvent(override val data: FormattedText) : + FcitxEvent(data) { - data class Data(val preedit: FormattedText, val clientPreedit: FormattedText) { - constructor() : this(FormattedText(), FormattedText()) - } + override val eventType = EventType.ClientPreedit - override fun toString(): String { - return "PreeditEvent(preedit=[${data.preedit}, ${data.preedit.cursor}], clientPreedit=[${data.clientPreedit}, ${data.clientPreedit.cursor}])" - } + override fun toString(): String = "ClientPreeditEvent('$data', ${data.cursor})" } - data class InputPanelAuxEvent(override val data: Data) : - FcitxEvent(data) { - override val eventType: EventType - get() = EventType.Aux + data class InputPanelEvent(override val data: Data) : FcitxEvent(data) { - data class Data(val auxUp: FormattedText, val auxDown: FormattedText) { - constructor() : this(FormattedText(), FormattedText()) + override val eventType = EventType.InputPanel + + data class Data( + val preedit: FormattedText, + val auxUp: FormattedText, + val auxDown: FormattedText + ) { + constructor() : this(FormattedText.Empty, FormattedText.Empty, FormattedText.Empty) } } 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, @@ -86,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 @@ -131,29 +203,43 @@ sealed class FcitxEvent(open val data: T) { enum class EventType { Candidate, Commit, - Preedit, - Aux, + ClientPreedit, + InputPanel, Ready, 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) = when (Types[type]) { - EventType.Candidate -> CandidateListEvent(params as Array) - EventType.Commit -> CommitStringEvent(params[0] as String) - EventType.Preedit -> PreeditEvent( - PreeditEvent.Data(params[0] as FormattedText, params[1] as FormattedText) + EventType.Candidate -> CandidateListEvent( + CandidateListEvent.Data( + params[0] as Int, + params[1] as Array + ) + ) + EventType.Commit -> CommitStringEvent( + CommitStringEvent.Data( + params[0] as String, + params[1] as Int + ) ) - EventType.Aux -> InputPanelAuxEvent( - InputPanelAuxEvent.Data(params[0] as FormattedText, params[1] as FormattedText) + EventType.ClientPreedit -> ClientPreeditEvent(params[0] as FormattedText) + EventType.InputPanel -> InputPanelEvent( + InputPanelEvent.Data( + params[0] as FormattedText, + params[1] as FormattedText, + params[2] as FormattedText + ) ) EventType.Ready -> ReadyEvent() EventType.Key -> KeyEvent( @@ -166,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 6c470fa98..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 @@ -27,9 +31,8 @@ class FcitxLifecycleRegistry : FcitxLifecycle { private var internalState = FcitxLifecycle.State.STOPPED - override val lifecycleScope: CoroutineScope by lazy { + override val lifecycleScope: CoroutineScope = FcitxLifecycleCoroutineScope(this).also { addObserver(it) } - } fun postEvent(event: FcitxLifecycle.Event) = synchronized(internalState) { when (event) { @@ -95,29 +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 437eff84a..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 @@ -38,6 +42,9 @@ data class FormattedText( constructor() : this(arrayOf(), intArrayOf(), -1) companion object { + @JvmStatic + val Empty = FormattedText() + @JvmStatic @Suppress("UNUSED") // called from JNI fun fromByteCursor( @@ -51,7 +58,7 @@ data class FormattedText( StringBuilder().apply { var byteSize = 0 strings.forEach { - val bytes = it.encodeToByteArray() + val bytes = it.toByteArray() val total = byteSize + bytes.size if (total < byteCursor) { append(it) 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 new file mode 100644 index 000000000..915ff6f9c --- /dev/null +++ b/app/src/main/java/org/fcitx/fcitx5/android/core/data/DataDescriptor.kt @@ -0,0 +1,29 @@ +/* + * 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 + +typealias SHA256 = String + +/** + * A list of files with sha256 + */ +@Serializable +data class DataDescriptor( + /** + * Implementation dependent, will be used to quick check if two descriptors are the same + */ + val sha256: SHA256, + /** + * path -> sha256 + * sha256 will be empty if the path is a directory + */ + 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 new file mode 100644 index 000000000..40e96e998 --- /dev/null +++ b/app/src/main/java/org/fcitx/fcitx5/android/core/data/DataHierarchy.kt @@ -0,0 +1,124 @@ +/* + * 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 +import java.security.MessageDigest + +/** + * Merge [DataDescriptor]s + * + * It records files' sources, i.e. what [DataDescriptor] they belong to + */ +class DataHierarchy { + private val files = mutableMapOf>() + private val descriptorSHA256 = mutableSetOf() + private val symlinks = mutableMapOf>() + + data class PathConflict(val path: String, val src: FileSource) : Exception() + data class SymlinkConflict(val path: String, val src: FileSource) : Exception() + + /** + * Merge a [DataDescriptor] + * + * @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 PathConflict(path, old.second) + } + } + Pair(sha256, src) + } + // merge new files only when there is no conflict with existing files + files.putAll(newFiles) + 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) + } + + /** + * Create a [DataDescriptor] from the file list, discarding other information + */ + fun downToDataDescriptor() = + DataDescriptor( + sha256(this), + files.mapValues { it.value.first }, + symlinks.mapValues { it.value.first }) + + companion object { + private val digest by lazy { MessageDigest.getInstance("SHA-256") } + + /** + * Calculate checksum according to merged descriptors + * + * Note: This is different from sha256 calculated by gradle task, + * in which the it is the hash string of file list itself + */ + private fun sha256(h: DataHierarchy): String = + digest.digest(h.descriptorSHA256.joinToString(separator = "").encodeToByteArray()) + .let { + Base64.encodeToString(it, 0).trim() + } + + /** + * Compute the difference between a [DataDescriptor] and [DataHierarchy], + * generating [FileAction]s to migrate from the [old] to [new] + */ + fun diff(old: DataDescriptor, new: DataHierarchy): List { + if (old.sha256 == sha256(new)) + return emptyList() + val diffFiles = new.files.mapNotNull { (path, v) -> + val (sha256, src) = v + when { + path !in old.files && sha256.isNotBlank() -> + FileAction.CreateFile(path, src) + old.files[path] != sha256 -> + if (sha256.isNotBlank()) + FileAction.UpdateFile(path, src) + else null + else -> null + } + }.toMutableList().apply { + addAll(old.files.filterKeys { it !in new.files } + .map { (path, sha256) -> + if (sha256.isNotBlank()) + FileAction.DeleteFile(path) + else + 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 new file mode 100644 index 000000000..f134cc767 --- /dev/null +++ b/app/src/main/java/org/fcitx/fcitx5/android/core/data/DataManager.kt @@ -0,0 +1,309 @@ +/* + * 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 +import android.content.Intent +import android.content.pm.PackageManager +import android.content.res.AssetManager +import android.os.Build +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.FileUtil +import org.fcitx.fcitx5.android.utils.appContext +import org.fcitx.fcitx5.android.utils.isJavaIdentifier +import org.xmlpull.v1.XmlPullParser +import timber.log.Timber +import java.io.File +import java.util.concurrent.locks.ReentrantLock +import kotlin.concurrent.withLock + +/** + * Build up a Filesystem hierarchy at [dataDir] + * + * Operations are synchronized + */ +object DataManager { + + data class PluginSet( + val loaded: Set, + val failed: Map + ) + + const val PLUGIN_INTENT = "${BuildConfig.APPLICATION_ID}.plugin.MANIFEST" + + private val lock = ReentrantLock() + + private val json by lazy { Json { prettyPrint = true } } + + var synced = false + private set + + // 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): String { + return json.encodeToString(descriptor) + } + + // 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(): DataDescriptor { + return open(BuildConfig.DATA_DESCRIPTOR_NAME) + .bufferedReader() + .use { it.readText() } + .let { deserializeDataDescriptor(it) } + } + + private val loadedPlugins = mutableSetOf() + private val failedPlugins = mutableMapOf() + + fun getLoadedPlugins(): Set = loadedPlugins + fun getFailedPlugins(): Map = failedPlugins + + fun getSyncedPluginSet() = PluginSet(loadedPlugins, failedPlugins) + + /** + * Will be cleared after each sync + */ + private val callbacks = mutableListOf<() -> Unit>() + + fun addOnNextSyncedCallback(block: () -> Unit) = + callbacks.add(block) + + fun detectPlugins(): PluginSet { + val toLoad = mutableSetOf() + val preloadFailed = mutableMapOf() + + val pm = appContext.packageManager + + val pluginPackages = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + pm.queryIntentActivities( + Intent(PLUGIN_INTENT), + PackageManager.ResolveInfoFlags.of(PackageManager.MATCH_ALL.toLong()) + ) + } else { + pm.queryIntentActivities(Intent(PLUGIN_INTENT), PackageManager.MATCH_ALL) + }.map { + it.activityInfo.packageName + } + + Timber.d("Detected plugin packages: ${pluginPackages.joinToString()}") + + // 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") + failedPlugins[packageName] = PluginLoadFailed.MissingPluginDescriptor + continue + } + val parser = res.getXml(resId) + var eventType = parser.eventType + 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) { + XmlPullParser.TEXT -> text = parser.text + XmlPullParser.END_TAG -> when (parser.name) { + "apiVersion" -> apiVersion = text + "domain" -> domain = text + "description" -> description = text + "hasService" -> hasService = text?.lowercase() == "true" + } + } + eventType = parser.next() + } + 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) + } + } + + if (apiVersion != null && description != null) { + if (PluginDescriptor.pluginAPI == apiVersion) { + val info = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + pm.getPackageInfo( + packageName, + PackageManager.PackageInfoFlags.of(PackageManager.GET_META_DATA.toLong()) + ) + } else { + pm.getPackageInfo(packageName, PackageManager.GET_META_DATA) + } + toLoad.add( + PluginDescriptor( + packageName, + apiVersion, + domain, + description, + hasService, + info.versionName ?: "", + info.applicationInfo?.nativeLibraryDir ?: "" + ) + ) + } else { + Timber.w("$packageName's api version [$apiVersion] doesn't match with the current [${PluginDescriptor.pluginAPI}]") + preloadFailed[packageName] = PluginLoadFailed.PluginAPIIncompatible(apiVersion) + } + } else { + Timber.w("Failed to parse plugin descriptor of $packageName") + preloadFailed[packageName] = PluginLoadFailed.PluginDescriptorParseError + } + } + return PluginSet(toLoad, preloadFailed) + } + + fun sync() = lock.withLock { + synced = false + loadedPlugins.clear() + failedPlugins.clear() + + val destDescriptorFile = File(dataDir, BuildConfig.DATA_DESCRIPTOR_NAME) + + // load last run's data descriptor + val oldDescriptor = destDescriptorFile + .runCatching { deserializeDataDescriptor(bufferedReader().use { it.readText() }) } + .getOrElse { DataDescriptor("", emptyMap(), emptyMap()) } + + // load app's data descriptor + val mainDescriptor = appContext.assets.getDataDescriptor() + + val (parsedDescriptors, failed) = detectPlugins() + failedPlugins.putAll(failed) + + Timber.d("Plugins to load: $parsedDescriptors") + + // Create an empty hierarchy + val newHierarchy = DataHierarchy() + // Always add app's first + newHierarchy.install(mainDescriptor, FileSource.Main) + + val pluginAssets = mutableMapOf() + + // Add plugin's one by one + for (plugin in parsedDescriptors) { + val pluginContext = appContext.createPackageContext(plugin.packageName, 0) + val assets = pluginContext.assets + val descriptor = runCatching { assets.getDataDescriptor() }.onFailure { + 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.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 + } + pluginAssets[plugin.name] = assets + loadedPlugins.add(plugin) + Timber.d("Merged data hierarchy of ${plugin.name}") + } + + Timber.d("Hierarchy created") + + // Compute the difference of the created one and the old one + // Run actions to migrate to the new hierarchy + DataHierarchy.diff(oldDescriptor, newHierarchy).sortedByDescending { it.ordinal }.forEach { + Timber.d("Action: $it") + when (it) { + is FileAction.CreateFile -> { + val assets = if (it.src is FileSource.Plugin) + pluginAssets.getValue(it.src.descriptor.name) + else appContext.assets + assets.copyFile(it.path) + } + is FileAction.DeleteDir -> { + removePath(it.path).getOrThrow() + } + is FileAction.DeleteFile -> { + removePath(it.path).getOrThrow() + } + is FileAction.UpdateFile -> { + val assets = if (it.src is FileSource.Plugin) + pluginAssets.getValue(it.src.descriptor.name) + 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.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 removePath(path: String) = + FileUtil.removeFile(dataDir.resolve(path)) + + 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 -> + File(dataDir, filename) + .also { it.parentFile?.mkdirs() } + .outputStream() + .use { o -> i.copyTo(o) } + } + } + + fun deleteAndSync() { + lock.withLock { + dataDir.resolve(BuildConfig.DATA_DESCRIPTOR_NAME).delete() + dataDir.resolve("README.md").delete() + dataDir.resolve("usr").deleteRecursively() + } + sync() + } + +} 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 new file mode 100644 index 000000000..ded187f09 --- /dev/null +++ b/app/src/main/java/org/fcitx/fcitx5/android/core/data/FileAction.kt @@ -0,0 +1,50 @@ +/* + * 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, delete directories and files, and finally create symlinks + */ + val ordinal: Int + + /** + * To create or update a file, we need its source. + */ + interface Sourced { + 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 { + override val ordinal: Int + get() = 3 + } + + data class UpdateFile(override val path: String, override val src: FileSource) : + FileAction, + Sourced { + override val ordinal: Int + get() = 2 + } + + data class DeleteFile(override val path: String) : FileAction { + override val ordinal: Int + get() = 0 + } + + data class DeleteDir(override val path: String) : FileAction { + override val ordinal: Int + get() = 1 + } +} \ No newline at end of file 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 new file mode 100644 index 000000000..21e0e78fd --- /dev/null +++ b/app/src/main/java/org/fcitx/fcitx5/android/core/data/FileSource.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.core.data + +sealed interface FileSource { + + /** + * This path belongs to app + */ + object Main : FileSource { + override fun toString(): String = "Main" + } + + /** + * This path belongs to plugin + */ + data class Plugin(val descriptor: PluginDescriptor) : FileSource +} \ No newline at end of file diff --git a/app/src/main/java/org/fcitx/fcitx5/android/core/data/PluginDescriptor.kt b/app/src/main/java/org/fcitx/fcitx5/android/core/data/PluginDescriptor.kt new file mode 100644 index 000000000..cec9078ec --- /dev/null +++ b/app/src/main/java/org/fcitx/fcitx5/android/core/data/PluginDescriptor.kt @@ -0,0 +1,44 @@ +/* + * 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 + +/** + * Metadata of a plugin, at `res/xml/plugin.xml` + */ +data class PluginDescriptor( + /** + * Must have [pluginPackagePrefix] prefix and end with `.debug` if it's debug variant + */ + val packageName: String, + /** + * For future incompatible updates + */ + val apiVersion: String, + /** + * May provide gettext domain + */ + val domain: String?, + /** + * 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 = packageName.removePrefix(pluginPackagePrefix).removeSuffix(pluginPackageSuffix) + + companion object { + 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 new file mode 100644 index 000000000..7a5f1a30b --- /dev/null +++ b/app/src/main/java/org/fcitx/fcitx5/android/core/data/PluginLoadFailed.kt @@ -0,0 +1,29 @@ +/* + * 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 { + data class PathConflict( + val plugin: PluginDescriptor, + val path: String, + val existingSrc: FileSource + ) : PluginLoadFailed + + data object MissingPluginDescriptor : PluginLoadFailed + + data object PluginDescriptorParseError : PluginLoadFailed + + data class MissingDataDescriptor( + val plugin: PluginDescriptor + ) : PluginLoadFailed + + data class DataDescriptorParseError( + val plugin: PluginDescriptor + ) : PluginLoadFailed + + data class PluginAPIIncompatible( + val api: String + ) : PluginLoadFailed +} \ No newline at end of file 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 6b0d95f0e..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,5 +1,10 @@ +/* + * 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 import org.fcitx.fcitx5.android.core.FcitxAPI /** @@ -30,4 +35,6 @@ interface FcitxConnection { * This function does not block or suspend the caller. */ fun runIfReady(block: suspend FcitxAPI.() -> Unit) + + val lifecycleScope: CoroutineScope } \ No newline at end of file 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 972284570..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,11 +1,27 @@ +/* + * 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 +import android.app.NotificationManager +import android.os.Build +import androidx.core.app.NotificationCompat +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking -import org.fcitx.fcitx5.android.core.* +import org.fcitx.fcitx5.android.FcitxApplication +import org.fcitx.fcitx5.android.R +import org.fcitx.fcitx5.android.core.Fcitx +import org.fcitx.fcitx5.android.core.FcitxAPI +import org.fcitx.fcitx5.android.core.FcitxLifecycle +import org.fcitx.fcitx5.android.core.lifeCycleScope +import org.fcitx.fcitx5.android.core.whenReady import org.fcitx.fcitx5.android.daemon.FcitxDaemon.connect import org.fcitx.fcitx5.android.daemon.FcitxDaemon.disconnect import org.fcitx.fcitx5.android.utils.appContext +import org.fcitx.fcitx5.android.utils.notificationManager import timber.log.Timber import java.util.concurrent.locks.ReentrantLock import kotlin.concurrent.withLock @@ -55,6 +71,9 @@ object FcitxDaemon { } } + override val lifecycleScope: CoroutineScope + get() = realFcitx.lifecycle.lifecycleScope + } private val lock = ReentrantLock() @@ -68,7 +87,7 @@ object FcitxDaemon { if (name in clients) return@withLock clients.getValue(name) if (realFcitx.lifecycle.currentState == FcitxLifecycle.State.STOPPED) { - Timber.d("Start fcitx") + Timber.d("FcitxDaemon start fcitx") realFcitx.start() } val new = mkConnection(name) @@ -84,7 +103,7 @@ object FcitxDaemon { return clients -= name if (clients.isEmpty()) { - Timber.d("Stop fcitx") + Timber.d("FcitxDaemon stop fcitx") realFcitx.stop() } } @@ -93,8 +112,66 @@ object FcitxDaemon { * Restart fcitx instance while keep the clients connected */ fun restartFcitx() = lock.withLock { + val id = RESTART_ID++ + NotificationCompat.Builder(appContext, CHANNEL_ID) + .setSmallIcon(R.drawable.ic_baseline_sync_24) + .setContentTitle(appContext.getString(R.string.fcitx_daemon)) + .setContentText(appContext.getString(R.string.restarting_fcitx)) + .setOngoing(true) + .setProgress(100, 0, true) + .setPriority(NotificationCompat.PRIORITY_HIGH) + .build().let { appContext.notificationManager.notify(id, it) } + realFcitx.stop() + realFcitx.start() + FcitxApplication.getInstance().coroutineScope.launch { + // cancel notification on ready + realFcitx.lifecycle.whenReady { + appContext.notificationManager.cancel(id) + } + } + } + + /** + * 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() + } + + private fun createNotificationChannel() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val channel = NotificationChannel( + CHANNEL_ID, + appContext.getText(R.string.fcitx_daemon), + NotificationManager.IMPORTANCE_HIGH + ).apply { description = CHANNEL_ID } + appContext.notificationManager.createNotificationChannel(channel) + } + } + + /** + * Reuse a connection for remote service + */ + fun getFirstConnectionOrNull() = clients.firstNotNullOfOrNull { it.value } + + + private const val CHANNEL_ID = "fcitx-daemon" + private var RESTART_ID = 0 + } \ No newline at end of file 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 033159b50..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,16 +1,15 @@ +/* + * 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 import kotlinx.coroutines.launch import org.fcitx.fcitx5.android.core.FcitxAPI -fun CoroutineScope.launchOnFcitxReady( - connection: FcitxConnection, - block: suspend CoroutineScope.(FcitxAPI) -> Unit -) { - launch { - connection.runOnReady { - this@launch.block(this) - } +fun FcitxConnection.launchOnReady(block: suspend CoroutineScope.(FcitxAPI) -> Unit) { + lifecycleScope.launch { + runOnReady { block(this) } } } \ No newline at end of file diff --git a/app/src/main/java/org/fcitx/fcitx5/android/data/DataManager.kt b/app/src/main/java/org/fcitx/fcitx5/android/data/DataManager.kt deleted file mode 100644 index bf4bc130c..000000000 --- a/app/src/main/java/org/fcitx/fcitx5/android/data/DataManager.kt +++ /dev/null @@ -1,151 +0,0 @@ -package org.fcitx.fcitx5.android.data - -import kotlinx.serialization.Serializable -import kotlinx.serialization.decodeFromString -import kotlinx.serialization.json.Json -import org.fcitx.fcitx5.android.utils.Const -import org.fcitx.fcitx5.android.utils.appContext -import timber.log.Timber -import java.io.File -import java.util.concurrent.locks.ReentrantLock -import kotlin.concurrent.withLock - -typealias SHA256 = String - -object DataManager { - - @Serializable - data class DataDescriptor( - val sha256: SHA256, - val files: Map - ) - - sealed class Diff { - abstract val key: String - abstract val order: Int - - data class New(override val key: String, val new: String) : Diff() { - override val order: Int - get() = 3 - } - - data class Update(override val key: String, val old: String, val new: String) : Diff() { - override val order: Int - get() = 2 - } - - data class Delete(override val key: String, val old: String) : Diff() { - override val order: Int - get() = 0 - } - - data class DeleteDir(override val key: String) : Diff() { - override val order: Int - get() = 1 - } - } - - val dataDir = File(appContext.applicationInfo.dataDir) - private val destDescriptorFile = File(dataDir, Const.dataDescriptorName) - - private val lock = ReentrantLock() - - // should be consistent with the deserialization in build.gradle.kts (:app) - private fun deserialize(raw: String) = runCatching { - Json.decodeFromString(raw) - } - - private fun diff(old: DataDescriptor, new: DataDescriptor): List = - if (old.sha256 == new.sha256) - listOf() - else - new.files.mapNotNull { - when { - // empty sha256 -> dir - it.key !in old.files && it.value.isNotBlank() -> Diff.New(it.key, it.value) - old.files[it.key] != it.value -> - // if the new one is not a dir - if (it.value.isNotBlank()) - Diff.Update( - it.key, - old.files.getValue(it.key), - it.value - ) - else null - else -> null - } - }.toMutableList().apply { - addAll(old.files.filterKeys { it !in new.files } - .map { - if (it.value.isNotBlank()) - Diff.Delete(it.key, it.value) - else - Diff.DeleteDir(it.key) - }) - } - - fun sync() = lock.withLock { - val destDescriptor = - destDescriptorFile - .takeIf { it.exists() && it.isFile } - ?.runCatching { readText() } - ?.getOrNull() - ?.let { deserialize(it) } - ?.getOrNull() - ?: DataDescriptor("", mapOf()) - - val bundledDescriptor = - appContext.assets - .open(Const.dataDescriptorName) - .bufferedReader() - .use { it.readText() } - .let { deserialize(it) } - .getOrThrow() - - val d = diff(destDescriptor, bundledDescriptor).sortedBy { it.order } - d.forEach { - Timber.d("Diff: $it") - when (it) { - is Diff.Delete -> deleteFile(it.key) - is Diff.DeleteDir -> deleteDir(it.key) - is Diff.New -> copyFile(it.key) - is Diff.Update -> copyFile(it.key) - } - } - - copyFile(Const.dataDescriptorName) - - Timber.i("Synced!") - } - - fun deleteAndSync() { - dataDir.deleteRecursively() - sync() - } - - private fun deleteFile(path: String) { - val file = File(dataDir, path) - if (file.exists() && file.isFile) - file.delete() - } - - private fun deleteDir(path: String) { - val dir = File(dataDir, path) - if (dir.exists() && dir.isDirectory) - dir.deleteRecursively() - } - - private fun copyFile(filename: String) { - with(appContext.assets) { - open(filename).use { i -> - File(dataDir, filename) - .also { it.parentFile?.mkdirs() } - .outputStream().use { o -> - i.copyTo(o) - Unit - } - } - } - } - -} 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/Licenses.kt b/app/src/main/java/org/fcitx/fcitx5/android/data/Licenses.kt deleted file mode 100644 index ee549ee45..000000000 --- a/app/src/main/java/org/fcitx/fcitx5/android/data/Licenses.kt +++ /dev/null @@ -1,53 +0,0 @@ -package org.fcitx.fcitx5.android.data - -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext -import kotlinx.serialization.Serializable -import kotlinx.serialization.builtins.ListSerializer -import kotlinx.serialization.builtins.MapSerializer -import kotlinx.serialization.builtins.serializer -import kotlinx.serialization.json.Json -import org.fcitx.fcitx5.android.utils.appContext - -object Licenses { - - @Serializable - data class LibraryLicense( - val artifactId: LibraryArtifactID, - val license: String? = null, - val licenseUrl: String? = null, - val normalizedLicense: String? = null, - val url: String? = null, - val libraryName: String, - ) - - @Serializable - data class LibraryArtifactID( - val name: String, - val group: String, - val version: String - ) - - private var parsed: List? = null - - suspend fun getAll(): Result> = runCatching { - parsed?.let { return@runCatching it } - withContext(Dispatchers.IO) { - val content = - appContext.assets.open(licensesJSON).bufferedReader().use { x -> x.readText() } - val list = Json.decodeFromString( - MapSerializer( - String.serializer(), - ListSerializer(LibraryLicense.serializer()) - ), - content - )["libraries"]!! - .filter { !it.licenseUrl.isNullOrEmpty() } - .sortedBy { it.libraryName } - parsed = list - list - } - } - - private const val licensesJSON = "licenses.json" -} \ 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 0b9077349..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,19 +1,31 @@ +/* + * 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 kotlinx.coroutines.* +import androidx.room.withTransaction +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext import org.fcitx.fcitx5.android.data.clipboard.db.ClipboardDao import org.fcitx.fcitx5.android.data.clipboard.db.ClipboardDatabase 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.utils.UTF8Utils import org.fcitx.fcitx5.android.utils.WeakHashSet -import splitties.systemservices.clipboardManager +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) { @@ -24,6 +36,8 @@ object ClipboardManager : ClipboardManager.OnPrimaryClipChangedListener, fun onUpdate(entry: ClipboardEntry) } + private val clipboardManager = appContext.clipboardManager + private val mutex = Mutex() var itemCount: Int = 0 @@ -35,6 +49,8 @@ object ClipboardManager : ClipboardManager.OnPrimaryClipChangedListener, private val onUpdateListeners = WeakHashSet() + var transformer: ((String) -> String)? = null + fun addOnUpdateListener(listener: OnClipboardUpdateListener) { onUpdateListeners.add(listener) } @@ -44,6 +60,8 @@ object ClipboardManager : ClipboardManager.OnPrimaryClipChangedListener, } private val enabledPref = AppPrefs.getInstance().clipboard.clipboardListening + + @Keep private val enabledListener = ManagedPreference.OnChangeListener { _, value -> if (value) { clipboardManager.addPrimaryClipChangedListener(this) @@ -53,6 +71,8 @@ object ClipboardManager : ClipboardManager.OnPrimaryClipChangedListener, } private val limitPref = AppPrefs.getInstance().clipboard.clipboardHistoryLimit + + @Keep private val limitListener = ManagedPreference.OnChangeListener { _, _ -> launch { removeOutdated() } } @@ -67,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()) @@ -78,7 +100,9 @@ object ClipboardManager : ClipboardManager.OnPrimaryClipChangedListener, suspend fun get(id: Int) = clbDao.get(id) - suspend fun getAll() = clbDao.getAll() + suspend fun haveUnpinned() = clbDao.haveUnpinned() + + fun allEntries() = clbDao.allEntries() suspend fun pin(id: Int) = clbDao.updatePinStatus(id, true) @@ -92,16 +116,28 @@ object ClipboardManager : ClipboardManager.OnPrimaryClipChangedListener, } suspend fun delete(id: Int) { - clbDao.delete(id) + clbDao.markAsDeleted(id) updateItemCount() } - suspend fun deleteAll(skipPinned: Boolean = true) { - if (skipPinned) - clbDao.deleteAllUnpinned() - else - clbDao.deleteAll() + suspend fun deleteAll(skipPinned: Boolean = true): IntArray { + val ids = if (skipPinned) { + clbDao.findUnpinnedIds() + } else { + clbDao.findAllIds() + } + clbDao.markAsDeleted(*ids) updateItemCount() + return ids + } + + suspend fun undoDelete(vararg ids: Int) { + clbDao.undoDelete(*ids) + updateItemCount() + } + + suspend fun realDelete() { + clbDao.realDelete() } suspend fun nukeTable() { @@ -111,38 +147,62 @@ 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() && UTF8Utils.instance.validateUTF8(it.text) } - ?.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() { val limit = limitPref.getValue() - val unpinned = clbDao.getAll().filter { !it.pinned } + val unpinned = clbDao.getAllUnpinned() if (unpinned.size > limit) { // the last one we will keep val last = unpinned .sortedBy { it.id } .getOrNull(unpinned.size - limit) // delete all unpinned before that, or delete all when limit <= 0 - clbDao.deleteUnpinnedIdLessThan(last?.timestamp ?: System.currentTimeMillis()) + clbDao.markUnpinnedAsDeletedEarlierThan(last?.timestamp ?: System.currentTimeMillis()) } } 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 04d6861eb..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,5 +1,10 @@ +/* + * 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 import androidx.room.Dao import androidx.room.Insert import androidx.room.Query @@ -18,30 +23,42 @@ interface ClipboardDao { @Query("UPDATE ${ClipboardEntry.TABLE_NAME} SET timestamp=:timestamp WHERE id=:id") suspend fun updateTime(id: Int, timestamp: Long) - @Query("SELECT COUNT(*) FROM ${ClipboardEntry.TABLE_NAME}") + @Query("SELECT COUNT(*) FROM ${ClipboardEntry.TABLE_NAME} WHERE deleted=0") suspend fun itemCount(): Int - @Query("SELECT * FROM ${ClipboardEntry.TABLE_NAME} WHERE id=:id LIMIT 1") + @Query("SELECT * FROM ${ClipboardEntry.TABLE_NAME} WHERE id=:id AND deleted=0 LIMIT 1") suspend fun get(id: Int): ClipboardEntry? - @Query("SELECT * FROM ${ClipboardEntry.TABLE_NAME} WHERE rowId=:rowId LIMIT 1") + @Query("SELECT * FROM ${ClipboardEntry.TABLE_NAME} WHERE rowId=:rowId AND deleted=0 LIMIT 1") suspend fun get(rowId: Long): ClipboardEntry? - @Query("SELECT * FROM ${ClipboardEntry.TABLE_NAME}") - suspend fun getAll(): List + @Query("SELECT EXISTS(SELECT 1 FROM ${ClipboardEntry.TABLE_NAME} WHERE pinned=0 AND deleted=0)") + suspend fun haveUnpinned(): Boolean - @Query("SELECT * FROM ${ClipboardEntry.TABLE_NAME} WHERE text=:text LIMIT 1") - suspend fun find(text: String): ClipboardEntry? + @Query("SELECT * FROM ${ClipboardEntry.TABLE_NAME} WHERE pinned=0 AND deleted=0") + suspend fun getAllUnpinned(): List - @Query("DELETE FROM ${ClipboardEntry.TABLE_NAME} WHERE timestamp<:timestamp AND NOT pinned") - suspend fun deleteUnpinnedIdLessThan(timestamp: Long) + @Query("SELECT * FROM ${ClipboardEntry.TABLE_NAME} WHERE deleted=0 ORDER BY pinned DESC, timestamp DESC") + fun allEntries(): PagingSource - @Query("DELETE FROM ${ClipboardEntry.TABLE_NAME} WHERE id=:id") - suspend fun delete(id: Int) + @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("DELETE FROM ${ClipboardEntry.TABLE_NAME} WHERE NOT pinned") - suspend fun deleteAllUnpinned() + @Query("SELECT id FROM ${ClipboardEntry.TABLE_NAME} WHERE deleted=0") + suspend fun findAllIds(): IntArray - @Query("DELETE FROM ${ClipboardEntry.TABLE_NAME}") - suspend fun deleteAll() + @Query("SELECT id FROM ${ClipboardEntry.TABLE_NAME} WHERE pinned=0 AND deleted=0") + suspend fun findUnpinnedIds(): IntArray + + @Query("UPDATE ${ClipboardEntry.TABLE_NAME} SET deleted=1 WHERE id in (:ids)") + suspend fun markAsDeleted(vararg ids: Int) + + @Query("UPDATE ${ClipboardEntry.TABLE_NAME} SET DELETED=1 WHERE timestamp<:timestamp AND pinned=0 AND deleted=0") + suspend fun markUnpinnedAsDeletedEarlierThan(timestamp: Long) + + @Query("UPDATE ${ClipboardEntry.TABLE_NAME} SET deleted=0 WHERE id in (:ids) AND deleted=1") + suspend fun undoDelete(vararg ids: Int) + + @Query("DELETE FROM ${ClipboardEntry.TABLE_NAME} WHERE deleted=1") + suspend fun realDelete() } \ No newline at end of file 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 241adc4e7..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,9 +10,11 @@ import androidx.room.RoomDatabase @Database( entities = [ClipboardEntry::class], - version = 2, + version = 4, autoMigrations = [ - AutoMigration(from = 1, to = 2) + AutoMigration(from = 1, to = 2), + 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 3a09dcbe3..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( @@ -15,14 +21,42 @@ data class ClipboardEntry( @ColumnInfo(defaultValue = "-1") val timestamp: Long = System.currentTimeMillis(), @ColumnInfo(defaultValue = ClipDescription.MIMETYPE_TEXT_PLAIN) - val type: String = ClipDescription.MIMETYPE_TEXT_PLAIN + 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 a4011f013..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 @@ -11,34 +17,38 @@ import java.io.IOException import java.io.InputStream object PinyinDictManager { - init { - System.loadLibrary("pinyindictionaryutils") - } private val pinyinDicDir = File( 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 40615797a..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,13 +1,29 @@ +/* + * 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.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.utils.getSystemProperty -import splitties.systemservices.vibrator +import org.fcitx.fcitx5.android.input.popup.EmojiModifier +import org.fcitx.fcitx5.android.utils.DeviceUtil +import org.fcitx.fcitx5.android.utils.appContext +import org.fcitx.fcitx5.android.utils.vibrator class AppPrefs(private val sharedPreferences: SharedPreferences) { @@ -18,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 @@ -50,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 } @@ -71,39 +102,52 @@ class AppPrefs(private val sharedPreferences: SharedPreferences) { 255, defaultLabel = R.string.system_default ) { - buttonHapticFeedback.getValue() - && Build.VERSION.SDK_INT >= Build.VERSION_CODES.O - && 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) + val toolbarNumRowOnPassword = + switch(R.string.toolbar_num_row_on_password, "toolbar_num_row_on_password", true) val popupOnKeyPress = switch(R.string.popup_on_key_press, "popup_on_key_press", true) val keepLettersUppercase = switch( R.string.keep_keyboard_letters_uppercase, "keep_keyboard_letters_uppercase", false ) - val swipeSymbolDirection = list( + val showVoiceInputButton = + switch(R.string.show_voice_input_button, "show_voice_input_button", false) + val expandKeypressArea = + switch(R.string.expand_keypress_area, "expand_keypress_area", false) + val swipeSymbolDirection = 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, @@ -114,6 +158,20 @@ class AppPrefs(private val sharedPreferences: SharedPreferences) { "ms", 10 ) + val spaceKeyLongPressBehavior = enumList( + R.string.space_long_press_behavior, + "space_long_press_behavior", + 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 @@ -148,7 +206,7 @@ class AppPrefs(private val sharedPreferences: SharedPreferences) { "keyboard_side_padding_landscape", 0, 0, - 200, + 300, "dp" ) keyboardSidePadding = primary @@ -175,21 +233,15 @@ class AppPrefs(private val sharedPreferences: SharedPreferences) { keyboardBottomPaddingLandscape = secondary } - val horizontalCandidateGrowth = - switch(R.string.horizontal_candidate_growth, "horizontal_candidate_growth", true) - val expandedCandidateStyle = list( + val horizontalCandidateStyle = enumList( + R.string.horizontal_candidate_style, + "horizontal_candidate_style", + HorizontalCandidateMode.AutoFillWidth + ) + 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 @@ -206,13 +258,67 @@ class AppPrefs(private val sharedPreferences: SharedPreferences) { 8, 4, 12, - ) { expandedCandidateStyle.getValue() == ExpandedCandidateStyle.Grid } + ) expandedCandidateGridSpanCount = primary expandedCandidateGridSpanCountLandscape = secondary } } + 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( @@ -231,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() @@ -249,17 +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]?.apply { - 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 7032f1062..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 @@ -22,29 +25,41 @@ abstract class ManagedPreference( fun onChange(key: String, value: T) } - private val listeners by lazy { WeakHashSet>() } - abstract fun setValue(value: T) 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 lateinit var listeners: MutableSet> + /** * **WARN:** No anonymous listeners, please **KEEP** the reference! + * + * You may need to reference the listener once outside of it's container's constructor, + * to prevent R8 from removing the field; + * 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() { - listeners.forEach { with(it) { onChange(key, getValue()) } } + if (!::listeners.isInitialized || listeners.isEmpty()) return + val newValue = getValue() + listeners.forEach { it.onChange(key, newValue) } } class PBool(sharedPreferences: SharedPreferences, key: String, defaultValue: Boolean) : @@ -54,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) : @@ -64,27 +90,45 @@ 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( sharedPreferences: SharedPreferences, key: String, defaultValue: T, - val codec: StringLikeCodec - ) : - ManagedPreference(sharedPreferences, key, defaultValue) { + private val codec: StringLikeCodec + ) : ManagedPreference(sharedPreferences, key, defaultValue) { override fun setValue(value: T) { 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())) + } } @@ -95,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) : @@ -104,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 68ba304e6..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,7 @@ 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 import org.fcitx.fcitx5.android.ui.main.settings.TwinSeekBarPreference @@ -27,8 +31,8 @@ abstract class ManagedPreferenceUi( @StringRes val summary: Int? = null, enableUiOn: (() -> Boolean)? = null - ) : ManagedPreferenceUi(key, enableUiOn) { - override fun createUi(context: Context) = SwitchPreference(context).apply { + ) : ManagedPreferenceUi(key, enableUiOn) { + override fun createUi(context: Context) = MySwitchPreference(context).apply { key = this@Switch.key isIconSpaceReserved = false isSingleLineTitle = false 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 69840d849..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,5 +1,11 @@ +/* + * 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 + class ManagedPreferenceVisibilityEvaluator( private val provider: ManagedPreferenceProvider, private val onVisibilityChanged: (Map) -> Unit @@ -8,14 +14,13 @@ class ManagedPreferenceVisibilityEvaluator( private val visibility = mutableMapOf() // it would be better to declare the dependency relationship, rather than reevaluating on each value changed - private val onValueChangeListener = ManagedPreference.OnChangeListener { _, _ -> + @Keep + private val onValueChangeListener = ManagedPreferenceProvider.OnChangeListener { evaluateVisibility() } init { - provider.managedPreferences.forEach { (_, pref) -> - pref.registerOnChangeListener(onValueChangeListener) - } + provider.registerOnChangeListener(onValueChangeListener) } fun evaluateVisibility() { @@ -33,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 224947c66..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.data.DataManager +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 2ec0c0825..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,40 +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 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.FcitxIni +import org.fcitx.fcitx5.android.utils.Ini import org.fcitx.fcitx5.android.utils.Locales import org.fcitx.fcitx5.android.utils.errorRuntime import timber.log.Timber import java.io.File -class TableBasedInputMethod(private val ini: FcitxIni, val file: File) { +class TableBasedInputMethod(val file: File) { + + private var ini = Ini.parseIniFromFile(file) ?: errorRuntime(R.string.invalid_im, file.name) var table: LibIMEDictionary? = null val name: String by lazy { - Name.format() - ini[InputMethod]?.let { im -> - im[NameI18n.format(Locales.languageWithCountry)] - ?: im[NameI18n.format(Locales.language)] - ?: im[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.get(Table, File)?.let { File(it).name } - ?: errorRuntime(R.string.invalid_im, ERROR_MISSING_TABLE_OR_FILE) + get() = ini.get(Table, File)?.value + ?.substringAfterLast('/') + ?: errorRuntime(R.string.invalid_im, ERROR_MISSING_TABLE_OR_FILE) set(value) { - ini.put(Table, File, "table/$value") - ?: errorRuntime(R.string.invalid_im, ERROR_MISSING_TABLE_OR_FILE) + ini.set(Table, File, str = "table/$value") } val tableFileExists get() = table != null fun save() { - ini.store() + Ini.writeIniToFile(ini, file) } fun delete() { @@ -59,8 +67,8 @@ class TableBasedInputMethod(private val ini: FcitxIni, val file: File) { .lowercase() + ".main.dict" fun new(configFile: File): TableBasedInputMethod { - val im = TableBasedInputMethod(FcitxIni(configFile), configFile) - Timber.i("Importing table im name=${im.name} tableFileName=${im.tableFileName}") + val im = TableBasedInputMethod(configFile) + Timber.d("new TableBasedInputMethod(name=${im.name}, tableFileName=${im.tableFileName})") return im } } 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 a12626036..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 @@ -13,10 +17,6 @@ import java.util.zip.ZipInputStream object TableManager { - init { - System.loadLibrary("tabledictionaryutils") - } - private val inputMethodDir = File( appContext.getExternalFilesDir(null)!!, "data/inputmethod" ).also { it.mkdirs() } @@ -26,31 +26,27 @@ 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 { ZipInputStream(src).use { zipStream -> withTempDir { tempDir -> val extracted = zipStream.extract(tempDir) - val confFile = - extracted.find { it.name.endsWith(".conf") || it.name.endsWith(".conf.in") } - ?: errorRuntime(R.string.exception_table_im) - val dictFile = - extracted.find { it.name.endsWith(".dict") || it.name.endsWith(".txt") } - ?: errorRuntime(R.string.exception_table) + val confFile = extracted.find { it.name.endsWith(".conf") } + ?: extracted.find { it.name.endsWith(".conf.in") } + ?: errorRuntime(R.string.exception_table_im) + val dictFile = extracted.find { it.name.endsWith(".dict") } + ?: extracted.find { it.name.endsWith(".txt") } + ?: errorRuntime(R.string.exception_table) importFiles(confFile, dictFile) } } @@ -63,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) } @@ -83,7 +79,7 @@ object TableManager { TableBasedInputMethod.new(importedConfFile) }.getOrElse { importedConfFile.delete() - errorRuntime(R.string.invalid_im, it.message) + throw it } val table = Dictionary.new(dictFile)!! im.tableFileName = TableBasedInputMethod.fixedTableFileName(table.name) @@ -97,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 f8c3ca5cf..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,6 +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.table.dict +import org.fcitx.fcitx5.android.R import org.fcitx.fcitx5.android.data.table.TableManager +import org.fcitx.fcitx5.android.utils.errorArg import java.io.File class LibIMEDictionary(file: File) : Dictionary() { @@ -10,11 +16,10 @@ class LibIMEDictionary(file: File) : Dictionary() { override val type: Type = Type.LibIME - override val name: String - get() = file.name.substringBefore(".${type.ext}") - init { ensureFileExists() + if (file.extension != type.ext) + errorArg(R.string.exception_dict_filename, file.name) } override fun toTextDictionary(dest: File): TextDictionary { 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 d6b755782..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 @@ -6,6 +10,7 @@ import org.fcitx.fcitx5.android.utils.errorArg import java.io.File class TextDictionary(file: File) : Dictionary() { + override var file: File = file private set 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 4cebac55b..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,11 @@ 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 sealed class Theme : Parcelable { @@ -25,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 @@ -62,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, @@ -83,11 +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 bitmap = BitmapFactory.decodeFile(croppedFilePath) ?: return null + 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) } } } @@ -107,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, @@ -132,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, @@ -153,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(), @@ -177,6 +203,9 @@ sealed class Theme : Parcelable { keyboardColor, keyBackgroundColor, keyTextColor, + candidateTextColor, + candidateLabelColor, + candidateCommentColor, altKeyBackgroundColor, altKeyTextColor, accentKeyBackgroundColor, @@ -198,6 +227,7 @@ sealed class Theme : Parcelable { originBackgroundImage: String, brightness: Int = 70, cropBackgroundRect: Rect? = null, + cropBackgroundRotation: Int = 0 ) = Custom( name, isDark, @@ -205,13 +235,17 @@ sealed class Theme : Parcelable { croppedBackgroundImage, originBackgroundImage, brightness, - cropBackgroundRect + cropBackgroundRect, + cropBackgroundRotation ), backgroundColor, barColor, keyboardColor, keyBackgroundColor, keyTextColor, + candidateTextColor, + candidateLabelColor, + candidateCommentColor, altKeyBackgroundColor, altKeyTextColor, accentKeyBackgroundColor, @@ -228,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 604533f44..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,375 +1,158 @@ +/* + * 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 androidx.annotation.StringRes -import kotlinx.serialization.json.Json -import org.fcitx.fcitx5.android.R +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.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.* -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 { - fun interface OnThemeChangedListener { - fun onThemeChanged(theme: Theme) + fun interface OnThemeChangeListener { + fun onThemeChange(theme: Theme) } - private val dir = File(appContext.getExternalFilesDir(null), "theme").also { it.mkdirs() } - - private val onChangeListeners = WeakHashSet() - - fun addOnChangedListener(listener: OnThemeChangedListener) { - onChangeListeners.add(listener) - } - - fun removeOnChangedListener(listener: OnThemeChangedListener) { - 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) - - val keyRippleEffect = switch(R.string.key_ripple_effect, "key_ripple_effect", false) + private var isDarkMode = false - val keyHorizontalMargin = - int(R.string.key_horizontal_margin, "key_horizontal_margin", 3, 0, 8, "dp") + private val onChangeListeners = WeakHashSet() - val keyVerticalMargin = - int(R.string.key_vertical_margin, "key_vertical_margin", 7, 0, 16, "dp") + fun addOnChangedListener(listener: OnThemeChangeListener) { + onChangeListeners.add(listener) + } - val keyRadius = int(R.string.key_radius, "key_radius", 4, 0, 48, "dp") + fun removeOnChangedListener(listener: OnThemeChangeListener) { + onChangeListeners.remove(listener) + } - 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 - ) - ) + 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 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() - }) - - 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 } - } - 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, - ) - - private val onActiveThemeNameChange = ManagedPreference.OnChangeListener { _, it -> - currentTheme = getTheme(internalPrefs.activeThemeName.getValue()) - ?: errorState(R.string.exception_theme_unknown, it) - this@ThemeManager.fireChange() + fun setNormalModeTheme(theme: Theme) { + // `normalModeTheme.setValue(theme)` would trigger `onThemePrefsChange` listener, + // which calls `fireChange()`. + // `activateTheme`'s setter would also trigger `fireChange()` when theme actually changes. + // write to backing property directly to avoid unnecessary `fireChange()` + _activeTheme = theme + prefs.normalModeTheme.setValue(theme) } - private val prefsChange = ManagedPreference.OnChangeListener { _, _ -> - this@ThemeManager.fireChange() + private fun evaluateActiveTheme(): Theme { + return if (prefs.followSystemDayNightTheme.getValue()) { + if (isDarkMode) prefs.darkModeTheme else prefs.lightModeTheme + } else { + prefs.normalModeTheme + }.getValue() } - private val dayLightThemePrefsChange = ManagedPreference.OnChangeListener { _, _ -> - onSystemDarkModeChanged() + @Keep + private val onThemePrefsChange = ManagedPreferenceProvider.OnChangeListener { key -> + if (prefs.dayNightModePrefNames.contains(key)) { + 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(prefsChange) - } - 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.followSystemDayNightTheme.registerOnChangeListener(dayLightThemePrefsChange) - prefs.lightModeTheme.registerOnChangeListener(dayLightThemePrefsChange) - prefs.darkModeTheme.registerOnChangeListener(dayLightThemePrefsChange) + prefs.registerOnChangeListener(onThemePrefsChange) + _activeTheme = evaluateActiveTheme() } - private lateinit var currentTheme: Theme - - fun fireChange() { - onChangeListeners.forEach { it.onThemeChanged(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 onSystemDarkModeChanged(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 afc5f2f5c..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,33 +1,78 @@ +/* + * 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 +import android.graphics.drawable.Icon import android.os.Build +import android.os.Bundle import android.os.SystemClock import android.text.InputType import android.util.LruCache -import android.view.* +import android.util.Size +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 +import androidx.annotation.RequiresApi +import androidx.autofill.inline.UiVersions +import androidx.autofill.inline.common.ImageViewStyle +import androidx.autofill.inline.common.TextViewStyle +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.flow.launchIn -import kotlinx.coroutines.flow.onEach -import org.fcitx.fcitx5.android.core.* +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.channels.consumeEach +import kotlinx.coroutines.launch +import org.fcitx.fcitx5.android.R +import org.fcitx.fcitx5.android.core.CapabilityFlags +import org.fcitx.fcitx5.android.core.FcitxAPI +import org.fcitx.fcitx5.android.core.FcitxEvent +import org.fcitx.fcitx5.android.core.FcitxKeyMapping +import org.fcitx.fcitx5.android.core.FormattedText +import org.fcitx.fcitx5.android.core.KeyStates +import org.fcitx.fcitx5.android.core.KeySym +import org.fcitx.fcitx5.android.core.ScancodeMapping +import org.fcitx.fcitx5.android.core.SubtypeManager import org.fcitx.fcitx5.android.daemon.FcitxConnection import org.fcitx.fcitx5.android.daemon.FcitxDaemon -import org.fcitx.fcitx5.android.daemon.launchOnFcitxReady +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 @@ -35,76 +80,161 @@ 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 lateinit var pkgNameCache: PackageNameCache + private lateinit var decorView: View + private lateinit var contentView: FrameLayout private var inputView: InputView? = null + private var candidatesView: CandidatesView? = null + + private val navbarMgr = NavigationBarManager() + private val inputDeviceMgr = InputDeviceManager onChange@{ + val w = window.window ?: return@onChange + navbarMgr.evaluate(w, useVirtualKeyboard = it) + } + + private var capabilityFlags = CapabilityFlags.DefaultFlags - var editorInfo: EditorInfo? = null + private val selection = CursorTracker() - val selection = CursorTracker() - val composing = CursorRange() - private var composingText = FormattedText() + val currentInputSelection: CursorRange + get() = selection.latest + + private val composing = CursorRange() + private var composingText = FormattedText.Empty + + private fun resetComposingState() { + composing.clear() + composingText = FormattedText.Empty + } private var cursorUpdateIndex: Int = 0 private var highlightColor: Int = 0x66008577 // material_deep_teal_500 with alpha 0.4 - private val ignoreSystemCursor by AppPrefs.getInstance().advanced.ignoreSystemCursor + private val prefs = AppPrefs.getInstance() + private val inlineSuggestions by prefs.keyboard.inlineSuggestions + private val ignoreSystemCursor by prefs.advanced.ignoreSystemCursor + + private val 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 recreateCandidatesViewListener = ManagedPreferenceProvider.OnChangeListener { + replaceCandidateView(ThemeManager.activeTheme) } - private val onThemeChangedListener = ThemeManager.OnThemeChangedListener { - recreateInputView(it) + @Keep + private val onThemeChangeListener = ThemeManager.OnThemeChangeListener { + replaceInputViews(it) } - private fun recreateInputView(theme: Theme) { - // InputView should be created first in onCreateInputView - // setInputView should be used to 'replace' current InputView only - inputView?.onDestroy() ?: return - InputView(this, fcitx, theme).also { - inputView = it - setInputView(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 = fcitx.runImmediately { eventFlow } - .onEach(::handleFcitxEvent).launchIn(lifecycleScope) + 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()) + } } - ThemeManager.addOnChangedListener(onThemeChangedListener) 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 @@ -120,18 +250,42 @@ 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") } } } } - is FcitxEvent.PreeditEvent -> { - updateComposingText(event.data.clientPreedit) + is FcitxEvent.ClientPreeditEvent -> { + updateComposingText(event.data) + } + is FcitxEvent.DeleteSurroundingEvent -> { + val (before, after) = event.data + handleDeleteSurrounding(before, after) } - else -> { + 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) } } @@ -142,36 +296,32 @@ class FcitxInputMethodService : LifecycleInputMethodService() { } else if (lastSelection.start > 0) { selection.predictOffset(-1) } - editorInfo?.apply { - // In practice nobody (apart form us) would set `privateImeOptions` to our - // `DeleteSurroundingFlag`, leading to a behavior of simulating backspace key pressing - // in almost every EditText. - if (privateImeOptions != DeleteSurroundingFlag || - inputType and InputType.TYPE_MASK_CLASS == InputType.TYPE_NULL - ) { + // 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 (currentInputEditorInfo.privateImeOptions != DeleteSurroundingFlag || + currentInputEditorInfo.inputType and InputType.TYPE_MASK_CLASS == InputType.TYPE_NULL + ) { + sendDownUpKeyEvents(KeyEvent.KEYCODE_DEL) + return + } + if (lastSelection.isEmpty()) { + if (lastSelection.start <= 0) { sendDownUpKeyEvents(KeyEvent.KEYCODE_DEL) return } - inputConnection?.apply { - if (lastSelection.isEmpty()) { - if (lastSelection.start <= 0) { - sendDownUpKeyEvents(KeyEvent.KEYCODE_DEL) - return - } - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { - deleteSurroundingTextInCodePoints(1, 0) - } else { - deleteSurroundingText(1, 0) - } - } else { - commitText("", 0) - } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + currentInputConnection.deleteSurroundingTextInCodePoints(1, 0) + } else { + currentInputConnection.deleteSurroundingText(1, 0) } - } ?: sendDownUpKeyEvents(KeyEvent.KEYCODE_DEL) + } else { + currentInputConnection.commitText("", 0) + } } private fun handleReturnKey() { - editorInfo?.apply { + currentInputEditorInfo.run { if (inputType and InputType.TYPE_MASK_CLASS == InputType.TYPE_NULL) { sendDownUpKeyEvents(KeyEvent.KEYCODE_ENTER) return @@ -181,30 +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) } - } ?: sendDownUpKeyEvents(KeyEvent.KEYCODE_ENTER) + } } - 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) { + val c = if (cursor == -1) text.length else cursor + val target = composing.start + c + resetComposingState() + 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) - // clear composing range - composing.clear() - composingText = FormattedText() - inputConnection?.commitText(text, 1) + resetComposingState() + 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, @@ -213,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(), @@ -229,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 ) ) @@ -239,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( @@ -248,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 @@ -262,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 @@ -281,33 +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) - lifecycleScope.launchOnFcitxReady(fcitx) { it.reset() } + postFcitxJob { reset() } } - override fun onCreateInputView(): View { - super.onCreateInputView() - 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 } @@ -317,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 { @@ -345,15 +539,22 @@ 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) { - lifecycleScope.launchOnFcitxReady(fcitx) { - 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) { - lifecycleScope.launchOnFcitxReady(fcitx) { it.sendKey(keySym, states, up, timestamp) } + postFcitxJob { + sendKey(keySym, states, event.scanCode, up, timestamp) + } return true } Timber.d("Skipped KeyEvent: $event") @@ -361,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) } @@ -368,44 +576,117 @@ 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.launchOnFcitxReady(fcitx) { - it.activate(uid, pkgName) + postFcitxJob { + // ensure InputContext has been created before focusing it + activate(uid, pkgName) + } + if (firstBindInput) { + firstBindInput = false + // only use input method from subtype for the first `onBindInput`, because + // 1. fcitx has `ShareInputState` option, thus reading input method from subtype + // everytime would ruin `ShareInputState=Program` + // 2. im from subtype should be read once, when user changes input method from other + // app to a subtype of ours via system input method picker (on 34+) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + val subtype = inputMethodManager.currentInputMethodSubtype ?: return + val im = SubtypeManager.inputMethodOf(subtype) + postFcitxJob { + activateIme(im) + } + } + } + } + + /** + * When input method changes internally (eg. via language switch key or keyboard shortcut), + * we want to notify system that subtype has changed (see [^1]), then ignore the incoming + * [onCurrentInputMethodSubtypeChanged] callback. + * Input method should only be changed when user changes subtype in system input method picker + * manually. + */ + private var skipNextSubtypeChange: String? = null + + override fun onCurrentInputMethodSubtypeChanged(newSubtype: InputMethodSubtype) { + super.onCurrentInputMethodSubtypeChanged(newSubtype) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + val im = SubtypeManager.inputMethodOf(newSubtype) + Timber.d("onCurrentInputMethodSubtypeChanged: im=$im") + // don't change input method if this "subtype change" was our notify to system + // see [^1] + if (skipNextSubtypeChange == im) { + skipNextSubtypeChange = null + return + } + postFcitxJob { + activateIme(im) + } } } override fun onStartInput(attribute: EditorInfo, restarting: Boolean) { // update selection as soon as possible + // sometimes when restarting input, onUpdateSelection happens before onStartInput, and + // initialSel{Start,End} is outdated. but it's the client app's responsibility to send + // right cursor position, try to workaround this would simply introduce more bugs. selection.resetTo(attribute.initialSelStart, attribute.initialSelEnd) - composing.clear() - composingText = FormattedText() - editorInfo = attribute + resetComposingState() + val flags = CapabilityFlags.fromEditorInfo(attribute) + capabilityFlags = flags Timber.d("onStartInput: initialSel=${selection.current}, restarting=$restarting") - if (restarting) return - lifecycleScope.launchOnFcitxReady(fcitx) { - it.setCapFlags(CapabilityFlags.fromEditorInfo(attribute)) - } - } - - override fun onStartInputView(info: EditorInfo, restarting: Boolean) { - Timber.d("onStartInputView: restarting=$restarting") - editorInfo = info - lifecycleScope.launchOnFcitxReady(fcitx) { + // wait until InputContext created/activated + postFcitxJob { if (restarting) { // when input restarts in the same editor, focus out to clear previous state - it.focus(false) + 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 - it.setCapFlags(CapabilityFlags.fromEditorInfo(info)) - it.focus(true) + setCapFlags(flags) + } + } + + override fun onStartInputView(info: EditorInfo, restarting: Boolean) { + Timber.d("onStartInputView: restarting=$restarting") + 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() + } } - inputView?.onShow() } override fun onUpdateSelection( @@ -417,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?.onSelectionUpdate(newSelStart, newSelEnd) + 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) { @@ -439,10 +781,10 @@ class FcitxInputMethodService : LifecycleInputMethodService() { if (newSelStart != newSelEnd) return // do reset if composing is empty && input panel is not empty if (composing.isEmpty()) { - lifecycleScope.launchOnFcitxReady(fcitx) { - if (!it.isEmpty()) { + postFcitxJob { + if (!isEmpty()) { Timber.d("handleCursorUpdate: reset") - it.reset() + reset() } } return @@ -456,35 +798,47 @@ class FcitxInputMethodService : LifecycleInputMethodService() { if (position != composingText.cursor) { // cursor in InvokeActionEvent counts by "UTF-8 characters" val codePointPosition = composingText.codePointCountUntil(position) - lifecycleScope.launchOnFcitxReady(fcitx) { - if (updateIndex != cursorUpdateIndex) return@launchOnFcitxReady + 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 - lifecycleScope.launchOnFcitxReady(fcitx) { - 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()) { @@ -516,34 +870,105 @@ 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? { + // 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) + val style = InlineSuggestionUi.newStyleBuilder() + .setSingleIconChipStyle( + ViewStyle.Builder() + .setBackgroundColor(Color.TRANSPARENT) + .setPadding(0, 0, 0, 0) + .build() + ) + .setChipStyle( + ViewStyle.Builder() + .setBackground(chipBg) + .setPadding(dp(10), 0, dp(10), 0) + .build() + ) + .setTitleStyle( + TextViewStyle.Builder() + .setLayoutMargin(dp(4), 0, dp(4), 0) + .setTextColor(theme.keyTextColor) + .setTextSize(14f) + .build() + ) + .setSubtitleStyle( + TextViewStyle.Builder() + .setTextColor(theme.altKeyTextColor) + .setTextSize(12f) + .build() + ) + .setStartIconStyle( + ImageViewStyle.Builder() + .setTintList(ColorStateList.valueOf(theme.altKeyTextColor)) + .build() + ) + .setEndIconStyle( + ImageViewStyle.Builder() + .setTintList(ColorStateList.valueOf(theme.altKeyTextColor)) + .build() + ) + .build() + val styleBundle = UiVersions.newStylesBuilder() + .addStyle(style) + .build() + val spec = InlinePresentationSpec + .Builder(Size(0, 0), Size(Int.MAX_VALUE, Int.MAX_VALUE)) + .setStyle(styleBundle) + .build() + return InlineSuggestionsRequest.Builder(listOf(spec)) + .setMaxSuggestionCount(InlineSuggestionsRequest.SUGGESTION_COUNT_UNLIMITED) + .build() + } + + @RequiresApi(Build.VERSION_CODES.R) + override fun onInlineSuggestionsResponse(response: InlineSuggestionsResponse): Boolean { + if (!inlineSuggestions || !inputDeviceMgr.isVirtualKeyboard) return false + return inputView?.handleInlineSuggestions(response) == true + } + override fun onFinishInputView(finishingInput: Boolean) { Timber.d("onFinishInputView: finishingInput=$finishingInput") - inputConnection?.finishComposingText() - lifecycleScope.launchOnFcitxReady(fcitx) { - it.focus(false) + decorLocationUpdated = false + inputDeviceMgr.onFinishInputView() + currentInputConnection?.apply { + finishComposingText() + monitorCursorAnchor(false) } - inputView?.onHide() + resetComposingState() + postFcitxJob { + focus(false) + } + showingDialog?.dismiss() } override fun onFinishInput() { Timber.d("onFinishInput") - editorInfo = null + capabilityFlags = CapabilityFlags.DefaultFlags } override fun onUnbindInput() { @@ -553,26 +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.launchOnFcitxReady(fcitx) { - it.deactivate(uid) + postFcitxJob { + deactivate(uid) } } override fun onDestroy() { - AppPrefs.getInstance().apply { - keyboard.systemTouchSounds.unregisterOnChangeListener(recreateInputViewListener) - advanced.disableAnimation.unregisterOnChangeListener(recreateInputViewListener) - } - ThemeManager.removeOnChangedListener(onThemeChangedListener) - inputView?.onDestroy() - eventHandlerJob?.cancel() - eventHandlerJob = null + recreateInputViewPrefs.forEach { + it.unregisterOnChangeListener(recreateInputViewListener) + } + prefs.candidates.unregisterOnChangeListener(recreateCandidatesViewListener) + ThemeManager.removeOnChangedListener(onThemeChangeListener) super.onDestroy() // Fcitx might be used in super.onDestroy() FcitxDaemon.disconnect(javaClass.name) } + private var showingDialog: Dialog? = null + + fun showDialog(dialog: Dialog) { + showingDialog?.dismiss() + dialog.window?.also { + it.attributes.apply { + token = decorView.windowToken + type = WindowManager.LayoutParams.TYPE_APPLICATION_ATTACHED_DIALOG + } + it.addFlags( + WindowManager.LayoutParams.FLAG_ALT_FOCUSABLE_IM or WindowManager.LayoutParams.FLAG_DIM_BEHIND + ) + it.setDimAmount(styledFloat(android.R.attr.backgroundDimAmount)) + } + dialog.setOnDismissListener { + showingDialog = null + } + dialog.show() + showingDialog = dialog + } + + @Suppress("ConstPropertyName") companion object { const val DeleteSurroundingFlag = "org.fcitx.fcitx5.android.DELETE_SURROUNDING" } -} \ No newline at end of file +} diff --git a/app/src/main/java/org/fcitx/fcitx5/android/input/InputDeviceManager.kt b/app/src/main/java/org/fcitx/fcitx5/android/input/InputDeviceManager.kt new file mode 100644 index 000000000..656f40dfd --- /dev/null +++ b/app/src/main/java/org/fcitx/fcitx5/android/input/InputDeviceManager.kt @@ -0,0 +1,146 @@ +/* + * SPDX-License-Identifier: LGPL-2.1-or-later + * SPDX-FileCopyrightText: Copyright 2024 Fcitx5 for Android Contributors + */ + +package org.fcitx.fcitx5.android.input + +import android.text.InputType +import android.view.KeyEvent +import android.view.MotionEvent +import android.view.View +import android.view.inputmethod.EditorInfo +import org.fcitx.fcitx5.android.data.prefs.AppPrefs +import org.fcitx.fcitx5.android.input.candidates.floating.FloatingCandidatesMode +import org.fcitx.fcitx5.android.utils.monitorCursorAnchor + +class InputDeviceManager(private val onChange: (Boolean) -> Unit) { + + private var inputView: InputView? = null + private var candidatesView: CandidatesView? = null + + private fun setupInputViewEvents(isVirtual: Boolean) { + inputView?.handleEvents = isVirtual + inputView?.visibility = if (isVirtual) View.VISIBLE else View.GONE + } + + private fun setupCandidatesViewEvents(isVirtual: Boolean) { + candidatesView?.handleEvents = !isVirtual + // hide CandidatesView when entering virtual keyboard mode, + // but preserve the visibility when entering physical keyboard mode (in case it's empty) + if (isVirtual) { + candidatesView?.visibility = View.GONE + } + } + + private fun setupViewEvents(isVirtual: Boolean) { + setupInputViewEvents(isVirtual) + setupCandidatesViewEvents(isVirtual) + } + + var isVirtualKeyboard = true + private set(value) { + field = value + setupViewEvents(value) + } + + fun setInputView(inputView: InputView) { + this.inputView = inputView + setupInputViewEvents(this.isVirtualKeyboard) + } + + fun setCandidatesView(candidatesView: CandidatesView) { + this.candidatesView = candidatesView + setupCandidatesViewEvents(this.isVirtualKeyboard) + } + + private fun applyMode(service: FcitxInputMethodService, useVirtualKeyboard: Boolean) { + if (useVirtualKeyboard == isVirtualKeyboard) { + return + } + // monitor CursorAnchorInfo when switching to CandidatesView + service.currentInputConnection.monitorCursorAnchor(!useVirtualKeyboard) + service.postFcitxJob { + setCandidatePagingMode(if (useVirtualKeyboard) 0 else 1) + } + isVirtualKeyboard = useVirtualKeyboard + onChange(isVirtualKeyboard) + } + + private var startedInputView = false + private var isNullInputType = true + + private var candidatesViewMode by AppPrefs.getInstance().candidates.mode + + /** + * @return should use virtual keyboard + */ + fun evaluateOnStartInputView(info: EditorInfo, service: FcitxInputMethodService): Boolean { + startedInputView = true + isNullInputType = info.inputType and InputType.TYPE_MASK_CLASS == InputType.TYPE_NULL + val useVirtualKeyboard = when (candidatesViewMode) { + FloatingCandidatesMode.SystemDefault -> service.superEvaluateInputViewShown() + FloatingCandidatesMode.InputDevice -> isVirtualKeyboard + FloatingCandidatesMode.Disabled -> true + } + applyMode(service, useVirtualKeyboard) + return useVirtualKeyboard + } + + /** + * @return should force show input views + */ + fun evaluateOnKeyDown(e: KeyEvent, service: FcitxInputMethodService): Boolean { + if (startedInputView) { + // filter out back/home/volume buttons and combination keys + if (e.isPrintingKey && e.hasNoModifiers()) { + // evaluate virtual keyboard visibility when pressing physical keyboard while InputView visible + evaluateOnKeyDownInner(service) + } + // no need to force show InputView since it's already visible + return false + } else { + // force show InputView when focusing on text input (likely inputType is not TYPE_NULL) + // and pressing any digit/letter/punctuation key on physical keyboard + val showInputView = !isNullInputType && e.isPrintingKey && e.hasNoModifiers() + if (showInputView) { + evaluateOnKeyDownInner(service) + } + return showInputView + } + } + + private fun evaluateOnKeyDownInner(service: FcitxInputMethodService) { + val useVirtualKeyboard = when (candidatesViewMode) { + FloatingCandidatesMode.SystemDefault -> service.superEvaluateInputViewShown() + FloatingCandidatesMode.InputDevice -> false + FloatingCandidatesMode.Disabled -> true + } + applyMode(service, useVirtualKeyboard) + } + + fun evaluateOnViewClicked(service: FcitxInputMethodService) { + if (!startedInputView) return + val useVirtualKeyboard = when (candidatesViewMode) { + FloatingCandidatesMode.SystemDefault -> service.superEvaluateInputViewShown() + else -> true + } + applyMode(service, useVirtualKeyboard) + } + + fun evaluateOnUpdateEditorToolType(toolType: Int, service: FcitxInputMethodService) { + if (!startedInputView) return + val useVirtualKeyboard = when (candidatesViewMode) { + FloatingCandidatesMode.SystemDefault -> service.superEvaluateInputViewShown() + FloatingCandidatesMode.InputDevice -> + // switch to virtual keyboard on touch screen events, otherwise preserve current mode + if (toolType == MotionEvent.TOOL_TYPE_FINGER || toolType == MotionEvent.TOOL_TYPE_STYLUS) true else isVirtualKeyboard + FloatingCandidatesMode.Disabled -> true + } + applyMode(service, useVirtualKeyboard) + } + + fun onFinishInputView() { + startedInputView = false + } +} diff --git a/app/src/main/java/org/fcitx/fcitx5/android/input/InputView.kt b/app/src/main/java/org/fcitx/fcitx5/android/input/InputView.kt index c6999f1d6..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,79 +1,107 @@ +/* + * 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.WindowManager +import android.view.View.OnClickListener +import android.view.WindowInsets +import android.view.inputmethod.EditorInfo +import android.view.inputmethod.InlineSuggestionsResponse import android.widget.ImageView -import android.widget.Space -import androidx.constraintlayout.widget.ConstraintLayout -import androidx.core.view.* -import androidx.lifecycle.lifecycleScope -import kotlinx.coroutines.flow.launchIn -import kotlinx.coroutines.flow.onEach +import androidx.annotation.Keep +import androidx.annotation.RequiresApi +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.candidates.CandidateViewBuilder -import org.fcitx.fcitx5.android.input.candidates.HorizontalCandidateComponent +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.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 -import org.fcitx.fcitx5.android.input.picker.symbolPicker import org.fcitx.fcitx5.android.input.picker.emoticonPicker +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.punctuation.PunctuationComponent 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 } - private val bottomPaddingSpace = view(::Space) + private val placeholderOnClickListener = OnClickListener { } - private val eventHandlerJob = fcitx.runImmediately { eventFlow } - .onEach(::handleFcitxEvent).launchIn(service.lifecycleScope) + // use clickable view as padding, so MotionEvent can be split to padding view and keyboard view + private val leftPaddingSpace = view(::View) { + setOnClickListener(placeholderOnClickListener) + } + private val rightPaddingSpace = view(::View) { + setOnClickListener(placeholderOnClickListener) + } + private val bottomPaddingSpace = view(::View) { + // height as keyboardBottomPadding + // bottomMargin as WindowInsets (Navigation Bar) offset + setOnClickListener(placeholderOnClickListener) + } private val scope = DynamicScope() private val themedContext = context.withTheme(R.style.Theme_InputViewTheme) private val broadcaster = InputBroadcaster() private val popup = PopupComponent() private val punctuation = PunctuationComponent() + private val returnKeyDrawable = ReturnKeyDrawableComponent() + private val preeditEmptyState = PreeditEmptyStateComponent() private val preedit = PreeditComponent() private val commonKeyActionListener = CommonKeyActionListener() - private val candidateViewBuilder = CandidateViewBuilder() private val windowManager = InputWindowManager() private val kawaiiBar = KawaiiBarComponent() private val horizontalCandidate = HorizontalCandidateComponent() @@ -91,20 +119,20 @@ class InputView( scope += broadcaster scope += popup scope += punctuation + scope += returnKeyDrawable + scope += preeditEmptyState scope += preedit scope += commonKeyActionListener - scope += candidateViewBuilder scope += windowManager scope += kawaiiBar scope += horizontalCandidate - scope += keyboardWindow - scope += symbolPicker - scope += emojiPicker - scope += emoticonPicker broadcaster.onScopeSetupFinished(scope) } 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 @@ -148,8 +176,11 @@ class InputView( return dp(value) } - private val onKeyboardSizeChangeListener = ManagedPreference.OnChangeListener { _, _ -> - updateKeyboardSize() + @Keep + private val onKeyboardSizeChangeListener = ManagedPreferenceProvider.OnChangeListener { key -> + if (keyboardSizePrefs.any { it.key == key }) { + updateKeyboardSize() + } } val keyboardView: View @@ -158,72 +189,56 @@ class InputView( // MUST call before any operation setupScope() - keyboardSizePrefs.forEach { - it.registerOnChangeListener(onKeyboardSizeChangeListener) + // restore punctuation mapping in case of InputView recreation + fcitx.launchOnReady { + punctuation.updatePunctuationMapping(it.statusAreaActionsCached) } + // 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 { - height = it.bottom - } - } - WindowInsetsCompat.CONSUMED - } - } - } - } - customBackground.imageDrawable = theme.backgroundDrawable(keyBorder) keyboardView = constraintLayout { + // allow MotionEvent to be delivered to keyboard while pressing on padding views. + // although it should be default for apps targeting Honeycomb (3.0, API 11) and higher, + // but it's not the case on some devices ... just set it here + isMotionEventSplittingEnabled = true add(customBackground, lParams { centerVertically() centerHorizontally() }) - add(kawaiiBar.view, lParams(matchParent, dp(40)) { + add(kawaiiBar.view, lParams(matchParent, dp(KawaiiBarComponent.HEIGHT)) { topOfParent() centerHorizontally() }) - add(windowManager.view, lParams(matchParent, keyboardHeightPx) { + add(leftPaddingSpace, lParams { + below(kawaiiBar.view) + startOfParent() + bottomOfParent() + }) + add(rightPaddingSpace, lParams { + below(kawaiiBar.view) + endOfParent() + bottomOfParent() + }) + add(windowManager.view, lParams { below(kawaiiBar.view) - centerHorizontally() above(bottomPaddingSpace) + /** + * set start and end constrain in [updateKeyboardSize] + */ }) - add(bottomPaddingSpace, lParams(matchParent) { - centerHorizontally() + add(bottomPaddingSpace, lParams { + startToEndOf(leftPaddingSpace) + endToStartOf(rightPaddingSpace) bottomOfParent() }) } @@ -242,106 +257,104 @@ class InputView( centerVertically() centerHorizontally() }) + + keyboardPrefs.registerOnChangeListener(onKeyboardSizeChangeListener) } private fun updateKeyboardSize() { windowManager.view.updateLayoutParams { height = keyboardHeightPx } - bottomPaddingSpace.updateLayoutParams { - bottomMargin = keyboardBottomPaddingPx + bottomPaddingSpace.updateLayoutParams { + height = keyboardBottomPaddingPx } val sidePadding = keyboardSidePaddingPx + if (sidePadding == 0) { + // hide side padding space views when unnecessary + leftPaddingSpace.visibility = GONE + rightPaddingSpace.visibility = GONE + windowManager.view.updateLayoutParams { + startToEnd = unset + endToStart = unset + startOfParent() + endOfParent() + } + } else { + leftPaddingSpace.visibility = VISIBLE + rightPaddingSpace.visibility = VISIBLE + leftPaddingSpace.updateLayoutParams { + width = sidePadding + } + rightPaddingSpace.updateLayoutParams { + width = sidePadding + } + windowManager.view.updateLayoutParams { + startToStart = unset + endToEnd = unset + startToEndOf(leftPaddingSpace) + endToStartOf(rightPaddingSpace) + } + } + preedit.ui.root.setPadding(sidePadding, 0, sidePadding, 0) kawaiiBar.view.setPadding(sidePadding, 0, sidePadding, 0) - windowManager.view.setPadding(sidePadding, 0, sidePadding, 0) } - override fun onDetachedFromWindow() { - keyboardSizePrefs.forEach { - it.unregisterOnChangeListener(onKeyboardSizeChangeListener) + override fun onApplyWindowInsets(insets: WindowInsets): WindowInsets { + bottomPaddingSpace.updateLayoutParams { + bottomMargin = getNavBarBottomInset(insets) } - ViewCompat.setOnApplyWindowInsetsListener(this, null) - super.onDetachedFromWindow() + return insets } - fun onShow() { - 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 - } - } - } + /** + * called when [InputView] is about to show, or restart + */ + fun startInput(info: EditorInfo, capFlags: CapabilityFlags, restarting: Boolean = false) { + broadcaster.onStartInput(info, capFlags) + returnKeyDrawable.updateDrawableOnEditorInfo(info) + if (focusChangeResetKeyboard || !restarting) { + windowManager.attachWindow(KeyboardWindow) } - kawaiiBar.onShow() - // We cannot use the key for keyboard window, - // as this is the only place where the window manager gets keyboard window instance - windowManager.attachWindow(keyboardWindow) - broadcaster.onEditorInfoUpdate(service.editorInfo) - } - - fun onHide() { - showingDialog?.dismiss() } - private fun handleFcitxEvent(it: FcitxEvent<*>) { + override fun handleFcitxEvent(it: FcitxEvent<*>) { when (it) { is FcitxEvent.CandidateListEvent -> { broadcaster.onCandidateUpdate(it.data) } - is FcitxEvent.PreeditEvent -> { - broadcaster.onPreeditUpdate(it.data) + is FcitxEvent.ClientPreeditEvent -> { + preeditEmptyState.updatePreeditEmptyState(clientPreedit = it.data) + broadcaster.onClientPreeditUpdate(it.data) } - is FcitxEvent.InputPanelAuxEvent -> { - broadcaster.onInputPanelAuxUpdate(it.data) + is FcitxEvent.InputPanelEvent -> { + preeditEmptyState.updatePreeditEmptyState(preedit = it.data.preedit) + broadcaster.onInputPanelUpdate(it.data) } is FcitxEvent.IMChangeEvent -> { broadcaster.onImeUpdate(it.data) } is FcitxEvent.StatusAreaEvent -> { - broadcaster.onStatusAreaUpdate(it.data) - } - else -> { + punctuation.updatePunctuationMapping(it.data.actions) + broadcaster.onStatusAreaUpdate(it.data.actions) } + else -> {} } } - fun onSelectionUpdate(start: Int, end: Int) { + fun updateSelection(start: Int, end: Int) { 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() - } + @RequiresApi(Build.VERSION_CODES.R) + fun handleInlineSuggestions(response: InlineSuggestionsResponse): Boolean { + return kawaiiBar.handleInlineSuggestions(response) } - fun onDestroy() { - showingDialog?.dismiss() - eventHandlerJob.cancel() + override fun onDetachedFromWindow() { + 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 dac4629f1..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,36 +1,34 @@ +/* + * 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 import androidx.lifecycle.LifecycleRegistry -import androidx.lifecycle.ViewTreeLifecycleOwner +import androidx.lifecycle.setViewTreeLifecycleOwner open class LifecycleInputMethodService : InputMethodService(), LifecycleOwner { + private val lifecycleRegistry by lazy { LifecycleRegistry(this) } - override fun getLifecycle(): Lifecycle = lifecycleRegistry + override val lifecycle = lifecycleRegistry @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? { - val decorView = window.window!!.decorView - ViewTreeLifecycleOwner.set(decorView, 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 ad9843687..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,9 +1,16 @@ +/* + * 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.State.* -import org.fcitx.fcitx5.android.input.bar.ExpandButtonStateMachine.TransitionEvent.* +import org.fcitx.fcitx5.android.input.bar.ExpandButtonStateMachine.BooleanKey.ExpandedCandidatesEmpty +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.utils.BuildTransitionEvent import org.fcitx.fcitx5.android.utils.EventStateMachine -import org.fcitx.fcitx5.android.utils.eventStateMachine +import org.fcitx.fcitx5.android.utils.TransitionBuildBlock object ExpandButtonStateMachine { @@ -13,23 +20,33 @@ object ExpandButtonStateMachine { Hidden } - enum class TransitionEvent { - ExpandedCandidatesUpdatedEmpty, - ExpandedCandidatesUpdatedNonEmpty, - ExpandedCandidatesAttached, - ExpandedCandidatesDetachedWithCandidatesEmpty, - ExpandedCandidatesDetachedWithCandidatesNonEmpty, + enum class BooleanKey : EventStateMachine.BooleanStateKey { + ExpandedCandidatesEmpty } - fun new(block: (State) -> Unit): EventStateMachine = - eventStateMachine( - Hidden - ) { - from(Hidden) transitTo ClickToAttachWindow on ExpandedCandidatesUpdatedNonEmpty - from(ClickToAttachWindow) transitTo Hidden on ExpandedCandidatesUpdatedEmpty - from(ClickToAttachWindow) transitTo ClickToDetachWindow on ExpandedCandidatesAttached - from(ClickToDetachWindow) transitTo ClickToAttachWindow on ExpandedCandidatesDetachedWithCandidatesNonEmpty - from(ClickToDetachWindow) transitTo Hidden on ExpandedCandidatesDetachedWithCandidatesEmpty - onNewState(block) + enum class TransitionEvent(val builder: TransitionBuildBlock) : + EventStateMachine.TransitionEvent by BuildTransitionEvent(builder) { + ExpandedCandidatesUpdated({ + from(Hidden) transitTo ClickToAttachWindow on (ExpandedCandidatesEmpty to false) + from(ClickToAttachWindow) transitTo Hidden on (ExpandedCandidatesEmpty to true) + }), + ExpandedCandidatesAttached({ + from(ClickToAttachWindow) transitTo ClickToDetachWindow + }), + ExpandedCandidatesDetached({ + from(ClickToDetachWindow) transitTo Hidden on (ExpandedCandidatesEmpty to true) + from(ClickToDetachWindow) transitTo ClickToAttachWindow on (ExpandedCandidatesEmpty to false) + }); + } + + fun new(block: (State) -> Unit) = + EventStateMachine( + initialState = Hidden, + externalBooleanStates = mutableMapOf( + ExpandedCandidatesEmpty to true + ) + ).apply { + onNewStateListener = block } -} \ No newline at end of file +} + diff --git a/app/src/main/java/org/fcitx/fcitx5/android/input/bar/IdleUiStateMachine.kt b/app/src/main/java/org/fcitx/fcitx5/android/input/bar/IdleUiStateMachine.kt deleted file mode 100644 index 277c8921e..000000000 --- a/app/src/main/java/org/fcitx/fcitx5/android/input/bar/IdleUiStateMachine.kt +++ /dev/null @@ -1,49 +0,0 @@ -package org.fcitx.fcitx5.android.input.bar - -import org.fcitx.fcitx5.android.input.bar.IdleUiStateMachine.State.* -import org.fcitx.fcitx5.android.input.bar.IdleUiStateMachine.TransitionEvent.* -import org.fcitx.fcitx5.android.utils.EventStateMachine -import org.fcitx.fcitx5.android.utils.eventStateMachine - -object IdleUiStateMachine { - enum class State { - Clipboard, Toolbar, Empty, ToolbarWithClip, ClipboardTimedOut - } - - enum class TransitionEvent { - Timeout, - Pasted, - MenuButtonClicked, - ClipboardUpdatedEmpty, - ClipboardUpdatedNonEmpty, - KawaiiBarShown, - } - - fun new( - toolbarByDefault: Boolean, - old: EventStateMachine? = null, - block: ((State) -> Unit)? = null - ): EventStateMachine { - val initialState = if (toolbarByDefault) Toolbar else Empty - return eventStateMachine(old?.currentState ?: initialState) { - from(Toolbar) transitTo Clipboard on ClipboardUpdatedNonEmpty - from(Toolbar) transitTo Empty on MenuButtonClicked - from(ToolbarWithClip) transitTo Toolbar on Timeout - from(ToolbarWithClip) transitTo Toolbar on ClipboardUpdatedEmpty - from(ToolbarWithClip) transitTo Clipboard on MenuButtonClicked - from(ToolbarWithClip) transitTo Clipboard on ClipboardUpdatedNonEmpty - from(Clipboard) transitTo ToolbarWithClip on MenuButtonClicked - from(Clipboard) transitTo ClipboardTimedOut on Timeout - from(Clipboard) transitTo initialState on Pasted - from(Clipboard) transitTo initialState on ClipboardUpdatedEmpty - from(ClipboardTimedOut) transitTo Toolbar on MenuButtonClicked - from(ClipboardTimedOut) transitTo initialState on KawaiiBarShown - from(ClipboardTimedOut) transitTo initialState on Pasted - from(ClipboardTimedOut) transitTo initialState on ClipboardUpdatedEmpty - from(ClipboardTimedOut) transitTo Clipboard on ClipboardUpdatedNonEmpty - from(Empty) transitTo Toolbar on MenuButtonClicked - from(Empty) transitTo Clipboard on ClipboardUpdatedNonEmpty - onNewState(old?.onNewStateListener ?: block ?: throw IllegalArgumentException()) - } - } -} 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 309c61358..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,95 +1,149 @@ +/* + * 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 import android.os.Build +import android.util.Size import android.view.KeyEvent import android.view.View +import android.view.ViewGroup import android.view.inputmethod.EditorInfo +import android.view.inputmethod.InlineSuggestion +import android.view.inputmethod.InlineSuggestionsResponse +import android.view.inputmethod.InputMethodSubtype import android.widget.FrameLayout import android.widget.ViewAnimator +import android.widget.inline.InlineContentView +import androidx.annotation.Keep +import androidx.annotation.RequiresApi import androidx.lifecycle.lifecycleScope 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.FcitxEvent +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.IdleUiStateMachine.TransitionEvent.* -import org.fcitx.fcitx5.android.input.bar.KawaiiBarStateMachine.TransitionEvent.* +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.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 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 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.InputMethodUtil import org.mechdancer.dependency.DynamicScope import org.mechdancer.dependency.manager.must import splitties.bitflags.hasFlag +import splitties.dimensions.dp 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 timber.log.Timber +import java.util.concurrent.Executor +import kotlin.coroutines.resume +import kotlin.coroutines.suspendCoroutine +import kotlin.math.min class KawaiiBarComponent : UniqueViewComponent(), InputBroadcastReceiver { private val context by manager.context() private val theme by manager.theme() - private val windowManager: InputWindowManager by manager.must() private val service by manager.inputMethodService() + private val windowManager: InputWindowManager by manager.must() private val horizontalCandidate: HorizontalCandidateComponent by manager.must() + private val commonKeyActionListener: CommonKeyActionListener by manager.must() + private val popup: PopupComponent by manager.must() + + private val prefs = AppPrefs.getInstance() - 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 = AppPrefs.getInstance().keyboard.expandToolbarByDefault + 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 = false + private var isInlineSuggestionPresent: Boolean = false + private var isCapabilityFlagsPassword: Boolean = false + private var isKeyboardLayoutNumber: Boolean = false + private var isToolbarManuallyToggled: Boolean = false + + @Keep private val onClipboardUpdateListener = ClipboardManager.OnClipboardUpdateListener { if (!clipboardSuggestion.getValue()) return@OnClipboardUpdateListener service.lifecycleScope.launch { if (it.text.isEmpty()) { - idleUiStateMachine.push(ClipboardUpdatedEmpty) + isClipboardFresh = false } else { - idleUi.setClipboardItemText(it.text.take(42)) - idleUiStateMachine.push(ClipboardUpdatedNonEmpty) + 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() } } + @Keep private val onClipboardSuggestionUpdateListener = ManagedPreference.OnChangeListener { _, it -> if (!it) { - idleUiStateMachine.push(ClipboardUpdatedEmpty) + isClipboardFresh = false + evalIdleUiState() clipboardTimeoutJob?.cancel() clipboardTimeoutJob = null } } + @Keep private val onClipboardTimeoutUpdateListener = ManagedPreference.OnChangeListener { _, _ -> - when (idleUiStateMachine.currentState) { - IdleUiStateMachine.State.Clipboard, - IdleUiStateMachine.State.ToolbarWithClip -> { + when (idleUi.currentState) { + IdleUi.State.Clipboard -> { // renew timeout when clipboard suggestion is present launchClipboardTimeoutJob() } @@ -97,18 +151,6 @@ class KawaiiBarComponent : UniqueViewComponent( } } - private val onExpandToolbarByDefaultUpdateListener = - ManagedPreference.OnChangeListener { _, it -> - idleUiStateMachine = IdleUiStateMachine.new(it, idleUiStateMachine) - } - - init { - ClipboardManager.addOnUpdateListener(onClipboardUpdateListener) - clipboardSuggestion.registerOnChangeListener(onClipboardSuggestionUpdateListener) - clipboardItemTimeout.registerOnChangeListener(onClipboardTimeoutUpdateListener) - expandToolbarByDefault.registerOnChangeListener(onExpandToolbarByDefaultUpdateListener) - } - private fun launchClipboardTimeoutJob() { clipboardTimeoutJob?.cancel() val timeout = clipboardItemTimeout.getValue() * 1000L @@ -116,61 +158,126 @@ class KawaiiBarComponent : UniqueViewComponent( if (timeout < 0L) return clipboardTimeoutJob = service.lifecycleScope.launch { delay(timeout) - idleUiStateMachine.push(Timeout) + isClipboardFresh = false clipboardTimeoutJob = null } } - private val idleUi: KawaiiBarUi.Idle by lazy { - KawaiiBarUi.Idle(context, theme) { idleUiStateMachine.currentState }.apply { + private fun evalIdleUiState(fromUser: Boolean = false) { + val newState = when { + isClipboardFresh -> IdleUi.State.Clipboard + isInlineSuggestionPresent -> IdleUi.State.InlineSuggestion + isCapabilityFlagsPassword && !isKeyboardLayoutNumber -> IdleUi.State.NumberRow + /** + * 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) + } + + private val hideKeyboardCallback = View.OnClickListener { + 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 { + val (id, subtype) = voiceInputSubtype ?: return@OnClickListener + InputMethodUtil.switchInputMethod(service, id, subtype) + } + + private val idleUi: IdleUi by lazy { + IdleUi(context, theme, popup, commonKeyActionListener).apply { menuButton.setOnClickListener { - idleUiStateMachine.push(MenuButtonClicked) + 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() } } - undoButton.setOnClickListener { - service.sendCombinationKeyEvents(KeyEvent.KEYCODE_Z, ctrl = true) - } - redoButton.setOnClickListener { - service.sendCombinationKeyEvents(KeyEvent.KEYCODE_Z, ctrl = true, shift = true) - } - cursorMoveButton.setOnClickListener { - windowManager.attachWindow(TextEditingWindow()) + hideKeyboardButton.apply { + setOnClickListener(hideKeyboardCallback) + swipeEnabled = true + swipeThresholdY = dp(HEIGHT.toFloat()) + onGestureListener = swipeDownHideKeyboardCallback } - clipboardButton.setOnClickListener { - windowManager.attachWindow(ClipboardWindow()) - } - moreButton.setOnClickListener { - windowManager.attachWindow(StatusAreaWindow()) - } - clipboardSuggestionItem.setOnClickListener { - ClipboardManager.lastEntry?.let { - service.commitText(it.text) + buttonsUi.apply { + undoButton.setOnClickListener { + service.sendCombinationKeyEvents(KeyEvent.KEYCODE_Z, ctrl = true) } - clipboardTimeoutJob?.cancel() - clipboardTimeoutJob = null - idleUiStateMachine.push(Pasted) - } - clipboardSuggestionItem.setOnLongClickListener { - ClipboardManager.lastEntry?.let { - AppUtil.launchClipboardEdit(context, it.id, true) + redoButton.setOnClickListener { + service.sendCombinationKeyEvents(KeyEvent.KEYCODE_Z, ctrl = true, shift = true) + } + cursorMoveButton.setOnClickListener { + windowManager.attachWindow(TextEditingWindow()) + } + clipboardButton.setOnClickListener { + windowManager.attachWindow(ClipboardWindow()) + } + moreButton.setOnClickListener { + windowManager.attachWindow(StatusAreaWindow()) } - true } - hideKeyboardButton.setOnClickListener { - service.requestHideSelf(0) + clipboardUi.suggestionView.apply { + setOnClickListener { + ClipboardManager.lastEntry?.let { + service.commitText(it.text) + } + clipboardTimeoutJob?.cancel() + clipboardTimeoutJob = null + isClipboardFresh = false + evalIdleUiState() + } + setOnLongClickListener { + ClipboardManager.lastEntry?.let { + AppUtil.launchClipboardEdit(context, it.id, true) + } + true + } } - switchUiByState(idleUiStateMachine.currentState) } } private val candidateUi by lazy { - KawaiiBarUi.Candidate(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 { KawaiiBarUi.Title(context, theme) } + private val titleUi by lazy { + TitleUi(context, theme) + } private val barStateMachine = KawaiiBarStateMachine.new { switchUiByState(it) @@ -192,10 +299,6 @@ class KawaiiBarComponent : UniqueViewComponent( } } - private var idleUiStateMachine = IdleUiStateMachine.new(expandToolbarByDefault.getValue()) { - idleUi.switchUiByState(it) - } - // set expand candidate button to create expand candidate private fun setExpandButtonToAttach() { candidateUi.expandButton.setOnClickListener { @@ -206,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 @@ -214,32 +318,20 @@ 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 private fun setExpandButtonEnabled(enabled: Boolean) { - if (enabled) - candidateUi.expandButton.visibility = View.VISIBLE - else - candidateUi.expandButton.visibility = View.INVISIBLE - } - - fun onShow() { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - idleUi.privateMode( - service.editorInfo?.imeOptions?.hasFlag(EditorInfo.IME_FLAG_NO_PERSONALIZED_LEARNING) == true - ) - } - idleUiStateMachine.push(KawaiiBarShown) + candidateUi.expandButton.visibility = if (enabled) View.VISIBLE else View.INVISIBLE } private fun switchUiByState(state: KawaiiBarStateMachine.State) { val index = state.ordinal - if (view.displayedChild == index) - return - Timber.d("Switch bar to $state") - if (view.getChildAt(index) != titleUi.root) { + if (view.displayedChild == index) return + val new = view.getChildAt(index) + if (new != titleUi.root) { titleUi.setReturnButtonOnClickListener { } titleUi.setTitle("") titleUi.removeExtension() @@ -266,20 +358,36 @@ class KawaiiBarComponent : UniqueViewComponent( onClipboardUpdateListener.onUpdate(it) } } + ClipboardManager.addOnUpdateListener(onClipboardUpdateListener) + clipboardSuggestion.registerOnChangeListener(onClipboardSuggestionUpdateListener) + clipboardItemTimeout.registerOnChangeListener(onClipboardTimeoutUpdateListener) } - override fun onPreeditUpdate(data: FcitxEvent.PreeditEvent.Data) { - barStateMachine.push( - if (data.preedit.isEmpty() && data.clientPreedit.isEmpty()) - PreeditUpdatedEmpty - else - PreeditUpdatedNonEmpty + override fun onStartInput(info: EditorInfo, capFlags: CapabilityFlags) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + idleUi.privateMode(info.imeOptions.hasFlag(EditorInfo.IME_FLAG_NO_PERSONALIZED_LEARNING)) + } + isCapabilityFlagsPassword = toolbarNumRowOnPassword && capFlags.has(CapabilityFlag.Password) + isInlineSuggestionPresent = false + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + idleUi.inlineSuggestionsBar.clear() + } + voiceInputSubtype = InputMethodUtil.firstVoiceInput() + val shouldShowVoiceInput = + showVoiceInputButton && voiceInputSubtype != null && !capFlags.has(CapabilityFlag.Password) + idleUi.setHideKeyboardIsVoiceInput( + shouldShowVoiceInput, + if (shouldShowVoiceInput) switchToVoiceInputCallback else hideKeyboardCallback ) + evalIdleUiState() + } + + override fun onPreeditEmptyStateUpdate(empty: Boolean) { + barStateMachine.push(PreeditUpdated, PreeditEmpty to empty) } - override fun onCandidateUpdate(data: Array) { - if (data.isNotEmpty()) - barStateMachine.push(CandidateUpdateNonEmpty) + override fun onCandidateUpdate(data: CandidateListEvent.Data) { + barStateMachine.push(CandidatesUpdated, CandidateEmpty to data.candidates.isEmpty()) } override fun onWindowAttached(window: InputWindow) { @@ -292,17 +400,78 @@ class KawaiiBarComponent : UniqueViewComponent( } barStateMachine.push(ExtendedWindowAttached) } - is InputWindow.SimpleInputWindow<*> -> { - } + else -> {} } } override fun onWindowDetached(window: InputWindow) { - barStateMachine.push( - if (horizontalCandidate.adapter.candidates.isEmpty()) - WindowDetachedWithCandidatesEmpty - else - WindowDetachedWithCandidatesNonEmpty - ) + barStateMachine.push(WindowDetached) } -} \ No newline at end of file + + private val suggestionSize by lazy { + Size(ViewGroup.LayoutParams.WRAP_CONTENT, context.dp(HEIGHT)) + } + + private val directExecutor by lazy { + Executor { it.run() } + } + + @RequiresApi(Build.VERSION_CODES.R) + fun handleInlineSuggestions(response: InlineSuggestionsResponse): Boolean { + val suggestions = response.inlineSuggestions + if (suggestions.isEmpty()) { + isInlineSuggestionPresent = false + return true + } + var pinned: InlineSuggestion? = null + val scrollable = mutableListOf() + var extraPinnedCount = 0 + suggestions.forEach { + if (it.info.isPinned) { + if (pinned == null) { + pinned = it + } else { + scrollable.add(extraPinnedCount++, it) + } + } else { + scrollable.add(it) + } + } + service.lifecycleScope.launch { + idleUi.inlineSuggestionsBar.setPinnedView( + pinned?.let { inflateInlineContentView(it) } + ) + } + service.lifecycleScope.launch { + val views = scrollable.map { s -> + service.lifecycleScope.async { + inflateInlineContentView(s) + } + }.awaitAll() + idleUi.inlineSuggestionsBar.setScrollableViews(views) + } + isInlineSuggestionPresent = true + evalIdleUiState() + return true + } + + @RequiresApi(Build.VERSION_CODES.R) + private suspend fun inflateInlineContentView(suggestion: InlineSuggestion): InlineContentView? { + return suspendCoroutine { c -> + // callback view might be null + suggestion.inflate(context, suggestionSize, directExecutor) { v -> + c.resume(v) + } + } + } + + companion object { + const val HEIGHT = 40 + } + + fun onKeyboardLayoutSwitched(isNumber: Boolean) { + isKeyboardLayoutNumber = isNumber + evalIdleUiState() + } + +} 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 0198ab479..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,32 +1,62 @@ +/* + * 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.State.* -import org.fcitx.fcitx5.android.input.bar.KawaiiBarStateMachine.TransitionEvent.* -import org.fcitx.fcitx5.android.utils.eventStateMachine +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.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 object KawaiiBarStateMachine { enum class State { Idle, Candidate, Title } - enum class TransitionEvent { - PreeditUpdatedEmpty, - PreeditUpdatedNonEmpty, - CandidateUpdateNonEmpty, - ExtendedWindowAttached, - WindowDetachedWithCandidatesEmpty, - WindowDetachedWithCandidatesNonEmpty + enum class BooleanKey : EventStateMachine.BooleanStateKey { + PreeditEmpty, CandidateEmpty } - fun new(block: (State) -> Unit) = eventStateMachine(Idle) { - from(Idle) transitTo Title on ExtendedWindowAttached - from(Idle) transitTo Candidate on PreeditUpdatedNonEmpty - from(Idle) transitTo Candidate on CandidateUpdateNonEmpty - from(Title) transitTo Idle on WindowDetachedWithCandidatesEmpty - from(Title) transitTo Candidate on WindowDetachedWithCandidatesNonEmpty - from(Candidate) transitTo Idle on PreeditUpdatedEmpty - from(Candidate) transitTo Title on ExtendedWindowAttached - onNewState(block) + enum class TransitionEvent(val builder: TransitionBuildBlock) : + EventStateMachine.TransitionEvent by BuildTransitionEvent(builder) { + PreeditUpdated({ + from(Candidate) transitTo Idle on (PreeditEmpty to true) + from(Idle) transitTo Candidate onF { + it(PreeditEmpty) == false && it(CandidateEmpty) == false + } + }), + CandidatesUpdated({ + from(Idle) transitTo Candidate on (CandidateEmpty to false) + from(Candidate) transitTo Idle onF { + it(PreeditEmpty) == true && it(CandidateEmpty) == true + } + }), + ExtendedWindowAttached({ + from(Idle) transitTo Title + from(Candidate) transitTo Title + }), + WindowDetached({ + // candidate state has higher priority so here it goes first + from(Title) transitTo Candidate on (CandidateEmpty to false) + from(Title) transitTo Idle + }), } + + fun new(block: (State) -> Unit) = + EventStateMachine( + 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/KawaiiBarUi.kt b/app/src/main/java/org/fcitx/fcitx5/android/input/bar/KawaiiBarUi.kt deleted file mode 100644 index e06d483cf..000000000 --- a/app/src/main/java/org/fcitx/fcitx5/android/input/bar/KawaiiBarUi.kt +++ /dev/null @@ -1,323 +0,0 @@ -package org.fcitx.fcitx5.android.input.bar - -import android.content.Context -import android.graphics.PorterDuff -import android.graphics.PorterDuffColorFilter -import android.graphics.Typeface -import android.text.TextUtils -import android.view.View -import android.view.animation.AlphaAnimation -import android.view.animation.AnimationSet -import android.view.animation.ScaleAnimation -import android.view.animation.TranslateAnimation -import android.widget.ViewAnimator -import androidx.annotation.DrawableRes -import androidx.constraintlayout.widget.ConstraintLayout -import androidx.core.view.isVisible -import org.fcitx.fcitx5.android.R -import org.fcitx.fcitx5.android.data.prefs.AppPrefs -import org.fcitx.fcitx5.android.data.theme.Theme -import org.fcitx.fcitx5.android.input.bar.IdleUiStateMachine.State.* -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.gravityCenter -import splitties.views.gravityVerticalCenter -import splitties.views.imageResource -import splitties.views.padding -import timber.log.Timber - -sealed class KawaiiBarUi(override val ctx: Context, protected val theme: Theme) : Ui { - - companion object { - val disableAnimation by AppPrefs.getInstance().advanced.disableAnimation - } - - protected fun toolButton(@DrawableRes icon: Int, initView: ToolButton.() -> Unit = {}) = - ToolButton(ctx, icon, theme).apply(initView) - - class Candidate(ctx: Context, theme: Theme, private val horizontalView: View) : - KawaiiBarUi(ctx, theme) { - - val expandButton = toolButton(R.drawable.ic_baseline_expand_more_24) { - id = R.id.expand_candidate_btn - visibility = View.INVISIBLE - } - - override val root = ctx.constraintLayout { - add(expandButton, lParams(dp(40)) { - centerVertically() - endOfParent() - }) - add(horizontalView, lParams { - centerVertically() - startOfParent() - before(expandButton) - }) - } - } - - class Idle( - ctx: Context, - theme: Theme, - private val getCurrentState: () -> IdleUiStateMachine.State, - ) : KawaiiBarUi(ctx, theme) { - - private val IdleUiStateMachine.State.menuButtonRotation - get() = - if (inPrivate) 0f - else when (this) { - Empty -> -90f - Clipboard -> -90f - Toolbar -> 90f - ToolbarWithClip -> 90f - ClipboardTimedOut -> -90f - } - - private var inPrivate = false - - val menuButton = toolButton(R.drawable.ic_baseline_expand_more_24) { - rotation = getCurrentState().menuButtonRotation - } - - val undoButton = toolButton(R.drawable.ic_baseline_undo_24) - - val redoButton = toolButton(R.drawable.ic_baseline_redo_24) - - val cursorMoveButton = toolButton(R.drawable.ic_cursor_move) - - val clipboardButton = toolButton(R.drawable.ic_clipboard) - - val moreButton = toolButton(R.drawable.ic_baseline_more_horiz_24) - - val hideKeyboardButton = toolButton(R.drawable.ic_baseline_arrow_drop_down_24) - - private fun ConstraintLayout.addButton( - v: View, - initParams: ConstraintLayout.LayoutParams.() -> Unit = {} - ) { - add(v, ConstraintLayout.LayoutParams(dp(40), dp(40)).apply { - centerVertically() - initParams(this) - }) - } - - private val buttonsBar = constraintLayout { - addButton(undoButton) { startOfParent(); before(redoButton) } - addButton(redoButton) { after(undoButton); before(cursorMoveButton) } - addButton(cursorMoveButton) { after(redoButton); before(clipboardButton) } - addButton(clipboardButton) { after(cursorMoveButton); before(moreButton) } - addButton(moreButton) { after(clipboardButton); endOfParent() } - } - - private val clipboardIcon = imageView { - imageResource = R.drawable.ic_clipboard - colorFilter = PorterDuffColorFilter(theme.altKeyTextColor, PorterDuff.Mode.SRC_IN) - } - - private val clipboardText = textView { - isSingleLine = true - maxWidth = dp(120) - ellipsize = TextUtils.TruncateAt.END - setTextColor(theme.altKeyTextColor) - } - - private val clipboardSuggestionLayout = horizontalLayout { - gravity = gravityCenter - padding = dp(4) - add(clipboardIcon, lParams(dp(20), dp(20))) - add(clipboardText, lParams { - leftMargin = dp(4) - }) - } - - val clipboardSuggestionItem = object : CustomGestureView(ctx) { - init { - visibility = View.GONE - isHapticFeedbackEnabled = false - background = rippleDrawable(theme.keyPressHighlightColor) - add(clipboardSuggestionLayout, lParams(wrapContent, matchParent)) - } - } - - private val clipboardBar = constraintLayout { - add(clipboardSuggestionItem, lParams(wrapContent, matchConstraints) { - topOfParent() - startOfParent() - endOfParent() - bottomOfParent() - verticalMargin = dp(4) - }) - } - - private val animator = ViewAnimator(ctx).apply { - add(clipboardBar, lParams(matchParent, matchParent)) - add(buttonsBar, lParams(matchParent, matchParent)) - - if (disableAnimation) { - inAnimation = null - outAnimation = null - } else { - inAnimation = AnimationSet(true).apply { - duration = 200L - addAnimation(AlphaAnimation(0f, 1f)) - addAnimation(ScaleAnimation(0f, 1f, 0f, 1f, 0f, dp(20f))) - addAnimation(TranslateAnimation(dp(-100f), 0f, 0f, 0f)) - } - outAnimation = AnimationSet(true).apply { - duration = 200L - addAnimation(AlphaAnimation(1f, 0f)) - addAnimation(ScaleAnimation(1f, 0f, 1f, 0f, 0f, dp(20f))) - addAnimation(TranslateAnimation(0f, dp(-100f), 0f, 0f)) - } - } - } - - override val root = constraintLayout { - addButton(menuButton) { startOfParent() } - add(animator, lParams(matchConstraints, dp(40)) { - after(menuButton) - before(hideKeyboardButton) - }) - addButton(hideKeyboardButton) { endOfParent() } - } - - fun privateMode(activate: Boolean = true) { - if (activate == inPrivate) return - inPrivate = activate - updateMenuButtonIcon() - updateMenuButtonRotation(instant = true) - } - - private fun updateMenuButtonIcon() { - menuButton.image.imageResource = - if (inPrivate) R.drawable.ic_view_private - else R.drawable.ic_baseline_expand_more_24 - } - - private fun updateMenuButtonRotation(instant: Boolean = false) { - val targetRotation = getCurrentState().menuButtonRotation - menuButton.apply { - if (targetRotation == rotation) return - if (instant || disableAnimation) { - rotation = targetRotation - } else { - animate().setDuration(200L).rotation(targetRotation) - } - } - } - - private fun transitionToClipboardBar() { - animator.displayedChild = 0 - } - - private fun transitionToButtonsBar() { - animator.displayedChild = 1 - } - - fun switchUiByState(state: IdleUiStateMachine.State) { - Timber.d("Switch idle ui to $state") - when (state) { - Clipboard -> { - transitionToClipboardBar() - enableClipboardItem() - } - Toolbar -> { - transitionToButtonsBar() - disableClipboardItem() - } - Empty -> { - // empty and clipboard share the same view - transitionToClipboardBar() - disableClipboardItem() - setClipboardItemText("") - } - ToolbarWithClip -> { - transitionToButtonsBar() - } - ClipboardTimedOut -> { - transitionToClipboardBar() - } - } - updateMenuButtonRotation() - } - - private fun enableClipboardItem() { - clipboardSuggestionItem.visibility = View.VISIBLE - } - - private fun disableClipboardItem() { - clipboardSuggestionItem.visibility = View.GONE - } - - fun setClipboardItemText(text: String) { - clipboardText.text = text - } - } - - class Title(ctx: Context, theme: Theme) : KawaiiBarUi(ctx, theme) { - - private val backButton = toolButton(R.drawable.ic_baseline_arrow_back_24) - - private val titleText = textView { - typeface = Typeface.defaultFromStyle(Typeface.BOLD) - setTextColor(theme.altKeyTextColor) - gravity = gravityVerticalCenter - textSize = 16f - } - - private var extension: View? = null - - override val root = constraintLayout { - add(backButton, lParams(dp(40), dp(40)) { - topOfParent() - startOfParent() - bottomOfParent() - }) - add(titleText, lParams(wrapContent, dp(40)) { - topOfParent() - after(backButton, dp(8)) - bottomOfParent() - }) - } - - fun setReturnButtonOnClickListener(block: () -> Unit) { - backButton.setOnClickListener { - block() - } - } - - fun setTitle(title: String) { - titleText.text = title - } - - fun addExtension(view: View, showTitle: Boolean) { - if (extension != null) { - throw IllegalStateException("TitleBar extension is already present") - } - backButton.isVisible = showTitle - titleText.isVisible = showTitle - extension = view - root.run { - add(view, lParams(matchConstraints, dp(40)) { - centerVertically() - if (showTitle) { - endOfParent(dp(5)) - } else { - centerHorizontally() - } - }) - } - } - - fun removeExtension() { - if (extension == null) - return - root.removeView(extension) - extension = null - } - } - -} \ No newline at end of file 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 new file mode 100644 index 000000000..bb756df0c --- /dev/null +++ b/app/src/main/java/org/fcitx/fcitx5/android/input/bar/ui/CandidateUi.kt @@ -0,0 +1,39 @@ +/* + * 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.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.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 + +class CandidateUi(override val ctx: Context, theme: Theme, private val horizontalView: View) : Ui { + + val expandButton = ToolButton(ctx, R.drawable.ic_baseline_expand_more_24, theme).apply { + id = R.id.expand_candidate_btn + visibility = View.INVISIBLE + } + + override val root = ctx.constraintLayout { + add(expandButton, lParams(dp(40)) { + centerVertically() + endOfParent() + }) + add(horizontalView, lParams { + centerVertically() + startOfParent() + before(expandButton) + }) + } +} 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 new file mode 100644 index 000000000..c6cc8ab8d --- /dev/null +++ b/app/src/main/java/org/fcitx/fcitx5/android/input/bar/ui/IdleUi.kt @@ -0,0 +1,223 @@ +/* + * 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 +import android.view.View +import android.view.animation.AlphaAnimation +import android.view.animation.AnimationSet +import android.view.animation.TranslateAnimation +import android.widget.Space +import android.widget.ViewAnimator +import org.fcitx.fcitx5.android.R +import org.fcitx.fcitx5.android.data.prefs.AppPrefs +import org.fcitx.fcitx5.android.data.theme.Theme +import org.fcitx.fcitx5.android.input.bar.KawaiiBarComponent +import org.fcitx.fcitx5.android.input.bar.ui.idle.ButtonsBarUi +import org.fcitx.fcitx5.android.input.bar.ui.idle.ClipboardSuggestionUi +import org.fcitx.fcitx5.android.input.bar.ui.idle.InlineSuggestionsUi +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.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 +import splitties.views.dsl.core.matchParent +import splitties.views.imageResource +import timber.log.Timber + +class IdleUi( + override val ctx: Context, + private val theme: Theme, + private val popup: PopupComponent, + private val commonKeyActionListener: CommonKeyActionListener +) : Ui { + + enum class State { + Empty, Toolbar, Clipboard, NumberRow, InlineSuggestion + } + + var currentState = State.Empty + private set + + private val disableAnimation by AppPrefs.getInstance().advanced.disableAnimation + + private var inPrivate = false + + private val translateDirection by lazy { + if (ctx.resources.configuration.layoutDirection == View.LAYOUT_DIRECTION_LTR) 1f else -1f + } + + private val menuButtonRotation + get() = when { + inPrivate -> 0f + currentState == State.Toolbar -> 90f * translateDirection + else -> -90f * translateDirection + } + + val menuButton = ToolButton(ctx, R.drawable.ic_baseline_expand_more_24, theme).apply { + rotation = menuButtonRotation + } + + val hideKeyboardButton = ToolButton(ctx, R.drawable.ic_baseline_arrow_drop_down_24, theme) + + val emptyBar = Space(ctx) + + val buttonsUi = ButtonsBarUi(ctx, theme) + + val clipboardUi = ClipboardSuggestionUi(ctx, theme) + + val numberRow = NumberRow(ctx, theme).apply { + visibility = View.GONE + } + + val inlineSuggestionsBar = InlineSuggestionsUi(ctx) + + private val animator = ViewAnimator(ctx).apply { + add(emptyBar, lParams(matchParent, matchParent)) + add(buttonsUi.root, lParams(matchParent, matchParent)) + add(clipboardUi.root, lParams(matchParent, matchParent)) + add(inlineSuggestionsBar.root, lParams(matchParent, matchParent)) + } + + private val inAnimation by lazy { + AnimationSet(true).apply { + duration = 200L + addAnimation(AlphaAnimation(0f, 1f)) + // 2 stands for Animation.RELATIVE_TO_PARENT + addAnimation(TranslateAnimation(2, -0.3f * translateDirection, 2, 0f, 0, 0f, 0, 0f)) + } + } + + private val outAnimation by lazy { + AnimationSet(true).apply { + duration = 200L + addAnimation(AlphaAnimation(1f, 0f)) + addAnimation(TranslateAnimation(2, 0f, 2, -0.3f * translateDirection, 0, 0f, 0, 0f)) + } + } + + override val root = constraintLayout { + val size = dp(KawaiiBarComponent.HEIGHT) + add(menuButton, lParams(size, size) { + startOfParent() + centerVertically() + }) + add(hideKeyboardButton, lParams(size, size) { + endOfParent() + centerVertically() + }) + add(animator, lParams(matchConstraints, matchParent) { + after(menuButton) + before(hideKeyboardButton) + centerVertically() + }) + add(numberRow, lParams(matchParent, matchParent)) + } + + fun privateMode(activate: Boolean = true) { + if (activate == inPrivate) return + inPrivate = activate + updateMenuButtonIcon() + updateMenuButtonContentDescription() + updateMenuButtonRotation(instant = true) + } + + private fun updateMenuButtonIcon() { + menuButton.image.imageResource = + if (inPrivate) R.drawable.ic_view_private + 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 { + if (targetRotation == rotation) return + animate().cancel() + if (!instant && !disableAnimation) { + animate().setDuration(200L).rotation(targetRotation) + } else { + rotation = targetRotation + } + } + } + + fun setHideKeyboardIsVoiceInput(isVoiceInput: Boolean, callback: View.OnClickListener) { + 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) + } + + private fun clearAnimation() { + animator.inAnimation = null + animator.outAnimation = null + } + + private fun setAnimation() { + animator.inAnimation = inAnimation + animator.outAnimation = outAnimation + } + + fun updateState(state: State, fromUser: Boolean = false) { + Timber.d("Switch idle ui to $state") + if ( + !fromUser || + disableAnimation || + (state == State.InlineSuggestion || currentState == State.InlineSuggestion) + ) { + clearAnimation() + } else { + setAnimation() + } + when (state) { + State.Empty -> animator.displayedChild = 0 + State.Toolbar -> animator.displayedChild = 1 + State.Clipboard -> animator.displayedChild = 2 + State.NumberRow -> {} + State.InlineSuggestion -> animator.displayedChild = 3 + } + if (state == State.NumberRow) { + menuButton.visibility = View.GONE + hideKeyboardButton.visibility = View.GONE + animator.visibility = View.GONE + numberRow.visibility = View.VISIBLE + numberRow.keyActionListener = commonKeyActionListener.listener + numberRow.popupActionListener = popup.listener + } else { + menuButton.visibility = View.VISIBLE + hideKeyboardButton.visibility = View.VISIBLE + animator.visibility = View.VISIBLE + numberRow.visibility = View.GONE + numberRow.keyActionListener = null + numberRow.popupActionListener = null + 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 new file mode 100644 index 000000000..9f746dba6 --- /dev/null +++ b/app/src/main/java/org/fcitx/fcitx5/android/input/bar/ui/TitleUi.kt @@ -0,0 +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.bar.ui + +import android.content.Context +import android.graphics.Typeface +import android.view.View +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.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 +import splitties.views.dsl.core.wrapContent +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 { + contentDescription = ctx.getString(R.string.back_to_keyboard) + } + + private val titleText = textView { + typeface = Typeface.defaultFromStyle(Typeface.BOLD) + setTextColor(theme.altKeyTextColor) + gravity = gravityVerticalCenter + textSize = 16f + } + + private var extension: View? = null + + override val root = constraintLayout { + add(backButton, lParams(dp(40), dp(40)) { + topOfParent() + startOfParent() + bottomOfParent() + }) + add(titleText, lParams(wrapContent, dp(40)) { + topOfParent() + after(backButton, dp(8)) + bottomOfParent() + }) + } + + fun setReturnButtonOnClickListener(block: () -> Unit) { + backButton.setOnClickListener { + block() + } + } + + fun setTitle(title: String) { + titleText.text = title + } + + fun addExtension(view: View, showTitle: Boolean) { + if (extension != null) { + throw IllegalStateException("TitleBar extension is already present") + } + backButton.isVisible = showTitle + titleText.isVisible = showTitle + extension = view + root.run { + add(view, lParams(matchConstraints, dp(40)) { + centerVertically() + if (showTitle) { + endOfParent(dp(5)) + } else { + centerHorizontally() + } + }) + } + } + + fun removeExtension() { + extension?.let { + root.removeView(it) + extension = null + } + } +} diff --git a/app/src/main/java/org/fcitx/fcitx5/android/input/bar/ToolButton.kt b/app/src/main/java/org/fcitx/fcitx5/android/input/bar/ui/ToolButton.kt similarity index 61% rename from app/src/main/java/org/fcitx/fcitx5/android/input/bar/ToolButton.kt rename to app/src/main/java/org/fcitx/fcitx5/android/input/bar/ui/ToolButton.kt index 08981de1a..be8e2ae40 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/input/bar/ToolButton.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/input/bar/ui/ToolButton.kt @@ -1,10 +1,13 @@ -package org.fcitx.fcitx5.android.input.bar +/* + * 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.annotation.SuppressLint 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 import org.fcitx.fcitx5.android.data.prefs.AppPrefs import org.fcitx.fcitx5.android.data.theme.Theme @@ -12,18 +15,15 @@ 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 -@SuppressLint("ViewConstructor") -class ToolButton(context: Context, @DrawableRes icon: Int, val theme: Theme) : - CustomGestureView(context) { +class ToolButton(context: Context) : CustomGestureView(context) { companion object { val disableAnimation by AppPrefs.getInstance().advanced.disableAnimation @@ -32,20 +32,26 @@ class ToolButton(context: Context, @DrawableRes icon: Int, val theme: Theme) : val image = imageView { isClickable = false isFocusable = false - imageDrawable = context.drawable(icon) padding = dp(10) scaleType = ImageView.ScaleType.CENTER_INSIDE - colorFilter = PorterDuffColorFilter(theme.altKeyTextColor, PorterDuff.Mode.SRC_IN) } - init { - val color = theme.keyPressHighlightColor + 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) { + image.imageResource = icon + } + + fun setPressHighlightColor(@ColorInt color: Int) { background = if (disableAnimation) { circlePressHighlightDrawable(color) } else { borderlessRippleDrawable(color, dp(20)) } - - add(image, lParams(wrapContent, wrapContent, gravityCenter)) } } 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 new file mode 100644 index 000000000..3170536fa --- /dev/null +++ b/app/src/main/java/org/fcitx/fcitx5/android/input/bar/ui/idle/ButtonsBarUi.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.input.bar.ui.idle + +import android.content.Context +import androidx.annotation.DrawableRes +import com.google.android.flexbox.AlignItems +import com.google.android.flexbox.FlexboxLayout +import com.google.android.flexbox.JustifyContent +import org.fcitx.fcitx5.android.R +import org.fcitx.fcitx5.android.data.theme.Theme +import org.fcitx.fcitx5.android.input.bar.ui.ToolButton +import splitties.dimensions.dp +import splitties.views.dsl.core.Ui +import splitties.views.dsl.core.view + +class ButtonsBarUi(override val ctx: Context, private val theme: Theme) : Ui { + + override val root = view(::FlexboxLayout) { + alignItems = AlignItems.CENTER + justifyContent = JustifyContent.SPACE_AROUND + } + + private fun toolButton(@DrawableRes icon: Int) = ToolButton(ctx, icon, theme).also { + val size = ctx.dp(40) + root.addView(it, FlexboxLayout.LayoutParams(size, size)) + } + + 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).apply { + contentDescription = ctx.getString(R.string.redo) + } + + val cursorMoveButton = toolButton(R.drawable.ic_cursor_move).apply { + contentDescription = ctx.getString(R.string.text_editing) + } + + val clipboardButton = toolButton(R.drawable.ic_clipboard).apply { + contentDescription = ctx.getString(R.string.clipboard) + } + + 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 new file mode 100644 index 000000000..7b46b33a8 --- /dev/null +++ b/app/src/main/java/org/fcitx/fcitx5/android/input/bar/ui/idle/ClipboardSuggestionUi.kt @@ -0,0 +1,74 @@ +/* + * 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.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.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 { + imageDrawable = drawable(R.drawable.ic_clipboard)!!.apply { + setTint(theme.altKeyTextColor) + } + } + + val text = textView { + isSingleLine = true + maxWidth = dp(120) + ellipsize = TextUtils.TruncateAt.END + setTextColor(theme.altKeyTextColor) + } + + private val layout = constraintLayout { + val spacing = dp(4) + add(icon, lParams(dp(20), dp(20)) { + startOfParent(spacing) + before(text) + centerVertically() + }) + add(text, lParams(wrapContent, wrapContent) { + after(icon, spacing) + endOfParent(spacing) + centerVertically() + }) + } + + val suggestionView = CustomGestureView(ctx).apply { + add(layout, lParams(wrapContent, matchParent)) + background = rippleDrawable(theme.keyPressHighlightColor) + } + + override val root = constraintLayout { + add(suggestionView, lParams(wrapContent, matchConstraints) { + centerInParent() + verticalMargin = dp(4) + }) + } +} 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 new file mode 100644 index 000000000..afcacc5dd --- /dev/null +++ b/app/src/main/java/org/fcitx/fcitx5/android/input/bar/ui/idle/InlineSuggestionsUi.kt @@ -0,0 +1,134 @@ +/* + * 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.os.Build +import android.view.SurfaceControl +import android.view.SurfaceView +import android.widget.FrameLayout +import android.widget.HorizontalScrollView +import android.widget.inline.InlineContentView +import androidx.annotation.RequiresApi +import androidx.core.view.updateLayoutParams +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.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 { + + private val scrollView = ctx.view(::HorizontalScrollView) { + isFillViewport = true + scrollBarSize = dp(1) + } + + private val scrollSurfaceView = ctx.view(::SurfaceView) { + setZOrderOnTop(true) + } + + private val scrollableContentViews = mutableListOf() + + private val pinnedView = frameLayout { } + + private var pinnedContentView: InlineContentView? = null + + override val root = constraintLayout { + add(scrollView, lParams(matchConstraints, matchParent) { + startOfParent() + before(pinnedView) + centerVertically() + }) + add(scrollSurfaceView, lParams(matchConstraints, matchParent) { + centerOn(scrollView) + }) + add(pinnedView, lParams(wrapContent, matchParent) { + endOfParent() + centerVertically() + }) + } + + @RequiresApi(Build.VERSION_CODES.R) + private fun clearScrollView() { + scrollView.scrollTo(0, 0) + scrollView.removeAllViews() + scrollableContentViews.forEach { v -> + v.surfaceControl?.let { sc -> + SurfaceControl.Transaction().reparent(sc, null).apply() + } + } + scrollableContentViews.clear() + } + + @RequiresApi(Build.VERSION_CODES.R) + private fun clearPinnedView() { + pinnedView.removeAllViews() + pinnedContentView = null + } + + @RequiresApi(Build.VERSION_CODES.R) + fun clear() { + clearScrollView() + clearPinnedView() + } + + @RequiresApi(Build.VERSION_CODES.R) + fun setPinnedView(view: InlineContentView?) { + pinnedView.removeAllViews() + pinnedContentView = view?.also { + pinnedView.addView(it) + it.updateLayoutParams { + horizontalMargin = ctx.dp(10) + } + } + } + + @RequiresApi(Build.VERSION_CODES.R) + 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) { + SurfaceControl.Transaction() + .reparent(surfaceControl, parentSurfaceControl) + .apply() + } + + override fun onDestroyed(surfaceControl: SurfaceControl) {} + }) + flexbox.addView(it) + it.updateLayoutParams { + flexShrink = 0f + } + } + scrollView.apply { + scrollTo(0, 0) + removeAllViews() + add(flexbox, lParams(wrapContent, matchParent)) + } + } +} 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 new file mode 100644 index 000000000..0da95932a --- /dev/null +++ b/app/src/main/java/org/fcitx/fcitx5/android/input/bar/ui/idle/NumberRow.kt @@ -0,0 +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.bar.ui.idle + +import android.annotation.SuppressLint +import android.content.Context +import org.fcitx.fcitx5.android.core.KeySym +import org.fcitx.fcitx5.android.data.theme.Theme +import org.fcitx.fcitx5.android.input.keyboard.BaseKeyboard +import org.fcitx.fcitx5.android.input.keyboard.KeyAction +import org.fcitx.fcitx5.android.input.keyboard.KeyDef + +@SuppressLint("ViewConstructor") +class NumberRow(ctx: Context, theme: Theme) : BaseKeyboard(ctx, theme, Layout) { + companion object { + val Layout = listOf( + listOf("1", "2", "3", "4", "5", "6", "7", "8", "9", "0").map { digit -> + KeyDef( + KeyDef.Appearance.Text( + displayText = digit, + textSize = 21f, + border = KeyDef.Appearance.Border.Off, + margin = false + ), + setOf( + KeyDef.Behavior.Press(KeyAction.SymAction(KeySym(digit.codePointAt(0)))) + ), + arrayOf(KeyDef.Popup.Preview(digit)) + ) + } + ) + } +} 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 83f2056d7..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,9 +1,16 @@ +/* + * 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 +import androidx.annotation.DrawableRes import org.fcitx.fcitx5.android.core.Action -import org.fcitx.fcitx5.android.core.FcitxEvent.InputPanelAuxEvent -import org.fcitx.fcitx5.android.core.FcitxEvent.PreeditEvent +import org.fcitx.fcitx5.android.core.CapabilityFlags +import org.fcitx.fcitx5.android.core.FcitxEvent.CandidateListEvent +import org.fcitx.fcitx5.android.core.FcitxEvent.InputPanelEvent +import org.fcitx.fcitx5.android.core.FormattedText import org.fcitx.fcitx5.android.core.InputMethodEntry import org.fcitx.fcitx5.android.input.wm.InputWindow import org.mechdancer.dependency.DynamicScope @@ -12,15 +19,15 @@ interface InputBroadcastReceiver { fun onScopeSetupFinished(scope: DynamicScope) {} - fun onEditorInfoUpdate(info: EditorInfo?) {} + fun onStartInput(info: EditorInfo, capFlags: CapabilityFlags) {} - fun onPreeditUpdate(data: PreeditEvent.Data) {} + fun onClientPreeditUpdate(data: FormattedText) {} - fun onInputPanelAuxUpdate(data: InputPanelAuxEvent.Data) {} + fun onInputPanelUpdate(data: InputPanelEvent.Data) {} fun onImeUpdate(ime: InputMethodEntry) {} - fun onCandidateUpdate(data: Array) {} + fun onCandidateUpdate(data: CandidateListEvent.Data) {} fun onStatusAreaUpdate(actions: Array) {} @@ -32,4 +39,8 @@ interface InputBroadcastReceiver { fun onPunctuationUpdate(mapping: Map) {} + fun onPreeditEmptyStateUpdate(empty: Boolean) {} + + fun onReturnKeyDrawableUpdate(@DrawableRes resourceId: Int) {} + } \ No newline at end of file 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 9fc409811..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,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.broadcast import android.view.inputmethod.EditorInfo import org.fcitx.fcitx5.android.core.Action -import org.fcitx.fcitx5.android.core.FcitxEvent.InputPanelAuxEvent -import org.fcitx.fcitx5.android.core.FcitxEvent.PreeditEvent +import org.fcitx.fcitx5.android.core.CapabilityFlags +import org.fcitx.fcitx5.android.core.FcitxEvent.CandidateListEvent +import org.fcitx.fcitx5.android.core.FcitxEvent.InputPanelEvent +import org.fcitx.fcitx5.android.core.FormattedText import org.fcitx.fcitx5.android.core.InputMethodEntry import org.fcitx.fcitx5.android.input.wm.InputWindow import org.mechdancer.dependency.Dependent @@ -23,6 +29,7 @@ class InputBroadcaster : UniqueComponent(), Dependent, InputBr receivers.add(scopeEvent.dependency as InputBroadcastReceiver) } } + is ScopeEvent.DependencyLeftEvent -> { if (scopeEvent.dependency is InputBroadcastReceiver && scopeEvent.dependency !is InputBroadcaster) { receivers.remove(scopeEvent.dependency as InputBroadcastReceiver) @@ -31,23 +38,23 @@ class InputBroadcaster : UniqueComponent(), Dependent, InputBr } } - override fun onPreeditUpdate(data: PreeditEvent.Data) { - receivers.forEach { it.onPreeditUpdate(data) } + override fun onClientPreeditUpdate(data: FormattedText) { + receivers.forEach { it.onClientPreeditUpdate(data) } } - override fun onInputPanelAuxUpdate(data: InputPanelAuxEvent.Data) { - receivers.forEach { it.onInputPanelAuxUpdate(data) } + override fun onInputPanelUpdate(data: InputPanelEvent.Data) { + receivers.forEach { it.onInputPanelUpdate(data) } } - override fun onEditorInfoUpdate(info: EditorInfo?) { - receivers.forEach { it.onEditorInfoUpdate(info) } + override fun onStartInput(info: EditorInfo, capFlags: CapabilityFlags) { + receivers.forEach { it.onStartInput(info, capFlags) } } override fun onImeUpdate(ime: InputMethodEntry) { receivers.forEach { it.onImeUpdate(ime) } } - override fun onCandidateUpdate(data: Array) { + override fun onCandidateUpdate(data: CandidateListEvent.Data) { receivers.forEach { it.onCandidateUpdate(data) } } @@ -75,4 +82,12 @@ class InputBroadcaster : UniqueComponent(), Dependent, InputBr receivers.forEach { it.onPunctuationUpdate(mapping) } } + override fun onPreeditEmptyStateUpdate(empty: Boolean) { + receivers.forEach { it.onPreeditEmptyStateUpdate(empty) } + } + + override fun onReturnKeyDrawableUpdate(resourceId: Int) { + receivers.forEach { it.onReturnKeyDrawableUpdate(resourceId) } + } + } \ No newline at end of file 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 new file mode 100644 index 000000000..3c517b8d5 --- /dev/null +++ b/app/src/main/java/org/fcitx/fcitx5/android/input/broadcast/PreeditEmptyStateComponent.kt @@ -0,0 +1,36 @@ +/* + * 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 +import org.fcitx.fcitx5.android.input.dependency.fcitx +import org.mechdancer.dependency.Dependent +import org.mechdancer.dependency.UniqueComponent +import org.mechdancer.dependency.manager.ManagedHandler +import org.mechdancer.dependency.manager.managedHandler +import org.mechdancer.dependency.manager.must + +class PreeditEmptyStateComponent : + UniqueComponent(), Dependent, ManagedHandler by managedHandler() { + + private val fcitx by manager.fcitx() + private val broadcaster: InputBroadcaster by manager.must() + private val returnKeyDrawable: ReturnKeyDrawableComponent by manager.must() + + var isEmpty: Boolean = true + private set + + fun updatePreeditEmptyState( + clientPreedit: FormattedText = fcitx.runImmediately { clientPreeditCached }, + preedit: FormattedText = fcitx.runImmediately { inputPanelCached.preedit } + ) { + val empty = clientPreedit.isEmpty() && preedit.isEmpty() + if (isEmpty == empty) return + isEmpty = empty + broadcaster.onPreeditEmptyStateUpdate(isEmpty) + returnKeyDrawable.updateDrawableOnPreedit(isEmpty) + } + +} diff --git a/app/src/main/java/org/fcitx/fcitx5/android/input/punctuation/PunctuationComponent.kt b/app/src/main/java/org/fcitx/fcitx5/android/input/broadcast/PunctuationComponent.kt similarity index 60% rename from app/src/main/java/org/fcitx/fcitx5/android/input/punctuation/PunctuationComponent.kt rename to app/src/main/java/org/fcitx/fcitx5/android/input/broadcast/PunctuationComponent.kt index ad80af288..67ad72192 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/input/punctuation/PunctuationComponent.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/input/broadcast/PunctuationComponent.kt @@ -1,11 +1,13 @@ -package org.fcitx.fcitx5.android.input.punctuation +/* + * 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 import kotlinx.coroutines.launch import org.fcitx.fcitx5.android.core.Action import org.fcitx.fcitx5.android.data.punctuation.PunctuationManager -import org.fcitx.fcitx5.android.input.broadcast.InputBroadcastReceiver -import org.fcitx.fcitx5.android.input.broadcast.InputBroadcaster import org.fcitx.fcitx5.android.input.dependency.fcitx import org.fcitx.fcitx5.android.input.dependency.inputMethodService import org.mechdancer.dependency.Dependent @@ -14,37 +16,40 @@ import org.mechdancer.dependency.manager.ManagedHandler import org.mechdancer.dependency.manager.managedHandler import org.mechdancer.dependency.manager.must -class PunctuationComponent : InputBroadcastReceiver, +class PunctuationComponent : UniqueComponent(), Dependent, ManagedHandler by managedHandler() { private val fcitx by manager.fcitx() private val service by manager.inputMethodService() private val broadcaster: InputBroadcaster by manager.must() - private var mapping: Map = mapOf() + private var mapping: Map = emptyMap() var enabled: Boolean = false private set fun transform(p: String) = mapping.getOrDefault(p, p) - private fun updateMapping(lang: String? = null) { + fun updatePunctuationMapping(actions: Array) { + enabled = actions.any { + // TODO: A better way to check if punctuation mapping is enabled + it.name == "punctuation" && it.icon == "fcitx-punc-active" + } service.lifecycleScope.launch { mapping = if (enabled) { fcitx.runOnReady { - PunctuationManager.load(this, lang ?: 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) } } - - override fun onStatusAreaUpdate(actions: Array) { - enabled = actions.any { - // TODO A better way to check if punctuation mapping is enabled - it.name == "punctuation" && it.icon == "fcitx-punc-active" - } - updateMapping() - } } 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 new file mode 100644 index 000000000..972a4c8af --- /dev/null +++ b/app/src/main/java/org/fcitx/fcitx5/android/input/broadcast/ReturnKeyDrawableComponent.kt @@ -0,0 +1,63 @@ +/* + * 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 +import androidx.annotation.DrawableRes +import org.fcitx.fcitx5.android.R +import org.mechdancer.dependency.Dependent +import org.mechdancer.dependency.UniqueComponent +import org.mechdancer.dependency.manager.ManagedHandler +import org.mechdancer.dependency.manager.managedHandler +import org.mechdancer.dependency.manager.must +import splitties.bitflags.hasFlag + +class ReturnKeyDrawableComponent : + UniqueComponent(), Dependent, ManagedHandler by managedHandler() { + + companion object { + @DrawableRes + val DEFAULT_DRAWABLE = R.drawable.ic_baseline_keyboard_return_24 + } + + private val broadcaster: InputBroadcaster by manager.must() + + @DrawableRes + var resourceId: Int = DEFAULT_DRAWABLE + private set + + @DrawableRes + private var actionDrawable: Int = DEFAULT_DRAWABLE + + @DrawableRes + private fun drawableFromEditorInfo(info: EditorInfo): Int { + if (info.imeOptions.hasFlag(EditorInfo.IME_FLAG_NO_ENTER_ACTION)) { + return R.drawable.ic_baseline_keyboard_return_24 + } + return when (info.imeOptions and EditorInfo.IME_MASK_ACTION) { + EditorInfo.IME_ACTION_GO -> R.drawable.ic_baseline_arrow_forward_24 + EditorInfo.IME_ACTION_SEARCH -> R.drawable.ic_baseline_search_24 + EditorInfo.IME_ACTION_SEND -> R.drawable.ic_baseline_send_24 + EditorInfo.IME_ACTION_NEXT -> R.drawable.ic_baseline_keyboard_tab_24 + EditorInfo.IME_ACTION_DONE -> R.drawable.ic_baseline_done_24 + EditorInfo.IME_ACTION_PREVIOUS -> R.drawable.ic_baseline_keyboard_tab_reverse_24 + else -> R.drawable.ic_baseline_keyboard_return_24 + } + } + + fun updateDrawableOnEditorInfo(info: EditorInfo) { + actionDrawable = drawableFromEditorInfo(info) + if (resourceId == actionDrawable) return + resourceId = actionDrawable + broadcaster.onReturnKeyDrawableUpdate(resourceId) + } + + fun updateDrawableOnPreedit(preeditEmpty: Boolean) { + val newResId = if (preeditEmpty) actionDrawable else DEFAULT_DRAWABLE + if (resourceId == newResId) return + resourceId = newResId + broadcaster.onReturnKeyDrawableUpdate(resourceId) + } +} 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/CandidateViewBuilder.kt b/app/src/main/java/org/fcitx/fcitx5/android/input/candidates/CandidateViewBuilder.kt deleted file mode 100644 index f7d7aa493..000000000 --- a/app/src/main/java/org/fcitx/fcitx5/android/input/candidates/CandidateViewBuilder.kt +++ /dev/null @@ -1,54 +0,0 @@ -package org.fcitx.fcitx5.android.input.candidates - -import android.graphics.drawable.ShapeDrawable -import android.graphics.drawable.shapes.RectShape -import android.view.ContextThemeWrapper -import androidx.lifecycle.lifecycleScope -import org.fcitx.fcitx5.android.daemon.FcitxConnection -import org.fcitx.fcitx5.android.daemon.launchOnFcitxReady -import org.fcitx.fcitx5.android.data.theme.Theme -import org.fcitx.fcitx5.android.input.FcitxInputMethodService -import org.fcitx.fcitx5.android.input.candidates.adapter.GridCandidateViewAdapter -import org.fcitx.fcitx5.android.input.candidates.adapter.SimpleCandidateViewAdapter -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.mechdancer.dependency.Dependent -import org.mechdancer.dependency.UniqueComponent -import org.mechdancer.dependency.manager.ManagedHandler -import org.mechdancer.dependency.manager.managedHandler -import splitties.dimensions.dp - -class CandidateViewBuilder : UniqueComponent(), Dependent, - ManagedHandler by managedHandler() { - - private val service: FcitxInputMethodService by manager.inputMethodService() - private val context: ContextThemeWrapper by manager.context() - private val fcitx: FcitxConnection by manager.fcitx() - private val theme by manager.theme() - - fun gridAdapter() = object : GridCandidateViewAdapter() { - override fun onSelect(idx: Int) { - service.lifecycleScope.launchOnFcitxReady(fcitx) { it.select(idx) } - } - - override val theme: Theme - get() = this@CandidateViewBuilder.theme - } - - fun simpleAdapter() = object : SimpleCandidateViewAdapter() { - override fun onSelect(idx: Int) { - service.lifecycleScope.launchOnFcitxReady(fcitx) { it.select(idx) } - } - - override val theme: Theme - get() = this@CandidateViewBuilder.theme - } - - fun dividerDrawable() = ShapeDrawable(RectShape()).apply { - intrinsicWidth = context.dp(1) - intrinsicHeight = context.dp(1) - paint.color = theme.dividerColor - } -} \ No newline at end of file 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/HorizontalCandidateComponent.kt b/app/src/main/java/org/fcitx/fcitx5/android/input/candidates/HorizontalCandidateComponent.kt deleted file mode 100644 index fef6f06c4..000000000 --- a/app/src/main/java/org/fcitx/fcitx5/android/input/candidates/HorizontalCandidateComponent.kt +++ /dev/null @@ -1,80 +0,0 @@ -package org.fcitx.fcitx5.android.input.candidates - -import android.content.Context -import android.view.ViewGroup -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 org.fcitx.fcitx5.android.R -import org.fcitx.fcitx5.android.data.prefs.AppPrefs -import org.fcitx.fcitx5.android.input.bar.ExpandButtonStateMachine.TransitionEvent.ExpandedCandidatesUpdatedEmpty -import org.fcitx.fcitx5.android.input.bar.ExpandButtonStateMachine.TransitionEvent.ExpandedCandidatesUpdatedNonEmpty -import org.fcitx.fcitx5.android.input.bar.KawaiiBarComponent -import org.fcitx.fcitx5.android.input.broadcast.InputBroadcastReceiver -import org.fcitx.fcitx5.android.input.candidates.expanded.decoration.FlexboxVerticalDecoration -import org.fcitx.fcitx5.android.input.dependency.UniqueViewComponent -import org.fcitx.fcitx5.android.input.dependency.context -import org.mechdancer.dependency.manager.must -import splitties.views.dsl.recyclerview.recyclerView - -class HorizontalCandidateComponent : - UniqueViewComponent(), InputBroadcastReceiver { - - private val builder: CandidateViewBuilder by manager.must() - private val context: Context by manager.context() - private val bar: KawaiiBarComponent by manager.must() - - val adapter by lazy { - builder.simpleAdapter() - } - - // Since expanded candidate window is created once the expand button was clicked, - // we need to replay the last offset - private val _expandedCandidateOffset = MutableSharedFlow( - replay = 1, - onBufferOverflow = BufferOverflow.DROP_OLDEST - ) - - val expandedCandidateOffset = _expandedCandidateOffset.asSharedFlow() - - val horizontalCandidateGrowth by AppPrefs.getInstance().keyboard.horizontalCandidateGrowth - - private fun refreshExpanded() { - runBlocking { - _expandedCandidateOffset.emit(view.childCount) - } - } - - override val view by lazy { - context.recyclerView(R.id.candidate_view) { - adapter = this@HorizontalCandidateComponent.adapter - layoutManager = object : FlexboxLayoutManager(context) { - override fun canScrollVertically(): Boolean = false - override fun canScrollHorizontally(): Boolean = false - override fun generateLayoutParams(lp: ViewGroup.LayoutParams?) = - LayoutParams(lp).apply { - flexGrow = if (horizontalCandidateGrowth) 1f else 0f - } - - override fun onLayoutCompleted(state: RecyclerView.State?) { - super.onLayoutCompleted(state) - refreshExpanded() - bar.expandButtonStateMachine.push( - if (adapter!!.itemCount - childCount > 0) - ExpandedCandidatesUpdatedNonEmpty - else - ExpandedCandidatesUpdatedEmpty - ) - } - } - addItemDecoration(FlexboxVerticalDecoration(builder.dividerDrawable())) - } - } - - override fun onCandidateUpdate(data: Array) { - adapter.updateCandidates(data) - } -} \ No newline at end of file diff --git a/app/src/main/java/org/fcitx/fcitx5/android/input/candidates/adapter/BaseCandidateViewAdapter.kt b/app/src/main/java/org/fcitx/fcitx5/android/input/candidates/adapter/BaseCandidateViewAdapter.kt deleted file mode 100644 index a955ce095..000000000 --- a/app/src/main/java/org/fcitx/fcitx5/android/input/candidates/adapter/BaseCandidateViewAdapter.kt +++ /dev/null @@ -1,53 +0,0 @@ -package org.fcitx.fcitx5.android.input.candidates.adapter - -import android.annotation.SuppressLint -import android.view.ViewGroup -import androidx.annotation.CallSuper -import androidx.recyclerview.widget.RecyclerView -import org.fcitx.fcitx5.android.data.theme.Theme -import org.fcitx.fcitx5.android.input.candidates.CandidateItemUi - -abstract class BaseCandidateViewAdapter : - RecyclerView.Adapter() { - inner class ViewHolder(val ui: CandidateItemUi) : RecyclerView.ViewHolder(ui.root) { - var idx = -1 - } - - var candidates: Array = arrayOf() - private set - - var offset = 0 - private set - - fun getCandidateAt(position: Int) = candidates[offset + position] - - @SuppressLint("NotifyDataSetChanged") - fun updateCandidates(data: Array) { - candidates = data - notifyDataSetChanged() - } - - fun updateCandidatesWithOffset(data: Array, offset: Int) { - this.offset = offset - updateCandidates(data) - } - - @CallSuper - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { - return ViewHolder(CandidateItemUi(parent.context, theme)).apply { - itemView.setOnClickListener { onSelect(this.idx + offset) } - } - } - - @CallSuper - override fun onBindViewHolder(holder: ViewHolder, position: Int) { - holder.ui.text.text = getCandidateAt(position) - holder.idx = position - } - - abstract val theme: Theme - - override fun getItemCount() = candidates.size - offset - - abstract fun onSelect(idx: Int) -} \ No newline at end of file diff --git a/app/src/main/java/org/fcitx/fcitx5/android/input/candidates/adapter/GridCandidateViewAdapter.kt b/app/src/main/java/org/fcitx/fcitx5/android/input/candidates/adapter/GridCandidateViewAdapter.kt deleted file mode 100644 index 2e675c500..000000000 --- a/app/src/main/java/org/fcitx/fcitx5/android/input/candidates/adapter/GridCandidateViewAdapter.kt +++ /dev/null @@ -1,35 +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 splitties.dimensions.dp -import splitties.views.dsl.core.matchParent - -abstract class GridCandidateViewAdapter : BaseCandidateViewAdapter() { - - // cache measureWidth - private val measuredWidths = LruCache(200) - - fun measureWidth(position: Int): Float { - val candidate = getCandidateAt(position) - 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 { - ui.root.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/SimpleCandidateViewAdapter.kt b/app/src/main/java/org/fcitx/fcitx5/android/input/candidates/adapter/SimpleCandidateViewAdapter.kt deleted file mode 100644 index d1f3fbcb6..000000000 --- a/app/src/main/java/org/fcitx/fcitx5/android/input/candidates/adapter/SimpleCandidateViewAdapter.kt +++ /dev/null @@ -1,20 +0,0 @@ -package org.fcitx.fcitx5.android.input.candidates.adapter - -import android.view.ViewGroup -import androidx.recyclerview.widget.GridLayoutManager -import splitties.dimensions.dp -import splitties.views.dsl.core.wrapContent -import splitties.views.setPaddingDp - -abstract class SimpleCandidateViewAdapter : BaseCandidateViewAdapter() { - - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { - return super.onCreateViewHolder(parent, viewType).apply { - ui.root.apply { - setPaddingDp(8, 0, 8, 0) - minimumWidth = dp(40) - layoutParams = GridLayoutManager.LayoutParams(wrapContent, 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 new file mode 100644 index 000000000..2b5f7c05c --- /dev/null +++ b/app/src/main/java/org/fcitx/fcitx5/android/input/candidates/expanded/CandidatesPagingSource.kt @@ -0,0 +1,35 @@ +/* + * 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 +import androidx.paging.PagingState +import org.fcitx.fcitx5.android.daemon.FcitxConnection +import timber.log.Timber + +class CandidatesPagingSource(val fcitx: FcitxConnection, val total: Int, val offset: Int) : + PagingSource() { + + override suspend fun load(params: LoadParams): LoadResult { + // 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)") + val candidates = fcitx.runOnReady { + getCandidates(startIndex, pageSize) + } + val prevKey = if (startIndex >= pageSize) startIndex - pageSize else null + 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) + } + + // always reload from beginning + override fun getRefreshKey(state: PagingState) = null + +} 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 ef654b8a4..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,24 +1,35 @@ +/* + * 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 import android.content.Context import androidx.constraintlayout.widget.ConstraintLayout -import androidx.recyclerview.widget.RecyclerView 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 @SuppressLint("ViewConstructor") -class ExpandedCandidateLayout( - context: Context, - theme: Theme, - initRecyclerView: RecyclerView.() -> Unit = {} -) : ConstraintLayout(context) { +class ExpandedCandidateLayout(context: Context, theme: Theme) : ConstraintLayout(context) { class Keyboard(context: Context, theme: Theme) : BaseKeyboard(context, theme, Layout) { companion object { @@ -56,6 +67,10 @@ class ExpandedCandidateLayout( val pageDnBtn: ImageKeyView by lazy { findViewById(DownBtnId) } val backspace: ImageKeyView by lazy { findViewById(R.id.button_backspace) } val `return`: ImageKeyView by lazy { findViewById(R.id.button_return) } + + override fun onReturnDrawableUpdate(returnDrawable: Int) { + `return`.img.imageResource = returnDrawable + } } private val keyBorder by ThemeManager.prefs.keyBorder @@ -80,19 +95,17 @@ class ExpandedCandidateLayout( add(recyclerView, lParams { topOfParent() - startOfParent() - before(embeddedKeyboard) + leftOfParent() + rightToLeftOf(embeddedKeyboard) bottomOfParent() }) add(embeddedKeyboard, lParams { matchConstraintPercentWidth = 0.15f topOfParent() - after(recyclerView) - endOfParent() + leftToRightOf(recyclerView) + rightOfParent() bottomOfParent() }) - - initRecyclerView(recyclerView) } fun resetPosition() { 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/expanded/PagingCandidateViewAdapter.kt b/app/src/main/java/org/fcitx/fcitx5/android/input/candidates/expanded/PagingCandidateViewAdapter.kt new file mode 100644 index 000000000..c7d4d556d --- /dev/null +++ b/app/src/main/java/org/fcitx/fcitx5/android/input/candidates/expanded/PagingCandidateViewAdapter.kt @@ -0,0 +1,48 @@ +/* + * 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 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) { + + companion object { + private val diffCallback = object : DiffUtil.ItemCallback() { + override fun areItemsTheSame(oldItem: String, newItem: String): Boolean { + return oldItem === newItem + } + + override fun areContentsTheSame(oldItem: String, newItem: String): Boolean { + return oldItem.contentEquals(newItem) + } + } + } + + var offset = 0 + private set + + fun refreshWithOffset(offset: Int) { + this.offset = offset + refresh() + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): CandidateViewHolder { + return CandidateViewHolder(CandidateItemUi(parent.context, theme)) + } + + 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 734fab72d..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,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.candidates.expanded import androidx.recyclerview.widget.GridLayoutManager -import org.fcitx.fcitx5.android.input.candidates.adapter.GridCandidateViewAdapter import kotlin.math.ceil import kotlin.math.max import kotlin.math.min class SpanHelper( - private val adapter: GridCandidateViewAdapter, + private val adapter: GridPagingCandidateViewAdapter, private val manager: GridLayoutManager ) : GridLayoutManager.SpanSizeLookup() { data class ItemLayout(val spanIndex: Int, val spanSize: Int, val groupIndex: Int) - private var layout: Array = arrayOfNulls(adapter.itemCount) - - private var layoutCount = 0 + private val layout = ArrayList() private fun getMinSpanSize(position: Int) = min( // approximately three characters or one Chinese characters per span @@ -27,11 +29,7 @@ class SpanHelper( * clear calculated layout */ private fun invalidate() { - // reallocate array only when space not enough - if (adapter.itemCount > layout.size) { - layout = arrayOfNulls(adapter.itemCount) - } - layoutCount = 0 + layout.clear() } override fun invalidateSpanIndexCache() { @@ -40,22 +38,18 @@ class SpanHelper( } /** - * Calculate layout for [position] to [layout] + * Ensure layout of item at [position] in [adapter] have been calculated and return its layout */ - // TODO maybe layout items in batch - // layout manager tend to call getSpanIndex.. methods one by one, - // causing layoutItem method can only layout 1 item per call - private fun layoutItem(position: Int) { + private fun layoutItem(position: Int): ItemLayout { // skip calculation if we already have the result - if (layoutCount > position) - return + if (layout.size > position) return layout[position] + val spanCount = manager.spanCount var span = 0 var group = 0 // start layout from the last known position - if (layoutCount > 0) { - val last = layout[layoutCount - 1]!! + layout.lastOrNull()?.also { last -> val lastSpan = last.spanIndex + last.spanSize - if (lastSpan == manager.spanCount) { + if (lastSpan == spanCount) { // the last known item is at the end of its group // start from beginning of next group group = last.groupIndex + 1 @@ -65,40 +59,40 @@ class SpanHelper( group = last.groupIndex } } - for (i in layoutCount..position) { + val itemCount = adapter.itemCount + // layout a row of items each time + val batchEnd = min(spanCount * (position / spanCount + 1), adapter.itemCount) + for (i in layout.size until batchEnd) { var size = getMinSpanSize(i) - val nextSize = if (i + 1 < adapter.itemCount) getMinSpanSize(i + 1) else null + val nextSize = if (i + 1 < itemCount) getMinSpanSize(i + 1) else null // we still have rest span spaces, // but it can't hold the next item - if (nextSize != null && span + size + nextSize > manager.spanCount) { + if (nextSize != null && span + size + nextSize > spanCount) { // stretch this item to fill the line - size = manager.spanCount - span + size = spanCount - span } // save calculated layout - layout[i] = ItemLayout(span, size, group) - layoutCount++ + layout.add(ItemLayout(span, size, group)) // accumulate span size span += size // bump group index - if (span == manager.spanCount) { + if (span == spanCount) { span = 0 group++ } } + return layout[position] } override fun getSpanIndex(position: Int, spanCount: Int): Int { - layoutItem(position) - return layout[position]!!.spanIndex + return layoutItem(position).spanIndex } override fun getSpanGroupIndex(adapterPosition: Int, spanCount: Int): Int { - layoutItem(adapterPosition) - return layout[adapterPosition]!!.groupIndex + return layoutItem(adapterPosition).groupIndex } override fun getSpanSize(position: Int): Int { - layoutItem(position) - return layout[position]!!.spanSize + return layoutItem(position).spanSize } } \ No newline at end of file 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 8a9c5f87e..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 @@ -15,7 +19,18 @@ class FlexboxVerticalDecoration(val drawable: Drawable) : RecyclerView.ItemDecor parent: RecyclerView, state: RecyclerView.State ) { - outRect.right = drawable.intrinsicWidth + when (parent.layoutDirection) { + View.LAYOUT_DIRECTION_LTR -> { + outRect.right = drawable.intrinsicWidth + } + View.LAYOUT_DIRECTION_RTL -> { + outRect.left = drawable.intrinsicWidth + } + else -> { + // should not reach here + outRect.set(0, 0, 0, 0) + } + } } override fun onDraw(c: Canvas, parent: RecyclerView, state: RecyclerView.State) { @@ -23,8 +38,23 @@ class FlexboxVerticalDecoration(val drawable: Drawable) : RecyclerView.ItemDecor for (i in 0 until layoutManager.childCount) { val view = parent.getChildAt(i) val lp = view.layoutParams as FlexboxLayoutManager.LayoutParams - val left = view.right + lp.rightMargin - val right = left + drawable.intrinsicWidth + val left: Int + val right: Int + when (parent.layoutDirection) { + View.LAYOUT_DIRECTION_LTR -> { + left = view.right + lp.rightMargin + right = left + drawable.intrinsicWidth + } + View.LAYOUT_DIRECTION_RTL -> { + right = view.left + lp.leftMargin + left = right - drawable.intrinsicWidth + } + else -> { + // should not reach here + left = view.left + right = left + drawable.intrinsicWidth + } + } val top = view.top - lp.topMargin val bottom = view.bottom + lp.bottomMargin // make the divider shorter 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 3bc13ec02..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 @@ -20,7 +24,18 @@ class GridDecoration(val drawable: Drawable) : RecyclerView.ItemDecoration() { val layoutManager = parent.layoutManager as GridLayoutManager // add space for items except last in each row if (lp.spanIndex + lp.spanSize != layoutManager.spanCount) { - outRect.right = drawable.intrinsicWidth + when (parent.layoutDirection) { + View.LAYOUT_DIRECTION_LTR -> { + outRect.right = drawable.intrinsicWidth + } + View.LAYOUT_DIRECTION_RTL -> { + outRect.left = drawable.intrinsicWidth + } + else -> { + // should not reach here + outRect.set(0, 0, 0, 0) + } + } } else { outRect.set(0, 0, 0, 0) } @@ -37,8 +52,23 @@ class GridDecoration(val drawable: Drawable) : RecyclerView.ItemDecoration() { // skip if it is the last item in each row if (lp.spanIndex + lp.spanSize == layoutManager.spanCount) continue - val left = view.right + lp.rightMargin - val right = left + drawable.intrinsicWidth + val left: Int + val right: Int + when (parent.layoutDirection) { + View.LAYOUT_DIRECTION_LTR -> { + left = view.right + lp.rightMargin + right = left + drawable.intrinsicWidth + } + View.LAYOUT_DIRECTION_RTL -> { + right = view.left + lp.leftMargin + left = right - drawable.intrinsicWidth + } + else -> { + // should not reach here + left = view.left + right = left + drawable.intrinsicWidth + } + } val top = view.top - lp.topMargin val bottom = view.bottom + lp.bottomMargin // make the divider shorter 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 7be81d486..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,22 +1,35 @@ +/* + * 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 androidx.lifecycle.LifecycleCoroutineScope -import androidx.lifecycle.findViewTreeLifecycleOwner +import android.graphics.drawable.ShapeDrawable +import android.graphics.drawable.shapes.RectShape +import android.view.View import androidx.lifecycle.lifecycleScope -import kotlinx.coroutines.Dispatchers +import androidx.paging.Pager +import androidx.paging.PagingConfig +import androidx.recyclerview.widget.RecyclerView import kotlinx.coroutines.Job -import kotlinx.coroutines.flow.launchIn -import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch -import org.fcitx.fcitx5.android.core.FcitxEvent +import org.fcitx.fcitx5.android.daemon.launchOnReady import org.fcitx.fcitx5.android.data.prefs.AppPrefs -import org.fcitx.fcitx5.android.input.bar.ExpandButtonStateMachine.TransitionEvent.* +import org.fcitx.fcitx5.android.input.bar.ExpandButtonStateMachine.BooleanKey.ExpandedCandidatesEmpty +import org.fcitx.fcitx5.android.input.bar.ExpandButtonStateMachine.TransitionEvent.ExpandedCandidatesAttached +import org.fcitx.fcitx5.android.input.bar.ExpandButtonStateMachine.TransitionEvent.ExpandedCandidatesDetached import org.fcitx.fcitx5.android.input.bar.KawaiiBarComponent import org.fcitx.fcitx5.android.input.broadcast.InputBroadcastReceiver -import org.fcitx.fcitx5.android.input.candidates.CandidateViewBuilder -import org.fcitx.fcitx5.android.input.candidates.HorizontalCandidateComponent -import org.fcitx.fcitx5.android.input.candidates.adapter.BaseCandidateViewAdapter +import org.fcitx.fcitx5.android.input.broadcast.ReturnKeyDrawableComponent +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 import org.fcitx.fcitx5.android.input.keyboard.CommonKeyActionListener import org.fcitx.fcitx5.android.input.keyboard.KeyAction @@ -25,26 +38,45 @@ 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.mechdancer.dependency.manager.must +import splitties.dimensions.dp +import kotlin.math.max abstract class BaseExpandedCandidateWindow> : InputWindow.SimpleInputWindow(), InputBroadcastReceiver { - protected val builder: CandidateViewBuilder by manager.must() + protected val service by manager.inputMethodService() protected val theme by manager.theme() + protected val fcitx by manager.fcitx() private val commonKeyActionListener: CommonKeyActionListener by manager.must() private val bar: KawaiiBarComponent by manager.must() private val horizontalCandidate: HorizontalCandidateComponent by manager.must() private val windowManager: InputWindowManager by manager.must() + private val returnKeyDrawable: ReturnKeyDrawableComponent by manager.must() protected val disableAnimation by AppPrefs.getInstance().advanced.disableAnimation - private lateinit var lifecycleCoroutineScope: LifecycleCoroutineScope - protected lateinit var candidateLayout: ExpandedCandidateLayout - private set + private lateinit var candidateLayout: ExpandedCandidateLayout + + protected val dividerDrawable by lazy { + ShapeDrawable(RectShape()).apply { + val intrinsicSize = max(1, context.dp(1)) + intrinsicWidth = intrinsicSize + intrinsicHeight = intrinsicSize + paint.color = theme.dividerColor + } + } abstract fun onCreateCandidateLayout(): ExpandedCandidateLayout - final override fun onCreateView() = onCreateCandidateLayout().also { candidateLayout = it } + final override fun onCreateView(): View { + candidateLayout = onCreateCandidateLayout().apply { + recyclerView.apply { + // disable item cross-fade animation + itemAnimator = null + } + } + return candidateLayout + } private val keyActionListener = KeyActionListener { it, source -> if (it is KeyAction.LayoutSwitchAction) { @@ -57,50 +89,79 @@ abstract class BaseExpandedCandidateWindow> : } } - abstract val adapter: BaseCandidateViewAdapter + abstract val adapter: PagingCandidateViewAdapter + abstract val layoutManager: RecyclerView.LayoutManager private var offsetJob: Job? = null + private val candidatesPager by lazy { + Pager( + config = PagingConfig( + pageSize = 48, + enablePlaceholders = false + ), + pagingSourceFactory = { + CandidatesPagingSource( + fcitx, + total = horizontalCandidate.adapter.total, + offset = adapter.offset + ) + } + ) + } + private var candidatesSubmitJob: Job? = null + abstract fun prevPage() abstract fun nextPage() override fun onAttached() { - lifecycleCoroutineScope = candidateLayout.findViewTreeLifecycleOwner()!!.lifecycleScope bar.expandButtonStateMachine.push(ExpandedCandidatesAttached) - candidateLayout.embeddedKeyboard.keyActionListener = keyActionListener - offsetJob = horizontalCandidate.expandedCandidateOffset - .onEach(this::updateCandidatesWithOffset) - .launchIn(lifecycleCoroutineScope) + candidateLayout.embeddedKeyboard.also { + it.onReturnDrawableUpdate(returnKeyDrawable.resourceId) + it.keyActionListener = keyActionListener + } + offsetJob = service.lifecycleScope.launch { + horizontalCandidate.expandedCandidateOffset.collect { + if (it <= 0) { + windowManager.attachWindow(KeyboardWindow) + } else { + candidateLayout.resetPosition() + adapter.refreshWithOffset(it) + } + } + } + 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.updateCandidatesWithOffset(candidates, 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( - if (adapter.candidates.size > adapter.offset) - ExpandedCandidatesDetachedWithCandidatesNonEmpty - else - ExpandedCandidatesDetachedWithCandidatesEmpty + ExpandedCandidatesDetached, + ExpandedCandidatesEmpty to (horizontalCandidate.adapter.total == adapter.offset) ) + candidatesSubmitJob?.cancel() offsetJob?.cancel() - offsetJob = null candidateLayout.embeddedKeyboard.keyActionListener = null } - override fun onPreeditUpdate(data: FcitxEvent.PreeditEvent.Data) { - if (data.preedit.isEmpty() && data.clientPreedit.isEmpty()) { + override fun onPreeditEmptyStateUpdate(empty: Boolean) { + if (empty) { windowManager.attachWindow(KeyboardWindow) } } + } \ No newline at end of file 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 3d218b6eb..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,39 +12,56 @@ 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.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 +import splitties.views.setPaddingDp class FlexboxExpandedCandidateWindow : BaseExpandedCandidateWindow() { override val adapter by lazy { - builder.simpleAdapter() + object : PagingCandidateViewAdapter(theme) { + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): CandidateViewHolder { + return super.onCreateViewHolder(parent, viewType).apply { + itemView.apply { + minimumWidth = dp(40) + setPaddingDp(10, 0, 10, 0) + layoutParams = FlexboxLayoutManager.LayoutParams(wrapContent, dp(40)) + .apply { flexGrow = 1f } + } + } + } + + override fun onBindViewHolder(holder: CandidateViewHolder, position: Int) { + super.onBindViewHolder(holder, position) + bindCandidateUiViewHolder(holder) + } + } } - val layoutManager: FlexboxLayoutManager - get() = candidateLayout.recyclerView.layoutManager as FlexboxLayoutManager + override val layoutManager by lazy { + FlexboxLayoutManager(context).apply { + justifyContent = JustifyContent.SPACE_AROUND + alignItems = AlignItems.FLEX_START + } + } override fun onCreateCandidateLayout(): ExpandedCandidateLayout = ExpandedCandidateLayout(context, theme).apply { recyclerView.apply { - layoutManager = object : FlexboxLayoutManager(context) { - init { - justifyContent = JustifyContent.SPACE_AROUND - alignItems = AlignItems.FLEX_START - } - - override fun generateLayoutParams(lp: ViewGroup.LayoutParams?) = - LayoutParams(lp).apply { flexGrow = 1f } - } adapter = this@FlexboxExpandedCandidateWindow.adapter - addItemDecoration(FlexboxHorizontalDecoration(builder.dividerDrawable())) + layoutManager = this@FlexboxExpandedCandidateWindow.layoutManager + addItemDecoration(FlexboxHorizontalDecoration(dividerDrawable)) addOnScrollListener(object : RecyclerView.OnScrollListener() { override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) { - (recyclerView.layoutManager as FlexboxLayoutManager).apply { - pageUpBtn.isEnabled = findFirstCompletelyVisibleItemPosition() != 0 + this@FlexboxExpandedCandidateWindow.layoutManager.apply { + 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 062c759c2..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 @@ -6,7 +11,9 @@ import androidx.recyclerview.widget.GridLayoutManager import androidx.recyclerview.widget.LinearSmoothScroller import androidx.recyclerview.widget.RecyclerView import org.fcitx.fcitx5.android.data.prefs.AppPrefs +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 @@ -23,26 +30,32 @@ class GridExpandedCandidateWindow : } override val adapter by lazy { - builder.gridAdapter() + object : GridPagingCandidateViewAdapter(theme) { + override fun onBindViewHolder(holder: CandidateViewHolder, position: Int) { + super.onBindViewHolder(holder, position) + bindCandidateUiViewHolder(holder) + } + } } - val layoutManager: GridLayoutManager - get() = candidateLayout.recyclerView.layoutManager as GridLayoutManager + override val layoutManager by lazy { + GridLayoutManager(context, gridSpanCount).apply { + spanSizeLookup = SpanHelper(adapter, this) + } + } override fun onCreateCandidateLayout(): ExpandedCandidateLayout = ExpandedCandidateLayout(context, theme).apply { recyclerView.apply { - layoutManager = GridLayoutManager(context, gridSpanCount).apply { - spanSizeLookup = SpanHelper(this@GridExpandedCandidateWindow.adapter, this) - } adapter = this@GridExpandedCandidateWindow.adapter - addItemDecoration(GridDecoration(builder.dividerDrawable())) + layoutManager = this@GridExpandedCandidateWindow.layoutManager + addItemDecoration(GridDecoration(dividerDrawable)) 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/horizontal/HorizontalCandidateComponent.kt b/app/src/main/java/org/fcitx/fcitx5/android/input/candidates/horizontal/HorizontalCandidateComponent.kt new file mode 100644 index 000000000..8ee143705 --- /dev/null +++ b/app/src/main/java/org/fcitx/fcitx5/android/input/candidates/horizontal/HorizontalCandidateComponent.kt @@ -0,0 +1,237 @@ +/* + * 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.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.CandidateItemUi +import org.fcitx.fcitx5.android.input.candidates.CandidateViewHolder +import org.fcitx.fcitx5.android.input.candidates.expanded.decoration.FlexboxVerticalDecoration +import org.fcitx.fcitx5.android.input.candidates.horizontal.HorizontalCandidateMode.AlwaysFillWidth +import org.fcitx.fcitx5.android.input.candidates.horizontal.HorizontalCandidateMode.AutoFillWidth +import org.fcitx.fcitx5.android.input.candidates.horizontal.HorizontalCandidateMode.NeverFillWidth +import org.fcitx.fcitx5.android.input.dependency.UniqueViewComponent +import org.fcitx.fcitx5.android.input.dependency.context +import org.fcitx.fcitx5.android.input.dependency.fcitx +import org.fcitx.fcitx5.android.input.dependency.inputMethodService +import org.fcitx.fcitx5.android.input.dependency.theme +import org.fcitx.fcitx5.android.utils.item +import org.mechdancer.dependency.manager.must +import splitties.dimensions.dp +import splitties.resources.styledColor +import kotlin.math.max + +class HorizontalCandidateComponent : + UniqueViewComponent(), InputBroadcastReceiver { + + private val service by manager.inputMethodService() + private val context by manager.context() + private val fcitx by manager.fcitx() + private val theme by manager.theme() + private val bar: KawaiiBarComponent by manager.must() + + private val fillStyle by AppPrefs.getInstance().keyboard.horizontalCandidateStyle + private val maxSpanCountPref by lazy { + AppPrefs.getInstance().keyboard.run { + if (context.resources.configuration.orientation == Configuration.ORIENTATION_PORTRAIT) + expandedCandidateGridSpanCount + else + expandedCandidateGridSpanCountLandscape + } + } + + private var layoutMinWidth = 0 + private var layoutFlexGrow = 1f + + /** + * (for [HorizontalCandidateMode.AutoFillWidth] only) + * Second layout pass is needed when: + * [^1] total candidates count < maxSpanCount && [^2] RecyclerView cannot display all of them + * In that case, displayed candidates should be stretched evenly (by setting flexGrow to 1.0f). + */ + private var secondLayoutPassNeeded = false + private var secondLayoutPassDone = false + + // Since expanded candidate window is created once the expand button was clicked, + // we need to replay the last offset + private val _expandedCandidateOffset = MutableSharedFlow( + replay = 1, + onBufferOverflow = BufferOverflow.DROP_OLDEST + ) + + val expandedCandidateOffset = _expandedCandidateOffset.asSharedFlow() + + 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: CandidateViewHolder, position: Int) { + super.onBindViewHolder(holder, position) + holder.itemView.updateLayoutParams { + minWidth = layoutMinWidth + flexGrow = layoutFlexGrow + } + holder.itemView.setOnClickListener { + fcitx.launchOnReady { it.select(holder.idx) } + } + holder.itemView.setOnLongClickListener { + showCandidateActionMenu(holder.idx, candidates[position], holder.ui) + true + } + } + } + } + + val layoutManager: FlexboxLayoutManager by lazy { + object : FlexboxLayoutManager(context) { + override fun canScrollVertically() = false + override fun canScrollHorizontally() = false + override fun onLayoutCompleted(state: RecyclerView.State) { + super.onLayoutCompleted(state) + val cnt = this.childCount + if (secondLayoutPassNeeded) { + 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 cnt) { + getChildAt(i)!!.updateLayoutParams { + flexGrow = 1f + } + } + } else { + secondLayoutPassNeeded = false + } + } + refreshExpanded(cnt) + } + // no need to override `generate{,Default}LayoutParams`, because HorizontalCandidateViewAdapter + // guarantees ViewHolder's layoutParams to be `FlexboxLayoutManager.LayoutParams` + } + } + + private val dividerDrawable by lazy { + ShapeDrawable(RectShape()).apply { + val intrinsicSize = max(1, context.dp(1)) + intrinsicWidth = intrinsicSize + intrinsicHeight = intrinsicSize + paint.color = theme.dividerColor + } + } + + override val view by lazy { + object : RecyclerView(context) { + override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) { + super.onSizeChanged(w, h, oldw, oldh) + if (fillStyle == AutoFillWidth) { + val maxSpanCount = maxSpanCountPref.getValue() + layoutMinWidth = w / maxSpanCount - dividerDrawable.intrinsicWidth + } + } + }.apply { + id = R.id.candidate_view + adapter = this@HorizontalCandidateComponent.adapter + layoutManager = this@HorizontalCandidateComponent.layoutManager + addItemDecoration(FlexboxVerticalDecoration(dividerDrawable)) + } + } + + override fun onCandidateUpdate(data: FcitxEvent.CandidateListEvent.Data) { + val candidates = data.candidates + val total = data.total + val maxSpanCount = maxSpanCountPref.getValue() + when (fillStyle) { + NeverFillWidth -> { + layoutMinWidth = 0 + layoutFlexGrow = 0f + secondLayoutPassNeeded = false + } + AutoFillWidth -> { + layoutMinWidth = view.width / maxSpanCount - dividerDrawable.intrinsicWidth + layoutFlexGrow = if (candidates.size < maxSpanCount) 0f else 1f + // [^1] total candidates count < maxSpanCount + secondLayoutPassNeeded = candidates.size < maxSpanCount + secondLayoutPassDone = false + } + AlwaysFillWidth -> { + layoutMinWidth = 0 + layoutFlexGrow = 1f + secondLayoutPassNeeded = false + } + } + 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/horizontal/HorizontalCandidateViewAdapter.kt b/app/src/main/java/org/fcitx/fcitx5/android/input/candidates/horizontal/HorizontalCandidateViewAdapter.kt new file mode 100644 index 000000000..f2d4c78b0 --- /dev/null +++ b/app/src/main/java/org/fcitx/fcitx5/android/input/candidates/horizontal/HorizontalCandidateViewAdapter.kt @@ -0,0 +1,58 @@ +/* + * 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 +import androidx.annotation.CallSuper +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() { + + var candidates: Array = arrayOf() + private set + + var total = -1 + private set + + @SuppressLint("NotifyDataSetChanged") + fun updateCandidates(data: Array, total: Int) { + this.candidates = data + this.total = total + notifyDataSetChanged() + } + + override fun getItemCount() = candidates.size + + @CallSuper + 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 CandidateViewHolder(ui) + } + + @CallSuper + 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 43499c211..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,180 +1,153 @@ +/* + * 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.View import android.view.ViewGroup import android.widget.PopupMenu -import androidx.annotation.DrawableRes -import androidx.annotation.StringRes -import androidx.lifecycle.findViewTreeLifecycleOwner -import androidx.lifecycle.lifecycleScope +import androidx.paging.PagingDataAdapter import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.RecyclerView -import kotlinx.coroutines.launch 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.collections.set import kotlin.math.min -abstract class ClipboardAdapter : - RecyclerView.Adapter() { +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() { + override fun areItemsTheSame( + oldItem: ClipboardEntry, + newItem: ClipboardEntry + ): Boolean { + return oldItem.id == newItem.id + } + + override fun areContentsTheSame( + oldItem: ClipboardEntry, + newItem: ClipboardEntry + ): Boolean { + return oldItem == newItem + } + } + /** * 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 { - val totalLength = str.length + 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) { val start = lineBreak + 1 // skip previous '\n' + val excerptEnd = min(start + chars, length) lineBreak = str.indexOf('\n', start) if (lineBreak < 0) { // no line breaks remaining, substring to end of text - append(str.substring(start, min(start + chars, totalLength))) + 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(min(lineBreak, start + chars), totalLength))) + if (mask) { + append(ClipboardEntry.BULLET.repeat(end - start)) + } else { + appendLine(str.substring(start, end)) + } } } } } - private val _entries = mutableListOf() - - // maps entry id to list index - // since we don't have much data, we are not using sparse int array here - private val _entriesId = mutableMapOf() - - val entries: List - get() = _entries - - fun getPositionById(id: Int) = _entriesId.getValue(id) - - fun getEntryById(id: Int) = entries[getPositionById(id)] - private var popupMenu: PopupMenu? = null - inner class ViewHolder(val entryUi: ClipboardEntryUi) : - RecyclerView.ViewHolder(entryUi.root) + 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) { - val entry = _entries[position] - text.text = excerptText(entry.text) - pin.visibility = if (entry.pinned) View.VISIBLE else View.INVISIBLE + setEntry(excerptText(entry.text, entry.sensitive && maskSensitive), entry.pinned) root.setOnClickListener { - onPaste(entry.id) + 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(root.context, root) - val scope = root.findViewTreeLifecycleOwner()!!.lifecycleScope - fun menuItem(@StringRes title: Int, @DrawableRes ic: Int, cb: suspend () -> Unit) { - popup.menu.add(title).apply { - icon = ctx.drawable(ic)?.apply { colorFilter = iconColorFilter } - setOnMenuItemClickListener { - scope.launch { cb() } - true - } + val popup = PopupMenu(ctx, root) + 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) - setPinStatus(entry.id, false) - } else menuItem(R.string.pin, R.drawable.ic_baseline_push_pin_24) { - onPin(entry.id) - setPinStatus(entry.id, true) - } - 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) { - delete(entry.id) - // make `onDelete` access entries after delete + 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 } } } - private fun delete(id: Int) { - val position = _entriesId.getValue(id) - _entries.removeAt(position) - _entriesId.remove(id) - // Update indices after the removed item - for (i in position until _entries.size) { - _entriesId[_entries[i].id] = i - } - notifyItemRemoved(position) - } - - private fun setPinStatus(id: Int, pinned: Boolean) { - val position = _entriesId.getValue(id) - _entries[position] = _entries[position].copy(pinned = pinned) - notifyItemChanged(position) - // pin will cause a change of order - updateEntries(_entries) - } - - fun updateEntries(entries: List) { - val sorted = entries.sortedWith { o1, o2 -> - when { - o1.pinned && !o2.pinned -> -1 - !o1.pinned && o2.pinned -> 1 - else -> o2.timestamp.compareTo(o1.timestamp) - } - } - val callback = ClipboardEntryDiffCallback(_entries, sorted) - val diff = DiffUtil.calculateDiff(callback) - _entries.clear() - _entries.addAll(sorted) - _entriesId.clear() - _entries.forEachIndexed { index, clipboardEntry -> - _entriesId[clipboardEntry.id] = index - } - diff.dispatchUpdatesTo(this) - } + fun getEntryAt(position: Int) = getItem(position) fun onDetached() { popupMenu?.dismiss() + popupMenu = null } - abstract val theme: Theme - - override fun getItemCount(): Int = _entries.size + abstract fun onPaste(entry: ClipboardEntry) - abstract fun onPaste(id: Int) + abstract fun onPin(id: Int) - abstract suspend fun onPin(id: Int) - - abstract suspend fun onUnpin(id: Int) + abstract fun onUnpin(id: Int) abstract fun onEdit(id: Int) - abstract suspend fun onDelete(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/ClipboardEntryDiffCallback.kt b/app/src/main/java/org/fcitx/fcitx5/android/input/clipboard/ClipboardEntryDiffCallback.kt deleted file mode 100644 index 6b05be4aa..000000000 --- a/app/src/main/java/org/fcitx/fcitx5/android/input/clipboard/ClipboardEntryDiffCallback.kt +++ /dev/null @@ -1,19 +0,0 @@ -package org.fcitx.fcitx5.android.input.clipboard - -import androidx.recyclerview.widget.DiffUtil -import org.fcitx.fcitx5.android.data.clipboard.db.ClipboardEntry - -class ClipboardEntryDiffCallback( - val old: List, - val new: List -) : DiffUtil.Callback() { - override fun getOldListSize(): Int = old.size - - override fun getNewListSize(): Int = new.size - - override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean = - old[oldItemPosition].id == new[newItemPosition].id - - override fun areContentsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean = - old[oldItemPosition] == new[newItemPosition] -} \ 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 4794fbaec..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,22 +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.clipboard import android.content.Context -import android.graphics.PorterDuff -import android.graphics.PorterDuffColorFilter +import android.content.res.ColorStateList +import android.graphics.Color +import android.graphics.drawable.GradientDrawable +import android.graphics.drawable.RippleDrawable import android.text.TextUtils -import androidx.cardview.widget.CardView +import android.view.View import org.fcitx.fcitx5.android.R import org.fcitx.fcitx5.android.data.theme.Theme -import org.fcitx.fcitx5.android.utils.rippleDrawable +import org.fcitx.fcitx5.android.input.keyboard.CustomGestureView 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.bottomOfParent +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.dsl.core.lParams +import splitties.views.dsl.core.matchParent +import splitties.views.dsl.core.textView +import splitties.views.dsl.core.wrapContent +import splitties.views.imageDrawable import splitties.views.setPaddingDp -class ClipboardEntryUi(override val ctx: Context, private val theme: Theme) : Ui { +class ClipboardEntryUi(override val ctx: Context, private val theme: Theme, radius: Float) : Ui { - val text = textView { + val textView = textView { + minLines = 1 maxLines = 4 textSize = 14f setPaddingDp(8, 4, 8, 4) @@ -25,17 +43,15 @@ 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) + } } - val wrapper = constraintLayout { - add(text, lParams(matchParent, wrapContent) { - topOfParent() - bottomOfParent() - startOfParent() - endOfParent() + val layout = constraintLayout { + add(textView, lParams(matchParent, wrapContent) { + centerVertically() }) add(pin, lParams(dp(12), dp(12)) { bottomOfParent(dp(2)) @@ -43,13 +59,25 @@ class ClipboardEntryUi(override val ctx: Context, private val theme: Theme) : Ui }) } - override val root = view(::CardView) { - minimumWidth = dp(40) - minimumHeight = dp(30) + override val root = CustomGestureView(ctx).apply { isClickable = true - foreground = rippleDrawable(theme.keyPressHighlightColor) - setCardBackgroundColor(theme.clipboardEntryColor) - cardElevation = 0f - add(wrapper, lParams(matchParent, wrapContent)) + minimumHeight = dp(30) + foreground = RippleDrawable( + ColorStateList.valueOf(theme.keyPressHighlightColor), null, + GradientDrawable().apply { + cornerRadius = radius + setColor(Color.WHITE) + } + ) + background = GradientDrawable().apply { + cornerRadius = radius + setColor(theme.clipboardEntryColor) + } + add(layout, lParams(matchParent, matchParent)) + } + + fun setEntry(text: String, pinned: Boolean) { + textView.text = text + pin.visibility = if (pinned) View.VISIBLE else View.GONE } } \ No newline at end of file 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 178e0e745..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,22 +1,34 @@ +/* + * 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.views.dsl.appcompat.AppCompatStyles -import splitties.views.dsl.constraintlayout.* -import splitties.views.dsl.core.* -import splitties.views.imageResource +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.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 { class Enable(ctx: Context, theme: Theme) : ClipboardInstructionUi(ctx, theme) { - private val appCompatStyles = AppCompatStyles(ctx) + private val androidStyles = AndroidStyles(ctx) private val instructionText = textView { setText(R.string.instruction_enable_clipboard_listening) @@ -24,7 +36,7 @@ sealed class ClipboardInstructionUi(override val ctx: Context, protected val the setTextColor(theme.keyTextColor) } - val enableButton = appCompatStyles.button.borderless { + val enableButton = androidStyles.button.borderless { setText(R.string.clipboard_enable) setTextColor(theme.accentKeyBackgroundColor) } @@ -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 48e57ece6..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,9 +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.clipboard -import org.fcitx.fcitx5.android.input.clipboard.ClipboardStateMachine.State.* -import org.fcitx.fcitx5.android.input.clipboard.ClipboardStateMachine.TransitionEvent.* +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.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.utils.BuildTransitionEvent import org.fcitx.fcitx5.android.utils.EventStateMachine -import org.fcitx.fcitx5.android.utils.eventStateMachine +import org.fcitx.fcitx5.android.utils.TransitionBuildBlock object ClipboardStateMachine { @@ -11,26 +19,38 @@ object ClipboardStateMachine { Normal, AddMore, EnableListening } - enum class TransitionEvent { - ClipboardDbUpdatedEmpty, - ClipboardDbUpdatedNonEmpty, - ClipboardListeningDisabled, - ClipboardListeningEnabledWithDbNonEmpty, - ClipboardListeningEnabledWithDbEmpty + enum class BooleanKey : EventStateMachine.BooleanStateKey { + ClipboardDbEmpty, + ClipboardListeningEnabled } - fun new( - initialState: State, - block: (State) -> Unit - ): EventStateMachine = eventStateMachine( - initialState - ) { - from(Normal) transitTo AddMore on ClipboardDbUpdatedEmpty - from(Normal) transitTo EnableListening on ClipboardListeningDisabled - from(EnableListening) transitTo Normal on ClipboardListeningEnabledWithDbNonEmpty - from(EnableListening) transitTo AddMore on ClipboardListeningEnabledWithDbEmpty - from(AddMore) transitTo Normal on ClipboardDbUpdatedNonEmpty - from(AddMore) transitTo EnableListening on ClipboardListeningDisabled - onNewState(block) + enum class TransitionEvent(val builder: TransitionBuildBlock) : + EventStateMachine.TransitionEvent by BuildTransitionEvent(builder) { + ClipboardDbUpdated({ + from(Normal) transitTo AddMore on (ClipboardDbEmpty to true) + from(AddMore) transitTo Normal on (ClipboardDbEmpty to false) + }), + ClipboardListeningUpdated({ + from(Normal) transitTo EnableListening on (ClipboardListeningEnabled to false) + from(EnableListening) transitTo Normal onF { + it(ClipboardListeningEnabled) == true && it(ClipboardDbEmpty) == false + } + from(EnableListening) transitTo AddMore onF { + it(ClipboardListeningEnabled) == true && it(ClipboardDbEmpty) == true + } + from(AddMore) transitTo EnableListening on (ClipboardListeningEnabled to false) + }) } + + 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 + } + } \ No newline at end of file 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 6264f88d8..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 @@ -9,10 +13,17 @@ import org.fcitx.fcitx5.android.R 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.bar.ToolButton +import org.fcitx.fcitx5.android.input.bar.ui.ToolButton import splitties.dimensions.dp import splitties.views.backgroundColor -import splitties.views.dsl.core.* +import splitties.views.dsl.coordinatorlayout.coordinatorLayout +import splitties.views.dsl.coordinatorlayout.defaultLParams +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 @@ -26,19 +37,25 @@ class ClipboardUi(override val ctx: Context, private val theme: Theme) : Ui { val emptyUi = ClipboardInstructionUi.Empty(ctx, theme) + val viewAnimator = view(::ViewAnimator) { + add(recyclerView, lParams(matchParent, matchParent)) + add(emptyUi.root, lParams(matchParent, matchParent)) + add(enableUi.root, lParams(matchParent, matchParent)) + } + private val keyBorder by ThemeManager.prefs.keyBorder private val disableAnimation by AppPrefs.getInstance().advanced.disableAnimation - override val root = view(::ViewAnimator) { + override val root = coordinatorLayout { if (!keyBorder) { backgroundColor = theme.barColor } - add(recyclerView, lParams(matchParent, matchParent)) - add(emptyUi.root, lParams(matchParent, matchParent)) - add(enableUi.root, lParams(matchParent, matchParent)) + 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))) @@ -54,15 +71,15 @@ class ClipboardUi(override val ctx: Context, private val theme: Theme) : Ui { TransitionManager.beginDelayedTransition(root, Fade().apply { duration = 100L }) when (state) { ClipboardStateMachine.State.Normal -> { - root.displayedChild = 0 + viewAnimator.displayedChild = 0 setDeleteButtonShown(true) } ClipboardStateMachine.State.AddMore -> { - root.displayedChild = 1 + viewAnimator.displayedChild = 1 setDeleteButtonShown(false) } ClipboardStateMachine.State.EnableListening -> { - root.displayedChild = 2 + viewAnimator.displayedChild = 2 setDeleteButtonShown(false) } } 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 cf83572e7..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,99 +1,131 @@ +/* + * 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 import android.widget.PopupMenu +import androidx.annotation.Keep 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.paging.Pager +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 import kotlinx.coroutines.launch import org.fcitx.fcitx5.android.R 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.Theme +import org.fcitx.fcitx5.android.data.theme.ThemeManager import org.fcitx.fcitx5.android.input.FcitxInputMethodService -import org.fcitx.fcitx5.android.input.clipboard.ClipboardStateMachine.State.* -import org.fcitx.fcitx5.android.input.clipboard.ClipboardStateMachine.TransitionEvent.* +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.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 kotlin.properties.Delegates +import splitties.views.dsl.core.withTheme 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 lateinit var stateMachine: EventStateMachine - - private var isClipboardDbEmpty by Delegates.observable(ClipboardManager.itemCount == 0) { _, _, new -> - stateMachine.push( - if (new) ClipboardDbUpdatedEmpty - else ClipboardDbUpdatedNonEmpty - ) + private val snackbarCtx by lazy { + context.withTheme(R.style.InputViewSnackbarTheme) } + private var snackbarInstance: Snackbar? = null + private lateinit var stateMachine: EventStateMachine + + @Keep private val clipboardEnabledListener = ManagedPreference.OnChangeListener { _, it -> stateMachine.push( - if (it) - if (isClipboardDbEmpty) ClipboardListeningEnabledWithDbEmpty - else ClipboardListeningEnabledWithDbNonEmpty - else ClipboardListeningDisabled + ClipboardListeningUpdated, ClipboardListeningEnabled to it ) } - private val clipboardEnabledPref = AppPrefs.getInstance().clipboard.clipboardListening + private val prefs = AppPrefs.getInstance().clipboard - private fun updateClipboardEntries() { - service.lifecycleScope.launch { - ClipboardManager.getAll().also { - isClipboardDbEmpty = it.isEmpty() - adapter.updateEntries(it) - } - } - } + private val clipboardEnabledPref = prefs.clipboardListening + private val clipboardReturnAfterPaste by prefs.clipboardReturnAfterPaste + private val clipboardMaskSensitive by prefs.clipboardMaskSensitive - private fun deleteAllEntries(skipPinned: Boolean = true) { - service.lifecycleScope.launch { - ClipboardManager.deleteAll(skipPinned) - if (skipPinned) { - ClipboardManager.getAll().also { - isClipboardDbEmpty = it.isEmpty() - adapter.updateEntries(it) - } - } else { - // manually set entries to empty - adapter.updateEntries(emptyList()) - isClipboardDbEmpty = true - } - } - } + private val clipboardEntryRadius by ThemeManager.prefs.clipboardEntryRadius - private val onClipboardUpdateListener = ClipboardManager.OnClipboardUpdateListener { - updateClipboardEntries() + private val clipboardEntriesPager by lazy { + Pager(PagingConfig(pageSize = 16)) { ClipboardManager.allEntries() } } + private var adapterSubmitJob: Job? = null private val adapter: ClipboardAdapter by lazy { - object : ClipboardAdapter() { - override suspend fun onPin(id: Int) = ClipboardManager.pin(id) - override suspend fun onUnpin(id: Int) = ClipboardManager.unpin(id) - override fun onEdit(id: Int) = AppUtil.launchClipboardEdit(context, id) - override suspend fun onDelete(id: Int) { - ClipboardManager.delete(id) - isClipboardDbEmpty = entries.isEmpty() + object : ClipboardAdapter( + theme, + context.dp(clipboardEntryRadius.toFloat()), + clipboardMaskSensitive + ) { + override fun onPin(id: Int) { + service.lifecycleScope.launch { ClipboardManager.pin(id) } } - override fun onPaste(id: Int) { - service.commitText(getEntryById(id).text) + override fun onUnpin(id: Int) { + service.lifecycleScope.launch { ClipboardManager.unpin(id) } } - override val theme: Theme - get() = this@ClipboardWindow.theme + override fun onEdit(id: Int) { + 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) + showUndoSnackbar(id) + } + } + + override fun onPaste(entry: ClipboardEntry) { + service.commitText(entry.text) + if (clipboardReturnAfterPaste) windowManager.attachWindow(KeyboardWindow) + } } } @@ -103,14 +135,36 @@ class ClipboardWindow : InputWindow.ExtendedInputWindow() { layoutManager = StaggeredGridLayoutManager(2, StaggeredGridLayoutManager.VERTICAL) adapter = this@ClipboardWindow.adapter } + ItemTouchHelper(object : ItemTouchHelper.Callback() { + override fun getMovementFlags( + recyclerView: RecyclerView, + viewHolder: RecyclerView.ViewHolder + ): Int { + return makeMovementFlags(0, ItemTouchHelper.LEFT or ItemTouchHelper.RIGHT) + } + + override fun onMove( + recyclerView: RecyclerView, + viewHolder: RecyclerView.ViewHolder, + target: RecyclerView.ViewHolder + ): Boolean { + return false + } + + override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) { + val entry = adapter.getEntryAt(viewHolder.bindingAdapterPosition) ?: return + service.lifecycleScope.launch { + ClipboardManager.delete(entry.id) + showUndoSnackbar(entry.id) + } + } + }).attachToRecyclerView(recyclerView) enableUi.enableButton.setOnClickListener { clipboardEnabledPref.setValue(true) } deleteAllButton.setOnClickListener { - if (adapter.entries.any { !it.pinned }) { - deleteAllEntries() - } else { - promptDeleteAllPinned() + service.lifecycleScope.launch { + promptDeleteAll(ClipboardManager.haveUnpinned()) } } } @@ -120,27 +174,21 @@ class ClipboardWindow : InputWindow.ExtendedInputWindow() { private var promptMenu: PopupMenu? = null - private fun promptDeleteAllPinned() { + 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(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 { - deleteAllEntries(false) - 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 { @@ -150,28 +198,89 @@ class ClipboardWindow : InputWindow.ExtendedInputWindow() { } } + private val pendingDeleteIds = arrayListOf() + + @SuppressLint("RestrictedApi") + private fun showUndoSnackbar(vararg id: Int) { + 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(*pendingDeleteIds.toIntArray()) + pendingDeleteIds.clear() + } + } + .addCallback(object : Snackbar.Callback() { + override fun onDismissed(transientBottomBar: Snackbar, event: Int) { + 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 { + val hMargin = snackbarCtx.dp(24) + val vMargin = snackbarCtx.dp(16) + view.updateLayoutParams { + leftMargin = hMargin + rightMargin = hMargin + bottomMargin = vMargin + } + ((view as FrameLayout).getChildAt(0) as SnackbarContentLayout).apply { + messageView.letterSpacing = 0f + actionView.letterSpacing = 0f + } + show() + } + } + 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) - // manually sync clipboard entries form db - updateClipboardEntries() + adapter.addLoadStateListener { + val empty = it.append.endOfPaginationReached && adapter.itemCount < 1 + stateMachine.push(ClipboardDbUpdated, ClipboardDbEmpty to empty) + } + adapterSubmitJob = service.lifecycleScope.launch { + clipboardEntriesPager.flow.collect { + adapter.submitData(it) + } + } clipboardEnabledPref.registerOnChangeListener(clipboardEnabledListener) - ClipboardManager.addOnUpdateListener(onClipboardUpdateListener) } override fun onDetached() { clipboardEnabledPref.unregisterOnChangeListener(clipboardEnabledListener) - ClipboardManager.removeOnUpdateListener(onClipboardUpdateListener) 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 0990b5513..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,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.input.dialog import android.content.Context import org.fcitx.fcitx5.android.core.FcitxAPI -import splitties.systemservices.inputMethodManager +import org.fcitx.fcitx5.android.utils.inputMethodManager data class InputMethodData( val uniqueName: String, @@ -14,7 +18,7 @@ data class InputMethodData( val enabled = fcitx.enabledIme() .map { InputMethodData(it.uniqueName, it.displayName, false) } .toMutableList() - enabled += inputMethodManager.enabledInputMethodList + enabled += context.inputMethodManager.enabledInputMethodList .filter { it.packageName != context.packageName } .map { val label = it.loadLabel(context.packageManager).toString() 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/InputMethodSwitcherDialog.kt b/app/src/main/java/org/fcitx/fcitx5/android/input/dialog/InputMethodPickerDialog.kt similarity index 93% rename from app/src/main/java/org/fcitx/fcitx5/android/input/dialog/InputMethodSwitcherDialog.kt rename to app/src/main/java/org/fcitx/fcitx5/android/input/dialog/InputMethodPickerDialog.kt index b6776c4ff..35878541c 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/input/dialog/InputMethodSwitcherDialog.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 @@ -17,7 +21,7 @@ import splitties.views.dsl.recyclerview.recyclerView import splitties.views.recyclerview.verticalLayoutManager import splitties.views.topPadding -object InputMethodSwitcherDialog { +object InputMethodPickerDialog { suspend fun build( fcitx: FcitxAPI, service: FcitxInputMethodService, 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 a807a7d95..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,203 +1,171 @@ +/* + * 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.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 org.fcitx.fcitx5.android.input.bar.ui.ToolButton 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 { topOfParent() - startOfParent() + leftOfParent() above(homeButton) - before(selectButton) + rightToLeftOf(selectButton) }) add(upButton, lParams { topOfParent() - after(leftButton) + leftToRightOf(leftButton) above(selectButton) - before(rightButton) + rightToLeftOf(rightButton) }) add(selectButton, lParams { below(upButton) - after(leftButton) + leftToRightOf(leftButton) above(downButton) - before(rightButton) + rightToLeftOf(rightButton) }) add(downButton, lParams { below(selectButton) - after(leftButton) + leftToRightOf(leftButton) above(homeButton) - before(rightButton) + rightToLeftOf(rightButton) }) add(rightButton, lParams { topOfParent() - after(selectButton) + leftToRightOf(selectButton) above(endButton) - before(copyButton) + rightToLeftOf(copyButton) }) add(homeButton, lParams { below(downButton) - startOfParent() + leftOfParent() bottomOfParent() - before(endButton) + rightToLeftOf(endButton) }) add(endButton, lParams { below(downButton) - after(homeButton) + leftToRightOf(homeButton) bottomOfParent() - before(backspaceButton) + rightToLeftOf(backspaceButton) }) add(selectAllButton, lParams { topOfParent() - after(rightButton) - endOfParent() + leftToRightOf(rightButton) + rightOfParent() above(cutButton) matchConstraintPercentWidth = 0.3f }) add(cutButton, lParams { below(selectAllButton) - after(rightButton) - endOfParent() + leftToRightOf(rightButton) + rightOfParent() above(copyButton) matchConstraintPercentWidth = 0.3f }) add(copyButton, lParams { below(cutButton) - after(rightButton) - endOfParent() + leftToRightOf(rightButton) + rightOfParent() above(pasteButton) matchConstraintPercentWidth = 0.3f }) add(pasteButton, lParams { below(copyButton) - after(rightButton) - endOfParent() + leftToRightOf(rightButton) + rightOfParent() above(backspaceButton) matchConstraintPercentWidth = 0.3f }) add(backspaceButton, lParams { below(pasteButton) - after(rightButton) - endOfParent() + leftToRightOf(rightButton) + rightOfParent() bottomOfParent() matchConstraintPercentWidth = 0.3f }) @@ -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 38c082af8..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,22 +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 androidx.lifecycle.lifecycleScope 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.launchOnFcitxReady +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(), @@ -24,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 @@ -35,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) } @@ -58,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 - service.lifecycleScope.launchOnFcitxReady(fcitx) { - it.sendKey(FcitxKeyMapping.FcitxKey_BackSpace) - } + service.sendDownUpKeyEvents(KeyEvent.KEYCODE_DEL) } clipboardButton.setOnClickListener { windowManager.attachWindow(ClipboardWindow()) @@ -94,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 92a3a9459..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 @@ -7,17 +11,22 @@ import android.text.TextUtils import android.view.inputmethod.EditorInfo import android.view.inputmethod.SurroundingText import splitties.bitflags.hasFlag +import java.lang.reflect.Field 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 - .filter { !Modifier.isStatic(it.modifiers) } + .filter { !it.isStatic() } ////////// EditorInfo constants private val EDITOR_INFO_STATIC = EditorInfo::class.java.declaredFields - .filter { Modifier.isStatic(it.modifiers) } + .filter { it.isStatic() } private val IME_ACTION = EDITOR_INFO_STATIC .filter { it.name.startsWith("IME_ACTION_") } @@ -28,21 +37,21 @@ object EditorInfoParser { ////////// InputType constants private val INPUT_TYPE_STATIC = InputType::class.java.declaredFields - .filter { Modifier.isStatic(it.modifiers) } + .filter { it.isStatic() } private val TYPE_CLASS = INPUT_TYPE_STATIC - .filter { it.name.run { startsWith("TYPE_") && contains("_CLASS_") } } + .filter { it.name.contains("_CLASS_") || it.name == "TYPE_NULL" } private val TYPE_FLAGS = INPUT_TYPE_STATIC - .filter { it.name.run { startsWith("TYPE_") && contains("_FLAG_") } } + .filter { it.name.contains("_FLAG_") } private val TYPE_VARIATION = INPUT_TYPE_STATIC - .filter { it.name.run { startsWith("TYPE_") && contains("_VARIATION_") } } + .filter { it.name.contains("_VARIATION_") } ////////// TextUtils constants private val CAP_MODE = TextUtils::class.java.declaredFields - .filter { Modifier.isStatic(it.modifiers) && it.name.startsWith("CAP_MODE_") } + .filter { it.isStatic() && it.name.startsWith("CAP_MODE_") } private fun parseImeOptions(imeOptions: Int): String { val action = imeOptions and EditorInfo.IME_MASK_ACTION @@ -61,8 +70,9 @@ object EditorInfoParser { .filter { flags.hasFlag(it.getInt(null)) } .joinToString("\n ") { it.name } val variation = inputType and InputType.TYPE_MASK_VARIATION + val variationPrefix = classString.replace("_CLASS", "") val variationString = TYPE_VARIATION - .filter { variation.hasFlag(it.getInt(null)) } + .filter { variation == it.getInt(null) && it.name.startsWith(variationPrefix) } .joinToString("\n ") { it.name } return "class=$classString\nflags=$flagsString\nvariation=$variationString" } @@ -74,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" @@ -82,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 @@ -106,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 45ca86417..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,15 +1,16 @@ +/* + * 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.view.inputmethod.EditorInfo import org.fcitx.fcitx5.android.R import org.fcitx.fcitx5.android.input.FcitxInputMethodService -import org.fcitx.fcitx5.android.input.broadcast.InputBroadcastReceiver import org.fcitx.fcitx5.android.input.dependency.inputMethodService import org.fcitx.fcitx5.android.input.dependency.theme import org.fcitx.fcitx5.android.input.wm.InputWindow -class EditorInfoWindow : InputWindow.ExtendedInputWindow(), - InputBroadcastReceiver { +class EditorInfoWindow : InputWindow.ExtendedInputWindow() { private val service: FcitxInputMethodService by manager.inputMethodService() private val theme by manager.theme() @@ -25,12 +26,7 @@ class EditorInfoWindow : InputWindow.ExtendedInputWindow(), override fun onCreateView() = ui.root override fun onAttached() { - onEditorInfoUpdate(service.editorInfo) - } - - override fun onEditorInfoUpdate(info: EditorInfo?) { - if (info == null) return - ui.setValues(EditorInfoParser.parse(info)) + 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 c5a5747b5..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,26 +1,44 @@ +/* + * 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 import android.graphics.Rect import android.view.MotionEvent import android.view.View -import android.view.inputmethod.EditorInfo import androidx.annotation.CallSuper import androidx.annotation.DrawableRes import androidx.constraintlayout.widget.ConstraintLayout import androidx.core.view.children -import org.fcitx.fcitx5.android.R -import org.fcitx.fcitx5.android.core.* +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 -import org.fcitx.fcitx5.android.input.popup.PopupListener -import splitties.bitflags.hasFlag +import org.fcitx.fcitx5.android.input.popup.PopupAction +import org.fcitx.fcitx5.android.input.popup.PopupActionListener 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.centerVertically +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.add -import splitties.views.imageResource import timber.log.Timber import kotlin.math.absoluteValue import kotlin.math.roundToInt @@ -33,43 +51,84 @@ 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 - var keyPopupListener: PopupListener? = null + private val spaceSwipeMoveCursor = prefs.keyboard.spaceSwipeMoveCursor + private val spaceKeys = mutableListOf() + private val spaceSwipeChangeListener = ManagedPreference.OnChangeListener { _, v -> + spaceKeys.forEach { + it.swipeEnabled = v + } + } + + private val vivoKeypressWorkaround by prefs.advanced.vivoKeypressWorkaround + + private val hapticOnRepeat by prefs.keyboard.hapticOnRepeat + + var popupActionListener: PopupActionListener? = null private val selectionSwipeThreshold = dp(10f) private val inputSwipeThreshold = dp(36f) + // a rather large threshold effectively disables swipe of the direction + private val disabledSwipeThreshold = dp(800f) + private val bounds = Rect() private val keyRows: List /** - * HashMap of [PointerId (Int)][MotionEvent.getPointerId] and [KeyView] + * HashMap of [PointerId (Int)][MotionEvent.getPointerId] to [KeyView] */ private val touchTarget = hashMapOf() init { + isMotionEventSplittingEnabled = true keyRows = keyLayout.map { row -> - val keyViews = row.map { def -> - createKeyView(def) - } + val keyViews = row.map(::createKeyView) constraintLayout Row@{ + var totalWidth = 0f keyViews.forEachIndexed { index, view -> add(view, lParams { - topOfParent() - bottomOfParent() + centerVertically() if (index == 0) { - startOfParent() + leftOfParent() horizontalChainStyle = LayoutParams.CHAIN_PACKED - } else after(keyViews[index - 1]) - if (index == keyViews.size - 1) endOfParent() - else before(keyViews[index + 1]) + } else { + leftToRightOf(keyViews[index - 1]) + } + if (index == keyViews.size - 1) { + rightOfParent() + // for RTL + horizontalChainStyle = LayoutParams.CHAIN_PACKED + } else { + rightToLeftOf(keyViews[index + 1]) + } 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) + } } } } @@ -79,32 +138,43 @@ abstract class BaseKeyboard( else below(keyRows[index - 1]) if (index == keyRows.size - 1) bottomOfParent() else above(keyRows[index + 1]) - startOfParent() - endOfParent() + 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 - onGestureListener = OnGestureListener { _, event -> + swipeThresholdY = disabledSwipeThreshold + 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 } @@ -116,12 +186,14 @@ abstract class BaseKeyboard( swipeEnabled = true swipeRepeatEnabled = true swipeThresholdX = selectionSwipeThreshold - onGestureListener = OnGestureListener { _, event -> + swipeThresholdY = disabledSwipeThreshold + 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 } @@ -148,12 +220,14 @@ abstract class BaseKeyboard( } is KeyDef.Behavior.Repeat -> { repeatEnabled = true - onRepeatListener = { _ -> + onRepeatListener = { view -> onAction(it.action) + if (hapticOnRepeat) InputFeedbacks.hapticFeedback(view) } } is KeyDef.Behavior.Swipe -> { swipeEnabled = true + swipeThresholdX = disabledSwipeThreshold swipeThresholdY = inputSwipeThreshold val oldOnGestureListener = onGestureListener ?: OnGestureListener.Empty onGestureListener = OnGestureListener { view, event -> @@ -184,7 +258,7 @@ abstract class BaseKeyboard( is KeyDef.Popup.Menu -> { setOnLongClickListener { view -> view as KeyView - onPopupMenu(view.id, it, view.bounds) + onPopupAction(PopupAction.ShowMenuAction(view.id, it, view.bounds)) // do not consume this LongClick gesture false } @@ -206,7 +280,7 @@ abstract class BaseKeyboard( is KeyDef.Popup.Keyboard -> { setOnLongClickListener { view -> view as KeyView - onPopupKeyboard(view.id, it, view.bounds) + onPopupAction(PopupAction.ShowKeyboardAction(view.id, it, view.bounds)) // do not consume this LongClick gesture false } @@ -229,17 +303,21 @@ abstract class BaseKeyboard( val oldOnGestureListener = onGestureListener ?: OnGestureListener.Empty onGestureListener = OnGestureListener { view, event -> view as KeyView - when (event.type) { - GestureType.Down -> { - onPopupPreview(view.id, it.content, view.bounds) - } - GestureType.Move -> { - val triggered = swipeSymbolDirection.checkY(event.totalY) - val text = if (triggered) it.alternative else it.content - onPopupPreviewUpdate(view.id, text) - } - GestureType.Up -> { - onPopupDismiss(view.id) + if (popupOnKeyPress) { + when (event.type) { + GestureType.Down -> onPopupAction( + PopupAction.PreviewAction(view.id, it.content, view.bounds) + ) + GestureType.Move -> { + val triggered = swipeSymbolDirection.checkY(event.totalY) + val text = if (triggered) it.alternative else it.content + onPopupAction( + PopupAction.PreviewUpdateAction(view.id, text) + ) + } + GestureType.Up -> { + onPopupAction(PopupAction.DismissAction(view.id)) + } } } // never consume gesture in preview popup @@ -250,14 +328,16 @@ abstract class BaseKeyboard( val oldOnGestureListener = onGestureListener ?: OnGestureListener.Empty onGestureListener = OnGestureListener { view, event -> view as KeyView - when (event.type) { - GestureType.Down -> { - onPopupPreview(view.id, it.content, view.bounds) - } - GestureType.Up -> { - onPopupDismiss(view.id) + if (popupOnKeyPress) { + when (event.type) { + GestureType.Down -> onPopupAction( + PopupAction.PreviewAction(view.id, it.content, view.bounds) + ) + GestureType.Up -> { + onPopupAction(PopupAction.DismissAction(view.id)) + } + else -> {} } - else -> {} } // never consume gesture in preview popup oldOnGestureListener.onGesture(view, event) @@ -268,39 +348,6 @@ abstract class BaseKeyboard( } } - @DrawableRes - protected fun drawableForReturn(info: EditorInfo?): Int { - if (info?.imeOptions?.hasFlag(EditorInfo.IME_FLAG_NO_ENTER_ACTION) == true) { - return R.drawable.ic_baseline_keyboard_return_24 - } - return when (info?.imeOptions?.and(EditorInfo.IME_MASK_ACTION)) { - EditorInfo.IME_ACTION_GO -> R.drawable.ic_baseline_arrow_forward_24 - EditorInfo.IME_ACTION_SEARCH -> R.drawable.ic_baseline_search_24 - EditorInfo.IME_ACTION_SEND -> R.drawable.ic_baseline_send_24 - EditorInfo.IME_ACTION_NEXT -> R.drawable.ic_baseline_keyboard_tab_24 - EditorInfo.IME_ACTION_DONE -> R.drawable.ic_baseline_done_24 - EditorInfo.IME_ACTION_PREVIOUS -> R.drawable.ic_baseline_keyboard_tab_reverse_24 - else -> R.drawable.ic_baseline_keyboard_return_24 - } - } - - // FIXME: need some new API to know exactly whether next enter would be captured by fcitx - protected fun updateReturnButton( - `return`: ImageKeyView, - info: EditorInfo?, - preedit: FcitxEvent.PreeditEvent.Data - // aux: FcitxEvent.InputPanelAuxEvent.Data - ) { - val hasPreedit = preedit.preedit.isNotEmpty() || preedit.clientPreedit.isNotEmpty() - // `auxUp` is not empty when switching input methods, ignore it to reduce flicker - // || aux.auxUp.isNotEmpty() - `return`.img.imageResource = if (hasPreedit) { - R.drawable.ic_baseline_keyboard_return_24 - } else { - drawableForReturn(info) - } - } - override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) { val (x, y) = intArrayOf(0, 0).also { getLocationInWindow(it) } bounds.set(x, y, x + width, y + height) @@ -408,7 +455,7 @@ abstract class BaseKeyboard( } @CallSuper - open fun onAction( + protected open fun onAction( action: KeyAction, source: KeyActionListener.Source = KeyActionListener.Source.Keyboard ) { @@ -416,54 +463,31 @@ abstract class BaseKeyboard( } @CallSuper - open fun onPopupPreview(viewId: Int, content: String, bounds: Rect) { - if (!popupOnKeyPress) return - keyPopupListener?.onPreview(viewId, content, bounds) - } - - @CallSuper - open fun onPopupPreviewUpdate(viewId: Int, content: String) { - if (!popupOnKeyPress) return - keyPopupListener?.onPreviewUpdate(viewId, content) + protected open fun onPopupAction(action: PopupAction) { + popupActionListener?.onPopupAction(action) } - @CallSuper - open fun onPopupDismiss(viewId: Int) { - keyPopupListener?.onDismiss(viewId) - } - - @CallSuper - open fun onPopupKeyboard(viewId: Int, keyboard: KeyDef.Popup.Keyboard, bounds: Rect) { - keyPopupListener?.onShowKeyboard(viewId, keyboard, bounds) + private fun onPopupChangeFocus(viewId: Int, x: Float, y: Float): Boolean { + val changeFocusAction = PopupAction.ChangeFocusAction(viewId, x, y) + popupActionListener?.onPopupAction(changeFocusAction) + return changeFocusAction.outResult } - open fun onPopupMenu(viewId: Int, menu: KeyDef.Popup.Menu, bounds: Rect) { - keyPopupListener?.onShowMenu(viewId, menu, bounds) - } - - @CallSuper - open fun onPopupChangeFocus(viewId: Int, x: Float, y: Float): Boolean { - return keyPopupListener?.onChangeFocus(viewId, x, y) ?: false - } - - @CallSuper - fun onPopupTrigger(viewId: Int): Boolean { + private fun onPopupTrigger(viewId: Int): Boolean { + val triggerAction = PopupAction.TriggerAction(viewId) // ask popup keyboard whether there's a pending KeyAction - val action = keyPopupListener?.onTrigger(viewId) ?: return false + onPopupAction(triggerAction) + val action = triggerAction.outAction ?: return false onAction(action, KeyActionListener.Source.Popup) - onPopupDismiss(viewId) + onPopupAction(PopupAction.DismissAction(viewId)) return true } - open fun onAttach(info: EditorInfo? = null) { - // do nothing by default - } - - open fun onEditorInfoChange(info: EditorInfo?) { + open fun onAttach() { // do nothing by default } - open fun onPreeditChange(info: EditorInfo?, data: FcitxEvent.PreeditEvent.Data) { + open fun onReturnDrawableUpdate(@DrawableRes returnDrawable: Int) { // do nothing by default } @@ -471,7 +495,7 @@ abstract class BaseKeyboard( // do nothing by default } - open fun onInputMethodChange(ime: InputMethodEntry) { + open fun onInputMethodUpdate(ime: InputMethodEntry) { // do nothing by default } 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 434b507aa..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,22 +1,40 @@ +/* + * 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.launchOnFcitxReady +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.InputMethodSwitcherDialog -import org.fcitx.fcitx5.android.input.keyboard.CommonKeyActionListener.BackspaceSwipeState.* -import org.fcitx.fcitx5.android.input.keyboard.KeyAction.* +import org.fcitx.fcitx5.android.input.dialog.InputMethodPickerDialog +import org.fcitx.fcitx5.android.input.keyboard.CommonKeyActionListener.BackspaceSwipeState.Reset +import org.fcitx.fcitx5.android.input.keyboard.CommonKeyActionListener.BackspaceSwipeState.Selection +import org.fcitx.fcitx5.android.input.keyboard.CommonKeyActionListener.BackspaceSwipeState.Stopped +import org.fcitx.fcitx5.android.input.keyboard.KeyAction.CommitAction +import org.fcitx.fcitx5.android.input.keyboard.KeyAction.DeleteSelectionAction +import org.fcitx.fcitx5.android.input.keyboard.KeyAction.FcitxKeyAction +import org.fcitx.fcitx5.android.input.keyboard.KeyAction.LangSwitchAction +import org.fcitx.fcitx5.android.input.keyboard.KeyAction.MoveSelectionAction +import org.fcitx.fcitx5.android.input.keyboard.KeyAction.PickerSwitchAction +import org.fcitx.fcitx5.android.input.keyboard.KeyAction.QuickPhraseAction +import org.fcitx.fcitx5.android.input.keyboard.KeyAction.ShowInputMethodPickerAction +import org.fcitx.fcitx5.android.input.keyboard.KeyAction.SpaceLongPressAction +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 @@ -33,62 +51,94 @@ 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 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 (preeditCached.run { preedit.isEmpty() && clientPreedit.isEmpty() }) { + 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() } } + private fun showInputMethodPicker() { + fcitx.launchOnReady { + service.lifecycleScope.launch { + service.showDialog(InputMethodPickerDialog.build(it, service, context)) + } + } + } + val listener by lazy { KeyActionListener { action, _ -> - service.lifecycleScope.launchOnFcitxReady(fcitx) { - when (action) { - is FcitxKeyAction -> it.sendKey(action.act, KeyState.Virtual.state) - is SymAction -> it.sendKey(action.sym, action.states) - is CommitAction -> { - it.commitAndReset() - service.commitText(action.text) - } - is QuickPhraseAction -> { - it.commitAndReset() - it.triggerQuickPhrase() - } - is UnicodeAction -> { - it.commitAndReset() - it.triggerUnicode() - } - is LangSwitchAction -> { - if (it.enabledIme().size < 2) { - inputView.showDialog(AddMoreInputMethodsPrompt.build(context)) - } else if (action.enumerate) { - it.enumerateIme() - } else { - it.toggleIme() + when (action) { + is FcitxKeyAction -> service.postFcitxJob { + sendKey(action.act, action.states.states, action.code) + } + is SymAction -> service.postFcitxJob { + sendKey(action.sym, action.states) + } + is CommitAction -> service.postFcitxJob { + commitAndReset() + service.lifecycleScope.launch { service.commitText(action.text) } + } + is QuickPhraseAction -> service.postFcitxJob { + commitAndReset() + triggerQuickPhrase() + } + is UnicodeAction -> service.postFcitxJob { + commitAndReset() + triggerUnicode() + } + 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() } } - is InputMethodSwitchAction -> { - inputView.showDialog( - InputMethodSwitcherDialog.build(it, service, context) - ) - } - is MoveSelectionAction -> when (backspaceSwipeState) { - Stopped -> backspaceSwipeState = it.preeditCached.let { p -> - if (p.preedit.isEmpty() && p.clientPreedit.isEmpty()) { - // update state to `Selection` and apply first offset + } + is ShowInputMethodPickerAction -> showInputMethodPicker() + is MoveSelectionAction -> { + when (backspaceSwipeState) { + Stopped -> { + backspaceSwipeState = if ( + preeditState.isEmpty && + horizontalCandidate.adapter.total <= 0 // total is -1 on initialization + ) { service.applySelectionOffset(action.start, action.end) Selection } else { @@ -100,28 +150,39 @@ class CommonKeyActionListener : } Reset -> {} } - is DeleteSelectionAction -> { - when (backspaceSwipeState) { - Stopped -> {} - Selection -> service.deleteSelection() - Reset -> if (action.totalCnt < 0) { // swipe left - it.reset() - } + } + is DeleteSelectionAction -> { + when (backspaceSwipeState) { + Stopped -> {} + Selection -> service.deleteSelection() + Reset -> if (action.totalCnt < 0) { // swipe left + service.postFcitxJob { reset() } } - backspaceSwipeState = Stopped } - is PickerSwitchAction -> { - // update lastSymbolType only when specified explicitly - val key = action.key?.also { k -> lastPickerType = k.name } - ?: runCatching { PickerWindow.Key.valueOf(lastPickerType) }.getOrNull() - ?: PickerWindow.Key.Emoji - ContextCompat.getMainExecutor(service).execute { - windowManager.attachWindow(key) - } + backspaceSwipeState = Stopped + } + is PickerSwitchAction -> { + // update lastSymbolType only when specified explicitly + val key = action.key?.also { k -> lastPickerType = k.name } + ?: runCatching { PickerWindow.Key.valueOf(lastPickerType) }.getOrNull() + ?: PickerWindow.Key.Emoji + ContextCompat.getMainExecutor(service).execute { + windowManager.attachWindow(key) } - else -> { + } + is SpaceLongPressAction -> { + when (spaceKeyLongPressBehavior) { + SpaceLongPressBehavior.None -> {} + SpaceLongPressBehavior.Enumerate -> service.postFcitxJob { + enumerateIme() + } + SpaceLongPressBehavior.ToggleActivate -> service.postFcitxJob { + toggleIme() + } + SpaceLongPressBehavior.ShowPicker -> showInputMethodPicker() } } + else -> {} } } } diff --git a/app/src/main/java/org/fcitx/fcitx5/android/input/keyboard/CustomGestureView.kt b/app/src/main/java/org/fcitx/fcitx5/android/input/keyboard/CustomGestureView.kt index 01c255961..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 splitties.systemservices.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 071ec94bb..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() - class LangSwitchAction(val enumerate: Boolean = true) : KeyAction() + data object LangSwitchAction : KeyAction() - object InputMethodSwitchAction : KeyAction() + data object ShowInputMethodPickerAction : KeyAction() data class LayoutSwitchAction(val act: String = "") : KeyAction() @@ -34,4 +38,6 @@ sealed class KeyAction { data class DeleteSelectionAction(val totalCnt: Int = 0) : KeyAction() data class PickerSwitchAction(val key: PickerWindow.Key? = null) : 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 c48819b04..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, @@ -12,7 +17,9 @@ open class KeyDef( val percentWidth: Float, val variant: Variant, val border: Border, + val margin: Boolean, val viewId: Int, + val soundEffect: InputFeedbacks.SoundEffect ) { enum class Variant { Normal, AltForeground, Alternative, Accent @@ -33,8 +40,10 @@ open class KeyDef( percentWidth: Float = 0.1f, variant: Variant = Variant.Normal, border: Border = Border.Default, - viewId: Int = -1 - ) : Appearance(percentWidth, variant, border, viewId) + margin: Boolean = true, + viewId: Int = -1, + soundEffect: InputFeedbacks.SoundEffect = InputFeedbacks.SoundEffect.Standard + ) : Appearance(percentWidth, variant, border, margin, viewId, soundEffect) class AltText( displayText: String, @@ -48,8 +57,9 @@ open class KeyDef( percentWidth: Float = 0.1f, variant: Variant = Variant.Normal, border: Border = Border.Default, - viewId: Int = -1 - ) : Text(displayText, textSize, textStyle, percentWidth, variant, border, viewId) + margin: Boolean = true, + viewId: Int = -1, + ) : Text(displayText, textSize, textStyle, percentWidth, variant, border, margin, viewId) class Image( @DrawableRes @@ -57,8 +67,27 @@ open class KeyDef( 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, + variant: Variant = Variant.Normal, + border: Border = Border.Default, + margin: Boolean = true, viewId: Int = -1 - ) : Appearance(percentWidth, variant, border, 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 1d35d5980..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 @@ -83,7 +88,7 @@ class AlphabetDigitKey( ) : this( char, digit.toString(), - 0xffb0 + digit, + FcitxKeyMapping.FcitxKey_KP_0 + digit, popup ) } @@ -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(",")) @@ -192,8 +199,8 @@ class LanguageKey : KeyDef( viewId = R.id.button_lang ), setOf( - Behavior.Press(KeyAction.LangSwitchAction()), - Behavior.LongPress(KeyAction.InputMethodSwitchAction) + Behavior.Press(KeyAction.LangSwitchAction), + Behavior.LongPress(KeyAction.ShowInputMethodPickerAction) ) ) @@ -203,11 +210,12 @@ 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))), - Behavior.LongPress(KeyAction.LangSwitchAction(enumerate = false)) + Behavior.LongPress(KeyAction.SpaceLongPressAction) ) ) @@ -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 3ccc8fe6f..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,42 +1,78 @@ +/* + * 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) { - val bordered = ThemeManager.prefs.keyBorder.getValue() - val rippled = ThemeManager.prefs.keyRippleEffect.getValue() - val radius = dp(ThemeManager.prefs.keyRadius.getValue().toFloat()) - val hMargin = dp(ThemeManager.prefs.keyHorizontalMargin.getValue()) - val vMargin = dp(ThemeManager.prefs.keyVerticalMargin.getValue()) + val bordered: Boolean + val rippled: Boolean + val radius: Float + val hMargin: Int + val vMargin: Int + + init { + val prefs = ThemeManager.prefs + bordered = prefs.keyBorder.getValue() + rippled = prefs.keyRippleEffect.getValue() + radius = dp(prefs.keyRadius.getValue().toFloat()) + 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) private val cachedBounds = Rect() @@ -46,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 } @@ -61,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 { @@ -93,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), @@ -111,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) } @@ -146,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 ) ) } @@ -170,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 ) ) } @@ -195,12 +236,13 @@ 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) setTextColor( @@ -213,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() }) @@ -224,14 +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 - // TODO darken altText color + textDirection = View.TEXT_DIRECTION_FIRST_STRONG_LTR setTextColor( when (def.variant) { Variant.Normal, Variant.AltForeground, Variant.Alternative -> theme.altKeyTextColor @@ -241,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) } @@ -254,13 +298,14 @@ 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 // set topToTop = parentId; topMargin = vMargin - startToStart = unset - endToEnd = parentId; endMargin = hMargin + dp(4) + leftToLeft = unset + rightToRight = parentId; rightMargin = hMargin + dp(4) } } @@ -272,25 +317,38 @@ 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 - endMargin = 0 + rightMargin = 0 // set - startToStart = parentId - endToEnd = parentId + leftToLeft = parentId + rightToRight = parentId bottomToBottom = parentId; bottomMargin = vMargin + dp(2) } } + 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() } } @@ -305,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 7c298acb9..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 @@ -8,17 +12,18 @@ import android.widget.FrameLayout import androidx.core.content.ContextCompat import androidx.transition.Slide import org.fcitx.fcitx5.android.R -import org.fcitx.fcitx5.android.core.FcitxEvent +import org.fcitx.fcitx5.android.core.CapabilityFlags import org.fcitx.fcitx5.android.core.InputMethodEntry import org.fcitx.fcitx5.android.data.prefs.AppPrefs -import org.fcitx.fcitx5.android.input.FcitxInputMethodService +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.dependency.fcitx import org.fcitx.fcitx5.android.input.dependency.inputMethodService import org.fcitx.fcitx5.android.input.dependency.theme import org.fcitx.fcitx5.android.input.picker.PickerWindow +import org.fcitx.fcitx5.android.input.popup.PopupActionListener import org.fcitx.fcitx5.android.input.popup.PopupComponent -import org.fcitx.fcitx5.android.input.popup.PopupListener import org.fcitx.fcitx5.android.input.wm.EssentialWindow import org.fcitx.fcitx5.android.input.wm.InputWindow import org.fcitx.fcitx5.android.input.wm.InputWindowManager @@ -31,12 +36,14 @@ import splitties.views.dsl.core.matchParent class KeyboardWindow : InputWindow.SimpleInputWindow(), EssentialWindow, InputBroadcastReceiver { - private val service: FcitxInputMethodService by manager.inputMethodService() + private val service by manager.inputMethodService() private val fcitx by manager.fcitx() private val theme by manager.theme() private val commonKeyActionListener: CommonKeyActionListener by manager.must() private val windowManager: InputWindowManager by manager.must() private val popup: PopupComponent by manager.must() + private val bar: KawaiiBarComponent by manager.must() + private val returnKeyDrawable: ReturnKeyDrawableComponent by manager.must() companion object : EssentialWindow.Key @@ -77,7 +84,7 @@ class KeyboardWindow : InputWindow.SimpleInputWindow(), Essentia } } - private val popupListener: PopupListener by lazy { + private val popupActionListener: PopupActionListener by lazy { popup.listener } @@ -93,7 +100,7 @@ class KeyboardWindow : InputWindow.SimpleInputWindow(), Essentia it.onDetach() keyboardView.removeView(it) it.keyActionListener = null - it.keyPopupListener = null + it.popupActionListener = null } } @@ -101,23 +108,27 @@ class KeyboardWindow : InputWindow.SimpleInputWindow(), Essentia currentKeyboardName = target currentKeyboard?.let { it.keyActionListener = keyActionListener - it.keyPopupListener = popupListener + it.popupActionListener = popupActionListener keyboardView.apply { add(it, lParams(matchParent, matchParent)) } - it.onAttach(service.editorInfo) - it.onInputMethodChange(fcitx.runImmediately { inputMethodEntryCached }) + it.onAttach() + it.onReturnDrawableUpdate(returnKeyDrawable.resourceId) + it.onInputMethodUpdate(fcitx.runImmediately { inputMethodEntryCached }) } } 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)) { + notifyBarLayoutChanged() + } } else { if (remember) { lastSymbolType = PickerWindow.Key.Symbol.name @@ -127,42 +138,49 @@ class KeyboardWindow : InputWindow.SimpleInputWindow(), Essentia } } - override fun onEditorInfoUpdate(info: EditorInfo?) { - val targetLayout = service.editorInfo?.let { - when (it.inputType and InputType.TYPE_MASK_CLASS) { - InputType.TYPE_CLASS_NUMBER -> NumberKeyboard.Name - InputType.TYPE_CLASS_PHONE -> NumberKeyboard.Name - else -> TextKeyboard.Name - } + override fun onStartInput(info: EditorInfo, capFlags: CapabilityFlags) { + val targetLayout = when (info.inputType and InputType.TYPE_MASK_CLASS) { + InputType.TYPE_CLASS_NUMBER -> NumberKeyboard.Name + InputType.TYPE_CLASS_PHONE -> NumberKeyboard.Name + else -> TextKeyboard.Name } - switchLayout(targetLayout ?: TextKeyboard.Name, remember = false) - currentKeyboard?.onEditorInfoChange(info) + switchLayout(targetLayout, remember = false) } override fun onImeUpdate(ime: InputMethodEntry) { - currentKeyboard?.onInputMethodChange(ime) - } - - override fun onPreeditUpdate(data: FcitxEvent.PreeditEvent.Data) { - currentKeyboard?.onPreeditChange(service.editorInfo, data) + currentKeyboard?.onInputMethodUpdate(ime) } override fun onPunctuationUpdate(mapping: Map) { currentKeyboard?.onPunctuationUpdate(mapping) } + override fun onReturnKeyDrawableUpdate(resourceId: Int) { + currentKeyboard?.onReturnDrawableUpdate(resourceId) + } + override fun onAttached() { currentKeyboard?.let { it.keyActionListener = keyActionListener - it.keyPopupListener = popupListener + it.popupActionListener = popupActionListener + it.onAttach() } + notifyBarLayoutChanged() } override fun onDetached() { currentKeyboard?.let { + it.onDetach() it.keyActionListener = null - it.keyPopupListener = null + it.popupActionListener = null } popup.dismissAll() } + + // Call this when + // 1) the keyboard window was newly attached + // 2) currently keyboard window is attached and switchLayout was used + private fun notifyBarLayoutChanged() { + bar.onKeyboardLayoutSwitched(currentKeyboardName == NumberKeyboard.Name) + } } \ No newline at end of file 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 216eed1b7..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,11 +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 android.annotation.SuppressLint import android.content.Context -import android.view.inputmethod.EditorInfo 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") @@ -41,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() ) @@ -55,8 +59,13 @@ class NumberKeyboard( val space: TextKeyView by lazy { findViewById(R.id.button_mini_space) } val `return`: ImageKeyView by lazy { findViewById(R.id.button_return) } - override fun onAttach(info: EditorInfo?) { - `return`.img.imageResource = drawableForReturn(info) + override fun onReturnDrawableUpdate(returnDrawable: Int) { + `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 new file mode 100644 index 000000000..adea1fce6 --- /dev/null +++ b/app/src/main/java/org/fcitx/fcitx5/android/input/keyboard/SpaceLongPressBehavior.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.input.keyboard + +import org.fcitx.fcitx5.android.R +import org.fcitx.fcitx5.android.data.prefs.ManagedPreferenceEnum + +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 e418534d3..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,18 +1,22 @@ +/* + * 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.graphics.Rect -import android.view.inputmethod.EditorInfo +import android.view.View +import androidx.annotation.Keep import androidx.core.view.allViews import org.fcitx.fcitx5.android.R -import org.fcitx.fcitx5.android.core.FcitxEvent 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 splitties.resources.drawable -import splitties.views.imageDrawable +import org.fcitx.fcitx5.android.input.popup.PopupAction 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", "@"), @@ -79,13 +83,18 @@ class TextKeyboard( val space: TextKeyView by lazy { findViewById(R.id.button_space) } val `return`: ImageKeyView by lazy { findViewById(R.id.button_return) } - private val keepLettersUppercase = AppPrefs.getInstance().keyboard.keepLettersUppercase - private val keepLettersUppercaseListener = ManagedPreference.OnChangeListener { _, _ -> - updateAlphabetKeys() + private val showLangSwitchKey = AppPrefs.getInstance().keyboard.showLangSwitchKey + + @Keep + private val showLangSwitchKeyListener = ManagedPreference.OnChangeListener { _, v -> + updateLangSwitchKey(v) } + private val keepLettersUppercase by AppPrefs.getInstance().keyboard.keepLettersUppercase + init { - keepLettersUppercase.registerOnChangeListener(keepLettersUppercaseListener) + updateLangSwitchKey(showLangSwitchKey.getValue()) + showLangSwitchKey.registerOnChangeListener(showLangSwitchKeyListener) } private val textKeys: List by lazy { @@ -104,44 +113,50 @@ 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 -> { - } + else -> {} } - super.onAction(action, source) + super.onAction(transformed, source) } - private fun transformKeyAction(action: KeyAction.FcitxKeyAction) { - if (action.act.length > 1) { - return - } - action.act = transformAlphabet(action.act) - if (capsState == CapsState.Once) switchCapsState() - } - - override fun onAttach(info: EditorInfo?) { + override fun onAttach() { + capsState = CapsState.None updateCapsButtonIcon() updateAlphabetKeys() - `return`.img.imageResource = drawableForReturn(info) } - override fun onEditorInfoChange(info: EditorInfo?) { - `return`.img.imageResource = drawableForReturn(info) - } - - override fun onPreeditChange(info: EditorInfo?, data: FcitxEvent.PreeditEvent.Data) { - updateReturnButton(`return`, info, data) + override fun onReturnDrawableUpdate(returnDrawable: Int) { + `return`.img.imageResource = returnDrawable } override fun onPunctuationUpdate(mapping: Map) { @@ -149,59 +164,74 @@ class TextKeyboard( updatePunctuationKeys() } - override fun onInputMethodChange(ime: InputMethodEntry) { + override fun onInputMethodUpdate(ime: InputMethodEntry) { space.mainText.text = buildString { append(ime.displayName) ime.subMode.run { label.ifEmpty { name.ifEmpty { null } } }?.let { append(" ($it)") } } + if (capsState != CapsState.None) { + switchCapsState() + } } - override fun onPopupPreview(viewId: Int, content: String, bounds: Rect) { - super.onPopupPreview(viewId, transformInputString(content), bounds) - } - - override fun onPopupPreviewUpdate(viewId: Int, content: String) { - super.onPopupPreviewUpdate(viewId, transformInputString(content)) + private fun transformPopupPreview(c: String): String { + if (c.length != 1) return c + if (c[0].isLetter()) return transformAlphabet(c) + return transformPunctuation(c) } - override fun onPopupKeyboard(viewId: Int, keyboard: KeyDef.Popup.Keyboard, bounds: Rect) { - val label = keyboard.label - val k = if (label.length == 1 && label[0].isLetter()) - KeyDef.Popup.Keyboard(transformAlphabet(label)) - else keyboard - super.onPopupKeyboard(viewId, k, bounds) + override fun onPopupAction(action: PopupAction) { + val newAction = when (action) { + 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()) + action.copy(keyboard = KeyDef.Popup.Keyboard(transformAlphabet(label))) + else action + } + else -> action + } + super.onPopupAction(newAction) } 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 + } } } + private fun updateLangSwitchKey(visible: Boolean) { + lang.visibility = if (visible) View.VISIBLE else View.GONE + } + private fun updateAlphabetKeys() { textKeys.forEach { if (it.def !is KeyDef.Appearance.AltText) return it.mainText.text = it.def.displayText.let { str -> if (str.length != 1 || !str[0].isLetter()) return@forEach - if (keepLettersUppercase.getValue()) str.uppercase() else transformAlphabet(str) + if (keepLettersUppercase) str.uppercase() else transformAlphabet(str) } } } 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 f838aae8c..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,9 +12,16 @@ 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 @SuppressLint("ViewConstructor") class PickerLayout(context: Context, theme: Theme, switchKey: KeyDef) : @@ -41,8 +52,11 @@ class PickerLayout(context: Context, theme: Theme, switchKey: KeyDef) : ) ) - val backspace: ImageKeyView by lazy { findViewById(R.id.button_backspace) } val `return`: ImageKeyView by lazy { findViewById(R.id.button_return) } + + override fun onReturnDrawableUpdate(returnDrawable: Int) { + `return`.img.imageResource = returnDrawable + } } val embeddedKeyboard = Keyboard(context, theme, switchKey) 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 391ff8536..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,175 +1,156 @@ +/* + * 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.graphics.Rect -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.keyboard.* +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.KeyAction.* +import org.fcitx.fcitx5.android.input.keyboard.ImageKeyView +import org.fcitx.fcitx5.android.input.keyboard.KeyAction.CommitAction +import org.fcitx.fcitx5.android.input.keyboard.KeyAction.FcitxKeyAction +import org.fcitx.fcitx5.android.input.keyboard.KeyAction.SymAction +import org.fcitx.fcitx5.android.input.keyboard.KeyActionListener import org.fcitx.fcitx5.android.input.keyboard.KeyActionListener.Source +import org.fcitx.fcitx5.android.input.keyboard.KeyDef import org.fcitx.fcitx5.android.input.keyboard.KeyDef.Appearance 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.popup.PopupListener -import splitties.views.dsl.constraintlayout.* +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.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.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, 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 } var keyActionListener: KeyActionListener? = null - var popupListener: PopupListener? = null + var popupActionListener: PopupActionListener? = null private val keyAppearance = Appearance.Text( 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 { @@ -177,9 +158,19 @@ class PickerPageUi(override val ctx: Context, val theme: Theme, val density: Den 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) } @@ -188,7 +179,7 @@ class PickerPageUi(override val ctx: Context, val theme: Theme, val density: Den 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) { @@ -200,10 +191,12 @@ class PickerPageUi(override val ctx: Context, val theme: Theme, val density: Den 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 @@ -212,7 +205,14 @@ class PickerPageUi(override val ctx: Context, val theme: Theme, val density: Den // the actual bounds on press. see [^1] as well view.updateBounds() } - onPopupKeyboard(view.id, text, view.bounds) + // TODO: maybe popup keyboard should just accept String as label? + onPopupAction( + PopupAction.ShowKeyboardAction( + view.id, + KeyDef.Popup.Keyboard(label), + bounds + ) + ) false } swipeEnabled = true @@ -226,16 +226,20 @@ class PickerPageUi(override val ctx: Context, val theme: Theme, val density: Den // eg. it's inside the next page of ViewPager // so update bounds when it's pressed view.updateBounds() - onPopupPreview(view.id, text, view.bounds) + onPopupAction( + PopupAction.PreviewAction(view.id, label, view.bounds) + ) } false } + CustomGestureView.GestureType.Move -> { - onPopupKeyboardChangeFocus(view.id, event.x, event.y) + onPopupChangeFocus(view.id, event.x, event.y) } + CustomGestureView.GestureType.Up -> { - onPopupKeyboardTrigger(view.id).also { - onPopupDismiss(view.id) + onPopupTrigger(view.id).also { + onPopupAction(PopupAction.DismissAction(view.id)) } } } @@ -245,28 +249,24 @@ class PickerPageUi(override val ctx: Context, val theme: Theme, val density: Den } } - private fun onPopupPreview(viewId: Int, content: String, bounds: Rect) { - popupListener?.onPreview(viewId, content, bounds) - } - - private fun onPopupDismiss(viewId: Int) { - popupListener?.onDismiss(viewId) + private fun onPopupAction(action: PopupAction) { + popupActionListener?.onPopupAction(action) } - private fun onPopupKeyboard(viewId: Int, label: String, bounds: Rect) { - // TODO: maybe popup keyboard should just accept String as label? - popupListener?.onShowKeyboard(viewId, KeyDef.Popup.Keyboard(label), bounds) + private fun onPopupChangeFocus(viewId: Int, x: Float, y: Float): Boolean { + val changeFocusAction = PopupAction.ChangeFocusAction(viewId, x, y) + popupActionListener?.onPopupAction(changeFocusAction) + return changeFocusAction.outResult } - private fun onPopupKeyboardChangeFocus(viewId: Int, x: Float, y: Float): Boolean { - return popupListener?.onChangeFocus(viewId, x, y) ?: false - } - - private fun onPopupKeyboardTrigger(viewId: Int): Boolean { + private fun onPopupTrigger(viewId: Int): Boolean { + val triggerAction = PopupAction.TriggerAction(viewId) // TODO: maybe popup keyboard should just yield String value? - val action = popupListener?.onTrigger(viewId) as? FcitxKeyAction ?: return false + onPopupAction(triggerAction) + val action = triggerAction.outAction as? FcitxKeyAction ?: return false onSymbolClick(action.act) - onPopupDismiss(viewId) + onPopupAction(PopupAction.DismissAction(viewId)) return true } + } 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 d453138d7..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,134 +1,100 @@ +/* + * 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 import org.fcitx.fcitx5.android.data.theme.Theme import org.fcitx.fcitx5.android.input.keyboard.KeyActionListener -import org.fcitx.fcitx5.android.input.popup.PopupListener +import org.fcitx.fcitx5.android.input.popup.PopupActionListener class PickerPagesAdapter( val theme: Theme, private val keyActionListener: KeyActionListener, - private val popupListener: PopupListener, + 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) { holder.ui.keyActionListener = keyActionListener if (holder.bindingAdapterPosition == 0) { // prevent popup on RecentlyUsed page - holder.ui.popupListener = null - // update RecentlyUsed when it's page attached - updateRecent() - holder.ui.setItems(pages[0]) + holder.ui.popupActionListener = null + // RecentlyUsed content are already modified with skin tones + holder.ui.setItems(recentlyUsed.items, withSkinTone = false) } else { - holder.ui.popupListener = popupListener + holder.ui.popupActionListener = popupActionListener } } override fun onViewDetachedFromWindow(holder: ViewHolder) { holder.ui.keyActionListener = null - holder.ui.popupListener = null + holder.ui.popupActionListener = null } companion object { 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 8dd0d82f2..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,16 +1,29 @@ +/* + * 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.graphics.Rect +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.input.broadcast.InputBroadcastReceiver +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 -import org.fcitx.fcitx5.android.input.popup.PopupListener import org.fcitx.fcitx5.android.input.wm.EssentialWindow import org.fcitx.fcitx5.android.input.wm.InputWindow import org.fcitx.fcitx5.android.input.wm.InputWindowManager @@ -18,11 +31,12 @@ 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 -) : InputWindow.ExtendedInputWindow(), EssentialWindow, InputBroadcastReceiver { + private val popupPreview: Boolean = true, + private val followKeyBorder: Boolean = true +) : InputWindow.ExtendedInputWindow(), EssentialWindow { enum class Key : EssentialWindow.Key { Symbol, @@ -30,10 +44,14 @@ 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 @@ -64,11 +82,13 @@ class PickerWindow( windowManager.attachWindow(KeyboardWindow) } } + is KeyAction.FcitxKeyAction -> { // we want the behavior of CommitAction (commit the character as-is), // but don't want to include it in recently used list commonKeyActionListener.listener.onKeyAction(KeyAction.CommitAction(it.act), source) } + else -> { if (it is KeyAction.CommitAction) { pickerPagesAdapter.insertRecent(it.text) @@ -78,53 +98,49 @@ class PickerWindow( } } - private val popupListener: PopupListener by lazy { - object : PopupListener by popup.listener { - override fun onPreview(viewId: Int, content: String, bounds: Rect) { - if (!popupPreview) return - popup.listener.onPreview(viewId, content, bounds) - } - - override fun onShowKeyboard( - viewId: Int, - keyboard: KeyDef.Popup.Keyboard, - bounds: Rect - ) { - // prevent ViewPager from consuming swipe gesture when popup keyboard shown - pickerLayout.pager.isUserInputEnabled = false - popup.listener.onShowKeyboard(viewId, keyboard, bounds) - } - - override fun onDismiss(viewId: Int) { - popup.listener.onDismiss(viewId) - // restore ViewPager scrolling - pickerLayout.pager.isUserInputEnabled = true + private val popupActionListener: PopupActionListener by lazy { + PopupActionListener { + when (it) { + is PopupAction.PreviewAction -> { + if (!popupPreview) return@PopupActionListener + } + is PopupAction.ShowKeyboardAction -> { + // prevent ViewPager from consuming swipe gesture when popup keyboard shown + pickerLayout.pager.isUserInputEnabled = false + } + is PopupAction.DismissAction -> { + // restore ViewPager scrolling + pickerLayout.pager.isUserInputEnabled = true + } + else -> {} } + popup.listener.onPopupAction(it) } } override fun onCreateView() = PickerLayout(context, theme, switchKey).apply { pickerLayout = this + val bordered = followKeyBorder && keyBorder + val isEmoji = key === Key.Emoji pickerPagesAdapter = PickerPagesAdapter( - theme, keyActionListener, popupListener, 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, @@ -132,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() } }) @@ -149,14 +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.keyActionListener = keyActionListener + 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 new file mode 100644 index 000000000..f6e076f33 --- /dev/null +++ b/app/src/main/java/org/fcitx/fcitx5/android/input/popup/PopupAction.kt @@ -0,0 +1,53 @@ +/* + * 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 +import org.fcitx.fcitx5.android.input.keyboard.KeyAction +import org.fcitx.fcitx5.android.input.keyboard.KeyDef + +sealed class PopupAction { + + abstract val viewId: Int + + data class PreviewAction( + override val viewId: Int, + val content: String, + val bounds: Rect + ) : PopupAction() + + data class PreviewUpdateAction( + override val viewId: Int, + val content: String, + ) : PopupAction() + + data class DismissAction( + override val viewId: Int + ) : PopupAction() + + data class ShowKeyboardAction( + override val viewId: Int, + val keyboard: KeyDef.Popup.Keyboard, + val bounds: Rect + ) : PopupAction() + + data class ShowMenuAction( + override val viewId: Int, + val menu: KeyDef.Popup.Menu, + val bounds: Rect + ) : PopupAction() + + data class ChangeFocusAction( + override val viewId: Int, + val x: Float, + val y: Float, + var outResult: Boolean = false + ) : PopupAction() + + data class TriggerAction( + override val viewId: Int, + var outAction: KeyAction? = null + ) : PopupAction() +} 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 new file mode 100644 index 000000000..1e3d7901e --- /dev/null +++ b/app/src/main/java/org/fcitx/fcitx5/android/input/popup/PopupActionListener.kt @@ -0,0 +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.popup + +fun interface PopupActionListener { + + fun onPopupAction(action: PopupAction) + +} 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 69aff3e9a..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,17 +1,22 @@ +/* + * 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 +import android.view.View import androidx.lifecycle.lifecycleScope import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.launch import org.fcitx.fcitx5.android.data.theme.ThemeManager +import org.fcitx.fcitx5.android.input.broadcast.PunctuationComponent import org.fcitx.fcitx5.android.input.dependency.context import org.fcitx.fcitx5.android.input.dependency.inputMethodService import org.fcitx.fcitx5.android.input.dependency.theme import org.fcitx.fcitx5.android.input.keyboard.KeyAction import org.fcitx.fcitx5.android.input.keyboard.KeyDef -import org.fcitx.fcitx5.android.input.punctuation.PunctuationComponent import org.mechdancer.dependency.Dependent import org.mechdancer.dependency.UniqueComponent import org.mechdancer.dependency.manager.ManagedHandler @@ -21,7 +26,7 @@ import splitties.dimensions.dp import splitties.views.dsl.core.add import splitties.views.dsl.core.frameLayout import splitties.views.dsl.core.lParams -import java.util.* +import java.util.LinkedList class PopupComponent : UniqueComponent(), Dependent, ManagedHandler by managedHandler() { @@ -54,14 +59,26 @@ 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) + } } } - fun showPopup(viewId: Int, content: String, bounds: Rect) { + private fun showPopup(viewId: Int, content: String, bounds: Rect) { showingEntryUi[viewId]?.apply { dismissJobs[viewId]?.also { dismissJobs.remove(viewId)?.cancel() @@ -85,12 +102,14 @@ class PopupComponent : showingEntryUi[viewId] = popup } - fun updatePopup(viewId: Int, content: String) { + private fun updatePopup(viewId: Int, content: String) { showingEntryUi[viewId]?.setText(content) } - fun showKeyboard(viewId: Int, keyboard: KeyDef.Popup.Keyboard, bounds: Rect) { - val keys = PopupPreset[keyboard.label] ?: return + private fun showKeyboard(viewId: Int, keyboard: KeyDef.Popup.Keyboard, bounds: Rect) { + 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) @@ -103,6 +122,7 @@ class PopupComponent : val keyboardUi = PopupKeyboardUi( context, theme, + rootBounds, bounds, { dismissPopup(viewId) }, popupRadius, @@ -113,44 +133,43 @@ 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) } - fun showMenu(viewId: Int, menu: KeyDef.Popup.Menu, bounds: Rect) { + private fun showMenu(viewId: Int, menu: KeyDef.Popup.Menu, bounds: Rect) { showingEntryUi[viewId]?.let { dismissPopupEntry(viewId, it) } 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 } - fun changeFocus(viewId: Int, x: Float, y: Float): Boolean { + private fun changeFocus(viewId: Int, x: Float, y: Float): Boolean { return showingContainerUi[viewId]?.changeFocus(x, y) ?: false } - fun triggerFocused(viewId: Int): KeyAction? { + private fun triggerFocused(viewId: Int): KeyAction? { return showingContainerUi[viewId]?.onTrigger() } - fun dismissPopup(viewId: Int) { + private fun dismissPopup(viewId: Int) { dismissPopupContainer(viewId) showingEntryUi[viewId]?.also { val timeLeft = it.lastShowTime + hideThreshold - System.currentTimeMillis() @@ -198,33 +217,17 @@ class PopupComponent : showingEntryUi.clear() } - val listener: PopupListener = object : PopupListener { - override fun onPreview(viewId: Int, content: String, bounds: Rect) { - showPopup(viewId, content, bounds) - } - - override fun onPreviewUpdate(viewId: Int, content: String) { - updatePopup(viewId, content) - } - - override fun onDismiss(viewId: Int) { - dismissPopup(viewId) - } - - override fun onShowKeyboard(viewId: Int, keyboard: KeyDef.Popup.Keyboard, bounds: Rect) { - showKeyboard(viewId, keyboard, bounds) - } - - override fun onShowMenu(viewId: Int, menu: KeyDef.Popup.Menu, bounds: Rect) { - showMenu(viewId, menu, bounds) - } - - override fun onChangeFocus(viewId: Int, x: Float, y: Float): Boolean { - return changeFocus(viewId, x, y) - } - - override fun onTrigger(viewId: Int): KeyAction? { - return triggerFocused(viewId) + val listener = PopupActionListener { action -> + with(action) { + when (this) { + is PopupAction.ChangeFocusAction -> outResult = changeFocus(viewId, x, y) + is PopupAction.DismissAction -> dismissPopup(viewId) + is PopupAction.PreviewAction -> showPopup(viewId, content, bounds) + is PopupAction.PreviewUpdateAction -> updatePopup(viewId, content) + is PopupAction.ShowKeyboardAction -> showKeyboard(viewId, keyboard, bounds) + is PopupAction.ShowMenuAction -> showMenu(viewId, menu, bounds) + is PopupAction.TriggerAction -> outAction = triggerFocused(viewId) + } } } } 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 9f2b3bb30..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,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 @@ -5,9 +9,17 @@ import android.graphics.Rect import android.graphics.drawable.GradientDrawable import android.view.ViewOutlineProvider 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.* +import splitties.views.dsl.core.Ui +import splitties.views.dsl.core.add +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.verticalLayout +import splitties.views.dsl.core.view import splitties.views.gravityCenter import splitties.views.gravityEnd import splitties.views.gravityStart @@ -18,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 @@ -32,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, @@ -40,7 +54,23 @@ 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 = view(::AutoScaleTextView) { + text = this@PopupKeyUi.text + scaleMode = AutoScaleTextView.Mode.Proportional + textSize = 23f + setTextColor(theme.keyTextColor) + } + + override val root = frameLayout { + add(textView, lParams { + gravity = gravityCenter + }) + } + } private val inactiveBackground = GradientDrawable().apply { cornerRadius = radius @@ -65,7 +95,7 @@ class PopupKeyboardUi( columnCount = (keyCount / rowCount).roundToInt() focusRow = 0 - focusColumn = calcInitialFocusedColumn(columnCount, keyWidth, bounds) + focusColumn = calcInitialFocusedColumn(columnCount, keyWidth, outerBounds, triggerBounds) } /** @@ -97,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) @@ -121,14 +151,8 @@ class PopupKeyboardUi( private var focusedIndex = keyOrders[focusRow][focusColumn] - private val keyViews = labels.map { - textView { - text = it - textSize = 23f - isSingleLine = true - gravity = gravityCenter - setTextColor(theme.keyTextColor) - } + private val keyUis = labels.map { + PopupKeyUi(ctx, theme, it) } init { @@ -144,14 +168,14 @@ class PopupKeyboardUi( val order = keyOrders[i] add(horizontalLayout row@{ for (j in 0 until columnCount) { - val view = keyViews.getOrNull(order[j]) - if (view == null) { + val keyUi = keyUis.getOrNull(order[j]) + if (keyUi == null) { // align columns to right (end) when first column is empty, eg. // | | 6 | 5 | 4 |(no free space) // | 3 | 2 | 1 | 0 |(no free space) gravity = if (j == 0) gravityEnd else gravityStart } else { - add(view, lParams(keyWidth, keyHeight)) + add(keyUi.root, lParams(keyWidth, keyHeight)) } } }, lParams(width = matchParent)) @@ -159,16 +183,16 @@ class PopupKeyboardUi( } private fun markFocus(index: Int) { - keyViews.getOrNull(index)?.apply { - background = focusBackground - setTextColor(theme.genericActiveForegroundColor) + keyUis.getOrNull(index)?.apply { + root.background = focusBackground + textView.setTextColor(theme.genericActiveForegroundColor) } } private fun markInactive(index: Int) { - keyViews.getOrNull(index)?.apply { - background = null - setTextColor(theme.popupTextColor) + keyUis.getOrNull(index)?.apply { + root.background = null + textView.setTextColor(theme.popupTextColor) } } @@ -185,7 +209,7 @@ class PopupKeyboardUi( newRow = limitIndex(newRow, rowCount) newColumn = limitIndex(newColumn, columnCount) val newFocus = keyOrders[newRow][newColumn] - if (newFocus < keyViews.size) { + if (newFocus < keyUis.size) { markInactive(focusedIndex) markFocus(newFocus) focusedIndex = newFocus @@ -193,5 +217,9 @@ class PopupKeyboardUi( return false } - override fun onTrigger() = keys.getOrNull(focusedIndex)?.let { KeyAction.FcitxKeyAction(it) } + override fun onTrigger(): KeyAction? { + val key = keys.getOrNull(focusedIndex) ?: return null + return KeyAction.FcitxKeyAction(key) + } + } diff --git a/app/src/main/java/org/fcitx/fcitx5/android/input/popup/PopupListener.kt b/app/src/main/java/org/fcitx/fcitx5/android/input/popup/PopupListener.kt deleted file mode 100644 index 648285dd1..000000000 --- a/app/src/main/java/org/fcitx/fcitx5/android/input/popup/PopupListener.kt +++ /dev/null @@ -1,15 +0,0 @@ -package org.fcitx.fcitx5.android.input.popup - -import android.graphics.Rect -import org.fcitx.fcitx5.android.input.keyboard.KeyAction -import org.fcitx.fcitx5.android.input.keyboard.KeyDef - -interface PopupListener { - fun onPreview(viewId: Int, content: String, bounds: Rect) - fun onPreviewUpdate(viewId: Int, content: String) - fun onDismiss(viewId: Int) - fun onShowKeyboard(viewId: Int, keyboard: KeyDef.Popup.Keyboard, bounds: Rect) - fun onShowMenu(viewId: Int, menu: KeyDef.Popup.Menu, bounds: Rect) - fun onChangeFocus(viewId: Int, x: Float, y: Float): Boolean - fun onTrigger(viewId: Int): KeyAction? -} 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 7b4e102f0..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,23 +25,22 @@ class PreeditComponent : UniqueComponent(), Dependent, InputBr private val context by manager.context() private val theme by manager.theme() - private var preedit = FcitxEvent.PreeditEvent.Data() - private var aux = FcitxEvent.InputPanelAuxEvent.Data() - - val ui by lazy { PreeditUi(context, theme) } - - override fun onPreeditUpdate(data: FcitxEvent.PreeditEvent.Data) { - preedit = data - updateView() - } - - override fun onInputPanelAuxUpdate(data: FcitxEvent.InputPanelAuxEvent.Data) { - aux = data - updateView() + 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 + } } - private fun updateView() { - ui.update(preedit, aux) + override fun onInputPanelUpdate(data: FcitxEvent.InputPanelEvent.Data) { + ui.update(data) ui.root.visibility = if (ui.visible) View.VISIBLE else View.INVISIBLE } } 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 6ad08743f..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,52 +58,50 @@ 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(preedit: FcitxEvent.PreeditEvent.Data, aux: FcitxEvent.InputPanelAuxEvent.Data) { - val bkgColor = theme.genericActiveBackgroundColor - val upText: SpannedString + fun update(inputPanel: FcitxEvent.InputPanelEvent.Data) { + val activeBkg = theme.genericActiveBackgroundColor + val upString: SpannedString val upCursor: Int - if (aux.auxUp.isEmpty()) { - upText = preedit.preedit.toSpannedString(bkgColor) - upCursor = preedit.preedit.cursor + if (inputPanel.auxUp.isEmpty()) { + upString = inputPanel.preedit.toSpannedString(activeBkg) + upCursor = inputPanel.preedit.cursor } else { - upText = buildSpannedString { - append(aux.auxUp.toSpannedString(bkgColor)) - append(preedit.preedit.toSpannedString(bkgColor)) + upString = buildSpannedString { + append(inputPanel.auxUp.toSpannedString(activeBkg)) + append(inputPanel.preedit.toSpannedString(activeBkg)) } - upCursor = preedit.preedit.cursor.let { + upCursor = inputPanel.preedit.cursor.let { if (it < 0) it - else aux.auxUp.length + it + else inputPanel.auxUp.length + it } } - val downString = aux.auxDown.toSpannedString(bkgColor) - val hasUp = upText.isNotEmpty() + val downString = inputPanel.auxDown.toSpannedString(activeBkg) + val hasUp = upString.isNotEmpty() val hasDown = downString.isNotEmpty() visible = hasUp || hasDown - if (!visible) return - val upString = if (upCursor < 0 || upCursor == upText.length) { - upText + if (!visible) { + updateTextView(upView, "", false) + updateTextView(downView, "", false) + return + } + val upStringWithCursor = if (upCursor < 0 || upCursor == upString.length) { + upString } else buildSpannedString { - if (upCursor > 0) append(upText, 0, upCursor) + if (upCursor > 0) append(upString, 0, upCursor) append('|') setSpan(cursorSpan, upCursor, upCursor + 1, Spanned.SPAN_INCLUSIVE_EXCLUSIVE) - append(upText, upCursor, upText.length) + append(upString, upCursor, upString.length) } - updateTextView(upView, upString, hasUp) + updateTextView(upView, upStringWithCursor, hasUp) updateTextView(downView, downString, hasDown) } } 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 951b3e63f..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,29 +1,43 @@ +/* + * 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.launchOnFcitxReady +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.ToolButton +import org.fcitx.fcitx5.android.input.bar.ui.ToolButton import org.fcitx.fcitx5.android.input.broadcast.InputBroadcastReceiver 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.editorinfo.EditorInfoWindow -import org.fcitx.fcitx5.android.input.status.StatusAreaEntry.Android.Type.* +import org.fcitx.fcitx5.android.input.status.StatusAreaEntry.Android.Type.InputMethod +import org.fcitx.fcitx5.android.input.status.StatusAreaEntry.Android.Type.Keyboard +import org.fcitx.fcitx5.android.input.status.StatusAreaEntry.Android.Type.ReloadConfig +import org.fcitx.fcitx5.android.input.status.StatusAreaEntry.Android.Type.ThemeList 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 @@ -59,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 ) @@ -67,31 +81,59 @@ class StatusAreaWindow : InputWindow.ExtendedInputWindow(), } private fun activateAction(action: Action) { - service.lifecycleScope.launchOnFcitxReady(fcitx) { 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 { @@ -99,9 +141,14 @@ class StatusAreaWindow : InputWindow.ExtendedInputWindow(), context, it.uniqueName, it.displayName ) } - ReloadConfig -> service.lifecycleScope.launchOnFcitxReady(fcitx) { f -> + ReloadConfig -> fcitx.launchOnReady { f -> f.reloadConfig() - Toast.makeText(service, R.string.done, Toast.LENGTH_SHORT).show() + 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() + } } Keyboard -> AppUtil.launchMainToKeyboard(context) ThemeList -> AppUtil.launchMainToThemeList(context) @@ -109,8 +156,7 @@ class StatusAreaWindow : InputWindow.ExtendedInputWindow(), } } - override val theme: Theme - get() = this@StatusAreaWindow.theme + override val theme = this@StatusAreaWindow.theme } } @@ -129,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]) } ) } @@ -137,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) } } } @@ -159,11 +207,16 @@ class StatusAreaWindow : InputWindow.ExtendedInputWindow(), override fun onCreateBarExtension() = barExtension override fun onAttached() { - service.lifecycleScope.launchOnFcitxReady(fcitx) { - onStatusAreaUpdate(it.statusArea()) + fcitx.launchOnReady { + val data = it.statusArea() + service.lifecycleScope.launch { + onStatusAreaUpdate(data) + } } } 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 737110d41..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 @@ -54,19 +57,24 @@ class InputWindowManager : UniqueViewComponent( } /** - * Associate essential window with its key - * This function does not create any view nor set up the scope + * Associate essential window with its key and add it to scope + * If [createView] is `true`, the view will be created immediately. + * Otherwise, it will be created on first attach */ @Suppress("BOUNDS_NOT_ALLOWED_IF_BOUNDED_BY_TYPE_PARAMETER") - fun addEssentialWindow(window: R) where R : W, R : E { - ensureThread() + fun addEssentialWindow( + window: R, + createView: Boolean = false + ) where R : W, R : E { if (window.key in essentialWindows) { - if (essentialWindows[window.key] === window) + if (essentialWindows[window.key]!!.first === window) Timber.d("Skip adding essential window $window") else throw IllegalStateException("${window.key} is already occupied") } - essentialWindows[window.key] = window to null + scope += window + val view = if (createView) window.onCreateView() else null + essentialWindows[window.key] = window to view } fun getEssentialWindow(windowKey: EssentialWindow.Key) = @@ -80,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") @@ -106,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) { @@ -158,8 +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 8f711f538..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,15 +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.appcompat.app.AlertDialog +import androidx.activity.OnBackPressedDispatcher 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 @@ -18,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 @@ -49,11 +71,12 @@ abstract class BaseDynamicListUi( initSettingsButton ) { + 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 { @@ -85,9 +108,16 @@ abstract class BaseDynamicListUi( } } - + /** + * whether to show "undo" snackbar after item update + */ var enableUndo = true + /** + * suspend "undo" snackbar temporarily to prevent undo undo + */ + private var suspendUndo = false + init { initEditButton = when (mode) { is Mode.ChooseOne -> { _ -> visibility = View.GONE } @@ -127,13 +157,23 @@ abstract class BaseDynamicListUi( override fun onItemRemovedBatch(indexed: List>) { updateFAB() + showUndoSnackbar(ctx.getString(R.string.removed_n_items, indexed.size)) { + indexed.sortedBy { it.first }.forEach { + addItem(it.first, it.second) + } + } } }) } - private fun showUndoSnackbar(text: String, action: View.OnClickListener) { + private fun showUndoSnackbar(text: String, action: () -> Unit) { + if (!enableUndo || suspendUndo) return Snackbar.make(root, text, Snackbar.LENGTH_SHORT) - .apply { if (enableUndo) setAction(R.string.undo, action) } + .setAction(R.string.undo) { + suspendUndo = true + action.invoke() + suspendUndo = false + } .addCallback(object : BaseTransientBottomBar.BaseCallback() { override fun onShown(transientBottomBar: Snackbar) { // snackbar is invisible when it attached to parent, @@ -151,8 +191,10 @@ abstract class BaseDynamicListUi( is Mode.ChooseOne -> { val candidatesSource = mode.candidatesSource(this) if (candidatesSource.isEmpty()) { + shouldShowFab = false fab.hide() } else { + shouldShowFab = true fab.show() fab.setOnClickListener { val items = candidatesSource.map { showEntry(it) }.toTypedArray() @@ -164,17 +206,35 @@ abstract class BaseDynamicListUi( } } is Mode.FreeAdd -> { + shouldShowFab = true fab.show() fab.setOnClickListener { showEditDialog(ctx.getString(R.string.add)) { addItem(item = it) } } } - is Mode.Immutable -> fab.hide() + is Mode.Immutable -> { + shouldShowFab = false + fab.hide() + } is Mode.Custom -> { } } } + override fun enterMultiSelect(onBackPressedDispatcher: OnBackPressedDispatcher) { + if (shouldShowFab) { + fab.hide() + } + super.enterMultiSelect(onBackPressedDispatcher) + } + + override fun exitMultiSelect() { + if (shouldShowFab) { + fab.show() + } + super.exitMultiSelect() + } + open fun showEditDialog( title: String, entry: T? = null, @@ -198,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 { @@ -226,8 +285,7 @@ abstract class BaseDynamicListUi( } fun addTouchCallback( - touchCallback: DynamicListTouchCallback = - DynamicListTouchCallback(this@BaseDynamicListUi) + touchCallback: DynamicListTouchCallback = DynamicListTouchCallback(ctx, this) ) { itemTouchHelper = ItemTouchHelper(touchCallback).also { it.attachToRecyclerView(recyclerView) @@ -253,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 c20493633..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, @@ -34,12 +38,7 @@ abstract class DynamicListAdapter( private val selected = mutableListOf() private var listener: OnItemChangedListener? = null - protected var itemTouchHelper: ItemTouchHelper? = null - - var removable: (T) -> Boolean = { true } - private var onBackPressedCallback: OnBackPressedCallback? = null - abstract fun showEntry(x: T): String fun removeItemChangedListener() { listener = null } @@ -48,6 +47,19 @@ abstract class DynamicListAdapter( listener = listener?.let { OnItemChangedListener.merge(it, x) } ?: x } + protected var itemTouchHelper: ItemTouchHelper? = null + + var removable: (T) -> Boolean = { true } + + private var onBackPressedCallback: OnBackPressedCallback? = null + + abstract fun showEntry(x: T): String + + private var mainViewModel: MainViewModel? = null + + fun setViewModel(model: MainViewModel) { + mainViewModel = model + } inner class ViewHolder(entryUi: DynamicListEntryUi) : RecyclerView.ViewHolder(entryUi.root) { val multiselectCheckBox = entryUi.multiselectCheckBox @@ -94,51 +106,54 @@ abstract class DynamicListAdapter( multiselectCheckBox.isChecked = item in selected if (enableAddAndDelete && removable(item)) { + multiselectCheckBox.setOnCheckedChangeListener { _, isChecked -> + select(item, isChecked) + } nameText.setOnLongClickListener { itemTouchHelper?.startDrag(holder) true } nameText.setOnClickListener { - select(item, multiselectCheckBox) + multiselectCheckBox.toggle() } } else { multiselectCheckBox.isEnabled = false + multiselectCheckBox.setOnCheckedChangeListener(null) + nameText.setOnClickListener(null) } } } - private fun select(item: T, checkBox: CheckBox) { + private fun select(item: T, shouldSelect: Boolean) { if (!enableAddAndDelete || !multiselect) return - if (item in selected) { - selected.remove(item) - checkBox.isChecked = false + if (shouldSelect) { + if (selected.indexOf(item) == -1) selected.add(item) } else { - selected.add(item) - checkBox.isChecked = true + selected.remove(item) } } @SuppressLint("NotifyDataSetChanged") - fun enterMultiSelect( - onBackPressedDispatcher: OnBackPressedDispatcher, - mainViewModel: MainViewModel - ) { - if (multiselect) - return - onBackPressedCallback = object : OnBackPressedCallback(true) { - override fun handleOnBackPressed() { - exitMultiSelect(mainViewModel) + @CallSuper + open fun enterMultiSelect(onBackPressedDispatcher: OnBackPressedDispatcher) { + mainViewModel?.let { + if (multiselect) + return + onBackPressedCallback = object : OnBackPressedCallback(true) { + override fun handleOnBackPressed() { + exitMultiSelect() + } } + onBackPressedDispatcher.addCallback(onBackPressedCallback!!) + it.enableToolbarDeleteButton { + deleteSelected() + exitMultiSelect() + } + it.hideToolbarEditButton() + multiselect = true + notifyDataSetChanged() } - onBackPressedDispatcher.addCallback(onBackPressedCallback!!) - mainViewModel.enableToolbarDeleteButton { - deleteSelected() - exitMultiSelect(mainViewModel) - } - mainViewModel.hideToolbarEditButton() - multiselect = true - notifyDataSetChanged() } @@ -156,15 +171,19 @@ abstract class DynamicListAdapter( } @SuppressLint("NotifyDataSetChanged") - fun exitMultiSelect(viewModel: MainViewModel) { - if (!multiselect) - return - onBackPressedCallback?.remove() - viewModel.disableToolbarDeleteButton() - multiselect = false - selected.clear() - notifyDataSetChanged() - viewModel.showToolbarEditButton() + @CallSuper + open fun exitMultiSelect() { + mainViewModel?.let { + if (!multiselect) + return + onBackPressedCallback?.remove() + it.disableToolbarDeleteButton() + multiselect = false + selected.clear() + notifyDataSetChanged() + if (entries.isNotEmpty()) + it.showToolbarEditButton() + } } override fun getItemCount(): Int = entries.size @@ -174,6 +193,7 @@ abstract class DynamicListAdapter( _entries.add(idx, item) notifyItemInserted(idx) listener?.onItemAdded(idx, item) + mainViewModel?.showToolbarEditButton() } @CallSuper @@ -181,6 +201,8 @@ abstract class DynamicListAdapter( val item = _entries.removeAt(idx) notifyItemRemoved(idx) listener?.onItemRemoved(idx, item) + if (entries.isEmpty()) + mainViewModel?.hideToolbarEditButton() return item } 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 613f995d1..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 { @@ -55,27 +76,27 @@ class DynamicListEntryUi(override val ctx: Context) : Ui { add(handleImage, lParams { width = dp(30) - height = 0 + height = matchConstraints centerVertically() startOfParent(paddingStart) }) add(multiselectCheckBox, lParams { width = dp(30) - height = 0 + height = matchConstraints centerVertically() after(handleImage, paddingStart) }) add(checkBox, lParams { width = dp(30) - height = 0 + height = matchConstraints centerVertically() after(multiselectCheckBox, paddingStart) }) add(nameText, lParams { - width = 0 + width = matchConstraints height = wrapContent centerVertically() after(checkBox, paddingStart) @@ -84,15 +105,14 @@ class DynamicListEntryUi(override val ctx: Context) : Ui { add(editButton, lParams { width = dp(53) - height = 0 + height = matchConstraints centerVertically() before(settingsButton) - after(nameText) }) add(settingsButton, lParams { width = dp(53) - height = 0 + height = matchConstraints centerVertically() endOfParent() }) 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 ae9ffdfe5..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,47 +1,45 @@ +/* + * 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.graphics.* +import android.content.Context +import android.graphics.Bitmap +import android.graphics.Canvas +import android.graphics.Rect import android.graphics.drawable.ColorDrawable import androidx.core.graphics.drawable.toBitmap import androidx.recyclerview.widget.ItemTouchHelper import androidx.recyclerview.widget.RecyclerView import org.fcitx.fcitx5.android.R import splitties.dimensions.dp -import splitties.resources.appColor -import splitties.resources.appDrawable -import splitties.resources.appStyledColor +import splitties.resources.color +import splitties.resources.drawable +import splitties.resources.styledColor import kotlin.math.absoluteValue -open class DynamicListTouchCallback(private val adapter: DynamicListAdapter) : - ItemTouchHelper.SimpleCallback( - if (adapter.enableOrder) - ItemTouchHelper.UP or ItemTouchHelper.DOWN - else ItemTouchHelper.ACTION_STATE_IDLE, - if (adapter.enableAddAndDelete) - ItemTouchHelper.LEFT - else ItemTouchHelper.ACTION_STATE_IDLE - ) { +open class DynamicListTouchCallback( + private val ctx: Context, + private val adapter: DynamicListAdapter +) : ItemTouchHelper.SimpleCallback( + /* dragDirs = */ if (adapter.enableOrder) ItemTouchHelper.UP or ItemTouchHelper.DOWN else 0, + /* swipeDirs = */ if (adapter.enableAddAndDelete) ItemTouchHelper.LEFT else 0 +) { private var selected = true private var reset = false - private val deleteBackground by lazy { + private val deleteBackground: ColorDrawable by lazy { ColorDrawable().apply { - color = appColor(R.color.red_400) + color = ctx.color(R.color.red_400) } } - private val deleteIcon by lazy { - appDrawable(R.drawable.ic_baseline_delete_24)!!.toBitmap() - } - - private val deleteIconPaint by lazy { - Paint().apply { - colorFilter = PorterDuffColorFilter( - appStyledColor(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 @@ -91,26 +89,24 @@ open class DynamicListTouchCallback(private val adapter: DynamicListAdapter 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 1727608e6..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,116 +1,53 @@ +/* + * 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.fragment.app.activityViewModels -import androidx.navigation.fragment.findNavController -import androidx.preference.Preference -import androidx.preference.PreferenceCategory +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() { - private val viewModel: MainViewModel by activityViewModels() - - override fun onResume() { - super.onResume() - viewModel.setToolbarTitle(getString(R.string.about)) - viewModel.disableToolbarSaveButton() - } - + @SuppressLint("UseKtx") override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { - val context = preferenceManager.context - val screen = preferenceManager.createPreferenceScreen(context) - - screen.addPreference(Preference(context).apply { - setTitle(R.string.privacy_policy) - isIconSpaceReserved = false - isSingleLineTitle = false - setOnPreferenceClickListener { + preferenceScreen = preferenceManager.createPreferenceScreen(requireContext()).apply { + addPreference(R.string.privacy_policy) { startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(Const.privacyPolicyUrl))) - true } - }) - - screen.addPreference(Preference(context).apply { - setTitle(R.string.open_source_licenses) - setSummary(R.string.licenses_of_third_party_libraries) - isIconSpaceReserved = false - isSingleLineTitle = false - setOnPreferenceClickListener { - findNavController().navigate(R.id.action_aboutFragment_to_licensesFragment) - true + addPreference( + R.string.open_source_licenses, + R.string.licenses_of_third_party_libraries + ) { + navigateWithAnim(SettingsRoute.License) } - }) - - screen.addPreference(Preference(context).apply { - setTitle(R.string.source_code) - setSummary(R.string.github_repo) - isIconSpaceReserved = false - isSingleLineTitle = false - setOnPreferenceClickListener { + addPreference(R.string.source_code, R.string.github_repo) { startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(Const.githubRepo))) - true } - }) - - screen.addPreference(Preference(context).apply { - setTitle(R.string.license) - summary = Const.lgpl - isIconSpaceReserved = false - isSingleLineTitle = false - setOnPreferenceClickListener { - startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(Const.lgplLicenseUrl))) - true + addPreference(R.string.license, Const.licenseSpdxId) { + startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(Const.licenseUrl))) } - }) - - val version = PreferenceCategory(context) - .also { screen.addPreference(it) } - - version.apply { - setTitle(R.string.version) - isIconSpaceReserved = false - isSingleLineTitle = false - } - - val versionName = Preference(context).apply { - setTitle(R.string.current_version) - summary = Const.versionName - isCopyingEnabled = true - isIconSpaceReserved = false - isSingleLineTitle = false - } - version.addPreference(versionName) - - version.addPreference(Preference(context).apply { - setTitle(R.string.build_git_hash) - isIconSpaceReserved = false - isSingleLineTitle = false - summary = Const.buildGitHash - isCopyingEnabled = true - setOnPreferenceClickListener { - startActivity( - Intent( - Intent.ACTION_VIEW, - Uri.parse("${Const.githubRepo}/commit/${Const.buildGitHash.takeWhile { it != '-' }}") - ) - ) - true + addCategory(R.string.version) { + isIconSpaceReserved = false + addPreference(R.string.current_version, Const.versionName) + 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(BuildConfig.BUILD_TIME)) } - }) - version.addPreference(Preference(context).apply { - setTitle(R.string.build_time) - isIconSpaceReserved = false - isSingleLineTitle = false - isCopyingEnabled = true - summary = formatDateTime(Const.buildTime) - }) - - preferenceScreen = screen + } } } 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 4b742b7dd..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,17 +1,27 @@ +/* + * 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 +import android.content.ClipData import android.content.Intent import android.os.Bundle import android.view.Gravity import android.view.inputmethod.InputMethodManager import android.widget.EditText -import kotlinx.coroutines.* +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.MainScope +import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.cancel +import kotlinx.coroutines.launch import org.fcitx.fcitx5.android.data.clipboard.ClipboardManager import org.fcitx.fcitx5.android.data.clipboard.db.ClipboardEntry import org.fcitx.fcitx5.android.databinding.ActivityClipboardEditBinding +import org.fcitx.fcitx5.android.utils.clipboardManager +import org.fcitx.fcitx5.android.utils.inputMethodManager import org.fcitx.fcitx5.android.utils.str -import splitties.systemservices.inputMethodManager class ClipboardEditActivity : Activity() { @@ -28,17 +38,20 @@ class ClipboardEditActivity : Activity() { editText = clipboardEditText clipboardEditCancel.setOnClickListener { finish() } clipboardEditOk.setOnClickListener { finishEditing() } - clipboardEditCopy.setOnClickListener { finishEditing() } + clipboardEditCopy.setOnClickListener { finishEditing(copy = true) } } setContentView(binding.root) inputMethodManager.showSoftInput(editText, InputMethodManager.SHOW_IMPLICIT) processIntent(intent) } - private fun finishEditing() { + private fun finishEditing(copy: Boolean = false) { val str = editText.str scope.launch(NonCancellable) { ClipboardManager.updateText(entryId, str) + if (copy) { + clipboardManager.setPrimaryClip(ClipData.newPlainText("", str)) + } } finish() } @@ -55,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 83e1425a2..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,98 +1,153 @@ +/* + * 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.widget.Toast +import android.os.Debug +import androidx.activity.result.ActivityResultLauncher +import androidx.activity.result.contract.ActivityResultContracts.CreateDocument import androidx.appcompat.app.AlertDialog -import androidx.fragment.app.activityViewModels import androidx.lifecycle.lifecycleScope -import androidx.preference.Preference -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.data.DataManager +import org.fcitx.fcitx5.android.core.data.DataManager +import org.fcitx.fcitx5.android.daemon.FcitxDaemon import org.fcitx.fcitx5.android.data.clipboard.ClipboardManager import org.fcitx.fcitx5.android.data.prefs.AppPrefs import org.fcitx.fcitx5.android.ui.common.PaddingPreferenceFragment +import org.fcitx.fcitx5.android.ui.main.modified.MySwitchPreference import org.fcitx.fcitx5.android.utils.AppUtil +import org.fcitx.fcitx5.android.utils.addPreference +import org.fcitx.fcitx5.android.utils.iso8601UTCDateTime +import org.fcitx.fcitx5.android.utils.startActivity +import org.fcitx.fcitx5.android.utils.toast +import java.io.File class DeveloperFragment : PaddingPreferenceFragment() { - private val viewModel: MainViewModel by activityViewModels() + private lateinit var hprofFile: File + private lateinit var launcher: ActivityResultLauncher - override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { - val context = preferenceManager.context - val screen = preferenceManager.createPreferenceScreen(context) - screen.addPreference(Preference(context).apply { - setTitle(R.string.real_time_logs) - isIconSpaceReserved = false - isSingleLineTitle = false - setOnPreferenceClickListener { - AppUtil.launchLog(context) - true + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + launcher = registerForActivityResult(CreateDocument("application/octet-stream")) { uri -> + if (uri == null) { + hprofFile.delete() + return@registerForActivityResult + } + val ctx = requireContext() + lifecycleScope.launch(NonCancellable + Dispatchers.IO) { + runCatching { + ctx.contentResolver.openOutputStream(uri)!!.use { o -> + hprofFile.inputStream().use { i -> i.copyTo(o) } + } + }.let { ctx.toast(it) } + hprofFile.delete() } - }) - screen.addPreference(SwitchPreference(context).apply { - key = AppPrefs.getInstance().internal.verboseLog.key - setTitle(R.string.verbose_log) - setSummary(R.string.verbose_log_summary) - setDefaultValue(false) - isIconSpaceReserved = false - isSingleLineTitle = false - }) - screen.addPreference(SwitchPreference(context).apply { - key = AppPrefs.getInstance().internal.editorInfoInspector.key - setTitle(R.string.editor_info_inspector) - setDefaultValue(false) - isIconSpaceReserved = false - isSingleLineTitle = false - }) + } + } - screen.addPreference(Preference(context).apply { - setTitle(R.string.delete_and_sync_data) - isIconSpaceReserved = false - isSingleLineTitle = false - setOnPreferenceClickListener { + override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { + preferenceScreen = preferenceManager.createPreferenceScreen(requireContext()).apply { + addPreference(R.string.real_time_logs) { + startActivity() + } + addPreference(MySwitchPreference(context).apply { + key = AppPrefs.getInstance().internal.verboseLog.key + setTitle(R.string.verbose_log) + setSummary(R.string.verbose_log_summary) + 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) } } } - .setNegativeButton(android.R.string.cancel) { _, _ -> } + .setNegativeButton(android.R.string.cancel, null) .show() - true } - }) - screen.addPreference(Preference(context).apply { - setTitle(R.string.clear_clb_db) - isIconSpaceReserved = false - isSingleLineTitle = false - setOnPreferenceClickListener { - lifecycleScope.launch { - ClipboardManager.nukeTable() - Toast.makeText(context, getString(R.string.done), Toast.LENGTH_SHORT).show() - } - true + addPreference(R.string.clear_clb_db) { + 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() } - }) - preferenceScreen = screen + addPreference(R.string.capture_heap_dump) { + val fileName = "${context.packageName}_${iso8601UTCDateTime()}.hprof" + hprofFile = context.cacheDir.resolve(fileName) + System.gc() + Debug.dumpHprofData(hprofFile.absolutePath) + launcher.launch(fileName) + } + } } - - override fun onResume() { - super.onResume() - viewModel.setToolbarTitle(getString(R.string.developer)) - viewModel.disableToolbarSaveButton() - } } \ No newline at end of file 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 45acb8c6e..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,45 +1,71 @@ +/* + * 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 import android.content.Intent import android.net.Uri import android.os.Bundle -import androidx.fragment.app.activityViewModels import androidx.lifecycle.lifecycleScope -import androidx.preference.Preference +import com.mikepenz.aboutlibraries.Libs +import com.mikepenz.aboutlibraries.entity.License import kotlinx.coroutines.launch import org.fcitx.fcitx5.android.R -import org.fcitx.fcitx5.android.data.Licenses import org.fcitx.fcitx5.android.ui.common.PaddingPreferenceFragment +import org.fcitx.fcitx5.android.utils.addPreference class LicensesFragment : PaddingPreferenceFragment() { - private val viewModel: MainViewModel by activityViewModels() - - override fun onResume() { - super.onResume() - viewModel.setToolbarTitle(getString(R.string.open_source_licenses)) - viewModel.disableToolbarSaveButton() - } - override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { lifecycleScope.launch { - Licenses.getAll().onSuccess { licenses -> - val context = preferenceManager.context - val screen = preferenceManager.createPreferenceScreen(context) - licenses.forEach { license -> - screen.addPreference(Preference(context).apply { - isIconSpaceReserved = false - title = license.libraryName - summary = license.artifactId.group - setOnPreferenceClickListener { - startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(license.licenseUrl))) - true + preferenceScreen = preferenceManager.createPreferenceScreen(requireContext()).apply { + val jsonString = resources.openRawResource(R.raw.aboutlibraries) + .bufferedReader() + .use { it.readText() } + Libs.Builder() + .withJson(jsonString) + .build() + .libraries + .sortedBy { + if (it.tag == "native") it.uniqueId.uppercase() else it.uniqueId.lowercase() + } + .forEach { + addPreference( + title = "${it.uniqueId}:${it.artifactVersion}", + summary = it.licenses.joinToString { l -> l.spdxId ?: l.name } + ) { + showLicenseDialog(it.uniqueId, it.licenses) } - }) - } - preferenceScreen = screen + } + } + } + } + + private fun showLicenseDialog(uniqueId: String, licenses: Set): Boolean { + when (licenses.size) { + 0 -> {} + 1 -> showLicenseContent(licenses.first()) + else -> { + val licenseArray = licenses.toTypedArray() + val licenseNames = licenseArray.map { it.spdxId ?: it.name }.toTypedArray() + AlertDialog.Builder(context) + .setTitle(uniqueId) + .setItems(licenseNames) { _, idx -> + showLicenseContent(licenseArray[idx]) + } + .setPositiveButton(android.R.string.cancel, null) + .show() } } + return true + } + + private fun showLicenseContent(license: License) { + if (license.url?.isNotBlank() == true) { + startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(license.url))) + } } } \ No newline at end of file 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 f98110943..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,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 import android.os.Bundle -import android.view.View +import android.view.Menu 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 @@ -11,7 +16,6 @@ import androidx.core.view.ViewCompat import androidx.core.view.WindowInsetsCompat import androidx.core.view.updateLayoutParams import androidx.lifecycle.lifecycleScope -import cat.ereza.customactivityoncrash.CustomActivityOnCrash import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.launch @@ -19,31 +23,40 @@ import org.fcitx.fcitx5.android.FcitxApplication import org.fcitx.fcitx5.android.R import org.fcitx.fcitx5.android.databinding.ActivityLogBinding import org.fcitx.fcitx5.android.ui.main.log.LogView -import org.fcitx.fcitx5.android.utils.* +import org.fcitx.fcitx5.android.utils.DeviceInfo +import org.fcitx.fcitx5.android.utils.Logcat +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() { + private var fromCrash = false + private lateinit var launcher: ActivityResultLauncher private lateinit var logView: LogView 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()) @@ -51,11 +64,9 @@ class LogActivity : AppCompatActivity() { binding.root.updateLayoutParams { leftMargin = navBars.left rightMargin = navBars.right - bottomMargin = navBars.bottom - } - binding.toolbar.updateLayoutParams { - topMargin = statusBars.top } + binding.toolbar.topPadding = statusBars.top + binding.logView.setBottomPadding(navBars.bottom) windowInsets } setContentView(binding.root) @@ -63,31 +74,43 @@ class LogActivity : AppCompatActivity() { with(binding) { setSupportActionBar(toolbar) this@LogActivity.logView = logView - logView.setLogcat( - if (CustomActivityOnCrash.getConfigFromIntent(intent) == null) { - supportActionBar!!.apply { - setDisplayHomeAsUpEnabled(true) - setTitle(R.string.real_time_logs) - } - Logcat() - } else { - supportActionBar!!.setTitle(R.string.crash_logs) - clearButton.visibility = View.GONE - AlertDialog.Builder(this@LogActivity) - .setTitle(R.string.app_crash) - .setMessage(R.string.app_crash_message) - .setPositiveButton(android.R.string.ok) { _, _ -> } - .show() - Logcat(FcitxApplication.getLastPid()) + if (intent.hasExtra(FROM_CRASH)) { + fromCrash = true + supportActionBar!!.setTitle(R.string.crash_logs) + AlertDialog.Builder(this@LogActivity) + .setTitle(R.string.app_crash) + .setMessage(R.string.app_crash_message) + .setPositiveButton(android.R.string.ok, null) + .show() + logView.append("--------- Crash stacktrace") + logView.append(intent.getStringExtra(CRASH_STACK_TRACE) ?: "") + logView.setLogcat(Logcat(FcitxApplication.getLastPid())) + } else { + supportActionBar!!.apply { + setDisplayHomeAsUpEnabled(true) + setTitle(R.string.real_time_logs) } - ) - clearButton.setOnClickListener { - logView.clear() - } - exportButton.setOnClickListener { - launcher.launch("$packageName-${iso8601UTCDateTime()}.txt") + logView.setLogcat(Logcat()) } } registerLauncher() } -} \ No newline at end of file + + override fun onCreateOptionsMenu(menu: Menu): Boolean { + val iconTint = styledColor(android.R.attr.colorControlNormal) + if (!fromCrash) { + menu.item(R.string.clear, R.drawable.ic_baseline_delete_24, iconTint, true) { + logView.clear() + } + } + menu.item(R.string.export, R.drawable.ic_baseline_save_24, iconTint, true) { + launcher.launch("$packageName-${iso8601UTCDateTime()}.txt") + } + return true + } + + companion object { + const val FROM_CRASH = "from_crash" + const val CRASH_STACK_TRACE = "crash_stack_trace" + } +} 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 002c2db31..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,38 +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.animation.ObjectAnimator -import android.animation.StateListAnimator +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 timber.log.Timber +import splitties.resources.styledColor +import splitties.views.topPadding class MainActivity : AppCompatActivity() { @@ -42,32 +48,28 @@ 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()) val navBars = windowInsets.getInsets(WindowInsetsCompat.Type.navigationBars()) - binding.appbar.updateLayoutParams { - leftMargin = navBars.left - rightMargin = navBars.right - } - binding.toolbar.updateLayoutParams { - topMargin = statusBars.top - } - binding.navHostFragment.updateLayoutParams { + binding.root.updateLayoutParams { leftMargin = navBars.left rightMargin = navBars.right } + binding.toolbar.topPadding = statusBars.top 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()) { @@ -80,154 +82,136 @@ class MainActivity : AppCompatActivity() { viewModel.toolbarTitle.observe(this) { binding.toolbar.title = it } - viewModel.appbarShadow.observe(this) { - binding.appbar.stateListAnimator = StateListAnimator().apply { - addState( - intArrayOf(android.R.attr.state_enabled), - ObjectAnimator.ofFloat(binding.appbar, "elevation", dp(if (it) 4f else 0f)) - ) - } + viewModel.toolbarShadow.observe(this) { + binding.toolbar.elevation = dp(if (it) 4f else 0f) } navController.addOnDestinationChangedListener { _, dest, _ -> - when (dest.id) { - R.id.themeListFragment -> viewModel.disableAppbarShadow() - else -> viewModel.enableAppbarShadow() + 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 - } - } - add(R.string.developer).apply { - aboutMenus.add(this) - setOnMenuItemClickListener { - navController.navigate(R.id.action_mainFragment_to_developerFragment) - true - } - } - add(R.string.about).apply { - aboutMenus.add(this) - setOnMenuItemClickListener { - navController.navigate(R.id.action_mainFragment_to_aboutFragment) - true - } - } - viewModel.aboutButton.apply { - observe(this@MainActivity) { enabled -> - aboutMenus.forEach { menu -> menu.isVisible = enabled } + }, + menu.item(R.string.developer) { + navController.navigateWithAnim(SettingsRoute.Developer) + }, + menu.item(R.string.about) { + navController.navigateWithAnim(SettingsRoute.About) } - setValue(value) + ) + viewModel.aboutButton.observe(this@MainActivity) { enabled -> + aboutMenuItems.forEach { menu -> menu.isVisible = enabled } } - - 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.edit, R.drawable.ic_baseline_edit_24, iconTint, true) { + viewModel.toolbarEditButtonOnClickListener.value?.invoke() + }.apply { + viewModel.toolbarEditButtonVisible.observe(this@MainActivity) { isVisible = it } } - - 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 - } + 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 } } - 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 3d77aa5da..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,97 +1,101 @@ +/* + * 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.StringRes import androidx.fragment.app.activityViewModels -import androidx.navigation.fragment.findNavController -import androidx.preference.Preference 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() { private val viewModel: MainViewModel by activityViewModels() - override fun onResume() { - super.onResume() - viewModel.setToolbarTitle(requireContext().getString(R.string.app_name)) - viewModel.disableToolbarSaveButton() + override fun onStart() { + super.onStart() viewModel.enableAboutButton() } - override fun onPause() { + override fun onStop() { viewModel.disableAboutButton() - super.onPause() + super.onStop() } - override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { - val context = preferenceManager.context - val screen = preferenceManager.createPreferenceScreen(context) - val fcitxCategory = PreferenceCategory(context).apply { - title = "Fcitx" + private fun PreferenceCategory.addDestinationPreference( + @StringRes title: Int, + @DrawableRes icon: Int, + route: SettingsRoute + ) { + addPreference(title, icon = icon) { + navigateWithAnim(route) } - screen.addPreference(fcitxCategory) - fcitxCategory.addPreference(Preference(context).apply { - setTitle(R.string.global_options) - setIcon(R.drawable.ic_baseline_tune_24) - setOnPreferenceClickListener { - findNavController().navigate(R.id.action_mainFragment_to_globalConfigFragment) - true - } - }) - fcitxCategory.addPreference(Preference(context).apply { - setTitle(R.string.input_methods) - setIcon(R.drawable.ic_baseline_language_24) - setOnPreferenceClickListener { - findNavController().navigate(R.id.action_mainFragment_to_imListFragment) - true - } - }) - fcitxCategory.addPreference(Preference(context).apply { - setTitle(R.string.addons) - setIcon(R.drawable.ic_baseline_extension_24) - setOnPreferenceClickListener { - findNavController().navigate(R.id.action_mainFragment_to_addonListFragment) - true - } - }) + } - val androidCategory = PreferenceCategory(context).apply { - title = "Android" - } - screen.addPreference(androidCategory) - androidCategory.addPreference(Preference(context).apply { - setTitle(R.string.keyboard) - setIcon(R.drawable.ic_baseline_keyboard_24) - setOnPreferenceClickListener { - findNavController().navigate(R.id.action_mainFragment_to_keyboardSettingsFragment) - true - } - }) - androidCategory.addPreference(Preference(context).apply { - setTitle(R.string.theme) - setIcon(R.drawable.ic_baseline_palette_24) - setOnPreferenceClickListener { - findNavController().navigate(R.id.action_mainFragment_to_themeListFragment) - true - } - }) - androidCategory.addPreference(Preference(context).apply { - setTitle(R.string.clipboard) - setIcon(R.drawable.ic_clipboard) - setOnPreferenceClickListener { - findNavController().navigate(R.id.action_mainFragment_to_clipboardSettingsFragment) - true + override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { + preferenceScreen = preferenceManager.createPreferenceScreen(requireContext()).apply { + addCategory("Fcitx") { + addDestinationPreference( + R.string.global_options, + R.drawable.ic_baseline_tune_24, + SettingsRoute.GlobalConfig + ) + addDestinationPreference( + R.string.input_methods, + R.drawable.ic_baseline_language_24, + SettingsRoute.InputMethodList + ) + addDestinationPreference( + R.string.addons, + R.drawable.ic_baseline_extension_24, + SettingsRoute.AddonList + ) } - }) - androidCategory.addPreference(Preference(context).apply { - setTitle(R.string.advanced) - setIcon(R.drawable.ic_baseline_more_horiz_24) - setOnPreferenceClickListener { - findNavController().navigate(R.id.action_mainFragment_to_advancedSettingsFragment) - true + addCategory("Android") { + addDestinationPreference( + R.string.theme, + R.drawable.ic_baseline_palette_24, + SettingsRoute.Theme + ) + addDestinationPreference( + R.string.virtual_keyboard, + R.drawable.ic_baseline_keyboard_24, + SettingsRoute.VirtualKeyboard + ) + addDestinationPreference( + R.string.candidates_window, + R.drawable.ic_baseline_list_alt_24, + SettingsRoute.CandidatesWindow + ) + addDestinationPreference( + R.string.clipboard, + R.drawable.ic_clipboard, + 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, + SettingsRoute.Plugin + ) + addDestinationPreference( + R.string.advanced, + R.drawable.ic_baseline_more_horiz_24, + SettingsRoute.Advanced + ) } - }) - preferenceScreen = screen + } } } \ No newline at end of file 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 e875b4dd1..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 @@ -11,7 +15,7 @@ class MainViewModel : ViewModel() { val toolbarTitle = MutableLiveData(appContext.getString(R.string.app_name)) - val appbarShadow = MutableLiveData(true) + val toolbarShadow = MutableLiveData(true) val toolbarSaveButtonOnClickListener = MutableLiveData<(() -> Unit)?>() @@ -29,12 +33,12 @@ class MainViewModel : ViewModel() { toolbarTitle.value = title } - fun enableAppbarShadow() { - appbarShadow.value = true + fun enableToolbarShadow() { + toolbarShadow.value = true } - fun disableAppbarShadow() { - appbarShadow.value = false + fun disableToolbarShadow() { + toolbarShadow.value = false } fun enableToolbarSaveButton(onClick: () -> Unit) { @@ -45,14 +49,13 @@ class MainViewModel : ViewModel() { toolbarSaveButtonOnClickListener.value = null } - fun enableToolbarEditButton(onClick: () -> Unit) { + fun enableToolbarEditButton(visible: Boolean = true, onClick: () -> Unit) { toolbarEditButtonOnClickListener.value = onClick - toolbarEditButtonVisible.value = true - showToolbarEditButton() + toolbarEditButtonVisible.value = visible } fun disableToolbarEditButton() { - toolbarEditButtonVisible.value = false + toolbarEditButtonOnClickListener.value = null hideToolbarEditButton() } 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 new file mode 100644 index 000000000..c5a03a16f --- /dev/null +++ b/app/src/main/java/org/fcitx/fcitx5/android/ui/main/PluginFragment.kt @@ -0,0 +1,202 @@ +/* + * 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 +import android.os.Bundle +import android.provider.Settings +import androidx.lifecycle.lifecycleScope +import androidx.preference.PreferenceScreen +import kotlinx.coroutines.launch +import kotlinx.coroutines.suspendCancellableCoroutine +import org.fcitx.fcitx5.android.R +import org.fcitx.fcitx5.android.core.data.DataManager +import org.fcitx.fcitx5.android.core.data.FileSource +import org.fcitx.fcitx5.android.core.data.PluginLoadFailed +import org.fcitx.fcitx5.android.daemon.FcitxDaemon +import org.fcitx.fcitx5.android.ui.common.PaddingPreferenceFragment +import org.fcitx.fcitx5.android.utils.addCategory +import org.fcitx.fcitx5.android.utils.addPreference + +class PluginFragment : PaddingPreferenceFragment() { + + 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 { + if (!synced) { + suspendCancellableCoroutine { + if (synced) { + it.resumeWith(Result.success(Unit)) + } else { + addOnNextSyncedCallback { + it.resumeWith(Result.success(Unit)) + } + } + } + } + block.invoke() + } + } + + override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { + DataManager.whenSynced { + synced = DataManager.getSyncedPluginSet() + detected = DataManager.detectPlugins() + preferenceScreen = createPreferenceScreen() + } + } + + 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 (synced != detected) { + addPreference(R.string.plugin_needs_reload, icon = R.drawable.ic_baseline_info_24) { + DataManager.addOnNextSyncedCallback { + synced = DataManager.getSyncedPluginSet() + detected = DataManager.detectPlugins() + preferenceScreen = createPreferenceScreen() + } + // DataManager.sync and and restart fcitx + FcitxDaemon.restartFcitx() + } + } + val (loaded, failed) = synced + if (loaded.isEmpty() && failed.isEmpty()) { + // use PreferenceCategory to show a divider below the "reload" preference + addCategory(R.string.no_plugins) { + isIconSpaceReserved = false + @SuppressLint("PrivateResource") + // we can't hide PreferenceCategory's title, + // but we can make it looks like a normal preference + layoutResource = androidx.preference.R.layout.preference_material + } + return@apply + } + if (loaded.isNotEmpty()) { + addCategory(R.string.plugins_loaded) { + isIconSpaceReserved = false + loaded.forEach { + addPreference(it.name, "${it.versionName}\n${it.description}") { + startPluginAboutActivity(it.packageName) + } + } + } + } + if (failed.isNotEmpty()) { + addCategory(R.string.plugins_failed) { + isIconSpaceReserved = false + failed.forEach { (packageName, reason) -> + val summary = when (reason) { + is PluginLoadFailed.DataDescriptorParseError -> { + getString(R.string.invalid_data_descriptor) + } + is PluginLoadFailed.MissingDataDescriptor -> { + getString(R.string.missing_data_descriptor) + } + PluginLoadFailed.MissingPluginDescriptor -> { + getString(R.string.missing_plugin_descriptor) + } + is PluginLoadFailed.PathConflict -> { + 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) + } + PluginLoadFailed.PluginDescriptorParseError -> { + getString(R.string.invalid_plugin_descriptor) + } + } + addPreference(packageName, summary) { + startPluginAboutActivity(packageName) + } + } + } + } + } + + private fun startPluginAboutActivity(pkg: String): Boolean { + val ctx = requireContext() + val pm = ctx.packageManager + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + pm.queryIntentActivities( + Intent(DataManager.PLUGIN_INTENT), + PackageManager.ResolveInfoFlags.of(PackageManager.MATCH_ALL.toLong()) + ) + } else { + pm.queryIntentActivities(Intent(DataManager.PLUGIN_INTENT), PackageManager.MATCH_ALL) + }.firstOrNull { + it.activityInfo.packageName == pkg + }?.also { + ctx.startActivity(Intent().apply { + flags = Intent.FLAG_ACTIVITY_NEW_TASK + component = ComponentName(it.activityInfo.packageName, it.activityInfo.name) + }) + } ?: run { + // fallback to settings app info page if activity not found + startActivity(Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply { + flags = Intent.FLAG_ACTIVITY_NEW_TASK + data = Uri.fromParts("package", pkg, null) + }) + } + return true + } + +} 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 c403f5f11..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,24 +1,25 @@ +/* + * 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 import android.os.Build -import android.text.SpannableString import android.view.ViewGroup import android.view.ViewGroup.MarginLayoutParams 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 -class LogAdapter(private val entries: ArrayList = ArrayList()) : +class LogAdapter(private val entries: ArrayList = ArrayList()) : RecyclerView.Adapter() { inner class Holder(val textView: TextView) : RecyclerView.ViewHolder(textView) - fun append(line: SpannableString) { + fun append(line: CharSequence) { val size = entries.size entries.add(line) notifyItemInserted(size) @@ -42,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 06abf4e8f..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,11 +1,14 @@ +/* + * 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 -import android.text.Spannable -import android.text.SpannableString -import android.text.style.ForegroundColorSpan import android.util.AttributeSet import android.widget.HorizontalScrollView +import androidx.core.text.buildSpannedString +import androidx.core.text.color import androidx.lifecycle.findViewTreeLifecycleOwner import androidx.lifecycle.lifecycleScope import kotlinx.coroutines.flow.launchIn @@ -13,6 +16,7 @@ import kotlinx.coroutines.flow.onEach import org.fcitx.fcitx5.android.R import org.fcitx.fcitx5.android.utils.Logcat import splitties.resources.styledColor +import splitties.views.bottomPadding import splitties.views.dsl.core.add import splitties.views.dsl.core.lParams import splitties.views.dsl.core.matchParent @@ -41,6 +45,12 @@ class LogView @JvmOverloads constructor(context: Context, attributeSet: Attribut super.onDetachedFromWindow() } + fun append(content: String) { + logAdapter.append(buildSpannedString { + color(styledColor(android.R.attr.colorForeground)) { append(content) } + }) + } + fun setLogcat(logcat: Logcat) { this.logcat = logcat logcat.initLogFlow() @@ -56,13 +66,8 @@ class LogView @JvmOverloads constructor(context: Context, attributeSet: Attribut else -> android.R.attr.colorForeground } ) - logAdapter.append(SpannableString(it).apply { - setSpan( - ForegroundColorSpan(color), - 0, - it.length, - Spannable.SPAN_EXCLUSIVE_EXCLUSIVE - ) + logAdapter.append(buildSpannedString { + color(color) { append(it) } }) }.launchIn(findViewTreeLifecycleOwner()!!.lifecycleScope) } @@ -73,4 +78,9 @@ class LogView @JvmOverloads constructor(context: Context, attributeSet: Attribut fun clear() { logAdapter.clear() } + + fun setBottomPadding(padding: Int) { + rv.clipToPadding = false + rv.bottomPadding = padding + } } \ No newline at end of file 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 6892d4efe..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 @@ -7,6 +11,7 @@ import androidx.preference.EditTextPreference import androidx.preference.ListPreference import androidx.preference.Preference import androidx.preference.PreferenceDialogFragmentCompat +import androidx.preference.SwitchPreference import splitties.dimensions.dp import splitties.views.dsl.core.verticalMargin @@ -20,11 +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() 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 new file mode 100644 index 000000000..6571a3eff --- /dev/null +++ b/app/src/main/java/org/fcitx/fcitx5/android/ui/main/modified/MySwitchPreference.kt @@ -0,0 +1,26 @@ +/* + * 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 +import androidx.appcompat.app.AlertDialog +import androidx.preference.PreferenceViewHolder +import androidx.preference.SwitchPreference +import org.fcitx.fcitx5.android.R + +class MySwitchPreference(context: Context) : SwitchPreference(context) { + override fun onBindViewHolder(holder: PreferenceViewHolder) { + super.onBindViewHolder(holder) + holder.itemView.setOnLongClickListener { + AlertDialog.Builder(context) + .setTitle(title ?: "Preference") + .setMessage(R.string.whether_reset_switch_preference) + .setNegativeButton(android.R.string.cancel) { _, _ -> } + .setPositiveButton(R.string.reset) { _, _ -> restore() } + .show() + true + } + } +} \ No newline at end of file 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 8e0e67611..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 @@ -5,19 +9,19 @@ import androidx.activity.OnBackPressedCallback import androidx.fragment.app.activityViewModels import androidx.lifecycle.lifecycleScope import androidx.navigation.fragment.findNavController -import androidx.preference.Preference 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.launchOnFcitxReady import org.fcitx.fcitx5.android.ui.common.PaddingPreferenceFragment import org.fcitx.fcitx5.android.ui.common.withLoadingDialog import org.fcitx.fcitx5.android.ui.main.MainViewModel +import org.fcitx.fcitx5.android.utils.addPreference abstract class FcitxPreferenceFragment : PaddingPreferenceFragment() { abstract fun getPageTitle(): String @@ -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 - scope.launchOnFcitxReady(fcitx) { - 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() @@ -73,26 +76,20 @@ abstract class FcitxPreferenceFragment : PaddingPreferenceFragment() { preferenceManager, parentFragmentManager, raw, ::save ).apply { if (isEmpty()) { - addPreference(Preference(context).apply { - setTitle(R.string.no_config_options) - isIconSpaceReserved = false - }) + addPreference(R.string.no_config_options) } } } else { preferenceManager.createPreferenceScreen(context).apply { - addPreference(Preference(context).apply { - setTitle(R.string.config_addon_not_loaded) - isIconSpaceReserved = false - }) + addPreference(R.string.config_addon_not_loaded) } } viewModel.disableAboutButton() } } - override fun onResume() { - super.onResume() + override fun onStart() { + super.onStart() viewModel.setToolbarTitle(getPageTitle()) } 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 7fe8e96a8..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,25 +19,27 @@ 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() - private val ui: BaseDynamicListUi<*> by lazy { + private var uiInitialized = false + private val ui: BaseDynamicListUi<*> by lazy { val ctx = requireContext() - when (descriptor) { - is ConfigDescriptor.ConfigEnumList -> { val d = descriptor as ConfigDescriptor.ConfigEnumList val available = d.entries.toSet() @@ -46,9 +52,8 @@ class ListFragment : Fragment() { show = { d.entriesI18n?.get(d.entries.indexOf(it)) ?: it } ) } - 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 -> { @@ -119,6 +124,9 @@ class ListFragment : Fragment() { } } else -> throw IllegalArgumentException("$descriptor is not a list-like descriptor") + }.also { + it.setViewModel(viewModel) + uiInitialized = true } } @@ -128,22 +136,22 @@ class ListFragment : Fragment() { savedInstanceState: Bundle? ): View = ui.root - override fun onResume() { - super.onResume() + override fun onStart() { + super.onStart() viewModel.setToolbarTitle(descriptor.description ?: descriptor.name) - viewModel.disableToolbarSaveButton() - viewModel.enableToolbarEditButton { - ui.enterMultiSelect( - requireActivity().onBackPressedDispatcher, - viewModel - ) + if (uiInitialized) { + viewModel.enableToolbarEditButton(ui.entries.isNotEmpty()) { + ui.enterMultiSelect(requireActivity().onBackPressedDispatcher) + } } } - override fun onPause() { - ui.exitMultiSelect(viewModel) + override fun onStop() { viewModel.disableToolbarEditButton() - super.onPause() + if (uiInitialized) { + ui.exitMultiSelect() + } + super.onStop() } override fun onDestroy() { @@ -153,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 0ab30c30a..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,79 +19,79 @@ 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.parcelable +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.queryFileName -import splitties.systemservices.notificationManager -import timber.log.Timber -import java.io.File import java.util.concurrent.atomic.AtomicBoolean -import kotlin.system.measureTimeMillis -class PinyinDictionaryFragment : Fragment(), OnItemChangedListener { +class PinyinDictionaryFragment : Fragment(), OnItemChangedListener { - private val viewModel: MainViewModel by activityViewModels() - - private val entries: List - get() = ui.entries + private val args by lazyRoute() - private val contentResolver: ContentResolver - get() = requireContext().contentResolver + private val viewModel: MainViewModel by activityViewModels() private lateinit var launcher: ActivityResultLauncher - private val dustman = NaiveDustman().apply { - onDirty = { - viewModel.enableToolbarSaveButton { reloadDict() } + private val dustman = NaiveDustman() - } - onClean = { - viewModel.disableToolbarSaveButton() - } - } private val busy: AtomicBoolean = AtomicBoolean(false) - private val ui: BaseDynamicListUi by lazy { - object : BaseDynamicListUi( + private var uiInitialized = false + + 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 } } ) { init { + enableUndo = false addTouchCallback() + // since FAB is always shown in this fragment, + // set shouldShowFab to true to hide it when entering multi select mode + shouldShowFab = true fab.setOnClickListener { launcher.launch("*/*") } + setViewModel(viewModel) + removable = { e -> 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 } } @@ -97,8 +101,6 @@ class PinyinDictionaryFragment : Fragment(), OnItemChangedListener(INTENT_DATA_URI) - ?.let { importFromUri(it) } + @SuppressLint("UseKtx") + args.uri?.let { importFromUri(Uri.parse(it)) } super.onViewCreated(view, savedInstanceState) } @@ -118,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 - } - val builder = - 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) - builder.build().let { notificationManager.notify(id, it) } - val inputStream = Option - .catch { contentResolver.openInputStream(uri) } - .mapNotNull { it } - .bind() - runCatching { - val result: LibIMEDictionary - measureTimeMillis { - inputStream.use { i -> - result = PinyinDictManager.importFromInputStream( - i, - file.name - ).getOrThrow() - } - }.also { Timber.d("Took $it to import $result") } - result + 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) } - .onFailure { - importErrorDialog(it.localizedMessage ?: it.stackTraceToString()) - } - .onSuccess { - launch(Dispatchers.Main) { - ui.addItem(item = it) - } - } + } catch (e: Exception) { + ctx.importErrorDialog(e) } - notificationManager.cancel(id) + nm.cancel(id) } - - private suspend fun importErrorDialog(message: String) { - errorDialog(requireContext(), getString(R.string.import_error), message) } private fun reloadDict() { - if (!dustman.dirty) - return + 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 = requireContext().notificationManager lifecycleScope.launch(NonCancellable + Dispatchers.IO) { if (busy.compareAndSet(false, true)) { - val builder = NotificationCompat.Builder(requireContext(), CHANNEL_ID) + val id = RELOAD_ID++ + NotificationCompat.Builder(requireContext(), CHANNEL_ID) .setSmallIcon(R.drawable.ic_baseline_library_books_24) .setContentTitle(getString(R.string.pinyin_dict)) .setContentText(getString(R.string.reloading)) .setOngoing(true) .setProgress(100, 0, true) .setPriority(NotificationCompat.PRIORITY_HIGH) - val id = RELOAD_ID++ - builder.build().let { notificationManager.notify(id, it) } - measureTimeMillis { - viewModel.fcitx.runOnReady { reloadPinyinDict() } - }.let { Timber.d("Took $it to reload dict") } - notificationManager.cancel(id) + .build().let { nm.notify(id, it) } + viewModel.fcitx.runOnReady { + reloadPinyinDict() + } + nm.cancel(id) busy.set(false) } } } private fun resetDustman() { - dustman.reset((entries.associate { it.name to it.isEnabled })) + dustman.reset(ui.entries.mapNotNull { it as? LibIMEDictionary } + .associate { it.name to it.isEnabled }) } - override fun onItemAdded(idx: Int, item: LibIMEDictionary) { + override fun onItemAdded(idx: Int, item: PinyinDictionary) { + item as LibIMEDictionary dustman.addOrUpdate(item.name, item.isEnabled) } - override fun onItemRemoved(idx: Int, item: LibIMEDictionary) { + override fun onItemRemoved(idx: Int, item: PinyinDictionary) { + item as LibIMEDictionary item.file.delete() dustman.remove(item.name) } - override fun onItemRemovedBatch(indexed: List>) { + 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) } - override fun onPause() { + override fun onStart() { + super.onStart() + if (uiInitialized) { + viewModel.enableToolbarEditButton(ui.entries.isNotEmpty()) { + ui.enterMultiSelect(requireActivity().onBackPressedDispatcher) + } + } + } + + override fun onStop() { reloadDict() - super.onPause() + viewModel.disableToolbarEditButton() + if (uiInitialized) { + ui.exitMultiSelect() + } + super.onStop() + } + + override fun onDestroy() { + if (uiInitialized) { + ui.removeItemChangedListener() + } + super.onDestroy() } companion object { private var RELOAD_ID = 0 private var IMPORT_ID = 0 const val CHANNEL_ID = "pinyin_dict" - const val INTENT_DATA_URI = "uri" } } \ No newline at end of file diff --git a/app/src/main/java/org/fcitx/fcitx5/android/ui/main/settings/PreferenceScreenFactory.kt b/app/src/main/java/org/fcitx/fcitx5/android/ui/main/settings/PreferenceScreenFactory.kt index e0c15fbe0..b0097ad8c 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/ui/main/settings/PreferenceScreenFactory.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/ui/main/settings/PreferenceScreenFactory.kt @@ -1,23 +1,47 @@ +/* + * 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.content.Context -import androidx.core.os.bundleOf +import android.os.Build +import androidx.core.content.ContextCompat import androidx.fragment.app.FragmentManager -import androidx.navigation.fragment.findNavController -import androidx.preference.* +import androidx.preference.DialogPreference +import androidx.preference.EditTextPreference +import androidx.preference.ListPreference +import androidx.preference.Preference import androidx.preference.Preference.SummaryProvider -import arrow.core.redeem +import androidx.preference.PreferenceCategory +import androidx.preference.PreferenceDataStore +import androidx.preference.PreferenceManager +import androidx.preference.PreferenceScreen +import arrow.core.getOrElse import org.fcitx.fcitx5.android.R import org.fcitx.fcitx5.android.core.Key import org.fcitx.fcitx5.android.core.RawConfig import org.fcitx.fcitx5.android.data.prefs.AppPrefs -import org.fcitx.fcitx5.android.ui.main.settings.addon.AddonConfigFragment -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.modified.MySwitchPreference +import org.fcitx.fcitx5.android.utils.LongClickPreference +import org.fcitx.fcitx5.android.utils.buildDocumentsProviderIntent +import org.fcitx.fcitx5.android.utils.buildPrimaryStorageIntent import org.fcitx.fcitx5.android.utils.config.ConfigDescriptor -import org.fcitx.fcitx5.android.utils.config.ConfigDescriptor.* +import org.fcitx.fcitx5.android.utils.config.ConfigDescriptor.ConfigBool +import org.fcitx.fcitx5.android.utils.config.ConfigDescriptor.ConfigCustom +import org.fcitx.fcitx5.android.utils.config.ConfigDescriptor.ConfigEnum +import org.fcitx.fcitx5.android.utils.config.ConfigDescriptor.ConfigEnumList +import org.fcitx.fcitx5.android.utils.config.ConfigDescriptor.ConfigExternal +import org.fcitx.fcitx5.android.utils.config.ConfigDescriptor.ConfigInt +import org.fcitx.fcitx5.android.utils.config.ConfigDescriptor.ConfigKey +import org.fcitx.fcitx5.android.utils.config.ConfigDescriptor.ConfigList +import org.fcitx.fcitx5.android.utils.config.ConfigDescriptor.ConfigString import org.fcitx.fcitx5.android.utils.config.ConfigType +import org.fcitx.fcitx5.android.utils.navigateWithAnim import org.fcitx.fcitx5.android.utils.parcelableArray +import org.fcitx.fcitx5.android.utils.toast +import timber.log.Timber object PreferenceScreenFactory { @@ -34,23 +58,19 @@ object PreferenceScreenFactory { val cfg = raw["cfg"] val desc = raw["desc"] val store = FcitxRawConfigStore(cfg) - - ConfigDescriptor - .parseTopLevel(desc) - .redeem({ throw it }) { - screen.title = it.name - it.values.forEach { d -> - 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, @@ -58,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) @@ -68,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() } @@ -154,35 +177,24 @@ 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 } } when (descriptor) { - is ConfigBool -> SwitchPreferenceCompat(context).apply { + is ConfigBool -> MySwitchPreference(context).apply { summary = descriptor.tooltip setDefaultValue(descriptor.defaultValue) } @@ -204,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) } @@ -229,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 { @@ -249,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) @@ -259,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 @@ -272,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 20d6f64e7..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 @@ -22,8 +26,6 @@ abstract class ProgressFragment : Fragment() { abstract suspend fun initialize(): View - open fun beforeCreateView() {} - protected val viewModel: MainViewModel by activityViewModels() protected val fcitx @@ -34,7 +36,6 @@ abstract class ProgressFragment : Fragment() { container: ViewGroup?, savedInstanceState: Bundle? ): View { - beforeCreateView() return requireContext().frameLayout().also { root = it } } 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 1d6c57588..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,41 +1,40 @@ +/* + * 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 androidx.lifecycle.lifecycleScope -import com.google.android.material.textfield.TextInputEditText -import com.google.android.material.textfield.TextInputLayout +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.launchOnFcitxReady +import org.fcitx.fcitx5.android.daemon.launchOnReady import org.fcitx.fcitx5.android.data.punctuation.PunctuationManager 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.* -import splitties.views.dsl.material.addInput +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 class PunctuationEditorFragment : ProgressFragment(), OnItemChangedListener { + private val args by lazyRoute() + private lateinit var lang: String private lateinit var keyDesc: String private lateinit var mappingDesc: String private lateinit var altMappingDesc: String - private val dustman = NaiveDustman().apply { - onDirty = { - viewModel.enableToolbarSaveButton { saveConfig() } - } - onClean = { - viewModel.disableToolbarSaveButton() - } - } - - private val entries - get() = ui.entries + private val dustman = NaiveDustman() private fun findDesc(raw: RawConfig) { // parse config desc to get description text of the options @@ -50,10 +49,9 @@ class PunctuationEditorFragment : ProgressFragment(), OnItemChangedListener private fun resetDustman() { - dustman.reset((entries.associateBy { it.key })) + dustman.reset(ui.entries.associateBy { it.key }) } override suspend fun initialize(): View { - lang = requireArguments().getString(LANG, DEFAULT_LANG) + lang = args.lang ?: DEFAULT_LANG val raw = fcitx.runOnReady { getPunctuationConfig(lang) } findDesc(raw) val initialEntries = PunctuationManager.parseRawConfig(raw) ui = object : BaseDynamicListUi( requireContext(), Mode.FreeAdd(hint = "", converter = { PunctuationMapEntry(it, "", "") }), - initialEntries + initialEntries, + enableOrder = true ) { init { addTouchCallback() addOnItemChangedListener(this@PunctuationEditorFragment) + setViewModel(viewModel) } override fun showEntry(x: PunctuationMapEntry) = x.run { @@ -88,20 +88,14 @@ class PunctuationEditorFragment : ProgressFragment(), OnItemChangedListener Unit ) { - val keyField: TextInputEditText - val keyLayout = view(::TextInputLayout) { + val (keyLayout, keyField) = materialTextInput { hint = keyDesc - keyField = addInput(View.NO_ID) } - val mappingField: TextInputEditText - val mappingLayout = view(::TextInputLayout) { + val (mappingLayout, mappingField) = materialTextInput { hint = mappingDesc - mappingField = addInput(View.NO_ID) } - val altMappingField: TextInputEditText - val altMappingLayout = view(::TextInputLayout) { + val (altMappingLayout, altMappingField) = materialTextInput { hint = altMappingDesc - altMappingField = addInput(View.NO_ID) } entry?.apply { keyField.setText(key) @@ -117,21 +111,35 @@ 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() - viewModel.enableToolbarEditButton { - ui.enterMultiSelect( - requireActivity().onBackPressedDispatcher, - viewModel - ) + viewModel.enableToolbarEditButton(initialEntries.isNotEmpty()) { + ui.enterMultiSelect(requireActivity().onBackPressedDispatcher) } return ui.root } @@ -152,29 +160,37 @@ class PunctuationEditorFragment : ProgressFragment(), OnItemChangedListener { + private val args by lazyRoute() - private lateinit var ui: BaseDynamicListUi + private val quickPhrase: QuickPhrase by lazy { + args.param.quickPhrase + } - private val entries: List - get() = ui.entries + private lateinit var ui: BaseDynamicListUi - private lateinit var quickPhrase: QuickPhrase - - private val dustman = NaiveDustman().apply { - onDirty = { - viewModel.enableToolbarSaveButton { saveConfig() } - } - onClean = { - viewModel.disableToolbarSaveButton() - } - } + 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?, block: (QuickPhraseEntry) -> Unit ) { - val keywordField: TextInputEditText - val keywordLayout = view(::TextInputLayout).apply { + val (keywordLayout, keywordField) = materialTextInput { setHint(R.string.quickphrase_keyword) - keywordField = addInput(View.NO_ID) } - val phraseField: TextInputEditText - val phraseLayout = view(::TextInputLayout).apply { + keywordField.apply { + isSingleLine = true + imeOptions = EditorInfo.IME_ACTION_NEXT + } + val (phraseLayout, phraseField) = materialTextInput { setHint(R.string.quickphrase_phrase) - phraseField = addInput(View.NO_ID) } entry?.apply { keywordField.setText(keyword) @@ -79,25 +82,46 @@ 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) ui.addTouchCallback() resetDustman() - viewModel.enableToolbarEditButton { - ui.enterMultiSelect( - requireActivity().onBackPressedDispatcher, - viewModel - ) + ui.setViewModel(viewModel) + viewModel.enableToolbarEditButton(initialEntries.isNotEmpty()) { + ui.enterMultiSelect(requireActivity().onBackPressedDispatcher) } return ui.root } @@ -122,47 +146,48 @@ class QuickPhraseEditFragment : ProgressFragment(), OnItemChangedListener { private val viewModel: MainViewModel by activityViewModels() - private val entries: List - get() = ui.entries - - private val contentResolver: ContentResolver - get() = requireContext().contentResolver - private lateinit var launcher: ActivityResultLauncher private val busy: AtomicBoolean = AtomicBoolean(false) - private val dustman = NaiveDustman().apply { - onDirty = { - viewModel.enableToolbarSaveButton { reloadQuickPhrase() } - } - onClean = { - viewModel.disableToolbarSaveButton() - } - } + + private val dustman = NaiveDustman() + + private var uiInitialized = false private val ui: BaseDynamicListUi by lazy { object : BaseDynamicListUi( @@ -87,42 +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 { - val actions = - arrayOf(getString(R.string.edit), getString(R.string.reset)) - AlertDialog.Builder(requireContext()) - .setItems(actions) { _, i -> - when (i) { - 0 -> edit() - 1 -> { - entry.deleteOverride() - ui.updateItem(ui.indexItem(entry), entry) - // not sure if the content changes - dustman.forceDirty() - } - } + PopupMenu(requireContext(), this).apply { + 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() } - .show() + show() + } } } else { - imageResource = R.drawable.ic_baseline_edit_24 + icon = R.drawable.ic_baseline_edit_24 setOnClickListener { edit() } @@ -130,59 +120,75 @@ 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 { enableUndo = false + 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()) - .setItems(actions) { _, i -> - when (i) { - 0 -> { - launcher.launch("*/*") - } - 1 -> { - val editText = editText { - setHint(R.string.name) - } - val layout = constraintLayout { - add(editText, lParams { - height = wrapContent - width = matchParent - topOfParent() - bottomOfParent() - leftOfParent(dp(20)) - rightOfParent(dp(20)) - }) - } - AlertDialog.Builder(requireContext()) - .setTitle(R.string.create_new) - .setView(layout) - .setPositiveButton(android.R.string.ok) { _, _ -> - ui.addItem(item = QuickPhraseManager.newEmpty(editText.text.toString())) - } - .setNegativeButton(android.R.string.cancel) { _, _ -> } - .show() - } - else -> {} - } - } - .show() + showImportOrCreateDialog() + } + setViewModel(viewModel) + // Builtin quick phrase shouldn't be removed + // But it can be disabled + removable = { e -> e !is BuiltinQuickPhrase } + 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() { @@ -192,10 +198,7 @@ class QuickPhraseListFragment : Fragment(), OnItemChangedListener { override fun showEntry(x: QuickPhrase): String = x.name }.also { - // Builtin quick phrase shouldn't be removed - // But it can be disabled - it.removable = { e -> e !is BuiltinQuickPhrase } - it.addTouchCallback() + uiInitialized = true } } @@ -206,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) } } @@ -217,108 +220,75 @@ class QuickPhraseListFragment : Fragment(), OnItemChangedListener { } } - private fun importFromUri(uri: Uri) = + private fun importFromUri(uri: Uri) { + 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 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 - } - - val builder = - 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) - builder.build().let { notificationManager.notify(id, it) } - val inputStream = Option.catch { contentResolver.openInputStream(uri) } - .mapNotNull { it } - .bind() - runCatching { - val result: CustomQuickPhrase - measureTimeMillis { - inputStream.use { i -> - result = QuickPhraseManager.importFromInputStream( - i, - file.name - ).getOrThrow() - } - }.also { Timber.d("Took $it to import $result") } - result + 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) } - .onFailure { - errorDialog(it.localizedMessage ?: it.stackTraceToString()) - } - .onSuccess { - launch(Dispatchers.Main) { - ui.addItem(item = it) - } - } + } catch (e: Exception) { + ctx.importErrorDialog(e) } - notificationManager.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() + nm.cancel(id) } } private fun reloadQuickPhrase() { - if (!dustman.dirty) - return + if (!dustman.dirty) return resetDustman() + // save the reference to NotificationManager, in case we need to cancel notification + // after Fragment detached + val nm = requireContext().notificationManager lifecycleScope.launch(NonCancellable + Dispatchers.IO) { if (busy.compareAndSet(false, true)) { - val builder = NotificationCompat.Builder( - requireContext(), - CHANNEL_ID - ) + val id = RELOAD_ID++ + NotificationCompat.Builder(requireContext(), CHANNEL_ID) .setSmallIcon(R.drawable.ic_baseline_library_books_24) .setContentTitle(getString(R.string.quickphrase_editor)) .setContentText(getString(R.string.reloading)) .setOngoing(true) .setProgress(100, 0, true) .setPriority(NotificationCompat.PRIORITY_HIGH) - val id = RELOAD_ID++ - builder.build().let { notificationManager.notify(id, it) } - measureTimeMillis { - viewModel.fcitx.runOnReady { reloadQuickPhrase() } - }.let { Timber.d("Took $it to reload quickphrase") } - notificationManager.cancel(id) + .build().let { nm.notify(id, it) } + viewModel.fcitx.runOnReady { + reloadQuickPhrase() + } + nm.cancel(id) busy.set(false) } } } - private fun resetDustman() { - dustman.reset(entries.associate { - it.name to it.isEnabled - }) + dustman.reset(ui.entries.associate { it.name to it.isEnabled }) } - override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, @@ -348,23 +318,29 @@ class QuickPhraseListFragment : Fragment(), OnItemChangedListener { dustman.addOrUpdate(new.name, new.isEnabled) } - override fun onResume() { - super.onResume() - viewModel.disableToolbarSaveButton() - viewModel.setToolbarTitle(getString(R.string.quickphrase_editor)) - viewModel.enableToolbarEditButton { - ui.enterMultiSelect( - requireActivity().onBackPressedDispatcher, - viewModel - ) + override fun onStart() { + super.onStart() + if (uiInitialized) { + viewModel.enableToolbarEditButton(ui.entries.isNotEmpty()) { + ui.enterMultiSelect(requireActivity().onBackPressedDispatcher) + } } } - override fun onPause() { + override fun onStop() { reloadQuickPhrase() - ui.exitMultiSelect(viewModel) viewModel.disableToolbarEditButton() - super.onPause() + if (uiInitialized) { + ui.exitMultiSelect() + } + super.onStop() + } + + override fun onDestroy() { + if (uiInitialized) { + ui.removeItemChangedListener() + } + super.onDestroy() } companion object { 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 6ca3f81f3..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,53 +15,49 @@ 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 import androidx.lifecycle.lifecycleScope -import arrow.core.getOrElse 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.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.* +import org.fcitx.fcitx5.android.utils.NaiveDustman +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.systemservices.notificationManager import splitties.views.imageDrawable -import java.io.File class TableInputMethodFragment : Fragment(), OnItemChangedListener { private val viewModel: MainViewModel by activityViewModels() - private val entries: List - get() = ui.entries - - private val contentResolver: ContentResolver - get() = requireContext().contentResolver - private lateinit var zipLauncher: ActivityResultLauncher 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().apply { - onDirty = { - viewModel.enableToolbarSaveButton { reloadConfig() } - } - onClean = { - viewModel.disableToolbarSaveButton() - } - } + private val dustman = NaiveDustman() + + private var uiInitialized = false private val ui: BaseDynamicListUi by lazy { object : BaseDynamicListUi( @@ -65,26 +65,30 @@ 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) + } } } } ) { init { addTouchCallback() + shouldShowFab = true fab.setOnClickListener { showImportDialog() } enableUndo = false + setViewModel(viewModel) } override fun updateFAB() { @@ -92,6 +96,8 @@ class TableInputMethodFragment : Fragment(), OnItemChangedListener if (uri != null) prepareDictFromUri(uri) } + replaceLauncher = registerForActivityResult(ActivityResultContracts.GetContent()) { uri -> + if (uri != null) replaceDictFromUri(uri) + } } private fun showImportDialog() { @@ -174,20 +185,20 @@ class TableInputMethodFragment : Fragment(), OnItemChangedListener + 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() { - if (!dustman.dirty) - return + if (!dustman.dirty) return resetDustman() - lifecycleScope.launch { - viewModel.fcitx.runOnReady { reloadConfig() } - } + FcitxDaemon.restartFcitx() } private fun resetDustman() { - dustman.reset((entries.associateBy { it.name })) + dustman.reset(ui.entries.associateBy { it.name }) } override fun onItemAdded(idx: Int, item: TableBasedInputMethod) { @@ -315,9 +395,34 @@ class TableInputMethodFragment : Fragment(), OnItemChangedListener>) { + batchRemove(indexed) + } + + override fun onStart() { + super.onStart() + if (uiInitialized) { + viewModel.enableToolbarEditButton(ui.entries.isNotEmpty()) { + ui.enterMultiSelect(requireActivity().onBackPressedDispatcher) + } + } + } + + override fun onStop() { reloadConfig() - super.onPause() + viewModel.disableToolbarEditButton() + if (uiInitialized) { + ui.exitMultiSelect() + } + super.onStop() + } + + override fun onDestroy() { + if (uiInitialized) { + // prevent dustman calling viewModel after Fragment detached + ui.removeItemChangedListener() + } + super.onDestroy() } companion object { 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 4b88c0ce0..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,38 +1,35 @@ +/* + * 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.lifecycle.lifecycleScope -import androidx.navigation.findNavController import org.fcitx.fcitx5.android.R import org.fcitx.fcitx5.android.core.AddonInfo import org.fcitx.fcitx5.android.core.FcitxAPI -import org.fcitx.fcitx5.android.daemon.launchOnFcitxReady +import org.fcitx.fcitx5.android.daemon.launchOnReady 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 { - private val entries: List - get() = ui.entries - private lateinit var ui: BaseDynamicListUi private val addonDisplayNames = mutableMapOf() private fun updateAddonState() { - if (!isInitialized) - return - with(entries) { - val ids = map { it.uniqueName }.toTypedArray() - val state = map { it.enabled }.toBooleanArray() - lifecycleScope.launchOnFcitxReady(fcitx) { - it.setAddonState(ids, state) - } + if (!isInitialized) return + val ids = ui.entries.map { it.uniqueName }.toTypedArray() + val state = ui.entries.map { it.enabled }.toBooleanArray() + fcitx.launchOnReady { + it.setAddonState(ids, state) } } @@ -78,11 +75,11 @@ class AddonListFragment : ProgressFragment(), OnItemChangedListener { reset() } .setPositiveButton(android.R.string.ok) { _, _ -> - entries.mapIndexedNotNull { idx, x -> - x.takeIf { it.uniqueName in depU }?.uniqueName?.let { idx } - }.forEach { + ui.entries.forEachIndexed { idx, addonInfo -> // TODO: combine update addon states - ui.updateItem(it, entries[it].copy(enabled = false)) + if (addonInfo.uniqueName in depU) { + ui.updateItem(idx, addonInfo.copy(enabled = false)) + } } ui.updateItem(ui.indexItem(entry), entry.copy(enabled = false)) } @@ -90,6 +87,8 @@ class AddonListFragment : ProgressFragment(), OnItemChangedListener { } else { ui.updateItem(ui.indexItem(entry), entry.copy(enabled = false)) } + } else { + ui.updateItem(ui.indexItem(entry), entry.copy(enabled = false)) } } @@ -121,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) ) } }, @@ -136,14 +131,15 @@ class AddonListFragment : ProgressFragment(), OnItemChangedListener { return ui.root } - override fun onResume() { - super.onResume() - viewModel.setToolbarTitle(requireContext().getString(R.string.addons_conf)) - viewModel.disableToolbarSaveButton() - } - override fun onItemUpdated(idx: Int, old: AddonInfo, new: AddonInfo) { updateAddonState() } + override fun onDestroy() { + if (isInitialized) { + ui.removeItemChangedListener() + } + super.onDestroy() + } + } \ No newline at end of file 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 7a6ca3896..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,5 +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: BehaviorSettingsFragment(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/BehaviorSettingsFragment.kt b/app/src/main/java/org/fcitx/fcitx5/android/ui/main/settings/behavior/BehaviorSettingsFragment.kt deleted file mode 100644 index f58e283be..000000000 --- a/app/src/main/java/org/fcitx/fcitx5/android/ui/main/settings/behavior/BehaviorSettingsFragment.kt +++ /dev/null @@ -1,23 +0,0 @@ -package org.fcitx.fcitx5.android.ui.main.settings.behavior - -import android.os.Bundle -import androidx.fragment.app.activityViewModels -import org.fcitx.fcitx5.android.data.prefs.ManagedPreferenceCategory -import org.fcitx.fcitx5.android.data.prefs.ManagedPreferenceFragment -import org.fcitx.fcitx5.android.ui.main.MainViewModel - -abstract class BehaviorSettingsFragment(val category: ManagedPreferenceCategory) : - ManagedPreferenceFragment(category) { - - private val viewModel: MainViewModel by activityViewModels() - - override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { - super.onCreatePreferences(savedInstanceState, rootKey) - viewModel.disableToolbarSaveButton() - } - - override fun onResume() { - super.onResume() - viewModel.setToolbarTitle(requireContext().getString(category.title)) - } -} 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 767c4f6e5..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,5 +1,10 @@ +/* + * 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 +import org.fcitx.fcitx5.android.data.prefs.ManagedPreferenceFragment -class ClipboardSettingsFragment: BehaviorSettingsFragment(AppPrefs.getInstance().clipboard) +class ClipboardSettingsFragment: ManagedPreferenceFragment(AppPrefs.getInstance().clipboard) 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 a85080f75..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,5 +1,10 @@ +/* + * 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 +import org.fcitx.fcitx5.android.data.prefs.ManagedPreferenceFragment -class KeyboardSettingsFragment : BehaviorSettingsFragment(AppPrefs.getInstance().keyboard) +class KeyboardSettingsFragment : ManagedPreferenceFragment(AppPrefs.getInstance().keyboard) 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 8a21cb200..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,27 +1,32 @@ +/* + * 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.lifecycle.lifecycleScope -import androidx.navigation.findNavController -import org.fcitx.fcitx5.android.R import org.fcitx.fcitx5.android.core.InputMethodEntry -import org.fcitx.fcitx5.android.daemon.launchOnFcitxReady +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 { - val entries: List - get() = ui.entries - private fun updateIMState() { - if (isInitialized) - lifecycleScope.launchOnFcitxReady(fcitx) { f -> - f.setEnabledIme(entries.map { it.uniqueName }.toTypedArray()) + if (isInitialized) { + fcitx.launchOnReady { f -> + f.setEnabledIme(ui.entries.map { it.uniqueName }.toTypedArray()) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + SubtypeManager.syncWith(f.enabledIme()) + } } + } } private lateinit var ui: BaseDynamicListUi @@ -37,39 +42,43 @@ 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), ) } }, show = { it.displayName } ) ui.addOnItemChangedListener(this@InputMethodListFragment) - // English keyboard shouldn't be removed - ui.removable = { it.uniqueName != "keyboard-us" } + ui.setViewModel(viewModel) + viewModel.enableToolbarEditButton(initialEnabled.isNotEmpty()) { + ui.enterMultiSelect(requireActivity().onBackPressedDispatcher) + } return ui.root } - override fun onResume() { - super.onResume() - viewModel.disableToolbarSaveButton() - viewModel.setToolbarTitle(requireContext().getString(R.string.input_methods_conf)) - viewModel.enableToolbarEditButton { - ui.enterMultiSelect( - requireActivity().onBackPressedDispatcher, - viewModel - ) + override fun onStart() { + super.onStart() + if (::ui.isInitialized) { + viewModel.enableToolbarEditButton(ui.entries.isNotEmpty()) { + ui.enterMultiSelect(requireActivity().onBackPressedDispatcher) + } } } - override fun onPause() { - ui.exitMultiSelect(viewModel) + override fun onStop() { + if (::ui.isInitialized) { + ui.exitMultiSelect() + } viewModel.disableToolbarEditButton() - super.onPause() + super.onStop() + } + + override fun onDestroy() { + if (::ui.isInitialized) { + ui.removeItemChangedListener() + } + super.onDestroy() } override fun onItemSwapped(fromIdx: Int, toIdx: Int, item: InputMethodEntry) { 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 87b559843..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,52 +1,83 @@ +/* + * 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.Rect import android.graphics.drawable.BitmapDrawable +import android.net.Uri import android.os.Bundle import android.os.Parcelable +import android.view.Menu import android.view.MenuItem import android.view.View -import android.view.ViewOutlineProvider +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.CropImageView -import com.canhub.cropper.options 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.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.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.constraintlayout.* -import splitties.views.dsl.core.* -import splitties.views.dsl.core.styles.AndroidStyles +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() { @@ -69,7 +100,14 @@ class CustomThemeActivity : AppCompatActivity() { } override fun parseResult(resultCode: Int, intent: Intent?): BackgroundResult? = - intent?.extras?.parcelable(RESULT) + intent?.parcelable(RESULT) + } + + private val toolbar by lazy { + view(::Toolbar) { + backgroundColor = styledColor(android.R.attr.colorPrimary) + elevation = dp(4f) + } } private lateinit var previewUi: KeyboardPreviewUi @@ -79,10 +117,10 @@ class CustomThemeActivity : AppCompatActivity() { setText(string) } gravity = gravityVerticalCenter - textAppearance = resolveThemeAttribute(androidx.appcompat.R.attr.textAppearanceListItem) + textAppearance = resolveThemeAttribute(android.R.attr.textAppearanceListItem) horizontalPadding = dp(16) if (ripple) { - background = styledDrawable(androidx.appcompat.R.attr.selectableItemBackground) + background = styledDrawable(android.R.attr.selectableItemBackground) } } @@ -90,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 { @@ -109,48 +150,6 @@ class CustomThemeActivity : AppCompatActivity() { createTextView(R.string.recrop_image, ripple = true) } - private val androidStyles by lazy { - AndroidStyles(this) - } - private val cancelButton by lazy { - androidStyles.button.borderless { - setText(android.R.string.cancel) - } - } - private val finishButton by lazy { - androidStyles.button.borderless { - setText(android.R.string.ok) - } - } - private val deleteButton by lazy { - androidStyles.button.borderless { - visibility = View.GONE - setText(R.string.delete) - setTextColor(color(R.color.red_400)) - } - } - private val buttonsBar by lazy { - constraintLayout { - backgroundColor = styledColor(android.R.attr.colorBackground) - outlineProvider = ViewOutlineProvider.BOUNDS - elevation = dp(8f) - add(cancelButton, lParams(wrapContent, wrapContent) { - topOfParent() - startOfParent() - bottomOfParent() - }) - add(deleteButton, lParams(wrapContent, wrapContent) { - after(cancelButton, dp(8)) - bottomOfParent() - }) - add(finishButton, lParams(wrapContent, wrapContent) { - topOfParent() - endOfParent() - bottomOfParent() - }) - } - } - private val scrollView by lazy { val lineHeight = dp(48) val itemMargin = dp(30) @@ -199,12 +198,12 @@ class CustomThemeActivity : AppCompatActivity() { private val ui by lazy { constraintLayout { - add(scrollView, lParams { + add(toolbar, lParams(matchParent, wrapContent) { topOfParent() centerHorizontally() - above(buttonsBar) }) - add(buttonsBar, lParams(matchConstraints, wrapContent) { + add(scrollView, lParams { + below(toolbar) centerHorizontally() bottomOfParent() }) @@ -216,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 @@ -240,39 +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) - cancelButton.setOnClickListener { - cancel() - } - finishButton.setOnClickListener { - done() - } // 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) } @@ -280,25 +268,74 @@ 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 { + if (theme.backgroundImage == null) { + brightnessLabel.visibility = View.GONE + cropLabel.visibility = View.GONE + variantLabel.visibility = View.GONE + variantSwitch.visibility = View.GONE + brightnessSeekBar.visibility = View.GONE + } + enableEdgeToEdge() + ViewCompat.setOnApplyWindowInsetsListener(ui) { _, windowInsets -> + val statusBars = windowInsets.getInsets(WindowInsetsCompat.Type.statusBars()) + val navBars = windowInsets.getInsets(WindowInsetsCompat.Type.navigationBars()) + ui.updateLayoutParams { + leftMargin = navBars.left + rightMargin = navBars.right + } + toolbar.topPadding = statusBars.top + scrollView.bottomPadding = navBars.bottom + windowInsets + } + // show Activity label on toolbar + setSupportActionBar(toolbar) + // show back button + supportActionBar!!.setDisplayHomeAsUpEnabled(true) + setContentView(ui) + whenHasBackground { background -> + brightnessSeekBar.progress = background.brightness + variantSwitch.isChecked = !theme.isDark + 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() + } + } + } 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(it, darkKeys = isChecked) + setKeyVariant(background, darkKeys = isChecked) } brightnessSeekBar.setOnSeekBarChangeListener(object : SeekBar.OnSeekBarChangeListener { @@ -310,43 +347,6 @@ class CustomThemeActivity : AppCompatActivity() { } }) } - if (theme.backgroundImage == null) { - brightnessLabel.visibility = View.GONE - cropLabel.visibility = View.GONE - variantLabel.visibility = View.GONE - variantSwitch.visibility = View.GONE - brightnessSeekBar.visibility = View.GONE - } - setContentView(ui) - 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() } - } - cropRect = it.cropRect!! - croppedBitmap = Bitmap.createScaledBitmap( - it.getBitmap(this@CustomThemeActivity)!!, - previewUi.intrinsicWidth, - previewUi.intrinsicHeight, - true - ) - filteredDrawable = BitmapDrawable(resources, croppedBitmap) - updateState() - } - } - } if (newCreated) { cropLabel.visibility = View.GONE @@ -356,10 +356,6 @@ class CustomThemeActivity : AppCompatActivity() { } } } else { - deleteButton.apply { - visibility = View.VISIBLE - setOnClickListener { delete() } - } whenHasBackground { updateState() } @@ -371,37 +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) + if (newCreated) { + launcher.launch(CropOption.New(w, h)) + } else { + launcher.launch( + CropOption.Edit( + width = w, + height = h, + Uri.fromFile(srcImageFile), + initialRect = cropRect, + initialRotation = cropRotation + ) + ) } - launcher.launch(options(srcImageFile.takeIf { it.exists() }?.toUri()) { - setInitialCropWindowRectangle(cropRect) - setGuidelines(CropImageView.Guidelines.ON_TOUCH) - setBorderLineColor(Color.WHITE) - setBorderLineThickness(dp(1f)) - setBorderCornerColor(Color.WHITE) - setBorderCornerOffset(0f) - setImageSource(includeGallery = true, includeCamera = false) - setAspectRatio(w, h) - setOutputUri(tempImageFile!!.toUri()) - setOutputCompressFormat(Bitmap.CompressFormat.PNG) - }) } @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() @@ -411,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) @@ -430,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 ) ) } @@ -455,7 +446,7 @@ class CustomThemeActivity : AppCompatActivity() { private fun delete() { setResult( - Activity.RESULT_OK, + RESULT_OK, Intent().apply { putExtra(RESULT, BackgroundResult.Deleted(theme.name)) } @@ -463,6 +454,31 @@ class CustomThemeActivity : AppCompatActivity() { finish() } + private fun promptDelete() { + AlertDialog.Builder(this) + .setTitle(R.string.delete_theme) + .setMessage(getString(R.string.delete_theme_msg, theme.name)) + .setPositiveButton(android.R.string.ok) { _, _ -> + delete() + } + .setNegativeButton(android.R.string.cancel, null) + .show() + } + + override fun onCreateOptionsMenu(menu: Menu): Boolean { + if (!newCreated) { + val iconTint = color(R.color.red_400) + menu.item(R.string.save, R.drawable.ic_baseline_delete_24, iconTint, true) { + promptDelete() + } + } + val iconTint = styledColor(android.R.attr.colorControlNormal) + menu.item(R.string.save, R.drawable.ic_baseline_check_24, iconTint, true) { + done() + } + return true + } + override fun onOptionsItemSelected(item: MenuItem) = when (item.itemId) { android.R.id.home -> { cancel() @@ -475,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 6da7fede7..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 } @@ -71,15 +93,34 @@ class KeyboardPreviewUi(override val ctx: Context, val theme: Theme) : Ui { private val fakeInputView = constraintLayout { add(bkg, lParams { - centerVertically() - centerHorizontally() + centerInParent() }) add(fakeKawaiiBar, lParams(height = dp(40)) { centerHorizontally() }) } - override val root: FrameLayout + override val root = object : FrameLayout(ctx) { + init { + add(fakeInputView, lParams()) + } + + override fun onAttachedToWindow() { + 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 @@ -95,34 +136,11 @@ 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 keyboardHeight = h setTheme(theme) - root = object : FrameLayout(ctx) { - override fun onAttachedToWindow() { - super.onAttachedToWindow() - recalculateSize() - onSizeMeasured?.invoke(intrinsicWidth, intrinsicHeight) - } - - override fun onConfigurationChanged(newConfig: Configuration?) { - recalculateSize() - } - }.apply { - add(fakeInputView, lParams()) - } } fun recalculateSize() { @@ -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() } } } @@ -167,7 +185,9 @@ class KeyboardPreviewUi(override val ctx: Context, val theme: Theme) : Ui { fakeInputView.removeView(fakeKeyboardWindow) } fakeKawaiiBar.backgroundColor = if (keyBorder) Color.TRANSPARENT else theme.barColor - fakeKeyboardWindow = TextKeyboard(ctx, theme) + fakeKeyboardWindow = TextKeyboard(ctx, theme).also { + it.onAttach() + } fakeInputView.apply { add(fakeKeyboardWindow, lParams(matchConstraints, keyboardHeight) { below(fakeKawaiiBar) 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 964e93813..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,12 +33,13 @@ 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 { - foreground = styledDrawable(androidx.appcompat.R.attr.selectableItemBackground) + foreground = styledDrawable(android.R.attr.selectableItemBackground) background = ctx.drawable(R.drawable.bkg_theme_choose_image) outlineProvider = ViewOutlineProvider.BOUNDS elevation = dp(2f) 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 importLauncher: ActivityResultLauncher private lateinit var exportLauncher: ActivityResultLauncher - private lateinit var previewUi: KeyboardPreviewUi - - private lateinit var adapter: ThemeListAdapter + private lateinit var themeListAdapter: ThemeListAdapter - private lateinit var themeList: RecyclerView + private var followSystemDayNightTheme by ThemeManager.prefs.followSystemDayNightTheme private var beingExported: Theme.Custom? = null - private val onThemeChangedListener = ThemeManager.OnThemeChangedListener { + @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()) + } } } } @@ -152,91 +136,42 @@ 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(androidx.appcompat.R.attr.textAppearanceListItem) - gravity = gravityVerticalCenter - } - val settingsButton = imageButton { - imageDrawable = drawable(R.drawable.ic_baseline_settings_24) - background = styledDrawable(androidx.appcompat.R.attr.actionBarItemBackground) - setOnClickListener { - findNavController().navigate(R.id.action_themeListFragment_to_themeSettingsFragment) - } + ): 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 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(androidx.appcompat.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(onThemeChangedListener) - - constraintLayout { - add(previewWrapper, lParams(height = wrapContent) { - topOfParent() - startOfParent() - endOfParent() - }) - add(themeList, lParams { - below(previewWrapper) - startOfParent() - endOfParent() - bottomOfParent() - }) - } } - override fun onResume() { - super.onResume() - viewModel.setToolbarTitle(requireContext().getString(R.string.theme)) + 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() { - val actions = - arrayOf( - getString(R.string.choose_image), - getString(R.string.import_from_file), - getString(R.string.duplicate_builtin_theme) - ) - AlertDialog.Builder(requireContext()) + val ctx = requireContext() + val actions = arrayOf( + getString(R.string.choose_image), + getString(R.string.import_from_file), + getString(R.string.duplicate_builtin_theme) + ) + AlertDialog.Builder(ctx) .setTitle(R.string.new_theme) .setNegativeButton(android.R.string.cancel, null) .setItems(actions) { _, i -> @@ -244,21 +179,21 @@ class ThemeListFragment : Fragment() { 0 -> imageLauncher.launch(null) 1 -> importLauncher.launch("application/zip") 2 -> { - val view = ResponsiveThemeListView(requireContext()).apply { + val view = ResponsiveThemeListView(ctx).apply { // force AlertDialog's customPanel to grow minimumHeight = Int.MAX_VALUE } - val dialog = AlertDialog.Builder(requireContext()) - .setTitle(R.string.duplicate_builtin_theme) + val dialog = AlertDialog.Builder(ctx) + .setTitle(getString(R.string.duplicate_builtin_theme).removeSuffix("…")) .setNegativeButton(android.R.string.cancel, null) .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() } @@ -271,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) { @@ -284,7 +236,7 @@ class ThemeListFragment : Fragment() { } override fun onDestroy() { - ThemeManager.removeOnChangedListener(onThemeChangedListener) + ThemeManager.removeOnChangedListener(onThemeChangeListener) super.onDestroy() } } 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 4a85db79c..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,13 +21,26 @@ 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 - outRect.set( - (n + 1) * offset + n * (itemWidth - columnWidth), - if (position < spanCount) offset else halfOffset, - 0, // (n + 1) * (columnWidth - itemWidth - offset) - if (position / spanCount == rowCount - 1) offset else halfOffset - ) + + when (parent.layoutDirection) { + View.LAYOUT_DIRECTION_LTR -> { + outRect.set( + (n + 1) * offset + n * (itemWidth - columnWidth), + if (position < spanCount) offset else halfOffset, + 0, // (n + 1) * (columnWidth - itemWidth - offset) + if (position / spanCount == rowCount - 1) offset else halfOffset + ) + } + View.LAYOUT_DIRECTION_RTL -> { + outRect.set( + 0, + if (position < spanCount) offset else halfOffset, + (n + 1) * offset + n * (itemWidth - columnWidth), + if (position / spanCount == rowCount - 1) offset else halfOffset + ) + } + } } } 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 d3f80080c..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,22 +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.fragment.app.activityViewModels -import org.fcitx.fcitx5.android.R +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 -import org.fcitx.fcitx5.android.ui.main.MainViewModel class ThemeSettingsFragment : ManagedPreferenceFragment(ThemeManager.prefs) { - private val viewModel: MainViewModel by activityViewModels() + + 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) - viewModel.disableToolbarSaveButton() + switchPreference = findPreference(followSystemDayNightTheme.key)!! } override fun onResume() { super.onResume() - viewModel.setToolbarTitle(requireContext().getString(R.string.theme)) + resumed = true + } + + override fun onPause() { + super.onPause() + resumed = false + } + + override fun onDestroy() { + followSystemDayNightTheme.unregisterOnChangeListener(listener) + super.onDestroy() } -} \ No newline at end of file +} 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 29fbe2793..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 { @@ -48,13 +73,12 @@ class ThemeThumbnailUi(override val ctx: Context) : Ui { add(bkg, lParams(matchParent, matchParent)) add(bar, lParams(matchParent, dp(14))) add(spaceBar, lParams(height = dp(10)) { - startOfParent() - endOfParent() + centerHorizontally() bottomOfParent(dp(6)) matchConstraintPercentWidth = 0.5f }) add(returnKey, lParams(dp(14), dp(14)) { - endOfParent(dp(4)) + rightOfParent(dp(4)) bottomOfParent(dp(4)) }) add(checkMark, lParams(dp(60), dp(60)) { @@ -64,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) } @@ -80,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 01f58db76..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,9 +26,7 @@ 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 splitties.systemservices.notificationManager +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 a60c5c398..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 @@ -19,17 +23,18 @@ class SetupFragment : Fragment() { private val page: SetupPage by lazy { requireArguments().serializable(PAGE)!! } private var isDone: Boolean = false - set(new) { - if (new && page.isLastPage()) + set(value) { + if (value && page.isLastPage()) { viewModel.isAllDone.value = true + } with(binding) { hintText.text = page.getHintText(requireContext()) - actionButton.visibility = if (new) View.GONE else View.VISIBLE + actionButton.visibility = if (value) View.GONE else View.VISIBLE actionButton.text = page.getButtonText(requireContext()) actionButton.setOnClickListener { page.getButtonAction(requireContext()) } - doneText.visibility = if (new) View.VISIBLE else View.GONE + doneText.visibility = if (value) View.VISIBLE else View.GONE } - field = new + field = value } override fun onCreateView( 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 c26de791c..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 @@ -24,7 +28,7 @@ enum class SetupPage { fun getButtonAction(context: Context) = when (this) { Enable -> InputMethodUtil.startSettingsActivity(context) - Select -> InputMethodUtil.showSelector(context) + Select -> InputMethodUtil.showPicker() } fun isDone() = when (this) { @@ -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 d9e14c564..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,15 +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 lgpl = "LGPL-2.1-or-later" - const val lgplLicenseUrl = "https://www.gnu.org/licenses/old-licenses/lgpl-2.1" + 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/" } \ 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 59334aafb..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,97 +1,149 @@ +/* + * 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 import timber.log.Timber -class EventStateMachine( - initialState: State, - private val stateGraph: ImmutableGraph> +class EventStateMachine, B : EventStateMachine.BooleanStateKey>( + private val initialState: State, + private val externalBooleanStates: MutableMap ) { - private var currentStateIx = stateGraph.vertices.indexOf(initialState) + interface BooleanStateKey { + val name: String + } + + interface TransitionEvent { + /** + * INVARIANT: No side effects + * @return the next state + */ + fun accept(initialState: State, currentState: State, useBoolean: (B) -> Boolean?): State + + } var onNewStateListener: ((State) -> Unit)? = null - val currentState - get() = stateGraph.vertices[currentStateIx] + var currentState = initialState + private set private val enableDebugLog: Boolean by AppPrefs.getInstance().internal.verboseLog - private val knownEvents by lazy { stateGraph.labels.flatten() } - /** * Push an event that may trigger a transition of state */ fun push(event: Event) { - if (event !in knownEvents) - throw IllegalArgumentException("$event is an unknown event") - val transitions = stateGraph.getEdgesOfVertexWithIndex(currentState) - val filtered = transitions.filter { event in it.second.label } - when (filtered.size) { - 0 -> { - // do nothing - if (enableDebugLog) - Timber.d("At $currentState ignored $event. All transitions ${transitions.map { it.second.label.joinToString() }} did not match") - } - 1 -> { - if (enableDebugLog) - Timber.d("At $currentState transited to ${filtered.first().second.vertex2}. Transition $event was matched") - currentStateIx = filtered.first().first.first - onNewStateListener?.invoke(filtered.first().second.vertex2) - } - else -> throw IllegalStateException("More than one transitions are found given $event on $currentState") + val newState = event.accept(initialState, currentState) { externalBooleanStates[it] } + if (newState == currentState) { + if (enableDebugLog) + Timber.d("At $currentState, $event didn't change the state") + return } + if (enableDebugLog) + Timber.d("At $currentState transited to $newState by $event") + currentState = newState + onNewStateListener?.invoke(newState) } -} + /** + * Update boolean states and push an event + */ + fun push(event: Event, vararg booleanStates: Pair) { + booleanStates.forEach { + setBooleanState(it.first, it.second) + } + push(event) + } + fun setBooleanState(key: B, value: Boolean) { + externalBooleanStates[key] = value + } -// DSL + fun getBooleanState(key: B) = + externalBooleanStates[key] -fun eventStateMachine( - initialState: State, - builder: EventStateMachineBuilder.() -> Unit -) = - EventStateMachineBuilder(initialState).apply(builder).build() + fun unsafeJump(state: State) { + currentState = state + onNewStateListener?.invoke(state) + } +} -class EventStateMachineBuilder( - private val initialState: State -) { - private val map = mutableMapOf, MutableList>() +// DSL +class TransitionEventBuilder { + + private val enableDebugLog: Boolean by AppPrefs.getInstance().internal.verboseLog - private var listener: ((State) -> Unit)? = null + private var raw: ((State, State, (B) -> Boolean?) -> State)? = null - inner class EventTransitionBuilder(val startState: State) { - lateinit var endState: State - lateinit var event: Event + inner class Builder(val source: State) { + lateinit var target: State + var pred: ((B) -> Boolean?) -> Boolean = { _ -> true } + } + infix fun Builder.transitTo(state: State) = apply { + target = state } - infix fun EventTransitionBuilder.transitTo(state: State) = apply { - endState = state + infix fun Builder.on(expected: Pair) = apply { + this.pred = { it(expected.first) == expected.second } } - infix fun EventTransitionBuilder.on(event: Event) = run { - this.event = event - if (startState to endState !in map) - map[startState to endState] = mutableListOf(event) - else - map.getValue(startState to endState) += event + infix fun Builder.onF(pred: ((B) -> Boolean?) -> Boolean) = apply { + this.pred = pred } - fun from(state: State) = EventTransitionBuilder(state) + val builders = mutableListOf() + + fun from(state: State) = Builder(state).also { builders += it } - fun onNewState(block: (State) -> Unit) { - listener = block + /** + * Use either [from] or [accept] to build the transition event + */ + fun accept(block: ((State, State, (B) -> Boolean?) -> State)) = apply { + raw = block } - fun build() = EventStateMachine( - initialState, - ImmutableGraph(map.map { (k, v) -> - ImmutableGraph.Edge(k.first, k.second, v) - }) - ).apply { - listener?.let { onNewStateListener = it } + fun build() = + object : EventStateMachine.TransitionEvent { + override fun accept( + initialState: State, + currentState: State, + useBoolean: (B) -> Boolean? + ): State { + if (raw != null) + return raw!!(initialState, currentState, useBoolean) + val filtered = builders.filter { it.source == currentState && it.pred(useBoolean) } + return when (filtered.size) { + 0 -> currentState + 1 -> filtered[0].target + else -> { + val first = filtered[0].target + if (enableDebugLog) + Timber.d("More than one target states at $currentState: ${filtered.joinToString()}. Take the first one: $first") + first + } + } + } + } +} + +typealias TransitionBuildBlock = TransitionEventBuilder.() -> Unit + +class BuildTransitionEvent(block: TransitionBuildBlock) : + EventStateMachine.TransitionEvent { + private val delegate: EventStateMachine.TransitionEvent by lazy { + TransitionEventBuilder().also(block).build() } + + override fun accept( + initialState: State, + currentState: State, + useBoolean: (B) -> Boolean? + ): State = + delegate.accept(initialState, currentState, useBoolean) + } 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/FcitxIni.kt b/app/src/main/java/org/fcitx/fcitx5/android/utils/FcitxIni.kt deleted file mode 100644 index 05e665067..000000000 --- a/app/src/main/java/org/fcitx/fcitx5/android/utils/FcitxIni.kt +++ /dev/null @@ -1,35 +0,0 @@ -package org.fcitx.fcitx5.android.utils - -import org.ini4j.Config -import org.ini4j.Ini -import java.io.File -import java.io.InputStream -import java.io.Reader -import java.net.URL - -class FcitxIni() : Ini() { - init { - val config = Config.getGlobal().clone() - config.isStrictOperator = true - config.isEscape = false - config.isEscapeNewline = false - setConfig(config) - } - - constructor(file: File) : this() { - setFile(file) - load() - } - - constructor(input: URL) : this() { - load(input) - } - - constructor(input: InputStream) : this() { - load(input) - } - - constructor(input: Reader) : this() { - load(input) - } -} \ No newline at end of file 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 512578323..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,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.utils -import java.util.* +import java.util.LinkedList +import java.util.Queue class ImmutableGraph( edges: List> @@ -24,32 +29,30 @@ class ImmutableGraph( } } - fun getEdgesOfVertexWithIndex(vertex: V) = adjacencyMatrix[vertices.indexOf(vertex)] - .asIterable() - .mapIndexedNotNull { v2Idx, labelIdx -> - labelIdx.takeIf { it != -1 }?.run { - (v2Idx to labelIdx) to Edge(vertex, vertices[v2Idx], labels[labelIdx]) - } - } - - fun bfs(vertex: V): List> { + /** + * @param predicate: whether to continue searching after this node + */ + fun bfs(vertex: V, predicate: (Int, V, L) -> Boolean = { _, _, _ -> true }): List> { val start = vertices.indexOf(vertex).takeIf { it != -1 } ?: return emptyList() val visited = BooleanArray(vertices.size) - val queue: Queue> = LinkedList() + val queue: Queue> = LinkedList() val result = mutableListOf>() + var level = 0 visited[start] = true - queue.add(start to -1) + queue.add(Triple(start, -1, true)) while (queue.isNotEmpty()) { - val (x, v) = queue.remove() + val (x, v, cont) = queue.remove() if (start != x) result.add(x to v) - visited.indices.forEach { i -> - val l = adjacencyMatrix[x][i].takeIf { it != -1 } - if (l != null && !visited[i]) { - queue.add(i to l) - visited[i] = true + if (cont) + visited.indices.forEach { i -> + val l = adjacencyMatrix[x][i].takeIf { it != -1 } + if (l != null && !visited[i]) { + queue.add(Triple(i, l, predicate(level, vertices[i], labels[l]))) + visited[i] = true + } } - } + level++ } return result.map { (v, l) -> vertices[v] to labels[l] } } 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 new file mode 100644 index 000000000..0c283ee31 --- /dev/null +++ b/app/src/main/java/org/fcitx/fcitx5/android/utils/Ini.kt @@ -0,0 +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 org.fcitx.fcitx5.android.core.RawConfig +import java.io.File + +@JvmInline +value class Ini(val core: RawConfig) { + + val value: String + get() = core.value + + fun get(vararg keys: String): Ini? { + if (keys.isEmpty()) return null + var current = core + keys.forEach { + current = current.findByName(it) ?: return null + } + return Ini(current) + } + + fun set(vararg keys: String, raw: RawConfig) { + var current = core + keys.forEach { + current = current.getOrCreate(it) + } + current.getOrCreate(raw.name).apply { + // RawConfig's comment is immutable; is fine. + value = raw.value + subItems = raw.subItems + } + } + + fun set(vararg keys: String, str: String) { + if (keys.isEmpty()) return + var current = core + keys.forEach { + current = current.getOrCreate(it) + } + current.value = str + } + + companion object { + @JvmStatic + private external fun readFromIni(src: String): RawConfig? + + @JvmStatic + private external fun writeAsIni(dest: String, value: RawConfig) + + fun parseIniFromFile(file: File) = readFromIni(file.path)?.let { Ini(it) } + + fun writeIniToFile(ini: Ini, file: File) = writeAsIni(file.path, ini.core) + } + +} \ 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 8d443b1e6..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,32 +1,69 @@ +/* + * 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 import android.content.Context import android.content.Intent +import android.os.Build import android.provider.Settings -import android.view.inputmethod.InputMethodManager +import android.view.inputmethod.InputMethodSubtype +import org.fcitx.fcitx5.android.BuildConfig import org.fcitx.fcitx5.android.input.FcitxInputMethodService object InputMethodUtil { - private val serviceName = + + @JvmField + val serviceName: String = FcitxInputMethodService::class.java.name + + @JvmField + val componentName: String = ComponentName(appContext, FcitxInputMethodService::class.java).flattenToShortString() - fun isEnabled() = serviceName in (Settings.Secure.getString( - appContext.contentResolver, - Settings.Secure.ENABLED_INPUT_METHODS - ) ?: "").split(':') + fun isEnabled(): Boolean { + return appContext.inputMethodManager.enabledInputMethodList.any { + it.packageName == BuildConfig.APPLICATION_ID && it.serviceName == serviceName + } + } - fun isSelected() = serviceName == Settings.Secure.getString( - appContext.contentResolver, - Settings.Secure.DEFAULT_INPUT_METHOD - ) + 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 { addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) }) - fun showSelector(context: Context) = - (context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager) - .showInputMethodPicker() + fun showPicker() = appContext.inputMethodManager.showInputMethodPicker() + + fun firstVoiceInput(): Pair? = + appContext.inputMethodManager + .shortcutInputMethodsAndSubtypes + .firstNotNullOfOrNull { + it.value.find { subType -> subType.mode.lowercase() == "voice" } + ?.let { subType -> it.key.id to subType } + } + + fun switchInputMethod( + service: FcitxInputMethodService, + id: String, + subtype: InputMethodSubtype + ) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + service.switchInputMethod(id, subtype) + } else { + @Suppress("DEPRECATION") + appContext.inputMethodManager + .setInputMethodAndSubtype(service.window.window!!.attributes.token, id, subtype) + } + } } \ No newline at end of file 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/MyParser.kt b/app/src/main/java/org/fcitx/fcitx5/android/utils/MyParser.kt deleted file mode 100644 index 272d4a501..000000000 --- a/app/src/main/java/org/fcitx/fcitx5/android/utils/MyParser.kt +++ /dev/null @@ -1,7 +0,0 @@ -package org.fcitx.fcitx5.android.utils - -import arrow.core.Either - -interface MyParser { - fun parse(raw: I): Either -} \ No newline at end of file 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 b635007b6..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,12 +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 kotlin.properties.Delegates class NaiveDustman { - private val initialValues: MutableMap = mutableMapOf() - private val dirtyStatus: MutableMap = mutableMapOf() - private val newKeys = mutableListOf() + private val initialValues = mutableMapOf() + + private val dirtyStatus = mutableSetOf() var dirty by Delegates.observable(false) { _, old, new -> if (old != new) { @@ -26,36 +30,28 @@ class NaiveDustman { } private fun updateDirtyStatus(key: String, boolean: Boolean) { - dirtyStatus[key] = boolean - dirty = newKeys.isNotEmpty() || dirtyStatus.any { it.value } + if (boolean) { + dirtyStatus.add(key) + } else { + dirtyStatus.remove(key) + } + dirty = dirtyStatus.isNotEmpty() } fun addOrUpdate(key: String, value: T) { - when { - (key !in initialValues) -> { - initialValues[key] = value - newKeys.add(key) - updateDirtyStatus(key, false) - } - initialValues[key] == value -> { - updateDirtyStatus(key, false) - } - else -> { - updateDirtyStatus(key, true) - } + if (initialValues.containsKey(key)) { + updateDirtyStatus(key, initialValues[key] != value) + } else { + updateDirtyStatus(key, true) } } fun remove(key: String) { - val a = initialValues.remove(key) != null - val b = newKeys.remove(key) - updateDirtyStatus(key, a || !b) + updateDirtyStatus(key, initialValues.containsKey(key)) } - fun reset(initial: Map) { dirty = false - newKeys.clear() dirtyStatus.clear() initialValues.putAll(initial) } 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/OptionSerializer.kt b/app/src/main/java/org/fcitx/fcitx5/android/utils/OptionSerializer.kt deleted file mode 100644 index 438356fb7..000000000 --- a/app/src/main/java/org/fcitx/fcitx5/android/utils/OptionSerializer.kt +++ /dev/null @@ -1,25 +0,0 @@ -package org.fcitx.fcitx5.android.utils - -import arrow.core.Option -import kotlinx.serialization.KSerializer -import kotlinx.serialization.descriptors.SerialDescriptor -import kotlinx.serialization.descriptors.buildClassSerialDescriptor -import kotlinx.serialization.encoding.Decoder -import kotlinx.serialization.encoding.Encoder - -/** - * A transparent wrapper of serializer, which ignores failures on deserialization - */ -class OptionSerializer(private val serializer: KSerializer) : KSerializer> { - override fun deserialize(decoder: Decoder): Option = - Option.catch { serializer.deserialize(decoder) } - - override val descriptor: SerialDescriptor = - buildClassSerialDescriptor("Option", serializer.descriptor) - - override fun serialize(encoder: Encoder, value: Option) { - value.fold({}) { - serializer.serialize(encoder, it) - } - } -} \ No newline at end of file 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 new file mode 100644 index 000000000..7349edccf --- /dev/null +++ b/app/src/main/java/org/fcitx/fcitx5/android/utils/PreferenceScreen.kt @@ -0,0 +1,108 @@ +/* + * 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 { + setTitle(title) + } + addPreference(category) + block.invoke(category) +} + +fun PreferenceScreen.addCategory(@StringRes title: Int, block: PreferenceCategory.() -> Unit) { + addCategory(context.getString(title), block) +} + +fun Preference.setup( + title: String, + summary: String? = null, + @DrawableRes icon: Int? = null, + onClick: (() -> Unit)? = null +) { + 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( + @StringRes title: Int, + summary: String, + @DrawableRes icon: Int? = null, + onClick: (() -> Unit)? = null +) { + addPreference(context.getString(title), summary, icon, onClick) +} + +fun PreferenceGroup.addPreference( + @StringRes title: Int, + @StringRes summary: Int? = null, + @DrawableRes icon: Int? = null, + onClick: (() -> Unit)? = null +) { + 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 new file mode 100644 index 000000000..2dfb02b24 --- /dev/null +++ b/app/src/main/java/org/fcitx/fcitx5/android/utils/SystemService.kt @@ -0,0 +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.os.storage.StorageManager +import android.view.WindowManager +import android.view.inputmethod.InputMethodManager +import androidx.core.content.getSystemService + +val Context.audioManager + get() = getSystemService()!! + +val Context.clipboardManager + get() = getSystemService()!! + +val Context.inputMethodManager + get() = getSystemService()!! + +val Context.notificationManager + get() = getSystemService()!! + +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 new file mode 100644 index 000000000..dc3a03142 --- /dev/null +++ b/app/src/main/java/org/fcitx/fcitx5/android/utils/TextinputLayout.kt @@ -0,0 +1,30 @@ +/* + * 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 com.google.android.material.textfield.TextInputEditText +import com.google.android.material.textfield.TextInputLayout +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 + +inline fun Context.materialTextInput( + initLayout: TextInputLayout.(TextInputEditText) -> Unit +): Pair { + val editText = view(::TextInputEditText) + val inputLayout = view(::TextInputLayout) { + add(editText, lParams(matchParent, wrapContent)) + initLayout.invoke(this, editText) + } + return inputLayout to editText +} + +inline fun Ui.materialTextInput( + initLayout: TextInputLayout.(TextInputEditText) -> Unit +) = ctx.materialTextInput(initLayout) 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/UTF8Utils.kt b/app/src/main/java/org/fcitx/fcitx5/android/utils/UTF8Utils.kt deleted file mode 100644 index 3cb59de68..000000000 --- a/app/src/main/java/org/fcitx/fcitx5/android/utils/UTF8Utils.kt +++ /dev/null @@ -1,13 +0,0 @@ -package org.fcitx.fcitx5.android.utils - -import com.sun.jna.Library - -interface UTF8Utils : Library { - - fun validateUTF8(str: String): Boolean - - companion object { - val instance: UTF8Utils by nativeLib("utf8utils") - } - -} \ No newline at end of file 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 90a944a23..000000000 --- a/app/src/main/java/org/fcitx/fcitx5/android/utils/Utils.kt +++ /dev/null @@ -1,272 +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.ContextWrapper -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.appcompat.app.AppCompatActivity -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 com.sun.jna.Library -import com.sun.jna.Native -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.io.path.createTempDirectory -import kotlin.io.path.pathString -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() - -inline fun nativeLib(name: String): Lazy = lazy { - Native.load(name, T::class.java) -} - -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? { - 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? { - 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? { - 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 - while (entry != null && !entry.isDirectory) { - val file = File(destDir, entry.name) - copyTo(file.outputStream()) - extracted.add(file) - entry = nextEntry - } - return extracted -} - -fun Context.getHostActivity(): AppCompatActivity? { - var context = this - while (context is ContextWrapper) { - if (context is AppCompatActivity) - return context - else - context = context.baseContext - } - return null -} - -inline fun withTempDir(block: (File) -> T): T { - // creates /data/user///cache/ - val dir = File(createTempDirectory().pathString) - try { - return block(dir) - } finally { - dir.delete() - } -} \ 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 index a22ac9e33..0e358afe3 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/utils/WeakHashSet.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/utils/WeakHashSet.kt @@ -1,30 +1,12 @@ -package org.fcitx.fcitx5.android.utils - -import java.util.* - -class WeakHashSet : MutableSet { - - private val core = WeakHashMap() - - private object PlaceHolder +/* + * SPDX-License-Identifier: LGPL-2.1-or-later + * SPDX-FileCopyrightText: Copyright 2024 Fcitx5 for Android Contributors + */ - val view = object : Set by core.keys {} - override val size get() = core.size - - override fun iterator(): MutableIterator = core.keys.iterator() - - override fun add(element: T) = core.put(element, PlaceHolder) != null - override fun addAll(elements: Collection) = elements.all(::add) - - override fun remove(element: T) = core.remove(element) != null - override fun removeAll(elements: Collection) = elements.all(::remove) - override fun clear() = core.clear() - - override fun retainAll(elements: Collection) = - removeAll(core.keys.filter { it !in elements }) +package org.fcitx.fcitx5.android.utils - override operator fun contains(element: T) = core.containsKey(element) - override fun containsAll(elements: Collection) = elements.all(::contains) +import java.util.Collections +import java.util.WeakHashMap - override fun isEmpty() = core.isEmpty() -} \ No newline at end of file +@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 83c7688aa..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,22 +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.MyParser +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>, @@ -24,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, @@ -38,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, @@ -86,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 @@ -105,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, @@ -129,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, @@ -147,22 +182,30 @@ 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 } companion object : - MyParser, Companion.ParseException> { + 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 @@ -191,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( @@ -205,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, @@ -223,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, @@ -243,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)) } } ) @@ -268,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 } @@ -279,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) } @@ -289,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 new file mode 100644 index 000000000..2762b7cb6 --- /dev/null +++ b/app/src/main/java/org/fcitx/fcitx5/android/utils/config/ConfigParser.kt @@ -0,0 +1,11 @@ +/* + * 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 + +interface ConfigParser { + fun parse(raw: I): Either +} \ No newline at end of file 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 6f95f3ff5..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,54 +1,55 @@ +/* + * 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 org.fcitx.fcitx5.android.utils.MyParser +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 : MyParser, Companion.UnknownConfigTypeException> { + companion object : ConfigParser, Companion.UnknownConfigTypeException> { data class UnknownConfigTypeException(val type: String) : Exception() override fun parse(raw: String): Either> = - either.eager { + either { when (raw) { "Integer" -> TyInt "String" -> TyString @@ -60,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/app/src/main/play/release-notes/en-US/21.txt b/app/src/main/play/release-notes/en-US/21.txt new file mode 100644 index 000000000..fe4d477e2 --- /dev/null +++ b/app/src/main/play/release-notes/en-US/21.txt @@ -0,0 +1,37 @@ +# 0.0.4 - Table Manager and Reproducible Build + +## Highlights + +- Table Manager: import/delete table input methods conveniently, it can also tell you whether the table file is malformatted +- Reproducible Build: with provided `build-metadata.json`, you can build the exact same APK as we do on CI + +## New features + +- Restore default value for a specific preference option +- Show fcitx configuration's annotation message +- Show a prompt before delete all clipboard items +- Sort clipboard items by time +- Add grave accent (`) to z longpress +- A optional, alternative touch event distribution routine, to workaround key press detection on some devices +- Table input methods management UI +- Long press return key to enter emoji picker +- Preedit/Composing text format +- More precise selection range tracking +- Use application package name (or shared uid name) for InputContext program +- Implement reproducible build + +## Bug fixes + +- Fcitx's configuration cannot be changed via settings UI sometimes +- Expanded candidate items got clipped when row count too much +- Crash when dismissing multiple popups +- Crash when attaching PickerWindow +- Crash on some wried "Multi-screen mode" mode +- Crash when clipboard limit set to 0 +- Keyboard becomes very laggy when clipboard content toooooo looooong +- Crash when enter punctuation mapping editor +- Popup misalignment when changing keyboard bottom/side padding +- Popup stays on screen after touch up +- Composing text comitted incorrectly when switch from a normal input to a password input +- Clipboard longpress menu stays on screen when keyboard hides and shows again +- Divider in input method picker dialog filckers on scroll diff --git a/app/src/main/play/release-notes/en-US/31.txt b/app/src/main/play/release-notes/en-US/31.txt new file mode 100644 index 000000000..14663b066 --- /dev/null +++ b/app/src/main/play/release-notes/en-US/31.txt @@ -0,0 +1,28 @@ +# 0.0.5 - OpenCC and Inline Suggestions + +## Highlights + +- OpenCC: convert between Traditional and Simplified Chinese in phrase-level, with multiple regional presets +- Inline Suggestions: display suggestions form autofill service on toolbar, requires Android 11+ + +## New features + +- Show number row on toolbar when inputing password +- Use OpenCC for Traditional-Simplified Chinese conversion +- Refined horizontal candidate layout +- Option to hide language switch key +- Customizable space long press behavior +- Show autofill results as inline suggestion +- Clipboard delete confirmation, swipe to delete, and undo deleting +- RTL layout direction support + +## Bug fixes + +- Cannot delete input methods with multi-select checkbox +- Cannot disable addons without dependencies +- Selected text may be cleared when switching input method +- Crash when clipboard updates before InputView setup finish +- "Copy" button in clipboard edit dialog doesn't work +- Some preferences does not take effect until restarting input method (recreate InputView actually) +- Popup keyboard's background highlight misalign when using some fonts +- Punctuation mapping lost after rotating screen (recreate InputView actually) diff --git a/app/src/main/play/release-notes/en-US/42.txt b/app/src/main/play/release-notes/en-US/42.txt new file mode 100644 index 000000000..279bc9b22 --- /dev/null +++ b/app/src/main/play/release-notes/en-US/42.txt @@ -0,0 +1,24 @@ +# 0.0.6 - Candidates Paging and Plugin System + +## Highlights + +- Candidates Paging: candidates are loaded on demand while scrolling through expanded candidate list +- Plugin System: load fcitx addon from plugin apk to support more languages. Currently "Anthy Plugin" is available for Japanese input + +## New features + +- Load candidates on demand +- Plugin System for build/detect/load fcitx addon from installed apk +- Redesign CustomThemeActivity and LogActivity to look like AppCompact style +- Show a button to switch to Google Voice Input if available +- Automatically restart fcitx to reload input methods after import/delete table input methods +- Support multi-select and delete in TableInputMethodFragment +- Long press on switch preference to restore default value + +## Bug fixes + +- InputView won't be destroyed when switching theme, causing memory leak +- Buttons on keyboard toolbar would disappear sometimes +- Settings activity toolbar title would flash when enter/leave certain fragment +- Keyboard keys won't accept touch events while pressing keyboard padding space +- Selecting numeric characters from keyboard long-press popup would select candidate 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/bkg_inline_suggestion_dark.xml b/app/src/main/res/drawable/bkg_inline_suggestion_dark.xml new file mode 100644 index 000000000..e50ffdc22 --- /dev/null +++ b/app/src/main/res/drawable/bkg_inline_suggestion_dark.xml @@ -0,0 +1,19 @@ + + + + + + + + + + + diff --git a/app/src/main/res/drawable/bkg_inline_suggestion_light.xml b/app/src/main/res/drawable/bkg_inline_suggestion_light.xml new file mode 100644 index 000000000..dbbcbd0c1 --- /dev/null +++ b/app/src/main/res/drawable/bkg_inline_suggestion_light.xml @@ -0,0 +1,19 @@ + + + + + + + + + + + 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 new file mode 100644 index 000000000..7abb6157a --- /dev/null +++ b/app/src/main/res/drawable/ic_baseline_android_24.xml @@ -0,0 +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 60a718079..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,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_keyboard_arrow_right_24.xml b/app/src/main/res/drawable/ic_baseline_keyboard_arrow_right_24.xml index 7f7b33ec0..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,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_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 new file mode 100644 index 000000000..267f2654b --- /dev/null +++ b/app/src/main/res/drawable/ic_baseline_keyboard_voice_24.xml @@ -0,0 +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 4b09f2fe4..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,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_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_log.xml b/app/src/main/res/layout/activity_log.xml index adc33ebc6..30b280d58 100644 --- a/app/src/main/res/layout/activity_log.xml +++ b/app/src/main/res/layout/activity_log.xml @@ -6,48 +6,23 @@ android:layout_height="match_parent" tools:context=".ui.main.LogActivity"> - - - - + app:layout_constraintTop_toTopOf="parent" /> - -