diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml index 5e063b4b..c6927ff3 100644 --- a/.github/workflows/android.yml +++ b/.github/workflows/android.yml @@ -68,16 +68,31 @@ jobs: run: chmod +x gradlew - name: Run unit tests, lint, and assemble - run: ./gradlew :app:testDebugUnitTest :app:lintDebug :app:assembleDebug --no-daemon --console=plain + shell: bash + run: | + set +e + ./gradlew :app:testDebugUnitTest :app:lintDebug :app:assembleDebug :app:assembleDebugAndroidTest --build-cache --parallel --no-daemon --console=plain + status=$? + if [[ $status -ne 0 && -f app/build/reports/lint-results-debug.txt ]]; then + echo "::group::Full Android Lint report" + cat app/build/reports/lint-results-debug.txt + echo "::endgroup::" + fi + exit $status - - name: Upload debug APK + - name: Upload test APKs uses: actions/upload-artifact@v7 with: - name: openIME-debug - path: app/build/outputs/apk/debug/app-debug.apk + name: openIME-test-apks + path: | + app/build/outputs/apk/debug/app-debug.apk + app/build/outputs/apk/androidTest/debug/app-debug-androidTest.apk if-no-files-found: error + compression-level: 0 + retention-days: 1 compatibility: + needs: verify name: Compatibility API ${{ matrix.api-level }} runs-on: ubuntu-latest timeout-minutes: 45 @@ -87,47 +102,43 @@ jobs: api-level: [29, 31] steps: - - name: Checkout source and LFS files - uses: actions/checkout@v7 - with: - lfs: true - - - name: Set up JDK 17 - uses: actions/setup-java@v6 - with: - distribution: temurin - java-version: '17' - cache: gradle + - name: Enable KVM group perms + shell: bash + run: | + echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules + sudo udevadm control --reload-rules + sudo udevadm trigger --name-match=kvm - name: Set up Android SDK uses: android-actions/setup-android@v4 with: packages: 'platform-tools' - - name: Install Android build components - shell: bash - run: | - sdkmanager "platforms;android-36" \ - "build-tools;35.0.0" \ - "ndk;27.0.12077973" \ - "cmake;3.22.1" + - name: Download prebuilt test APKs + uses: actions/download-artifact@v7 + with: + name: openIME-test-apks + path: ci-apks - - name: Verify LFS objects + - name: Prepare instrumentation runner shell: bash run: | - git lfs checkout - if grep -RIl --exclude-dir=.git "version https://git-lfs.github.com/spec/v1" app/libs app/src/main/assets/models; then - echo "A Git LFS pointer was left unresolved" >&2 - exit 1 - fi - - - name: Fetch pinned librime dependencies - shell: bash - run: bash scripts/fetch_rime_deps.sh - - - name: Ensure Gradle wrapper is executable - shell: bash - run: chmod +x gradlew + cat > ci-run-instrumentation.sh <<'EOF' + #!/usr/bin/env bash + set -euo pipefail + api_level="$1" + mkdir -p ci-reports + app_apk="$(find ci-apks -type f -name 'app-debug.apk' -print -quit)" + test_apk="$(find ci-apks -type f -name 'app-debug-androidTest.apk' -print -quit)" + test -n "$app_apk" + test -n "$test_apk" + adb install -r "$app_apk" + adb install -r "$test_apk" + adb shell am instrument -w -r \ + llc.slacker.openime.test/androidx.test.runner.AndroidJUnitRunner \ + | tee "ci-reports/instrumentation-api-${api_level}.txt" + EOF + chmod +x ci-run-instrumentation.sh - name: Run compatibility instrumentation uses: reactivecircus/android-emulator-runner@v2 @@ -138,14 +149,14 @@ jobs: profile: pixel_2 disable-animations: true emulator-options: -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none - script: ./gradlew :app:connectedDebugAndroidTest --no-daemon --console=plain + script: bash ci-run-instrumentation.sh ${{ matrix.api-level }} - - name: Upload compatibility test reports + - name: Upload compatibility test report if: always() uses: actions/upload-artifact@v7 with: name: compatibility-api-${{ matrix.api-level }} - path: | - app/build/reports/androidTests/connected - app/build/outputs/androidTest-results/connected + path: ci-reports if-no-files-found: warn + compression-level: 0 + retention-days: 7 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 00000000..5575bf61 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,179 @@ +name: Android Release + +on: + push: + tags: + - 'v*' + +permissions: + contents: write + +jobs: + release: + name: Build signed arm64 release + runs-on: ubuntu-latest + timeout-minutes: 60 + + steps: + - name: Checkout source and LFS files + uses: actions/checkout@v7 + with: + lfs: true + + - name: Set up JDK 17 + uses: actions/setup-java@v6 + with: + distribution: temurin + java-version: '17' + cache: gradle + + - name: Set up Android SDK + uses: android-actions/setup-android@v4 + with: + packages: 'platform-tools' + + - name: Install Android build components + shell: bash + run: | + sdkmanager "platforms;android-36" \ + "build-tools;35.0.0" \ + "ndk;27.0.12077973" \ + "cmake;3.22.1" + + - name: Verify LFS objects + shell: bash + run: | + git lfs checkout + if grep -RIl --exclude-dir=.git "version https://git-lfs.github.com/spec/v1" app/libs app/src/main/assets/models; then + echo "A Git LFS pointer was left unresolved" >&2 + exit 1 + fi + + - name: Fetch pinned librime dependencies + shell: bash + run: bash scripts/fetch_rime_deps.sh + + - name: Ensure Gradle wrapper is executable + shell: bash + run: chmod +x gradlew + + - name: Validate signing secrets + shell: bash + env: + OPENIME_KEYSTORE_B64: ${{ secrets.OPENIME_KEYSTORE_B64 }} + OPENIME_KEYSTORE_PASSWORD: ${{ secrets.OPENIME_KEYSTORE_PASSWORD }} + OPENIME_KEY_ALIAS: ${{ secrets.OPENIME_KEY_ALIAS }} + OPENIME_KEY_PASSWORD: ${{ secrets.OPENIME_KEY_PASSWORD }} + run: | + for name in \ + OPENIME_KEYSTORE_B64 \ + OPENIME_KEYSTORE_PASSWORD \ + OPENIME_KEY_ALIAS \ + OPENIME_KEY_PASSWORD + do + if [[ -z "${!name}" ]]; then + echo "Missing required release signing secret: ${name}" >&2 + exit 1 + fi + done + + - name: Decode release keystore + shell: bash + env: + OPENIME_KEYSTORE_B64: ${{ secrets.OPENIME_KEYSTORE_B64 }} + run: | + printf '%s' "$OPENIME_KEYSTORE_B64" \ + | base64 --decode \ + > "$RUNNER_TEMP/openime-release.jks" + chmod 600 "$RUNNER_TEMP/openime-release.jks" + + - name: Run tests, lint, and release build + shell: bash + env: + OPENIME_KEYSTORE_PATH: ${{ runner.temp }}/openime-release.jks + OPENIME_KEYSTORE_PASSWORD: ${{ secrets.OPENIME_KEYSTORE_PASSWORD }} + OPENIME_KEY_ALIAS: ${{ secrets.OPENIME_KEY_ALIAS }} + OPENIME_KEY_PASSWORD: ${{ secrets.OPENIME_KEY_PASSWORD }} + run: | + ./gradlew \ + :app:testDebugUnitTest \ + :app:lintRelease \ + :app:assembleRelease \ + --no-daemon \ + --console=plain + + - name: Verify signed arm64 artifact + id: artifact + shell: bash + run: | + APK="$(find app/build/outputs/apk/release -maxdepth 1 -type f -name 'app-release*.apk' | head -n 1)" + if [[ -z "$APK" || ! -f "$APK" ]]; then + echo "Release APK not found" >&2 + exit 1 + fi + + "$ANDROID_HOME/build-tools/35.0.0/apksigner" verify --verbose "$APK" + + mapfile -t ABIS < <( + unzip -Z1 "$APK" \ + | awk -F/ '/^lib\/[^/]+\/[^/]+$/ {print $2}' \ + | sort -u + ) + if [[ "${#ABIS[@]}" -ne 1 || "${ABIS[0]}" != "arm64-v8a" ]]; then + echo "Release APK must contain only arm64-v8a; found: ${ABIS[*]:-none}" >&2 + exit 1 + fi + + VERSION_NAME="$( + sed -n 's/^[[:space:]]*versionName = "\(.*\)"/\1/p' app/build.gradle.kts \ + | head -n 1 + )" + if [[ -z "$VERSION_NAME" ]]; then + echo "Unable to read versionName" >&2 + exit 1 + fi + if [[ "$GITHUB_REF_NAME" != "v$VERSION_NAME" ]]; then + echo "Tag $GITHUB_REF_NAME does not match versionName $VERSION_NAME" >&2 + exit 1 + fi + + OUTPUT="openIME-v${VERSION_NAME}-arm64-release.apk" + cp "$APK" "$OUTPUT" + sha256sum "$OUTPUT" > SHA256SUMS.txt + cp THIRD_PARTY_NOTICES.md THIRD_PARTY_NOTICES.release.md + echo "apk=$OUTPUT" >> "$GITHUB_OUTPUT" + echo "version=$VERSION_NAME" >> "$GITHUB_OUTPUT" + + - name: Create release notes + shell: bash + run: | + cat > RELEASE_NOTES.md <<'EOF' + ## 更新内容 + - 此版本包含标签所对应提交中的 openIME 功能、稳定性与界面更新。 + + ## 已知问题 + - 首次安装时,完整 Rime 词典可能需要完成首次部署后才达到稳定候选表现。 + - 发布 APK 仅包含 arm64-v8a;模拟器继续使用 Debug 构建。 + + ## 校验方式 + 下载 APK 后,在同一目录执行: + + ```bash + sha256sum -c SHA256SUMS.txt + ``` + + 校验通过后再安装。 + EOF + + - name: Publish GitHub Release + shell: bash + env: + GH_TOKEN: ${{ github.token }} + run: | + gh release create "$GITHUB_REF_NAME" \ + "${{ steps.artifact.outputs.apk }}" \ + SHA256SUMS.txt \ + THIRD_PARTY_NOTICES.release.md \ + --verify-tag \ + --title "openIME $GITHUB_REF_NAME" \ + --notes-file RELEASE_NOTES.md diff --git a/.gitignore b/.gitignore index b9dcf657..c70d5c04 100644 --- a/.gitignore +++ b/.gitignore @@ -24,6 +24,8 @@ artifacts/ # Local screenshots and UI dumps kept outside the source tree *.png +!docs/images/ +!docs/images/*.png *.xml !app/src/**/*.xml !app/src/main/assets/ diff --git a/README.md b/README.md index 5c142a90..35d8ee71 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,34 @@ # openIME -独立的 Android 系统输入法,包名为 `llc.slacker.openime`,应用显示名为 -`openIME`。它不依赖 `Minis for Android`,也不与其他输入法共享进程、数据或服务。 +

+ openIME +

+ +

本地优先的 Android 中文输入法。

+ +openIME 是独立 Android 系统输入法,包名 `llc.slacker.openime`。拼音候选、用户学习和语音识别均在设备内运行;应用不声明 `INTERNET` 权限。 [![Android CI](https://github.com/Slacker-LLC/openIME/actions/workflows/android.yml/badge.svg)](https://github.com/Slacker-LLC/openIME/actions/workflows/android.yml) +## 下载 + +正式版本从 [GitHub Releases](https://github.com/Slacker-LLC/openIME/releases/latest) 下载。发布 APK 使用固定发布签名,文件名为 `openIME-v{versionName}-arm64-release.apk`,同一 Release 同时提供 `SHA256SUMS.txt`。 + +当前正式发布包只包含 `arm64-v8a`;开发用 Debug APK 仍保留 `x86_64`,用于模拟器回归。 + +## 安装四步 + +1. **启用**:安装 APK 后打开 openIME,进入系统输入法设置并启用 `openIME`。 +2. **切换**:回到引导页,打开系统输入法选择器并切换到 `openIME`。 +3. **授权(可选)**:需要本地语音输入时授权麦克风;不授权可直接跳过。 +4. **试打**:在引导页输入框确认键盘、候选和上屏链路正常。 + +语音输入长按空格达到系统长按阈值后开始录音,松手后识别并上屏。 + +## 截图 + +真实截图由 `scripts/visual_matrix_regression.ps1` 和视觉验收流程生成。仓库不提交占位图;26 键、九键与设置页的浅色/深色截图将在真实视觉矩阵验收后写入 `docs/images/`。 + ## 项目定位 openIME 的界面、输入法引擎和本地语音链路均在同一个独立 APK 内运行: @@ -36,7 +60,7 @@ InputConnection - Emoji、符号、剪贴板、文本编辑、浮动键盘和设置面板;手写目前仅保留笔迹采集 UI,识别引擎尚未接入,正式入口默认隐藏。 - 根据输入法窗口实际可用宽度动态计算列宽与间距;宽屏限制内容最大宽度并居中, 系统底部区域通过 WindowInsets 处理。 -- 空格短按输入空格或提交首选,长按约 150 ms 进入唯一的语音输入流程;删除键上滑清空。 +- 空格短按输入空格或提交首选,长按达到系统长按时长(`ViewConfiguration.getLongPressTimeout()`)进入唯一的语音输入流程;删除键上滑清空。 - 选词或首选上屏后统一清除拼音、候选与 Rime composition,随后删除键只处理目标输入框。 - Android 密码编辑器的隐私边界、麦克风权限失败回退和本地模型校验。 @@ -94,10 +118,9 @@ Windows PowerShell: .\scripts\build_ascii.ps1 ``` -APK 输出为 `app/build/outputs/apk/debug/app-debug.apk`。本地交付副本可放在被忽略的 -`artifacts/openIME-1.0-debug.apk`,不会提交到源码仓库。 +Debug APK 输出为 `app/build/outputs/apk/debug/app-debug.apk`,仅用于开发与回归;正式分发使用 GitHub Release 中固定签名的 arm64 APK。 -## 安装并启用 +开发设备可用 ADB 安装 Debug APK: ```bash adb install -r app/build/outputs/apk/debug/app-debug.apk @@ -105,8 +128,7 @@ adb shell ime enable --user 0 llc.slacker.openime/.LocalVoiceImeService adb shell ime set --user 0 llc.slacker.openime/.LocalVoiceImeService ``` -也可以打开 APK 的设置页,按系统提示启用 `openIME`。调试测试 Activity 和 E2E -Receiver 只存在于 debug 变体,不会成为正式输入法的公共控制入口。 +调试测试 Activity 和 E2E Receiver 只存在于 debug 变体,不会成为正式输入法的公共控制入口。 ## 验证 @@ -148,12 +170,15 @@ bash scripts/verify_linux.sh emulator-5554 - [适配与坐标规范](docs/COORDINATE_SYSTEM.md) - [测试流程 SOP](docs/TEST_SOP.md) - [脚本说明](scripts/README.md) +- [发布流程](docs/RELEASE.md) ## 隐私与安全 -语音 PCM 只在当前会话的有界内存缓冲区中处理,结束、取消或失败时清空;模型和 -词典随 APK 提供。密码输入框不写入候选、剪贴板或日志,但允许用户从剪贴板粘贴;发现安全问题请不要直接 -创建公开 Issue,先按 [SECURITY.md](SECURITY.md) 联系维护者。 +**本应用不含联网权限,数据只存在本机。** + +语音 PCM 只在当前会话的有界内存缓冲区中处理,结束、取消或失败时清空;模型和词典随 APK 提供。密码输入框不写入候选、剪贴板或日志,但允许用户从剪贴板粘贴。设置中的“关于与数据”可导出/合并导入用户数据;剪贴板历史不导出。卸载会清除本机全部数据,包括学习的用户词库。 + +发现安全问题请不要直接创建公开 Issue,先按 [SECURITY.md](SECURITY.md) 联系维护者。 ## 许可证 diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index 48a9386d..09dba908 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -1,19 +1,26 @@ -# 第三方资源 +# 第三方资源与分发核对 -## Microsoft Fluent Emoji +本文件记录 openIME 实际随源码或 APK 分发的主要第三方代码、数据和模型。主项目许可证仍由项目所有者决定,本文件不为 openIME 本身授予许可证。 -`app/src/main/assets/emoji/fluent/` 中的 3D 表情资源来自 Microsoft 的 -[fluentui-emoji](https://github.com/microsoft/fluentui-emoji) 项目,按其 MIT License 使用。 +| 组件 | 仓库内位置 | 上游/来源 | 许可证 | 分发核对 | +|---|---|---|---|---| +| librime | `app/src/main/cpp/vendor/librime/` | https://github.com/rime/librime | BSD-3-Clause | vendored `LICENSE` 保留;APK 同时包含 `assets/licenses/librime-BSD-3-Clause.txt` | +| OpenCC | `app/src/main/cpp/vendor/OpenCC/` | https://github.com/BYVoid/OpenCC | Apache-2.0 | vendored `LICENSE` 保留;APK 同时包含 `assets/licenses/OpenCC-Apache-2.0.txt` | +| Snappy | `app/src/main/cpp/vendor/snappy/` | https://github.com/google/snappy | BSD-3-Clause | vendored `COPYING` 保留;APK 同时包含 `assets/licenses/snappy-BSD-3-Clause.txt` | +| Rime Ice 词典 | `app/src/main/assets/rime-data/openime_dicts/`、`pinyin_phrases.tsv` | https://github.com/iDvel/rime-ice,固定提交 `75e6572bebc05b49021e842949ce947882e3e4b2` | GPL-3.0-only | 已核对;完整文本随 APK/源码位于 `app/src/main/assets/licenses/rime-ice-GPL-3.0.txt` | +| Rime 基础数据 | `app/src/main/assets/rime/` | Rime / luna-pinyin / essay 等上游数据 | 以各目录 `AUTHORS` / 上游许可为准 | 已保留上游 AUTHORS;发布前不得删除这些归属文件 | +| sherpa-onnx Android runtime | `app/libs/sherpa-onnx-1.13.6.aar` | https://github.com/k2-fsa/sherpa-onnx | Apache-2.0 | APK 包含 `assets/licenses/sherpa-onnx-Apache-2.0.txt` | +| 中英双语 Streaming Paraformer INT8 语音模型 | `app/src/main/assets/models/voice/bilingual-paraformer/` | https://huggingface.co/csukuangfj/sherpa-onnx-streaming-paraformer-bilingual-zh-en | Apache-2.0 | 只打包 `encoder.int8.onnx`、`decoder.int8.onnx` 和 `tokens.txt`;APK 包含 `assets/licenses/paraformer-model-Apache-2.0.txt`,`manifest.json` 固定模型 ID/版本与文件哈希 | +| Microsoft Fluent Emoji | `app/src/main/assets/emoji/fluent/` | https://github.com/microsoft/fluentui-emoji | MIT | 仅打包当前实际使用的基础情绪表情资源;APK 包含 `assets/licenses/fluent-emoji-MIT.txt` | -应用只打包「Smileys & Emotion」中的基础黄豆脸/情绪表情;没有把手势、动物、食物或贴图资源打进 APK。 +## 发布前检查 -## Rime Ice 中文词库 +- 不要删除 vendored 源码目录中的 LICENSE、COPYING、AUTHORS 或 NOTICE 类文件。 +- `app/src/main/assets/licenses/` 下的第三方许可文本必须继续随 APK 打包,包括 Rime Ice、librime、OpenCC、Snappy、sherpa-onnx、Paraformer 模型和 Fluent Emoji。 +- 语音 runtime 与模型升级时,重新核对**具体版本/模型**的许可证,不要只沿用本文件旧结论。 +- 内置词库来源或固定提交变化时,同步更新本文件、`docs/LICENSING.md` 和 APK 内许可证文件。 +- 主项目 `LICENSE` 在项目所有者决定前保持缺失;README 对主项目许可状态的现有表述保持不变。 -`app/src/main/assets/rime-data/openime_dicts/` 中的 `8105`、`base`、`ext`、 -`others` 词库,以及由它们生成的 `app/src/main/assets/pinyin_phrases.tsv` 高频子集, -来自 [rime-ice](https://github.com/iDvel/rime-ice),固定于提交 -`75e6572bebc05b49021e842949ce947882e3e4b2`,按 GPL-3.0-only 使用。 +## 备注 -完整许可证随 APK 和源码保存在 -`app/src/main/assets/licenses/rime-ice-GPL-3.0.txt`。openIME 没有引入体积大、 -部署耗时更长的 `tencent` 自动注音词表。 +Git LFS、GitHub Release 或 APK 打包方式不会改变第三方作品本身的许可证义务。此清单只记录仓库当前已核对的信息,不替代各上游许可证原文。 diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 9773dee8..70da241e 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -5,6 +5,21 @@ if (customBuildDir != null) { layout.buildDirectory.set(file(customBuildDir)) } +fun releaseValue(name: String): String? = + providers.gradleProperty(name).orNull + ?: providers.environmentVariable(name).orNull + +val releaseKeystorePath = releaseValue("OPENIME_KEYSTORE_PATH") +val releaseKeystorePassword = releaseValue("OPENIME_KEYSTORE_PASSWORD") +val releaseKeyAlias = releaseValue("OPENIME_KEY_ALIAS") +val releaseKeyPassword = releaseValue("OPENIME_KEY_PASSWORD") +val releaseSigningReady = listOf( + releaseKeystorePath, + releaseKeystorePassword, + releaseKeyAlias, + releaseKeyPassword, +).all { !it.isNullOrBlank() } + plugins { id("com.android.application") id("org.jetbrains.kotlin.android") @@ -23,11 +38,39 @@ android { versionCode = 4 versionName = "1.0.3" - ndk { - // Physical phones in scope are arm64; x86_64 keeps the existing - // emulator regression path available without shipping 32-bit - // native Rime binaries we do not need. - abiFilters += listOf("arm64-v8a", "x86_64") + } + + signingConfigs { + if (releaseSigningReady) { + create("release") { + storeFile = file(releaseKeystorePath!!) + storePassword = releaseKeystorePassword + keyAlias = releaseKeyAlias + keyPassword = releaseKeyPassword + } + } + } + + buildTypes { + getByName("debug") { + ndk { + abiFilters.clear() + abiFilters += listOf("arm64-v8a", "x86_64") + } + } + getByName("release") { + isMinifyEnabled = false + ndk { + abiFilters.clear() + abiFilters += "arm64-v8a" + } + if (releaseSigningReady) { + signingConfig = signingConfigs.getByName("release") + } else { + logger.warn( + "openIME release signing is not configured; assembleRelease will produce an unsigned APK", + ) + } } } @@ -59,6 +102,7 @@ android { dependencies { implementation(files("libs/sherpa-onnx-1.13.6.aar")) testImplementation("junit:junit:4.13.2") + testImplementation("org.json:json:20260814") androidTestImplementation("androidx.test.ext:junit:1.2.1") androidTestImplementation("androidx.test:runner:1.6.2") } diff --git a/app/src/androidTest/java/llc/slacker/openime/AuditInteractionInstrumentedTest.kt b/app/src/androidTest/java/llc/slacker/openime/AuditInteractionInstrumentedTest.kt index b7b4c43f..66b1d782 100644 --- a/app/src/androidTest/java/llc/slacker/openime/AuditInteractionInstrumentedTest.kt +++ b/app/src/androidTest/java/llc/slacker/openime/AuditInteractionInstrumentedTest.kt @@ -35,6 +35,7 @@ class AuditInteractionInstrumentedTest { val finals = mutableListOf() val characters = mutableListOf() val fuzzyChanges = mutableListOf() + val textEdits = mutableListOf() var starts = 0 var stops = 0 var cancels = 0 @@ -66,6 +67,7 @@ class AuditInteractionInstrumentedTest { "onSpace" -> { spaces++; null } "onBackspace" -> { backspaces++; null } "onClearAll" -> { clears++; null } + "onTextEdit" -> { textEdits.add(args!![0] as String); null } "onFloatingKeyboardChanged" -> { keyboard.setFloatingWindowMode(args!![0] as Boolean) null @@ -450,11 +452,11 @@ class AuditInteractionInstrumentedTest { assertTrue("${theme.label} must be selectable from settings", chip != null) assertTrue("${theme.label} must expose a 48dp target", chip!!.minimumHeight >= keyboard.resources.displayMetrics.density * 48f) } - val cyberpunk = keyboard.findTestTarget(ImeTheme.CYBERPUNK.label) - assertTrue(cyberpunk!!.performClick()) + val ios = keyboard.findTestTarget(ImeTheme.IOS.label) + assertTrue(ios!!.performClick()) assertTrue( "Selected theme must expose its accessible state", - keyboard.findTestTarget(ImeTheme.CYBERPUNK.label)!!.contentDescription.toString().contains("已选中"), + keyboard.findTestTarget(ImeTheme.IOS.label)!!.contentDescription.toString().contains("已选中"), ) true } @@ -483,6 +485,8 @@ class AuditInteractionInstrumentedTest { mainHandler = Handler(Looper.getMainLooper()), windowProvider = { null }, keyboardHeightPx = { keyboard.measuredHeight.takeIf { it > 0 } }, + floatingWidthPercent = { 88 }, + floatingOpacityPercent = { 100 }, ) controller.enable() assertTrue(controller.enabled) @@ -588,6 +592,10 @@ class AuditInteractionInstrumentedTest { harness.awaitMain { val expand = keyboard.findViewWithTag("candidate-expand") assertTrue("Empty composition must not expose a dead overflow action", !expand.isEnabled) + assertTrue( + "Empty candidate overflow must use the shared disabled alpha", + expand.alpha == ImeSurfacePolicy.DISABLED_ALPHA, + ) assertTrue(expand.contentDescription.toString().contains("暂无更多候选")) keyboard.renderState(ImeState(composition = "ni", candidates = listOf("你"))) assertTrue("Candidate overflow must become available with a result", expand.isEnabled) @@ -625,6 +633,10 @@ class AuditInteractionInstrumentedTest { keyboard.renderState(ImeState(passwordField = true)) val clipboard = keyboard.findViewWithTag("clipboard-toolbar") assertFalse("Password fields must disable clipboard history", clipboard.isEnabled) + assertTrue( + "Password clipboard action must use the shared disabled alpha", + clipboard.alpha == ImeSurfacePolicy.DISABLED_ALPHA, + ) assertTrue(clipboard.contentDescription.toString().contains("密码输入中不可用")) assertTrue("Sensitive editors must not list clipboard in tools", run { keyboard.showPanel(Panel.TOOLS) @@ -1047,6 +1059,70 @@ class AuditInteractionInstrumentedTest { } } + @Test + fun spaceJitterDoesNotEnterCursorModeAndHorizontalSwipeDoes() = withKeyboard { harness, recorder, keyboard -> + val density = keyboard.resources.displayMetrics.density + harness.awaitMain { + val origin = keyPoint(keyboard, "key-space") + var downTime = SystemClock.uptimeMillis() + pointers(keyboard, downTime, MotionEvent.ACTION_DOWN, listOf(origin)) + SystemClock.sleep(40L) + pointers( + keyboard, + downTime, + MotionEvent.ACTION_MOVE, + listOf(origin.copy(x = origin.x + 3f * density)), + ) + SystemClock.sleep(40L) + pointers(keyboard, downTime, MotionEvent.ACTION_UP, listOf(origin.copy(x = origin.x + 3f * density))) + assertTrue("A small jitter must not move the cursor", recorder.textEdits.isEmpty()) + val spacesAfterJitter = recorder.spaces + + val swipeOrigin = keyPoint(keyboard, "key-space") + downTime = SystemClock.uptimeMillis() + pointers(keyboard, downTime, MotionEvent.ACTION_DOWN, listOf(swipeOrigin)) + pointers( + keyboard, + downTime, + MotionEvent.ACTION_MOVE, + listOf(swipeOrigin.copy(x = swipeOrigin.x + 19f * density)), + ) + pointers( + keyboard, + downTime, + MotionEvent.ACTION_MOVE, + listOf(swipeOrigin.copy(x = swipeOrigin.x + 31f * density)), + ) + pointers( + keyboard, + downTime, + MotionEvent.ACTION_MOVE, + listOf(swipeOrigin.copy(x = swipeOrigin.x + 43f * density)), + ) + pointers( + keyboard, + downTime, + MotionEvent.ACTION_MOVE, + listOf(swipeOrigin.copy(x = swipeOrigin.x + 30f * density)), + ) + pointers( + keyboard, + downTime, + MotionEvent.ACTION_UP, + listOf(swipeOrigin.copy(x = swipeOrigin.x + 30f * density)), + ) + + assertEquals( + "A deliberate drag must move across characters and reverse direction", + listOf("right", "right", "left"), + recorder.textEdits, + ) + assertEquals("Cursor movement must not commit a space", spacesAfterJitter, recorder.spaces) + assertEquals("Cursor movement must not arm voice", 0, recorder.starts) + true + } + } + @Test fun spaceOwnerPointerUpBeforeLongPressCancelsPendingArmWithoutGhostSpace() = withKeyboard { harness, recorder, keyboard -> lateinit var owner: Finger diff --git a/app/src/androidTest/java/llc/slacker/openime/NineKeyChineseInstrumentedTest.kt b/app/src/androidTest/java/llc/slacker/openime/NineKeyChineseInstrumentedTest.kt index 740a85b7..e6b3a46e 100644 --- a/app/src/androidTest/java/llc/slacker/openime/NineKeyChineseInstrumentedTest.kt +++ b/app/src/androidTest/java/llc/slacker/openime/NineKeyChineseInstrumentedTest.kt @@ -5,6 +5,7 @@ import android.view.ViewGroup import android.widget.TextView import androidx.test.ext.junit.runners.AndroidJUnit4 import org.junit.After +import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Test @@ -49,6 +50,36 @@ class NineKeyChineseInstrumentedTest { assertComposition("zhe") } + @Test + fun ambiguousRailShowsInputtablePinyinAndSelectionKeepsCompositionActive() { + tapDigits("64") + + val paths = harness.awaitMain { activity -> + val root = activity.findViewById(android.R.id.content) ?: return@awaitMain emptyList() + val content = findView(root) { it.tag == "nine-symbol-scroll-content" } as? ViewGroup + ?: return@awaitMain emptyList() + (0 until content.childCount).mapNotNull { index -> + (content.getChildAt(index) as? TextView)?.text?.toString() + } + } + assertTrue("64 must keep ni as an available path: $paths", "ni" in paths) + assertTrue("64 must keep mi as an available path: $paths", "mi" in paths) + assertFalse("64 must not show the non-Pinyin spelling oh: $paths", "oh" in paths) + + val selected = harness.awaitMain { activity -> + val root = activity.findViewById(android.R.id.content) ?: return@awaitMain false + val content = findView(root) { it.tag == "nine-symbol-scroll-content" } as? ViewGroup + ?: return@awaitMain false + val choice = (0 until content.childCount) + .mapNotNull { content.getChildAt(it) as? TextView } + .firstOrNull { it.text.toString() == "mi" } + ?: return@awaitMain false + choice.performClick() + } + assertTrue("The mi path must be independently selectable", selected) + assertComposition("mi") + } + private fun enterChineseNineKey() { harness.awaitMain { activity -> val root = activity.findViewById(android.R.id.content) ?: return@awaitMain null diff --git a/app/src/androidTest/java/llc/slacker/openime/RimeNineKeyInstrumentedTest.kt b/app/src/androidTest/java/llc/slacker/openime/RimeNineKeyInstrumentedTest.kt index f0641f30..52bf7b61 100644 --- a/app/src/androidTest/java/llc/slacker/openime/RimeNineKeyInstrumentedTest.kt +++ b/app/src/androidTest/java/llc/slacker/openime/RimeNineKeyInstrumentedTest.kt @@ -1,9 +1,12 @@ package llc.slacker.openime import android.os.SystemClock +import android.util.Log import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.platform.app.InstrumentationRegistry +import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue +import org.junit.Assume.assumeTrue import org.junit.Test import org.junit.runner.RunWith @@ -56,4 +59,47 @@ class RimeNineKeyInstrumentedTest { rime.shutdown() } } + + @Test + fun auditProductionPresetsAgainstNativeRimePreeditWithoutPresetLayer() { + val instrumentation = InstrumentationRegistry.getInstrumentation() + assumeTrue( + "manual production preset audit; pass -e runPresetAudit true", + InstrumentationRegistry.getArguments().getString("runPresetAudit") == "true", + ) + val context = instrumentation.targetContext + val originalFuzzy = ImeSettingsRepository.loadFuzzy(context) + ImeSettingsRepository.saveFuzzy(context, false) + val rime = RimeEngine(context = context) + try { + rime.start() + val deadline = SystemClock.elapsedRealtime() + 1_200_000L + while (!rime.isReady && rime.errorMessage.isBlank() && SystemClock.elapsedRealtime() < deadline) { + SystemClock.sleep(100L) + } + assertTrue("librime failed to start: ${rime.errorMessage}", rime.isReady) + + val report = NineKeyPresets.combinations.map { (digits, expectedPaths) -> + val snapshot = RimeNative.nativeSetInput(digits).orEmpty() + val preedit = snapshot.getOrNull(1).orEmpty() + val normalized = preedit + .lowercase() + .filter { it in 'a'..'z' || it == 'ü' } + val matches = expectedPaths.any { expected -> + expected.lowercase().filter { it in 'a'..'z' || it == 'ü' } == normalized + } + Log.i( + "OpenImeNineKeyAudit", + "digits=$digits preedit=$preedit expected=${expectedPaths.joinToString("|")} matches=$matches", + ) + digits to matches + } + assertEquals(NineKeyPresets.combinations.size, report.size) + } finally { + ImeSettingsRepository.saveFuzzy(context, originalFuzzy) + rime.invalidateSettingsCache() + rime.shutdown() + } + } + } diff --git a/app/src/androidTest/java/llc/slacker/openime/SetupFormActionInstrumentedTest.kt b/app/src/androidTest/java/llc/slacker/openime/SetupFormActionInstrumentedTest.kt index 533bf06b..319b09bd 100644 --- a/app/src/androidTest/java/llc/slacker/openime/SetupFormActionInstrumentedTest.kt +++ b/app/src/androidTest/java/llc/slacker/openime/SetupFormActionInstrumentedTest.kt @@ -42,7 +42,10 @@ class SetupFormActionInstrumentedTest { ?: error("save button missing") assertFalse("Blank quick phrase must not be saveable", save.isEnabled) - assertTrue("Disabled save action must look disabled", save.alpha < 1f) + assertTrue( + "Disabled save action must use the shared disabled alpha", + save.alpha == ImeSurfacePolicy.DISABLED_ALPHA, + ) assertTrue( "Disabled save action must explain the missing content", save.contentDescription.toString().contains("不可用"), @@ -65,8 +68,11 @@ class SetupFormActionInstrumentedTest { harness.launch(intent = intent) harness.awaitMain { activity -> val category = activity.findViewById(R.id.quick_phrase_category_editor) + val code = activity.findViewById(R.id.quick_phrase_code_editor) val phrase = activity.findViewById(R.id.quick_phrase_text_editor) category.onEditorAction(EditorInfo.IME_ACTION_NEXT) + assertSame(code, activity.currentFocus) + code.onEditorAction(EditorInfo.IME_ACTION_NEXT) assertSame(phrase, activity.currentFocus) phrase.onEditorAction(EditorInfo.IME_ACTION_DONE) } diff --git a/app/src/androidTest/java/llc/slacker/openime/TextEditControlsInstrumentedTest.kt b/app/src/androidTest/java/llc/slacker/openime/TextEditControlsInstrumentedTest.kt index c466d41c..90fd4720 100644 --- a/app/src/androidTest/java/llc/slacker/openime/TextEditControlsInstrumentedTest.kt +++ b/app/src/androidTest/java/llc/slacker/openime/TextEditControlsInstrumentedTest.kt @@ -102,7 +102,10 @@ class TextEditControlsInstrumentedTest { assertNotNull("password editor must still show $label", control) assertFalse("password $label must not remain clickable", control!!.isClickable) assertFalse("password $label must expose an enabled state", control.isEnabled) - assertTrue("password $label should look unavailable", control.alpha < 1f) + assertTrue( + "password $label should use the shared disabled alpha", + control.alpha == ImeSurfacePolicy.DISABLED_ALPHA, + ) val description = control.contentDescription?.toString().orEmpty() assertTrue( "password $label must remain named when unavailable", diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 184638ee..60b250f3 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -8,7 +8,8 @@ android:allowBackup="false" android:dataExtractionRules="@xml/data_extraction_rules" android:fullBackupContent="@xml/backup_rules" - android:icon="@drawable/ic_launcher" + android:icon="@mipmap/ic_launcher" + android:roundIcon="@mipmap/ic_launcher_round" android:label="@string/app_name" android:supportsRtl="true" android:theme="@style/AppTheme"> @@ -27,21 +28,26 @@ + + 0 + 1 + 2 +and@@ 3 +筑 4 +陨 5 +眺 6 +塘 7 +檩 8 +衷 9 +氧 10 +孔 11 +阖 12 +邠 13 +坎 14 +喵 15 +曰 16 +鼠 17 +隐 18 +腊 19 +族 20 +矧 21 +敉 22 +俜 23 +似 24 +怫 25 +塔 26 +price 27 +春 28 +罍 29 +娅 30 +棉 31 +弃 32 +茱 33 +应 34 +汈 35 +擦 36 +贺 37 +鹇 38 +these 39 +迅 40 +诬 41 +do@@ 42 +盍 43 +秕 44 +啃 45 +颟 46 +辑 47 +彘 48 +ps 49 +斜 50 +瞭 51 +铟 52 +漭 53 +蹇 54 +旆 55 +窳 56 +臊 57 +览 58 +嘿 59 +淖 60 +尴 61 +袆 62 +斧 63 +筹 64 +媵 65 +挞 66 +臧 67 +齐 68 +璨 69 +笥 70 +滂 71 +即 72 +愔 73 +思 74 +gr@@ 75 +幅 76 +祛 77 +箬 78 +礁 79 +茅 80 +北 81 +澡 82 +俭 83 +蘅 84 +ing 85 +肺 86 +肢 87 +巢 88 +九 89 +蠓 90 +路 91 +藻 92 +沱 93 +ness 94 +璐 95 +积 96 +寞 97 +栳 98 +舆 99 +医 100 +眷 101 +岳 102 +勘 103 +璃 104 +黔 105 +犇 106 +哎 107 +罡 108 +k 109 +丝 110 +de 111 +跣 112 +梦 113 +需 114 +毅 115 +峡 116 +竞 117 +砦 118 +研 119 +眙 120 +滋 121 +鹳 122 +肝 123 +阼 124 +per 125 +忱 126 +乏 127 +废 128 +邦 129 +輶 130 +驯 131 +夫 132 +寳 133 +忪 134 +崑 135 +睾 136 +逗 137 +峰 138 +越 139 +狗 140 +蟒 141 +笆 142 +适 143 +洇 144 +缶 145 +ore 146 +辎 147 +粤 148 +蹴 149 +黄 150 +浞 151 +comp@@ 152 +犀 153 +藏 154 +本 155 +嗖 156 +黻 157 +這 158 +绰 159 +鉏 160 +麼 161 +喆 162 +袪 163 +刚 164 +侦 165 +ic@@ 166 +骧 167 +瓫 168 +柤 169 +桃 170 +鲴 171 +褙 172 +韵 173 +妓 174 +甍 175 +to@@ 176 +轱 177 +塑 178 +坯 179 +貌 180 +n@@ 181 +蕞 182 +疡 183 +伏 184 +酉 185 +暇 186 +霖 187 +了 188 +萸 189 +嘶 190 +nu@@ 191 +挼 192 +by 193 +聩 194 +袓 195 +嶓 196 +桎 197 +抖 198 +攥 199 +鞬 200 +毳 201 +旗 202 +庸 203 +呋 204 +诲 205 +tting 206 +狭 207 +魄 208 +伎 209 +喋 210 +樱 211 +翎 212 +怯 213 +场 214 +睽 215 +盒 216 +times 217 +鲳 218 +爆 219 +绵 220 +皋 221 +尢 222 +嘎 223 +渝 224 +迂 225 +嘁 226 +袷 227 +始 228 +奚 229 +台 230 +禄 231 +挢 232 +座 233 +绀 234 +漱 235 +龃 236 +tu@@ 237 +榫 238 +诛 239 +minu@@ 240 +萼 241 +裛 242 +玠 243 +谵 244 +亳 245 +副 246 +any@@ 247 +诊 248 +唑 249 +頫 250 +斿 251 +赁 252 +骇 253 +训 254 +母 255 +床 256 +微 257 +椰 258 +迢 259 +埃 260 +辏 261 +汩 262 +叟 263 +辔 264 +隙 265 +遵 266 +pp@@ 267 +翕 268 +佬 269 +栖 270 +踉 271 +皕 272 +苏 273 +痂 274 +奋 275 +阄 276 +悌 277 +点 278 +碡 279 +茎 280 +睫 281 +闫 282 +few 283 +籁 284 +孰 285 +拥 286 +for 287 +曾 288 +疲 289 +辞 290 +赖 291 +姆 292 +与 293 +诰 294 +怨 295 +沙 296 +with@@ 297 +睱 298 +谓 299 +晰 300 +嗌 301 +id 302 +aga@@ 303 +実 304 +魏 305 +鲟 306 +寅 307 +滗 308 +珲 309 +腑 310 +冠 311 +夺 312 +娶 313 +宇 314 +侩 315 +筘 316 +an 317 +磺 318 +邛 319 +着 320 +踵 321 +ite 322 +狝 323 +ele@@ 324 +蓼 325 +猿 326 +豆 327 +蔷 328 +沽 329 +去 330 +铥 331 +癀 332 +站 333 +甪 334 +璧 335 +范 336 +哓 337 +菏 338 +龠 339 +岷 340 +嫉 341 +拧 342 +札 343 +戒 344 +琏 345 +绪 346 +澌 347 +楠 348 +莱 349 +cer@@ 350 +here 351 +别 352 +帻 353 +嗉 354 +假 355 +拽 356 +髭 357 +穰 358 +勋 359 +栓 360 +塬 361 +ou@@ 362 +橫 363 +刻 364 +侣 365 +鎉 366 +bre@@ 367 +趸 368 +稳 369 +岌 370 +拎 371 +落 372 +岙 373 +氨 374 +桴 375 +鬶 376 +clu@@ 377 +蚣 378 +肌 379 +讹 380 +骼 381 +忧 382 +雹 383 +0@@ 384 +算 385 +腔 386 +璇 387 +酣 388 +锭 389 +蟾 390 +逦 391 +椟 392 +频 393 +ts 394 +矫 395 +拨 396 +珅 397 +侨 398 +蚨 399 +皯 400 +翛 401 +儋 402 +恳 403 +瀍 404 +敌 405 +砬 406 +奁 407 +耵 408 +烂 409 +绿 410 +缆 411 +辣 412 +time 413 +蔗 414 +too 415 +圾 416 +骞 417 +慥 418 +啶 419 +帔 420 +楢 421 +bi@@ 422 +蚤 423 +浛 424 +ine 425 +綦 426 +old 427 +肩 428 +擎 429 +ling 430 +瘆 431 +娲 432 +prob@@ 433 +暑 434 +鲨 435 +焚 436 +剽 437 +玚 438 +乔 439 +纪 440 +bo@@ 441 +熏 442 +毗 443 +鳙 444 +鞥 445 +絜 446 +糠 447 +菔 448 +廛 449 +谪 450 +冬 451 +遐 452 +衽 453 +ich 454 +柿 455 +峭 456 +渴 457 +亓 458 +荠 459 +蝠 460 +扆 461 +鄞 462 +诎 463 +尙 464 +摸 465 +牙 466 +薤 467 +da@@ 468 +w 469 +呜 470 +陂 471 +磻 472 +匹 473 +while 474 +迁 475 +良 476 +郤 477 +ct@@ 478 +蚕 479 +浥 480 +鲋 481 +腱 482 +pres@@ 483 +氕 484 +颉 485 +夥 486 +ter@@ 487 +左 488 +侉 489 +妍 490 +嚒 491 +殖 492 +私 493 +bas@@ 494 +锱 495 +篪 496 +吻 497 +鄅 498 +鳗 499 +疳 500 +cor@@ 501 +毛 502 +歼 503 +邵 504 +圪 505 +inn 506 +舛 507 +埗 508 +貉 509 +帐 510 +妮 511 +ged 512 +窒 513 +put 514 +诉 515 +堌 516 +气 517 +國 518 +摩 519 +沫 520 +谁 521 +转 522 +语 523 +琵 524 +羊 525 +檠 526 +慎 527 +踮 528 +啾 529 +瞻 530 +山 531 +播 532 +筌 533 +财 534 +飚 535 +苟 536 +扣 537 +mis@@ 538 +桌 539 +侑 540 +jo@@ 541 +ke@@ 542 +冶 543 +滔 544 +蠹 545 +呓 546 +捷 547 +证 548 +崞 549 +阵 550 +掐 551 +劳 552 +皆 553 +巧 554 +肏 555 +肆 556 +葆 557 +檄 558 +画 559 +狨 560 +谧 561 +傑 562 +诱 563 +纭 564 +荥 565 +厥 566 +bri@@ 567 +绁 568 +睇 569 +布 570 +江 571 +噎 572 +灞 573 +鄏 574 +煊 575 +蒹 576 +厮 577 +馓 578 +狴 579 +碍 580 +穷 581 +lar@@ 582 +食 583 +迩 584 +meeting 585 +tter 586 +趣 587 +辜 588 +椁 589 +汽 590 +燠 591 +ate 592 +礴 593 +骈 594 +will 595 +not 596 +煽 597 +嗳 598 +秆 599 +勤 600 +陆 601 +键 602 +墎 603 +官 604 +蘧 605 +酤 606 +唐 607 +颓 608 +仝 609 +iting 610 +田 611 +楫 612 +瑁 613 +鄢 614 +our 615 +诘 616 +venue 617 +霜 618 +镏 619 +痫 620 +娟 621 +婢 622 +埋 623 +汕 624 +铋 625 +徭 626 +隰 627 +猊 628 +卞 629 +慑 630 +said 631 +裘 632 +bus@@ 633 +召 634 +粱 635 +返 636 +缨 637 +纻 638 +磋 639 +炴 640 +凊 641 +曝 642 +兀 643 +洼 644 +杓 645 +榆 646 +hotels 647 +睹 648 +糯 649 +窘 650 +葩 651 +帮 652 +荷 653 +塌 654 +矍 655 +圯 656 +er@@ 657 +蚪 658 +篼 659 +咳 660 +吸 661 +喃 662 +岩 663 +嚟 664 +谱 665 +崆 666 +蟭 667 +wal@@ 668 +姗 669 +谛 670 +东 671 +菱 672 +ction 673 +肫 674 +祥 675 +述 676 +璋 677 +仙 678 +唇 679 +硕 680 +嘉 681 +醒 682 +兔 683 +恒 684 +银 685 +収 686 +cre@@ 687 +弭 688 +曌 689 +螯 690 +苡 691 +疾 692 +鸱 693 +权 694 +搔 695 +途 696 +茂 697 +皑 698 +补 699 +肃 700 +ns 701 +market 702 +讠 703 +阈 704 +机 705 +苒 706 +髙 707 +is@@ 708 +幢 709 +郐 710 +萎 711 +帛 712 +烖 713 +襟 714 +崟 715 +溶 716 +螺 717 +铲 718 +仉 719 +舌 720 +敖 721 +倍 722 +锏 723 +鸦 724 +夌 725 +埌 726 +腰 727 +雠 728 +ol@@ 729 +涙 730 +翳 731 +夹 732 +疏 733 +el 734 +驭 735 +鹯 736 +瑜 737 +薛 738 +奇 739 +娴 740 +靳 741 +镥 742 +伍 743 +暨 744 +what 745 +萧 746 +皴 747 +烦 748 +敷 749 +样 750 +ri@@ 751 +睆 752 +伦 753 +後 754 +祺 755 +贾 756 +汹 757 +箸 758 +掂 759 +械 760 +ob@@ 761 +赜 762 +黎 763 +潵 764 +鲽 765 +牒 766 +罐 767 +inter@@ 768 +冰 769 +成 770 +薳 771 +if 772 +堡 773 +搜 774 +漪 775 +赕 776 +症 777 +参 778 +鲫 779 +克 780 +浴 781 +薹 782 +梫 783 +调 784 +劢 785 +茹 786 +飐 787 +r 788 +her 789 +鲊 790 +妪 791 +惟 792 +榜 793 +谒 794 +梃 795 +twenty 796 +ni@@ 797 +猥 798 +兴 799 +氓 800 +肘 801 +饕 802 +陵 803 +within 804 +轲 805 +cted 806 +単 807 +嚗 808 +唼 809 +inte@@ 810 +钋 811 +抒 812 +郓 813 +牖 814 +蔻 815 +邂 816 +懦 817 +邻 818 +茸 819 +辈 820 +种 821 +黹 822 +hard 823 +悦 824 +综 825 +ved 826 +oo@@ 827 +used 828 +溱 829 +藁 830 +士 831 +猝 832 +芹 833 +char@@ 834 +we 835 +呒 836 +ss@@ 837 +岐 838 +桖 839 +迟 840 +荞 841 +哏 842 +仵 843 +抟 844 +淇 845 +il 846 +电 847 +褀 848 +果 849 +bir@@ 850 +ges 851 +暻 852 +椊 853 +吴 854 +sal@@ 855 +獐 856 +煲 857 +箥 858 +爰 859 +呣 860 +邶 861 +尻 862 +水 863 +sh@@ 864 +fore 865 +mat@@ 866 +浅 867 +枘 868 +稷 869 +艨 870 +唧 871 +can@@ 872 +eng 873 +know 874 +桠 875 +亵 876 +tou@@ 877 +伥 878 +虏 879 +綮 880 +sha@@ 881 +腌 882 +蔓 883 +莶 884 +戬 885 +鋆 886 +酥 887 +璜 888 +阐 889 +贶 890 +远 891 +验 892 +dre@@ 893 +赎 894 +惭 895 +裴 896 +晌 897 +铡 898 +愆 899 +隧 900 +祐 901 +貔 902 +片 903 +al 904 +抾 905 +柰 906 +探 907 +喈 908 +蟮 909 +潎 910 +批 911 +胴 912 +郁 913 +蓿 914 +稿 915 +傲 916 +垒 917 +甙 918 +靼 919 +chic@@ 920 +效 921 +栗 922 +齁 923 +仲 924 +担 925 +har@@ 926 +仟 927 +砂 928 +i 929 +舰 930 +吝 931 +慆 932 +芸 933 +why 934 +tic 935 +哥 936 +佩 937 +铀 938 +妊 939 +x 940 +刍 941 +殄 942 +躬 943 +莙 944 +客 945 +拚 946 +葬 947 +哼 948 +婵 949 +ance 950 +棺 951 +没 952 +崎 953 +曛 954 +宛 955 +斌 956 +掠 957 +醇 958 +庾 959 +黥 960 +乗 961 +旉 962 +铰 963 +寨 964 +钨 965 +沥 966 +gh@@ 967 +吇 968 +嬖 969 +厨 970 +箔 971 +ah 972 +譞 973 +鲖 974 +蒗 975 +銶 976 +角 977 +脏 978 +夬 979 +who 980 +毂 981 +赓 982 +嘢 983 +蹶 984 +駃 985 +avenue 986 +呃 987 +that's 988 +訾 989 +蒜 990 +就 991 +抿 992 +霫 993 +que@@ 994 +牁 995 +叻 996 +绌 997 +捣 998 +埭 999 +蛩 1000 +迤 1001 +ir 1002 +最 1003 +不 1004 +鹚 1005 +钧 1006 +晃 1007 +钍 1008 +岍 1009 +烯 1010 +授 1011 +笨 1012 +馔 1013 +甸 1014 +啰 1015 +赌 1016 +蠼 1017 +荆 1018 +濉 1019 +摹 1020 +剔 1021 +浪 1022 +瓦 1023 +涤 1024 +阬 1025 +eng@@ 1026 +墨 1027 +鲢 1028 +老 1029 +拮 1030 +轿 1031 +弈 1032 +秸 1033 +ken 1034 +省 1035 +穹 1036 +跨 1037 +芤 1038 +剰 1039 +湍 1040 +吥 1041 +喧 1042 +借 1043 +伯 1044 +咋 1045 +噢 1046 +剩 1047 +略 1048 +图 1049 +毕 1050 +爻 1051 +箭 1052 +ans@@ 1053 +no 1054 +缣 1055 +fic@@ 1056 +必 1057 +礻 1058 +视 1059 +侔 1060 +乸 1061 +缎 1062 +比 1063 +殉 1064 +禅 1065 +蹈 1066 +茶 1067 +沔 1068 +腹 1069 +更 1070 +倢 1071 +骑 1072 +俦 1073 +一 1074 +巉 1075 +糌 1076 +there 1077 +笾 1078 +泺 1079 +虫 1080 +随 1081 +室 1082 +谙 1083 +淞 1084 +even 1085 +嘌 1086 +掉 1087 +进 1088 +栈 1089 +隋 1090 +钳 1091 +饲 1092 +裾 1093 +搞 1094 +朽 1095 +嚏 1096 +垱 1097 +倘 1098 +sy@@ 1099 +蒂 1100 +訚 1101 +火 1102 +葱 1103 +踹 1104 +only 1105 +den@@ 1106 +胰 1107 +曦 1108 +汨 1109 +奴 1110 +院 1111 +晶 1112 +臇 1113 +赭 1114 +蚵 1115 +便 1116 +藜 1117 +鍪 1118 +穆 1119 +尿 1120 +find 1121 +偾 1122 +项 1123 +嬅 1124 +济 1125 +area 1126 +皿 1127 +蹽 1128 +af@@ 1129 +曈 1130 +ger 1131 +袭 1132 +温 1133 +包 1134 +惎 1135 +枝 1136 +槁 1137 +跑 1138 +汇 1139 +嫦 1140 +崒 1141 +颇 1142 +丐 1143 +丛 1144 +哠 1145 +鲲 1146 +佯 1147 +疱 1148 +來 1149 +彝 1150 +件 1151 +鸫 1152 +张 1153 +缋 1154 +檎 1155 +港 1156 +尸 1157 +comm@@ 1158 +瘘 1159 +囍 1160 +锅 1161 +惫 1162 +衔 1163 +蔚 1164 +龚 1165 +酱 1166 +ina 1167 +尚 1168 +孪 1169 +蔵 1170 +帧 1171 +弯 1172 +迄 1173 +訇 1174 +恕 1175 +紡 1176 +吱 1177 +觐 1178 +印 1179 +need 1180 +叭 1181 +茫 1182 +汶 1183 +邢 1184 +磅 1185 +焜 1186 +蜣 1187 +米 1188 +俎 1189 +ath 1190 +蛔 1191 +组 1192 +壹 1193 +诈 1194 +ing@@ 1195 +希 1196 +茨 1197 +砧 1198 +has 1199 +蝶 1200 +矛 1201 +拖 1202 +乍 1203 +浇 1204 +another 1205 +输 1206 +朗 1207 +殡 1208 +壶 1209 +灿 1210 +礌 1211 +钡 1212 +瓤 1213 +序 1214 +误 1215 +毖 1216 +静 1217 +鸾 1218 +墚 1219 +璟 1220 +咱 1221 +惘 1222 +化 1223 +腾 1224 +苍 1225 +苼 1226 +七 1227 +芾 1228 +囝 1229 +淄 1230 +馆 1231 +榉 1232 +荸 1233 +摧 1234 +醋 1235 +缦 1236 +帘 1237 +蛋 1238 +曙 1239 +萩 1240 +莉 1241 +犸 1242 +拜 1243 +特 1244 +蕊 1245 +并 1246 +冼 1247 +埝 1248 +茴 1249 +佶 1250 +噶 1251 +ked 1252 +port 1253 +柠 1254 +吶 1255 +竿 1256 +鞧 1257 +糙 1258 +栻 1259 +褂 1260 +杉 1261 +陛 1262 +shi@@ 1263 +朋 1264 +升 1265 +钛 1266 +拭 1267 +walk 1268 +钱 1269 +岸 1270 +衲 1271 +若 1272 +燕 1273 +墩 1274 +戛 1275 +ations 1276 +诳 1277 +冨 1278 +强 1279 +掌 1280 +腺 1281 +淤 1282 +鼍 1283 +妥 1284 +亥 1285 +俵 1286 +鹩 1287 +占 1288 +佤 1289 +棋 1290 +does 1291 +tes 1292 +拒 1293 +劼 1294 +绩 1295 +ren@@ 1296 +货 1297 +g@@ 1298 +深 1299 +钯 1300 +棬 1301 +墟 1302 +疼 1303 +骊 1304 +摅 1305 +祧 1306 +兊 1307 +坠 1308 +int 1309 +use 1310 +泞 1311 +赦 1312 +甾 1313 +葺 1314 +辘 1315 +炆 1316 +旭 1317 +鸯 1318 +茆 1319 +融 1320 +艄 1321 +晖 1322 +钺 1323 +勉 1324 +嘘 1325 +龛 1326 +蕙 1327 +渀 1328 +钞 1329 +写 1330 +弋 1331 +颦 1332 +灌 1333 +埚 1334 +鲷 1335 +亡 1336 +矩 1337 +轰 1338 +a 1339 +单 1340 +觚 1341 +呯 1342 +祏 1343 +rec@@ 1344 +逢 1345 +憧 1346 +蒽 1347 +內 1348 +乡 1349 +鸠 1350 +卜 1351 +庄 1352 +仰 1353 +how 1354 +铓 1355 +踝 1356 +隆 1357 +避 1358 +豌 1359 +low@@ 1360 +ak 1361 +劣 1362 +哺 1363 +头 1364 +proble@@ 1365 +es 1366 +说 1367 +哇 1368 +折 1369 +祝 1370 +偻 1371 +揆 1372 +的 1373 +盎 1374 +初 1375 +骝 1376 +荻 1377 +饷 1378 +耽 1379 +莸 1380 +just 1381 +簰 1382 +现 1383 +pl@@ 1384 +籍 1385 +珉 1386 +蕲 1387 +臌 1388 +闪 1389 +崮 1390 +gra@@ 1391 +琯 1392 +圆 1393 +瓴 1394 +赬 1395 +镧 1396 +被 1397 +共 1398 +芯 1399 +蚧 1400 +stu@@ 1401 +mee@@ 1402 +沧 1403 +伲 1404 +觌 1405 +筏 1406 +庑 1407 +still 1408 +题 1409 +wat@@ 1410 +4 1411 +绱 1412 +入 1413 +亚 1414 +sho@@ 1415 +珫 1416 +饴 1417 +點 1418 +than 1419 +good 1420 +l@@ 1421 +梁 1422 +忿 1423 +荐 1424 +躺 1425 +蹡 1426 +呕 1427 +圩 1428 +唷 1429 +陌 1430 +ue 1431 +鲭 1432 +碗 1433 +怪 1434 +飘 1435 +country 1436 +粑 1437 +怹 1438 +飕 1439 +烨 1440 +吹 1441 +嵇 1442 +驺 1443 +纰 1444 +in@@ 1445 +间 1446 +馈 1447 +榑 1448 +窜 1449 +泗 1450 +硪 1451 +躏 1452 +th 1453 +耸 1454 +贞 1455 +wom@@ 1456 +排 1457 +箩 1458 +绽 1459 +舵 1460 +焉 1461 +振 1462 +镶 1463 +thirty 1464 +闲 1465 +摁 1466 +堰 1467 +牵 1468 +栋 1469 +堤 1470 +馀 1471 +盟 1472 +t 1473 +旄 1474 +凇 1475 +洣 1476 +録 1477 +韭 1478 +por@@ 1479 +孑 1480 +茄 1481 +闺 1482 +淀 1483 +坡 1484 +烟 1485 +洺 1486 +gre@@ 1487 +敦 1488 +哉 1489 +到 1490 +ding 1491 +遑 1492 +钒 1493 +壳 1494 +lo 1495 +纾 1496 +砲 1497 +灶 1498 +lee 1499 +玘 1500 +up 1501 +梵 1502 +旖 1503 +佗 1504 +竽 1505 +绋 1506 +砩 1507 +酒 1508 +苯 1509 +焕 1510 +祚 1511 +苁 1512 +嗓 1513 +ail@@ 1514 +殽 1515 +om@@ 1516 +棨 1517 +翼 1518 +墼 1519 +萄 1520 +垭 1521 +碱 1522 +cts 1523 +渲 1524 +矱 1525 +掇 1526 +best 1527 +锃 1528 +谶 1529 +喜 1530 +雌 1531 +辊 1532 +啀 1533 +嗞 1534 +谢 1535 +疹 1536 +玎 1537 +唤 1538 +兆 1539 +彳 1540 +溧 1541 +丕 1542 +棒 1543 +桁 1544 +樓 1545 +跟 1546 +蝼 1547 +哭 1548 +啭 1549 +替 1550 +乩 1551 +箪 1552 +城 1553 +朾 1554 +ear@@ 1555 +鲌 1556 +ship 1557 +吕 1558 +粉 1559 +舜 1560 +伛 1561 +觏 1562 +燮 1563 +铊 1564 +硝 1565 +撤 1566 +瘝 1567 +thanks 1568 +锵 1569 +圣 1570 +contin@@ 1571 +侬 1572 +浮 1573 +棵 1574 +歭 1575 +ici@@ 1576 +珞 1577 +褔 1578 +券 1579 +演 1580 +箫 1581 +缵 1582 +篾 1583 +鲮 1584 +砒 1585 +含 1586 +郡 1587 +快 1588 +栏 1589 +瘟 1590 +饤 1591 +tw@@ 1592 +拃 1593 +盹 1594 +壕 1595 +桯 1596 +嗪 1597 +鞨 1598 +甏 1599 +锫 1600 +涕 1601 +冕 1602 +鄣 1603 +淌 1604 +辰 1605 +唿 1606 +暲 1607 +蚀 1608 +跋 1609 +郸 1610 +镀 1611 +ku@@ 1612 +赔 1613 +姺 1614 +课 1615 +础 1616 +耷 1617 +涪 1618 +day 1619 +笳 1620 +away 1621 +稞 1622 +鹈 1623 +珍 1624 +毯 1625 +酮 1626 +汀 1627 +梆 1628 +嫫 1629 +准 1630 +ces 1631 +巷 1632 +晋 1633 +肉 1634 +莆 1635 +痢 1636 +缗 1637 +怜 1638 +鄙 1639 +搠 1640 +fri@@ 1641 +仳 1642 +该 1643 +宓 1644 +珂 1645 +圉 1646 +弨 1647 +悬 1648 +buil@@ 1649 +绸 1650 +太 1651 +外 1652 +祢 1653 +蓍 1654 +圹 1655 +侓 1656 +跸 1657 +谊 1658 +获 1659 +髈 1660 +迮 1661 +鹤 1662 +卦 1663 +嗻 1664 +佐 1665 +愠 1666 +媲 1667 +殍 1668 +齉 1669 +妹 1670 +残 1671 +嗄 1672 +钾 1673 +court 1674 +踞 1675 +脯 1676 +菖 1677 +琼 1678 +傻 1679 +三 1680 +虿 1681 +唪 1682 +逶 1683 +鲤 1684 +镌 1685 +肇 1686 +弘 1687 +李 1688 +履 1689 +恩 1690 +蒌 1691 +夙 1692 +环 1693 +坒 1694 +gar@@ 1695 +ans 1696 +嘣 1697 +嵯 1698 +命 1699 +酢 1700 +屏 1701 +鈇 1702 +麟 1703 +旨 1704 +旼 1705 +疮 1706 +with 1707 +解 1708 +屈 1709 +趴 1710 +蠛 1711 +密 1712 +瞩 1713 +屎 1714 +显 1715 +魁 1716 +衯 1717 +钇 1718 +酩 1719 +鳌 1720 +戆 1721 +芈 1722 +十 1723 +gu@@ 1724 +陪 1725 +黑 1726 +缌 1727 +ch@@ 1728 +摇 1729 +梨 1730 +胼 1731 +撷 1732 +疤 1733 +砟 1734 +el@@ 1735 +唛 1736 +芪 1737 +速 1738 +ol 1739 +细 1740 +馥 1741 +犰 1742 +bal@@ 1743 +鲇 1744 +韂 1745 +焰 1746 +胗 1747 +粹 1748 +枌 1749 +嵬 1750 +古 1751 +she 1752 +through 1753 +筛 1754 +翀 1755 +协 1756 +se 1757 +魃 1758 +格 1759 +mes 1760 +晥 1761 +跱 1762 +掺 1763 +阕 1764 +智 1765 +松 1766 +st@@ 1767 +靠 1768 +斟 1769 +粒 1770 +舞 1771 +瀣 1772 +棅 1773 +茭 1774 +韫 1775 +鐎 1776 +灵 1777 +龢 1778 +卷 1779 +lion 1780 +曹 1781 +哒 1782 +皝 1783 +哲 1784 +pe@@ 1785 +患 1786 +逸 1787 +涠 1788 +蛰 1789 +佣 1790 +猇 1791 +狈 1792 +nine 1793 +囫 1794 +风 1795 +态 1796 +慈 1797 +慜 1798 +俨 1799 +汲 1800 +肛 1801 +隶 1802 +坩 1803 +赍 1804 +海 1805 +癫 1806 +my 1807 +委 1808 +ill 1809 +胤 1810 +覩 1811 +臬 1812 +矶 1813 +炷 1814 +衬 1815 +前 1816 +馊 1817 +伽 1818 +艳 1819 +妗 1820 +肠 1821 +檗 1822 +soon 1823 +氙 1824 +琅 1825 +谏 1826 +light 1827 +変 1828 +seven@@ 1829 +旸 1830 +芭 1831 +en@@ 1832 +烧 1833 +诃 1834 +攘 1835 +陧 1836 +觅 1837 +铑 1838 +氐 1839 +余 1840 +night 1841 +hou@@ 1842 +鹀 1843 +膜 1844 +炙 1845 +抨 1846 +珊 1847 +ses 1848 +漩 1849 +both 1850 +桉 1851 +笺 1852 +鎛 1853 +led 1854 +披 1855 +膛 1856 +蜻 1857 +菽 1858 +娼 1859 +団 1860 +揽 1861 +测 1862 +f@@ 1863 +芎 1864 +吅 1865 +sion 1866 +遹 1867 +瓘 1868 +慕 1869 +他 1870 +鄩 1871 +矽 1872 +thou@@ 1873 +沒 1874 +唁 1875 +匿 1876 +设 1877 +嵖 1878 +髹 1879 +ine@@ 1880 +恸 1881 +窣 1882 +-@@ 1883 +街 1884 +膝 1885 +碑 1886 +national 1887 +it@@ 1888 +瘢 1889 +ci@@ 1890 +侮 1891 +l 1892 +陉 1893 +照 1894 +原 1895 +厐 1896 +悚 1897 +答 1898 +犷 1899 +罔 1900 +绘 1901 +敞 1902 +ys 1903 +捆 1904 +殚 1905 +填 1906 +挟 1907 +tal@@ 1908 +萌 1909 +卑 1910 +甃 1911 +吉 1912 +蜮 1913 +帑 1914 +笖 1915 +new 1916 +昺 1917 +诤 1918 +襜 1919 +矗 1920 +藠 1921 +苓 1922 +th@@ 1923 +哝 1924 +its 1925 +蚋 1926 +ran@@ 1927 +澳 1928 +eight 1929 +贱 1930 +傕 1931 +亦 1932 +续 1933 +槐 1934 +筚 1935 +追 1936 +醺 1937 +錾 1938 +蹒 1939 +玟 1940 +look 1941 +圃 1942 +颗 1943 +旎 1944 +圮 1945 +绷 1946 +op@@ 1947 +咙 1948 +槃 1949 +冫 1950 +乳 1951 +鸣 1952 +柴 1953 +蚴 1954 +擞 1955 +锴 1956 +姣 1957 +惯 1958 +管 1959 +奎 1960 +ra@@ 1961 +瞠 1962 +侍 1963 +恵 1964 +岬 1965 +喎 1966 +摭 1967 +卺 1968 +wee@@ 1969 +羧 1970 +cep@@ 1971 +fron@@ 1972 +妁 1973 +很 1974 +禹 1975 +巯 1976 +夼 1977 +鄫 1978 +re@@ 1979 +动 1980 +迓 1981 +狐 1982 +瑕 1983 +棹 1984 +屹 1985 +皈 1986 +陔 1987 +殛 1988 +仿 1989 +蝥 1990 +缘 1991 +镫 1992 +品 1993 +ase 1994 +row 1995 +缜 1996 +stance 1997 +予 1998 +custom@@ 1999 +抬 2000 +鞶 2001 +蛘 2002 +埏 2003 +漂 2004 +凝 2005 +虻 2006 +姒 2007 +痒 2008 +邝 2009 +ss 2010 +战 2011 +悴 2012 +spe@@ 2013 +羝 2014 +吮 2015 +锗 2016 +湘 2017 +端 2018 +淸 2019 +孢 2020 +3 2021 +郴 2022 +卬 2023 +fif@@ 2024 +濯 2025 +射 2026 +簏 2027 +锌 2028 +啖 2029 +懑 2030 +霪 2031 +棻 2032 +簺 2033 +怅 2034 +g 2035 +毎 2036 +犳 2037 +ffe@@ 2038 +镉 2039 +閦 2040 +吲 2041 +驹 2042 +are@@ 2043 +埇 2044 +心 2045 +漴 2046 +娃 2047 +侯 2048 +蔽 2049 +值 2050 +鲧 2051 +fr@@ 2052 +ful 2053 +嘹 2054 +滥 2055 +騠 2056 +ility 2057 +喹 2058 +悉 2059 +嗽 2060 +些 2061 +硁 2062 +mer@@ 2063 +磊 2064 +霆 2065 +麝 2066 +曲 2067 +蜃 2068 +police 2069 +镩 2070 +笪 2071 +苾 2072 +靑 2073 +凼 2074 +多 2075 +质 2076 +缇 2077 +嗫 2078 +沏 2079 +ened 2080 +花 2081 +诹 2082 +尉 2083 +珥 2084 +崩 2085 +ld 2086 +x@@ 2087 +揎 2088 +纷 2089 +缂 2090 +轹 2091 +庙 2092 +渚 2093 +鸪 2094 +乒 2095 +惧 2096 +peop@@ 2097 +歌 2098 +唾 2099 +樘 2100 +膺 2101 +fro@@ 2102 +哂 2103 +腼 2104 +霄 2105 +坞 2106 +霰 2107 +掎 2108 +娿 2109 +镬 2110 +巨 2111 +碇 2112 +藩 2113 +活 2114 +荤 2115 +团 2116 +缪 2117 +钵 2118 +飞 2119 +儡 2120 +苤 2121 +貊 2122 +柄 2123 +蓠 2124 +防 2125 +贮 2126 +碾 2127 +狞 2128 +艏 2129 +喏 2130 +稚 2131 +映 2132 +i'm 2133 +谤 2134 +蜿 2135 +车 2136 +乂 2137 +寕 2138 +啧 2139 +虔 2140 +ster@@ 2141 +垣 2142 +嗛 2143 +讪 2144 +ves 2145 +again 2146 +隗 2147 +帜 2148 +嗾 2149 +绂 2150 +公 2151 +卮 2152 +抱 2153 +仕 2154 +以 2155 +栘 2156 +拊 2157 +萤 2158 +him 2159 +荪 2160 +淬 2161 +7 2162 +鋹 2163 +敢 2164 +颖 2165 +ment 2166 +嫩 2167 +棕 2168 +show 2169 +跩 2170 +out@@ 2171 +汤 2172 +迕 2173 +榨 2174 +暗 2175 +糍 2176 +晡 2177 +9 2178 +稂 2179 +曼 2180 +蒺 2181 +ture 2182 +鬄 2183 +逅 2184 +岚 2185 +芟 2186 +昶 2187 +埤 2188 +幺 2189 +猖 2190 +伙 2191 +pub@@ 2192 +南 2193 +荨 2194 +趁 2195 +淑 2196 +嘲 2197 +悔 2198 +藉 2199 +争 2200 +渔 2201 +pool 2202 +簟 2203 +谀 2204 +噘 2205 +窀 2206 +祟 2207 +阜 2208 +涸 2209 +掖 2210 +癃 2211 +疑 2212 +搢 2213 +漏 2214 +锉 2215 +钹 2216 +耱 2217 +踢 2218 +骎 2219 +稣 2220 +锲 2221 +繇 2222 +缊 2223 +劈 2224 +啻 2225 +蕴 2226 +仔 2227 +昝 2228 +且 2229 +滚 2230 +柢 2231 +镊 2232 +响 2233 +凰 2234 +噗 2235 +瑴 2236 +嗔 2237 +简 2238 +蜇 2239 +有 2240 +豢 2241 +ap@@ 2242 +啓 2243 +翅 2244 +愤 2245 +peri@@ 2246 +蚶 2247 +弄 2248 +禨 2249 +蚡 2250 +坝 2251 +换 2252 +纨 2253 +蹑 2254 +for@@ 2255 +草 2256 +荛 2257 +懈 2258 +奉 2259 +鳊 2260 +疗 2261 +搂 2262 +串 2263 +幸 2264 +岽 2265 +牍 2266 +蝰 2267 +絶 2268 +秣 2269 +缴 2270 +at 2271 +网 2272 +嗑 2273 +岗 2274 +绊 2275 +圳 2276 +恁 2277 +反 2278 +方 2279 +癞 2280 +煞 2281 +雪 2282 +尤 2283 +鐧 2284 +麒 2285 +黡 2286 +殷 2287 +都 2288 +则 2289 +剃 2290 +揄 2291 +毐 2292 +噱 2293 +fi 2294 +氹 2295 +泠 2296 +樾 2297 +迳 2298 +嫚 2299 +齿 2300 +殳 2301 +墒 2302 +役 2303 +晟 2304 +咔 2305 +芃 2306 +睁 2307 +柽 2308 +戍 2309 +屺 2310 +虱 2311 +韦 2312 +涅 2313 +姚 2314 +鋈 2315 +sure 2316 +既 2317 +涯 2318 +甯 2319 +嘤 2320 +硞 2321 +som@@ 2322 +惴 2323 +狻 2324 +堑 2325 +屉 2326 +愿 2327 +li@@ 2328 +行 2329 +谲 2330 +嶂 2331 +峣 2332 +碜 2333 +暂 2334 +h@@ 2335 +鏖 2336 +瘊 2337 +蜈 2338 +浈 2339 +萦 2340 +职 2341 +蚊 2342 +汴 2343 +people 2344 +妱 2345 +鸰 2346 +易 2347 +芜 2348 +挪 2349 +影 2350 +竹 2351 +洸 2352 +烀 2353 +鹘 2354 +胜 2355 +兵 2356 +咧 2357 +楷 2358 +币 2359 +妖 2360 +ant 2361 +臣 2362 +桩 2363 +创 2364 +囹 2365 +na@@ 2366 +鞑 2367 +楂 2368 +逡 2369 +惆 2370 +卿 2371 +闱 2372 +耀 2373 +那 2374 +童 2375 +钰 2376 +玮 2377 +郄 2378 +昏 2379 +乘 2380 +钩 2381 +晳 2382 +笼 2383 +核 2384 +芙 2385 +小 2386 +忋 2387 +区 2388 +as@@ 2389 +颢 2390 +our@@ 2391 +that 2392 +稻 2393 +销 2394 +韶 2395 +刑 2396 +延 2397 +k@@ 2398 +teen 2399 +幄 2400 +pic@@ 2401 +叱 2402 +骷 2403 +棰 2404 +羁 2405 +垝 2406 +犴 2407 +媱 2408 +兄 2409 +尓 2410 +乞 2411 +鲦 2412 +划 2413 +壬 2414 +芡 2415 +hotel 2416 +佃 2417 +氯 2418 +您 2419 +颌 2420 +汝 2421 +缫 2422 +幂 2423 +竣 2424 +喾 2425 +疥 2426 +long 2427 +广 2428 +镂 2429 +酫 2430 +ings 2431 +ood 2432 +柊 2433 +唣 2434 +辽 2435 +稀 2436 +襞 2437 +讼 2438 +篱 2439 +坻 2440 +袂 2441 +华 2442 +自 2443 +歧 2444 +昂 2445 +摺 2446 +gh 2447 +聿 2448 +犟 2449 +敛 2450 +牺 2451 +旳 2452 +锥 2453 +玛 2454 +低 2455 +鄮 2456 +漳 2457 +叠 2458 +川 2459 +呼 2460 +where 2461 +戳 2462 +嗮 2463 +琦 2464 +厓 2465 +窠 2466 +cas@@ 2467 +舷 2468 +甦 2469 +凛 2470 +谖 2471 +旷 2472 +沌 2473 +狒 2474 +溉 2475 +绍 2476 +劲 2477 +滟 2478 +in 2479 +褊 2480 +fam@@ 2481 +楽 2482 +金 2483 +磕 2484 +see 2485 +斩 2486 +佛 2487 +壅 2488 +境 2489 +诂 2490 +around 2491 +羑 2492 +浆 2493 +矜 2494 +铈 2495 +provi@@ 2496 +藐 2497 +伉 2498 +阶 2499 +哀 2500 +潼 2501 +精 2502 +像 2503 +凶 2504 +琇 2505 +秧 2506 +涂 2507 +豫 2508 +镒 2509 +蒟 2510 +叹 2511 +颜 2512 +莫 2513 +阀 2514 +痕 2515 +爬 2516 +嬲 2517 +滓 2518 +牮 2519 +沐 2520 +璈 2521 +窸 2522 +湮 2523 +喊 2524 +徘 2525 +而 2526 +仞 2527 +蛆 2528 +吵 2529 +栟 2530 +郯 2531 +谄 2532 +膑 2533 +垯 2534 +恰 2535 +筠 2536 +淝 2537 +剌 2538 +vie@@ 2539 +估 2540 +first 2541 +渊 2542 +鶗 2543 +缬 2544 +踺 2545 +呦 2546 +宄 2547 +颎 2548 +蔼 2549 +挒 2550 +亹 2551 +墉 2552 +倧 2553 +梪 2554 +猱 2555 +顼 2556 +泫 2557 +鸳 2558 +赠 2559 +聋 2560 +鬲 2561 +隽 2562 +胚 2563 +驱 2564 +丶 2565 +邪 2566 +鲚 2567 +韩 2568 +婆 2569 +sed 2570 +it 2571 +审 2572 +屠 2573 +众 2574 +翩 2575 +铺 2576 +磨 2577 +醲 2578 +瘼 2579 +佑 2580 +霹 2581 +臀 2582 +坮 2583 +俯 2584 +舸 2585 +辍 2586 +谗 2587 +甥 2588 +祭 2589 +tell 2590 +商 2591 +ace 2592 +宾 2593 +骡 2594 +浍 2595 +冉 2596 +肾 2597 +im@@ 2598 +win@@ 2599 +甬 2600 +蹚 2601 +粕 2602 +脖 2603 +遽 2604 +next 2605 +expe@@ 2606 +榕 2607 +蹂 2608 +邹 2609 +stru@@ 2610 +沁 2611 +宸 2612 +旮 2613 +锁 2614 +侂 2615 +拢 2616 +辫 2617 +仁 2618 +be 2619 +洱 2620 +摘 2621 +律 2622 +预 2623 +徕 2624 +鬣 2625 +挠 2626 +戟 2627 +嘴 2628 +杖 2629 +骍 2630 +劵 2631 +哮 2632 +雁 2633 +擀 2634 +鴐 2635 +衎 2636 +芮 2637 +据 2638 +霭 2639 +com@@ 2640 +俗 2641 +伝 2642 +ory 2643 +轭 2644 +博 2645 +谐 2646 +孺 2647 +te@@ 2648 +锹 2649 +瞥 2650 +导 2651 +糜 2652 +堙 2653 +乾 2654 +搌 2655 +鏐 2656 +你 2657 +con@@ 2658 +琍 2659 +art 2660 +徇 2661 +塞 2662 +讽 2663 +瞄 2664 +rence 2665 +溢 2666 +卉 2667 +逞 2668 +阮 2669 +阊 2670 +婊 2671 +mil@@ 2672 +专 2673 +姜 2674 +浉 2675 +府 2676 +sing@@ 2677 +嗵 2678 +哨 2679 +砺 2680 +吋 2681 +闹 2682 +败 2683 +居 2684 +娓 2685 +ce 2686 +囟 2687 +楼 2688 +元 2689 +鲥 2690 +嗙 2691 +tely 2692 +帷 2693 +還 2694 +懋 2695 +欷 2696 +ong 2697 +郝 2698 +丨 2699 +breakfast 2700 +崴 2701 +橼 2702 +停 2703 +沾 2704 +under@@ 2705 +tion@@ 2706 +非 2707 +堵 2708 +仆 2709 +铗 2710 +难 2711 +蛑 2712 +狙 2713 +找 2714 +熠 2715 +over@@ 2716 +檀 2717 +鸩 2718 +檐 2719 +彀 2720 +蟋 2721 +腚 2722 +槟 2723 +泄 2724 +舅 2725 +痼 2726 +秤 2727 +氆 2728 +罄 2729 +啼 2730 +啡 2731 +冽 2732 +疎 2733 +嵨 2734 +吿 2735 +航 2736 +采 2737 +mb@@ 2738 +裟 2739 +檿 2740 +辆 2741 +眍 2742 +溃 2743 +can 2744 +唻 2745 +媖 2746 +佺 2747 +狰 2748 +仪 2749 +rent 2750 +沓 2751 +话 2752 +霾 2753 +婷 2754 +雨 2755 +eigh@@ 2756 +白 2757 +瞧 2758 +澎 2759 +洞 2760 +阔 2761 +ta@@ 2762 +侧 2763 +躇 2764 +莘 2765 +骏 2766 +宰 2767 +縠 2768 +birth 2769 +萃 2770 +men@@ 2771 +秉 2772 +轮 2773 +刹 2774 +fl@@ 2775 +鸮 2776 +忾 2777 +胖 2778 +攫 2779 +磁 2780 +飧 2781 +鲈 2782 +邙 2783 +阌 2784 +皂 2785 +危 2786 +搹 2787 +靺 2788 +唔 2789 +撴 2790 +柝 2791 +垮 2792 +膈 2793 +辋 2794 +榷 2795 +邘 2796 +锂 2797 +戚 2798 +蔹 2799 +粝 2800 +翊 2801 +攵 2802 +悯 2803 +涝 2804 +媞 2805 +俤 2806 +镲 2807 +梳 2808 +蓥 2809 +艾 2810 +guest 2811 +顿 2812 +譬 2813 +兹 2814 +囚 2815 +倌 2816 +遣 2817 +朔 2818 +such 2819 +篷 2820 +囷 2821 +宫 2822 +戊 2823 +嵎 2824 +娉 2825 +箅 2826 +檫 2827 +玷 2828 +please 2829 +彰 2830 +蜓 2831 +怃 2832 +癯 2833 +怛 2834 +镇 2835 +还 2836 +诿 2837 +庞 2838 +开 2839 +节 2840 +卒 2841 +逵 2842 +颔 2843 +杰 2844 +蘸 2845 +楚 2846 +颍 2847 +吐 2848 +堃 2849 +澜 2850 +弧 2851 +流 2852 +堍 2853 +严 2854 +焱 2855 +on 2856 +纬 2857 +巽 2858 +确 2859 +子 2860 +紙 2861 +沭 2862 +戞 2863 +屙 2864 +胭 2865 +劫 2866 +珧 2867 +信 2868 +樵 2869 +讴 2870 +豺 2871 +叽 2872 +钎 2873 +霁 2874 +瀛 2875 +糟 2876 +噌 2877 +豝 2878 +湜 2879 +洎 2880 +菌 2881 +悆 2882 +ree 2883 +凯 2884 +徜 2885 +郏 2886 +today 2887 +勾 2888 +嬉 2889 +螵 2890 +戕 2891 +璞 2892 +忝 2893 +俞 2894 +言 2895 +庵 2896 +贼 2897 +费 2898 +kind 2899 +扁 2900 +骁 2901 +咪 2902 +凿 2903 +讳 2904 +掊 2905 +ated 2906 +苄 2907 +鳝 2908 +噍 2909 +茧 2910 +govern@@ 2911 +筼 2912 +颋 2913 +愛 2914 +渭 2915 +踟 2916 +罪 2917 +汔 2918 +踩 2919 +陽 2920 +疽 2921 +闵 2922 +我 2923 +蒡 2924 +缠 2925 +曺 2926 +婪 2927 +农 2928 +露 2929 +染 2930 +sent 2931 +氽 2932 +et 2933 +咷 2934 +圧 2935 +咀 2936 +site 2937 +sti@@ 2938 +梗 2939 +water 2940 +舔 2941 +嚣 2942 +蜉 2943 +逖 2944 +湄 2945 +栅 2946 +刳 2947 +薢 2948 +ally 2949 +诸 2950 +藕 2951 +钔 2952 +伋 2953 +莜 2954 +硬 2955 +窟 2956 +sa@@ 2957 +愚 2958 +蟪 2959 +秩 2960 +雯 2961 +褚 2962 +鹎 2963 +泃 2964 +ner 2965 +ast 2966 +菜 2967 +晦 2968 +枨 2969 +偲 2970 +嚩 2971 +遴 2972 +su@@ 2973 +掸 2974 +千 2975 +馄 2976 +功 2977 +胺 2978 +rep@@ 2979 +涡 2980 +ther 2981 +孩 2982 +液 2983 +狲 2984 +业 2985 +巡 2986 +脍 2987 +甚 2988 +珜 2989 +郜 2990 +蔑 2991 +疔 2992 +庚 2993 +硌 2994 +裉 2995 +骘 2996 +sequ@@ 2997 +迎 2998 +盖 2999 +噪 3000 +尺 3001 +咒 3002 +蜕 3003 +店 3004 +镐 3005 +蝉 3006 +宝 3007 +卍 3008 +弩 3009 +学 3010 +猁 3011 +犊 3012 +妄 3013 +葭 3014 +every@@ 3015 +螋 3016 +馃 3017 +ating 3018 +壮 3019 +熟 3020 +rela@@ 3021 +嗬 3022 +约 3023 +锞 3024 +呫 3025 +护 3026 +磒 3027 +疙 3028 +羞 3029 +绦 3030 +铳 3031 +掕 3032 +宗 3033 +荀 3034 +玢 3035 +ser@@ 3036 +啦 3037 +氪 3038 +盯 3039 +疸 3040 +鬐 3041 +绚 3042 +锡 3043 +鬻 3044 +瓮 3045 +麸 3046 +旱 3047 +娱 3048 +敕 3049 +跄 3050 +烘 3051 +蠕 3052 +te 3053 +诽 3054 +重 3055 +翠 3056 +珑 3057 +慰 3058 +鲍 3059 +勣 3060 +袱 3061 +瑙 3062 +tly 3063 +庆 3064 +government 3065 +荦 3066 +阗 3067 +烫 3068 +倓 3069 +俏 3070 +鸹 3071 +倦 3072 +ound 3073 +co@@ 3074 +竟 3075 +腋 3076 +昙 3077 +濂 3078 +啋 3079 +揶 3080 +泣 3081 +郾 3082 +垍 3083 +轳 3084 +某 3085 +酎 3086 +板 3087 +晤 3088 +廑 3089 +奶 3090 +醴 3091 +镑 3092 +讣 3093 +缤 3094 +龅 3095 +畿 3096 +脁 3097 +ma@@ 3098 +醢 3099 +嗟 3100 +丗 3101 +殿 3102 +魅 3103 +熨 3104 +wr@@ 3105 +嚷 3106 +彤 3107 +栎 3108 +americ@@ 3109 +谡 3110 +泽 3111 +柬 3112 +髎 3113 +盆 3114 +诅 3115 +瘁 3116 +萘 3117 +喁 3118 +媒 3119 +忸 3120 +阍 3121 +曡 3122 +裰 3123 +锦 3124 +something 3125 +犋 3126 +爱 3127 +煺 3128 +揉 3129 +苇 3130 +嘈 3131 +胪 3132 +铁 3133 +屁 3134 +颂 3135 +锩 3136 +骅 3137 +渌 3138 +邱 3139 +脬 3140 +滏 3141 +罹 3142 +散 3143 +鼹 3144 +父 3145 +摔 3146 +边 3147 +申 3148 +苛 3149 +敝 3150 +冈 3151 +蕈 3152 +郕 3153 +耨 3154 +闳 3155 +逄 3156 +拔 3157 +将 3158 +fe 3159 +鲂 3160 +颙 3161 +0 3162 +room 3163 +胙 3164 +澍 3165 +媚 3166 +廪 3167 +量 3168 +贩 3169 +镕 3170 +nine@@ 3171 +恚 3172 +鹾 3173 +旃 3174 +铵 3175 +堺 3176 +剑 3177 +ket 3178 +支 3179 +墙 3180 +洒 3181 +俳 3182 +ors 3183 +诐 3184 +黟 3185 +珩 3186 +跻 3187 +浸 3188 +孟 3189 +mp@@ 3190 +狷 3191 +踌 3192 +渎 3193 +逝 3194 +颁 3195 +务 3196 +羹 3197 +羖 3198 +阽 3199 +跪 3200 +褒 3201 +乜 3202 +择 3203 +盩 3204 +鸭 3205 +抗 3206 +递 3207 +褪 3208 +怂 3209 +cou@@ 3210 +蟠 3211 +cen@@ 3212 +傜 3213 +砍 3214 +鲔 3215 +蹙 3216 +restaurant 3217 +杆 3218 +茵 3219 +尊 3220 +耻 3221 +淮 3222 +躜 3223 +蜡 3224 +嘱 3225 +谩 3226 +蝗 3227 +堞 3228 +姨 3229 +happ@@ 3230 +铿 3231 +楝 3232 +park 3233 +力 3234 +殊 3235 +畹 3236 +say 3237 +练 3238 +纱 3239 +溪 3240 +虮 3241 +篑 3242 +蜱 3243 +惜 3244 +跌 3245 +啁 3246 +溜 3247 +饼 3248 +裥 3249 +勇 3250 +柱 3251 +惨 3252 +陟 3253 +殪 3254 +安 3255 +徼 3256 +纛 3257 +痍 3258 +谨 3259 +取 3260 +犒 3261 +鄄 3262 +粟 3263 +眨 3264 +坤 3265 +妤 3266 +剟 3267 +雉 3268 +傩 3269 +嫄 3270 +嘧 3271 +咽 3272 +愀 3273 +簸 3274 +赏 3275 +箾 3276 +龈 3277 +放 3278 +菅 3279 +坚 3280 +奠 3281 +黢 3282 +琴 3283 +潞 3284 +朝 3285 +tually 3286 +铞 3287 +乇 3288 +醪 3289 +潍 3290 +槊 3291 +纯 3292 +瀹 3293 +诋 3294 +慢 3295 +奸 3296 +嘭 3297 +揠 3298 +昃 3299 +革 3300 +司 3301 +ce@@ 3302 +趵 3303 +醮 3304 +碘 3305 +器 3306 +澶 3307 +知 3308 +& 3309 +厢 3310 +啷 3311 +晔 3312 +炜 3313 +ook 3314 +斐 3315 +盏 3316 +妻 3317 +娣 3318 +燋 3319 +窈 3320 +法 3321 +also 3322 +菸 3323 +炽 3324 +选 3325 +埕 3326 +击 3327 +滤 3328 +铖 3329 +觎 3330 +莩 3331 +le@@ 3332 +匏 3333 +踅 3334 +酸 3335 +鹑 3336 +enjoy 3337 +闰 3338 +毡 3339 +祆 3340 +身 3341 +郊 3342 +笄 3343 +乖 3344 +甩 3345 +like 3346 +否 3347 +厖 3348 +爹 3349 +牦 3350 +起 3351 +僔 3352 +钓 3353 +浚 3354 +忙 3355 +get 3356 +冯 3357 +樊 3358 +识 3359 +蹉 3360 +鑫 3361 +畚 3362 +滕 3363 +掮 3364 +尾 3365 +war@@ 3366 +桶 3367 +瓢 3368 +毫 3369 +膊 3370 +髌 3371 +钗 3372 +桼 3373 +碲 3374 +辨 3375 +唉 3376 +竲 3377 +痰 3378 +膻 3379 +锖 3380 +嘡 3381 +雩 3382 +版 3383 +昧 3384 +敬 3385 +蕨 3386 +伴 3387 +徍 3388 +襀 3389 +盘 3390 +憷 3391 +涑 3392 +different 3393 +after 3394 +虽 3395 +何 3396 +煎 3397 +宽 3398 +ori@@ 3399 +攀 3400 +冢 3401 +零 3402 +樯 3403 +哌 3404 +瓣 3405 +馍 3406 +唰 3407 +炪 3408 +旰 3409 +厔 3410 +葵 3411 +痞 3412 +己 3413 +靓 3414 +凄 3415 +服 3416 +烤 3417 +仮 3418 +恨 3419 +喳 3420 +贯 3421 +郞 3422 +饪 3423 +铄 3424 +滠 3425 +蠢 3426 +薅 3427 +齑 3428 +褓 3429 +黼 3430 +涿 3431 +n't 3432 +磬 3433 +匾 3434 +沂 3435 +镳 3436 +nice 3437 +浯 3438 +悝 3439 +淫 3440 +捅 3441 +箨 3442 +瞀 3443 +勠 3444 +屐 3445 +蹁 3446 +蹦 3447 +槿 3448 +类 3449 +栉 3450 +脘 3451 +页 3452 +桕 3453 +脊 3454 +欲 3455 +蝽 3456 +勃 3457 +坷 3458 +酶 3459 +售 3460 +縻 3461 +欺 3462 +膏 3463 +词 3464 +兢 3465 +楸 3466 +娡 3467 +娩 3468 +陡 3469 +ple 3470 +尧 3471 +幷 3472 +豉 3473 +桫 3474 +滁 3475 +麋 3476 +罘 3477 +朕 3478 +耗 3479 +汉 3480 +登 3481 +较 3482 +逾 3483 +蔫 3484 +赳 3485 +秭 3486 +咫 3487 +斑 3488 +跚 3489 +舒 3490 +莞 3491 +闾 3492 +氤 3493 +骸 3494 +槩 3495 +瓶 3496 +餐 3497 +瞪 3498 +沉 3499 +朱 3500 +had 3501 +鲣 3502 +嵪 3503 +nothing 3504 +嫡 3505 +恍 3506 +衢 3507 +轴 3508 +杈 3509 +赂 3510 +津 3511 +red 3512 +拤 3513 +狠 3514 +卣 3515 +蚱 3516 +疆 3517 +捞 3518 +婉 3519 +固 3520 +梶 3521 +垸 3522 +逋 3523 +髡 3524 +晓 3525 +骶 3526 +季 3527 +炀 3528 +喷 3529 +垛 3530 +蜂 3531 +sts 3532 +阝 3533 +未 3534 +熬 3535 +绲 3536 +坟 3537 +苈 3538 +are 3539 +寮 3540 +吧 3541 +皖 3542 +捌 3543 +炤 3544 +b 3545 +the 3546 +哈 3547 +鞍 3548 +ir@@ 3549 +掘 3550 +tation 3551 +噙 3552 +酊 3553 +忄 3554 +硼 3555 +耕 3556 +偎 3557 +雎 3558 +磴 3559 +锺 3560 +over 3561 +侃 3562 +婚 3563 +吗 3564 +竺 3565 +man 3566 +也 3567 +苣 3568 +绛 3569 +冤 3570 +呈 3571 +孱 3572 +缭 3573 +埼 3574 +犬 3575 +麦 3576 +蓐 3577 +技 3578 +夜 3579 +tri@@ 3580 +杭 3581 +佝 3582 +莨 3583 +di@@ 3584 +毙 3585 +贿 3586 +猎 3587 +桡 3588 +so 3589 +氖 3590 +叼 3591 +哚 3592 +濠 3593 +湾 3594 +全 3595 +读 3596 +盐 3597 +钚 3598 +鬘 3599 +万 3600 +鲠 3601 +貂 3602 +鋐 3603 +堼 3604 +茺 3605 +拟 3606 +牡 3607 +蝮 3608 +镢 3609 +嚯 3610 +束 3611 +喱 3612 +彿 3613 +col@@ 3614 +仃 3615 +涌 3616 +蚯 3617 +妩 3618 +箢 3619 +隹 3620 +亰 3621 +疃 3622 +嬗 3623 +喇 3624 +攒 3625 +am 3626 +听 3627 +fe@@ 3628 +zero 3629 +痛 3630 +诗 3631 +干 3632 +疫 3633 +嬴 3634 +降 3635 +签 3636 +丈 3637 +sm@@ 3638 +髑 3639 +劬 3640 +萏 3641 +诀 3642 +镵 3643 +坭 3644 +咤 3645 +池 3646 +榼 3647 +岕 3648 +崃 3649 +ined 3650 +chu@@ 3651 +糸 3652 +祸 3653 +猜 3654 +婿 3655 +搋 3656 +咛 3657 +箴 3658 +辚 3659 +悄 3660 +荒 3661 +挑 3662 +托 3663 +drive 3664 +撇 3665 +莒 3666 +鄜 3667 +冒 3668 +稹 3669 +户 3670 +ould 3671 +铤 3672 +翃 3673 +try 3674 +圊 3675 +艿 3676 +桐 3677 +兒 3678 +揿 3679 +堋 3680 +档 3681 +ink 3682 +email 3683 +犍 3684 +铍 3685 +招 3686 +鳇 3687 +敲 3688 +雳 3689 +奌 3690 +裝 3691 +戏 3692 +哗 3693 +栱 3694 +哆 3695 +ds 3696 +浙 3697 +岂 3698 +挝 3699 +莲 3700 +腩 3701 +杬 3702 +促 3703 +斥 3704 +蛱 3705 +诓 3706 +炖 3707 +璘 3708 +怵 3709 +礽 3710 +咬 3711 +珐 3712 +韡 3713 +邺 3714 +祀 3715 +皇 3716 +渑 3717 +困 3718 +潜 3719 +添 3720 +ter 3721 +che@@ 3722 +如 3723 +萹 3724 +熙 3725 +扇 3726 +亭 3727 +亍 3728 +瘗 3729 +舍 3730 +皌 3731 +诌 3732 +虢 3733 +欣 3734 +掰 3735 +棘 3736 +岞 3737 +毽 3738 +卵 3739 +罴 3740 +疋 3741 +ali@@ 3742 +整 3743 +i@@ 3744 +紫 3745 +mu@@ 3746 +涘 3747 +携 3748 +奏 3749 +cri@@ 3750 +馘 3751 +翘 3752 +抚 3753 +筝 3754 +玄 3755 +霎 3756 +铮 3757 +澹 3758 +嫰 3759 +夏 3760 +咿 3761 +围 3762 +従 3763 +猾 3764 +pre@@ 3765 +逊 3766 +糖 3767 +漫 3768 +聍 3769 +及 3770 +瘵 3771 +绢 3772 +棱 3773 +笈 3774 +铯 3775 +釭 3776 +恼 3777 +癎 3778 +苢 3779 +斁 3780 +醣 3781 +植 3782 +鱼 3783 +涓 3784 +z 3785 +目 3786 +par@@ 3787 +撬 3788 +戎 3789 +偓 3790 +已 3791 +打 3792 +busine@@ 3793 +桦 3794 +庇 3795 +坪 3796 +problem 3797 +舾 3798 +centr@@ 3799 +fifty 3800 +梧 3801 +𫖯 3802 +restaurants 3803 +beau@@ 3804 +fac@@ 3805 +谈 3806 +腙 3807 +阉 3808 +孛 3809 +秘 3810 +庶 3811 +畴 3812 +例 3813 +ang 3814 +幛 3815 +下 3816 +溅 3817 +彗 3818 +魟 3819 +诏 3820 +鸢 3821 +邽 3822 +瘪 3823 +ay 3824 +胱 3825 +勒 3826 +槲 3827 +橹 3828 +男 3829 +ide@@ 3830 +治 3831 +锒 3832 +祯 3833 +慌 3834 +佥 3835 +苔 3836 +暴 3837 +时 3838 +撩 3839 +俶 3840 +屋 3841 +抠 3842 +嫘 3843 +浔 3844 +鲻 3845 +暌 3846 +窿 3847 +炒 3848 +溇 3849 +髯 3850 +簿 3851 +嶖 3852 +峒 3853 +si@@ 3854 +觥 3855 +午 3856 +联 3857 +ty 3858 +猫 3859 +腭 3860 +晻 3861 +袝 3862 +戾 3863 +波 3864 +啐 3865 +戈 3866 +蒎 3867 +麓 3868 +汾 3869 +茉 3870 +埔 3871 +蛳 3872 +徳 3873 +鸶 3874 +艇 3875 +顶 3876 +髻 3877 +徒 3878 +豨 3879 +碳 3880 +欻 3881 +薰 3882 +hi 3883 +呲 3884 +舳 3885 +劾 3886 +形 3887 +' 3888 +渥 3889 +羔 3890 +枇 3891 +模 3892 +弁 3893 +坏 3894 +徊 3895 +馗 3896 +刨 3897 +璀 3898 +锤 3899 +钤 3900 +囔 3901 +拦 3902 +剐 3903 +揸 3904 +滈 3905 +缚 3906 +ach@@ 3907 +斯 3908 +诒 3909 +寂 3910 +裒 3911 +同 3912 +should 3913 +蒴 3914 +窑 3915 +示 3916 +戗 3917 +录 3918 +恶 3919 +培 3920 +迦 3921 +邮 3922 +熜 3923 +泊 3924 +乌 3925 +篌 3926 +center 3927 +癸 3928 +昔 3929 +牠 3930 +濛 3931 +噻 3932 +苋 3933 +ang@@ 3934 +give 3935 +姥 3936 +祖 3937 +搬 3938 +悠 3939 +瓒 3940 +嫱 3941 +wit@@ 3942 +畏 3943 +神 3944 +湟 3945 +扃 3946 +桀 3947 +醭 3948 +谘 3949 +虾 3950 +玳 3951 +斡 3952 +寥 3953 +攻 3954 +忑 3955 +man@@ 3956 +僻 3957 +money 3958 +飊 3959 +国 3960 +吠 3961 +扦 3962 +j@@ 3963 +幹 3964 +泛 3965 +摈 3966 +匼 3967 +廉 3968 +通 3969 +袍 3970 +楹 3971 +搦 3972 +缮 3973 +ft 3974 +岱 3975 +scho@@ 3976 +丹 3977 +蜗 3978 +蓓 3979 +卟 3980 +倥 3981 +喽 3982 +蛾 3983 +殭 3984 +绣 3985 +chan@@ 3986 +震 3987 +棍 3988 +潋 3989 +葫 3990 +嚎 3991 +ed 3992 +漉 3993 +阻 3994 +俐 3995 +德 3996 +様 3997 +酝 3998 +倒 3999 +橱 4000 +send 4001 +谯 4002 +嗐 4003 +署 4004 +贴 4005 +搭 4006 +坦 4007 +蹯 4008 +緛 4009 +队 4010 +死 4011 +閟 4012 +岭 4013 +倪 4014 +诜 4015 +偱 4016 +醐 4017 +痊 4018 +社 4019 +凡 4020 +畯 4021 +摒 4022 +迭 4023 +措 4024 +挌 4025 +媛 4026 +same 4027 +蚓 4028 +擤 4029 +澈 4030 +眈 4031 +剪 4032 +嫣 4033 +鹱 4034 +娆 4035 +凸 4036 +厅 4037 +臼 4038 +枭 4039 +炎 4040 +烊 4041 +掀 4042 +洄 4043 +僳 4044 +velo@@ 4045 +觉 4046 +逼 4047 +have 4048 +皎 4049 +酯 4050 +怦 4051 +叶 4052 +统 4053 +thir@@ 4054 +从 4055 +纡 4056 +les 4057 +术 4058 +径 4059 +征 4060 +come 4061 +躅 4062 +此 4063 +甜 4064 +践 4065 +br@@ 4066 +鎸 4067 +讲 4068 +玲 4069 +衣 4070 +麾 4071 +枥 4072 +拈 4073 +腮 4074 +reas@@ 4075 +芩 4076 +鹖 4077 +more 4078 +儆 4079 +愕 4080 +淏 4081 +躄 4082 +玕 4083 +届 4084 +永 4085 +哟 4086 +虓 4087 +sit@@ 4088 +宴 4089 +夷 4090 +梭 4091 +紧 4092 +瘰 4093 +recei@@ 4094 +匐 4095 +号 4096 +from 4097 +况 4098 +畋 4099 +谜 4100 +莽 4101 +锣 4102 +pas@@ 4103 +驽 4104 +昱 4105 +喟 4106 +v@@ 4107 +呀 4108 +'re 4109 +阒 4110 +踬 4111 +认 4112 +订 4113 +黝 4114 +筻 4115 +seven 4116 +们 4117 +舱 4118 +揭 4119 +妾 4120 +礼 4121 +高 4122 +传 4123 +香 4124 +谬 4125 +篆 4126 +逮 4127 +玑 4128 +眄 4129 +驿 4130 +all@@ 4131 +嗅 4132 +五 4133 +淹 4134 +裨 4135 +咕 4136 +焖 4137 +氚 4138 +ari@@ 4139 +崤 4140 +接 4141 +沈 4142 +寰 4143 +轻 4144 +旒 4145 +维 4146 +test 4147 +麴 4148 +枱 4149 +挚 4150 +句 4151 +驾 4152 +筷 4153 +坂 4154 +须 4155 +鼩 4156 +炳 4157 +推 4158 +姿 4159 +溴 4160 +庠 4161 +箻 4162 +燹 4163 +拂 4164 +呱 4165 +愫 4166 +袤 4167 +睑 4168 +眩 4169 +冷 4170 +葸 4171 +but 4172 +睢 4173 +掣 4174 +唠 4175 +陈 4176 +喉 4177 +晬 4178 +溯 4179 +艺 4180 +苦 4181 +腐 4182 +蟹 4183 +燃 4184 +候 4185 +伸 4186 +萝 4187 +汭 4188 +6@@ 4189 +僭 4190 +蘖 4191 +瘫 4192 +书 4193 +控 4194 +乪 4195 +溘 4196 +痦 4197 +lo@@ 4198 +蝣 4199 +啂 4200 +缩 4201 +part@@ 4202 +只 4203 +施 4204 +鸨 4205 +鎏 4206 +悕 4207 +诵 4208 +孬 4209 +獾 4210 +玩 4211 +汪 4212 +ary 4213 +吁 4214 +拄 4215 +诟 4216 +扰 4217 +鼎 4218 +珪 4219 +橇 4220 +隍 4221 +close 4222 +姑 4223 +ty@@ 4224 +迹 4225 +骆 4226 +崐 4227 +go@@ 4228 +勺 4229 +倜 4230 +ble 4231 +盥 4232 +剂 4233 +qu@@ 4234 +圻 4235 +荧 4236 +荣 4237 +疵 4238 +酪 4239 +稗 4240 +幔 4241 +井 4242 +蛭 4243 +泔 4244 +定 4245 +q 4246 +楱 4247 +刈 4248 +使 4249 +潟 4250 +螅 4251 +铒 4252 +巇 4253 +呖 4254 +垂 4255 +啉 4256 +戮 4257 +佉 4258 +寓 4259 +翚 4260 +啪 4261 +fa@@ 4262 +w@@ 4263 +绐 4264 +抛 4265 +謷 4266 +忐 4267 +趿 4268 +位 4269 +螨 4270 +last 4271 +迫 4272 +爷 4273 +碎 4274 +廊 4275 +啮 4276 +柯 4277 +酰 4278 +烜 4279 +煳 4280 +粲 4281 +求 4282 +楞 4283 +考 4284 +挲 4285 +触 4286 +荽 4287 +荫 4288 +疴 4289 +遨 4290 +仫 4291 +瑭 4292 +扩 4293 +恭 4294 +藦 4295 +羣 4296 +味 4297 +缀 4298 +享 4299 +枣 4300 +宠 4301 +浊 4302 +back 4303 +扪 4304 +篥 4305 +sou@@ 4306 +涉 4307 +son 4308 +翔 4309 +讫 4310 +理 4311 +玙 4312 +栩 4313 +闩 4314 +龄 4315 +亢 4316 +桄 4317 +sil 4318 +幡 4319 +婀 4320 +脚 4321 +疠 4322 +歔 4323 +兰 4324 +査 4325 +镗 4326 +涎 4327 +霏 4328 +待 4329 +忻 4330 +警 4331 +科 4332 +脐 4333 +琚 4334 +真 4335 +own 4336 +醚 4337 +绡 4338 +ices 4339 +嶷 4340 +胶 4341 +u@@ 4342 +峦 4343 +箜 4344 +丰 4345 +痨 4346 +衡 4347 +锋 4348 +宦 4349 +皦 4350 +感 4351 +矮 4352 +爨 4353 +亟 4354 +装 4355 +鹁 4356 +房 4357 +捡 4358 +乐 4359 +胫 4360 +彧 4361 +kil@@ 4362 +葛 4363 +氏 4364 +捋 4365 +戢 4366 +牢 4367 +5 4368 +dis@@ 4369 +园 4370 +孓 4371 +柃 4372 +榄 4373 +喺 4374 +悛 4375 +俢 4376 +c 4377 +缢 4378 +富 4379 +je@@ 4380 +竭 4381 +way 4382 +拙 4383 +牧 4384 +阋 4385 +究 4386 +钘 4387 +濮 4388 +皙 4389 +问 4390 +告 4391 +鞅 4392 +燚 4393 +肴 4394 +螭 4395 +篁 4396 +当 4397 +洮 4398 +渠 4399 +码 4400 +辗 4401 +慨 4402 +崇 4403 +诫 4404 +意 4405 +姤 4406 +飓 4407 +噬 4408 +铐 4409 +寡 4410 +咨 4411 +伺 4412 +殓 4413 +容 4414 +蚁 4415 +柏 4416 +枧 4417 +瑗 4418 +挛 4419 +绔 4420 +月 4421 +绾 4422 +隘 4423 +僖 4424 +纇 4425 +握 4426 +耜 4427 +舐 4428 +tom@@ 4429 +旅 4430 +翮 4431 +products 4432 +缑 4433 +肄 4434 +闿 4435 +硫 4436 +砚 4437 +蜚 4438 +bu@@ 4439 +鍉 4440 +晗 4441 +阃 4442 +衾 4443 +蝴 4444 +丽 4445 +髅 4446 +煜 4447 +习 4448 +窃 4449 +芑 4450 +櫈 4451 +ru@@ 4452 +deta@@ 4453 +谌 4454 +俄 4455 +桨 4456 +芦 4457 +泵 4458 +企 4459 +粽 4460 +揣 4461 +领 4462 +qui@@ 4463 +鄚 4464 +肱 4465 +尼 4466 +滦 4467 +椽 4468 +癜 4469 +甄 4470 +local 4471 +fol@@ 4472 +偿 4473 +丸 4474 +涞 4475 +铜 4476 +du@@ 4477 +噜 4478 +倬 4479 +珣 4480 +圜 4481 +墅 4482 +car@@ 4483 +撂 4484 +栀 4485 +评 4486 +螽 4487 +蘑 4488 +扬 4489 +挨 4490 +轵 4491 +殇 4492 +酺 4493 +high 4494 +ach 4495 +鲛 4496 +啥 4497 +think 4498 +don't 4499 +表 4500 +cho@@ 4501 +盈 4502 +啊 4503 +怔 4504 +滉 4505 +艮 4506 +徽 4507 +撸 4508 +淯 4509 +茈 4510 +硚 4511 +桢 4512 +魍 4513 +潸 4514 +at@@ 4515 +仄 4516 +鹭 4517 +沅 4518 +操 4519 +炭 4520 +砝 4521 +之 4522 +讷 4523 +吔 4524 +疰 4525 +葚 4526 +夭 4527 +跶 4528 +蘼 4529 +鲩 4530 +滴 4531 +菘 4532 +滨 4533 +加 4534 +倾 4535 +体 4536 +茕 4537 +髀 4538 +雅 4539 +ile 4540 +欠 4541 +崧 4542 +囤 4543 +僦 4544 +守 4545 +辂 4546 +棂 4547 +se@@ 4548 +捶 4549 +扛 4550 +铠 4551 +铩 4552 +京 4553 +熘 4554 +洛 4555 +娄 4556 +the@@ 4557 +痿 4558 +箱 4559 +驮 4560 +礅 4561 +郃 4562 +谣 4563 +ack@@ 4564 +晴 4565 +res 4566 +lot 4567 +纲 4568 +诖 4569 +ways 4570 +谋 4571 +煌 4572 +绗 4573 +炼 4574 +卤 4575 +屄 4576 +界 4577 +拶 4578 +ar 4579 +遠 4580 +周 4581 +驩 4582 +肖 4583 +跖 4584 +莳 4585 +级 4586 +咣 4587 +hu@@ 4588 +啕 4589 +歀 4590 +忤 4591 +临 4592 +many 4593 +募 4594 +ned 4595 +their 4596 +睿 4597 +冥 4598 +久 4599 +巍 4600 +堇 4601 +洽 4602 +郎 4603 +董 4604 +this 4605 +毒 4606 +醉 4607 +ton 4608 +诶 4609 +旺 4610 +蛉 4611 +规 4612 +琶 4613 +騑 4614 +捉 4615 +imp@@ 4616 +尥 4617 +拗 4618 +e@@ 4619 +歆 4620 +刀 4621 +跆 4622 +three 4623 +锢 4624 +侏 4625 +拳 4626 +扤 4627 +饨 4628 +剥 4629 +潡 4630 +俊 4631 +嗤 4632 +穑 4633 +地 4634 +情 4635 +憔 4636 +惹 4637 +奢 4638 +inclu@@ 4639 +mo@@ 4640 +铭 4641 +至 4642 +浐 4643 +祎 4644 +really 4645 +帆 4646 +dri@@ 4647 +楮 4648 +邬 4649 +弛 4650 +篮 4651 +兮 4652 +up@@ 4653 +慧 4654 +腆 4655 +碴 4656 +挂 4657 +计 4658 +豚 4659 +滑 4660 +indi@@ 4661 +into 4662 +褭 4663 +酞 4664 +t@@ 4665 +符 4666 +彼 4667 +涔 4668 +body 4669 +ins 4670 +漶 4671 +峪 4672 +down 4673 +缓 4674 +剀 4675 +福 4676 +叮 4677 +wa@@ 4678 +失 4679 +隅 4680 +怄 4681 +扳 4682 +great 4683 +泰 4684 +娌 4685 +孙 4686 +彬 4687 +毁 4688 +蛲 4689 +萱 4690 +泻 4691 +舀 4692 +dge 4693 +瞅 4694 +vo@@ 4695 +咆 4696 +祜 4697 +枋 4698 +憩 4699 +文 4700 +gro@@ 4701 +倮 4702 +鹄 4703 +all 4704 +槎 4705 +忆 4706 +絯 4707 +裢 4708 +two 4709 +兑 4710 +明 4711 +遥 4712 +窍 4713 +吃 4714 +it's 4715 +启 4716 +妽 4717 +郅 4718 +ies 4719 +centre 4720 +吒 4721 +亿 4722 +雲 4723 +ask 4724 +叩 4725 +媜 4726 +蚰 4727 +奄 4728 +垡 4729 +椿 4730 +篡 4731 +聘 4732 +窖 4733 +垩 4734 +diffe@@ 4735 +购 4736 +褥 4737 +豸 4738 +腠 4739 +咦 4740 +眦 4741 +敏 4742 +鼐 4743 +昉 4744 +顽 4745 +琊 4746 +砌 4747 +僮 4748 +乱 4749 +瞒 4750 +撑 4751 +羲 4752 +纳 4753 +赞 4754 +焐 4755 +橛 4756 +嵩 4757 +陀 4758 +楔 4759 +牛 4760 +鹊 4761 +帽 4762 +may 4763 +偬 4764 +丫 4765 +兜 4766 +砰 4767 +濡 4768 +獬 4769 +好 4770 +腿 4771 +靶 4772 +木 4773 +唸 4774 +纹 4775 +裹 4776 +cl@@ 4777 +hund@@ 4778 +优 4779 +猲 4780 +焯 4781 +岘 4782 +辱 4783 +丞 4784 +查 4785 +秽 4786 +胀 4787 +鳖 4788 +岈 4789 +喒 4790 +默 4791 +见 4792 +forty 4793 +纥 4794 +泚 4795 +驳 4796 +铃 4797 +萋 4798 +筱 4799 +蛏 4800 +琲 4801 +鸵 4802 +这 4803 +链 4804 +read@@ 4805 +垵 4806 +踦 4807 +奭 4808 +別 4809 +软 4810 +盲 4811 +黛 4812 +纤 4813 +chil@@ 4814 +扱 4815 +狡 4816 +贽 4817 +贻 4818 +鳏 4819 +篓 4820 +der@@ 4821 +滢 4822 +嵌 4823 +妺 4824 +臭 4825 +谥 4826 +夯 4827 +韬 4828 +惺 4829 +检 4830 +峨 4831 +ms 4832 +胛 4833 +恫 4834 +构 4835 +鹂 4836 +刺 4837 +讦 4838 +唬 4839 +梈 4840 +捯 4841 +由 4842 +志 4843 +达 4844 +梢 4845 +歉 4846 +al@@ 4847 +淳 4848 +般 4849 +恐 4850 +獗 4851 +央 4852 +喔 4853 +蟀 4854 +伞 4855 +sh 4856 +'s 4857 +姮 4858 +痈 4859 +ck 4860 +俾 4861 +髫 4862 +蜒 4863 +愧 4864 +呆 4865 +mer 4866 +桤 4867 +鬏 4868 +型 4869 +四 4870 +哖 4871 +龋 4872 +鳔 4873 +骗 4874 +砷 4875 +泷 4876 +猷 4877 +茯 4878 +茁 4879 +cha@@ 4880 +small 4881 +倨 4882 +乎 4883 +奕 4884 +鹅 4885 +痉 4886 +奥 4887 +辙 4888 +sting 4889 +冲 4890 +赅 4891 +泅 4892 +羰 4893 +悲 4894 +焘 4895 +瞵 4896 +紊 4897 +卖 4898 +take 4899 +鳐 4900 +菊 4901 +姸 4902 +辐 4903 +嘻 4904 +fast 4905 +囿 4906 +凫 4907 +纵 4908 +咝 4909 +箧 4910 +well 4911 +钬 4912 +he@@ 4913 +辕 4914 +鹜 4915 +unk@@ 4916 +诔 4917 +泐 4918 +世 4919 +镖 4920 +骄 4921 +ve 4922 +寸 4923 +pay 4924 +鴂 4925 +睐 4926 +氲 4927 +牟 4928 +洏 4929 +尔 4930 +quo@@ 4931 +椭 4932 +芨 4933 +险 4934 +耳 4935 +柁 4936 +牾 4937 +蚂 4938 +much 4939 +most 4940 +now 4941 +singapore 4942 +丙 4943 +洋 4944 +燊 4945 +啵 4946 +胬 4947 +媾 4948 +碟 4949 +濆 4950 +陇 4951 +扥 4952 +势 4953 +慷 4954 +笋 4955 +泥 4956 +鄘 4957 +hote@@ 4958 +罕 4959 +沤 4960 +葡 4961 +蠡 4962 +产 4963 +鲵 4964 +凉 4965 +萆 4966 +籽 4967 +胞 4968 +哦 4969 +侠 4970 +晚 4971 +咵 4972 +杯 4973 +邗 4974 +甑 4975 +胁 4976 +历 4977 +嘏 4978 +喘 4979 +is 4980 +霉 4981 +仇 4982 +鳀 4983 +鉴 4984 +猛 4985 +纂 4986 +决 4987 +阏 4988 +饯 4989 +宣 4990 +象 4991 +儿 4992 +猗 4993 +瀑 4994 +荚 4995 +满 4996 +茳 4997 +酷 4998 +鼬 4999 +旁 5000 +屡 5001 +榇 5002 +s@@ 5003 +呷 5004 +驻 5005 +薮 5006 +鹏 5007 +馋 5008 +鹪 5009 +纠 5010 +thank 5011 +轶 5012 +渺 5013 +镁 5014 +cost 5015 +汐 5016 +谅 5017 +甫 5018 +publi@@ 5019 +储 5020 +抹 5021 +杨 5022 +裳 5023 +歙 5024 +鏊 5025 +犼 5026 +蹐 5027 +愦 5028 +断 5029 +able 5030 +ind 5031 +狂 5032 +雒 5033 +妧 5034 +巴 5035 +嘟 5036 +筵 5037 +喤 5038 +舫 5039 +刃 5040 +fi@@ 5041 +company 5042 +郿 5043 +瞢 5044 +褰 5045 +清 5046 +巿 5047 +苕 5048 +内 5049 +暾 5050 +垠 5051 +戌 5052 +溟 5053 +剋 5054 +葶 5055 +猡 5056 +蔺 5057 +岖 5058 +邕 5059 +any 5060 +pri@@ 5061 +卸 5062 +基 5063 +馒 5064 +出 5065 +柞 5066 +溍 5067 +吨 5068 +蜥 5069 +炟 5070 +徂 5071 +inve@@ 5072 +桂 5073 +frien@@ 5074 +铪 5075 +庖 5076 +廋 5077 +誉 5078 +嗡 5079 +忘 5080 +i'@@ 5081 +螃 5082 +胯 5083 +un@@ 5084 +铨 5085 +酹 5086 +鷃 5087 +wi 5088 +眚 5089 +跤 5090 +汁 5091 +针 5092 +钏 5093 +雀 5094 +限 5095 +面 5096 +蛐 5097 +黃 5098 +脩 5099 +叛 5100 +枷 5101 +故 5102 +sk@@ 5103 +编 5104 +菥 5105 +勐 5106 +汗 5107 +胃 5108 +洗 5109 +洹 5110 +ers 5111 +髦 5112 +宏 5113 +聊 5114 +嵫 5115 +囎 5116 +淠 5117 +垧 5118 +欧 5119 +oms 5120 +揵 5121 +before 5122 +畈 5123 +饬 5124 +秦 5125 +莓 5126 +努 5127 +炔 5128 +匠 5129 +际 5130 +蕉 5131 +蝇 5132 +po@@ 5133 +匽 5134 +year 5135 +橥 5136 +pping 5137 +注 5138 +承 5139 +琨 5140 +又 5141 +玉 5142 +彖 5143 +缸 5144 +怒 5145 +遏 5146 +坍 5147 +sta@@ 5148 +痹 5149 +埆 5150 +叵 5151 +氟 5152 +脂 5153 +昭 5154 +ments 5155 +宕 5156 +盅 5157 +惩 5158 +馁 5159 +绕 5160 +遢 5161 +扫 5162 +蔡 5163 +崾 5164 +阎 5165 +葳 5166 +殂 5167 +潢 5168 +每 5169 +ro@@ 5170 +靖 5171 +主 5172 +tional 5173 +闸 5174 +涖 5175 +第 5176 +n 5177 +镅 5178 +蜜 5179 +能 5180 +衄 5181 +猹 5182 +鹰 5183 +俘 5184 +豪 5185 +人 5186 +痱 5187 +蓑 5188 +骒 5189 +所 5190 +绨 5191 +粦 5192 +望 5193 +减 5194 +魔 5195 +释 5196 +ars 5197 +逑 5198 +clo@@ 5199 +帏 5200 +遘 5201 +许 5202 +誓 5203 +牤 5204 +刷 5205 +扒 5206 +峻 5207 +9@@ 5208 +昕 5209 +轩 5210 +嬷 5211 +them 5212 +窗 5213 +蹄 5214 +骰 5215 +湓 5216 +堨 5217 +巂 5218 +婧 5219 +骖 5220 +螬 5221 +酋 5222 +ag@@ 5223 +闷 5224 +滞 5225 +郧 5226 +朴 5227 +supp@@ 5228 +倡 5229 +腴 5230 +褴 5231 +极 5232 +漷 5233 +佾 5234 +煨 5235 +沚 5236 +肓 5237 +experi@@ 5238 +亮 5239 +堕 5240 +对 5241 +秋 5242 +every 5243 +铅 5244 +蛴 5245 +擅 5246 +a@@ 5247 +鳃 5248 +保 5249 +ze 5250 +弓 5251 +趄 5252 +翦 5253 +涛 5254 +盗 5255 +道 5256 +遮 5257 +屿 5258 +绥 5259 +cont@@ 5260 +部 5261 +den 5262 +付 5263 +诮 5264 +粘 5265 +镔 5266 +溏 5267 +啬 5268 +贫 5269 +轼 5270 +猩 5271 +薯 5272 +胳 5273 +抻 5274 +than@@ 5275 +king 5276 +燎 5277 +笫 5278 +鸂 5279 +蓊 5280 +峋 5281 +ent 5282 +搡 5283 +磡 5284 +昌 5285 +妈 5286 +玺 5287 +叫 5288 +済 5289 +彩 5290 +lim 5291 +孀 5292 +囧 5293 +we@@ 5294 +绒 5295 +驸 5296 +架 5297 +斛 5298 +资 5299 +碣 5300 +濞 5301 +冿 5302 +吡 5303 +作 5304 +because 5305 +晏 5306 +餍 5307 +瞾 5308 +栊 5309 +蹿 5310 +荩 5311 +尖 5312 +依 5313 +氍 5314 +炝 5315 +抡 5316 +中 5317 +潏 5318 +潦 5319 +mon@@ 5320 +域 5321 +杩 5322 +兼 5323 +捎 5324 +怩 5325 +正 5326 +f 5327 +near 5328 +刮 5329 +搪 5330 +鸿 5331 +俺 5332 +耪 5333 +牯 5334 +瞎 5335 +鬼 5336 +惠 5337 +bro@@ 5338 +沛 5339 +浃 5340 +pe 5341 +釜 5342 +vi@@ 5343 +jal@@ 5344 +拴 5345 +冇 5346 +暎 5347 +魉 5348 +痘 5349 +盔 5350 +枚 5351 +秾 5352 +杲 5353 +郛 5354 +僚 5355 +昞 5356 +足 5357 +sen@@ 5358 +family 5359 +岿 5360 +榖 5361 +蛊 5362 +绯 5363 +村 5364 +鹃 5365 +钼 5366 +拓 5367 +dly 5368 +苫 5369 +锄 5370 +跎 5371 +碰 5372 +钝 5373 +淆 5374 +鳟 5375 +浓 5376 +柇 5377 +桷 5378 +愬 5379 +裔 5380 +邃 5381 +恂 5382 +楙 5383 +沨 5384 +ban@@ 5385 +ssi@@ 5386 +鳞 5387 +褛 5388 +钌 5389 +y 5390 +搀 5391 +骺 5392 +坊 5393 +娘 5394 +鄯 5395 +袈 5396 +匡 5397 +厦 5398 +艘 5399 +障 5400 +舂 5401 +蹼 5402 +材 5403 +胡 5404 +痄 5405 +凖 5406 +憋 5407 +ll 5408 +睥 5409 +窦 5410 +砕 5411 +雇 5412 +個 5413 +籀 5414 +饥 5415 +价 5416 +笞 5417 +鞴 5418 +筊 5419 +树 5420 +揖 5421 +派 5422 +垄 5423 +檬 5424 +眠 5425 +偌 5426 +闯 5427 +姘 5428 +少 5429 +破 5430 +怿 5431 +扼 5432 +恽 5433 +岔 5434 +亲 5435 +垕 5436 +踱 5437 +泳 5438 +截 5439 +轫 5440 +烽 5441 +够 5442 +武 5443 +差 5444 +声 5445 +可 5446 +绫 5447 +巅 5448 +邯 5449 +椎 5450 +硭 5451 +畀 5452 +常 5453 +沸 5454 +榧 5455 +忡 5456 +妫 5457 +皮 5458 +讯 5459 +铣 5460 +轾 5461 +衙 5462 +蒙 5463 +蕃 5464 +things 5465 +腓 5466 +坐 5467 +鞘 5468 +榻 5469 +霈 5470 +垆 5471 +ous 5472 +粳 5473 +枹 5474 +薨 5475 +胆 5476 +龙 5477 +绺 5478 +ning 5479 +med 5480 +挹 5481 +诨 5482 +译 5483 +但 5484 +半 5485 +辩 5486 +芊 5487 +变 5488 +翻 5489 +尹 5490 +靸 5491 +酬 5492 +咶 5493 +暧 5494 +姝 5495 +貘 5496 +墓 5497 +匙 5498 +been 5499 +墀 5500 +芗 5501 +sc@@ 5502 +妲 5503 +狮 5504 +岫 5505 +ants 5506 +谔 5507 +鞭 5508 +婶 5509 +one 5510 +踏 5511 +讵 5512 +land 5513 +幼 5514 +軎 5515 +晕 5516 +wi@@ 5517 +瑞 5518 +供 5519 +啯 5520 +朵 5521 +枓 5522 +奘 5523 +咩 5524 +訢 5525 +臆 5526 +硗 5527 +帖 5528 +dent 5529 +戽 5530 +瘿 5531 +段 5532 +傅 5533 +oun@@ 5534 +枞 5535 +属 5536 +野 5537 +芫 5538 +诣 5539 +钐 5540 +堀 5541 +uring 5542 +试 5543 +衍 5544 +馏 5545 +龇 5546 +讧 5547 +stre@@ 5548 +妒 5549 +驴 5550 +著 5551 +槭 5552 +洧 5553 +side 5554 +氾 5555 +孵 5556 +狎 5557 +歩 5558 +囯 5559 +愎 5560 +过 5561 +pu@@ 5562 +right 5563 +赋 5564 +阳 5565 +mber 5566 +谕 5567 +town 5568 +盾 5569 +救 5570 +饱 5571 +loc@@ 5572 +spa 5573 +橡 5574 +剅 5575 +蒲 5576 +姐 5577 +厕 5578 +铚 5579 +套 5580 +弗 5581 +栴 5582 +鹨 5583 +糨 5584 +騕 5585 +雷 5586 +裁 5587 +拌 5588 +鼻 5589 +some 5590 +搐 5591 +均 5592 +蓣 5593 +ice 5594 +卢 5595 +ad@@ 5596 +瘌 5597 +湶 5598 +氩 5599 +谰 5600 +缙 5601 +ard 5602 +栾 5603 +迨 5604 +浣 5605 +秏 5606 +萨 5607 +always 5608 +污 5609 +踔 5610 +杏 5611 +ded 5612 +髽 5613 +秀 5614 +湉 5615 +粪 5616 +展 5617 +槻 5618 +垓 5619 +赫 5620 +惮 5621 +lar 5622 +薷 5623 +迈 5624 +p 5625 +塾 5626 +ga@@ 5627 +ey 5628 +ho@@ 5629 +恺 5630 +晩 5631 +泸 5632 +茚 5633 +嗰 5634 +绳 5635 +lit@@ 5636 +渍 5637 +遇 5638 +洑 5639 +晞 5640 +溽 5641 +ying 5642 +枸 5643 +无 5644 +逍 5645 +珰 5646 +纣 5647 +罩 5648 +ons 5649 +嶃 5650 +瑚 5651 +裙 5652 +搛 5653 +6 5654 +咾 5655 +唱 5656 +瑠 5657 +睃 5658 +於 5659 +din@@ 5660 +伟 5661 +骓 5662 +函 5663 +瘤 5664 +僆 5665 +鞣 5666 +兕 5667 +伄 5668 +柚 5669 +席 5670 +峄 5671 +鄱 5672 +抔 5673 +an@@ 5674 +物 5675 +loca@@ 5676 +穸 5677 +懊 5678 +2 5679 +amer@@ 5680 +稼 5681 +叉 5682 +bra@@ 5683 +拇 5684 +str@@ 5685 +res@@ 5686 +沆 5687 +饰 5688 +pen@@ 5689 +厘 5690 +傈 5691 +ality 5692 +廷 5693 +式 5694 +夔 5695 +督 5696 +雍 5697 +晨 5698 +剿 5699 +赶 5700 +疖 5701 +砜 5702 +归 5703 +min@@ 5704 +徙 5705 +阚 5706 +tions 5707 +女 5708 +营 5709 +邅 5710 +做 5711 +伊 5712 +er 5713 +筜 5714 +蛎 5715 +business 5716 +顾 5717 +瘸 5718 +洪 5719 +钕 5720 +觯 5721 +政 5722 +劝 5723 +撮 5724 +break@@ 5725 +氛 5726 +漾 5727 +sor@@ 5728 +址 5729 +帼 5730 +增 5731 +ation 5732 +瞋 5733 +稊 5734 +慊 5735 +肯 5736 +躁 5737 +糗 5738 +美 5739 +豕 5740 +us 5741 +炸 5742 +饽 5743 +挎 5744 +挫 5745 +枢 5746 +dr@@ 5747 +铉 5748 +奖 5749 +槌 5750 +绮 5751 +擂 5752 +虬 5753 +钽 5754 +胍 5755 +凌 5756 +辟 5757 +虹 5758 +铂 5759 +杂 5760 +砗 5761 +赪 5762 +潇 5763 +合 5764 +鰕 5765 +吭 5766 +迷 5767 +滘 5768 +弹 5769 +ff 5770 +痤 5771 +蛄 5772 +觱 5773 +杳 5774 +嘬 5775 +偕 5776 +赘 5777 +烩 5778 +孖 5779 +钉 5780 +龉 5781 +钪 5782 +睬 5783 +撵 5784 +big 5785 +笠 5786 +俅 5787 +锯 5788 +呛 5789 +欹 5790 +cle@@ 5791 +珈 5792 +浑 5793 +墕 5794 +hel@@ 5795 +彪 5796 +姊 5797 +拆 5798 +雄 5799 +hi@@ 5800 +钴 5801 +壑 5802 +幇 5803 +卧 5804 +痧 5805 +澴 5806 +长 5807 +船 5808 +六 5809 +继 5810 +棷 5811 +尞 5812 +窨 5813 +燥 5814 +膦 5815 +镝 5816 +裸 5817 +办 5818 +resta@@ 5819 +廆 5820 +宅 5821 +蓉 5822 +娑 5823 +猪 5824 +阁 5825 +蟆 5826 +恢 5827 +讥 5828 +蜾 5829 +of@@ 5830 +怏 5831 +笕 5832 +瞽 5833 +赤 5834 +岁 5835 +禺 5836 +昀 5837 +她 5838 +钫 5839 +慝 5840 +滩 5841 +ack 5842 +跃 5843 +贰 5844 +巳 5845 +寿 5846 +铙 5847 +pro@@ 5848 +朊 5849 +mor@@ 5850 +箦 5851 +丧 5852 +ready 5853 +止 5854 +跺 5855 +缉 5856 +桅 5857 +论 5858 +摽 5859 +状 5860 +驷 5861 +咂 5862 +铢 5863 +绎 5864 +叨 5865 +遁 5866 +锨 5867 +里 5868 +笊 5869 +缰 5870 +嵊 5871 +膘 5872 +帕 5873 +泱 5874 +鳜 5875 +嫒 5876 +仅 5877 +捕 5878 +徵 5879 +each 5880 +vil@@ 5881 +媪 5882 +袁 5883 +裕 5884 +佻 5885 +赙 5886 +末 5887 +笔 5888 +啲 5889 +轺 5890 +掞 5891 +彭 5892 +氢 5893 +got 5894 +缁 5895 +蠊 5896 +攉 5897 +寻 5898 +骋 5899 +噤 5900 +ar@@ 5901 +棣 5902 +疣 5903 +豳 5904 +祠 5905 +衩 5906 +匀 5907 +王 5908 +觫 5909 +庭 5910 +亶 5911 +篇 5912 +铎 5913 +咖 5914 +禛 5915 +艹 5916 +哐 5917 +hope 5918 +系 5919 +娇 5920 +窄 5921 +髓 5922 +阱 5923 +懿 5924 +右 5925 +离 5926 +穴 5927 +渣 5928 +邨 5929 +肸 5930 +杞 5931 +肚 5932 +垗 5933 +幕 5934 +ct 5935 +苊 5936 +隼 5937 +wh@@ 5938 +宋 5939 +煤 5940 +柸 5941 +镋 5942 +耘 5943 +kes 5944 +激 5945 +痣 5946 +work 5947 +吽 5948 +彟 5949 +株 5950 +筐 5951 +贸 5952 +尕 5953 +ere 5954 +潬 5955 +矣 5956 +縢 5957 +瑄 5958 +油 5959 +満 5960 +漆 5961 +rooms 5962 +霞 5963 +咏 5964 +e 5965 +珮 5966 +仡 5967 +眶 5968 +醛 5969 +票 5970 +鼓 5971 +突 5972 +ia 5973 +hundred 5974 +瓯 5975 +尬 5976 +no@@ 5977 +西 5978 +潴 5979 +楪 5980 +傍 5981 +蜘 5982 +yes 5983 +蛀 5984 +sig@@ 5985 +gs 5986 +筮 5987 +因 5988 +谳 5989 +bur@@ 5990 +跹 5991 +鲑 5992 +惑 5993 +肋 5994 +dy 5995 +螟 5996 +霓 5997 +眼 5998 +郇 5999 +宙 6000 +畲 6001 +刬 6002 +翟 6003 +诧 6004 +跫 6005 +or 6006 +颅 6007 +跷 6008 +淅 6009 +臑 6010 +楦 6011 +屯 6012 +走 6013 +羸 6014 +you 6015 +咘 6016 +als 6017 +斤 6018 +畔 6019 +晧 6020 +忠 6021 +肥 6022 +实 6023 +凋 6024 +褶 6025 +ba@@ 6026 +摆 6027 +熊 6028 +篝 6029 +乃 6030 +绑 6031 +沟 6032 +瞿 6033 +哜 6034 +舶 6035 +甘 6036 +事 6037 +瑛 6038 +踧 6039 +黯 6040 +鲞 6041 +cal@@ 6042 +璩 6043 +enty 6044 +剧 6045 +洿 6046 +孳 6047 +祓 6048 +天 6049 +岑 6050 +拾 6051 +nineteen 6052 +cause 6053 +愇 6054 +翁 6055 +瞟 6056 +昆 6057 +弢 6058 +摛 6059 +义 6060 +咸 6061 +勮 6062 +伪 6063 +瑱 6064 +橄 6065 +丘 6066 +铧 6067 +怖 6068 +终 6069 +膨 6070 +5@@ 6071 +扮 6072 +to 6073 +猕 6074 +琬 6075 +群 6076 +叁 6077 +狄 6078 +眵 6079 +烹 6080 +蚝 6081 +儒 6082 +援 6083 +ber@@ 6084 +1 6085 +掾 6086 +裂 6087 +藓 6088 +r@@ 6089 +squ@@ 6090 +鲁 6091 +潺 6092 +晷 6093 +消 6094 +鸸 6095 +觊 6096 +诡 6097 +却 6098 +as 6099 +弼 6100 +脰 6101 +铷 6102 +册 6103 +懵 6104 +啜 6105 +涣 6106 +扈 6107 +垚 6108 +森 6109 +赵 6110 +垫 6111 +镪 6112 +怼 6113 +峁 6114 +惚 6115 +缄 6116 +ass 6117 +诼 6118 +tive 6119 +never 6120 +昇 6121 +ence 6122 +耍 6123 +异 6124 +朐 6125 +枕 6126 +襻 6127 +契 6128 +祈 6129 +骜 6130 +羯 6131 +豁 6132 +瘕 6133 +贲 6134 +禥 6135 +哔 6136 +祁 6137 +螫 6138 +锆 6139 +孕 6140 +st 6141 +忏 6142 +掏 6143 +昵 6144 +哪 6145 +鹞 6146 +疍 6147 +遄 6148 +蠲 6149 +恹 6150 +狩 6151 +憾 6152 +悫 6153 +弦 6154 +脾 6155 +soci@@ 6156 +爸 6157 +home 6158 +奔 6159 +懂 6160 +爿 6161 +帙 6162 +襦 6163 +除 6164 +ters 6165 +ted 6166 +景 6167 +昚 6168 +eas@@ 6169 +ven@@ 6170 +蹩 6171 +ac@@ 6172 +歇 6173 +fas@@ 6174 +抄 6175 +牌 6176 +浦 6177 +榭 6178 +lan@@ 6179 +巺 6180 +刁 6181 +secon@@ 6182 +顒 6183 +个 6184 +鲒 6185 +嗨 6186 +out 6187 +凤 6188 +褫 6189 +瑮 6190 +珏 6191 +惦 6192 +斝 6193 +监 6194 +土 6195 +did 6196 +date 6197 +陋 6198 +眉 6199 +湴 6200 +谎 6201 +撖 6202 +礞 6203 +抑 6204 +旻 6205 +倩 6206 +蟑 6207 +produ@@ 6208 +忍 6209 +free 6210 +瓜 6211 +康 6212 +晾 6213 +偏 6214 +麇 6215 +缱 6216 +唏 6217 +杪 6218 +戥 6219 +坳 6220 +皲 6221 +拘 6222 +簋 6223 +怆 6224 +负 6225 +陬 6226 +埽 6227 +皞 6228 +素 6229 +锐 6230 +苘 6231 +菀 6232 +嚜 6233 +免 6234 +嫌 6235 +靡 6236 +d 6237 +尪 6238 +颧 6239 +苷 6240 +de@@ 6241 +inc@@ 6242 +策 6243 +浠 6244 +窾 6245 +么 6246 +壁 6247 +pi@@ 6248 +荟 6249 +愣 6250 +砫 6251 +巻 6252 +ke 6253 +旌 6254 +咉 6255 +矢 6256 +婺 6257 +绝 6258 +线 6259 +奂 6260 +袋 6261 +喻 6262 +贡 6263 +鍒 6264 +逛 6265 +days 6266 +疟 6267 +筇 6268 +髃 6269 +锛 6270 +俱 6271 +缅 6272 +锊 6273 +骟 6274 +belie@@ 6275 +跏 6276 +岜 6277 +睚 6278 +蜊 6279 +铝 6280 +闭 6281 +麈 6282 +趺 6283 +锜 6284 +彻 6285 +缟 6286 +甭 6287 +苞 6288 +钥 6289 +ph@@ 6290 +ef@@ 6291 +灼 6292 +銮 6293 +塍 6294 +淙 6295 +鸷 6296 +撼 6297 +浒 6298 +though 6299 +或 6300 +av@@ 6301 +荇 6302 +要 6303 +road 6304 +wn 6305 +纺 6306 +师 6307 +裇 6308 +颚 6309 +end 6310 +汜 6311 +驰 6312 +8 6313 +椹 6314 +牂 6315 +ties 6316 +侪 6317 +樉 6318 +扎 6319 +吞 6320 +蕤 6321 +垦 6322 +茜 6323 +泌 6324 +wor@@ 6325 +艋 6326 +次 6327 +珙 6328 +建 6329 +切 6330 +盱 6331 +簌 6332 +bus 6333 +ten@@ 6334 +check 6335 +硅 6336 +概 6337 +袒 6338 +厚 6339 +馐 6340 +橐 6341 +涵 6342 +抢 6343 +兖 6344 +颠 6345 +扑 6346 +渐 6347 +底 6348 +獒 6349 +瘴 6350 +喝 6351 +期 6352 +踪 6353 +镭 6354 +糕 6355 +翡 6356 +养 6357 +苺 6358 +gi@@ 6359 +顷 6360 +蚜 6361 +琛 6362 +县 6363 +槽 6364 +滹 6365 +钙 6366 +ple@@ 6367 +叙 6368 +囵 6369 +煮 6370 +杵 6371 +铛 6372 +丢 6373 +任 6374 +咹 6375 +莎 6376 +请 6377 +喂 6378 +旬 6379 +枫 6380 +颊 6381 +圄 6382 +霍 6383 +耆 6384 +阅 6385 +嵴 6386 +鱿 6387 +倭 6388 +me 6389 +咎 6390 +sing 6391 +眸 6392 +羽 6393 +二 6394 +橦 6395 +菉 6396 +巩 6397 +柑 6398 +赧 6399 +暮 6400 +d@@ 6401 +损 6402 +world 6403 +隻 6404 +鑹 6405 +捧 6406 +轨 6407 +按 6408 +啄 6409 +聒 6410 +炅 6411 +湣 6412 +詹 6413 +饧 6414 +柒 6415 +恃 6416 +掬 6417 +爽 6418 +料 6419 +窎 6420 +歹 6421 +刿 6422 +完 6423 +餮 6424 +鵀 6425 +是 6426 +榈 6427 +透 6428 +粗 6429 +俛 6430 +音 6431 +份 6432 +置 6433 +砊 6434 +蓬 6435 +蘘 6436 +缛 6437 +going 6438 +遯 6439 +your 6440 +ever@@ 6441 +制 6442 +淼 6443 +冏 6444 +妆 6445 +钣 6446 +怡 6447 +vern@@ 6448 +z@@ 6449 +弇 6450 +砥 6451 +蜴 6452 +饸 6453 +冮 6454 +衫 6455 +韘 6456 +寖 6457 +虐 6458 +玹 6459 +3@@ 6460 +tho@@ 6461 +闼 6462 +旋 6463 +煿 6464 +嶝 6465 +涟 6466 +龌 6467 +ko@@ 6468 +ge@@ 6469 +遛 6470 +郢 6471 +褡 6472 +薪 6473 +邓 6474 +嫁 6475 +叔 6476 +嫤 6477 +漠 6478 +gue@@ 6479 +藿 6480 +臾 6481 +屣 6482 +ily 6483 +早 6484 +痴 6485 +埴 6486 +囊 6487 +俩 6488 +ours 6489 +蓦 6490 +渗 6491 +锟 6492 +朦 6493 +附 6494 +赴 6495 +make 6496 +灾 6497 +汛 6498 +埸 6499 +吾 6500 +栲 6501 +翰 6502 +磔 6503 +汰 6504 +椋 6505 +锸 6506 +搴 6507 +蒐 6508 +韧 6509 +撒 6510 +纽 6511 +笙 6512 +order 6513 +坼 6514 +伷 6515 +val@@ 6516 +烬 6517 +梡 6518 +瑶 6519 +陷 6520 +go 6521 +绤 6522 +琉 6523 +湖 6524 +硷 6525 +熹 6526 +觇 6527 +侥 6528 +往 6529 +牲 6530 +租 6531 +矾 6532 +祉 6533 +跂 6534 +age 6535 +刘 6536 +缃 6537 +复 6538 +阂 6539 +刊 6540 +am@@ 6541 +苻 6542 +步 6543 +cess 6544 +蓖 6545 +he 6546 +喙 6547 +畜 6548 +鄠 6549 +让 6550 +雱 6551 +噫 6552 +sel@@ 6553 +钜 6554 +胥 6555 +蹬 6556 +help 6557 +捻 6558 +芽 6559 +肷 6560 +掳 6561 +旧 6562 +謦 6563 +甲 6564 +ort 6565 +搽 6566 +哧 6567 +骢 6568 +吼 6569 +口 6570 +倚 6571 +肽 6572 +憨 6573 +竦 6574 +product 6575 +棚 6576 +黍 6577 +砾 6578 +愈 6579 +昊 6580 +阑 6581 +魂 6582 +持 6583 +献 6584 +pa@@ 6585 +凳 6586 +厩 6587 +佚 6588 +鲱 6589 +粮 6590 +鹋 6591 +姬 6592 +抉 6593 +籇 6594 +part 6595 +found 6596 +嗍 6597 +提 6598 +蛇 6599 +籓 6600 +盼 6601 +裱 6602 +暄 6603 +妞 6604 +喑 6605 +惶 6606 +wha@@ 6607 +窥 6608 +ks 6609 +橘 6610 +绞 6611 +菁 6612 +饭 6613 +ste@@ 6614 +猞 6615 +芘 6616 +搧 6617 +tain 6618 +street 6619 +呔 6620 +懆 6621 +袼 6622 +肼 6623 +on@@ 6624 +亨 6625 +谑 6626 +椅 6627 +莹 6628 +校 6629 +躔 6630 +颈 6631 +圬 6632 +匕 6633 +mail 6634 +税 6635 +年 6636 +脆 6637 +郦 6638 +拿 6639 +fo@@ 6640 +鑱 6641 +改 6642 +la 6643 +轸 6644 +掩 6645 +芒 6646 +can't 6647 +彺 6648 +觑 6649 +邴 6650 +埵 6651 +莴 6652 +伢 6653 +腕 6654 +阴 6655 +tre@@ 6656 +眇 6657 +责 6658 +青 6659 +钠 6660 +point 6661 +僵 6662 +庹 6663 +具 6664 +迠 6665 +穗 6666 +逃 6667 +源 6668 +咻 6669 +今 6670 +绻 6671 +发 6672 +嗝 6673 +逆 6674 +侈 6675 +抓 6676 +羌 6677 +款 6678 +耧 6679 +锰 6680 +罢 6681 +跗 6682 +汞 6683 +赚 6684 +累 6685 +掷 6686 +啫 6687 +膳 6688 +捭 6689 +禀 6690 +崂 6691 +栝 6692 +掴 6693 +讶 6694 +y@@ 6695 +瑾 6696 +账 6697 +挈 6698 +鹬 6699 +儁 6700 +鳎 6701 +骛 6702 +釉 6703 +矬 6704 +潮 6705 +ort@@ 6706 +病 6707 +日 6708 +o 6709 +憍 6710 +镍 6711 +蜛 6712 +壤 6713 +葑 6714 +聚 6715 +灭 6716 +立 6717 +怙 6718 +m@@ 6719 +诙 6720 +躲 6721 +泡 6722 +would 6723 +缺 6724 +横 6725 +违 6726 +蛛 6727 +ls 6728 +鹮 6729 +鲐 6730 +茌 6731 +罚 6732 +ques@@ 6733 +畦 6734 +厍 6735 +缳 6736 +劭 6737 +拐 6738 +萍 6739 +债 6740 +偈 6741 +镛 6742 +踽 6743 +趋 6744 +扭 6745 +椒 6746 +痔 6747 +史 6748 +称 6749 +thing 6750 +q@@ 6751 +fre@@ 6752 +剁 6753 +beach 6754 +舢 6755 +黠 6756 +执 6757 +饵 6758 +欤 6759 +骥 6760 +蓄 6761 +邈 6762 +疚 6763 +'@@ 6764 +吓 6765 +撄 6766 +苌 6767 +蜢 6768 +mar@@ 6769 +桓 6770 +made 6771 +煦 6772 +襁 6773 +垃 6774 +姓 6775 +送 6776 +柜 6777 +埪 6778 +怠 6779 +蚍 6780 +little 6781 +殴 6782 +炕 6783 +蛙 6784 +伱 6785 +禁 6786 +睺 6787 +捍 6788 +颀 6789 +猬 6790 +蟊 6791 +岢 6792 +o@@ 6793 +纩 6794 +妨 6795 +阡 6796 +率 6797 +蒋 6798 +寐 6799 +孚 6800 +想 6801 +洁 6802 +沮 6803 +铦 6804 +镟 6805 +潲 6806 +杮 6807 +tal 6808 +镯 6809 +虞 6810 +鶒 6811 +椐 6812 +簦 6813 +锈 6814 +顗 6815 +仑 6816 +额 6817 +掼 6818 +耿 6819 +荑 6820 +撙 6821 +谍 6822 +沃 6823 +惊 6824 +碉 6825 +皓 6826 +峯 6827 +炮 6828 +厶 6829 +拼 6830 +得 6831 +帅 6832 +蜎 6833 +莠 6834 +近 6835 +诩 6836 +喓 6837 +sequence 6838 +愉 6839 +ure 6840 +枯 6841 +thous@@ 6842 +槅 6843 +移 6844 +菡 6845 +菪 6846 +ten 6847 +癍 6848 +珀 6849 +湎 6850 +瓿 6851 +名 6852 +妇 6853 +再 6854 +傺 6855 +蚬 6856 +荡 6857 +孽 6858 +匜 6859 +堠 6860 +荜 6861 +绉 6862 +刽 6863 +des 6864 +1@@ 6865 +留 6866 +裎 6867 +赐 6868 +ving 6869 +光 6870 +ly 6871 +睟 6872 +遂 6873 +琮 6874 +俟 6875 +岛 6876 +蛹 6877 +蒿 6878 +nor@@ 6879 +钦 6880 +麻 6881 +纫 6882 +coun@@ 6883 +杷 6884 +宿 6885 +吣 6886 +蝎 6887 +湃 6888 +惬 6889 +enjo@@ 6890 +卯 6891 +牸 6892 +诠 6893 +谷 6894 +摞 6895 +螈 6896 +然 6897 +盂 6898 +mple 6899 +萁 6900 +灰 6901 +畊 6902 +when 6903 +烈 6904 +俸 6905 +衿 6906 +龟 6907 +谭 6908 +唳 6909 +楀 6910 +痪 6911 +曳 6912 +羿 6913 +罂 6914 +脱 6915 +廿 6916 +sur@@ 6917 +拝 6918 +洵 6919 +湿 6920 +鸽 6921 +冂 6922 +跛 6923 +school 6924 +尡 6925 +挡 6926 +疭 6927 +议 6928 +self 6929 +拍 6930 +溺 6931 +垅 6932 +瞳 6933 +笑 6934 +thousand 6935 +乓 6936 +噼 6937 +lie@@ 6938 +沿 6939 +俣 6940 +灏 6941 +赈 6942 +砭 6943 +造 6944 +标 6945 +loo@@ 6946 +罅 6947 +鳣 6948 +翥 6949 +铬 6950 +诞 6951 +酌 6952 +佟 6953 +歘 6954 +瑀 6955 +眬 6956 +璁 6957 +拣 6958 +u 6959 +颤 6960 +笏 6961 +ur@@ 6962 +蝌 6963 +蛟 6964 +number 6965 +馕 6966 +place 6967 +伐 6968 +热 6969 +ver@@ 6970 +淜 6971 +嚼 6972 +鼢 6973 +謩 6974 +趟 6975 +恬 6976 +搅 6977 +伫 6978 +杌 6979 +琐 6980 +崽 6981 +苗 6982 +其 6983 +熄 6984 +在 6985 +泾 6986 +毓 6987 +very 6988 +琥 6989 +孤 6990 +扞 6991 +恪 6992 +t's 6993 +埙 6994 +do 6995 +攮 6996 +昴 6997 +靥 6998 +feel 6999 +酗 7000 +蕫 7001 +der 7002 +焙 7003 +gen@@ 7004 +辛 7005 +觞 7006 +平 7007 +椤 7008 +耦 7009 +黜 7010 +寇 7011 +醍 7012 +耶 7013 +撰 7014 +蔌 7015 +曜 7016 +谂 7017 +烛 7018 +旦 7019 +kno@@ 7020 +爪 7021 +絺 7022 +欢 7023 +圈 7024 +臂 7025 +ch 7026 +谆 7027 +荏 7028 +巫 7029 +擐 7030 +嵋 7031 +霸 7032 +ames 7033 +wel@@ 7034 +叆 7035 +傧 7036 +tra@@ 7037 +隳 7038 +唆 7039 +澧 7040 +焦 7041 +窭 7042 +剜 7043 +菇 7044 +锇 7045 +锑 7046 +枳 7047 +娈 7048 +侗 7049 +橙 7050 +愊 7051 +泯 7052 +茼 7053 +荙 7054 +遭 7055 +蟥 7056 +捱 7057 +挤 7058 +蹊 7059 +暹 7060 +ity 7061 +珠 7062 +驼 7063 +嗷 7064 +贪 7065 +扔 7066 +硒 7067 +沣 7068 +鸡 7069 +蕖 7070 +仓 7071 +looking 7072 +冦 7073 +邳 7074 +赝 7075 +嵛 7076 +chi@@ 7077 +典 7078 +桧 7079 +about 7080 +茏 7081 +es@@ 7082 +缒 7083 +蕻 7084 +闽 7085 +独 7086 +骨 7087 +尰 7088 +鲼 7089 +und@@ 7090 +详 7091 +姩 7092 +脉 7093 +腥 7094 +拯 7095 +婕 7096 +杜 7097 +俑 7098 +经 7099 +they 7100 +距 7101 +僧 7102 +辅 7103 +骂 7104 +睛 7105 +夤 7106 +door 7107 +捊 7108 +靴 7109 +庅 7110 +劓 7111 +舣 7112 +饿 7113 +幻 7114 +ry 7115 +悱 7116 +畎 7117 +先 7118 +畠 7119 +ye@@ 7120 +those 7121 +菲 7122 +榔 7123 +邑 7124 +city 7125 +泖 7126 +夕 7127 +better 7128 +吏 7129 +酡 7130 +蕾 7131 +犯 7132 +磲 7133 +锝 7134 +鹌 7135 +鹫 7136 +you@@ 7137 +耐 7138 +梯 7139 +鸬 7140 +榛 7141 +笛 7142 +睡 7143 +歪 7144 +遍 7145 +飒 7146 +笃 7147 +蓝 7148 +于 7149 +欸 7150 +为 7151 +琎 7152 +宁 7153 +ki@@ 7154 +tle 7155 +直 7156 +漕 7157 +years 7158 +烙 7159 +捏 7160 +馅 7161 +re 7162 +诺 7163 +tur@@ 7164 +deci@@ 7165 +励 7166 +佘 7167 +bl@@ 7168 +of 7169 +噩 7170 +阪 7171 +梿 7172 +孃 7173 +杻 7174 +覃 7175 +瞆 7176 +瑷 7177 +观 7178 +循 7179 +泪 7180 +six@@ 7181 +集 7182 +cust@@ 7183 +瘠 7184 +尽 7185 +鱀 7186 +虎 7187 +来 7188 +other 7189 +衮 7190 +芷 7191 +卓 7192 +擒 7193 +锶 7194 +玻 7195 +lu@@ 7196 +缝 7197 +瀬 7198 +缯 7199 +嫖 7200 +偭 7201 +运 7202 +厣 7203 +顺 7204 +璝 7205 +锬 7206 +悼 7207 +off 7208 +苑 7209 +恤 7210 +囡 7211 +锻 7212 +舁 7213 +粼 7214 +丑 7215 +字 7216 +酽 7217 +鲜 7218 +豹 7219 +厝 7220 +琤 7221 +谞 7222 +簖 7223 +癣 7224 +蚌 7225 +退 7226 +戡 7227 +云 7228 +颞 7229 +cur@@ 7230 +腻 7231 +呗 7232 +沼 7233 +骤 7234 +崭 7235 +扯 7236 +怍 7237 +app@@ 7238 +存 7239 +害 7240 +色 7241 +踣 7242 +淘 7243 +嗦 7244 +獠 7245 +怗 7246 +砸 7247 +镜 7248 +毹 7249 +table 7250 +@ 7251 +鲀 7252 +寤 7253 +桥 7254 +嘞 7255 +髁 7256 +哙 7257 +织 7258 +嗒 7259 +麂 7260 +let 7261 +偁 7262 +苎 7263 +燧 7264 +rou@@ 7265 +哃 7266 +鄂 7267 +翌 7268 +酚 7269 +咄 7270 +嗥 7271 +蒸 7272 +虚 7273 +囗 7274 +条 7275 +鹕 7276 +挖 7277 +per@@ 7278 +矿 7279 +珦 7280 +囨 7281 +冻 7282 +佰 7283 +洲 7284 +漓 7285 +菹 7286 +镱 7287 +尝 7288 +low 7289 +苜 7290 +剞 7291 +could 7292 +埒 7293 +铼 7294 +结 7295 +侵 7296 +星 7297 +then 7298 +埠 7299 +峤 7300 +晒 7301 +睦 7302 +搓 7303 +時 7304 +铫 7305 +偃 7306 +might 7307 +氦 7308 +呻 7309 +淦 7310 +ge 7311 +缧 7312 +脑 7313 +膴 7314 +英 7315 +pla@@ 7316 +案 7317 +生 7318 +顔 7319 +记 7320 +艰 7321 +tic@@ 7322 +砖 7323 +药 7324 +宪 7325 +涩 7326 +鞠 7327 +潆 7328 +郭 7329 +宵 7330 +砀 7331 +蟛 7332 +岀 7333 +赃 7334 +蛤 7335 +粢 7336 +驶 7337 +膂 7338 +垤 7339 +nee@@ 7340 +逐 7341 +芳 7342 +螂 7343 +超 7344 +change 7345 +j 7346 +锔 7347 +箓 7348 +笤 7349 +弱 7350 +畸 7351 +分 7352 +攸 7353 +蛸 7354 +烷 7355 +脒 7356 +昨 7357 +咴 7358 +羚 7359 +芍 7360 +卲 7361 +噔 7362 +飖 7363 +蜍 7364 +some@@ 7365 +崦 7366 +嬛 7367 +仍 7368 +柳 7369 +鹗 7370 +増 7371 +乙 7372 +嫂 7373 +骚 7374 +巾 7375 +憬 7376 +刭 7377 +顸 7378 +磙 7379 +伃 7380 +孜 7381 +碌 7382 +堐 7383 +祗 7384 +鳉 7385 +汊 7386 +ab@@ 7387 +袄 7388 +诚 7389 +剡 7390 +锷 7391 +锕 7392 +益 7393 +箕 7394 +瘾 7395 +restaur@@ 7396 +鹣 7397 +瘩 7398 +矸 7399 +his 7400 +灸 7401 +勖 7402 +括 7403 +呢 7404 +漯 7405 +罟 7406 +嚓 7407 +焗 7408 +tru@@ 7409 +赛 7410 +伶 7411 +垢 7412 +擘 7413 +镠 7414 +惰 7415 +贬 7416 +惙 7417 +捺 7418 +sto@@ 7419 +犁 7420 +were 7421 +艉 7422 +佞 7423 +配 7424 +耙 7425 +猢 7426 +遗 7427 +豊 7428 +急 7429 +蓟 7430 +泓 7431 +屑 7432 +焊 7433 +筲 7434 +缈 7435 +柔 7436 +蜀 7437 +珺 7438 +怕 7439 +淡 7440 +. 7441 +靛 7442 +载 7443 +堎 7444 +潥 7445 +懒 7446 +邸 7447 +惇 7448 +粥 7449 +斫 7450 +漼 7451 +re's 7452 +簇 7453 +修 7454 +梏 7455 +tr@@ 7456 +肟 7457 +葙 7458 +鲸 7459 +呐 7460 +游 7461 +廖 7462 +收 7463 +鳅 7464 +覆 7465 +琳 7466 +熵 7467 +翱 7468 +民 7469 +飨 7470 +螳 7471 +媳 7472 +辖 7473 +旯 7474 +榴 7475 +瑰 7476 +嗯 7477 +渤 7478 +ic 7479 +熔 7480 +暝 7481 +柘 7482 +鬟 7483 +斋 7484 +夸 7485 +晢 7486 +氰 7487 +炫 7488 +dn't 7489 +押 7490 +撺 7491 +烁 7492 +偶 7493 +貅 7494 +冀 7495 +胎 7496 +糊 7497 +润 7498 +咭 7499 +各 7500 +摐 7501 +錞 7502 +垌 7503 +插 7504 +舯 7505 +嶙 7506 +筋 7507 +住 7508 +酆 7509 +镰 7510 +工 7511 +嗲 7512 +铱 7513 +暖 7514 +off@@ 7515 +茔 7516 +髂 7517 +挺 7518 +瞑 7519 +伤 7520 +忌 7521 +僬 7522 +萜 7523 +普 7524 +that@@ 7525 +爵 7526 +稍 7527 +瓠 7528 +崛 7529 +um 7530 +磐 7531 +投 7532 +畅 7533 +缕 7534 +楯 7535 +悒 7536 +判 7537 +筢 7538 +馇 7539 +芰 7540 +遒 7541 +黩 7542 +迪 7543 +蝾 7544 +腈 7545 +才 7546 +峙 7547 +悟 7548 +禩 7549 +惋 7550 +five 7551 +局 7552 +砘 7553 +ver 7554 +恋 7555 +斓 7556 +蹀 7557 +犹 7558 +绶 7559 +ep 7560 +喛 7561 +亩 7562 +嵘 7563 +汆 7564 +狸 7565 +迸 7566 +盉 7567 +line 7568 +妣 7569 +躐 7570 +琢 7571 +赣 7572 +le 7573 +疬 7574 +穿 7575 +焓 7576 +跳 7577 +匪 7578 +feat@@ 7579 +鹦 7580 +璠 7581 +蔸 7582 +百 7583 +愁 7584 +芋 7585 +吙 7586 +谚 7587 +熳 7588 +哩 7589 +ad 7590 +轧 7591 +祇 7592 +泬 7593 +瘳 7594 +列 7595 +簃 7596 +谦 7597 +lea@@ 7598 +垴 7599 +啤 7600 +滇 7601 +饮 7602 +瞰 7603 +獭 7604 +倏 7605 +肿 7606 +宜 7607 +涮 7608 +礓 7609 +数 7610 +屌 7611 +濒 7612 +蟜 7613 +上 7614 +詝 7615 +褐 7616 +眭 7617 +菟 7618 +瘅 7619 +蝙 7620 +眜 7621 +羡 7622 +硖 7623 +背 7624 +芣 7625 +歃 7626 +红 7627 +血 7628 +混 7629 +橞 7630 +樽 7631 +纶 7632 +圭 7633 +stay 7634 +冗 7635 +竑 7636 +玥 7637 +它 7638 +怎 7639 +薜 7640 +殆 7641 +看 7642 +剖 7643 +棠 7644 +瘙 7645 +be@@ 7646 +裤 7647 +篙 7648 +带 7649 +助 7650 +骀 7651 +锍 7652 +廓 7653 +库 7654 +绅 7655 +劂 7656 +蝻 7657 +锘 7658 +濩 7659 +等 7660 +魑 7661 +酿 7662 +阆 7663 +弑 7664 +悖 7665 +姹 7666 +傀 7667 +which 7668 +股 7669 +湲 7670 +贝 7671 +相 7672 +茑 7673 +荔 7674 +钲 7675 +致 7676 +沦 7677 +另 7678 +桑 7679 +佳 7680 +班 7681 +昼 7682 +埂 7683 +剕 7684 +箐 7685 +岵 7686 +涨 7687 +柩 7688 +两 7689 +勰 7690 +氮 7691 +稽 7692 +匝 7693 +ting 7694 +瘀 7695 +缔 7696 +鹉 7697 +雕 7698 +蛮 7699 +镞 7700 +魈 7701 +謇 7702 +me@@ 7703 +set 7704 +豇 7705 +h 7706 +秫 7707 +荬 7708 +赢 7709 +沩 7710 +向 7711 +石 7712 +踖 7713 +迥 7714 +受 7715 +抅 7716 +镘 7717 +嚭 7718 +双 7719 +锚 7720 +ex@@ 7721 +辉 7722 +谠 7723 +綉 7724 +ws 7725 +吟 7726 +湔 7727 +ll@@ 7728 +市 7729 +娥 7730 +堢 7731 +跽 7732 +处 7733 +厂 7734 +朓 7735 +笸 7736 +悭 7737 +孥 7738 +嘀 7739 +恻 7740 +皱 7741 +塄 7742 +who@@ 7743 +妙 7744 +栽 7745 +瓷 7746 +稔 7747 +碧 7748 +哽 7749 +pol@@ 7750 +徨 7751 +s 7752 +邋 7753 +息 7754 +樨 7755 +鼾 7756 +拱 7757 +谟 7758 +墡 7759 +匆 7760 +speci@@ 7761 +ha@@ 7762 +庐 7763 +鹿 7764 +ak@@ 7765 +镣 7766 +m 7767 +唯 7768 +州 7769 +筒 7770 +性 7771 +髋 7772 +哄 7773 +镡 7774 +贇 7775 +and 7776 +菩 7777 +姻 7778 +聪 7779 +tion 7780 +催 7781 +醌 7782 +唢 7783 +雾 7784 +堂 7785 +芝 7786 +擢 7787 +匈 7788 +悸 7789 +钢 7790 +gy 7791 +徉 7792 +渡 7793 +誊 7794 +ti@@ 7795 +军 7796 +铸 7797 +和 7798 +沄 7799 +兽 7800 +短 7801 +育 7802 +韪 7803 +邀 7804 +谴 7805 +耋 7806 +浩 7807 +public 7808 +胝 7809 +猴 7810 +six 7811 +黧 7812 +being 7813 +弊 7814 +捂 7815 +ical 7816 +癖 7817 +堆 7818 +伧 7819 +割 7820 +引 7821 +la@@ 7822 +鼙 7823 +逻 7824 +堪 7825 +cap@@ 7826 +首 7827 +谝 7828 +瘦 7829 +硐 7830 +郑 7831 +凑 7832 +fu@@ 7833 +b@@ 7834 +睨 7835 +骠 7836 +佼 7837 +篦 7838 +贵 7839 +铌 7840 +act 7841 +呑 7842 +竖 7843 +莅 7844 +陕 7845 +飙 7846 +黏 7847 +镆 7848 +屦 7849 +仨 7850 +敫 7851 +马 7852 +埜 7853 +垇 7854 +阇 7855 +耒 7856 +笩 7857 +蓂 7858 +妃 7859 +讨 7860 +see@@ 7861 +吊 7862 +踊 7863 +弥 7864 +梅 7865 +坑 7866 +渫 7867 +au@@ 7868 +赡 7869 +坛 7870 +苹 7871 +don@@ 7872 +察 7873 +俚 7874 +fir@@ 7875 +濑 7876 +块 7877 +浏 7878 +唵 7879 +婴 7880 +恙 7881 +隈 7882 +麽 7883 +ca@@ 7884 +莼 7885 +御 7886 +鞋 7887 +禧 7888 +衰 7889 +潩 7890 +茬 7891 +ven 7892 +瞌 7893 +鸥 7894 +凭 7895 +咯 7896 +饺 7897 +耄 7898 +uni@@ 7899 +瑟 7900 +煟 7901 +舟 7902 +尅 7903 +帚 7904 +鼋 7905 +嫔 7906 +婄 7907 +赟 7908 +棼 7909 +沪 7910 +秃 7911 +粧 7912 +roo@@ 7913 +蚳 7914 +亏 7915 +备 7916 +压 7917 +悍 7918 +嚅 7919 +fin@@ 7920 +交 7921 +癔 7922 +跞 7923 +莪 7924 +闻 7925 +罾 7926 +炁 7927 +钻 7928 +鹆 7929 +友 7930 +傣 7931 +糁 7932 +service 7933 +邾 7934 +窝 7935 +扉 7936 +鲡 7937 +盛 7938 +玖 7939 +卫 7940 +ne@@ 7941 +眛 7942 +鸟 7943 +党 7944 +杼 7945 +碓 7946 +篚 7947 +裆 7948 +钟 7949 +大 7950 +介 7951 +揩 7952 +砻 7953 +卅 7954 +弟 7955 +颛 7956 +嫪 7957 +代 7958 +会 7959 +悻 7960 +者 7961 +箍 7962 +番 7963 +疯 7964 +sp@@ 7965 +聃 7966 +瀚 7967 +挥 7968 +拷 7969 +氡 7970 +璎 7971 +肪 7972 +tan 7973 +ul@@ 7974 +蠃 7975 +龊 7976 +家 7977 +挽 7978 +must 7979 +蹭 7980 +樟 7981 +令 7982 +饶 7983 +buy 7984 +忖 7985 +娠 7986 +手 7987 +house 7988 +贷 7989 +嘚 7990 +莺 7991 +茛 7992 +宥 7993 +刼 7994 +傥 7995 +rest 7996 +厄 7997 +洙 7998 +牝 7999 +林 8000 +铆 8001 +觳 8002 +嬢 8003 +芥 8004 +充 8005 +絮 8006 +陲 8007 +zer@@ 8008 +藤 8009 +air@@ 8010 +贤 8011 +趾 8012 +孝 8013 +袖 8014 +氇 8015 +章 8016 +柷 8017 +恓 8018 +麹 8019 +胧 8020 +came 8021 +休 8022 +舲 8023 +籼 8024 +惕 8025 +泼 8026 +馨 8027 +町 8028 +wer 8029 +鹧 8030 +咚 8031 +崔 8032 +嵂 8033 +蝈 8034 +ils 8035 +湛 8036 +报 8037 +蜷 8038 +削 8039 +根 8040 +恣 8041 +呙 8042 +郫 8043 +把 8044 +窕 8045 +癌 8046 +xt 8047 +榘 8048 +傫 8049 +用 8050 +撕 8051 +蚩 8052 +菰 8053 +崖 8054 +酐 8055 +襄 8056 +cu@@ 8057 +呤 8058 +帯 8059 +炊 8060 +揲 8061 +啸 8062 +鲶 8063 +ve@@ 8064 +艟 8065 +嗜 8066 +ili@@ 8067 +凹 8068 +球 8069 +鬓 8070 +荼 8071 +给 8072 +亘 8073 +醯 8074 +燔 8075 +鳕 8076 +侄 8077 +簪 8078 +洳 8079 +嘛 8080 +稠 8081 +'t 8082 +氘 8083 +暍 8084 +指 8085 +泆 8086 +总 8087 +offer 8088 +糅 8089 +膀 8090 +毋 8091 +蹲 8092 +煅 8093 +桔 8094 +什 8095 +浜 8096 +八 8097 +涧 8098 +跬 8099 +祷 8100 +湫 8101 +脸 8102 +描 8103 +pr@@ 8104 +狱 8105 +赉 8106 +砼 8107 +袜 8108 +馑 8109 +枪 8110 +碁 8111 +娜 8112 +辇 8113 +颐 8114 +捩 8115 +峥 8116 +聆 8117 +善 8118 +询 8119 +疝 8120 +坜 8121 +碚 8122 +缥 8123 +鼯 8124 +钮 8125 +腘 8126 +溥 8127 +脔 8128 +殁 8129 +肮 8130 +泮 8131 +捐 8132 +槛 8133 +吆 8134 +河 8135 +溆 8136 +度 8137 +殒 8138 +骕 8139 +饹 8140 +cour@@ 8141 +臜 8142 +空 8143 +2@@ 8144 +允 8145 +枉 8146 +蒯 8147 +or@@ 8148 +趔 8149 +菠 8150 +栌 8151 +view 8152 +possi@@ 8153 +仗 8154 +鳄 8155 +摊 8156 +寝 8157 +躯 8158 +湝 8159 +后 8160 +删 8161 +揪 8162 +徐 8163 +寒 8164 +坨 8165 +挣 8166 +举 8167 +氅 8168 +臻 8169 +饔 8170 +丁 8171 +彦 8172 +泉 8173 +曷 8174 +衅 8175 +ei@@ 8176 +呸 8177 +狍 8178 +甓 8179 +禽 8180 +薄 8181 +shipping 8182 +ght 8183 +慵 8184 +幽 8185 +匣 8186 +炬 8187 +访 8188 +four 8189 +秒 8190 +樗 8191 +陶 8192 +脓 8193 +枰 8194 +磷 8195 +哕 8196 +君 8197 +脲 8198 +灯 8199 +回 8200 +索 8201 +璺 8202 +邡 8203 +愍 8204 +薏 8205 +辄 8206 +珽 8207 +離 8208 +鬃 8209 +赊 8210 +枲 8211 +邰 8212 +佷 8213 +颏 8214 +寺 8215 +抵 8216 +鲅 8217 +刎 8218 +給 8219 +戴 8220 +溲 8221 +袢 8222 +less 8223 +逯 8224 +封 8225 +food 8226 +匮 8227 +关 8228 +尘 8229 +皤 8230 +鳍 8231 +搏 8232 +幌 8233 +连 8234 +杠 8235 +蹋 8236 +彷 8237 +虺 8238 +怀 8239 +雏 8240 +钿 8241 +澄 8242 +扶 8243 +错 8244 +樋 8245 +黉 8246 +胄 8247 +薇 8248 +阿 8249 +墁 8250 +觜 8251 +利 8252 +汵 8253 +羟 8254 +杀 8255 +淋 8256 +蓁 8257 +腧 8258 +哞 8259 +was 8260 +钊 8261 +胸 8262 +艽 8263 +撞 8264 +虑 8265 +煋 8266 +飗 8267 +throu@@ 8268 +门 8269 +玦 8270 +琪 8271 +煸 8272 +酵 8273 +婌 8274 +serv@@ 8275 +硿 8276 +净 8277 +夐 8278 +撅 8279 +頠 8280 +want 8281 +犄 8282 +厉 8283 +p@@ 8284 +en 8285 +斗 8286 +层 8287 +玫 8288 +life 8289 +潘 8290 +骐 8291 +臃 8292 +谮 8293 +殃 8294 +厌 8295 +摄 8296 +磾 8297 +v 8298 +dress 8299 +络 8300 +囱 8301 +眯 8302 +忽 8303 +壸 8304 +咐 8305 +搁 8306 +肤 8307 +魇 8308 +芬 8309 +窡 8310 +拉 8311 +纮 8312 +楣 8313 +蔬 8314 +寄 8315 +妯 8316 +教 8317 +俪 8318 +颡 8319 +碛 8320 +互 8321 +奈 8322 +憎 8323 +炉 8324 +蹰 8325 +聂 8326 +员 8327 +呶 8328 +瞬 8329 +il@@ 8330 +恿 8331 +阙 8332 +卡 8333 +mi@@ 8334 +禾 8335 +椴 8336 +yo@@ 8337 +帀 8338 +醵 8339 +帝 8340 +隔 8341 +忒 8342 +哑 8343 +効 8344 +楗 8345 +鼱 8346 +塽 8347 +苴 8348 +蜞 8349 +健 8350 +醅 8351 +ju@@ 8352 +新 8353 +程 8354 +茗 8355 +琰 8356 +几 8357 +揍 8358 +匍 8359 +砣 8360 +禳 8361 +罗 8362 +勿 8363 +擗 8364 +畛 8365 +框 8366 +泒 8367 +析 8368 +沢 8369 +偷 8370 +繁 8371 +嗣 8372 +呵 8373 +念 8374 +so@@ 8375 +溷 8376 +曩 8377 +spon@@ 8378 +狼 8379 +倔 8380 +威 8381 +潭 8382 +踯 8383 +晁 8384 +吩 8385 +袅 8386 +喀 8387 +洌 8388 +炯 8389 +纸 8390 +抽 8391 +簧 8392 +c@@ 8393 +买 8394 +吖 8395 +俬 8396 +梓 8397 +叡 8398 +祼 8399 +烃 8400 +荃 8401 +眀 8402 + 8403 diff --git a/app/src/main/assets/models/voice/bilingual-zipformer/decoder-epoch-99-avg-1.onnx b/app/src/main/assets/models/voice/bilingual-zipformer/decoder-epoch-99-avg-1.onnx deleted file mode 100644 index ac9909ad..00000000 --- a/app/src/main/assets/models/voice/bilingual-zipformer/decoder-epoch-99-avg-1.onnx +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:2e3b5ec371f8899ee6acd829fd753ba45772df57a91bdf37cde3136354e7db7d -size 13876452 diff --git a/app/src/main/assets/models/voice/bilingual-zipformer/encoder-epoch-99-avg-1.int8.onnx b/app/src/main/assets/models/voice/bilingual-zipformer/encoder-epoch-99-avg-1.int8.onnx deleted file mode 100644 index 2be64b88..00000000 --- a/app/src/main/assets/models/voice/bilingual-zipformer/encoder-epoch-99-avg-1.int8.onnx +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:8fa764187a261844f859d7143ebaa563af5d10adfece4c18a8f414c88cba2a9b -size 181895032 diff --git a/app/src/main/assets/models/voice/bilingual-zipformer/joiner-epoch-99-avg-1.int8.onnx b/app/src/main/assets/models/voice/bilingual-zipformer/joiner-epoch-99-avg-1.int8.onnx deleted file mode 100644 index 3a445a3b..00000000 --- a/app/src/main/assets/models/voice/bilingual-zipformer/joiner-epoch-99-avg-1.int8.onnx +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:1ed689c5ed19dbaa725d9d191bb4822b5f4855a39e1ffd28cbc1f340d25b2ee0 -size 3228404 diff --git a/app/src/main/assets/models/voice/bilingual-zipformer/tokens.txt b/app/src/main/assets/models/voice/bilingual-zipformer/tokens.txt deleted file mode 100644 index 980dd6cd..00000000 --- a/app/src/main/assets/models/voice/bilingual-zipformer/tokens.txt +++ /dev/null @@ -1,6257 +0,0 @@ - 0 - 1 - 2 -▁AS 3 -▁ONE 4 -▁OF 5 -▁A 6 -▁COMP 7 -AN 8 -Y 9 -▁SOME 10 -▁HUNDRED 11 -▁AND 12 -▁FI 13 -F 14 -T 15 -▁ME 16 -N 17 -▁WO 18 -M 19 -EN 20 -▁NOT 21 -▁LA 22 -BO 23 -UR 24 -ING 25 -▁UNDER 26 -▁ANY 27 -▁SU 28 -S 29 -P 30 -IC 31 -ION 32 -▁ 33 -LU 34 -AC 35 -▁THAT 36 -▁THE 37 -▁CO 38 -▁CHA 39 -CE 40 -RY 41 -▁THOUGH 42 -▁SH 43 -IN 44 -B 45 -J 46 -E 47 -C 48 -▁MUCH 49 -▁PO 50 -UL 51 -AR 52 -▁PRE 53 -U 54 -DI 55 -▁AT 56 -▁WHICH 57 -▁POINT 58 -▁I 59 -▁JU 60 -D 61 -GE 62 -▁E 63 -▁HAD 64 -▁CA 65 -▁IN 66 -▁MY 67 -▁DI 68 -RE 69 -CTION 70 -▁WAS 71 -L 72 -MO 73 -ATE 74 -▁THERE 75 -▁BEEN 76 -▁HE 77 -IT 78 -TED 79 -▁T 80 -RI 81 -VI 82 -AL 83 -▁B 84 -LE 85 -ISH 86 -▁OR 87 -▁SO 88 -▁IT 89 -▁RA 90 -TE 91 -▁PRO 92 -G 93 -▁BUT 94 -▁THIS 95 -▁EX 96 -A 97 -ER 98 -ATED 99 -▁EN 100 -TI 101 -LY 102 -OW 103 -▁TO 104 -▁PA 105 -R 106 -IM 107 -ON 108 -▁PUBLIC 109 -▁GU 110 -IL 111 -▁APP 112 -ED 113 -▁UN 114 -ENT 115 -▁MOST 116 -▁DE 117 -TER 118 -▁MAN 119 -▁ON 120 -▁BY 121 -▁NO 122 -▁MEAN 123 -▁NUMBER 124 -O 125 -▁BELIEVE 126 -▁RI 127 -CH 128 -▁SECOND 129 -▁OTHER 130 -▁K 131 -▁WILL 132 -▁DO 133 -▁WELL 134 -▁SEE 135 -UND 136 -▁JO 137 -KE 138 -▁BE 139 -▁BO 140 -OK 141 -▁COULD 142 -▁THEM 143 -▁THESE 144 -▁P 145 -AGE 146 -▁SHA 147 -ME 148 -IOUS 149 -▁IS 150 -▁ONLY 151 -▁WORD 152 -▁RE 153 -K 154 -I 155 -▁SHOULD 156 -▁HAVE 157 -OR 158 -ITY 159 -▁WHAT 160 -LL 161 -▁SP 162 -TA 163 -NE 164 -OUS 165 -▁COM 166 -US 167 -TION 168 -▁HAS 169 -▁S 170 -TH 171 -▁MI 172 -RO 173 -▁GOOD 174 -▁FRIEND 175 -▁LE 176 -W 177 -ES 178 -▁WITH 179 -▁WHO 180 -▁THINK 181 -▁MU 182 -IG 183 -▁MO 184 -▁MIGHT 185 -▁AN 186 -QU 187 -ATION 188 -▁FROM 189 -▁NA 190 -TURE 191 -▁ALL 192 -IES 193 -▁LET 194 -▁TIME 195 -▁WHEN 196 -▁EVEN 197 -▁NEED 198 -▁O 199 -VE 200 -▁WI 201 -FUL 202 -▁NE 203 -LI 204 -AD 205 -ERS 206 -▁BEFORE 207 -▁WORK 208 -▁LIKE 209 -▁HAND 210 -▁THEN 211 -SH 212 -▁WERE 213 -OM 214 -▁KNOW 215 -EL 216 -MA 217 -▁OVER 218 -▁LO 219 -TING 220 -▁LI 221 -OL 222 -▁HA 223 -MP 224 -LA 225 -ABLE 226 -MB 227 -▁WE 228 -▁ST 229 -ET 230 -▁IF 231 -▁WA 232 -▁NEW 233 -IR 234 -▁FA 235 -▁G 236 -IAN 237 -ND 238 -VER 239 -IS 240 -▁DIS 241 -CO 242 -UN 243 -▁SEVEN 244 -▁TH 245 -▁AFTER 246 -WARD 247 -▁RO 248 -▁WOULD 249 -▁SA 250 -▁FOR 251 -▁FE 252 -▁LONG 253 -▁STILL 254 -▁CON 255 -X 256 -▁HERE 257 -▁EVERYTHING 258 -▁SE 259 -ANT 260 -ALLY 261 -RU 262 -▁THAN 263 -ANCE 264 -▁ARE 265 -▁NEXT 266 -▁DOWN 267 -▁CHI 268 -▁MA 269 -CK 270 -▁DR 271 -Z 272 -▁F 273 -ROW 274 -▁GO 275 -▁INTO 276 -INE 277 -▁SIX 278 -▁YEAR 279 -▁HIS 280 -CI 281 -ENCE 282 -▁CLOSE 283 -▁LAST 284 -▁C 285 -▁MORE 286 -▁THOUSAND 287 -LO 288 -▁UP 289 -▁WANT 290 -▁JA 291 -▁GA 292 -▁THROUGH 293 -▁PLACE 294 -▁HU 295 -MAN 296 -UGH 297 -IST 298 -▁HO 299 -▁BETTER 300 -▁THEIR 301 -▁VERY 302 -NG 303 -▁ANOTHER 304 -▁UM 305 -AS 306 -▁TE 307 -V 308 -▁HER 309 -▁HIGH 310 -QUI 311 -▁BECAUSE 312 -▁SHOW 313 -▁WHERE 314 -▁DAY 315 -▁BRO 316 -▁OLD 317 -MENT 318 -▁HARD 319 -▁THOSE 320 -CU 321 -AT 322 -▁INTEREST 323 -▁EVERY 324 -HE 325 -▁GIVE 326 -▁FACT 327 -▁FOUND 328 -▁HEAR 329 -▁NEVER 330 -▁CAN 331 -▁COME 332 -▁SORT 333 -ITION 334 -EST 335 -IGHT 336 -SHIP 337 -SIDE 338 -▁GREAT 339 -▁MAR 340 -▁GET 341 -▁OUT 342 -▁STA 343 -OP 344 -AIN 345 -▁PI 346 -RA 347 -ID 348 -▁DA 349 -▁LOOK 350 -END 351 -▁BA 352 -VO 353 -H 354 -▁LITTLE 355 -▁THEY 356 -▁ALWAYS 357 -CA 358 -▁HOUSE 359 -TIC 360 -IVE 361 -▁RU 362 -HO 363 -▁RIGHT 364 -ICAL 365 -HA 366 -▁AGAIN 367 -▁EXP 368 -IBLE 369 -▁CERTAIN 370 -▁SHE 371 -▁PRI 372 -▁PLAY 373 -▁VA 374 -LIC 375 -▁TWO 376 -▁THREE 377 -▁DOES 378 -▁YOU 379 -▁TALK 380 -▁ABOUT 381 -UT 382 -▁WITHOUT 383 -▁PEOPLE 384 -FF 385 -▁DON 386 -▁HAPPEN 387 -▁PERSON 388 -▁MADE 389 -▁PART 390 -▁HOW 391 -▁WHY 392 -▁MAKE 393 -▁HIM 394 -▁STATE 395 -▁GRA 396 -▁TRA 397 -FOR 398 -▁LIFE 399 -▁PER 400 -AM 401 -▁DU 402 -PORT 403 -IA 404 -▁BUSINESS 405 -▁UNDERSTAND 406 -▁PLAN 407 -▁KEEP 408 -▁GOT 409 -▁MONEY 410 -▁OWN 411 -▁MANY 412 -ONE 413 -▁BU 414 -▁REALLY 415 -▁COURSE 416 -▁THINGS 417 -▁SAID 418 -▁US 419 -▁WAY 420 -▁TAKE 421 -▁WORLD 422 -▁THING 423 -▁SAY 424 -▁TA 425 -▁SOMETHING 426 -▁LEARN 427 -DE 428 -▁QUESTION 429 -DAY 430 -▁NOW 431 -▁BACK 432 -▁YOUR 433 -ACH 434 -▁SPEAK 435 -▁TURN 436 -ATIVE 437 -▁OUR 438 -▁JE 439 -▁VI 440 -▁HI 441 -▁OP 442 -▁WEEK 443 -▁IDEA 444 -▁AWAY 445 -▁COUNT 446 -NESS 447 -▁REASON 448 -PH 449 -▁TWENTY 450 -▁QUITE 451 -▁CHANGE 452 -▁LOVE 453 -DUC 454 -▁SAME 455 -▁ENOUGH 456 -▁YES 457 -▁FEEL 458 -▁FIRST 459 -▁WHILE 460 -IZE 461 -▁ANYTHING 462 -▁DID 463 -▁KIND 464 -▁POWER 465 -▁JUST 466 -IF 467 -▁PH 468 -▁ANSWER 469 -▁FIND 470 -▁THANK 471 -▁BUILD 472 -▁GOING 473 -▁CAME 474 -▁TOGETHER 475 -▁IMPORTANT 476 -▁HELP 477 -▁FOUR 478 -▁DIFFERENT 479 -▁AROUND 480 -▁AMERICA 481 -▁ALSO 482 -▁NINE 483 -▁AH 484 -▁LOT 485 -▁BETWEEN 486 -▁START 487 -▁SCHOOL 488 -▁SYSTEM 489 -▁MAYBE 490 -▁ACTUALLY 491 -▁PROBLEM 492 -Q 493 -▁MR 494 -▁YEAH 495 -▁OKAY 496 -以 497 -后 498 -你 499 -是 500 -男 501 -孩 502 -子 503 -曾 504 -丽 505 -婷 506 -妈 507 -很 508 -想 509 -兰 510 -州 511 -哪 512 -有 513 -买 514 -路 515 -虎 516 -汽 517 -车 518 -的 519 -我 520 -家 521 -狗 522 -叫 523 -什 524 -么 525 -名 526 -字 527 -现 528 -在 529 -网 530 -络 531 -怎 532 -样 533 -鞍 534 -山 535 -到 536 -郑 537 -大 538 -巴 539 -上 540 -没 541 -厕 542 -所 543 -英 544 -文 545 -给 546 -一 547 -个 548 -惊 549 -喜 550 -看 551 -日 552 -程 553 -表 554 -形 555 -容 556 -人 557 -强 558 -势 559 -面 560 -前 561 -不 562 -敢 563 -吭 564 -声 565 -词 566 -儿 567 -些 568 -办 569 -法 570 -说 571 -话 572 -唱 573 -首 574 -老 575 -与 576 -海 577 -婆 578 -笨 579 -蛋 580 -为 581 -欢 582 -女 583 -呢 584 -杭 585 -里 586 -可 587 -打 588 -炮 589 -朋 590 -友 591 -啊 592 -对 593 -练 594 -语 595 -听 596 -力 597 -帮 598 -助 599 -歌 600 -请 601 -推 602 -荐 603 -几 604 -谢 605 -明 606 -天 607 -早 608 -七 609 -点 610 -起 611 -床 612 -时 613 -候 614 -睡 615 -觉 616 -会 617 -了 618 -吗 619 -查 620 -本 621 -地 622 -气 623 -公 624 -快 625 -吧 626 -注 627 -意 628 -安 629 -全 630 -要 631 -错 632 -过 633 -四 634 -川 635 -再 636 -讲 637 -笑 638 -好 639 -午 640 -连 641 -烟 642 -台 643 -轮 644 -船 645 -史 646 -记 647 -商 648 -君 649 -列 650 -传 651 -孙 652 -鞅 653 -乃 654 -遂 655 -西 656 -入 657 -秦 658 -翻 659 -译 660 -做 661 -下 662 -提 663 -醒 664 -发 665 -送 666 -排 667 -班 668 -邮 669 -件 670 -刘 671 -俊 672 -峰 673 -电 674 -播 675 -放 676 -曲 677 -最 678 -炫 679 -民 680 -族 681 -风 682 -还 683 -年 684 -中 685 -泰 686 -拉 687 -石 688 -光 689 -剑 690 -任 691 -务 692 -玛 693 -雅 694 -预 695 -言 696 -真 697 -视 698 -频 699 -爱 700 -范 701 -冰 702 -出 703 -演 704 -剧 705 -六 706 -三 707 -十 708 -分 709 -退 710 -当 711 -模 712 -式 713 -附 714 -近 715 -洗 716 -浴 717 -方 718 -交 719 -和 720 -玩 721 -见 722 -工 723 -作 724 -干 725 -就 726 -能 727 -疖 728 -吃 729 -饭 730 -或 731 -者 732 -其 733 -他 734 -东 735 -国 736 -移 737 -动 738 -通 739 -信 740 -限 741 -司 742 -介 743 -绍 744 -讯 745 -录 746 -载 747 -装 748 -跟 749 -位 750 -置 751 -离 752 -莞 753 -长 754 -站 755 -多 756 -远 757 -福 758 -建 759 -高 760 -速 761 -服 762 -少 763 -把 764 -这 765 -定 766 -成 767 -彩 768 -铃 769 -手 770 -机 771 -别 772 -忘 773 -盒 774 -拿 775 -回 776 -校 777 -区 778 -万 779 -口 780 -坐 781 -冷 782 -漠 783 -醉 784 -红 785 -颜 786 -来 787 -猪 788 -张 789 -波 790 -炎 791 -亚 792 -纶 793 -媳 794 -妇 795 -马 796 -志 797 -华 798 -短 799 -清 800 -图 801 -片 802 -生 803 -五 804 -钟 805 -开 806 -启 807 -蓝 808 -牙 809 -锂 810 -池 811 -初 812 -次 813 -充 814 -让 815 -今 816 -号 817 -顷 818 -等 819 -于 820 -平 821 -也 822 -呀 823 -聊 824 -问 825 -主 826 -结 827 -婚 828 -恭 829 -情 830 -流 831 -返 832 -洒 833 -热 834 -泪 835 -诗 836 -那 837 -去 838 -眠 839 -药 840 -功 841 -使 842 -用 843 -象 844 -间 845 -估 846 -计 847 -厚 848 -德 849 -物 850 -思 851 -搞 852 -们 853 -只 854 -知 855 -道 856 -奥 857 -特 858 -曼 859 -越 860 -野 861 -比 862 -较 863 -重 864 -新 865 -陈 866 -奕 867 -迅 868 -泉 869 -湾 870 -票 871 -呵 872 -活 873 -经 874 -历 875 -摇 876 -痴 877 -此 878 -刻 879 -呈 880 -报 881 -脑 882 -总 883 -着 884 -她 885 -阿 886 -斯 887 -顿 888 -丁 889 -影 890 -终 891 -幻 892 -雨 893 -否 894 -带 895 -伞 896 -休 897 -息 898 -值 899 -百 900 -每 901 -月 902 -找 903 -扎 904 -屯 905 -线 906 -仙 907 -奇 908 -侠 909 -业 910 -写 911 -完 912 -姐 913 -稍 914 -烤 915 -鹅 916 -肠 917 -团 918 -狩 919 -猎 920 -美 921 -忍 922 -向 923 -冲 924 -常 925 -熟 926 -度 927 -折 928 -扣 929 -二 930 -乘 931 -教 932 -实 933 -认 934 -证 935 -考 936 -试 937 -答 938 -案 939 -费 940 -脸 941 -自 942 -恋 943 -广 944 -府 945 -待 946 -赶 947 -集 948 -告 949 -诉 950 -太 951 -懂 952 -坏 953 -泡 954 -妞 955 -伊 956 -更 957 -健 958 -康 959 -检 960 -乙 961 -肝 962 -厦 963 -门 964 -急 965 -控 966 -毒 967 -产 968 -头 969 -脚 970 -轻 971 -感 972 -空 973 -订 974 -从 975 -碑 976 -店 977 -北 978 -京 979 -火 980 -鸡 981 -白 982 -态 983 -啥 984 -幺 985 -哈 986 -尔 987 -滨 988 -至 989 -硬 990 -座 991 -换 992 -姿 993 -亲 994 -级 995 -紫 996 -秋 997 -叶 998 -刀 999 -爆 1000 -率 1001 -馨 1002 -予 1003 -帅 1004 -汇 1005 -询 1006 -小 1007 -唉 1008 -性 1009 -挺 1010 -厉 1011 -害 1012 -八 1013 -米 1014 -跑 1015 -步 1016 -达 1017 -标 1018 -规 1019 -准 1020 -码 1021 -音 1022 -拨 1023 -器 1024 -油 1025 -价 1026 -卡 1027 -尿 1028 -防 1029 -杀 1030 -死 1031 -倍 1032 -晋 1033 -映 1034 -部 1035 -韩 1036 -己 1037 -右 1038 -键 1039 -编 1040 -辑 1041 -您 1042 -房 1043 -租 1044 -具 1045 -体 1046 -牌 1047 -瑞 1048 -士 1049 -浪 1050 -琴 1051 -简 1052 -永 1053 -拥 1054 -灿 1055 -烂 1056 -朝 1057 -阳 1058 -解 1059 -赢 1060 -事 1061 -滚 1062 -周 1063 -吕 1064 -忠 1065 -页 1066 -心 1067 -份 1068 -被 1069 -龙 1070 -肖 1071 -半 1072 -喊 1073 -改 1074 -堵 1075 -饿 1076 -瞌 1077 -叔 1078 -戏 1079 -兴 1080 -化 1081 -备 1082 -萍 1083 -乡 1084 -钓 1085 -鱼 1086 -岛 1087 -消 1088 -忻 1089 -襄 1090 -县 1091 -况 1092 -得 1093 -王 1094 -属 1095 -假 1096 -期 1097 -像 1098 -脾 1099 -零 1100 -九 1101 -关 1102 -晚 1103 -陪 1104 -危 1105 -苦 1106 -难 1107 -例 1108 -顺 1109 -序 1110 -盖 1111 -茨 1112 -邦 1113 -故 1114 -警 1115 -戒 1116 -搜 1117 -索 1118 -黄 1119 -照 1120 -底 1121 -识 1122 -武 1123 -汉 1124 -第 1125 -博 1126 -基 1127 -尼 1128 -删 1129 -除 1130 -沃 1131 -狐 1132 -场 1133 -卖 1134 -便 1135 -宜 1136 -营 1137 -厅 1138 -资 1139 -料 1140 -哥 1141 -句 1142 -员 1143 -随 1144 -导 1145 -航 1146 -学 1147 -星 1148 -脱 1149 -毛 1150 -膏 1151 -种 1152 -乐 1153 -贵 1154 -妃 1155 -酒 1156 -内 1157 -条 1158 -联 1159 -系 1160 -希 1161 -望 1162 -非 1163 -苹 1164 -果 1165 -银 1166 -登 1167 -户 1168 -密 1169 -师 1170 -沟 1171 -莫 1172 -才 1173 -喝 1174 -累 1175 -彭 1176 -裘 1177 -莹 1178 -珠 1179 -徐 1180 -管 1181 -爸 1182 -敏 1183 -腰 1184 -闪 1185 -舟 1186 -凯 1187 -肯 1188 -水 1189 -济 1190 -闻 1191 -约 1192 -伦 1193 -乱 1194 -舞 1195 -春 1196 -叮 1197 -咚 1198 -昨 1199 -又 1200 -宵 1201 -省 1202 -途 1203 -无 1204 -院 1205 -吉 1206 -农 1207 -股 1208 -胶 1209 -布 1210 -谁 1211 -免 1212 -疫 1213 -逗 1214 -闹 1215 -闭 1216 -青 1217 -景 1218 -花 1219 -园 1220 -富 1221 -桥 1222 -哭 1223 -节 1224 -树 1225 -茂 1226 -盛 1227 -边 1228 -余 1229 -姚 1230 -走 1231 -原 1232 -行 1233 -都 1234 -旅 1235 -馆 1236 -宾 1237 -根 1238 -修 1239 -理 1240 -厂 1241 -板 1242 -诊 1243 -专 1244 -复 1245 -鹰 1246 -队 1247 -桌 1248 -财 1249 -港 1250 -色 1251 -诚 1252 -勿 1253 -扰 1254 -持 1255 -孟 1256 -古 1257 -医 1258 -研 1259 -究 1260 -取 1261 -卫 1262 -目 1263 -铁 1264 -麻 1265 -将 1266 -浏 1267 -览 1268 -湿 1269 -朱 1270 -沱 1271 -合 1272 -江 1273 -何 1274 -祈 1275 -祷 1276 -义 1277 -酷 1278 -派 1279 -如 1280 -咩 1281 -正 1282 -算 1283 -楼 1284 -距 1285 -震 1286 -借 1287 -政 1288 -策 1289 -温 1290 -宝 1291 -沙 1292 -角 1293 -肚 1294 -疼 1295 -林 1296 -宿 1297 -舍 1298 -阴 1299 -晴 1300 -圆 1301 -缺 1302 -微 1303 -辽 1304 -刚 1305 -牛 1306 -偷 1307 -吓 1308 -跳 1309 -城 1310 -吴 1311 -称 1312 -呼 1313 -爷 1314 -埋 1315 -怨 1316 -缠 1317 -柴 1318 -钱 1319 -极 1320 -先 1321 -辞 1322 -职 1323 -哦 1324 -啦 1325 -售 1326 -保 1327 -黑 1328 -客 1329 -立 1330 -足 1331 -之 1332 -鬼 1333 -留 1334 -辟 1335 -邪 1336 -谱 1337 -减 1338 -肥 1339 -皇 1340 -萨 1341 -舅 1342 -幽 1343 -默 1344 -细 1345 -胞 1346 -溪 1347 -懒 1348 -书 1349 -杨 1350 -慧 1351 -芝 1352 -屁 1353 -画 1354 -晨 1355 -宁 1356 -侦 1357 -探 1358 -柯 1359 -南 1360 -涛 1361 -应 1362 -该 1363 -弑 1364 -神 1365 -魔 1366 -创 1367 -世 1368 -游 1369 -犯 1370 -已 1371 -泽 1372 -村 1373 -变 1374 -奏 1375 -杰 1376 -偶 1377 -命 1378 -乔 1379 -恩 1380 -并 1381 -阅 1382 -读 1383 -左 1384 -般 1385 -低 1386 -调 1387 -阔 1388 -烦 1389 -球 1390 -峡 1391 -界 1392 -霆 1393 -锋 1394 -柏 1395 -需 1396 -艺 1397 -术 1398 -弄 1399 -章 1400 -悲 1401 -咒 1402 -版 1403 -统 1404 -羊 1405 -肉 1406 -斤 1407 -嘿 1408 -郁 1409 -闷 1410 -进 1411 -姨 1412 -庆 1413 -威 1414 -两 1415 -岸 1416 -普 1417 -琪 1418 -玫 1419 -瑰 1420 -香 1421 -碟 1422 -眼 1423 -收 1424 -湖 1425 -禽 1426 -涮 1427 -汪 1428 -盘 1429 -夹 1430 -存 1431 -枕 1432 -指 1433 -针 1434 -仓 1435 -库 1436 -餐 1437 -块 1438 -咪 1439 -毕 1440 -薪 1441 -赛 1442 -纪 1443 -末 1444 -典 1445 -娱 1446 -傻 1447 -嘲 1448 -彪 1449 -升 1450 -润 1451 -核 1452 -遍 1453 -接 1454 -元 1455 -鼓 1456 -屿 1457 -爹 1458 -胡 1459 -雪 1460 -招 1461 -喂 1462 -齿 1463 -侣 1464 -土 1465 -豆 1466 -铿 1467 -锵 1468 -聪 1469 -但 1470 -飞 1471 -鹤 1472 -壁 1473 -摞 1474 -久 1475 -冬 1476 -骏 1477 -然 1478 -讨 1479 -论 1480 -腾 1481 -易 1482 -筋 1483 -转 1484 -弯 1485 -耳 1486 -齐 1487 -阶 1488 -革 1489 -代 1490 -许 1491 -圣 1492 -诞 1493 -吸 1494 -血 1495 -燕 1496 -松 1497 -鼠 1498 -确 1499 -凤 1500 -凰 1501 -由 1502 -翔 1503 -段 1504 -超 1505 -精 1506 -支 1507 -扶 1508 -室 1509 -包 1510 -菜 1511 -田 1512 -骂 1513 -洋 1514 -舒 1515 -衡 1516 -款 1517 -嘴 1518 -菲 1519 -嘛 1520 -嗨 1521 -鸟 1522 -玉 1523 -数 1524 -贝 1525 -郝 1526 -漫 1527 -诺 1528 -衣 1529 -嫖 1530 -娼 1531 -穿 1532 -骨 1533 -拆 1534 -伤 1535 -获 1536 -奖 1537 -稿 1538 -锅 1539 -购 1540 -治 1541 -痘 1542 -遗 1543 -疤 1544 -痕 1545 -饱 1546 -寻 1547 -瓷 1548 -妹 1549 -徽 1550 -参 1551 -格 1552 -题 1553 -凭 1554 -住 1555 -剖 1556 -腹 1557 -祝 1558 -贺 1559 -加 1560 -跪 1561 -潞 1562 -云 1563 -端 1564 -木 1565 -烁 1566 -朵 1567 -赵 1568 -潘 1569 -津 1570 -滋 1571 -燥 1572 -利 1573 -负 1574 -昆 1575 -因 1576 -森 1577 -及 1578 -病 1579 -固 1580 -市 1581 -烧 1582 -番 1583 -茄 1584 -炒 1585 -而 1586 -凉 1587 -冒 1588 -量 1589 -夸 1590 -尾 1591 -崔 1592 -另 1593 -处 1594 -铺 1595 -沈 1596 -哎 1597 -身 1598 -哟 1599 -习 1600 -虞 1601 -瞧 1602 -烈 1603 -皮 1604 -鞋 1605 -深 1606 -圳 1607 -委 1608 -胖 1609 -猴 1610 -军 1611 -素 1612 -楷 1613 -补 1614 -偿 1615 -屏 1616 -散 1617 -效 1618 -丹 1619 -念 1620 -绝 1621 -艳 1622 -够 1623 -狼 1624 -且 1625 -龄 1626 -乌 1627 -蓉 1628 -厘 1629 -含 1630 -庚 1631 -澈 1632 -犬 1633 -致 1634 -运 1635 -慢 1636 -钻 1637 -李 1638 -轩 1639 -育 1640 -项 1641 -咱 1642 -误 1643 -弟 1644 -依 1645 -尽 1646 -河 1647 -夫 1648 -沁 1649 -始 1650 -芳 1651 -禺 1652 -旧 1653 -坑 1654 -胜 1655 -酱 1656 -漂 1657 -亮 1658 -战 1659 -斗 1660 -严 1661 -娟 1662 -逼 1663 -添 1664 -盈 1665 -萝 1666 -莉 1667 -肌 1668 -唐 1669 -兵 1670 -辆 1671 -双 1672 -佛 1673 -傅 1674 -劲 1675 -直 1676 -测 1677 -苏 1678 -迁 1679 -沭 1680 -祥 1681 -婴 1682 -品 1683 -销 1684 -禹 1685 -哲 1686 -嗯 1687 -趟 1688 -拐 1689 -金 1690 -满 1691 -套 1692 -倒 1693 -千 1694 -迎 1695 -淇 1696 -驾 1697 -拟 1698 -良 1699 -揭 1700 -杯 1701 -淄 1702 -睛 1703 -制 1704 -枪 1705 -抢 1706 -狸 1707 -泥 1708 -造 1709 -哇 1710 -羯 1711 -庙 1712 -逃 1713 -朗 1714 -领 1715 -悟 1716 -湛 1717 -贸 1718 -垃 1719 -圾 1720 -软 1721 -莲 1722 -味 1723 -旺 1724 -旦 1725 -潮 1726 -奶 1727 -央 1728 -惜 1729 -续 1730 -咨 1731 -茅 1732 -父 1733 -母 1734 -笔 1735 -封 1736 -同 1737 -黎 1738 -共 1739 -科 1740 -相 1741 -镇 1742 -贤 1743 -宏 1744 -洲 1745 -瓦 1746 -寨 1747 -受 1748 -梦 1749 -呗 1750 -苍 1751 -丘 1752 -避 1753 -孕 1754 -灰 1755 -尤 1756 -击 1757 -腮 1758 -腺 1759 -兆 1760 -坨 1761 -屎 1762 -忧 1763 -草 1764 -赠 1765 -外 1766 -险 1767 -某 1768 -磁 1769 -贷 1770 -反 1771 -罚 1772 -昌 1773 -饰 1774 -辉 1775 -权 1776 -材 1777 -炯 1778 -签 1779 -追 1780 -求 1781 -催 1782 -痛 1783 -盱 1784 -眙 1785 -围 1786 -淘 1787 -幸 1788 -扫 1789 -旭 1790 -切 1791 -磋 1792 -冠 1793 -征 1794 -郎 1795 -骗 1796 -哄 1797 -释 1798 -蒙 1799 -涯 1800 -碧 1801 -斜 1802 -嫁 1803 -幕 1804 -哼 1805 -钢 1806 -碳 1807 -纤 1808 -维 1809 -决 1810 -妻 1811 -未 1812 -绳 1813 -断 1814 -寺 1815 -妙 1816 -伯 1817 -蹲 1818 -宅 1819 -吵 1820 -娃 1821 -兄 1822 -廷 1823 -夺 1824 -社 1825 -示 1826 -按 1827 -饺 1828 -甜 1829 -蜜 1830 -咖 1831 -啡 1832 -优 1833 -逛 1834 -街 1835 -著 1836 -杂 1837 -址 1838 -荷 1839 -塘 1840 -拜 1841 -币 1842 -迟 1843 -货 1844 -粉 1845 -刮 1846 -破 1847 -射 1848 -狂 1849 -苗 1850 -罗 1851 -设 1852 -困 1853 -湘 1854 -潭 1855 -评 1856 -娘 1857 -涉 1858 -采 1859 -芙 1860 -夜 1861 -捷 1862 -斩 1863 -摩 1864 -托 1865 -泳 1866 -琳 1867 -律 1868 -官 1869 -劫 1870 -蛮 1871 -替 1872 -架 1873 -悬 1874 -浮 1875 -窗 1876 -顶 1877 -敦 1878 -善 1879 -哉 1880 -桂 1881 -勇 1882 -荆 1883 -镜 1884 -监 1885 -怕 1886 -呐 1887 -劳 1888 -莱 1889 -狮 1890 -宽 1891 -袋 1892 -囊 1893 -秀 1894 -卸 1895 -链 1896 -嵩 1897 -韭 1898 -葆 1899 -额 1900 -翼 1901 -忙 1902 -瓶 1903 -梅 1904 -堰 1905 -粒 1906 -汤 1907 -谋 1908 -樊 1909 -恨 1910 -愿 1911 -锡 1912 -申 1913 -护 1914 -庄 1915 -临 1916 -源 1917 -环 1918 -境 1919 -礼 1920 -恐 1921 -晒 1922 -虫 1923 -划 1924 -鸣 1925 -怖 1926 -伍 1927 -佰 1928 -岁 1929 -组 1930 -响 1931 -类 1932 -韶 1933 -克 1934 -洛 1935 -玲 1936 -裤 1937 -柔 1938 -疆 1939 -篮 1940 -伟 1941 -扔 1942 -掉 1943 -媒 1944 -涨 1945 -透 1946 -纯 1947 -怀 1948 -坊 1949 -麦 1950 -菠 1951 -养 1952 -晕 1953 -群 1954 -展 1955 -厌 1956 -拒 1957 -单 1958 -静 1959 -刷 1960 -插 1961 -肛 1962 -互 1963 -蘑 1964 -菇 1965 -姑 1966 -桐 1967 -辛 1968 -察 1969 -毫 1970 -质 1971 -差 1972 -翰 1973 -爽 1974 -欣 1975 -议 1976 -铜 1977 -籍 1978 -争 1979 -喆 1980 -孔 1981 -堤 1982 -薇 1983 -茵 1984 -席 1985 -琼 1986 -杠 1987 -衔 1988 -概 1989 -往 1990 -邢 1991 -惠 1992 -烫 1993 -绑 1994 -崇 1995 -帝 1996 -据 1997 -貌 1998 -似 1999 -胸 2000 -罩 2001 -构 2002 -尊 2003 -秘 2004 -它 2005 -详 2006 -悠 2007 -闲 2008 -违 2009 -陆 2010 -割 2011 -绩 2012 -企 2013 -绥 2014 -辐 2015 -舌 2016 -寂 2017 -寞 2018 -宇 2019 -携 2020 -拳 2021 -观 2022 -魏 2023 -郭 2024 -磊 2025 -副 2026 -梁 2027 -斌 2028 -须 2029 -僧 2030 -徒 2031 -季 2032 -灯 2033 -梯 2034 -墙 2035 -付 2036 -坦 2037 -殊 2038 -曹 2039 -操 2040 -捡 2041 -赤 2042 -盗 2043 -废 2044 -蒋 2045 -浙 2046 -食 2047 -咯 2048 -童 2049 -坡 2050 -剪 2051 -唯 2052 -疗 2053 -状 2054 -暴 2055 -缓 2056 -誉 2057 -衰 2058 -宋 2059 -娜 2060 -雄 2061 -谛 2062 -糖 2063 -羽 2064 -棋 2065 -滩 2066 -佳 2067 -臭 2068 -帆 2069 -岳 2070 -疲 2071 -惫 2072 -滴 2073 -倾 2074 -盆 2075 -谷 2076 -施 2077 -晶 2078 -赚 2079 -澡 2080 -遇 2081 -鲁 2082 -祭 2083 -灶 2084 -独 2085 -谈 2086 -承 2087 -蜀 2088 -丰 2089 -归 2090 -辜 2091 -扇 2092 -渴 2093 -羡 2094 -慕 2095 -裸 2096 -宗 2097 -纬 2098 -亦 2099 -儒 2100 -霸 2101 -翡 2102 -翠 2103 -芭 2104 -抱 2105 -歉 2106 -邱 2107 -夏 2108 -隆 2109 -灵 2110 -珍 2111 -浩 2112 -乾 2113 -坤 2114 -培 2115 -训 2116 -压 2117 -偏 2118 -骤 2119 -熙 2120 -葬 2121 -姆 2122 -兽 2123 -筑 2124 -丝 2125 -若 2126 -诡 2127 -异 2128 -侯 2129 -摆 2130 -俗 2131 -缚 2132 -束 2133 -愁 2134 -盟 2135 -却 2136 -显 2137 -肤 2138 -茹 2139 -荣 2140 -增 2141 -宫 2142 -局 2143 -适 2144 -楚 2145 -驻 2146 -纽 2147 -秒 2148 -辣 2149 -虾 2150 -甘 2151 -肃 2152 -粕 2153 -喻 2154 -敬 2155 -谨 2156 -慎 2157 -竭 2158 -止 2159 -际 2160 -寓 2161 -勤 2162 -挫 2163 -泣 2164 -奈 2165 -圭 2166 -焰 2167 -猩 2168 -守 2169 -允 2170 -兔 2171 -篇 2172 -敌 2173 -辕 2174 -猫 2175 -柠 2176 -檬 2177 -橘 2178 -卜 2179 -妓 2180 -既 2181 -闯 2182 -胆 2183 -刁 2184 -竟 2185 -竞 2186 -冯 2187 -陇 2188 -赣 2189 -呆 2190 -滞 2191 -停 2192 -邯 2193 -郸 2194 -域 2195 -徕 2196 -患 2197 -甲 2198 -亡 2199 -鼻 2200 -背 2201 -戴 2202 -幼 2203 -伙 2204 -括 2205 -邓 2206 -谐 2207 -担 2208 -浑 2209 -抖 2210 -耍 2211 -综 2212 -失 2213 -蕾 2214 -鸭 2215 -莘 2216 -选 2217 -糸 2218 -桶 2219 -弃 2220 -暗 2221 -卓 2222 -榜 2223 -拼 2224 -壅 2225 -丈 2226 -锈 2227 -恢 2228 -刺 2229 -嘻 2230 -顾 2231 -投 2232 -晓 2233 -巨 2234 -抽 2235 -档 2236 -乳 2237 -迪 2238 -蠢 2239 -裹 2240 -唤 2241 -焦 2242 -择 2243 -俺 2244 -技 2245 -暧 2246 -昧 2247 -怪 2248 -坛 2249 -眉 2250 -嘉 2251 -逸 2252 -课 2253 -栏 2254 -撬 2255 -框 2256 -液 2257 -凝 2258 -暑 2259 -型 2260 -烘 2261 -簿 2262 -扬 2263 -汁 2264 -诸 2265 -迹 2266 -禁 2267 -株 2268 -泸 2269 -屠 2270 -宰 2271 -忽 2272 -炼 2273 -必 2274 -妆 2275 -飘 2276 -鹿 2277 -敲 2278 -拾 2279 -躺 2280 -歇 2281 -狠 2282 -沾 2283 -畅 2284 -镕 2285 -贪 2286 -污 2287 -斧 2288 -巡 2289 -弹 2290 -盐 2291 -枝 2292 -渝 2293 -壑 2294 -郴 2295 -落 2296 -牡 2297 -钛 2298 -剩 2299 -俄 2300 -抵 2301 -押 2302 -郊 2303 -弱 2304 -授 2305 -蟹 2306 -糕 2307 -败 2308 -各 2309 -伴 2310 -享 2311 -居 2312 -障 2313 -棕 2314 -旁 2315 -屌 2316 -绒 2317 -酸 2318 -隔 2319 -瞄 2320 -俩 2321 -柳 2322 -册 2323 -弊 2324 -逮 2325 -绵 2326 -挣 2327 -闵 2328 -勒 2329 -陶 2330 -寒 2331 -吻 2332 -桃 2333 -悍 2334 -绮 2335 -贞 2336 -疾 2337 -诫 2338 -菌 2339 -则 2340 -谭 2341 -咏 2342 -麟 2343 -棠 2344 -抬 2345 -棺 2346 -均 2347 -纸 2348 -碱 2349 -沧 2350 -董 2351 -挤 2352 -虚 2353 -钠 2354 -胃 2355 -躁 2356 -智 2357 -畔 2358 -墨 2359 -堂 2360 -喔 2361 -宣 2362 -丑 2363 -嚣 2364 -辈 2365 -孤 2366 -鞭 2367 -验 2368 -夕 2369 -印 2370 -欧 2371 -阵 2372 -咋 2373 -驹 2374 -挂 2375 -轿 2376 -拍 2377 -洁 2378 -凑 2379 -蕉 2380 -诱 2381 -惑 2382 -颐 2383 -箭 2384 -樱 2385 -辖 2386 -捕 2387 -炸 2388 -斋 2389 -恒 2390 -沉 2391 -侧 2392 -跌 2393 -暇 2394 -掌 2395 -筷 2396 -彬 2397 -稚 2398 -傲 2399 -腔 2400 -藏 2401 -浦 2402 -瓣 2403 -捆 2404 -卧 2405 -欠 2406 -犀 2407 -甩 2408 -敷 2409 -衍 2410 -谅 2411 -积 2412 -怡 2413 -阁 2414 -趣 2415 -掰 2416 -耽 2417 -蒜 2418 -菱 2419 -葛 2420 -聚 2421 -露 2422 -帐 2423 -紧 2424 -郓 2425 -聘 2426 -桑 2427 -众 2428 -圈 2429 -渡 2430 -鲜 2431 -杜 2432 -甫 2433 -遵 2434 -骚 2435 -吹 2436 -蚊 2437 -塔 2438 -赏 2439 -荒 2440 -欺 2441 -揍 2442 -锁 2443 -恼 2444 -忐 2445 -忑 2446 -输 2447 -描 2448 -触 2449 -糊 2450 -涂 2451 -熊 2452 -妮 2453 -抄 2454 -裙 2455 -塑 2456 -橡 2457 -阜 2458 -獒 2459 -励 2460 -黔 2461 -臣 2462 -憔 2463 -悴 2464 -昂 2465 -党 2466 -沐 2467 -浓 2468 -灾 2469 -捐 2470 -柿 2471 -瑟 2472 -翁 2473 -侨 2474 -督 2475 -振 2476 -鹏 2477 -乒 2478 -乓 2479 -巷 2480 -贡 2481 -祖 2482 -即 2483 -绿 2484 -搭 2485 -配 2486 -骑 2487 -届 2488 -举 2489 -伸 2490 -整 2491 -突 2492 -陌 2493 -糟 2494 -惩 2495 -硕 2496 -茫 2497 -趋 2498 -仁 2499 -钙 2500 -雕 2501 -井 2502 -撒 2503 -岩 2504 -悄 2505 -搁 2506 -浅 2507 -救 2508 -饮 2509 -佩 2510 -赌 2511 -涕 2512 -薯 2513 -令 2514 -泌 2515 -蔬 2516 -批 2517 -攀 2518 -怜 2519 -淮 2520 -寝 2521 -填 2522 -卿 2523 -萱 2524 -寄 2525 -窝 2526 -纳 2527 -洱 2528 -惹 2529 -锦 2530 -浒 2531 -欲 2532 -棉 2533 -箱 2534 -仅 2535 -述 2536 -摸 2537 -纲 2538 -澳 2539 -染 2540 -兼 2541 -岭 2542 -淋 2543 -肿 2544 -旗 2545 -嘞 2546 -乖 2547 -酮 2548 -颠 2549 -覆 2550 -誓 2551 -递 2552 -蛟 2553 -占 2554 -乎 2555 -融 2556 -甸 2557 -幂 2558 -钥 2559 -匙 2560 -酬 2561 -皆 2562 -胎 2563 -腐 2564 -痿 2565 -绣 2566 -枫 2567 -蝴 2568 -蝶 2569 -抛 2570 -撞 2571 -植 2572 -僵 2573 -尸 2574 -巾 2575 -煌 2576 -逊 2577 -引 2578 -兑 2579 -荫 2580 -朔 2581 -丢 2582 -扩 2583 -摄 2584 -龟 2585 -鑫 2586 -谦 2587 -豪 2588 -噬 2589 -眷 2590 -挑 2591 -仲 2592 -穷 2593 -玻 2594 -璃 2595 -岗 2596 -姥 2597 -横 2598 -蚌 2599 -埠 2600 -邀 2601 -蔚 2602 -虹 2603 -降 2604 -疣 2605 -鱿 2606 -喵 2607 -囧 2608 -茶 2609 -猜 2610 -玮 2611 -莎 2612 -冼 2613 -榕 2614 -媛 2615 -瓜 2616 -煮 2617 -耕 2618 -镶 2619 -虽 2620 -驳 2621 -霍 2622 -仗 2623 -窍 2624 -魅 2625 -访 2626 -邻 2627 -抗 2628 -莆 2629 -涵 2630 -筒 2631 -疯 2632 -赖 2633 -豌 2634 -碍 2635 -症 2636 -卤 2637 -翅 2638 -膀 2639 -蓬 2640 -咸 2641 -尚 2642 -瘦 2643 -缸 2644 -爬 2645 -鄂 2646 -塞 2647 -稻 2648 -召 2649 -荡 2650 -桨 2651 -税 2652 -呃 2653 -渠 2654 -骥 2655 -伏 2656 -枥 2657 -邑 2658 -净 2659 -弦 2660 -蔽 2661 -诀 2662 -咳 2663 -嗽 2664 -芯 2665 -储 2666 -缘 2667 -冻 2668 -厨 2669 -鉴 2670 -擦 2671 -棒 2672 -损 2673 -暂 2674 -殖 2675 -焊 2676 -募 2677 -邵 2678 -饶 2679 -梭 2680 -鄙 2681 -骄 2682 -蔡 2683 -辄 2684 -努 2685 -洽 2686 -宙 2687 -鲈 2688 -葫 2689 -芦 2690 -梧 2691 -燎 2692 -缴 2693 -薄 2694 -执 2695 -垫 2696 -靠 2697 -拢 2698 -萧 2699 -醋 2700 -脊 2701 -慰 2702 -攻 2703 -狱 2704 -吝 2705 -啬 2706 -煤 2707 -楞 2708 -脏 2709 -迷 2710 -椒 2711 -侄 2712 -璇 2713 -耐 2714 -庵 2715 -帽 2716 -崎 2717 -峻 2718 -援 2719 -娶 2720 -丫 2721 -犹 2722 -豫 2723 -罪 2724 -恶 2725 -陛 2726 -樟 2727 -截 2728 -巧 2729 -驰 2730 -轨 2731 -继 2732 -葱 2733 -蘸 2734 -汕 2735 -蜘 2736 -蛛 2737 -聋 2738 -俱 2739 -捉 2740 -卢 2741 -骆 2742 -氓 2743 -耶 2744 -仔 2745 -激 2746 -渊 2747 -钾 2748 -暖 2749 -钰 2750 -裁 2751 -判 2752 -略 2753 -墓 2754 -洪 2755 -凌 2756 -符 2757 -壮 2758 -陵 2759 -挥 2760 -夷 2761 -尘 2762 -沪 2763 -榆 2764 -涧 2765 -析 2766 -孝 2767 -弘 2768 -椅 2769 -贴 2770 -蛇 2771 -浣 2772 -镯 2773 -枣 2774 -佐 2775 -柑 2776 -谓 2777 -洞 2778 -漳 2779 -撕 2780 -叉 2781 -诛 2782 -糯 2783 -粽 2784 -碎 2785 -幅 2786 -赘 2787 -浆 2788 -循 2789 -偕 2790 -诙 2791 -阚 2792 -摘 2793 -串 2794 -悉 2795 -蜕 2796 -残 2797 -诅 2798 -祁 2799 -仪 2800 -璐 2801 -瑶 2802 -楠 2803 -崂 2804 -供 2805 -掖 2806 -椎 2807 -铆 2808 -钉 2809 -铐 2810 -镣 2811 -栋 2812 -潇 2813 -抓 2814 -屋 2815 -鸦 2816 -玄 2817 -芜 2818 -钨 2819 -毯 2820 -矿 2821 -缩 2822 -酶 2823 -焕 2824 -埃 2825 -霞 2826 -噢 2827 -韵 2828 -艾 2829 -虐 2830 -俘 2831 -颗 2832 -巩 2833 -牵 2834 -汝 2835 -搅 2836 -廉 2837 -啤 2838 -苑 2839 -辍 2840 -缝 2841 -纫 2842 -膜 2843 -娄 2844 -倩 2845 -魂 2846 -姜 2847 -彻 2848 -扉 2849 -镁 2850 -氢 2851 -铝 2852 -淀 2853 -雹 2854 -妍 2855 -鼎 2856 -碗 2857 -亭 2858 -闽 2859 -献 2860 -耻 2861 -畜 2862 -蚯 2863 -蚓 2864 -杆 2865 -靓 2866 -颖 2867 -瘾 2868 -腿 2869 -咧 2870 -嗦 2871 -忆 2872 -卑 2873 -鸽 2874 -藤 2875 -滑 2876 -蝇 2877 -蚂 2878 -蚁 2879 -迢 2880 -蝎 2881 -斑 2882 -赞 2883 -氧 2884 -姝 2885 -擎 2886 -憋 2887 -屈 2888 -讶 2889 -袜 2890 -吐 2891 -秽 2892 -哑 2893 -蓦 2894 -阑 2895 -珊 2896 -披 2897 -巫 2898 -妖 2899 -坪 2900 -疏 2901 -抒 2902 -炉 2903 -舰 2904 -贱 2905 -搬 2906 -遥 2907 -燃 2908 -咽 2909 -喉 2910 -熔 2911 -婵 2912 -奔 2913 -汗 2914 -蓄 2915 -辰 2916 -肩 2917 -洮 2918 -琅 2919 -径 2920 -廊 2921 -姬 2922 -衬 2923 -雯 2924 -滁 2925 -泗 2926 -筹 2927 -诵 2928 -奴 2929 -跨 2930 -娴 2931 -绯 2932 -惯 2933 -谎 2934 -蹈 2935 -潜 2936 -搂 2937 -逆 2938 -钞 2939 -辅 2940 -凶 2941 -橱 2942 -柜 2943 -婕 2944 -矮 2945 -邹 2946 -嫂 2947 -饼 2948 -撸 2949 -壶 2950 -握 2951 -鸳 2952 -鸯 2953 -寸 2954 -堕 2955 -哀 2956 -械 2957 -蜈 2958 -蚣 2959 -袁 2960 -鸿 2961 -穆 2962 -泊 2963 -衮 2964 -弗 2965 -雷 2966 -谜 2967 -俞 2968 -灏 2969 -毅 2970 -迈 2971 -蜂 2972 -辨 2973 -沂 2974 -灭 2975 -腊 2976 -脍 2977 -炙 2978 -卦 2979 -霄 2980 -扯 2981 -泾 2982 -脂 2983 -肪 2984 -淹 2985 -灌 2986 -辱 2987 -丸 2988 -账 2989 -秤 2990 -褐 2991 -芬 2992 -窖 2993 -慈 2994 -益 2995 -亿 2996 -颈 2997 -糜 2998 -隋 2999 -霉 3000 -署 3001 -狄 3002 -酪 3003 -旋 3004 -蔷 3005 -皱 3006 -纹 3007 -枯 3008 -粤 3009 -拔 3010 -菩 3011 -驱 3012 -咦 3013 -掀 3014 -菊 3015 -涩 3016 -耀 3017 -娥 3018 -奘 3019 -眯 3020 -芊 3021 -绪 3022 -沛 3023 -锐 3024 -姓 3025 -氏 3026 -垂 3027 -迫 3028 -絮 3029 -藕 3030 -捎 3031 -蓓 3032 -沫 3033 -奎 3034 -贩 3035 -泵 3036 -疑 3037 -岐 3038 -拓 3039 -詹 3040 -韦 3041 -粥 3042 -瞎 3043 -层 3044 -寿 3045 -淑 3046 -琦 3047 -履 3048 -痣 3049 -蔓 3050 -延 3051 -措 3052 -氰 3053 -胺 3054 -炭 3055 -鸥 3056 -谊 3057 -宛 3058 -悦 3059 -谣 3060 -茎 3061 -堆 3062 -鲤 3063 -坚 3064 -澎 3065 -溜 3066 -贫 3067 -擅 3068 -锌 3069 -竹 3070 -苟 3071 -磷 3072 -庐 3073 -嵌 3074 -潍 3075 -悚 3076 -岑 3077 -稀 3078 -奋 3079 -呦 3080 -梳 3081 -伐 3082 -芒 3083 -吶 3084 -凡 3085 -臂 3086 -驴 3087 -殿 3088 -雁 3089 -粹 3090 -凋 3091 -葵 3092 -烛 3093 -肾 3094 -尝 3095 -磨 3096 -晏 3097 -甄 3098 -嬛 3099 -盼 3100 -肇 3101 -咬 3102 -洼 3103 -匪 3104 -啰 3105 -硅 3106 -铅 3107 -矛 3108 -盾 3109 -贼 3110 -霜 3111 -螺 3112 -漏 3113 -帕 3114 -杉 3115 -矶 3116 -耗 3117 -责 3118 -靖 3119 -呸 3120 -驶 3121 -吞 3122 -睁 3123 -笼 3124 -茉 3125 -赈 3126 -纱 3127 -艘 3128 -炖 3129 -仿 3130 -瞬 3131 -嵊 3132 -澄 3133 -丞 3134 -摔 3135 -宠 3136 -爪 3137 -笋 3138 -庭 3139 -蜡 3140 -戈 3141 -锻 3142 -粗 3143 -糙 3144 -混 3145 -荚 3146 -曙 3147 -凄 3148 -抚 3149 -瀑 3150 -挖 3151 -掘 3152 -垮 3153 -奢 3154 -侈 3155 -揉 3156 -穹 3157 -钦 3158 -蛙 3159 -荧 3160 -悔 3161 -彦 3162 -忏 3163 -祸 3164 -攒 3165 -慌 3166 -簸 3167 -箕 3168 -繁 3169 -尖 3170 -芋 3171 -铠 3172 -沿 3173 -扮 3174 -隐 3175 -促 3176 -庾 3177 -葡 3178 -萄 3179 -硝 3180 -溶 3181 -淡 3182 -炅 3183 -昕 3184 -尧 3185 -妊 3186 -娠 3187 -曰 3188 -祛 3189 -枚 3190 -卒 3191 -陕 3192 -昭 3193 -龚 3194 -债 3195 -嗓 3196 -陷 3197 -阱 3198 -庞 3199 -盲 3200 -侵 3201 -匣 3202 -愤 3203 -怒 3204 -瞅 3205 -遭 3206 -脉 3207 -馒 3208 -愉 3209 -栗 3210 -鲍 3211 -挎 3212 -匆 3213 -缕 3214 -昵 3215 -鳄 3216 -阙 3217 -坟 3218 -捏 3219 -喽 3220 -雀 3221 -贯 3222 -苇 3223 -鹄 3224 -愈 3225 -裂 3226 -伪 3227 -劣 3228 -歹 3229 -溅 3230 -雌 3231 -猛 3232 -逞 3233 -饥 3234 -愚 3235 -牧 3236 -碰 3237 -帜 3238 -佝 3239 -偻 3240 -讪 3241 -馍 3242 -役 3243 -栈 3244 -唾 3245 -缆 3246 -袄 3247 -闸 3248 -织 3249 -筐 3250 -婉 3251 -昏 3252 -拖 3253 -毙 3254 -咙 3255 -褪 3256 -驼 3257 -壳 3258 -孽 3259 -审 3260 -脖 3261 -恳 3262 -孢 3263 -矫 3264 -臻 3265 -兖 3266 -俏 3267 -棍 3268 -唻 3269 -肘 3270 -俭 3271 -冕 3272 -葩 3273 -佑 3274 -鬓 3275 -柚 3276 -赴 3277 -崖 3278 -塌 3279 -厢 3280 -窈 3281 -窕 3282 -逑 3283 -卷 3284 -拂 3285 -蟑 3286 -螂 3287 -契 3288 -羞 3289 -函 3290 -逐 3291 -拌 3292 -肺 3293 -阻 3294 -纵 3295 -痰 3296 -狙 3297 -惋 3298 -枰 3299 -崽 3300 -胚 3301 -骡 3302 -萎 3303 -泄 3304 -呜 3305 -籁 3306 -濮 3307 -阆 3308 -琵 3309 -琶 3310 -跃 3311 -筝 3312 -勃 3313 -楂 3314 -奉 3315 -础 3316 -吆 3317 -壹 3318 -飙 3319 -虑 3320 -脆 3321 -黛 3322 -栓 3323 -逻 3324 -螃 3325 -轰 3326 -仑 3327 -券 3328 -逢 3329 -疮 3330 -私 3331 -窃 3332 -儋 3333 -泼 3334 -熬 3335 -焚 3336 -梨 3337 -吟 3338 -棱 3339 -稳 3340 -翘 3341 -祠 3342 -遮 3343 -瘤 3344 -稣 3345 -唇 3346 -阖 3347 -堡 3348 -禾 3349 -钗 3350 -爵 3351 -赐 3352 -绕 3353 -粘 3354 -癌 3355 -矜 3356 -虱 3357 -婧 3358 -坝 3359 -菏 3360 -隶 3361 -尺 3362 -滕 3363 -竿 3364 -恰 3365 -喱 3366 -冤 3367 -枉 3368 -叠 3369 -穴 3370 -搏 3371 -窦 3372 -栀 3373 -踪 3374 -昼 3375 -氯 3376 -陋 3377 -铭 3378 -禅 3379 -屑 3380 -巢 3381 -咻 3382 -喇 3383 -叭 3384 -棵 3385 -吊 3386 -诈 3387 -娇 3388 -绘 3389 -圩 3390 -仰 3391 -疙 3392 -瘩 3393 -桦 3394 -妾 3395 -丧 3396 -昊 3397 -湄 3398 -靴 3399 -迭 3400 -劝 3401 -溧 3402 -靡 3403 -梗 3404 -倪 3405 -刍 3406 -芽 3407 -篱 3408 -笆 3409 -漯 3410 -镖 3411 -协 3412 -叙 3413 -汾 3414 -豚 3415 -锷 3416 -瑙 3417 -瑜 3418 -伽 3419 -彰 3420 -扒 3421 -麝 3422 -赔 3423 -焉 3424 -亏 3425 -煅 3426 -翱 3427 -哽 3428 -煦 3429 -喷 3430 -舱 3431 -惨 3432 -哗 3433 -躲 3434 -佘 3435 -憾 3436 -旷 3437 -芹 3438 -簧 3439 -疹 3440 -簇 3441 -羹 3442 -刊 3443 -鹦 3444 -鹉 3445 -狡 3446 -猾 3447 -锯 3448 -呛 3449 -泛 3450 -汶 3451 -毗 3452 -衫 3453 -猕 3454 -祺 3455 -悸 3456 -昙 3457 -莽 3458 -杏 3459 -钮 3460 -叛 3461 -锄 3462 -砸 3463 -囤 3464 -犁 3465 -溃 3466 -疡 3467 -迦 3468 -轲 3469 -噜 3470 -犒 3471 -薰 3472 -薛 3473 -哺 3474 -竖 3475 -氟 3476 -渐 3477 -柒 3478 -贰 3479 -捌 3480 -傍 3481 -抹 3482 -褶 3483 -仇 3484 -偎 3485 -馅 3486 -旱 3487 -渭 3488 -昔 3489 -癣 3490 -挚 3491 -姻 3492 -炽 3493 -嘎 3494 -矢 3495 -汀 3496 -馋 3497 -淌 3498 -莓 3499 -貂 3500 -啧 3501 -茱 3502 -萸 3503 -涌 3504 -臀 3505 -恃 3506 -蒲 3507 -朴 3508 -嘟 3509 -扁 3510 -剂 3511 -歧 3512 -啪 3513 -啵 3514 -匠 3515 -帖 3516 -痒 3517 -睿 3518 -踢 3519 -衿 3520 -叽 3521 -崩 3522 -顽 3523 -嫌 3524 -扛 3525 -浔 3526 -拯 3527 -戊 3528 -戚 3529 -蛔 3530 -醇 3531 -笛 3532 -氛 3533 -沦 3534 -婊 3535 -仍 3536 -镍 3537 -渤 3538 -舶 3539 -哆 3540 -睹 3541 -萤 3542 -弧 3543 -辙 3544 -旯 3545 -纷 3546 -熄 3547 -挽 3548 -帘 3549 -蒸 3550 -橄 3551 -榄 3552 -滥 3553 -掩 3554 -兮 3555 -庸 3556 -玟 3557 -垦 3558 -惟 3559 -朕 3560 -脯 3561 -歪 3562 -吾 3563 -碘 3564 -锰 3565 -矾 3566 -拙 3567 -践 3568 -纠 3569 -赡 3570 -暨 3571 -凳 3572 -雾 3573 -缔 3574 -啫 3575 -毁 3576 -宥 3577 -邛 3578 -崃 3579 -禧 3580 -醛 3581 -滤 3582 -嘀 3583 -缪 3584 -萌 3585 -芥 3586 -胀 3587 -鲨 3588 -腩 3589 -勾 3590 -裳 3591 -雍 3592 -蹭 3593 -匹 3594 -髓 3595 -砍 3596 -孰 3597 -辩 3598 -唰 3599 -慷 3600 -慨 3601 -畏 3602 -坠 3603 -钝 3604 -箫 3605 -愧 3606 -劈 3607 -嘶 3608 -粮 3609 -轼 3610 -蟒 3611 -翊 3612 -澧 3613 -揽 3614 -烹 3615 -饪 3616 -踏 3617 -弛 3618 -婢 3619 -奸 3620 -掏 3621 -泓 3622 -袖 3623 -笈 3624 -刑 3625 -俑 3626 -浇 3627 -骊 3628 -蛀 3629 -蚤 3630 -杵 3631 -兹 3632 -晰 3633 -癫 3634 -痫 3635 -逝 3636 -炬 3637 -讼 3638 -陂 3639 -蚕 3640 -绸 3641 -槽 3642 -纨 3643 -牢 3644 -晃 3645 -窄 3646 -蒂 3647 -湃 3648 -硫 3649 -眨 3650 -耸 3651 -浠 3652 -梵 3653 -纺 3654 -贾 3655 -膨 3656 -阀 3657 -堀 3658 -扭 3659 -捂 3660 -扑 3661 -椭 3662 -鳟 3663 -丙 3664 -烯 3665 -冈 3666 -衷 3667 -牟 3668 -郫 3669 -畴 3670 -腥 3671 -亩 3672 -淤 3673 -禄 3674 -倘 3675 -烷 3676 -仆 3677 -刨 3678 -炜 3679 -挨 3680 -鳅 3681 -奚 3682 -峪 3683 -呻 3684 -佣 3685 -渔 3686 -肢 3687 -霏 3688 -旨 3689 -爰 3690 -吨 3691 -珑 3692 -隽 3693 -橙 3694 -箍 3695 -岚 3696 -啸 3697 -倌 3698 -剃 3699 -御 3700 -沸 3701 -棘 3702 -瘫 3703 -痪 3704 -仕 3705 -闺 3706 -炳 3707 -乏 3708 -拱 3709 -墅 3710 -铢 3711 -痤 3712 -琥 3713 -珈 3714 -荟 3715 -翩 3716 -搓 3717 -阮 3718 -芸 3719 -抠 3720 -弓 3721 -锣 3722 -赫 3723 -挡 3724 -侃 3725 -诶 3726 -沽 3727 -绫 3728 -濑 3729 -龈 3730 -乞 3731 -丐 3732 -宴 3733 -馁 3734 -牲 3735 -闰 3736 -亢 3737 -辫 3738 -铲 3739 -嫦 3740 -卵 3741 -佚 3742 -谬 3743 -倡 3744 -抑 3745 -赋 3746 -跆 3747 -削 3748 -氮 3749 -嫩 3750 -噻 3751 -蜗 3752 -鹂 3753 -靶 3754 -妥 3755 -衢 3756 -腻 3757 -砖 3758 -翎 3759 -拈 3760 -卉 3761 -皂 3762 -曦 3763 -荔 3764 -晤 3765 -曜 3766 -趵 3767 -纣 3768 -捞 3769 -蕲 3770 -猿 3771 -榈 3772 -憎 3773 -媚 3774 -绞 3775 -峙 3776 -饲 3777 -瑾 3778 -寡 3779 -釜 3780 -凸 3781 -凹 3782 -嫉 3783 -妒 3784 -婪 3785 -驸 3786 -荤 3787 -弥 3788 -蹦 3789 -驮 3790 -汞 3791 -唠 3792 -叨 3793 -袈 3794 -裟 3795 -毽 3796 -蔗 3797 -蹄 3798 -犍 3799 -珞 3800 -谚 3801 -煎 3802 -腋 3803 -瞳 3804 -丛 3805 -挪 3806 -榴 3807 -钩 3808 -梓 3809 -骁 3810 -烙 3811 -舜 3812 -暮 3813 -擀 3814 -兜 3815 -癜 3816 -姗 3817 -藜 3818 -擒 3819 -歼 3820 -冉 3821 -倚 3822 -漱 3823 -嫣 3824 -椰 3825 -隘 3826 -掐 3827 -栾 3828 -巍 3829 -咔 3830 -稽 3831 -惆 3832 -怅 3833 -镑 3834 -娲 3835 -芷 3836 -藻 3837 -伺 3838 -忌 3839 -桔 3840 -绅 3841 -坂 3842 -澜 3843 -嚓 3844 -苔 3845 -诣 3846 -倔 3847 -酿 3848 -槟 3849 -榔 3850 -粪 3851 -渺 3852 -馗 3853 -峨 3854 -碚 3855 -阎 3856 -巅 3857 -颊 3858 -戬 3859 -吒 3860 -鸵 3861 -岂 3862 -廖 3863 -娅 3864 -旬 3865 -猥 3866 -琐 3867 -扈 3868 -滔 3869 -枷 3870 -崴 3871 -捣 3872 -泻 3873 -甙 3874 -俯 3875 -撑 3876 -芮 3877 -舆 3878 -邂 3879 -逅 3880 -宪 3881 -晖 3882 -岔 3883 -哒 3884 -酵 3885 -痔 3886 -苓 3887 -捶 3888 -睫 3889 -裕 3890 -彤 3891 -潢 3892 -酉 3893 -聂 3894 -氨 3895 -嗷 3896 -皎 3897 -焖 3898 -袭 3899 -惦 3900 -惘 3901 -隙 3902 -彝 3903 -鞘 3904 -厄 3905 -殷 3906 -罕 3907 -嚏 3908 -拇 3909 -尹 3910 -蔻 3911 -颂 3912 -皖 3913 -霖 3914 -屉 3915 -崛 3916 -砣 3917 -穗 3918 -枸 3919 -杞 3920 -竣 3921 -勋 3922 -坍 3923 -溢 3924 -廓 3925 -煽 3926 -囚 3927 -涪 3928 -墩 3929 -琢 3930 -胳 3931 -膊 3932 -彼 3933 -陀 3934 -汹 3935 -柱 3936 -颁 3937 -闫 3938 -熠 3939 -叹 3940 -婿 3941 -娩 3942 -藓 3943 -岷 3944 -婺 3945 -桓 3946 -赁 3947 -罢 3948 -姊 3949 -瓢 3950 -桩 3951 -淫 3952 -堪 3953 -艰 3954 -枢 3955 -枞 3956 -晗 3957 -泷 3958 -逍 3959 -筱 3960 -烽 3961 -渍 3962 -蒿 3963 -殴 3964 -玖 3965 -罐 3966 -剿 3967 -喀 3968 -磕 3969 -铵 3970 -蕊 3971 -篓 3972 -痞 3973 -磅 3974 -礴 3975 -磐 3976 -拘 3977 -瘙 3978 -惕 3979 -孜 3980 -杖 3981 -撇 3982 -敖 3983 -踩 3984 -刹 3985 -蹿 3986 -坎 3987 -氦 3988 -汨 3989 -垣 3990 -垢 3991 -胁 3992 -趴 3993 -苷 3994 -镒 3995 -幢 3996 -鞠 3997 -逾 3998 -鬃 3999 -尉 4000 -韧 4001 -锤 4002 -嘘 4003 -呷 4004 -噎 4005 -煲 4006 -恍 4007 -粱 4008 -亳 4009 -鳞 4010 -懦 4011 -酚 4012 -酞 4013 -哨 4014 -祀 4015 -刃 4016 -蕴 4017 -晟 4018 -菀 4019 -甬 4020 -鼾 4021 -鳖 4022 -螳 4023 -稼 4024 -栽 4025 -蝗 4026 -颌 4027 -咀 4028 -掠 4029 -嘱 4030 -甚 4031 -菅 4032 -奂 4033 -讽 4034 -秸 4035 -釉 4036 -坞 4037 -雇 4038 -绢 4039 -捧 4040 -狈 4041 -桀 4042 -骜 4043 -摊 4044 -臆 4045 -竺 4046 -栅 4047 -贬 4048 -飒 4049 -浸 4050 -噩 4051 -晾 4052 -绐 4053 -殡 4054 -挠 4055 -於 4056 -茁 4057 -瞪 4058 -窠 4059 -汰 4060 -魁 4061 -忒 4062 -璋 4063 -怠 4064 -莺 4065 -冶 4066 -绰 4067 -邈 4068 -圻 4069 -湮 4070 -亨 4071 -躬 4072 -砂 4073 -鹭 4074 -浊 4075 -楹 4076 -珉 4077 -撵 4078 -筏 4079 -荨 4080 -鳝 4081 -沥 4082 -邳 4083 -殉 4084 -憨 4085 -啼 4086 -熏 4087 -蜃 4088 -毋 4089 -彗 4090 -噪 4091 -绛 4092 -祟 4093 -蝙 4094 -蝠 4095 -漆 4096 -酰 4097 -锑 4098 -栖 4099 -肆 4100 -邕 4101 -弋 4102 -绽 4103 -嚼 4104 -霹 4105 -雳 4106 -谍 4107 -恹 4108 -怏 4109 -倦 4110 -轶 4111 -曛 4112 -疚 4113 -棚 4114 -漕 4115 -浃 4116 -勘 4117 -暄 4118 -趁 4119 -斥 4120 -苞 4121 -膳 4122 -赎 4123 -崭 4124 -笙 4125 -摁 4126 -嗅 4127 -瞒 4128 -舵 4129 -铸 4130 -咫 4131 -涅 4132 -瘪 4133 -潼 4134 -粑 4135 -漾 4136 -噶 4137 -鸠 4138 -铉 4139 -豹 4140 -遛 4141 -襟 4142 -壤 4143 -甭 4144 -吮 4145 -耒 4146 -钊 4147 -泞 4148 -拦 4149 -昱 4150 -腑 4151 -惧 4152 -韬 4153 -焗 4154 -窘 4155 -喳 4156 -溏 4157 -鲛 4158 -慵 4159 -菁 4160 -攥 4161 -埔 4162 -呕 4163 -蓑 4164 -笠 4165 -孑 4166 -咕 4167 -觐 4168 -漓 4169 -碾 4170 -浜 4171 -嬉 4172 -迂 4173 -笃 4174 -勉 4175 -锥 4176 -篷 4177 -亥 4178 -龌 4179 -龊 4180 -煞 4181 -蓟 4182 -皓 4183 -惰 4184 -勺 4185 -缨 4186 -峥 4187 -苯 4188 -豁 4189 -颓 4190 -拽 4191 -啄 4192 -麒 4193 -雎 4194 -鲢 4195 -睬 4196 -渣 4197 -唔 4198 -桧 4199 -癞 4200 -蛤 4201 -蟆 4202 -撩 4203 -酯 4204 -戳 4205 -舔 4206 -孺 4207 -怂 4208 -恿 4209 -臃 4210 -戟 4211 -惭 4212 -耿 4213 -徵 4214 -柬 4215 -朽 4216 -磺 4217 -媲 4218 -懿 4219 -悼 4220 -绎 4221 -缅 4222 -茜 4223 -瞻 4224 -炀 4225 -脓 4226 -罄 4227 -秃 4228 -拎 4229 -譬 4230 -榉 4231 -拭 4232 -玥 4233 -崆 4234 -峒 4235 -胛 4236 -糗 4237 -佗 4238 -佬 4239 -袍 4240 -炊 4241 -仞 4242 -霎 4243 -掺 4244 -匀 4245 -姹 4246 -妯 4247 -娌 4248 -帷 4249 -岢 4250 -柄 4251 -阪 4252 -玺 4253 -窑 4254 -肽 4255 -涡 4256 -窟 4257 -阉 4258 -硼 4259 -蛳 4260 -呤 4261 -砚 4262 -偌 4263 -贿 4264 -芗 4265 -蹊 4266 -跷 4267 -雏 4268 -膝 4269 -嗜 4270 -扦 4271 -涟 4272 -殆 4273 -郡 4274 -洵 4275 -酋 4276 -匡 4277 -胤 4278 -撤 4279 -辗 4280 -冀 4281 -捺 4282 -吏 4283 -衩 4284 -腕 4285 -灸 4286 -绔 4287 -瓯 4288 -蜻 4289 -蜓 4290 -窜 4291 -躯 4292 -髦 4293 -诏 4294 -缄 4295 -筠 4296 -沌 4297 -酐 4298 -皋 4299 -隧 4300 -鹊 4301 -傀 4302 -儡 4303 -诲 4304 -嘏 4305 -寅 4306 -骇 4307 -喧 4308 -癀 4309 -瑚 4310 -碉 4311 -羔 4312 -掂 4313 -痹 4314 -孚 4315 -绡 4316 -馊 4317 -虏 4318 -悖 4319 -漪 4320 -琉 4321 -缉 4322 -冥 4323 -饯 4324 -蔺 4325 -瘆 4326 -榨 4327 -盯 4328 -鄞 4329 -妨 4330 -哐 4331 -寇 4332 -鹃 4333 -卞 4334 -喘 4335 -藩 4336 -踹 4337 -粟 4338 -陨 4339 -遣 4340 -鳌 4341 -烨 4342 -抉 4343 -臧 4344 -墉 4345 -疽 4346 -拷 4347 -赃 4348 -哮 4349 -馥 4350 -砰 4351 -拗 4352 -汐 4353 -矣 4354 -沅 4355 -裴 4356 -阐 4357 -蟋 4358 -蟀 4359 -蚀 4360 -恁 4361 -恙 4362 -蝉 4363 -荀 4364 -彧 4365 -銮 4366 -侮 4367 -驿 4368 -婶 4369 -檀 4370 -哩 4371 -镐 4372 -轴 4373 -扳 4374 -飓 4375 -麓 4376 -牺 4377 -垛 4378 -稞 4379 -桴 4380 -痧 4381 -揣 4382 -殇 4383 -邬 4384 -撅 4385 -邸 4386 -鼬 4387 -剥 4388 -胥 4389 -撼 4390 -溟 4391 -鄱 4392 -鲫 4393 -觅 4394 -犊 4395 -恕 4396 -铂 4397 -褔 4398 -淼 4399 -骝 4400 -藉 4401 -裔 4402 -痨 4403 -颤 4404 -尴 4405 -尬 4406 -癖 4407 -拄 4408 -蠕 4409 -虻 4410 -迄 4411 -攸 4412 -浚 4413 -盔 4414 -肮 4415 -侬 4416 -锏 4417 -憧 4418 -憬 4419 -镰 4420 -懈 4421 -挟 4422 -缤 4423 -涎 4424 -睾 4425 -惶 4426 -褚 4427 -藐 4428 -眺 4429 -艇 4430 -昀 4431 -妄 4432 -祗 4433 -壬 4434 -浯 4435 -衲 4436 -來 4437 -黯 4438 -芩 4439 -敞 4440 -绊 4441 -娣 4442 -掷 4443 -茯 4444 -琍 4445 -蛹 4446 -钧 4447 -瘘 4448 -蜥 4449 -蜴 4450 -唬 4451 -驭 4452 -阂 4453 -诃 4454 -疟 4455 -潦 4456 -谀 4457 -肱 4458 -黏 4459 -甥 4460 -眶 4461 -秩 4462 -庇 4463 -钏 4464 -咝 4465 -肴 4466 -宸 4467 -湟 4468 -沣 4469 -煊 4470 -盂 4471 -弈 4472 -瞩 4473 -聆 4474 -疥 4475 -腼 4476 -腆 4477 -胭 4478 -匕 4479 -讳 4480 -戮 4481 -茧 4482 -趾 4483 -亵 4484 -吖 4485 -漩 4486 -逵 4487 -寰 4488 -滇 4489 -渎 4490 -寮 4491 -嘁 4492 -珂 4493 -珀 4494 -稠 4495 -羌 4496 -徘 4497 -徊 4498 -苛 4499 -蕨 4500 -薏 4501 -苡 4502 -戌 4503 -卯 4504 -馈 4505 -溥 4506 -熹 4507 -屡 4508 -巳 4509 -璜 4510 -铮 4511 -踊 4512 -锚 4513 -濠 4514 -噫 4515 -怦 4516 -蓥 4517 -碌 4518 -霓 4519 -牦 4520 -妤 4521 -屹 4522 -缈 4523 -蹉 4524 -驷 4525 -菡 4526 -谔 4527 -琛 4528 -吡 4529 -喹 4530 -呲 4531 -溺 4532 -鳗 4533 -慑 4534 -秆 4535 -骋 4536 -脐 4537 -涤 4538 -荞 4539 -淅 4540 -罘 4541 -焱 4542 -孵 4543 -斟 4544 -酌 4545 -痊 4546 -秉 4547 -砌 4548 -瘁 4549 -胱 4550 -笫 4551 -燮 4552 -衅 4553 -腱 4554 -垒 4555 -锟 4556 -缀 4557 -疵 4558 -墟 4559 -盏 4560 -舂 4561 -侗 4562 -琨 4563 -唧 4564 -怆 4565 -沮 4566 -敛 4567 -瑕 4568 -奠 4569 -汴 4570 -衙 4571 -歆 4572 -嘹 4573 -饽 4574 -拧 4575 -濒 4576 -锭 4577 -嬴 4578 -吱 4579 -靳 4580 -眸 4581 -渲 4582 -睦 4583 -蝼 4584 -瞿 4585 -剁 4586 -紊 4587 -翟 4588 -攘 4589 -蹂 4590 -躏 4591 -淞 4592 -跎 4593 -侍 4594 -铛 4595 -绷 4596 -仟 4597 -瀚 4598 -赉 4599 -俪 4600 -魄 4601 -吼 4602 -酗 4603 -嚒 4604 -彷 4605 -徨 4606 -煜 4607 -曝 4608 -嗑 4609 -俅 4610 -嵘 4611 -隍 4612 -唆 4613 -郜 4614 -栩 4615 -尻 4616 -咗 4617 -茗 4618 -疱 4619 -斐 4620 -菘 4621 -芎 4622 -帼 4623 -枭 4624 -矩 4625 -仨 4626 -幄 4627 -鲸 4628 -猬 4629 -梢 4630 -槐 4631 -璧 4632 -坷 4633 -逯 4634 -踝 4635 -濡 4636 -樵 4637 -肓 4638 -劵 4639 -羚 4640 -髫 4641 -笄 4642 -俾 4643 -匿 4644 -帛 4645 -孀 4646 -焙 4647 -瘟 4648 -籽 4649 -萦 4650 -灼 4651 -箴 4652 -筵 4653 -窒 4654 -裆 4655 -旎 4656 -砝 4657 -妲 4658 -恺 4659 -覃 4660 -寐 4661 -酝 4662 -啃 4663 -塬 4664 -醴 4665 -蜿 4666 -蜒 4667 -愣 4668 -恤 4669 -撂 4670 -瘸 4671 -檐 4672 -琰 4673 -狒 4674 -摧 4675 -诠 4676 -孪 4677 -嘚 4678 -鼹 4679 -囡 4680 -茴 4681 -噤 4682 -僻 4683 -钕 4684 -锴 4685 -渗 4686 -嗫 4687 -撮 4688 -缭 4689 -粼 4690 -咄 4691 -挝 4692 -蛾 4693 -恪 4694 -皙 4695 -莒 4696 -叼 4697 -诽 4698 -妩 4699 -叱 4700 -咤 4701 -挞 4702 -萼 4703 -饵 4704 -澹 4705 -惺 4706 -呶 4707 -铤 4708 -佟 4709 -丕 4710 -靛 4711 -伶 4712 -涣 4713 -桢 4714 -狭 4715 -卅 4716 -蟠 4717 -蟾 4718 -朦 4719 -胧 4720 -咆 4721 -滦 4722 -岖 4723 -篙 4724 -痍 4725 -胰 4726 -谏 4727 -坳 4728 -樯 4729 -橹 4730 -孬 4731 -潴 4732 -厥 4733 -椐 4734 -谩 4735 -恬 4736 -琬 4737 -遁 4738 -褥 4739 -咎 4740 -羁 4741 -苣 4742 -殁 4743 -懵 4744 -褒 4745 -蜚 4746 -蛊 4747 -筛 4748 -耙 4749 -耨 4750 -嬷 4751 -驯 4752 -赅 4753 -畲 4754 -滢 4755 -伎 4756 -庹 4757 -踉 4758 -戎 4759 -膛 4760 -嗡 4761 -吔 4762 -唏 4763 -喏 4764 -哧 4765 -缇 4766 -蚝 4767 -璀 4768 -璨 4769 -捅 4770 -妁 4771 -曳 4772 -吩 4773 -咐 4774 -罂 4775 -垌 4776 -揪 4777 -壕 4778 -跺 4779 -辘 4780 -轳 4781 -噔 4782 -斓 4783 -厮 4784 -叁 4785 -仄 4786 -沼 4787 -鸢 4788 -醪 4789 -郢 4790 -圃 4791 -碜 4792 -鲅 4793 -嚯 4794 -淳 4795 -迩 4796 -诋 4797 -鬟 4798 -汲 4799 -艮 4800 -跤 4801 -麋 4802 -橇 4803 -悱 4804 -恻 4805 -啷 4806 -惮 4807 -樨 4808 -毓 4809 -裱 4810 -堇 4811 -埸 4812 -叵 4813 -腚 4814 -畀 4815 -钼 4816 -赦 4817 -悯 4818 -谴 4819 -稷 4820 -嘢 4821 -盎 4822 -跶 4823 -窥 4824 -瑄 4825 -谤 4826 -柘 4827 -垄 4828 -蠡 4829 -邝 4830 -娆 4831 -俐 4832 -铷 4833 -肋 4834 -涿 4835 -俎 4836 -捜 4837 -罡 4838 -嗝 4839 -唛 4840 -酣 4841 -鹬 4842 -瑀 4843 -帚 4844 -镭 4845 -搽 4846 -钣 4847 -蜇 4848 -嗞 4849 -颉 4850 -耘 4851 -忡 4852 -噼 4853 -睐 4854 -簋 4855 -镚 4856 -朐 4857 -戛 4858 -扪 4859 -鹩 4860 -稹 4861 -嗣 4862 -睇 4863 -弩 4864 -侥 4865 -绚 4866 -虔 4867 -溴 4868 -毂 4869 -漉 4870 -郧 4871 -杈 4872 -埭 4873 -哝 4874 -纾 4875 -箔 4876 -蚍 4877 -呋 4878 -喃 4879 -旌 4880 -袅 4881 -嫡 4882 -2 4883 -睢 4884 -榭 4885 -濉 4886 -雉 4887 -糍 4888 -谙 4889 -坻 4890 -遨 4891 -囔 4892 -鹜 4893 -垩 4894 -嵋 4895 -葑 4896 -叻 4897 -剌 4898 -铀 4899 -鲟 4900 -珏 4901 -唑 4902 -拴 4903 -乍 4904 -镊 4905 -歩 4906 -姘 4907 -戍 4908 -娈 4909 -槿 4910 -魇 4911 -叩 4912 -啾 4913 -腈 4914 -骞 4915 -殃 4916 -髋 4917 -嶙 4918 -璟 4919 -嚷 4920 -鹳 4921 -嗬 4922 -梆 4923 -晁 4924 -龛 4925 -嚎 4926 -熨 4927 -倭 4928 -峦 4929 -蜍 4930 -桉 4931 -齁 4932 -搀 4933 -铬 4934 -刽 4935 -謝 4936 -沒 4937 -簪 4938 -邺 4939 -嵬 4940 -馄 4941 -饨 4942 -蜢 4943 -嗒 4944 -芨 4945 -弶 4946 -晞 4947 -搔 4948 -昴 4949 -夙 4950 -徙 4951 -霾 4952 -嗖 4953 -碴 4954 -秧 4955 -芍 4956 -匝 4957 -泫 4958 -琯 4959 -扼 4960 -砒 4961 -栎 4962 -卟 4963 -琊 4964 -怯 4965 -侩 4966 -峯 4967 -忿 4968 -藁 4969 -蹼 4970 -毡 4971 -埤 4972 -膘 4973 -噗 4974 -阕 4975 -嘭 4976 -椿 4977 -涸 4978 -祯 4979 -芵 4980 -螨 4981 -寥 4982 -梶 4983 -嘈 4984 -泠 4985 -侏 4986 -棂 4987 -缶 4988 -捋 4989 -钜 4990 -璞 4991 -媞 4992 -唢 4993 -邰 4994 -蚱 4995 -薜 4996 -牒 4997 -缥 4998 -咿 4999 -遐 5000 -蕙 5001 -惬 5002 -惚 5003 -硚 5004 -麽 5005 -踌 5006 -褂 5007 -蜉 5008 -蝣 5009 -腌 5010 -熘 5011 -缮 5012 -锢 5013 -犽 5014 -蹬 5015 -皈 5016 -剔 5017 -芪 5018 -妪 5019 -钇 5020 -仃 5021 -荏 5022 -苒 5023 -塾 5024 -阡 5025 -瑨 5026 -冢 5027 -匈 5028 -庶 5029 -荃 5030 -茬 5031 -妗 5032 -暹 5033 -犷 5034 -嵴 5035 -鳃 5036 -羲 5037 -岱 5038 -烩 5039 -勐 5040 -霁 5041 -厝 5042 -飚 5043 -瀛 5044 -炕 5045 -桅 5046 -垓 5047 -晌 5048 -黒 5049 -蚩 5050 -夔 5051 -垚 5052 -烊 5053 -眀 5054 -荼 5055 -蘼 5056 -尅 5057 -舫 5058 -拣 5059 -蹋 5060 -劭 5061 -耆 5062 -陡 5063 -樽 5064 -谒 5065 -觞 5066 -箩 5067 -槛 5068 -傈 5069 -僳 5070 -爻 5071 -皑 5072 -滘 5073 -嬅 5074 -丶 5075 -邋 5076 -遢 5077 -讴 5078 -隅 5079 -邃 5080 -谑 5081 -哔 5082 -矬 5083 -姣 5084 -凛 5085 -冽 5086 -殒 5087 -眈 5088 -鹧 5089 -鸪 5090 -飕 5091 -亘 5092 -篝 5093 -嘅 5094 -乜 5095 -黜 5096 -颇 5097 -鄄 5098 -蔫 5099 -贻 5100 -猝 5101 -绌 5102 -芈 5103 -隼 5104 -戆 5105 -鹫 5106 -霑 5107 -宕 5108 -凇 5109 -铨 5110 -町 5111 -礁 5112 -蕃 5113 -淖 5114 -搐 5115 -饴 5116 -榛 5117 -晔 5118 -祢 5119 -酥 5120 -丨 5121 -赂 5122 -噘 5123 -黍 5124 -幌 5125 -骅 5126 -黝 5127 -帧 5128 -胯 5129 -埙 5130 -敕 5131 -涓 5132 -掣 5133 -圪 5134 -榻 5135 -濛 5136 -擞 5137 -篡 5138 -榷 5139 -亟 5140 -渌 5141 -锹 5142 -啐 5143 -捍 5144 -嘣 5145 -跻 5146 -桠 5147 -贮 5148 -蛰 5149 -猖 5150 -骸 5151 -溉 5152 -铎 5153 -吁 5154 -溯 5155 -踞 5156 -俨 5157 -茌 5158 -蒯 5159 -篆 5160 -膺 5161 -垭 5162 -匮 5163 -撰 5164 -擂 5165 -倜 5166 -傥 5167 -蔑 5168 -弼 5169 -珮 5170 -颢 5171 -钿 5172 -迸 5173 -凿 5174 -湫 5175 -焯 5176 -硒 5177 -畈 5178 -觑 5179 -揶 5180 -禀 5181 -宦 5182 -杷 5183 -讷 5184 -踮 5185 -掳 5186 -窿 5187 -捻 5188 -褴 5189 -褛 5190 -瑛 5191 -胫 5192 -喋 5193 -沓 5194 -汛 5195 -掴 5196 -魉 5197 -馀 5198 -隗 5199 -咘 5200 -呱 5201 -獭 5202 -畊 5203 -莜 5204 -祐 5205 -轧 5206 -魍 5207 -昶 5208 -诓 5209 -囗 5210 -莠 5211 -岌 5212 -潸 5213 -涞 5214 -綦 5215 -畸 5216 -阄 5217 -遏 5218 -啶 5219 -冇 5220 -懋 5221 -煨 5222 -羱 5223 -诟 5224 -枳 5225 -鲶 5226 -燊 5227 -猷 5228 -铄 5229 -缰 5230 -搪 5231 -赊 5232 -诩 5233 -佼 5234 -钵 5235 -谌 5236 -嬗 5237 -砥 5238 -砺 5239 -觊 5240 -觎 5241 -颅 5242 -怵 5243 -疸 5244 -锆 5245 -缢 5246 -棣 5247 -蛎 5248 -鄯 5249 -茸 5250 -谶 5251 -蹶 5252 -侑 5253 -滂 5254 -襁 5255 -褓 5256 -杳 5257 -臊 5258 -摒 5259 -袂 5260 -掸 5261 -鹞 5262 -忱 5263 -湉 5264 -汩 5265 -剽 5266 -槌 5267 -塍 5268 -喟 5269 -讹 5270 -抡 5271 -烃 5272 -咁 5273 -珺 5274 -槎 5275 -砼 5276 -泯 5277 -泮 5278 -遴 5279 -匾 5280 -沏 5281 -悌 5282 -麾 5283 -垡 5284 -鏖 5285 -垅 5286 -斛 5287 -镂 5288 -骷 5289 -髅 5290 -豺 5291 -诿 5292 -狰 5293 -狞 5294 -泱 5295 -榫 5296 -嗤 5297 -瞥 5298 -揄 5299 -哌 5300 -婀 5301 -恸 5302 -蛐 5303 -镀 5304 -霈 5305 -钒 5306 -踱 5307 -淆 5308 -薹 5309 -纭 5310 -瘠 5311 -戾 5312 -夭 5313 -铰 5314 -渚 5315 -犇 5316 -舀 5317 -傣 5318 -獗 5319 -瞭 5320 -兢 5321 -犟 5322 -袒 5323 -铖 5324 -颚 5325 -徜 5326 -徉 5327 -囍 5328 -酆 5329 -铡 5330 -睽 5331 -裨 5332 -饕 5333 -躇 5334 -噱 5335 -赓 5336 -懊 5337 -蟊 5338 -趸 5339 -鄢 5340 -埝 5341 -椟 5342 -粳 5343 -跛 5344 -莴 5345 -娉 5346 -嗄 5347 -邙 5348 -渑 5349 -佶 5350 -颍 5351 -溆 5352 -诧 5353 -抨 5354 -憷 5355 -涠 5356 -痼 5357 -砀 5358 -剐 5359 -缙 5360 -鞑 5361 -坭 5362 -烬 5363 -唁 5364 -臼 5365 -瓮 5366 -袱 5367 -珩 5368 -蝌 5369 -蚪 5370 -诬 5371 -迥 5372 -楸 5373 -皿 5374 -蜷 5375 -遑 5376 -啖 5377 -篪 5378 -崮 5379 -讧 5380 -盹 5381 -瞑 5382 -鲳 5383 -谟 5384 -拮 5385 -琏 5386 -瞰 5387 -憩 5388 -馏 5389 -炷 5390 -眩 5391 -羿 5392 -洙 5393 -珲 5394 -愫 5395 -佯 5396 -舸 5397 -祎 5398 -旮 5399 -翌 5400 -畿 5401 -桎 5402 -梏 5403 -钳 5404 -鳍 5405 -犸 5406 -祉 5407 -缜 5408 -硌 5409 -殓 5410 -砾 5411 -酩 5412 -酊 5413 -兀 5414 -矸 5415 -髙 5416 -疝 5417 -膑 5418 -哂 5419 -僚 5420 -耷 5421 -窨 5422 -孳 5423 -鲠 5424 -淝 5425 -搡 5426 -伢 5427 -鲷 5428 -谕 5429 -頫 5430 -泺 5431 -谧 5432 -煳 5433 -萁 5434 -馕 5435 -鹌 5436 -鹑 5437 -钴 5438 -埇 5439 -摈 5440 -踵 5441 -冗 5442 -铣 5443 -萃 5444 -忤 5445 -揩 5446 -铧 5447 -矗 5448 -闾 5449 -柞 5450 -貉 5451 -撺 5452 -掇 5453 -灞 5454 -醍 5455 -痱 5456 -粲 5457 -糠 5458 -讣 5459 -蹴 5460 -茆 5461 -螈 5462 -旻 5463 -蔼 5464 -咣 5465 -麸 5466 -涝 5467 -渥 5468 -垤 5469 -咭 5470 -玳 5471 -瑁 5472 -郏 5473 -纂 5474 -扞 5475 -峭 5476 -铩 5477 -锨 5478 -坩 5479 -埚 5480 -瑭 5481 -札 5482 -舛 5483 -臬 5484 -郯 5485 -晦 5486 -耄 5487 -耋 5488 -俚 5489 -鲭 5490 -柩 5491 -黟 5492 -骼 5493 -蛆 5494 -跋 5495 -俸 5496 -幡 5497 -愕 5498 -噙 5499 -峋 5500 -厩 5501 -夯 5502 -擢 5503 -枋 5504 -葳 5505 -偃 5506 -赝 5507 -昝 5508 -镉 5509 -嫔 5510 -潋 5511 -娓 5512 -郅 5513 -瘀 5514 -奄 5515 -荇 5516 -咂 5517 -痉 5518 -挛 5519 -祚 5520 -庖 5521 -纰 5522 -簌 5523 -淬 5524 -掮 5525 -俟 5526 -臾 5527 -雒 5528 -吋 5529 -颧 5530 -嗔 5531 -诘 5532 -焘 5533 -獾 5534 -氤 5535 -氲 5536 -鲲 5537 -麂 5538 -罹 5539 -澍 5540 -镳 5541 -囱 5542 -玷 5543 -嗳 5544 -擘 5545 -濂 5546 -逡 5547 -骛 5548 -镔 5549 -湍 5550 -讥 5551 -蹁 5552 -跹 5553 -淦 5554 -骰 5555 -疃 5556 -腓 5557 -嵇 5558 -怄 5559 -谯 5560 -啕 5561 -坯 5562 -钎 5563 -锒 5564 -伉 5565 -佻 5566 -腴 5567 -怼 5568 -浐 5569 -摹 5570 -僮 5571 -芾 5572 -矍 5573 -泔 5574 -蚬 5575 -屐 5576 -翕 5577 -唿 5578 -苋 5579 -氪 5580 -楔 5581 -莪 5582 -掬 5583 -舷 5584 -骐 5585 -嗲 5586 -荻 5587 -缱 5588 -绻 5589 -嫚 5590 -铟 5591 -饷 5592 -醐 5593 -伫 5594 -澶 5595 -郇 5596 -蹚 5597 -藿 5598 -鳕 5599 -蝈 5600 -钯 5601 -铍 5602 -骠 5603 -盅 5604 -蜊 5605 -腭 5606 -谘 5607 -孛 5608 -豇 5609 -囫 5610 -囵 5611 -抿 5612 -楣 5613 -廾 5614 -貔 5615 -貅 5616 -蛉 5617 -猹 5618 -蚴 5619 -轱 5620 -葚 5621 -胗 5622 -鸮 5623 -篦 5624 -谆 5625 -篑 5626 -莅 5627 -砷 5628 -蝾 5629 -疴 5630 -葺 5631 -瘴 5632 -滹 5633 -砭 5634 -噌 5635 -鸾 5636 -珙 5637 -碣 5638 -餮 5639 -荸 5640 -荠 5641 -犄 5642 -歙 5643 -樾 5644 -淙 5645 -痢 5646 -濯 5647 -轫 5648 -琮 5649 -啜 5650 -闳 5651 -椁 5652 -蓼 5653 -垴 5654 -唷 5655 -炔 5656 -峁 5657 -囹 5658 -尕 5659 -嗪 5660 -缎 5661 -拚 5662 -稔 5663 -牍 5664 -赳 5665 -忪 5666 -菖 5667 -佃 5668 -埂 5669 -宓 5670 -瞠 5671 -洹 5672 -锲 5673 -睑 5674 -攫 5675 -竽 5676 -蹩 5677 -慜 5678 -锉 5679 -羧 5680 -崧 5681 -醺 5682 -舐 5683 -讫 5684 -熵 5685 -▁GONNA 5686 -瘢 5687 -秭 5688 -跄 5689 -绀 5690 -懑 5691 -弭 5692 -萋 5693 -篁 5694 -缛 5695 -茭 5696 -吠 5697 -鲑 5698 -幔 5699 -潺 5700 -鹈 5701 -鹕 5702 -椴 5703 -哕 5704 -剜 5705 -湎 5706 -玑 5707 -槃 5708 -暌 5709 -蹒 5710 -跚 5711 -恣 5712 -磬 5713 -悭 5714 -劾 5715 -唳 5716 -绉 5717 -枇 5718 -蜱 5719 -瞟 5720 -膈 5721 -磴 5722 -嶂 5723 -苫 5724 -邡 5725 -骈 5726 -惴 5727 -硖 5728 -鳜 5729 -羸 5730 -秣 5731 -殚 5732 -桷 5733 -罔 5734 -颦 5735 -桁 5736 -鸩 5737 -孱 5738 -伥 5739 -愎 5740 -圄 5741 -贲 5742 -旖 5743 -荥 5744 -徇 5745 -镌 5746 -偈 5747 -敝 5748 -刎 5749 -跬 5750 -欸 5751 -髌 5752 -椤 5753 -觥 5754 -踟 5755 -斡 5756 -陉 5757 -谡 5758 -龅 5759 -鸨 5760 -豢 5761 -豉 5762 -悻 5763 -曈 5764 -茼 5765 -谗 5766 -忖 5767 -牯 5768 -痂 5769 -虢 5770 -馓 5771 -跖 5772 -聿 5773 -箅 5774 -塅 5775 -丼 5776 -獐 5777 -肏 5778 -逄 5779 -钡 5780 -叒 5781 -霭 5782 -鲮 5783 -凫 5784 -鹥 5785 -鳙 5786 -玦 5787 -蒡 5788 -嘬 5789 -鹗 5790 -鬄 5791 -鎏 5792 -嘤 5793 -绦 5794 -涔 5795 -齑 5796 -蒌 5797 -墘 5798 -俠 5799 -蛭 5800 -薅 5801 -叕 5802 -砧 5803 -嘧 5804 -媺 5805 -蚵 5806 -楽 5807 -浄 5808 -厍 5809 -鳊 5810 -泂 5811 -龋 5812 -瓒 5813 -瑧 5814 -邨 5815 -峣 5816 -蚺 5817 -鲉 5818 -滟 5819 -堑 5820 -豳 5821 -骧 5822 -艹 5823 -柾 5824 -鬣 5825 -眦 5826 -畦 5827 -虬 5828 -睨 5829 -飨 5830 -蘖 5831 -羟 5832 -瓤 5833 -岫 5834 -惇 5835 -鲵 5836 -痦 5837 -笤 5838 -憙 5839 -痩 5840 -煋 5841 -媤 5842 -佤 5843 -羮 5844 -鏊 5845 -昇 5846 -蛱 5847 -珅 5848 -庋 5849 -搵 5850 -旸 5851 -岿 5852 -亓 5853 -揸 5854 -谂 5855 -淠 5856 -糅 5857 -儆 5858 -苕 5859 -刿 5860 -呒 5861 -岙 5862 -荜 5863 -玧 5864 -鄠 5865 -讬 5866 -祕 5867 -箦 5868 -醚 5869 -膻 5870 -笕 5871 -蛏 5872 -哞 5873 -饸 5874 -饹 5875 -愻 5876 -汫 5877 -鹇 5878 -栉 5879 -沇 5880 -擤 5881 -徳 5882 -黢 5883 -狍 5884 -錫 5885 -暝 5886 -機 5887 -鉅 5888 -菓 5889 -廋 5890 -橛 5891 -羣 5892 -笊 5893 -魃 5894 -掼 5895 -魑 5896 -靥 5897 -酔 5898 -铱 5899 -峄 5900 -哋 5901 -畹 5902 -鍪 5903 -髀 5904 -嚄 5905 -秾 5906 -苾 5907 -孓 5908 -汆 5909 -嗟 5910 -锺 5911 -睥 5912 -炝 5913 -怔 5914 -咛 5915 -巉 5916 -墒 5917 -岘 5918 -禛 5919 -陟 5920 -皲 5921 -萘 5922 -妣 5923 -芃 5924 -煸 5925 -郦 5926 -蒗 5927 -仝 5928 -抻 5929 -苜 5930 -蓿 5931 -鎵 5932 -減 5933 -燧 5934 -娭 5935 -毑 5936 -诂 5937 -烔 5938 -猗 5939 -哏 5940 -氙 5941 -匯 5942 -颛 5943 -鞣 5944 -笺 5945 -枖 5946 -忾 5947 -黉 5948 -埯 5949 -敩 5950 -玏 5951 -钺 5952 -纥 5953 -佈 5954 -沖 5955 -蚜 5956 -莨 5957 -菟 5958 -麇 5959 -沤 5960 -耦 5961 -赭 5962 -祊 5963 -璄 5964 -旼 5965 -浞 5966 -痄 5967 -蔸 5968 -璎 5969 -屛 5970 -錤 5971 -弢 5972 -绨 5973 -員 5974 -誕 5975 -祜 5976 -勍 5977 -浉 5978 -娑 5979 -呓 5980 -啉 5981 -嗐 5982 -弁 5983 -绺 5984 -撷 5985 -崑 5986 -诌 5987 -標 5988 -甯 5989 -俣 5990 -趔 5991 -趄 5992 -垠 5993 -赟 5994 -馐 5995 -畑 5996 -給 5997 -幣 5998 -產 5999 -恵 6000 -併 6001 -蒹 6002 -葭 6003 -後 6004 -瀍 6005 -愠 6006 -莛 6007 -蝰 6008 -鹮 6009 -逶 6010 -侪 6011 -蒽 6012 -巽 6013 -瓴 6014 -鲱 6015 -薙 6016 -過 6017 -億 6018 -車 6019 -鲇 6020 -淨 6021 -嗎 6022 -诨 6023 -靚 6024 -內 6025 -糁 6026 -錾 6027 -刈 6028 -滯 6029 -炆 6030 -徂 6031 -傩 6032 -鲺 6033 -叟 6034 -埗 6035 -篠 6036 -焐 6037 -暻 6038 -盃 6039 -髻 6040 -樘 6041 -墈 6042 -菉 6043 -巯 6044 -嘌 6045 -遒 6046 -鼋 6047 -匍 6048 -匐 6049 -臜 6050 -馔 6051 -鲎 6052 -獠 6053 -蟇 6054 -栄 6055 -騎 6056 -賽 6057 -場 6058 -幾 6059 -鐘 6060 -镛 6061 -鸶 6062 -镆 6063 -窸 6064 -庠 6065 -蒺 6066 -溱 6067 -倮 6068 -楪 6069 -帀 6070 -躶 6071 -洰 6072 -圉 6073 -圊 6074 -捨 6075 -谝 6076 -呔 6077 -勖 6078 -揖 6079 -喈 6080 -霰 6081 -觋 6082 -嫪 6083 -毐 6084 -繇 6085 -珐 6086 -馃 6087 -孃 6088 -逖 6089 -骶 6090 -喬 6091 -奧 6092 -風 6093 -裵 6094 -胍 6095 -確 6096 -揠 6097 -榀 6098 -聒 6099 -谪 6100 -歘 6101 -粿 6102 -舾 6103 -聩 6104 -嫘 6105 -砟 6106 -侉 6107 -捯 6108 -饬 6109 -囏 6110 -喙 6111 -笥 6112 -燿 6113 -鮀 6114 -芡 6115 -蛄 6116 -铳 6117 -挲 6118 -笞 6119 -廿 6120 -蠹 6121 -湋 6122 -暎 6123 -霙 6124 -颔 6125 -苁 6126 -啮 6127 -囖 6128 -寤 6129 -炟 6130 -乩 6131 -熥 6132 -桡 6133 -阈 6134 -孖 6135 -鐢 6136 -衾 6137 -怍 6138 -沆 6139 -囿 6140 -胬 6141 -陲 6142 -缦 6143 -誇 6144 -醮 6145 -箬 6146 -盥 6147 -鹘 6148 -诳 6149 -氡 6150 -狎 6151 -枧 6152 -谄 6153 -芣 6154 -苢 6155 -俤 6156 -誊 6157 -殄 6158 -辋 6159 -係 6160 -迤 6161 -謦 6162 -簰 6163 -滓 6164 -嬢 6165 -倏 6166 -睺 6167 -滏 6168 -脘 6169 -嗙 6170 -谥 6171 -歃 6172 -锃 6173 -欻 6174 -挼 6175 -襙 6176 -檄 6177 -龇 6178 -楫 6179 -咵 6180 -徭 6181 -闱 6182 -嚅 6183 -鳑 6184 -鲏 6185 -佞 6186 -箜 6187 -篌 6188 -蹑 6189 -喑 6190 -胄 6191 -鞥 6192 -蟥 6193 -骢 6194 -蹙 6195 -柰 6196 -蕤 6197 -癸 6198 -哙 6199 -睚 6200 -绾 6201 -篾 6202 -鳏 6203 -谲 6204 -袤 6205 -翳 6206 -蹰 6207 -槊 6208 -黠 6209 -姒 6210 -锱 6211 -猢 6212 -狲 6213 -粝 6214 -戕 6215 -茕 6216 -瀣 6217 -踽 6218 -绶 6219 -媾 6220 -舢 6221 -螯 6222 -茏 6223 -廪 6224 -诰 6225 -辇 6226 -琚 6227 -汜 6228 -洇 6229 -還 6230 -遽 6231 -槁 6232 -靼 6233 -髡 6234 -鸬 6235 -鹚 6236 -捭 6237 -黩 6238 -俶 6239 -個 6240 -圜 6241 -颞 6242 -苻 6243 -恽 6244 -腧 6245 -甾 6246 -辎 6247 -顼 6248 -阗 6249 -鬻 6250 -鬶 6251 -沔 6252 -狃 6253 -#0 6254 -#1 6255 -#2 6256 diff --git a/app/src/main/assets/models/voice/manifest.json b/app/src/main/assets/models/voice/manifest.json index 0f17a4bb..c2aa644e 100644 --- a/app/src/main/assets/models/voice/manifest.json +++ b/app/src/main/assets/models/voice/manifest.json @@ -1,16 +1,15 @@ { - "modelId": "sherpa-onnx-streaming-zipformer-bilingual-zh-en-2023-02-20-int8", - "modelVersion": "2023-02-20-int8", + "modelId": "sherpa-onnx-streaming-paraformer-bilingual-zh-en-int8", + "modelVersion": "hf-8e40c43-int8", "language": "zh-CN,en-US", - "modelType": "zipformer", + "modelType": "paraformer", "engineVersion": "sherpa-onnx-v1.13.6", - "fileHash": "f2b835dfe8231bccc08692b4d8751ff50c2360b9b39a7501136a45e1bd97db85", + "fileHash": "b2a084021f5b4aa5293e750e0733b6a8819d37ddd6452518faffa0b3408c5263", "supportsPunctuation": false, - "requiredMemory": 420000000, + "requiredMemory": 520000000, "files": [ - "models/voice/bilingual-zipformer/decoder-epoch-99-avg-1.onnx", - "models/voice/bilingual-zipformer/encoder-epoch-99-avg-1.int8.onnx", - "models/voice/bilingual-zipformer/joiner-epoch-99-avg-1.int8.onnx", - "models/voice/bilingual-zipformer/tokens.txt" + "models/voice/bilingual-paraformer/decoder.int8.onnx", + "models/voice/bilingual-paraformer/encoder.int8.onnx", + "models/voice/bilingual-paraformer/tokens.txt" ] } diff --git a/app/src/main/assets/models/voice/trusted-downloads.tsv b/app/src/main/assets/models/voice/trusted-downloads.tsv index fe60b815..55e8d2ca 100644 --- a/app/src/main/assets/models/voice/trusted-downloads.tsv +++ b/app/src/main/assets/models/voice/trusted-downloads.tsv @@ -1,3 +1,3 @@ # openIME trusted downloaded voice model catalog v1 # publishermanifest_fingerprint_sha256file_sizes_in_manifest_order -Slacker-LLC 12bf0e2a9d5090bdb935c90941a0b3ef6269346860398fed3e45cef91940a525 13876452,181895032,3228404,56317 +Slacker-LLC 46e714643e31aa2cba5524cc69bcda2a3c4c2d7e6f826d025c2d9d81473d4366 71664561,165462184,75756 diff --git a/app/src/main/cpp/local_rime_jni.cc b/app/src/main/cpp/local_rime_jni.cc index 88f1be7f..e4c6f817 100644 --- a/app/src/main/cpp/local_rime_jni.cc +++ b/app/src/main/cpp/local_rime_jni.cc @@ -2,11 +2,16 @@ #include #include +#include #include #include #include #include +#include +#include +#include +#include namespace { @@ -163,6 +168,27 @@ jobjectArray make_strings(JNIEnv* env, const std::vector& values) { return result; } +bool candidate_is_user_learned(size_t index) { + if (!g_session) return false; + auto session = rime::Service::instance().GetSession(g_session); + if (!session) return false; + auto* context = session->context(); + if (!context || context->composition().empty()) return false; + auto candidate = context->composition().back().GetCandidateAt(index); + if (!candidate) return false; + + const auto genuine_candidates = + rime::Candidate::GetGenuineCandidates(candidate); + for (const auto& genuine : genuine_candidates) { + if (!genuine) continue; + const auto& type = genuine->type(); + if (type == "user_phrase" || type == "user_table") { + return true; + } + } + return false; +} + std::string take_commit() { if (!g_api || !g_session) return {}; RIME_STRUCT(RimeCommit, commit); @@ -233,6 +259,42 @@ bool select_schema_locked(const std::string& schema_id) { g_api->select_schema(g_session, schema_id.c_str()); } +bool safe_user_dict_name(const std::string& name) { + return !name.empty() && name.size() <= 128 && name != "." && name != ".." && + name.find('/') == std::string::npos && + name.find('\\') == std::string::npos; +} + +std::string suspend_session_locked() { + std::string schema; + if (g_api && g_session && g_api->get_current_schema) { + char current[128] = {0}; + if (g_api->get_current_schema(g_session, current, sizeof(current))) { + schema = current; + } + } + if (g_api && g_session) { + g_api->destroy_session(g_session); + } + g_session = 0; + return schema; +} + +bool resume_session_locked(const std::string& schema) { + if (!g_api) return false; + g_session = g_api->create_session(); + if (!g_session) return false; + const bool selected = + (!schema.empty() && select_schema_locked(schema)) || + select_schema_locked("luna_pinyin_simp") || + select_schema_locked("luna_pinyin"); + if (!selected) { + g_api->destroy_session(g_session); + g_session = 0; + } + return selected; +} + void shutdown_locked() { if (g_api && g_session) g_api->destroy_session(g_session); g_session = 0; @@ -330,6 +392,87 @@ Java_llc_slacker_openime_RimeNative_nativeSelectCandidate( return utf8_to_jstring(env, finish_selection()); } +extern "C" JNIEXPORT jboolean JNICALL +Java_llc_slacker_openime_RimeNative_nativeIsUserLearnedCandidate( + JNIEnv*, jclass, jint index) { + std::lock_guard lock(g_mutex); + if (!g_api || !g_session || index < 0) return JNI_FALSE; + return candidate_is_user_learned(static_cast(index)) + ? JNI_TRUE + : JNI_FALSE; +} + +extern "C" JNIEXPORT jboolean JNICALL +Java_llc_slacker_openime_RimeNative_nativeDeleteCandidate( + JNIEnv*, jclass, jint index) { + std::lock_guard lock(g_mutex); + if (!g_api || !g_session || index < 0 || !g_api->delete_candidate) { + return JNI_FALSE; + } + return g_api->delete_candidate( + g_session, + static_cast(index)) + ? JNI_TRUE + : JNI_FALSE; +} + +extern "C" JNIEXPORT jobjectArray JNICALL +Java_llc_slacker_openime_RimeNative_nativeExportUserDictionaries( + JNIEnv* env, jclass, jstring target_dir) { + std::lock_guard lock(g_mutex); + if (!g_api || !g_session) return nullptr; + + const std::string target = jstring_to_utf8(env, target_dir); + if (target.empty()) return nullptr; + std::error_code error; + std::filesystem::create_directories(target, error); + if (error) return nullptr; + + const std::string schema = suspend_session_locked(); + std::vector exported; + { + rime::UserDictManager manager(&rime::Service::instance().deployer()); + rime::UserDictList dictionaries; + manager.GetUserDictList(&dictionaries); + size_t index = 0; + bool failed = false; + for (const auto& name : dictionaries) { + const std::filesystem::path path = + std::filesystem::path(target) / + ("dict-" + std::to_string(index++) + ".userdb.txt"); + const int count = manager.Export(name, path); + if (count < 0) { + failed = true; + break; + } + exported.emplace_back(name + "\t" + path.string()); + } + const bool resumed = resume_session_locked(schema); + if (failed || !resumed) return nullptr; + } + return make_strings(env, exported); +} + +extern "C" JNIEXPORT jint JNICALL +Java_llc_slacker_openime_RimeNative_nativeImportUserDictionary( + JNIEnv* env, jclass, jstring dict_name, jstring source_file) { + std::lock_guard lock(g_mutex); + if (!g_api || !g_session) return -1; + + const std::string name = jstring_to_utf8(env, dict_name); + const std::string source = jstring_to_utf8(env, source_file); + if (!safe_user_dict_name(name) || source.empty()) return -1; + + const std::string schema = suspend_session_locked(); + int imported = -1; + { + rime::UserDictManager manager(&rime::Service::instance().deployer()); + imported = manager.Import(name, std::filesystem::path(source)); + } + resume_session_locked(schema); + return imported; +} + extern "C" JNIEXPORT jstring JNICALL Java_llc_slacker_openime_RimeNative_nativeCommitFirst( JNIEnv* env, jclass) { diff --git a/app/src/main/java/llc/slacker/openime/AboutDataActivity.kt b/app/src/main/java/llc/slacker/openime/AboutDataActivity.kt new file mode 100644 index 00000000..9f6bd606 --- /dev/null +++ b/app/src/main/java/llc/slacker/openime/AboutDataActivity.kt @@ -0,0 +1,378 @@ +package llc.slacker.openime + +import android.app.Activity +import android.app.AlertDialog +import android.content.Intent +import android.net.Uri +import android.os.Bundle +import android.view.WindowInsets +import android.widget.LinearLayout +import android.widget.ScrollView +import android.widget.TextView +import android.widget.Toast +import java.io.File + +class AboutDataActivity : Activity() { + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + val content = LinearLayout(this).apply { + orientation = LinearLayout.VERTICAL + setPadding( + dp(ImeSpacingTokens.XL_DP), + dp(ImeSpacingTokens.LG_DP), + dp(ImeSpacingTokens.XL_DP), + dp(ImeSpacingTokens.XXL_DP), + ) + addView( + SetupUi.activityTopBar( + context = this@AboutDataActivity, + title = "关于与数据", + onBack = ::finish, + ), + LinearLayout.LayoutParams( + LinearLayout.LayoutParams.MATCH_PARENT, + dp(ImeGeometryTokens.TOP_BAR_HEIGHT_DP), + ), + ) + addView( + infoCard( + title = "openIME " + versionName(), + body = "本应用不含联网权限,数据只存在本机。", + ), + wrap().apply { topMargin = dp(ImeSpacingTokens.MD_DP) }, + ) + addView( + infoCard( + title = "用户数据", + body = "JSON 导出包含常用语、自定义符号、备用用户词条和设置项;剪贴板历史不导出。Rime 自动学习词库在已加载时一并导出,并在导入时按词库合并。", + ), + wrap().apply { topMargin = dp(ImeSpacingTokens.MD_DP) }, + ) + + val actions = LinearLayout(this@AboutDataActivity).apply { + orientation = LinearLayout.HORIZONTAL + addView( + SetupUi.primaryButton(this@AboutDataActivity, "导出") { + requestExportDocument() + }, + LinearLayout.LayoutParams( + 0, + dp(ImeGeometryTokens.PRIMARY_ROW_HEIGHT_DP), + 1f, + ).apply { marginEnd = dp(ImeSpacingTokens.SM_DP) }, + ) + addView( + SetupUi.secondaryButton(this@AboutDataActivity, "导入") { + requestImportDocument() + }, + LinearLayout.LayoutParams( + 0, + dp(ImeGeometryTokens.PRIMARY_ROW_HEIGHT_DP), + 1f, + ), + ) + } + addView( + actions, + LinearLayout.LayoutParams( + LinearLayout.LayoutParams.MATCH_PARENT, + dp(ImeGeometryTokens.PRIMARY_ROW_HEIGHT_DP), + ).apply { topMargin = dp(ImeSpacingTokens.LG_DP) }, + ) + addView( + infoCard( + title = "卸载前", + body = "卸载会清除本机全部数据,包括学习的用户词库。卸载前可先导出用户数据。", + ), + wrap().apply { topMargin = dp(ImeSpacingTokens.LG_DP) }, + ) + } + + setContentView( + ScrollView(this).apply { + setBackgroundColor(getColor(R.color.setup_page_bg)) + isFillViewport = true + setOnApplyWindowInsetsListener { view, insets -> + if (android.os.Build.VERSION.SDK_INT >= 30) { + val bars = insets.getInsets( + WindowInsets.Type.systemBars() or + WindowInsets.Type.displayCutout(), + ) + view.setPadding(bars.left, bars.top, bars.right, bars.bottom) + } else { + @Suppress("DEPRECATION") + view.setPadding( + insets.systemWindowInsetLeft, + insets.systemWindowInsetTop, + insets.systemWindowInsetRight, + insets.systemWindowInsetBottom, + ) + } + insets + } + addView(content) + }, + ) + } + + @Deprecated("Deprecated in Java") + override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { + super.onActivityResult(requestCode, resultCode, data) + if (resultCode != RESULT_OK) return + val uri = data?.data ?: return + when (requestCode) { + REQUEST_EXPORT -> exportTo(uri) + REQUEST_IMPORT -> importFrom(uri) + } + } + + private fun requestExportDocument() { + startActivityForResult( + Intent(Intent.ACTION_CREATE_DOCUMENT).apply { + addCategory(Intent.CATEGORY_OPENABLE) + type = "application/json" + putExtra(Intent.EXTRA_TITLE, "openIME-user-data.json") + }, + REQUEST_EXPORT, + ) + } + + private fun requestImportDocument() { + startActivityForResult( + Intent(Intent.ACTION_OPEN_DOCUMENT).apply { + addCategory(Intent.CATEGORY_OPENABLE) + type = "application/json" + }, + REQUEST_IMPORT, + ) + } + + private fun exportTo(uri: Uri) { + val service = LocalVoiceImeService.activeInstance + val rimeDir = File(cacheDir, "rime-user-export").apply { + deleteRecursively() + mkdirs() + } + if (service == null) { + confirmExportWithoutRime(uri) + return + } + val queued = service.exportRimeUserData(rimeDir) { dictionaries -> + if (dictionaries == null) { + rimeDir.deleteRecursively() + runOnUiThread { + showError("Rime 自动学习词库导出失败,未生成不完整备份。") + } + return@exportRimeUserData + } + runCatching { + writeArchive(uri, UserDataRepository.snapshot(this, dictionaries)) + }.onSuccess { + runOnUiThread { + Toast.makeText(this, "用户数据已导出", Toast.LENGTH_SHORT).show() + } + }.onFailure { error -> + runOnUiThread { + showError("导出失败:" + (error.message ?: "无法写入文件")) + } + } + rimeDir.deleteRecursively() + } + if (!queued) { + rimeDir.deleteRecursively() + confirmExportWithoutRime(uri) + } + } + + private fun confirmExportWithoutRime(uri: Uri) { + val dialog = AlertDialog.Builder(this) + .setTitle("自动学习词库尚未加载") + .setMessage("继续导出的 JSON 不包含 Rime 自动学习词库。先切换到 openIME 并等待输入法就绪后再导出,可包含这部分数据。") + .setNegativeButton("取消", null) + .setPositiveButton("继续导出") { _, _ -> + Thread { + runCatching { + writeArchive(uri, UserDataRepository.snapshot(this)) + }.onSuccess { + runOnUiThread { + Toast.makeText(this, "用户数据已导出", Toast.LENGTH_SHORT).show() + } + }.onFailure { error -> + runOnUiThread { + showError("导出失败:" + (error.message ?: "无法写入文件")) + } + } + }.start() + } + .create() + dialog.setOnShowListener { SetupUi.styleDialog(dialog, this) } + dialog.show() + } + + private fun writeArchive(uri: Uri, archive: UserDataArchive) { + val output = contentResolver.openOutputStream(uri, "wt") + ?: error("无法打开目标文件") + output.bufferedWriter(Charsets.UTF_8).use { writer -> + writer.write(UserDataArchiveCodec.encode(archive)) + } + } + + private fun importFrom(uri: Uri) { + Thread { + val result = runCatching { + val input = contentResolver.openInputStream(uri) + ?: error("无法打开所选文件") + val archive = input.bufferedReader(Charsets.UTF_8).use { reader -> + UserDataArchiveCodec.decode(reader.readText()) + } + archive to UserDataRepository.preview(this, archive) + } + runOnUiThread { + result.onSuccess { pair -> + confirmImport(pair.first, pair.second) + }.onFailure { error -> + showError( + "无法读取 openIME 用户数据:" + + (error.message ?: "文件格式错误"), + ) + } + } + }.start() + } + + private fun confirmImport( + archive: UserDataArchive, + preview: UserDataImportPreview, + ) { + val settingsLine = if (preview.settingsWillChange) "设置项将更新。" else "设置项无变化。" + val rimeLine = + if (preview.rimeDictionaryFiles > 0) { + "Rime 自动学习词库 " + preview.rimeDictionaryFiles + " 个文件将合并。" + } else { + "文件中没有 Rime 自动学习词库。" + } + val message = + "将新增常用语 " + preview.quickPhrasesToAdd + + " 条、自定义符号 " + preview.customSymbolsToAdd + + " 条、备用用户词 " + preview.userPhrasesToAdd + + " 条。" + settingsLine + rimeLine + "现有条目不会删除。" + val dialog = AlertDialog.Builder(this) + .setTitle("导入用户数据") + .setMessage(message) + .setNegativeButton("取消", null) + .setPositiveButton("导入") { _, _ -> performImport(archive) } + .create() + dialog.setOnShowListener { SetupUi.styleDialog(dialog, this) } + dialog.show() + } + + private fun performImport(archive: UserDataArchive) { + val rime = archive.rimeUserDictionaries + if (rime.isEmpty()) { + Thread { + UserDataRepository.importKotlinData(this, archive) + LocalVoiceImeService.activeInstance?.refreshPersistedSettingsFromActivity() + runOnUiThread { + Toast.makeText(this, "用户数据已导入", Toast.LENGTH_SHORT).show() + } + }.start() + return + } + + val service = LocalVoiceImeService.activeInstance + if (service == null) { + showError("文件包含 Rime 自动学习词库。请先切换到 openIME,等待输入法就绪后再导入。") + return + } + val sourceDir = File(cacheDir, "rime-user-import").apply { + deleteRecursively() + mkdirs() + } + val queued = service.importRimeUserData(sourceDir, rime) { imported -> + if (imported == null) { + sourceDir.deleteRecursively() + runOnUiThread { + showError("Rime 自动学习词库未全部导入;常用语、符号和设置未修改,已成功写入的 Rime 词条可能保留。") + } + return@importRimeUserData + } + UserDataRepository.importKotlinData(this, archive) + service.refreshPersistedSettingsFromActivity() + sourceDir.deleteRecursively() + runOnUiThread { + Toast.makeText( + this, + "用户数据已导入,Rime 合并 " + imported + " 条词条", + Toast.LENGTH_SHORT, + ).show() + } + } + if (!queued) { + sourceDir.deleteRecursively() + showError("Rime 自动学习词库尚未就绪,请稍后重试。") + } + } + + private fun infoCard(title: String, body: String): LinearLayout = + LinearLayout(this).apply { + orientation = LinearLayout.VERTICAL + setPadding( + dp(ImeSpacingTokens.LG_DP), + dp(ImeSpacingTokens.LG_DP), + dp(ImeSpacingTokens.LG_DP), + dp(ImeSpacingTokens.LG_DP), + ) + background = SetupUi.rounded( + getColor(R.color.setup_surface), + dp(ImeGeometryTokens.CARD_RADIUS_DP).toFloat(), + getColor(R.color.setup_input_line), + ) + addView( + TextView(this@AboutDataActivity).apply { + text = title + textSize = ImeTypographyTokens.TITLE_SP + setTextColor(getColor(R.color.setup_title)) + setTypeface(typeface, android.graphics.Typeface.BOLD) + }, + wrap(), + ) + addView( + TextView(this@AboutDataActivity).apply { + text = body + textSize = ImeTypographyTokens.BODY_SP + setTextColor(getColor(R.color.setup_body)) + setPadding(0, dp(ImeSpacingTokens.SM_DP), 0, 0) + }, + wrap(), + ) + } + + @Suppress("DEPRECATION") + private fun versionName(): String = + packageManager.getPackageInfo(packageName, 0).versionName.orEmpty() + .ifBlank { "未知版本" } + + private fun showError(message: String) { + val dialog = AlertDialog.Builder(this) + .setTitle("操作未完成") + .setMessage(message) + .setPositiveButton("关闭", null) + .create() + dialog.setOnShowListener { SetupUi.styleDialog(dialog, this) } + dialog.show() + } + + private fun wrap() = LinearLayout.LayoutParams( + LinearLayout.LayoutParams.MATCH_PARENT, + LinearLayout.LayoutParams.WRAP_CONTENT, + ) + + private fun dp(value: Int): Int = SetupUi.dp(this, value) + + private companion object { + const val REQUEST_EXPORT = 8101 + const val REQUEST_IMPORT = 8102 + } +} diff --git a/app/src/main/java/llc/slacker/openime/BackspaceGestureController.kt b/app/src/main/java/llc/slacker/openime/BackspaceGestureController.kt index 00cf41e9..eabce336 100644 --- a/app/src/main/java/llc/slacker/openime/BackspaceGestureController.kt +++ b/app/src/main/java/llc/slacker/openime/BackspaceGestureController.kt @@ -7,16 +7,19 @@ import android.view.ViewConfiguration import kotlin.math.abs /** - * Owns the held-backspace gesture: repeat delete, upward clear-all arming, - * hysteresis, and pointer/session state. Editor mutations remain callbacks. + * Owns the held-backspace gesture: repeat delete, upward clear-all, and the + * one-shot downward restore that is available immediately after a successful + * clear gesture. Editor mutations remain callbacks. */ internal class BackspaceGestureController( private val toPx: (Int) -> Int, private val onDeleteOne: () -> Unit, private val onClearAll: () -> Unit, + private val onUndoClear: () -> Boolean, private val onPressFeedback: () -> Unit, private val onHapticFeedback: () -> Unit, private val onShowClearPopup: (View) -> Unit, + private val onShowUndoPopup: (View) -> Unit, private val onHidePopup: () -> Unit, ) { private val handler = Handler(Looper.getMainLooper()) @@ -29,6 +32,8 @@ internal class BackspaceGestureController( private set private var clearArmed = false + private var undoArmed = false + private var undoAvailable = false private var repeatStarted = false private var repeatSuspended = false private var startX = 0f @@ -39,8 +44,9 @@ internal class BackspaceGestureController( private val repeatAction = object : Runnable { override fun run() { - if (!active || clearArmed) return + if (!active || clearArmed || undoArmed) return repeatStarted = true + undoAvailable = false onDeleteOne() handler.postDelayed(this, REPEAT_INTERVAL_MS) } @@ -59,6 +65,7 @@ internal class BackspaceGestureController( active = true clearArmed = false + undoArmed = false repeatStarted = false repeatSuspended = false startX = rawX @@ -74,7 +81,7 @@ internal class BackspaceGestureController( onPressFeedback() val startRepeat = Runnable { - if (active && !clearArmed) repeatAction.run() + if (active && !clearArmed && !undoArmed) repeatAction.run() } repeatStartAction = startRepeat handler.postDelayed(startRepeat, longPressTimeoutMs) @@ -83,31 +90,61 @@ internal class BackspaceGestureController( fun update(rawX: Float, rawY: Float) { if (!active) return val upward = startY - rawY + val downward = rawY - startY val horizontal = abs(rawX - startX) - // Suspend repeat-delete as soon as the gesture clearly points upward, - // so a slow clear swipe stays atomic instead of deleting on the way. - if (upward >= toPx(8) && horizontal <= toPx(96)) { + // Suspend repeat-delete as soon as the gesture clearly becomes a + // vertical command. Otherwise a slow swipe could mutate text before + // clear/restore is armed. + val verticalCommand = + horizontal <= toPx(96) && + (upward >= toPx(8) || (undoAvailable && downward >= toPx(8))) + if (verticalCommand) { repeatSuspended = true repeatStartAction?.let(handler::removeCallbacks) handler.removeCallbacks(repeatAction) - } else if (repeatSuspended) { + } else if (repeatSuspended && !clearArmed && !undoArmed) { repeatSuspended = false repeatStartAction?.let { - handler.postDelayed(it, if (repeatStarted) REPEAT_INTERVAL_MS else longPressTimeoutMs) + handler.postDelayed( + it, + if (repeatStarted) REPEAT_INTERVAL_MS else longPressTimeoutMs, + ) } } - val shouldArm = if (clearArmed) { + val shouldUndo = undoAvailable && if (undoArmed) { + downward > toPx(16) && horizontal <= toPx(120) + } else { + downward >= toPx(36) && horizontal <= toPx(96) + } + if (shouldUndo != undoArmed) { + undoArmed = shouldUndo + if (undoArmed) { + clearArmed = false + clearPreviewChanged?.invoke(false) + repeatStartAction?.let(handler::removeCallbacks) + handler.removeCallbacks(repeatAction) + anchor?.let(onShowUndoPopup) + onHapticFeedback() + return + } else { + onHidePopup() + onHapticFeedback() + } + } + if (undoArmed) return + + val shouldClear = if (clearArmed) { upward > toPx(16) && horizontal <= toPx(120) } else { - upward >= toPx(36) && horizontal <= toPx(96) + upward >= toPx(56) && horizontal <= toPx(96) } - if (shouldArm == clearArmed) return + if (shouldClear == clearArmed) return - clearArmed = shouldArm - clearPreviewChanged?.invoke(shouldArm) - if (shouldArm) { + clearArmed = shouldClear + clearPreviewChanged?.invoke(shouldClear) + if (shouldClear) { repeatStartAction?.let(handler::removeCallbacks) handler.removeCallbacks(repeatAction) anchor?.let(onShowClearPopup) @@ -119,8 +156,9 @@ internal class BackspaceGestureController( fun finish(commit: Boolean) { if (!active) return + val restoreClear = commit && undoArmed val clearAll = commit && clearArmed - val deleteOnce = commit && !clearArmed && !repeatStarted + val deleteOnce = commit && !clearArmed && !undoArmed && !repeatStarted handler.removeCallbacks(repeatAction) repeatStartAction?.let(handler::removeCallbacks) @@ -134,6 +172,7 @@ internal class BackspaceGestureController( pointerId = -1 clearPreviewChanged?.invoke(false) clearArmed = false + undoArmed = false repeatStarted = false repeatSuspended = false anchor = null @@ -141,11 +180,23 @@ internal class BackspaceGestureController( onHidePopup() when { + restoreClear -> { + onHapticFeedback() + onUndoClear() + // The gateway independently validates whether the one-shot + // snapshot is still legal. Either way this gesture consumes + // our local affordance so stale undo is never offered twice. + undoAvailable = false + } clearAll -> { onHapticFeedback() onClearAll() + undoAvailable = true + } + deleteOnce -> { + undoAvailable = false + onDeleteOne() } - deleteOnce -> onDeleteOne() } } @@ -156,6 +207,7 @@ internal class BackspaceGestureController( handler.removeCallbacksAndMessages(null) repeatStartAction = null pointerId = -1 + undoAvailable = false } private companion object { diff --git a/app/src/main/java/llc/slacker/openime/BackspaceKeyFactory.kt b/app/src/main/java/llc/slacker/openime/BackspaceKeyFactory.kt index c7b572b8..f0b9210b 100644 --- a/app/src/main/java/llc/slacker/openime/BackspaceKeyFactory.kt +++ b/app/src/main/java/llc/slacker/openime/BackspaceKeyFactory.kt @@ -28,7 +28,7 @@ internal class BackspaceKeyFactory( fun build(): ImeKeyView = createBaseKey(onDeleteOne).apply { tag = "key-backspace" - contentDescription = "删除,向上滑清空" + contentDescription = "删除,向上滑清空;清空后向下滑撤回" accessibilityDelegate = object : View.AccessibilityDelegate() { override fun onInitializeAccessibilityNodeInfo( host: View, @@ -59,8 +59,8 @@ internal class BackspaceKeyFactory( } val clearHint = TextView(context).apply { - text = "↑ 清空" - textSize = 7.5f + text = "清空" + textSize = ImeTypographyTokens.CAPTION_SP gravity = Gravity.CENTER includeFontPadding = false alpha = 0.72f diff --git a/app/src/main/java/llc/slacker/openime/CandidateBarController.kt b/app/src/main/java/llc/slacker/openime/CandidateBarController.kt index bf644a2c..071e504a 100644 --- a/app/src/main/java/llc/slacker/openime/CandidateBarController.kt +++ b/app/src/main/java/llc/slacker/openime/CandidateBarController.kt @@ -1,18 +1,23 @@ package llc.slacker.openime +import android.app.AlertDialog import android.content.Context import android.graphics.Color import android.graphics.Typeface import android.graphics.drawable.Drawable import android.os.Build +import android.os.SystemClock import android.view.Gravity +import android.view.MotionEvent import android.view.View import android.view.ViewGroup import android.view.animation.DecelerateInterpolator import android.widget.HorizontalScrollView +import android.widget.ImageView import android.widget.LinearLayout import android.widget.ScrollView import android.widget.TextView +import kotlin.math.abs /** * Owns candidate presentation: strip diff/reuse, scroll preservation, @@ -25,7 +30,7 @@ internal class CandidateBarController( private val context: Context, private val row: LinearLayout, private val scroll: HorizontalScrollView, - private val expandButton: TextView, + private val expandButton: ImageView, private val overlay: LinearLayout, private val keyboardBody: LinearLayout, private val toPx: (Int) -> Int, @@ -38,11 +43,59 @@ internal class CandidateBarController( private val statefulBackground: (Int, Int, Int) -> Drawable, private val onFeedback: () -> Unit, private val onCandidateSelected: (String) -> Unit, + private val onCandidateLongPressed: (String) -> Unit, ) { private var renderedCandidates: List? = null private var renderedComposition: String? = null private var renderedExpandedCandidates: List? = null private var renderedExpandedComposition: String? = null + private var candidateTouchDownX = 0f + private var candidateTouchDownY = 0f + private var candidateTouchActive = false + private var scrollingActiveUntilMs = 0L + + init { + // Keep ordinary horizontal scrolling native. A deliberate downward + // swipe on the candidate strip is only an alternate affordance for + // the existing expand button, so it does not create another state + // owner or candidate-navigation path. + scroll.setOnTouchListener { _, event -> + when (event.actionMasked) { + MotionEvent.ACTION_DOWN -> { + candidateTouchActive = true + candidateTouchDownX = event.x + candidateTouchDownY = event.y + } + MotionEvent.ACTION_UP -> { + candidateTouchActive = false + val dx = event.x - candidateTouchDownX + val dy = event.y - candidateTouchDownY + if ( + !expandedOpen && + expandButton.isEnabled && + dy >= toPx(CANDIDATE_EXPAND_SWIPE_DP) && + dy > abs(dx) * 1.15f + ) { + expandButton.performClick() + } + } + MotionEvent.ACTION_CANCEL -> { + candidateTouchActive = false + candidateTouchDownX = 0f + candidateTouchDownY = 0f + } + } + false + } + scroll.setOnScrollChangeListener { _, _, _, _, _ -> + scrollingActiveUntilMs = + SystemClock.uptimeMillis() + SCROLL_SETTLE_WINDOW_MS + } + } + + fun isInteractionActive(): Boolean = + candidateTouchActive || + SystemClock.uptimeMillis() < scrollingActiveUntilMs var expandedOpen: Boolean = false private set @@ -116,7 +169,8 @@ internal class CandidateBarController( ) { val canExpandOrClose = expandedOpen || hasCandidates expandButton.isEnabled = canExpandOrClose - expandButton.alpha = if (canExpandOrClose) 1f else 0.38f + expandButton.alpha = + if (canExpandOrClose) 1f else ImeSurfacePolicy.DISABLED_ALPHA if (!canExpandOrClose) { expandButton.contentDescription = "暂无更多候选" if (android.os.Build.VERSION.SDK_INT >= 30) { @@ -133,7 +187,7 @@ internal class CandidateBarController( compositionPreview: String, ) { if (!open) { - expandButton.text = "⌄" + expandButton.rotation = 0f expandButton.contentDescription = "展开更多候选" overlay.animate().cancel() keyboardBody.animate().cancel() @@ -172,7 +226,7 @@ internal class CandidateBarController( renderedExpandedCandidates = candidates.toList() renderedExpandedComposition = compositionPreview expandedOpen = true - expandButton.text = "⌃" + expandButton.rotation = 180f expandButton.contentDescription = "收起候选" if (Build.VERSION.SDK_INT >= 30) { expandButton.stateDescription = "已展开" @@ -267,7 +321,7 @@ internal class CandidateBarController( expandedOpen = false renderedExpandedCandidates = null renderedExpandedComposition = null - expandButton.text = "⌄" + expandButton.rotation = 0f expandButton.contentDescription = "展开更多候选" } @@ -281,7 +335,7 @@ internal class CandidateBarController( isClickable = true addView( TextView(context).apply { - textSize = 14f + textSize = ImeTypographyTokens.CANDIDATE_SP maxLines = 1 includeFontPadding = false setPadding(toPx(12), 0, toPx(12), 0) @@ -305,19 +359,56 @@ internal class CandidateBarController( word.typeface = if (index == 0) Typeface.DEFAULT_BOLD else Typeface.DEFAULT val palette = tokens() - word.setTextColor(if (index == 0) palette.keyText else palette.candidateText) + val normalBackground = + if (index == 0) ImeSurfacePolicy.selectedSurface(palette) else Color.TRANSPARENT + word.setTextColor( + if (index == 0) ImeSurfacePolicy.selectedText(palette) else palette.candidateText, + ) item.background = statefulBackground( - if (index == 0) palette.keyBackground else Color.TRANSPARENT, - palette.keyPressedBackground, + normalBackground, + if (index == 0) { + ImeSurfacePolicy.pressedSurface(normalBackground, palette) + } else { + ImeSurfacePolicy.pressedSurface(palette.candidateBackground, palette) + }, toPx(ImeGeometryTokens.KEY_RADIUS_DP), ) + item.setOnTouchListener { _, event -> + when (event.actionMasked) { + MotionEvent.ACTION_DOWN -> candidateTouchActive = true + MotionEvent.ACTION_UP, + MotionEvent.ACTION_CANCEL, + -> candidateTouchActive = false + } + false + } item.setOnClickListener { onFeedback() onCandidateSelected(candidate) } + item.setOnLongClickListener { + onFeedback() + onCandidateLongPressed(candidate) + true + } + } + + fun confirmCandidateDeletion(candidate: String, onConfirm: () -> Unit) { + val dialog = AlertDialog.Builder(context) + .setTitle("删除此词") + .setMessage("从个人学习词中删除“$candidate”?") + .setPositiveButton("删除") { _, _ -> onConfirm() } + .setNegativeButton("取消", null) + .create() + dialog.setOnShowListener { + SetupUi.styleDialog(dialog, context, destructivePositive = true) + } + dialog.show() } private companion object { const val STRIP_LIMIT = 24 + const val CANDIDATE_EXPAND_SWIPE_DP = 36 + const val SCROLL_SETTLE_WINDOW_MS = 96L } } diff --git a/app/src/main/java/llc/slacker/openime/CandidateEngine.kt b/app/src/main/java/llc/slacker/openime/CandidateEngine.kt index 520119db..998910c2 100644 --- a/app/src/main/java/llc/slacker/openime/CandidateEngine.kt +++ b/app/src/main/java/llc/slacker/openime/CandidateEngine.kt @@ -339,6 +339,87 @@ class CandidateEngine(externalPinyin: Map> = emptyMap()) { private fun fuzzyVariants(py: String): List = pinyinFuzzyVariants(py) + private fun typoCandidatesFor( + pinyin: String, + fuzzy: Boolean, + ): List { + val result = linkedSetOf() + + if (fuzzy) { + fuzzyVariants(pinyin).forEach { variant -> + result.addAll(ImeData.phraseDict[variant].orEmpty().take(2)) + result.addAll(pinyinDict[variant].orEmpty().take(2)) + } + } + result.addAll(ImeData.phraseDict[pinyin].orEmpty().take(4)) + result.addAll(pinyinDict[pinyin].orEmpty().take(4)) + + // Prefix lookup is indexed and bounded; unlike getCandidates(), this + // path never performs segmentation or initial-index expansion for each + // neighboring-key variant. + phrasePrefixIndex.lookup(pinyin) + .values + .asSequence() + .flatMap { it.asSequence() } + .filter { it.isNotEmpty() } + .take(4) + .forEach(result::add) + + var index = lowerBound(sortedPinyinKeys, pinyin) + var scanned = 0 + while (index < sortedPinyinKeys.size && scanned < 4) { + val key = sortedPinyinKeys[index] + if (!key.startsWith(pinyin)) break + pinyinDict[key].orEmpty().firstOrNull()?.let(result::add) + index += 1 + scanned += 1 + } + + return result + .filter { candidate -> candidate.any { ch -> ch.code > 0x7f } } + .take(8) + } + + /** + * Recover common 26-key slips by substituting exactly one physically + * adjacent QWERTY key. This is intentionally conservative: it never + * changes the user's composition, never chains multiple corrections, and + * only contributes real non-ASCII candidates behind the normal result. + */ + fun getAdjacentKeyCorrections( + rawPinyin: String, + fuzzy: Boolean = false, + limit: Int = 24, + ): List { + val input = rawPinyin.lowercase().trim() + if ( + input.length !in 2..12 || + input.any { it !in 'a'..'z' } || + limit <= 0 + ) { + return emptyList() + } + + val result = linkedSetOf() + for (index in input.indices.reversed()) { + val original = input[index] + QWERTY_NEIGHBORS[original].orEmpty().forEach { replacement -> + val variant = buildString(input.length) { + append(input, 0, index) + append(replacement) + append(input, index + 1, input.length) + } + typoCandidatesFor(variant, fuzzy) + .take(2) + .forEach { candidate -> + result += candidate + } + if (result.size >= limit) return result.take(limit) + } + } + return result.take(limit) + } + fun get9KeyCandidates(numberStr: String): NineKeyResult { val digits = numberStr .take(MAX_NINE_KEY_DIGITS) @@ -492,6 +573,35 @@ class CandidateEngine(externalPinyin: Map> = emptyMap()) { private const val MAX_NINE_MATCHES = 12 private const val MAX_LOCAL_RESOLVE_LENGTH = 32 + private val QWERTY_NEIGHBORS = mapOf( + 'q' to "wa", + 'w' to "qase", + 'e' to "wsdr", + 'r' to "edft", + 't' to "rfgy", + 'y' to "tghu", + 'u' to "yhji", + 'i' to "ujko", + 'o' to "iklp", + 'p' to "ol", + 'a' to "qwsz", + 's' to "awedxz", + 'd' to "serfcx", + 'f' to "drtgvc", + 'g' to "ftyhbv", + 'h' to "gyujnb", + 'j' to "huikmn", + 'k' to "jiolm", + 'l' to "kop", + 'z' to "asx", + 'x' to "zsdc", + 'c' to "xdfv", + 'v' to "cfgb", + 'b' to "vghn", + 'n' to "bhjm", + 'm' to "njk", + ) + /** * Correct common legacy table mistakes without trusting the table at * runtime. Every value still passes nineKeyDigitsForPinyin() before it diff --git a/app/src/main/java/llc/slacker/openime/CandidatePipeline.kt b/app/src/main/java/llc/slacker/openime/CandidatePipeline.kt index 6744cb70..54d3fc04 100644 --- a/app/src/main/java/llc/slacker/openime/CandidatePipeline.kt +++ b/app/src/main/java/llc/slacker/openime/CandidatePipeline.kt @@ -45,14 +45,22 @@ class CandidatePipeline internal constructor( val displayPinyinPaths: List = emptyList(), ) - private val nineKeyDecoder = NineKeyLocalDecoder(engine) + private val nineKeyDecoder: NineKeyLocalDecoder = run { + val startedAt = System.nanoTime() + NineKeyLocalDecoder(engine).also { + NineKeyPerformanceTrace.recordDecoderConstruction( + elapsedNs = System.nanoTime() - startedAt, + threadName = Thread.currentThread().name, + ) + } + } override fun candidatesFor( mode: KeyboardMode, composition: String, fuzzy: Boolean, ): List = when (mode) { - KeyboardMode.PINYIN_26 -> engine.getCandidates(composition, fuzzy) + KeyboardMode.PINYIN_26 -> pinyin26Candidates(composition, fuzzy) KeyboardMode.ENGLISH_26 -> englishCandidates(composition) KeyboardMode.PINYIN_9 -> if (composition.length <= 32) { engine.getCandidates(composition, fuzzy) @@ -62,6 +70,24 @@ class CandidatePipeline internal constructor( KeyboardMode.DIGITS -> emptyList() } + private fun pinyin26Candidates( + composition: String, + fuzzy: Boolean, + ): List { + val normal = engine.getCandidates(composition, fuzzy) + val meaningfulNormal = normal.count { candidate -> + candidate != composition && candidate.any { it.code > 0x7f } + } + if (meaningfulNormal >= TYPO_CORRECTION_MIN_NORMAL_CANDIDATES) { + return normal.take(MAX_CANDIDATES) + } + + return (normal + engine.getAdjacentKeyCorrections(composition, fuzzy)) + .filter { it.isNotEmpty() } + .distinct() + .take(MAX_CANDIDATES) + } + /** * English completion is advisory. The exact text the user typed must stay * candidate #1 because punctuation, space and mode changes commit that @@ -199,6 +225,7 @@ class CandidatePipeline internal constructor( companion object { private const val MAX_CANDIDATES = 96 private const val PER_PATH_CANDIDATES = 24 + private const val TYPO_CORRECTION_MIN_NORMAL_CANDIDATES = 8 fun nineKeyDigitsFor(pinyin: String): String? = NineKeyLocalDecoder.digitsForPinyin(pinyin) diff --git a/app/src/main/java/llc/slacker/openime/CandidateQueryCoordinator.kt b/app/src/main/java/llc/slacker/openime/CandidateQueryCoordinator.kt index 72590f33..c9819df0 100644 --- a/app/src/main/java/llc/slacker/openime/CandidateQueryCoordinator.kt +++ b/app/src/main/java/llc/slacker/openime/CandidateQueryCoordinator.kt @@ -8,6 +8,7 @@ import java.util.concurrent.atomic.AtomicLong internal data class CandidateNativeQueryResult( val choices: List, val latencyMs: Long, + val resultLatencyMs: Long = latencyMs, ) internal data class CandidateQueryTicket( @@ -63,6 +64,7 @@ internal class CandidateQueryCoordinator( ) -> Unit, ): CandidateQueryTicket { val request = generation.incrementAndGet() + val requestedAt = SystemClock.elapsedRealtime() val queryInputs = rimeInputs .asSequence() .map { it.trim() } @@ -96,7 +98,14 @@ internal class CandidateQueryCoordinator( mainHandler.post { if (!isCurrent(request, queryInputs)) return@post - onResult(request, queryInputs, query) + onResult( + request, + queryInputs, + query.copy( + resultLatencyMs = + SystemClock.elapsedRealtime() - requestedAt, + ), + ) } } diff --git a/app/src/main/java/llc/slacker/openime/ClipboardPanelController.kt b/app/src/main/java/llc/slacker/openime/ClipboardPanelController.kt index 743afabf..ebeae88a 100644 --- a/app/src/main/java/llc/slacker/openime/ClipboardPanelController.kt +++ b/app/src/main/java/llc/slacker/openime/ClipboardPanelController.kt @@ -209,7 +209,7 @@ internal class ClipboardPanelController( private fun addRefreshAction(column: LinearLayout) { column.addView( - createPanelButton("重新读取", 12f, true).apply { + createPanelButton("重新读取", ImeTypographyTokens.CAPTION_SP, true).apply { tag = "clipboard-refresh" contentDescription = "重新读取剪贴板" setOnClickListener { @@ -226,7 +226,7 @@ internal class ClipboardPanelController( private fun renderQuickPhrases(column: LinearLayout) { column.addView( - createPanelButton("新增常用语", 13f, true).apply { + createPanelButton("新增常用语", ImeTypographyTokens.BODY_SP, true).apply { tag = "quick-phrase-add" setOnClickListener { onFeedback() @@ -270,18 +270,24 @@ internal class ClipboardPanelController( orientation = LinearLayout.HORIZONTAL tag = "phrase-card" addView( - createKey(phrase.text, 13f) { + createKey(phrase.text, ImeTypographyTokens.BODY_SP) { onCharacter(phrase.text) }.apply { setPadding(toPx(12), 0, toPx(12), 0) tag = "phrase:${phrase.id}" + contentDescription = + if (phrase.inputCode.isBlank()) { + "常用语:${phrase.text}" + } else { + "常用语:${phrase.text},输入码${phrase.inputCode}" + } }, LinearLayout.LayoutParams(0, toPx(ImeGeometryTokens.TOUCH_TARGET_DP), 1f).apply { marginEnd = toPx(5) }, ) addView( - createPanelButton("编辑", 11f, true).apply { + createPanelButton("编辑", ImeTypographyTokens.CAPTION_SP, true).apply { tag = "phrase-edit:${phrase.id}" setOnClickListener { onFeedback() @@ -293,7 +299,7 @@ internal class ClipboardPanelController( }, ) addView( - createPanelButton("删除", 11f, true).apply { + createPanelButton("删除", ImeTypographyTokens.CAPTION_SP, true).apply { tag = "phrase-delete:${phrase.id}" setOnClickListener { onFeedback() @@ -368,7 +374,7 @@ internal class ClipboardPanelController( meta.addView( createPanelButton( if (entry.pinned) "取消置顶" else "置顶", - 10f, + ImeTypographyTokens.CAPTION_SP, true, ).apply { tag = "clip-pin:${entry.text}" @@ -381,7 +387,7 @@ internal class ClipboardPanelController( wrapParams(), ) meta.addView( - createPanelButton("使用", 10f, true).apply { + createPanelButton("使用", ImeTypographyTokens.CAPTION_SP, true).apply { tag = "clip-use:${entry.text}" setOnClickListener { onFeedback() diff --git a/app/src/main/java/llc/slacker/openime/EmojiCellFactory.kt b/app/src/main/java/llc/slacker/openime/EmojiCellFactory.kt index 8fe478a6..db2515d5 100644 --- a/app/src/main/java/llc/slacker/openime/EmojiCellFactory.kt +++ b/app/src/main/java/llc/slacker/openime/EmojiCellFactory.kt @@ -31,7 +31,7 @@ internal class EmojiCellFactory( } val fallback = TextView(context).apply { text = emoji - textSize = 21f + textSize = ImeTypographyTokens.KEY_LETTER_SP gravity = Gravity.CENTER includeFontPadding = false importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO diff --git a/app/src/main/java/llc/slacker/openime/FloatingKeyboardController.kt b/app/src/main/java/llc/slacker/openime/FloatingKeyboardController.kt index 19951feb..c3efa443 100644 --- a/app/src/main/java/llc/slacker/openime/FloatingKeyboardController.kt +++ b/app/src/main/java/llc/slacker/openime/FloatingKeyboardController.kt @@ -78,7 +78,7 @@ internal class FloatingKeyboardController( ) mainDock.clipToOutline = true } else { - mainDock.setBackgroundColor(tokens.expandedBackground) + mainDock.setBackgroundColor(tokens.keyboardBackground) mainDock.clipToOutline = false } } diff --git a/app/src/main/java/llc/slacker/openime/FloatingWindowController.kt b/app/src/main/java/llc/slacker/openime/FloatingWindowController.kt index d1516b8b..365dd65d 100644 --- a/app/src/main/java/llc/slacker/openime/FloatingWindowController.kt +++ b/app/src/main/java/llc/slacker/openime/FloatingWindowController.kt @@ -17,6 +17,8 @@ internal class FloatingWindowController( private val mainHandler: Handler, private val windowProvider: () -> Window?, private val keyboardHeightPx: () -> Int?, + private val floatingWidthPercent: () -> Int, + private val floatingOpacityPercent: () -> Int, private val debugLog: (String) -> Unit = {}, ) { var enabled: Boolean = false @@ -28,6 +30,7 @@ internal class FloatingWindowController( private var baseWidth: Int? = null private var baseHeight: Int? = null private var baseSoftInputMode: Int? = null + private var baseAlpha: Float? = null fun enable(resetPosition: Boolean = x == 0 && y == 0) { enabled = true @@ -61,6 +64,7 @@ internal class FloatingWindowController( attrs.width = baseWidth ?: WindowManager.LayoutParams.MATCH_PARENT attrs.height = baseHeight ?: WindowManager.LayoutParams.WRAP_CONTENT baseSoftInputMode?.let { attrs.softInputMode = it } + baseAlpha?.let { attrs.alpha = it } attrs.x = 0 attrs.y = 0 imeWindow.attributes = attrs @@ -77,6 +81,7 @@ internal class FloatingWindowController( baseWidth = attrs.width baseHeight = attrs.height baseSoftInputMode = attrs.softInputMode + baseAlpha = attrs.alpha } val (screenWidth, screenHeight) = displaySize() @@ -96,6 +101,7 @@ internal class FloatingWindowController( attrs.width = desiredWidth attrs.height = currentHeight attrs.softInputMode = WindowManager.LayoutParams.SOFT_INPUT_ADJUST_NOTHING + attrs.alpha = floatingOpacityPercent().coerceIn(82, 100) / 100f attrs.x = x.coerceIn(bounds[0], bounds[1]) attrs.y = y.coerceIn(bounds[2], bounds[3]) x = attrs.x @@ -127,6 +133,7 @@ internal class FloatingWindowController( attrs.width = width attrs.height = height attrs.softInputMode = WindowManager.LayoutParams.SOFT_INPUT_ADJUST_NOTHING + attrs.alpha = floatingOpacityPercent().coerceIn(82, 100) / 100f attrs.x = x attrs.y = y imeWindow.attributes = attrs @@ -149,20 +156,20 @@ internal class FloatingWindowController( } private fun floatingWidth(screenWidth: Int): Int { - val landscape = resources.configuration.orientation == Configuration.ORIENTATION_LANDSCAPE - val preferred = if (landscape) { - dp(ImeGeometryTokens.FLOATING_LANDSCAPE_WIDTH_DP) - } else { - (screenWidth * 0.88f).toInt() - } + val landscape = + resources.configuration.orientation == Configuration.ORIENTATION_LANDSCAPE + val percent = floatingWidthPercent().coerceIn(72, 96) + val preferred = (screenWidth * percent / 100f).toInt() val maximum = dp( - if (landscape) ImeGeometryTokens.FLOATING_LANDSCAPE_WIDTH_DP else 400, + if (landscape) ImeGeometryTokens.FLOATING_LANDSCAPE_WIDTH_DP else 420, ) + val available = (screenWidth - dp(16)).coerceAtLeast(dp(1)) + val minimum = minOf(dp(if (landscape) 300 else 280), available) return minOf( preferred, maximum, - (screenWidth - dp(16)).coerceAtLeast(dp(1)), - ).coerceAtLeast(dp(320)) + available, + ).coerceAtLeast(minimum) } private fun displaySize(): Pair { diff --git a/app/src/main/java/llc/slacker/openime/ImeData.kt b/app/src/main/java/llc/slacker/openime/ImeData.kt index 756bc78a..ca847652 100644 --- a/app/src/main/java/llc/slacker/openime/ImeData.kt +++ b/app/src/main/java/llc/slacker/openime/ImeData.kt @@ -525,7 +525,7 @@ object ImeData { ) val keypad9Combinations: Map> = mapOf( - "64" to listOf("ni", "mi", "oh"), + "64" to listOf("ni", "mi"), "64426" to listOf("nihao"), "9664" to listOf("yong"), "426" to listOf("gong", "hao"), diff --git a/app/src/main/java/llc/slacker/openime/ImeDesignTokens.kt b/app/src/main/java/llc/slacker/openime/ImeDesignTokens.kt index 063a18b3..8dac18cc 100644 --- a/app/src/main/java/llc/slacker/openime/ImeDesignTokens.kt +++ b/app/src/main/java/llc/slacker/openime/ImeDesignTokens.kt @@ -32,7 +32,8 @@ internal object ImeGeometryTokens { const val SWITCH_KNOB_DP = 20 const val SWITCH_PADDING_DP = 4 const val SWITCH_KNOB_TRAVEL_DP = SWITCH_WIDTH_DP - SWITCH_PADDING_DP * 2 - SWITCH_KNOB_DP - const val KEY_ROW_GAP_DP = 6 + const val KEY_GAP_DP = 6 + const val KEY_ROW_GAP_DP = KEY_GAP_DP const val KEY_SIDE_MARGIN_DP = 2 const val KEY_POPUP_HEIGHT_DP = 76 const val KEY_POPUP_MIN_WIDTH_DP = 40 @@ -43,9 +44,24 @@ internal object ImeGeometryTokens { // The toolbar itself is compact; the top zone still reserves the larger // composed height so typing never moves the keyboard window. const val TOOLBAR_HEIGHT_DP = TOUCH_TARGET_DP + const val TOP_BAR_HEIGHT_DP = 56 + const val ICON_SIZE_DP = 24 + const val STEP_MARK_SIZE_DP = 28 + const val HERO_MARK_SIZE_DP = 72 const val COMPOSED_TOP_ZONE_HEIGHT_DP = 70 } +/** Shared spacing scale for every non-keyboard layout. */ +internal object ImeSpacingTokens { + const val XXS_DP = 2 + const val XS_DP = 4 + const val SM_DP = 8 + const val MD_DP = 12 + const val LG_DP = 16 + const val XL_DP = 24 + const val XXL_DP = 32 +} + /** Shared motion timing for native IME surfaces. */ internal object ImeMotionTokens { const val POPUP_ENTER_MS = 80L @@ -54,13 +70,20 @@ internal object ImeMotionTokens { const val STANDARD_TRANSITION_MS = 160L } -/** Shared native text roles; keyboard glyph sizing remains renderer-specific. */ +/** Six text roles shared by the keyboard and every app surface. */ internal object ImeTypographyTokens { - const val PANEL_TITLE_SP = 13f - const val PANEL_BODY_SP = 13f - const val PANEL_NOTE_SP = 12f const val CAPTION_SP = 11f - const val CANDIDATE_SP = 12f + const val BODY_SP = 14f + const val TITLE_SP = 16f + const val CANDIDATE_SP = 18f + const val KEY_LETTER_SP = 21f + const val DISPLAY_SP = 28f + + // Compatibility names for existing callers; every alias resolves to the + // canonical six-step scale above. + const val PANEL_TITLE_SP = TITLE_SP + const val PANEL_BODY_SP = BODY_SP + const val PANEL_NOTE_SP = BODY_SP } /** @@ -72,11 +95,7 @@ internal object ImeTypographyTokens { * compatibility, but the product intentionally exposes only this skin. */ enum class ImeTheme(val key: String, val label: String) { - IOS("theme-ios", "iOS 极简玻璃"), - DARK("theme-dark", "午夜深色"), - CYBERPUNK("theme-cyberpunk", "赛博霓虹"), - CLASSIC("theme-classic", "经典桌面"), - MACOS("theme-macos", "macOS Tahoe 极简"), + IOS("theme-ios", "iOS 极简"), ; data class Tokens( @@ -103,7 +122,19 @@ enum class ImeTheme(val key: String, val label: String) { val toolCardBackground: Int, val panelHeadBackground: Int, val destructive: Int = Color.parseColor("#F4212E"), - ) + val success: Int = Color.parseColor("#1F8A4C"), + val onAccent: Int = Color.parseColor("#07131D"), + val textSecondaryRole: Int? = null, + ) { + val canvas: Int get() = keyboardBackground + val surface: Int get() = toolbarBackground + val outline: Int get() = border + val text: Int get() = keyText + val textSecondary: Int get() = textSecondaryRole ?: keySecondaryText + val accent: Int get() = primary + val danger: Int get() = destructive + val accentPressed: Int get() = ImeSurfacePolicy.primaryPressed(this) + } fun tokens( appearance: ImeAppearance = ImeAppearance.DARK, @@ -116,47 +147,22 @@ enum class ImeTheme(val key: String, val label: String) { ImeAppearance.LIGHT -> false ImeAppearance.DARK -> true } - val base = when (this) { - // minis_ime_dual_theme_renderer.html 的 Dark Obsidian / Light Crystal - // 调色板。390 × 296 只是设计基准,尺寸仍由原生 View 的实际窗口计算。 - IOS -> if (useDark) { - Tokens( - c("#afc6ff"), c("#181a1e"), c("#202228"), c("#202228"), c("#e8e9ed"), - c("#303238"), c("#e8e9ed"), c("#b3b7c2"), c("#25272c"), c("#d7dae2"), c("#3a3d44"), - c("#25272c"), c("#343740"), c("#25272c"), c("#181a1e"), c("#202228"), - c("#303238"), c("#e8e9ed"), c("#25272c"), c("#d7dae2"), c("#25272c"), c("#202228"), - ) - } else { - Tokens( - c("#1D9BF0"), c("#e6e7eb"), c("#f0f1f3"), c("#f0f1f3"), c("#202124"), - c("#fafafb"), c("#202124"), c("#555b66"), c("#d2d4db"), c("#374151"), c("#e3e5e9"), - c("#f5f6f8"), c("#c8cbd2"), c("#d2d4db"), c("#e6e7eb"), c("#f0f1f3"), - c("#fafafb"), c("#202124"), c("#d2d4db"), c("#374151"), c("#f8f9fa"), c("#f0f1f3"), - ) - } - DARK -> Tokens( - c("#3b82f6"), c("#1e293b"), c("#0f172a"), c("#0f172a"), c("#f1f5f9"), - c("#334155"), c("#f8fafc"), c("#94a3b8"), c("#1e293b"), c("#94a3b8"), c("#475569"), - c("#1e3a8a"), Color.argb(20, 255, 255, 255), c("#475569"), c("#0f172a"), c("#0f172a"), - c("#e2e8f0"), c("#0f172a"), c("#475569"), c("#f1f5f9"), c("#1e293b"), c("#0f172a"), - ) - CYBERPUNK -> Tokens( - c("#ff007f"), c("#0d0e1f"), c("#12142e"), c("#080918"), c("#00f0ff"), - c("#161838"), c("#00f0ff"), c("#ffe600"), c("#090a18"), c("#00f0ff"), c("#ff007f"), - c("#2a0845"), Color.argb(76, 0, 240, 255), c("#161838"), c("#080918"), c("#080918"), - c("#161838"), c("#00f0ff"), c("#090a18"), c("#ffe600"), c("#12142e"), c("#0d0e1f"), - ) - CLASSIC -> Tokens( - c("#e11d48"), c("#f3f4f6"), c("#ffffff"), c("#ffffff"), c("#111827"), - c("#ffffff"), c("#111827"), c("#9ca3af"), c("#e5e7eb"), c("#374151"), c("#d1d5db"), - c("#fee2e2"), c("#d1d5db"), c("#e5e7eb"), c("#ffffff"), c("#ffffff"), - c("#ffffff"), c("#111827"), c("#e5e7eb"), c("#111827"), c("#f8fafc"), c("#e2e8f0"), + val base = if (useDark) { + Tokens( + c("#6EC3F7"), c("#1C1C1E"), c("#242426"), c("#262628"), c("#F2F2F7"), + c("#3A3A3C"), c("#F2F2F7"), c("#AEAEB2"), c("#2C2C2E"), c("#F2F2F7"), c("#4A4A4D"), + c("#242426"), c("#48484A"), c("#2C2C2E"), c("#202022"), c("#242426"), + c("#3A3A3C"), c("#F2F2F7"), c("#2C2C2E"), c("#F2F2F7"), c("#303033"), c("#242426"), + success = c("#5BD08A"), ) - MACOS -> Tokens( - c("#0ea5e9"), c("#e2e8f0"), c("#ffffff"), c("#ffffff"), c("#1e293b"), - c("#ffffff"), c("#0f172a"), c("#64748b"), c("#cbd5e1"), c("#475569"), c("#94a3b8"), - c("#e0f2fe"), Color.argb(15, 0, 0, 0), c("#cbd5e1"), c("#ffffff"), c("#ffffff"), - c("#ffffff"), c("#0f172a"), c("#cbd5e1"), c("#0f172a"), c("#f1f5f9"), c("#e2e8f0"), + } else { + Tokens( + c("#1D9BF0"), c("#D5D8DE"), c("#EEF0F3"), c("#F7F8FA"), c("#1F2023"), + c("#FFFFFF"), c("#1C1C1E"), c("#6E6E73"), c("#C5C9D1"), c("#2C2D31"), c("#DDE1E7"), + c("#F2F3F5"), c("#B7BCC5"), c("#C5C9D1"), c("#F1F2F4"), c("#F8F9FA"), + c("#FFFFFF"), c("#1C1C1E"), c("#C5C9D1"), c("#2C2D31"), c("#FFFFFF"), c("#E4E7EB"), + success = c("#1F8A4C"), + textSecondaryRole = c("#6D6D72"), ) } val accent = accentOverride ?: return base @@ -164,6 +170,114 @@ enum class ImeTheme(val key: String, val label: String) { } } +/** + * Derived interaction roles. Base palettes own neutral hierarchy; these + * functions derive selected/pressed/disabled surfaces so every panel uses the + * same state language instead of inventing another gray. + */ +internal object ImeSurfacePolicy { + const val DISABLED_ALPHA = 0.42f + + fun isDark(tokens: ImeTheme.Tokens): Boolean = + ImeContrastPolicy.relativeLuminance(tokens.keyboardBackground) < 0.16 + + fun selectedSurface(tokens: ImeTheme.Tokens): Int = + ImeDrawableFactory.blend( + tokens.primary, + tokens.candidateBackground, + if (isDark(tokens)) 0.24f else 0.12f, + ) + + fun selectedText(tokens: ImeTheme.Tokens): Int = + if ( + ImeContrastPolicy.contrastRatio(tokens.primary, selectedSurface(tokens)) >= 4.5 + ) { + tokens.primary + } else { + tokens.keyText + } + + fun pressedSurface(base: Int, tokens: ImeTheme.Tokens): Int = + ImeDrawableFactory.blend( + tokens.keyText, + base, + if (isDark(tokens)) 0.12f else 0.08f, + ) + + fun primaryPressed(tokens: ImeTheme.Tokens): Int = + adjustHslLightness( + tokens.primary, + if (isDark(tokens)) -0.06f else -0.08f, + ) + + private fun adjustHslLightness(color: Int, delta: Float): Int { + val r = Color.red(color) / 255f + val g = Color.green(color) / 255f + val b = Color.blue(color) / 255f + val max = maxOf(r, g, b) + val min = minOf(r, g, b) + var h = 0f + var s = 0f + val l = (max + min) / 2f + val d = max - min + if (d != 0f) { + s = d / (1f - kotlin.math.abs(2f * l - 1f)) + h = when (max) { + r -> 60f * (((g - b) / d) % 6f) + g -> 60f * (((b - r) / d) + 2f) + else -> 60f * (((r - g) / d) + 4f) + } + if (h < 0f) h += 360f + } + val targetL = (l + delta).coerceIn(0f, 1f) + val chroma = (1f - kotlin.math.abs(2f * targetL - 1f)) * s + val x = chroma * (1f - kotlin.math.abs((h / 60f) % 2f - 1f)) + val m = targetL - chroma / 2f + val (rr, gg, bb) = when { + h < 60f -> Triple(chroma, x, 0f) + h < 120f -> Triple(x, chroma, 0f) + h < 180f -> Triple(0f, chroma, x) + h < 240f -> Triple(0f, x, chroma) + h < 300f -> Triple(x, 0f, chroma) + else -> Triple(chroma, 0f, x) + } + return Color.rgb( + ((rr + m) * 255f).toInt().coerceIn(0, 255), + ((gg + m) * 255f).toInt().coerceIn(0, 255), + ((bb + m) * 255f).toInt().coerceIn(0, 255), + ) + } + + fun subtleAccentSurface(tokens: ImeTheme.Tokens): Int = + ImeDrawableFactory.blend( + tokens.primary, + tokens.toolCardBackground, + if (isDark(tokens)) 0.18f else 0.08f, + ) + + fun destructiveSurface(tokens: ImeTheme.Tokens): Int = + ImeDrawableFactory.blend( + tokens.destructive, + tokens.toolCardBackground, + if (isDark(tokens)) 0.18f else 0.10f, + ) + + fun destructiveText(tokens: ImeTheme.Tokens): Int { + val surface = destructiveSurface(tokens) + return if (ImeContrastPolicy.contrastRatio(tokens.destructive, surface) >= 4.5) { + tokens.destructive + } else { + ImeDrawableFactory.contrastText(surface) + } + } + + fun divider(tokens: ImeTheme.Tokens): Int = + ImeDrawableFactory.withAlpha( + tokens.border, + if (isDark(tokens)) 150 else 180, + ) +} + /** Shared WCAG contrast decisions for every native surface. */ internal object ImeContrastPolicy { private const val DARK_CONTENT = 0xff07131d.toInt() @@ -204,3 +318,39 @@ internal object ImeFocusRingPolicy { ImeContrastPolicy.contrastText(background) } } + +/** User-selectable accents share the same color source as the rest of the IME. */ +object AccentPalette { + const val DEFAULT = "#1D9BF0" + val presets = listOf( + "#1D9BF0" to "蓝色", + "#FFD400" to "黄色", + "#F91880" to "粉色", + "#7856FF" to "紫色", + "#FF7A00" to "橙色", + "#00BA7C" to "绿色", + "#00C2D7" to "青色", + "#38BDF8" to "天蓝", + "#5865F2" to "靛蓝", + "#9B5DE5" to "深紫", + "#E94FB8" to "洋红", + "#F4212E" to "红色", + "#FF5A5F" to "珊瑚红", + "#F59E0B" to "琥珀", + "#84CC16" to "青柠", + "#22C55E" to "翠绿", + "#10CFA0" to "薄荷", + "#14B8A6" to "蓝绿", + ) + + fun parse(value: String?): Int = runCatching { + val raw = normalize(value).removePrefix("#") + (0xFF000000L or raw.toLong(16)).toInt() + }.getOrDefault((0xFF000000L or DEFAULT.removePrefix("#").toLong(16)).toInt()) + + fun normalize(value: String?): String { + val raw = value.orEmpty().trim() + val hex = if (raw.startsWith("#")) raw else "#$raw" + return if (hex.matches(Regex("#?[0-9a-fA-F]{6}"))) hex.uppercase() else DEFAULT + } +} diff --git a/app/src/main/java/llc/slacker/openime/ImeDrawableFactory.kt b/app/src/main/java/llc/slacker/openime/ImeDrawableFactory.kt index b2f169df..884525aa 100644 --- a/app/src/main/java/llc/slacker/openime/ImeDrawableFactory.kt +++ b/app/src/main/java/llc/slacker/openime/ImeDrawableFactory.kt @@ -80,6 +80,22 @@ internal object ImeDrawableFactory { } } + /** + * Mix two opaque UI colors without bringing in another color library. + * amount=0 returns background; amount=1 returns foreground. + */ + fun blend(foreground: Int, background: Int, amount: Float): Int { + val t = amount.coerceIn(0f, 1f) + fun channel(front: Int, back: Int): Int = + (back + (front - back) * t).toInt().coerceIn(0, 255) + return Color.argb( + channel(Color.alpha(foreground), Color.alpha(background)), + channel(Color.red(foreground), Color.red(background)), + channel(Color.green(foreground), Color.green(background)), + channel(Color.blue(foreground), Color.blue(background)), + ) + } + fun withAlpha(color: Int, alpha: Int): Int = Color.argb( alpha.coerceIn(0, 255), Color.red(color), diff --git a/app/src/main/java/llc/slacker/openime/ImeKeyView.kt b/app/src/main/java/llc/slacker/openime/ImeKeyView.kt index dfe18273..c1649360 100644 --- a/app/src/main/java/llc/slacker/openime/ImeKeyView.kt +++ b/app/src/main/java/llc/slacker/openime/ImeKeyView.kt @@ -27,7 +27,7 @@ class ImeKeyView( text: String = "", secondary: String? = null, iconRes: Int = 0, - mainTextSize: Float = 17f, + mainTextSize: Float = ImeTypographyTokens.KEY_LETTER_SP, fitMainText: Boolean = false, ) : FrameLayout(context) { @@ -153,7 +153,7 @@ class ImeKeyView( secondaryTextView = secondary?.takeIf { it.isNotEmpty() }?.let { sub -> TextView(context).apply { this.text = sub - textSize = 9f + textSize = ImeTypographyTokens.CAPTION_SP gravity = Gravity.CENTER isAllCaps = false includeFontPadding = false @@ -166,8 +166,8 @@ class ImeKeyView( addView( view, FrameLayout.LayoutParams( - dp(if (text.isEmpty()) 18 else 13), - dp(if (text.isEmpty()) 18 else 13), + dp(if (text.isEmpty()) 20 else 16), + dp(if (text.isEmpty()) 20 else 16), ).apply { gravity = if (text.isEmpty()) { Gravity.CENTER @@ -241,6 +241,10 @@ class ImeKeyView( } } + fun setSecondaryAlpha(alpha: Float) { + secondaryTextView?.alpha = alpha.coerceIn(0f, 1f) + } + fun allowTwoLineLabel() { mainTextView?.maxLines = 2 } diff --git a/app/src/main/java/llc/slacker/openime/ImeKeyboardView.kt b/app/src/main/java/llc/slacker/openime/ImeKeyboardView.kt index ab4809ab..9f4bf482 100644 --- a/app/src/main/java/llc/slacker/openime/ImeKeyboardView.kt +++ b/app/src/main/java/llc/slacker/openime/ImeKeyboardView.kt @@ -9,6 +9,7 @@ import android.media.AudioManager import android.os.Build import android.os.Handler import android.os.Looper +import android.os.SystemClock import android.text.TextUtils import android.view.Gravity import android.view.HapticFeedbackConstants @@ -41,6 +42,8 @@ open class ImeKeyboardView( fun onCharacter(char: String) fun onBackspace() fun onClearAll() + fun onUndoClear(): Boolean = false + fun onUndoClearExpired() {} fun onSpace() fun onFloatingKeyboardChanged(floating: Boolean) fun onFloatingKeyboardDragged(deltaX: Float, deltaY: Float) @@ -72,6 +75,7 @@ open class ImeKeyboardView( onCompositionChanged(composition, candidates) } fun onCandidateSelected(candidate: String) + fun onCandidateLongPressed(candidate: String) {} /** * An association ("联想") chip is rendered only *after* a candidate was @@ -95,6 +99,10 @@ open class ImeKeyboardView( fun onPopupChanged(enabled: Boolean) fun onFuzzyChanged(enabled: Boolean) fun onSkinChanged(opacity: Int, radius: Int, fontSize: Int, primaryColor: String) {} + fun onHandednessChanged(handedness: ImeHandedness) {} + fun onKeyboardHeightChanged(percent: Int) {} + fun onFloatingStyleChanged(widthPercent: Int, opacityPercent: Int) {} + fun onOpenAboutData() {} } /** Visual class marker for white keys (nine/digits grid). */ @@ -114,9 +122,11 @@ open class ImeKeyboardView( toPx = ::dp, onDeleteOne = ::performBackspaceOnce, onClearAll = listener::onClearAll, + onUndoClear = listener::onUndoClear, onPressFeedback = ::feedback, onHapticFeedback = ::hapticFeedback, onShowClearPopup = { anchor -> showPopup(anchor, "清空") }, + onShowUndoPopup = { anchor -> showPopup(anchor, "撤回") }, onHidePopup = ::hidePopup, ) private val backspaceKeyFactory: BackspaceKeyFactory by lazy { @@ -129,7 +139,7 @@ open class ImeKeyboardView( text = "", func = true, secondary = null, - mainTextSizeOverride = 15f, + mainTextSizeOverride = ImeTypographyTokens.BODY_SP, iconRes = R.drawable.ic_backspace, onTap = onTap, ) @@ -151,6 +161,9 @@ open class ImeKeyboardView( onCancelPreviewChanged = { cancelling -> voicePanelController.setCancelPreview(cancelling) }, + onCursorStep = { direction -> + listener.onTextEdit(if (direction < 0) "left" else "right") + }, ) private val spaceVoiceKeyFactory: SpaceVoiceKeyFactory by lazy { SpaceVoiceKeyFactory( @@ -160,7 +173,7 @@ open class ImeKeyboardView( text = label, func = true, secondary = null, - mainTextSizeOverride = 14f, + mainTextSizeOverride = ImeTypographyTokens.BODY_SP, iconRes = R.drawable.ic_mic, onTap = { if (!insertIntoInlineEditor(" ")) onTap() @@ -215,9 +228,14 @@ open class ImeKeyboardView( private var appliedOrientation = resources.configuration.orientation private var appliedFontScale = resources.configuration.fontScale private var appliedDensityDpi = resources.displayMetrics.densityDpi + private var keyboardHandedness = ImeSettingsRepository.loadHandedness(context) + private var keyboardHeightPercent = ImeSettingsRepository.loadKeyboardHeightPercent(context) + private var floatingWidthPercent = ImeSettingsRepository.loadFloatingWidthPercent(context) + private var floatingOpacityPercent = ImeSettingsRepository.loadFloatingOpacityPercent(context) private var layoutMetrics = KeyboardLayoutMetrics( landscape = appliedOrientation == android.content.res.Configuration.ORIENTATION_LANDSCAPE, fontScale = appliedFontScale, + heightPercent = keyboardHeightPercent, ) private var lastTextMode = KeyboardMode.PINYIN_26 private var preferredChineseMode = ImeSettingsRepository.loadPreferredChineseMode(context) @@ -454,6 +472,7 @@ open class ImeKeyboardView( previousPanel = { panelBackStack.lastOrNull() }, onBack = ::closePanelToKeyboard, onFeedback = ::feedback, + standalone = standalonePanel, ) } private val panelRenderer: ImePanelRenderer by lazy { @@ -558,6 +577,10 @@ open class ImeKeyboardView( currentSkinRadius = { skinRadius }, currentSkinFontSize = { skinFontSize }, currentSkinColor = { skinPrimaryColor }, + currentHandedness = { keyboardHandedness }, + currentKeyboardHeightPercent = { keyboardHeightPercent }, + currentFloatingWidthPercent = { floatingWidthPercent }, + currentFloatingOpacityPercent = { floatingOpacityPercent }, onThemeSelected = ::setTheme, onAppearanceSelected = { selected -> setAppearance(selected) @@ -577,7 +600,11 @@ open class ImeKeyboardView( ) applyTheme() }, + onHandednessChanged = ::setHandedness, + onKeyboardHeightChanged = ::setKeyboardHeightPercent, + onFloatingStyleChanged = ::setFloatingStyle, onShowFuzzySettings = { showPanel(Panel.FUZZY_SETTINGS) }, + onOpenAboutData = listener::onOpenAboutData, onFeedback = ::feedback, applyTheme = ::applyTheme, onHierarchyRebuilt = ::onViewHierarchyRebuilt, @@ -803,6 +830,7 @@ open class ImeKeyboardView( layoutMetrics = KeyboardLayoutMetrics( landscape = appliedOrientation == android.content.res.Configuration.ORIENTATION_LANDSCAPE, fontScale = appliedFontScale, + heightPercent = keyboardHeightPercent, ) if (!geometryChanged) return // Do not yank the user out of an open panel. @@ -923,6 +951,31 @@ open class ImeKeyboardView( */ private fun updateResponsiveGeometry(measuredWidthPx: Int) { if (measuredWidthPx <= 0) return + + val dockWidthPx = + if (floatingKeyboardController.enabled || keyboardHandedness == ImeHandedness.STANDARD) { + measuredWidthPx + } else { + (measuredWidthPx * 0.82f).toInt() + .coerceAtLeast(dp(280)) + .coerceAtMost(measuredWidthPx) + } + + (mainDock.layoutParams as? FrameLayout.LayoutParams)?.let { params -> + if (dockWidthPx == measuredWidthPx) { + params.width = FrameLayout.LayoutParams.MATCH_PARENT + params.gravity = Gravity.TOP + } else { + params.width = dockWidthPx + params.gravity = Gravity.TOP or if (keyboardHandedness == ImeHandedness.LEFT) { + Gravity.START + } else { + Gravity.END + } + } + mainDock.layoutParams = params + } + if (floatingKeyboardController.enabled) { // A configuration pass can briefly report the physical display // width before WindowManager applies the floating window bounds. @@ -948,7 +1001,7 @@ open class ImeKeyboardView( } val minimumInset = dp(5) val maxWidth = dp(maxContentWidthDp) - contentInsetPx = maxOf(minimumInset, (measuredWidthPx - maxWidth) / 2) + contentInsetPx = maxOf(minimumInset, (dockWidthPx - maxWidth) / 2) keyboardBody.setPadding( contentInsetPx, dp(6), @@ -956,7 +1009,7 @@ open class ImeKeyboardView( dp(16), ) keyboardBody.findViewWithTag("key-row-secondary")?.let { row -> - val rowWidth = ((measuredWidthPx - contentInsetPx * 2) * 0.9f).toInt() + val rowWidth = ((dockWidthPx - contentInsetPx * 2) * 0.9f).toInt() val params = row.layoutParams as? LinearLayout.LayoutParams if (params != null) { params.gravity = Gravity.CENTER_HORIZONTAL @@ -994,6 +1047,8 @@ open class ImeKeyboardView( updateTopZone(composition.text?.isNotEmpty() == true) listener.onCandidateExpanded(open) }, + onUndoClear = { listener.onUndoClear() }, + onUndoClearExpired = listener::onUndoClearExpired, ) candidateBarController = CandidateBarController( context = context, @@ -1011,6 +1066,11 @@ open class ImeKeyboardView( }.apply { allowTwoLineLabel() contentDescription = "候选:$candidate" + setOnLongClickListener { + feedback() + listener.onCandidateLongPressed(candidate) + true + } } }, createEmptyLabel = { title("暂无候选", small = true) }, @@ -1025,6 +1085,7 @@ open class ImeKeyboardView( statefulBackground = ::statefulRounded, onFeedback = ::feedback, onCandidateSelected = listener::onCandidateSelected, + onCandidateLongPressed = listener::onCandidateLongPressed, ) mainDock.addView( topZone, @@ -1207,6 +1268,9 @@ open class ImeKeyboardView( } stopVoiceIfActive() panel = Panel.NONE + if (renderedMode != mode) { + renderModeBody() + } expandedPanel.animate().cancel() expandedPanel.visibility = View.GONE mainDock.animate().cancel() @@ -1317,7 +1381,7 @@ open class ImeKeyboardView( val available = !passwordField clipboardButton.isEnabled = available clipboardButton.isClickable = available - clipboardButton.alpha = if (available) 1f else 0.38f + clipboardButton.alpha = if (available) 1f else ImeSurfacePolicy.DISABLED_ALPHA clipboardButton.contentDescription = if (available) { "剪贴板" } else { @@ -1391,6 +1455,45 @@ open class ImeKeyboardView( * several times. Keep the individual setters for user actions, but use * this atomic boundary whenever a persisted snapshot is loaded. */ + private fun setHandedness(next: ImeHandedness) { + if (keyboardHandedness == next) return + keyboardHandedness = next + ImeSettingsRepository.saveHandedness(context, next) + listener.onHandednessChanged(next) + updateResponsiveGeometry(width) + } + + private fun setFloatingStyle(widthPercent: Int, opacityPercent: Int) { + val width = widthPercent.coerceIn(72, 96) + val opacity = opacityPercent.coerceIn(82, 100) + if (floatingWidthPercent == width && floatingOpacityPercent == opacity) return + floatingWidthPercent = width + floatingOpacityPercent = opacity + ImeSettingsRepository.saveFloatingWidthPercent(context, width) + ImeSettingsRepository.saveFloatingOpacityPercent(context, opacity) + listener.onFloatingStyleChanged(width, opacity) + } + + private fun setKeyboardHeightPercent(percent: Int) { + val bounded = percent.coerceIn(92, 120) + if (keyboardHeightPercent == bounded) return + keyboardHeightPercent = bounded + ImeSettingsRepository.saveKeyboardHeightPercent(context, bounded) + listener.onKeyboardHeightChanged(bounded) + renderedMode = null + layoutMetrics = KeyboardLayoutMetrics( + landscape = appliedOrientation == android.content.res.Configuration.ORIENTATION_LANDSCAPE, + fontScale = appliedFontScale, + heightPercent = keyboardHeightPercent, + ) + applyDynamicHeights() + if (!standalonePanel && panel == Panel.NONE) { + renderModeBody() + } else { + requestLayout() + } + } + internal fun applyPersistedSettings( newTheme: ImeTheme, newAppearance: ImeAppearance, @@ -1404,6 +1507,15 @@ open class ImeKeyboardView( primaryColor: String, ) { val normalizedColor = AccentPalette.normalize(primaryColor) + val persistedHandedness = ImeSettingsRepository.loadHandedness(context) + val persistedHeight = ImeSettingsRepository.loadKeyboardHeightPercent(context) + val persistedFloatingWidth = ImeSettingsRepository.loadFloatingWidthPercent(context) + val persistedFloatingOpacity = ImeSettingsRepository.loadFloatingOpacityPercent(context) + val floatingStyleChanged = + floatingWidthPercent != persistedFloatingWidth || + floatingOpacityPercent != persistedFloatingOpacity + val heightChanged = keyboardHeightPercent != persistedHeight + val geometryChanged = keyboardHandedness != persistedHandedness || heightChanged val visualChanged = theme != newTheme || appearance != newAppearance || skinOpacity != opacity || @@ -1421,9 +1533,52 @@ open class ImeKeyboardView( skinRadius = radius skinFontSize = fontSize skinPrimaryColor = normalizedColor + if (geometryChanged) { + keyboardHandedness = persistedHandedness + keyboardHeightPercent = persistedHeight + if (heightChanged) renderedMode = null + layoutMetrics = KeyboardLayoutMetrics( + landscape = appliedOrientation == android.content.res.Configuration.ORIENTATION_LANDSCAPE, + fontScale = appliedFontScale, + heightPercent = keyboardHeightPercent, + ) + applyDynamicHeights() + if (!standalonePanel && panel == Panel.NONE) { + renderModeBody() + } else { + requestLayout() + } + updateResponsiveGeometry(width) + } + if (floatingStyleChanged) { + floatingWidthPercent = persistedFloatingWidth + floatingOpacityPercent = persistedFloatingOpacity + listener.onFloatingStyleChanged( + floatingWidthPercent, + floatingOpacityPercent, + ) + } if (visualChanged) applyTheme() } + fun isCandidateInteractionActive(): Boolean = + ::candidateBarController.isInitialized && + candidateBarController.isInteractionActive() + + fun confirmCandidateDeletion(candidate: String, onConfirm: () -> Unit) { + if (::candidateBarController.isInitialized) { + candidateBarController.confirmCandidateDeletion(candidate, onConfirm) + } + } + + fun showClearUndo() { + if (::topZone.isInitialized) topZone.showUndoClear() + } + + fun hideClearUndo(discardSnapshot: Boolean = false) { + if (::topZone.isInitialized) topZone.hideUndoClear(discardSnapshot) + } + fun setAppearance(newAppearance: ImeAppearance) { if (appearance == newAppearance) return appearance = newAppearance @@ -1706,6 +1861,7 @@ open class ImeKeyboardView( composition = composition, onCommit = listener::onCharacter, onFeedback = ::feedback, + onRailChanged = ::applyThemeToSubtree, ).also { nineKeySymbolRailController = it } } @@ -1759,8 +1915,9 @@ open class ImeKeyboardView( MotionEvent.ACTION_MOVE -> { val index = event.findPointerIndex(spaceVoiceGestureController.pointerId) if (index < 0) return handled + val pointerX = event.rawX + event.getX(index) - event.x val pointerY = event.rawY + event.getY(index) - event.y - spaceVoiceGestureController.move(pointerY) + spaceVoiceGestureController.move(pointerX, pointerY) } MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL, MotionEvent.ACTION_POINTER_UP -> { if (event.actionMasked == MotionEvent.ACTION_POINTER_UP && @@ -1899,6 +2056,7 @@ open class ImeKeyboardView( .putExtra(QuickPhraseEditActivity.EXTRA_ID, phrase?.id ?: 0L) .putExtra(QuickPhraseEditActivity.EXTRA_CATEGORY, phrase?.category.orEmpty()) .putExtra(QuickPhraseEditActivity.EXTRA_TEXT, phrase?.text.orEmpty()) + .putExtra(QuickPhraseEditActivity.EXTRA_INPUT_CODE, phrase?.inputCode.orEmpty()) context.startActivity(intent) } @@ -1916,7 +2074,7 @@ open class ImeKeyboardView( private fun sectionTitle(textValue: String): TextView = TextView(context).apply { text = textValue - textSize = 12f + textSize = ImeTypographyTokens.TITLE_SP includeFontPadding = false setPadding(dp(4), dp(2), 0, dp(8)) tag = "panel-section-title" @@ -2078,12 +2236,18 @@ open class ImeKeyboardView( preferredSuffix: String? = null, cursorPosition: Int? = null, ) { + val resolveStartedAt = SystemClock.elapsedRealtimeNanos() val resolution = requireCandidateProvider().resolveNineKey( digits = digits, segmentPrefix = lastNineSegmentPrefix, preferredSuffix = preferredSuffix, fuzzy = fuzzyEnabled, ) + NineKeyPerformanceTrace.recordResolve( + digitLength = digits.length, + elapsedNs = SystemClock.elapsedRealtimeNanos() - resolveStartedAt, + threadName = Thread.currentThread().name, + ) val preview = resolution.preview val pinyinPaths = resolution.pinyinPaths val candidates = resolution.candidates @@ -2392,7 +2556,11 @@ open class ImeKeyboardView( text = text, secondary = if (func) null else secondary, iconRes = iconRes, - mainTextSize = mainTextSizeOverride ?: (if (func) 15f else 20f), + mainTextSize = mainTextSizeOverride ?: if (func) { + ImeTypographyTokens.BODY_SP + } else { + ImeTypographyTokens.KEY_LETTER_SP + }, fitMainText = func || text.length > 2, ).apply { tag = "key:$text" @@ -2453,7 +2621,7 @@ open class ImeKeyboardView( private fun title(text: String, small: Boolean = false) = TextView(context).apply { this.text = text - textSize = if (small) 12f else 16f + textSize = if (small) ImeTypographyTokens.BODY_SP else ImeTypographyTokens.TITLE_SP setPadding(0, 0, 0, dp(4)) } @@ -2480,15 +2648,15 @@ open class ImeKeyboardView( protected fun applyTheme() { val t = currentThemeTokens() setBackgroundColor(t.keyboardBackground) - mainDock.setBackgroundColor(t.expandedBackground) + mainDock.setBackgroundColor(t.keyboardBackground) keyboardBody.setBackgroundColor(t.keyboardBackground) topZone.setBackgroundColor(t.toolbarBackground) expandedPanel.setBackgroundColor(t.expandedBackground) candidateOverlay.setBackgroundColor(t.expandedBackground) themeApplier.apply(this, t) composition.setTextColor(t.keySecondaryText) - topZone.candidateExpandButton.setTextColor(t.keySecondaryText) - topZone.candidateEmojiButton.setTextColor(t.keySecondaryText) + topZone.candidateExpandButton.imageTintList = android.content.res.ColorStateList.valueOf(t.keySecondaryText) + topZone.candidateEmojiButton.imageTintList = android.content.res.ColorStateList.valueOf(t.keySecondaryText) floatingKeyboardController.applyTheme(t) inlineVoicePresenter.refreshPalette() } diff --git a/app/src/main/java/llc/slacker/openime/ImePanelRenderer.kt b/app/src/main/java/llc/slacker/openime/ImePanelRenderer.kt index aaabe335..221efa85 100644 --- a/app/src/main/java/llc/slacker/openime/ImePanelRenderer.kt +++ b/app/src/main/java/llc/slacker/openime/ImePanelRenderer.kt @@ -2,6 +2,7 @@ package llc.slacker.openime import android.content.Context import android.content.Intent +import android.graphics.Typeface import android.os.Build import android.view.Gravity import android.view.View @@ -91,7 +92,7 @@ internal class ImePanelRenderer( val row = LinearLayout(context).apply { orientation = LinearLayout.HORIZONTAL } chunk.forEach { (modeValue, label) -> row.addView( - createKey(label, true, 13f) { onModeSelected(modeValue) }.apply { + createKey(label, true, ImeTypographyTokens.BODY_SP) { onModeSelected(modeValue) }.apply { val selected = currentMode() == modeValue tag = if (selected) "tab-active" else "keyboard-choice" contentDescription = "$label,${if (selected) "已选中" else "未选中"}" @@ -253,7 +254,7 @@ internal class ImePanelRenderer( ).forEach { (button, label) -> button ?: return@forEach button.isEnabled = hasStrokes - button.alpha = if (hasStrokes) 1f else 0.42f + button.alpha = if (hasStrokes) 1f else ImeSurfacePolicy.DISABLED_ALPHA button.contentDescription = if (hasStrokes) label else "$label(暂无笔画)" if (Build.VERSION.SDK_INT >= 30) { button.stateDescription = if (hasStrokes) "可用" else "不可用" @@ -274,7 +275,7 @@ internal class ImePanelRenderer( is HandwritingResult.Success -> { result.candidates.forEach { candidate -> candidateRow.addView( - createKey(candidate, false, 15f) { onCharacter(candidate) }, + createKey(candidate, false, ImeTypographyTokens.TITLE_SP) { onCharacter(candidate) }, wrapParams(), ) } @@ -295,8 +296,8 @@ internal class ImePanelRenderer( val actions = LinearLayout(context).apply { orientation = LinearLayout.HORIZONTAL } - undoButton = createKey("撤销", true, 13f) { pad.undo() } - clearButton = createKey("清空", true, 13f) { pad.clear() } + undoButton = createKey("撤销", true, ImeTypographyTokens.BODY_SP) { pad.undo() } + clearButton = createKey("清空", true, ImeTypographyTokens.BODY_SP) { pad.clear() } actions.addView( undoButton, LinearLayout.LayoutParams(0, toPx(ImeGeometryTokens.TOUCH_TARGET_DP), 1f).apply { marginEnd = toPx(6) }, @@ -306,7 +307,7 @@ internal class ImePanelRenderer( LinearLayout.LayoutParams(0, toPx(ImeGeometryTokens.TOUCH_TARGET_DP), 1f).apply { marginEnd = toPx(6) }, ) actions.addView( - createKey("空格", true, 13f, onSpace), + createKey("空格", true, ImeTypographyTokens.BODY_SP, onSpace), LinearLayout.LayoutParams(0, toPx(ImeGeometryTokens.TOUCH_TARGET_DP), 1f), ) refreshStrokeActions(false) @@ -442,7 +443,7 @@ internal class ImePanelRenderer( if (symbolCategory == "自定义") { body.addView( - createPanelButton("管理自定义符号", 12f, true).apply { + createPanelButton("管理自定义符号", ImeTypographyTokens.BODY_SP, true).apply { contentDescription = "管理自定义符号" setOnClickListener { onFeedback() @@ -482,7 +483,7 @@ internal class ImePanelRenderer( createKey( symbol, false, - if (symbol.length > 2) 12f else 17f, + ImeTypographyTokens.BODY_SP, ) { onSymbolSelected(symbol) }, gridCellParams(48, 6, 6), ) @@ -607,6 +608,7 @@ internal class ImePanelRenderer( minimumHeight = toPx(ImeGeometryTokens.TOUCH_TARGET_DP) setPadding(toPx(10), 0, toPx(10), 0) tag = if (active) "tab-active" else "panel-tab" + typeface = if (active) Typeface.DEFAULT_BOLD else Typeface.DEFAULT contentDescription = "$label,${if (active) "已选中" else "未选中"}" if (Build.VERSION.SDK_INT >= 30) { stateDescription = if (active) "已选中" else "未选中" diff --git a/app/src/main/java/llc/slacker/openime/ImeSettingsActivity.kt b/app/src/main/java/llc/slacker/openime/ImeSettingsActivity.kt index 40a07e4c..edece6d3 100644 --- a/app/src/main/java/llc/slacker/openime/ImeSettingsActivity.kt +++ b/app/src/main/java/llc/slacker/openime/ImeSettingsActivity.kt @@ -1,6 +1,7 @@ package llc.slacker.openime import android.app.Activity +import android.content.Intent import android.os.Build import android.os.Bundle import android.view.View @@ -196,4 +197,21 @@ class ImeSettingsActivity : Activity(), ImeKeyboardView.Listener { refreshWindowChrome() refreshLiveIme() } + override fun onHandednessChanged(handedness: ImeHandedness) { + ImeSettingsRepository.saveHandedness(this, handedness) + refreshLiveIme() + } + override fun onKeyboardHeightChanged(percent: Int) { + ImeSettingsRepository.saveKeyboardHeightPercent(this, percent) + refreshLiveIme() + } + override fun onFloatingStyleChanged(widthPercent: Int, opacityPercent: Int) { + ImeSettingsRepository.saveFloatingWidthPercent(this, widthPercent) + ImeSettingsRepository.saveFloatingOpacityPercent(this, opacityPercent) + refreshLiveIme() + } + + override fun onOpenAboutData() { + startActivity(Intent(this, AboutDataActivity::class.java)) + } } diff --git a/app/src/main/java/llc/slacker/openime/ImeSettingsRepository.kt b/app/src/main/java/llc/slacker/openime/ImeSettingsRepository.kt index 49e13220..d407ca7f 100644 --- a/app/src/main/java/llc/slacker/openime/ImeSettingsRepository.kt +++ b/app/src/main/java/llc/slacker/openime/ImeSettingsRepository.kt @@ -2,6 +2,12 @@ package llc.slacker.openime import android.content.Context +enum class ImeHandedness(val label: String) { + STANDARD("标准"), + LEFT("左手"), + RIGHT("右手"), +} + /** Lightweight persistent IME settings. */ object ImeSettingsRepository { @@ -17,6 +23,10 @@ object ImeSettingsRepository { private const val KEY_SKIN_FONT = "skin_font" private const val KEY_SKIN_COLOR = "skin_color" private const val KEY_PREFERRED_CHINESE_MODE = "preferred_chinese_mode" + private const val KEY_HANDEDNESS = "handedness" + private const val KEY_KEYBOARD_HEIGHT = "keyboard_height_percent" + private const val KEY_FLOATING_WIDTH = "floating_width_percent" + private const val KEY_FLOATING_OPACITY = "floating_opacity_percent" /** * The user's preferred Chinese layout (26-key vs 9-key). Only PINYIN_26 and @@ -38,19 +48,63 @@ object ImeSettingsRepository { .edit().putString(KEY_PREFERRED_CHINESE_MODE, mode.name).apply() } - /** - * Every ImeTheme ships a complete token set in ImeDesignTokens, but the - * getter used to hard-code IOS, which made four finished skins unreachable - * and made a persisted choice impossible to restore. - */ - fun loadTheme(context: Context): ImeTheme = + fun loadHandedness(context: Context): ImeHandedness = runCatching { - ImeTheme.valueOf( + ImeHandedness.valueOf( context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) - .getString(KEY_THEME, ImeTheme.IOS.name) - ?: ImeTheme.IOS.name, + .getString(KEY_HANDEDNESS, ImeHandedness.STANDARD.name) + ?: ImeHandedness.STANDARD.name, ) - }.getOrDefault(ImeTheme.IOS) + }.getOrDefault(ImeHandedness.STANDARD) + + fun saveHandedness(context: Context, handedness: ImeHandedness) { + context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) + .edit().putString(KEY_HANDEDNESS, handedness.name).apply() + } + + fun loadKeyboardHeightPercent(context: Context): Int = + context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) + .getInt(KEY_KEYBOARD_HEIGHT, 100) + .coerceIn(92, 120) + + fun saveKeyboardHeightPercent(context: Context, percent: Int) { + context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) + .edit().putInt(KEY_KEYBOARD_HEIGHT, percent.coerceIn(92, 120)).apply() + } + + fun loadFloatingWidthPercent(context: Context): Int = + context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) + .getInt(KEY_FLOATING_WIDTH, 88) + .coerceIn(72, 96) + + fun saveFloatingWidthPercent(context: Context, percent: Int) { + context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) + .edit().putInt(KEY_FLOATING_WIDTH, percent.coerceIn(72, 96)).apply() + } + + fun loadFloatingOpacityPercent(context: Context): Int = + context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) + .getInt(KEY_FLOATING_OPACITY, 100) + .coerceIn(82, 100) + + fun saveFloatingOpacityPercent(context: Context, percent: Int) { + context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) + .edit().putInt(KEY_FLOATING_OPACITY, percent.coerceIn(82, 100)).apply() + } + + internal fun parseTheme(value: String?): ImeTheme = + runCatching { ImeTheme.valueOf(value ?: ImeTheme.IOS.name) } + .getOrDefault(ImeTheme.IOS) + + /** + * Historical theme names can still exist in SharedPreferences after an + * upgrade. They intentionally fall back to the only supported skin. + */ + fun loadTheme(context: Context): ImeTheme = + parseTheme( + context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) + .getString(KEY_THEME, ImeTheme.IOS.name), + ) fun saveTheme(context: Context, theme: ImeTheme) { context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) @@ -133,38 +187,3 @@ object ImeSettingsRepository { .apply() } } - - -object AccentPalette { - const val DEFAULT = "#1D9BF0" - val presets = listOf( - "#1D9BF0" to "蓝色", - "#FFD400" to "黄色", - "#F91880" to "粉色", - "#7856FF" to "紫色", - "#FF7A00" to "橙色", - "#00BA7C" to "绿色", - "#00C2D7" to "青色", - "#38BDF8" to "天蓝", - "#5865F2" to "靛蓝", - "#9B5DE5" to "深紫", - "#E94FB8" to "洋红", - "#F4212E" to "红色", - "#FF5A5F" to "珊瑚红", - "#F59E0B" to "琥珀", - "#84CC16" to "青柠", - "#22C55E" to "翠绿", - "#10CFA0" to "薄荷", - "#14B8A6" to "蓝绿", - ) - - fun parse(value: String?): Int = runCatching { - android.graphics.Color.parseColor(normalize(value)) - }.getOrDefault(android.graphics.Color.parseColor(DEFAULT)) - - fun normalize(value: String?): String { - val raw = value.orEmpty().trim() - val hex = if (raw.startsWith("#")) raw else "#$raw" - return if (hex.matches(Regex("#?[0-9a-fA-F]{6}"))) hex.uppercase() else DEFAULT - } -} diff --git a/app/src/main/java/llc/slacker/openime/ImeThemeApplier.kt b/app/src/main/java/llc/slacker/openime/ImeThemeApplier.kt index 6164f2b1..a009fd97 100644 --- a/app/src/main/java/llc/slacker/openime/ImeThemeApplier.kt +++ b/app/src/main/java/llc/slacker/openime/ImeThemeApplier.kt @@ -2,6 +2,8 @@ package llc.slacker.openime import android.content.res.ColorStateList import android.graphics.Color +import android.graphics.drawable.InsetDrawable +import android.graphics.drawable.LayerDrawable import android.view.View import android.view.ViewGroup import android.widget.FrameLayout @@ -83,14 +85,20 @@ internal class ImeThemeApplier( else -> t.keyBackground } val pressedColor = when { - primary -> ImeDrawableFactory.dim(color, 0.88f) - side -> ImeDrawableFactory.dim(t.sideKeyBackground, 0.88f) - function -> ImeDrawableFactory.dim(t.functionKeyBackground, 0.88f) - else -> t.keyPressedBackground + primary -> ImeSurfacePolicy.primaryPressed(t) + side -> ImeSurfacePolicy.pressedSurface(t.sideKeyBackground, t) + function -> ImeSurfacePolicy.pressedSurface(t.functionKeyBackground, t) + else -> ImeSurfacePolicy.pressedSurface(color, t) } view.applyMainTextScale(keyMainTextScale()) - view.background = statefulRounded(color, pressedColor, skinRadiusPx()) + val keyFace = statefulRounded(color, pressedColor, skinRadiusPx()) + val keyEdge = ImeDrawableFactory.rounded(pressedColor, skinRadiusPx()) + val keyCap = LayerDrawable(arrayOf(keyEdge, keyFace)).apply { + setLayerInset(1, 0, 0, 0, toPx(1).coerceAtLeast(1)) + } + val halfGap = toPx(ImeGeometryTokens.KEY_GAP_DP) / 2 + view.background = InsetDrawable(keyCap, halfGap, halfGap, halfGap, halfGap) view.background?.alpha = skinOpacity().coerceIn(70, 100) * 255 / 100 view.elevation = 0f @@ -118,23 +126,24 @@ internal class ImeThemeApplier( private fun applyLinearLayout(view: LinearLayout, t: ImeTheme.Tokens) { when (view.tag) { "candidate-first-row" -> { + val selected = ImeSurfacePolicy.selectedSurface(t) view.background = statefulRounded( - t.keyBackground, - t.keyPressedBackground, + selected, + ImeSurfacePolicy.pressedSurface(selected, t), toPx(ImeGeometryTokens.KEY_RADIUS_DP), ) } "candidate-row" -> { view.background = statefulRounded( Color.TRANSPARENT, - t.keyPressedBackground, + ImeSurfacePolicy.pressedSurface(t.candidateBackground, t), toPx(ImeGeometryTokens.KEY_RADIUS_DP), ) } "setting-row" -> if (view.isClickable) { view.background = statefulRounded( Color.TRANSPARENT, - t.keyPressedBackground, + ImeSurfacePolicy.pressedSurface(t.toolCardBackground, t), toPx(ImeGeometryTokens.CONTROL_RADIUS_DP), ) } @@ -150,18 +159,32 @@ internal class ImeThemeApplier( view.background = ImeDrawableFactory.rounded( t.toolCardBackground, toPx(ImeGeometryTokens.CARD_RADIUS_DP), + ImeSurfacePolicy.divider(t), + toPx(1).coerceAtLeast(1), ) } "clip-card" -> { view.background = ImeDrawableFactory.rounded( t.toolCardBackground, toPx(ImeGeometryTokens.CONTROL_RADIUS_DP), + ImeSurfacePolicy.divider(t), + toPx(1).coerceAtLeast(1), ) } "panel-head" -> { view.background = ImeDrawableFactory.rounded( t.panelHeadBackground, toPx(ImeGeometryTokens.CARD_RADIUS_DP), + ImeSurfacePolicy.divider(t), + toPx(1).coerceAtLeast(1), + ) + } + "toolbar-row" -> view.setBackgroundColor(t.toolbarBackground) + "compose-zone" -> view.setBackgroundColor(t.candidateBackground) + "candidate-field" -> { + view.background = ImeDrawableFactory.rounded( + t.candidateBackground, + toPx(ImeGeometryTokens.CONTROL_RADIUS_DP), ) } else -> { @@ -171,7 +194,7 @@ internal class ImeThemeApplier( ) { view.background = statefulRounded( t.toolCardBackground, - t.keyPressedBackground, + ImeSurfacePolicy.pressedSurface(t.toolCardBackground, t), toPx(ImeGeometryTokens.CONTROL_RADIUS_DP), ) } @@ -185,7 +208,7 @@ internal class ImeThemeApplier( ) { view.background = statefulRounded( t.toolCardBackground, - t.keyPressedBackground, + ImeSurfacePolicy.pressedSurface(t.toolCardBackground, t), toPx(ImeGeometryTokens.CONTROL_RADIUS_DP), ) } @@ -202,36 +225,26 @@ internal class ImeThemeApplier( private fun applyImageView(view: ImageView, t: ImeTheme.Tokens) { when { + view.tag == "candidate-emoji" || view.tag == "candidate-expand" -> { + view.imageTintList = ColorStateList.valueOf(t.keySecondaryText) + view.background = statefulRounded( + Color.TRANSPARENT, + ImeSurfacePolicy.pressedSurface(t.candidateBackground, t), + toPx(ImeGeometryTokens.KEY_RADIUS_DP), + ) + } view.tag == "setting-icon" -> { - val icon = t.primary - view.imageTintList = ColorStateList.valueOf(icon) - val dark = - ImeDrawableFactory.contrastText(t.keyboardBackground) == - Color.WHITE + view.imageTintList = ColorStateList.valueOf(t.primary) view.background = ImeDrawableFactory.rounded( - if (dark) { - Color.argb( - 42, - Color.red(icon), - Color.green(icon), - Color.blue(icon), - ) - } else { - Color.argb( - 24, - Color.red(icon), - Color.green(icon), - Color.blue(icon), - ) - }, + ImeSurfacePolicy.subtleAccentSurface(t), toPx(ImeGeometryTokens.KEY_RADIUS_DP), ) } view.tag == "key-panel-back" -> { - view.imageTintList = ColorStateList.valueOf(t.keyText) + view.imageTintList = ColorStateList.valueOf(t.keySecondaryText) view.background = statefulRounded( t.panelHeadBackground, - ImeDrawableFactory.dim(t.panelHeadBackground), + ImeSurfacePolicy.pressedSurface(t.panelHeadBackground, t), toPx(ImeGeometryTokens.CONTROL_RADIUS_DP), ) } @@ -239,11 +252,11 @@ internal class ImeThemeApplier( view.parent is LinearLayout && (view.parent as LinearLayout).tag == "toolbar-row" ) || hasAncestorTag(view, "tools-panel") -> { - view.imageTintList = ColorStateList.valueOf(t.keyText) + view.imageTintList = ColorStateList.valueOf(t.keySecondaryText) if (view.isClickable) { view.background = statefulRounded( Color.TRANSPARENT, - t.keyPressedBackground, + ImeSurfacePolicy.pressedSurface(t.toolbarBackground, t), toPx(ImeGeometryTokens.CONTROL_RADIUS_DP), ) } @@ -268,15 +281,30 @@ internal class ImeThemeApplier( tag == "backspace-clear-hint" -> view.setTextColor(t.keySecondaryText) + tag == "undo-clear-action" -> { + view.setTextColor(t.primary) + view.background = statefulRounded( + Color.TRANSPARENT, + ImeSurfacePolicy.subtleAccentSurface(t), + toPx(ImeGeometryTokens.CONTROL_RADIUS_DP), + ) + } + tag == "candidate-first" -> view.setTextColor(t.keyText) tag == "candidate-word" -> view.setTextColor(t.candidateText) - tag == "panel-note" -> + tag == "panel-note" || + tag == "setting-value" || + tag == "setting-chevron" || + tag == "panel-section-title" -> view.setTextColor(t.keySecondaryText) + tag == "setting-label" -> + view.setTextColor(t.keyText) + tag == "panel-error" -> { val error = t.destructive view.setTextColor(error) @@ -292,10 +320,11 @@ internal class ImeThemeApplier( } tag == "tab-active" -> { - view.setTextColor(ImeDrawableFactory.contrastText(t.primary)) + val selected = ImeSurfacePolicy.selectedSurface(t) + view.setTextColor(ImeSurfacePolicy.selectedText(t)) view.background = statefulRounded( - t.primary, - ImeDrawableFactory.dim(t.primary), + selected, + ImeSurfacePolicy.pressedSurface(selected, t), toPx(ImeGeometryTokens.CONTROL_RADIUS_DP), ) } @@ -304,7 +333,7 @@ internal class ImeThemeApplier( view.setTextColor(t.keySecondaryText) view.background = statefulRounded( t.panelHeadBackground, - ImeDrawableFactory.dim(t.panelHeadBackground), + ImeSurfacePolicy.pressedSurface(t.panelHeadBackground, t), toPx(ImeGeometryTokens.CONTROL_RADIUS_DP), ) } @@ -313,7 +342,7 @@ internal class ImeThemeApplier( view.setTextColor(ImeDrawableFactory.contrastText(t.primary)) view.background = statefulRounded( t.primary, - ImeDrawableFactory.dim(t.primary), + ImeSurfacePolicy.primaryPressed(t), toPx(ImeGeometryTokens.CONTROL_RADIUS_DP), ) } @@ -322,12 +351,11 @@ internal class ImeThemeApplier( tag == "clipboard-refresh" || tag?.startsWith("clip-pin:") == true || tag?.startsWith("clip-use:") == true || - tag?.startsWith("phrase-edit:") == true || - tag?.startsWith("phrase-delete:") == true -> { + tag?.startsWith("phrase-edit:") == true -> { view.setTextColor(t.keyText) view.background = statefulRounded( t.panelHeadBackground, - ImeDrawableFactory.dim(t.panelHeadBackground), + ImeSurfacePolicy.pressedSurface(t.panelHeadBackground, t), toPx(ImeGeometryTokens.CONTROL_RADIUS_DP), ) } @@ -336,18 +364,19 @@ internal class ImeThemeApplier( view.setTextColor(t.keyText) view.background = statefulRounded( t.panelHeadBackground, - ImeDrawableFactory.dim(t.panelHeadBackground), + ImeSurfacePolicy.pressedSurface(t.panelHeadBackground, t), toPx(ImeGeometryTokens.CONTROL_RADIUS_DP), ) } - tag == "clipboard-retention-destructive" -> { - view.setTextColor( - ImeDrawableFactory.contrastText(t.destructive), - ) + tag == "clipboard-retention-destructive" || + tag == "clipboard-clear-confirm" || + tag?.startsWith("phrase-delete:") == true -> { + val destructiveSurface = ImeSurfacePolicy.destructiveSurface(t) + view.setTextColor(ImeSurfacePolicy.destructiveText(t)) view.background = statefulRounded( - t.destructive, - ImeDrawableFactory.dim(t.destructive, 0.86f), + destructiveSurface, + ImeSurfacePolicy.pressedSurface(destructiveSurface, t), toPx(ImeGeometryTokens.CONTROL_RADIUS_DP), ) } @@ -357,16 +386,26 @@ internal class ImeThemeApplier( view.setTextColor(t.keyText) view.background = statefulRounded( Color.TRANSPARENT, - t.keyPressedBackground, + ImeSurfacePolicy.pressedSurface(t.sideKeyBackground, t), + toPx(ImeGeometryTokens.KEY_RADIUS_DP), + ) + } + + tag == "nine-pinyin-path-selected" -> { + val selected = ImeSurfacePolicy.selectedSurface(t) + view.setTextColor(ImeSurfacePolicy.selectedText(t)) + view.background = statefulRounded( + selected, + ImeSurfacePolicy.pressedSurface(selected, t), toPx(ImeGeometryTokens.KEY_RADIUS_DP), ) } tag == "nine-pinyin-path-filter" -> { - view.setTextColor(ImeDrawableFactory.contrastText(t.primary)) + view.setTextColor(t.sideKeyText) view.background = statefulRounded( - t.primary, - ImeDrawableFactory.dim(t.primary, 0.86f), + Color.TRANSPARENT, + ImeSurfacePolicy.pressedSurface(t.sideKeyBackground, t), toPx(ImeGeometryTokens.KEY_RADIUS_DP), ) } @@ -386,13 +425,13 @@ internal class ImeThemeApplier( view.background = if (customSelected) { statefulRounded( t.primary, - ImeDrawableFactory.dim(t.primary), + ImeSurfacePolicy.primaryPressed(t), toPx(ImeGeometryTokens.CONTROL_RADIUS_DP), ) } else { statefulRounded( t.panelHeadBackground, - ImeDrawableFactory.dim(t.panelHeadBackground), + ImeSurfacePolicy.pressedSurface(t.panelHeadBackground, t), toPx(ImeGeometryTokens.CONTROL_RADIUS_DP), ) } @@ -411,7 +450,7 @@ internal class ImeThemeApplier( view.setTextColor(t.keyText) view.background = statefulRounded( t.panelHeadBackground, - ImeDrawableFactory.dim(t.panelHeadBackground), + ImeSurfacePolicy.pressedSurface(t.panelHeadBackground, t), toPx(ImeGeometryTokens.CONTROL_RADIUS_DP), ) } @@ -422,8 +461,8 @@ internal class ImeThemeApplier( tag == "candidate-emoji" || tag == "candidate-expand" -> { view.setTextColor(t.keySecondaryText) view.background = statefulRounded( - t.panelHeadBackground, - ImeDrawableFactory.dim(t.panelHeadBackground), + Color.TRANSPARENT, + ImeSurfacePolicy.pressedSurface(t.candidateBackground, t), toPx(ImeGeometryTokens.KEY_RADIUS_DP), ) } @@ -443,7 +482,7 @@ internal class ImeThemeApplier( view.setTextColor(t.candidateText) view.background = statefulRounded( t.toolCardBackground, - ImeDrawableFactory.dim(t.toolCardBackground), + ImeSurfacePolicy.pressedSurface(t.toolCardBackground, t), toPx(ImeGeometryTokens.CONTROL_RADIUS_DP), ) } @@ -452,12 +491,12 @@ internal class ImeThemeApplier( view.setTextColor(t.keySecondaryText) tag == "voice-mic" -> { - view.setTextColor( - ImeDrawableFactory.contrastText(t.primary), - ) + val onAccent = ImeDrawableFactory.contrastText(t.primary) + view.setTextColor(onAccent) + view.compoundDrawableTintList = ColorStateList.valueOf(onAccent) view.background = statefulRounded( t.primary, - ImeDrawableFactory.dim(t.primary, 0.88f), + ImeSurfacePolicy.primaryPressed(t), toPx(ImeGeometryTokens.CONTROL_RADIUS_DP), ) } @@ -479,14 +518,14 @@ internal class ImeThemeApplier( "emoji-cell" -> { view.background = statefulRounded( Color.TRANSPARENT, - t.keyPressedBackground, + ImeSurfacePolicy.pressedSurface(t.sideKeyBackground, t), toPx(ImeGeometryTokens.KEY_RADIUS_DP), ) } "accent-swatch" -> { view.background = statefulRounded( Color.TRANSPARENT, - t.keyPressedBackground, + ImeSurfacePolicy.pressedSurface(t.sideKeyBackground, t), toPx(ImeGeometryTokens.KEY_RADIUS_DP), ) } @@ -517,8 +556,20 @@ internal class ImeThemeApplier( toPx(ImeGeometryTokens.PILL_RADIUS_DP), ) } + "toggle-knob" -> { + val parent = view.parent as? FrameLayout + val seed = parent?.contentDescription?.toString() + ?.substringBefore(',') + .orEmpty() + val trackColor = + if (toggleState(seed)) t.primary else t.panelHeadBackground + view.background = ImeDrawableFactory.rounded( + ImeDrawableFactory.contrastText(trackColor), + toPx(ImeGeometryTokens.PILL_RADIUS_DP), + ) + } "setting-divider" -> - view.setBackgroundColor(t.border) + view.setBackgroundColor(ImeSurfacePolicy.divider(t)) } } diff --git a/app/src/main/java/llc/slacker/openime/ImeTopZone.kt b/app/src/main/java/llc/slacker/openime/ImeTopZone.kt index 3adc4f10..a7f5bd70 100644 --- a/app/src/main/java/llc/slacker/openime/ImeTopZone.kt +++ b/app/src/main/java/llc/slacker/openime/ImeTopZone.kt @@ -42,6 +42,8 @@ internal class ImeTopZone( onHideKeyboard: () -> Unit, onTools: () -> Unit, onExpandCandidates: () -> Unit, + private val onUndoClear: () -> Unit, + private val onUndoClearExpired: () -> Unit, ) : LinearLayout(context) { val toolbarRow = LinearLayout(context) val composeZone = LinearLayout(context) @@ -50,12 +52,19 @@ internal class ImeTopZone( val candidateRow = LinearLayout(context) val candidateScroll = HorizontalScrollView(context) val associationRow = LinearLayout(context) - val candidateExpandButton = TextView(context) - val candidateEmojiButton = TextView(context) + val candidateExpandButton = ImageView(context) + val candidateEmojiButton = ImageView(context) val voiceInlineZone = LinearLayout(context) val voiceInlineIcon = ImageView(context) val voiceInlineStatus = TextView(context) val voiceInlineWaves = mutableListOf() + val undoClearAction = TextView(context) + private val hideUndoClearRunnable = Runnable { + if (undoClearAction.visibility == View.VISIBLE) { + undoClearAction.visibility = View.GONE + onUndoClearExpired() + } + } init { tag = "ime_toolbar" @@ -112,6 +121,32 @@ internal class ImeTopZone( 1f, ).apply { marginStart = toPx(4) }, ) + undoClearAction.apply { + tag = "undo-clear-action" + text = "撤销" + textSize = ImeTypographyTokens.BODY_SP + gravity = Gravity.CENTER + includeFontPadding = false + minWidth = toPx(ImeGeometryTokens.TOUCH_TARGET_DP) + minimumHeight = toPx(ImeGeometryTokens.TOUCH_TARGET_DP) + visibility = View.GONE + isClickable = true + isFocusable = true + contentDescription = "撤销清空" + setOnClickListener { + removeCallbacks(hideUndoClearRunnable) + visibility = View.GONE + onFeedback() + onUndoClear() + } + } + toolbarRow.addView( + undoClearAction, + LinearLayout.LayoutParams( + toPx(64), + toPx(ImeGeometryTokens.TOUCH_TARGET_DP), + ), + ) toolbarRow.addView( toolbarIcon(R.drawable.ic_keyboard_hide, "收起键盘", "keyboard-hide", onHideKeyboard), touchTargetParams(), @@ -136,7 +171,7 @@ internal class ImeTopZone( composition.apply { tag = "pinyin-composition-editor" contentDescription = "可编辑拼音预编辑" - textSize = 13f + textSize = ImeTypographyTokens.BODY_SP gravity = Gravity.CENTER_VERTICAL or Gravity.START inputType = InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_FLAG_NO_SUGGESTIONS setSingleLine(true) @@ -196,9 +231,8 @@ internal class ImeTopZone( candidateEmojiButton.apply { tag = "candidate-emoji" - text = "☺" - textSize = 17f - gravity = Gravity.CENTER + setImageResource(R.drawable.ic_emoji) + scaleType = ImageView.ScaleType.CENTER_INSIDE contentDescription = "表情" setPadding(toPx(7), 0, toPx(7), 0) isClickable = true @@ -212,9 +246,8 @@ internal class ImeTopZone( candidateExpandButton.apply { tag = "candidate-expand" - text = "⌄" - textSize = 15f - gravity = Gravity.CENTER + setImageResource(R.drawable.ic_chevron_down) + scaleType = ImageView.ScaleType.CENTER_INSIDE contentDescription = "展开更多候选" setPadding(toPx(7), 0, toPx(7), 0) isClickable = true @@ -270,7 +303,7 @@ internal class ImeTopZone( voiceInlineStatus.apply { tag = "voice-inline-status" text = "正在聆听…" - textSize = 14f + textSize = ImeTypographyTokens.BODY_SP setTextColor(Color.WHITE) includeFontPadding = false maxLines = 1 @@ -321,6 +354,20 @@ internal class ImeTopZone( ) } + fun showUndoClear() { + removeCallbacks(hideUndoClearRunnable) + undoClearAction.visibility = View.VISIBLE + undoClearAction.bringToFront() + postDelayed(hideUndoClearRunnable, CLEAR_UNDO_VISIBLE_MS) + } + + fun hideUndoClear(discardSnapshot: Boolean = false) { + removeCallbacks(hideUndoClearRunnable) + val wasVisible = undoClearAction.visibility == View.VISIBLE + undoClearAction.visibility = View.GONE + if (discardSnapshot && wasVisible) onUndoClearExpired() + } + fun setContentInset(contentInsetPx: Int) { toolbarRow.setPadding(contentInsetPx + toPx(10), 0, contentInsetPx + toPx(10), 0) composition.setPadding(contentInsetPx + toPx(14), toPx(3), contentInsetPx + toPx(14), 0) @@ -365,4 +412,8 @@ internal class ImeTopZone( toPx(ImeGeometryTokens.TOUCH_TARGET_DP), toPx(ImeGeometryTokens.TOUCH_TARGET_DP), ) + + private companion object { + const val CLEAR_UNDO_VISIBLE_MS = 5_000L + } } diff --git a/app/src/main/java/llc/slacker/openime/InputConnectionGateway.kt b/app/src/main/java/llc/slacker/openime/InputConnectionGateway.kt index f3e1bded..1ff15afb 100644 --- a/app/src/main/java/llc/slacker/openime/InputConnectionGateway.kt +++ b/app/src/main/java/llc/slacker/openime/InputConnectionGateway.kt @@ -42,6 +42,7 @@ class InputConnectionGateway( private val context: Context?, private val connection: () -> InputConnection?, private val isPassword: () -> Boolean = { false }, + private val nowMs: () -> Long = { System.nanoTime() / 1_000_000L }, ) { data class CursorSnapshot( @@ -63,13 +64,44 @@ class InputConnectionGateway( } } + private data class ClearUndoSnapshot( + val text: String, + val selectionStart: Int, + val selectionEnd: Int, + val connection: InputConnection, + val expiresAtMs: Long, + ) + + private var clearUndoSnapshot: ClearUndoSnapshot? = null + + private fun invalidateClearUndo() { + clearUndoSnapshot = null + } + + fun discardClearUndo() { + invalidateClearUndo() + } + + fun hasClearUndo(): Boolean { + if (isPassword()) return false + val snapshot = clearUndoSnapshot ?: return false + val current = connection() ?: return false + if (snapshot.connection !== current || nowMs() > snapshot.expiresAtMs) { + invalidateClearUndo() + return false + } + return true + } + fun commitText(text: String) { if (text.isEmpty()) return + invalidateClearUndo() connection()?.commitText(text, 1) } fun setComposingText(text: String) { if (isPassword()) return + if (text.isNotEmpty()) invalidateClearUndo() val ic = connection() ?: return if (text.isEmpty()) { ic.finishComposingText() @@ -108,6 +140,7 @@ class InputConnectionGateway( } fun deleteBackwards() { + invalidateClearUndo() val ic = connection() ?: return if (deleteSelection()) return @@ -133,6 +166,7 @@ class InputConnectionGateway( /** Delete the active selection without falling back to one-character delete. */ fun deleteSelection(): Boolean { + invalidateClearUndo() val ic = connection() ?: return false if (isPassword()) { if (knownSelectionStart >= 0 && knownSelectionEnd >= 0 && knownSelectionStart != knownSelectionEnd) { @@ -188,6 +222,7 @@ class InputConnectionGateway( } fun deleteForwards() { + invalidateClearUndo() val ic = connection() ?: return if (deleteSelection()) return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { @@ -228,6 +263,7 @@ class InputConnectionGateway( fun clearAllText(): Boolean { if (isPassword()) return false val ic = connection() ?: return false + clearUndoSnapshot = null ic.beginBatchEdit() return try { val originalSelection = selectionBeforeDestructiveSelectAll(ic) @@ -241,6 +277,7 @@ class InputConnectionGateway( restoreSelectionAfterFailedClear(ic, originalSelection) } else { ic.finishComposingText() + rememberClearUndo(selected, originalSelection, ic) } return cleared } @@ -259,6 +296,7 @@ class InputConnectionGateway( restoreSelectionAfterFailedClear(ic, originalSelection) } else { ic.finishComposingText() + rememberClearUndo(selectedWindow.text, originalSelection, ic) } return cleared } @@ -278,6 +316,7 @@ class InputConnectionGateway( restoreSelectionAfterFailedClear(ic, originalSelection) } else { ic.finishComposingText() + rememberClearUndo(window.text, originalSelection, ic) } cleared } finally { @@ -285,11 +324,91 @@ class InputConnectionGateway( } } + /** + * Restore only the most recent successful full-document clear. Any normal + * edit invalidates the snapshot, and restoration is rejected unless the + * target document is still empty. + */ + fun restoreLastClear(): Boolean { + if (isPassword()) return false + val snapshot = clearUndoSnapshot ?: return false + val ic = connection() ?: return false + if (snapshot.connection !== ic || nowMs() > snapshot.expiresAtMs) { + invalidateClearUndo() + return false + } + + val window = extractedWindow(ic) + val documentEmpty = if (window?.isCompleteDocument == true) { + window.text.isEmpty() + } else { + val before = runCatching { ic.getTextBeforeCursor(1, 0)?.isEmpty() == true } + .getOrDefault(false) + val after = runCatching { ic.getTextAfterCursor(1, 0)?.isEmpty() == true } + .getOrDefault(false) + val selected = runCatching { ic.getSelectedText(0)?.isEmpty() != false } + .getOrDefault(false) + before && after && selected + } + if (!documentEmpty) { + invalidateClearUndo() + return false + } + + ic.beginBatchEdit() + return try { + val committed = runCatching { ic.commitText(snapshot.text, 1) }.getOrDefault(false) + if (!committed) return false + ic.finishComposingText() + val start = snapshot.selectionStart.coerceIn(0, snapshot.text.length) + val end = snapshot.selectionEnd.coerceIn(0, snapshot.text.length) + runCatching { ic.setSelection(start, end) } + knownSelectionStart = start + knownSelectionEnd = end + invalidateClearUndo() + true + } finally { + ic.endBatchEdit() + } + } + + private fun rememberClearUndo( + text: String, + selection: SelectionSnapshot?, + ic: InputConnection, + ) { + if (text.isEmpty()) return + val start: Int + val end: Int + when (selection) { + is SelectionSnapshot.Absolute -> { + start = selection.start + end = selection.end + } + is SelectionSnapshot.Relative -> { + start = selection.cursor + end = selection.cursor + } + null -> { + start = text.length + end = text.length + } + } + clearUndoSnapshot = ClearUndoSnapshot( + text = text, + selectionStart = start.coerceIn(0, text.length), + selectionEnd = end.coerceIn(0, text.length), + connection = ic, + expiresAtMs = nowMs() + CLEAR_UNDO_TIMEOUT_MS, + ) + } + fun performEditorAction(action: Int) { connection()?.performEditorAction(action) } fun sendKeyDownUp(keyCode: Int) { + invalidateClearUndo() val ic = connection() ?: return sendKeyDownUp(ic, keyCode) } @@ -495,14 +614,17 @@ class InputConnectionGateway( clip.takeIf { it.itemCount > 0 }?.getItemAt(0)?.coerceToText(safeContext)?.toString().orEmpty() }.getOrDefault("") if (text.isEmpty()) return "" + invalidateClearUndo() val committed = connection()?.commitText(text, 1) == true if (!committed) return "" onPasted(clip) return text } - fun commitContent(info: InputContentInfo): Boolean = - connection()?.commitContent(info, 0, null) == true + fun commitContent(info: InputContentInfo): Boolean { + invalidateClearUndo() + return connection()?.commitContent(info, 0, null) == true + } private fun selectionSnapshot(): SelectionSnapshot? { if (isPassword()) return null @@ -605,5 +727,6 @@ class InputConnectionGateway( private companion object { const val FALLBACK_WINDOW_CHARS = 8_192 + const val CLEAR_UNDO_TIMEOUT_MS = 5_000L } } diff --git a/app/src/main/java/llc/slacker/openime/KeyPopupController.kt b/app/src/main/java/llc/slacker/openime/KeyPopupController.kt index cfe832f5..f3e75237 100644 --- a/app/src/main/java/llc/slacker/openime/KeyPopupController.kt +++ b/app/src/main/java/llc/slacker/openime/KeyPopupController.kt @@ -27,9 +27,27 @@ internal class KeyPopupController( private val feedback: () -> Unit, private val onSymbolSelected: (String) -> Unit, ) { + private val previewPopup = TextView(host.context).apply { + visibility = View.GONE + includeFontPadding = false + maxLines = 1 + ellipsize = TextUtils.TruncateAt.END + gravity = Gravity.CENTER + setPadding(dp(8), dp(6), dp(8), dp(6)) + elevation = dp(2).toFloat() + } private var popupView: View? = null private var keepAfterKeyUp = false + init { + host.addView( + previewPopup, + FrameLayout.LayoutParams(1, 1).apply { + gravity = Gravity.TOP or Gravity.START + }, + ) + } + fun show(anchor: View, text: String) { hide() keepAfterKeyUp = false @@ -45,23 +63,35 @@ internal class KeyPopupController( val popupHeight = dp( if (text == "清空") 36 else ImeGeometryTokens.KEY_POPUP_HEIGHT_DP, ) - val popup = TextView(host.context).apply { + val popupBackground = if (text == "清空") t.destructive else t.keyBackground + previewPopup.apply { this.text = text - textSize = if (text.length > 1) 13f else 16f - includeFontPadding = false - maxLines = 1 - ellipsize = TextUtils.TruncateAt.END - gravity = Gravity.CENTER - setPadding(dp(8), dp(6), dp(8), dp(6)) - val popupBackground = if (text == "清空") t.destructive else t.keyBackground + textSize = if (text.length > 1) { + ImeTypographyTokens.BODY_SP + } else { + ImeTypographyTokens.DISPLAY_SP + } setTextColor(if (text == "清空") contrastText(popupBackground) else t.keyText) - background = rounded(popupBackground, dp(ImeGeometryTokens.CONTROL_RADIUS_DP)) - elevation = dp(2).toFloat() + background = + if (text == "清空") { + rounded(popupBackground, dp(ImeGeometryTokens.CONTROL_RADIUS_DP)) + } else { + ImeDrawableFactory.rounded( + popupBackground, + dp(ImeGeometryTokens.CONTROL_RADIUS_DP), + ImeSurfacePolicy.divider(t), + dp(1).coerceAtLeast(1), + ) + } } - placeAbove(anchor, popup, popupWidth, popupHeight) - popupView = popup - animateIn(popup, popupWidth, popupHeight) + positionAttachedPopup(anchor, previewPopup, popupWidth, popupHeight) + previewPopup.visibility = View.VISIBLE + previewPopup.alpha = 1f + previewPopup.scaleX = 1f + previewPopup.scaleY = 1f + previewPopup.bringToFront() + popupView = previewPopup } fun showChoices(anchor: View, choices: List) { @@ -73,7 +103,12 @@ internal class KeyPopupController( orientation = LinearLayout.HORIZONTAL gravity = Gravity.CENTER setPadding(dp(5), dp(5), dp(5), dp(5)) - background = rounded(t.keyBackground, dp(ImeGeometryTokens.CONTROL_RADIUS_DP)) + background = ImeDrawableFactory.rounded( + t.keyBackground, + dp(ImeGeometryTokens.CONTROL_RADIUS_DP), + ImeSurfacePolicy.divider(t), + dp(1).coerceAtLeast(1), + ) elevation = dp(2).toFloat() contentDescription = "长按符号选择" } @@ -82,13 +117,13 @@ internal class KeyPopupController( row.addView( TextView(host.context).apply { text = symbol - textSize = 16f + textSize = ImeTypographyTokens.BODY_SP includeFontPadding = false gravity = Gravity.CENTER setTextColor(t.keyText) background = statefulRounded( Color.TRANSPARENT, - t.keyPressedBackground, + ImeSurfacePolicy.pressedSurface(t.keyBackground, t), dp(ImeGeometryTokens.KEY_RADIUS_DP), ) isClickable = true @@ -133,15 +168,45 @@ internal class KeyPopupController( } fun hide() { - popupView?.let { - it.animate().cancel() - host.removeView(it) + popupView?.let { popup -> + popup.animate().cancel() + if (popup === previewPopup) { + popup.visibility = View.GONE + } else { + host.removeView(popup) + } } popupView = null keepAfterKeyUp = false } + private fun positionAttachedPopup( + anchor: View, + popup: View, + popupWidth: Int, + popupHeight: Int, + ) { + val placement = placementAbove(anchor, popupWidth, popupHeight) + popup.layoutParams = FrameLayout.LayoutParams(popupWidth, popupHeight).apply { + gravity = Gravity.TOP or Gravity.START + leftMargin = placement.first + topMargin = placement.second + } + } + private fun placeAbove(anchor: View, popup: View, popupWidth: Int, popupHeight: Int) { + val placement = placementAbove(anchor, popupWidth, popupHeight) + host.addView( + popup, + FrameLayout.LayoutParams(popupWidth, popupHeight).apply { + gravity = Gravity.TOP or Gravity.START + leftMargin = placement.first + topMargin = placement.second + }, + ) + } + + private fun placementAbove(anchor: View, popupWidth: Int, popupHeight: Int): Pair { val anchorLocation = IntArray(2) val rootLocation = IntArray(2) anchor.getLocationOnScreen(anchorLocation) @@ -156,15 +221,7 @@ internal class KeyPopupController( val top = ( anchorTop - popupHeight - dp(ImeGeometryTokens.KEY_POPUP_VERTICAL_GAP_DP) ).coerceAtLeast(dp(4)) - - host.addView( - popup, - FrameLayout.LayoutParams(popupWidth, popupHeight).apply { - gravity = Gravity.TOP or Gravity.START - leftMargin = left - topMargin = top - }, - ) + return left to top } private fun animateIn(view: View, popupWidth: Int, popupHeight: Int) { diff --git a/app/src/main/java/llc/slacker/openime/KeyboardGeometry.kt b/app/src/main/java/llc/slacker/openime/KeyboardGeometry.kt index a94f03c6..34ea84b0 100644 --- a/app/src/main/java/llc/slacker/openime/KeyboardGeometry.kt +++ b/app/src/main/java/llc/slacker/openime/KeyboardGeometry.kt @@ -53,6 +53,7 @@ data class NormalizedBounds( internal data class KeyboardLayoutMetrics( val landscape: Boolean, val fontScale: Float, + val heightPercent: Int = 100, ) { val keyRowHeightDp: Int = run { val base = if (landscape) { @@ -63,7 +64,8 @@ internal data class KeyboardLayoutMetrics( val fontGrow = ((fontScale - 1f).coerceAtLeast(0f) * 12f) .toInt() .coerceAtMost(12) - base + fontGrow + val scaled = ((base + fontGrow) * heightPercent.coerceIn(92, 120) / 100f).toInt() + scaled.coerceAtLeast(if (landscape) 38 else 44) } val nineGridHeightDp: Int = @@ -82,7 +84,10 @@ internal data class KeyboardLayoutMetrics( keyRowHeightDp * 4 + ImeGeometryTokens.KEY_ROW_GAP_DP * 3 + 22 - maxOf(if (landscape) 264 else 302, derived) + val baseMinimum = if (landscape) 264 else 302 + val scaledMinimum = + (baseMinimum * heightPercent.coerceIn(92, 120) / 100f).toInt() + maxOf(scaledMinimum, derived) } val keyboardBodyHeightDp: Int = imeHeightDp - topZoneHeightDp diff --git a/app/src/main/java/llc/slacker/openime/LocalAudioVoiceBackend.kt b/app/src/main/java/llc/slacker/openime/LocalAudioVoiceBackend.kt index dd00d980..05bd6cf1 100644 --- a/app/src/main/java/llc/slacker/openime/LocalAudioVoiceBackend.kt +++ b/app/src/main/java/llc/slacker/openime/LocalAudioVoiceBackend.kt @@ -7,9 +7,11 @@ import android.content.pm.PackageManager import android.media.AudioFormat import android.media.AudioRecord import android.media.MediaRecorder +import android.os.SystemClock import android.util.Log import java.util.concurrent.atomic.AtomicBoolean import java.util.concurrent.Executors +import java.util.concurrent.TimeUnit import java.util.concurrent.atomic.AtomicLong import kotlin.math.sqrt @@ -179,6 +181,10 @@ class LocalAudioVoiceBackend( } private val startGeneration = AtomicLong(0L) private val stopRequestedGeneration = AtomicLong(-1L) + private val stopRequestedAtMs = AtomicLong(0L) + private val stopExecutor = Executors.newSingleThreadScheduledExecutor { runnable -> + Thread(runnable, "local-voice-tail-stop").apply { isDaemon = true } + } private val routeManager = VoiceAudioRouteManager(context) override fun isAvailable(): Boolean = runtimeProvider.isExpectedAvailable() @@ -200,6 +206,7 @@ class LocalAudioVoiceBackend( val traceToken = runtimeProvider.performanceTraceToken() ?: VoicePerformanceTrace.begin() val generation = startGeneration.incrementAndGet() stopRequestedGeneration.set(-1L) + stopRequestedAtMs.set(0L) startExecutor.execute { initializeSession(generation, languageTag, events, traceToken) } @@ -282,7 +289,11 @@ class LocalAudioVoiceBackend( return } session.stopRequested.set(stopRequestedGeneration.get() == generation) - if (session.stopRequested.get()) VoicePerformanceTrace.markVoiceRelease(traceToken) + if (session.stopRequested.get()) { + session.releaseAtMs = stopRequestedAtMs.get().takeIf { it > 0L } + ?: SystemClock.elapsedRealtime() + VoicePerformanceTrace.markVoiceRelease(traceToken) + } val modelEvents = object : VoiceRecognitionEvents { override fun onPartial(text: String) { @@ -321,7 +332,7 @@ class LocalAudioVoiceBackend( } startCaptureThread(session) events.onReady() - if (session.stopRequested.get()) requestCaptureStop(session) + if (session.stopRequested.get()) scheduleCaptureStop(session, generation) // Waiting for verification/model mapping is safe here: capture is // already live and the bounded ring keeps the spoken prefix. @@ -353,10 +364,13 @@ class LocalAudioVoiceBackend( runtimeProvider.performanceTraceToken()?.let(VoicePerformanceTrace::markVoiceRelease) } val generation = startGeneration.get() + val releasedAt = SystemClock.elapsedRealtime() stopRequestedGeneration.set(generation) + stopRequestedAtMs.set(releasedAt) if (current == null) return + current.releaseAtMs = releasedAt current.stopRequested.set(true) - requestCaptureStop(current) + if (current.running.get()) scheduleCaptureStop(current, generation) if (current.modelReady.get()) startInferenceThread(current) // If the first model load is still running, initializeSession keeps // this session alive, then drains the buffered PCM and emits one final. @@ -365,6 +379,7 @@ class LocalAudioVoiceBackend( override fun cancel() { startGeneration.incrementAndGet() stopRequestedGeneration.set(-1L) + stopRequestedAtMs.set(0L) val current = synchronized(lock) { val value = session session = null @@ -405,6 +420,24 @@ class LocalAudioVoiceBackend( session.captureThread?.interrupt() } + private fun scheduleCaptureStop(session: Session, generation: Long) { + if (!session.captureStopScheduled.compareAndSet(false, true)) return + stopExecutor.schedule( + { + if ( + isCurrent(generation, session) && + session.stopRequested.get() && + !session.cancelled.get() && + !session.failed.get() + ) { + requestCaptureStop(session) + } + }, + TAIL_CAPTURE_MILLIS, + TimeUnit.MILLISECONDS, + ) + } + private fun startCaptureThread(session: Session) { session.captureFinished.set(false) session.captureThread = Thread({ captureLoop(session) }, "local-voice-capture") @@ -432,7 +465,7 @@ class LocalAudioVoiceBackend( session.degraded.compareAndSet(false, true) ) { Log.w( - "OpenImeVoicePerf", + TAG, "PCM pipeline degraded droppedPcmSamples=${session.ring.droppedSamples}", ) } @@ -490,7 +523,27 @@ class LocalAudioVoiceBackend( !session.cancelled.get() && session.finished.compareAndSet(false, true) ) { - val raw = voiceSession.inputFinished().ifBlank { session.lastPartial } + val finalizeStartedAt = SystemClock.elapsedRealtime() + val remainingBeforeFinish = session.ring.size + Log.i( + TAG, + "inputFinished begin releaseAtMs=${session.releaseAtMs} " + + "nowMs=$finalizeStartedAt ringRemainingSamples=$remainingBeforeFinish " + + "droppedSamples=${session.ring.droppedSamples} " + + "lastPartialLength=${session.lastPartial.length}", + ) + val finalRaw = voiceSession.inputFinished() + val usedOuterFallback = finalRaw.isBlank() + val raw = finalRaw.ifBlank { session.lastPartial } + val finalizeEndedAt = SystemClock.elapsedRealtime() + Log.i( + TAG, + "inputFinished end releaseAtMs=${session.releaseAtMs} " + + "nowMs=$finalizeEndedAt finalLength=${finalRaw.length} " + + "resolvedLength=${raw.length} lastPartialLength=${session.lastPartial.length} " + + "fallback=$usedOuterFallback " + + "releaseToFinalMs=${if (session.releaseAtMs > 0L) finalizeEndedAt - session.releaseAtMs else -1L}", + ) VoicePerformanceTrace.markFinalAsr(session.traceToken) val punctuated = if (raw.isBlank()) raw else voiceSession.punctuate(raw) ?: raw val final = VoiceCorrectionRepository.apply(punctuated) @@ -544,8 +597,16 @@ class LocalAudioVoiceBackend( val finished = AtomicBoolean(false) val failed = AtomicBoolean(false) val degraded = AtomicBoolean(false) + val captureStopScheduled = AtomicBoolean(false) var captureThread: Thread? = null var inferenceThread: Thread? = null + @Volatile + var releaseAtMs: Long = 0L var lastPartial: String = "" } + + private companion object { + const val TAG = "OpenImeVoicePerf" + const val TAIL_CAPTURE_MILLIS = 300L + } } diff --git a/app/src/main/java/llc/slacker/openime/LocalVoiceImeService.kt b/app/src/main/java/llc/slacker/openime/LocalVoiceImeService.kt index a48ff85d..cf7b58fe 100644 --- a/app/src/main/java/llc/slacker/openime/LocalVoiceImeService.kt +++ b/app/src/main/java/llc/slacker/openime/LocalVoiceImeService.kt @@ -1,5 +1,6 @@ package llc.slacker.openime +import android.content.Intent import android.inputmethodservice.InputMethodService import android.os.Handler import android.os.Looper @@ -11,6 +12,7 @@ import android.view.View import android.view.inputmethod.EditorInfo import android.view.inputmethod.InputMethodManager import android.view.inputmethod.InputMethodSubtype +import java.io.File /** * Native system IME service. The view is a thin native renderer; all candidate @@ -23,13 +25,15 @@ class LocalVoiceImeService : InputMethodService(), ImeKeyboardView.Listener, Can val nativeCount: Int = 0, val fallbackCount: Int = 0, val nativeLatencyMs: Long = 0L, + val resultLatencyMs: Long = 0L, val pathCount: Int = 0, val finalCandidateSource: String = "none", ) { fun asLogFields(): String = "learnedCount=$learnedCount nativeCount=$nativeCount " + "fallbackCount=$fallbackCount nativeLatencyMs=$nativeLatencyMs " + - "pathCount=$pathCount finalCandidateSource=$finalCandidateSource" + "resultLatencyMs=$resultLatencyMs pathCount=$pathCount " + + "finalCandidateSource=$finalCandidateSource" } private var keyboardView: ImeKeyboardView? = null @@ -47,6 +51,8 @@ class LocalVoiceImeService : InputMethodService(), ImeKeyboardView.Listener, Can mainHandler = mainHandler, windowProvider = { getWindow().window }, keyboardHeightPx = { keyboardView?.measuredHeight }, + floatingWidthPercent = { ImeSettingsRepository.loadFloatingWidthPercent(this) }, + floatingOpacityPercent = { ImeSettingsRepository.loadFloatingOpacityPercent(this) }, debugLog = { message -> if (verboseLogging) Log.d(TAG, message) }, @@ -189,6 +195,10 @@ class LocalVoiceImeService : InputMethodService(), ImeKeyboardView.Listener, Can override fun onStartInput(attribute: EditorInfo?, restarting: Boolean) { super.onStartInput(attribute, restarting) + if (!restarting) { + gateway.discardClearUndo() + keyboardView?.hideClearUndo() + } reloadPersistedSettings() voiceCorrectionTracker.clear() val previousRimeInputs = candidateQueries.activeInputs @@ -282,9 +292,13 @@ class LocalVoiceImeService : InputMethodService(), ImeKeyboardView.Listener, Can ) } - /** Apply settings changed by the standalone preferences Activity immediately. */ + /** Apply settings changed by standalone Activities on the IME main thread. */ internal fun refreshPersistedSettingsFromActivity() { - reloadPersistedSettings() + if (Looper.myLooper() == Looper.getMainLooper()) { + reloadPersistedSettings() + } else { + mainHandler.post(::reloadPersistedSettings) + } } override fun onCurrentInputMethodSubtypeChanged(newSubtype: InputMethodSubtype) { @@ -661,13 +675,47 @@ class LocalVoiceImeService : InputMethodService(), ImeKeyboardView.Listener, Can // editor. Otherwise a late native result can restore the just-cleared // pre-edit on the very next key press. clearImeCompositionState(render = false) - if (!gateway.clearAllText()) { + val cleared = gateway.clearAllText() + if (!cleared) { android.widget.Toast.makeText(this, "当前应用未能清空全部文本", android.widget.Toast.LENGTH_SHORT).show() } voiceCorrectionTracker.clear() voiceComposing = false state = state.copy(voiceState = VoiceUiState()) keyboardView?.renderState(state) + if (cleared && gateway.hasClearUndo()) { + keyboardView?.showClearUndo() + } else { + keyboardView?.hideClearUndo() + } + } + + override fun onUndoClear(): Boolean { + if (state.passwordField) return false + val restored = gateway.restoreLastClear() + if (!restored) { + showTextEditFeedback("没有可撤回的清空内容") + return false + } + voiceCorrectionTracker.clear() + voiceComposing = false + state = state.copy( + composition = "", + candidates = emptyList(), + expandedCandidates = emptyList(), + voiceState = VoiceUiState(), + ) + lastComposition = "" + renderedCandidateSnapshot = null + keyboardView?.clearAssociationCandidates() + keyboardView?.hideClearUndo() + keyboardView?.renderState(state) + return true + } + + override fun onUndoClearExpired() { + gateway.discardClearUndo() + keyboardView?.hideClearUndo() } override fun onFloatingKeyboardChanged(floating: Boolean) { @@ -679,6 +727,10 @@ class LocalVoiceImeService : InputMethodService(), ImeKeyboardView.Listener, Can floatingWindow.drag(deltaX, deltaY) } + override fun onFloatingStyleChanged(widthPercent: Int, opacityPercent: Int) { + if (floatingWindow.enabled) floatingWindow.reapply() + } + override fun onSpace() { prepareForManualInput() if (keyboardView?.insertIntoInlineEditor(" ") == true) return @@ -926,6 +978,87 @@ class LocalVoiceImeService : InputMethodService(), ImeKeyboardView.Listener, Can selectCandidate(candidate) } + override fun onCandidateLongPressed(candidate: String) { + val composition = lastComposition + if ( + state.passwordField || + composition.isBlank() || + candidate.isBlank() || + !allowsPersonalizedLearning() + ) { + return + } + + if ( + QuickPhraseRepository.candidatesForInputCode( + context = this, + rawCode = composition, + exactOnly = false, + limit = 16, + ).contains(candidate) + ) { + return + } + + val modeAtRequest = state.keyboardMode + + fun refreshAfterDelete() { + if (lastComposition != composition || state.keyboardMode != modeAtRequest) return + val fallback = fallbackCandidatesFor(composition, modeAtRequest) + handleCompositionChanged( + composition = composition, + candidates = fallback, + rimeInputs = listOf(composition), + ) + } + + fun confirmDelete(onConfirm: () -> Unit) { + if (lastComposition != composition || state.keyboardMode != modeAtRequest) return + keyboardView?.confirmCandidateDeletion(candidate, onConfirm) + } + + if (!rime.isReady) { + if (!UserPhraseRepository.contains(composition, candidate)) return + confirmDelete { + if (UserPhraseRepository.forget(composition, candidate)) { + PersonalizationRepository.forget(candidate) + refreshAfterDelete() + showTextEditFeedback("已删除个人学习词") + } + } + return + } + + val queued = rime.isUserLearnedCandidate(composition, candidate) { learned -> + mainHandler.post { + if ( + !learned || + lastComposition != composition || + state.keyboardMode != modeAtRequest + ) { + return@post + } + confirmDelete { + val deleteQueued = rime.deleteCandidate(composition, candidate) { deleted -> + mainHandler.post { + if (deleted) { + PersonalizationRepository.forget(candidate) + refreshAfterDelete() + showTextEditFeedback("已删除个人学习词") + } + } + } + if (!deleteQueued) { + showTextEditFeedback("暂时无法修改个人词") + } + } + } + } + if (!queued) { + return + } + } + /** * Association ("联想") chips are produced only after a commit, so there is * no composition for [selectCandidate] to match against. Commit the word @@ -1095,8 +1228,26 @@ class LocalVoiceImeService : InputMethodService(), ImeKeyboardView.Listener, Can commitFirstCandidate() } - private fun fallbackCandidatesFor(composition: String, mode: KeyboardMode): List = - candidatePipeline.candidatesFor(mode, composition, state.fuzzyPinyinEnabled) + private fun fallbackCandidatesFor( + composition: String, + mode: KeyboardMode, + ): List { + val normal = candidatePipeline.candidatesFor( + mode, + composition, + state.fuzzyPinyinEnabled, + ) + if (mode != KeyboardMode.PINYIN_26 && mode != KeyboardMode.ENGLISH_26) { + return normal + } + val quickPhrases = QuickPhraseRepository.candidatesForInputCode( + context = this, + rawCode = composition, + exactOnly = false, + limit = 8, + ) + return (quickPhrases + normal).distinct().take(MAX_CANDIDATES) + } /** The extra learner is only a repeated-choice fallback while Rime is unavailable. */ private fun immediateCandidates(composition: String, fallback: List): List { @@ -1129,12 +1280,23 @@ class LocalVoiceImeService : InputMethodService(), ImeKeyboardView.Listener, Can mode = mode, rimeInputs = rimeInputs, onResult = result@{ request, queryInputs, query -> - if ( - state.keyboardMode != mode || - lastComposition != composition - ) return@result - - val native = query.choices + fun applyWhenCandidateSurfaceIdle() { + if ( + state.keyboardMode != mode || + lastComposition != composition || + candidateQueries.currentGeneration() != request + ) { + return + } + if (keyboardView?.isCandidateInteractionActive() == true) { + mainHandler.postDelayed( + { applyWhenCandidateSurfaceIdle() }, + CANDIDATE_REFRESH_IDLE_POLL_MS, + ) + return + } + + val native = query.choices // Once Rime returns candidates, its mature dictionary and // userdb ordering replace the transient Kotlin preview. The // fallback is retained only when native has no answer. @@ -1144,7 +1306,30 @@ class LocalVoiceImeService : InputMethodService(), ImeKeyboardView.Listener, Can emptyList() } val finalCandidates = if (native.isNotEmpty()) { - native.map { it.text } + val nativeText = native.map { it.text } + val exactQuickPhrases = + if (mode == KeyboardMode.PINYIN_26 || mode == KeyboardMode.ENGLISH_26) { + QuickPhraseRepository.candidatesForInputCode( + context = this, + rawCode = composition, + exactOnly = true, + limit = 8, + ) + } else { + emptyList() + } + if (exactQuickPhrases.isNotEmpty()) { + (exactQuickPhrases + nativeText + fallback) + .distinct() + .take(MAX_CANDIDATES) + } else if ( + mode == KeyboardMode.PINYIN_26 && + nativeText.size < TYPO_CORRECTION_NATIVE_THRESHOLD + ) { + (nativeText + fallback).distinct().take(MAX_CANDIDATES) + } else { + nativeText + } } else { (learned + fallback).distinct().take(MAX_CANDIDATES) } @@ -1158,6 +1343,7 @@ class LocalVoiceImeService : InputMethodService(), ImeKeyboardView.Listener, Can nativeCount = native.size, fallbackCount = fallback.distinct().size, nativeLatencyMs = query.latencyMs, + resultLatencyMs = query.resultLatencyMs, pathCount = queryInputs.size, finalCandidateSource = when { native.isNotEmpty() -> "native" @@ -1178,6 +1364,8 @@ class LocalVoiceImeService : InputMethodService(), ImeKeyboardView.Listener, Can nativeReferences = nativeReferences, ) keyboardView?.renderState(state) + } + applyWhenCandidateSurfaceIdle() }, ) @@ -1316,11 +1504,35 @@ class LocalVoiceImeService : InputMethodService(), ImeKeyboardView.Listener, Can renderedCandidateSnapshot = null } + internal fun exportRimeUserData( + targetDir: File, + onComplete: (List?) -> Unit, + ): Boolean = + ::rime.isInitialized && + rime.exportUserDictionaries(targetDir, onComplete) + + internal fun importRimeUserData( + sourceDir: File, + dictionaries: List, + onComplete: (Int?) -> Unit, + ): Boolean = + ::rime.isInitialized && + rime.importUserDictionaries(sourceDir, dictionaries, onComplete) + + override fun onOpenAboutData() { + startActivity( + Intent(this, AboutDataActivity::class.java) + .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK), + ) + } + internal companion object { const val TAG = "OpenIme" + const val CANDIDATE_REFRESH_IDLE_POLL_MS = 16L const val MAX_RIME_INPUT_LENGTH = 256 const val MAX_RIME_NINE_KEY_PATHS = 6 const val MAX_CANDIDATES = 96 + const val TYPO_CORRECTION_NATIVE_THRESHOLD = 8 @Volatile var activeInstance: LocalVoiceImeService? = null } diff --git a/app/src/main/java/llc/slacker/openime/MainActivity.kt b/app/src/main/java/llc/slacker/openime/MainActivity.kt index a3461d09..2ecc0b66 100644 --- a/app/src/main/java/llc/slacker/openime/MainActivity.kt +++ b/app/src/main/java/llc/slacker/openime/MainActivity.kt @@ -3,9 +3,12 @@ package llc.slacker.openime import android.Manifest import android.app.Activity import android.content.Intent +import android.content.res.ColorStateList import android.content.pm.PackageManager import android.os.Bundle import android.os.Build +import android.text.Editable +import android.text.TextWatcher import android.view.HapticFeedbackConstants import android.view.View import android.view.WindowInsets @@ -13,10 +16,9 @@ import android.provider.Settings import android.net.Uri import android.view.inputmethod.InputMethodManager import android.widget.TextView +import android.widget.ImageView import android.widget.EditText import android.widget.Toast -import android.graphics.Color -import android.graphics.drawable.GradientDrawable import android.graphics.drawable.StateListDrawable /** Match the selected IME by the exact package component, never by substring. */ @@ -58,15 +60,60 @@ class MainActivity : Activity() { startActivity(Intent(this, ImeSettingsActivity::class.java)) } setupClick(findViewById(R.id.voice_permission)) { - if (checkSelfPermission(Manifest.permission.RECORD_AUDIO) == PackageManager.PERMISSION_GRANTED) return@setupClick - val requested = getPreferences(MODE_PRIVATE).getBoolean("microphone_requested", false) + if (checkSelfPermission(Manifest.permission.RECORD_AUDIO) == PackageManager.PERMISSION_GRANTED) { + return@setupClick + } + val prefs = getPreferences(MODE_PRIVATE) + prefs.edit().putBoolean("microphone_skipped", false).apply() + val requested = prefs.getBoolean("microphone_requested", false) if (requested && !shouldShowRequestPermissionRationale(Manifest.permission.RECORD_AUDIO)) { - startActivity(Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS, Uri.parse("package:$packageName"))) + startActivity( + Intent( + Settings.ACTION_APPLICATION_DETAILS_SETTINGS, + Uri.parse("package:$packageName"), + ), + ) } else { - getPreferences(MODE_PRIVATE).edit().putBoolean("microphone_requested", true).apply() + prefs.edit().putBoolean("microphone_requested", true).apply() requestPermissions(arrayOf(Manifest.permission.RECORD_AUDIO), 1) } } + setupClick(findViewById(R.id.voice_permission_skip)) { + getPreferences(MODE_PRIVATE) + .edit() + .putBoolean("microphone_skipped", true) + .apply() + refreshSetupState() + } + setupClick(findViewById(R.id.test_step)) { + val input = findViewById(R.id.test_input) + input.requestFocus() + getSystemService(InputMethodManager::class.java).showSoftInput( + input, + InputMethodManager.SHOW_IMPLICIT, + ) + } + findViewById(R.id.test_input).addTextChangedListener( + object : TextWatcher { + override fun beforeTextChanged( + s: CharSequence?, + start: Int, + count: Int, + after: Int, + ) = Unit + + override fun onTextChanged( + s: CharSequence?, + start: Int, + before: Int, + count: Int, + ) { + refreshSetupState() + } + + override fun afterTextChanged(s: Editable?) = Unit + }, + ) } override fun onResume() { @@ -84,15 +131,30 @@ class MainActivity : Activity() { val enabled = status.enabled val selected = status.selected val accent = AccentPalette.parse(ImeSettingsRepository.loadSkinColor(this)) - findViewById(R.id.test_input).let { view -> - view.background = SetupUi.focusRingBackground(this) - SetupUi.styleCursor(this, view) - } - findViewById(R.id.status).setText(when { - selected -> R.string.setup_ready - enabled -> R.string.setup_choose - else -> R.string.setup_enable - }) + val prefs = getPreferences(MODE_PRIVATE) + val microphoneGranted = + checkSelfPermission(Manifest.permission.RECORD_AUDIO) == PackageManager.PERMISSION_GRANTED + val microphoneRequested = prefs.getBoolean("microphone_requested", false) + val microphoneSkipped = prefs.getBoolean("microphone_skipped", false) + val microphoneDeniedPermanently = + microphoneRequested && + !microphoneGranted && + !shouldShowRequestPermissionRationale(Manifest.permission.RECORD_AUDIO) + val testInput = findViewById(R.id.test_input) + val testDone = testInput.text?.isNotBlank() == true + + testInput.background = SetupUi.focusRingBackground(this) + SetupUi.styleCursor(this, testInput) + + findViewById(R.id.status).setText( + when { + !enabled -> R.string.setup_enable + !selected -> R.string.setup_choose + testDone -> R.string.setup_ready + else -> R.string.test_step + }, + ) + styleStep( row = findViewById(R.id.open_ime_settings), mark = findViewById(R.id.open_ime_settings_mark), @@ -105,6 +167,7 @@ class MainActivity : Activity() { markText = "1", accent = accent, ) + styleStep( row = findViewById(R.id.choose_ime), mark = findViewById(R.id.choose_ime_mark), @@ -119,36 +182,81 @@ class MainActivity : Activity() { ) findViewById(R.id.choose_ime).apply { isEnabled = enabled - alpha = when { - selected -> 0.72f - enabled -> 1f - else -> 0.55f - } + alpha = if (enabled) 1f else ImeSurfacePolicy.DISABLED_ALPHA } - val microphoneGranted = checkSelfPermission(Manifest.permission.RECORD_AUDIO) == PackageManager.PERMISSION_GRANTED + + val voiceDone = microphoneGranted || microphoneSkipped + val voiceActive = selected && !voiceDone + val voiceActiveText = + if (microphoneDeniedPermanently) { + getString(R.string.voice_permission_settings) + } else { + getString(R.string.voice_permission_enable) + } + val voiceDoneText = + if (microphoneGranted) { + getString(R.string.voice_permission_ready) + } else { + getString(R.string.voice_permission_skipped) + } + styleStep( + row = findViewById(R.id.voice_permission), + mark = findViewById(R.id.voice_permission_mark), + label = findViewById(R.id.voice_permission_label), + chevron = findViewById(R.id.voice_permission_chevron), + done = voiceDone, + active = voiceActive, + doneText = voiceDoneText, + activeText = voiceActiveText, + markText = "3", + accent = accent, + ) findViewById(R.id.voice_permission).apply { - isEnabled = !microphoneGranted - alpha = if (microphoneGranted) 0.72f else 1f - background = SetupUi.mutedPillBackground(this@MainActivity) + isEnabled = selected && !microphoneGranted + alpha = if (selected) 1f else ImeSurfacePolicy.DISABLED_ALPHA } - findViewById(R.id.voice_permission_label).apply { - setText(if (microphoneGranted) R.string.voice_permission_ready else R.string.voice_permission_enable) - setTextColor(getColor(if (microphoneGranted) R.color.setup_muted_text else R.color.setup_title)) + findViewById(R.id.voice_permission_description).setText( + when { + microphoneGranted -> R.string.voice_permission_ready_description + microphoneDeniedPermanently -> R.string.voice_permission_denied + else -> R.string.voice_permission_description + }, + ) + findViewById(R.id.voice_permission_skip).apply { + visibility = + if (selected && !microphoneGranted && !microphoneSkipped) { + View.VISIBLE + } else { + View.GONE + } + isEnabled = visibility == View.VISIBLE + background = SetupUi.secondaryBackground(this@MainActivity) } - findViewById(R.id.voice_permission_chevron).visibility = - if (microphoneGranted) View.GONE else View.VISIBLE - findViewById(R.id.voice_permission).contentDescription = getString( - if (microphoneGranted) R.string.voice_permission_ready else R.string.voice_permission_enable, + + styleStep( + row = findViewById(R.id.test_step), + mark = findViewById(R.id.test_step_mark), + label = findViewById(R.id.test_step_label), + chevron = null, + done = testDone, + active = selected && !testDone, + doneText = getString(R.string.test_step_done), + activeText = getString(R.string.test_step), + markText = "4", + accent = accent, ) - if (Build.VERSION.SDK_INT >= 30) { - findViewById(R.id.voice_permission).stateDescription = - if (microphoneGranted) "已授权" else "未授权,可选" + findViewById(R.id.test_step).apply { + isEnabled = selected + alpha = if (selected) 1f else ImeSurfacePolicy.DISABLED_ALPHA } + testInput.isEnabled = selected + testInput.alpha = if (selected) 1f else ImeSurfacePolicy.DISABLED_ALPHA + val ready = enabled && selected findViewById(R.id.open_app_settings).apply { isEnabled = ready - alpha = if (ready) 1f else 0.45f - background = SetupUi.mutedPillBackground(this@MainActivity) + alpha = if (ready) 1f else ImeSurfacePolicy.DISABLED_ALPHA + background = SetupUi.secondaryBackground(this@MainActivity) contentDescription = getString( if (ready) R.string.open_app_settings else R.string.setup_need_switch, ) @@ -158,7 +266,6 @@ class MainActivity : Activity() { } } - private fun isImeReady(): Boolean { val status = imeStatus() return status.enabled && status.selected @@ -186,7 +293,7 @@ class MainActivity : Activity() { row: View, mark: TextView, label: TextView, - chevron: TextView?, + chevron: ImageView?, done: Boolean, active: Boolean, doneText: String, @@ -198,9 +305,9 @@ class MainActivity : Activity() { // the system picker or input-method settings after initial setup. row.isEnabled = true row.background = when { - active -> primaryPill(accent) - done -> completedPill(accent) - else -> SetupUi.mutedPillBackground(this) + active -> SetupUi.buttonBackground(this, accent) + done -> completedBackground(accent) + else -> SetupUi.secondaryBackground(this) } label.text = if (done) doneText else activeText label.setTextColor( @@ -211,7 +318,16 @@ class MainActivity : Activity() { // A completed step is easier to scan as a result than as an old // step number. Keep the number for the current step so the flow still // reads as 1 -> 2 while the completed state reads as a check. - mark.text = if (done) "✓" else markText + mark.text = if (done) "" else markText + mark.setCompoundDrawablesRelativeWithIntrinsicBounds( + if (done) R.drawable.ic_check else 0, + 0, + 0, + 0, + ) + mark.compoundDrawableTintList = ColorStateList.valueOf( + if (done) contrastText(getColor(R.color.setup_ready)) else accent, + ) mark.setBackgroundResource( when { active -> R.drawable.bg_setup_mark_active @@ -220,19 +336,13 @@ class MainActivity : Activity() { }, ) mark.setTextColor( - if (active) accent else if (done) getColor(R.color.setup_ready) else accent, + when { + active -> accent + done -> contrastText(getColor(R.color.setup_ready)) + else -> accent + }, ) - if (active) { - mark.background = rounded( - contrastText(accent), - ImeGeometryTokens.BADGE_RADIUS_DP.toFloat(), - ) - } else if (done) { - mark.setBackgroundResource(R.drawable.bg_setup_mark_done) - } else { - mark.setBackgroundResource(R.drawable.bg_setup_mark) - } - chevron?.setTextColor( + chevron?.imageTintList = ColorStateList.valueOf( if (active) contrastText(accent) else getColor(R.color.setup_body), ) chevron?.visibility = if (done) View.GONE else View.VISIBLE @@ -251,60 +361,32 @@ class MainActivity : Activity() { } } - private fun primaryPill(color: Int): StateListDrawable { - val pressed = dim(color, 0.86f) + private fun completedBackground(accent: Int): StateListDrawable { + val surface = getColor(R.color.setup_muted) + val tint = ImeDrawableFactory.blend(surface, accent, 0.10f) + val pressed = ImeDrawableFactory.blend(surface, accent, 0.16f) + val radius = SetupUi.dp(this, ImeGeometryTokens.CONTROL_RADIUS_DP).toFloat() return StateListDrawable().apply { addState( intArrayOf(android.R.attr.state_pressed), - rounded(pressed, ImeGeometryTokens.SETUP_PILL_RADIUS_DP.toFloat()), + ImeDrawableFactory.rounded(pressed, radius), ) addState( intArrayOf(android.R.attr.state_focused), - rounded(color, ImeGeometryTokens.SETUP_PILL_RADIUS_DP.toFloat(), contrastText(color)), - ) - addState(intArrayOf(), rounded(color, ImeGeometryTokens.SETUP_PILL_RADIUS_DP.toFloat())) - } - } - - private fun completedPill(accent: Int): StateListDrawable { - val surface = getColor(R.color.setup_muted) - val tint = blend(surface, accent, 0.10f) - val pressed = blend(surface, accent, 0.16f) - return StateListDrawable().apply { - addState( - intArrayOf(android.R.attr.state_pressed), - rounded(pressed, ImeGeometryTokens.SETUP_PILL_RADIUS_DP.toFloat()), + ImeDrawableFactory.rounded( + color = tint, + radiusPx = radius, + strokeColor = accent, + strokeWidthPx = SetupUi.dp(this@MainActivity, 1), + ), ) addState( - intArrayOf(android.R.attr.state_focused), - rounded(tint, ImeGeometryTokens.SETUP_PILL_RADIUS_DP.toFloat(), accent), + intArrayOf(), + ImeDrawableFactory.rounded(tint, radius), ) - addState(intArrayOf(), rounded(tint, ImeGeometryTokens.SETUP_PILL_RADIUS_DP.toFloat())) } } - private fun rounded(color: Int, radiusDp: Float, strokeColor: Int? = null): GradientDrawable = GradientDrawable().apply { - shape = GradientDrawable.RECTANGLE - setColor(color) - cornerRadius = radiusDp * resources.displayMetrics.density - strokeColor?.let { setStroke(resources.displayMetrics.density.toInt().coerceAtLeast(1), it) } - } - - private fun dim(color: Int, factor: Float): Int = Color.rgb( - (Color.red(color) * factor).toInt().coerceIn(0, 255), - (Color.green(color) * factor).toInt().coerceIn(0, 255), - (Color.blue(color) * factor).toInt().coerceIn(0, 255), - ) - - private fun blend(base: Int, tint: Int, amount: Float): Int { - val ratio = amount.coerceIn(0f, 1f) - return Color.rgb( - (Color.red(base) * (1f - ratio) + Color.red(tint) * ratio).toInt(), - (Color.green(base) * (1f - ratio) + Color.green(tint) * ratio).toInt(), - (Color.blue(base) * (1f - ratio) + Color.blue(tint) * ratio).toInt(), - ) - } - private fun contrastText(background: Int): Int { return ImeContrastPolicy.contrastText(background) } diff --git a/app/src/main/java/llc/slacker/openime/NineKeyLocalDecoder.kt b/app/src/main/java/llc/slacker/openime/NineKeyLocalDecoder.kt index 18b23f33..97d3b74d 100644 --- a/app/src/main/java/llc/slacker/openime/NineKeyLocalDecoder.kt +++ b/app/src/main/java/llc/slacker/openime/NineKeyLocalDecoder.kt @@ -149,21 +149,15 @@ internal class NineKeyLocalDecoder( ?.pinyin ?.take(bounded.length) ?.takeIf { digitsForPinyin(it) == bounded } - val preferredFallbackPreview = continuationBase?.let { base -> - val baseDigits = digitsForPinyin(base) ?: return@let null - if (!bounded.startsWith(baseDigits) || bounded.length <= baseDigits.length) { - return@let null - } - base + fallbackLetters(bounded.removePrefix(baseDigits)) - } val preview = when { stable != null -> stable continuous != null -> continuous + bounded.length == 1 -> bounded + paths.isEmpty() -> bounded continuationBase != null && validPreset.isEmpty() && prefixPreview != null -> prefixPreview - validPreset.isEmpty() && preferredFallbackPreview != null -> preferredFallbackPreview paths.isNotEmpty() -> paths.first() prefixPreview != null -> prefixPreview - else -> fallbackLetters(bounded) + else -> bounded } previousDigits = bounded @@ -340,24 +334,6 @@ internal class NineKeyLocalDecoder( private fun isDisplayCandidate(value: String): Boolean = value.isNotBlank() && value.none(Char::isDigit) && value.any { it.code > 0x7f } - private fun fallbackLetters(digits: String): String = buildString(digits.length) { - digits.forEach { digit -> - append( - when (digit) { - '2' -> 'a' - '3' -> 'd' - '4' -> 'g' - '5' -> 'j' - '6' -> 'm' - '7' -> 'p' - '8' -> 't' - '9' -> 'w' - else -> return@forEach - }, - ) - } - } - companion object { const val MAX_DIGITS = 64 private const val MAX_PATHS = 12 diff --git a/app/src/main/java/llc/slacker/openime/NineKeyPerformanceTrace.kt b/app/src/main/java/llc/slacker/openime/NineKeyPerformanceTrace.kt new file mode 100644 index 00000000..2816c0be --- /dev/null +++ b/app/src/main/java/llc/slacker/openime/NineKeyPerformanceTrace.kt @@ -0,0 +1,65 @@ +package llc.slacker.openime + +import android.util.Log +import java.util.Locale +import java.util.concurrent.atomic.AtomicInteger +import kotlin.math.ceil + +internal object NineKeyPerformanceTrace { + private const val TAG = "OpenImeNineKeyPerf" + private const val WINDOW_SIZE = 20 + + private val constructionCount = AtomicInteger(0) + private val lock = Any() + private val resolveSamplesNs = LinkedHashMap>() + + fun recordDecoderConstruction(elapsedNs: Long, threadName: String) { + val ordinal = constructionCount.incrementAndGet() + safeLog( + "decoder-init ordinal=${ordinal} first=${ordinal == 1} " + + "elapsedMs=${formatMs(elapsedNs)} thread=$threadName " + + "phase=CandidatePipeline.init", + ) + } + + fun recordResolve(digitLength: Int, elapsedNs: Long, threadName: String) { + if (digitLength !in 1..NineKeyLocalDecoder.MAX_DIGITS) return + val summary = synchronized(lock) { + val samples = resolveSamplesNs.getOrPut(digitLength) { ArrayList(WINDOW_SIZE) } + samples += elapsedNs + if (samples.size < WINDOW_SIZE) return@synchronized null + val window = samples.takeLast(WINDOW_SIZE).sorted() + ResolveSummary( + p50Ns = percentile(window, 0.50), + p95Ns = percentile(window, 0.95), + ).also { + samples.clear() + } + } ?: return + + safeLog( + "resolve length=$digitLength samples=$WINDOW_SIZE " + + "p50Ms=${formatMs(summary.p50Ns)} " + + "p95Ms=${formatMs(summary.p95Ns)} thread=$threadName", + ) + } + + private fun safeLog(message: String) { + runCatching { Log.d(TAG, message) } + } + + private fun percentile(sorted: List, quantile: Double): Long { + if (sorted.isEmpty()) return 0L + val index = (ceil(sorted.size * quantile).toInt() - 1) + .coerceIn(0, sorted.lastIndex) + return sorted[index] + } + + private fun formatMs(valueNs: Long): String = + String.format(Locale.US, "%.3f", valueNs / 1_000_000.0) + + private data class ResolveSummary( + val p50Ns: Long, + val p95Ns: Long, + ) +} diff --git a/app/src/main/java/llc/slacker/openime/NineKeyPresets.kt b/app/src/main/java/llc/slacker/openime/NineKeyPresets.kt index 7c3ca24e..3b896c5c 100644 --- a/app/src/main/java/llc/slacker/openime/NineKeyPresets.kt +++ b/app/src/main/java/llc/slacker/openime/NineKeyPresets.kt @@ -3,7 +3,7 @@ package llc.slacker.openime /** Small high-confidence 9-key overrides. Every entry is code-validated in tests. */ internal object NineKeyPresets { val combinations: Map> = linkedMapOf( - "64" to listOf("ni", "mi", "oh"), + "64" to listOf("ni", "mi"), "64426" to listOf("nihao"), "9664" to listOf("yong"), "426" to listOf("hao"), diff --git a/app/src/main/java/llc/slacker/openime/NineKeySymbolRailController.kt b/app/src/main/java/llc/slacker/openime/NineKeySymbolRailController.kt index 48a0621a..afdf59d7 100644 --- a/app/src/main/java/llc/slacker/openime/NineKeySymbolRailController.kt +++ b/app/src/main/java/llc/slacker/openime/NineKeySymbolRailController.kt @@ -1,28 +1,39 @@ package llc.slacker.openime import android.content.Context +import android.graphics.Typeface import android.os.Build import android.text.Editable import android.text.TextWatcher import android.view.Gravity +import android.view.View import android.widget.EditText import android.widget.LinearLayout import android.widget.ScrollView import android.widget.TextView /** - * Owns the Chinese 9-key side symbol rail and ambiguity filter presentation. + * Owns the Chinese 9-key side rail. * - * The renderer receives the built rail directly. Composition updates also use - * explicit editor/rail references rather than searching the whole View tree. + * With no ambiguity it behaves as the normal vertically scrollable symbol + * rail. Once one digit sequence maps to multiple Pinyin paths, the same rail + * becomes a directly selectable Pinyin list. This mirrors mature 9-key IMEs: + * the user sees all useful spellings at once instead of cycling one hidden + * option through a single button. */ internal class NineKeySymbolRailController( private val context: Context, private val composition: EditText, private val onCommit: (String) -> Unit, private val onFeedback: () -> Unit, + private val onRailChanged: (View) -> Unit, ) { + private enum class RailMode { SYMBOLS, PINYIN } + private var rail: ScrollView? = null + private var railMode = RailMode.SYMBOLS + private var renderedChoices: List = emptyList() + private var renderedSelected: String? = null init { composition.addTextChangedListener(object : TextWatcher { @@ -51,35 +62,36 @@ internal class NineKeySymbolRailController( context = context, railTag = NINE_RAIL_TAG, contentTag = NINE_CONTENT_TAG, - contentDescription = "九键常用符号,上下滑动查看更多", + contentDescription = "九键拼音筛选或常用符号,上下滑动查看更多", symbols = commonSymbols(), tagPrefix = "punct:", onCommit = onCommit, onFeedback = onFeedback, ).also { rail = it + railMode = RailMode.SYMBOLS + renderedChoices = emptyList() + renderedSelected = null refreshPinyinFilters() } + /** + * A symbol edit may happen while the Pinyin filter is visible. Re-resolve + * the current composition first; symbols are repopulated only when the + * rail is actually in symbol mode. + */ fun refreshSymbols() { - val current = rail ?: return - val content = current.getChildAt(0) as? LinearLayout ?: return - val header = content.findViewWithTag(FILTER_TAG) - SymbolRailRenderer.populate( - scroll = current, - symbols = commonSymbols(), - tagPrefix = "punct:", - preservedHeader = header, - onCommit = onCommit, - onFeedback = onFeedback, - ) - refreshPinyinFilters() + if (railMode == RailMode.SYMBOLS) { + renderSymbols(force = true) + } else { + refreshPinyinFilters() + } } private fun refreshPinyinFilters() { val text = composition.text?.toString().orEmpty() if (text.isBlank()) { - setPinyinFilters(emptyList(), null) {} + renderSymbols() return } @@ -89,86 +101,142 @@ internal class NineKeySymbolRailController( val digits = CandidatePipeline.nineKeyDigitsFor(suffix) val code = digits?.let { NineKeyLocalDecoder.nativeCode(prefix, it) } if (code.isNullOrEmpty()) { - setPinyinFilters(emptyList(), null) {} + renderSymbols() + return + } + + val resolver = context as? CandidateResolver + if (resolver == null) { + renderSymbols() return } - val resolver = context as? CandidateResolver ?: return val choices = resolver.nineKeyPathsFor(code) - val selected = resolver.selectedNineKeyPathFor(code) ?: text - setPinyinFilters( - filters = choices, + .asSequence() + .map { it.trim() } + .filter { it.isNotEmpty() } + .distinct() + .take(MAX_VISIBLE_PATHS) + .toList() + + // One spelling needs no disambiguation. Keep the valuable symbol rail + // available instead of occupying the whole column with redundant UI. + if (choices.size <= 1) { + renderSymbols() + return + } + + val selected = resolver.selectedNineKeyPathFor(code) + ?.takeIf { it in choices } + ?: text.takeIf { it in choices } + ?: choices.first() + + renderPinyinChoices( + choices = choices, selected = selected, ) { chosen -> resolver.selectNineKeyPath(code, chosen) - if (chosen == composition.text?.toString()) return@setPinyinFilters + if (chosen == composition.text?.toString()) return@renderPinyinChoices composition.setText(chosen) composition.setSelection(chosen.length) } } - private fun setPinyinFilters( - filters: List, - selected: String?, + private fun renderSymbols(force: Boolean = false) { + val scroll = rail ?: return + if (railMode == RailMode.SYMBOLS && !force) return + + SymbolRailRenderer.populate( + scroll = scroll, + symbols = commonSymbols(), + tagPrefix = "punct:", + onCommit = onCommit, + onFeedback = onFeedback, + ) + railMode = RailMode.SYMBOLS + renderedChoices = emptyList() + renderedSelected = null + if (scroll.scrollY != 0) scroll.post { scroll.scrollTo(0, 0) } + scroll.contentDescription = "九键常用符号,上下滑动查看更多" + onRailChanged(scroll) + } + + private fun renderPinyinChoices( + choices: List, + selected: String, onSelect: (String) -> Unit, ) { val scroll = rail ?: return - val content = scroll.getChildAt(0) as? LinearLayout ?: return - val choices = filters - .asSequence() - .map { it.trim() } - .filter { it.isNotEmpty() } - .distinct() - .take(8) - .toList() - - val existing = content.findViewWithTag(FILTER_TAG) - if (choices.size <= 1) { - if (existing != null) content.removeView(existing) + if ( + railMode == RailMode.PINYIN && + renderedChoices == choices && + renderedSelected == selected + ) { return } + val content = scroll.getChildAt(0) as? LinearLayout ?: return + val inheritedTextColor = (0 until content.childCount) + .asSequence() + .mapNotNull { content.getChildAt(it) as? TextView } + .firstOrNull() + ?.currentTextColor - var active = selected?.takeIf { it in choices } ?: choices.first() - val view = existing ?: TextView(content.context).apply { - tag = FILTER_TAG - textSize = 12f - gravity = Gravity.CENTER - isClickable = true - isFocusable = true - minimumHeight = SymbolRailRenderer.cellHeightPx(content.context) - val inherited = (0 until content.childCount) - .asSequence() - .mapNotNull { content.getChildAt(it) as? TextView } - .firstOrNull { it.tag != FILTER_TAG } - ?.currentTextColor - inherited?.let(::setTextColor) + content.removeAllViews() + choices.forEachIndexed { index, choice -> + val active = choice == selected content.addView( - this, - 0, - SymbolRailRenderer.cellParams(content.context, withGap = true), + TextView(content.context).apply { + text = displayPath(choice) + textSize = ImeTypographyTokens.BODY_SP + gravity = Gravity.CENTER + tag = if (active) SELECTED_FILTER_TAG else FILTER_TAG + contentDescription = + if (active) { + "九键拼音${speakablePath(choice)},已选择" + } else { + "九键拼音${speakablePath(choice)},双击选择" + } + if (Build.VERSION.SDK_INT >= 30) { + stateDescription = if (active) "已选择" else "未选择" + } + isSelected = active + typeface = if (active) Typeface.DEFAULT_BOLD else Typeface.DEFAULT + isClickable = true + isFocusable = true + maxLines = 2 + minimumHeight = SymbolRailRenderer.cellHeightPx(content.context) + inheritedTextColor?.let(::setTextColor) + setOnClickListener { + onFeedback() + onSelect(choice) + } + }, + SymbolRailRenderer.cellParams( + content.context, + withGap = index < choices.lastIndex, + ), ) } - fun updatePresentation() { - view.text = active.replace(" ", "·") + " ›" - view.contentDescription = - "九键拼音筛选,当前${active.replace(" ", "、")},点击切换" - if (Build.VERSION.SDK_INT >= 30) { - view.stateDescription = "当前${active.replace(" ", "、")}" - } + railMode = RailMode.PINYIN + renderedChoices = choices.toList() + renderedSelected = selected + scroll.contentDescription = "九键拼音筛选,上下滑动查看更多" + onRailChanged(scroll) + val selectedIndex = choices.indexOf(selected).coerceAtLeast(0) + scroll.post { + val target = content.getChildAt(selectedIndex) ?: return@post + val viewport = scroll.height.coerceAtLeast(SymbolRailRenderer.cellHeightPx(content.context)) + val targetY = (target.top - (viewport - target.height) / 2).coerceAtLeast(0) + scroll.scrollTo(0, targetY) } + } - updatePresentation() - view.setOnClickListener { - onFeedback() - val current = choices.indexOf(active).coerceAtLeast(0) - active = choices[(current + 1) % choices.size] - updatePresentation() - onSelect(active) - } + private fun displayPath(path: String): String = + path.replace(" ", "·").replace("|", "·").replace("'", "·") - if (scroll.scrollY != 0) scroll.post { scroll.scrollTo(0, 0) } - } + private fun speakablePath(path: String): String = + path.replace(" ", "、").replace("|", "、").replace("'", "、") private fun commonSymbols(): List = CustomSymbolRepository.load(context) @@ -185,5 +253,7 @@ internal class NineKeySymbolRailController( const val NINE_RAIL_TAG = "nine-punct-stack" const val NINE_CONTENT_TAG = "nine-symbol-scroll-content" const val FILTER_TAG = "nine-pinyin-path-filter" + const val SELECTED_FILTER_TAG = "nine-pinyin-path-selected" + const val MAX_VISIBLE_PATHS = 16 } } diff --git a/app/src/main/java/llc/slacker/openime/NumericKeyboardRenderer.kt b/app/src/main/java/llc/slacker/openime/NumericKeyboardRenderer.kt index 5756e2ca..6c06f63b 100644 --- a/app/src/main/java/llc/slacker/openime/NumericKeyboardRenderer.kt +++ b/app/src/main/java/llc/slacker/openime/NumericKeyboardRenderer.kt @@ -71,13 +71,13 @@ internal class NumericKeyboardRenderer( ), ) left.addView( - createKey("符号", true, 13f, onShowSymbols).apply { + createKey("符号", true, ImeTypographyTokens.BODY_SP, onShowSymbols).apply { markSideKey(this) }, LinearLayout.LayoutParams( LinearLayout.LayoutParams.MATCH_PARENT, toPx(keyRowHeightDp()), - ).apply { topMargin = toPx(ImeGeometryTokens.KEY_ROW_GAP_DP) }, + ), ) container.addView(left, adaptiveColumnParams(1f)) @@ -89,7 +89,7 @@ internal class NumericKeyboardRenderer( val row = LinearLayout(context).apply { orientation = LinearLayout.HORIZONTAL } rowDigits.forEach { digit -> row.addView( - createKey(digit, false, 22f) { onCommitCharacter(digit) }.apply { + createKey(digit, false, ImeTypographyTokens.KEY_LETTER_SP) { onCommitCharacter(digit) }.apply { tag = "key:$digit" markWhiteKey(this) }, @@ -101,11 +101,7 @@ internal class NumericKeyboardRenderer( LinearLayout.LayoutParams( LinearLayout.LayoutParams.MATCH_PARENT, toPx(keyRowHeightDp()), - ).apply { - if (rowIndex < DIGIT_ROWS.lastIndex) { - bottomMargin = toPx(ImeGeometryTokens.KEY_ROW_GAP_DP) - } - }, + ), ) } @@ -121,7 +117,7 @@ internal class NumericKeyboardRenderer( orientation = LinearLayout.HORIZONTAL } centerBottom.addView( - createKey("返回", true, 14f, onReturnToText).apply { + createKey("返回", true, ImeTypographyTokens.BODY_SP, onReturnToText).apply { tag = "key:mode" markSideKey(this) }, @@ -130,7 +126,7 @@ internal class NumericKeyboardRenderer( centerBottom.addView( if (phone) { val value = literal("key-space", "*") - createKey(value, true, 14f) { onCommitCharacter(value) }.apply { + createKey(value, true, ImeTypographyTokens.BODY_SP) { onCommitCharacter(value) }.apply { tag = "key-space" markWhiteKey(this) } @@ -142,7 +138,7 @@ internal class NumericKeyboardRenderer( flexKeyParams(), ) centerBottom.addView( - createKey(literal("key:.", "."), false, 22f) { + createKey(literal("key:.", "."), false, ImeTypographyTokens.KEY_LETTER_SP) { onCommitCharacter(literal("key:.", ".")) }.apply { tag = "key:." @@ -165,26 +161,26 @@ internal class NumericKeyboardRenderer( } side.addView( createBackspaceKey().apply { markSideKey(this) }, - sideKeyParams(includeBottomGap = true), + sideKeyParams(), ) side.addView( - createKey("0", false, 22f) { onCommitCharacter("0") }.apply { + createKey("0", false, ImeTypographyTokens.KEY_LETTER_SP) { onCommitCharacter("0") }.apply { tag = "key:0" markSideKey(this) }, - sideKeyParams(includeBottomGap = true), + sideKeyParams(), ) side.addView( - createKey(literal("key:@", "@"), true, 15f) { + createKey(literal("key:@", "@"), true, ImeTypographyTokens.BODY_SP) { onCommitCharacter(literal("key:@", "@")) }.apply { tag = "key:@" markSideKey(this) }, - sideKeyParams(includeBottomGap = true), + sideKeyParams(), ) side.addView( - createKey(enterLabel, true, 13f, onEnter).apply { + createKey(enterLabel, true, ImeTypographyTokens.BODY_SP, onEnter).apply { tag = "key-enter" markSideKey(this) }, @@ -227,28 +223,20 @@ internal class NumericKeyboardRenderer( 0, toPx(keyRowHeightDp()), weight, - ).apply { - marginStart = toPx(ImeGeometryTokens.KEY_SIDE_MARGIN_DP) - marginEnd = toPx(ImeGeometryTokens.KEY_SIDE_MARGIN_DP) - } + ) - private fun sideKeyParams(includeBottomGap: Boolean = false) = + private fun sideKeyParams() = LinearLayout.LayoutParams( LinearLayout.LayoutParams.MATCH_PARENT, toPx(keyRowHeightDp()), - ).apply { - if (includeBottomGap) bottomMargin = toPx(ImeGeometryTokens.KEY_ROW_GAP_DP) - } + ) private fun adaptiveColumnParams(weight: Float) = LinearLayout.LayoutParams( 0, toPx(nineBodyHeightDp()), weight, - ).apply { - marginStart = toPx(ImeGeometryTokens.KEY_SIDE_MARGIN_DP) - marginEnd = toPx(ImeGeometryTokens.KEY_SIDE_MARGIN_DP) - } + ) private companion object { const val DIGITS_RAIL_TAG = "digits-symbol-scroll" diff --git a/app/src/main/java/llc/slacker/openime/PanelHeaderFactory.kt b/app/src/main/java/llc/slacker/openime/PanelHeaderFactory.kt index 49117412..d06a755e 100644 --- a/app/src/main/java/llc/slacker/openime/PanelHeaderFactory.kt +++ b/app/src/main/java/llc/slacker/openime/PanelHeaderFactory.kt @@ -17,9 +17,24 @@ internal class PanelHeaderFactory( private val previousPanel: () -> Panel?, private val onBack: () -> Boolean, private val onFeedback: () -> Unit, + private val standalone: Boolean = false, ) { fun create(name: String): LinearLayout { val backTarget = previousPanel()?.let(::titleFor) ?: "键盘" + if (standalone) { + return SetupUi.activityTopBar( + context = context, + title = name, + backContentDescription = "返回$backTarget", + ) { + onFeedback() + onBack() + }.apply { + tag = "panel-head" + getChildAt(0)?.tag = "key-panel-back" + getChildAt(1)?.tag = "panel-title" + } + } return LinearLayout(context).apply { orientation = LinearLayout.HORIZONTAL gravity = Gravity.CENTER_VERTICAL diff --git a/app/src/main/java/llc/slacker/openime/PersonalizationRepository.kt b/app/src/main/java/llc/slacker/openime/PersonalizationRepository.kt index 07c36c3b..bc44d132 100644 --- a/app/src/main/java/llc/slacker/openime/PersonalizationRepository.kt +++ b/app/src/main/java/llc/slacker/openime/PersonalizationRepository.kt @@ -70,6 +70,16 @@ object PersonalizationRepository { } } + fun forget(text: String) { + val value = normalize(text) + if (value.isEmpty()) return + synchronized(lock) { + if (entries.remove(value) != null) { + saveLocked() + } + } + } + fun clear() { synchronized(lock) { entries.clear() diff --git a/app/src/main/java/llc/slacker/openime/Pinyin26KeyboardRenderer.kt b/app/src/main/java/llc/slacker/openime/Pinyin26KeyboardRenderer.kt index 76df459f..04df68a0 100644 --- a/app/src/main/java/llc/slacker/openime/Pinyin26KeyboardRenderer.kt +++ b/app/src/main/java/llc/slacker/openime/Pinyin26KeyboardRenderer.kt @@ -69,7 +69,7 @@ internal class Pinyin26KeyboardRenderer( val bottom = rowHost() bottom.addView( - createKey("123", true, null, 15f, 0, onDigits), + createKey("123", true, null, ImeTypographyTokens.BODY_SP, 0, onDigits), flexKeyParams(weights.leftOuter), ) val punctuation = if (english) "." else "," @@ -78,7 +78,7 @@ internal class Pinyin26KeyboardRenderer( punctuation, true, null, - 15f, + ImeTypographyTokens.BODY_SP, 0, ) { onCommitCharacter(punctuation) }, flexKeyParams(weights.leftInner), @@ -88,22 +88,22 @@ internal class Pinyin26KeyboardRenderer( flexKeyParams(weights.space), ) bottom.addView( - createKey("中/英", true, null, 14f, 0, onModeSwitch).apply { + createKey("中/英", true, null, ImeTypographyTokens.BODY_SP, 0, onModeSwitch).apply { tag = "key:mode" }, flexKeyParams(weights.rightInner), ) bottom.addView( - createKey(enterLabel, true, null, 15f, 0, onEnter).apply { + createKey(enterLabel, true, null, ImeTypographyTokens.BODY_SP, 0, onEnter).apply { tag = "key-enter" }, flexKeyParams(weights.rightOuter), ) - keyboardBody.addView(bottom, rowParams(includeBottomGap = false)) + keyboardBody.addView(bottom, rowParams()) } private fun segmentKey(): ImeKeyView = - createKey("分词", true, "@#/", 12f, 0, onPinyinSegment).apply { + createKey("分词", true, "@#/", ImeTypographyTokens.BODY_SP, 0, onPinyinSegment).apply { tag = "key-segment" contentDescription = "分词,长按输入@井号或斜杠" setOnLongClickListener { @@ -139,14 +139,13 @@ internal class Pinyin26KeyboardRenderer( base } val secondary = if (english) null else DIGIT_HINTS[character] - return createKey(main, false, secondary, 20f, 0) { + return createKey(main, false, secondary, ImeTypographyTokens.KEY_LETTER_SP, 0) { onLetter(base) }.apply { tag = "key:$base" - // 26-key keeps the surface visually clean while preserving the - // long-press digit action. if (secondary != null) { - setSecondaryVisible(false) + setSecondaryVisible(true) + setSecondaryAlpha(0.4f) setOnLongClickListener { onCommitCharacter(secondary) true @@ -161,23 +160,18 @@ internal class Pinyin26KeyboardRenderer( layoutParams = rowParams() } - private fun rowParams(includeBottomGap: Boolean = true) = + private fun rowParams() = LinearLayout.LayoutParams( LinearLayout.LayoutParams.MATCH_PARENT, toPx(keyRowHeightDp()), - ).apply { - if (includeBottomGap) bottomMargin = toPx(ImeGeometryTokens.KEY_ROW_GAP_DP) - } + ) private fun flexKeyParams(weight: Float = 1f) = LinearLayout.LayoutParams( 0, toPx(keyRowHeightDp()), weight, - ).apply { - marginStart = toPx(ImeGeometryTokens.KEY_SIDE_MARGIN_DP) - marginEnd = toPx(ImeGeometryTokens.KEY_SIDE_MARGIN_DP) - } + ) private companion object { val ROWS = listOf("qwertyuiop", "asdfghjkl", "zxcvbnm") diff --git a/app/src/main/java/llc/slacker/openime/Pinyin9KeyboardRenderer.kt b/app/src/main/java/llc/slacker/openime/Pinyin9KeyboardRenderer.kt index 635c9a76..b31724b8 100644 --- a/app/src/main/java/llc/slacker/openime/Pinyin9KeyboardRenderer.kt +++ b/app/src/main/java/llc/slacker/openime/Pinyin9KeyboardRenderer.kt @@ -58,13 +58,13 @@ internal class Pinyin9KeyboardRenderer( ), ) left.addView( - createKey("符号", true, null, 13f, onShowSymbols).apply { + createKey("符号", true, null, ImeTypographyTokens.BODY_SP, onShowSymbols).apply { markSideKey(this) }, LinearLayout.LayoutParams( LinearLayout.LayoutParams.MATCH_PARENT, toPx(keyRowHeightDp()), - ).apply { topMargin = toPx(ImeGeometryTokens.KEY_ROW_GAP_DP) }, + ), ) container.addView(left, adaptiveColumnParams(1f)) @@ -80,7 +80,7 @@ internal class Pinyin9KeyboardRenderer( orientation = LinearLayout.HORIZONTAL } centerBottom.addView( - createKey("123", true, null, 13f, onDigits).apply { + createKey("123", true, null, ImeTypographyTokens.BODY_SP, onDigits).apply { markSideKey(this) }, flexKeyParams(0.925f), @@ -90,7 +90,7 @@ internal class Pinyin9KeyboardRenderer( flexKeyParams(3.4f), ) centerBottom.addView( - createKey("中/英", true, null, 13f, onModeSwitch).apply { + createKey("中/英", true, null, ImeTypographyTokens.BODY_SP, onModeSwitch).apply { tag = "key:mode" markSideKey(this) }, @@ -111,16 +111,16 @@ internal class Pinyin9KeyboardRenderer( } side.addView( createBackspaceKey().apply { markSideKey(this) }, - sideKeyParams(keyRowHeightDp(), includeBottomGap = true), + sideKeyParams(keyRowHeightDp()), ) side.addView( - createKey("重输", true, null, 13f, onRetranslate).apply { + createKey("重输", true, null, ImeTypographyTokens.BODY_SP, onRetranslate).apply { markSideKey(this) }, - sideKeyParams(keyRowHeightDp(), includeBottomGap = true), + sideKeyParams(keyRowHeightDp()), ) side.addView( - createKey(enterLabel, true, null, 13f, onEnter).apply { + createKey(enterLabel, true, null, ImeTypographyTokens.BODY_SP, onEnter).apply { tag = "key-enter" markSideKey(this) }, @@ -147,7 +147,7 @@ internal class Pinyin9KeyboardRenderer( if (segmentation) "分词" else subLabel, false, if (segmentation) "@#/" else null, - if (segmentation) 12f else 17f, + if (segmentation) ImeTypographyTokens.BODY_SP else ImeTypographyTokens.KEY_LETTER_SP, ) { if (segmentation) onPinyinSegment() else onNineKey(digit) }.apply { @@ -176,9 +176,7 @@ internal class Pinyin9KeyboardRenderer( LinearLayout.LayoutParams( LinearLayout.LayoutParams.MATCH_PARENT, toPx(keyRowHeightDp()), - ).apply { - if (rowIndex < 2) bottomMargin = toPx(ImeGeometryTokens.KEY_ROW_GAP_DP) - }, + ), ) } return grid @@ -189,30 +187,20 @@ internal class Pinyin9KeyboardRenderer( 0, toPx(keyRowHeightDp()), weight, - ).apply { - marginStart = toPx(ImeGeometryTokens.KEY_SIDE_MARGIN_DP) - marginEnd = toPx(ImeGeometryTokens.KEY_SIDE_MARGIN_DP) - } + ) - private fun sideKeyParams( - heightDp: Int, - includeBottomGap: Boolean = false, - ) = LinearLayout.LayoutParams( - LinearLayout.LayoutParams.MATCH_PARENT, - toPx(heightDp), - ).apply { - if (includeBottomGap) bottomMargin = toPx(ImeGeometryTokens.KEY_ROW_GAP_DP) - } + private fun sideKeyParams(heightDp: Int) = + LinearLayout.LayoutParams( + LinearLayout.LayoutParams.MATCH_PARENT, + toPx(heightDp), + ) private fun adaptiveColumnParams(weight: Float) = LinearLayout.LayoutParams( 0, toPx(nineBodyHeightDp()), weight, - ).apply { - marginStart = toPx(ImeGeometryTokens.KEY_SIDE_MARGIN_DP) - marginEnd = toPx(ImeGeometryTokens.KEY_SIDE_MARGIN_DP) - } + ) private companion object { val NINE_KEYS = listOf( diff --git a/app/src/main/java/llc/slacker/openime/QuickPhraseEditActivity.kt b/app/src/main/java/llc/slacker/openime/QuickPhraseEditActivity.kt index 09668a0a..e44cb403 100644 --- a/app/src/main/java/llc/slacker/openime/QuickPhraseEditActivity.kt +++ b/app/src/main/java/llc/slacker/openime/QuickPhraseEditActivity.kt @@ -1,19 +1,15 @@ package llc.slacker.openime import android.app.Activity -import android.content.res.ColorStateList import android.os.Build import android.os.Bundle import android.text.Editable import android.text.TextWatcher -import android.util.TypedValue import android.view.Gravity -import android.view.HapticFeedbackConstants import android.view.View import android.view.WindowInsets import android.view.inputmethod.EditorInfo import android.widget.EditText -import android.widget.ImageButton import android.widget.LinearLayout import android.widget.ScrollView import android.widget.TextView @@ -25,13 +21,16 @@ class QuickPhraseEditActivity : Activity() { const val EXTRA_ID = "quick_phrase_id" const val EXTRA_CATEGORY = "quick_phrase_category" const val EXTRA_TEXT = "quick_phrase_text" + const val EXTRA_INPUT_CODE = "quick_phrase_input_code" } private val density by lazy { resources.displayMetrics.density } private lateinit var categoryEdit: EditText + private lateinit var codeEdit: EditText private lateinit var phraseEdit: EditText private var phraseId = 0L private var initialCategory = "" + private var initialCode = "" private var initialPhrase = "" private var savedScrollY = 0 private var savedFocusId = R.id.quick_phrase_text_editor @@ -47,13 +46,19 @@ class QuickPhraseEditActivity : Activity() { ?: R.id.quick_phrase_text_editor initialCategory = savedInstanceState?.getString("baseline_category") ?: intent.getStringExtra(EXTRA_CATEGORY).orEmpty() + initialCode = savedInstanceState?.getString("baseline_code") + ?: intent.getStringExtra(EXTRA_INPUT_CODE).orEmpty() initialPhrase = savedInstanceState?.getString("baseline_phrase") ?: intent.getStringExtra(EXTRA_TEXT).orEmpty() val category = savedInstanceState?.getString("draft_category") ?: initialCategory + val code = savedInstanceState?.getString("draft_code") ?: initialCode val phrase = savedInstanceState?.getString("draft_phrase") ?: initialPhrase - render(category, phrase) - if (savedFocusId == R.id.quick_phrase_category_editor) categoryEdit.requestFocus() - else phraseEdit.requestFocus() + render(category, code, phrase) + when (savedFocusId) { + R.id.quick_phrase_category_editor -> categoryEdit.requestFocus() + R.id.quick_phrase_code_editor -> codeEdit.requestFocus() + else -> phraseEdit.requestFocus() + } if (Build.VERSION.SDK_INT >= 33) { backCallback = OnBackInvokedCallback { requestClose() } onBackInvokedDispatcher.registerOnBackInvokedCallback( @@ -79,8 +84,10 @@ class QuickPhraseEditActivity : Activity() { override fun onSaveInstanceState(outState: Bundle) { outState.putString("baseline_category", initialCategory) + outState.putString("baseline_code", initialCode) outState.putString("baseline_phrase", initialPhrase) outState.putString("draft_category", categoryEdit.text.toString()) + outState.putString("draft_code", codeEdit.text.toString()) outState.putString("draft_phrase", phraseEdit.text.toString()) outState.putInt("focused_field", currentFocus?.id ?: savedFocusId) outState.putInt( @@ -90,35 +97,12 @@ class QuickPhraseEditActivity : Activity() { super.onSaveInstanceState(outState) } - private fun render(category: String, phrase: String) { - val accent = SetupUi.accent(this) - val title = TextView(this).apply { - text = if (phraseId > 0L) "编辑常用语" else "新增常用语" - textSize = 22f - setTextColor(getColor(R.color.setup_title)) - setTypeface(typeface, android.graphics.Typeface.BOLD) - gravity = Gravity.CENTER_VERTICAL - includeFontPadding = false - if (Build.VERSION.SDK_INT >= 28) setAccessibilityHeading(true) - } - val header = LinearLayout(this).apply { - orientation = LinearLayout.HORIZONTAL - gravity = Gravity.CENTER_VERTICAL - addView(ImageButton(this@QuickPhraseEditActivity).apply { - setImageResource(R.drawable.ic_arrow_back) - imageTintList = ColorStateList.valueOf(accent) - contentDescription = "返回" - setMinimumWidth(dp(ImeGeometryTokens.TOUCH_TARGET_DP)) - setMinimumHeight(dp(ImeGeometryTokens.TOUCH_TARGET_DP)) - isClickable = true - isFocusable = true - applySelectableBackground(this) - setOnClickListener { - it.performHapticFeedback(HapticFeedbackConstants.VIRTUAL_KEY) - requestClose() - } - }, LinearLayout.LayoutParams(dp(ImeGeometryTokens.TOUCH_TARGET_DP), dp(56))) - addView(title, LinearLayout.LayoutParams(0, dp(56), 1f)) + private fun render(category: String, code: String, phrase: String) { + val header = SetupUi.activityTopBar( + context = this, + title = if (phraseId > 0L) "编辑常用语" else "新增常用语", + ) { + requestClose() } categoryEdit = EditText(this).apply { @@ -127,9 +111,20 @@ class QuickPhraseEditActivity : Activity() { setText(category) setSingleLine(true) imeOptions = EditorInfo.IME_ACTION_NEXT - textSize = 16f + textSize = ImeTypographyTokens.BODY_SP } SetupUi.styleInput(this, categoryEdit) + codeEdit = EditText(this).apply { + id = R.id.quick_phrase_code_editor + hint = "例如:dz、mail、addr" + setText(code) + setSingleLine(true) + inputType = android.text.InputType.TYPE_CLASS_TEXT or + android.text.InputType.TYPE_TEXT_FLAG_NO_SUGGESTIONS + imeOptions = EditorInfo.IME_ACTION_NEXT + textSize = ImeTypographyTokens.BODY_SP + } + SetupUi.styleInput(this, codeEdit) phraseEdit = EditText(this).apply { id = R.id.quick_phrase_text_editor hint = "输入常用语" @@ -138,7 +133,7 @@ class QuickPhraseEditActivity : Activity() { maxLines = 8 gravity = Gravity.TOP or Gravity.START imeOptions = EditorInfo.IME_ACTION_DONE - textSize = 17f + textSize = ImeTypographyTokens.BODY_SP } SetupUi.styleInput(this, phraseEdit) val save = SetupUi.primaryButton(this, "保存") { @@ -150,6 +145,7 @@ class QuickPhraseEditActivity : Activity() { phraseId, categoryEdit.text.toString(), phraseEdit.text.toString(), + inputCode = codeEdit.text.toString(), ) != null ) { finish() @@ -158,7 +154,7 @@ class QuickPhraseEditActivity : Activity() { fun refreshSaveState() { val valid = phraseEdit.text.toString().isNotBlank() save.isEnabled = valid - save.alpha = if (valid) 1f else 0.38f + save.alpha = if (valid) 1f else ImeSurfacePolicy.DISABLED_ALPHA save.contentDescription = if (valid) { "保存" } else { @@ -179,6 +175,14 @@ class QuickPhraseEditActivity : Activity() { requestClose() } categoryEdit.setOnEditorActionListener { _, actionId, _ -> + if (actionId == EditorInfo.IME_ACTION_NEXT) { + codeEdit.requestFocus() + true + } else { + false + } + } + codeEdit.setOnEditorActionListener { _, actionId, _ -> if (actionId == EditorInfo.IME_ACTION_NEXT) { phraseEdit.requestFocus() true @@ -215,6 +219,23 @@ class QuickPhraseEditActivity : Activity() { LinearLayout.LayoutParams.MATCH_PARENT, dp(ImeGeometryTokens.FIELD_HEIGHT_DP), ).apply { bottomMargin = dp(14) }) + addView(fieldLabel("输入码(可选)", R.id.quick_phrase_code_editor), LinearLayout.LayoutParams( + LinearLayout.LayoutParams.MATCH_PARENT, + LinearLayout.LayoutParams.WRAP_CONTENT, + )) + addView(codeEdit, LinearLayout.LayoutParams( + LinearLayout.LayoutParams.MATCH_PARENT, + dp(ImeGeometryTokens.FIELD_HEIGHT_DP), + ).apply { bottomMargin = dp(14) }) + addView(TextView(this@QuickPhraseEditActivity).apply { + text = "设置后,输入至少 2 个字符的短码即可在候选栏召回这条常用语。" + textSize = ImeTypographyTokens.BODY_SP + setTextColor(getColor(R.color.setup_body)) + setPadding(dp(4), 0, dp(4), dp(12)) + }, LinearLayout.LayoutParams( + LinearLayout.LayoutParams.MATCH_PARENT, + LinearLayout.LayoutParams.WRAP_CONTENT, + )) addView(fieldLabel("常用语内容", R.id.quick_phrase_text_editor), LinearLayout.LayoutParams( LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT, @@ -225,7 +246,7 @@ class QuickPhraseEditActivity : Activity() { ).apply { bottomMargin = dp(8) }) addView(TextView(this@QuickPhraseEditActivity).apply { text = "保存后会在剪贴板面板中按分类显示,可直接点选输入。" - textSize = 12f + textSize = ImeTypographyTokens.BODY_SP setTextColor(getColor(R.color.setup_body)) setPadding(dp(4), 0, dp(4), dp(12)) }, LinearLayout.LayoutParams( @@ -242,8 +263,8 @@ class QuickPhraseEditActivity : Activity() { setPadding(dp(20), dp(24), dp(20), dp(24)) addView(header, LinearLayout.LayoutParams( LinearLayout.LayoutParams.MATCH_PARENT, - dp(56), - ).apply { bottomMargin = dp(12) }) + dp(ImeGeometryTokens.TOP_BAR_HEIGHT_DP), + ).apply { bottomMargin = dp(ImeSpacingTokens.MD_DP) }) addView(form, LinearLayout.LayoutParams( LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT, @@ -276,15 +297,19 @@ class QuickPhraseEditActivity : Activity() { private fun fieldLabel(label: String, targetId: Int) = TextView(this).apply { text = label - textSize = 12f + textSize = ImeTypographyTokens.BODY_SP setTextColor(getColor(R.color.setup_body)) setPadding(dp(4), 0, dp(4), dp(4)) labelFor = targetId } private fun requestClose() { - if (!::categoryEdit.isInitialized || !::phraseEdit.isInitialized || - (categoryEdit.text.toString() == initialCategory && phraseEdit.text.toString() == initialPhrase) + if (!::categoryEdit.isInitialized || !::codeEdit.isInitialized || !::phraseEdit.isInitialized || + ( + categoryEdit.text.toString() == initialCategory && + codeEdit.text.toString() == initialCode && + phraseEdit.text.toString() == initialPhrase + ) ) { finish() return @@ -305,16 +330,5 @@ class QuickPhraseEditActivity : Activity() { dialog.show() } - private fun applySelectableBackground(view: View) { - val value = TypedValue() - if ( - theme.resolveAttribute( - android.R.attr.selectableItemBackgroundBorderless, - value, - true, - ) && value.resourceId != 0 - ) { - view.setBackgroundResource(value.resourceId) - } - } + } diff --git a/app/src/main/java/llc/slacker/openime/QuickPhraseRepository.kt b/app/src/main/java/llc/slacker/openime/QuickPhraseRepository.kt index f413db15..88cc5396 100644 --- a/app/src/main/java/llc/slacker/openime/QuickPhraseRepository.kt +++ b/app/src/main/java/llc/slacker/openime/QuickPhraseRepository.kt @@ -8,17 +8,34 @@ data class QuickPhrase( val id: Long, val category: String, val text: String, + val inputCode: String = "", ) /** Persistent user-editable quick phrases used by the clipboard panel. */ object QuickPhraseRepository { private const val PREFS = "ime_quick_phrases" private const val KEY_ITEMS = "items" + private val cacheLock = Any() + private var cachedRaw: String? = null + private var cachedItems: List? = null fun load(context: Context): List { val prefs = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) - val raw = prefs.getString(KEY_ITEMS, null) ?: return defaults() - return runCatching { + val raw = prefs.getString(KEY_ITEMS, null) + synchronized(cacheLock) { + if (cachedRaw == raw && cachedItems != null) { + return cachedItems.orEmpty() + } + } + if (raw == null) { + val defaults = defaults() + synchronized(cacheLock) { + cachedRaw = null + cachedItems = defaults + } + return defaults + } + val parsed = runCatching { val array = JSONArray(raw) buildList { for (index in 0 until array.length()) { @@ -30,27 +47,71 @@ object QuickPhraseRepository { id = item.optLong("id", index.toLong() + 1L), category = item.optString("category", "常用").ifBlank { "常用" }, text = text, + inputCode = normalizeInputCode(item.optString("input_code", "")), ), ) } } } }.getOrElse { defaults() } + synchronized(cacheLock) { + cachedRaw = raw + cachedItems = parsed + } + return parsed } - fun upsert(context: Context, id: Long, category: String, text: String): QuickPhrase? { + fun upsert( + context: Context, + id: Long, + category: String, + text: String, + inputCode: String? = null, + ): QuickPhrase? { val value = text.trim() if (value.isEmpty()) return null val items = load(context).toMutableList() val safeCategory = category.trim().ifBlank { "常用" } val actualId = if (id > 0L) id else nextId(items) - val updated = QuickPhrase(actualId, safeCategory, value) + val existing = items.firstOrNull { it.id == actualId } + val safeCode = if (inputCode == null) { + existing?.inputCode.orEmpty() + } else { + normalizeInputCode(inputCode) + } + val updated = QuickPhrase(actualId, safeCategory, value, safeCode) val index = items.indexOfFirst { it.id == actualId } if (index >= 0) items[index] = updated else items.add(updated) save(context, items) return updated } + fun candidatesForInputCode( + context: Context, + rawCode: String, + exactOnly: Boolean = false, + limit: Int = 8, + ): List { + val code = normalizeInputCode(rawCode) + if (code.length < 2 || limit <= 0) return emptyList() + val phrases = load(context) + val exact = phrases.asSequence() + .filter { it.inputCode == code } + .map { it.text } + val prefix = if (exactOnly) { + emptySequence() + } else { + phrases.asSequence() + .filter { it.inputCode.length > code.length && it.inputCode.startsWith(code) } + .map { it.text } + } + return (exact + prefix) + .filter { it.isNotBlank() } + .distinct() + .take(limit) + .toList() + } + fun remove(context: Context, id: Long) { save(context, load(context).filterNot { it.id == id }) } @@ -63,12 +124,19 @@ object QuickPhraseRepository { id = (categoryIndex + 1L) * 10_000L + phraseIndex + 1L, category = category, text = text, + inputCode = "", ), ) } } } + private fun normalizeInputCode(value: String): String = + value.trim() + .lowercase() + .filter { it in 'a'..'z' || it in '0'..'9' || it == '_' || it == '-' } + .take(24) + private fun nextId(items: List): Long = (items.maxOfOrNull { it.id } ?: 0L) + 1L @@ -79,12 +147,18 @@ object QuickPhraseRepository { JSONObject() .put("id", item.id) .put("category", item.category) - .put("text", item.text), + .put("text", item.text) + .put("input_code", item.inputCode), ) } + val raw = array.toString() + synchronized(cacheLock) { + cachedRaw = raw + cachedItems = items.toList() + } context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) .edit() - .putString(KEY_ITEMS, array.toString()) + .putString(KEY_ITEMS, raw) .apply() } } diff --git a/app/src/main/java/llc/slacker/openime/RimeEngine.kt b/app/src/main/java/llc/slacker/openime/RimeEngine.kt index 4194f79c..e36bf1d9 100644 --- a/app/src/main/java/llc/slacker/openime/RimeEngine.kt +++ b/app/src/main/java/llc/slacker/openime/RimeEngine.kt @@ -290,6 +290,148 @@ class RimeEngine( return "" } + fun isUserLearnedCandidate( + input: String, + candidate: String, + onComplete: (Boolean) -> Unit, + ): Boolean { + val normalized = RimeInputNormalizer.normalize(input) + val visible = candidate.trim() + if (!isReady || normalized.isBlank() || visible.isEmpty()) return false + + return mutationQueue.submit { + val learned = synchronized(lock) { + if (!isReady) { + false + } else { + runCatching { + if (!syncSchemaFromSettingsLocked()) return@runCatching false + val snapshot = RimeNative.nativeSetInput(normalized) + val entry = snapshotCandidateEntries(snapshot) + .firstOrNull { it.text == visible } + ?: return@runCatching false + RimeNative.nativeIsUserLearnedCandidate(entry.nativeIndex) + }.getOrDefault(false) + } + } + onComplete(learned) + } + } + + /** + * Delete one user-learned candidate without blocking the IME thread. + * Built-in dictionary entries are not deletable and report false. + */ + fun deleteCandidate( + input: String, + candidate: String, + onComplete: (Boolean) -> Unit, + ): Boolean { + val normalized = RimeInputNormalizer.normalize(input) + val visible = candidate.trim() + if (!isReady || normalized.isBlank() || visible.isEmpty()) return false + + return mutationQueue.submit { + val deleted = synchronized(lock) { + if (!isReady) { + false + } else { + runCatching { + if (!syncSchemaFromSettingsLocked()) return@runCatching false + val snapshot = RimeNative.nativeSetInput(normalized) + val entry = snapshotCandidateEntries(snapshot) + .firstOrNull { it.text == visible } + ?: return@runCatching false + if (!RimeNative.nativeIsUserLearnedCandidate(entry.nativeIndex)) { + return@runCatching false + } + RimeNative.nativeDeleteCandidate(entry.nativeIndex) + }.getOrDefault(false) + } + } + onComplete(deleted) + } + } + + internal fun exportUserDictionaries( + targetDir: File, + onComplete: (List?) -> Unit, + ): Boolean { + if (!isReady) return false + return mutationQueue.submit { + val exported: List? = synchronized(lock) { + if (!isReady) return@synchronized null + try { + if (!syncSchemaFromSettingsLocked()) return@synchronized null + if (!targetDir.exists() && !targetDir.mkdirs()) { + return@synchronized null + } + val mappings = + RimeNative.nativeExportUserDictionaries(targetDir.absolutePath) + ?: return@synchronized null + val result = mutableListOf() + for (mapping in mappings) { + val separator = mapping.indexOf('\t') + if (separator <= 0 || separator >= mapping.lastIndex) { + return@synchronized null + } + val name = mapping.substring(0, separator).trim() + val path = mapping.substring(separator + 1) + val file = File(path) + if ( + !UserDataArchiveCodec.isSafeRimeDictionaryName(name) || + !file.isFile + ) { + return@synchronized null + } + result += RimeUserDictionaryArchive( + name = name, + content = file.readText(), + ) + } + result + } catch (error: Exception) { + Log.w(TAG, "Rime user dictionary export failed", error) + null + } + } + onComplete(exported) + } + } + + internal fun importUserDictionaries( + sourceDir: File, + dictionaries: List, + onComplete: (Int?) -> Unit, + ): Boolean { + if (!isReady) return false + return mutationQueue.submit { + val imported = synchronized(lock) { + if (!isReady) { + null + } else { + runCatching { + if (!syncSchemaFromSettingsLocked()) return@runCatching null + sourceDir.mkdirs() + var total = 0 + dictionaries.forEachIndexed { index, item -> + val file = File(sourceDir, "dict-$index.userdb.txt") + file.writeText(item.content) + val count = RimeNative.nativeImportUserDictionary( + item.name, + file.absolutePath, + ) + if (count < 0) return@runCatching null + total += count + } + total + }.getOrNull() + } + } + onComplete(imported) + } + } + fun commitFirst(input: String, allowLearning: Boolean = true): String { if (!allowLearning) return candidates(input).firstOrNull().orEmpty() val normalized = RimeInputNormalizer.normalize(input) diff --git a/app/src/main/java/llc/slacker/openime/RimeNative.kt b/app/src/main/java/llc/slacker/openime/RimeNative.kt index b35d1650..764d57ac 100644 --- a/app/src/main/java/llc/slacker/openime/RimeNative.kt +++ b/app/src/main/java/llc/slacker/openime/RimeNative.kt @@ -26,6 +26,23 @@ internal object RimeNative { @JvmStatic external fun nativeSelectCandidate(index: Int): String? + /** True only when the active visible candidate is backed by Rime user data. */ + @JvmStatic + external fun nativeIsUserLearnedCandidate(index: Int): Boolean + + /** Remove a deletable candidate from the active Rime user dictionary. */ + @JvmStatic + external fun nativeDeleteCandidate(index: Int): Boolean + + /** Export every available Rime user dictionary as UTF-8 text files. */ + @JvmStatic + external fun nativeExportUserDictionaries(targetDir: String): Array? + + /** Merge one UTF-8 user dictionary snapshot back into the named Rime dictionary. */ + @JvmStatic + external fun nativeImportUserDictionary(dictName: String, sourceFile: String): Int + + @JvmStatic external fun nativeCommitFirst(): String? diff --git a/app/src/main/java/llc/slacker/openime/SettingsPanelController.kt b/app/src/main/java/llc/slacker/openime/SettingsPanelController.kt index 48fa0f2b..ffffd5a3 100644 --- a/app/src/main/java/llc/slacker/openime/SettingsPanelController.kt +++ b/app/src/main/java/llc/slacker/openime/SettingsPanelController.kt @@ -51,11 +51,19 @@ internal class SettingsPanelController( private val currentSkinRadius: () -> Int, private val currentSkinFontSize: () -> Int, private val currentSkinColor: () -> String, + private val currentHandedness: () -> ImeHandedness, + private val currentKeyboardHeightPercent: () -> Int, + private val currentFloatingWidthPercent: () -> Int, + private val currentFloatingOpacityPercent: () -> Int, private val onThemeSelected: (ImeTheme) -> Unit, private val onAppearanceSelected: (ImeAppearance) -> Unit, private val onToggleChanged: (String, Boolean) -> Unit, private val onSkinChanged: (Int, Int, Int, String) -> Unit, + private val onHandednessChanged: (ImeHandedness) -> Unit, + private val onKeyboardHeightChanged: (Int) -> Unit, + private val onFloatingStyleChanged: (Int, Int) -> Unit, private val onShowFuzzySettings: () -> Unit, + private val onOpenAboutData: () -> Unit, private val onFeedback: () -> Unit, private val applyTheme: () -> Unit, private val onHierarchyRebuilt: () -> Unit, @@ -84,7 +92,7 @@ internal class SettingsPanelController( createHeader("偏好设置"), LinearLayout.LayoutParams( LinearLayout.LayoutParams.MATCH_PARENT, - toPx(ImeGeometryTokens.TOUCH_TARGET_DP), + toPx(ImeGeometryTokens.TOP_BAR_HEIGHT_DP), ), ) } else { @@ -138,6 +146,55 @@ internal class SettingsPanelController( chipParams(), ) + content.addView(createSectionTitle("键盘布局"), wrapParams()) + content.addView( + createChipScroll( + ImeHandedness.entries.map { it.label }, + currentHandedness().label, + ) { label -> + ImeHandedness.entries.firstOrNull { it.label == label }?.let { selected -> + onHandednessChanged(selected) + renderSettings(reusePanel = true) + } + }, + chipParams(), + ) + content.addView( + settingGroup( + settingsSlider( + "键盘高度", + 92, + 120, + currentKeyboardHeightPercent(), + onKeyboardHeightChanged, + ), + ), + groupParams(), + ) + + content.addView(createSectionTitle("浮动键盘"), wrapParams()) + content.addView( + settingGroup( + settingsSlider( + "浮动宽度", + 72, + 96, + currentFloatingWidthPercent(), + ) { width -> + onFloatingStyleChanged(width, currentFloatingOpacityPercent()) + }, + settingsSlider( + "浮动透明度", + 82, + 100, + currentFloatingOpacityPercent(), + ) { opacity -> + onFloatingStyleChanged(currentFloatingWidthPercent(), opacity) + }, + ), + groupParams(), + ) + content.addView(createSectionTitle("强调色"), wrapParams()) content.addView( accentColorRow(), @@ -200,6 +257,18 @@ internal class SettingsPanelController( groupParams(), ) + content.addView(createSectionTitle("数据"), wrapParams()) + content.addView( + settingGroup( + settingNavigationRow( + "关于与数据", + "版本、隐私、导出与导入", + onOpenAboutData, + ), + ), + groupParams(), + ) + scroll.addView( content, ViewGroup.LayoutParams( @@ -340,6 +409,7 @@ internal class SettingsPanelController( "触感震动" -> R.drawable.ic_vibration "按键气泡" -> R.drawable.ic_bubble "模糊音与智能纠错", "启用模糊音" -> R.drawable.ic_tune + "关于与数据" -> R.drawable.ic_info else -> R.drawable.ic_tune }, ) @@ -382,9 +452,9 @@ internal class SettingsPanelController( LinearLayout(context).apply { orientation = LinearLayout.VERTICAL gravity = Gravity.CENTER_VERTICAL - addView(labelText(label, 14f), wrapParams()) + addView(labelText(label, ImeTypographyTokens.BODY_SP), wrapParams()) addView( - labelText(sub, 11f).apply { + labelText(sub, ImeTypographyTokens.CAPTION_SP).apply { setPadding(0, toPx(3), 0, 0) }, wrapParams(), @@ -431,9 +501,9 @@ internal class SettingsPanelController( LinearLayout(context).apply { orientation = LinearLayout.VERTICAL gravity = Gravity.CENTER_VERTICAL - addView(labelText(label, 14f), wrapParams()) + addView(labelText(label, ImeTypographyTokens.BODY_SP), wrapParams()) addView( - labelText(sub, 11f).apply { + labelText(sub, ImeTypographyTokens.CAPTION_SP).apply { setPadding(0, toPx(3), 0, 0) }, wrapParams(), @@ -447,10 +517,16 @@ internal class SettingsPanelController( ), ) addView( - TextView(context).apply { - text = "›" - textSize = 18f - gravity = Gravity.CENTER + ImageView(context).apply { + setImageResource(R.drawable.ic_arrow_back) + rotation = 180f + imageTintList = ColorStateList.valueOf( + ImeTheme.IOS.tokens( + appearance = currentAppearance(), + accentOverride = AccentPalette.parse(currentSkinColor()), + ).keySecondaryText, + ) + scaleType = ImageView.ScaleType.CENTER_INSIDE importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO tag = "setting-chevron" }, @@ -470,10 +546,7 @@ internal class SettingsPanelController( ).apply { gravity = Gravity.START or Gravity.CENTER_VERTICAL } - background = ImeDrawableFactory.rounded( - Color.WHITE, - toPx(ImeGeometryTokens.PILL_RADIUS_DP), - ) + tag = "toggle-knob" translationX = if (isOn) toPx(ImeGeometryTokens.SWITCH_KNOB_TRAVEL_DP).toFloat() else 0f @@ -592,11 +665,14 @@ internal class SettingsPanelController( ) if (selected) { addView( - TextView(context).apply { - text = "✓" - textSize = ImeTypographyTokens.PANEL_BODY_SP - gravity = Gravity.CENTER - includeFontPadding = false + ImageView(context).apply { + setImageResource(R.drawable.ic_check) + imageTintList = ColorStateList.valueOf( + ImeDrawableFactory.contrastText( + AccentPalette.parse(hex), + ), + ) + scaleType = ImageView.ScaleType.CENTER_INSIDE tag = "accent-selected-mark:$hex" importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO @@ -767,7 +843,8 @@ internal class SettingsPanelController( ), ) val valueView = TextView(context).apply { - textSize = 12f + tag = "setting-value" + textSize = ImeTypographyTokens.BODY_SP gravity = Gravity.CENTER includeFontPadding = false minWidth = toPx(ImeGeometryTokens.TOUCH_TARGET_DP) @@ -777,6 +854,7 @@ internal class SettingsPanelController( "圆角" -> " dp" "不透明度" -> "%" "按键字号" -> " sp" + "键盘高度", "浮动宽度", "浮动透明度" -> "%" else -> "" } val seekBar = SeekBar(context).apply { @@ -853,6 +931,7 @@ internal class SettingsPanelController( text = value textSize = size includeFontPadding = false + tag = if (size <= 11.5f) "panel-note" else "setting-label" } private fun chipParams() = diff --git a/app/src/main/java/llc/slacker/openime/SetupUi.kt b/app/src/main/java/llc/slacker/openime/SetupUi.kt index b5dc172c..1bda3365 100644 --- a/app/src/main/java/llc/slacker/openime/SetupUi.kt +++ b/app/src/main/java/llc/slacker/openime/SetupUi.kt @@ -5,16 +5,19 @@ import android.content.Context import android.content.res.ColorStateList import android.graphics.Color import android.graphics.drawable.ColorDrawable -import android.graphics.drawable.GradientDrawable import android.graphics.drawable.StateListDrawable import android.os.Build +import android.util.TypedValue +import android.view.Gravity +import android.view.HapticFeedbackConstants +import android.widget.ImageButton +import android.widget.LinearLayout import android.widget.Button import android.widget.EditText import android.widget.TextView /** Shared visual primitives for the non-IME setup and editor screens. */ object SetupUi { - private const val DESTRUCTIVE = "#F4212E" fun dp(context: Context, value: Int): Int = (value * context.resources.displayMetrics.density).toInt() @@ -22,79 +25,107 @@ object SetupUi { fun accent(context: Context): Int = AccentPalette.parse(ImeSettingsRepository.loadSkinColor(context)) + private fun tokens(context: Context): ImeTheme.Tokens { + val nightMask = + context.resources.configuration.uiMode and + android.content.res.Configuration.UI_MODE_NIGHT_MASK + return ImeTheme.IOS.tokens( + appearance = ImeSettingsRepository.loadAppearance(context), + systemDark = nightMask == android.content.res.Configuration.UI_MODE_NIGHT_YES, + accentOverride = accent(context), + ) + } + fun contrastText(background: Int): Int = ImeDrawableFactory.contrastText(background) + fun rounded( + color: Int, + radiusPx: Float, + strokeColor: Int? = null, + strokeWidthPx: Int = 1, + ) = ImeDrawableFactory.rounded( + color = color, + radiusPx = radiusPx, + strokeColor = strokeColor, + strokeWidthPx = strokeWidthPx, + ) + fun dim(color: Int, factor: Float): Int = ImeDrawableFactory.dim(color, factor, preserveAlpha = false) - fun rounded(color: Int, radiusDp: Float, strokeColor: Int? = null): GradientDrawable = - ImeDrawableFactory.rounded( - color = color, - radiusPx = radiusDp, - strokeColor = strokeColor, - strokeWidthPx = 1, - ) - fun buttonBackground( context: Context, color: Int, radiusDp: Float = ImeGeometryTokens.CONTROL_RADIUS_DP.toFloat(), ): StateListDrawable { - val pressed = dim(color, 0.86f) + val pressed = + if (color == accent(context)) tokens(context).accentPressed else dim(color, 0.86f) return StateListDrawable().apply { addState( intArrayOf(android.R.attr.state_enabled, android.R.attr.state_pressed), - rounded(pressed, dp(context, radiusDp.toInt()).toFloat()), + ImeDrawableFactory.rounded( + pressed, + dp(context, radiusDp.toInt()).toFloat(), + ), ) addState( intArrayOf(android.R.attr.state_enabled, android.R.attr.state_focused), - rounded( - color, - dp(context, radiusDp.toInt()).toFloat(), - contrastText(color), + ImeDrawableFactory.rounded( + color = color, + radiusPx = dp(context, radiusDp.toInt()).toFloat(), + strokeColor = contrastText(color), + strokeWidthPx = dp(context, 1), ), ) addState( intArrayOf(-android.R.attr.state_enabled), - rounded( + ImeDrawableFactory.rounded( context.getColor(R.color.setup_muted), dp(context, radiusDp.toInt()).toFloat(), ), ) addState( intArrayOf(), - rounded(color, dp(context, radiusDp.toInt()).toFloat()), + ImeDrawableFactory.rounded( + color, + dp(context, radiusDp.toInt()).toFloat(), + ), ) } } /** Secondary rounded-rectangle control used by setup actions. */ - fun mutedPillBackground(context: Context): StateListDrawable { + fun secondaryBackground(context: Context): StateListDrawable { val surface = context.getColor(R.color.setup_muted) val pressed = context.getColor(R.color.setup_muted_pressed) val accent = accent(context) - val radius = dp(context, ImeGeometryTokens.SETUP_PILL_RADIUS_DP).toFloat() + val radius = dp(context, ImeGeometryTokens.CONTROL_RADIUS_DP).toFloat() return StateListDrawable().apply { addState( intArrayOf(android.R.attr.state_enabled, android.R.attr.state_pressed), - rounded(pressed, radius), + ImeDrawableFactory.rounded(pressed, radius), ) addState( intArrayOf(android.R.attr.state_focused), - rounded(surface, radius, accent), + ImeDrawableFactory.rounded( + color = surface, + radiusPx = radius, + strokeColor = accent, + strokeWidthPx = dp(context, 1), + ), ) addState( intArrayOf(-android.R.attr.state_enabled), - rounded(dim(surface, 0.72f), radius), + ImeDrawableFactory.rounded(dim(surface, 0.72f), radius), ) - addState(intArrayOf(), rounded(surface, radius)) + addState(intArrayOf(), ImeDrawableFactory.rounded(surface, radius)) } } fun secondaryButton(context: Context, label: String, onClick: () -> Unit): Button = Button(context).apply { text = label - textSize = 12f + textSize = ImeTypographyTokens.BODY_SP isAllCaps = false minHeight = dp(context, 48) minWidth = 0 @@ -115,20 +146,38 @@ object SetupUi { return StateListDrawable().apply { addState( intArrayOf(android.R.attr.state_pressed), - rounded(dim(surface, 0.94f), radius, line), + ImeDrawableFactory.rounded( + color = dim(surface, 0.94f), + radiusPx = radius, + strokeColor = line, + strokeWidthPx = dp(context, 1), + ), ) addState( intArrayOf(android.R.attr.state_focused), - rounded(surface, radius, accent(context)), + ImeDrawableFactory.rounded( + color = surface, + radiusPx = radius, + strokeColor = accent(context), + strokeWidthPx = dp(context, 1), + ), + ) + addState( + intArrayOf(), + ImeDrawableFactory.rounded( + color = surface, + radiusPx = radius, + strokeColor = line, + strokeWidthPx = dp(context, 1), + ), ) - addState(intArrayOf(), rounded(surface, radius, line)) } } fun primaryButton(context: Context, label: String, onClick: () -> Unit): Button = Button(context).apply { text = label - textSize = 15f + textSize = ImeTypographyTokens.BODY_SP isAllCaps = false minHeight = dp(context, ImeGeometryTokens.PRIMARY_ROW_HEIGHT_DP) background = buttonBackground( @@ -178,13 +227,18 @@ object SetupUi { radiusPx = radius, focusedStrokeColor = accent, defaultStrokeColor = null, - strokeWidthPx = dp(context, 2), + strokeWidthPx = dp(context, ImeSpacingTokens.XXS_DP), ) } fun styleInput(context: Context, input: EditText) { input.background = inputBackground(context) - input.setPadding(dp(context, 16), dp(context, 8), dp(context, 16), dp(context, 8)) + input.setPadding( + dp(context, ImeSpacingTokens.LG_DP), + dp(context, ImeSpacingTokens.SM_DP), + dp(context, ImeSpacingTokens.LG_DP), + dp(context, ImeSpacingTokens.SM_DP), + ) input.setTextColor(context.getColor(R.color.setup_title)) input.setHintTextColor(context.getColor(R.color.setup_muted_text)) styleCursor(context, input) @@ -204,6 +258,73 @@ object SetupUi { } } + fun activityTopBar( + context: Context, + title: String, + backContentDescription: String = "返回", + onBack: () -> Unit, + ): LinearLayout = + LinearLayout(context).apply { + orientation = LinearLayout.HORIZONTAL + gravity = Gravity.CENTER_VERTICAL + minimumHeight = dp(context, ImeGeometryTokens.TOP_BAR_HEIGHT_DP) + + addView( + ImageButton(context).apply { + setImageResource(R.drawable.ic_arrow_back) + imageTintList = ColorStateList.valueOf(accent(context)) + scaleType = android.widget.ImageView.ScaleType.CENTER + setPadding( + dp(context, ImeSpacingTokens.MD_DP), + dp(context, ImeSpacingTokens.LG_DP), + dp(context, ImeSpacingTokens.MD_DP), + dp(context, ImeSpacingTokens.LG_DP), + ) + contentDescription = backContentDescription + minimumWidth = dp(context, ImeGeometryTokens.TOUCH_TARGET_DP) + minimumHeight = dp(context, ImeGeometryTokens.TOUCH_TARGET_DP) + isClickable = true + isFocusable = true + val selectable = TypedValue() + if ( + context.theme.resolveAttribute( + android.R.attr.selectableItemBackgroundBorderless, + selectable, + true, + ) && selectable.resourceId != 0 + ) { + setBackgroundResource(selectable.resourceId) + } else { + background = null + } + setOnClickListener { + performHapticFeedback(HapticFeedbackConstants.VIRTUAL_KEY) + onBack() + } + }, + LinearLayout.LayoutParams( + dp(context, ImeGeometryTokens.TOUCH_TARGET_DP), + dp(context, ImeGeometryTokens.TOP_BAR_HEIGHT_DP), + ), + ) + addView( + TextView(context).apply { + text = title + textSize = ImeTypographyTokens.TITLE_SP + setTextColor(context.getColor(R.color.setup_title)) + setTypeface(typeface, android.graphics.Typeface.BOLD) + gravity = Gravity.CENTER_VERTICAL + includeFontPadding = false + if (Build.VERSION.SDK_INT >= 28) setAccessibilityHeading(true) + }, + LinearLayout.LayoutParams( + 0, + dp(context, ImeGeometryTokens.TOP_BAR_HEIGHT_DP), + 1f, + ), + ) + } + fun styleDialog(dialog: AlertDialog, context: Context, destructivePositive: Boolean = false) { val accent = accent(context) val alertTitleId = context.resources.getIdentifier("alertTitle", "id", "android") @@ -216,12 +337,12 @@ object SetupUi { context.getColor(R.color.setup_body), ) dialog.getButton(AlertDialog.BUTTON_POSITIVE)?.setTextColor( - if (destructivePositive) Color.parseColor(DESTRUCTIVE) else accent, + if (destructivePositive) tokens(context).danger else accent, ) dialog.getButton(AlertDialog.BUTTON_NEGATIVE)?.setTextColor(accent) dialog.getButton(AlertDialog.BUTTON_NEUTRAL)?.setTextColor(accent) dialog.window?.setBackgroundDrawable( - rounded( + ImeDrawableFactory.rounded( context.getColor(R.color.setup_surface), dp(context, ImeGeometryTokens.DIALOG_RADIUS_DP).toFloat(), ), diff --git a/app/src/main/java/llc/slacker/openime/SpaceVoiceGestureController.kt b/app/src/main/java/llc/slacker/openime/SpaceVoiceGestureController.kt index d96b4ace..e69b4299 100644 --- a/app/src/main/java/llc/slacker/openime/SpaceVoiceGestureController.kt +++ b/app/src/main/java/llc/slacker/openime/SpaceVoiceGestureController.kt @@ -4,13 +4,15 @@ import android.os.Handler import android.os.Looper import android.view.View import android.view.ViewConfiguration +import kotlin.math.abs /** * Owns the touch state for the combined space/voice key. * - * A short press remains the key's normal click. Holding past Android's - * configured long-press timeout arms voice; sliding upward toggles cancel - * preview, and release either stops or cancels the active voice gesture. + * A short press remains space. A deliberate horizontal drag enters cursor + * movement before the long-press timeout. Holding still past Android's + * configured long-press timeout arms voice; sliding upward then toggles the + * voice cancel preview. */ internal class SpaceVoiceGestureController( private val toPx: (Int) -> Int, @@ -20,6 +22,7 @@ internal class SpaceVoiceGestureController( private val onVoiceStop: () -> Unit, private val onVoiceCancel: () -> Unit, private val onCancelPreviewChanged: (Boolean) -> Unit, + private val onCursorStep: (Int) -> Unit, ) { private val handler = Handler(Looper.getMainLooper()) private val longPressTimeoutMs = ViewConfiguration.getLongPressTimeout().toLong() @@ -33,12 +36,18 @@ internal class SpaceVoiceGestureController( var pointerId: Int = -1 private set + var cursorMode: Boolean = false + private set + + private var downX = 0f private var downY = 0f + private var lastX = 0f + private var cursorTravel = 0f private var cancelPreview = false private var anchor: View? = null private val armVoice = Runnable { - if (!trackingTouch || active || !canStartVoice()) return@Runnable + if (!trackingTouch || active || cursorMode || !canStartVoice()) return@Runnable active = true cancelPreview = false onArmFeedback() @@ -48,25 +57,64 @@ internal class SpaceVoiceGestureController( fun begin( anchor: View, pointerId: Int, + rawX: Float, rawY: Float, ) { reset() trackingTouch = true active = false + cursorMode = false this.anchor = anchor this.pointerId = pointerId + downX = rawX downY = rawY + lastX = rawX + cursorTravel = 0f cancelPreview = false handler.postDelayed(armVoice, longPressTimeoutMs) } /** - * @return true once voice owns the gesture, so the normal key click is - * suppressed for MOVE/UP after the long press has armed. + * @return true after either cursor movement or voice owns the gesture. */ - fun move(rawY: Float): Boolean { - if (!trackingTouch || !active) return false - val cancelNow = downY - rawY >= toPx(CANCEL_DISTANCE_DP) + fun move(rawX: Float, rawY: Float): Boolean { + if (!trackingTouch) return false + + if (!active) { + val horizontal = rawX - downX + val vertical = rawY - downY + if ( + !cursorMode && + abs(horizontal) >= toPx(CURSOR_MODE_START_DP) && + abs(horizontal) > abs(vertical) * 1.25f + ) { + cursorMode = true + cursorTravel = 0f + lastX = rawX + handler.removeCallbacks(armVoice) + anchor?.isPressed = false + } + + if (cursorMode) { + cursorTravel += rawX - lastX + lastX = rawX + val step = toPx(CURSOR_STEP_DP).coerceAtLeast(1).toFloat() + while (abs(cursorTravel) >= step) { + val direction = if (cursorTravel > 0f) 1 else -1 + onCursorStep(direction) + cursorTravel -= step * direction + } + return true + } + return false + } + + val upwardDistance = downY - rawY + val cancelNow = if (cancelPreview) { + upwardDistance >= toPx(CANCEL_EXIT_DISTANCE_DP) + } else { + upwardDistance >= toPx(CANCEL_ENTER_DISTANCE_DP) + } if (cancelNow != cancelPreview) { cancelPreview = cancelNow onCancelPreviewChanged(cancelNow) @@ -75,20 +123,39 @@ internal class SpaceVoiceGestureController( } /** - * @return true when an armed voice gesture consumed this release; false - * for a short press so the normal View click can commit a space. + * @return true when cursor/voice owned this release; false for a short tap + * so the normal View click can commit one space. */ fun finish(cancelled: Boolean): Boolean { if (!trackingTouch) return false handler.removeCallbacks(armVoice) + + if (cursorMode) { + trackingTouch = false + active = false + cursorMode = false + pointerId = -1 + downX = 0f + downY = 0f + lastX = 0f + cursorTravel = 0f + cancelPreview = false + anchor = null + return true + } + val wasActive = active val shouldCancel = wasActive && (cancelPreview || cancelled) if (wasActive) anchor?.isPressed = false trackingTouch = false active = false + cursorMode = false pointerId = -1 + downX = 0f downY = 0f + lastX = 0f + cursorTravel = 0f cancelPreview = false anchor = null @@ -107,8 +174,12 @@ internal class SpaceVoiceGestureController( if (active) anchor?.isPressed = false trackingTouch = false active = false + cursorMode = false pointerId = -1 + downX = 0f downY = 0f + lastX = 0f + cursorTravel = 0f cancelPreview = false anchor = null } @@ -119,6 +190,9 @@ internal class SpaceVoiceGestureController( } private companion object { - const val CANCEL_DISTANCE_DP = 48 + const val CANCEL_ENTER_DISTANCE_DP = 72 + const val CANCEL_EXIT_DISTANCE_DP = 48 + const val CURSOR_MODE_START_DP = 18 + const val CURSOR_STEP_DP = 12 } } diff --git a/app/src/main/java/llc/slacker/openime/SpaceVoiceKeyFactory.kt b/app/src/main/java/llc/slacker/openime/SpaceVoiceKeyFactory.kt index 6a076468..3d6de4c8 100644 --- a/app/src/main/java/llc/slacker/openime/SpaceVoiceKeyFactory.kt +++ b/app/src/main/java/llc/slacker/openime/SpaceVoiceKeyFactory.kt @@ -29,7 +29,7 @@ internal class SpaceVoiceKeyFactory( } }.apply { tag = "key-space" - contentDescription = "$label,点击空格,长按语音输入" + contentDescription = "$label,点击空格,左右滑动移动光标,长按语音输入" setOnLongClickListener { if (!canStartVoice()) return@setOnLongClickListener true @@ -59,6 +59,7 @@ internal class SpaceVoiceKeyFactory( gestureController.begin( anchor = view, pointerId = event.getPointerId(event.actionIndex), + rawX = event.rawX, rawY = event.rawY, ) false @@ -71,8 +72,9 @@ internal class SpaceVoiceKeyFactory( if (wasTracking) suppressNextTap = true wasTracking } else { + val pointerX = event.rawX + event.getX(index) - event.x val pointerY = event.rawY + event.getY(index) - event.y - gestureController.move(pointerY) + gestureController.move(pointerX, pointerY) } } MotionEvent.ACTION_POINTER_UP -> { diff --git a/app/src/main/java/llc/slacker/openime/SymbolManagerActivity.kt b/app/src/main/java/llc/slacker/openime/SymbolManagerActivity.kt index 06762f1c..bd7295a0 100644 --- a/app/src/main/java/llc/slacker/openime/SymbolManagerActivity.kt +++ b/app/src/main/java/llc/slacker/openime/SymbolManagerActivity.kt @@ -2,21 +2,17 @@ package llc.slacker.openime import android.app.Activity import android.content.ClipData -import android.content.res.ColorStateList import android.os.Build import android.os.Bundle import android.text.Editable import android.text.TextUtils import android.text.TextWatcher -import android.util.TypedValue import android.view.DragEvent import android.view.Gravity -import android.view.HapticFeedbackConstants import android.view.View import android.view.WindowInsets import android.view.inputmethod.EditorInfo import android.widget.EditText -import android.widget.ImageButton import android.widget.LinearLayout import android.widget.ScrollView import android.widget.TextView @@ -91,38 +87,21 @@ class SymbolManagerActivity : Activity() { orientation = LinearLayout.VERTICAL setPadding(dp(20), dp(24), dp(20), dp(24)) } - val title = TextView(this).apply { - text = "自定义符号" - textSize = 22f - setTextColor(getColor(R.color.setup_title)) - setTypeface(typeface, android.graphics.Typeface.BOLD) - gravity = Gravity.CENTER_VERTICAL - includeFontPadding = false - if (Build.VERSION.SDK_INT >= 28) setAccessibilityHeading(true) - } - val header = LinearLayout(this).apply { - orientation = LinearLayout.HORIZONTAL - gravity = Gravity.CENTER_VERTICAL - addView(ImageButton(this@SymbolManagerActivity).apply { - setImageResource(R.drawable.ic_arrow_back) - imageTintList = ColorStateList.valueOf(accent) - contentDescription = "返回" - setMinimumWidth(dp(ImeGeometryTokens.TOUCH_TARGET_DP)) - setMinimumHeight(dp(ImeGeometryTokens.TOUCH_TARGET_DP)) - isClickable = true - isFocusable = true - applySelectableBackground(this) - setOnClickListener { - it.performHapticFeedback(HapticFeedbackConstants.VIRTUAL_KEY) - requestClose() - } - }, LinearLayout.LayoutParams(dp(ImeGeometryTokens.TOUCH_TARGET_DP), dp(56))) - addView(title, LinearLayout.LayoutParams(0, dp(56), 1f)) + val header = SetupUi.activityTopBar( + context = this, + title = "自定义符号", + ) { + requestClose() } - content.addView(header, fullHeight(56).apply { bottomMargin = dp(8) }) + content.addView( + header, + fullHeight(ImeGeometryTokens.TOP_BAR_HEIGHT_DP).apply { + bottomMargin = dp(ImeSpacingTokens.SM_DP) + }, + ) content.addView(TextView(this).apply { text = "可添加、分类、固定和删除。点击箭头调整顺序,也可长按符号行拖动排序。" - textSize = 13f + textSize = ImeTypographyTokens.BODY_SP setTextColor(getColor(R.color.setup_body)) setLineSpacing(dp(2).toFloat(), 1f) setPadding(0, 0, 0, dp(12)) @@ -133,7 +112,7 @@ class SymbolManagerActivity : Activity() { hint = "分组,例如:常用箭头" setSingleLine(true) imeOptions = EditorInfo.IME_ACTION_NEXT - textSize = 16f + textSize = ImeTypographyTokens.BODY_SP setText(draftGroup) } SetupUi.styleInput(this, groupEdit) @@ -144,7 +123,7 @@ class SymbolManagerActivity : Activity() { hint = "符号,例如:⇢ 或 自定义文本" setSingleLine(true) imeOptions = EditorInfo.IME_ACTION_DONE - textSize = 20f + textSize = ImeTypographyTokens.BODY_SP setText(draftSymbol) } SetupUi.styleInput(this, symbolEdit) @@ -208,7 +187,7 @@ class SymbolManagerActivity : Activity() { content.addView(save, fullHeight(ImeGeometryTokens.PRIMARY_ROW_HEIGHT_DP).apply { bottomMargin = dp(20) }) content.addView(TextView(this).apply { text = "已保存符号" - textSize = 16f + textSize = ImeTypographyTokens.TITLE_SP setTextColor(getColor(R.color.setup_title)) setTypeface(typeface, android.graphics.Typeface.BOLD) setPadding(0, 0, 0, dp(8)) @@ -219,7 +198,7 @@ class SymbolManagerActivity : Activity() { if (symbols.isEmpty()) { content.addView(TextView(this).apply { text = "还没有保存的自定义符号;在上方填写后点击保存符号。" - textSize = 13f + textSize = ImeTypographyTokens.BODY_SP setTextColor(getColor(R.color.setup_body)) setPadding(dp(4), dp(4), dp(4), dp(8)) tag = "symbol-empty-state" @@ -229,7 +208,7 @@ class SymbolManagerActivity : Activity() { .forEach { (group, groupedSymbols) -> content.addView(TextView(this).apply { text = group - textSize = 14f + textSize = ImeTypographyTokens.TITLE_SP setTextColor(accent) setTypeface(typeface, android.graphics.Typeface.BOLD) setPadding(0, dp(8), 0, dp(4)) @@ -279,7 +258,7 @@ class SymbolManagerActivity : Activity() { ) addView(TextView(this@SymbolManagerActivity).apply { text = item.symbol - textSize = 21f + textSize = ImeTypographyTokens.KEY_LETTER_SP setTextColor(getColor(R.color.setup_title)) maxLines = 2 ellipsize = TextUtils.TruncateAt.END @@ -288,7 +267,7 @@ class SymbolManagerActivity : Activity() { }, fullWrap()) addView(TextView(this@SymbolManagerActivity).apply { text = if (item.pinned) "已固定" else "未固定" - textSize = 12f + textSize = ImeTypographyTokens.CAPTION_SP setTextColor(if (item.pinned) accent else getColor(R.color.setup_body)) importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO }, fullWrap()) @@ -387,7 +366,7 @@ class SymbolManagerActivity : Activity() { private fun fieldLabel(label: String, targetId: Int) = TextView(this).apply { text = label - textSize = 12f + textSize = ImeTypographyTokens.BODY_SP setTextColor(getColor(R.color.setup_body)) setPadding(dp(4), 0, dp(4), dp(4)) labelFor = targetId @@ -421,19 +400,6 @@ class SymbolManagerActivity : Activity() { groupEdit.text.toString() != initialGroup || symbolEdit.text.toString() != initialSymbol - private fun applySelectableBackground(view: View) { - val value = TypedValue() - if ( - theme.resolveAttribute( - android.R.attr.selectableItemBackgroundBorderless, - value, - true, - ) && value.resourceId != 0 - ) { - view.setBackgroundResource(value.resourceId) - } - } - private fun fullWrap() = LinearLayout.LayoutParams( LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT, diff --git a/app/src/main/java/llc/slacker/openime/SymbolRailRenderer.kt b/app/src/main/java/llc/slacker/openime/SymbolRailRenderer.kt index 53c1e7ef..3a0fcd72 100644 --- a/app/src/main/java/llc/slacker/openime/SymbolRailRenderer.kt +++ b/app/src/main/java/llc/slacker/openime/SymbolRailRenderer.kt @@ -110,7 +110,7 @@ internal object SymbolRailRenderer { onFeedback: () -> Unit, ): TextView = TextView(context).apply { text = symbol - textSize = if (symbol.length > 2) 12f else 17f + textSize = ImeTypographyTokens.BODY_SP gravity = Gravity.CENTER tag = "$tagPrefix$symbol" contentDescription = symbol diff --git a/app/src/main/java/llc/slacker/openime/TextEditorPanelController.kt b/app/src/main/java/llc/slacker/openime/TextEditorPanelController.kt index a469aae7..8b80808d 100644 --- a/app/src/main/java/llc/slacker/openime/TextEditorPanelController.kt +++ b/app/src/main/java/llc/slacker/openime/TextEditorPanelController.kt @@ -56,7 +56,7 @@ internal class TextEditorPanelController( orientation = LinearLayout.HORIZONTAL } QUICK_ACTIONS.forEach { (label, action) -> - val control = createKey(label, 10f) { onTextEdit(action) }.apply { + val control = createKey(label, ImeTypographyTokens.CAPTION_SP) { onTextEdit(action) }.apply { tag = "textedit-action:$action" } actionControls[action] = ActionControl(label, control) @@ -85,7 +85,7 @@ internal class TextEditorPanelController( center: Boolean = false, ): TextView = createPanelButton( label ?: "", - if (center) 9f else 14f, + if (center) ImeTypographyTokens.CAPTION_SP else ImeTypographyTokens.BODY_SP, !center, ).apply { if (action != null) { @@ -104,9 +104,9 @@ internal class TextEditorPanelController( } listOf( - listOf(cell(), cell("▲", "up"), cell()), - listOf(cell("◀", "left"), cell(center = true), cell("▶", "right")), - listOf(cell(), cell("▼", "down"), cell()), + listOf(cell(), cell("上", "up"), cell()), + listOf(cell("左", "left"), cell(center = true), cell("右", "right")), + listOf(cell(), cell("下", "down"), cell()), ).forEach { rowItems -> val row = LinearLayout(context).apply { orientation = LinearLayout.HORIZONTAL @@ -193,7 +193,7 @@ internal class TextEditorPanelController( val unavailable = reason != null control.view.isEnabled = !unavailable control.view.isClickable = !unavailable - control.view.alpha = if (unavailable) 0.38f else 1f + control.view.alpha = if (unavailable) ImeSurfacePolicy.DISABLED_ALPHA else 1f control.view.contentDescription = if (unavailable && Build.VERSION.SDK_INT < 30) { "${control.label},不可用:$reason" diff --git a/app/src/main/java/llc/slacker/openime/UserDataArchive.kt b/app/src/main/java/llc/slacker/openime/UserDataArchive.kt new file mode 100644 index 00000000..3b1bb6f3 --- /dev/null +++ b/app/src/main/java/llc/slacker/openime/UserDataArchive.kt @@ -0,0 +1,433 @@ +package llc.slacker.openime + +import android.content.Context +import org.json.JSONArray +import org.json.JSONObject + +internal data class ArchiveQuickPhrase( + val category: String, + val text: String, + val inputCode: String, +) + +internal data class ArchiveCustomSymbol( + val group: String, + val symbol: String, + val pinned: Boolean, +) + +internal data class ArchiveUserPhrase( + val code: String, + val text: String, + val frequency: Int, + val lastUsed: Long, +) + +internal data class ArchiveSettings( + val theme: String, + val appearance: String, + val sound: Boolean, + val haptic: Boolean, + val popup: Boolean, + val fuzzy: Boolean, + val skinOpacity: Int, + val skinRadius: Int, + val skinFont: Int, + val skinColor: String, + val preferredChineseMode: String, + val handedness: String, + val keyboardHeightPercent: Int, + val floatingWidthPercent: Int, + val floatingOpacityPercent: Int, +) + +internal data class RimeUserDictionaryArchive( + val name: String, + val content: String, +) + +internal data class UserDataArchive( + val schemaVersion: Int = UserDataArchiveCodec.SCHEMA_VERSION, + val quickPhrases: List, + val customSymbols: List, + val userPhrases: List, + val settings: ArchiveSettings, + val rimeUserDictionaries: List = emptyList(), +) + +internal data class UserDataImportPreview( + val quickPhrasesToAdd: Int, + val customSymbolsToAdd: Int, + val userPhrasesToAdd: Int, + val settingsWillChange: Boolean, + val rimeDictionaryFiles: Int, +) { + val totalKotlinItemsToAdd: Int + get() = quickPhrasesToAdd + customSymbolsToAdd + userPhrasesToAdd +} + +internal object UserDataArchiveCodec { + const val SCHEMA_VERSION = 1 + + fun encode(value: UserDataArchive): String { + val root = JSONObject() + .put("schema_version", value.schemaVersion) + .put("quick_phrases", JSONArray().apply { + value.quickPhrases.forEach { item -> + put( + JSONObject() + .put("category", item.category) + .put("text", item.text) + .put("input_code", item.inputCode), + ) + } + }) + .put("custom_symbols", JSONArray().apply { + value.customSymbols.forEach { item -> + put( + JSONObject() + .put("group", item.group) + .put("symbol", item.symbol) + .put("pinned", item.pinned), + ) + } + }) + .put("user_phrases", JSONArray().apply { + value.userPhrases.forEach { item -> + put( + JSONObject() + .put("code", item.code) + .put("text", item.text) + .put("frequency", item.frequency) + .put("last_used", item.lastUsed), + ) + } + }) + .put("settings", settingsToJson(value.settings)) + .put("rime_user_dictionaries", JSONArray().apply { + value.rimeUserDictionaries.forEach { item -> + put( + JSONObject() + .put("name", item.name) + .put("content", item.content), + ) + } + }) + return root.toString(2) + } + + fun decode(raw: String): UserDataArchive { + val root = JSONObject(raw) + val version = root.optInt("schema_version", -1) + require(version == SCHEMA_VERSION) { + "Unsupported openIME data schema: $version" + } + return UserDataArchive( + schemaVersion = version, + quickPhrases = root.optJSONArray("quick_phrases").toObjects { item -> + ArchiveQuickPhrase( + category = item.optString("category", "常用").ifBlank { "常用" }, + text = item.optString("text", "").trim(), + inputCode = item.optString("input_code", "").trim(), + ) + }.filter { it.text.isNotEmpty() }, + customSymbols = root.optJSONArray("custom_symbols").toObjects { item -> + ArchiveCustomSymbol( + group = item.optString("group", "自定义").ifBlank { "自定义" }, + symbol = item.optString("symbol", "").trim(), + pinned = item.optBoolean("pinned", false), + ) + }.filter { it.symbol.isNotEmpty() }, + userPhrases = root.optJSONArray("user_phrases").toObjects { item -> + ArchiveUserPhrase( + code = item.optString("code", "").trim(), + text = item.optString("text", "").trim(), + frequency = item.optInt("frequency", 1).coerceAtLeast(1), + lastUsed = item.optLong("last_used", 0L).coerceAtLeast(0L), + ) + }.filter { it.code.isNotEmpty() && it.text.isNotEmpty() }, + settings = settingsFromJson(root.optJSONObject("settings") ?: JSONObject()), + rimeUserDictionaries = + root.optJSONArray("rime_user_dictionaries").toObjects { item -> + RimeUserDictionaryArchive( + name = item.optString("name", "").trim(), + content = item.optString("content", ""), + ) + }.filter { + isSafeRimeDictionaryName(it.name) && it.content.isNotEmpty() + }.distinctBy { it.name }, + ) + } + + internal fun isSafeRimeDictionaryName(name: String): Boolean = + name.length in 1..128 && + name != "." && + name != ".." && + name.none { char -> char == '/' || char == '\\' || char == '\u0000' } + + private fun settingsToJson(value: ArchiveSettings): JSONObject = + JSONObject() + .put("theme", value.theme) + .put("appearance", value.appearance) + .put("sound", value.sound) + .put("haptic", value.haptic) + .put("popup", value.popup) + .put("fuzzy", value.fuzzy) + .put("skin_opacity", value.skinOpacity) + .put("skin_radius", value.skinRadius) + .put("skin_font", value.skinFont) + .put("skin_color", value.skinColor) + .put("preferred_chinese_mode", value.preferredChineseMode) + .put("handedness", value.handedness) + .put("keyboard_height_percent", value.keyboardHeightPercent) + .put("floating_width_percent", value.floatingWidthPercent) + .put("floating_opacity_percent", value.floatingOpacityPercent) + + private fun settingsFromJson(value: JSONObject): ArchiveSettings = + ArchiveSettings( + theme = value.optString("theme", ImeTheme.IOS.name), + appearance = value.optString("appearance", ImeAppearance.SYSTEM.name), + sound = value.optBoolean("sound", true), + haptic = value.optBoolean("haptic", true), + popup = value.optBoolean("popup", false), + fuzzy = value.optBoolean("fuzzy", false), + skinOpacity = value.optInt("skin_opacity", 95).coerceIn(70, 100), + skinRadius = value.optInt("skin_radius", 8).coerceIn(0, 24), + skinFont = value.optInt("skin_font", 17).coerceIn(14, 22), + skinColor = AccentPalette.normalize(value.optString("skin_color", AccentPalette.DEFAULT)), + preferredChineseMode = value.optString( + "preferred_chinese_mode", + KeyboardMode.PINYIN_26.name, + ), + handedness = value.optString("handedness", ImeHandedness.STANDARD.name), + keyboardHeightPercent = + value.optInt("keyboard_height_percent", 100).coerceIn(92, 120), + floatingWidthPercent = + value.optInt("floating_width_percent", 88).coerceIn(72, 96), + floatingOpacityPercent = + value.optInt("floating_opacity_percent", 100).coerceIn(82, 100), + ) + + private inline fun JSONArray?.toObjects(block: (JSONObject) -> T): List { + if (this == null) return emptyList() + return buildList { + for (index in 0 until length()) { + optJSONObject(index)?.let { add(block(it)) } + } + } + } +} + +internal object UserDataArchiveMerger { + fun preview(existing: UserDataArchive, incoming: UserDataArchive): UserDataImportPreview { + val existingQuick = existing.quickPhrases.mapTo(HashSet(), ::quickKey) + val existingSymbols = existing.customSymbols.mapTo(HashSet(), ::symbolKey) + val existingUser = existing.userPhrases.mapTo(HashSet(), ::userPhraseKey) + return UserDataImportPreview( + quickPhrasesToAdd = incoming.quickPhrases.distinctBy(::quickKey) + .count { quickKey(it) !in existingQuick }, + customSymbolsToAdd = incoming.customSymbols.distinctBy(::symbolKey) + .count { symbolKey(it) !in existingSymbols }, + userPhrasesToAdd = incoming.userPhrases.distinctBy(::userPhraseKey) + .count { userPhraseKey(it) !in existingUser }, + settingsWillChange = incoming.settings != existing.settings, + rimeDictionaryFiles = incoming.rimeUserDictionaries + .map { it.name } + .distinct() + .size, + ) + } + + fun merge(existing: UserDataArchive, incoming: UserDataArchive): UserDataArchive { + val quick = existing.quickPhrases.toMutableList() + val quickKeys = quick.mapTo(HashSet(), ::quickKey) + incoming.quickPhrases.forEach { item -> + if (quickKeys.add(quickKey(item))) quick += item + } + + val symbols = existing.customSymbols.toMutableList() + val symbolKeys = symbols.mapTo(HashSet(), ::symbolKey) + incoming.customSymbols.forEach { item -> + if (symbolKeys.add(symbolKey(item))) symbols += item + } + + val users = LinkedHashMap() + existing.userPhrases.forEach { users[userPhraseKey(it)] = it } + incoming.userPhrases.forEach { item -> + val key = userPhraseKey(item) + val current = users[key] + users[key] = if (current == null) { + item + } else { + current.copy( + frequency = maxOf(current.frequency, item.frequency), + lastUsed = maxOf(current.lastUsed, item.lastUsed), + ) + } + } + + val rime = LinkedHashMap() + existing.rimeUserDictionaries.forEach { rime[it.name] = it } + incoming.rimeUserDictionaries.forEach { rime[it.name] = it } + + return UserDataArchive( + quickPhrases = quick, + customSymbols = symbols, + userPhrases = users.values.toList(), + settings = incoming.settings, + rimeUserDictionaries = rime.values.toList(), + ) + } + + private fun quickKey(value: ArchiveQuickPhrase): String = + listOf(value.category.trim(), value.text.trim(), value.inputCode.trim().lowercase()) + .joinToString("\u0000") + + private fun symbolKey(value: ArchiveCustomSymbol): String = + listOf(value.group.trim(), value.symbol.trim()).joinToString("\u0000") + + private fun userPhraseKey(value: ArchiveUserPhrase): String = + listOf(value.code.trim().lowercase(), value.text.trim()).joinToString("\u0000") +} + +internal object UserDataRepository { + fun snapshot( + context: Context, + rimeUserDictionaries: List = emptyList(), + ): UserDataArchive { + UserPhraseRepository.configure(context) + return UserDataArchive( + quickPhrases = QuickPhraseRepository.load(context).map { + ArchiveQuickPhrase( + category = it.category, + text = it.text, + inputCode = it.inputCode, + ) + }, + customSymbols = CustomSymbolRepository.load(context).map { + ArchiveCustomSymbol( + group = it.group, + symbol = it.symbol, + pinned = it.pinned, + ) + }, + userPhrases = UserPhraseRepository.exportArchiveEntries(), + settings = ArchiveSettings( + theme = ImeSettingsRepository.loadTheme(context).name, + appearance = ImeSettingsRepository.loadAppearance(context).name, + sound = ImeSettingsRepository.loadSound(context), + haptic = ImeSettingsRepository.loadHaptic(context), + popup = ImeSettingsRepository.loadPopup(context), + fuzzy = ImeSettingsRepository.loadFuzzy(context), + skinOpacity = ImeSettingsRepository.loadSkinOpacity(context), + skinRadius = ImeSettingsRepository.loadSkinRadius(context), + skinFont = ImeSettingsRepository.loadSkinFont(context), + skinColor = ImeSettingsRepository.loadSkinColor(context), + preferredChineseMode = + ImeSettingsRepository.loadPreferredChineseMode(context).name, + handedness = ImeSettingsRepository.loadHandedness(context).name, + keyboardHeightPercent = + ImeSettingsRepository.loadKeyboardHeightPercent(context), + floatingWidthPercent = + ImeSettingsRepository.loadFloatingWidthPercent(context), + floatingOpacityPercent = + ImeSettingsRepository.loadFloatingOpacityPercent(context), + ), + rimeUserDictionaries = rimeUserDictionaries, + ) + } + + fun preview(context: Context, incoming: UserDataArchive): UserDataImportPreview = + UserDataArchiveMerger.preview(snapshot(context), incoming) + + fun importKotlinData(context: Context, incoming: UserDataArchive): UserDataImportPreview { + val preview = preview(context, incoming) + + val quickKeys = QuickPhraseRepository.load(context) + .mapTo(HashSet()) { + Triple(it.category.trim(), it.text.trim(), it.inputCode.trim().lowercase()) + } + incoming.quickPhrases.forEach { item -> + val key = Triple( + item.category.trim(), + item.text.trim(), + item.inputCode.trim().lowercase(), + ) + if (quickKeys.add(key)) { + QuickPhraseRepository.upsert( + context = context, + id = 0L, + category = item.category, + text = item.text, + inputCode = item.inputCode, + ) + } + } + + val symbolKeys = CustomSymbolRepository.load(context) + .mapTo(HashSet()) { it.group.trim() to it.symbol.trim() } + incoming.customSymbols.forEach { item -> + val key = item.group.trim() to item.symbol.trim() + if (symbolKeys.add(key)) { + val added = CustomSymbolRepository.upsert( + context = context, + id = 0L, + group = item.group, + symbol = item.symbol, + ) + if (item.pinned && added != null) { + CustomSymbolRepository.togglePinned(context, added.id) + } + } + } + + UserPhraseRepository.mergeArchiveEntries(incoming.userPhrases) + applySettings(context, incoming.settings) + return preview + } + + private fun applySettings(context: Context, value: ArchiveSettings) { + ImeSettingsRepository.saveTheme( + context, + runCatching { ImeTheme.valueOf(value.theme) }.getOrDefault(ImeTheme.IOS), + ) + ImeSettingsRepository.saveAppearance( + context, + runCatching { ImeAppearance.valueOf(value.appearance) } + .getOrDefault(ImeAppearance.SYSTEM), + ) + ImeSettingsRepository.saveSound(context, value.sound) + ImeSettingsRepository.saveHaptic(context, value.haptic) + ImeSettingsRepository.savePopup(context, value.popup) + ImeSettingsRepository.saveFuzzy(context, value.fuzzy) + ImeSettingsRepository.saveSkin( + context, + opacity = value.skinOpacity.coerceIn(70, 100), + radius = value.skinRadius.coerceIn(0, 24), + fontSize = value.skinFont.coerceIn(14, 22), + primaryColor = value.skinColor, + ) + ImeSettingsRepository.savePreferredChineseMode( + context, + runCatching { KeyboardMode.valueOf(value.preferredChineseMode) } + .getOrDefault(KeyboardMode.PINYIN_26), + ) + ImeSettingsRepository.saveHandedness( + context, + runCatching { ImeHandedness.valueOf(value.handedness) } + .getOrDefault(ImeHandedness.STANDARD), + ) + ImeSettingsRepository.saveKeyboardHeightPercent( + context, + value.keyboardHeightPercent, + ) + ImeSettingsRepository.saveFloatingWidthPercent( + context, + value.floatingWidthPercent, + ) + ImeSettingsRepository.saveFloatingOpacityPercent( + context, + value.floatingOpacityPercent, + ) + } +} diff --git a/app/src/main/java/llc/slacker/openime/UserPhraseRepository.kt b/app/src/main/java/llc/slacker/openime/UserPhraseRepository.kt index eac84f16..46ceb480 100644 --- a/app/src/main/java/llc/slacker/openime/UserPhraseRepository.kt +++ b/app/src/main/java/llc/slacker/openime/UserPhraseRepository.kt @@ -96,6 +96,31 @@ object UserPhraseRepository { } } + fun contains(code: String, text: String): Boolean { + val key = normalize(code) + val value = text.trim() + if (key.isEmpty() || value.isEmpty()) return false + synchronized(lock) { + return entries.containsKey("$key\u0000$value") + } + } + + /** + * Forget one exact fallback-learned phrase. This repository is used only + * while librime is unavailable, so deleting here never mutates the native + * Rime user database. + */ + fun forget(code: String, text: String): Boolean { + val key = normalize(code) + val value = text.trim() + if (key.isEmpty() || value.isEmpty()) return false + synchronized(lock) { + val removed = entries.remove("$key\u0000$value") != null + if (removed) scheduleSaveLocked() + return removed + } + } + /** Write the latest immutable snapshot synchronously when a lifecycle owner needs durability now. */ fun flush() { synchronized(persistenceLock) { @@ -166,6 +191,57 @@ object UserPhraseRepository { } } + internal fun exportArchiveEntries(): List = + synchronized(lock) { + entries.values + .sortedWith( + compareByDescending { it.lastUsed } + .thenByDescending { it.frequency }, + ) + .map { entry -> + ArchiveUserPhrase( + code = entry.code, + text = entry.text, + frequency = entry.frequency, + lastUsed = entry.lastUsed, + ) + } + } + + internal fun mergeArchiveEntries(incoming: List): Int { + var added = 0 + synchronized(lock) { + incoming.forEach { item -> + val code = normalize(item.code) + val text = item.text.trim() + if (code.isEmpty() || text.isEmpty() || text == code) return@forEach + val key = "$code\u0000$text" + val current = entries[key] + if (current == null) { + entries[key] = Entry( + code = code, + text = text, + frequency = item.frequency.coerceIn(1, 1_000_000), + lastUsed = item.lastUsed.coerceAtLeast(0L), + ) + added += 1 + } else { + current.frequency = maxOf( + current.frequency, + item.frequency.coerceIn(1, 1_000_000), + ) + current.lastUsed = maxOf( + current.lastUsed, + item.lastUsed.coerceAtLeast(0L), + ) + } + } + trimLocked() + if (incoming.isNotEmpty()) scheduleSaveLocked() + } + return added + } + fun clear() { synchronized(persistenceLock) { synchronized(lock) { diff --git a/app/src/main/java/llc/slacker/openime/VoicePanelController.kt b/app/src/main/java/llc/slacker/openime/VoicePanelController.kt index 081df889..aef63828 100644 --- a/app/src/main/java/llc/slacker/openime/VoicePanelController.kt +++ b/app/src/main/java/llc/slacker/openime/VoicePanelController.kt @@ -93,7 +93,7 @@ internal class VoicePanelController( refreshLanguageControl() onInlineState("正在准备麦克风…", false, false, null) - setMicState("⏹", "语音输入进行中,松开空格结束,上滑取消") + setMicState(R.drawable.ic_stop, "语音输入进行中,松开空格结束,上滑取消") setGestureHint( "松开空格上屏 · 上滑取消", "松开空格结束语音并自动上屏,上滑取消", @@ -111,7 +111,7 @@ internal class VoicePanelController( fun stop() { if (!active || stopRequested) return listener.stopVoiceRecognition() - setMicState("🎤", "正在整理语音识别结果,请稍候") + setMicState(R.drawable.ic_mic, "正在整理语音识别结果,请稍候") setGestureHint("整理识别结果…", "正在整理语音识别结果,请稍候") stopRequested = true refreshLanguageControl() @@ -130,7 +130,7 @@ internal class VoicePanelController( refreshLanguageControl() listener.cancelVoiceRecognition() recognizedText = "" - setMicState("🎤", "语音状态,已取消,仅支持长按空格启动") + setMicState(R.drawable.ic_mic, "语音状态,已取消,仅支持长按空格启动") setGestureHint( "长按空格开始", "长按空格开始语音,松开自动上屏,上滑取消", @@ -145,7 +145,7 @@ internal class VoicePanelController( fun setCancelPreview(cancelling: Boolean) { cancelPreview = cancelling if (cancelling) { - setMicState("⏹", "取消语音输入中,松开将丢弃本次语音") + setMicState(R.drawable.ic_stop, "取消语音输入中,松开将丢弃本次语音") setGestureHint( "上滑取消 · 松开丢弃", "继续上滑取消语音,松开将丢弃本次语音", @@ -154,7 +154,7 @@ internal class VoicePanelController( setModelStatus("取消状态 · 松开将丢弃") onInlineState("松开取消", true, false, null) } else { - setMicState("⏹", "语音输入进行中,松开空格结束,上滑取消") + setMicState(R.drawable.ic_stop, "语音输入进行中,松开空格结束,上滑取消") setGestureHint( "松开空格上屏 · 上滑取消", "松开空格结束语音并自动上屏,上滑取消", @@ -205,7 +205,7 @@ internal class VoicePanelController( if (stopRequested) { setModelStatus("正在整理识别结果…") - setMicState("⏹", "正在整理语音识别结果,请稍候") + setMicState(R.drawable.ic_stop, "正在整理语音识别结果,请稍候") setGestureHint( "整理识别结果…", "正在整理语音识别结果,请稍候", @@ -213,7 +213,7 @@ internal class VoicePanelController( onInlineState("正在识别…", false, false, null) } else { setModelStatus("正在聆听 · 松开空格结束") - setMicState("⏹", "语音输入进行中,松开空格结束,上滑取消") + setMicState(R.drawable.ic_stop, "语音输入进行中,松开空格结束,上滑取消") setGestureHint( "松开空格上屏 · 上滑取消", "松开空格结束语音并自动上屏,上滑取消", @@ -239,7 +239,7 @@ internal class VoicePanelController( eventGeneration++ if (text.isNotBlank()) recognizedText = text setTranscript(text) - setMicState("🎤", "语音状态,已完成识别,仅支持长按空格启动") + setMicState(R.drawable.ic_mic, "语音状态,已完成识别,仅支持长按空格启动") setGestureHint( "长按空格开始", "长按空格开始语音,松开自动上屏,上滑取消", @@ -303,7 +303,7 @@ internal class VoicePanelController( eventGeneration++ recognizedText = "" setTranscript(message) - setMicState("🎤", "语音状态,识别失败,仅支持长按空格重试") + setMicState(R.drawable.ic_mic, "语音状态,识别失败,仅支持长按空格重试") setGestureHint( "长按空格开始", "长按空格重新开始语音,松开自动上屏,上滑取消", @@ -329,7 +329,7 @@ internal class VoicePanelController( expandedPanel.post { if (generation != eventGeneration || cancelled) return@post if (active && !stopRequested) { - setMicState("⏹", "语音输入进行中,松开空格结束,上滑取消") + setMicState(R.drawable.ic_stop, "语音输入进行中,松开空格结束,上滑取消") setGestureHint( "松开空格上屏 · 上滑取消", "松开空格结束语音并自动上屏,上滑取消", @@ -342,7 +342,7 @@ internal class VoicePanelController( null, ) } else { - setMicState("⏹", "正在整理语音识别结果,请稍候") + setMicState(R.drawable.ic_stop, "正在整理语音识别结果,请稍候") setGestureHint( "整理识别结果…", "正在整理语音识别结果,请稍候", @@ -365,7 +365,7 @@ internal class VoicePanelController( return@post } modelPrepared = true - setMicState("⏹", "语音输入进行中,松开空格结束,上滑取消") + setMicState(R.drawable.ic_stop, "语音输入进行中,松开空格结束,上滑取消") setGestureHint( "松开空格上屏 · 上滑取消", "松开空格结束语音并自动上屏,上滑取消", @@ -391,7 +391,7 @@ internal class VoicePanelController( private fun syncViewPresentation() { when { active && stopRequested -> { - setMicState("🎤", "正在整理语音识别结果,请稍候") + setMicState(R.drawable.ic_mic, "正在整理语音识别结果,请稍候") setGestureHint( "整理识别结果…", "正在整理语音识别结果,请稍候", @@ -402,7 +402,7 @@ internal class VoicePanelController( ) } active -> { - setMicState("⏹", "语音输入进行中,松开空格结束,上滑取消") + setMicState(R.drawable.ic_stop, "语音输入进行中,松开空格结束,上滑取消") setGestureHint( "松开空格上屏 · 上滑取消", "松开空格结束语音并自动上屏,上滑取消", @@ -415,8 +415,8 @@ internal class VoicePanelController( } } - private fun setMicState(icon: String, description: String) { - view?.setMicState(icon, description) + private fun setMicState(iconRes: Int, description: String) { + view?.setMicState(iconRes, description) } private fun setGestureHint(label: String, description: String) { diff --git a/app/src/main/java/llc/slacker/openime/VoicePanelView.kt b/app/src/main/java/llc/slacker/openime/VoicePanelView.kt index 236a870a..7563b3b9 100644 --- a/app/src/main/java/llc/slacker/openime/VoicePanelView.kt +++ b/app/src/main/java/llc/slacker/openime/VoicePanelView.kt @@ -63,7 +63,7 @@ internal class VoicePanelView( transcript.apply { text = "只需长按空格;松开自动上屏,上滑取消" - textSize = 16f + textSize = ImeTypographyTokens.BODY_SP gravity = Gravity.CENTER_VERTICAL maxLines = 2 ellipsize = TextUtils.TruncateAt.END @@ -103,7 +103,7 @@ internal class VoicePanelView( } languageButton = createButton( LANGUAGES[languageIndex].first, - 13f, + ImeTypographyTokens.BODY_SP, true, ).apply { tag = "voice-language" @@ -124,8 +124,10 @@ internal class VoicePanelView( ), ) - micButton = createButton("🎤", 18f, false).apply { + micButton = createButton("", ImeTypographyTokens.BODY_SP, false).apply { tag = "voice-mic" + setCompoundDrawablesRelativeWithIntrinsicBounds(R.drawable.ic_mic, 0, 0, 0) + compoundDrawablePadding = 0 isEnabled = false contentDescription = "语音状态,当前未开始,仅支持长按空格启动" } @@ -137,7 +139,7 @@ internal class VoicePanelView( ), ) - gestureHint = createButton("长按空格开始", 13f, true).apply { + gestureHint = createButton("长按空格开始", ImeTypographyTokens.BODY_SP, true).apply { tag = "voice-gesture-hint" isEnabled = false contentDescription = "长按空格开始语音,松开自动上屏,上滑取消" @@ -165,12 +167,12 @@ internal class VoicePanelView( fun refreshLanguageControl(locked: Boolean) { languageButton.isEnabled = !locked languageButton.isClickable = !locked - languageButton.alpha = if (locked) 0.52f else 1f + languageButton.alpha = if (locked) ImeSurfacePolicy.DISABLED_ALPHA else 1f updateLanguagePresentation(locked) } - fun setMicState(icon: String, description: String) { - micButton.text = icon + fun setMicState(iconRes: Int, description: String) { + micButton.setCompoundDrawablesRelativeWithIntrinsicBounds(iconRes, 0, 0, 0) micButton.contentDescription = description } diff --git a/app/src/main/java/llc/slacker/openime/VoiceRecognitionBackend.kt b/app/src/main/java/llc/slacker/openime/VoiceRecognitionBackend.kt index cb4a1942..a6b71cf3 100644 --- a/app/src/main/java/llc/slacker/openime/VoiceRecognitionBackend.kt +++ b/app/src/main/java/llc/slacker/openime/VoiceRecognitionBackend.kt @@ -1,12 +1,13 @@ package llc.slacker.openime import android.content.Context +import android.util.Log import com.k2fsa.sherpa.onnx.FeatureConfig import com.k2fsa.sherpa.onnx.OnlineModelConfig +import com.k2fsa.sherpa.onnx.OnlineParaformerModelConfig import com.k2fsa.sherpa.onnx.OnlineRecognizer import com.k2fsa.sherpa.onnx.OnlineRecognizerConfig import com.k2fsa.sherpa.onnx.OnlineStream -import com.k2fsa.sherpa.onnx.OnlineTransducerModelConfig import java.io.File import java.util.concurrent.atomic.AtomicBoolean @@ -156,7 +157,7 @@ object EmbeddedVoiceRuntimeFactory { selection: VoiceModelSelection, ): EmbeddedVoiceModelRuntime? { val manifest = selection.manifest ?: return null - if (manifest.modelType != "zipformer") return null + if (manifest.modelType != "paraformer") return null val downloadedRoot = File(context.filesDir, VOICE_MODEL_DOWNLOADED_DIR) if (resolveSherpaRuntimeModelFiles(selection, downloadedRoot) == null) return null return SherpaOnnxStreamingRuntime(context, selection) @@ -164,7 +165,7 @@ object EmbeddedVoiceRuntimeFactory { } /** - * Streaming Zipformer runtime for either signed APK assets or a verified + * Streaming Paraformer runtime for either signed APK assets or a verified * app-private downloaded package. Model loading is lazy and the recognizer is * reused until the selected source changes or the lifecycle releases it. */ @@ -174,6 +175,7 @@ private class SherpaOnnxStreamingRuntime( ) : StreamingEmbeddedVoiceModelRuntime { companion object { private const val SAMPLE_RATE = LocalVoiceAudioSpec.SAMPLE_RATE + private const val PARAFORMER_TAIL_PADDING_SAMPLES = SAMPLE_RATE * 3 / 10 } private val runtimeLock = Any() @@ -188,7 +190,7 @@ private class SherpaOnnxStreamingRuntime( override val isReady: Boolean get() = synchronized(runtimeLock) { val manifest = selection.manifest - manifest?.modelType == "zipformer" && modelFiles != null + manifest?.modelType == "paraformer" && modelFiles != null } override fun preload() { @@ -256,21 +258,17 @@ private class SherpaOnnxStreamingRuntime( val config = OnlineRecognizerConfig( featConfig = FeatureConfig(sampleRate = SAMPLE_RATE, featureDim = 80), modelConfig = OnlineModelConfig( - transducer = OnlineTransducerModelConfig( + paraformer = OnlineParaformerModelConfig( encoder = files.encoder, decoder = files.decoder, - joiner = files.joiner, ), tokens = files.tokens, numThreads = 2, provider = "cpu", - modelType = "zipformer", - modelingUnit = "cjkchar", + modelType = "paraformer", ), enableEndpoint = false, - decodingMethod = "modified_beam_search", - maxActivePaths = 4, - hotwordsScore = 1.8f, + decodingMethod = "greedy_search", ) val assetManager = if (files.storage == SherpaRuntimeStorage.ASSETS) { context.assets @@ -285,12 +283,10 @@ private class SherpaOnnxStreamingRuntime( events: VoiceRecognitionEvents, ): SherpaSession { val currentRecognizer = ensureRecognizerLocked() - val hotwords = VoiceHotwordProvider.current() - val stream = if (hotwords.isBlank()) { - currentRecognizer.createStream() - } else { - currentRecognizer.createStream(hotwords) - } + // sherpa-onnx dynamic hotword graphs are implemented by the + // transducer recognizer. Streaming Paraformer uses a plain stream; + // openIME still applies its local post-ASR correction repository. + val stream = currentRecognizer.createStream() return SherpaSession( streamLease = VoiceStreamLease(stream) { it.release() }, languageTag = languageTag, @@ -322,10 +318,21 @@ private class SherpaOnnxStreamingRuntime( checkOpenLocked() val currentRecognizer = checkNotNull(recognizer) { "语音识别器已释放" } val currentStream = streamLease.value + // sherpa-onnx's streaming Paraformer examples append 300 ms + // silence before InputFinished so the last chunk can flush. + currentStream.acceptWaveform(FloatArray(PARAFORMER_TAIL_PADDING_SAMPLES), SAMPLE_RATE) currentStream.inputFinished() val decoded = decodeReadyLocked(currentRecognizer, currentStream) - val final = currentRecognizer.getResult(currentStream).text.trim() - .ifBlank { lastPartial } + val recognizerFinal = currentRecognizer.getResult(currentStream).text.trim() + val usedFallback = recognizerFinal.isBlank() && lastPartial.isNotBlank() + if (usedFallback) { + Log.i( + "OpenImeVoicePerf", + "sherpa inputFinished fallback=true finalLength=${recognizerFinal.length} " + + "lastPartialLength=${lastPartial.length}", + ) + } + val final = recognizerFinal.ifBlank { lastPartial } lastPartial = final final to decoded } diff --git a/app/src/main/java/llc/slacker/openime/VoiceRuntimeModelSource.kt b/app/src/main/java/llc/slacker/openime/VoiceRuntimeModelSource.kt index 20bd567a..913807a8 100644 --- a/app/src/main/java/llc/slacker/openime/VoiceRuntimeModelSource.kt +++ b/app/src/main/java/llc/slacker/openime/VoiceRuntimeModelSource.kt @@ -4,10 +4,9 @@ import java.io.File import java.nio.file.Files internal const val VOICE_MODEL_DOWNLOADED_DIR = "voice-models" -internal const val SHERPA_MODEL_ROOT = "models/voice/bilingual-zipformer" -internal const val SHERPA_ENCODER = "$SHERPA_MODEL_ROOT/encoder-epoch-99-avg-1.int8.onnx" -internal const val SHERPA_DECODER = "$SHERPA_MODEL_ROOT/decoder-epoch-99-avg-1.onnx" -internal const val SHERPA_JOINER = "$SHERPA_MODEL_ROOT/joiner-epoch-99-avg-1.int8.onnx" +internal const val SHERPA_MODEL_ROOT = "models/voice/bilingual-paraformer" +internal const val SHERPA_ENCODER = "$SHERPA_MODEL_ROOT/encoder.int8.onnx" +internal const val SHERPA_DECODER = "$SHERPA_MODEL_ROOT/decoder.int8.onnx" internal const val SHERPA_TOKENS = "$SHERPA_MODEL_ROOT/tokens.txt" internal enum class SherpaRuntimeStorage { @@ -19,7 +18,6 @@ internal data class SherpaRuntimeModelFiles( val storage: SherpaRuntimeStorage, val encoder: String, val decoder: String, - val joiner: String, val tokens: String, ) @@ -38,8 +36,8 @@ internal fun resolveSherpaRuntimeModelFiles( downloadedRoot: File, ): SherpaRuntimeModelFiles? { val manifest = selection.manifest ?: return null - if (manifest.modelType != "zipformer") return null - val required = listOf(SHERPA_ENCODER, SHERPA_DECODER, SHERPA_JOINER, SHERPA_TOKENS) + if (manifest.modelType != "paraformer") return null + val required = listOf(SHERPA_ENCODER, SHERPA_DECODER, SHERPA_TOKENS) if (!manifest.files.containsAll(required)) return null return when (selection.source) { @@ -47,7 +45,6 @@ internal fun resolveSherpaRuntimeModelFiles( storage = SherpaRuntimeStorage.ASSETS, encoder = SHERPA_ENCODER, decoder = SHERPA_DECODER, - joiner = SHERPA_JOINER, tokens = SHERPA_TOKENS, ) @@ -69,7 +66,6 @@ internal fun resolveSherpaRuntimeModelFiles( storage = SherpaRuntimeStorage.FILES, encoder = resolve(SHERPA_ENCODER), decoder = resolve(SHERPA_DECODER), - joiner = resolve(SHERPA_JOINER), tokens = resolve(SHERPA_TOKENS), ) }.getOrNull() diff --git a/app/src/main/res/drawable/bg_setup_card.xml b/app/src/main/res/drawable/bg_setup_card.xml new file mode 100644 index 00000000..06b5cff6 --- /dev/null +++ b/app/src/main/res/drawable/bg_setup_card.xml @@ -0,0 +1,9 @@ + + + + + + diff --git a/app/src/main/res/drawable/bg_setup_mark.xml b/app/src/main/res/drawable/bg_setup_mark.xml index e3bf82d7..1997b25b 100644 --- a/app/src/main/res/drawable/bg_setup_mark.xml +++ b/app/src/main/res/drawable/bg_setup_mark.xml @@ -1,5 +1,6 @@ - - - + + + diff --git a/app/src/main/res/drawable/bg_setup_mark_active.xml b/app/src/main/res/drawable/bg_setup_mark_active.xml index 1377d0ec..3fb11ae5 100644 --- a/app/src/main/res/drawable/bg_setup_mark_active.xml +++ b/app/src/main/res/drawable/bg_setup_mark_active.xml @@ -1,5 +1,5 @@ - - - + + diff --git a/app/src/main/res/drawable/bg_setup_mark_done.xml b/app/src/main/res/drawable/bg_setup_mark_done.xml index f603b24a..79be4f9b 100644 --- a/app/src/main/res/drawable/bg_setup_mark_done.xml +++ b/app/src/main/res/drawable/bg_setup_mark_done.xml @@ -1,5 +1,5 @@ - - - + + diff --git a/app/src/main/res/drawable/bg_setup_muted_pill.xml b/app/src/main/res/drawable/bg_setup_muted.xml similarity index 100% rename from app/src/main/res/drawable/bg_setup_muted_pill.xml rename to app/src/main/res/drawable/bg_setup_muted.xml diff --git a/app/src/main/res/drawable/bg_setup_primary_pill.xml b/app/src/main/res/drawable/bg_setup_primary.xml similarity index 100% rename from app/src/main/res/drawable/bg_setup_primary_pill.xml rename to app/src/main/res/drawable/bg_setup_primary.xml diff --git a/app/src/main/res/drawable/ic_backspace.xml b/app/src/main/res/drawable/ic_backspace.xml index 54f03480..8ce73a5e 100644 --- a/app/src/main/res/drawable/ic_backspace.xml +++ b/app/src/main/res/drawable/ic_backspace.xml @@ -1,6 +1,6 @@ + + diff --git a/app/src/main/res/drawable/ic_caps_lock.xml b/app/src/main/res/drawable/ic_caps_lock.xml index c399a5fa..a161eda3 100644 --- a/app/src/main/res/drawable/ic_caps_lock.xml +++ b/app/src/main/res/drawable/ic_caps_lock.xml @@ -1,6 +1,6 @@ + + diff --git a/app/src/main/res/drawable/ic_chevron_down.xml b/app/src/main/res/drawable/ic_chevron_down.xml new file mode 100644 index 00000000..e84e73eb --- /dev/null +++ b/app/src/main/res/drawable/ic_chevron_down.xml @@ -0,0 +1,8 @@ + + + diff --git a/app/src/main/res/drawable/ic_clipboard.xml b/app/src/main/res/drawable/ic_clipboard.xml index da312e9c..bd260caf 100644 --- a/app/src/main/res/drawable/ic_clipboard.xml +++ b/app/src/main/res/drawable/ic_clipboard.xml @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_close.xml b/app/src/main/res/drawable/ic_close.xml new file mode 100644 index 00000000..113b04b0 --- /dev/null +++ b/app/src/main/res/drawable/ic_close.xml @@ -0,0 +1,8 @@ + + + diff --git a/app/src/main/res/drawable/ic_edit.xml b/app/src/main/res/drawable/ic_edit.xml index 70cb6c85..ad93744e 100644 --- a/app/src/main/res/drawable/ic_edit.xml +++ b/app/src/main/res/drawable/ic_edit.xml @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_emoji.xml b/app/src/main/res/drawable/ic_emoji.xml index c72b8a1f..f8fbb147 100644 --- a/app/src/main/res/drawable/ic_emoji.xml +++ b/app/src/main/res/drawable/ic_emoji.xml @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_game.xml b/app/src/main/res/drawable/ic_game.xml index e3d50d91..b644590b 100644 --- a/app/src/main/res/drawable/ic_game.xml +++ b/app/src/main/res/drawable/ic_game.xml @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_grid.xml b/app/src/main/res/drawable/ic_grid.xml index d5ac16f1..5c8f1cb2 100644 --- a/app/src/main/res/drawable/ic_grid.xml +++ b/app/src/main/res/drawable/ic_grid.xml @@ -1,7 +1,9 @@ - + android:width="24dp" + android:height="24dp" + android:viewportWidth="24" + android:viewportHeight="24"> + diff --git a/app/src/main/res/drawable/ic_handwriting.xml b/app/src/main/res/drawable/ic_handwriting.xml index dc6adb3a..a2eab4df 100644 --- a/app/src/main/res/drawable/ic_handwriting.xml +++ b/app/src/main/res/drawable/ic_handwriting.xml @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_info.xml b/app/src/main/res/drawable/ic_info.xml new file mode 100644 index 00000000..878f0ece --- /dev/null +++ b/app/src/main/res/drawable/ic_info.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_keyboard.xml b/app/src/main/res/drawable/ic_keyboard.xml index 0c48ec6a..decb2778 100644 --- a/app/src/main/res/drawable/ic_keyboard.xml +++ b/app/src/main/res/drawable/ic_keyboard.xml @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_keyboard_hide.xml b/app/src/main/res/drawable/ic_keyboard_hide.xml index 12a38a02..969d51c4 100644 --- a/app/src/main/res/drawable/ic_keyboard_hide.xml +++ b/app/src/main/res/drawable/ic_keyboard_hide.xml @@ -4,10 +4,7 @@ android:viewportWidth="24" android:viewportHeight="24"> + android:fillColor="#FF000000" + android:fillType="evenOdd" + android:pathData="M3,3h18v12H3zM5,6h2v2H5zM9,6h2v2H9zM13,6h2v2h-2zM17,6h2v2h-2zM6,10h12v2H6zM7,17h10l-5,5z" /> diff --git a/app/src/main/res/drawable/ic_launcher.xml b/app/src/main/res/drawable/ic_launcher.xml deleted file mode 100644 index 44226a42..00000000 --- a/app/src/main/res/drawable/ic_launcher.xml +++ /dev/null @@ -1 +0,0 @@ - diff --git a/app/src/main/res/drawable/ic_launcher_foreground.xml b/app/src/main/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 00000000..fc620fcf --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_mic.xml b/app/src/main/res/drawable/ic_mic.xml index cfd8d10c..94769cde 100644 --- a/app/src/main/res/drawable/ic_mic.xml +++ b/app/src/main/res/drawable/ic_mic.xml @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_more.xml b/app/src/main/res/drawable/ic_more.xml index 6021c998..05f4c07d 100644 --- a/app/src/main/res/drawable/ic_more.xml +++ b/app/src/main/res/drawable/ic_more.xml @@ -1,5 +1,9 @@ - + android:width="24dp" + android:height="24dp" + android:viewportWidth="24" + android:viewportHeight="24"> + diff --git a/app/src/main/res/drawable/ic_settings.xml b/app/src/main/res/drawable/ic_settings.xml index 67eb13bc..8f789b5c 100644 --- a/app/src/main/res/drawable/ic_settings.xml +++ b/app/src/main/res/drawable/ic_settings.xml @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_shift.xml b/app/src/main/res/drawable/ic_shift.xml index d8be8b5b..d05525b9 100644 --- a/app/src/main/res/drawable/ic_shift.xml +++ b/app/src/main/res/drawable/ic_shift.xml @@ -1,6 +1,6 @@ + + diff --git a/app/src/main/res/drawable/ic_symbols.xml b/app/src/main/res/drawable/ic_symbols.xml index 50990c96..2a6870d3 100644 --- a/app/src/main/res/drawable/ic_symbols.xml +++ b/app/src/main/res/drawable/ic_symbols.xml @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml index 97df6522..e80e5be9 100644 --- a/app/src/main/res/layout/activity_main.xml +++ b/app/src/main/res/layout/activity_main.xml @@ -9,254 +9,323 @@ + android:orientation="vertical" + android:paddingStart="@dimen/ime_space_xl" + android:paddingTop="@dimen/ime_space_xxl" + android:paddingEnd="@dimen/ime_space_xl" + android:paddingBottom="@dimen/ime_space_xxl"> - - - + android:importantForAccessibility="no" + android:src="@drawable/ic_brand_mark" /> + android:textSize="@dimen/ime_text_display" + android:textStyle="bold" /> + android:textSize="@dimen/ime_text_body" /> + android:textSize="@dimen/ime_text_body" /> + android:paddingStart="@dimen/ime_space_lg" + android:paddingEnd="@dimen/ime_space_lg"> + + android:textSize="@dimen/ime_text_body" + android:textStyle="bold" /> + - + + + android:layout_width="@dimen/setup_icon_size" + android:layout_height="@dimen/setup_icon_size" + android:importantForAccessibility="no" + android:rotation="180" + android:src="@drawable/ic_arrow_back" + android:tint="@color/setup_body" /> + android:paddingStart="@dimen/ime_space_lg" + android:paddingEnd="@dimen/ime_space_lg"> + + android:textSize="@dimen/ime_text_body" + android:textStyle="bold" /> + - + + + + + + + + + + + + + + + + + android:textSize="@dimen/ime_text_body" /> + + + android:padding="@dimen/ime_space_sm"> + + + + + + + + + android:minLines="3" + android:textColor="@color/setup_title" + android:textColorHint="@color/setup_muted_text" + android:textSize="@dimen/ime_text_title" /> + + android:textSize="@dimen/ime_text_body" /> + android:paddingStart="@dimen/ime_space_lg" + android:paddingEnd="@dimen/ime_space_lg"> + - - + android:textSize="@dimen/ime_text_title" + android:textStyle="bold" /> - - - + - - diff --git a/app/src/main/res/mipmap-anydpi/ic_launcher.xml b/app/src/main/res/mipmap-anydpi/ic_launcher.xml new file mode 100644 index 00000000..5c84730c --- /dev/null +++ b/app/src/main/res/mipmap-anydpi/ic_launcher.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/app/src/main/res/mipmap-anydpi/ic_launcher_round.xml b/app/src/main/res/mipmap-anydpi/ic_launcher_round.xml new file mode 100644 index 00000000..5c84730c --- /dev/null +++ b/app/src/main/res/mipmap-anydpi/ic_launcher_round.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/app/src/main/res/values-en/strings.xml b/app/src/main/res/values-en/strings.xml new file mode 100644 index 00000000..c1428fb0 --- /dev/null +++ b/app/src/main/res/values-en/strings.xml @@ -0,0 +1,36 @@ + + + openIME + Chinese + English + Local Pinyin input method\nDictionary and voice recognition run on-device + Try typing here + Enable + Enabled + Switch + Switched to openIME + Enable openIME to start typing + Enabled. Switch to openIME to type + Ready. Try typing below + Type nihao to get 你好. Tap a candidate to commit it, or press Space to choose the first candidate. + Voice input needs microphone permission. Audio is processed only on this device and is not uploaded. You can grant permission now or skip it and continue testing. + Microphone permission was not granted, so voice input is unavailable. Grant it to speak by holding Space. + Allow + Open system settings + Skip + Skipped. You can grant permission at any time + Allowed + Microphone permission is granted. Hold Space to speak, then release it to recognize your speech. + Try typing + Typing test complete + Open preferences + Enable and switch to openIME before opening preferences + Accent color + Custom + Enter a 6-digit hexadecimal color, for example 5B6B7A + Preferences + Preferences + Quick phrases + Custom symbols + About & data + diff --git a/app/src/main/res/values-night/colors.xml b/app/src/main/res/values-night/colors.xml index 0bc75d2c..b29c2e4f 100644 --- a/app/src/main/res/values-night/colors.xml +++ b/app/src/main/res/values-night/colors.xml @@ -1,19 +1,20 @@ - #121417 - #1C1F24 - #F3F4F6 - #9AA3B2 - #1D9BF0 - #1688D0 + #1C1C1E + #242426 + #F2F2F7 + #AEAEB2 + #6EC3F7 + #50B6F5 #07131D - #262A31 - #30353E - #8B93A3 + #242426 + #303033 + #AEAEB2 #5BD08A - #1B3A28 + #242426 #5BD08A - #243056 - #3A404A - #243056 + #242426 + #48484A + #242426 + #6EC3F7 diff --git a/app/src/main/res/values-v31/themes.xml b/app/src/main/res/values-v31/themes.xml new file mode 100644 index 00000000..a013ae1b --- /dev/null +++ b/app/src/main/res/values-v31/themes.xml @@ -0,0 +1,15 @@ + + + + diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml index 92572005..47c231ab 100644 --- a/app/src/main/res/values/colors.xml +++ b/app/src/main/res/values/colors.xml @@ -1,19 +1,21 @@ - #F4F6FA - #FFFFFFFF - #1C1D21 - #6B7280 + #D5D8DE + #EEF0F3 + #1C1C1E + #6D6D72 #1D9BF0 - #1688D0 + #0E85D5 #07131D - #EEF1F6 - #E3E7EE - #9AA3B2 + #EEF0F3 + #DDE1E7 + #6D6D72 #1F8A4C - #E8F7EE + #EEF0F3 #1F8A4C - #E8EEFF - #D7DCE5 - #E8EEFF + #EEF0F3 + #B7BCC5 + #EEF0F3 + #1D9BF0 + #1D9BF0 diff --git a/app/src/main/res/values/dimens.xml b/app/src/main/res/values/dimens.xml new file mode 100644 index 00000000..f6c3e14a --- /dev/null +++ b/app/src/main/res/values/dimens.xml @@ -0,0 +1,23 @@ + + + 11sp + 14sp + 16sp + 18sp + 21sp + 28sp + + 2dp + 4dp + 8dp + 12dp + 16dp + 24dp + 32dp + 48dp + 56dp + 24dp + 28dp + 16dp + 72dp + diff --git a/app/src/main/res/values/editor_ids.xml b/app/src/main/res/values/editor_ids.xml index a931bebc..7f77112f 100644 --- a/app/src/main/res/values/editor_ids.xml +++ b/app/src/main/res/values/editor_ids.xml @@ -1,6 +1,7 @@ + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 967177ce..db440796 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -5,21 +5,32 @@ 英文 本地拼音输入法\n词库和语音都在设备内运行 在这里试打 - 第一步 启用 openIME - 已启用,可再次打开 - 第二步 切换到 openIME - 当前正在使用,可切换 + 启用 + 已启用 + 切换 + 已切换到 openIME 启用后即可开始输入 已启用,切换后就能打字 已就绪,在下方试打 输入 nihao 得到 你好。点候选上屏,点空格选首选。 - 语音可选。授权后长按空格说话,不授权也能打字。 - 开启本地语音 - 麦克风已授权 + 语音输入需要麦克风权限。音频只在本机处理,不会上传。可选择授权,也可以跳过后继续试打。 + 未获得麦克风授权,语音输入无法使用。授权后即可长按空格说话。 + 授权 + 前往系统设置 + 跳过 + 已跳过,可随时授权 + 已授权 + 麦克风已授权,语音输入可以使用。长按空格开始说话,松手即可识别。 + 试打 + 试打完成 打开偏好设置 先启用并切换到 openIME,再进入设置 强调色 自定义 输入 6 位十六进制颜色,例如 5B6B7A 偏好设置 + 偏好设置 + 常用语 + 自定义符号 + 关于与数据 diff --git a/app/src/test/java/llc/slacker/openime/ContrastTest.kt b/app/src/test/java/llc/slacker/openime/ContrastTest.kt new file mode 100644 index 00000000..c794ae33 --- /dev/null +++ b/app/src/test/java/llc/slacker/openime/ContrastTest.kt @@ -0,0 +1,43 @@ +package llc.slacker.openime + +import kotlin.math.max +import kotlin.math.min +import kotlin.math.pow +import org.junit.Assert.assertTrue +import org.junit.Test + +class ContrastTest { + @Test + fun canonicalTextPairsMeetWcagAa() { + listOf( + "#1C1C1E" to "#FFFFFF", + "#6D6D72" to "#EEF0F3", + "#1F2023" to "#F7F8FA", + "#F2F2F7" to "#3A3A3C", + "#AEAEB2" to "#242426", + "#07131D" to "#1D9BF0", + "#07131D" to "#6EC3F7", + ).forEach { (foreground, background) -> + val ratio = contrastRatio(foreground, background) + assertTrue( + "$foreground on $background must be >= 4.5:1 but was $ratio", + ratio >= 4.5, + ) + } + } + + private fun contrastRatio(first: String, second: String): Double { + val a = luminance(first) + val b = luminance(second) + return (max(a, b) + 0.05) / (min(a, b) + 0.05) + } + + private fun luminance(hex: String): Double { + val value = hex.removePrefix("#") + fun component(offset: Int): Double { + val raw = value.substring(offset, offset + 2).toInt(16) / 255.0 + return if (raw <= 0.04045) raw / 12.92 else ((raw + 0.055) / 1.055).pow(2.4) + } + return 0.2126 * component(0) + 0.7152 * component(2) + 0.0722 * component(4) + } +} diff --git a/app/src/test/java/llc/slacker/openime/IconLintTest.kt b/app/src/test/java/llc/slacker/openime/IconLintTest.kt new file mode 100644 index 00000000..e32d0469 --- /dev/null +++ b/app/src/test/java/llc/slacker/openime/IconLintTest.kt @@ -0,0 +1,58 @@ +package llc.slacker.openime + +import java.io.File +import org.junit.Assert.fail +import org.junit.Test + +class IconLintTest { + @Test + fun productIconsUseCanonicalVectorContract() { + val root = repoRoot() + val violations = mutableListOf() + File(root, "app/src/main/res/drawable") + .listFiles() + .orEmpty() + .filter { + it.isFile && + it.extension == "xml" && + it.name.startsWith("ic_") && + !it.name.startsWith("ic_launcher") && + it.name != "ic_brand_mark.xml" + } + .forEach { file -> + val xml = file.readText() + fun requireContains(value: String, message: String) { + if (!xml.contains(value)) violations += "${file.name}: $message" + } + requireContains("""android:width="24dp"""", "width must be 24dp") + requireContains("""android:height="24dp"""", "height must be 24dp") + requireContains("""android:viewportWidth="24"""", "viewportWidth must be 24") + requireContains("""android:viewportHeight="24"""", "viewportHeight must be 24") + if (xml.contains("android:strokeWidth=")) { + violations += "${file.name}: strokeWidth is forbidden" + } + val colors = Regex("""android:(?:fillColor|strokeColor)="(#[0-9A-Fa-f]{8})"""") + .findAll(xml) + .map { it.groupValues[1].uppercase() } + .toList() + if (colors.isEmpty()) { + violations += "${file.name}: icon must define a fill color" + } else if (colors.any { it != "#FF000000" }) { + violations += "${file.name}: all vector colors must be #FF000000, got $colors" + } + } + + if (violations.isNotEmpty()) { + fail("Icon contract violations:\n" + violations.joinToString("\n")) + } + } + + private fun repoRoot(): File { + var root = File(System.getProperty("user.dir")).absoluteFile + repeat(5) { + if (File(root, "app/src/main").isDirectory) return root + root = root.parentFile ?: return@repeat + } + error("Cannot locate repository root") + } +} diff --git a/app/src/test/java/llc/slacker/openime/ImeThemePersistenceTest.kt b/app/src/test/java/llc/slacker/openime/ImeThemePersistenceTest.kt new file mode 100644 index 00000000..64704ab2 --- /dev/null +++ b/app/src/test/java/llc/slacker/openime/ImeThemePersistenceTest.kt @@ -0,0 +1,18 @@ +package llc.slacker.openime + +import org.junit.Assert.assertEquals +import org.junit.Test + +class ImeThemePersistenceTest { + @Test + fun legacyThemeValuesFallBackToIos() { + listOf("DARK", "CYBERPUNK", "CLASSIC", "MACOS", "unknown", null).forEach { stored -> + assertEquals(ImeTheme.IOS, ImeSettingsRepository.parseTheme(stored)) + } + } + + @Test + fun iosThemeValueStillRestoresNormally() { + assertEquals(ImeTheme.IOS, ImeSettingsRepository.parseTheme("IOS")) + } +} diff --git a/app/src/test/java/llc/slacker/openime/InputConnectionGatewayTest.kt b/app/src/test/java/llc/slacker/openime/InputConnectionGatewayTest.kt index f7415050..247a6fa4 100644 --- a/app/src/test/java/llc/slacker/openime/InputConnectionGatewayTest.kt +++ b/app/src/test/java/llc/slacker/openime/InputConnectionGatewayTest.kt @@ -315,6 +315,54 @@ class InputConnectionGatewayTest { assertTrue(fake.events.none { it.startsWith("delete") }) } + @Test + fun clearUndoExpiresAfterFiveSeconds() { + var now = 1_000L + val fake = FakeInputConnection( + selectedText = "可恢复文本", + contextMenuResult = true, + extractedText = extracted("", 0, 0, 0), + ) + val gateway = InputConnectionGateway(null, { fake }, nowMs = { now }) + + assertTrue(gateway.clearAllText()) + assertTrue(gateway.hasClearUndo()) + + now += 5_001L + assertFalse(gateway.hasClearUndo()) + assertFalse(gateway.restoreLastClear()) + } + + @Test + fun clearUndoIsBoundToTheOriginalInputConnection() { + val first = FakeInputConnection( + selectedText = "第一个输入框", + contextMenuResult = true, + extractedText = extracted("", 0, 0, 0), + ) + val second = FakeInputConnection(extractedText = extracted("", 0, 0, 0)) + var current: InputConnection = first + val gateway = InputConnectionGateway(null, { current }, nowMs = { 1_000L }) + + assertTrue(gateway.clearAllText()) + assertTrue(gateway.hasClearUndo()) + + current = second + assertFalse(gateway.hasClearUndo()) + assertFalse(gateway.restoreLastClear()) + assertTrue(second.events.none { it.startsWith("commit:") }) + } + + @Test + fun passwordClearNeverCreatesUndoSnapshot() { + val fake = FakeInputConnection(selectedText = "secret", contextMenuResult = true) + val gateway = InputConnectionGateway(null, { fake }, isPassword = { true }) + + assertFalse(gateway.clearAllText()) + assertFalse(gateway.hasClearUndo()) + assertFalse(gateway.restoreLastClear()) + } + @Test fun failedClearAllRestoresSelectionWithoutPreDeletingContent() { val fake = FakeInputConnection( diff --git a/app/src/test/java/llc/slacker/openime/NineKeyChineseTest.kt b/app/src/test/java/llc/slacker/openime/NineKeyChineseTest.kt index deba0819..39629931 100644 --- a/app/src/test/java/llc/slacker/openime/NineKeyChineseTest.kt +++ b/app/src/test/java/llc/slacker/openime/NineKeyChineseTest.kt @@ -35,6 +35,12 @@ class NineKeyChineseTest { } } + @Test + fun incompleteDigitOnlyPrefixesStayAsDigitsInsteadOfFakeLetters() { + assertEquals("2", resolve("2").preview) + assertEquals("6", resolve("6").preview) + } + @Test fun commonChineseSequencesResolveToStableExpectedPreview() { assertEquals("ni", resolve("64").preview) diff --git a/app/src/test/java/llc/slacker/openime/NoDecorativeGlyphTest.kt b/app/src/test/java/llc/slacker/openime/NoDecorativeGlyphTest.kt new file mode 100644 index 00000000..7de71821 --- /dev/null +++ b/app/src/test/java/llc/slacker/openime/NoDecorativeGlyphTest.kt @@ -0,0 +1,49 @@ +package llc.slacker.openime + +import java.io.File +import org.junit.Assert.fail +import org.junit.Test + +class NoDecorativeGlyphTest { + @Test + fun interactiveUiDoesNotUseUnicodeAsIcons() { + val root = repoRoot() + val forbidden = listOf("⌄", "⌃", "✓", "⏹", "🎤", "☺", "›", "▲", "◀", "▶", "▼") + val excluded = setOf("ImeData.kt") + val violations = File(root, "app/src/main/java") + .walkTopDown() + .filter { it.isFile && it.extension == "kt" && it.name !in excluded } + .flatMap { file -> + file.readLines().asSequence().mapIndexedNotNull { index, line -> + val glyph = forbidden.firstOrNull(line::contains) + if (glyph != null) "${file.relativeTo(root)}:${index + 1}: $glyph" else null + } + } + .toMutableList() + + File(root, "app/src/main/res/layout") + .walkTopDown() + .filter { it.isFile && it.extension == "xml" } + .forEach { file -> + file.readLines().forEachIndexed { index, line -> + val glyph = forbidden.firstOrNull(line::contains) + if (glyph != null) { + violations += "${file.relativeTo(root)}:${index + 1}: $glyph" + } + } + } + + if (violations.isNotEmpty()) { + fail("Interactive UI glyphs must use vector drawables:\n" + violations.joinToString("\n")) + } + } + + private fun repoRoot(): File { + var root = File(System.getProperty("user.dir")).absoluteFile + repeat(5) { + if (File(root, "app/src/main").isDirectory) return root + root = root.parentFile ?: return@repeat + } + error("Cannot locate repository root") + } +} diff --git a/app/src/test/java/llc/slacker/openime/NoRawColorTest.kt b/app/src/test/java/llc/slacker/openime/NoRawColorTest.kt new file mode 100644 index 00000000..8b61d3de --- /dev/null +++ b/app/src/test/java/llc/slacker/openime/NoRawColorTest.kt @@ -0,0 +1,36 @@ +package llc.slacker.openime + +import java.io.File +import org.junit.Assert.fail +import org.junit.Test + +class NoRawColorTest { + @Test + fun productionKotlinDoesNotDefineRawColorsOutsideDesignTokens() { + val root = repoRoot() + val violations = File(root, "app/src/main/java") + .walkTopDown() + .filter { it.isFile && it.extension == "kt" && it.name != "ImeDesignTokens.kt" } + .flatMap { file -> + file.readLines().asSequence().mapIndexedNotNull { index, line -> + val hasHex = Regex("""#[0-9A-Fa-f]{6,8}""").containsMatchIn(line) + val hasParse = line.contains("Color.parseColor(") + if (hasHex || hasParse) "${file.relativeTo(root)}:${index + 1}: ${line.trim()}" else null + } + } + .toList() + + if (violations.isNotEmpty()) { + fail("Raw production colors must live in ImeDesignTokens.kt:\n" + violations.joinToString("\n")) + } + } + + private fun repoRoot(): File { + var root = File(System.getProperty("user.dir")).absoluteFile + repeat(5) { + if (File(root, "app/src/main").isDirectory) return root + root = root.parentFile ?: return@repeat + } + error("Cannot locate repository root") + } +} diff --git a/app/src/test/java/llc/slacker/openime/NoRawTextSizeTest.kt b/app/src/test/java/llc/slacker/openime/NoRawTextSizeTest.kt new file mode 100644 index 00000000..9b595bca --- /dev/null +++ b/app/src/test/java/llc/slacker/openime/NoRawTextSizeTest.kt @@ -0,0 +1,74 @@ +package llc.slacker.openime + +import java.io.File +import org.junit.Assert.fail +import org.junit.Test + +class NoRawTextSizeTest { + @Test + fun productionKotlinUsesTypographyTokens() { + val root = repoRoot() + val violations = mutableListOf() + File(root, "app/src/main/java") + .walkTopDown() + .filter { it.isFile && it.extension == "kt" && it.name != "ImeDesignTokens.kt" } + .forEach { file -> + val lines = file.readLines() + lines.forEachIndexed { index, line -> + if (Regex("""textSize\s*=\s*\d""").containsMatchIn(line)) { + violations += "${file.relativeTo(root)}:${index + 1}: ${line.trim()}" + } + if ( + listOf("createKey(", "createButton(", "createPanelButton(", "labelText(") + .any(line::contains) + ) { + val window = lines.subList(index, minOf(lines.size, index + 6)).joinToString(" ") + val rawHelperSize = Regex( + """(?:createKey|createButton|createPanelButton|labelText)\([^)]*,\s*\d+(?:\.\d+)?f\b""", + ) + if (rawHelperSize.containsMatchIn(window)) { + violations += "${file.relativeTo(root)}:${index + 1}: ${line.trim()}" + } + } + } + } + + if (violations.isNotEmpty()) { + fail("Raw text sizes must use ImeTypographyTokens:\n" + violations.distinct().joinToString("\n")) + } + } + + @Test + fun layoutXmlUsesTypographyDimens() { + val root = repoRoot() + val violations = File(root, "app/src/main/res/layout") + .walkTopDown() + .filter { it.isFile && it.extension == "xml" } + .flatMap { file -> + file.readLines().asSequence().mapIndexedNotNull { index, line -> + if ( + line.contains("android:textSize=") && + !line.contains("@dimen/ime_text_") + ) { + "${file.relativeTo(root)}:${index + 1}: ${line.trim()}" + } else { + null + } + } + } + .toList() + + if (violations.isNotEmpty()) { + fail("Layout text sizes must use ime_text_* dimens:\n" + violations.joinToString("\n")) + } + } + + private fun repoRoot(): File { + var root = File(System.getProperty("user.dir")).absoluteFile + repeat(5) { + if (File(root, "app/src/main").isDirectory) return root + root = root.parentFile ?: return@repeat + } + error("Cannot locate repository root") + } +} diff --git a/app/src/test/java/llc/slacker/openime/TokenDriftTest.kt b/app/src/test/java/llc/slacker/openime/TokenDriftTest.kt new file mode 100644 index 00000000..b2b4f6b0 --- /dev/null +++ b/app/src/test/java/llc/slacker/openime/TokenDriftTest.kt @@ -0,0 +1,118 @@ +package llc.slacker.openime + +import java.io.File +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class TokenDriftTest { + @Test + fun canonicalTokenScaleDoesNotDrift() { + val source = source("app/src/main/java/llc/slacker/openime/ImeDesignTokens.kt") + listOf( + "const val CAPTION_SP = 11f", + "const val BODY_SP = 14f", + "const val TITLE_SP = 16f", + "const val CANDIDATE_SP = 18f", + "const val KEY_LETTER_SP = 21f", + "const val DISPLAY_SP = 28f", + "const val XXS_DP = 2", + "const val XS_DP = 4", + "const val SM_DP = 8", + "const val MD_DP = 12", + "const val LG_DP = 16", + "const val XL_DP = 24", + "const val XXL_DP = 32", + "const val KEY_GAP_DP = 6", + ).forEach { expected -> + assertTrue("Missing canonical token: $expected", source.contains(expected)) + } + + val enumBlock = source.substringAfter("enum class ImeTheme").substringBefore("data class Tokens") + assertTrue(enumBlock.contains("IOS(")) + listOf("DARK(", "CYBERPUNK(", "CLASSIC(", "MACOS(").forEach { + assertFalse("Legacy theme must stay removed: $it", enumBlock.contains(it)) + } + } + + @Test + fun setupResourcePaletteMatchesNativeTokens() { + val light = source("app/src/main/res/values/colors.xml") + val dark = source("app/src/main/res/values-night/colors.xml") + + mapOf( + "setup_page_bg" to "#D5D8DE", + "setup_surface" to "#EEF0F3", + "setup_title" to "#1C1C1E", + "setup_body" to "#6D6D72", + "setup_primary" to "#1D9BF0", + "setup_input_line" to "#B7BCC5", + ).forEach { (name, value) -> + assertTrue("$name drifted from the light IME palette", light.contains("$value")) + } + mapOf( + "setup_page_bg" to "#1C1C1E", + "setup_surface" to "#242426", + "setup_title" to "#F2F2F7", + "setup_body" to "#AEAEB2", + "setup_primary" to "#6EC3F7", + "setup_input_line" to "#48484A", + ).forEach { (name, value) -> + assertTrue("$name drifted from the dark IME palette", dark.contains("$value")) + } + } + + @Test + fun setupDimensionsMirrorDesignTokens() { + val dimens = source("app/src/main/res/values/dimens.xml") + listOf( + """2dp""", + """4dp""", + """8dp""", + """12dp""", + """16dp""", + """24dp""", + """32dp""", + """48dp""", + """56dp""", + """24dp""", + """28dp""", + """16dp""", + """72dp""", + ).forEach { expected -> + assertTrue("Resource dimension drifted: $expected", dimens.contains(expected)) + } + } + + @Test + fun productVectorIconsUseOneCanvas() { + val drawableDir = file("app/src/main/res/drawable") + drawableDir.listFiles() + .orEmpty() + .filter { + it.name.startsWith("ic_") && + it.extension == "xml" && + !it.name.startsWith("ic_launcher") && + it.name != "ic_brand_mark.xml" + } + .forEach { icon -> + val xml = icon.readText() + assertTrue("${icon.name} width must be 24dp", xml.contains("android:width=\"24dp\"")) + assertTrue("${icon.name} height must be 24dp", xml.contains("android:height=\"24dp\"")) + assertTrue("${icon.name} viewport width must be 24", xml.contains("android:viewportWidth=\"24\"")) + assertTrue("${icon.name} viewport height must be 24", xml.contains("android:viewportHeight=\"24\"")) + } + } + + private fun source(path: String): String = file(path).readText() + + private fun file(path: String): File { + var root = File(System.getProperty("user.dir")).absoluteFile + repeat(5) { + val candidate = File(root, path) + if (candidate.isFile || candidate.isDirectory) return candidate + root = root.parentFile ?: return@repeat + } + error("Cannot locate repository path: $path from ${System.getProperty("user.dir")}") + } +} diff --git a/app/src/test/java/llc/slacker/openime/UserDataArchiveTest.kt b/app/src/test/java/llc/slacker/openime/UserDataArchiveTest.kt new file mode 100644 index 00000000..48eeee74 --- /dev/null +++ b/app/src/test/java/llc/slacker/openime/UserDataArchiveTest.kt @@ -0,0 +1,123 @@ +package llc.slacker.openime + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class UserDataArchiveTest { + private val settings = ArchiveSettings( + theme = ImeTheme.IOS.name, + appearance = ImeAppearance.SYSTEM.name, + sound = true, + haptic = false, + popup = true, + fuzzy = false, + skinOpacity = 92, + skinRadius = 10, + skinFont = 18, + skinColor = "#1D9BF0", + preferredChineseMode = KeyboardMode.PINYIN_9.name, + handedness = ImeHandedness.LEFT.name, + keyboardHeightPercent = 104, + floatingWidthPercent = 84, + floatingOpacityPercent = 96, + ) + + @Test + fun jsonRoundTripPreservesAllExportedSections() { + val archive = UserDataArchive( + quickPhrases = listOf( + ArchiveQuickPhrase("工作", "收到,我稍后处理。", "sh"), + ), + customSymbols = listOf( + ArchiveCustomSymbol("箭头", "⇢", pinned = true), + ), + userPhrases = listOf( + ArchiveUserPhrase("ni hao", "你好呀", frequency = 7, lastUsed = 1234L), + ), + settings = settings, + rimeUserDictionaries = listOf( + RimeUserDictionaryArchive("luna_pinyin", "# Rime user dictionary\n"), + ), + ) + + val decoded = UserDataArchiveCodec.decode(UserDataArchiveCodec.encode(archive)) + + assertEquals(archive, decoded) + } + + @Test + fun decodeDropsRimeDictionaryNamesThatCanEscapeTheUserDataDirectory() { + val archive = UserDataArchive( + quickPhrases = emptyList(), + customSymbols = emptyList(), + userPhrases = emptyList(), + settings = settings, + rimeUserDictionaries = listOf( + RimeUserDictionaryArchive("luna_pinyin", "safe"), + RimeUserDictionaryArchive("luna_pinyin", "duplicate"), + RimeUserDictionaryArchive("../outside", "unsafe"), + RimeUserDictionaryArchive("nested/path", "unsafe"), + RimeUserDictionaryArchive("nested\\path", "unsafe"), + ), + ) + + val decoded = UserDataArchiveCodec.decode(UserDataArchiveCodec.encode(archive)) + + assertEquals( + listOf("luna_pinyin"), + decoded.rimeUserDictionaries.map { it.name }, + ) + assertTrue(UserDataArchiveCodec.isSafeRimeDictionaryName("luna_pinyin")) + assertFalse(UserDataArchiveCodec.isSafeRimeDictionaryName("../outside")) + } + + @Test + fun mergeAddsNewRecordsWithoutDroppingExistingRecords() { + val existing = UserDataArchive( + quickPhrases = listOf( + ArchiveQuickPhrase("常用", "原有常用语", "old"), + ), + customSymbols = listOf( + ArchiveCustomSymbol("常用", "→", pinned = false), + ), + userPhrases = listOf( + ArchiveUserPhrase("ni", "你", frequency = 5, lastUsed = 100L), + ), + settings = settings, + ) + val incoming = UserDataArchive( + quickPhrases = listOf( + ArchiveQuickPhrase("常用", "原有常用语", "old"), + ArchiveQuickPhrase("工作", "新增常用语", "new"), + ), + customSymbols = listOf( + ArchiveCustomSymbol("常用", "→", pinned = true), + ArchiveCustomSymbol("数学", "≈", pinned = false), + ), + userPhrases = listOf( + ArchiveUserPhrase("ni", "你", frequency = 9, lastUsed = 200L), + ArchiveUserPhrase("hao", "好", frequency = 3, lastUsed = 300L), + ), + settings = settings.copy(sound = false), + ) + + val preview = UserDataArchiveMerger.preview(existing, incoming) + val merged = UserDataArchiveMerger.merge(existing, incoming) + + assertEquals(1, preview.quickPhrasesToAdd) + assertEquals(1, preview.customSymbolsToAdd) + assertEquals(1, preview.userPhrasesToAdd) + assertTrue(preview.settingsWillChange) + assertEquals(2, merged.quickPhrases.size) + assertEquals(2, merged.customSymbols.size) + assertEquals(2, merged.userPhrases.size) + assertTrue(merged.quickPhrases.any { it.text == "原有常用语" }) + assertTrue(merged.customSymbols.any { it.symbol == "→" }) + val learned = merged.userPhrases.single { it.code == "ni" && it.text == "你" } + assertEquals(9, learned.frequency) + assertEquals(200L, learned.lastUsed) + assertFalse(merged.settings.sound) + } +} diff --git a/app/src/test/java/llc/slacker/openime/VoiceModelTrustPolicyTest.kt b/app/src/test/java/llc/slacker/openime/VoiceModelTrustPolicyTest.kt index 2929c30d..df69d4f6 100644 --- a/app/src/test/java/llc/slacker/openime/VoiceModelTrustPolicyTest.kt +++ b/app/src/test/java/llc/slacker/openime/VoiceModelTrustPolicyTest.kt @@ -17,22 +17,10 @@ class VoiceModelTrustPolicyTest { File("app/src/main/assets"), ).firstOrNull { it.isDirectory } ?: error("missing test asset root") - private fun officialManifest(): VoiceModelManifest = VoiceModelManifest( - modelId = "sherpa-onnx-streaming-zipformer-bilingual-zh-en-2023-02-20-int8", - modelVersion = "2023-02-20-int8", - language = "zh-CN,en-US", - modelType = "zipformer", - engineVersion = "sherpa-onnx-v1.13.6", - fileHash = "f2b835dfe8231bccc08692b4d8751ff50c2360b9b39a7501136a45e1bd97db85", - supportsPunctuation = false, - requiredMemory = 420_000_000L, - files = listOf( - "models/voice/bilingual-zipformer/decoder-epoch-99-avg-1.onnx", - "models/voice/bilingual-zipformer/encoder-epoch-99-avg-1.int8.onnx", - "models/voice/bilingual-zipformer/joiner-epoch-99-avg-1.int8.onnx", - "models/voice/bilingual-zipformer/tokens.txt", - ), - ) + private fun officialManifest(): VoiceModelManifest = + VoiceModelManifest.parse( + File(assetRoot(), "models/voice/manifest.json").readText(), + ) private fun sampleManifest(): VoiceModelManifest = VoiceModelManifest( modelId = "test-model", @@ -50,8 +38,13 @@ class VoiceModelTrustPolicyTest { fun bundledCatalogTrustsOnlyExactOfficialManifestAndLayout() { val manifest = officialManifest() assertEquals( - "12bf0e2a9d5090bdb935c90941a0b3ef6269346860398fed3e45cef91940a525", - manifest.trustFingerprint(), + "sherpa-onnx-streaming-paraformer-bilingual-zh-en-int8", + manifest.modelId, + ) + assertEquals("paraformer", manifest.modelType) + assertEquals( + listOf(SHERPA_DECODER, SHERPA_ENCODER, SHERPA_TOKENS), + manifest.files, ) val catalog = TrustedVoiceModelCatalog.parse( File(assetRoot(), "models/voice/trusted-downloads.tsv").readText(), @@ -59,10 +52,6 @@ class VoiceModelTrustPolicyTest { val entry = catalog.entryFor(manifest) assertNotNull(entry) assertEquals("Slacker-LLC", entry?.publisher) - assertEquals( - listOf(13_876_452L, 181_895_032L, 3_228_404L, 56_317L), - entry?.fileSizes, - ) assertTrue(entry?.matchesLayout(manifest, assetRoot()) == true) assertEquals(null, catalog.entryFor(manifest.copy(requiredMemory = 1L))) diff --git a/app/src/test/java/llc/slacker/openime/VoiceRuntimeModelSourceTest.kt b/app/src/test/java/llc/slacker/openime/VoiceRuntimeModelSourceTest.kt index 3171a450..f26beed8 100644 --- a/app/src/test/java/llc/slacker/openime/VoiceRuntimeModelSourceTest.kt +++ b/app/src/test/java/llc/slacker/openime/VoiceRuntimeModelSourceTest.kt @@ -15,7 +15,7 @@ class VoiceRuntimeModelSourceTest { modelId = modelId, modelVersion = "1", language = "zh-CN,en-US", - modelType = "zipformer", + modelType = "paraformer", engineVersion = "sherpa-test", fileHash = "0".repeat(64), supportsPunctuation = false, @@ -23,7 +23,6 @@ class VoiceRuntimeModelSourceTest { files = listOf( SHERPA_DECODER, SHERPA_ENCODER, - SHERPA_JOINER, SHERPA_TOKENS, ), ) @@ -42,7 +41,6 @@ class VoiceRuntimeModelSourceTest { assertEquals(SherpaRuntimeStorage.ASSETS, source?.storage) assertEquals(SHERPA_ENCODER, source?.encoder) assertEquals(SHERPA_DECODER, source?.decoder) - assertEquals(SHERPA_JOINER, source?.joiner) assertEquals(SHERPA_TOKENS, source?.tokens) } finally { downloadedRoot.deleteRecursively() @@ -70,10 +68,9 @@ class VoiceRuntimeModelSourceTest { assertEquals(SherpaRuntimeStorage.FILES, source?.storage) assertEquals(File(packageRoot, SHERPA_ENCODER).canonicalPath, File(source!!.encoder).canonicalPath) assertEquals(File(packageRoot, SHERPA_DECODER).canonicalPath, File(source.decoder).canonicalPath) - assertEquals(File(packageRoot, SHERPA_JOINER).canonicalPath, File(source.joiner).canonicalPath) assertEquals(File(packageRoot, SHERPA_TOKENS).canonicalPath, File(source.tokens).canonicalPath) assertTrue( - listOf(source.encoder, source.decoder, source.joiner, source.tokens) + listOf(source.encoder, source.decoder, source.tokens) .all { File(it).isFile }, ) } finally { diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md new file mode 100644 index 00000000..5d9ba25f --- /dev/null +++ b/docs/DECISIONS.md @@ -0,0 +1,11 @@ +# Visual language decisions + +- 2026-09-29|浅色次级文字对比度|保留键盘原始 `keySecondaryText=#6E6E73`,新增页面角色 `textSecondaryRole=#6D6D72`|原值对 `surface=#EEF0F3` 约 4.44:1,低于规范 4.5:1;只修页面角色,避免改变键盘既有视觉。 +- 2026-09-29|候选展开图标状态|复用 `ic_chevron_down`,展开时旋转 180°|保持单一矢量资产,不再用 Unicode 上下箭头。 +- 2026-09-29|Setup XML 色板|仅保留启动前必须使用的资源色,并由 TokenDriftTest 与 Kotlin token 对齐|Android XML 在 Kotlin 初始化前需要资源颜色,不能完全移除。 +- 2026-09-29|退格连删节奏|继续保留现有 60ms 匀速重复|视觉语言 v1 明确要求本轮不改,留作后续交互专项。 +- 2026-09-29|语音松手尾部|松手后保留 300ms 采集窗口,并用 generation/session 所有权阻止旧会话影响新会话|修复 AudioRecord 尾部被立即截断,同时保持取消即时生效。 +- 2026-09-29|九键解码线程策略|先记录 `publishNineKeyDigits -> resolveNineKey` 的 20 次窗口 P50/P95;未取得中低端机 P95 前保持现有线程,不提前迁移|只有 P95 超过 8ms 才按规范迁到 `CandidateQueryCoordinator`。 +- 2026-09-29|九键首帧与 Rime 刷新|候选按压或滚动期间暂缓应用异步 Rime 结果;同时记录从请求到结果可应用的端到端延迟|防止手指下的候选列表重排;是否在 Rime 就绪时跳过首帧回退,等实测 P95 是否低于 40ms 后再定。 +- 2026-09-29|Rime 用户词库导出|vendored librime 1.17.0 已编入 levers 模块,`UserDictManager::Export/Import` 可在关闭用户库会话后导出/合并 UTF-8 快照|用户数据 JSON 在 Rime 会话已加载时包含自动学习词库;不可用时必须明确提示,不静默遗漏。 +- 2026-09-29|流式语音模型|默认模型切换为 `sherpa-onnx-streaming-paraformer-bilingual-zh-en` 的 INT8 encoder/decoder;运行时使用 `OnlineParaformerModelConfig` + `greedy_search`,结束时补 300ms 静音;不再向 Paraformer stream 传 transducer-only 动态 hotwords|优先降低模型体积和保持中英流式识别,同时遵循 sherpa-onnx v1.13.6 官方 Paraformer 配置。 diff --git a/docs/LICENSING.md b/docs/LICENSING.md index 4f8aa6d9..5a73bb59 100644 --- a/docs/LICENSING.md +++ b/docs/LICENSING.md @@ -24,6 +24,16 @@ `app/src/main/assets/licenses/rime-ice-GPL-3.0.txt`,来源明细见根目录 `THIRD_PARTY_NOTICES.md`。 -语音 runtime 以 `app/libs/sherpa-onnx-1.13.6.aar` 提供,语音模型位于 -`app/src/main/assets/models/voice/`。发布 APK 前应同时核对 runtime、模型和词典的 -上游许可证与再分发条件;Git LFS 只负责文件存储,不改变文件的许可证。 +语音 runtime 以 `app/libs/sherpa-onnx-1.13.6.aar` 提供,上游 +`k2-fsa/sherpa-onnx` 使用 Apache-2.0。内置中英双语 Streaming Paraformer 模型 +`csukuangfj/sherpa-onnx-streaming-paraformer-bilingual-zh-en` +模型卡标记为 Apache-2.0;正式包只使用其 INT8 encoder/decoder。 +具体来源、文件位置和发布核对项统一记录在根目录 `THIRD_PARTY_NOTICES.md`。 +Git LFS 只负责文件存储,不改变文件的许可证。 + + +## APK 内许可证 + +正式 APK 在 `assets/licenses/` 内携带主要第三方许可证文本,包括 librime、OpenCC、Snappy、Rime Ice、sherpa-onnx runtime、当前 Paraformer 模型和 Fluent Emoji。根目录 `THIRD_PARTY_NOTICES.md` 记录组件、来源、版本或固定提交与对应文件位置。 + +发布工作流同时把 `THIRD_PARTY_NOTICES.md` 作为 GitHub Release 附件上传,便于在 APK 外直接查看。 diff --git a/docs/LOCAL_VOICE_MODEL.md b/docs/LOCAL_VOICE_MODEL.md index bef975b7..2bc1edb8 100644 --- a/docs/LOCAL_VOICE_MODEL.md +++ b/docs/LOCAL_VOICE_MODEL.md @@ -34,7 +34,7 @@ app/src/main/assets/models/voice/ "modelId": "paraformer-zh-en-int8", "modelVersion": "...", "language": "zh-CN,en-US", - "modelType": "streaming-paraformer", + "modelType": "paraformer", "engineVersion": "...", "fileHash": "sha256", "supportsPunctuation": true, @@ -67,11 +67,15 @@ app/src/main/assets/models/voice/ ## 当前交付状态 -当前 APK 已内置官方 `sherpa-onnx v1.13.6` native runtime 和中英双语流式 -Zipformer INT8 模型,模型路径为 `models/voice/bilingual-zipformer/`。模型使用 -`OnlineRecognizer` 按 16 kHz PCM 流式解码,键盘出现时异步预热,10 秒冷却期内 -中文或英文语音段复用同一个已加载识别器。当前内置的是纯识别模型,`punctuate()` 保留了独立 -标点模型的扩展边界;没有标点模型时会安全回退原始识别文字。 +当前 APK 使用官方 `sherpa-onnx v1.13.6` native runtime 和 +`sherpa-onnx-streaming-paraformer-bilingual-zh-en` 的 INT8 encoder/decoder, +模型路径为 `models/voice/bilingual-paraformer/`。运行时通过 +`OnlineParaformerModelConfig`、`modelType="paraformer"` 和 +`greedy_search` 按 16 kHz PCM 流式解码;键盘出现时异步预热,10 秒冷却期内 +中文或英文语音段复用同一个已加载识别器。结束语音段时追加 300 ms 静音尾垫, +再调用 `inputFinished()`,与 sherpa-onnx 官方流式 Paraformer 示例一致。 +当前内置的是纯识别模型,`punctuate()` 保留独立标点扩展边界;没有标点模型时 +安全回退原始识别文字。 模型包的每个文件都在 `manifest.json` 的 SHA-256 清单内,APK 启动时只选择校验 通过的内置包。下载模型仍然必须走 `VoiceModelRepository` 的校验和原子切换, @@ -79,8 +83,9 @@ Zipformer INT8 模型,模型路径为 `models/voice/bilingual-zipformer/`。 ## 个性化与性能边界 -- 重复选择的本地用户词会生成有上限的 sherpa 动态 hotwords;当前内置模型缺少 - `bpe.vocab`,因此只对可可靠编码的中文/中英混合词做上下文增强,普通英文识别不受影响。 +- Streaming Paraformer 不走 sherpa-onnx 的 transducer hotword graph,因此不再把 + `VoiceHotwordProvider` 动态热词传给 native stream;本地 `VoiceCorrectionRepository` + 的识别后纠正仍保留。 - 用户在语音上屏后立即删除并改正的文本会形成私有 `VoiceCorrectionRepository` 对; 后续相同 ASR 原结果先应用本地纠正,改正目标也会回流动态热词。 - 密码框不进入热词或纠错学习;日志不记录 PCM、转写、热词、纠错内容。 diff --git a/docs/README.md b/docs/README.md index 3e756425..d71a1d67 100644 --- a/docs/README.md +++ b/docs/README.md @@ -14,6 +14,7 @@ - [TEST_SOP.md](TEST_SOP.md):L0~L3 正式测试流程。 - [TEST_SOP_CHECKLIST.md](TEST_SOP_CHECKLIST.md):多设备与人工交互验收清单。 - [LICENSING.md](LICENSING.md):主项目与第三方组件许可证边界。 +- [RELEASE.md](RELEASE.md):固定签名、arm64 正式包、标签发布、校验和与第三方清单。 旧的按 PR/分支推进的审计状态文档已删除。当前状态以 GitHub 分支/PR/CI 为准,长期执行顺序只维护在 `REPAIR_PLAN.md`,避免两份计划互相冲突。 diff --git a/docs/RELEASE.md b/docs/RELEASE.md new file mode 100644 index 00000000..ab0cb24e --- /dev/null +++ b/docs/RELEASE.md @@ -0,0 +1,52 @@ +# 发布流程 + +## 发布产物 + +正式发布只生成 `arm64-v8a` APK,文件名为: + +```text +openIME-v{versionName}-arm64-release.apk +``` + +同一 GitHub Release 同时上传: + +- `SHA256SUMS.txt` +- `THIRD_PARTY_NOTICES.release.md` + +Debug 构建继续保留 `arm64-v8a + x86_64`,用于真机与模拟器回归。 + +## 固定发布签名 + +GitHub Actions 需要以下四个 repository secrets: + +- `OPENIME_KEYSTORE_B64`:发布 keystore 的 Base64 内容。 +- `OPENIME_KEYSTORE_PASSWORD` +- `OPENIME_KEY_ALIAS` +- `OPENIME_KEY_PASSWORD` + +本地 release 构建也可使用同名环境变量;其中 `OPENIME_KEYSTORE_PATH` 指向本机 keystore 文件,不使用 Base64。 + +keystore 不进入仓库,不提交到 Issue、PR、Actions artifact 或 Release。 + +## 发布步骤 + +1. 更新 `app/build.gradle.kts` 的 `versionCode` 和 `versionName`。 +2. 在目标提交上完成 JVM 单测、Lint、Debug 构建和设备回归。 +3. 创建与 `versionName` 完全一致的标签,例如 `versionName = "1.0.3"` 对应 `v1.0.3`。 +4. 推送标签。 +5. `.github/workflows/release.yml` 自动执行单测、`lintRelease`、`assembleRelease`、APK 签名校验和 ABI 校验。 +6. 工作流生成 SHA-256,并创建 GitHub Release。 + +标签与 `versionName` 不一致、签名 secrets 缺失、APK 未签名或包含非 `arm64-v8a` native ABI 时,发布任务会失败。 + +## 社交预览 + +仓库内提供 `docs/images/social-preview.png`(1280×640),由 `scripts/generate_brand_assets.py` 生成。GitHub 的仓库 Social preview 不是源码文件配置项,因此需要仓库管理员在 **Settings → General → Social preview** 上传该 PNG;这一步不能通过当前仓库代码提交自动完成。 + +## 发布前检查 + +- `AndroidManifest.xml` 不含 `INTERNET` 权限。 +- `android:allowBackup="false"` 保持不变。 +- `THIRD_PARTY_NOTICES.md` 与 `app/src/main/assets/licenses/` 同步。 +- 语音模型、词库或第三方 runtime 版本变化时重新核对对应许可证。 +- 不从 Debug keystore 发布。 diff --git a/docs/images/openime-brand.png b/docs/images/openime-brand.png new file mode 100644 index 00000000..c43c509c Binary files /dev/null and b/docs/images/openime-brand.png differ diff --git a/docs/images/social-preview.png b/docs/images/social-preview.png new file mode 100644 index 00000000..1c8eb0b4 Binary files /dev/null and b/docs/images/social-preview.png differ diff --git a/scripts/generate_brand_assets.py b/scripts/generate_brand_assets.py new file mode 100755 index 00000000..5f087dae --- /dev/null +++ b/scripts/generate_brand_assets.py @@ -0,0 +1,93 @@ +#!/usr/bin/env python3 +"""Generate checked-in openIME brand PNG assets. + +Requires Pillow: + python -m pip install Pillow +""" + +from pathlib import Path + +from PIL import Image, ImageDraw, ImageFont + +ROOT = Path(__file__).resolve().parents[1] +OUTPUT = ROOT / "docs" / "images" +ACCENT = "#6EC3F7" +DARK = "#1C1C1E" +TEXT = "#F2F2F7" + + +def draw_mark(image: Image.Image, box: tuple[int, int, int, int], fill, cutout) -> None: + draw = ImageDraw.Draw(image) + x0, y0, x1, y1 = box + scale = (x1 - x0) / 48.0 + + def point(x: float, y: float) -> tuple[float, float]: + return x0 + x * scale, y0 + y * scale + + draw.rounded_rectangle( + [*point(4, 4), *point(44, 44)], + radius=11 * scale, + fill=fill, + ) + draw.polygon( + [ + point(17, 11), + point(31, 11), + point(31, 14), + point(26, 14), + point(26, 34), + point(31, 34), + point(31, 37), + point(17, 37), + point(17, 34), + point(22, 34), + point(22, 14), + point(17, 14), + ], + fill=cutout, + ) + + +def load_font(size: int) -> ImageFont.ImageFont: + candidates = ( + "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", + "/usr/share/fonts/truetype/liberation2/LiberationSans-Bold.ttf", + "C:/Windows/Fonts/arialbd.ttf", + ) + for candidate in candidates: + path = Path(candidate) + if path.is_file(): + return ImageFont.truetype(str(path), size) + raise RuntimeError("No supported bold font found; install DejaVu Sans or Liberation Sans") + + +def generate_brand_png() -> None: + image = Image.new("RGBA", (288, 288), (0, 0, 0, 0)) + draw_mark(image, (0, 0, 288, 288), ACCENT, (0, 0, 0, 0)) + image.save(OUTPUT / "openime-brand.png", optimize=True) + + +def generate_social_preview() -> None: + image = Image.new("RGB", (1280, 640), DARK) + draw_mark(image, (520, 105, 760, 345), ACCENT, DARK) + + draw = ImageDraw.Draw(image) + label = "openIME" + font = load_font(92) + bounds = draw.textbbox((0, 0), label, font=font) + width = bounds[2] - bounds[0] + draw.text(((1280 - width) / 2, 400), label, font=font, fill=TEXT) + + image.save(OUTPUT / "social-preview.png", optimize=True) + + +def main() -> None: + OUTPUT.mkdir(parents=True, exist_ok=True) + generate_brand_png() + generate_social_preview() + print(OUTPUT / "openime-brand.png") + print(OUTPUT / "social-preview.png") + + +if __name__ == "__main__": + main()