diff --git a/.github/workflows/android-apk.yml b/.github/workflows/android-apk.yml index 014e4c3f5..e923734f7 100644 --- a/.github/workflows/android-apk.yml +++ b/.github/workflows/android-apk.yml @@ -4,6 +4,8 @@ name: Android APK (debug) # - push v*-tauri tag → signed release APK + minisign + updater manifest → GitHub Release # - workflow_dispatch → signed release APK when ANDROID_KEYSTORE_* secrets exist # (overlay install + user data preserved); otherwise unsigned debug APK (annotated, non-blocking) +# - workflow_dispatch + signed_debug=true — debug APK signed with the release +# keystore (when secrets exist); artifact remains openless-android-debug-*. # # #1103 build-time changes: # - Do not wipe Cargo/Gradle/target before cache post-save (removed Free disk step). @@ -26,8 +28,12 @@ on: description: 'Dispatch-only fast release profile (no LTO, codegen-units=16). Ignored on tags.' type: boolean default: false + signed_debug: + description: 'Build debug mode but sign it with the release keystore (requires ANDROID_KEYSTORE_* secrets).' + type: boolean + default: false -# 同一 tag 重复推送只跑最新一次;workflow_dispatch 用 run_id 隔离避免互相取消。 +# 同一 tag 重复推送只跑最新一次;手动构建按 run_id 隔离。 concurrency: group: ${{ github.workflow }}-${{ github.event_name == 'workflow_dispatch' && github.run_id || github.ref }} cancel-in-progress: ${{ github.event_name == 'push' }} @@ -62,6 +68,7 @@ jobs: ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }} ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }} ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }} + INPUT_SIGNED_DEBUG: ${{ github.event.inputs.signed_debug }} run: | set -euo pipefail is_tag=false @@ -89,6 +96,11 @@ jobs: echo "label=${{ github.ref_name }}" >> "$GITHUB_OUTPUT" echo "signing=tag-release" >> "$GITHUB_OUTPUT" echo "signing_label=Tag release with release keystore" >> "$GITHUB_OUTPUT" + elif [[ "${INPUT_SIGNED_DEBUG:-false}" == "true" ]] && $has_keystore; then + echo "mode=debug" >> "$GITHUB_OUTPUT" + echo "label=signed-debug-run-${{ github.run_number }}" >> "$GITHUB_OUTPUT" + echo "signing=signed-debug" >> "$GITHUB_OUTPUT" + echo "signing_label=Manual dispatch debug APK signed with release keystore" >> "$GITHUB_OUTPUT" elif $has_keystore; then echo "mode=release" >> "$GITHUB_OUTPUT" echo "label=dispatch-run-${{ github.run_number }}" >> "$GITHUB_OUTPUT" @@ -100,6 +112,9 @@ jobs: echo "signing=debug-fallback" >> "$GITHUB_OUTPUT" echo "signing_label=Unsigned debug fallback (no keystore secrets)" >> "$GITHUB_OUTPUT" echo "::notice title=Unsigned debug APK::ANDROID_KEYSTORE_* secrets not configured. Building debug APK without release signing. Overlay install and user data preservation require the release keystore; uninstall before installing if replacing a signed build." + if [[ "${INPUT_SIGNED_DEBUG:-false}" == "true" ]]; then + echo "::warning::signed_debug was requested but Android keystore secrets are incomplete; falling back to an unsigned debug APK." + fi fi - name: Resolve ABI matrix @@ -286,13 +301,15 @@ jobs: run: node scripts/merge-android-updater-manifest.mjs - name: Configure Android release signing - if: needs.plan.outputs.mode == 'release' + if: needs.plan.outputs.mode == 'release' || needs.plan.outputs.signing == 'signed-debug' working-directory: openless-all/app env: ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }} ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }} ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }} ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }} + # signed_debug: also wire debug buildType to the release keystore + OPENLESS_SIGN_DEBUG: ${{ needs.plan.outputs.signing == 'signed-debug' && 'true' || 'false' }} run: node scripts/configure-android-release-signing.mjs - name: Prime Gradle wrapper @@ -313,6 +330,10 @@ jobs: - name: Build Android debug APK if: needs.plan.outputs.mode == 'debug' working-directory: openless-all/app + env: + ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }} + ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }} + ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }} run: npm run tauri:android:build:debug - name: Build Android release APK @@ -378,6 +399,10 @@ jobs: cat >> "$GITHUB_STEP_SUMMARY" << 'EOF' > **Signed release APK** (manual dispatch). Same keystore as tag releases — supports overlay install and preserves user data when upgrading from an existing signed install. EOF + elif [ "${{ needs.plan.outputs.signing }}" = "signed-debug" ]; then + cat >> "$GITHUB_STEP_SUMMARY" << 'EOF' + > **Signed debug APK** (manual dispatch). Debug mode is retained for diagnostics; the release keystore is used so `adb install -r` can upgrade the matching signed app without changing the debug artifact name. + EOF fi publish-android-release: @@ -528,3 +553,4 @@ jobs: append_body: true body_path: ${{ runner.temp }}/android-release-body.md files: ${{ steps.release_assets.outputs.files }} + diff --git a/.gitignore b/.gitignore index e93cb50e8..e4100810f 100644 --- a/.gitignore +++ b/.gitignore @@ -85,6 +85,10 @@ docs/plans/ /AGENTS.md /issue-*-plan.md +# 本地会话记录(Claude Code 对话存档),仅供本人下次取用,不入库 +/SESSION_NOTES.md +/SESSION_TRANSCRIPT.jsonl + # .github 内部协作 + 审计报告(ISSUE_TEMPLATE / workflows / pr template 仍保留发布) .github/COOPER_*.md .github/BUILD_TEST_REPORT.md diff --git a/README.zh.md b/README.zh.md index 7443e9663..7f1c8e2fa 100644 --- a/README.zh.md +++ b/README.zh.md @@ -204,6 +204,8 @@ OpenLess 只做一件事:**把语音变成可用的书面文字(尤其是 AI 提 ## 当前状态 +本 Fork 的 Android 输入法架构、四个面板的使用说明、截图和从 fork 基线开始的功能时间线,见 [Android 输入法:架构、功能与 fork 演进](docs/android-ime.md)。 + 下面每一项,都是一层已经沉降为默认、你授权一次之后就不必再操心的能力——这就是开屏之后你所站立的基础设施: - 共享 Rust 后端位于 `openless-core`。macOS 与 Windows 使用薄 Tauri 2 宿主和 React/TypeScript 前端;Android 暂时保留 Tauri mobile 宿主;Linux 使用独立原生宿主,不编译 Tauri 或 WebKitGTK。 diff --git a/docs/android-ime.md b/docs/android-ime.md new file mode 100644 index 000000000..7052871a3 --- /dev/null +++ b/docs/android-ime.md @@ -0,0 +1,59 @@ +# OpenLess Android IME:架构、功能与 fork 演进 + +这份页面记录 Android 输入法分支从 `beta` fork 基线到当前实现的功能演进,并把界面截图与实际源码入口对应起来。它描述的是当前代码状态,不把构建成功等同于完整真机验收。 + +## 当前架构 + +用户操作进入 `OpenLessImeService`,它提供语音、笔画、剪贴板和英文四个输入面板,并通过 Android `InputConnection` 将结果提交到当前应用。Rust/Tauri 负责 ASR、AI 处理和共享后端;`OpenLessRuntimeService` 作为 `START_STICKY` 前台运行时监督器持有 JNI Activity Context;`OpenLessBackendWarmupActivity` 承载 Tauri/WebView 设置页与启动入口。 + +主要源码入口: + +- `openless-all/app/android/kotlin/OpenLessImeService.kt`:四面板切换、语音生命周期、剪贴板/英文面板与跨面板 UI 基础设施。 +- `openless-all/app/android/kotlin/StrokeInputController.kt`:笔画编码、候选/联想查询与提交、数字符号子面板;这是 2026-09-24 完成的第一处面板拆分。 +- `openless-all/app/android/kotlin/OpenLessRuntimeService.kt`:运行时守护和 JNI Context 挂载。 +- `openless-all/app/android/kotlin/OpenLessBackendWarmupActivity.kt`:Tauri host、设置页和 WebView 冷启动恢复。 +- `openless-all/app/src-tauri/src/android/native_bridge.rs`:Rust ↔ Kotlin JNI 桥接。 + +## 已实现的 Android 输入能力 + +- 语音输入:录音、取消、原文/整理模式、波形/状态反馈,并通过 `commitText()` 写入当前光标位置。 +- 笔画输入:离线笔画字典、单字候选、确认后的词语联想、分词、简繁偏好、数字/符号面板、上滑数字和个人词频。 +- 剪贴板:历史记录、收藏/删除/分类、选择/复制/粘贴、纠正词写入全局词典。 +- 英文键盘:字母/数字/符号三层布局、英文候选词、个人词频、自定义词长按删除、按键预览和上滑输入数字/符号。 +- 跨应用插入:按可用性使用无障碍、Shizuku 或剪贴板回退;权限和输入法启用状态在 Android 侧单独管理。 + +## 2026-09-11 至 2026-09-24 的演进 + +`615d6ac4` 是 fork 后开发使用的 `beta` 基线,`0bc3a979` 是第一笔 Android IME 实现。之后的提交按功能阶段演进: + +1. IME 服务骨架与四面板交互。 +2. 笔画字典、索引、候选排序、联想、多编码、分词、简繁和个人词频。 +3. 剪贴板历史、纠正词桥接、英文键盘、候选词和手势预览。 +4. Runtime Service、Activity 生命周期、JNI Context、WebView 黑屏/冷启动恢复和重启诊断。 +5. 2026-09-23 至 24:英文上滑数字/符号和长按删词;候选 View 复用;联想词典预热、缓存和 Trie 建索引优化;新增 `StrokeInputController`。 + +完整提交时间线可用以下命令复核: + +```text +git log --reverse --format='%h %ad %s' --date=short 615d6ac4..HEAD +``` + +## 截图与使用说明 + +![OpenLess Android 架构与使用指南](images/android-ime/android-architecture-guide.png) + +| 面板 | 截图 | +| --- | --- | +| 语音:待机、录音、思考 | ![语音待机](images/android-ime/voice-idle-green.jpg) ![录音](images/android-ime/voice-recording-red.jpg) ![思考](images/android-ime/voice-thinking.jpg) | +| 笔画:中文与数字/符号 | ![中文笔画](images/android-ime/stroke-chinese.jpg) ![数字面板](images/android-ime/stroke-numeric.jpg) | +| 剪贴板与英文 | ![剪贴板](images/android-ime/clipboard.jpg) ![英文键盘](images/android-ime/english.jpg) | + +## 小米/Redmi 真机边界 + +现有开发记录确认 APK 已安装到包含小米设备在内的测试设备;但候选栏复用、控制器拆分和英文/笔画手势仍需要完整真机回归。正式发布前应继续覆盖:正常输入、分词联想、退格、数字/符号切换、简繁切换、跨键滑动、后台恢复、Activity 被系统回收后的冷启动,以及不同 OEM 的插入权限路径。 + +## 相关文档 + +- [Android 源码架构与开发记录](../openless-all/app/android/README.md) +- [根 README 的 Android 状态说明](../README.md#android-input-method) +- [Android 架构总览](architecture.md) diff --git a/docs/images/android-ime/android-architecture-guide.png b/docs/images/android-ime/android-architecture-guide.png new file mode 100644 index 000000000..5d473381d Binary files /dev/null and b/docs/images/android-ime/android-architecture-guide.png differ diff --git a/docs/images/android-ime/clipboard.jpg b/docs/images/android-ime/clipboard.jpg new file mode 100644 index 000000000..4ed3f1c94 Binary files /dev/null and b/docs/images/android-ime/clipboard.jpg differ diff --git a/docs/images/android-ime/english.jpg b/docs/images/android-ime/english.jpg new file mode 100644 index 000000000..d63c4f547 Binary files /dev/null and b/docs/images/android-ime/english.jpg differ diff --git a/docs/images/android-ime/stroke-chinese.jpg b/docs/images/android-ime/stroke-chinese.jpg new file mode 100644 index 000000000..9153337b2 Binary files /dev/null and b/docs/images/android-ime/stroke-chinese.jpg differ diff --git a/docs/images/android-ime/stroke-numeric.jpg b/docs/images/android-ime/stroke-numeric.jpg new file mode 100644 index 000000000..b0f1277f5 Binary files /dev/null and b/docs/images/android-ime/stroke-numeric.jpg differ diff --git a/docs/images/android-ime/voice-idle-green.jpg b/docs/images/android-ime/voice-idle-green.jpg new file mode 100644 index 000000000..1052eb47f Binary files /dev/null and b/docs/images/android-ime/voice-idle-green.jpg differ diff --git a/docs/images/android-ime/voice-idle-orange.jpg b/docs/images/android-ime/voice-idle-orange.jpg new file mode 100644 index 000000000..5fd932979 Binary files /dev/null and b/docs/images/android-ime/voice-idle-orange.jpg differ diff --git a/docs/images/android-ime/voice-recording-red.jpg b/docs/images/android-ime/voice-recording-red.jpg new file mode 100644 index 000000000..90cc35748 Binary files /dev/null and b/docs/images/android-ime/voice-recording-red.jpg differ diff --git a/docs/images/android-ime/voice-thinking.jpg b/docs/images/android-ime/voice-thinking.jpg new file mode 100644 index 000000000..e8d55b33d Binary files /dev/null and b/docs/images/android-ime/voice-thinking.jpg differ diff --git a/openless-all/app/android/README.md b/openless-all/app/android/README.md index 99a7beb27..58c79f16b 100644 --- a/openless-all/app/android/README.md +++ b/openless-all/app/android/README.md @@ -40,6 +40,10 @@ Manifest 合并脚本: - [`scripts/merge-android-shizuku-manifest.mjs`](../scripts/merge-android-shizuku-manifest.mjs) — Shizuku Provider / 授权 Activity - [`scripts/patch-android-shizuku-deps.mjs`](../scripts/patch-android-shizuku-deps.mjs) — Shizuku Gradle 依赖 +### 轻量拼音输入(Pinyin,可选模式) + +英文键盘长按空格可在英文 / 拼音之间切换。拼音模式为离线精确匹配查找(单字全拼 + 高频简拼词语),不含 NDK / JNI / librime / 语言模型,不新增联网权限。核心类:`LitePinyinController`(编码缓冲 + 查询)、`LitePinyinRepository`(词库索引 + 排序)、`LitePinyinUserFrequency`(本地选择频率)。词库资源 `android/assets/pinyin_chars.tsv`(约 8105 字)与 `pinyin_phrases.tsv`(约 2000 词),由 [`scripts/generate-pinyin-characters.mjs`](../scripts/generate-pinyin-characters.mjs) / [`scripts/generate-pinyin-phrases.mjs`](../scripts/generate-pinyin-phrases.mjs) 从三方开源数据生成,来源、版本与许可证见同目录下的 `pinyin_chars.LICENSE.txt` / `pinyin_phrases.LICENSE.txt`。 + ## 前端(`android/frontend/`,别名 `@android`) | 路径 | 职责 | diff --git a/openless-all/app/android/assets/english-frequency.LICENSE.txt b/openless-all/app/android/assets/english-frequency.LICENSE.txt new file mode 100644 index 000000000..052ac79fa --- /dev/null +++ b/openless-all/app/android/assets/english-frequency.LICENSE.txt @@ -0,0 +1,17 @@ +english-frequency.tsv — attribution and license +================================================ + +Source: hermitdave/FrequencyWords + https://github.com/hermitdave/FrequencyWords + content/2018/en/en_50k.txt (English, OpenSubtitles-2018 corpus) + +License: MIT License, Copyright (c) 2016 Hermit Dave. Full text: + https://github.com/hermitdave/FrequencyWords/blob/master/LICENSE + +Generation: this repo's scripts/generate-english-dictionary.mjs filters the +source list to plain lowercase English words (rejecting subtitle-tokenizer +contraction fragments such as "'s"/"n't", numbers, and non-letter tokens), +keeps the top 20,000 by frequency, and writes them as tab-separated +"wordcount" rows sorted by descending frequency. No runtime code in +OpenLess downloads, modifies, or redistributes the original source files — +only this generated, filtered derivative ships in the app. diff --git a/openless-all/app/android/assets/english-frequency.tsv b/openless-all/app/android/assets/english-frequency.tsv new file mode 100644 index 000000000..f66a77fa4 --- /dev/null +++ b/openless-all/app/android/assets/english-frequency.tsv @@ -0,0 +1,20000 @@ +you 28787591 +i 27086011 +the 22761659 +to 17099834 +a 14484562 +it 13631703 +and 10572938 +that 10203742 +of 8915110 +is 7400675 +in 7337058 +what 6900164 +we 6755687 +me 6444985 +this 5739788 +he 5516364 +for 5174060 +my 4938948 +on 4821861 +have 4764010 +your 4610945 +do 4419883 +was 4401531 +no 4374975 +not 4262273 +be 4210868 +are 4203821 +don 4158644 +know 3892394 +can 3826118 +with 3806977 +but 3631462 +all 3544700 +so 3434152 +just 3334392 +there 3148528 +here 3138591 +they 3060204 +like 2983027 +get 2883193 +she 2778359 +go 2738504 +if 2630800 +right 2576821 +out 2510010 +about 2487348 +up 2459855 +at 2431398 +him 2431059 +now 2298830 +oh 2298308 +one 2263956 +come 2203919 +well 2159909 +her 2154982 +how 2140365 +yeah 2000641 +will 1969807 +got 1968389 +want 1950845 +think 1839473 +as 1792220 +see 1781493 +did 1742764 +good 1741730 +who 1734491 +why 1722797 +from 1709582 +let 1705262 +his 1666361 +yes 1634050 +when 1531731 +going 1520767 +time 1453708 +an 1449181 +okay 1428884 +back 1405024 +look 1348467 +us 1346903 +would 1340070 +them 1327509 +where 1322226 +were 1315964 +take 1312232 +then 1275502 +had 1274537 +or 1267646 +been 1265150 +our 1191941 +gonna 1188190 +tell 1176290 +really 1174710 +man 1173588 +some 1166914 +say 1153915 +hey 1149292 +could 1111837 +didn 1100643 +by 1090424 +need 1040131 +something 1038638 +has 1035310 +too 1022558 +more 1010390 +way 1000182 +down 978966 +make 963885 +very 945653 +never 930831 +only 887363 +people 885915 +over 880841 +because 880112 +little 869522 +please 842120 +love 830324 +should 823711 +mean 821275 +said 818878 +sorry 818019 +give 811919 +off 799351 +am 785830 +thank 773577 +any 767968 +two 764564 +even 744544 +much 738599 +doing 732145 +sure 709390 +thing 697528 +these 683128 +help 666286 +first 656387 +into 655747 +anything 640206 +still 639540 +find 626520 +life 624596 +nothing 622415 +sir 619500 +day 617674 +god 611924 +work 611677 +their 601171 +uh 592651 +again 590019 +maybe 581087 +must 580931 +before 578606 +other 577197 +wait 574752 +stop 574016 +call 572997 +after 572761 +won 571621 +talk 567036 +away 564268 +than 561014 +thought 558542 +home 556894 +night 556318 +put 552246 +great 545768 +those 528404 +last 527395 +better 520328 +everything 519795 +told 517951 +new 508486 +things 503642 +always 503417 +keep 498392 +long 496267 +years 481860 +leave 478282 +does 478174 +money 471643 +around 471463 +doesn 471037 +name 455543 +place 444863 +ever 444088 +feel 444014 +guys 442241 +father 437537 +guy 435832 +made 433900 +old 431911 +isn 429536 +which 422483 +big 417218 +lot 411660 +done 408760 +hello 405534 +nice 405497 +believe 403874 +girl 402557 +someone 401162 +fine 400530 +thanks 395690 +wanted 392510 +kind 392242 +coming 389949 +house 388585 +every 388520 +ok 388338 +through 388127 +being 386528 +course 381816 +stay 378726 +left 378587 +dad 378540 +enough 376973 +happened 375703 +came 374486 +may 373526 +mother 372846 +wrong 370949 +world 370620 +bad 369996 +might 367145 +three 366626 +today 366395 +listen 365791 +another 365421 +understand 365210 +hear 360665 +remember 358291 +ask 354323 +own 352542 +same 348167 +show 347408 +else 340756 +kill 339509 +talking 337629 +found 337382 +next 335573 +getting 331186 +care 330956 +car 330261 +looking 326550 +son 325997 +try 325748 +woman 324673 +went 321824 +hi 320316 +dead 317589 +many 314850 +mind 312927 +wasn 312240 +friend 308204 +best 307071 +mom 305139 +hell 304275 +morning 303550 +trying 303288 +boy 302508 +together 302404 +yourself 302013 +job 298911 +saw 297985 +family 294543 +real 294536 +without 290509 +baby 290496 +room 290206 +wouldn 288422 +already 287837 +move 283807 +most 283006 +seen 282420 +live 281046 +miss 280930 +actually 279595 +huh 274656 +shit 273312 +both 270720 +heard 269851 +once 269704 +ready 267268 +head 266844 +called 264588 +used 263566 +idea 263097 +knew 262988 +hold 262367 +happy 262141 +door 261118 +such 261104 +fuck 260121 +brother 259877 +also 259803 +pretty 259500 +bit 258929 +took 258808 +haven 258463 +yet 257947 +men 257319 +whole 257109 +start 256417 +use 256079 +while 255970 +since 254427 +wife 254038 +days 253171 +guess 250320 +tomorrow 248729 +matter 247812 +meet 247466 +bring 247343 +tonight 246514 +everyone 246032 +run 244200 +wanna 243109 +hard 242871 +ah 242456 +alone 240475 +myself 240460 +school 240176 +gone 240082 +um 238853 +end 237387 +saying 236600 +phone 235246 +play 233865 +looks 233814 +couldn 233368 +fucking 232845 +problem 232325 +few 231375 +friends 230464 +gotta 230351 +ago 229751 +open 229669 +anyone 228332 +killed 228297 +hope 226993 +face 225482 +until 225171 +lost 222398 +police 221380 +excuse 219855 +turn 218563 +business 215855 +case 215274 +wants 215149 +says 215090 +true 214773 +die 214062 +heart 213914 +soon 213279 +each 211943 +worry 211329 +later 208878 +year 208705 +watch 207949 +music 207205 +hand 206247 +having 206106 +probably 202736 +beautiful 200811 +doctor 198424 +sit 198149 +eat 197654 +thinking 195875 +young 194810 +second 193687 +working 193172 +water 193014 +person 192652 +part 192248 +kids 192050 +late 191989 +stuff 190629 +exactly 189513 +under 189347 +death 189000 +minute 188958 +pay 188822 +crazy 188767 +forget 188628 +everybody 188532 +kid 187858 +change 186962 +gave 186445 +happen 186041 +damn 184228 +five 183626 +drink 182580 +far 182287 +knows 181663 +its 180594 +whatever 180082 +eyes 179260 +shut 178378 +aren 178286 +hit 177770 +taking 177667 +easy 177337 +times 176522 +check 176507 +hands 176291 +minutes 175091 +deal 174929 +different 174657 +means 174311 +point 174205 +inside 174081 +makes 172281 +asked 171740 +somebody 171596 +mine 170636 +making 169037 +body 168798 +afraid 168774 +sleep 168103 +chance 167981 +dear 167351 +quite 166980 +four 166957 +anyway 166702 +close 166677 +ain 166551 +party 166137 +fun 165693 +against 165033 +word 164938 +comes 164503 +important 164386 +set 164165 +shall 164162 +story 164040 +number 163259 +daughter 163071 +least 162905 +waiting 162571 +hurt 161421 +wish 160204 +moment 159995 +fight 159911 +week 159444 +husband 159394 +girls 159061 +rest 158976 +married 157817 +fire 157532 +game 157501 +nobody 157014 +mr 156641 +children 156181 +side 155667 +stand 155391 +read 154667 +though 154479 +cut 154407 +started 154345 +sister 154095 +supposed 153117 +between 152520 +child 151864 +goes 151195 +hours 151156 +speak 150927 +women 150803 +behind 148469 +almost 148405 +truth 148389 +blood 147913 +able 147205 +lady 147121 +anymore 146903 +playing 146615 +gets 146064 +shot 144772 +reason 144399 +trouble 143235 +break 143107 +war 142825 +city 142573 +walk 142055 +town 141835 +trust 141327 +met 141208 +office 141140 +question 140970 +brought 140661 +yours 140553 +welcome 140327 +high 140078 +wow 139593 +couple 138996 +half 138527 +died 138432 +cool 138139 +free 137598 +either 137448 +seems 136699 +power 136611 +whoa 136564 +bye 136276 +buy 136137 +telling 136109 +honey 135631 +tried 135401 +front 135341 +team 134358 +answer 134088 +gun 133072 +boys 132594 +line 132433 +send 131999 +news 131599 +stupid 131497 +bed 131445 +hurry 130182 +full 129735 +months 129732 +save 129390 +sometimes 128779 +become 128716 +along 128220 +hate 128153 +food 127657 +outside 127251 +light 126657 +needs 126398 +dog 125769 +country 125731 +clear 125730 +order 125446 +em 125033 +fact 124995 +lord 124880 +captain 124874 +six 124087 +hot 123994 +funny 123924 +black 123718 +alive 122833 +pick 122698 +feeling 122065 +living 121552 +cause 121264 +ahead 120295 +lose 118712 +king 118230 +plan 118129 +dinner 117970 +sighs 117410 +sort 117267 +leaving 117050 +shouldn 117029 +running 116973 +boss 116736 +alright 116629 +promise 115908 +taken 115236 +safe 114104 +ma 114026 +book 113963 +sent 113959 +white 112881 +hour 112782 +anybody 112582 +small 112316 +perfect 112229 +lives 110583 +special 110344 +parents 110206 +john 109966 +himself 109748 +perhaps 109240 +sounds 109140 +serious 108774 +sick 108596 +company 108073 +ha 107974 +scared 107906 +uncle 107826 +poor 107444 +red 106589 +past 106439 +earth 106394 +possible 106080 +shoot 106067 +touch 105846 +sound 105298 +top 105282 +ass 105009 +laughs 104913 +cannot 103913 +asking 102974 +win 102956 +glad 102848 +control 102782 +hmm 102607 +human 102595 +drive 102436 +hair 101852 +jack 101666 +bitch 101302 +luck 101228 +murder 101133 +happens 100847 +air 100137 +ten 100133 +daddy 100005 +finally 99904 +chuckles 99607 +fast 99084 +cold 99032 +seem 98575 +laughing 98420 +words 98242 +hospital 98076 +street 98007 +hang 97778 +dance 97339 +meeting 97328 +till 97044 +others 96631 +catch 96609 +follow 96529 +sense 96442 +sex 96431 +lie 95930 +evening 95321 +master 95131 +known 95125 +dream 95089 +write 94964 +million 94514 +voice 94104 +rather 94057 +sweet 94057 +felt 93826 +sign 92512 +lucky 92377 +somewhere 92265 +bet 91573 +jesus 91143 +longer 91035 +calling 90868 +worked 90771 +quiet 90552 +looked 90441 +less 90011 +pull 89880 +beat 89787 +careful 89667 +coffee 89650 +return 89451 +secret 89236 +weeks 88925 +weren 88427 +date 88407 +seeing 88192 +fall 87762 +given 87738 +ooh 87683 +fault 87420 +straight 87378 +takes 86959 +song 86877 +future 86832 +gentlemen 86660 +loved 86138 +changed 86133 +road 85751 +calm 85638 +wonderful 85617 +mad 85366 +turned 85279 +drop 85186 +ladies 85029 +learn 84936 +step 84882 +absolutely 84856 +early 84836 +explain 84368 +clean 84238 +piece 84224 +yesterday 83823 +throw 83679 +picture 83461 +land 83422 +feet 83295 +wonder 82903 +questions 82887 +speaking 82769 +worth 82559 +darling 82240 +dude 82190 +giving 82085 +president 81980 +eye 81953 +quick 81548 +moving 81193 +figure 81048 +state 80818 +strong 80501 +sam 80423 +none 80315 +amazing 80267 +ones 79546 +works 79323 +act 79020 +needed 78920 +weird 78317 +law 78264 +worried 78083 +report 77824 +goodbye 77514 +missing 77326 +choice 77197 +happening 77052 +chief 76951 +wedding 76940 +strange 76863 +general 76816 +pain 76799 +kidding 76744 +decided 76683 +ya 76658 +pass 76635 +tired 76540 +class 76489 +officer 76015 +kept 75930 +wake 75776 +worse 75595 +busy 75557 +eh 75178 +mistake 74957 +kiss 74790 +court 74580 +building 74492 +finish 74299 +during 74298 +age 74262 +ship 74224 +caught 74060 +marry 73938 +meant 73929 +sell 73802 +dark 73770 +watching 73700 +system 73678 +suppose 73465 +evidence 73113 +movie 73070 +ride 73070 +completely 72777 +mouth 72733 +totally 72478 +birthday 72140 +tv 72054 +forgive 71915 +born 71906 +imagine 71873 +information 71840 +instead 71571 +definitely 71524 +security 71503 +certainly 71435 +film 71407 +month 71163 +lying 71098 +unless 70979 +train 70974 +seven 70968 +wear 70955 +clothes 70929 +michael 70741 +hotel 70659 +christmas 70624 +attack 70583 +hasn 70449 +round 70043 +expect 69614 +sing 69575 +terrible 69499 +george 69441 +bag 69186 +history 69119 +blue 68839 +near 68790 +broke 68629 +station 68571 +seriously 68524 +forever 68510 +david 68505 +frank 68472 +except 68440 +thinks 68354 +message 68305 +entire 68282 +joe 68244 +table 67702 +talked 67666 +across 67595 +rock 67361 +lovely 67236 +handle 67089 +middle 66734 +buddy 66718 +paid 66526 +protect 66508 +using 66503 +floor 66456 +ran 66177 +swear 66157 +spend 66119 +situation 66029 +ring 66018 +anywhere 65981 +dangerous 65974 +bill 65959 +york 65880 +army 65795 +lead 65756 +bought 65752 +finished 65659 +fair 65474 +letter 65448 +sun 65448 +fool 65432 +attention 65421 +club 65275 +simple 65090 +interesting 65082 +space 64991 +test 64969 +box 64788 +group 64761 +single 64614 +sitting 64521 +marriage 64465 +join 64456 +fear 64352 +peace 64262 +forgot 64169 +force 63737 +short 63714 +normal 63676 +charlie 63672 +present 63307 +enjoy 63294 +mike 63219 +crime 63084 +horse 62923 +ground 62860 +american 62835 +count 62763 +area 62755 +charge 62746 +honor 62722 +lunch 62582 +miles 62523 +radio 62502 +idiot 62357 +ball 62325 +surprise 62170 +paper 62166 +key 62153 +boat 62148 +quickly 62072 +gold 61935 +bar 61927 +fish 61813 +mark 61805 +tom 61785 +wearing 61749 +crying 61698 +accident 61671 +government 61646 +eight 61400 +fell 61371 +cover 61052 +certain 61050 +interested 61029 +deep 60938 +star 60903 +sea 60812 +agree 60716 +detective 60633 +problems 60633 +prison 60581 +major 60560 +stick 60548 +offer 60444 +difficult 60404 +smart 60397 +personal 60381 +record 60360 +stopped 60200 +hide 60133 +whether 59993 +bank 59962 +trip 59906 +relax 59715 +america 59703 +public 59687 +list 59557 +afternoon 59466 +brain 59464 +fix 59349 +bastard 59332 +proud 59309 +service 59277 +tea 59277 +screaming 59151 +forward 59064 +angry 58748 +park 58713 +soul 58677 +fighting 58657 +peter 58654 +rich 58651 +agent 58608 +blow 58531 +paul 58529 +dress 58429 +mary 58366 +missed 58324 +scene 58017 +killing 57890 +standing 57866 +saved 57667 +mm 57659 +respect 57647 +killer 57555 +ice 57545 +mess 57542 +tough 57486 +feels 57388 +church 57254 +sad 57179 +cell 57033 +drunk 56919 +share 56854 +camera 56757 +mm-hmm 56720 +within 56709 +card 56659 +fly 56595 +ben 56502 +girlfriend 56475 +laugh 56460 +smell 56399 +broken 56391 +mum 56281 +honest 56234 +often 55998 +starting 55979 +calls 55940 +spent 55836 +third 55750 +english 55677 +visit 55638 +mama 55372 +judge 55336 +window 55285 +hungry 55259 +dare 55131 +relationship 55128 +moved 55096 +prove 55079 +private 54978 +wall 54813 +seat 54615 +i-i 54460 +position 54449 +lieutenant 54321 +realize 54306 +lt 54305 +especially 54148 +machine 54140 +walking 54115 +art 54096 +pleasure 54085 +bloody 54026 +college 53933 +french 53911 +involved 53718 +cry 53674 +became 53603 +lived 53550 +impossible 53527 +obviously 53527 +neither 53452 +accept 53291 +boyfriend 53124 +besides 53110 +queen 53006 +teacher 53004 +cops 52981 +sake 52827 +loves 52751 +carry 52734 +teach 52731 +apartment 52714 +upset 52565 +green 52557 +la 52546 +liked 52485 +cute 52240 +evil 52175 +professor 52026 +contact 51750 +joke 51728 +cop 51701 +huge 51581 +holy 51499 +store 51492 +jail 51429 +likes 51334 +lawyer 51332 +doubt 51287 +continue 51279 +appreciate 51258 +cat 51175 +shop 50976 +driving 50866 +congratulations 50847 +wrote 50800 +village 50792 +quit 50649 +field 50617 +james 50562 +wine 50522 +decision 50446 +south 50364 +sleeping 50357 +slow 50276 +laughter 50196 +island 50107 +glass 50103 +beginning 50069 +cash 49976 +dying 49963 +hundred 49823 +whose 49808 +difference 49743 +plane 49704 +push 49690 +continues 49684 +mmm 49655 +singing 49144 +eating 49119 +north 49107 +mrs 49087 +tree 49017 +madam 48931 +gift 48891 +truck 48807 +putting 48797 +bear 48674 +board 48666 +grab 48653 +beer 48641 +stuck 48497 +magic 48484 +support 48351 +rules 48345 +grunts 48253 +partner 48247 +reach 48221 +wind 48063 +colonel 48036 +immediately 47938 +max 47938 +seconds 47936 +thousand 47934 +gives 47900 +experience 47824 +cheers 47819 +victim 47739 +upon 47699 +computer 47672 +christ 47650 +planet 47564 +promised 47559 +bus 47534 +henry 47491 +dirty 47462 +search 47439 +staying 47241 +dreams 47210 +arrest 46994 +holding 46938 +suddenly 46833 +usually 46770 +lots 46570 +shoes 46490 +er 46383 +jump 46358 +rid 46242 +yo 46233 +knock 46228 +owe 46153 +harry 46028 +grow 46010 +worst 45845 +aunt 45834 +river 45834 +patient 45828 +kitchen 45812 +aah 45774 +fat 45737 +passed 45678 +final 45614 +summer 45611 +bob 45572 +listening 45564 +escape 45553 +everywhere 45430 +moon 45422 +arms 45390 +turns 45374 +address 45332 +ow 45251 +match 45223 +gasps 45214 +grand 45183 +yep 45096 +shh 44939 +nervous 44913 +de 44903 +choose 44853 +themselves 44832 +decide 44806 +mate 44782 +drinking 44770 +press 44624 +bother 44619 +foot 44588 +hadn 44545 +blame 44534 +crap 44529 +drugs 44516 +type 44489 +rings 44331 +mission 44289 +named 44264 +heaven 44231 +picked 44114 +paris 44110 +race 44089 +risk 44005 +books 43897 +further 43839 +danny 43801 +action 43798 +allowed 43797 +orders 43786 +learned 43716 +price 43671 +arrived 43640 +nick 43636 +otherwise 43596 +pictures 43550 +fit 43502 +smoke 43421 +favor 43370 +played 43357 +notice 43315 +awesome 43298 +smile 43295 +director 43294 +guard 43270 +begin 43130 +spot 43079 +surprised 43018 +innocent 42962 +narrator 42916 +herself 42884 +london 42880 +feelings 42843 +enemy 42799 +battle 42785 +ourselves 42731 +alex 42710 +dollars 42705 +allow 42649 +nine 42538 +gay 42536 +department 42487 +guilty 42459 +apart 42417 +earlier 42407 +duty 42329 +jim 42323 +suit 42315 +bell 42299 +west 42219 +legs 42152 +hero 42093 +destroy 42078 +stage 42074 +bunch 42064 +according 42026 +chicken 41996 +bigger 41873 +grunting 41866 +low 41837 +helping 41832 +admit 41817 +closed 41801 +names 41772 +witness 41753 +upstairs 41732 +arm 41726 +steal 41720 +jimmy 41706 +kick 41697 +twice 41693 +cross 41692 +ways 41675 +sergeant 41650 +indeed 41591 +gas 41535 +keeping 41371 +energy 41290 +pregnant 41290 +waste 41240 +helped 41235 +fired 41193 +favorite 41153 +tony 41145 +taste 41080 +locked 41079 +places 41041 +writing 41035 +adam 41028 +brothers 41025 +starts 41012 +prince 40914 +form 40833 +sold 40796 +silly 40758 +mention 40753 +build 40738 +throat 40719 +hole 40658 +figured 40590 +track 40578 +ringing 40518 +lock 40462 +leg 40449 +above 40388 +hiding 40387 +steve 40377 +seemed 40343 +breakfast 40285 +engine 40282 +written 40246 +complete 40210 +video 40195 +applause 40194 +however 40098 +pressure 40065 +fresh 40049 +weapon 40033 +sky 39986 +stole 39897 +study 39882 +burn 39874 +reading 39869 +crowd 39856 +treat 39803 +roll 39791 +double 39770 +spirit 39556 +danger 39518 +cost 39485 +empty 39482 +level 39476 +memory 39331 +itself 39330 +acting 39326 +interest 39252 +nose 39194 +plans 39142 +following 39136 +bathroom 39095 +built 39041 +closer 39022 +ray 38995 +sarah 38933 +band 38918 +groans 38867 +apparently 38705 +excited 38640 +richard 38625 +losing 38585 +animals 38518 +flight 38504 +nature 38496 +raise 38449 +pop 38419 +client 38407 +bomb 38395 +neck 38371 +suspect 38365 +warm 38355 +extra 38335 +bottle 38229 +van 38223 +heavy 38220 +mommy 38167 +dogs 38164 +wild 38162 +ridiculous 38121 +simply 38100 +lee 38047 +animal 38036 +showed 38007 +billy 37951 +keeps 37914 +shooting 37914 +camp 37907 +guns 37893 +medical 37844 +shame 37793 +hoping 37769 +whom 37735 +bird 37711 +flowers 37699 +majesty 37699 +famous 37634 +asleep 37535 +beauty 37515 +driver 37474 +keys 37443 +rain 37417 +awful 37405 +large 37373 +local 37344 +deserve 37283 +goddamn 37270 +stone 37203 +jane 37198 +grace 37180 +consider 37165 +weekend 37115 +wondering 37023 +lay 36984 +plenty 36941 +willing 36938 +pants 36916 +sweetheart 36907 +skin 36861 +excellent 36849 +asshole 36844 +beach 36841 +faith 36815 +beg 36807 +fuckin 36802 +responsible 36781 +military 36733 +cheering 36723 +opportunity 36711 +common 36698 +bottom 36680 +german 36610 +whoever 36535 +cook 36531 +walked 36528 +papers 36502 +justice 36481 +hall 36354 +commander 36345 +drug 36316 +main 36292 +knife 36197 +devil 36189 +necessary 36124 +although 36046 +princess 36046 +lights 36039 +flying 36023 +dick 35996 +rose 35942 +knowing 35876 +clearly 35837 +hat 35828 +agreed 35752 +johnny 35735 +corner 35734 +code 35711 +note 35701 +tommy 35690 +due 35669 +correct 35595 +apologize 35587 +language 35569 +stars 35549 +faster 35547 +cars 35472 +folks 35431 +bullshit 35397 +fellow 35335 +several 35232 +grandma 35216 +shows 35209 +leader 35155 +leaves 35150 +restaurant 35126 +east 35109 +shouting 35108 +blind 35066 +ghost 35051 +cup 35016 +gotten 35001 +tight 34972 +conversation 34935 +tells 34873 +lies 34827 +nor 34821 +pulled 34816 +hanging 34808 +speed 34794 +stories 34792 +health 34788 +advice 34773 +held 34733 +uh-huh 34672 +murdered 34599 +beyond 34595 +rule 34517 +hardly 34482 +inspector 34442 +possibly 34442 +cousin 34395 +trial 34375 +emergency 34359 +ought 34342 +eddie 34314 +somehow 34291 +hearing 34211 +states 34128 +account 34088 +spoke 34035 +file 33969 +understood 33953 +angel 33922 +tape 33904 +milk 33863 +powerful 33854 +weapons 33779 +manager 33775 +practice 33775 +pardon 33755 +vote 33737 +jake 33704 +national 33697 +career 33682 +minister 33653 +super 33639 +taught 33613 +robert 33595 +biggest 33564 +plays 33550 +natural 33540 +dancing 33491 +copy 33489 +plus 33414 +martin 33293 +cake 33256 +freedom 33196 +among 33190 +breath 33176 +operation 33161 +chris 33139 +crew 33113 +challenge 33099 +market 33081 +meat 33050 +towards 33032 +bringing 33020 +dropped 32997 +student 32987 +lied 32979 +strength 32950 +breathe 32841 +size 32841 +color 32837 +monster 32819 +loud 32757 +photo 32757 +aw 32746 +nearly 32730 +sight 32715 +greatest 32692 +games 32623 +bridge 32588 +dressed 32547 +arrested 32506 +horrible 32492 +coach 32471 +planning 32400 +checked 32333 +breaking 32321 +noticed 32301 +fantastic 32288 +screams 32273 +serve 32190 +ideas 32182 +investigation 32178 +center 32146 +older 32130 +pack 32110 +soldiers 32098 +nonsense 32097 +doc 32073 +project 32040 +training 31973 +example 31952 +trick 31937 +prepared 31857 +science 31833 +united 31812 +travel 31791 +incredible 31786 +grandpa 31767 +paying 31696 +character 31657 +teeth 31615 +criminal 31574 +charles 31567 +chinese 31561 +truly 31514 +honestly 31501 +bro 31473 +survive 31440 +bobby 31434 +target 31415 +feed 31409 +nurse 31406 +fake 31355 +records 31339 +breathing 31306 +sweetie 31295 +numbers 31285 +oil 31269 +suicide 31261 +belong 31225 +whoo 31217 +perfectly 31201 +amy 31193 +forgotten 31150 +remain 31135 +original 31108 +papa 31095 +onto 31052 +concerned 31047 +credit 31029 +ugh 31008 +invited 30993 +discuss 30922 +research 30907 +easier 30876 +view 30874 +chair 30826 +hurts 30804 +strike 30801 +roger 30768 +fill 30683 +condition 30624 +mountain 30565 +dr 30549 +nowhere 30470 +sheriff 30458 +kim 30447 +turning 30420 +brown 30395 +recognize 30366 +heads 30350 +audience 30335 +jealous 30274 +pretend 30225 +society 30208 +finding 30196 +shirt 30183 +comfortable 30156 +meaning 30144 +guest 30128 +pieces 30122 +dry 30102 +letting 30057 +began 30049 +aye 30029 +jeff 29984 +female 29953 +release 29892 +ho 29883 +cards 29871 +pray 29862 +unfortunately 29849 +balls 29799 +destroyed 29789 +ended 29775 +universe 29765 +prepare 29762 +opinion 29749 +movies 29742 +soldier 29719 +wash 29707 +program 29668 +heat 29645 +usual 29636 +ticket 29625 +stolen 29618 +prefer 29606 +aware 29598 +surely 29565 +male 29548 +base 29547 +matters 29539 +lift 29528 +lab 29504 +command 29479 +proof 29430 +cream 29378 +selling 29357 +believed 29337 +create 29323 +afford 29290 +sunday 29282 +total 29264 +dumb 29229 +threw 29223 +france 29172 +birth 29167 +created 29134 +realized 29081 +british 29069 +noise 29049 +nuts 29037 +students 29034 +birds 29028 +social 28990 +brilliant 28972 +bodies 28945 +tie 28920 +opened 28868 +ours 28853 +bucks 28842 +kevin 28773 +mister 28739 +ugly 28713 +ryan 28655 +focus 28619 +dan 28601 +opens 28532 +exist 28526 +followed 28510 +england 28505 +draw 28503 +purpose 28471 +letters 28465 +daniel 28404 +opening 28386 +bullet 28377 +anna 28373 +lately 28340 +stayed 28309 +falling 28291 +season 28290 +ends 28242 +suggest 28213 +joy 28200 +distance 28199 +responsibility 28183 +whenever 28151 +issue 28140 +thousands 27986 +process 27930 +sword 27928 +shower 27901 +weak 27884 +fucked 27883 +lonely 27863 +happiness 27844 +eric 27842 +tiny 27829 +desk 27820 +pool 27818 +property 27815 +forced 27813 +settle 27798 +indistinct 27777 +weight 27761 +received 27728 +gang 27722 +bite 27719 +friday 27696 +disappeared 27695 +interview 27692 +expecting 27676 +kinda 27662 +surgery 27630 +horses 27542 +mayor 27530 +babe 27518 +ancient 27510 +handsome 27462 +thomas 27442 +saturday 27434 +staff 27397 +lines 27383 +unit 27376 +fan 27372 +gentleman 27345 +introduce 27302 +fate 27299 +split 27273 +recently 27204 +expected 27140 +add 27132 +ordered 27127 +slowly 27121 +alarm 27116 +member 27112 +slept 27077 +enter 27074 +signed 27074 +spanish 27061 +garden 27044 +brings 27005 +brave 26990 +pig 26989 +model 26977 +finger 26967 +medicine 26958 +access 26937 +failed 26879 +flat 26848 +easily 26840 +discovered 26836 +based 26829 +screw 26824 +insane 26788 +cares 26777 +weather 26740 +fingers 26736 +san 26690 +scott 26686 +path 26681 +soft 26642 +harm 26574 +style 26569 +community 26540 +sees 26505 +basically 26461 +al 26447 +signal 26438 +nope 26427 +spare 26393 +speech 26393 +covered 26386 +shake 26384 +loose 26356 +snow 26308 +russian 26288 +lake 26265 +bright 26241 +roof 26204 +ohh 26185 +sending 26184 +paint 26174 +remind 26171 +pal 26163 +naked 26122 +post 26113 +sugar 26110 +heading 26098 +streets 26067 +damage 26061 +silence 26053 +doors 26020 +pete 26016 +ed 26011 +success 25989 +wet 25987 +nah 25962 +amount 25945 +members 25937 +kate 25931 +manage 25874 +safety 25861 +returned 25860 +harder 25812 +fbi 25798 +block 25785 +showing 25785 +fancy 25771 +chef 25754 +contract 25746 +dig 25698 +chest 25688 +good-bye 25685 +drinks 25643 +dave 25633 +buried 25623 +brian 25604 +trade 25560 +journey 25518 +stomach 25500 +changes 25456 +details 25451 +thoughts 25436 +divorce 25433 +funeral 25423 +maria 25422 +football 25398 +reality 25398 +theory 25360 +gosh 25335 +ruined 25312 +gate 25308 +william 25303 +spread 25291 +outta 25290 +japanese 25288 +sudden 25262 +coat 25261 +sooner 25236 +cheese 25222 +larry 25197 +spring 25184 +page 25183 +ears 25168 +simon 25165 +castle 25150 +hidden 25143 +storm 25143 +cos 25116 +personally 25058 +artist 25056 +hill 25054 +exciting 25052 +permission 24999 +jerry 24979 +expensive 24978 +tickets 24973 +forest 24943 +barely 24909 +eggs 24897 +goin 24873 +regret 24846 +lesson 24843 +lover 24842 +bread 24828 +andy 24822 +subject 24820 +legal 24785 +growing 24779 +ill 24763 +jason 24753 +mood 24739 +owner 24722 +caused 24686 +beeping 24670 +points 24659 +dating 24645 +loss 24625 +secretary 24623 +revenge 24565 +santa 24532 +likely 24527 +rent 24451 +connection 24450 +assistant 24449 +reasons 24431 +yelling 24425 +painting 24401 +trees 24400 +doctors 24380 +rush 24375 +foreign 24370 +rough 24370 +century 24363 +murderer 24363 +nights 24324 +pair 24293 +runs 24284 +pocket 24261 +farm 24254 +matt 24245 +bike 24233 +obvious 24230 +ate 24218 +grew 24208 +professional 24205 +tim 24197 +goodness 24190 +parts 24186 +alan 24104 +university 24079 +square 24077 +grandfather 24064 +europe 24042 +genius 24032 +cases 24023 +ruin 24008 +winter 24005 +tongue 24001 +memories 24000 +da 23954 +buying 23941 +cancer 23935 +clock 23907 +ocean 23905 +dna 23889 +liar 23882 +tour 23875 +thief 23870 +bleep 23861 +lisa 23839 +rights 23824 +including 23813 +competition 23805 +smith 23799 +planned 23797 +emily 23792 +boring 23737 +victims 23737 +mentioned 23716 +bones 23635 +plant 23599 +bless 23587 +warning 23583 +knocking 23577 +re 23559 +crash 23547 +bedroom 23542 +lower 23529 +silver 23523 +groaning 23492 +madame 23490 +defense 23479 +results 23478 +toilet 23467 +event 23463 +complicated 23462 +shape 23460 +priest 23453 +royal 23448 +cheap 23430 +romantic 23427 +downstairs 23403 +invite 23394 +fortune 23379 +tears 23377 +avoid 23374 +reached 23358 +higher 23354 +familiar 23348 +telephone 23343 +burning 23326 +filled 23319 +kelly 23313 +alice 23311 +airport 23293 +jobs 23268 +grown 23266 +walter 23260 +rachel 23258 +giant 23227 +insurance 23218 +woods 23211 +scare 23192 +period 23158 +pleased 23158 +political 23126 +player 23087 +stops 23086 +secrets 23070 +laura 23050 +repeat 23036 +photos 23035 +finds 23019 +statement 22990 +suck 22982 +younger 22961 +china 22926 +humans 22912 +delicious 22901 +particular 22890 +proper 22889 +rome 22884 +belongs 22870 +attacked 22868 +bath 22866 +hired 22838 +site 22834 +knowledge 22822 +led 22806 +guests 22805 +celebrate 22764 +map 22726 +horn 22716 +eventually 22679 +pity 22667 +powers 22649 +ashamed 22623 +assume 22616 +glasses 22604 +rise 22596 +fixed 22575 +request 22533 +officers 22496 +pounds 22483 +data 22478 +carefully 22477 +per 22472 +depends 22413 +jury 22389 +waited 22384 +positive 22369 +attorney 22345 +direction 22341 +families 22338 +doin 22289 +forces 22284 +location 22282 +walls 22247 +useless 22228 +grant 22210 +saving 22196 +speaks 22181 +fought 22170 +meal 22145 +deliver 22124 +answers 22116 +changing 22102 +temple 22036 +scary 22035 +millions 22009 +offered 21983 +regular 21961 +carrying 21905 +official 21871 +jacket 21863 +switch 21862 +grave 21831 +chase 21830 +role 21813 +odd 21790 +sexy 21774 +faces 21771 +becomes 21768 +closes 21761 +badly 21750 +confused 21737 +tall 21737 +affair 21719 +television 21718 +shock 21717 +raised 21712 +panting 21711 +pizza 21677 +image 21669 +clears 21657 +golden 21644 +patients 21543 +watched 21542 +committed 21517 +sexual 21499 +suffer 21496 +arthur 21491 +wherever 21483 +plate 21466 +appear 21449 +chocolate 21440 +clever 21429 +hm 21429 +mercy 21410 +shots 21399 +lucy 21366 +dealing 21356 +trap 21352 +charges 21347 +phil 21338 +bang 21327 +poison 21323 +butt 21314 +drove 21309 +yourselves 21282 +headed 21258 +babies 21235 +yellow 21226 +soup 21202 +mystery 21181 +picking 21173 +sat 21163 +wound 21144 +traffic 21084 +courage 21070 +hunt 21068 +indian 21049 +rat 21042 +terms 21034 +italian 21021 +emma 21005 +checking 20978 +disease 20973 +managed 20964 +winner 20958 +council 20941 +appointment 20930 +monday 20923 +crack 20916 +threat 20913 +jenny 20899 +physical 20898 +nation 20894 +source 20892 +chose 20888 +healthy 20853 +carl 20851 +victory 20846 +rick 20835 +stood 20827 +kicked 20800 +annie 20794 +disgusting 20786 +palace 20785 +below 20771 +shopping 20754 +neighborhood 20746 +march 20744 +lips 20725 +midnight 20723 +piss 20720 +advantage 20711 +pure 20685 +india 20682 +aside 20677 +jerk 20633 +mail 20627 +dawn 20598 +effect 20595 +spell 20592 +freak 20578 +wood 20570 +screwed 20566 +enemies 20563 +gary 20553 +awake 20552 +chuckling 20547 +vacation 20544 +violence 20527 +leo 20521 +grandmother 20515 +modern 20496 +luke 20488 +firm 20482 +prime 20480 +heh 20476 +touched 20462 +talent 20450 +whore 20444 +piano 20426 +license 20413 +cooking 20410 +honour 20377 +concern 20376 +moves 20349 +available 20347 +factory 20341 +gods 20335 +value 20324 +central 20322 +union 20316 +mirror 20313 +studio 20312 +media 20306 +taxi 20268 +dies 20261 +iron 20252 +hearts 20232 +desire 20229 +rob 20228 +claire 20226 +fred 20224 +songs 20215 +washington 20212 +monkey 20211 +pride 20205 +pills 20181 +miracle 20179 +swim 20171 +burned 20170 +smells 20103 +joking 20073 +christian 20041 +bleeding 20017 +hook 20015 +beating 20006 +protection 19995 +ear 19950 +treatment 19950 +carter 19940 +metal 19938 +disappear 19933 +grateful 19921 +extremely 19901 +treasure 19899 +rescue 19892 +capable 19848 +chatter 19834 +passing 19834 +result 19823 +suffering 19819 +sisters 19817 +laid 19791 +governor 19790 +guards 19757 +louis 19754 +text 19730 +literally 19728 +becoming 19723 +remove 19723 +performance 19713 +stronger 19713 +rate 19700 +deeply 19685 +scream 19681 +desert 19666 +vehicle 19664 +illegal 19658 +pulling 19630 +throwing 19613 +josh 19609 +unbelievable 19591 +ted 19583 +zero 19582 +dean 19581 +curious 19576 +sean 19572 +candy 19571 +bone 19546 +tied 19530 +edge 19515 +load 19510 +susan 19462 +ahh 19459 +mountains 19451 +boom 19432 +former 19422 +holiday 19399 +riding 19398 +scoffs 19383 +hundreds 19379 +motherfucker 19379 +sue 19375 +bags 19374 +stealing 19327 +appears 19313 +arrive 19306 +remains 19299 +decent 19294 +issues 19278 +tip 19263 +fail 19258 +bride 19249 +pissed 19247 +penny 19236 +claim 19233 +friendship 19225 +desperate 19211 +flower 19199 +dramatic 19189 +refuse 19168 +solve 19166 +theme 19163 +loving 19152 +properly 19152 +dragon 19123 +mostly 19116 +chuck 19107 +directly 19104 +surface 19100 +false 19075 +cast 19073 +junior 19055 +hunting 19043 +silent 19042 +thou 19007 +egg 19000 +germany 18972 +punch 18951 +africa 18950 +woke 18947 +intelligence 18945 +borrow 18900 +winning 18898 +falls 18897 +popular 18896 +escaped 18891 +witch 18890 +convinced 18875 +warn 18847 +announcer 18839 +champagne 18824 +dust 18820 +kyle 18807 +someday 18800 +fallen 18795 +stranger 18791 +presence 18788 +tear 18766 +internet 18765 +monsieur 18763 +therefore 18733 +technology 18679 +tires 18678 +toast 18638 +wise 18634 +clark 18625 +americans 18614 +notes 18614 +smoking 18610 +hank 18609 +ls 18609 +april 18608 +wolf 18566 +precious 18562 +rooms 18562 +coast 18547 +treated 18542 +material 18538 +released 18533 +uniform 18533 +hated 18529 +considered 18518 +rice 18503 +exchange 18494 +steps 18492 +blake 18491 +convince 18445 +beast 18414 +cow 18392 +files 18385 +signs 18381 +drag 18380 +cab 18379 +hung 18378 +carried 18366 +ordinary 18352 +successful 18352 +eve 18346 +mistakes 18340 +houses 18338 +chattering 18327 +brains 18320 +japan 18308 +creature 18303 +fourth 18301 +separate 18291 +cleaning 18286 +california 18275 +lf 18264 +ll 18255 +shift 18253 +elizabeth 18247 +chicago 18217 +exact 18213 +karen 18204 +goal 18194 +expert 18181 +served 18169 +direct 18122 +score 18120 +marks 18117 +row 18117 +twenty 18112 +receive 18100 +series 18098 +section 18094 +pilot 18070 +jackson 18045 +darkness 18029 +thy 18023 +sale 18014 +destiny 18000 +cure 17999 +spoken 17993 +armed 17960 +rare 17927 +helen 17910 +grade 17882 +juice 17879 +wide 17850 +tower 17842 +solution 17830 +schedule 17817 +explosion 17799 +wheel 17795 +cigarette 17794 +julie 17794 +fruit 17773 +blew 17768 +victor 17768 +talks 17763 +sacrifice 17760 +range 17758 +button 17747 +reports 17743 +prisoner 17740 +pie 17716 +effort 17715 +youth 17703 +pot 17692 +eaten 17689 +taylor 17672 +knees 17671 +garage 17652 +parking 17645 +ambulance 17637 +staring 17630 +chances 17624 +circumstances 17621 +sin 17617 +gordon 17605 +progress 17605 +unusual 17603 +county 17598 +stairs 17597 +campaign 17583 +vision 17563 +reporter 17553 +equipment 17536 +moments 17531 +joey 17518 +mass 17516 +alien 17513 +trapped 17495 +helps 17480 +route 17472 +defend 17469 +trace 17465 +nasty 17464 +tests 17462 +magazine 17461 +rocks 17459 +dump 17455 +rude 17432 +clue 17430 +searching 17430 +amen 17429 +connected 17429 +pushed 17408 +merry 17407 +zone 17386 +senior 17374 +closing 17372 +freeze 17372 +emperor 17360 +incident 17339 +snake 17335 +mexico 17326 +lily 17324 +actor 17318 +newspaper 17312 +sentence 17312 +greg 17299 +leading 17295 +friendly 17294 +jones 17294 +chosen 17293 +engaged 17285 +julia 17281 +charming 17277 +mustn 17276 +anger 17258 +spending 17255 +learning 17252 +spy 17251 +sharp 17246 +shadow 17240 +warrant 17239 +elevator 17231 +kingdom 17231 +ricky 17230 +jamie 17227 +squad 17220 +shoulder 17198 +wave 17194 +amanda 17189 +highness 17174 +kinds 17172 +fishing 17170 +sometime 17142 +attitude 17122 +sucks 17110 +negative 17091 +screen 17070 +workers 17054 +bored 17028 +cameras 17026 +similar 17016 +understanding 17000 +beeps 16989 +chain 16978 +bull 16952 +gasping 16948 +fully 16943 +robin 16921 +collect 16907 +gorgeous 16895 +morgan 16891 +passion 16878 +writer 16877 +empire 16875 +international 16875 +curse 16867 +hire 16865 +bastards 16864 +puts 16846 +leads 16844 +id 16835 +blows 16823 +sons 16815 +estate 16813 +customers 16788 +maggie 16770 +gunshot 16763 +device 16734 +troops 16729 +hug 16724 +design 16691 +movement 16686 +thunder 16685 +object 16680 +loser 16663 +pen 16656 +sobbing 16648 +politics 16636 +despite 16632 +alcohol 16625 +climb 16614 +clients 16613 +conference 16610 +provide 16609 +marty 16606 +seek 16595 +witnesses 16584 +earn 16558 +trash 16555 +valley 16540 +fashion 16521 +episode 16500 +howard 16500 +prize 16489 +previously 16487 +sports 16468 +wanting 16465 +debt 16464 +wishes 16453 +hitler 16449 +wire 16416 +circle 16410 +thee 16410 +approach 16374 +stock 16373 +facts 16365 +remembered 16359 +percent 16352 +normally 16348 +tail 16347 +joined 16342 +education 16326 +bravo 16320 +library 16307 +rape 16303 +wings 16298 +growling 16293 +thrown 16290 +tank 16273 +exhales 16267 +authority 16265 +current 16251 +failure 16251 +nightmare 16248 +hitting 16244 +glory 16221 +knocked 16221 +studying 16217 +barry 16213 +agency 16192 +apple 16192 +border 16172 +rope 16159 +duck 16153 +reputation 16145 +confidence 16138 +yard 16138 +beloved 16136 +lack 16112 +actual 16107 +skills 16100 +films 16089 +mental 16088 +salt 16079 +flesh 16064 +anne 16062 +secure 16048 +highly 16045 +federal 16026 +sand 16019 +emotional 16018 +setting 16012 +nothin 15992 +senator 15992 +services 15982 +robbery 15978 +hates 15968 +fed 15965 +reward 15944 +theater 15940 +johnson 15936 +commit 15921 +patrick 15903 +entirely 15891 +extraordinary 15889 +ships 15878 +opposite 15870 +parties 15865 +stands 15856 +terry 15820 +chat 15812 +events 15804 +mummy 15781 +bay 15777 +slip 15773 +disappointed 15771 +settled 15766 +begins 15758 +andrew 15748 +alert 15747 +detail 15745 +pink 15742 +accepted 15736 +fellas 15736 +background 15730 +garbage 15709 +belt 15707 +minds 15707 +panic 15707 +blowing 15705 +hollywood 15691 +agreement 15659 +jackie 15659 +ability 15648 +pushing 15648 +tiger 15646 +whispering 15642 +culture 15640 +saint 15618 +jesse 15612 +task 15600 +solid 15581 +doll 15579 +intend 15570 +district 15566 +pa 15566 +gee 15564 +custody 15562 +ignore 15550 +naturally 15531 +useful 15524 +attempt 15516 +abandoned 15502 +cutting 15498 +kissed 15495 +barking 15493 +guarantee 15493 +gather 15484 +policy 15482 +violent 15464 +maid 15457 +embarrassing 15455 +childhood 15445 +wasting 15434 +bow 15423 +chick 15412 +sara 15387 +disaster 15366 +revolution 15350 +online 15339 +demon 15328 +heavily 15326 +coincidence 15318 +perform 15312 +terrific 15296 +thin 15296 +mac 15285 +crown 15281 +identity 15281 +virgin 15276 +impressive 15272 +windows 15229 +no-one 15212 +potential 15193 +guitar 15191 +committee 15152 +dozen 15139 +delivery 15137 +advance 15126 +quietly 15115 +stan 15106 +teaching 15100 +hunter 15097 +latest 15091 +hurting 15089 +swing 15079 +capital 15073 +counting 15069 +drew 15068 +dirt 15061 +whistle 15045 +marie 15044 +hits 15034 +doug 15030 +deny 15012 +urgent 15006 +threatened 14987 +behavior 14984 +molly 14980 +mixed 14977 +stays 14944 +assure 14943 +subtitles 14939 +explanation 14934 +wins 14921 +unique 14905 +chasing 14899 +billion 14895 +duke 14892 +production 14892 +agents 14888 +slave 14888 +carol 14887 +lets 14876 +cruel 14872 +mask 14863 +sally 14857 +behave 14851 +bury 14798 +massive 14789 +pathetic 14789 +species 14775 +approaching 14768 +jo 14768 +loan 14751 +struggle 14736 +dish 14734 +trusted 14722 +jumped 14699 +firing 14695 +channel 14688 +abby 14677 +stopping 14667 +asks 14655 +jean 14651 +swimming 14648 +daily 14647 +basement 14645 +linda 14644 +quarter 14642 +erm 14625 +bound 14619 +navy 14616 +article 14614 +guide 14614 +accused 14612 +transfer 14609 +guts 14598 +quality 14596 +fella 14583 +roman 14575 +greater 14569 +damned 14557 +couch 14529 +cheer 14507 +vegas 14494 +shoe 14489 +title 14488 +shy 14479 +punishment 14476 +clicks 14462 +los 14462 +closet 14440 +bye-bye 14436 +fever 14430 +coward 14421 +flash 14418 +impressed 14417 +bruce 14413 +siren 14411 +prisoners 14410 +sides 14403 +bowl 14401 +katie 14399 +sensitive 14396 +supply 14396 +hopefully 14389 +rolling 14377 +roy 14370 +dollar 14367 +tone 14366 +voices 14350 +cheating 14310 +confess 14303 +demand 14303 +museum 14302 +unknown 14300 +drama 14284 +suspicious 14279 +adult 14267 +todd 14262 +frightened 14246 +warned 14244 +steady 14241 +kills 14233 +crossed 14222 +lou 14221 +particularly 14220 +sandwich 14200 +laws 14196 +joint 14190 +bullets 14186 +package 14169 +trained 14156 +thursday 14150 +boots 14143 +possibility 14141 +albert 14140 +civil 14131 +june 14126 +germans 14122 +baseball 14106 +express 14103 +miserable 14078 +tuesday 14074 +parker 14072 +lion 14069 +joseph 14059 +refused 14058 +shine 14055 +assault 14053 +hong 14048 +jokes 14047 +breaks 14045 +argue 14042 +trail 14033 +option 14026 +recall 14021 +mighty 14014 +collection 14008 +oliver 13994 +wilson 13994 +accent 13993 +believes 13985 +decisions 13978 +embarrassed 13972 +mobile 13970 +customer 13954 +terribly 13954 +sec 13941 +charity 13935 +considering 13923 +pussy 13922 +donna 13918 +protecting 13916 +flag 13907 +kidnapped 13898 +italy 13890 +balance 13887 +creatures 13885 +nuclear 13873 +wallet 13863 +shout 13856 +vincent 13852 +entered 13850 +impression 13849 +angela 13848 +jay 13847 +degrees 13842 +favour 13830 +reaction 13825 +network 13816 +jessica 13814 +version 13814 +concert 13801 +gear 13780 +diamond 13777 +singer 13777 +defeat 13775 +steven 13772 +lane 13771 +blast 13764 +wounded 13763 +anytime 13761 +partners 13760 +ages 13759 +charlotte 13759 +ceremony 13753 +league 13742 +bars 13738 +orange 13738 +specific 13708 +conditions 13699 +plastic 13694 +crisis 13690 +fifth 13690 +term 13676 +self 13657 +offering 13655 +bud 13654 +suffered 13646 +reported 13643 +commissioner 13637 +rip 13626 +mysterious 13617 +entrance 13616 +messed 13609 +tunnel 13593 +designed 13591 +neighbor 13590 +aboard 13582 +sweat 13581 +justin 13578 +ghosts 13576 +crimes 13566 +costs 13561 +deserves 13558 +dreaming 13557 +bust 13552 +el 13551 +teddy 13549 +kong 13546 +messages 13545 +enjoying 13542 +afterwards 13540 +comfort 13536 +mason 13518 +gym 13513 +surrender 13513 +kitty 13499 +crush 13497 +yup 13494 +arranged 13475 +suits 13469 +pee 13455 +cave 13446 +stress 13446 +wing 13445 +jordan 13443 +arrange 13442 +comrade 13438 +noble 13437 +tricks 13428 +betty 13426 +launch 13420 +legend 13418 +mix 13418 +golf 13415 +imagination 13415 +fox 13409 +butter 13398 +kissing 13398 +tax 13389 +gross 13387 +edward 13367 +texas 13351 +insist 13332 +marcus 13314 +classic 13308 +selfish 13305 +frankie 13299 +skull 13299 +shown 13295 +bat 13294 +print 13293 +survived 13293 +champion 13290 +bills 13286 +therapy 13278 +lifetime 13272 +throughout 13270 +nathan 13261 +toward 13252 +sauce 13249 +experiment 13242 +margaret 13236 +describe 13221 +ross 13220 +grass 13219 +cheat 13204 +attend 13202 +homework 13202 +lewis 13201 +betrayed 13199 +cap 13198 +servant 13198 +tyler 13196 +bible 13193 +practically 13180 +catherine 13179 +steel 13177 +wipe 13177 +burns 13175 +generation 13163 +cabin 13159 +financial 13153 +industry 13143 +bond 13131 +sheep 13127 +scientists 13124 +beaten 13120 +deck 13118 +heck 13118 +fans 13116 +script 13108 +brand 13107 +degree 13103 +painful 13100 +coughing 13093 +religion 13083 +cats 13076 +stephen 13069 +fuel 13066 +influence 13066 +drawing 13056 +uh-oh 13043 +rabbit 13027 +exercise 13015 +attractive 13008 +touching 12992 +virus 12984 +realise 12977 +torture 12970 +blessed 12960 +plain 12952 +rebecca 12950 +recent 12948 +barbara 12947 +footsteps 12944 +sacred 12939 +removed 12931 +pretending 12910 +wasted 12909 +jewish 12898 +goodnight 12889 +robot 12886 +crystal 12885 +confirm 12878 +lad 12876 +mile 12874 +division 12873 +scratch 12856 +reminds 12848 +walks 12837 +ending 12835 +russia 12828 +contest 12826 +valuable 12803 +distant 12801 +dumped 12793 +motion 12791 +electricity 12789 +centre 12782 +murders 12778 +tina 12777 +jeremy 12772 +seal 12770 +ellen 12762 +standard 12762 +wore 12754 +surrounded 12750 +struck 12749 +gentle 12745 +inform 12745 +status 12743 +purse 12740 +prints 12737 +players 12735 +charged 12733 +begging 12730 +delivered 12713 +kenny 12713 +buck 12711 +nina 12708 +confirmed 12705 +construction 12705 +confession 12702 +trigger 12698 +cells 12693 +ali 12691 +frankly 12691 +exit 12688 +response 12684 +cancel 12681 +argument 12669 +principal 12667 +turkey 12662 +lucas 12659 +clinic 12658 +greetings 12651 +neighbors 12646 +vice 12641 +toy 12634 +hannah 12632 +everyday 12627 +generous 12622 +gain 12620 +idiots 12614 +holly 12610 +enjoyed 12603 +jungle 12593 +link 12591 +underground 12588 +smiling 12580 +mistress 12573 +teams 12567 +beef 12560 +product 12560 +whispers 12549 +presents 12544 +religious 12544 +identify 12538 +chip 12537 +chffffff 12527 +surveillance 12526 +carlos 12522 +vampire 12502 +routine 12497 +uses 12485 +michelle 12482 +underneath 12477 +systems 12464 +temperature 12455 +tribe 12451 +waves 12451 +brad 12442 +deputy 12440 +headquarters 12437 +sophie 12437 +equal 12433 +phones 12424 +ken 12419 +reckon 12411 +incredibly 12408 +related 12408 +chill 12405 +spit 12399 +tracks 12399 +oscar 12396 +makeup 12393 +bug 12385 +sounded 12385 +spirits 12381 +nerve 12377 +divorced 12374 +stake 12356 +doorbell 12355 +port 12355 +worries 12344 +nephew 12340 +miller 12331 +units 12323 +proceed 12314 +landing 12310 +traitor 12303 +outfit 12301 +chloe 12298 +bail 12295 +fields 12290 +patience 12288 +recording 12286 +foolish 12268 +loaded 12268 +tokyo 12261 +davis 12258 +costume 12253 +wayne 12252 +injured 12251 +somethin 12242 +ian 12239 +pet 12235 +cage 12231 +digging 12218 +spain 12200 +seats 12199 +awkward 12197 +cleaned 12182 +pattern 12181 +filthy 12179 +visiting 12178 +jews 12170 +answering 12168 +concentrate 12157 +someplace 12156 +citizens 12153 +aim 12152 +nancy 12149 +affairs 12143 +thick 12143 +sport 12131 +basic 12130 +electric 12125 +pleasant 12122 +cliff 12109 +nail 12105 +russell 12102 +environment 12101 +western 12101 +average 12100 +ease 12094 +interrupt 12088 +raped 12088 +judy 12085 +satisfied 12083 +beep 12076 +starving 12075 +documents 12070 +anniversary 12069 +beth 12061 +election 12060 +warrior 12054 +forth 12049 +banks 12029 +fetch 12029 +placed 12026 +timing 12009 +stones 12006 +complex 11993 +frozen 11992 +replace 11983 +prayer 11982 +skip 11977 +angeles 11974 +guilt 11973 +tune 11959 +woo 11943 +actions 11940 +conscience 11934 +officially 11933 +martha 11902 +machines 11899 +smaller 11897 +determined 11890 +blown 11886 +hail 11884 +unhappy 11881 +booth 11880 +pour 11879 +berlin 11871 +cleared 11869 +packed 11868 +wrap 11865 +randy 11863 +behalf 11840 +reasonable 11835 +trunk 11828 +homes 11826 +festival 11823 +tradition 11821 +cigarettes 11817 +le 11814 +beside 11813 +harvey 11810 +motive 11808 +beings 11805 +bishop 11803 +dealer 11801 +backup 11798 +defendant 11798 +wounds 11782 +ouch 11779 +ann 11776 +nearby 11769 +drank 11765 +effects 11765 +jonathan 11764 +benefit 11751 +dennis 11751 +adventure 11744 +territory 11737 +ve 11729 +apology 11723 +dylan 11723 +unlike 11719 +owns 11717 +boxes 11699 +thus 11684 +clay 11682 +developed 11679 +busted 11678 +pipe 11672 +gray 11667 +goods 11659 +favourite 11650 +salad 11646 +loyal 11641 +atmosphere 11639 +eva 11633 +freaking 11630 +dropping 11626 +strangers 11621 +mouse 11619 +downtown 11608 +francisco 11606 +heroes 11601 +pit 11599 +rotten 11586 +paradise 11584 +meantime 11582 +jess 11577 +organization 11576 +hills 11575 +exam 11573 +cock 11565 +earl 11562 +fairy 11562 +comment 11561 +activity 11555 +frame 11555 +knight 11542 +testing 11541 +habit 11536 +shelter 11527 +flow 11524 +jennifer 11509 +holes 11505 +anthony 11504 +prevent 11504 +lend 11500 +cooper 11490 +figures 11488 +boston 11484 +sample 11484 +landed 11481 +strip 11481 +buzzing 11477 +monk 11475 +slightly 11474 +produce 11471 +annoying 11467 +judgment 11459 +laundry 11458 +ron 11456 +lousy 11454 +souls 11454 +existence 11448 +belly 11442 +tries 11441 +foster 11435 +returning 11434 +answered 11431 +ward 11428 +plants 11419 +actress 11417 +chairman 11416 +hopes 11409 +individual 11409 +tattoo 11407 +fence 11402 +punk 11400 +sink 11400 +confident 11399 +yay 11393 +mistaken 11385 +limit 11384 +bothering 11383 +st 11375 +uncomfortable 11371 +wednesday 11367 +gifts 11365 +policeman 11364 +na 11360 +precisely 11346 +lawyers 11345 +greek 11339 +criminals 11335 +merely 11335 +underwear 11333 +earned 11315 +hoped 11315 +reveal 11310 +appeared 11306 +derek 11306 +heavens 11304 +personality 11303 +batman 11300 +virginia 11288 +wives 11285 +colour 11281 +worker 11277 +pope 11257 +instructions 11252 +intelligent 11251 +vince 11246 +worrying 11246 +comin 11237 +cried 11236 +traveling 11223 +bells 11222 +impact 11206 +relief 11188 +robbed 11188 +host 11183 +footage 11171 +odds 11170 +patrol 11170 +circus 11167 +mud 11166 +captured 11164 +lessons 11157 +occasion 11152 +sets 11151 +pulse 11145 +ad 11131 +invented 11131 +diamonds 11128 +matthew 11128 +auntie 11127 +cloud 11125 +francis 11123 +angels 11121 +classes 11120 +hers 11120 +mm-hm 11120 +signature 11114 +complain 11112 +blah 11108 +monitor 11106 +options 11104 +claims 11103 +flies 11093 +pat 11084 +britain 11077 +hid 11076 +wailing 11070 +listened 11059 +countries 11055 +vic 11054 +yell 11053 +rats 11046 +wondered 11041 +smooth 11036 +resist 11034 +companies 11033 +fantasy 11031 +passport 11030 +pitch 11022 +hammer 11015 +homicide 11008 +casey 11004 +holds 11004 +flew 10994 +jacob 10984 +noon 10982 +charm 10980 +dishes 10980 +helicopter 10980 +spin 10980 +slap 10977 +apply 10970 +fools 10969 +screeching 10966 +discover 10961 +previous 10960 +kit 10959 +authorities 10958 +moaning 10957 +photograph 10953 +sales 10950 +fifty 10941 +mickey 10939 +beneath 10937 +farewell 10937 +clouds 10936 +slipped 10931 +represent 10930 +deaf 10924 +facing 10917 +offense 10906 +citizen 10905 +clown 10883 +snap 10881 +messing 10878 +hood 10873 +twelve 10871 +interests 10866 +cheated 10865 +liz 10861 +informed 10853 +humanity 10845 +producer 10843 +technically 10839 +accounts 10832 +extreme 10829 +gettin 10829 +cia 10825 +ii 10823 +pays 10813 +profile 10808 +oxygen 10804 +jeez 10797 +gene 10796 +shed 10791 +minor 10781 +ex 10780 +theatre 10776 +scientific 10774 +lovers 10769 +chaos 10764 +rocket 10752 +math 10739 +stubborn 10738 +august 10736 +oi 10736 +chips 10734 +intense 10734 +grey 10729 +talkin 10729 +terrorist 10728 +angle 10724 +invitation 10721 +gus 10715 +gambling 10714 +respond 10704 +thirty 10699 +procedure 10698 +absolute 10696 +investigate 10695 +tragedy 10691 +stable 10690 +session 10682 +capture 10679 +marrying 10677 +ripped 10671 +attacks 10669 +stretch 10667 +bush 10666 +dates 10660 +unable 10659 +gates 10658 +mars 10656 +artists 10655 +increase 10629 +tastes 10620 +cared 10611 +bottles 10606 +highest 10590 +meanwhile 10589 +whirring 10589 +nate 10583 +grabbed 10579 +pigs 10566 +chop 10564 +olivia 10560 +wheels 10557 +shocked 10546 +commercial 10539 +reverend 10539 +escort 10537 +engagement 10535 +corpse 10531 +louise 10525 +scale 10519 +worthy 10519 +beats 10517 +williams 10516 +threatening 10510 +academy 10509 +exists 10509 +acts 10506 +hop 10501 +judges 10490 +furniture 10487 +shared 10484 +ralph 10474 +consequences 10473 +engineer 10471 +schools 10470 +softly 10462 +bombs 10459 +caroline 10457 +shark 10456 +transport 10456 +population 10449 +succeed 10441 +creepy 10420 +sneak 10416 +studies 10402 +destruction 10401 +keith 10400 +protected 10400 +monsters 10398 +joining 10396 +punished 10395 +lightning 10394 +malcolm 10382 +shell 10381 +fascinating 10378 +chamber 10376 +ethan 10374 +romance 10374 +instance 10371 +jumping 10367 +groups 10365 +exhausted 10362 +testify 10361 +pin 10355 +studied 10355 +walker 10355 +imagined 10353 +drill 10349 +investigating 10348 +ye 10344 +elena 10323 +experienced 10323 +loyalty 10321 +necklace 10321 +junk 10313 +cole 10307 +cries 10306 +stanley 10305 +rita 10304 +southern 10300 +blocks 10290 +emotions 10289 +begun 10285 +maya 10285 +liver 10281 +serving 10281 +matches 10279 +surgeon 10278 +granted 10275 +jazz 10272 +july 10271 +supplies 10271 +deeper 10270 +typical 10266 +kicking 10265 +obey 10262 +dancer 10251 +remote 10235 +daughters 10230 +moron 10229 +latin 10223 +teachers 10214 +toby 10209 +sandy 10207 +dragged 10206 +strategy 10205 +parent 10204 +jin 10200 +album 10199 +compared 10181 +rubbish 10181 +helpful 10173 +powder 10172 +sync 10168 +bum 10165 +passes 10162 +joan 10153 +fort 10152 +choices 10148 +alley 10145 +supper 10144 +penis 10143 +alibi 10141 +sammy 10138 +pole 10136 +coke 10133 +scientist 10126 +fighter 10121 +highway 10103 +blade 10100 +alpha 10093 +diet 10092 +liquor 10090 +jet 10082 +liberty 10080 +philip 10080 +widow 10080 +moral 10077 +carrie 10062 +award 10055 +grief 10054 +thirsty 10053 +ashley 10047 +random 10039 +suspects 10038 +intention 10035 +julian 10026 +active 10024 +tools 10020 +driven 10014 +trauma 10014 +headache 10008 +alexander 10001 +safely 10001 +knee 10000 +lads 9999 +novel 9997 +conversations 9995 +waters 9992 +invisible 9991 +lookin 9991 +internal 9988 +peaceful 9987 +humming 9986 +washed 9985 +drives 9981 +talented 9970 +li 9969 +aid 9965 +elephant 9951 +troubles 9940 +core 9939 +serial 9936 +painted 9934 +divine 9926 +jam 9923 +goat 9922 +opera 9918 +thieves 9915 +guessing 9912 +objection 9894 +whiskey 9893 +florida 9889 +resistance 9888 +dressing 9887 +attached 9884 +aaron 9883 +brief 9876 +punish 9876 +eternal 9873 +lois 9872 +required 9862 +victoria 9858 +fabulous 9853 +twins 9850 +characters 9847 +cooked 9846 +ln 9844 +ruth 9836 +dreamed 9835 +arts 9831 +naughty 9829 +stabbed 9824 +tend 9824 +diane 9819 +tap 9813 +soap 9811 +development 9803 +locker 9803 +images 9802 +prick 9801 +global 9793 +string 9786 +bitter 9785 +sharing 9785 +corn 9781 +craig 9781 +fits 9779 +tent 9777 +forms 9776 +votes 9774 +harold 9771 +propose 9771 +monica 9766 +constantly 9765 +granny 9765 +arguing 9761 +nerves 9761 +nicely 9761 +abuse 9756 +relatives 9754 +survival 9752 +gloves 9750 +tracking 9747 +zoe 9744 +bend 9739 +review 9732 +connect 9723 +separated 9723 +elder 9722 +beard 9718 +admiral 9717 +diana 9717 +salary 9710 +areas 9709 +disturb 9707 +pro 9706 +maintain 9704 +solved 9703 +wealth 9699 +bitches 9695 +possession 9686 +sang 9686 +gimme 9685 +plates 9684 +shoulders 9683 +burnt 9678 +recorded 9676 +upper 9674 +counts 9673 +profit 9672 +tale 9672 +colleague 9669 +hostage 9667 +warehouse 9667 +shore 9666 +porn 9665 +miami 9663 +fifteen 9662 +visitors 9662 +bo 9661 +net 9660 +insult 9659 +owen 9658 +causing 9655 +cities 9655 +heal 9653 +lemon 9653 +banging 9652 +honeymoon 9650 +appeal 9647 +marshall 9644 +creating 9636 +critical 9636 +scotland 9635 +crane 9625 +enormous 9624 +testimony 9624 +fights 9619 +praise 9619 +commission 9616 +indians 9616 +growls 9614 +nicole 9611 +sins 9604 +fraud 9599 +branch 9598 +happily 9592 +bout 9588 +covering 9576 +occurred 9568 +beans 9553 +raw 9552 +muscle 9544 +pages 9543 +tense 9542 +relationships 9541 +management 9540 +assignment 9538 +blonde 9536 +catching 9534 +exposed 9534 +canada 9533 +dont 9523 +han 9522 +von 9519 +comedy 9514 +marco 9511 +stroke 9508 +whistling 9506 +buildings 9504 +shaking 9503 +facility 9502 +appropriate 9500 +remarkable 9499 +transferred 9494 +drawn 9493 +tits 9493 +clicking 9492 +symbol 9490 +motor 9489 +employees 9481 +ambassador 9480 +mothers 9479 +feeding 9476 +pile 9476 +soda 9472 +checks 9469 +bears 9466 +cookies 9466 +slut 9463 +awfully 9457 +levels 9454 +stepped 9454 +toys 9454 +differently 9453 +magnificent 9453 +steak 9450 +leaders 9449 +tube 9449 +superior 9444 +herr 9443 +pan 9442 +expression 9438 +currently 9421 +deadly 9410 +throne 9410 +bee 9409 +tooth 9408 +depressed 9400 +potatoes 9398 +wicked 9391 +resources 9388 +native 9386 +centuries 9384 +karl 9383 +poetry 9374 +multiple 9370 +cable 9369 +shortly 9364 +buzz 9363 +fingerprints 9361 +socks 9361 +chin 9359 +goddess 9359 +anxious 9354 +colors 9351 +september 9351 +basketball 9350 +promises 9347 +explained 9346 +appearance 9341 +musical 9325 +sends 9325 +slide 9324 +aii 9323 +shawn 9321 +ultimate 9313 +pork 9308 +communication 9307 +payment 9305 +structure 9302 +paperwork 9301 +obsessed 9298 +lazy 9297 +russians 9294 +actors 9292 +squeeze 9291 +magical 9287 +colleagues 9282 +admire 9273 +madness 9271 +roads 9270 +entry 9269 +injury 9265 +burden 9262 +racing 9262 +manner 9260 +freezing 9255 +bits 9246 +norman 9245 +mount 9239 +battery 9237 +dessert 9237 +pound 9235 +motel 9233 +visited 9233 +rumbling 9232 +cinema 9230 +fond 9227 +halfway 9225 +rifle 9221 +reception 9216 +statue 9214 +fridge 9213 +brick 9209 +abandon 9208 +lap 9207 +natalie 9194 +wars 9192 +recommend 9188 +brush 9186 +nelson 9184 +concept 9181 +pill 9181 +delay 9179 +korea 9178 +quarters 9178 +janet 9176 +rub 9168 +mall 9166 +soil 9166 +complaint 9162 +sail 9160 +baker 9155 +coughs 9145 +bucket 9143 +description 9140 +badge 9133 +measure 9133 +hip 9131 +function 9130 +indistinctly 9127 +harris 9124 +foundation 9122 +labor 9117 +willie 9117 +waiter 9109 +homeless 9106 +proved 9104 +unfortunate 9101 +log 9100 +meetings 9099 +giggles 9098 +combat 9092 +poem 9092 +polish 9090 +handed 9089 +mia 9086 +weed 9084 +raymond 9076 +tons 9075 +jersey 9059 +satellite 9059 +eagle 9058 +privacy 9058 +chapter 9056 +treating 9054 +barn 9047 +entering 9047 +flip 9047 +wisdom 9047 +farmer 9046 +clan 9045 +budget 9026 +pub 9026 +temporary 9025 +effective 9022 +sonny 9017 +wendy 9013 +juan 9011 +lamb 9011 +plot 9006 +riley 9001 +proposal 8992 +sticks 8989 +searched 8988 +blessing 8982 +rage 8981 +bothered 8977 +sydney 8976 +basis 8975 +manners 8973 +recover 8972 +twist 8971 +neil 8967 +leak 8964 +item 8963 +catholic 8955 +sighing 8952 +retired 8951 +kings 8944 +inner 8942 +affect 8941 +cents 8941 +forbidden 8938 +patch 8938 +drops 8934 +necessarily 8933 +provided 8929 +meters 8928 +booked 8926 +returns 8924 +fires 8921 +gloria 8921 +humor 8919 +torn 8908 +e-mail 8903 +rocky 8902 +introduced 8900 +creative 8898 +betray 8895 +soviet 8894 +various 8893 +added 8891 +cowboy 8890 +discovery 8890 +suspected 8878 +suggested 8874 +delighted 8873 +sticking 8873 +dive 8872 +shane 8870 +prosecutor 8868 +radar 8868 +amber 8866 +vast 8866 +quinn 8864 +owned 8858 +cookie 8853 +felix 8852 +finest 8852 +pump 8846 +valentine 8845 +counter 8839 +sorts 8835 +coin 8830 +warren 8829 +slaves 8828 +marine 8827 +illness 8825 +sack 8822 +bid 8821 +moscow 8819 +require 8819 +largest 8817 +washing 8817 +achieve 8816 +causes 8812 +mo 8810 +dope 8809 +nest 8803 +palm 8801 +logan 8799 +constant 8796 +boo 8795 +medication 8789 +booze 8781 +gravity 8781 +noah 8779 +abroad 8778 +evan 8778 +christine 8775 +rising 8769 +analysis 8765 +cuts 8764 +eats 8763 +employee 8759 +vodka 8757 +damaged 8754 +height 8753 +pops 8753 +solar 8753 +yards 8749 +terrified 8744 +suitcase 8738 +chemical 8730 +preparing 8724 +advanced 8719 +fund 8719 +wears 8715 +chirping 8714 +web 8698 +phase 8690 +prom 8687 +suggesting 8686 +fleet 8685 +lame 8681 +irish 8678 +editor 8675 +october 8672 +assumed 8669 +whip 8668 +mel 8664 +region 8664 +amongst 8659 +temper 8657 +discussion 8656 +engines 8653 +operate 8647 +sunshine 8645 +acted 8641 +undercover 8638 +horror 8635 +smash 8631 +tested 8631 +contrary 8629 +roses 8627 +poker 8624 +wrapped 8624 +korean 8622 +graham 8620 +mi 8618 +fu 8617 +ivan 8613 +focused 8612 +liam 8611 +tragic 8607 +carla 8604 +demons 8596 +rounds 8595 +entertainment 8594 +gathered 8592 +uhh 8591 +lincoln 8590 +yells 8590 +crossing 8585 +blanket 8580 +nut 8577 +pockets 8576 +travis 8576 +limited 8575 +paintings 8569 +sober 8569 +caesar 8567 +sis 8566 +intended 8563 +computers 8561 +niece 8561 +tennis 8551 +needle 8546 +chicks 8545 +traditional 8545 +delicate 8544 +independent 8539 +leslie 8534 +lauren 8528 +grandson 8524 +repair 8522 +brandon 8518 +executive 8515 +colin 8513 +recognized 8510 +holmes 8509 +regarding 8508 +weakness 8507 +aliens 8506 +gunshots 8505 +european 8504 +method 8503 +tool 8496 +demands 8492 +permit 8491 +towel 8491 +daisy 8489 +northern 8488 +nowadays 8488 +spider 8488 +rangers 8484 +zoo 8483 +antonio 8477 +praying 8477 +passengers 8474 +con 8473 +australia 8472 +technique 8469 +cellphone 8464 +gently 8463 +operations 8461 +planes 8460 +bunny 8457 +gunfire 8452 +shining 8451 +backwards 8448 +skill 8447 +baron 8441 +kidnapping 8437 +iike 8435 +marshal 8433 +petty 8431 +sealed 8428 +massage 8424 +diary 8423 +menu 8423 +raising 8423 +fucker 8421 +wagon 8420 +audition 8417 +mitch 8417 +mexican 8416 +casino 8413 +bacon 8411 +diego 8408 +dutch 8403 +promotion 8402 +click 8401 +forgiveness 8399 +burst 8398 +permanent 8395 +interfere 8394 +chanting 8391 +dammit 8389 +spencer 8387 +recovered 8386 +registered 8379 +sunny 8377 +packing 8376 +scenes 8376 +superman 8375 +questioning 8373 +unfair 8369 +admitted 8368 +ancestors 8368 +polite 8368 +completed 8357 +swallow 8357 +conduct 8352 +prayers 8348 +sheet 8346 +fried 8345 +avenue 8333 +poisoned 8332 +reverse 8324 +newspapers 8321 +ford 8320 +complaining 8315 +senses 8313 +investment 8308 +absurd 8303 +potato 8297 +understands 8295 +grows 8294 +inspired 8287 +celebrating 8278 +ruby 8278 +tag 8276 +ram 8274 +bonnie 8269 +requires 8269 +bugs 8268 +leonard 8267 +elliot 8266 +hooked 8265 +jill 8264 +ranch 8263 +safer 8257 +worn 8256 +crashing 8254 +lands 8252 +heather 8248 +ash 8244 +leather 8241 +grounds 8236 +assuming 8235 +nails 8234 +conspiracy 8232 +dana 8232 +pierre 8229 +thanksgiving 8229 +guardian 8218 +rumors 8216 +megan 8215 +yen 8215 +ashes 8212 +terror 8207 +misunderstanding 8203 +tire 8199 +boats 8192 +parade 8190 +halt 8189 +immunity 8186 +adults 8185 +steam 8179 +closely 8176 +kang 8176 +pearl 8175 +soccer 8169 +adrian 8159 +priority 8157 +shouts 8155 +donald 8151 +happier 8150 +khan 8150 +struggling 8148 +root 8144 +larger 8128 +november 8128 +boot 8127 +conflict 8125 +laughed 8125 +debbie 8120 +cherry 8118 +honking 8117 +celebration 8115 +concerns 8115 +sore 8113 +cattle 8112 +unconscious 8112 +breasts 8109 +fairly 8109 +fee 8109 +holidays 8107 +radiation 8107 +raining 8107 +recognise 8107 +wade 8107 +forgetting 8105 +lungs 8097 +register 8096 +shadows 8095 +specifically 8092 +sarge 8091 +ideal 8087 +turtle 8080 +spoil 8079 +deserved 8074 +ronnie 8072 +gina 8069 +candidate 8068 +lance 8068 +types 8068 +bloke 8066 +explode 8064 +belonged 8061 +immediate 8061 +identified 8056 +di 8051 +hunger 8049 +closest 8047 +combination 8040 +nanny 8037 +seth 8037 +detectives 8036 +develop 8036 +gabriel 8036 +severe 8032 +en 8029 +organized 8028 +franklin 8022 +gut 8020 +splendid 8020 +dismissed 8012 +pm 8007 +yang 8005 +halloween 8004 +ellie 8003 +continued 7998 +vulnerable 7998 +vital 7997 +bureau 7996 +comrades 7996 +drawer 7995 +harper 7993 +retreat 7993 +carson 7991 +benny 7990 +pillow 7989 +ace 7987 +troy 7987 +cart 7985 +chan 7983 +nap 7983 +ham 7982 +announce 7978 +borrowed 7976 +parked 7969 +galaxy 7968 +muffled 7967 +voted 7967 +sebastian 7966 +mmm-hmm 7965 +jewelry 7964 +kisses 7961 +luggage 7957 +groom 7954 +oops 7954 +lighter 7953 +masters 7948 +alliance 7944 +buddies 7942 +clothing 7940 +denied 7939 +drown 7935 +alike 7933 +excellency 7933 +relative 7928 +photographs 7925 +burger 7924 +dough 7923 +alicia 7921 +wreck 7920 +approve 7918 +produced 7916 +christina 7915 +physically 7914 +stations 7913 +eli 7912 +samantha 7912 +spiritual 7910 +roaring 7909 +sire 7909 +stab 7907 +inch 7906 +servants 7906 +limits 7905 +unexpected 7899 +arrival 7898 +whimpering 7898 +basket 7897 +sheets 7895 +scandal 7894 +stink 7891 +homer 7887 +christopher 7886 +tara 7886 +toes 7886 +shield 7881 +shooter 7879 +instant 7872 +objects 7871 +rehearsal 7869 +disturbing 7868 +directed 7864 +scan 7861 +electronic 7860 +overnight 7860 +republic 7860 +counsel 7855 +sirens 7854 +dorothy 7853 +bait 7852 +dignity 7850 +explains 7850 +lean 7849 +heroin 7848 +tracy 7845 +rex 7841 +barney 7839 +gossip 7839 +economy 7837 +humble 7837 +worlds 7827 +impress 7819 +clara 7818 +tissue 7818 +et 7814 +threaten 7814 +bump 7813 +supreme 7809 +bingo 7807 +mitchell 7806 +excitement 7805 +mankind 7803 +document 7799 +broad 7798 +vessel 7796 +vietnam 7796 +dale 7794 +lit 7794 +connor 7790 +killers 7790 +mario 7787 +foul 7784 +stare 7782 +deaths 7780 +hut 7776 +elements 7774 +coma 7770 +laying 7769 +coffin 7767 +paula 7766 +cocaine 7765 +puppy 7764 +louder 7761 +announcement 7759 +handled 7759 +oath 7758 +mob 7753 +cotton 7748 +deposit 7747 +taxes 7741 +injuries 7735 +jen 7731 +autopsy 7728 +advise 7727 +gig 7723 +lecture 7721 +posted 7719 +skinny 7719 +lo 7717 +include 7716 +revealed 7713 +infected 7711 +assistance 7707 +relieved 7707 +solo 7703 +determine 7695 +terrorists 7694 +elsewhere 7693 +lamp 7692 +practical 7685 +marked 7684 +adorable 7682 +rubber 7682 +operating 7681 +purple 7681 +suite 7680 +apologies 7679 +personnel 7676 +beam 7674 +poet 7674 +samples 7673 +arrow 7670 +counselor 7670 +allen 7667 +carpet 7667 +audrey 7665 +spotted 7664 +floating 7661 +corrected 7648 +located 7648 +journalist 7646 +insisted 7645 +operator 7633 +rely 7632 +singh 7631 +fooled 7627 +arrives 7625 +leon 7619 +eastern 7614 +walt 7614 +ay 7613 +shave 7607 +remaining 7606 +toss 7606 +anderson 7602 +acid 7597 +lip 7597 +nora 7593 +si 7588 +december 7586 +anonymous 7582 +inn 7578 +planted 7577 +oldest 7576 +breast 7568 +sixth 7566 +error 7563 +reporting 7562 +egypt 7553 +islands 7553 +brother-in-law 7545 +jules 7536 +roommate 7535 +sings 7535 +scum 7530 +visitor 7527 +described 7525 +geez 7525 +giggling 7525 +inches 7524 +commitment 7523 +hercules 7521 +react 7521 +cows 7517 +protest 7517 +misery 7513 +embrace 7510 +theft 7510 +darn 7507 +harsh 7507 +andrea 7506 +communicate 7505 +fathers 7504 +faithful 7503 +hector 7501 +uh-uh 7501 +flame 7500 +medal 7498 +photographer 7495 +silk 7495 +cemetery 7493 +trevor 7493 +banana 7491 +freddy 7491 +rear 7491 +allison 7485 +emotion 7482 +chap 7480 +dated 7478 +excuses 7477 +shitty 7475 +bin 7468 +jew 7465 +crushed 7462 +gum 7462 +content 7459 +efforts 7459 +corporal 7450 +realised 7449 +del 7447 +rap 7446 +hopeless 7445 +debate 7444 +era 7442 +luckily 7442 +ton 7438 +liquid 7435 +swell 7434 +feast 7431 +peggy 7431 +murphy 7430 +attracted 7428 +application 7420 +profession 7420 +bench 7419 +vanessa 7418 +ahem 7416 +symptoms 7415 +scout 7409 +crashed 7406 +keen 7404 +aircraft 7395 +formed 7394 +hon 7387 +aggressive 7383 +pale 7382 +significant 7382 +duncan 7380 +clubs 7379 +visual 7379 +embassy 7375 +cabinet 7374 +defence 7374 +display 7373 +fame 7372 +gibbs 7369 +drowned 7366 +filming 7364 +wolves 7364 +dunno 7363 +cursed 7360 +twin 7358 +sorrow 7354 +vault 7351 +defeated 7350 +watson 7349 +quote 7348 +bargain 7347 +penalty 7347 +pepper 7346 +speaker 7343 +compare 7342 +helmet 7341 +satan 7341 +scar 7339 +copies 7335 +fuss 7333 +envelope 7330 +perry 7325 +stinks 7323 +african 7322 +arrangements 7321 +wished 7318 +honored 7316 +believing 7311 +discussed 7309 +assigned 7308 +rosa 7305 +oven 7301 +attacking 7299 +jenna 7299 +tin 7297 +sonic 7293 +collar 7290 +mere 7287 +romeo 7286 +belief 7284 +storage 7281 +rusty 7276 +climbing 7269 +psycho 7269 +melissa 7267 +fears 7266 +sum 7265 +dedicated 7262 +ladder 7260 +alternative 7253 +raid 7253 +dining 7250 +ms 7250 +corporate 7248 +folk 7245 +jon 7243 +replaced 7242 +bailey 7241 +un 7239 +heels 7238 +duties 7236 +existed 7236 +smarter 7236 +surprising 7235 +chess 7234 +requested 7233 +cruise 7226 +primary 7225 +rhythm 7224 +drum 7222 +models 7221 +motorcycle 7221 +adopted 7218 +barrel 7217 +cargo 7217 +planets 7215 +shove 7215 +wha 7214 +surprises 7208 +rosie 7206 +dings 7204 +trailer 7200 +cannon 7198 +arse 7196 +tables 7196 +ally 7195 +lawrence 7193 +wee 7190 +tapes 7186 +dull 7185 +sandra 7185 +teenage 7184 +kent 7183 +cooperate 7180 +risks 7178 +dug 7177 +worthless 7177 +arriving 7175 +kindness 7173 +ties 7172 +items 7171 +grip 7167 +relations 7164 +rumor 7163 +skirt 7161 +frog 7160 +paige 7153 +lloyd 7151 +claimed 7146 +instrumental 7146 +perfume 7146 +association 7145 +flames 7145 +loses 7142 +january 7140 +spike 7140 +israel 7137 +eleven 7136 +gallery 7134 +ginger 7134 +activities 7131 +iraq 7131 +boarding 7121 +fist 7118 +improve 7114 +stuffed 7114 +creation 7113 +connie 7111 +infection 7108 +strikes 7107 +tension 7107 +wiped 7107 +whistles 7105 +kindly 7104 +certificate 7103 +curtis 7102 +bold 7100 +claudia 7099 +threats 7099 +sid 7095 +suspended 7092 +twisted 7092 +affected 7090 +envy 7089 +whale 7087 +dial 7086 +cameron 7085 +martial 7085 +controlled 7084 +lung 7084 +nigga 7077 +coal 7074 +upside 7074 +imperial 7073 +pam 7072 +paranoid 7063 +signing 7061 +congress 7060 +villa 7056 +candles 7055 +filling 7055 +becky 7053 +comic 7053 +faint 7053 +recovery 7052 +volunteer 7052 +tech 7051 +bleed 7050 +positions 7050 +cindy 7047 +narrow 7036 +forbid 7033 +stuart 7032 +perspective 7030 +faced 7028 +neat 7027 +pierce 7027 +observe 7024 +cycle 7022 +handling 7022 +psychic 7022 +goose 7019 +accidentally 7018 +conclusion 7018 +designer 7017 +sits 7016 +importance 7014 +spots 7006 +francs 7004 +sweep 7003 +destroying 7000 +communist 6994 +blackmail 6988 +pacific 6984 +risky 6984 +glorious 6982 +filed 6981 +hatred 6973 +cam 6972 +deals 6971 +pos 6970 +fooling 6967 +persons 6965 +scotch 6965 +executed 6959 +brakes 6958 +blank 6956 +tender 6954 +kurt 6953 +turner 6951 +tub 6944 +sucker 6939 +slight 6937 +addition 6933 +campus 6931 +ritual 6931 +prey 6930 +so-called 6929 +graduate 6927 +anyways 6926 +ew 6926 +fry 6923 +genuine 6921 +ned 6918 +doubts 6915 +judging 6915 +warden 6914 +ceiling 6909 +practicing 6904 +somewhat 6901 +established 6899 +attempted 6898 +generations 6895 +zombie 6895 +gathering 6886 +warriors 6885 +mick 6882 +electrical 6880 +collins 6875 +sobs 6875 +couples 6872 +depend 6872 +institute 6870 +miranda 6868 +principle 6868 +fixing 6867 +therapist 6866 +compete 6863 +compliment 6859 +worship 6858 +residence 6855 +democracy 6848 +joel 6844 +helpless 6843 +myth 6843 +published 6843 +screwing 6841 +kennedy 6836 +execution 6833 +appetite 6828 +formal 6824 +strictly 6823 +sucked 6822 +tanks 6821 +chickens 6815 +gwen 6813 +technical 6813 +stella 6811 +rod 6808 +meets 6803 +shepherd 6803 +brazil 6802 +brutal 6802 +alison 6801 +vain 6800 +chi 6797 +switched 6797 +deer 6789 +brooklyn 6787 +dresses 6786 +retire 6786 +inspiration 6778 +overcome 6778 +benjamin 6777 +cunt 6777 +nicky 6777 +thrilled 6775 +suggestion 6771 +tae 6771 +dummy 6768 +sailor 6767 +destination 6765 +assholes 6760 +bass 6760 +picnic 6760 +idol 6759 +trucks 6757 +rank 6755 +denise 6751 +yu 6750 +veronica 6749 +blocked 6747 +strict 6747 +hiya 6746 +formula 6745 +clerk 6743 +disturbed 6738 +samurai 6738 +cafe 6737 +vera 6737 +wandering 6737 +consciousness 6735 +val 6731 +nikki 6728 +min 6721 +bare 6720 +caleb 6717 +hans 6717 +arrangement 6715 +butcher 6715 +chased 6714 +ministry 6714 +brandy 6713 +boobs 6711 +sweater 6711 +locate 6706 +apologise 6701 +nazi 6700 +examine 6694 +parole 6694 +candle 6692 +trains 6692 +prior 6689 +stream 6688 +passage 6681 +organ 6680 +reaching 6680 +directions 6678 +asses 6677 +morris 6677 +outer 6677 +proves 6677 +luxury 6673 +pistol 6669 +losers 6666 +funds 6662 +strings 6661 +sunset 6661 +fortunately 6660 +gotcha 6660 +tomb 6655 +freddie 6654 +engineering 6649 +element 6645 +balloon 6642 +broadcast 6639 +spray 6639 +ty 6639 +ninja 6637 +eternity 6636 +handy 6634 +crawl 6626 +nations 6625 +slice 6625 +behaviour 6624 +pace 6620 +stiff 6620 +lick 6617 +allah 6610 +concrete 6610 +vegetables 6608 +euros 6606 +sausage 6605 +constable 6604 +spinning 6603 +rolls 6601 +genetic 6597 +loudly 6596 +old-fashioned 6596 +marvelous 6595 +subway 6595 +creep 6594 +scheme 6590 +performed 6584 +ex-wife 6583 +plug 6583 +rejected 6579 +brenda 6576 +respected 6573 +backs 6569 +protocol 6562 +muscles 6555 +kidney 6554 +finishing 6553 +im 6553 +products 6553 +wu 6549 +affection 6544 +invasion 6544 +endless 6543 +woody 6543 +discipline 6542 +horns 6541 +absence 6538 +waking 6537 +dudes 6536 +philosophy 6535 +cracked 6533 +nicholas 6531 +reed 6529 +verdict 6529 +drake 6527 +connections 6526 +privilege 6526 +anyhow 6523 +scent 6523 +naive 6522 +daylight 6521 +airplane 6520 +controls 6520 +jolly 6519 +values 6519 +swore 6516 +blaring 6515 +consent 6513 +puzzle 6510 +disagree 6505 +argh 6496 +winds 6496 +vanished 6494 +wizard 6493 +ranger 6492 +celebrity 6491 +sniffles 6491 +stores 6488 +aha 6485 +intentions 6482 +farmers 6480 +plague 6480 +breeze 6478 +rascal 6477 +bonus 6470 +convenient 6470 +dong 6470 +hush 6469 +entitled 6463 +instinct 6463 +maurice 6462 +porter 6462 +wrist 6461 +coins 6459 +originally 6459 +drain 6453 +furious 6452 +phoebe 6452 +physics 6452 +sniffs 6450 +vampires 6450 +defending 6447 +sector 6447 +income 6446 +finn 6445 +offended 6444 +decides 6440 +laser 6438 +fled 6436 +accurate 6435 +beers 6435 +boxing 6435 +kung 6434 +snakes 6431 +thumb 6429 +bald 6428 +sharon 6425 +suspicion 6424 +convicted 6423 +roots 6422 +shares 6420 +bernard 6419 +jessie 6418 +wang 6418 +blues 6412 +harrison 6412 +confusing 6410 +las 6408 +lobby 6408 +recipe 6408 +winston 6405 +generally 6401 +secretly 6398 +seeking 6398 +declare 6397 +freaked 6395 +missile 6395 +ensure 6394 +tricky 6391 +pointed 6390 +hockey 6389 +bubble 6388 +contacts 6388 +maximum 6386 +reminded 6386 +carriage 6381 +curtain 6374 +clues 6370 +author 6367 +circles 6367 +hostages 6367 +wallace 6366 +receiving 6362 +debts 6359 +cellar 6358 +tortured 6357 +tips 6355 +mates 6354 +mortal 6352 +reporters 6352 +associate 6351 +honesty 6350 +laptop 6347 +jung 6345 +katherine 6339 +rainbow 6336 +seed 6336 +mature 6334 +deed 6333 +kay 6331 +expenses 6320 +discussing 6319 +meals 6318 +pirate 6318 +performing 6317 +remained 6315 +adams 6312 +prostitute 6312 +retirement 6309 +platform 6308 +claus 6307 +erica 6304 +beds 6300 +illusion 6299 +writes 6299 +dealt 6298 +mill 6298 +hears 6292 +manhattan 6290 +tryin 6290 +mole 6289 +prosecution 6287 +rang 6287 +fatal 6284 +copper 6283 +troubled 6282 +heir 6278 +traces 6277 +length 6276 +owners 6276 +dentist 6275 +enterprise 6274 +lesbian 6274 +zach 6274 +tournament 6273 +travelling 6273 +confessed 6270 +roast 6269 +website 6268 +ja 6266 +mikey 6266 +flood 6263 +harbor 6263 +lola 6262 +rory 6259 +mansion 6257 +richie 6257 +mademoiselle 6256 +auction 6255 +desires 6252 +bachelor 6250 +haunted 6247 +distracted 6245 +smashed 6240 +rodney 6238 +scheduled 6236 +allows 6229 +champ 6229 +negotiate 6229 +shotgun 6226 +cocktail 6224 +nigger 6224 +donkey 6222 +pointing 6222 +butler 6218 +dizzy 6217 +methods 6216 +reference 6216 +bully 6215 +georgia 6215 +lydia 6215 +collapse 6214 +alfred 6211 +ana 6209 +minimum 6209 +chairs 6208 +hee 6207 +patty 6204 +owes 6202 +carmen 6201 +civilization 6201 +included 6201 +prices 6196 +wealthy 6193 +bart 6192 +depression 6192 +tide 6191 +rented 6190 +bra 6189 +lin 6189 +fellows 6186 +password 6186 +rot 6186 +th 6185 +kirk 6184 +pedro 6184 +spill 6182 +cured 6180 +hardest 6179 +naomi 6179 +deceased 6176 +sleepy 6175 +static 6174 +hawaii 6172 +almighty 6170 +murray 6169 +offers 6169 +bruno 6167 +disgrace 6167 +phrase 6167 +sources 6166 +approval 6165 +turtles 6164 +screech 6163 +snack 6163 +collected 6158 +ding 6157 +fur 6156 +jan 6153 +jewels 6153 +cheek 6152 +ditch 6152 +legally 6152 +benefits 6150 +roars 6149 +arnold 6145 +dime 6143 +domestic 6143 +chuckle 6141 +chang 6138 +hack 6138 +ransom 6138 +bore 6136 +muslim 6134 +loads 6133 +waitress 6133 +glenn 6131 +nurses 6131 +spite 6131 +clearing 6130 +rebel 6130 +andre 6129 +fortunate 6127 +survivors 6127 +disappoint 6125 +reply 6125 +expose 6124 +orbit 6124 +gin 6118 +austin 6117 +toe 6116 +sympathy 6115 +intel 6106 +growth 6104 +psychiatrist 6102 +encounter 6100 +slams 6100 +daphne 6097 +passenger 6096 +monkeys 6095 +accompany 6092 +hats 6092 +column 6088 +sigh 6086 +pounding 6085 +economic 6081 +tales 6080 +theirs 6075 +brat 6073 +follows 6072 +spreading 6072 +sister-in-law 6071 +burt 6070 +hilarious 6069 +hallway 6066 +hamilton 6063 +exclusive 6060 +chimes 6059 +decades 6059 +greet 6059 +bowling 6056 +buttons 6055 +confusion 6054 +thud 6046 +hooker 6042 +dreadful 6041 +ruining 6041 +collecting 6036 +lions 6034 +sheila 6030 +douglas 6028 +february 6027 +elvis 6024 +forty 6023 +sleeps 6022 +sung 6022 +brass 6019 +shrink 6019 +zack 6017 +whimpers 6016 +sworn 6015 +email 6010 +proven 6008 +tasty 6008 +covers 6007 +rarely 6006 +sandwiches 6006 +wyatt 6002 +programme 6001 +irene 5998 +dee 5994 +contacted 5990 +chemistry 5989 +assured 5988 +applied 5987 +elected 5987 +subjects 5985 +lust 5983 +label 5982 +shakespeare 5981 +ego 5980 +tan 5980 +forgiven 5979 +companion 5978 +phoenix 5978 +lottery 5977 +publicity 5976 +worm 5976 +butterfly 5975 +dialogue 5975 +cal 5973 +tremendous 5971 +pervert 5970 +establish 5968 +dearest 5967 +intimate 5967 +eager 5966 +se 5964 +cathy 5963 +atlantic 5960 +contains 5960 +thompson 5959 +bets 5958 +shirley 5958 +inhales 5957 +presentation 5957 +allergic 5956 +den 5956 +wells 5955 +spoiled 5953 +chen 5952 +accomplished 5951 +repay 5951 +logic 5950 +politicians 5950 +gratitude 5948 +afghanistan 5946 +exception 5946 +ultimately 5946 +compromise 5944 +majority 5944 +pablo 5944 +journal 5943 +signals 5942 +bent 5941 +dock 5939 +meg 5939 +campbell 5938 +essential 5938 +climate 5937 +experiments 5936 +ink 5936 +administration 5935 +magazines 5935 +rivers 5935 +teresa 5935 +passionate 5934 +lawn 5932 +precise 5932 +jeans 5929 +via 5929 +murmuring 5928 +resort 5926 +announced 5925 +buddha 5922 +fries 5920 +capacity 5918 +lena 5917 +opponent 5917 +chains 5916 +apples 5914 +caitlyn 5914 +minus 5914 +angie 5913 +seattle 5911 +aww 5910 +instrument 5908 +healing 5907 +convention 5906 +messenger 5906 +explore 5904 +sonia 5903 +versus 5900 +survivor 5895 +ruled 5893 +boyd 5892 +clarence 5890 +madison 5890 +cough 5883 +disguise 5881 +experts 5881 +archer 5880 +bicycle 5880 +noises 5871 +inviting 5870 +pregnancy 5868 +declared 5867 +yi 5866 +vicky 5865 +drums 5864 +wooden 5862 +innocence 5860 +mouths 5855 +bees 5854 +creaking 5853 +cloth 5852 +sasha 5850 +unh 5848 +allies 5846 +painter 5846 +skies 5846 +miguel 5845 +pond 5843 +runway 5842 +robbie 5841 +assist 5839 +lana 5839 +presented 5835 +mona 5834 +sickness 5834 +civilian 5833 +facebook 5832 +fighters 5832 +customs 5831 +florence 5828 +remembers 5824 +sharks 5822 +juliet 5821 +shrimp 5817 +tore 5817 +seventh 5814 +ou 5813 +avoiding 5809 +dawson 5808 +isaac 5807 +championship 5803 +rolled 5803 +household 5802 +chad 5801 +float 5801 +ballet 5799 +cracking 5798 +raj 5797 +possessed 5796 +harmless 5794 +curiosity 5792 +vicious 5792 +glove 5789 +officials 5789 +nearest 5787 +begged 5786 +manny 5786 +que 5785 +withdraw 5784 +shocking 5781 +guessed 5777 +forehead 5776 +greedy 5776 +nightmares 5776 +corps 5775 +pirates 5774 +rudy 5774 +reservation 5770 +fog 5768 +hunters 5768 +flu 5765 +good-looking 5762 +hint 5757 +peanut 5757 +stark 5756 +erin 5755 +gamble 5755 +autumn 5753 +bracelet 5753 +contain 5752 +arrogant 5748 +simpson 5745 +piper 5744 +knocks 5743 +gesture 5741 +sperm 5741 +materials 5740 +lindsay 5736 +alexis 5734 +standards 5733 +ticking 5733 +ribs 5731 +receipt 5727 +regards 5724 +whatsoever 5721 +homeland 5720 +leap 5719 +targets 5719 +damon 5718 +snoring 5718 +leaf 5717 +jar 5713 +brooke 5712 +proposed 5711 +caring 5708 +savage 5708 +approximately 5707 +ivy 5707 +kidnap 5702 +bounce 5701 +carries 5701 +creek 5700 +cleaner 5698 +pony 5698 +businessman 5697 +ha-ha 5695 +lunatic 5695 +gal 5694 +unlikely 5694 +colony 5693 +situations 5693 +sells 5691 +chambers 5690 +choi 5690 +volume 5684 +sweets 5683 +panties 5682 +respects 5681 +reads 5680 +seeds 5678 +engage 5676 +explosives 5673 +stewart 5670 +lighting 5668 +mutual 5668 +violet 5668 +lenny 5667 +scores 5667 +savings 5666 +beware 5665 +stephanie 5664 +educated 5662 +complaints 5661 +cassie 5660 +doomed 5660 +offensive 5656 +harvest 5655 +qualified 5654 +cape 5652 +relaxed 5652 +buffalo 5651 +triple 5650 +armor 5648 +fireworks 5648 +palmer 5648 +poverty 5647 +leadership 5644 +rode 5640 +ambition 5633 +cakes 5632 +bean 5627 +melt 5627 +trousers 5627 +racist 5624 +bam 5623 +ankle 5622 +nicer 5618 +occupied 5618 +correctly 5615 +terrifying 5615 +hugh 5614 +gasp 5613 +villain 5613 +willy 5613 +marc 5610 +draft 5608 +factor 5601 +countess 5597 +whew 5597 +portrait 5595 +font 5589 +suggests 5589 +blair 5588 +learnt 5587 +coordinates 5582 +pencil 5582 +collapsed 5580 +reserve 5579 +jeffrey 5577 +shirts 5574 +umbrella 5570 +stevie 5568 +ta 5568 +traveled 5566 +auto 5565 +chew 5565 +logical 5565 +eliminate 5563 +beverly 5562 +cancelled 5562 +carbon 5562 +crawling 5562 +independence 5562 +stepping 5562 +medium 5560 +canceled 5559 +tess 5555 +fanny 5554 +seated 5554 +upbeat 5553 +asian 5552 +drowning 5552 +messy 5552 +quest 5551 +fletcher 5548 +memorial 5548 +represents 5548 +doyle 5546 +bearing 5544 +stressed 5544 +watches 5544 +spoon 5540 +cooperation 5539 +earthquake 5535 +tucker 5535 +sailing 5534 +involve 5533 +houston 5532 +asia 5524 +reliable 5523 +spectacular 5523 +breach 5520 +sophisticated 5518 +echo 5517 +indicate 5517 +swamp 5516 +vehicles 5515 +elegant 5512 +manual 5512 +compound 5508 +scares 5508 +serves 5508 +pursue 5507 +rattling 5507 +dreamt 5506 +stefan 5504 +revolutionary 5502 +wa 5502 +rig 5500 +convincing 5498 +fiona 5497 +tribal 5497 +cleveland 5496 +depth 5495 +lex 5495 +aids 5492 +blaming 5492 +elaine 5491 +wakes 5491 +occur 5488 +camping 5480 +fart 5480 +kathy 5478 +stamp 5478 +approved 5475 +assassin 5475 +annual 5474 +crucial 5474 +torch 5474 +dallas 5473 +fulfill 5472 +bon 5471 +resting 5469 +reunion 5466 +witnessed 5464 +graduated 5462 +sub 5460 +howling 5459 +clyde 5458 +hospitals 5457 +stall 5456 +legacy 5455 +otto 5454 +riot 5454 +shops 5453 +whereabouts 5453 +teenager 5451 +samuel 5450 +squealing 5449 +danced 5448 +mines 5448 +crowded 5447 +orleans 5445 +literature 5444 +morgue 5443 +strongest 5442 +agnes 5441 +transmission 5439 +panel 5438 +sequence 5438 +sexually 5438 +sole 5438 +georgie 5436 +hugo 5436 +denny 5435 +smack 5434 +anxiety 5432 +congratulate 5432 +choking 5430 +tolerate 5428 +mode 5426 +communications 5425 +dice 5425 +welfare 5425 +orphan 5424 +advertising 5422 +locks 5422 +settlement 5422 +mcgee 5419 +uniforms 5419 +devoted 5418 +shoots 5418 +principles 5417 +circuit 5416 +dot 5415 +elderman 5415 +nickname 5415 +scenario 5414 +choir 5413 +smoked 5410 +stunt 5410 +isolated 5409 +equally 5408 +statements 5407 +gil 5406 +lipstick 5405 +shaw 5405 +courtesy 5401 +hobby 5399 +hatch 5398 +hesitate 5398 +luis 5397 +phoned 5393 +sylvia 5393 +experiences 5392 +popped 5391 +quid 5389 +fragile 5388 +fiction 5387 +husbands 5387 +dolls 5385 +pastor 5385 +persuade 5385 +ji 5384 +ming 5384 +pose 5384 +rupees 5383 +tricked 5382 +ruins 5381 +owed 5380 +sadly 5380 +importantly 5376 +industrial 5375 +louie 5375 +musician 5371 +labour 5370 +evolution 5369 +salute 5367 +ba 5366 +cent 5364 +bartender 5363 +calendar 5363 +hal 5363 +miracles 5363 +balcony 5359 +horny 5357 +regard 5357 +valerie 5356 +fold 5355 +organs 5353 +nigel 5349 +einstein 5348 +theo 5348 +boris 5346 +suitable 5346 +inaudible 5343 +son-in-law 5343 +melody 5333 +movements 5331 +phillip 5331 +differences 5330 +barbecue 5329 +strongly 5326 +trading 5326 +fluid 5325 +scholarship 5324 +translation 5323 +cricket 5321 +moans 5321 +shuttle 5321 +quicker 5316 +murderers 5315 +picks 5315 +videos 5315 +initial 5314 +perimeter 5313 +questioned 5312 +yea 5312 +cousins 5309 +divided 5308 +linked 5307 +laboratory 5304 +crab 5300 +pulls 5299 +landlord 5298 +reggie 5298 +hostile 5297 +classified 5295 +cultural 5293 +measures 5293 +update 5287 +backyard 5286 +cottage 5286 +amateur 5285 +chopper 5284 +despair 5284 +rash 5284 +honorable 5282 +shiny 5281 +hawk 5280 +shorts 5280 +ni 5277 +representative 5276 +wong 5276 +distress 5275 +pouring 5275 +tanner 5274 +telegram 5273 +dip 5272 +pimp 5272 +trophy 5272 +es 5269 +hallelujah 5269 +marilyn 5269 +neal 5269 +howdy 5267 +misses 5262 +flush 5260 +magician 5257 +cuba 5256 +dynamite 5256 +conquer 5253 +unpleasant 5251 +jealousy 5250 +regrets 5249 +lifted 5247 +increased 5246 +toxic 5246 +broadway 5244 +pudding 5241 +sakes 5241 +rally 5239 +bloom 5238 +needing 5238 +buys 5237 +corporation 5237 +disorder 5236 +interrupting 5236 +sadness 5235 +needn 5234 +barnes 5233 +cease 5233 +scoundrel 5232 +evans 5231 +sensible 5231 +specialist 5230 +dragging 5229 +bribe 5227 +bothers 5226 +burke 5226 +interrogation 5225 +prescription 5224 +referring 5224 +knives 5221 +graduation 5220 +t-shirt 5219 +bubbles 5217 +grid 5217 +liking 5217 +haircut 5212 +regardless 5210 +lodge 5209 +poisoning 5209 +feathers 5207 +edgar 5206 +software 5205 +marina 5202 +sophia 5202 +queens 5201 +oak 5200 +trips 5200 +avery 5199 +awhile 5199 +scaring 5199 +sofa 5198 +conscious 5197 +instincts 5196 +swords 5196 +deliberately 5195 +starve 5194 +olive 5193 +venus 5193 +maniac 5188 +psychological 5187 +robinson 5185 +belle 5182 +soo 5182 +landscape 5181 +straw 5179 +dam 5177 +mandy 5175 +accuse 5174 +sayin 5174 +rehab 5172 +agenda 5171 +switzerland 5171 +glue 5170 +salesman 5170 +diner 5167 +projects 5166 +floors 5165 +mainly 5165 +biological 5164 +sincere 5158 +weigh 5157 +pad 5156 +swiss 5155 +cue 5154 +opposed 5153 +witches 5153 +brand-new 5151 +tourists 5149 +harvard 5148 +sneaking 5148 +spying 5147 +sketch 5146 +parliament 5143 +behold 5141 +blamed 5140 +topic 5140 +offices 5138 +sacrificed 5135 +drivers 5134 +loop 5134 +battles 5133 +blond 5132 +plead 5132 +races 5132 +tanya 5131 +salmon 5129 +bizarre 5128 +rico 5128 +isabel 5127 +wig 5127 +heights 5126 +i-i-i 5126 +reaches 5126 +possibilities 5122 +wax 5121 +confidential 5120 +pursuit 5120 +travels 5120 +herd 5119 +scissors 5119 +cd 5118 +exams 5116 +invention 5116 +spies 5116 +explaining 5113 +bombing 5112 +replacement 5112 +erik 5109 +springs 5108 +chelsea 5107 +sabrina 5107 +kicks 5105 +integrity 5104 +dash 5102 +jae 5100 +senate 5100 +mentally 5097 +flee 5096 +beautifully 5095 +gangster 5095 +roberts 5094 +slim 5093 +choosing 5091 +promising 5091 +objective 5090 +altogether 5089 +joyce 5089 +attract 5088 +ernie 5087 +melanie 5087 +nailed 5086 +neighbours 5086 +prep 5086 +briefcase 5085 +buzzer 5085 +puppet 5083 +quitting 5083 +restore 5083 +betting 5082 +corridor 5082 +dozens 5082 +pancakes 5082 +embarrass 5081 +cheryl 5080 +database 5080 +visits 5080 +achieved 5078 +sucking 5077 +encourage 5075 +grades 5075 +barks 5074 +gps 5074 +patterns 5072 +relevant 5068 +hudson 5067 +mafia 5067 +expense 5065 +intact 5064 +kiddo 5064 +occasionally 5064 +heartbeat 5062 +identical 5062 +cigar 5060 +alec 5058 +billions 5058 +gained 5057 +venice 5057 +classical 5054 +consistent 5053 +noodles 5053 +clip 5051 +insects 5051 +resign 5051 +failing 5050 +organic 5050 +seize 5050 +charging 5049 +mug 5049 +iris 5046 +crow 5042 +ella 5041 +abortion 5040 +fountain 5040 +observation 5040 +railroad 5039 +straighten 5039 +legitimate 5036 +sting 5035 +youngest 5033 +du 5032 +flynn 5032 +fork 5032 +ciao 5031 +coroner 5030 +jonas 5030 +marching 5029 +touches 5029 +jade 5028 +feared 5027 +griffin 5027 +expedition 5026 +forcing 5024 +digital 5016 +neighbour 5016 +bernie 5015 +deeds 5015 +milan 5015 +attic 5014 +jr 5012 +whores 5012 +cooler 5011 +sentimental 5011 +bert 5008 +eun 5008 +feds 5007 +reese 5007 +plea 5006 +polly 5005 +harmony 5002 +metres 5002 +bands 4998 +difficulty 4998 +dried 4998 +aunty 4996 +maker 4996 +randall 4995 +supporting 4995 +brake 4994 +girlfriends 4994 +features 4993 +photography 4993 +inappropriate 4992 +vengeance 4992 +vacuum 4989 +throws 4988 +endure 4987 +kane 4987 +residents 4987 +corrupt 4986 +potter 4986 +substance 4986 +investigator 4985 +thread 4985 +priests 4984 +gap 4983 +scarf 4980 +whining 4980 +institution 4978 +immortal 4976 +marsh 4974 +accidents 4973 +alcoholic 4973 +chapel 4973 +desperately 4973 +fabric 4971 +nazis 4969 +railway 4969 +sal 4969 +dame 4968 +rebels 4966 +custom 4965 +pension 4963 +cans 4962 +possess 4961 +kira 4960 +poster 4959 +developing 4956 +addict 4953 +feature 4953 +cope 4952 +lords 4951 +delightful 4950 +wrestling 4948 +rio 4945 +reduce 4943 +rescued 4940 +peg 4939 +mercedes 4937 +accountant 4936 +emotionally 4936 +guaranteed 4936 +poland 4936 +theories 4935 +tumor 4933 +amusing 4932 +automatic 4932 +higgins 4930 +unnecessary 4930 +voting 4928 +buster 4927 +stadium 4924 +easter 4923 +jeep 4923 +visible 4923 +dj 4922 +les 4922 +garcia 4921 +tracked 4921 +ignorant 4919 +deserted 4918 +esther 4918 +pledge 4918 +rum 4917 +fisher 4915 +wheelchair 4914 +salvation 4913 +lester 4912 +wonders 4912 +clattering 4911 +maintenance 4911 +tobacco 4910 +underwater 4910 +divide 4907 +discount 4906 +slightest 4906 +yoga 4906 +rushing 4905 +vagina 4905 +make-up 4904 +modest 4904 +wes 4902 +reduced 4901 +pressing 4898 +tops 4898 +whisper 4898 +variety 4897 +helena 4896 +ignored 4896 +grain 4895 +writers 4895 +essentially 4894 +archie 4893 +individuals 4893 +meredith 4893 +ofthe 4893 +gavin 4892 +interviews 4891 +subtle 4891 +pinky 4886 +cord 4885 +brennan 4884 +sites 4884 +maddie 4882 +vienna 4882 +issued 4881 +lasted 4881 +explosive 4879 +origin 4878 +unto 4877 +rogue 4876 +x-ray 4875 +cody 4874 +jacques 4874 +superintendent 4874 +ol 4872 +challenges 4869 +corruption 4868 +sullivan 4867 +shutter 4866 +erase 4864 +ski 4862 +forensics 4861 +fastest 4859 +flavor 4859 +joon 4857 +vet 4856 +civilians 4852 +lobster 4851 +housing 4846 +canal 4845 +robots 4845 +shade 4843 +ambitious 4842 +orchestra 4840 +guidance 4837 +succeeded 4837 +hence 4836 +bella 4833 +seoul 4833 +jose 4828 +inevitable 4826 +tick 4825 +rival 4823 +refuses 4820 +purchase 4819 +wander 4819 +hanged 4818 +hanna 4818 +ireland 4818 +napoleon 4813 +craft 4812 +elite 4812 +cult 4811 +whisky 4810 +profits 4809 +allowing 4806 +chart 4806 +kai 4806 +shelf 4806 +accusing 4803 +buzzes 4803 +weekends 4803 +humiliated 4801 +hissing 4800 +granddaughter 4799 +treats 4799 +serena 4797 +submit 4796 +supervisor 4796 +sniffing 4795 +resident 4794 +tearing 4793 +obliged 4792 +knights 4791 +opportunities 4791 +hangs 4789 +jerusalem 4786 +stew 4786 +counted 4785 +instruments 4783 +triumph 4781 +frequency 4780 +nun 4780 +marines 4779 +resume 4779 +distract 4777 +satisfaction 4775 +historical 4774 +creates 4772 +costumes 4771 +swedish 4771 +lizzie 4769 +depending 4766 +sour 4765 +spark 4765 +freaks 4764 +freely 4764 +scotty 4764 +shan 4763 +globe 4762 +peak 4762 +sentenced 4762 +fierce 4761 +hollow 4761 +scam 4761 +anton 4760 +concentration 4760 +hoo 4760 +presume 4760 +eleanor 4759 +invest 4759 +radical 4758 +protective 4757 +smiles 4756 +frustrated 4753 +stunning 4753 +adore 4752 +breed 4752 +suspenseful 4752 +controlling 4751 +reagan 4751 +pasta 4750 +precinct 4749 +execute 4748 +swallowed 4748 +architect 4747 +towels 4747 +delta 4746 +honks 4744 +whoo-hoo 4743 +preserve 4742 +dose 4741 +stove 4739 +codes 4738 +bugger 4737 +instantly 4737 +vivian 4736 +yoon 4736 +doo 4732 +haul 4731 +orphanage 4730 +bolt 4726 +autograph 4724 +sharply 4724 +habits 4723 +villagers 4723 +cracks 4722 +dwight 4722 +omar 4721 +assembly 4720 +meds 4720 +marge 4719 +magnetic 4718 +romans 4716 +joshua 4714 +assets 4711 +sorted 4711 +essence 4707 +insulted 4707 +ling 4707 +outstanding 4707 +wardrobe 4707 +nam 4706 +benson 4705 +restaurants 4704 +disco 4702 +flirting 4702 +abilities 4700 +dolly 4700 +handful 4700 +mice 4698 +pumpkin 4698 +bennett 4696 +sweating 4695 +anchor 4693 +interior 4693 +thankful 4693 +moore 4690 +urge 4688 +surrounding 4686 +strain 4683 +blanche 4682 +curtains 4682 +gypsy 4682 +tomato 4682 +bonds 4681 +compassion 4681 +greece 4679 +ceo 4678 +arabic 4677 +examination 4677 +participate 4674 +veins 4674 +cups 4673 +additional 4672 +obsession 4671 +sofia 4671 +intent 4670 +responsibilities 4670 +ridge 4670 +axe 4669 +reckless 4668 +missiles 4667 +corners 4664 +razor 4664 +frightening 4663 +rains 4662 +mutant 4660 +wally 4659 +whereas 4656 +heavenly 4655 +promoted 4655 +dang 4654 +comments 4652 +maiden 4652 +batteries 4649 +bark 4647 +seas 4647 +congressman 4645 +canyon 4642 +casual 4642 +rack 4642 +expectations 4638 +adjust 4634 +flowing 4634 +decade 4633 +pine 4633 +tails 4633 +baxter 4630 +wires 4628 +pigeon 4626 +finance 4624 +respectable 4622 +demonstration 4620 +appointed 4618 +prank 4618 +treason 4615 +views 4612 +violin 4612 +stir 4611 +lifestyle 4610 +realm 4610 +monty 4609 +missy 4608 +roosevelt 4608 +fitting 4606 +tuck 4605 +tuna 4605 +inspection 4604 +nursing 4603 +jared 4602 +cardinal 4601 +poop 4601 +altar 4600 +carlo 4600 +herman 4600 +conviction 4599 +tourist 4599 +jelly 4597 +lounge 4597 +thrill 4597 +cha 4596 +zip 4596 +dimension 4595 +hogan 4595 +backing 4594 +choke 4594 +bodyguard 4593 +pains 4593 +rug 4593 +jaw 4592 +ants 4591 +popcorn 4590 +courtroom 4589 +pointless 4589 +reynolds 4589 +documentary 4587 +donor 4586 +mortgage 4586 +tunnels 4586 +enforcement 4585 +cocks 4584 +demanding 4584 +eliminated 4584 +agh 4582 +rapidly 4581 +refer 4580 +fails 4577 +shipment 4577 +tasted 4577 +michel 4576 +rushed 4575 +efficient 4574 +ordering 4574 +outrageous 4569 +gracious 4566 +slaughter 4562 +marijuana 4561 +monroe 4560 +pipes 4559 +backed 4558 +approached 4557 +vest 4557 +mechanic 4555 +mumbai 4553 +arizona 4552 +legendary 4550 +delayed 4549 +resolve 4548 +relation 4547 +amelia 4545 +fare 4542 +mechanical 4542 +jefferson 4541 +karma 4541 +chorus 4540 +extend 4539 +nana 4539 +info 4533 +saddle 4531 +pretended 4528 +equals 4526 +motherfuckers 4524 +artificial 4523 +accomplish 4521 +burial 4520 +lynn 4519 +detention 4518 +vow 4518 +bake 4517 +breakdown 4517 +dvd 4515 +delhi 4512 +selected 4507 +lightly 4506 +dealers 4504 +gown 4501 +layer 4501 +courtney 4495 +incoming 4492 +reid 4492 +cheaper 4491 +countryside 4491 +smelled 4491 +devon 4490 +ducks 4490 +attending 4489 +lethal 4489 +swan 4489 +moonlight 4488 +dispatch 4487 +ropes 4487 +notebook 4484 +punched 4484 +identification 4483 +contracts 4482 +scattered 4482 +trusting 4477 +prayed 4476 +susie 4476 +remembering 4474 +volunteers 4474 +adopt 4473 +calvin 4472 +bandits 4470 +consideration 4470 +worms 4470 +moses 4469 +zombies 4468 +lionel 4466 +non 4464 +transplant 4463 +crackling 4462 +frasier 4461 +loneliness 4461 +epic 4458 +gladly 4458 +marcel 4457 +mon 4456 +frost 4455 +registration 4453 +launched 4451 +prophet 4451 +constitution 4446 +claude 4441 +roland 4441 +sock 4440 +dragons 4439 +buyer 4438 +regulations 4435 +mimi 4432 +accomplice 4431 +volcano 4431 +bounty 4430 +hotels 4429 +favors 4428 +opinions 4428 +pressed 4427 +asylum 4426 +blunt 4425 +duel 4425 +consult 4422 +niles 4422 +carnival 4420 +nevertheless 4420 +au 4419 +baba 4419 +hah 4418 +paths 4415 +cynthia 4414 +handcuffs 4414 +rides 4414 +shannon 4414 +te 4414 +tray 4414 +barrier 4413 +supposedly 4412 +faggot 4411 +disposal 4408 +hunch 4408 +yacht 4408 +stain 4407 +healed 4405 +thoughtful 4405 +thinkin 4404 +syndrome 4403 +hairy 4402 +kansas 4400 +entertain 4399 +happiest 4399 +y-you 4399 +ambush 4397 +childish 4397 +recommended 4397 +gilbert 4395 +meter 4395 +lease 4394 +climbed 4393 +nadia 4393 +unstable 4393 +exploded 4391 +apollo 4390 +politician 4389 +rogers 4389 +rs 4389 +awaits 4388 +abraham 4387 +evelyn 4386 +particles 4385 +violation 4385 +lined 4384 +amazed 4383 +lure 4383 +lyrics 4382 +pod 4382 +fran 4381 +universal 4381 +drunken 4376 +hulk 4376 +hyah 4376 +continuing 4375 +goals 4374 +warming 4372 +chemicals 4371 +dc 4371 +owl 4370 +xena 4368 +compromised 4367 +grounded 4367 +cane 4366 +avenge 4364 +dexter 4364 +phantom 4364 +notion 4363 +directors 4362 +doe 4361 +forensic 4361 +tease 4361 +outcome 4360 +shattered 4360 +slam 4360 +baked 4359 +cheque 4359 +sought 4357 +hammond 4355 +racket 4355 +exotic 4352 +rider 4352 +cory 4351 +eighth 4350 +extent 4350 +shipping 4345 +stack 4345 +wheat 4345 +satisfy 4343 +departure 4341 +finch 4341 +kara 4341 +blocking 4340 +regiment 4338 +kin 4336 +klaus 4335 +surf 4333 +kilometers 4330 +frighten 4329 +rises 4329 +abused 4328 +gerald 4325 +colours 4324 +hath 4323 +chester 4322 +marvin 4322 +spaghetti 4322 +texts 4322 +bosses 4320 +contained 4319 +elephants 4319 +snarling 4319 +arab 4317 +bumped 4317 +laurie 4317 +chili 4316 +yah 4315 +abducted 4313 +panicked 4313 +sustained 4313 +diving 4312 +shallow 4312 +artie 4311 +generator 4311 +harriet 4311 +federation 4310 +mulder 4310 +arresting 4309 +techniques 4309 +feather 4308 +printed 4306 +untie 4306 +jupiter 4305 +hay 4300 +stalking 4300 +conrad 4299 +brett 4298 +phony 4298 +scored 4297 +whack 4297 +continent 4293 +articles 4292 +oxford 4291 +organize 4289 +sensors 4288 +shanghai 4288 +wh 4287 +sniper 4285 +framed 4284 +horizon 4284 +noisy 4284 +reflection 4283 +twilight 4283 +godfather 4281 +masks 4281 +merchant 4280 +wages 4280 +robe 4279 +waist 4279 +disappointment 4277 +stinking 4277 +venture 4274 +voyage 4273 +curry 4271 +ominous 4270 +sunlight 4270 +aaah 4269 +casualties 4269 +oui 4269 +overseas 4269 +grill 4265 +marketing 4265 +mattress 4265 +accepting 4264 +deadline 4264 +freed 4264 +occupation 4264 +exhibit 4263 +poems 4263 +compliments 4262 +echoing 4262 +shin 4262 +define 4261 +til 4261 +urine 4261 +foreman 4260 +facial 4259 +combined 4257 +brody 4255 +probation 4254 +reject 4253 +catches 4248 +dances 4248 +gemma 4248 +ingredients 4247 +pinch 4247 +ra 4247 +hurricane 4245 +chatting 4244 +mini 4243 +adding 4241 +heel 4241 +bandit 4238 +destined 4236 +limo 4235 +spa 4235 +graves 4234 +turkish 4234 +corpses 4233 +listed 4233 +evacuate 4232 +matty 4232 +publish 4232 +banned 4231 +judith 4231 +snaps 4231 +cheeks 4230 +holland 4229 +ohio 4229 +predict 4229 +confront 4228 +pals 4228 +pilots 4228 +umm 4228 +utterly 4228 +jonah 4227 +improved 4226 +ironic 4226 +popping 4226 +mills 4224 +delivering 4220 +farther 4220 +flattered 4218 +skipper 4218 +coconut 4217 +acknowledge 4214 +shuts 4213 +sweden 4213 +hooray 4212 +sip 4212 +blessings 4210 +grocery 4210 +terminal 4208 +awards 4205 +fade 4205 +meaningless 4205 +parks 4205 +hiring 4204 +burton 4203 +fees 4203 +wretched 4202 +krishna 4199 +exhibition 4198 +spends 4198 +thoroughly 4198 +denver 4197 +doris 4193 +winners 4193 +disappearance 4192 +luther 4192 +madrid 4192 +advised 4190 +supported 4190 +regina 4187 +supermarket 4187 +hereby 4186 +pier 4186 +shorter 4186 +betrayal 4185 +philadelphia 4185 +christians 4184 +psychology 4181 +danielle 4180 +canadian 4179 +detroit 4179 +keeper 4179 +trent 4179 +su 4178 +associates 4177 +gerry 4176 +restless 4173 +collector 4172 +marvellous 4172 +exposure 4171 +physician 4171 +grudge 4170 +followers 4166 +lid 4166 +glow 4165 +pawn 4164 +submarine 4164 +towns 4164 +edith 4162 +includes 4161 +lu 4160 +proposition 4160 +joker 4159 +haley 4158 +jang 4158 +opposition 4158 +spilled 4158 +hose 4157 +partnership 4157 +usa 4155 +timmy 4154 +breathes 4151 +whoops 4151 +astronaut 4150 +leonardo 4148 +porch 4148 +responding 4148 +wishing 4148 +boil 4146 +kneel 4146 +clumsy 4141 +tammy 4141 +rapid 4140 +xiao 4140 +stevens 4139 +invested 4138 +janice 4138 +justify 4137 +roller 4137 +thai 4137 +garlic 4136 +yelled 4135 +ammunition 4134 +simone 4134 +shaft 4133 +sushi 4133 +definition 4132 +gardens 4131 +chandler 4129 +brady 4128 +ne 4128 +notify 4128 +beasts 4127 +setup 4127 +greed 4123 +dared 4122 +classroom 4121 +unlucky 4121 +treaty 4120 +dagger 4118 +hike 4118 +spine 4115 +detect 4113 +chow 4112 +des 4110 +handwriting 4109 +teenagers 4109 +challenging 4108 +mock 4108 +salon 4108 +lawsuit 4107 +virtue 4107 +clap 4105 +shelly 4105 +mustache 4104 +med 4103 +offence 4103 +attraction 4102 +sultan 4102 +franco 4101 +courts 4100 +fortress 4100 +peel 4097 +asset 4096 +disappears 4096 +petrol 4096 +warp 4096 +workshop 4095 +traps 4094 +jeannie 4091 +appreciated 4090 +condemned 4089 +garrett 4088 +muttering 4087 +shook 4087 +naval 4086 +egyptian 4085 +expand 4084 +filmed 4083 +jingle 4083 +lordship 4083 +peach 4083 +sunrise 4083 +swine 4083 +oughta 4080 +pakistan 4080 +selection 4079 +squeaking 4079 +condom 4078 +sadie 4077 +alongside 4076 +barbie 4076 +risking 4075 +dancers 4074 +democratic 4074 +nerd 4074 +crook 4072 +grasp 4071 +mack 4071 +translate 4070 +behaving 4068 +humiliating 4067 +quarrel 4066 +fury 4065 +lively 4065 +battlefield 4064 +elders 4064 +policemen 4064 +sensei 4064 +glen 4063 +sticky 4063 +liu 4062 +primitive 4062 +reign 4062 +eugene 4061 +stash 4061 +evolved 4060 +shifts 4060 +devices 4059 +unlock 4059 +peculiar 4058 +anita 4057 +talents 4057 +foreigners 4056 +snacks 4056 +mint 4055 +fuse 4054 +manuel 4054 +pissing 4054 +towers 4054 +freezer 4052 +inheritance 4051 +villages 4049 +beckett 4045 +bollocks 4045 +jewel 4045 +shells 4044 +swept 4043 +recognition 4042 +shovel 4040 +artistic 4039 +qualities 4037 +ferry 4034 +darren 4031 +misunderstood 4029 +portal 4029 +atomic 4028 +ava 4027 +javier 4027 +claiming 4025 +defensive 4025 +examined 4025 +kenneth 4024 +ahhh 4023 +kat 4023 +sensation 4023 +increasing 4021 +fugitive 4020 +urban 4019 +visions 4017 +bathing 4016 +delight 4016 +gregory 4016 +genes 4014 +iot 4014 +boundaries 4012 +caution 4012 +ellis 4011 +strangled 4011 +thorough 4010 +ike 4009 +bites 4008 +nude 4008 +patricia 4007 +tidy 4007 +tate 4006 +obligation 4005 +ari 4003 +clearance 4001 +reserved 4000 +nasa 3999 +spock 3996 +marion 3995 +jammed 3994 +reflect 3994 +classy 3992 +sleeve 3992 +fag 3990 +angelo 3989 +bananas 3988 +bradley 3988 +ex-husband 3988 +saints 3988 +longest 3987 +confirmation 3986 +mushrooms 3986 +cyrus 3985 +toni 3985 +finals 3983 +millionaire 3983 +adoption 3982 +wired 3981 +cheerful 3980 +comet 3980 +deb 3980 +handing 3980 +acceptable 3978 +realistic 3978 +sufficient 3977 +waits 3977 +cosmic 3976 +gabe 3975 +intellectual 3975 +nolan 3975 +vomit 3972 +warmth 3970 +magnum 3969 +associated 3968 +alas 3967 +engineers 3967 +offend 3966 +overwhelming 3966 +sherry 3966 +inherited 3965 +antoine 3964 +scars 3962 +whooping 3962 +sheldon 3960 +purposes 3955 +robbing 3955 +darrin 3954 +instructor 3954 +moms 3954 +refrigerator 3954 +overtime 3953 +laurel 3952 +percy 3952 +females 3951 +sacrifices 3947 +drift 3943 +representing 3943 +po 3942 +ruthless 3942 +arguments 3941 +crappy 3941 +musicians 3940 +depressing 3938 +lasts 3938 +sidney 3938 +doom 3937 +cant 3936 +milo 3936 +backpack 3934 +aisle 3933 +insulting 3933 +edition 3932 +losses 3932 +sh 3932 +preacher 3931 +schmidt 3929 +swinging 3925 +lacey 3924 +managing 3924 +sinking 3924 +casting 3922 +trials 3919 +demonstrate 3918 +diseases 3918 +beggar 3917 +amounts 3916 +traced 3915 +raven 3913 +activate 3912 +slapped 3911 +heroic 3908 +roar 3907 +queer 3906 +matching 3905 +uk 3905 +trump 3904 +resignation 3903 +newton 3901 +saul 3900 +sincerely 3900 +stacy 3900 +madman 3899 +jenkins 3898 +poke 3898 +spicy 3897 +swap 3897 +programs 3894 +blink 3893 +schultz 3893 +sheer 3893 +spaceship 3893 +steer 3892 +males 3891 +injection 3890 +ant 3888 +mother-in-law 3888 +reform 3888 +properties 3887 +camps 3886 +virtually 3886 +tapping 3884 +tequila 3882 +fainted 3881 +inquiry 3881 +formation 3879 +escaping 3876 +shields 3876 +unemployed 3875 +murdering 3874 +interviewed 3873 +striking 3872 +donovan 3871 +vows 3871 +antique 3870 +il 3870 +puke 3869 +sherlock 3869 +hysterical 3868 +taller 3868 +employment 3867 +thats 3866 +agrees 3865 +dodge 3864 +unite 3860 +mumbling 3859 +rabbits 3857 +locals 3856 +mule 3856 +repeating 3856 +neighbourhood 3853 +bats 3852 +beliefs 3852 +hayes 3852 +spared 3851 +vegetable 3850 +bunker 3848 +liv 3848 +channels 3847 +hips 3846 +phenomenon 3846 +grayson 3844 +expelled 3840 +camille 3839 +iv 3838 +languages 3838 +premises 3837 +ng 3836 +pronounce 3836 +ape 3834 +bridges 3834 +julius 3833 +bruises 3832 +convict 3831 +prettier 3830 +elections 3829 +annoyed 3828 +hometown 3828 +discreet 3827 +earrings 3827 +morphine 3826 +poured 3826 +henderson 3824 +jun 3824 +narrating 3823 +peanuts 3823 +rookie 3823 +abe 3822 +camel 3822 +chewing 3821 +hyun 3821 +imagining 3821 +runner 3821 +tomatoes 3819 +sponsor 3818 +fulfilled 3816 +probe 3816 +karate 3813 +grease 3812 +dinosaurs 3811 +rebellion 3810 +marta 3808 +lone 3807 +nan 3806 +underestimate 3804 +acres 3803 +carlton 3801 +hacked 3800 +barber 3799 +mining 3799 +aged 3798 +designs 3797 +onions 3796 +preferred 3796 +stray 3796 +void 3793 +hannibal 3792 +sixteen 3792 +slower 3792 +rabbi 3791 +sabotage 3791 +concerning 3789 +dixon 3789 +holt 3787 +dental 3786 +mannix 3786 +noted 3786 +obama 3786 +peterson 3786 +committing 3785 +squirrel 3785 +werewolf 3785 +mm-mm 3784 +slipping 3784 +explosions 3783 +matthews 3783 +russ 3781 +seo 3781 +whitney 3781 +cavalry 3780 +ignoring 3780 +infinite 3780 +bomber 3779 +lam 3779 +suzanne 3779 +ruling 3777 +boiling 3775 +needles 3775 +semester 3773 +irrelevant 3772 +savior 3771 +tramp 3769 +veil 3769 +deepest 3768 +perfection 3768 +roberto 3768 +shutting 3768 +iii 3766 +mentor 3766 +tiffany 3766 +branches 3765 +enthusiasm 3764 +arena 3763 +battalion 3762 +greatly 3762 +hardware 3762 +listens 3762 +irresponsible 3761 +moustache 3761 +audio 3760 +ga 3760 +initiative 3759 +sweaty 3759 +noel 3758 +distraction 3757 +requests 3757 +entertaining 3754 +parrot 3754 +steed 3754 +whipped 3753 +assaulted 3752 +proving 3752 +pause 3750 +piggy 3750 +surviving 3750 +acquaintance 3748 +bedtime 3748 +dinosaur 3748 +funding 3748 +cowards 3746 +rail 3746 +rooster 3746 +hastings 3745 +container 3743 +lori 3743 +secured 3742 +stripper 3741 +tossed 3741 +steele 3740 +clamoring 3739 +waving 3739 +bacteria 3738 +banker 3738 +indicates 3738 +assassination 3737 +cupboard 3735 +drawings 3735 +pairs 3735 +sanctuary 3735 +starring 3735 +surgical 3735 +elderly 3734 +geoffrey 3734 +purely 3734 +temptation 3734 +vibe 3733 +crop 3732 +gong 3732 +profound 3732 +transformed 3732 +detailed 3731 +scrap 3730 +abigail 3729 +careless 3727 +dove 3726 +sloppy 3726 +atlanta 3723 +hideous 3722 +lizard 3720 +intern 3719 +separation 3719 +tab 3719 +psych 3718 +ranks 3718 +parish 3717 +booty 3716 +comb 3715 +oooh 3715 +peasant 3715 +squeal 3715 +bash 3714 +restored 3713 +junkie 3712 +platoon 3712 +involves 3710 +rupert 3709 +carrier 3707 +fatty 3707 +norm 3707 +difficulties 3706 +producers 3705 +superb 3705 +tha 3704 +candidates 3703 +summoned 3703 +refuge 3702 +righteous 3702 +carolina 3701 +natasha 3699 +onion 3699 +pickup 3698 +spells 3697 +wesley 3697 +armies 3696 +carrington 3696 +billie 3695 +seasons 3695 +senor 3695 +specialty 3690 +thirst 3690 +rendezvous 3689 +select 3689 +smiled 3688 +colorado 3687 +visa 3686 +spice 3685 +caller 3684 +foods 3684 +rational 3684 +scottish 3683 +cunning 3682 +grunt 3681 +knot 3681 +hairs 3680 +payments 3679 +granddad 3678 +massacre 3678 +competitive 3677 +herb 3677 +mae 3676 +grenade 3674 +immune 3674 +regularly 3674 +kilos 3673 +stakes 3673 +monks 3672 +gail 3671 +guinea 3671 +invent 3670 +nursery 3669 +protein 3669 +elbow 3668 +keller 3667 +clowns 3666 +flown 3665 +mustard 3665 +tattoos 3665 +thirteen 3665 +terrace 3664 +alter 3663 +franz 3662 +lens 3661 +transportation 3661 +alaska 3660 +ha-ha-ha 3660 +interference 3660 +clive 3659 +crooked 3659 +freakin 3659 +mourning 3659 +weep 3659 +wont 3659 +janitor 3658 +trainer 3658 +gradually 3657 +father-in-law 3656 +filth 3656 +immigration 3656 +momma 3656 +cho 3655 +culprit 3655 +jackass 3655 +reminder 3655 +gabrielle 3654 +innit 3653 +magistrate 3653 +jasmine 3652 +injustice 3650 +texting 3650 +womb 3650 +figuring 3649 +fracture 3648 +insanity 3646 +blossom 3645 +peasants 3645 +etc 3644 +agony 3642 +oz 3642 +sounding 3642 +elementary 3640 +lovejoy 3636 +seized 3636 +teaches 3636 +eighteen 3635 +phillips 3635 +definite 3634 +shack 3634 +teen 3634 +scrub 3633 +contents 3631 +mold 3631 +ruler 3631 +antidote 3630 +bitten 3630 +saves 3628 +bunk 3627 +graveyard 3625 +republican 3625 +thor 3625 +aspect 3624 +attempts 3624 +poirot 3624 +relate 3622 +lemonade 3621 +flour 3620 +nypd 3618 +biology 3617 +dusty 3617 +felony 3617 +jax 3617 +fundamental 3616 +jai 3614 +donnie 3613 +potentially 3613 +successfully 3613 +condoms 3612 +quantum 3612 +strangely 3612 +utter 3611 +activated 3610 +installed 3608 +interrupted 3608 +devastated 3607 +discharge 3607 +splash 3607 +substitute 3607 +julio 3606 +aspirin 3605 +exile 3605 +authentic 3602 +beck 3601 +summit 3601 +addiction 3600 +drone 3600 +lent 3600 +involving 3599 +boyfriends 3598 +tango 3595 +permanently 3594 +province 3593 +attempting 3591 +deceived 3589 +skipped 3588 +triangle 3588 +strawberry 3587 +mirrors 3585 +overwhelmed 3583 +preston 3583 +self-defense 3583 +swift 3583 +creeps 3581 +token 3580 +diagnosis 3579 +heidi 3579 +poisonous 3579 +temporarily 3579 +locations 3578 +ninth 3578 +retrieve 3577 +robbers 3576 +conclusions 3575 +disabled 3575 +joanna 3575 +ira 3573 +extension 3572 +lining 3572 +freeman 3571 +hip-hop 3571 +yuri 3571 +scoop 3569 +merlin 3568 +bridget 3567 +revving 3566 +transform 3563 +hospitality 3562 +shelby 3562 +brooks 3561 +survey 3561 +cozy 3560 +hunted 3560 +recommendation 3559 +microphone 3558 +providing 3558 +skeleton 3558 +muslims 3557 +toll 3556 +creaks 3555 +reconsider 3555 +extended 3554 +overboard 3554 +spear 3554 +ai 3552 +clinton 3551 +petition 3550 +baggage 3549 +scumbag 3549 +tribute 3548 +weddings 3547 +bangs 3545 +claw 3545 +facilities 3545 +donation 3544 +isabelle 3543 +traitors 3542 +mei 3541 +jasper 3540 +australian 3538 +borders 3538 +colby 3538 +boxer 3537 +whilst 3536 +elaborate 3535 +misfortune 3535 +sights 3533 +maestro 3531 +mysteries 3530 +gifted 3529 +loosen 3529 +involvement 3526 +affects 3525 +sherman 3525 +summon 3525 +churchill 3523 +theresa 3523 +baltimore 3522 +olympic 3522 +solitary 3522 +wilderness 3522 +mechanism 3521 +undo 3521 +vanish 3520 +vietnamese 3520 +biting 3519 +dome 3519 +ronald 3519 +texted 3518 +sage 3516 +slack 3515 +tactics 3515 +yummy 3515 +snapped 3514 +weekly 3514 +bushes 3508 +grandparents 3508 +henri 3506 +hart 3505 +obtain 3505 +flights 3503 +adventures 3502 +hottest 3501 +dom 3500 +eden 3500 +grown-up 3500 +mild 3500 +fourteen 3499 +blend 3498 +smuggling 3498 +chancellor 3497 +biscuits 3496 +gorilla 3496 +dynasty 3495 +acquired 3494 +businesses 3494 +countless 3493 +fritz 3492 +brent 3489 +barracks 3488 +cooks 3488 +flirt 3488 +gardener 3488 +distorted 3486 +carved 3484 +jumps 3484 +superhero 3484 +clapping 3482 +forests 3482 +psychiatric 3482 +roughly 3481 +screamed 3481 +snuck 3481 +disappearing 3480 +raja 3480 +establishment 3479 +whales 3478 +dwarf 3477 +environmental 3477 +lump 3477 +mam 3477 +pint 3476 +diplomatic 3475 +loading 3475 +context 3474 +exclaims 3474 +gangs 3473 +risked 3473 +volunteered 3473 +boost 3472 +dante 3471 +fax 3471 +partial 3471 +gaze 3469 +refusing 3469 +rene 3469 +jerome 3468 +nuisance 3468 +terrorism 3468 +donate 3467 +relieve 3467 +ungrateful 3467 +felicity 3465 +grandchildren 3465 +maureen 3464 +represented 3464 +co 3462 +neutral 3462 +philippe 3462 +steering 3462 +workin 3462 +presidential 3461 +rm 3461 +challenged 3460 +rental 3460 +feeds 3459 +julien 3459 +je 3458 +surprisingly 3458 +teasing 3458 +vegetarian 3458 +iran 3457 +shrine 3457 +automatically 3456 +timer 3456 +gasoline 3455 +juliette 3455 +dorm 3453 +google 3453 +millie 3453 +scouts 3453 +athlete 3452 +convent 3451 +robber 3451 +shiva 3451 +balloons 3450 +barb 3449 +crawford 3449 +enters 3448 +maps 3448 +meth 3448 +fruits 3447 +ifyou 3447 +colored 3446 +metro 3445 +poppy 3445 +clarke 3444 +fernando 3444 +scully 3443 +resolved 3440 +sessions 3440 +conservative 3439 +rates 3439 +forged 3438 +legends 3438 +arrows 3437 +charley 3437 +dismiss 3436 +authorized 3434 +slavery 3433 +attended 3432 +decorated 3432 +detected 3432 +employed 3432 +flows 3432 +parallel 3432 +coverage 3431 +grove 3431 +hen 3430 +pyramid 3430 +overall 3429 +hum 3428 +specially 3428 +rebuild 3427 +exclaiming 3426 +producing 3426 +occasions 3425 +sewing 3425 +boards 3423 +trumpet 3423 +shaped 3422 +daughter-in-law 3421 +matched 3421 +ammo 3420 +typing 3419 +blades 3418 +macgyver 3418 +thailand 3418 +becca 3417 +wo 3415 +cement 3414 +debris 3414 +hamlet 3414 +luca 3414 +communists 3413 +nat 3413 +showers 3412 +comedian 3411 +kitt 3411 +sailors 3411 +tougher 3411 +harassment 3410 +missus 3409 +monitoring 3409 +inventory 3408 +shaved 3408 +smashing 3408 +housekeeper 3406 +tribes 3404 +long-term 3403 +vile 3403 +gotham 3402 +imaginary 3402 +artillery 3401 +ping 3401 +cereal 3400 +portion 3399 +zoom 3399 +exceptional 3398 +irony 3398 +despise 3396 +humour 3396 +slit 3396 +ss 3396 +waltz 3396 +crashes 3395 +gu 3395 +stammering 3395 +coaches 3393 +joo 3393 +await 3391 +bryan 3391 +celebrated 3390 +encouraged 3390 +devils 3388 +gag 3387 +telescope 3386 +verse 3386 +e-mails 3385 +stalin 3385 +iet 3384 +babysitter 3382 +pr 3382 +distinguished 3381 +licence 3381 +addicted 3380 +bauer 3380 +speeches 3379 +wit 3379 +bliss 3377 +donny 3377 +programmed 3377 +elsa 3376 +com 3374 +recorder 3374 +hides 3372 +apartments 3371 +shameless 3371 +tipped 3370 +turf 3369 +discharged 3368 +psychologist 3368 +tang 3368 +tu 3368 +duh 3366 +delete 3365 +jock 3365 +wrath 3364 +estimate 3363 +jurisdiction 3363 +tutor 3363 +impulse 3362 +penguin 3362 +tubes 3361 +claws 3360 +nickel 3360 +playground 3360 +sew 3360 +daring 3359 +giants 3359 +posters 3357 +peacefully 3356 +caves 3354 +nikita 3354 +rag 3354 +stern 3353 +condolences 3352 +ohhh 3352 +dearly 3351 +cartoon 3350 +humiliation 3350 +worldwide 3350 +disrespect 3349 +shhh 3347 +underworld 3347 +chun 3346 +lilly 3346 +marble 3346 +curly 3345 +judged 3345 +bidding 3344 +erased 3344 +confuse 3343 +masterpiece 3343 +oppa 3343 +pentagon 3343 +tow 3342 +seduce 3341 +bathtub 3340 +bricks 3340 +goa 3340 +pets 3340 +contempt 3339 +hotter 3339 +regime 3339 +batch 3338 +compartment 3336 +ramsay 3336 +speeding 3336 +tackle 3336 +wage 3335 +lifting 3334 +shines 3334 +hustle 3333 +inmates 3332 +km 3332 +psst 3331 +scooter 3330 +relatively 3329 +stoned 3328 +supernatural 3326 +darwin 3324 +viewers 3324 +lorenzo 3323 +treasures 3323 +yankee 3323 +charts 3322 +lang 3322 +taco 3322 +brigade 3321 +bankrupt 3320 +marianne 3320 +bikes 3318 +lookout 3317 +merchandise 3317 +slippery 3317 +duchess 3316 +gulf 3316 +foam 3315 +numerous 3315 +steals 3315 +flock 3314 +potion 3314 +ripping 3314 +trusts 3314 +fiance 3313 +glimpse 3313 +compensation 3312 +invincible 3312 +kettle 3312 +brendan 3311 +stroll 3311 +trish 3311 +cafeteria 3310 +sane 3310 +amsterdam 3309 +currency 3309 +radius 3309 +founded 3308 +medic 3308 +peek 3308 +verify 3308 +sewer 3307 +callie 3306 +vernon 3306 +bbc 3305 +hull 3305 +madonna 3304 +solomon 3304 +toad 3301 +maxwell 3299 +powell 3299 +moose 3298 +souvenir 3298 +compass 3297 +stitch 3297 +locking 3296 +mercury 3296 +dislike 3295 +edwards 3295 +frederick 3295 +pamela 3294 +rib 3294 +stu 3294 +ban 3293 +brace 3293 +gracias 3292 +stitches 3290 +thrust 3288 +achievement 3287 +links 3287 +referred 3287 +denying 3286 +monastery 3285 +newly 3284 +impatient 3283 +recruit 3282 +sunk 3279 +drugged 3278 +ignorance 3278 +predicted 3278 +floyd 3277 +creator 3276 +artery 3275 +civilized 3275 +dynamic 3275 +mississippi 3275 +buffy 3274 +competing 3274 +distribution 3272 +turk 3272 +ark 3271 +carpenter 3271 +ethel 3271 +stereo 3271 +voiceover 3271 +warner 3271 +industries 3270 +melted 3270 +upsetting 3270 +cruz 3269 +labs 3268 +bender 3266 +bluff 3266 +unbearable 3265 +eerie 3264 +juicy 3264 +narcotics 3264 +ingrid 3262 +mwah 3261 +slippers 3261 +freaky 3260 +troop 3259 +vip 3259 +dani 3258 +footprints 3258 +pact 3258 +apparent 3257 +repeated 3257 +foreigner 3255 +feelin 3253 +journalists 3252 +employer 3251 +grim 3248 +medieval 3248 +penelope 3248 +damien 3247 +frustrating 3246 +microwave 3246 +preparation 3246 +rehearse 3246 +travelled 3246 +denial 3245 +flute 3245 +juvenile 3245 +paddy 3245 +canvas 3242 +memo 3242 +retarded 3242 +and-and 3241 +daly 3241 +fa 3241 +mccoy 3241 +symbols 3241 +driveway 3240 +funky 3239 +kitten 3239 +nicest 3238 +deceive 3237 +chopped 3236 +consultant 3236 +negro 3236 +heating 3234 +rifles 3234 +rays 3233 +architecture 3232 +gideon 3232 +butch 3231 +contribution 3229 +warmer 3229 +advisor 3228 +peoples 3228 +behaved 3227 +groceries 3227 +experiencing 3226 +settling 3225 +imitating 3224 +squadron 3224 +tractor 3224 +unacceptable 3224 +briggs 3223 +smallest 3223 +ethics 3222 +gibson 3222 +devotion 3221 +plumber 3221 +briefing 3220 +broom 3220 +burgers 3220 +knots 3220 +curve 3219 +informant 3219 +andrews 3218 +app 3218 +applauding 3218 +whites 3218 +blog 3217 +ox 3217 +vase 3217 +collective 3216 +glance 3216 +poles 3216 +sixty 3216 +fantasies 3215 +heritage 3215 +fisherman 3213 +motherfucking 3213 +armstrong 3212 +nder 3211 +complained 3210 +nightclub 3209 +preliminary 3208 +server 3208 +willow 3208 +mat 3206 +provides 3206 +a-a 3205 +appreciation 3205 +historic 3205 +jorge 3204 +processing 3204 +promote 3204 +high-pitched 3203 +naples 3203 +pumping 3202 +stalker 3202 +francine 3201 +goats 3201 +smokes 3201 +introduction 3199 +portuguese 3198 +valid 3198 +negotiations 3197 +breeding 3196 +iove 3196 +overheard 3196 +wheeler 3196 +barge 3195 +part-time 3195 +refugees 3194 +wah 3194 +observed 3193 +tigers 3193 +brothel 3191 +missions 3191 +ashore 3190 +discretion 3190 +panda 3190 +yale 3189 +disappointing 3188 +insect 3188 +absent 3186 +machinery 3186 +prophecy 3186 +wanda 3186 +carrot 3184 +ferrari 3184 +generals 3184 +gestapo 3183 +wrecked 3183 +greene 3182 +suggestions 3180 +puff 3179 +punching 3178 +supportive 3178 +hughes 3177 +intervention 3177 +layers 3177 +lulu 3177 +scholar 3176 +accusations 3175 +libby 3175 +predators 3175 +olympics 3173 +accounting 3172 +jj 3172 +darryl 3171 +expertise 3171 +fitz 3171 +strap 3171 +butts 3170 +approaches 3169 +flags 3169 +embarrassment 3168 +faking 3166 +jamal 3166 +vanilla 3166 +beacon 3164 +dominic 3164 +grapes 3164 +handkerchief 3164 +rubbing 3163 +mallory 3162 +rustling 3162 +lighten 3161 +cuban 3159 +flora 3159 +polar 3159 +translator 3156 +congrats 3154 +grabbing 3154 +passports 3154 +alf 3153 +brotherhood 3153 +ernest 3153 +professionals 3153 +understandable 3152 +yuan 3152 +banner 3151 +academic 3150 +orphans 3150 +publicly 3150 +sa 3150 +thuds 3150 +pearls 3149 +predator 3149 +addison 3146 +frances 3146 +cartel 3144 +nash 3144 +syrup 3144 +walsh 3144 +bluffing 3143 +norma 3143 +permitted 3143 +unpredictable 3142 +addresses 3141 +suspension 3141 +wagner 3141 +category 3140 +stamps 3140 +transition 3140 +cautious 3139 +comparison 3139 +isabella 3139 +peas 3139 +coats 3138 +islam 3137 +grams 3135 +thugs 3135 +freshman 3134 +toilets 3134 +banquet 3133 +coup 3132 +investigations 3132 +shaken 3132 +toothbrush 3131 +expressed 3130 +vulgar 3130 +haunt 3128 +improvement 3128 +zeus 3128 +murdoch 3127 +smelling 3127 +kirby 3125 +repairs 3124 +conducted 3123 +jeong 3123 +ollie 3123 +bakery 3121 +busting 3121 +investigated 3120 +admission 3118 +essay 3118 +twitter 3118 +depths 3117 +fewer 3116 +altered 3115 +ecstasy 3115 +filing 3115 +guarding 3115 +snatch 3114 +dalton 3113 +pharmacy 3113 +stored 3113 +tropical 3113 +admired 3112 +altitude 3112 +dickhead 3112 +mamma 3112 +priya 3112 +bombay 3111 +grind 3111 +lowest 3111 +moe 3111 +makin 3109 +sentences 3109 +full-time 3108 +mute 3108 +scratching 3108 +imprisoned 3107 +danish 3106 +glowing 3105 +martini 3105 +zhang 3105 +empress 3104 +equipped 3104 +leah 3104 +finale 3103 +tarzan 3103 +storms 3101 +sniff 3100 +slick 3098 +unarmed 3098 +vent 3097 +violated 3097 +breakthrough 3096 +dd 3096 +addressed 3095 +stud 3095 +tactical 3095 +vessels 3095 +procedures 3094 +ching 3093 +dine 3093 +draws 3093 +crowds 3091 +limb 3091 +wool 3091 +giovanni 3090 +montana 3090 +jerks 3089 +numb 3088 +commanding 3087 +oceans 3087 +priceless 3087 +relaxing 3087 +rocking 3087 +clatters 3086 +gerard 3086 +merci 3086 +packs 3086 +cabbage 3085 +parlor 3085 +intruder 3084 +argued 3083 +considerable 3083 +paladin 3082 +tellin 3082 +pupils 3081 +inspire 3080 +positively 3080 +igor 3079 +traded 3079 +feminine 3078 +shipped 3078 +partly 3077 +vague 3077 +insight 3076 +lucia 3075 +stages 3075 +screenplay 3074 +marquis 3073 +rahul 3072 +ripe 3072 +awaiting 3070 +clayton 3068 +decline 3068 +fang 3068 +inherit 3068 +pennsylvania 3068 +pigeons 3068 +tempted 3068 +shitting 3067 +tens 3067 +gigantic 3066 +guv 3066 +readings 3066 +headmaster 3064 +tenth 3064 +peyton 3063 +seals 3063 +avoided 3061 +blankets 3060 +flipped 3060 +overlapping 3060 +ads 3059 +backstage 3059 +yield 3059 +quiz 3058 +mosque 3056 +puppies 3055 +weaver 3055 +stated 3054 +belongings 3053 +vicki 3053 +weirdo 3053 +functions 3052 +reservations 3052 +chilly 3051 +betsy 3050 +eldest 3050 +factories 3049 +feedback 3049 +repeatedly 3049 +traditions 3049 +exquisite 3048 +pickle 3048 +masses 3047 +pauline 3047 +crosses 3045 +frogs 3045 +arctic 3044 +headaches 3044 +reinforcements 3043 +pajamas 3042 +secondary 3042 +asteroid 3041 +roles 3041 +mixing 3040 +unsub 3040 +courier 3039 +carrots 3038 +jeanne 3038 +fuckers 3037 +translated 3037 +cigars 3035 +longing 3035 +manipulate 3035 +bun 3034 +investors 3034 +liberal 3034 +pickles 3034 +sly 3034 +sweetest 3033 +temperatures 3033 +moss 3032 +motto 3032 +yum 3032 +melting 3029 +requesting 3028 +yan 3028 +lava 3027 +kris 3026 +monthly 3026 +noses 3026 +tickle 3025 +ufo 3023 +timothy 3022 +herbert 3021 +midst 3021 +stacey 3021 +purchased 3020 +tis 3018 +convenience 3016 +flashlight 3016 +dewey 3015 +tripped 3015 +bryce 3014 +easiest 3014 +shouted 3014 +splitting 3014 +commands 3013 +demanded 3013 +cosmos 3012 +cuff 3012 +curfew 3011 +equivalent 3011 +instructed 3011 +notorious 3011 +pitiful 3011 +prague 3011 +streak 3011 +fin 3010 +doorstep 3009 +greeting 3009 +propaganda 3009 +butterflies 3008 +fascinated 3008 +hilda 3008 +beatrice 3007 +jude 3007 +dumping 3006 +miriam 3006 +condo 3005 +karan 3005 +fearless 3004 +cripple 3003 +errand 3003 +halls 3002 +harley 3001 +imitates 3001 +ravi 3001 +gretchen 3000 +sailed 3000 +baking 2999 +connecting 2999 +nico 2999 +bourbon 2998 +cathedral 2998 +malone 2997 +bong 2996 +lists 2996 +strangle 2996 +tying 2996 +loans 2995 +manchester 2995 +stripped 2995 +briefly 2994 +tessa 2994 +experimental 2993 +unfinished 2993 +greeks 2992 +lil 2991 +mainland 2990 +w-what 2990 +judgement 2987 +stammers 2987 +pike 2986 +liars 2985 +slot 2985 +amazon 2984 +flooded 2983 +mermaid 2983 +rapist 2983 +torment 2983 +expects 2982 +lisbon 2982 +sanders 2982 +triggered 2982 +keyboard 2981 +reader 2981 +roam 2980 +seventeen 2980 +openly 2979 +deacon 2978 +olga 2978 +jackpot 2977 +soaked 2976 +printing 2975 +shorty 2975 +yuck 2975 +extract 2973 +you-you 2972 +cherish 2971 +prostitutes 2971 +logs 2970 +takin 2969 +sponge 2968 +bates 2967 +cuffs 2965 +torturing 2965 +virginity 2965 +coop 2963 +unity 2963 +forge 2962 +morse 2962 +guru 2960 +tai 2960 +skilled 2958 +dev 2957 +insists 2957 +calf 2956 +oof 2956 +allowance 2955 +homosexual 2955 +shapes 2954 +vijay 2954 +idle 2953 +rosemary 2953 +slaughtered 2953 +resent 2952 +cassandra 2950 +leaking 2950 +likewise 2950 +ghetto 2949 +riddle 2949 +sunglasses 2949 +vega 2948 +newest 2946 +fucks 2945 +gangsters 2945 +geek 2945 +valve 2945 +pies 2944 +publisher 2943 +troubling 2943 +conducting 2942 +douche 2942 +marathon 2942 +trembling 2942 +shrieks 2941 +sundays 2941 +fiancee 2937 +frankenstein 2937 +liang 2936 +summers 2936 +wei 2936 +burglar 2935 +norway 2933 +spiders 2933 +crops 2932 +falcon 2932 +infant 2932 +humiliate 2929 +pursuing 2929 +encountered 2928 +hydrogen 2928 +karaoke 2928 +alberto 2927 +corey 2927 +scope 2927 +cultures 2926 +speakers 2925 +superstar 2925 +byron 2924 +calculations 2924 +gravy 2924 +orgasm 2924 +vocal 2924 +hating 2923 +severely 2923 +novels 2922 +mindy 2918 +anders 2917 +boiled 2917 +fades 2917 +kindergarten 2917 +metaphor 2917 +fi 2915 +brazilian 2914 +devastating 2913 +bea 2912 +interfering 2911 +dripping 2910 +cuz 2909 +ops 2909 +holden 2908 +affirmative 2907 +hayley 2907 +merciful 2907 +suited 2907 +runaway 2906 +adapt 2905 +italians 2905 +santiago 2905 +shoo 2905 +wales 2905 +blacks 2904 +mal 2904 +renee 2904 +smartest 2904 +struggled 2903 +payback 2902 +prostitution 2902 +buenos 2901 +vintage 2901 +courthouse 2900 +diapers 2900 +killings 2900 +cubs 2898 +indiana 2898 +surfing 2898 +tong 2898 +frontier 2897 +spotlight 2897 +monte 2896 +stocks 2895 +denmark 2893 +holiness 2893 +patterson 2893 +isolation 2892 +starters 2892 +acquainted 2890 +resigned 2890 +appearances 2889 +meow 2889 +loft 2888 +snitch 2888 +dares 2887 +ribbon 2887 +fallon 2886 +greatness 2886 +coolest 2885 +decency 2884 +mayday 2883 +michigan 2883 +buckle 2882 +earning 2882 +reactor 2882 +simmons 2882 +vanity 2882 +docks 2880 +marriages 2880 +alleged 2878 +asap 2878 +lopez 2878 +bugging 2877 +conquered 2877 +preparations 2877 +cuckoo 2876 +crib 2874 +secondly 2874 +yogurt 2874 +fishy 2873 +flick 2873 +nixon 2873 +portland 2873 +scratched 2873 +chimney 2872 +hale 2872 +muffin 2872 +tailor 2872 +ezra 2871 +farming 2871 +homemade 2871 +nuns 2871 +packet 2871 +daniels 2870 +genuinely 2870 +rumours 2870 +blouse 2869 +kite 2869 +buses 2868 +hassan 2868 +invaded 2868 +rhyme 2868 +quentin 2867 +equation 2866 +lime 2866 +morons 2866 +rotting 2866 +transmitter 2866 +remorse 2865 +adds 2864 +aiming 2864 +marker 2864 +otis 2864 +blasted 2863 +chores 2863 +courageous 2863 +gem 2863 +grabs 2863 +beaver 2862 +crickets 2862 +determination 2862 +publishing 2862 +well-known 2862 +exercises 2860 +testament 2860 +apocalypse 2859 +phew 2859 +dicks 2858 +headlines 2857 +paralyzed 2857 +breaths 2856 +santos 2856 +stripes 2856 +abort 2855 +heap 2855 +ketchup 2853 +edmund 2852 +gracie 2852 +protects 2852 +responded 2852 +tummy 2851 +hog 2850 +weeping 2850 +yakuza 2850 +catastrophe 2849 +fragments 2849 +bp 2847 +churches 2847 +pins 2847 +shaving 2847 +legit 2845 +yoo 2845 +simpler 2844 +targeted 2844 +lars 2843 +moreover 2843 +noticing 2843 +rover 2843 +releasing 2842 +fascist 2841 +greta 2841 +spitting 2841 +colt 2840 +hardy 2839 +tristan 2839 +plaza 2837 +truce 2837 +bronze 2836 +guarded 2834 +airline 2833 +ramon 2833 +incapable 2832 +punches 2832 +hq 2830 +sanchez 2830 +barrett 2828 +privately 2828 +surround 2828 +vatican 2828 +alvin 2827 +consolation 2827 +edna 2827 +gutter 2827 +astonishing 2826 +broker 2826 +lan 2826 +seizure 2826 +fright 2825 +leaning 2825 +mic 2825 +runnin 2825 +berry 2824 +scarlett 2824 +lacking 2823 +paulie 2823 +verge 2823 +contribute 2822 +lucifer 2822 +jewellery 2821 +richards 2821 +erotic 2820 +dre 2818 +est 2818 +feng 2818 +siblings 2818 +cass 2817 +disk 2817 +op 2817 +comics 2816 +ale 2815 +coral 2815 +obstacle 2815 +biscuit 2814 +hamburger 2814 +redemption 2814 +thug 2813 +concussion 2812 +martinez 2812 +disgust 2809 +wright 2808 +arise 2806 +celebrities 2806 +nsa 2806 +dolphins 2805 +kev 2805 +kidneys 2804 +leaked 2804 +solving 2804 +plasma 2803 +conductor 2802 +ministers 2802 +regain 2802 +scram 2802 +singapore 2802 +gender 2801 +cocky 2800 +removing 2800 +deciding 2799 +richest 2799 +poo 2798 +dispute 2797 +brute 2796 +incidents 2796 +capsule 2794 +chefs 2794 +telly 2794 +dumpster 2793 +psychopath 2793 +bind 2792 +jarod 2792 +lire 2792 +chops 2791 +crust 2791 +bundle 2790 +poorly 2790 +vibrating 2790 +weighs 2790 +dwayne 2789 +encouraging 2789 +polo 2789 +ricardo 2789 +ada 2788 +singers 2788 +firmly 2787 +meteor 2787 +prettiest 2786 +hormones 2785 +starboard 2784 +chauffeur 2783 +download 2782 +garrison 2781 +heated 2781 +recovering 2781 +abi 2779 +cctv 2779 +celia 2779 +convert 2779 +darlin 2779 +hillary 2779 +postcard 2779 +sissy 2779 +ar 2778 +calmly 2778 +mathematics 2778 +reef 2778 +treasury 2778 +ups 2777 +abbey 2776 +shameful 2776 +strategic 2775 +payroll 2774 +wrists 2774 +beethoven 2773 +heartless 2772 +immense 2772 +magnus 2771 +banged 2770 +montgomery 2769 +protector 2768 +michelangelo 2767 +raging 2767 +teller 2767 +applying 2766 +atom 2766 +mop 2766 +paolo 2766 +sol 2766 +detector 2765 +favorites 2765 +handles 2765 +switching 2765 +unload 2765 +lina 2764 +stinky 2764 +norton 2763 +regional 2763 +heil 2762 +richer 2762 +secrecy 2762 +rigged 2761 +thesis 2761 +thighs 2761 +zeke 2761 +ahold 2760 +consumed 2760 +tuned 2759 +atlantis 2758 +leverage 2758 +repaired 2758 +weary 2758 +winnie 2758 +costa 2757 +cowboys 2757 +rue 2757 +employ 2756 +provoke 2756 +stool 2756 +beau 2755 +extensive 2755 +postpone 2755 +lotus 2754 +optimistic 2754 +urgently 2754 +apologizing 2753 +maze 2753 +skate 2753 +spooky 2753 +arrests 2752 +dialing 2752 +beetle 2751 +fireplace 2751 +gt 2751 +horace 2751 +vicar 2751 +homo 2750 +pupil 2750 +restricted 2750 +skating 2750 +stupidity 2750 +suspicions 2750 +thumbs 2749 +cheng 2748 +hostess 2748 +sneaky 2747 +swollen 2747 +eliza 2746 +gladys 2746 +por 2746 +unlocked 2746 +mixture 2745 +diagnosed 2744 +porsche 2744 +liberation 2743 +ri 2743 +vaughn 2743 +nay 2742 +crows 2740 +rockets 2739 +starfleet 2739 +reindeer 2738 +milton 2737 +shampoo 2737 +mai 2736 +skiing 2736 +disturbance 2735 +overhead 2735 +cohen 2734 +crank 2734 +spits 2734 +bulls 2732 +crocodile 2732 +medals 2732 +dangers 2731 +exploring 2731 +motivation 2731 +sierra 2731 +mist 2730 +galaxies 2729 +ju 2729 +adrenaline 2728 +malik 2728 +marjorie 2728 +sculpture 2728 +allied 2727 +carly 2726 +convoy 2726 +elliott 2726 +manor 2726 +bikini 2725 +cutter 2725 +forming 2725 +merit 2725 +stressful 2725 +sway 2723 +nell 2722 +salty 2722 +soy 2722 +virgil 2722 +belts 2721 +disney 2721 +elf 2721 +yvonne 2721 +contractor 2720 +crackers 2720 +rodeo 2720 +arch 2719 +operated 2719 +playboy 2719 +crews 2718 +dads 2718 +limbs 2718 +scanner 2718 +argentina 2717 +bathe 2717 +cardiac 2717 +persuaded 2717 +hyde 2716 +rewarded 2716 +stem 2716 +taiwan 2716 +velvet 2716 +filter 2715 +lakhs 2715 +pumped 2715 +warfare 2715 +sterling 2714 +fills 2713 +gran 2713 +chet 2712 +disc 2711 +percentage 2711 +bravery 2710 +diaper 2710 +edges 2710 +ida 2709 +lyle 2709 +preserved 2708 +sutton 2707 +champions 2706 +katrina 2706 +morality 2706 +partying 2706 +pd 2706 +sergei 2706 +complications 2705 +crate 2705 +mina 2705 +sausages 2704 +smoothly 2704 +cradle 2703 +leopard 2703 +rafael 2703 +chu 2702 +abu 2701 +athens 2701 +gardner 2701 +wh-what 2701 +kramer 2700 +magnet 2700 +psychotic 2699 +aggression 2698 +courses 2698 +motivated 2698 +vaccine 2698 +willis 2696 +bases 2695 +expanding 2695 +vance 2695 +buns 2694 +burglary 2694 +stumbled 2694 +confronted 2693 +geneva 2693 +increasingly 2693 +shady 2693 +sidewalk 2693 +defended 2692 +chooses 2691 +resolution 2691 +tapped 2691 +exploding 2690 +punks 2690 +scenery 2689 +assessment 2688 +hitch 2688 +initially 2688 +severed 2688 +wink 2688 +miners 2687 +shredder 2687 +villains 2687 +introducing 2686 +amendment 2685 +focusing 2685 +rainy 2685 +blackout 2684 +getaway 2684 +leash 2684 +muhammad 2684 +recognised 2684 +eyebrows 2683 +livin 2683 +bouncing 2682 +nipples 2682 +pharaoh 2682 +pots 2682 +raft 2682 +abel 2681 +refreshing 2681 +roz 2681 +wandered 2681 +beatles 2680 +confined 2680 +contagious 2680 +beijing 2679 +frequently 2679 +madeline 2679 +operational 2679 +shakes 2678 +babylon 2677 +basil 2677 +suffers 2677 +thanking 2677 +agencies 2674 +buffet 2674 +drilling 2674 +cocoa 2673 +farms 2673 +motives 2673 +perception 2673 +targeting 2671 +thudding 2670 +blares 2668 +dense 2668 +pinned 2668 +pending 2667 +crunch 2666 +nod 2666 +stains 2665 +nervously 2664 +bouquet 2663 +caribbean 2663 +stabbing 2663 +boone 2662 +bulb 2662 +philosopher 2662 +screws 2662 +drip 2660 +editing 2660 +ongoing 2660 +sinner 2660 +veteran 2660 +inspiring 2659 +mara 2659 +flint 2658 +gale 2658 +nevada 2658 +nova 2658 +manning 2657 +membership 2657 +puerto 2656 +raphael 2656 +chalk 2655 +liable 2655 +manuscript 2655 +organised 2655 +clanging 2653 +markets 2651 +austria 2650 +chocolates 2650 +rama 2650 +trafficking 2649 +philly 2648 +unaware 2648 +clone 2647 +loot 2647 +perish 2647 +processed 2647 +whooshing 2647 +hangover 2646 +columbia 2645 +flatter 2645 +weasel 2645 +wretch 2645 +israeli 2644 +shatters 2644 +indication 2643 +lace 2643 +allan 2642 +cora 2642 +drifting 2642 +dolphin 2641 +faked 2641 +grady 2641 +undress 2641 +depart 2640 +monument 2640 +tyres 2640 +marissa 2639 +hazel 2638 +invade 2638 +lorraine 2638 +pow 2638 +rep 2638 +scooby 2638 +heavier 2636 +mocking 2635 +tart 2635 +forgets 2634 +sinister 2633 +christy 2632 +dex 2632 +assemble 2631 +judas 2631 +shortcut 2630 +amusement 2629 +generosity 2629 +posts 2629 +transformation 2629 +beta 2628 +bobo 2627 +counseling 2627 +evidently 2626 +gamma 2626 +honors 2626 +measured 2626 +phyllis 2626 +snatched 2626 +squeals 2626 +caitlin 2625 +dubai 2625 +lantern 2625 +mildred 2625 +combine 2624 +nero 2624 +trey 2624 +limp 2623 +toughest 2623 +dots 2622 +implying 2622 +kilometres 2622 +matrix 2622 +evacuation 2621 +earliest 2620 +incompetent 2620 +analyze 2619 +closure 2619 +crushing 2619 +descent 2619 +parachute 2619 +classmates 2618 +excessive 2618 +tighter 2618 +fuzzy 2617 +hump 2617 +punishing 2617 +tad 2617 +clare 2616 +pageant 2616 +reveals 2616 +wan 2616 +wildlife 2616 +homecoming 2614 +poking 2614 +rehearsing 2614 +swelling 2614 +shrieking 2613 +squawking 2613 +caine 2612 +tardis 2612 +statues 2610 +christianity 2609 +practiced 2609 +hazard 2607 +stretched 2607 +dea 2606 +frustration 2606 +slug 2606 +trudy 2606 +edie 2605 +deception 2604 +comforting 2603 +elimination 2603 +len 2603 +lillian 2603 +practise 2603 +preach 2603 +trespassing 2603 +damages 2601 +appointments 2600 +ex-boyfriend 2600 +exits 2600 +yer 2600 +cleo 2599 +drummer 2599 +misunderstand 2599 +significance 2599 +specimen 2599 +voodoo 2599 +advocate 2598 +asthma 2598 +errands 2598 +hypocrite 2598 +snorts 2598 +cristina 2597 +cutie 2597 +obtained 2597 +radioactive 2597 +richmond 2597 +apprentice 2596 +ideals 2596 +unreasonable 2595 +inmate 2594 +siege 2593 +cobra 2592 +harbour 2592 +increases 2592 +illegally 2591 +noodle 2590 +rattle 2590 +trim 2590 +antibiotics 2589 +declaration 2589 +intuition 2589 +mattered 2589 +revs 2589 +virtual 2589 +darcy 2588 +scales 2588 +bypass 2587 +gigi 2587 +manly 2587 +pneumonia 2587 +testified 2587 +twenty-five 2587 +dungeon 2586 +groan 2586 +medicines 2586 +slowing 2586 +badass 2585 +chaps 2585 +consequence 2585 +intercept 2585 +ncis 2585 +examples 2584 +fists 2584 +klink 2584 +brag 2582 +pitcher 2582 +substantial 2582 +amigo 2581 +courtyard 2581 +onboard 2581 +ca 2580 +royalty 2580 +spontaneous 2580 +negotiating 2579 +peep 2579 +restroom 2579 +ache 2575 +emerge 2575 +gambler 2575 +swat 2575 +attendant 2574 +bonjour 2574 +darkest 2574 +insecure 2574 +references 2574 +sympathetic 2573 +lila 2572 +choked 2571 +heroine 2571 +plumbing 2570 +unidentified 2570 +ariel 2569 +darker 2569 +crude 2568 +auditions 2567 +demo 2567 +dictionary 2567 +sparks 2567 +ta-da 2567 +collateral 2566 +fingernails 2566 +mineral 2566 +oppose 2566 +istanbul 2565 +tempting 2565 +venom 2565 +bamboo 2564 +particle 2564 +relay 2564 +broadcasting 2563 +torres 2563 +ying 2563 +banking 2562 +axl 2561 +cassidy 2561 +reminding 2561 +estimated 2560 +ferguson 2560 +hebrew 2559 +electronics 2558 +meaningful 2558 +octopus 2558 +stranded 2558 +daytime 2557 +admitting 2556 +collision 2556 +comeback 2556 +dowry 2556 +ex-girlfriend 2556 +starved 2556 +faculty 2555 +hungarian 2555 +liza 2555 +operative 2555 +tenants 2554 +revealing 2553 +ko 2552 +mediterranean 2552 +ounce 2552 +gadget 2551 +paramedics 2551 +console 2550 +cyril 2550 +drawers 2550 +caravan 2549 +spinal 2549 +bummer 2548 +bled 2547 +crippled 2547 +defy 2546 +luna 2546 +herbs 2545 +hwang 2544 +blinded 2543 +queue 2543 +studios 2543 +goddammit 2542 +countdown 2541 +dracula 2541 +maddy 2541 +recess 2541 +alarms 2540 +gaining 2539 +maxine 2538 +quarterback 2538 +eminence 2537 +investigators 2537 +willingly 2537 +clearer 2536 +gino 2536 +speculation 2536 +cheeky 2535 +lapd 2535 +powerless 2535 +cruelty 2534 +external 2534 +allegations 2533 +arjun 2533 +quarantine 2533 +shush 2533 +sands 2531 +spelling 2531 +tito 2531 +che 2530 +headline 2529 +tablet 2529 +taliban 2529 +founder 2528 +gandhi 2528 +jets 2527 +josie 2527 +saloon 2527 +squash 2527 +occasional 2526 +tame 2526 +tuition 2526 +apologized 2525 +clinical 2525 +criticism 2525 +serpent 2525 +fading 2524 +sawyer 2524 +viktor 2524 +prop 2523 +evie 2522 +lonesome 2522 +mend 2522 +reg 2522 +separately 2522 +shoved 2522 +legion 2521 +waitin 2521 +accompanied 2520 +arabs 2520 +findings 2520 +tread 2520 +tsk 2520 +toronto 2519 +exploit 2518 +proposing 2518 +scorpion 2518 +shi 2518 +curb 2517 +zoey 2517 +athletic 2516 +bombed 2516 +opium 2516 +jumper 2515 +suing 2514 +weaker 2514 +bianca 2513 +commence 2513 +confirms 2513 +seminar 2513 +debra 2511 +plains 2511 +caviar 2510 +cleaners 2510 +kimberly 2510 +morale 2510 +neglected 2510 +readers 2510 +tavern 2510 +welcoming 2510 +worthwhile 2510 +evaluation 2509 +occurs 2508 +rituals 2507 +disguised 2506 +morton 2506 +precision 2506 +cube 2505 +doggy 2505 +realizing 2505 +revelation 2505 +titanic 2505 +babbling 2504 +cheung 2502 +dora 2502 +riches 2502 +tenant 2502 +diabetes 2501 +alternate 2500 +diversion 2499 +companions 2498 +dedication 2498 +effectively 2498 +wager 2498 +bailed 2497 +sinclair 2497 +smelly 2497 +ratings 2496 +trench 2496 +cameraman 2495 +dillon 2495 +ajay 2494 +edison 2494 +hawkins 2494 +stanton 2494 +abduction 2493 +batter 2493 +brighter 2493 +dye 2493 +morrison 2493 +prospect 2493 +wider 2493 +alma 2492 +damp 2492 +manslaughter 2492 +remedy 2492 +accusation 2491 +cologne 2491 +outsider 2491 +thigh 2491 +serum 2490 +spaces 2490 +whats 2490 +oral 2489 +shades 2489 +woe 2489 +alias 2488 +elise 2488 +receipts 2488 +apron 2487 +notified 2487 +kentucky 2486 +squeaks 2485 +tonic 2485 +vein 2485 +cinderella 2484 +dim 2484 +presenting 2484 +sod 2484 +steep 2484 +cub 2483 +chung 2482 +eyewitness 2481 +flattering 2480 +doubled 2479 +kathleen 2479 +overdose 2479 +della 2477 +suckers 2477 +automobile 2476 +cain 2476 +loo 2475 +brittany 2474 +bullied 2474 +humphrey 2474 +spider-man 2474 +dispose 2473 +kidnapper 2473 +margarita 2473 +tyson 2473 +grieving 2472 +hash 2472 +initials 2472 +boogie 2470 +hound 2470 +cloak 2469 +dawg 2469 +perp 2469 +crabs 2468 +pastry 2468 +hola 2467 +michele 2467 +successor 2467 +cheerleader 2466 +slips 2466 +critics 2465 +macho 2464 +recruited 2464 +stunned 2464 +fuller 2463 +mornings 2463 +accidental 2461 +convey 2461 +lara 2461 +prototype 2461 +atoms 2460 +barcelona 2460 +fling 2460 +froze 2460 +superiors 2460 +athletes 2459 +cupcake 2459 +kidnappers 2459 +warsaw 2459 +pleasures 2458 +ax 2457 +inclined 2456 +kumar 2456 +uptight 2456 +scrooge 2455 +stockings 2455 +fingerprint 2454 +harmed 2454 +sensational 2454 +intercom 2453 +jockey 2453 +mourn 2453 +penthouse 2452 +tory 2452 +eileen 2451 +forthe 2451 +ginny 2451 +quack 2451 +gospel 2450 +peck 2450 +presumably 2449 +aspects 2448 +assumption 2448 +hyung 2448 +signatures 2448 +hugging 2447 +rumbles 2447 +sideways 2447 +organisation 2446 +theodore 2446 +ct 2445 +jaws 2445 +lowered 2445 +youtube 2445 +designers 2444 +mushroom 2444 +squid 2444 +troll 2444 +believer 2443 +freeway 2443 +possessions 2442 +cowardly 2441 +tornado 2441 +tunes 2441 +notch 2440 +routes 2440 +annoy 2439 +elijah 2439 +winters 2439 +commercials 2438 +idiotic 2438 +loaf 2438 +inconvenience 2437 +plum 2437 +solicitor 2437 +amnesia 2436 +deborah 2436 +distinct 2436 +arson 2435 +doubted 2435 +furthermore 2435 +sweeping 2435 +quarry 2434 +farts 2432 +guo 2432 +puss 2432 +depot 2431 +circulation 2430 +evenings 2430 +melinda 2430 +ole 2430 +socialist 2430 +dryer 2429 +mutters 2429 +vomiting 2429 +brock 2428 +candis 2428 +fei 2428 +donated 2426 +moody 2426 +receiver 2426 +reich 2426 +semen 2425 +converted 2424 +rags 2424 +harness 2423 +padre 2423 +slate 2423 +sonya 2423 +necessity 2422 +dinners 2421 +emails 2421 +orderly 2421 +rumpole 2421 +assassins 2420 +scold 2420 +certainty 2419 +knox 2419 +marian 2419 +patron 2418 +trek 2418 +upright 2418 +babes 2417 +reborn 2417 +reel 2417 +sorrows 2417 +typewriter 2417 +havin 2416 +utmost 2416 +bhai 2415 +governments 2415 +patsy 2415 +privileges 2415 +throats 2415 +glee 2414 +lockdown 2414 +remotely 2414 +revolver 2414 +brunch 2413 +coyote 2413 +dwell 2413 +woof 2413 +manic 2412 +squat 2412 +bangkok 2411 +groove 2411 +uncovered 2410 +lynch 2409 +mailbox 2409 +objections 2409 +commandant 2408 +conventional 2408 +scrape 2408 +stockholm 2408 +swings 2408 +iced 2407 +ignition 2407 +oy 2407 +paulo 2407 +raises 2407 +transaction 2407 +beaches 2406 +interpol 2406 +epidemic 2403 +honoured 2402 +shaggy 2402 +friggin 2401 +policies 2400 +bah 2399 +shattering 2399 +statistics 2399 +charms 2396 +flashing 2396 +intercourse 2396 +copied 2395 +destructive 2395 +grilled 2395 +hookers 2395 +oregon 2395 +panther 2395 +paycheck 2395 +solitude 2395 +exhausting 2393 +maids 2393 +performances 2393 +lambert 2392 +omega 2391 +belgium 2390 +liability 2390 +stationed 2390 +x-rays 2390 +yankees 2390 +whichever 2389 +hunk 2388 +trout 2388 +crystals 2387 +learns 2387 +prosperity 2386 +repent 2386 +assembled 2385 +literary 2385 +martyr 2384 +ounces 2384 +connecticut 2383 +northwest 2383 +privileged 2383 +webster 2383 +excess 2382 +unemployment 2380 +viking 2380 +blackmailing 2379 +munich 2379 +arrogance 2378 +hacker 2378 +install 2378 +jeopardy 2378 +pleases 2377 +tongues 2377 +dependent 2376 +perkins 2376 +reset 2376 +risen 2376 +sued 2376 +goons 2375 +macleod 2375 +milady 2375 +wolfe 2375 +abnormal 2374 +bandage 2374 +heed 2374 +bullying 2373 +charter 2373 +mabel 2373 +pronounced 2373 +litter 2372 +shelley 2372 +turks 2372 +izzy 2371 +ltd 2371 +standby 2371 +angles 2370 +baths 2370 +generate 2370 +kerry 2370 +peaches 2370 +cobb 2369 +residue 2368 +composed 2367 +paco 2367 +immigrants 2366 +kilo 2366 +probable 2366 +enjoys 2364 +newman 2363 +seeks 2363 +vicinity 2362 +clint 2361 +der 2361 +flank 2361 +functioning 2361 +horatio 2360 +predictable 2360 +expansion 2359 +jedi 2359 +scrambled 2359 +pittsburgh 2358 +angus 2357 +natives 2357 +bedrooms 2356 +bing 2356 +itchy 2356 +scarlet 2356 +stirring 2356 +barrels 2354 +ensign 2354 +penn 2354 +structures 2354 +voters 2354 +geoff 2353 +madeleine 2353 +belonging 2352 +bumper 2352 +commotion 2352 +existing 2352 +extinct 2352 +haste 2352 +joints 2352 +mcdonald 2352 +mao 2351 +mare 2351 +coleman 2350 +cynical 2350 +heist 2350 +influenced 2350 +input 2349 +cary 2348 +recite 2348 +bottoms 2347 +eagles 2347 +expired 2347 +palms 2347 +slash 2347 +columbus 2345 +georges 2344 +vous 2344 +exaggerate 2343 +guided 2343 +pineapple 2343 +spence 2342 +ankles 2341 +builds 2340 +colleen 2340 +morals 2340 +posing 2339 +communities 2338 +fireman 2338 +lawson 2338 +thorn 2338 +workout 2338 +conceived 2337 +extraterrestrial 2337 +orlando 2337 +raylan 2337 +tennessee 2337 +tightly 2337 +booking 2336 +diaz 2336 +fraser 2336 +gallagher 2336 +infantry 2336 +sleeves 2336 +vacant 2336 +yeon 2336 +oranges 2335 +breakup 2334 +layla 2334 +rhodes 2334 +honourable 2333 +purity 2333 +mechanics 2332 +militia 2332 +bronx 2331 +mackenzie 2331 +hasty 2329 +realizes 2329 +colorful 2328 +ramp 2328 +boulevard 2327 +intriguing 2327 +rook 2327 +xavier 2327 +callen 2326 +papi 2326 +acquire 2325 +atm 2325 +episodes 2325 +gents 2325 +necks 2325 +sinners 2325 +dedicate 2324 +oswald 2324 +rests 2324 +debut 2323 +milord 2323 +pi 2323 +interviewing 2321 +occupy 2321 +aimed 2320 +emerged 2319 +restraining 2319 +sloan 2319 +clarity 2318 +cod 2318 +daft 2318 +calculated 2317 +islamic 2317 +lakes 2317 +grumpy 2316 +ki 2316 +consulting 2315 +eddy 2315 +obstacles 2315 +picasso 2315 +infirmary 2314 +nicolas 2314 +pesos 2314 +plotting 2314 +manipulated 2313 +sermon 2313 +scratches 2312 +aidan 2310 +critic 2310 +deeks 2310 +greasy 2310 +seung 2309 +zealand 2309 +supports 2307 +agreeing 2306 +anatomy 2306 +placing 2305 +prejudice 2305 +straightforward 2305 +flare 2304 +milky 2304 +masterchef 2303 +premiere 2303 +vladimir 2303 +cinnamon 2302 +glamorous 2302 +syd 2302 +civilisation 2301 +referee 2301 +sensor 2301 +threshold 2301 +tends 2300 +louisa 2299 +savages 2299 +emil 2298 +torpedo 2298 +ere 2297 +housewife 2297 +abdomen 2296 +contestants 2296 +fossil 2296 +replied 2296 +soak 2296 +tours 2296 +hoffman 2295 +princes 2295 +stretching 2295 +bumps 2294 +educational 2294 +truman 2294 +huang 2293 +latte 2293 +outfits 2293 +politically 2293 +pratt 2293 +resurrection 2293 +whoosh 2293 +ds 2292 +rejection 2292 +wiser 2292 +cambridge 2291 +clutch 2291 +cocktails 2291 +fusion 2291 +gloomy 2291 +madly 2291 +chronic 2290 +fluids 2290 +lighthouse 2290 +treatments 2290 +ahmed 2289 +deleted 2289 +stanford 2289 +sustain 2289 +buyers 2288 +couid 2288 +darius 2288 +valet 2288 +condemn 2287 +nobel 2287 +resemblance 2287 +sank 2287 +southeast 2287 +avatar 2286 +packages 2286 +suicidal 2286 +taped 2286 +wed 2286 +wireless 2286 +lice 2285 +paddle 2285 +emilio 2284 +gibberish 2284 +ingredient 2284 +itch 2284 +spreads 2284 +tablets 2284 +porno 2283 +prevented 2283 +pry 2283 +withdrawal 2283 +br 2282 +sexuality 2282 +vinnie 2282 +oh-oh 2281 +considerate 2280 +pas 2279 +sovereign 2279 +voicemail 2279 +cadet 2278 +clarify 2277 +immature 2277 +marguerite 2276 +t-shirts 2276 +banished 2275 +chemo 2275 +nicki 2275 +dairy 2274 +attach 2273 +murdock 2273 +pushes 2273 +sliding 2273 +spectacle 2273 +yin 2273 +allegiance 2271 +delusional 2271 +pooja 2271 +slope 2271 +awareness 2270 +decker 2270 +dino 2270 +jed 2270 +roaming 2269 +shores 2269 +competitors 2268 +observing 2268 +overreacting 2268 +tomas 2268 +fiery 2267 +paste 2267 +wits 2267 +eclipse 2266 +typically 2266 +oysters 2265 +produces 2265 +scraping 2264 +pros 2263 +alabama 2262 +impose 2262 +revenue 2262 +departed 2261 +user 2261 +arnie 2260 +cracker 2260 +hu 2260 +mozart 2260 +undoubtedly 2260 +charmed 2259 +dent 2259 +spiral 2259 +hmmm 2258 +ka 2258 +scans 2258 +kirsten 2257 +louisiana 2257 +rumour 2257 +correction 2256 +mornin 2256 +nadine 2256 +posh 2256 +touchdown 2256 +chubby 2255 +manifest 2255 +fleeing 2254 +genie 2254 +safest 2254 +steroids 2254 +dakota 2253 +archives 2252 +trance 2252 +witchcraft 2252 +rocco 2251 +symphony 2251 +traumatic 2251 +frequent 2250 +skipping 2250 +affecting 2249 +modeling 2249 +negotiation 2249 +spacecraft 2249 +sporting 2249 +benton 2248 +calculate 2248 +fiddle 2248 +reno 2248 +talbot 2248 +sails 2247 +bruise 2246 +despicable 2246 +persistent 2246 +stein 2246 +salem 2245 +escapes 2244 +mischief 2244 +cupcakes 2243 +mckay 2243 +arc 2242 +brussels 2242 +ripper 2242 +cognac 2241 +ritchie 2241 +vitamins 2241 +caps 2240 +careers 2240 +imbecile 2240 +sim 2240 +steaks 2240 +cove 2239 +lair 2239 +directing 2237 +slammed 2237 +stepfather 2237 +congregation 2236 +illinois 2236 +viral 2236 +coco 2234 +crisp 2234 +helicopters 2234 +flexible 2233 +goo 2233 +licking 2233 +outrage 2233 +funk 2232 +ice-cream 2232 +prefers 2232 +canteen 2231 +captive 2231 +cee 2231 +knuckles 2231 +montreal 2231 +tao 2231 +zen 2231 +controversial 2230 +credibility 2230 +disconnected 2230 +flats 2230 +enthusiastic 2229 +janey 2229 +acute 2228 +cork 2228 +icy 2228 +priorities 2228 +bladder 2227 +flyer 2227 +wormhole 2227 +caretaker 2225 +pep 2225 +roommates 2225 +astronauts 2224 +grape 2224 +programming 2224 +destroys 2223 +drained 2223 +metallic 2223 +seldom 2223 +sergio 2223 +hacking 2222 +poetic 2222 +riders 2222 +contaminated 2221 +transported 2221 +dimensions 2219 +duct 2219 +echoes 2219 +insensitive 2219 +emmett 2218 +grinding 2218 +initiate 2218 +nonetheless 2218 +adjourned 2217 +composition 2217 +flaw 2216 +indulge 2216 +messiah 2216 +wrench 2216 +accustomed 2215 +concluded 2215 +napkin 2215 +movin 2214 +doorway 2213 +i-it 2213 +muddy 2212 +steph 2212 +intentionally 2211 +saturn 2211 +appearing 2210 +canned 2210 +chiming 2210 +ballistics 2209 +nicked 2209 +hideout 2208 +pillows 2208 +replacing 2208 +solemn 2208 +startled 2208 +suction 2208 +francesca 2207 +override 2207 +submitted 2207 +carver 2206 +lea 2206 +mateo 2206 +mosquito 2205 +detained 2204 +fragrance 2204 +pennies 2204 +chopping 2203 +meadow 2203 +cables 2202 +needy 2202 +refugee 2202 +satellites 2202 +youngsters 2202 +luc 2201 +whitey 2201 +break-in 2200 +harassing 2200 +inhabitants 2200 +yun 2200 +burying 2199 +interpretation 2199 +ro 2199 +bree 2198 +incomplete 2198 +blimey 2197 +titan 2196 +agatha 2195 +dilemma 2195 +mutt 2195 +prominent 2195 +retard 2195 +temples 2195 +hoover 2194 +cesar 2193 +consul 2193 +lindsey 2193 +temp 2193 +drones 2192 +hairdresser 2192 +planting 2192 +jensen 2191 +justified 2191 +norwegian 2191 +stuffing 2191 +dork 2190 +stalling 2190 +birthdays 2189 +examiner 2189 +imminent 2189 +intimacy 2189 +masked 2189 +qualify 2189 +tucked 2189 +allie 2188 +bulk 2188 +diesel 2188 +meditation 2188 +mumbles 2188 +prosecute 2188 +defined 2187 +oklahoma 2186 +paw 2186 +crawled 2185 +default 2185 +doughnuts 2184 +haunting 2184 +margot 2184 +advances 2183 +solutions 2183 +staircase 2183 +plaster 2182 +proceedings 2182 +amends 2181 +fraternity 2181 +armored 2180 +cannons 2180 +freud 2180 +kendall 2180 +birdie 2179 +colonies 2179 +perez 2179 +volcanic 2179 +carve 2178 +cleopatra 2178 +samson 2178 +brink 2177 +fishermen 2177 +hybrid 2176 +knockout 2176 +martian 2176 +playin 2176 +chunk 2175 +constructed 2175 +confiscated 2174 +interrogate 2174 +commerce 2173 +oprah 2173 +tasks 2173 +voyager 2173 +precaution 2172 +printer 2172 +boob 2171 +finishes 2171 +rossi 2171 +nagging 2170 +stephens 2169 +hag 2167 +imported 2167 +lunchtime 2167 +skinner 2167 +transcript 2166 +conceal 2165 +holler 2165 +iife 2165 +chug 2164 +compelled 2163 +paranoia 2163 +silas 2163 +styles 2163 +airborne 2162 +pea 2162 +sentiment 2162 +syria 2162 +babysitting 2161 +improving 2161 +connects 2160 +hare 2160 +staged 2160 +withdrawn 2160 +admirable 2159 +englishman 2159 +hopkins 2159 +indicated 2159 +kristen 2159 +pumps 2159 +reviews 2159 +vouch 2159 +vey 2158 +yuki 2158 +trivial 2157 +parcel 2156 +desired 2155 +scroll 2155 +appealing 2154 +designated 2154 +weighed 2154 +bubba 2153 +imprisonment 2153 +ferris 2152 +fleming 2152 +cooling 2151 +containing 2150 +giles 2150 +impressions 2150 +advantages 2149 +cartwright 2149 +uncertain 2149 +anticipated 2148 +deliberate 2148 +isolate 2148 +tighten 2148 +corporations 2147 +petra 2147 +venue 2147 +irritating 2146 +revolt 2145 +whatcha 2145 +bombers 2144 +lunar 2144 +rightful 2144 +southwest 2144 +chained 2143 +maine 2143 +marvel 2143 +colder 2142 +snooping 2142 +tit 2142 +trunks 2142 +excused 2141 +seduced 2141 +sully 2141 +beams 2140 +bloodshed 2140 +communicating 2140 +offender 2140 +bruising 2139 +monstrous 2139 +offspring 2139 +doorman 2138 +prohibited 2138 +props 2138 +aiden 2137 +intervene 2137 +osaka 2137 +penetrate 2137 +tobias 2137 +cairo 2136 +niggers 2136 +bowls 2135 +jonny 2135 +patriot 2135 +rufus 2135 +barton 2134 +infamous 2134 +pits 2134 +surgeons 2134 +crooks 2133 +lettuce 2133 +shag 2133 +exaggerating 2132 +foryou 2132 +inferior 2132 +zac 2132 +boredom 2131 +amos 2130 +cashier 2130 +tolerance 2130 +caliber 2129 +polls 2129 +katy 2128 +seller 2128 +sensed 2128 +underpants 2128 +dart 2127 +sections 2127 +vertical 2127 +zipper 2127 +acceptance 2126 +gabby 2126 +shankar 2126 +traveler 2126 +emerald 2125 +gays 2125 +timeline 2125 +pc 2124 +hassle 2123 +thrilling 2123 +conditioning 2122 +hopeful 2122 +sneakers 2122 +stokes 2122 +instruction 2121 +insults 2121 +rodriguez 2121 +supporters 2121 +teal 2121 +clatter 2120 +flaming 2120 +flipping 2120 +freight 2119 +representatives 2119 +invitations 2118 +lucille 2118 +undressed 2118 +alfredo 2117 +benedict 2117 +boiler 2117 +davey 2117 +davies 2117 +edo 2117 +efficiency 2117 +oneself 2117 +uneasy 2117 +gomez 2116 +yelps 2116 +afterlife 2115 +counterfeit 2115 +eccentric 2115 +l-i 2115 +one-way 2115 +smiley 2115 +twat 2115 +memphis 2114 +vi 2114 +wand 2114 +consume 2113 +formidable 2113 +gel 2113 +lesser 2113 +luthor 2113 +praised 2113 +surrendered 2113 +welcomed 2113 +launching 2112 +tee 2112 +wendell 2112 +cocksucker 2111 +crores 2110 +downs 2110 +retiring 2110 +spears 2110 +authorization 2109 +outsiders 2109 +pancake 2109 +vikram 2109 +accessory 2108 +probability 2108 +contrast 2107 +deported 2107 +peters 2107 +blokes 2106 +crater 2106 +hooks 2106 +liaison 2106 +remarks 2106 +adele 2105 +chiefs 2105 +distribute 2105 +photographed 2105 +rehearsals 2105 +salvatore 2105 +vocalizing 2105 +whoop 2105 +credits 2104 +hup 2103 +shalt 2103 +stability 2103 +flea 2102 +stench 2102 +taps 2102 +woken 2102 +allright 2101 +compelling 2101 +dolores 2101 +strawberries 2101 +convictions 2100 +hubby 2099 +self-esteem 2099 +threatens 2099 +bragging 2098 +clocks 2098 +peacock 2098 +suburbs 2098 +gabi 2097 +hurrah 2097 +moo 2096 +fatso 2095 +lacks 2095 +pluck 2095 +reactions 2095 +silently 2095 +subconscious 2095 +adolf 2094 +lays 2094 +mira 2094 +refined 2094 +bungalow 2093 +surge 2093 +practices 2092 +precautions 2092 +thanked 2092 +carolyn 2091 +ernesto 2091 +jody 2091 +menace 2091 +uranium 2091 +aka 2090 +christie 2090 +fractured 2090 +cone 2089 +sauna 2089 +bonding 2088 +equality 2088 +extinction 2088 +randolph 2088 +bourgeois 2087 +csi 2087 +insert 2087 +downhill 2086 +kiki 2086 +pedal 2086 +teens 2086 +irving 2085 +levi 2085 +peru 2085 +stables 2085 +ambitions 2084 +buddhist 2084 +terrain 2084 +turbo 2084 +hae 2083 +coaching 2082 +explodes 2082 +mu 2082 +premature 2082 +riots 2082 +screening 2082 +lsn 2081 +qi 2081 +undone 2081 +communism 2080 +fitted 2080 +treacherous 2079 +inspect 2078 +elias 2077 +hurray 2077 +irresistible 2077 +joanne 2077 +northeast 2077 +sparrow 2077 +brew 2076 +gypsies 2076 +performer 2076 +ingenious 2075 +snapping 2075 +titles 2075 +turd 2075 +challenger 2074 +baghdad 2073 +bulletin 2073 +ditched 2072 +immortality 2072 +jinx 2072 +preview 2072 +procession 2072 +recruits 2072 +tempt 2072 +eduardo 2071 +aires 2070 +knob 2070 +melon 2070 +weaknesses 2070 +conclude 2069 +domain 2069 +bao 2068 +berries 2068 +dresser 2068 +beads 2067 +straining 2067 +windy 2067 +merchants 2066 +patent 2066 +prisons 2065 +profitable 2065 +ryder 2065 +lass 2064 +mentioning 2064 +salvage 2064 +poof 2063 +productive 2063 +sonja 2063 +tox 2063 +derby 2062 +doth 2062 +ewing 2062 +goldfish 2062 +hosting 2062 +buff 2061 +capitol 2061 +leisure 2061 +illusions 2060 +notices 2060 +odin 2060 +springfield 2060 +kyoto 2059 +peed 2059 +tracker 2059 +bryant 2058 +dickie 2058 +euro 2058 +goodwill 2058 +heave 2058 +vitamin 2058 +boar 2057 +bogus 2057 +slater 2057 +strapped 2057 +brunette 2056 +hippie 2056 +jab 2056 +rhymes 2056 +face-to-face 2055 +incidentally 2055 +ting 2055 +gi 2054 +compensate 2053 +tasting 2053 +webb 2053 +hallucinations 2052 +outbreak 2052 +sow 2052 +copenhagen 2051 +factors 2051 +golly 2051 +hiv 2051 +pyramids 2051 +dandy 2050 +desperation 2050 +detour 2050 +entrusted 2050 +err 2050 +parting 2050 +resisting 2050 +wrestle 2050 +bursting 2049 +savannah 2049 +warrants 2049 +concealed 2048 +dreamer 2048 +gagging 2048 +mandatory 2048 +finances 2047 +nightingale 2047 +grenades 2046 +lamps 2046 +premier 2046 +squire 2046 +periods 2045 +borrowing 2044 +kgb 2044 +parasite 2044 +uhm 2044 +absorb 2043 +influential 2043 +mammy 2043 +rodrigo 2043 +cece 2042 +concentrated 2042 +investments 2042 +laps 2042 +tyrant 2042 +underestimated 2042 +applies 2041 +britney 2041 +exchanged 2041 +measuring 2041 +origins 2041 +portugal 2041 +decorations 2040 +maths 2040 +niggas 2040 +orion 2039 +kyung 2038 +ownership 2038 +pepe 2038 +tweet 2038 +firstly 2037 +racial 2037 +raspberry 2037 +intimidated 2036 +parsons 2036 +skunk 2036 +suv 2036 +audible 2035 +nearer 2035 +yong 2035 +bmw 2034 +communion 2034 +whinnies 2034 +hindu 2033 +leroy 2033 +anomaly 2032 +klein 2032 +pursued 2032 +renaissance 2032 +sphere 2032 +awarded 2031 +chant 2031 +mid 2031 +outlaw 2031 +penguins 2031 +surname 2031 +applications 2030 +classmate 2030 +emmy 2029 +subtitle 2029 +economics 2028 +laden 2028 +ballroom 2027 +czech 2027 +maneuver 2027 +mort 2027 +phenomenal 2027 +shortage 2027 +sinned 2027 +tremble 2027 +hooking 2026 +moons 2026 +screeches 2026 +ae 2025 +bounced 2024 +consulate 2024 +dane 2024 +disrespectful 2024 +sweetness 2024 +parenting 2023 +robberies 2023 +describing 2022 +hilary 2022 +logo 2022 +remark 2022 +scalpel 2022 +bein 2021 +big-time 2021 +cecilia 2021 +competent 2021 +jaime 2021 +seymour 2021 +strangest 2021 +basics 2020 +bi 2020 +lectures 2020 +conquest 2019 +contestant 2019 +leftovers 2019 +mounted 2019 +norris 2019 +pens 2019 +texture 2019 +wimp 2019 +algorithm 2018 +chile 2018 +columns 2018 +wails 2018 +decree 2017 +jeremiah 2017 +momentum 2017 +rewrite 2017 +apes 2016 +attorneys 2016 +cadillac 2016 +captioning 2016 +desmond 2015 +fairies 2015 +lesbians 2015 +alzheimer 2014 +discovering 2014 +hua 2014 +monitors 2014 +roasted 2014 +subpoena 2014 +weighing 2014 +cm 2013 +foe 2013 +hicks 2013 +circuits 2012 +hiking 2012 +nag 2012 +stepmother 2012 +formally 2011 +personalities 2011 +revive 2011 +unnatural 2011 +betraying 2010 +comfy 2010 +onstage 2010 +pip 2010 +prizes 2010 +renting 2010 +blaine 2009 +heath 2009 +marrow 2009 +minority 2009 +marcos 2008 +popularity 2008 +portable 2008 +hayden 2007 +hesitation 2007 +sylvester 2007 +telegraph 2007 +alistair 2006 +cluster 2006 +grandad 2006 +jog 2005 +slay 2005 +germs 2004 +ooo 2004 +shithead 2004 +sparkling 2004 +tiring 2004 +fuhrer 2003 +handbag 2003 +splinter 2003 +thumping 2003 +unlimited 2003 +vinegar 2003 +faults 2002 +remarkably 2002 +sketches 2002 +supposing 2002 +enchanted 2001 +hari 2001 +nineteen 2001 +respectful 2001 +bien 2000 +doughnut 2000 +honk 2000 +tags 2000 +tar 2000 +wrapping 2000 +delia 1999 +dinozzo 1999 +ducky 1999 +jojo 1999 +om 1999 +rae 1999 +unfaithful 1999 +barker 1998 +financially 1998 +stance 1998 +blur 1997 +clam 1997 +clanking 1997 +nosy 1997 +organizing 1997 +sarcastic 1997 +boyle 1996 +pasha 1996 +attacker 1995 +descend 1995 +horribly 1995 +inject 1995 +wi 1995 +burner 1994 +fluffy 1994 +liberated 1994 +anal 1993 +melancholy 1993 +puppets 1993 +awe 1992 +gallons 1992 +grieve 1992 +earring 1991 +jamaica 1990 +unprecedented 1990 +yank 1990 +brutally 1989 +disciples 1989 +extortion 1989 +guardians 1989 +elves 1988 +malibu 1988 +simultaneously 1988 +chapman 1987 +lupin 1987 +neville 1987 +postman 1987 +senseless 1987 +hostel 1986 +marcia 1986 +melvin 1986 +sittin 1986 +chemist 1985 +clamp 1985 +cockroach 1985 +poets 1985 +faded 1984 +gunpowder 1984 +professionally 1984 +vargas 1984 +gala 1983 +inc 1983 +rivals 1983 +surroundings 1983 +wisconsin 1983 +cackling 1982 +contemporary 1982 +half-hour 1982 +harlem 1982 +marlon 1982 +withstand 1982 +arsenal 1981 +donuts 1981 +endured 1981 +underway 1981 +daryl 1980 +digs 1980 +frickin 1980 +injected 1980 +billionaire 1979 +elisabeth 1979 +siegfried 1979 +bandages 1978 +cardboard 1978 +hugs 1978 +cooperative 1977 +diploma 1977 +everlasting 1977 +liverpool 1977 +fortunes 1976 +neural 1976 +rural 1976 +abbott 1975 +antiques 1975 +labels 1975 +receptionist 1975 +millennium 1974 +sizes 1974 +immoral 1973 +layout 1973 +manages 1973 +hisses 1972 +impulsive 1972 +usher 1972 +admirer 1971 +pollution 1971 +rejoice 1971 +iowa 1970 +marlene 1970 +hindi 1969 +holder 1969 +journalism 1968 +merge 1968 +nominated 1968 +squeezed 1968 +sundown 1968 +assistants 1967 +lotta 1967 +miraculous 1967 +prescribed 1967 +stared 1967 +astrid 1966 +consumption 1966 +guarantees 1966 +iceland 1966 +junction 1966 +massachusetts 1966 +metropolis 1966 +swearing 1966 +bridal 1965 +victorious 1965 +virgins 1965 +squirt 1964 +blaze 1963 +casket 1963 +hanson 1963 +modified 1963 +pads 1963 +cliffs 1962 +destroyer 1962 +kimble 1962 +offshore 1962 +first-class 1961 +nip 1961 +scanning 1961 +tendency 1961 +endangered 1960 +gustav 1960 +intensive 1960 +va 1960 +viva 1960 +ave 1959 +circling 1959 +stashed 1959 +backward 1958 +chamberlain 1958 +das 1958 +fiber 1958 +hai 1958 +last-minute 1958 +opponents 1958 +sari 1958 +thermal 1958 +circumstance 1957 +geography 1957 +toto 1957 +interpret 1956 +stargate 1956 +bosom 1955 +eighty 1955 +pisses 1955 +preaching 1955 +salsa 1955 +shred 1955 +sicily 1955 +transporter 1955 +beggars 1954 +dread 1954 +duration 1954 +josephine 1954 +mein 1954 +networks 1954 +formality 1953 +heater 1953 +par 1953 +agriculture 1952 +aigoo 1952 +ordeal 1952 +rhonda 1952 +smug 1952 +abyss 1951 +descended 1951 +fowler 1951 +katya 1951 +lasting 1951 +aurora 1950 +marries 1950 +sleigh 1950 +frenchman 1948 +rouge 1948 +survives 1948 +margo 1947 +redhead 1947 +terrence 1947 +aubrey 1946 +corny 1946 +creativity 1946 +dominant 1946 +graphic 1946 +isis 1946 +kip 1946 +irrational 1945 +mathematical 1945 +paws 1945 +sacked 1945 +andreas 1944 +celestial 1944 +cetera 1944 +chariot 1944 +havoc 1944 +midget 1944 +nino 1944 +blasting 1943 +it-it 1943 +skulls 1943 +upgrade 1943 +ancestor 1942 +babysit 1942 +videotape 1942 +wedded 1942 +rained 1941 +transit 1941 +abide 1940 +reserves 1940 +thingy 1940 +wellington 1940 +chico 1939 +rim 1939 +settles 1939 +decisive 1938 +motors 1938 +cheesy 1937 +symbolic 1937 +brightest 1936 +clink 1936 +cranky 1936 +handicapped 1936 +mating 1936 +meyer 1936 +molecules 1936 +roxy 1936 +animation 1935 +grail 1935 +sheridan 1935 +creeping 1934 +furnace 1934 +thine 1934 +voluntarily 1934 +bu 1933 +chinatown 1933 +pierced 1933 +slade 1933 +titus 1933 +sai 1932 +toothpaste 1932 +cuddle 1931 +egyptians 1931 +langley 1931 +bankruptcy 1930 +lever 1930 +nipple 1930 +shaman 1930 +weeds 1930 +becker 1929 +reacted 1929 +mashed 1928 +bentley 1927 +crusade 1927 +decoy 1927 +regent 1927 +sloane 1927 +tiles 1927 +slides 1926 +vivid 1926 +catering 1925 +dishonest 1925 +jennings 1925 +airplanes 1924 +lurking 1924 +shaky 1924 +subs 1924 +alligator 1923 +barren 1923 +moth 1923 +axel 1922 +clacking 1922 +doggie 1922 +expressing 1922 +lorna 1922 +uncover 1922 +airlines 1921 +dum 1921 +goofy 1921 +jackets 1921 +marcy 1921 +unicorn 1921 +alarmed 1920 +bums 1920 +businessmen 1920 +defender 1920 +folded 1920 +chic 1919 +goody 1919 +noose 1919 +forgiving 1918 +index 1918 +thatcher 1918 +amateurs 1917 +blush 1917 +fertile 1917 +armour 1916 +deliveries 1916 +alexandra 1915 +parted 1915 +shits 1915 +syndicate 1915 +twinkle 1915 +werner 1915 +regions 1914 +righty 1914 +baroness 1913 +castro 1913 +skirts 1913 +technician 1913 +waffles 1913 +calories 1912 +cuisine 1912 +malfunction 1912 +memorable 1912 +middle-aged 1912 +motorbike 1912 +thea 1912 +acknowledged 1911 +snoop 1911 +spelled 1911 +evolve 1910 +cox 1909 +array 1908 +sedative 1908 +tenderness 1908 +garland 1907 +klingon 1907 +nicola 1906 +raju 1906 +smuggled 1906 +beforehand 1905 +cecil 1905 +continuous 1905 +reversed 1905 +royce 1905 +yoko 1905 +agitated 1904 +discoveries 1904 +forgave 1904 +kimmy 1904 +mri 1904 +seniors 1904 +synthetic 1904 +attendance 1903 +maple 1903 +casa 1902 +firearms 1902 +offenders 1902 +pools 1902 +wanker 1902 +moan 1901 +obligations 1901 +signor 1901 +defect 1900 +ethical 1900 +graffiti 1900 +lengths 1900 +linen 1900 +patriotic 1900 +pest 1900 +supplied 1900 +minnie 1899 +vin 1899 +bounds 1898 +decorate 1898 +distinction 1898 +spectrum 1898 +hums 1897 +mandarin 1897 +reluctant 1897 +rand 1896 +retain 1896 +educate 1895 +justine 1895 +minnesota 1895 +cruiser 1894 +exploration 1894 +mace 1894 +mp 1894 +permits 1894 +screwdriver 1894 +newborn 1893 +oddly 1893 +shivering 1893 +slime 1893 +attaboy 1892 +badger 1892 +bruised 1892 +eyeballs 1892 +luigi 1892 +absorbed 1891 +boundary 1891 +drought 1891 +hypothesis 1891 +institutions 1891 +panama 1891 +rembrandt 1891 +clinking 1890 +executioner 1890 +catalina 1889 +darts 1889 +lingerie 1889 +lured 1889 +flaws 1888 +gon 1888 +thinner 1888 +lucien 1887 +bertie 1886 +cornered 1886 +tug 1886 +aerial 1885 +clash 1885 +uptown 1885 +hansen 1884 +raul 1884 +relentless 1884 +viable 1884 +wen 1884 +limitations 1883 +marley 1883 +scholars 1883 +shelves 1883 +criticize 1882 +forrest 1882 +funerals 1882 +jacqueline 1882 +shaun 1882 +balanced 1881 +bookstore 1881 +continental 1881 +ramsey 1881 +theta 1881 +workplace 1881 +bankers 1880 +clause 1880 +dixie 1880 +dumbass 1880 +superstitious 1880 +assignments 1879 +bennet 1879 +councilman 1879 +fudge 1879 +slogan 1879 +boarded 1878 +quadrant 1878 +trinity 1878 +builder 1877 +composer 1877 +covert 1877 +dire 1877 +gears 1877 +initiated 1877 +insignificant 1877 +ivory 1877 +mccarthy 1877 +velocity 1877 +applaud 1876 +comply 1876 +credentials 1876 +organizations 1876 +trend 1876 +chump 1875 +derrick 1875 +donations 1875 +providence 1875 +sparkle 1875 +allegedly 1874 +artifacts 1874 +deploy 1874 +flooding 1874 +howl 1874 +ladyship 1874 +maintained 1874 +shaolin 1874 +showtime 1874 +snowing 1874 +deposition 1873 +fractures 1873 +handshake 1873 +thornton 1873 +adapted 1872 +bess 1872 +cyber 1872 +neglect 1872 +patrols 1872 +hetty 1871 +protocols 1871 +appoint 1870 +headphones 1870 +identities 1870 +overdue 1870 +slayer 1870 +aging 1869 +pitching 1869 +rotation 1869 +solely 1869 +tasha 1869 +examining 1868 +freshen 1868 +memorize 1868 +viewing 1868 +mango 1867 +sonar 1867 +valued 1867 +crichton 1865 +decay 1865 +eu 1865 +miniature 1865 +nightfall 1865 +olives 1865 +omen 1865 +bai 1864 +chum 1864 +kristin 1864 +posse 1864 +what-what 1864 +mutiny 1863 +toaster 1863 +blames 1862 +jumbo 1862 +mystical 1862 +researchers 1862 +span 1862 +disgusted 1861 +dung 1861 +exposing 1861 +frat 1861 +lifts 1861 +waterfall 1861 +welsh 1861 +windshield 1861 +yikes 1861 +akira 1860 +antenna 1860 +bodyguards 1860 +maintaining 1860 +plantation 1860 +receives 1860 +allergies 1859 +anya 1859 +comparing 1859 +edit 1859 +havana 1859 +invites 1859 +superficial 1859 +cartoons 1858 +dirk 1858 +mexicans 1858 +supervision 1858 +timber 1858 +siberia 1857 +francois 1856 +hiccup 1856 +intensity 1856 +johan 1856 +rust 1856 +winding 1856 +confrontation 1855 +flyers 1855 +jarvis 1855 +jogging 1855 +trustworthy 1855 +adios 1854 +coaster 1854 +mosquitoes 1854 +paragraph 1854 +amuse 1853 +lovin 1853 +russo 1853 +unexpectedly 1853 +bundy 1852 +dominate 1852 +howie 1852 +lorry 1852 +soviets 1852 +splashing 1852 +failures 1851 +jp 1851 +mouthing 1851 +screens 1851 +unreal 1851 +ziggy 1851 +bending 1850 +commanded 1850 +gallant 1850 +recon 1850 +representation 1850 +satisfying 1850 +assign 1849 +cheater 1849 +pornography 1849 +urn 1849 +auspicious 1848 +tripping 1848 +uncles 1848 +vine 1848 +mellow 1847 +seafood 1847 +ac 1846 +announcing 1846 +hawaiian 1846 +resumes 1846 +adultery 1845 +centers 1845 +lotion 1844 +scatter 1844 +dominated 1843 +opposing 1843 +testicles 1842 +transmit 1842 +unpack 1842 +violate 1842 +zhao 1841 +ames 1840 +albums 1839 +gavel 1839 +hungary 1839 +kayla 1839 +porridge 1839 +prem 1839 +well-being 1839 +eliot 1838 +rattles 1838 +titties 1838 +strengthen 1837 +fitzgerald 1836 +removal 1836 +sensitivity 1836 +tamara 1836 +accepts 1835 +crowns 1835 +downloaded 1835 +hive 1835 +morocco 1835 +nerds 1835 +radha 1835 +sacks 1835 +saddam 1835 +sniffling 1835 +dew 1834 +ing 1834 +mt 1834 +outdoor 1834 +raquel 1834 +currents 1833 +explanations 1833 +soaking 1833 +tanaka 1833 +joins 1832 +navigation 1832 +vitals 1832 +aa 1831 +anthem 1831 +everett 1831 +harp 1831 +intrigued 1831 +kylie 1831 +margin 1831 +amazingly 1830 +distracting 1830 +fertility 1830 +horrific 1829 +indictment 1829 +shifted 1829 +sincerity 1829 +superstition 1829 +ultrasound 1829 +davy 1828 +hamburg 1828 +trashed 1828 +casper 1827 +digest 1827 +dumplings 1827 +whacked 1827 +bethany 1826 +casualty 1826 +jacks 1826 +kiddin 1826 +proportion 1825 +thump 1825 +deceiving 1824 +janine 1824 +quantity 1824 +corrupted 1823 +hikaru 1823 +sdh 1823 +weave 1823 +afar 1822 +budge 1822 +fraction 1822 +gallows 1822 +hiroshi 1820 +landlady 1820 +leela 1820 +measurements 1820 +certified 1819 +dusk 1819 +insisting 1819 +luisa 1819 +hawkeye 1818 +hilton 1818 +indoors 1818 +homey 1817 +saliva 1817 +whispered 1817 +accounted 1816 +cyanide 1816 +diver 1816 +marched 1816 +maura 1816 +abandoning 1815 +awaken 1815 +blossoms 1815 +eligible 1815 +messes 1815 +robbins 1815 +wiggle 1815 +cid 1814 +stained 1814 +ballard 1813 +elevated 1813 +ryo 1813 +transparent 1813 +aria 1812 +hwa 1812 +strokes 1812 +unclear 1812 +corinne 1811 +regarded 1811 +sidekick 1811 +solidarity 1811 +starter 1811 +bradford 1810 +godzilla 1810 +vulcan 1810 +belgian 1809 +cling 1809 +seemingly 1809 +components 1808 +vibrates 1808 +constance 1807 +missouri 1807 +batting 1806 +chanel 1806 +marbles 1806 +sap 1806 +shifting 1806 +wiping 1806 +famine 1805 +puzzles 1805 +refill 1805 +sneeze 1805 +platinum 1804 +provisions 1804 +shite 1804 +syringe 1804 +walkin 1804 +consumer 1803 +electromagnetic 1803 +exhaust 1803 +oyster 1803 +preferably 1803 +barnaby 1802 +catastrophic 1802 +lasagna 1802 +winchester 1802 +evacuated 1801 +serge 1801 +woah 1801 +describes 1800 +merger 1800 +muck 1800 +relic 1800 +buds 1799 +deposits 1799 +enlisted 1799 +socially 1799 +archbishop 1798 +climax 1798 +philippines 1798 +smuggle 1798 +progressive 1797 +wheezing 1797 +fearful 1796 +pillar 1796 +saucer 1796 +singles 1796 +spade 1796 +addicts 1795 +craving 1795 +ratio 1795 +clifford 1794 +beauties 1793 +nashville 1793 +orson 1793 +largely 1792 +obscure 1792 +rubble 1792 +signora 1792 +troublesome 1792 +admiration 1791 +ahn 1791 +fong 1791 +irina 1791 +brutality 1790 +flap 1790 +lizzy 1790 +anguish 1789 +brats 1789 +mariana 1789 +prevail 1789 +tattooed 1789 +whim 1789 +leaks 1788 +nada 1788 +colonial 1787 +cum 1787 +cupid 1787 +hana 1787 +emptied 1786 +frau 1786 +perpetrator 1786 +speeds 1786 +watermelon 1786 +athena 1785 +rooftop 1785 +sponsored 1785 +extraction 1784 +managers 1784 +rhino 1784 +stat 1784 +colombia 1783 +diarrhea 1783 +disregard 1783 +goggles 1783 +warmed 1783 +fitness 1782 +struggles 1782 +scalp 1781 +suitcases 1781 +veterans 1781 +caress 1780 +kensi 1780 +watchman 1780 +aura 1779 +devlin 1779 +disciple 1779 +exclusively 1779 +misty 1779 +adored 1778 +buckets 1778 +crockett 1778 +helm 1778 +inhale 1778 +hedge 1777 +kari 1777 +revoir 1777 +shillings 1777 +tents 1777 +breaker 1776 +digger 1776 +meatballs 1776 +snorting 1776 +suppress 1776 +viper 1776 +witty 1776 +encouragement 1775 +goddamned 1775 +intercepted 1775 +shogun 1775 +wonderfully 1775 +abbot 1774 +cleanse 1774 +competitor 1774 +posting 1774 +sizzling 1774 +trenches 1774 +lyon 1773 +mustang 1773 +stewie 1773 +kg 1772 +reflected 1772 +rested 1772 +habitat 1771 +molecular 1771 +obnoxious 1771 +stand-up 1771 +sarcasm 1770 +tung 1770 +wraith 1770 +blondie 1769 +exaggerated 1769 +goon 1769 +pulp 1769 +refund 1769 +renowned 1769 +tofu 1769 +wildly 1769 +antony 1768 +chateau 1768 +embedded 1768 +harlan 1768 +provoked 1768 +alfie 1767 +cages 1767 +guido 1767 +lennox 1767 +nile 1767 +resemble 1767 +skates 1767 +sticker 1767 +characteristics 1766 +fe 1766 +isle 1766 +unstoppable 1766 +veal 1766 +ambushed 1765 +cold-blooded 1765 +deployed 1765 +earthquakes 1765 +reaper 1765 +zap 1765 +biblical 1764 +puck 1764 +aya 1763 +gaby 1763 +intends 1763 +mystic 1763 +muffins 1762 +republicans 1762 +speedy 1762 +bolts 1761 +enrique 1761 +hoax 1761 +intersection 1761 +presently 1761 +raided 1761 +shuffle 1761 +supplier 1761 +brighton 1760 +distinctive 1760 +edited 1760 +outdoors 1760 +rubbed 1760 +upcoming 1760 +vegan 1760 +glitter 1759 +granger 1759 +oracle 1759 +peeing 1759 +wagons 1759 +amulet 1758 +suspend 1758 +convertible 1757 +guild 1757 +squared 1757 +battling 1755 +steward 1755 +gateway 1754 +rites 1754 +weston 1754 +distributed 1753 +fragment 1753 +jt 1753 +pavement 1753 +projector 1753 +rugby 1753 +baldwin 1752 +obscene 1752 +raping 1752 +zebra 1752 +analyst 1751 +elbows 1751 +eta 1751 +detonator 1750 +disneyland 1750 +earthly 1750 +modesty 1750 +rails 1750 +assess 1749 +librarian 1749 +moran 1749 +vinci 1749 +bribes 1748 +crowley 1748 +gravitational 1748 +homie 1748 +slumber 1748 +suffice 1748 +aces 1747 +sodium 1747 +canary 1746 +credible 1746 +declined 1746 +eyesight 1746 +hubert 1746 +trajectory 1746 +developments 1745 +duplicate 1745 +jug 1745 +capitalism 1744 +crates 1744 +defenses 1744 +polished 1744 +xi 1744 +chaotic 1743 +differ 1743 +enzo 1743 +fences 1743 +hodgins 1743 +gardening 1742 +gram 1742 +forecast 1741 +ti 1741 +tinker 1741 +macy 1740 +pinched 1740 +plunge 1740 +tangled 1740 +arturo 1739 +bulletproof 1739 +firewood 1739 +grover 1739 +module 1739 +novak 1739 +overly 1739 +pizzas 1739 +retail 1739 +appalling 1738 +felicia 1738 +rows 1738 +concentrating 1737 +growl 1737 +mash 1737 +transferring 1737 +braces 1736 +livestock 1736 +rewards 1736 +sliced 1736 +dictate 1735 +gunman 1735 +recruiting 1735 +users 1735 +bathrooms 1734 +linus 1734 +nobles 1734 +organism 1734 +picky 1734 +platter 1734 +vultures 1734 +crackles 1733 +manufacturing 1733 +mitzvah 1733 +proudly 1733 +pup 1733 +scarecrow 1733 +sweeter 1733 +cara 1732 +frying 1732 +genetically 1732 +interpreter 1732 +ledge 1732 +leigh 1732 +redeem 1732 +charleston 1731 +qing 1731 +tyranny 1731 +cutest 1730 +descendants 1730 +nomination 1730 +rosy 1730 +sabine 1730 +skins 1730 +textbook 1730 +dubois 1729 +finland 1729 +hast 1729 +loretta 1729 +pioneer 1729 +saunders 1729 +unsolved 1729 +angelica 1728 +cesare 1728 +hugged 1728 +ramirez 1728 +suzie 1728 +throttle 1728 +allergy 1727 +carving 1727 +powered 1727 +thrive 1727 +icu 1726 +pong 1726 +squares 1726 +suzy 1726 +accordingly 1725 +alphabet 1725 +internship 1725 +maternity 1725 +morty 1725 +scarce 1725 +sect 1725 +threesome 1725 +nellie 1724 +sirs 1724 +tones 1724 +unwell 1724 +anticipate 1723 +import 1723 +mohammed 1723 +temporal 1723 +democrats 1722 +pfft 1722 +spree 1722 +terminated 1722 +ami 1721 +futile 1721 +interviewer 1721 +respectfully 1721 +dictator 1720 +stuffy 1720 +fasten 1719 +giggle 1719 +grandchild 1719 +mammals 1719 +neptune 1719 +wai 1719 +itching 1718 +persian 1718 +pharmaceutical 1718 +tactic 1718 +austrian 1717 +jiang 1717 +observer 1717 +tesla 1717 +thorne 1717 +wallpaper 1717 +output 1716 +giorgio 1715 +goliath 1715 +joyful 1715 +spouse 1715 +trophies 1715 +hobbies 1714 +naming 1714 +rightly 1714 +stale 1714 +teamwork 1714 +vito 1714 +yates 1714 +obedient 1713 +switches 1713 +boner 1712 +dioxide 1712 +rating 1712 +raving 1712 +regulation 1712 +bach 1711 +bc 1711 +construct 1711 +espresso 1711 +implant 1711 +mari 1711 +satisfactory 1711 +sleeper 1711 +anjali 1710 +coordinate 1710 +munch 1710 +rations 1710 +backseat 1709 +dominique 1709 +stag 1709 +analyzed 1708 +chrissy 1708 +reschedule 1708 +bono 1707 +princeton 1707 +sorority 1707 +uphold 1707 +crumbs 1706 +inconvenient 1706 +confidentiality 1705 +embraced 1705 +overruled 1705 +pipeline 1705 +reconstruction 1705 +reunited 1705 +comfortably 1704 +enlightenment 1704 +mortals 1704 +puddle 1704 +misha 1703 +vlad 1703 +swam 1702 +ukraine 1702 +annette 1701 +demise 1701 +demonic 1701 +donatello 1701 +sora 1701 +barefoot 1700 +beak 1700 +fungus 1700 +skeletons 1700 +tacos 1700 +fanfare 1699 +felipe 1699 +maryland 1699 +drastic 1698 +lemonis 1698 +mascot 1698 +picard 1698 +intrude 1697 +presidents 1697 +proceeding 1697 +stiles 1697 +sums 1697 +advertisement 1696 +deprived 1696 +farting 1696 +adjustment 1695 +blanks 1695 +charade 1695 +foremost 1695 +puncture 1695 +spank 1695 +stig 1695 +straightened 1695 +abs 1694 +booing 1694 +minimal 1694 +utah 1694 +flashes 1693 +gals 1693 +andrei 1692 +greenhouse 1692 +marx 1692 +requirements 1692 +canoe 1691 +comprehend 1691 +vermont 1691 +arsehole 1690 +baton 1690 +graduating 1690 +intimidating 1689 +kinky 1689 +microscope 1689 +partially 1689 +sensing 1689 +titans 1689 +babu 1688 +geese 1688 +hardship 1688 +ren 1688 +territories 1688 +vaguely 1688 +doubles 1687 +exceptions 1687 +florrick 1687 +gramps 1687 +jing 1687 +karin 1687 +knitting 1687 +administrator 1686 +blacksmith 1686 +correspondence 1686 +ctu 1686 +fashionable 1686 +slowed 1686 +tolling 1686 +arcade 1685 +gestures 1685 +lonnie 1685 +matron 1685 +overlooked 1685 +siobhan 1685 +lockhart 1684 +outnumbered 1684 +quits 1684 +mc 1683 +terminate 1683 +verbal 1683 +chirps 1682 +hosts 1682 +presidency 1682 +roberta 1682 +rye 1682 +devote 1681 +dumps 1681 +hallo 1680 +improvise 1680 +monique 1680 +pooh 1680 +starship 1680 +cawing 1679 +fumes 1679 +spades 1679 +aluminum 1678 +drafted 1678 +everest 1678 +michaels 1678 +squeak 1678 +ssh 1678 +candace 1677 +cappuccino 1677 +commonwealth 1677 +cucumber 1677 +favours 1676 +folder 1676 +inventor 1676 +peppers 1676 +slamming 1676 +appeals 1675 +reasoning 1675 +walton 1675 +adequate 1674 +glued 1674 +godmother 1674 +hangar 1674 +ifs 1674 +nets 1674 +snickers 1674 +empty-handed 1673 +foolishness 1673 +forum 1673 +joys 1673 +crest 1672 +sybil 1672 +bleach 1671 +disastrous 1671 +grown-ups 1671 +horrors 1671 +peers 1671 +playful 1671 +coronation 1670 +glamour 1670 +halo 1670 +lineup 1670 +errors 1669 +highlight 1669 +ponies 1669 +sookie 1669 +viv 1669 +ziva 1669 +alvarez 1668 +arabia 1668 +imperative 1668 +pluto 1668 +trader 1668 +weights 1668 +drugstore 1667 +galileo 1667 +lenses 1667 +obstruction 1667 +tainted 1667 +envious 1666 +gertrude 1666 +icon 1666 +insured 1666 +nikolai 1666 +overlook 1666 +segment 1666 +twisting 1666 +camilla 1665 +commentator 1665 +pitt 1665 +rollin 1665 +cerebral 1664 +cushion 1664 +frames 1664 +qin 1664 +spoiling 1664 +tsar 1664 +zelda 1664 +angelina 1663 +cavity 1663 +chute 1663 +divers 1663 +hallowed 1663 +reds 1663 +sahib 1663 +strung 1663 +volcanoes 1663 +monopoly 1662 +watts 1662 +back-up 1661 +bros 1661 +immigrant 1661 +apache 1660 +caffeine 1660 +encounters 1660 +postponed 1660 +squeezing 1660 +calmed 1659 +opener 1659 +rut 1659 +stuttering 1659 +baek 1658 +franchise 1658 +impotent 1658 +tabs 1658 +entity 1657 +hygiene 1657 +slimy 1657 +andi 1656 +containment 1656 +irregular 1656 +jumpy 1656 +knickers 1656 +pistols 1656 +anarchy 1655 +blackmailed 1655 +controller 1655 +dudley 1655 +hades 1655 +lowly 1655 +rung 1655 +wisely 1655 +borg 1654 +dramatically 1654 +researching 1654 +bullies 1653 +fay 1653 +seventy 1653 +touchy 1653 +abdul 1652 +divisions 1652 +divya 1652 +poll 1652 +stealth 1652 +yearbook 1652 +blinds 1651 +sneezes 1651 +bigfoot 1650 +cockpit 1650 +cramp 1650 +dharma 1650 +ninety 1650 +truthful 1650 +edwin 1649 +ghastly 1649 +hangin 1649 +lilith 1649 +whines 1649 +cola 1648 +corrections 1648 +infinity 1648 +missionary 1648 +moist 1648 +penance 1648 +founding 1647 +investor 1647 +krystle 1647 +sham 1647 +caldwell 1646 +orientation 1646 +captains 1645 +columbo 1645 +flop 1645 +jamming 1645 +yoo-hoo 1645 +crossroads 1644 +infrared 1644 +pleading 1644 +carroll 1643 +flushed 1643 +magda 1643 +sven 1643 +component 1642 +gaming 1642 +gases 1642 +gurgling 1642 +prairie 1642 +saudi 1642 +slapping 1642 +vinny 1642 +abusive 1641 +aching 1641 +bracelets 1641 +lau 1641 +rift 1641 +tyre 1641 +vermin 1641 +wiring 1641 +abstract 1640 +gauge 1640 +lifelong 1640 +robby 1640 +johnnie 1639 +mythology 1639 +pilgrimage 1639 +theoretically 1639 +android 1638 +iq 1638 +licked 1638 +photographers 1638 +pianist 1638 +render 1638 +rhythmic 1638 +rocked 1638 +wilma 1638 +clover 1637 +feat 1637 +salvador 1637 +troupe 1637 +unwanted 1637 +drying 1636 +preposterous 1636 +primarily 1636 +raoul 1636 +unseen 1636 +constantine 1635 +functional 1635 +git 1635 +outlet 1635 +pictured 1635 +vulture 1635 +cc 1634 +nitrogen 1634 +lest 1633 +right-hand 1633 +speechless 1633 +delusions 1632 +follow-up 1632 +peeping 1632 +refrain 1632 +accountable 1631 +engaging 1631 +jerking 1631 +ch 1630 +intro 1630 +orgy 1630 +piles 1630 +rescuing 1630 +zane 1630 +jaguar 1629 +prue 1629 +abusing 1628 +charitable 1628 +concerts 1628 +fatigue 1628 +schemes 1628 +taj 1628 +attracts 1627 +lili 1627 +stirred 1627 +gras 1626 +heartbroken 1626 +invaders 1626 +kittens 1626 +riggs 1626 +wench 1626 +amar 1625 +suffocating 1625 +underage 1625 +youse 1625 +bugged 1624 +dimitri 1624 +klinger 1624 +cds 1623 +hamster 1623 +metropolitan 1623 +omelet 1623 +unions 1623 +worf 1623 +grad 1622 +grub 1622 +humility 1622 +obedience 1622 +conway 1621 +lassie 1621 +posed 1621 +resentment 1621 +victorian 1621 +aunts 1620 +booming 1620 +dat 1620 +didi 1620 +locke 1620 +brigadier 1619 +hedgehog 1619 +intrusion 1619 +recipes 1619 +shay 1619 +tacky 1619 +admiring 1618 +anyplace 1618 +hk 1618 +physicist 1618 +saviour 1618 +yonder 1618 +avengers 1617 +forfeit 1617 +lullaby 1617 +oasis 1617 +unleash 1617 +boxers 1616 +indifferent 1616 +truthfully 1616 +actresses 1615 +arranging 1615 +grin 1615 +organise 1615 +hydra 1614 +pearson 1614 +shutters 1614 +voila 1614 +aloud 1613 +daria 1613 +energetic 1613 +genesis 1613 +henrik 1613 +postal 1613 +archive 1612 +constitutional 1612 +ernst 1612 +funniest 1612 +hmph 1612 +muse 1612 +nauseous 1612 +cloudy 1611 +covenant 1611 +olivier 1611 +prestige 1611 +recital 1611 +astray 1610 +decks 1610 +gunnar 1610 +helper 1610 +pencils 1610 +casing 1609 +cooing 1609 +ip 1609 +leland 1609 +ozzy 1609 +sterile 1609 +that-that 1609 +bagel 1608 +intimidate 1608 +lorelai 1608 +recognizes 1608 +releases 1608 +forgery 1607 +kenya 1607 +bred 1606 +dunn 1606 +huck 1606 +vector 1606 +detonate 1605 +horsepower 1605 +inhuman 1605 +lenin 1605 +nausea 1605 +sakura 1605 +violating 1605 +hooting 1604 +informer 1604 +oriental 1604 +pascal 1604 +plugged 1604 +pretends 1604 +chaplain 1603 +farce 1603 +parasites 1603 +reyes 1603 +spoils 1603 +tram 1603 +cruising 1602 +tile 1602 +violently 1602 +whipping 1602 +ashtray 1601 +donut 1601 +neighing 1601 +und 1601 +zones 1601 +abdominal 1600 +decoration 1600 +kelso 1600 +nobility 1600 +scoot 1600 +sharpe 1600 +spoilt 1600 +updated 1600 +blueprints 1599 +dealings 1599 +pitched 1599 +resource 1599 +coalition 1598 +hoss 1598 +professors 1598 +cheyenne 1597 +dishwasher 1597 +espionage 1597 +minded 1597 +barbarians 1596 +brewster 1596 +cons 1596 +loony 1596 +protesting 1596 +resulted 1596 +wrestler 1596 +dialect 1595 +kangaroo 1595 +rite 1595 +counsellor 1594 +incorrect 1594 +locket 1594 +parameters 1594 +pilgrims 1594 +risotto 1594 +sorcerer 1594 +tsunami 1594 +unreliable 1594 +agricultural 1593 +geniuses 1593 +marital 1593 +willard 1593 +hera 1592 +scramble 1592 +wilkes 1592 +aquarium 1591 +caste 1591 +flavors 1591 +roxanne 1591 +sanity 1591 +starvation 1591 +summons 1591 +swears 1591 +wedge 1591 +bedside 1590 +experimenting 1590 +tribunal 1590 +truths 1590 +automated 1589 +darlene 1589 +emerson 1589 +explorer 1589 +treachery 1589 +amused 1588 +dunk 1588 +fishes 1588 +greens 1588 +kappa 1588 +oleg 1588 +appreciates 1587 +commissioned 1587 +disclose 1587 +goblin 1587 +lark 1587 +politely 1587 +reassuring 1587 +seaside 1587 +folly 1586 +incense 1586 +pear 1586 +preoccupied 1586 +vikings 1586 +graceful 1585 +grande 1585 +irs 1585 +lavender 1585 +stretcher 1585 +basin 1584 +discussions 1584 +disobey 1584 +groovy 1584 +lasers 1584 +luncheon 1584 +sacrificing 1584 +simulation 1584 +aide 1583 +gruesome 1583 +mil 1583 +shen 1583 +sinful 1583 +snot 1583 +yearning 1583 +haha 1582 +masculine 1582 +banjo 1581 +blazing 1581 +camelot 1581 +crowned 1581 +dina 1581 +dos 1581 +extras 1581 +gonzalo 1581 +guides 1581 +hopper 1581 +mortimer 1581 +painkillers 1581 +cassette 1580 +cover-up 1580 +foley 1580 +misplaced 1580 +practising 1580 +rave 1580 +silvia 1580 +sorting 1580 +sprung 1580 +sylvie 1580 +designing 1579 +enterprises 1579 +howls 1579 +patti 1579 +prehistoric 1579 +suspense 1579 +japs 1578 +paints 1578 +relying 1578 +renounce 1578 +n-no 1577 +rendered 1577 +circumstantial 1576 +herald 1576 +investing 1576 +mugged 1576 +protestant 1576 +randomly 1576 +smear 1576 +spleen 1576 +stylish 1576 +artifact 1575 +cursing 1575 +erection 1575 +flavour 1575 +gravel 1575 +guiding 1575 +hollis 1575 +pantry 1575 +classics 1574 +raccoon 1574 +binding 1573 +delivers 1573 +enhance 1573 +enlighten 1573 +spinach 1573 +goldman 1572 +invalid 1572 +touring 1572 +fashioned 1571 +gilmore 1571 +johns 1571 +ledger 1571 +spilling 1571 +containers 1570 +frequencies 1570 +historian 1570 +majestic 1570 +marisa 1570 +meadows 1570 +quieter 1570 +zhou 1570 +aryan 1569 +diplomat 1569 +wrinkles 1569 +insulin 1568 +natalia 1568 +nationals 1568 +pagan 1568 +palestine 1568 +para 1568 +rudolph 1568 +subtitling 1568 +swimmer 1568 +torpedoes 1568 +accommodate 1567 +bygones 1567 +disgraced 1567 +intellect 1567 +adjusting 1566 +giraffe 1566 +peer 1566 +petersburg 1566 +posture 1566 +audiences 1565 +hammering 1565 +religions 1565 +gigs 1564 +ruben 1564 +biggie 1563 +chewed 1563 +colossal 1563 +damaging 1563 +dougie 1563 +emile 1563 +iceberg 1563 +pietro 1563 +renew 1563 +vigilante 1563 +bribed 1562 +cellular 1562 +dangerously 1562 +duane 1562 +indecent 1562 +rascals 1562 +chai 1561 +grammar 1561 +hutch 1561 +pager 1561 +ramona 1561 +runners 1561 +stumble 1561 +thrash 1561 +dispatcher 1560 +iast 1560 +stump 1560 +thankyou 1560 +wraps 1560 +disconnect 1559 +gator 1559 +skye 1559 +teammates 1559 +weakest 1559 +apiece 1558 +cooperating 1558 +distinguish 1558 +feud 1558 +melts 1558 +morally 1558 +passions 1558 +strive 1558 +theatrical 1558 +flourish 1557 +reeves 1556 +unnecessarily 1556 +accuracy 1555 +bitterness 1555 +cosmo 1555 +janie 1555 +perceive 1555 +upstate 1555 +circular 1554 +fundraiser 1554 +generated 1554 +insolent 1554 +reflects 1554 +strauss 1554 +manufacture 1553 +mingle 1553 +pilgrim 1553 +purge 1553 +rumble 1553 +squirrels 1553 +xander 1553 +casually 1552 +confinement 1552 +firemen 1552 +handler 1552 +hobbs 1552 +multiply 1552 +phd 1552 +slain 1552 +alfonso 1551 +divert 1551 +patches 1551 +adriana 1550 +ambrose 1550 +ia 1550 +mit 1550 +mustafa 1550 +numbered 1550 +protests 1550 +versions 1550 +youthful 1550 +damian 1549 +gunther 1549 +herbal 1549 +kathryn 1549 +lawful 1549 +reasonably 1549 +stacked 1549 +logged 1548 +myra 1548 +suzuki 1548 +alimony 1547 +mainstream 1547 +prospects 1547 +stating 1547 +sykes 1547 +budapest 1546 +awakened 1545 +curl 1545 +curses 1545 +nelly 1545 +ore 1545 +slices 1545 +uprising 1545 +badges 1544 +boulder 1544 +cabaret 1544 +manpower 1544 +manufacturer 1544 +mush 1544 +ration 1544 +disarm 1543 +elusive 1543 +recycling 1543 +snarls 1543 +chiu 1542 +considers 1542 +flawless 1542 +harmful 1542 +youre 1542 +admits 1541 +helene 1541 +horseback 1541 +morales 1541 +owens 1541 +esteemed 1540 +ethnic 1540 +kono 1540 +navigate 1540 +pun 1540 +recreate 1540 +thankfully 1540 +travelers 1540 +voice-over 1540 +dissolve 1539 +duffy 1539 +exterior 1539 +gems 1539 +iraqi 1539 +licensed 1539 +moira 1539 +mutants 1539 +robes 1539 +testifying 1539 +triad 1539 +booby 1538 +knack 1538 +lodged 1538 +nbc 1538 +portfolio 1538 +pussycat 1538 +simms 1538 +tossing 1538 +wildest 1538 +cpr 1537 +lei 1537 +mayo 1537 +nato 1537 +racism 1537 +gillian 1536 +stingy 1536 +continents 1535 +kaiser 1535 +napkins 1535 +registry 1535 +shooters 1535 +blinking 1534 +duo 1534 +fags 1534 +gymnastics 1534 +jag 1534 +shah 1534 +attachment 1533 +bernadette 1533 +exploited 1533 +valiant 1533 +administrative 1532 +crave 1532 +crispy 1532 +hawks 1532 +intruders 1532 +mega 1532 +owning 1532 +persuasive 1532 +qu 1532 +reviewed 1532 +trans 1532 +bette 1531 +elevators 1531 +fulfilling 1531 +promoting 1531 +ronny 1531 +shoving 1531 +turmoil 1531 +unworthy 1531 +irma 1530 +mcqueen 1530 +rooting 1530 +stoop 1530 +demonstrated 1529 +ee 1529 +greer 1529 +panels 1529 +panicking 1529 +berkeley 1528 +cheats 1528 +chord 1528 +mattie 1528 +myths 1528 +oblige 1528 +volleyball 1528 +anticipation 1527 +aroused 1527 +daleks 1527 +fibers 1527 +radiant 1527 +ferocious 1526 +heinous 1526 +paralysis 1526 +compassionate 1525 +emergencies 1525 +enjoyable 1525 +olaf 1525 +roulette 1525 +torso 1525 +delegation 1524 +softer 1524 +alonso 1523 +fangs 1523 +moi 1523 +plaque 1523 +bowman 1522 +buggy 1522 +denies 1522 +kristina 1522 +neighs 1522 +tally 1522 +tibet 1522 +unusually 1522 +uterus 1522 +charcoal 1520 +fez 1520 +healer 1520 +weirdest 1520 +camouflage 1519 +eel 1519 +hub 1519 +mailman 1519 +naw 1519 +recordings 1519 +seagulls 1519 +shoulda 1519 +tamil 1519 +buckley 1518 +furry 1518 +pronto 1518 +amir 1517 +edible 1517 +kermit 1517 +sedan 1517 +trifle 1517 +calcutta 1516 +colon 1516 +crotch 1516 +cubes 1516 +delusion 1516 +foundations 1516 +costing 1515 +leila 1515 +resisted 1515 +snail 1515 +strippers 1515 +tariq 1515 +ursula 1515 +fiend 1514 +sorta 1514 +ultra 1514 +vista 1514 +pinkie 1513 +restrain 1513 +voluntary 1513 +widely 1513 +blackie 1512 +eiffel 1512 +erika 1512 +farrell 1512 +infiltrate 1512 +medallion 1512 +radios 1512 +ringo 1512 +sophomore 1512 +uncommon 1512 +bailiff 1511 +dyke 1511 +wilder 1511 +yamato 1511 +assassinated 1510 +bonnet 1510 +emilia 1510 +emptiness 1510 +helmets 1510 +normandy 1510 +peril 1510 +plaintiff 1510 +transmitted 1510 +cheetah 1509 +clips 1509 +saga 1509 +thicker 1509 +whiz 1509 +accord 1508 +bawk 1508 +binoculars 1508 +birmingham 1508 +bullock 1508 +diva 1508 +duckman 1508 +jeanette 1508 +pick-up 1508 +clams 1507 +concierge 1507 +questionable 1507 +sita 1507 +volunteering 1507 +brook 1506 +hereafter 1506 +lunches 1506 +pinpoint 1506 +spooked 1506 +verses 1506 +weakened 1506 +cleans 1505 +deposited 1505 +ironically 1505 +marshals 1505 +bodily 1504 +civic 1504 +distances 1504 +dominion 1504 +hypnosis 1504 +secretive 1504 +tori 1504 +bursts 1503 +cello 1503 +sponsors 1503 +streams 1503 +tickles 1503 +tights 1503 +uncertainty 1503 +vortex 1503 +ab 1502 +disability 1502 +griffith 1502 +herring 1502 +hostility 1502 +orchid 1502 +conflicts 1501 +housekeeping 1501 +interrogated 1501 +pans 1501 +panthers 1501 +ruiz 1501 +wonderland 1501 +andromeda 1500 +broccoli 1499 +garfield 1499 +mussolini 1499 +pottery 1499 +premium 1499 +shrapnel 1499 +giuseppe 1498 +jacked 1498 +planner 1498 +robyn 1498 +rounded 1498 +cactus 1497 +defendants 1497 +jia 1497 +motherland 1497 +wilt 1497 +belinda 1496 +executives 1496 +mnh-mnh 1496 +spoons 1496 +unbelievably 1496 +catcher 1495 +continuously 1495 +fairness 1495 +monarch 1495 +narrative 1495 +organisms 1495 +regretted 1495 +roth 1495 +spices 1495 +alejandro 1494 +beginnings 1494 +bonded 1494 +controversy 1494 +dictatorship 1494 +dignified 1494 +josef 1494 +manu 1494 +peaks 1494 +tilt 1494 +brownies 1493 +dell 1493 +diversity 1493 +jeopardize 1493 +osborne 1493 +overdo 1493 +pretentious 1493 +shabby 1493 +sharma 1493 +tissues 1493 +crunching 1492 +harmon 1492 +horrid 1492 +marnie 1492 +overrated 1492 +rethink 1492 +separating 1492 +tel 1492 +eww 1491 +mastered 1491 +reopen 1491 +camels 1490 +denis 1490 +depended 1490 +tak 1490 +tux 1490 +whine 1490 +axis 1489 +cherries 1489 +craziest 1489 +orchard 1489 +pleasing 1489 +rods 1489 +unforgivable 1489 +bubbling 1488 +distressed 1488 +interact 1488 +manipulating 1488 +blacked 1487 +burgundy 1487 +caucasian 1487 +flushing 1487 +onwards 1487 +participation 1487 +reservoir 1487 +rewind 1487 +trillion 1487 +lao 1486 +manhood 1486 +serbian 1486 +dinah 1485 +exhale 1485 +in-laws 1485 +matilda 1485 +blooming 1484 +caramel 1484 +drunkard 1484 +identifying 1484 +one-on-one 1484 +rinse 1484 +slander 1484 +stocking 1484 +artwork 1483 +catholics 1483 +cor 1483 +elisa 1483 +refresh 1483 +stormy 1483 +afghan 1482 +backside 1482 +chilling 1482 +congo 1482 +expanded 1482 +floats 1482 +antarctica 1481 +investigative 1481 +sandals 1481 +searches 1481 +wills 1481 +yawns 1481 +collaboration 1480 +hussein 1480 +psyched 1480 +untouched 1480 +bummed 1479 +compatible 1479 +departments 1479 +finer 1479 +heinrich 1479 +mak 1479 +manure 1479 +moor 1479 +tuxedo 1479 +dona 1478 +ids 1478 +keg 1478 +monsignor 1478 +peeled 1478 +seeker 1478 +stings 1478 +watering 1478 +aj 1477 +lending 1477 +malaria 1477 +sinatra 1477 +walters 1477 +achievements 1476 +braun 1476 +dodgers 1476 +excellence 1476 +fortnight 1476 +meddling 1476 +overthrow 1476 +postmortem 1476 +reassure 1476 +coppers 1475 +dickens 1475 +latch 1475 +raids 1475 +drifted 1474 +leeds 1474 +implants 1473 +lynette 1473 +midday 1473 +oatmeal 1473 +obsessive 1473 +unmarried 1473 +upwards 1473 +bowie 1472 +copying 1472 +freya 1472 +helga 1472 +mercenary 1472 +murat 1472 +outrun 1472 +perks 1472 +sewers 1472 +upload 1472 +shu 1471 +bumping 1470 +humbly 1470 +injections 1470 +inland 1470 +yorkshire 1470 +cockroaches 1469 +laurent 1469 +participating 1469 +python 1469 +crumble 1468 +puberty 1468 +technologies 1468 +clot 1467 +exterminate 1467 +high-school 1467 +miki 1467 +rake 1467 +roscoe 1467 +toots 1467 +augustus 1466 +bottled 1466 +charlene 1466 +cholesterol 1466 +deli 1466 +fung 1466 +sublime 1466 +the-the 1466 +aches 1465 +ahoy 1465 +appendix 1465 +dashing 1465 +demolition 1465 +distractions 1465 +methane 1465 +plank 1465 +chevy 1464 +invading 1464 +makers 1464 +medications 1464 +zurich 1464 +comm 1463 +commando 1463 +grissom 1463 +loner 1463 +mantle 1463 +miner 1463 +senile 1463 +broth 1462 +interaction 1462 +lsd 1462 +manufactured 1462 +auschwitz 1461 +sesame 1461 +devour 1460 +govern 1460 +orb 1460 +respiratory 1460 +restraint 1460 +sardines 1460 +zachary 1460 +consists 1459 +elope 1459 +lumber 1459 +webber 1459 +wreckage 1459 +alps 1458 +assassinate 1458 +edinburgh 1458 +faulty 1458 +needless 1458 +operates 1458 +santo 1458 +bologna 1457 +jillian 1457 +murmurs 1457 +staggering 1457 +unleashed 1457 +brushing 1456 +advancing 1455 +celeste 1455 +coffees 1455 +commonly 1455 +mentality 1455 +surgeries 1455 +trixie 1455 +cordelia 1454 +hammered 1454 +luciano 1454 +mastermind 1454 +meltdown 1454 +olsen 1454 +rayna 1454 +brow 1453 +burrito 1453 +galactic 1453 +muriel 1453 +strand 1453 +tedious 1453 +builders 1452 +encrypted 1452 +gill 1452 +judd 1452 +threads 1452 +wronged 1452 +collectors 1451 +cremated 1451 +disciplinary 1451 +ferdinand 1451 +handicap 1451 +prosper 1451 +sheppard 1451 +terri 1451 +alain 1450 +export 1450 +hardcore 1450 +hens 1450 +inventions 1450 +vowed 1450 +conceive 1449 +cromwell 1449 +kingdoms 1449 +reacting 1449 +snappy 1449 +tj 1449 +vocabulary 1449 +sling 1448 +transporting 1448 +checkmate 1447 +marius 1447 +murderous 1447 +patio 1447 +baptized 1446 +enable 1446 +hooves 1446 +jodie 1446 +payday 1446 +pranks 1446 +rebound 1446 +seizures 1446 +bravely 1445 +cyborg 1445 +delirious 1445 +gianni 1445 +huey 1445 +markings 1445 +seaweed 1445 +telephoned 1445 +widows 1445 +dibs 1444 +fielding 1444 +hologram 1444 +triumphant 1444 +washes 1444 +buckingham 1443 +hauling 1443 +lexi 1443 +processes 1443 +steadily 1443 +wilbur 1443 +acquitted 1442 +bey 1442 +buts 1442 +creed 1442 +kwon 1442 +meddle 1442 +mink 1442 +projection 1442 +scoundrels 1442 +blushing 1441 +close-up 1441 +faye 1441 +spectators 1441 +barlow 1440 +frigging 1440 +horseman 1440 +kato 1440 +mix-up 1440 +prescott 1440 +torches 1440 +avalanche 1439 +fret 1439 +marking 1439 +vacations 1439 +editorial 1438 +eyeball 1438 +fleas 1438 +glasgow 1438 +pacey 1438 +bonfire 1437 +insides 1437 +micah 1437 +trails 1437 +anu 1436 +bearer 1436 +urgency 1436 +capitalist 1435 +chaplin 1435 +clueless 1435 +conner 1435 +mercer 1435 +provincial 1435 +tempo 1435 +bumpy 1434 +cheeseburger 1434 +chime 1434 +didnt 1434 +harass 1434 +maternal 1434 +stomp 1434 +festive 1433 +fleeting 1433 +gags 1433 +geezer 1433 +mayhem 1433 +oppression 1433 +resembles 1433 +thorns 1433 +zodiac 1433 +brutus 1432 +cylinder 1432 +freelance 1432 +pouch 1432 +sato 1432 +scots 1432 +teri 1432 +whatnot 1432 +fossils 1431 +marko 1431 +twenty-four 1431 +brushed 1430 +crosby 1430 +dai 1430 +dax 1430 +disgraceful 1430 +encore 1430 +holed 1430 +ludwig 1430 +plausible 1430 +ceremonies 1429 +entertained 1429 +fascists 1429 +heads-up 1429 +impulses 1429 +mckenzie 1429 +refers 1429 +resulting 1429 +shatter 1429 +takeoff 1429 +lama 1428 +rin 1428 +specialists 1428 +a-and 1427 +phenomena 1427 +preventing 1427 +affectionate 1426 +blindfold 1426 +contributed 1426 +precedent 1426 +raf 1426 +schmuck 1426 +bambi 1425 +carefree 1425 +gao 1425 +hatchet 1425 +offscreen 1425 +statute 1425 +bowel 1424 +combo 1424 +coping 1424 +dialed 1424 +endurance 1424 +hallucinating 1424 +hypothetically 1424 +jana 1424 +mayonnaise 1424 +penitentiary 1424 +pollen 1424 +tam 1424 +vineyard 1424 +virtues 1424 +advertise 1423 +digits 1423 +evolutionary 1423 +francesco 1423 +kabir 1423 +myers 1423 +ribbons 1423 +barbarian 1422 +brewing 1422 +disrupt 1422 +edgy 1422 +girlie 1422 +parental 1422 +rao 1422 +regained 1422 +saline 1422 +tat 1422 +candice 1421 +high-end 1421 +homesick 1421 +implications 1421 +scallops 1421 +unanimous 1421 +vibrations 1421 +casanova 1420 +stung 1420 +tormented 1420 +baptism 1419 +harassed 1419 +kwan 1419 +leagues 1419 +steamed 1419 +thunderclap 1419 +wi-fi 1419 +bows 1418 +chimp 1418 +consulted 1418 +restoration 1418 +retribution 1418 +scripts 1418 +velma 1418 +adventurous 1417 +afterward 1417 +unforgettable 1417 +expectation 1416 +inevitably 1416 +ooh-ooh 1416 +swab 1416 +armand 1415 +carcass 1415 +lukas 1415 +notary 1415 +xu 1415 +alphahff 1414 +dazzling 1414 +perverted 1414 +snipers 1414 +spat 1414 +surrogate 1414 +capabilities 1413 +cartman 1413 +gains 1413 +lai 1413 +midwife 1413 +bleak 1412 +grumbling 1412 +houdini 1412 +inquiries 1412 +retainer 1412 +berger 1411 +expel 1411 +markers 1411 +reap 1411 +specs 1411 +adi 1410 +carole 1410 +coloured 1410 +deceit 1410 +degenerate 1410 +morn 1410 +petals 1410 +accurately 1409 +battered 1409 +darlings 1409 +dues 1409 +mound 1409 +patton 1409 +starbuck 1409 +tensed 1409 +withdrew 1409 +frenzy 1408 +nemesis 1408 +rocker 1408 +saturdays 1408 +senators 1408 +whiff 1408 +fearing 1407 +tagged 1407 +carey 1406 +fringe 1406 +peninsula 1406 +radiator 1406 +tolerated 1406 +clucking 1405 +reuben 1405 +specimens 1405 +stun 1405 +tor 1405 +accomplices 1404 +assailant 1404 +ke 1404 +mutation 1404 +somber 1404 +capturing 1403 +gaston 1403 +paramedic 1403 +reclaim 1403 +spun 1403 +straightaway 1403 +biopsy 1402 +colleges 1402 +doubting 1402 +funnier 1402 +proportions 1402 +theoretical 1402 +disasters 1401 +forwards 1401 +indefinitely 1401 +quo 1401 +sen 1401 +whee 1401 +addressing 1400 +iolaus 1400 +blackjack 1399 +felon 1399 +inflation 1399 +neon 1399 +pakistani 1399 +slurping 1399 +stallion 1399 +zordon 1399 +hijacked 1398 +irritated 1398 +jingling 1398 +macaroni 1398 +manipulation 1398 +milligrams 1398 +passive 1398 +pushy 1398 +strips 1398 +worldly 1398 +bulldog 1397 +checkpoint 1397 +folding 1397 +hateful 1397 +maxim 1397 +reduction 1397 +rigid 1397 +soothing 1397 +achilles 1396 +empathy 1396 +frontal 1396 +loaned 1396 +undermine 1396 +victories 1396 +overload 1395 +piercing 1395 +topanga 1395 +boils 1394 +bristol 1394 +crocodiles 1394 +detection 1394 +hypothetical 1394 +potent 1394 +snob 1394 +stamped 1394 +subjected 1394 +unharmed 1394 +coupons 1393 +culinary 1393 +mahjong 1393 +fleur 1392 +giddy 1392 +lira 1392 +niko 1392 +nonstop 1392 +seong 1392 +teachings 1392 +clinging 1391 +disposition 1391 +goodman 1391 +ramen 1391 +silenced 1391 +enforce 1390 +gunner 1390 +ploy 1390 +ragnar 1390 +suburban 1390 +catalog 1389 +cheerleaders 1389 +conception 1389 +imply 1389 +sleepover 1389 +slutty 1389 +werewolves 1389 +aaaah 1388 +biker 1388 +paranormal 1388 +shun 1388 +adviser 1387 +alerted 1387 +commune 1387 +granite 1387 +jellyfish 1387 +municipal 1387 +ruckus 1387 +defenseless 1386 +fargo 1386 +formalities 1386 +fr 1386 +innovation 1386 +paradox 1386 +proximity 1386 +unavailable 1386 +barrow 1385 +infrastructure 1385 +interns 1385 +koreans 1385 +taro 1385 +trooper 1385 +valleys 1385 +walden 1385 +bertha 1384 +declan 1384 +feminist 1384 +hovering 1384 +observations 1384 +proctor 1384 +prone 1384 +spins 1384 +ares 1383 +conversion 1383 +crazier 1383 +dustin 1383 +emerging 1383 +goldie 1383 +od 1383 +sheffield 1383 +blindly 1382 +brownie 1382 +bulbs 1382 +rabies 1382 +smurf 1382 +barriers 1381 +chastity 1381 +desirable 1381 +formerly 1381 +grains 1381 +hoops 1381 +considerably 1380 +anand 1379 +baptist 1379 +decorating 1379 +lahey 1379 +proposals 1379 +staging 1379 +baskets 1378 +diabetic 1378 +expressions 1378 +footprint 1378 +glacier 1378 +hiroshima 1378 +imitation 1378 +macbeth 1378 +muscular 1378 +rehabilitation 1378 +revolting 1378 +beaumont 1377 +commanders 1377 +confirming 1377 +displayed 1377 +jacobs 1377 +stupidest 1377 +unborn 1377 +dorian 1376 +enlightened 1376 +garment 1376 +hel 1376 +incision 1376 +indonesia 1376 +inquire 1376 +latter 1376 +ogre 1376 +plight 1376 +warnings 1376 +apb 1375 +butters 1375 +dci 1375 +hind 1375 +kisser 1375 +listener 1375 +prophets 1375 +rapping 1375 +substances 1375 +tampered 1375 +tripp 1375 +ulysses 1375 +dublin 1374 +meows 1374 +minding 1374 +mirage 1374 +prediction 1374 +preference 1374 +backbone 1373 +dodgy 1373 +drenched 1373 +sightings 1373 +begs 1372 +bessie 1372 +earns 1372 +enquiry 1372 +floods 1372 +goku 1372 +grizzly 1372 +juror 1372 +monitored 1372 +slob 1372 +strained 1372 +virtuous 1372 +brethren 1371 +contracted 1371 +oblivion 1371 +amnesty 1370 +bookie 1370 +cosmetics 1370 +cot 1370 +painters 1370 +mortar 1369 +adieu 1368 +correspondent 1368 +drunks 1368 +friction 1368 +gaga 1368 +mann 1368 +marlowe 1368 +outskirts 1368 +sparky 1368 +veer 1368 +wacky 1368 +wilfred 1368 +fulfil 1367 +hamburgers 1367 +informing 1367 +interruption 1367 +mediocre 1367 +swung 1367 +trina 1367 +disposed 1366 +pathologist 1366 +schedules 1366 +sinks 1366 +excluded 1365 +knit 1365 +marcie 1365 +scraps 1365 +sob 1365 +unhealthy 1365 +borgia 1364 +concubine 1364 +prestigious 1364 +rotate 1364 +sanjay 1364 +tracey 1364 +ballot 1363 +bugle 1363 +complexion 1363 +fischer 1363 +gurney 1363 +holocaust 1363 +hoot 1363 +humanitarian 1363 +ridden 1363 +sumo 1363 +whit 1363 +calcium 1362 +fabio 1362 +fern 1362 +forbes 1362 +hartley 1362 +io 1362 +salim 1362 +servers 1362 +sleepless 1362 +yao 1362 +frown 1361 +deranged 1360 +discomfort 1360 +exhaustion 1360 +legislation 1360 +one-night 1360 +rebuilt 1360 +sighting 1360 +bermuda 1359 +lacked 1359 +saigon 1359 +technological 1359 +tru 1359 +disperse 1358 +entrepreneur 1358 +hit-and-run 1358 +kosher 1358 +siu 1358 +tyra 1358 +wasrt 1358 +wept 1358 +worshipped 1358 +connors 1357 +drinker 1357 +flanders 1357 +long-distance 1357 +melbourne 1357 +romanian 1357 +unauthorized 1357 +europeans 1356 +hedley 1356 +hotshot 1356 +joaquin 1356 +myrtle 1356 +wyoming 1356 +cate 1355 +deputies 1355 +highlights 1355 +musashi 1355 +plateau 1355 +remy 1355 +ther 1355 +placement 1354 +quota 1354 +set-up 1354 +sordid 1354 +we-we 1354 +adjusted 1353 +apparatus 1353 +bartlett 1353 +curves 1353 +gringo 1353 +hormone 1353 +perceived 1353 +rockefeller 1353 +samaritan 1353 +tuning 1353 +vancouver 1353 +catalogue 1352 +escobar 1352 +gall 1352 +greeted 1352 +luckiest 1352 +thelma 1352 +vending 1352 +beverage 1351 +fellowship 1351 +housewives 1351 +scoring 1351 +thieving 1351 +fetus 1350 +genre 1350 +tidal 1350 +unfit 1350 +entrust 1349 +lanes 1349 +outlaws 1349 +oversight 1349 +reflex 1349 +rosalie 1349 +socialism 1349 +bev 1348 +certificates 1348 +exercising 1348 +goldberg 1348 +mercenaries 1348 +prosperous 1348 +symptom 1348 +vacate 1348 +detain 1347 +inquisition 1347 +larsen 1347 +roadblock 1347 +suffocate 1347 +discrimination 1346 +eater 1346 +flapping 1346 +paparazzi 1346 +prefect 1346 +qualifications 1346 +callahan 1345 +collects 1345 +hodges 1345 +incorporated 1345 +productions 1345 +publication 1345 +rowdy 1345 +strengths 1345 +winslow 1345 +astronomers 1344 +museums 1344 +nebraska 1344 +prevents 1344 +windsor 1344 +inscription 1343 +left-handed 1343 +lobe 1343 +outpost 1343 +poses 1343 +snuff 1343 +contributions 1342 +delilah 1342 +documented 1342 +luxurious 1342 +purgatory 1342 +showcase 1342 +hendrix 1341 +jurors 1341 +lefty 1341 +moisture 1341 +replica 1341 +unimportant 1341 +esteban 1340 +hampshire 1340 +horoscope 1340 +wondrous 1340 +commodore 1339 +departing 1339 +disagreement 1339 +humane 1339 +instruct 1339 +misguided 1339 +morbid 1339 +optimism 1339 +traders 1339 +calamity 1338 +callin 1338 +initiation 1338 +judicial 1338 +nectar 1338 +operatives 1338 +rink 1338 +swarm 1338 +annabelle 1337 +drumming 1337 +shithole 1337 +spartacus 1337 +bowels 1336 +density 1336 +denton 1336 +garth 1336 +iong 1336 +meself 1336 +researcher 1336 +romania 1336 +rowan 1336 +theorists 1336 +timid 1336 +trolley 1336 +earnings 1335 +fours 1335 +kel 1335 +righteousness 1335 +slaps 1335 +stalk 1335 +believers 1334 +bravest 1334 +confide 1334 +fencing 1334 +laurence 1334 +masturbate 1334 +painless 1334 +triggers 1334 +vale 1334 +wiener 1334 +dispatched 1333 +eruption 1333 +essex 1333 +eternally 1333 +mojo 1333 +ness 1333 +obligated 1333 +repulsive 1333 +billboard 1332 +crucified 1332 +customary 1332 +devised 1332 +donors 1332 +exiled 1332 +headlights 1332 +impeccable 1332 +oome 1332 +carr 1331 +costly 1331 +definitive 1331 +imitate 1331 +pavel 1331 +scolded 1331 +separates 1331 +simplicity 1331 +waved 1331 +kommandant 1330 +lakh 1330 +nichols 1330 +setback 1330 +bog 1329 +brawl 1329 +cdc 1329 +civilizations 1329 +clockwork 1329 +liters 1329 +pressures 1329 +relics 1329 +ventilation 1329 +chills 1328 +disable 1328 +gob 1328 +jars 1328 +phrases 1328 +pricks 1328 +pussies 1328 +rivera 1328 +tex 1328 +godfrey 1327 +lancaster 1327 +novelty 1327 +pickled 1327 +buffer 1326 +capone 1326 +castles 1326 +doubtful 1326 +equations 1326 +sexist 1326 +blueberry 1325 +goodwin 1325 +mm-mmm 1325 +oskar 1325 +reproduce 1325 +ryu 1325 +algeria 1324 +bahamas 1324 +bleating 1324 +dumbest 1324 +flo 1324 +geometry 1324 +horizontal 1324 +potty 1324 +assisted 1323 +fertilizer 1323 +indebted 1323 +spook 1323 +financing 1322 +helium 1322 +hinges 1322 +idaho 1322 +implied 1322 +imposed 1322 +restrictions 1322 +techno 1322 +zhu 1322 +analyzing 1321 +evident 1321 +gogh 1321 +implement 1321 +lucious 1321 +simplest 1321 +skank 1321 +supervise 1321 +traits 1321 +airfield 1320 +bargaining 1320 +coastal 1320 +drooling 1320 +matrimony 1320 +princesses 1320 +sensual 1320 +takashi 1320 +cycles 1319 +feeble 1319 +grimes 1319 +hutton 1319 +lennon 1319 +moreno 1319 +rugged 1319 +spikes 1319 +devious 1318 +elton 1318 +homage 1318 +mimics 1318 +postcards 1318 +potassium 1318 +reviewing 1318 +tombs 1318 +activist 1317 +conditioner 1317 +crimson 1317 +quoting 1317 +rowing 1317 +rulers 1317 +doses 1316 +filmmaker 1316 +outline 1316 +perjury 1316 +softball 1316 +envelopes 1315 +etiquette 1315 +intestines 1315 +joyous 1315 +residential 1315 +blasts 1314 +completion 1314 +davenport 1314 +fetish 1314 +heartbreak 1314 +safari 1314 +tortoise 1314 +unjust 1314 +boast 1313 +brochure 1313 +hiccups 1313 +larson 1313 +limousine 1313 +portraits 1313 +collapsing 1312 +coupon 1312 +escorted 1312 +magnitude 1312 +selina 1312 +courting 1311 +gourmet 1311 +motions 1311 +snore 1311 +utility 1311 +wrecking 1311 +aldo 1310 +apprehended 1310 +blindness 1310 +installation 1310 +interface 1310 +lian 1310 +likeness 1310 +meera 1310 +metals 1310 +one-time 1310 +prescribe 1310 +sluts 1310 +suicides 1310 +thakur 1310 +accelerator 1309 +awol 1309 +firms 1309 +kale 1309 +stretches 1309 +auxiliary 1308 +shelters 1308 +swipe 1308 +tack 1308 +childbirth 1307 +livelihood 1307 +longed 1307 +resourceful 1307 +swordsman 1307 +gaps 1306 +harding 1306 +incentive 1306 +sabbath 1306 +self-destruct 1306 +showdown 1306 +stormed 1306 +franky 1305 +frosty 1305 +henceforth 1305 +molten 1305 +petey 1305 +ref 1305 +renewed 1305 +sitter 1305 +catfish 1304 +cocking 1304 +fingertips 1304 +handcuffed 1304 +richardson 1304 +charger 1303 +como 1303 +disciplined 1303 +johann 1303 +outing 1303 +captioned 1302 +firepower 1302 +flawed 1302 +glitch 1302 +grants 1302 +hampton 1302 +jell-o 1302 +porcelain 1302 +remarried 1302 +una 1302 +arteries 1301 +belgrade 1301 +coded 1301 +griff 1301 +memorized 1301 +negligence 1301 +rips 1301 +roach 1301 +scarcely 1301 +stairwell 1301 +uno 1301 +buttocks 1300 +cedric 1300 +forks 1300 +loco 1300 +mentions 1300 +stunts 1300 +toot 1300 +adler 1299 +audit 1299 +bellows 1299 +hymn 1299 +mika 1299 +pelvis 1299 +snowy 1299 +aloha 1298 +calves 1298 +frantic 1298 +negroes 1298 +pao 1298 +slashed 1298 +archaeologists 1297 +freshly 1297 +poodle 1297 +sabotaged 1297 +sahara 1297 +gore 1296 +horrifying 1296 +maggots 1296 +meatball 1296 +boomer 1295 +briefed 1295 +cider 1295 +coulson 1295 +hard-on 1295 +pompous 1295 +prosecuted 1295 +waiters 1295 +abundance 1294 +barricade 1294 +gallon 1294 +implies 1294 +kali 1294 +literal 1294 +scrubbing 1294 +almond 1293 +chestnut 1293 +contacting 1293 +criteria 1293 +discouraged 1293 +kowalski 1293 +syphilis 1293 +trainee 1293 +elvira 1292 +farmhouse 1292 +hallucination 1292 +haskell 1292 +heals 1292 +lizards 1292 +poe 1292 +rad 1292 +rambo 1292 +samba 1292 +paces 1291 +reptiles 1291 +silicon 1291 +straws 1291 +waldo 1291 +fenton 1290 +slugs 1290 +accommodation 1289 +bashed 1289 +convicts 1289 +freighter 1289 +jerky 1289 +leftover 1289 +slag 1289 +solemnly 1289 +ze 1289 +lemme 1288 +lemons 1288 +non-stop 1288 +whitman 1288 +fancied 1287 +jethro 1287 +number-one 1287 +partridge 1287 +verified 1287 +woo-hoo 1287 +defiance 1286 +hottie 1286 +laila 1286 +rohan 1286 +ce 1285 +dyson 1285 +hypocrisy 1285 +invaluable 1285 +lyla 1285 +mutton 1285 +pant 1285 +stripping 1285 +akbar 1284 +broads 1284 +cbs 1284 +endlessly 1284 +he-he 1284 +hitchcock 1284 +lucrative 1284 +orchestral 1284 +penal 1284 +slows 1284 +uploaded 1284 +blender 1283 +ceased 1283 +dmv 1283 +illegitimate 1283 +redo 1283 +self-respect 1283 +accordance 1282 +brandt 1282 +dobbs 1282 +enhanced 1282 +makeover 1282 +mow 1282 +scanned 1282 +unprofessional 1282 +viagra 1282 +whiskers 1282 +confessions 1281 +hounds 1281 +illiterate 1281 +jakob 1281 +listeners 1281 +patriots 1281 +predicament 1281 +rebirth 1281 +tourism 1281 +vendor 1281 +cher 1280 +extraterrestrials 1280 +florist 1280 +frail 1280 +honda 1280 +labyrinth 1280 +malicious 1280 +nathaniel 1280 +psyche 1280 +unthinkable 1280 +diplomacy 1279 +elle 1279 +flair 1279 +heavyweight 1279 +hitched 1279 +observatory 1279 +planetary 1279 +plato 1279 +ruse 1279 +caterpillar 1278 +ev 1278 +extravagant 1278 +ideology 1278 +nutty 1278 +skeptical 1278 +sri 1278 +tonya 1278 +chainsaw 1277 +healthier 1277 +linking 1277 +pak 1277 +prudent 1277 +cutler 1276 +hermann 1276 +junkies 1276 +laundering 1276 +mugs 1276 +ot 1276 +rudder 1276 +kaufman 1275 +pints 1275 +possesses 1275 +succession 1275 +urges 1275 +administer 1274 +atmospheric 1274 +blizzard 1274 +extracted 1274 +fireball 1274 +grimm 1274 +lollipop 1274 +meghan 1274 +pinocchio 1274 +sory 1274 +state-of-the-art 1274 +stride 1274 +structural 1274 +weirder 1274 +burgess 1273 +flask 1273 +hubble 1273 +individually 1273 +obeyed 1273 +rohit 1273 +swain 1273 +bearded 1272 +booster 1272 +coffins 1272 +erratic 1272 +fucked-up 1272 +salami 1272 +typed 1272 +undergo 1272 +hamptons 1271 +marsha 1271 +ports 1271 +versa 1271 +dementia 1270 +domination 1270 +downfall 1270 +friendships 1270 +oval 1270 +ulcer 1270 +warwick 1270 +arthritis 1269 +journals 1269 +juvie 1269 +performers 1269 +refusal 1269 +volatile 1269 +beneficial 1268 +carlson 1268 +hui 1268 +hysteria 1268 +iranian 1268 +mortality 1268 +seaman 1268 +sled 1268 +sputtering 1268 +beseech 1267 +endings 1267 +miscarriage 1267 +shea 1267 +hun 1266 +moya 1266 +patrons 1266 +racer 1266 +dojo 1265 +hoop 1265 +persecution 1265 +plots 1265 +slab 1265 +unfamiliar 1265 +vents 1265 +carmichael 1264 +celine 1264 +coz 1264 +dumb-ass 1264 +foggy 1264 +herpes 1264 +himmler 1264 +monarchy 1264 +philosophical 1264 +ping-pong 1264 +riddles 1264 +soften 1264 +specialized 1264 +astronomy 1263 +corridors 1263 +deemed 1263 +mikhail 1263 +parvati 1263 +beginner 1262 +duet 1262 +elect 1262 +fascination 1262 +fussy 1262 +nebula 1262 +reins 1262 +viewed 1262 +yous 1262 +collier 1261 +diameter 1261 +evaluate 1261 +hearty 1261 +intoxicated 1261 +mammoth 1261 +microscopic 1261 +oppressed 1261 +photographic 1261 +shocks 1261 +tailing 1261 +descending 1260 +pioneers 1260 +prostate 1260 +stakeout 1260 +submission 1260 +towed 1260 +aneurysm 1259 +bids 1259 +electrician 1259 +gaius 1259 +hr 1259 +ointment 1259 +recalled 1259 +actively 1258 +ey 1258 +mortuary 1258 +neha 1258 +serenity 1258 +sulking 1258 +funded 1257 +mixer 1257 +mmmm 1257 +pearce 1257 +rockin 1257 +shreds 1257 +assurance 1256 +bolted 1256 +draining 1256 +tania 1256 +zapping 1256 +beheaded 1255 +bossy 1255 +commentary 1255 +unnoticed 1255 +venezuela 1255 +waffle 1255 +branded 1254 +cleanup 1254 +cnn 1254 +cosy 1254 +onward 1254 +rollins 1254 +aft 1253 +deluxe 1253 +farley 1253 +neurotic 1253 +plow 1253 +rani 1253 +schneider 1253 +chords 1252 +settlers 1252 +tanker 1252 +tides 1252 +berta 1251 +bio 1251 +extraordinarily 1251 +geisha 1251 +reflexes 1251 +rosen 1251 +snowman 1251 +transactions 1251 +assaulting 1250 +chunks 1250 +consultation 1250 +obsolete 1250 +shekhar 1250 +agreeable 1249 +brilliantly 1249 +hattie 1249 +impostor 1249 +liberate 1249 +lobo 1249 +maturity 1249 +selma 1249 +sheik 1249 +surfer 1249 +wasp 1249 +assumptions 1248 +bridegroom 1248 +capability 1248 +defective 1248 +delicacy 1248 +joss 1248 +leanne 1248 +lowell 1248 +mysteriously 1248 +priors 1248 +rach 1248 +sameer 1248 +silva 1248 +snores 1248 +tombstone 1248 +tumble 1248 +writings 1248 +fidel 1247 +fig 1247 +hospitalized 1247 +inflicted 1247 +sha 1247 +speculate 1247 +yeh 1247 +deuce 1246 +engineered 1246 +newcomer 1246 +outs 1246 +riddance 1246 +seasoned 1246 +sighted 1246 +significantly 1246 +stabilize 1246 +violations 1246 +youngster 1246 +abc 1245 +aziz 1245 +insomnia 1245 +jeeves 1245 +mecca 1245 +picket 1245 +betcha 1244 +dvds 1244 +harmonica 1244 +heartache 1244 +ich 1244 +intruding 1244 +jace 1244 +listing 1244 +pendant 1244 +proceeds 1244 +ruthie 1244 +trumpets 1244 +viruses 1244 +democrat 1243 +horrified 1243 +inspectors 1243 +jameson 1243 +lambs 1243 +lovebirds 1243 +neighborhoods 1243 +pasture 1243 +reddy 1243 +rivalry 1243 +sprained 1243 +arsenic 1242 +bret 1242 +peeking 1242 +rv 1242 +stats 1242 +talisman 1242 +vial 1242 +buchanan 1241 +intake 1241 +sheikh 1241 +takeover 1241 +acquaintances 1240 +alphonse 1240 +championships 1240 +foreplay 1240 +gareth 1240 +lotte 1240 +pines 1240 +scanners 1240 +thames 1240 +brands 1239 +cornell 1239 +firearm 1239 +infectious 1239 +recession 1239 +rewarding 1239 +shepard 1239 +squawks 1239 +superboy 1239 +swallows 1239 +ukrainian 1239 +morrow 1238 +pious 1238 +robertson 1238 +yahoo 1238 +brainwashed 1237 +colourful 1237 +jimbo 1237 +milly 1237 +quantities 1237 +sedated 1237 +societies 1237 +wines 1237 +bribery 1236 +jacuzzi 1236 +old-school 1236 +pours 1236 +superheroes 1236 +time-out 1236 +bathed 1235 +bunnies 1235 +crossword 1235 +geological 1235 +undead 1235 +valuables 1235 +comms 1234 +grinder 1234 +harem 1234 +linger 1234 +moods 1234 +proxy 1234 +rebuilding 1234 +roster 1234 +weber 1234 +kincaid 1233 +mendoza 1233 +originated 1233 +timed 1233 +toxins 1233 +tracing 1233 +docking 1232 +hauled 1232 +ironing 1232 +mar 1232 +nemo 1232 +quotes 1232 +wham 1232 +accelerate 1231 +adjustments 1231 +aggravated 1231 +brushes 1231 +downright 1231 +oh-ho 1231 +rabb 1231 +relive 1231 +sciences 1231 +chandi 1230 +confessing 1230 +hemisphere 1230 +lucie 1230 +paso 1230 +quaint 1230 +steaming 1230 +wary 1230 +xiang 1230 +accordion 1229 +aisha 1229 +bailing 1229 +cowardice 1229 +cramped 1229 +curt 1229 +fo 1229 +instagram 1229 +magically 1229 +cheesecake 1228 +coil 1228 +crummy 1228 +discovers 1228 +fend 1228 +lise 1228 +lumps 1228 +lux 1228 +stamina 1228 +testosterone 1228 +upbringing 1228 +updates 1228 +barbaric 1227 +franny 1227 +geronimo 1227 +manolo 1227 +thong 1227 +transmitting 1227 +ballerina 1226 +casinos 1226 +elegance 1226 +genetics 1226 +kenji 1226 +lilies 1226 +sats 1226 +trespass 1226 +yak 1226 +anew 1225 +cleaver 1225 +marseille 1225 +navigator 1225 +odor 1225 +paged 1225 +streaming 1225 +welles 1225 +anesthesia 1224 +crypt 1224 +esteem 1224 +great-grandfather 1224 +raiders 1224 +scandalous 1224 +spam 1224 +sprayed 1224 +teeny 1224 +twig 1224 +benji 1223 +breached 1223 +carmine 1223 +devi 1223 +givin 1223 +ripley 1223 +spanking 1223 +bb 1222 +haunts 1222 +kyoko 1222 +selfie 1222 +strategies 1222 +thriving 1222 +trot 1222 +amidst 1221 +coconuts 1221 +compose 1221 +hopping 1221 +powering 1221 +snails 1221 +awakening 1220 +bop 1220 +exorcism 1220 +hippies 1220 +indigenous 1220 +gamblers 1219 +jd 1219 +maddox 1219 +scoob 1219 +swiftly 1219 +ample 1218 +blinding 1218 +brando 1218 +clang 1218 +comforts 1218 +disqualified 1218 +engraved 1218 +hyo 1218 +kahn 1218 +labeled 1218 +maude 1218 +meteorite 1218 +taels 1218 +understatement 1218 +alexei 1217 +centimeters 1217 +darnell 1217 +fae 1217 +flares 1217 +gupta 1217 +leukemia 1217 +reconnaissance 1217 +seok 1217 +trio 1217 +camper 1216 +commandments 1216 +din 1216 +frankfurt 1216 +isa 1216 +kelsey 1216 +optimus 1216 +palermo 1216 +shareholders 1216 +smoker 1216 +intentional 1215 +jocelyn 1215 +serene 1215 +trolls 1215 +bali 1214 +fore 1214 +gorge 1214 +icing 1214 +countrymen 1213 +dosage 1213 +fakes 1213 +fictional 1213 +hermit 1213 +smuggler 1213 +vibrant 1213 +adores 1212 +antonia 1212 +ibrahim 1212 +intolerable 1212 +la-la 1212 +livia 1212 +madge 1212 +pavilion 1212 +pretext 1212 +runt 1212 +skid 1212 +wail 1212 +barbed 1211 +cunningham 1211 +ecstatic 1211 +galactica 1211 +jekyll 1211 +mtv 1211 +phoning 1211 +pledged 1211 +swapped 1211 +ancients 1210 +genitals 1210 +yee 1210 +african-american 1209 +citizenship 1209 +lowe 1209 +perpetual 1209 +responds 1209 +terra 1209 +turbulence 1209 +typhoon 1209 +weakly 1209 +irons 1208 +lash 1208 +marshmallows 1208 +shagging 1208 +skis 1208 +tipping 1208 +trades 1208 +abner 1207 +enchanting 1207 +jericho 1207 +possum 1207 +saxon 1207 +sleazy 1207 +amaze 1206 +dis 1206 +disobeyed 1206 +fleischman 1206 +imaging 1206 +johannes 1206 +kimono 1206 +stefano 1206 +upsets 1206 +accomplishment 1205 +complicate 1205 +court-martial 1205 +firsthand 1205 +lifeguard 1205 +mcbride 1205 +smallpox 1205 +traction 1205 +vans 1205 +voltage 1205 +vu 1205 +discarded 1204 +goodbyes 1204 +infiltrated 1204 +romano 1204 +swimsuit 1204 +universities 1204 +guillotine 1203 +ipod 1203 +nuke 1203 +specifics 1203 +chipped 1202 +cleansing 1202 +davidson 1202 +katz 1202 +khun 1202 +lima 1202 +limbo 1202 +marketplace 1202 +moto 1202 +rehearsed 1202 +rj 1202 +auggie 1201 +cholera 1201 +dissolved 1201 +migraine 1201 +mockery 1201 +packets 1201 +pepperoni 1201 +punctual 1201 +rafe 1201 +scientifically 1201 +bends 1200 +casserole 1200 +cedar 1200 +fallout 1200 +fancies 1200 +girly 1200 +graft 1200 +margie 1200 +myung 1200 +yvette 1200 +arun 1199 +benefactor 1199 +boon 1199 +chiang 1199 +dottie 1199 +fatherland 1199 +lazarus 1199 +participated 1199 +stickers 1199 +vendetta 1199 +banish 1198 +firefighter 1198 +frightens 1198 +gulps 1198 +leavin 1198 +lush 1198 +pups 1198 +soar 1198 +talker 1198 +washroom 1198 +concepts 1197 +desserts 1197 +indicating 1197 +infect 1197 +off-limits 1197 +stimulating 1197 +wipes 1197 +earnest 1196 +sequel 1196 +sherwood 1196 +abomination 1195 +compact 1195 +disposable 1195 +finnish 1195 +lem 1195 +noir 1195 +so-so 1195 +sturdy 1195 +traditionally 1195 +wook 1195 +chino 1194 +forsaken 1194 +grandkids 1194 +hints 1194 +hyderabad 1194 +minerals 1194 +rec 1194 +samir 1194 +shredded 1194 +souvenirs 1194 +anus 1193 +diaries 1193 +high-tech 1193 +lucius 1193 +pharmacist 1193 +puffs 1193 +rubles 1193 +unannounced 1193 +alarming 1192 +assisting 1192 +battleship 1192 +bias 1192 +blindfolded 1192 +calculating 1192 +durant 1192 +estates 1192 +exceptionally 1192 +fatima 1192 +honolulu 1192 +kingsley 1192 +pods 1192 +presumed 1192 +scouting 1192 +topless 1192 +airports 1191 +al-qaeda 1191 +believable 1191 +fearsome 1191 +molested 1191 +offerings 1191 +overrun 1191 +parlour 1191 +draper 1190 +dummies 1190 +endanger 1190 +heh-heh 1190 +ticks 1190 +versailles 1190 +widower 1190 +exploitation 1189 +sook 1189 +drool 1188 +joanie 1188 +piled 1188 +plugs 1188 +summary 1188 +taelons 1188 +thank-you 1188 +authorize 1187 +dover 1187 +faithfully 1187 +informal 1187 +promptly 1187 +robotnik 1187 +stabler 1187 +gemini 1186 +leech 1186 +phelps 1186 +selfless 1186 +perverts 1185 +sentencing 1185 +burr 1184 +carton 1184 +celebrations 1184 +featuring 1184 +heller 1184 +lim 1184 +machete 1184 +micky 1184 +nab 1184 +nd 1184 +provinces 1184 +rebellious 1184 +reconcile 1184 +regroup 1184 +sassy 1184 +scapegoat 1184 +technicians 1184 +vegetation 1184 +armenian 1183 +aroma 1183 +barred 1183 +captivity 1183 +crumbling 1183 +eunuch 1183 +manila 1183 +merits 1183 +pe 1183 +prosecutors 1183 +snowball 1183 +unrelated 1183 +atlas 1182 +evasive 1182 +penetrated 1182 +piling 1182 +volts 1182 +watkins 1182 +yolanda 1182 +bimbo 1181 +commodity 1181 +cullen 1181 +eisenhower 1181 +riverside 1181 +starbucks 1181 +afternoons 1180 +ballad 1180 +cheerleading 1180 +contamination 1180 +dada 1180 +dismissal 1180 +haystack 1180 +hye 1180 +perv 1180 +authors 1179 +blurry 1179 +gah 1179 +lowlife 1179 +malaysia 1179 +recreation 1179 +reddington 1179 +fascism 1178 +fridays 1178 +outgoing 1178 +penniless 1178 +regulars 1178 +scheming 1178 +weaken 1178 +wrongly 1178 +wrongs 1178 +birch 1177 +colombian 1177 +incompetence 1177 +patiently 1177 +sandoval 1177 +spartan 1177 +sweeney 1177 +whirs 1177 +gorillas 1176 +kaos 1176 +kobe 1176 +lew 1176 +oakland 1176 +prohibition 1176 +requirement 1176 +skateboard 1176 +zo 1176 +accessories 1175 +aviation 1175 +despised 1175 +diverse 1175 +grazie 1175 +inferno 1175 +linden 1175 +perished 1175 +quill 1175 +similarities 1175 +swoop 1175 +wilhelm 1175 +brenner 1174 +carpets 1174 +hawking 1174 +mambo 1174 +megatron 1174 +novelist 1174 +patel 1174 +payoff 1174 +raines 1174 +relevance 1174 +retaliation 1174 +whorehouse 1174 +wigs 1174 +amp 1173 +cochran 1173 +detecting 1173 +grotesque 1173 +jukebox 1173 +minors 1173 +oo 1173 +shoplifting 1173 +soho 1173 +stewardess 1173 +suffocated 1173 +algae 1172 +atheist 1172 +establishing 1172 +interfered 1172 +pillars 1172 +uni 1172 +algebra 1171 +conor 1171 +energies 1171 +haiti 1171 +krista 1171 +luka 1171 +mouthful 1171 +pandora 1171 +berserk 1170 +collections 1170 +cortex 1170 +disadvantage 1170 +ganga 1170 +headless 1170 +nevermind 1170 +punjab 1170 +regan 1170 +trojan 1170 +valium 1170 +adamant 1169 +distinctly 1169 +flack 1169 +kimi 1169 +ritz 1169 +shortest 1169 +transfers 1169 +carts 1168 +cords 1168 +doorknob 1168 +indoor 1168 +millionaires 1168 +ruptured 1168 +arlene 1167 +carmela 1167 +celery 1167 +conveniently 1167 +foil 1167 +jonesy 1167 +ranking 1167 +residency 1167 +responses 1167 +jaffa 1166 +landry 1166 +man-made 1166 +palestinian 1166 +slum 1166 +sweety 1166 +troublemaker 1166 +all-time 1165 +bucky 1165 +carnage 1165 +floss 1165 +hap 1165 +maw 1165 +rapper 1165 +shushing 1165 +aditya 1164 +compromising 1164 +poole 1164 +squeaky 1164 +troopers 1164 +undertaker 1164 +vogue 1164 +burrows 1163 +daisuke 1163 +emphasis 1163 +encryption 1163 +eureka 1163 +hoist 1163 +john-boy 1163 +privy 1163 +sacramento 1163 +cincinnati 1162 +elmo 1162 +mainframe 1162 +moderate 1162 +retro 1162 +sociopath 1162 +aims 1161 +cannabis 1161 +delaney 1161 +mads 1161 +mistook 1161 +namely 1161 +nighttime 1161 +spraying 1161 +arlo 1160 +ballistic 1160 +craziness 1160 +fussing 1160 +rainforest 1160 +salaam 1160 +stares 1160 +uncanny 1160 +benito 1159 +confederate 1159 +ebay 1159 +futures 1159 +hunts 1159 +kelvin 1159 +lunatics 1159 +ridicule 1159 +flushes 1158 +gregor 1158 +nai 1158 +unethical 1158 +aeryn 1157 +chronicle 1157 +cooled 1157 +crafty 1157 +groot 1157 +jer 1157 +jingles 1157 +manson 1157 +monuments 1157 +ringtone 1157 +trademark 1157 +vested 1157 +yue 1157 +alba 1156 +band-aid 1156 +brilliance 1156 +cherished 1156 +clem 1156 +genocide 1156 +ku 1156 +mules 1156 +persecuted 1156 +persuasion 1156 +piglet 1156 +shang 1156 +toledo 1156 +trampled 1156 +hes 1155 +inability 1155 +quincy 1155 +shalom 1155 +toil 1155 +tuesdays 1155 +weiss 1155 +alexandria 1154 +bosnia 1154 +custard 1154 +epi 1154 +orthodox 1154 +sdi 1154 +cylon 1153 +detonation 1153 +evicted 1153 +mondo 1153 +wuss 1153 +berg 1152 +bland 1152 +heirs 1152 +liberties 1152 +mailed 1152 +martine 1152 +newport 1152 +osama 1152 +praises 1152 +untrue 1152 +ancestral 1151 +bras 1151 +flattery 1151 +medici 1151 +relish 1151 +romero 1151 +seating 1151 +toxin 1151 +unspeakable 1151 +bangalore 1150 +characteristic 1150 +cramps 1150 +enjoyment 1150 +evolving 1150 +marin 1150 +muster 1150 +reincarnation 1150 +schizophrenic 1150 +britt 1149 +casings 1149 +employers 1149 +painfully 1149 +behaves 1148 +canceling 1148 +colette 1148 +delays 1148 +dreary 1148 +mindless 1148 +overweight 1148 +poisons 1148 +tragically 1148 +unheard 1148 +viola 1148 +cinch 1147 +cisco 1147 +commend 1147 +contractors 1147 +flashy 1147 +mater 1147 +plucked 1147 +presses 1147 +pyjamas 1147 +carriers 1146 +conan 1146 +faraway 1146 +gen 1146 +mackerel 1146 +omaha 1146 +participants 1146 +payne 1146 +repeats 1146 +yawning 1146 +da-da 1145 +descendant 1145 +flakes 1145 +fyi 1145 +loki 1145 +meyers 1145 +nostalgia 1145 +raincoat 1145 +reilly 1145 +sewn 1145 +thereby 1145 +bouncer 1144 +handyman 1144 +hopped 1144 +lagoon 1144 +lifeless 1144 +merciless 1144 +speck 1144 +amish 1143 +anarchist 1143 +donner 1143 +lancelot 1143 +mart 1143 +ophelia 1143 +westminster 1143 +acre 1142 +animated 1142 +croft 1142 +decipher 1142 +dwelling 1142 +fad 1142 +pointy 1142 +shiver 1142 +sneaked 1142 +susanna 1142 +sympathize 1142 +tod 1142 +conned 1141 +ehh 1141 +merrick 1141 +penetration 1141 +predictions 1141 +queenie 1141 +suk 1141 +tampering 1141 +unsafe 1141 +ap 1140 +chaperone 1140 +clipped 1140 +contractions 1140 +loathe 1140 +lurch 1140 +manipulative 1140 +masha 1140 +outraged 1140 +singular 1140 +delegate 1139 +directive 1139 +pedophile 1139 +ultimatum 1139 +hurtful 1138 +latino 1138 +pleaded 1138 +reducing 1138 +short-term 1138 +smoothie 1138 +traumatized 1138 +wick 1138 +channing 1137 +fugitives 1137 +herbie 1137 +persona 1137 +thermometer 1137 +vines 1137 +aargh 1136 +arkansas 1136 +ist 1136 +marcello 1136 +norfolk 1136 +pritchard 1136 +taping 1136 +yamamoto 1136 +askin 1135 +congressional 1135 +haze 1135 +macau 1135 +bazaar 1134 +deserts 1134 +eloped 1134 +enabled 1134 +mutilated 1134 +steiner 1134 +walnut 1134 +co-workers 1133 +doomsday 1133 +excite 1133 +pegasus 1133 +plutonium 1133 +swallowing 1133 +talia 1133 +asparagus 1132 +cougar 1132 +cycling 1132 +guillaume 1132 +moriarty 1132 +one-man 1132 +quagmire 1132 +spaniards 1132 +blasphemy 1131 +centauri 1131 +continually 1131 +donnelly 1131 +nathalie 1131 +rents 1131 +sims 1131 +thorpe 1131 +adolescent 1130 +crescent 1130 +huddle 1130 +newlyweds 1130 +nu 1130 +alternatives 1129 +bleeds 1129 +doves 1129 +giulia 1129 +gustavo 1129 +interstate 1129 +intoxication 1129 +itinerary 1129 +loch 1129 +monsoon 1129 +nudity 1129 +saddest 1129 +slipper 1129 +spokesman 1129 +turnout 1129 +appetizers 1128 +cackles 1128 +coy 1128 +kiddies 1128 +lovable 1128 +pilar 1128 +pox 1128 +rabid 1128 +relaxation 1128 +scripture 1128 +termination 1128 +unfold 1128 +ceremonial 1127 +eyelids 1127 +high-speed 1127 +insufficient 1127 +leaned 1127 +malt 1127 +motorcycles 1127 +muller 1127 +bidder 1126 +brewery 1126 +displays 1126 +hectic 1126 +narrowed 1126 +nba 1126 +owls 1126 +primal 1126 +soames 1126 +sudan 1126 +asher 1125 +breathtaking 1125 +diagnostic 1125 +dipped 1125 +dormant 1125 +galen 1125 +saber 1125 +selfridge 1125 +specials 1125 +apt 1124 +winnings 1124 +xo 1124 +chapters 1123 +embracing 1123 +foxy 1123 +georg 1123 +gladiator 1123 +homosexuality 1123 +louvre 1123 +scuba 1123 +solace 1123 +whinnying 1123 +areyou 1122 +brah 1122 +cannibal 1122 +chilled 1122 +damsel 1122 +deprive 1122 +fender 1122 +kessler 1122 +leopold 1122 +lui 1122 +mags 1122 +reckons 1122 +atleast 1121 +milwaukee 1121 +reginald 1121 +rightfully 1121 +robotic 1121 +shrinking 1121 +supplying 1121 +vandalism 1121 +bilko 1120 +garibaldi 1120 +hiro 1120 +hoyt 1120 +judo 1120 +karim 1120 +krauts 1120 +pj 1120 +ragged 1120 +rolf 1120 +roma 1120 +surplus 1120 +top-secret 1120 +accessible 1119 +audi 1119 +crazed 1119 +doctrine 1119 +estelle 1119 +gunning 1119 +no-no 1119 +ostrich 1119 +oversee 1119 +purposely 1119 +sleet 1119 +yugoslavia 1119 +atrocities 1118 +cheerio 1118 +clangs 1118 +fanatic 1118 +generators 1118 +holdings 1118 +incriminating 1118 +jap 1118 +landmark 1118 +stimulate 1118 +unimaginable 1118 +acoustic 1117 +afloat 1117 +ante 1117 +dehydrated 1117 +kapoor 1117 +sewage 1117 +surreal 1117 +charisma 1116 +constructive 1116 +elk 1116 +enormously 1116 +exhaling 1116 +genevieve 1116 +iconic 1116 +sadistic 1116 +zeo 1116 +admissions 1115 +clubhouse 1115 +declaring 1115 +illustrious 1115 +inexperienced 1115 +mast 1115 +meade 1115 +misjudged 1115 +nucleus 1115 +prepping 1115 +whomever 1115 +a-ha 1114 +camden 1114 +councillor 1114 +cowl 1114 +curls 1114 +mag 1114 +townsend 1114 +unsure 1114 +chul 1113 +colton 1113 +costello 1113 +cures 1113 +henrietta 1113 +marla 1113 +prentiss 1113 +swanson 1113 +autographs 1112 +borderline 1112 +cadets 1112 +nutrients 1112 +occult 1112 +perverse 1112 +provocation 1112 +browning 1111 +cabot 1111 +framing 1111 +influences 1111 +junkyard 1111 +markham 1111 +mio 1111 +shawl 1111 +socket 1111 +twitch 1111 +biography 1110 +burps 1110 +cooped 1110 +demonstrations 1110 +deserving 1110 +dost 1110 +goro 1110 +jfk 1110 +ludicrous 1110 +rooted 1110 +upward 1110 +bridesmaid 1109 +concludes 1109 +cy 1109 +murmur 1109 +penicillin 1109 +retreating 1109 +satin 1109 +accessed 1108 +ashton 1108 +avon 1108 +blowjob 1108 +canon 1108 +deployment 1108 +feeny 1108 +martinis 1108 +pence 1108 +predecessor 1108 +tian 1108 +underlying 1108 +biff 1107 +blockade 1107 +doreen 1107 +driscoll 1107 +jiffy 1107 +kaplan 1107 +mischievous 1107 +repressed 1107 +showered 1107 +transfusion 1107 +yarn 1107 +astonished 1106 +catchy 1106 +dah 1106 +deserter 1106 +documentation 1106 +explored 1106 +guineas 1106 +hotch 1106 +maguire 1106 +titanium 1106 +blanca 1105 +bom 1105 +dunham 1105 +explorers 1105 +faraday 1105 +feisty 1105 +imam 1105 +playoffs 1105 +w-we 1105 +federico 1104 +inadequate 1104 +plainly 1104 +theaters 1104 +astounding 1103 +borne 1103 +caddy 1103 +clans 1103 +highlands 1103 +hustler 1103 +paternity 1103 +washer 1103 +andie 1102 +attracting 1102 +cabe 1102 +disclosure 1102 +gilles 1102 +hagen 1102 +indigestion 1102 +mayer 1102 +negotiated 1102 +renegade 1102 +schnapps 1102 +spur 1102 +barf 1101 +barley 1101 +cheapest 1101 +complains 1101 +foxes 1101 +koji 1101 +repairing 1101 +sox 1101 +spiked 1101 +bitching 1100 +featured 1100 +humidity 1100 +inquest 1100 +keiko 1100 +magicians 1100 +midwest 1100 +mythical 1100 +paramount 1100 +priscilla 1100 +reptile 1100 +unpaid 1100 +abbie 1099 +hippo 1099 +neatly 1099 +smacked 1099 +volvo 1099 +brigitte 1098 +copyright 1098 +dodger 1098 +fallin 1098 +frivolous 1098 +hailey 1098 +prepped 1098 +rigby 1098 +shepherds 1098 +smokin 1098 +swamped 1098 +duly 1097 +enclosed 1097 +fai 1097 +glide 1097 +twenty-two 1097 +witnessing 1097 +armando 1096 +constellation 1096 +electrons 1096 +howe 1096 +nameless 1096 +rai 1096 +agreements 1095 +billing 1095 +cornwall 1095 +deformed 1095 +implore 1095 +inhabited 1095 +johanna 1095 +malice 1095 +no-good 1095 +sketchy 1095 +ci 1094 +complimentary 1094 +crusher 1094 +hemingway 1094 +janis 1094 +newark 1094 +orchids 1094 +outdated 1094 +provocative 1094 +reinforced 1094 +riker 1094 +strains 1094 +trainers 1094 +yamada 1094 +bethlehem 1093 +bordeaux 1093 +festivities 1093 +geeks 1093 +goodies 1093 +suppressed 1093 +brig 1092 +checkers 1092 +migration 1092 +ah-ha 1091 +ashok 1091 +bloodstream 1091 +desolate 1091 +distraught 1091 +fiasco 1091 +grasshopper 1091 +hardships 1091 +jem 1091 +masturbation 1091 +rudi 1091 +tickling 1091 +falsely 1090 +gullible 1090 +hereditary 1090 +kung-fu 1090 +orient 1090 +psychologically 1090 +sufficiently 1090 +abed 1089 +friar 1089 +full-on 1089 +gunpoint 1089 +jim-bob 1089 +jiro 1089 +luo 1089 +realtor 1089 +tuvok 1089 +underwood 1089 +vern 1089 +attire 1088 +clientele 1088 +dreaded 1088 +passages 1088 +hardened 1087 +hatched 1087 +innovative 1087 +navid 1087 +chennai 1086 +conclusive 1086 +delgado 1086 +guidelines 1086 +juices 1086 +kroner 1086 +latex 1086 +lockup 1086 +patched 1086 +rey 1086 +slopes 1086 +collapses 1085 +dealership 1085 +deathbed 1085 +fuzz 1085 +hearings 1085 +imposing 1085 +kei 1085 +nationwide 1085 +schoolgirl 1085 +schoolteacher 1085 +settings 1085 +brightly 1084 +dames 1084 +drills 1084 +manicure 1084 +negotiator 1084 +neutralize 1084 +ninjas 1084 +rosario 1084 +atone 1083 +dipping 1083 +eavesdropping 1083 +meditate 1083 +mormon 1083 +muzzle 1083 +redneck 1083 +richter 1083 +seatbelt 1083 +splits 1083 +viewer 1083 +wake-up 1083 +envoy 1082 +forgives 1082 +hardworking 1082 +highland 1082 +improper 1082 +niagara 1082 +poorer 1082 +sentiments 1082 +sweeps 1082 +tia 1082 +upside-down 1082 +chittering 1081 +claps 1081 +enlist 1081 +gia 1081 +medically 1081 +outcast 1081 +pappy 1081 +scarred 1081 +wilcox 1081 +archaeologist 1080 +breathed 1080 +consensus 1080 +faucet 1080 +gobble 1080 +maneuvers 1080 +prepares 1080 +taboo 1080 +tolls 1080 +weaponry 1080 +darby 1079 +gibbering 1079 +hernandez 1079 +maud 1079 +rica 1079 +rican 1079 +smitty 1079 +widespread 1079 +boutique 1078 +contradict 1078 +ignite 1078 +provider 1078 +quake 1078 +realities 1078 +scraped 1078 +serbia 1078 +sprinkle 1078 +valentina 1078 +albany 1077 +armory 1077 +disorders 1077 +electrocuted 1077 +everytime 1077 +indestructible 1077 +powdered 1077 +shaping 1077 +silverware 1077 +terence 1077 +auditorium 1076 +cyclops 1076 +delegates 1076 +floated 1076 +inspires 1076 +intrigue 1076 +pathology 1076 +poked 1076 +scumbags 1076 +doodle 1075 +emery 1075 +inspirational 1075 +kashmir 1075 +raisins 1075 +sibling 1075 +accelerated 1074 +anomalies 1074 +concession 1074 +dormitory 1074 +fluke 1074 +forrester 1074 +honorary 1074 +midway 1074 +reload 1074 +sweaters 1074 +bloated 1073 +choreography 1073 +dwarves 1073 +exploiting 1073 +kendra 1073 +larkin 1073 +middleton 1073 +mmm-mmm 1073 +nutrition 1073 +proclaim 1073 +warlock 1073 +detached 1072 +endorsement 1072 +fest 1072 +gage 1072 +intensely 1072 +niki 1072 +patriotism 1072 +raced 1072 +sash 1072 +sprint 1072 +carlisle 1071 +chopsticks 1071 +crowbar 1071 +gauntlet 1071 +groin 1071 +guthrie 1071 +hobo 1071 +inseparable 1071 +progressing 1071 +prudence 1071 +cavanaugh 1070 +drains 1070 +fiat 1070 +foo 1070 +saxophone 1070 +thrusters 1070 +complexity 1069 +homicidal 1069 +klara 1069 +passionately 1069 +rom 1069 +scolding 1069 +succeeds 1069 +tink 1069 +tiresome 1069 +visibility 1069 +wannabe 1069 +alexa 1068 +bondage 1068 +indicted 1068 +insider 1068 +kamal 1068 +moz 1068 +prix 1068 +uninvited 1068 +baines 1067 +campaigns 1067 +chao 1067 +compton 1067 +gunn 1067 +indifference 1067 +resistant 1067 +slang 1067 +taser 1067 +tending 1067 +tum 1067 +urged 1067 +fidelity 1066 +hard-working 1066 +mcnally 1066 +quickest 1066 +rattled 1066 +reacts 1066 +snag 1066 +stalls 1066 +burma 1065 +cashed 1065 +copycat 1065 +damnation 1065 +dodging 1065 +wilde 1065 +canopy 1064 +goof 1064 +ilana 1064 +macdonald 1064 +ogden 1064 +reckoned 1064 +retching 1064 +ridley 1064 +rolex 1064 +seriousness 1064 +toothless 1064 +twister 1064 +airway 1063 +butchered 1063 +elsie 1063 +esposito 1063 +herds 1063 +immensely 1063 +lm 1063 +orbiting 1063 +overlooking 1063 +propulsion 1063 +rickshaw 1063 +aeroplane 1062 +checkup 1062 +comprehensive 1062 +confiscate 1062 +coworkers 1062 +fabian 1062 +israelis 1062 +journeys 1062 +reckoning 1062 +shelton 1062 +thursdays 1062 +wad 1062 +daybreak 1061 +endeavor 1061 +estimates 1061 +inflict 1061 +innocents 1061 +mandate 1061 +shootings 1061 +bellamy 1060 +drapes 1060 +notebooks 1060 +socrates 1060 +subspace 1060 +unrest 1060 +wolverine 1060 +annika 1059 +bearings 1059 +cao 1059 +cram 1059 +exemplary 1059 +hyuk 1059 +infinitely 1059 +lala 1059 +perfected 1059 +replicate 1059 +salts 1059 +shiro 1059 +son-of-a-bitch 1059 +baboon 1058 +completing 1058 +medusa 1058 +olympus 1058 +reproach 1058 +sewed 1058 +abruptly 1057 +adversary 1057 +captions 1057 +doyou 1057 +ether 1057 +extradition 1057 +hoodie 1057 +karev 1057 +namaste 1057 +rapes 1057 +sioux 1057 +stacks 1057 +stitched 1057 +teased 1057 +vincenzo 1057 +ange 1056 +bozo 1056 +cambodia 1056 +clicked 1056 +enrolled 1056 +ion 1056 +jeb 1056 +relating 1056 +tallest 1056 +undertaking 1056 +unison 1056 +fink 1055 +good-for-nothing 1055 +irwin 1055 +projected 1055 +secretaries 1055 +sprouts 1055 +turkeys 1055 +zedd 1055 +africans 1054 +macarthur 1054 +sipping 1054 +alyssa 1053 +borden 1053 +jeffries 1053 +kemp 1053 +sie 1053 +talkative 1053 +twenty-one 1053 +unkind 1053 +visionary 1053 +blondes 1052 +breather 1052 +conservation 1052 +diabolical 1052 +heartfelt 1052 +interrogating 1052 +outlook 1052 +pegged 1052 +teleport 1052 +valeria 1052 +veto 1052 +wrinkled 1052 +auditioning 1051 +barclay 1051 +cantonese 1051 +dae 1051 +dearie 1051 +desks 1051 +dyed 1051 +eyebrow 1051 +franks 1051 +hester 1051 +nearing 1051 +pharmaceuticals 1051 +profoundly 1051 +rashid 1051 +sightseeing 1051 +thirty-five 1051 +tut 1051 +unwilling 1051 +administered 1050 +clarkson 1050 +coincidences 1050 +downside 1050 +downward 1050 +ito 1050 +martina 1050 +oats 1050 +okinawa 1050 +ortiz 1050 +outright 1050 +pika 1050 +toki 1050 +emmet 1049 +enduring 1049 +graduates 1049 +jihad 1049 +lucrezia 1049 +martians 1049 +nakamura 1049 +nasal 1049 +palate 1049 +winged 1049 +bernice 1048 +dumber 1048 +erich 1048 +faggots 1048 +musketeers 1048 +petrified 1048 +stature 1048 +thrashing 1048 +tumour 1048 +vibration 1048 +baht 1047 +eunice 1047 +hiss 1047 +ieast 1047 +lakshmi 1047 +lightweight 1047 +pharrell 1047 +rah 1047 +rematch 1047 +attentive 1046 +brooch 1046 +dunbar 1046 +equity 1046 +fates 1046 +ge 1046 +negatives 1046 +ringer 1046 +tvs 1046 +ado 1045 +bangles 1045 +delinquent 1045 +inca 1045 +netherlands 1045 +odessa 1045 +schizophrenia 1045 +sculptor 1045 +charismatic 1044 +chokes 1044 +comets 1044 +coordinator 1044 +garret 1044 +guerrilla 1044 +marisol 1044 +neurological 1044 +santana 1044 +slums 1044 +startling 1044 +strife 1044 +xev 1044 +campfire 1043 +castillo 1043 +curator 1043 +decaf 1043 +funnel 1043 +himalayas 1043 +historically 1043 +improvements 1043 +jez 1043 +looting 1043 +marshmallow 1043 +pinball 1043 +storeroom 1043 +tulip 1043 +darla 1042 +guitars 1042 +hathaway 1042 +leaping 1042 +mckinley 1042 +refreshments 1042 +sharpen 1042 +tensions 1042 +greenwich 1041 +isaiah 1041 +reconciliation 1041 +vou 1041 +waterloo 1041 +blueprint 1040 +cortez 1040 +cosmetic 1040 +dalek 1040 +grooming 1040 +idols 1040 +induce 1040 +ofyou 1040 +rejecting 1040 +schooling 1040 +wasteland 1040 +analogy 1039 +barkley 1039 +biz 1039 +dinging 1039 +hoof 1039 +leprechaun 1039 +lon 1039 +nostalgic 1039 +smokey 1039 +stomping 1039 +timetable 1039 +transmissions 1039 +zulu 1039 +bagels 1038 +demolished 1038 +judgmental 1038 +levine 1038 +meowing 1038 +nightgown 1038 +wallets 1038 +bobbie 1037 +calculation 1037 +cones 1037 +dreamy 1037 +electron 1037 +lafayette 1037 +motherfuckin 1037 +airs 1036 +asgard 1036 +bolivia 1036 +burnett 1036 +clones 1036 +commitments 1036 +companionship 1036 +dangling 1036 +paired 1036 +pic 1036 +rushes 1036 +suppliers 1036 +vigilant 1036 +vogel 1036 +withholding 1036 +apprehend 1035 +demented 1035 +fda 1035 +learner 1035 +minions 1035 +sandro 1035 +scenarios 1035 +toro 1035 +tubbs 1035 +bellies 1034 +contradiction 1034 +gregg 1034 +nia 1034 +overreacted 1034 +vat 1034 +fm 1033 +horizons 1033 +insolence 1033 +mardi 1033 +nominee 1033 +puny 1033 +rev 1033 +wartime 1033 +concede 1032 +deodorant 1032 +detectors 1032 +grapefruit 1032 +hendricks 1032 +hierarchy 1032 +homosexuals 1032 +nachos 1032 +remnants 1032 +sever 1032 +shing 1032 +attitudes 1031 +folds 1031 +gyu 1031 +jens 1031 +munitions 1031 +rochelle 1031 +trailing 1031 +acceleration 1030 +chilli 1030 +develops 1030 +dries 1030 +emerges 1030 +gruber 1030 +homicides 1030 +hotline 1030 +scrubbed 1030 +stomachs 1030 +surya 1030 +twain 1030 +ezekiel 1029 +murph 1029 +seduction 1029 +trait 1029 +asteroids 1028 +chemotherapy 1028 +defines 1028 +hackers 1028 +hazardous 1028 +pancho 1028 +taelon 1028 +translating 1028 +boobies 1027 +collide 1027 +flores 1027 +iphone 1027 +meatloaf 1027 +mohan 1027 +nephews 1027 +salaries 1027 +asa 1026 +burglars 1026 +cinematography 1026 +coulda 1026 +fergus 1026 +maia 1026 +milkshake 1026 +newbie 1026 +porky 1026 +railways 1026 +revoked 1026 +sheltered 1026 +stellar 1026 +undertake 1026 +variation 1026 +abode 1025 +clarice 1025 +crucify 1025 +dud 1025 +elmer 1025 +isles 1025 +latitude 1025 +salazar 1025 +tiniest 1025 +blurred 1024 +consistently 1024 +filet 1024 +lexx 1024 +locally 1024 +menus 1024 +nostrils 1024 +shrewd 1024 +sledge 1024 +spatter 1024 +u-turn 1024 +annabel 1023 +beige 1023 +bumblebee 1023 +hypnotized 1023 +ingenuity 1023 +juggling 1023 +mccall 1023 +oslo 1023 +scriptures 1023 +synchronized 1023 +thrashed 1023 +tres 1023 +tutoring 1023 +visualize 1023 +bedford 1022 +enigma 1022 +lanterns 1022 +marseilles 1022 +memento 1022 +prosecuting 1022 +uphill 1022 +albanian 1021 +bagged 1021 +cahill 1021 +claudio 1021 +contraband 1021 +debating 1021 +protesters 1021 +ravine 1021 +staten 1021 +volumes 1021 +baloney 1020 +benevolent 1020 +e-mailed 1020 +heroism 1020 +nutcase 1020 +prolonged 1020 +squashed 1020 +stalked 1020 +sweatshirt 1020 +sweethearts 1020 +writ 1020 +attain 1019 +deity 1019 +developer 1019 +distributor 1019 +housework 1019 +hype 1019 +jailed 1019 +lecturing 1019 +one-eyed 1019 +portions 1019 +quebec 1019 +shackles 1019 +unni 1019 +chandelier 1018 +dubious 1018 +ferret 1018 +ghostly 1018 +kiev 1018 +mami 1018 +snort 1018 +albuquerque 1017 +bubbly 1017 +consuming 1017 +follower 1017 +jacky 1017 +mammal 1017 +mstoll 1017 +smugglers 1017 +starling 1017 +sup 1017 +suspecting 1017 +burp 1016 +explicit 1016 +jams 1016 +mathias 1016 +neighboring 1016 +titty 1016 +alumni 1015 +anwar 1015 +bitchy 1015 +boop 1015 +dual 1015 +edmond 1015 +extends 1015 +gums 1015 +hearse 1015 +madhouse 1015 +nicotine 1015 +plastered 1015 +snickering 1015 +tremendously 1015 +win-win 1015 +wizards 1015 +belfast 1014 +holdup 1014 +humankind 1014 +mantis 1014 +oaks 1014 +partisans 1014 +recollection 1014 +baldy 1013 +birthmark 1013 +exclude 1013 +fundamentally 1013 +martyrs 1013 +micki 1013 +supporter 1013 +traveller 1013 +banter 1012 +ichi 1012 +ratted 1012 +stabilized 1012 +tucson 1012 +unravel 1012 +vp 1012 +accelerating 1011 +architects 1011 +brides 1011 +consecutive 1011 +drilled 1011 +inventing 1011 +malloy 1011 +nisha 1011 +pesetas 1011 +pew 1011 +red-handed 1011 +taxis 1011 +trough 1011 +untouchable 1011 +wither 1011 +chameleon 1010 +embark 1010 +nicholson 1010 +selves 1010 +spawn 1010 +stalled 1010 +taggart 1010 +fatter 1009 +quad 1009 +rodent 1009 +sellers 1009 +soaring 1009 +stevenson 1009 +tendencies 1009 +walkie-talkie 1009 +bolton 1008 +commits 1008 +kaylie 1008 +measles 1008 +schwartz 1008 +slid 1008 +tyrone 1008 +yanks 1008 +aligned 1007 +benign 1007 +charities 1007 +charmer 1007 +gnome 1007 +topped 1007 +vamos 1007 +wholesale 1007 +barging 1006 +blitz 1006 +boldly 1006 +cylons 1006 +dole 1006 +laddie 1006 +mildly 1006 +paddles 1006 +plunged 1006 +reflecting 1006 +staked 1006 +sur 1006 +tended 1006 +cecile 1005 +infidelity 1005 +leung 1005 +memoirs 1005 +na-na 1005 +nugget 1005 +vocation 1005 +aki 1004 +comedians 1004 +dialysis 1004 +lineage 1004 +notions 1004 +operators 1004 +rejects 1004 +securities 1004 +swells 1004 +wharf 1004 +adopting 1003 +annalise 1003 +atta 1003 +beetles 1003 +complication 1003 +hemorrhage 1003 +nests 1003 +polygraph 1003 +puttin 1003 +recipient 1003 +recommendations 1003 +reconstruct 1003 +anesthetic 1002 +chaz 1002 +consort 1002 +mats 1002 +owing 1002 +revived 1002 +unorthodox 1002 +carnegie 1001 +cobbler 1001 +damnit 1001 +dion 1001 +forthcoming 1001 +freezes 1001 +honours 1001 +manually 1001 +meticulous 1001 +quark 1001 +resigning 1001 +seagull 1001 +simpsons 1001 +slop 1001 +attila 1000 +cranberry 1000 +daffy 1000 +erect 1000 +fir 1000 +gigolo 1000 +hawke 1000 +persist 1000 +polishing 1000 +puffy 1000 +startle 1000 +submarines 1000 +swiped 1000 +vibes 1000 +wingman 1000 +canine 999 +editors 999 +governess 999 +hors 999 +horton 999 +integrated 999 +kerosene 999 +lifeline 999 +lockers 999 +mutually 999 +prodigy 999 +reformed 999 +roadside 999 +achieving 998 +brighten 998 +chavez 998 +mondays 998 +scottie 998 +world-class 998 +bouncy 997 +bueno 997 +hark 997 +hopelessly 997 +lag 997 +lug 997 +optical 997 +respecting 997 +spirited 997 +beirut 996 +davina 996 +hurried 996 +jong 996 +kimchi 996 +moretti 996 +mounting 996 +shilling 996 +slaughterhouse 996 +billings 995 +collars 995 +grinning 995 +kneeling 995 +mets 995 +overslept 995 +selfishness 995 +tarts 995 +unloading 995 +yuen 995 +bae 994 +dodo 994 +eliminating 994 +eviction 994 +jimi 994 +lobsters 994 +manhunt 994 +millimeter 994 +munna 994 +riviera 994 +rog 994 +sanderson 994 +straps 994 +whirlwind 994 +crabtree 993 +delaware 993 +detachment 993 +faction 993 +fished 993 +fragrant 993 +glands 993 +hasan 993 +hooper 993 +hurley 993 +ive 993 +legions 993 +marek 993 +mps 993 +palaces 993 +wield 993 +beka 992 +cabs 992 +casts 992 +cryin 992 +eloise 992 +gulp 992 +hairstyle 992 +marple 992 +nominees 992 +pastries 992 +rooney 992 +sleepwalking 992 +sobriety 992 +spotless 992 +synchro 992 +tijuana 992 +aesthetic 991 +briefs 991 +fixes 991 +heirloom 991 +intensifies 991 +merrily 991 +swarming 991 +theres 991 +unified 991 +campers 990 +cheered 990 +directory 990 +entertainer 990 +priestess 990 +tabitha 990 +whirl 990 +aspen 989 +bernstein 989 +chunky 989 +devastation 989 +discreetly 989 +evils 989 +guesses 989 +ipad 989 +lumpy 989 +pia 989 +podium 989 +squads 989 +wrinkle 989 +advising 988 +awoke 988 +cj 988 +custer 988 +discredit 988 +gadgets 988 +grammy 988 +ajax 987 +chucked 987 +compel 987 +furs 987 +lebanon 987 +mano 987 +messengers 987 +midtown 987 +ottoman 987 +parrish 987 +pawnee 987 +pulmonary 987 +ramos 987 +stumbling 987 +ammonia 986 +gibbons 986 +kitchens 986 +makoto 986 +markus 986 +ru 986 +swans 986 +two-bit 986 +walkers 986 +adama 985 +carousel 985 +checkbook 985 +commute 985 +drags 985 +fickle 985 +fronts 985 +jubilee 985 +lavon 985 +pretzels 985 +punctured 985 +rounding 985 +stronghold 985 +stutters 985 +visitation 985 +whips 985 +wholly 985 +yumi 985 +bergman 984 +civilised 984 +compulsive 984 +diagram 984 +egan 984 +lawsuits 984 +mould 984 +offenses 984 +parisian 984 +revival 984 +specialize 984 +year-old 984 +cubic 983 +jennie 983 +million-dollar 983 +pressured 983 +seasonal 983 +shui 983 +yogi 983 +alibis 982 +bosco 982 +exchanging 982 +friedrich 982 +hesitated 982 +kinder 982 +kryptonite 982 +proverb 982 +restart 982 +wanderer 982 +airlock 981 +break-up 981 +depraved 981 +finalists 981 +gambled 981 +medicinal 981 +mimicking 981 +weaving 981 +bullshitting 980 +contemplate 980 +physicists 980 +roofs 980 +addictive 979 +advisors 979 +dynamics 979 +heartbreaking 979 +heathen 979 +outward 979 +rambling 979 +restoring 979 +solves 979 +urgh 979 +biologist 978 +boxed 978 +braddock 978 +camped 978 +clarissa 978 +cognitive 978 +conservatory 978 +defining 978 +diminished 978 +donating 978 +frisbee 978 +horsemen 978 +momo 978 +praising 978 +stoked 978 +tatiana 978 +topics 978 +appliances 977 +councilor 977 +elm 977 +fasting 977 +oww 977 +riff 977 +starr 977 +conspicuous 976 +gloom 976 +gopal 976 +muy 976 +pacing 976 +scrolls 976 +conquering 975 +creations 975 +crusaders 975 +errol 975 +fines 975 +frightful 975 +garza 975 +handcuff 975 +implanted 975 +intellectuals 975 +omelette 975 +shep 975 +soprano 975 +vanishing 975 +verma 975 +bolo 974 +chou 974 +cipher 974 +denounce 974 +hugely 974 +impersonating 974 +late-night 974 +lowering 974 +ordinarily 974 +perceptive 974 +polka 974 +taxpayers 974 +tonnes 974 +travers 974 +wilkins 974 +activists 973 +affidavit 973 +anthrax 973 +moths 973 +notre 973 +paloma 973 +patrolling 973 +premeditated 973 +prescriptions 973 +shipyard 973 +adjacent 972 +bren 972 +cuter 972 +gloss 972 +koran 972 +mannequin 972 +nooo 972 +phoney 972 +pratap 972 +territorial 972 +toyota 972 +bel 971 +calming 971 +carp 971 +farnsworth 971 +fluent 971 +keepers 971 +lister 971 +minivan 971 +papal 971 +paxton 971 +rochester 971 +yearn 971 +yorker 971 +ahjussi 970 +descriptions 970 +enrico 970 +generating 970 +gowns 970 +hillbilly 970 +raghu 970 +takeout 970 +tiara 970 +unconditional 970 +unofficial 970 +clank 969 +clogged 969 +fiesta 969 +footing 969 +hearsay 969 +lax 969 +lucinda 969 +misled 969 +pastures 969 +pimps 969 +remington 969 +renovation 969 +securing 969 +twists 969 +degrading 968 +gs 968 +illicit 968 +jie 968 +lashes 968 +raffle 968 +repaid 968 +similarly 968 +sweetly 968 +tien 968 +adelaide 967 +delphine 967 +denzel 967 +dicky 967 +fuji 967 +homing 967 +mead 967 +nobleman 967 +stasis 967 +sulfur 967 +bins 966 +contraption 966 +dancin 966 +earp 966 +fester 966 +implicated 966 +maverick 966 +momentarily 966 +newt 966 +reece 966 +slender 966 +stairway 966 +cubicle 965 +flake 965 +flashed 965 +harrington 965 +indispensable 965 +mans 965 +rainer 965 +trivia 965 +alderman 964 +bickering 964 +bruv 964 +conspired 964 +dodged 964 +entourage 964 +hickey 964 +hurrying 964 +kimura 964 +manifesto 964 +mints 964 +pretzel 964 +prized 964 +wednesdays 964 +acquisition 963 +amigos 963 +conceited 963 +gutted 963 +imposter 963 +keypad 963 +leeches 963 +profiles 963 +tampa 963 +thaw 963 +unexplained 963 +unholy 963 +correctional 962 +defiant 962 +five-o 962 +hewes 962 +infernal 962 +insanely 962 +landscapes 962 +mousse 962 +pubic 962 +pubs 962 +rated 962 +utopia 962 +bane 961 +dividing 961 +finder 961 +georgina 961 +greenland 961 +huff 961 +lyin 961 +merle 961 +naught 961 +puked 961 +puking 961 +veterinarian 961 +announcements 960 +corral 960 +crutches 960 +dredge 960 +gauze 960 +handgun 960 +infections 960 +rabble 960 +tribune 960 +vaginal 960 +archaeological 959 +augustine 959 +climbs 959 +ditto 959 +likelihood 959 +middle-class 959 +renata 959 +rommel 959 +universes 959 +coastline 958 +javi 958 +nestor 958 +rigor 958 +dolan 957 +donkeys 957 +glare 957 +hanuman 957 +imagery 957 +lucid 957 +majors 957 +masturbating 957 +misconduct 957 +ornament 957 +preservation 957 +presley 957 +shrunk 957 +skinned 957 +sneezing 957 +watchin 957 +accompanying 956 +aston 956 +exceeded 956 +flaps 956 +mahoney 956 +sami 956 +scrutiny 956 +sculptures 956 +aiding 955 +alms 955 +diverted 955 +domino 955 +ephram 955 +escorting 955 +garner 955 +ho-ho 955 +invoice 955 +maris 955 +mime 955 +penises 955 +revelations 955 +shiv 955 +sprout 955 +tilly 955 +unh-unh 955 +upgraded 955 +aspirations 954 +chimps 954 +furnished 954 +gunmen 954 +inserted 954 +oop 954 +roper 954 +smallville 954 +stressing 954 +temperament 954 +unprotected 954 +arouse 953 +caffrey 953 +cordon 953 +emir 953 +freeing 953 +limping 953 +lol 953 +madhu 953 +naina 953 +rejoin 953 +suitor 953 +tangible 953 +travellers 953 +architectural 952 +baylor 952 +bipolar 952 +chandra 952 +devout 952 +drinkin 952 +hurl 952 +killin 952 +monumental 952 +supervised 952 +unwind 952 +almonds 951 +appetizer 951 +crunchy 951 +decrease 951 +hamish 951 +larceny 951 +lear 951 +medina 951 +originals 951 +paved 951 +sci-fi 951 +treasurer 951 +trilling 951 +abundant 950 +beatings 950 +calms 950 +elixir 950 +gots 950 +guatemala 950 +knicks 950 +knuckle 950 +miley 950 +miraculously 950 +perrine 950 +seein 950 +tenure 950 +unmarked 950 +waterfront 950 +bead 949 +goalie 949 +kebab 949 +milking 949 +nurture 949 +populated 949 +real-life 949 +redundant 949 +salads 949 +tumbling 949 +uber 949 +yuko 949 +yusuf 949 +bernardo 948 +celtic 948 +cocoon 948 +giovanna 948 +landslide 948 +mako 948 +mileage 948 +shucks 948 +starlight 948 +superiority 948 +surrounds 948 +suture 948 +uncool 948 +valves 948 +bojack 947 +circled 947 +ex-con 947 +foyle 947 +hover 947 +marches 947 +mellie 947 +okey 947 +rainbows 947 +restrained 947 +rimmer 947 +terminator 947 +tinkling 947 +claudius 946 +galley 946 +gloat 946 +jodi 946 +lodging 946 +magma 946 +rupture 946 +thinker 946 +variations 946 +babs 945 +cannes 945 +chases 945 +corpus 945 +day-to-day 945 +horrendous 945 +nein 945 +railing 945 +reproduction 945 +ridiculously 945 +strolling 945 +turin 945 +wayward 945 +wolfgang 945 +angrily 944 +bot 944 +bowler 944 +caterer 944 +cultivate 944 +fullest 944 +mcnamara 944 +naruto 944 +positioned 944 +rampage 944 +yeung 944 +damascus 943 +digestive 943 +extending 943 +forbids 943 +forsake 943 +holloway 943 +jerri 943 +mocked 943 +nightly 943 +odo 943 +petting 943 +retained 943 +severance 943 +ust 943 +varsity 943 +w-wait 943 +exploits 942 +firefighters 942 +jagger 942 +quoted 942 +scientology 942 +skips 942 +throbbing 942 +barrister 941 +bri 941 +christening 941 +deficit 941 +doggone 941 +dunes 941 +ethiopia 941 +evade 941 +gallo 941 +goner 941 +gothic 941 +pepsi 941 +reeks 941 +relied 941 +betrothed 940 +candid 940 +dingo 940 +justification 940 +relies 940 +scrabble 940 +shroud 940 +smacks 940 +small-time 940 +spurs 940 +stimulation 940 +warped 940 +counselors 939 +dooley 939 +emissions 939 +evenly 939 +isobel 939 +koo 939 +liter 939 +vcr 939 +visually 939 +christa 938 +conqueror 938 +favorable 938 +hancock 938 +herriot 938 +holster 938 +marv 938 +mayan 938 +metre 938 +montague 938 +overturned 938 +putin 938 +qui 938 +revolutionaries 938 +tahiti 938 +tarp 938 +capri 937 +crowe 937 +faintly 937 +hawthorne 937 +realization 937 +statistically 937 +tuberculosis 937 +clary 936 +detest 936 +exceed 936 +flutter 936 +inhaling 936 +jackal 936 +modify 936 +odette 936 +plough 936 +tolerant 936 +andres 935 +duff 935 +fathom 935 +foresee 935 +kingston 935 +montecito 935 +odyssey 935 +pears 935 +premonition 935 +psychiatry 935 +rhys 935 +sapphire 935 +scariest 935 +seizing 935 +sexiest 935 +sy 935 +unsuccessful 935 +austen 934 +creak 934 +hani 934 +hoods 934 +hospice 934 +maniacs 934 +templar 934 +therese 934 +yip 934 +adaptation 933 +behavioral 933 +eyewitnesses 933 +indies 933 +intricate 933 +keaton 933 +mavis 933 +meatwad 933 +rotating 933 +subtitled 933 +swede 933 +aristotle 932 +cannibals 932 +cheques 932 +coloring 932 +cyprus 932 +danke 932 +devoured 932 +independently 932 +nirvana 932 +offline 932 +quail 932 +robust 932 +selena 932 +suh 932 +bombings 931 +carlin 931 +darrell 931 +deduction 931 +dependable 931 +duran 931 +extremes 931 +fives 931 +impolite 931 +introductions 931 +mitsuko 931 +mundane 931 +patrice 931 +pestering 931 +pledges 931 +zeb 931 +barack 930 +bison 930 +char 930 +claustrophobic 930 +closets 930 +dismantle 930 +entrances 930 +hick 930 +injunction 930 +jamaican 930 +lingering 930 +open-minded 930 +pebble 930 +rachael 930 +relapse 930 +reside 930 +retrieved 930 +windmill 930 +bonkers 929 +chivalry 929 +jest 929 +judah 929 +meena 929 +poseidon 929 +sanjana 929 +surfaces 929 +teammate 929 +antoinette 928 +bloodthirsty 928 +chants 928 +curved 928 +entries 928 +high-profile 928 +incarcerated 928 +juanita 928 +lager 928 +nightcap 928 +novice 928 +propeller 928 +scarier 928 +stylist 928 +toddler 928 +warms 928 +communal 927 +cornelius 927 +hemorrhaging 927 +pu 927 +rustle 927 +vertigo 927 +viceroy 927 +avi 926 +ballpark 926 +blazer 926 +consistency 926 +marino 926 +pedestrian 926 +plagued 926 +puzzled 926 +springtime 926 +syrian 926 +ahhhh 925 +bertrand 925 +bronson 925 +deena 925 +drivin 925 +grazing 925 +karthik 925 +paging 925 +plotted 925 +polluted 925 +recharge 925 +regretting 925 +relativity 925 +smoky 925 +untraceable 925 +aman 924 +bloodbath 924 +buggers 924 +cassius 924 +deepak 924 +katia 924 +mulligan 924 +nandu 924 +psychosis 924 +revered 924 +uss 924 +vittorio 924 +affections 923 +backgrounds 923 +cbi 923 +downing 923 +finley 923 +humping 923 +krang 923 +ramesh 923 +repercussions 923 +bub 922 +dietrich 922 +distortion 922 +emperors 922 +famished 922 +hornet 922 +meek 922 +mme 922 +publishers 922 +rhetorical 922 +shone 922 +unsettling 922 +vishnu 922 +wacko 922 +carotid 921 +chinaman 921 +competitions 921 +gayle 921 +lapse 921 +light-years 921 +plump 921 +plunder 921 +reunite 921 +sears 921 +starsky 921 +telephones 921 +terrance 921 +tock 921 +trendy 921 +unloaded 921 +bureaucracy 920 +carlotta 920 +conspiring 920 +fins 920 +intending 920 +rhode 920 +vasquez 920 +aviv 919 +bicycles 919 +clinics 919 +condescending 919 +ese 919 +frigid 919 +incurable 919 +lenore 919 +mckenna 919 +moonshine 919 +nominate 919 +nottingham 919 +occurring 919 +oh-oh-oh 919 +orgasms 919 +pamphlet 919 +sadist 919 +sculpting 919 +seminary 919 +summertime 919 +termites 919 +thunderbird 919 +wholesome 919 +arabian 918 +bikers 918 +brood 918 +creamy 918 +dumpling 918 +eggman 918 +gaines 918 +goddam 918 +induced 918 +lacrosse 918 +missionaries 918 +rarity 918 +roadblocks 918 +skype 918 +supergirl 918 +weenie 918 +adrien 917 +annoys 917 +belches 917 +cabbie 917 +charly 917 +dimensional 917 +extinguisher 917 +hooligans 917 +lavatory 917 +lennie 917 +ornaments 917 +reverence 917 +seductive 917 +tintin 917 +wards 917 +audacity 916 +bewitched 916 +breeds 916 +cadaver 916 +fluff 916 +madagascar 916 +raina 916 +sicilian 916 +vader 916 +willpower 916 +yoshida 916 +aorta 915 +aqua 915 +candies 915 +cannonball 915 +defeating 915 +esta 915 +head-on 915 +kruger 915 +penetrating 915 +quitter 915 +sampson 915 +sidelines 915 +calculus 914 +clinks 914 +hydraulic 914 +peeling 914 +pooped 914 +pulitzer 914 +tau 914 +trucker 914 +wil 914 +cartridge 913 +choppers 913 +cultured 913 +dears 913 +delights 913 +ell 913 +fainting 913 +filters 913 +historians 913 +honoring 913 +kobayashi 913 +langston 913 +magnets 913 +nuggets 913 +overtake 913 +sera 913 +shanti 913 +transforming 913 +bertram 912 +continuity 912 +gazette 912 +packaging 912 +pauly 912 +sheba 912 +soothe 912 +suns 912 +unhappiness 912 +alliances 911 +commie 911 +coyotes 911 +decorator 911 +eggplant 911 +enmity 911 +huts 911 +laird 911 +liner 911 +mansfield 911 +scorn 911 +singin 911 +sitcom 911 +staple 911 +toasted 911 +vary 911 +appealed 910 +articulate 910 +giulio 910 +gynecologist 910 +molecule 910 +mossad 910 +nudge 910 +os 910 +palestinians 910 +petite 910 +quilt 910 +space-time 910 +stutter 910 +synagogue 910 +warhead 910 +atf 909 +bombshell 909 +booker 909 +brooding 909 +coordination 909 +jumong 909 +mathematician 909 +monaco 909 +mordecai 909 +therapeutic 909 +vertebrae 909 +amor 908 +avenger 908 +conduit 908 +dipper 908 +giselle 908 +kabul 908 +limestone 908 +mizuki 908 +modern-day 908 +prospective 908 +smeared 908 +stampede 908 +uncontrollable 908 +willoughby 908 +disapprove 907 +doubling 907 +impending 907 +intimidation 907 +lyric 907 +mishap 907 +orbits 907 +sachin 907 +scrapes 907 +sentry 907 +shamed 907 +shootin 907 +shrinks 907 +themes 907 +ahmad 906 +bonny 906 +caveman 906 +crumb 906 +faintest 906 +grandeur 906 +hawkes 906 +indications 906 +limitless 906 +senora 906 +tiberius 906 +contingency 905 +corroborate 905 +deke 905 +dildo 905 +executions 905 +prasad 905 +showering 905 +avenged 904 +bestow 904 +combinations 904 +defenders 904 +glaciers 904 +godforsaken 904 +mahal 904 +mila 904 +pedestal 904 +primo 904 +stout 904 +suri 904 +translates 904 +dishonor 903 +eminent 903 +moby 903 +otter 903 +supernova 903 +valentino 903 +cabins 902 +elastic 902 +flips 902 +java 902 +mani 902 +mooing 902 +pang 902 +populations 902 +presumptuous 902 +seasick 902 +subversive 902 +tokugawa 902 +weir 902 +accomplishments 901 +impaired 901 +lateral 901 +libya 901 +pristine 901 +remainder 901 +resides 901 +retreated 901 +single-handedly 901 +sweats 901 +width 901 +yellowstone 901 +biased 900 +fisk 900 +hermes 900 +internally 900 +nickels 900 +prying 900 +remembrance 900 +rhoda 900 +self-defence 900 +smelt 900 +sphinx 900 +strangulation 900 +sympathies 900 +two-way 900 +aladdin 899 +casablanca 899 +confessor 899 +danvers 899 +dara 899 +diseased 899 +douchebag 899 +downloading 899 +ekg 899 +eskimo 899 +galloping 899 +kraut 899 +lamar 899 +lotto 899 +never-ending 899 +rephrase 899 +sanitation 899 +ventilator 899 +brodie 898 +caged 898 +flashback 898 +imperfect 898 +plymouth 898 +quantico 898 +rearrange 898 +reconnect 898 +routines 898 +ruddy 898 +rutledge 898 +sanctioned 898 +synced 898 +vulnerability 898 +canister 897 +citadel 897 +enslaved 897 +forcibly 897 +guillermo 897 +insinuating 897 +intersect 897 +malignant 897 +nibble 897 +world-famous 897 +zeros 897 +big-ass 896 +britta 896 +candlelight 896 +clancy 896 +gunned 896 +meng 896 +mythbusters 896 +salesmen 896 +undefeated 896 +criticizing 895 +daisies 895 +hairdo 895 +maxie 895 +oblivious 895 +thad 895 +truffle 895 +vie 895 +yoda 895 +abduct 894 +cloning 894 +coronary 894 +crisps 894 +dazzle 894 +jive 894 +lard 894 +mathieu 894 +petit 894 +ranked 894 +shedding 894 +shootout 894 +stork 894 +strangling 894 +blackmailer 893 +bora 893 +buddhism 893 +endorse 893 +hefty 893 +immaculate 893 +linear 893 +massages 893 +mehmet 893 +monologue 893 +o-okay 893 +poaching 893 +satanic 893 +stocked 893 +swami 893 +th-that 893 +bloodline 892 +categories 892 +enid 892 +graces 892 +hernia 892 +highways 892 +kimmie 892 +liege 892 +looky 892 +madden 892 +philosophers 892 +rapists 892 +recommending 892 +septic 892 +snip 892 +subsequent 892 +alonzo 891 +avocado 891 +corbett 891 +cummings 891 +hammers 891 +logically 891 +mueller 891 +mushy 891 +oaf 891 +prompt 891 +shinji 891 +yeast 891 +amaar 890 +auf 890 +beets 890 +bling 890 +campsite 890 +danes 890 +eels 890 +ei 890 +farted 890 +flex 890 +fruitcake 890 +fulton 890 +heiress 890 +izzie 890 +racetrack 890 +safeguard 890 +escorts 889 +ganesh 889 +gazing 889 +imprint 889 +leaps 889 +pap 889 +parson 889 +rajesh 889 +ach 888 +appalled 888 +birthplace 888 +constantinople 888 +coven 888 +discourage 888 +jigsaw 888 +lisbeth 888 +palin 888 +recycle 888 +sheesh 888 +unlawful 888 +wank 888 +archangel 887 +brianna 887 +briscoe 887 +clutches 887 +hepatitis 887 +hires 887 +insistent 887 +publicist 887 +schiller 887 +submerged 887 +tentacles 887 +thriller 887 +yves 887 +anastasia 886 +castor 886 +cliche 886 +crock 886 +forty-five 886 +hither 886 +intoxicating 886 +manuela 886 +rd 886 +ah-ah 885 +enquiries 885 +governed 885 +healy 885 +hearst 885 +hypocritical 885 +manufacturers 885 +occurrence 885 +pilates 885 +proteins 885 +resurrected 885 +sharper 885 +timeless 885 +airtight 884 +all-out 884 +butchers 884 +cushions 884 +dilated 884 +environments 884 +garbled 884 +halves 884 +louse 884 +menopause 884 +neutron 884 +ponytail 884 +preserving 884 +prognosis 884 +psychiatrists 884 +armageddon 883 +blazes 883 +bulgaria 883 +bulgarian 883 +compounds 883 +coney 883 +contributing 883 +inauguration 883 +jazzy 883 +lebeau 883 +restraints 883 +sinbad 883 +slicing 883 +ufos 883 +yo-yo 883 +accents 882 +calculator 882 +cavern 882 +frederic 882 +kremlin 882 +militant 882 +newscaster 882 +nicknames 882 +objectives 882 +olly 882 +verne 882 +warship 882 +abuses 881 +daycare 881 +kiddie 881 +programmes 881 +replay 881 +righto 881 +tibetan 881 +yung 881 +zee 881 +darned 880 +dwarfs 880 +embryo 880 +frosting 880 +mono 880 +obituary 880 +rein 880 +snout 880 +susceptible 880 +tickled 880 +truffles 880 +braid 879 +brits 879 +canvass 879 +cherokee 879 +heaps 879 +neanderthals 879 +pebbles 879 +perk 879 +tingling 879 +tov 879 +twenties 879 +assaults 878 +bj 878 +chirp 878 +docked 878 +fleece 878 +mas 878 +moping 878 +pheasant 878 +sensory 878 +variables 878 +websites 878 +conventions 877 +craps 877 +distrust 877 +histories 877 +mi-nam 877 +minefield 877 +popeye 877 +problematic 877 +smitten 877 +sp 877 +vigil 877 +yapping 877 +arose 876 +barnett 876 +coordinated 876 +corky 876 +discard 876 +eyelashes 876 +frannie 876 +giacomo 876 +googled 876 +invoke 876 +oily 876 +prejudiced 876 +ralphie 876 +rundown 876 +scrambling 876 +seams 876 +unconventional 876 +hijack 875 +ignacio 875 +masquerade 875 +purchasing 875 +yam 875 +arises 874 +dempsey 874 +eagerly 874 +fetching 874 +goebbels 874 +janeway 874 +logging 874 +maggot 874 +michiko 874 +orbital 874 +settlements 874 +wring 874 +alignment 873 +cheddar 873 +eatin 873 +emissary 873 +fats 873 +hangman 873 +heinz 873 +heterosexual 873 +irritate 873 +keel 873 +mana 873 +meryl 873 +narrows 873 +nationality 873 +taunting 873 +umbrellas 873 +bjorn 872 +bleeping 872 +comma 872 +cunts 872 +demonstrating 872 +format 872 +godspeed 872 +hitman 872 +peachy 872 +sony 872 +whimsical 872 +bestowed 871 +censorship 871 +fitch 871 +hispanic 871 +jove 871 +overflowing 871 +rizzo 871 +shuddering 871 +tully 871 +astronomical 870 +bachelorette 870 +bonaparte 870 +impartial 870 +mccann 870 +metabolism 870 +obsessing 870 +pathological 870 +seema 870 +silhouette 870 +sugars 870 +heresy 869 +homeboy 869 +newcomers 869 +preschool 869 +remake 869 +slurps 869 +tabloid 869 +vanquish 869 +willa 869 +zorro 869 +ag 868 +boca 868 +finesse 868 +minimize 868 +ravishing 868 +tick-tock 868 +woodrow 868 +arraignment 867 +astronomer 867 +choo 867 +crawley 867 +nigh 867 +nil 867 +selective 867 +springer 867 +superpowers 867 +cath 866 +daniela 866 +equator 866 +gossiping 866 +hokkaido 866 +impenetrable 866 +incorporate 866 +jig 866 +long-range 866 +poached 866 +sculpt 866 +sexier 866 +shivers 866 +underdog 866 +vinyl 866 +accumulated 865 +ascend 865 +blob 865 +determines 865 +ecosystem 865 +emilie 865 +etienne 865 +forefathers 865 +gladiators 865 +grit 865 +malpractice 865 +nylon 865 +snug 865 +soles 865 +vs 865 +avail 864 +clothed 864 +collaborate 864 +defied 864 +ellison 864 +gentry 864 +hem 864 +hide-and-seek 864 +krusty 864 +ning 864 +parkinson 864 +prune 864 +stef 864 +uproar 864 +valentin 864 +cade 863 +fairfax 863 +hots 863 +lawfully 863 +mimic 863 +tumors 863 +ver 863 +aftermath 862 +blisters 862 +dod 862 +dusting 862 +gordo 862 +inhaler 862 +mermaids 862 +nacho 862 +ori 862 +residual 862 +sancho 862 +schoolboy 862 +shitload 862 +stems 862 +striped 862 +tabloids 862 +thoughtless 862 +thrice 862 +continuum 861 +csu 861 +dukes 861 +fused 861 +guan 861 +lube 861 +measurement 861 +neanderthal 861 +observant 861 +peng 861 +peppermint 861 +pierrot 861 +prima 861 +provoking 861 +qualifies 861 +reinforce 861 +uma 861 +authenticity 860 +duval 860 +ensemble 860 +forman 860 +motivate 860 +sergey 860 +soundtrack 860 +strut 860 +swagger 860 +taipei 860 +tantrum 860 +annihilate 859 +bale 859 +beale 859 +brandi 859 +clergy 859 +confided 859 +disrupted 859 +divorcing 859 +maui 859 +medics 859 +signore 859 +wreath 859 +advancement 858 +aida 858 +armpit 858 +bieber 858 +cavalier 858 +chong 858 +combing 858 +diligence 858 +districts 858 +genome 858 +hwan 858 +impertinent 858 +loops 858 +neela 858 +retaliate 858 +specializes 858 +tranquilizer 858 +vacancy 858 +adversity 857 +beneficiary 857 +bran 857 +buoy 857 +compulsion 857 +criticized 857 +mascara 857 +massa 857 +revised 857 +self-control 857 +arne 856 +compression 856 +flowed 856 +graphics 856 +herod 856 +huntsmen 856 +incomprehensible 856 +interpreted 856 +kaoru 856 +lenient 856 +openings 856 +ozone 856 +perky 856 +punjabi 856 +retract 856 +unavoidable 856 +amid 855 +anonymously 855 +crocker 855 +demolish 855 +dispense 855 +generously 855 +occupational 855 +peabody 855 +prodigal 855 +roe 855 +telepathy 855 +youths 855 +hyena 854 +loudspeaker 854 +osgood 854 +roxie 854 +stardate 854 +torched 854 +yawn 854 +dal 853 +hillside 853 +imaginative 853 +mazel 853 +milkman 853 +prevention 853 +reiko 853 +toenails 853 +tout 853 +transcripts 853 +arithmetic 852 +cardassian 852 +clanks 852 +dramas 852 +heretic 852 +hives 852 +renewal 852 +repression 852 +squawk 852 +subsequently 852 +y-yes 852 +burbank 851 +deadbeat 851 +geology 851 +interval 851 +mooney 851 +nav 851 +performs 851 +proceeded 851 +prometheus 851 +quickie 851 +senorita 851 +shocker 851 +spectacles 851 +withdrawing 851 +feldman 850 +governors 850 +kennel 850 +marquise 850 +millennia 850 +sinus 850 +six-pack 850 +subscription 850 +thi 850 +unintelligible 850 +warlord 850 +zed 850 +dag 849 +gaia 849 +withheld 849 +boa 848 +brittle 848 +cutters 848 +involuntary 848 +massacred 848 +oils 848 +pelt 848 +reciting 848 +rosetta 848 +unprepared 848 +willingness 848 +bebe 847 +blaster 847 +coarse 847 +contemplating 847 +defeats 847 +frieda 847 +ins 847 +mala 847 +portrayed 847 +scrubs 847 +tricia 847 +walrus 847 +annihilation 846 +facade 846 +filipino 846 +filmmakers 846 +firewall 846 +hollering 846 +jawohl 846 +luv 846 +lyons 846 +ps 846 +simmer 846 +tennyson 846 +textbooks 846 +valencia 846 +weld 846 +conserve 845 +divulge 845 +gab 845 +khanna 845 +kieran 845 +quartet 845 +ravens 845 +sapna 845 +stepdad 845 +ta-ta 845 +weirdos 845 +wiley 845 +breathless 844 +clocked 844 +conferences 844 +dusted 844 +epiphany 844 +joseon 844 +kick-ass 844 +mantra 844 +motherhood 844 +silicone 844 +smacking 844 +tanning 844 +vests 844 +check-up 843 +cherie 843 +decor 843 +flux 843 +maynard 843 +perch 843 +secluded 843 +smother 843 +totem 843 +bridesmaids 842 +dem 842 +informants 842 +jehovah 842 +keung 842 +mower 842 +patronize 842 +pediatrician 842 +anti 841 +coca-cola 841 +dials 841 +exes 841 +gland 841 +harming 841 +pliers 841 +pooch 841 +reinstated 841 +stricken 841 +thunk 841 +tract 841 +zeta 841 +blabbering 840 +clerks 840 +coo 840 +curriculum 840 +disputes 840 +dizziness 840 +festivals 840 +firehouse 840 +frida 840 +groves 840 +grudges 840 +haines 840 +knowingly 840 +newcastle 840 +plural 840 +pug 840 +stalag 840 +tandy 840 +chopin 839 +dahlia 839 +dictated 839 +dios 839 +liras 839 +peking 839 +rebekah 839 +savvy 839 +siding 839 +valor 839 +affordable 838 +disorderly 838 +eulogy 838 +felonies 838 +gent 838 +gingerbread 838 +marlow 838 +misdemeanor 838 +psi 838 +scrawny 838 +unattractive 838 +accommodations 837 +baby-sit 837 +cayman 837 +commons 837 +compressed 837 +crammed 837 +flagged 837 +mori 837 +mugging 837 +optimist 837 +possessive 837 +shutdown 837 +spaniard 837 +waitresses 837 +wanders 837 +archers 836 +aspiring 836 +autism 836 +commandment 836 +cremation 836 +crossbow 836 +czechoslovakia 836 +fateful 836 +geologist 836 +kovac 836 +leningrad 836 +lexington 836 +petroleum 836 +rooftops 836 +blooms 835 +clair 835 +debrief 835 +diem 835 +dysfunctional 835 +ifl 835 +lamborghini 835 +make-believe 835 +moped 835 +picturing 835 +pulses 835 +purchases 835 +antarctic 834 +atkins 834 +awaited 834 +barbershop 834 +beards 834 +chaser 834 +chatty 834 +condone 834 +crafts 834 +devin 834 +excitedly 834 +inez 834 +maintains 834 +manned 834 +rana 834 +subbing 834 +archibald 833 +baffled 833 +foolproof 833 +gunny 833 +insatiable 833 +jungles 833 +nutter 833 +probst 833 +readily 833 +sheng 833 +skater 833 +spacious 833 +upstanding 833 +abba 832 +confine 832 +daydreaming 832 +diablo 832 +exaggeration 832 +fam 832 +incest 832 +leone 832 +mathilde 832 +shorten 832 +unanswered 832 +alot 831 +americas 831 +biggs 831 +clarinet 831 +crusader 831 +cuddly 831 +garde 831 +graze 831 +guise 831 +middleman 831 +ramu 831 +scavenger 831 +altercation 830 +baa 830 +freckles 830 +furthest 830 +mcduck 830 +overhear 830 +propane 830 +sickening 830 +skaar 830 +thrills 830 +toothache 830 +tudor 830 +aagh 829 +ambassadors 829 +courtship 829 +enraged 829 +esmeralda 829 +fitzpatrick 829 +fuses 829 +grocer 829 +locating 829 +loomis 829 +ordinance 829 +superpower 829 +swamps 829 +unpopular 829 +zhen 829 +cabinets 828 +conjure 828 +cylinders 828 +glorified 828 +hustling 828 +pics 828 +ripple 828 +shipments 828 +smashes 828 +spied 828 +winch 828 +zak 828 +chlorine 827 +dui 827 +h-how 827 +hadley 827 +ifwe 827 +mongrel 827 +oscars 827 +projections 827 +radiology 827 +soggy 827 +assumes 826 +banners 826 +boycott 826 +bradshaw 826 +consumers 826 +excursion 826 +germ 826 +klingons 826 +mesa 826 +pats 826 +progressed 826 +regal 826 +stafford 826 +telescopes 826 +unclean 826 +dun 825 +gm 825 +heartland 825 +initiating 825 +jenn 825 +mmhmm 825 +modifications 825 +spontaneously 825 +takeda 825 +alcoholics 824 +campaigning 824 +diaphragm 824 +droid 824 +feces 824 +fiver 824 +grips 824 +handbook 824 +horde 824 +obstinate 824 +outburst 824 +premise 824 +roderick 824 +scorpions 824 +trev 824 +croak 823 +derived 823 +executing 823 +interim 823 +rudolf 823 +serbs 823 +snowed 823 +adrift 822 +bisexual 822 +crore 822 +drowns 822 +eradicate 822 +expendable 822 +hyper 822 +inning 822 +pippi 822 +pointers 822 +rosary 822 +scourge 822 +self-conscious 822 +snyder 822 +spunk 822 +three-way 822 +tic 822 +addams 821 +alleys 821 +chandu 821 +clooney 821 +financials 821 +hanger 821 +hazy 821 +irritable 821 +kalinda 821 +neuro 821 +noelle 821 +ono 821 +relocate 821 +silky 821 +sunflower 821 +sutter 821 +tricking 821 +dink 820 +encyclopedia 820 +moroccan 820 +popsicle 820 +ptsd 820 +second-hand 820 +sermons 820 +stationary 820 +stripe 820 +urging 820 +yau 820 +callum 819 +compute 819 +consensual 819 +durga 819 +excruciating 819 +guinness 819 +harald 819 +helsinki 819 +liberals 819 +masterson 819 +mcguire 819 +nikhil 819 +overjoyed 819 +parched 819 +predicting 819 +ravioli 819 +trays 819 +warehouses 819 +cartridges 818 +commissar 818 +fruity 818 +rafa 818 +raiding 818 +soiled 818 +standpoint 818 +stayin 818 +stereotype 818 +sustainable 818 +whitaker 818 +alchemy 817 +antimatter 817 +creams 817 +drifter 817 +egon 817 +fairytale 817 +financed 817 +intimately 817 +keisha 817 +purest 817 +raisin 817 +saki 817 +spectator 817 +thereafter 817 +watcher 817 +withered 817 +wrapper 817 +curing 816 +deluded 816 +deprivation 816 +holling 816 +landon 816 +loren 816 +maki 816 +piers 816 +rutherford 816 +shovels 816 +wynn 816 +banzai 815 +binds 815 +botched 815 +botox 815 +caved 815 +eject 815 +immersed 815 +keating 815 +kouji 815 +mackey 815 +nailing 815 +nfl 815 +overcooked 815 +pinto 815 +qc 815 +rollo 815 +rumored 815 +supplement 815 +takeshi 815 +tinkle 815 +tragedies 815 +aristocrat 814 +asphalt 814 +bobbi 814 +bowed 814 +charred 814 +crucifix 814 +czar 814 +defects 814 +floorboards 814 +muppet 814 +seismic 814 +tamed 814 +tycoon 814 +vocals 814 +withhold 814 +balm 813 +cooker 813 +epilepsy 813 +garments 813 +nagasaki 813 +poachers 813 +recurring 813 +self-centered 813 +shakily 813 +swaying 813 +wakey 813 +welcomes 813 +weller 813 +fifi 812 +flunked 812 +georgetown 812 +mach 812 +moors 812 +nim 812 +pardoned 812 +quirky 812 +ritter 812 +sheds 812 +smart-ass 812 +sparring 812 +steamer 812 +yeong 812 +yippee 812 +blackberry 811 +ceases 811 +fiercely 811 +impound 811 +lexie 811 +nesting 811 +sabina 811 +ucla 811 +bengal 810 +cache 810 +consequently 810 +creme 810 +daeso 810 +fluttering 810 +hallie 810 +hos 810 +larvae 810 +lifeboat 810 +revolutions 810 +rhine 810 +rosewood 810 +shim 810 +shyam 810 +yeti 810 +accessing 809 +aphrodite 809 +cools 809 +gregson 809 +hosted 809 +jeanie 809 +kicker 809 +lasse 809 +mandela 809 +misleading 809 +occupying 809 +ordained 809 +pre 809 +presiding 809 +riled 809 +sharona 809 +snuggle 809 +turnover 809 +brazen 808 +colonists 808 +cyclone 808 +eah 808 +hogs 808 +hombre 808 +lovingly 808 +nassau 808 +nook 808 +physicians 808 +prof 808 +researched 808 +splashes 808 +stitching 808 +unwise 808 +zheng 808 +affliction 807 +aspire 807 +bela 807 +carnal 807 +deirdre 807 +distressing 807 +dow 807 +generic 807 +hooch 807 +lacerations 807 +lifetimes 807 +melodramatic 807 +nepal 807 +pai 807 +projecting 807 +sentinel 807 +takahashi 807 +tasteless 807 +unattended 807 +unbreakable 807 +welch 807 +yelping 807 +chore 806 +dept 806 +efficiently 806 +emphasize 806 +gonzales 806 +hashtag 806 +hellhole 806 +lasalle 806 +melons 806 +recruitment 806 +reek 806 +renault 806 +wilhelmina 806 +wobbly 806 +aired 805 +busts 805 +chardonnay 805 +combining 805 +deactivate 805 +evasion 805 +goblins 805 +lowry 805 +meats 805 +merrier 805 +monetary 805 +napping 805 +nigeria 805 +notjust 805 +ofyour 805 +regrettable 805 +scat 805 +tac 805 +tormenting 805 +ugliest 805 +vixen 805 +alamo 804 +clementine 804 +communicator 804 +fuels 804 +halibut 804 +laced 804 +overworked 804 +phi 804 +pta 804 +salome 804 +seon 804 +supervising 804 +waterproof 804 +chakotay 803 +detonated 803 +double-check 803 +femur 803 +garnish 803 +germaine 803 +implicate 803 +kam 803 +leper 803 +mendez 803 +nicknamed 803 +obeying 803 +ponder 803 +seducing 803 +situated 803 +slogans 803 +spruce 803 +technicality 803 +twit 803 +unpacking 803 +vindictive 803 +captures 802 +contender 802 +enlarged 802 +gallop 802 +glittering 802 +gopi 802 +hammock 802 +hypocrites 802 +illnesses 802 +larissa 802 +lieutenants 802 +lutz 802 +margaritas 802 +mural 802 +orpheus 802 +prolong 802 +shogunate 802 +waldorf 802 +woven 802 +bs 801 +centered 801 +chihuahua 801 +foes 801 +ie 801 +issuing 801 +pasadena 801 +proprietor 801 +rosita 801 +speciality 801 +stroller 801 +tehran 801 +tig 801 +whistler 801 +cardio 800 +delaying 800 +edwina 800 +fahrenheit 800 +fingernail 800 +gills 800 +granting 800 +hindus 800 +hubbard 800 +lolly 800 +mistresses 800 +puyo 800 +savoy 800 +sit-down 800 +sos 800 +starry 800 +anonymity 799 +barricades 799 +berth 799 +buttercup 799 +chaste 799 +clippings 799 +contests 799 +dia 799 +dodd 799 +hitter 799 +incriminate 799 +insecurity 799 +just-just 799 +mod 799 +night-night 799 +stoner 799 +variable 799 +abrupt 798 +browns 798 +celsius 798 +chloroform 798 +co-worker 798 +dislocated 798 +essays 798 +exodus 798 +gonzo 798 +hauser 798 +intervened 798 +kuwait 798 +poised 798 +proclaimed 798 +refinery 798 +rustic 798 +startin 798 +techs 798 +allegation 797 +confessional 797 +duped 797 +hoe 797 +lastly 797 +mane 797 +motorway 797 +mun 797 +pinching 797 +polio 797 +rupa 797 +sayonara 797 +sideshow 797 +spout 797 +tagging 797 +undergoing 797 +buick 796 +climber 796 +confronting 796 +diplomats 796 +exhibits 796 +garvey 796 +healthcare 796 +liberating 796 +mattresses 796 +micro 796 +nowt 796 +opal 796 +reba 796 +smothered 796 +sulu 796 +teapot 796 +toxicology 796 +built-in 795 +cater 795 +contend 795 +disoriented 795 +insights 795 diff --git a/openless-all/app/android/assets/openless_wordmark.jpg b/openless-all/app/android/assets/openless_wordmark.jpg new file mode 100644 index 000000000..daf3411b3 Binary files /dev/null and b/openless-all/app/android/assets/openless_wordmark.jpg differ diff --git a/openless-all/app/android/assets/pinyin_chars.LICENSE.txt b/openless-all/app/android/assets/pinyin_chars.LICENSE.txt new file mode 100644 index 000000000..000024a8e --- /dev/null +++ b/openless-all/app/android/assets/pinyin_chars.LICENSE.txt @@ -0,0 +1,38 @@ +pinyin_chars.tsv — attribution, source pinning, and license +============================================================= + +Sources: + +1. mozillazg/pinyin-data — kMandarin_8105.txt (character set + primary + reading) and pinyin.txt (supplementary readings for polyphones) + https://github.com/mozillazg/pinyin-data + Commit: 923b108dc5d45dee061324c011b478fb649f8b73 + Downloaded: 2026-09-25 + SHA-256 (kMandarin_8105.txt): 1b1546f6e190c0f1cbf23e3bb436b41c3ace16f852be4daebc2c7295636628a4 + SHA-256 (pinyin.txt): 621f8ca9eff8519f47e2b17b564fd318161e13bca07eea8c8e04993cd5d3b52e + License: MIT License, Copyright (c) 2016 mozillazg. Full text: + https://github.com/mozillazg/pinyin-data/blob/master/LICENSE + +2. rime/rime-pinyin-simp — pinyin_simp.dict.yaml (usage-frequency weight + per character+reading; single-character rows only — see + pinyin_phrases.LICENSE.txt for this same file's multi-character rows) + https://github.com/rime/rime-pinyin-simp + Commit: 0c6861ef7420ee780270ca6d993d18d4101049d0 + Downloaded: 2026-09-25 + SHA-256: e341598343a0f0f2035bb1aafc34a7f3bb7887deeecb3f60796262aaa2983e6b + License: Apache License 2.0. Full text: + https://github.com/rime/rime-pinyin-simp/blob/master/LICENSE + +Generation: this repo's scripts/generate-pinyin-characters.mjs — see that +script's own header comment for the exact re-fetch/regenerate commands and +selection/ranking rules. Covers the full 8105-character 《通用规范汉字表》 +set (raised from an initial ~5000-character cutoff on 2026-09-25, once the +smaller set proved noticeably incomplete in real use — the plan's own 7.1 +anticipated this: "如果5000字实测仍然很小,可在不改变架构的情况下扩展到 +8105个规范汉字"), primary reading always kept, up to 2 additional "常用" +polyphone readings kept when their own weight clears a threshold. Output +rows are tab-separated +"characterpinyinweight", pinyin lowercase ASCII with ü normalized +to v and tone marks stripped. No runtime code in OpenLess downloads, +modifies, or redistributes either original source — only this generated, +filtered derivative ships in the app. diff --git a/openless-all/app/android/assets/pinyin_chars.tsv b/openless-all/app/android/assets/pinyin_chars.tsv new file mode 100644 index 000000000..66dc8360c --- /dev/null +++ b/openless-all/app/android/assets/pinyin_chars.tsv @@ -0,0 +1,8225 @@ +的 de 4828294 +了 le 1500186 +我 wo 1192789 +是 shi 1180957 +在 zai 974740 +你 ni 973526 +和 he 664874 +有 you 613906 +不 bu 590643 +就 jiu 558432 +也 ye 404388 +都 dou 401877 +要 yao 396900 +吧 ba 385205 +好 hao 378543 +他 ta 343908 +说 shuo 338803 +去 qu 330876 +到 dao 296879 +没 mei 295035 +上 shang 284918 +对 dui 279642 +还 hai 279642 +那 na 269381 +人 ren 250285 +个 ge 245957 +看 kan 245039 +中 zhong 243213 +呢 ne 239902 +来 lai 233997 +吗 ma 220136 +为 wei 210479 +会 hui 208391 +她 ta 204022 +与 yu 202250 +给 gei 191937 +等 deng 182604 +这 zhe 178109 +用 yong 168186 +把 ba 165484 +下 xia 163638 +恩 en 162015 +能 neng 161410 +很 hen 160208 +做 zuo 154137 +多 duo 154137 +想 xiang 141790 +被 bei 138128 +点 dian 137956 +而 er 135908 +后 hou 134560 +一 yi 134392 +又 you 132893 +从 cong 126905 +再 zai 120734 +将 jiang 119239 +时 shi 117616 +但 dan 115581 +过 guo 111897 +么 me 110511 +打 da 107925 +大 da 106058 +年 nian 105268 +啦 la 102549 +地 di 101785 +以 yi 100400 +买 mai 100275 +里 li 98050 +让 rang 97928 +小 xiao 92013 +着 zhe 87757 +先 xian 84326 +啊 a 83803 +或 huo 83282 +呀 ya 79232 +并 bing 79035 +找 zhao 78056 +新 xin 77282 +及 ji 77186 +得 de 75568 +跟 gen 74632 +于 yu 73708 +太 tai 71180 +嘛 ma 70738 +号 hao 70299 +日 ri 70124 +才 cai 69949 +发 fa 69515 +可 ke 68911 +比 bi 68654 +玩 wan 67131 +们 men 65806 +最 zui 65806 +月 yue 65397 +吃 chi 65315 +真 zhen 65153 +它 ta 58897 +其 qi 58168 +话 hua 57807 +请 qing 55964 +钱 qian 55755 +叫 jiao 55685 +出 chu 55547 +开 kai 54927 +哈 ha 53642 +当 dang 52846 +家 jia 52256 +向 xiang 52126 +问 wen 49777 +之 zhi 49530 +更 geng 48734 +该 gai 48251 +别 bie 48130 +走 zou 48130 +快 kuai 48011 +前 qian 46770 +由 you 46538 +高 gao 45167 +您 nin 44386 +加 jia 44110 +天 tian 44055 +所 suo 44055 +却 que 43347 +已 yi 43347 +谁 shui 41704 +内 nei 40881 +写 xie 40881 +啥 sha 40526 +搞 gao 39924 +老 lao 39924 +事 shi 38942 +只 zhi 38748 +拿 na 38603 +带 dai 38459 +如 ru 37936 +死 si 36726 +行 xing 35822 +卖 mai 35689 +晕 yun 35555 +地 de 35028 +像 xiang 34680 +使 shi 34422 +刚 gang 33324 +成 cheng 33117 +换 huan 32585 +无 wu 32585 +嗯 n 31982 +嗯 ng 31982 +哪 na 31902 +回 hui 31665 +爱 ai 31546 +听 ting 31156 +少 shao 31117 +元 yuan 30885 +卡 ka 30770 +靠 kao 30617 +帮 bang 30125 +算 suan 29938 +者 zhe 29752 +忙 mang 29641 +起 qi 29383 +各 ge 29019 +分 fen 28803 +学 xue 28803 +总 zong 28517 +则 ze 28270 +水 shui 27608 +改 gai 27437 +咯 lo 27403 +唉 ai 27403 +张 zhang 27300 +三 san 26761 +连 lian 26463 +见 jian 26266 +放 fang 26038 +车 che 26005 +万 wan 25941 +全 quan 25876 +按 an 25812 +区 qu 25334 +早 zao 25302 +拉 la 25270 +偶 ou 24864 +本 ben 24833 +页 ye 24679 +进 jin 24648 +性 xing 24434 +弄 nong 24374 +第 di 24313 +完 wan 24072 +同 tong 24012 +系 xi 23892 +花 hua 23744 +怕 pa 22787 +女 nv 22702 +讲 jiang 22589 +杀 sha 22449 +组 zu 22421 +干 gan 22116 +错 cuo 22088 +些 xie 21951 +住 zhu 21924 +跑 pao 21815 +名 ming 21788 +次 ci 21278 +睡 shui 21225 +网 wang 21198 +装 zhuang 20962 +挺 ting 20754 +掉 diao 20677 +王 wang 20625 +图 tu 20548 +正 zheng 20523 +外 wai 20471 +路 lu 20471 +二 er 20319 +生 sheng 20268 +头 tou 20243 +应 ying 20243 +心 xin 20168 +因 yin 19893 +子 zi 19769 +强 qiang 19428 +作 zuo 19307 +类 lei 19235 +送 song 19211 +每 mei 19115 +书 shu 19044 +乖 guai 18529 +法 fa 18506 +拍 pai 18299 +表 biao 18141 +市 shi 18050 +撒 sa 18028 +至 zhi 17782 +接 jie 17760 +美 mei 17650 +未 wei 17562 +办 ban 17518 +非 fei 17475 +哎 ai 17453 +级 ji 17388 +即 ji 17366 +此 ci 17237 +版 ban 17087 +副 fu 17045 +差 cha 16939 +俺 an 16897 +收 shou 16855 +男 nan 16792 +了 liao 16750 +字 zi 16750 +股 gu 16667 +几 ji 16625 +考 kao 16604 +两 liang 16584 +转 zhuan 16584 +块 kuai 16563 +线 xian 16338 +周 zhou 16297 +手 shou 16196 +道 dao 16155 +黄 huang 16135 +便 bian 16095 +米 mi 15995 +聊 liao 15975 +猪 zhu 15935 +取 qu 15876 +哇 wa 15817 +低 di 15738 +难 nan 15640 +哦 o 15582 +儿 er 15408 +包 bao 15389 +受 shou 15389 +阿 a 15389 +喝 he 15293 +白 bai 15236 +金 jin 15142 +管 guan 15029 +近 jin 15029 +四 si 15010 +定 ding 14898 +够 gou 14732 +交 jiao 14714 +门 men 14714 +样 yang 14677 +李 li 14513 +笑 xiao 14513 +传 chuan 14495 +自 zi 14405 +慢 man 14352 +边 bian 14352 +刷 shua 14316 +黑 hei 14316 +型 xing 14298 +穿 chuan 14227 +越 yue 14156 +位 wei 14033 +贵 gui 13963 +长 chang 13911 +部 bu 13825 +得 dei 13791 +据 ju 13756 +怪 guai 13739 +牛 niu 13739 +处 chu 13722 +选 xuan 13705 +站 zhan 13654 +倒 dao 13603 +省 sheng 13586 +谈 tan 13518 +称 cheng 13485 +台 tai 13451 +群 qun 13451 +汗 han 13318 +累 lei 13301 +经 jing 13284 +查 cha 13251 +象 xiang 13251 +变 bian 13218 +半 ban 13186 +数 shu 13153 +格 ge 13038 +面 mian 13022 +红 hong 12941 +陈 chen 12941 +五 wu 12861 +方 fang 12765 +顶 ding 12733 +楼 lou 12718 +读 du 12702 +某 mou 12623 +乱 luan 12576 +条 tiao 12498 +远 yuan 12498 +马 ma 12498 +海 hai 12451 +单 dan 12374 +相 xiang 12374 +光 guang 12327 +热 re 12281 +且 qie 12175 +咋 za 12160 +种 zhong 12160 +挂 gua 12144 +风 feng 12099 +林 lin 12069 +满 man 12039 +额 e 12039 +谁 shei 11979 +化 hua 11875 +班 ban 11860 +滴 di 11816 +涨 zhang 11728 +关 guan 11612 +店 dian 11612 +机 ji 11612 +安 an 11597 +坐 zuo 11583 +俄 e 11525 +度 du 11468 +约 yue 11468 +飞 fei 11468 +刘 liu 11439 +歌 ge 11397 +信 xin 11312 +报 bao 11312 +若 ruo 11227 +原 yuan 11213 +饭 fan 11199 +文 wen 11186 +晚 wan 11186 +量 liang 11116 +村 cun 11088 +式 shi 11075 +空 kong 10992 +毛 mao 10978 +山 shan 10951 +主 zhu 10842 +另 ling 10842 +伐 fa 10721 +间 jian 10668 +入 ru 10589 +占 zhan 10562 +贴 tie 10562 +爽 shuang 10536 +盘 pan 10536 +赚 zhuan 10432 +往 wang 10406 +队 dui 10367 +杨 yang 10328 +片 pian 10277 +狗 gou 10200 +国 guo 10112 +坏 huai 10112 +费 fei 10112 +岁 sui 10074 +血 xue 10049 +离 li 9937 +西 xi 9937 +共 gong 9924 +龙 long 9924 +明 ming 9887 +妈 ma 9875 +超 chao 9875 +均 jun 9826 +切 qie 9716 +期 qi 9716 +照 zhao 9704 +达 da 9692 +留 liu 9584 +特 te 9524 +动 dong 9465 +气 qi 9442 +哥 ge 9418 +神 shen 9418 +鸟 niao 9418 +教 jiao 9371 +甘 gan 9371 +梦 meng 9302 +蛮 man 9290 +唔 wu 9244 +值 zhi 9175 +长 zhang 9175 +鱼 yu 9175 +骂 ma 9163 +陪 pei 9129 +既 ji 9095 +哭 ku 9073 +镇 zhen 9073 +建 jian 9016 +洗 xi 9016 +力 li 8983 +懂 dong 8960 +双 shuang 8838 +口 kou 8827 +指 zhi 8783 +跌 die 8772 +仍 reng 8751 +活 huo 8740 +球 qiu 8740 +练 lian 8718 +东 dong 8696 +提 ti 8578 +清 qing 8578 +亿 yi 8567 +除 chu 8546 +云 yun 8398 +通 tong 8398 +酒 jiu 8304 +冲 chong 8294 +饿 e 8284 +猫 mao 8212 +咩 mie 8191 +票 piao 8191 +假 jia 8140 +久 jiu 8100 +六 liu 8090 +病 bing 8090 +画 hua 8040 +抓 zhua 8029 +呗 bei 8019 +曾 ceng 8019 +平 ping 8010 +急 ji 7980 +段 duan 7980 +套 tao 7960 +课 ke 7950 +药 yao 7930 +草 cao 7871 +任 ren 7851 +理 li 7822 +城 cheng 7812 +罗 luo 7793 +令 ling 7764 +货 huo 7754 +唱 chang 7744 +雨 yu 7744 +左 zuo 7716 +哟 yo 7696 +蓝 lan 7677 +抢 qiang 7639 +常 chang 7630 +菜 cai 7620 +色 se 7601 +县 xian 7573 +混 hun 7535 +集 ji 7498 +感 gan 7460 +操 cao 7451 +火 huo 7451 +敢 gan 7442 +较 jiao 7414 +退 tui 7395 +房 fang 7386 +南 nan 7350 +脸 lian 7322 +破 po 7313 +件 jian 7286 +噢 o 7268 +呃 e 7259 +吴 wu 7222 +德 de 7178 +借 jie 7169 +声 sheng 7169 +傻 sha 7160 +克 ke 7151 +整 zheng 7151 +雪 xue 7151 +喊 han 7142 +局 ju 7098 +跳 tiao 7089 +补 bu 7071 +养 yang 7053 +华 hua 7045 +恶 e 7036 +团 tuan 7027 +现 xian 7027 +反 fan 6966 +砸 za 6923 +待 dai 6914 +篇 pian 6897 +烦 fan 6871 +投 tou 6854 +根 gen 6854 +深 shen 6820 +求 qiu 6812 +亲 qin 6778 +电 dian 6752 +配 pei 6736 +修 xiu 6710 +沈 shen 6710 +款 kuan 6685 +八 ba 6677 +知 zhi 6669 +叶 ye 6660 +鬼 gui 6652 +何 he 6636 +器 qi 6578 +试 shi 6578 +乐 le 6513 +谢 xie 6505 +层 ceng 6480 +木 mu 6480 +仅 jin 6472 +北 bei 6464 +肉 rou 6416 +份 fen 6408 +重 zhong 6392 +情 qing 6384 +赵 zhao 6376 +需 xu 6368 +记 ji 6360 +七 qi 6313 +流 liu 6305 +合 he 6297 +列 lie 6282 +升 sheng 6266 +噶 ga 6258 +招 zhao 6258 +论 lun 6258 +痛 tong 6235 +率 lv 6211 +封 feng 6204 +石 shi 6204 +香 xiang 6204 +眼 yan 6142 +冷 leng 6135 +易 yi 6119 +付 fu 6081 +派 pai 6066 +帅 shuai 6051 +阳 yang 6013 +胡 hu 6006 +厂 chang 5991 +十 shi 5976 +排 pai 5969 +油 you 5961 +停 ting 5939 +树 shu 5939 +奥 ao 5814 +军 jun 5771 +牌 pai 5771 +科 ke 5764 +身 shen 5764 +翻 fan 5742 +星 xing 5735 +帖 tie 5721 +随 sui 5721 +权 quan 5707 +泡 pao 5700 +价 jia 5685 +还 huan 5685 +商 shang 5650 +库 ku 5650 +玉 yu 5643 +追 zhui 5643 +场 chang 5636 +须 xu 5622 +物 wu 5615 +背 bei 5608 +章 zhang 5608 +丢 diu 5594 +朱 zhu 5587 +宝 bao 5580 +脚 jiao 5573 +体 ti 5566 +伤 shang 5552 +解 jie 5545 +都 du 5545 +剑 jian 5532 +咱 zan 5532 +代 dai 5504 +亦 yi 5497 +底 di 5497 +孙 sun 5484 +业 ye 5470 +室 shi 5463 +旧 jiu 5456 +工 gong 5450 +极 ji 5429 +曾 zeng 5422 +刀 dao 5409 +波 bo 5395 +铁 tie 5369 +词 ci 5362 +笨 ben 5349 +哦 e 5335 +丫 ya 5309 +推 tui 5296 +证 zheng 5296 +炒 chao 5276 +春 chun 5269 +耍 shua 5269 +输 shu 5263 +骗 pian 5243 +轻 qing 5236 +含 han 5223 +九 jiu 5217 +俩 lia 5217 +千 qian 5217 +江 jiang 5217 +秒 miao 5204 +徐 xu 5191 +落 luo 5191 +短 duan 5185 +青 qing 5178 +节 jie 5152 +夜 ye 5146 +吹 chui 5133 +游 you 5120 +土 tu 5101 +姐 jie 5089 +板 ban 5076 +赢 ying 5076 +许 xu 5070 +直 zhi 5038 +苦 ku 5038 +公 gong 5026 +君 jun 5013 +烂 lan 5013 +皮 pi 5013 +秀 xiu 5013 +博 bo 5007 +亏 kui 4988 +存 cun 4982 +师 shi 4963 +立 li 4957 +素 su 4957 +利 li 4951 +吾 wu 4951 +烧 shao 4945 +诗 shi 4945 +睇 di 4939 +命 ming 4908 +制 zhi 4902 +静 jing 4902 +民 min 4896 +欲 yu 4883 +汤 tang 4871 +冰 bing 4841 +断 duan 4835 +造 zao 4835 +忘 wang 4829 +茶 cha 4823 +呆 dai 4817 +抽 chou 4805 +案 an 4805 +供 gong 4781 +尽 jin 4781 +布 bu 4781 +田 tian 4781 +抱 bao 4769 +烟 yan 4763 +初 chu 4757 +野 ye 4734 +胖 pang 4710 +品 pin 4693 +紧 jin 4681 +绿 lv 4675 +项 xiang 4675 +故 gu 4669 +签 qian 4658 +调 tiao 4646 +服 fu 4634 +苏 su 4634 +奖 jiang 4629 +兵 bing 4611 +填 tian 4606 +演 yan 4606 +百 bai 4588 +英 ying 4588 +嘴 zui 4583 +般 ban 4577 +挖 wa 4566 +准 zhun 4543 +重 chong 4543 +保 bao 4532 +果 guo 4532 +硬 ying 4526 +凡 fan 4520 +寒 han 4520 +挑 tiao 4515 +唐 tang 4509 +莫 mo 4509 +鸡 ji 4509 +夏 xia 4503 +寄 ji 4492 +言 yan 4470 +闹 nao 4470 +防 fang 4470 +竟 jing 4453 +纯 chun 4448 +距 ju 4448 +题 ti 4442 +官 guan 4437 +余 yu 4431 +源 yuan 4426 +街 jie 4420 +座 zuo 4409 +古 gu 4404 +思 si 4404 +删 shan 4393 +亮 liang 4387 +灯 deng 4376 +穷 qiong 4376 +郭 guo 4365 +戏 xi 4360 +桥 qiao 4360 +党 dang 4354 +爆 bao 4354 +首 shou 4344 +粉 fen 4338 +念 nian 4327 +钟 zhong 4317 +属 shu 4306 +戴 dai 4306 +疼 teng 4306 +然 ran 4290 +韩 han 4279 +扣 kou 4258 +租 zu 4253 +捏 nie 4237 +港 gang 4237 +狼 lang 4237 +盖 gai 4231 +雷 lei 4231 +兰 lan 4205 +狂 kuang 4205 +引 yin 4195 +佛 fo 4148 +压 ya 4148 +梁 liang 4148 +猛 meng 4138 +船 chuan 4132 +今 jin 4112 +齐 qi 4112 +亚 ya 4107 +托 tuo 4107 +恨 hen 4102 +赛 sai 4086 +宋 song 4081 +客 ke 4081 +斯 si 4076 +沙 sha 4071 +腿 tui 4066 +银 yin 4061 +领 ling 4061 +码 ma 4046 +友 you 4041 +虽 sui 4041 +祝 zhu 4031 +员 yuan 4026 +句 ju 4021 +丁 ding 4011 +望 wang 3996 +战 zhan 3991 +缺 que 3991 +食 shi 3981 +床 chuang 3976 +着 zhao 3976 +抗 kang 3971 +贼 zei 3971 +乡 xiang 3961 +界 jie 3961 +困 kun 3946 +河 he 3946 +松 song 3941 +郑 zheng 3941 +否 fou 3931 +步 bu 3931 +园 yuan 3927 +折 zhe 3927 +尚 shang 3912 +羊 yang 3912 +福 fu 3902 +宁 ning 3897 +必 bi 3878 +遭 zao 3873 +猜 cai 3859 +伊 yi 3849 +秋 qiu 3839 +端 duan 3825 +插 cha 3811 +毒 du 3811 +滚 gun 3811 +熊 xiong 3811 +获 huo 3801 +秦 qin 3797 +卷 juan 3792 +持 chi 3792 +设 she 3787 +架 jia 3782 +朝 chao 3764 +喂 wei 3754 +归 gui 3754 +广 guang 3745 +踢 ti 3745 +灵 ling 3722 +搬 ban 3708 +迷 mi 3703 +胜 sheng 3699 +基 ji 3694 +意 yi 3694 +赶 gan 3694 +乃 nai 3671 +甲 jia 3666 +拖 tuo 3653 +摸 mo 3639 +史 shi 3635 +右 you 3635 +惨 can 3630 +爸 ba 3625 +塔 ta 3616 +订 ding 3616 +纸 zhi 3607 +散 san 3585 +呈 cheng 3576 +姓 xing 3576 +圈 quan 3567 +洋 yang 3567 +咬 yao 3558 +喜 xi 3558 +嘎 ga 3558 +闪 shan 3554 +富 fu 3549 +治 zhi 3536 +喔 wo 3527 +料 liao 3523 +汉 han 3523 +批 pi 3519 +院 yuan 3519 +逛 guang 3514 +峰 feng 3510 +狠 hen 3506 +醒 xing 3506 +咧 lie 3501 +爬 pa 3501 +梅 mei 3492 +瘦 shou 3488 +降 jiang 3488 +结 jie 3484 +户 hu 3479 +疯 feng 3471 +偷 tou 3462 +语 yu 3458 +枪 qiang 3453 +踩 cai 3453 +足 zu 3449 +精 jing 3445 +志 zhi 3436 +支 zhi 3428 +呵 he 3423 +软 ruan 3419 +杜 du 3411 +赞 zan 3402 +依 yi 3390 +丽 li 3385 +展 zhan 3377 +康 kang 3377 +兴 xing 3373 +救 jiu 3373 +倍 bei 3368 +堂 tang 3364 +鞋 xie 3364 +愿 yuan 3339 +键 jian 3306 +似 si 3298 +冒 mao 3290 +守 shou 3290 +音 yin 3290 +观 guan 3281 +仲 zhong 3273 +弱 ruo 3273 +替 ti 3257 +世 shi 3253 +笔 bi 3249 +闲 xian 3241 +逼 bi 3237 +牙 ya 3233 +舞 wu 3233 +荣 rong 3233 +著 zhu 3225 +遍 bian 3213 +影 ying 3209 +行 hang 3209 +曲 qu 3201 +丑 chou 3197 +吓 xia 3197 +州 zhou 3193 +巴 ba 3193 +测 ce 3189 +状 zhuang 3181 +宽 kuan 3177 +武 wu 3161 +箱 xiang 3161 +程 cheng 3157 +偏 pian 3153 +庄 zhuang 3153 +细 xi 3153 +耶 ye 3149 +联 lian 3141 +湖 hu 3137 +晒 shai 3130 +柳 liu 3130 +锁 suo 3122 +印 yin 3118 +味 wei 3099 +紫 zi 3099 +众 zhong 3095 +环 huan 3091 +零 ling 3091 +实 shi 3087 +答 da 3087 +厚 hou 3079 +娘 niang 3079 +懒 lan 3072 +寸 cun 3068 +标 biao 3068 +兼 jian 3049 +孔 kong 3045 +欧 ou 3041 +勒 le 3034 +吖 a 3034 +闷 men 3034 +岛 dao 3026 +灭 mie 3026 +登 deng 3026 +顾 gu 3015 +瓦 wa 3011 +运 yun 3011 +抛 pao 3004 +顺 shun 3004 +陆 lu 3000 +雅 ya 2996 +司 si 2981 +温 wen 2981 +轮 lun 2977 +微 wei 2970 +社 she 2966 +杰 jie 2963 +奶 nai 2952 +杯 bei 2948 +搜 sou 2944 +凭 ping 2937 +奇 qi 2926 +居 ju 2926 +洞 dong 2922 +魔 mo 2922 +编 bian 2919 +虎 hu 2911 +佳 jia 2904 +充 chong 2904 +川 chuan 2883 +酸 suan 2875 +形 xing 2865 +墙 qiang 2861 +圆 yuan 2854 +害 hai 2854 +档 dang 2850 +负 fu 2850 +喽 lou 2847 +范 fan 2843 +糖 tang 2840 +煮 zhu 2836 +拟 ni 2833 +致 zhi 2826 +屁 pi 2822 +稳 wen 2822 +景 jing 2819 +觉 jue 2815 +吐 tu 2812 +碰 peng 2812 +注 zhu 2808 +肥 fei 2808 +免 mian 2805 +欠 qian 2805 +蛋 dan 2805 +挣 zheng 2794 +晓 xiao 2791 +曹 cao 2791 +侯 hou 2784 +盛 sheng 2773 +良 liang 2770 +擦 ca 2753 +脱 tuo 2753 +仙 xian 2749 +暗 an 2749 +砍 kan 2746 +夫 fu 2742 +熟 shu 2735 +兄 xiong 2729 +尔 er 2729 +皆 jie 2725 +琴 qin 2722 +创 chuang 2712 +永 yong 2712 +宫 gong 2702 +继 ji 2685 +谷 gu 2678 +宏 hong 2665 +专 zhuan 2662 +录 lu 2662 +施 shi 2662 +善 shan 2658 +增 zeng 2658 +例 li 2655 +税 shui 2652 +产 chan 2648 +嫩 nen 2645 +肯 ken 2642 +押 ya 2635 +麻 ma 2635 +视 shi 2625 +怒 nu 2622 +横 heng 2622 +扔 reng 2619 +母 mu 2619 +忍 ren 2612 +腰 yao 2606 +臭 chou 2596 +宗 zong 2593 +泪 lei 2586 +艾 ai 2586 +净 jing 2577 +肖 xiao 2577 +敏 min 2573 +吵 chao 2570 +罪 zui 2567 +争 zheng 2564 +伟 wei 2561 +响 xiang 2554 +棒 bang 2554 +邓 deng 2554 +这 zhei 2551 +减 jian 2548 +斗 dou 2548 +氏 shi 2548 +告 gao 2532 +吉 ji 2529 +圣 sheng 2529 +拆 chai 2529 +蔡 cai 2526 +闻 wen 2526 +末 mo 2523 +杂 za 2519 +衣 yi 2516 +麦 mai 2513 +冯 feng 2510 +妹 mei 2507 +义 yi 2504 +塞 sai 2504 +尼 ni 2504 +董 dong 2494 +角 jiao 2494 +旁 pang 2491 +潘 pan 2491 +耐 nai 2491 +醉 zui 2491 +呢 ni 2485 +宇 yu 2482 +丝 si 2479 +计 ji 2476 +校 xiao 2470 +姜 jiang 2467 +抄 chao 2467 +宜 yi 2457 +蒋 jiang 2451 +核 he 2445 +维 wei 2445 +灰 hui 2442 +钢 gang 2439 +催 cui 2436 +楚 chu 2427 +屋 wu 2421 +丹 dan 2418 +认 ren 2412 +乘 cheng 2409 +俊 jun 2406 +返 fan 2406 +榜 bang 2403 +芳 fang 2403 +仁 ren 2400 +矿 kuang 2400 +瑞 rui 2391 +严 yan 2385 +添 tian 2385 +评 ping 2385 +酷 ku 2385 +绝 jue 2382 +币 bi 2376 +飘 piao 2376 +威 wei 2373 +刻 ke 2370 +摆 bai 2364 +炸 zha 2358 +嘿 hei 2355 +犯 fan 2350 +京 jing 2347 +鲁 lu 2347 +具 ju 2344 +挤 ji 2344 +铜 tong 2344 +届 jie 2341 +密 mi 2338 +瓶 ping 2338 +夹 jia 2332 +骨 gu 2332 +黎 li 2323 +农 nong 2318 +射 she 2318 +馆 guan 2318 +士 shi 2309 +索 suo 2303 +季 ji 2300 +寻 xun 2300 +拼 pin 2300 +族 zu 2300 +显 xian 2300 +洪 hong 2297 +骑 qi 2295 +巨 ju 2292 +厄 e 2289 +厅 ting 2289 +容 rong 2278 +术 shu 2278 +复 fu 2272 +智 zhi 2272 +辉 hui 2272 +普 pu 2269 +剧 ju 2266 +采 cai 2263 +扯 che 2261 +燕 yan 2261 +竹 zhu 2261 +撞 zhuang 2258 +脑 nao 2258 +礼 li 2255 +聚 ju 2255 +膜 mo 2247 +讯 xun 2247 +诺 nuo 2244 +搭 da 2238 +魏 wei 2238 +泽 ze 2235 +胃 wei 2227 +曰 yue 2224 +侧 ce 2221 +彭 peng 2213 +钻 zuan 2213 +凤 feng 2210 +袁 yuan 2210 +吕 lv 2205 +窝 wo 2199 +凉 liang 2197 +乜 mie 2194 +阴 yin 2194 +蒙 meng 2191 +躲 duo 2188 +赖 lai 2186 +堆 dui 2183 +甜 tian 2180 +赴 fu 2178 +劲 jin 2175 +盐 yan 2175 +孟 meng 2172 +粗 cu 2172 +举 ju 2164 +甚 shen 2161 +载 zai 2159 +帐 zhang 2156 +申 shen 2151 +露 lu 2148 +劝 quan 2132 +瞎 xia 2132 +训 xun 2132 +驻 zhu 2132 +惹 re 2129 +逃 tao 2129 +休 xiu 2127 +彩 cai 2127 +独 du 2124 +庆 qing 2119 +藏 cang 2119 +仓 cang 2113 +斤 jin 2111 +姚 yao 2108 +浪 lang 2108 +喇 la 2106 +纳 na 2103 +针 zhen 2103 +吨 dun 2100 +嘉 jia 2100 +忠 zhong 2098 +吸 xi 2095 +森 sen 2095 +泰 tai 2095 +萧 xiao 2092 +功 gong 2090 +跨 kua 2090 +症 zheng 2079 +贝 bei 2074 +哼 heng 2072 +灌 guan 2064 +缘 yuan 2061 +卢 lu 2059 +卫 wei 2059 +喷 pen 2059 +蛇 she 2059 +盒 he 2054 +井 jing 2051 +导 dao 2043 +吊 diao 2033 +扫 sao 2033 +拜 bai 2033 +购 gou 2028 +贱 jian 2026 +饮 yin 2026 +调 diao 2023 +昏 hun 2021 +虚 xu 2018 +敲 qiao 2016 +岩 yan 2003 +埋 mai 2001 +寺 si 2001 +巧 qiao 2001 +娜 na 1998 +冬 dong 1996 +侬 nong 1991 +弹 tan 1988 +愁 chou 1988 +凯 kai 1983 +目 mu 1983 +淡 dan 1981 +优 you 1968 +虫 chong 1966 +池 chi 1964 +汇 hui 1959 +萨 sa 1956 +屏 ping 1954 +泥 ni 1944 +勇 yong 1939 +喔 o 1939 +怀 huai 1932 +扬 yang 1930 +迟 chi 1930 +铺 pu 1930 +磨 mo 1925 +呼 hu 1920 +怎 zen 1920 +赌 du 1920 +乔 qiao 1913 +涂 tu 1910 +财 cai 1906 +栏 lan 1903 +遇 yu 1894 +泉 quan 1889 +薛 xue 1889 +汪 wang 1887 +着 zhuo 1887 +稍 shao 1887 +嫁 jia 1884 +锅 guo 1884 +终 zhong 1882 +攻 gong 1880 +扎 zha 1873 +质 zhi 1873 +叼 diao 1868 +娃 wa 1868 +赔 pei 1866 +隔 ge 1866 +祥 xiang 1863 +呐 na 1861 +液 ye 1859 +豆 dou 1856 +碗 wan 1854 +绕 rao 1854 +佐 zuo 1852 +旺 wang 1852 +漏 lou 1850 +凑 cou 1847 +贾 jia 1845 +雄 xiong 1845 +委 wei 1840 +临 lin 1838 +吻 wen 1838 +湿 shi 1838 +崔 cui 1836 +框 kuang 1836 +探 tan 1829 +胸 xiong 1827 +碟 die 1824 +洛 luo 1822 +健 jian 1820 +辆 liang 1820 +速 su 1820 +顿 dun 1820 +唯 wei 1815 +拔 ba 1815 +暴 bao 1813 +失 shi 1804 +沟 gou 1802 +仪 yi 1797 +郎 lang 1793 +尿 niao 1791 +脏 zang 1786 +希 xi 1784 +逗 dou 1784 +识 shi 1779 +惠 hui 1777 +珠 zhu 1777 +菲 fei 1773 +谭 tan 1771 +透 tou 1771 +麽 me 1766 +凌 ling 1764 +府 fu 1757 +播 bo 1755 +父 fu 1755 +嫌 xian 1751 +窗 chuang 1744 +耳 er 1740 +残 can 1738 +砖 zhuan 1731 +患 huan 1729 +昌 chang 1729 +阵 zhen 1729 +哩 li 1723 +废 fei 1723 +扁 bian 1723 +捡 jian 1723 +苗 miao 1723 +帝 di 1718 +剪 jian 1712 +稿 gao 1710 +坑 keng 1708 +邦 bang 1708 +陶 tao 1708 +营 ying 1701 +剂 ji 1693 +熬 ao 1693 +舒 shu 1693 +丰 feng 1689 +模 mo 1689 +玲 ling 1689 +甩 shuai 1689 +贤 xian 1687 +拨 bo 1684 +趁 chen 1684 +栋 dong 1682 +羽 yu 1682 +沿 yan 1672 +乙 yi 1670 +盗 dao 1668 +伴 ban 1657 +妖 yao 1653 +尊 zun 1653 +幽 you 1647 +琳 lin 1647 +锦 jin 1647 +域 yu 1643 +瓜 gua 1641 +浅 qian 1637 +袋 dai 1637 +限 xian 1635 +浩 hao 1631 +霜 shuang 1629 +豪 hao 1625 +置 zhi 1619 +俾 bi 1617 +祖 zu 1615 +夸 kua 1613 +玄 xuan 1613 +颜 yan 1613 +惊 jing 1611 +肺 fei 1611 +煤 mei 1609 +趟 tang 1603 +辣 la 1603 +涛 tao 1601 +附 fu 1595 +遂 sui 1593 +划 hua 1591 +恒 heng 1589 +的 di 1589 +欣 xin 1587 +墨 mo 1585 +围 wei 1581 +剩 sheng 1579 +娶 qu 1577 +慧 hui 1573 +备 bei 1571 +鲜 xian 1571 +撤 che 1563 +助 zhu 1559 +撑 cheng 1557 +沉 chen 1555 +诸 zhu 1555 +险 xian 1551 +震 zhen 1549 +鹏 peng 1549 +兆 zhao 1548 +割 ge 1546 +斌 bin 1546 +烤 kao 1546 +控 kong 1542 +哒 da 1540 +暨 ji 1540 +贺 he 1538 +凶 xiong 1534 +锋 feng 1532 +童 tong 1528 +伯 bo 1525 +挡 dang 1525 +饰 shi 1525 +壳 ke 1521 +恋 lian 1521 +诚 cheng 1521 +馨 xin 1521 +浓 nong 1519 +振 zhen 1517 +轩 xuan 1517 +虾 xia 1515 +庭 ting 1513 +晴 qing 1513 +坊 fang 1509 +靓 liang 1509 +晨 chen 1506 +曼 man 1506 +镜 jing 1506 +魂 hun 1506 +益 yi 1504 +肝 gan 1504 +征 zheng 1502 +禁 jin 1502 +爹 die 1498 +粥 zhou 1498 +盾 dun 1496 +尘 chen 1493 +炎 yan 1493 +消 xiao 1489 +喃 nan 1485 +犬 quan 1481 +艳 yan 1481 +纪 ji 1480 +枫 feng 1478 +己 ji 1476 +妞 niu 1474 +昂 ang 1474 +暖 nuan 1474 +柱 zhu 1472 +隆 long 1472 +奔 ben 1469 +败 bai 1469 +政 zheng 1467 +滑 hua 1467 +妻 qi 1463 +廖 liao 1463 +误 wu 1463 +尹 yin 1459 +舍 she 1452 +刺 ci 1445 +沪 hu 1441 +弓 gong 1436 +邱 qiu 1432 +润 run 1431 +莲 lian 1431 +捞 lao 1427 +冠 guan 1425 +婚 hun 1425 +止 zhi 1425 +兔 tu 1422 +舔 tian 1418 +仔 zai 1416 +湾 wan 1416 +躺 tang 1416 +桑 sang 1415 +鑫 xin 1415 +链 lian 1411 +饱 bao 1411 +乌 wu 1409 +促 cu 1409 +敬 jing 1409 +迪 di 1408 +咪 mi 1406 +摄 she 1406 +蒸 zheng 1404 +串 chuan 1399 +愈 yu 1399 +阁 ge 1399 +承 cheng 1397 +摔 shuai 1397 +勤 qin 1394 +杆 gan 1394 +潮 chao 1394 +碎 sui 1392 +医 yi 1390 +臣 chen 1390 +鼠 shu 1388 +葛 ge 1383 +邪 xie 1380 +吼 hou 1376 +妙 miao 1375 +始 shi 1371 +珍 zhen 1371 +俗 su 1370 +迎 ying 1368 +续 xu 1364 +抬 tai 1363 +歪 wai 1361 +辛 xin 1361 +售 shou 1358 +伦 lun 1356 +启 qi 1356 +胶 jiao 1356 +捧 peng 1354 +研 yan 1354 +晃 huang 1353 +符 fu 1353 +苑 yuan 1349 +韦 wei 1349 +爷 ye 1348 +雾 wu 1346 +亭 ting 1344 +刮 gua 1343 +兽 shou 1341 +哄 hong 1341 +嗲 dia 1341 +尤 you 1339 +邹 zou 1334 +嗨 hai 1333 +鸣 ming 1333 +卓 zhuo 1329 +旗 qi 1329 +罚 fa 1329 +积 ji 1323 +斜 xie 1318 +湘 xiang 1318 +铝 lv 1318 +欢 huan 1313 +预 yu 1313 +乎 hu 1311 +枝 zhi 1309 +溪 xi 1301 +芬 fen 1301 +洲 zhou 1298 +鸭 ya 1296 +捐 juan 1293 +炮 pao 1292 +岗 gang 1288 +轴 zhou 1287 +简 jian 1282 +翔 xiang 1282 +勾 gou 1280 +堵 du 1280 +携 xie 1277 +染 ran 1276 +矣 yi 1276 +饼 bing 1276 +罢 ba 1274 +萍 ping 1274 +隐 yin 1272 +餐 can 1272 +戒 jie 1266 +逸 yi 1266 +岳 yue 1265 +荷 he 1265 +贷 dai 1265 +憋 bie 1261 +薄 bo 1255 +仿 fang 1252 +肾 shen 1252 +异 yi 1249 +朗 lang 1249 +扑 pu 1247 +桂 gui 1247 +瞧 qiao 1246 +颗 ke 1246 +鸿 hong 1246 +弯 wan 1244 +晶 jing 1240 +霞 xia 1240 +朝 zhao 1238 +延 yan 1233 +箭 jian 1233 +棉 mian 1232 +鼓 gu 1232 +籍 ji 1229 +桌 zhuo 1227 +互 hu 1223 +坤 kun 1221 +玛 ma 1221 +艺 yi 1221 +私 si 1220 +柔 rou 1215 +勿 wu 1214 +眉 mei 1214 +倾 qing 1211 +检 jian 1209 +腾 teng 1209 +焦 jiao 1205 +弟 di 1203 +浴 yu 1203 +碧 bi 1203 +资 zi 1203 +亩 mu 1202 +盆 pen 1202 +毁 hui 1200 +烫 tang 1200 +皇 huang 1199 +汁 zhi 1193 +粒 li 1193 +伞 san 1190 +莱 lai 1188 +洁 jie 1187 +哲 zhe 1185 +涵 han 1185 +企 qi 1179 +叔 shu 1178 +岭 ling 1178 +咳 hai 1176 +护 hu 1173 +浜 bang 1173 +屎 shi 1170 +衰 shuai 1170 +盟 meng 1166 +翼 yi 1166 +判 pan 1165 +钙 gai 1165 +傅 fu 1163 +妮 ni 1163 +驴 lv 1162 +迈 mai 1160 +铭 ming 1160 +柯 ke 1159 +宠 chong 1156 +桶 tong 1155 +殿 dian 1153 +菌 jun 1152 +敌 di 1150 +淫 yin 1150 +旭 xu 1145 +航 hang 1145 +棋 qi 1142 +仔 zi 1140 +访 fang 1140 +拽 zhuai 1139 +翠 cui 1136 +坡 po 1135 +纹 wen 1133 +鹰 ying 1133 +孝 xiao 1132 +赤 chi 1130 +技 ji 1129 +译 yi 1129 +挨 ai 1128 +予 yu 1126 +劳 lao 1126 +唔 n 1126 +唔 ng 1126 +肩 jian 1126 +露 lou 1126 +册 ce 1123 +介 jie 1122 +培 pei 1119 +融 rong 1118 +移 yi 1116 +韵 yun 1115 +势 shi 1112 +毕 bi 1112 +截 jie 1108 +渡 du 1108 +薇 wei 1108 +扶 fu 1107 +啃 ken 1105 +壮 zhuang 1103 +怡 yi 1103 +摘 zhai 1103 +宅 zhai 1101 +盼 pan 1100 +耗 hao 1100 +妍 yan 1097 +摇 yao 1097 +抹 mo 1094 +莉 li 1093 +乳 ru 1092 +孤 gu 1092 +锡 xi 1090 +夺 duo 1083 +确 que 1082 +醋 cu 1082 +损 sun 1081 +握 wo 1081 +旅 lv 1081 +帕 pa 1079 +拳 quan 1079 +芯 xin 1078 +奴 nu 1077 +颇 po 1077 +宣 xuan 1075 +击 ji 1074 +咸 xian 1073 +姬 ji 1070 +尖 jian 1070 +伍 wu 1069 +邵 shao 1069 +讨 tao 1067 +酶 mei 1067 +掌 zhang 1063 +桃 tao 1059 +寿 shou 1058 +规 gui 1058 +谋 mou 1058 +逢 feng 1058 +垫 dian 1057 +穆 mu 1057 +暂 zan 1055 +腻 ni 1055 +矮 ai 1054 +龟 gui 1054 +庙 miao 1053 +朵 duo 1051 +职 zhi 1051 +霍 huo 1049 +绑 bang 1046 +菊 ju 1046 +辞 ci 1044 +闯 chuang 1044 +壁 bi 1041 +枕 zhen 1038 +渐 jian 1037 +岸 an 1036 +婆 po 1035 +胆 dan 1035 +尝 chang 1032 +晋 jin 1032 +虹 hong 1032 +凝 ning 1031 +弦 xian 1029 +撮 cuo 1029 +澡 zao 1029 +避 bi 1029 +抵 di 1028 +贪 tan 1028 +俱 ju 1027 +噻 sai 1027 +息 xi 1027 +丸 wan 1024 +参 can 1023 +骚 sao 1022 +塘 tang 1020 +漆 qi 1018 +蝶 die 1018 +於 yu 1014 +兮 xi 1012 +郝 hao 1012 +穴 xue 1009 +芝 zhi 1009 +浮 fu 1008 +材 cai 1005 +娟 juan 1004 +缝 feng 1004 +耀 yao 1004 +萌 meng 1004 +蹭 ceng 1002 +烈 lie 1000 +痒 yang 1000 +伸 shen 997 +槽 cao 997 +翁 weng 995 +柜 gui 994 +盈 ying 994 +审 shen 993 +斑 ban 989 +渣 zha 989 +恐 kong 985 +殷 yin 984 +悲 bei 983 +溜 liu 982 +炉 lu 982 +幻 huan 981 +逆 ni 981 +伪 wei 978 +柴 chai 978 +券 quan 977 +固 gu 977 +抖 dou 976 +番 fan 976 +悦 yue 974 +斩 zhan 974 +屈 qu 973 +脉 mai 972 +衫 shan 970 +忽 hu 968 +怨 yuan 968 +粮 liang 968 +冻 dong 967 +彬 bin 967 +献 xian 966 +熙 xi 965 +摩 mo 964 +胎 tai 962 +薄 bao 962 +婷 ting 961 +缩 suo 956 +兹 zi 955 +赏 shang 955 +鹿 lu 955 +津 jin 954 +递 di 954 +枚 mei 952 +卿 qing 950 +函 han 949 +济 ji 949 +颖 ying 949 +扇 shan 947 +彦 yan 946 +拷 kao 946 +漫 man 945 +墓 mu 943 +淘 tao 943 +谱 pu 943 +候 hou 941 +鹤 he 939 +幅 fu 935 +茂 mao 933 +咯 luo 931 +戈 ge 931 +弄 long 927 +牵 qian 927 +娇 jiao 926 +雁 yan 926 +御 yu 924 +窄 zhai 922 +郡 jun 922 +掏 tao 921 +映 ying 921 +琼 qiong 921 +缓 huan 921 +唇 chun 920 +弃 qi 920 +佩 pei 919 +汝 ru 918 +昨 zuo 917 +策 ce 914 +琪 qi 911 +肌 ji 911 +雯 wen 911 +坛 tan 910 +邀 yao 910 +协 xie 907 +猴 hou 904 +销 xiao 903 +揉 rou 902 +漂 piao 901 +攒 zan 900 +肠 chang 900 +债 zhai 898 +倪 ni 894 +坚 jian 894 +舟 zhou 894 +藤 teng 893 +裤 ku 892 +爪 zhua 891 +什 shen 890 +磊 lei 889 +慌 huang 886 +务 wu 885 +蒂 di 885 +诉 su 884 +态 tai 883 +蟹 xie 883 +梨 li 882 +幸 xing 881 +炼 lian 881 +忧 you 880 +鼻 bi 879 +育 yu 876 +舌 she 875 +酱 jiang 874 +叹 tan 873 +堡 bao 873 +拐 guai 873 +陷 xian 873 +鹅 e 871 +腹 fu 870 +蜜 mi 870 +席 xi 869 +粤 yue 869 +葱 cong 869 +苍 cang 867 +捉 zhuo 866 +孩 hai 863 +赫 he 861 +尾 wei 859 +尾 yi 859 +肿 zhong 859 +效 xiao 858 +帧 zhen 857 +澳 ao 857 +钓 diao 857 +侠 xia 855 +儒 ru 855 +决 jue 854 +扭 niu 854 +闭 bi 854 +叉 cha 853 +癌 ai 853 +泵 beng 852 +捷 jie 851 +妆 zhuang 849 +彼 bi 847 +适 shi 846 +泛 fan 845 +瘤 liu 843 +龚 gong 843 +盯 ding 842 +忌 ji 840 +炖 dun 840 +纵 zong 840 +默 mo 840 +逾 yu 839 +绣 xiu 838 +悬 xuan 837 +宰 zai 836 +埃 ai 834 +辰 chen 833 +那 nei 832 +罐 guan 831 +奈 nai 828 +序 xu 828 +洒 sa 828 +叻 le 827 +伏 fu 826 +执 zhi 826 +幼 you 825 +鼎 ding 825 +卵 luan 824 +莹 ying 824 +旋 xuan 821 +午 wu 820 +刑 xing 819 +囊 nang 817 +担 dan 817 +粘 nian 816 +触 chu 815 +踏 ta 815 +宾 bin 813 +悟 wu 812 +腔 qiang 812 +牢 lao 811 +帽 mao 810 +聂 nie 810 +典 dian 809 +奉 feng 808 +妃 fei 806 +煎 jian 806 +频 pin 806 +历 li 804 +昭 zhao 804 +拥 yong 801 +巷 xiang 800 +犹 you 800 +弘 hong 797 +扛 kang 796 +揭 jie 796 +裸 luo 795 +柏 bai 794 +账 zhang 794 +霸 ba 793 +僧 seng 792 +啵 bo 792 +摊 tan 791 +掐 qia 789 +突 tu 788 +蓉 rong 788 +蕾 lei 787 +揍 zou 786 +彪 biao 786 +觉 jiao 786 +壹 yi 785 +毅 yi 784 +疑 yi 784 +罩 zhao 783 +拒 ju 781 +淋 lin 781 +赠 zeng 780 +警 jing 778 +逐 zhu 778 +痕 hen 777 +歇 xie 776 +统 tong 776 +迁 qian 775 +钩 gou 775 +垂 chui 773 +扮 ban 770 +廷 ting 769 +祸 huo 769 +淑 shu 768 +渝 yu 768 +裂 lie 768 +昆 kun 767 +涅 nie 767 +颈 jing 767 +喻 yu 766 +尸 shi 766 +嘞 lei 765 +忆 yi 765 +尺 chi 764 +朕 zhen 764 +惯 guan 762 +氧 yang 762 +仕 shi 761 +境 jing 761 +棍 gun 758 +律 lv 756 +恰 qia 755 +惜 xi 753 +斋 zhai 753 +坪 ping 752 +姆 mu 752 +束 shu 752 +浦 pu 752 +稀 xi 751 +拓 tuo 748 +吞 tun 745 +裙 qun 745 +叠 die 743 +疏 shu 743 +芸 yun 743 +劫 jie 740 +颠 dian 740 +砂 sha 739 +郁 yu 739 +锌 xin 739 +亡 wang 738 +尧 yao 737 +沾 zhan 737 +株 zhu 736 +涌 yong 736 +穗 sui 736 +翘 qiao 736 +邮 you 736 +潜 qian 735 +聪 cong 734 +糊 hu 732 +谓 wei 732 +陵 ling 732 +壶 hu 731 +媚 mei 730 +饶 rao 730 +庚 geng 729 +坦 tan 728 +慕 mu 728 +丘 qiu 725 +咖 ga 725 +兜 dou 724 +寨 zhai 724 +搓 cuo 724 +监 jian 724 +繁 fan 724 +蚊 wen 724 +霖 lin 722 +绍 shao 721 +遥 yao 721 +楠 nan 720 +牧 mu 720 +验 yan 720 +吟 yin 719 +裴 pei 717 +缠 chan 713 +哉 zai 712 +浇 jiao 711 +齿 chi 711 +挥 hui 710 +祭 ji 709 +庞 pang 706 +惟 wei 706 +拾 shi 706 +朋 peng 706 +膏 gao 703 +坝 ba 701 +锐 rui 699 +敷 fu 697 +筒 tong 697 +喵 miao 696 +涉 she 696 +粪 fen 693 +挪 nuo 688 +署 shu 687 +佬 lao 686 +拧 ning 686 +阮 ruan 686 +储 chu 685 +碳 tan 684 +丙 bing 683 +桩 zhuang 682 +荡 dang 682 +屯 tun 680 +趴 pa 680 +蹲 dun 680 +遮 zhe 680 +戳 chuo 679 +纱 sha 679 +崇 chong 675 +钦 qin 675 +瑶 yao 672 +赋 fu 671 +奎 kui 670 +渠 qu 667 +习 xi 665 +仇 chou 664 +宿 su 664 +夕 xi 663 +盲 mang 662 +坎 kan 660 +蹦 beng 660 +扩 kuo 659 +跃 yue 657 +杭 hang 656 +弗 fu 655 +泼 po 655 +臂 bi 655 +钉 ding 655 +幕 mu 654 +邢 xing 654 +筹 chou 653 +荒 huang 652 +棚 peng 651 +焊 han 651 +禅 chan 650 +召 zhao 649 +岚 lan 648 +拌 ban 647 +疾 ji 647 +裕 yu 647 +奏 zou 643 +栽 zai 643 +滨 bin 643 +筋 jin 643 +趋 qu 643 +示 shi 642 +卦 gua 641 +蠢 chun 641 +贞 zhen 640 +卧 wo 639 +苯 ben 639 +阻 zu 638 +咒 zhou 637 +琦 qi 636 +甫 fu 636 +绳 sheng 635 +耿 geng 635 +樊 fan 633 +贫 pin 633 +阅 yue 633 +撕 si 631 +铃 ling 631 +艘 sou 630 +浆 jiang 629 +竞 jing 629 +搅 jiao 628 +靖 jing 628 +珊 shan 627 +笼 long 625 +倚 yi 622 +妇 fu 620 +痰 tan 620 +缸 gang 620 +舰 jian 620 +帆 fan 619 +议 yi 618 +炫 xuan 617 +狐 hu 617 +徒 tu 614 +植 zhi 613 +责 ze 613 +释 shi 612 +拣 jian 611 +脂 zhi 610 +赐 ci 610 +享 xiang 608 +佑 you 608 +秘 mi 608 +沃 wo 607 +袖 xiu 607 +鲍 bao 607 +述 shu 606 +姨 yi 605 +啸 xiao 604 +硅 gui 604 +杖 zhang 602 +焉 yan 601 +捅 tong 600 +芽 ya 600 +倩 qian 599 +筑 zhu 598 +藏 zang 598 +傲 ao 597 +姿 zi 597 +径 jing 596 +邻 lin 595 +激 ji 594 +咦 yi 593 +铅 qian 593 +雕 diao 593 +姑 gu 592 +角 jue 592 +咯 ge 590 +捂 wu 590 +桐 tong 590 +幢 zhuang 587 +舱 cang 587 +踹 chuai 587 +辩 bian 587 +潭 tan 586 +覆 fu 586 +赚 zuan 586 +钠 na 586 +劲 jing 585 +恤 xu 585 +拦 lan 585 +煲 bao 584 +糟 zao 584 +趣 qu 584 +燃 ran 583 +脆 cui 583 +轰 hong 583 +阎 yan 583 +似 shi 582 +凸 tu 582 +龄 ling 582 +窜 cuan 581 +锤 chui 581 +鞍 an 580 +痴 chi 579 +孕 yun 578 +瑟 se 574 +茹 ru 574 +搁 ge 572 +缴 jiao 571 +弹 dan 570 +滩 tan 570 +熏 xun 570 +驱 qu 570 +咏 yong 569 +搂 lou 569 +浸 jin 569 +磷 lin 569 +织 zhi 569 +哀 ai 567 +昊 hao 567 +衡 heng 567 +阶 jie 567 +驾 jia 564 +辅 fu 562 +崩 beng 561 +谦 qian 560 +鄂 e 560 +窑 yao 559 +刃 ren 557 +晖 hui 557 +蒲 pu 557 +泻 xie 555 +硕 shuo 555 +屡 lv 554 +涩 se 553 +择 ze 552 +灾 zai 552 +丛 cong 550 +碑 bei 550 +柄 bing 549 +奸 jian 548 +揽 lan 548 +樱 ying 548 +禾 he 547 +俩 liang 546 +竖 shu 546 +婉 wan 545 +措 cuo 545 +眠 mian 545 +薰 xun 545 +磁 ci 544 +麟 lin 544 +侃 kan 543 +坠 zhui 543 +揪 jiu 543 +禄 lu 543 +咽 yan 542 +娅 ya 542 +仗 zhang 541 +革 ge 540 +瑜 yu 539 +畅 chang 537 +碱 jian 535 +肚 du 535 +逝 shi 535 +攀 pan 534 +皱 zhou 534 +阀 fa 534 +宴 yan 533 +瘾 yin 533 +翟 zhai 532 +跪 gui 532 +唤 huan 531 +途 tu 531 +勋 xun 530 +帘 lian 530 +狄 di 530 +瓷 ci 530 +辽 liao 530 +鞭 bian 530 +谜 mi 529 +厉 li 528 +磅 bang 528 +闸 zha 527 +凹 ao 526 +瞄 miao 526 +裁 cai 526 +娥 e 525 +宪 xian 525 +崖 ya 523 +扒 ba 523 +沁 qin 523 +刊 kan 522 +迹 ji 521 +血 xie 519 +乾 qian 516 +氮 dan 516 +霉 mei 516 +傍 bang 514 +葬 zang 514 +喘 chuan 513 +钧 jun 513 +詹 zhan 512 +俞 shu 510 +俞 yu 510 +闫 yan 508 +骆 luo 508 +察 cha 507 +授 shou 506 +楞 leng 506 +辨 bian 506 +喉 hou 505 +嚼 jiao 505 +拎 lin 505 +虞 yu 505 +辑 ji 505 +媛 yuan 504 +披 pi 504 +遗 yi 504 +阔 kuo 504 +剥 bo 503 +坟 fen 503 +塌 ta 503 +岂 qi 503 +悠 you 503 +卸 xie 502 +便 pian 501 +愚 yu 501 +狮 shi 501 +窦 dou 501 +藕 ou 499 +垮 kua 498 +怜 lian 498 +慈 ci 496 +拱 gong 496 +脾 pi 494 +氢 qing 493 +巢 chao 491 +幺 yao 491 +抚 fu 491 +溶 rong 491 +焕 huan 491 +廉 lian 490 +禹 yu 490 +亨 heng 489 +燥 zao 488 +卜 bu 487 +危 wei 487 +泄 xie 487 +聘 pin 487 +钾 jia 484 +橙 cheng 483 +滕 teng 482 +颂 song 482 +构 gou 479 +栓 shuan 479 +铲 chan 479 +堤 di 478 +枯 ku 478 +睿 rui 478 +允 yun 476 +堪 kan 473 +暮 mu 473 +冤 yuan 472 +篮 lan 472 +粘 zhan 472 +抠 kou 471 +巫 wu 470 +酥 su 470 +劈 pi 469 +畔 pan 469 +裹 guo 469 +雀 que 469 +骏 jun 469 +灿 can 468 +灶 zao 467 +芒 mang 467 +冥 ming 466 +杏 xing 466 +腐 fu 466 +绘 hui 465 +渔 yu 463 +筱 xiao 463 +蜀 shu 463 +醇 chun 463 +薪 xin 462 +袭 xi 462 +婕 jie 461 +梳 shu 461 +黛 dai 461 +孰 shu 459 +兑 dui 457 +卑 bei 457 +瞪 deng 457 +铸 zhu 457 +茨 ci 456 +轨 gui 456 +拂 fu 455 +搏 bo 455 +渊 yuan 454 +蜡 la 454 +豹 bao 454 +驰 chi 454 +荤 hun 453 +蒜 suan 453 +皓 hao 452 +嫂 sao 451 +弊 bi 451 +戚 qi 451 +际 ji 451 +馅 xian 450 +茅 mao 449 +叁 san 445 +枣 zao 445 +曦 xi 444 +棵 ke 444 +嗦 suo 443 +澄 cheng 443 +鉴 jian 443 +飙 biao 443 +冉 ran 442 +塑 su 439 +炳 bing 439 +耕 geng 439 +誓 shi 439 +朴 pu 438 +勺 shao 437 +敦 dun 437 +央 yang 435 +瞳 tong 435 +羞 xiu 435 +彤 tong 434 +稻 dao 434 +媒 mei 433 +笛 di 433 +乍 zha 432 +冀 ji 432 +匹 pi 432 +恭 gong 432 +贰 er 432 +祁 qi 431 +莪 e 431 +蜂 feng 431 +眺 tiao 430 +渴 ke 429 +妾 qie 427 +沐 mu 427 +估 gu 426 +仰 yang 425 +忒 tei 425 +滥 lan 425 +夷 yi 424 +嘘 xu 423 +丧 sang 421 +弥 mi 421 +辈 bei 421 +钛 tai 421 +捕 bu 420 +煞 sha 420 +磕 ke 420 +陀 tuo 420 +努 nu 418 +沧 cang 418 +茎 jing 417 +呸 pei 416 +汽 qi 416 +溅 jian 416 +炜 wei 416 +梗 geng 415 +蕊 rui 415 +屠 tu 414 +捣 dao 414 +旦 dan 414 +闵 min 414 +氨 an 413 +玮 wei 412 +翅 chi 412 +叙 xu 411 +氯 lv 411 +奕 yi 410 +惑 huo 410 +呦 you 409 +氟 fu 409 +豫 yu 409 +撇 pie 408 +荆 jing 408 +络 luo 407 +胀 zhang 407 +贯 guan 406 +详 xiang 405 +瓣 ban 404 +淮 huai 403 +迥 jiong 403 +况 kuang 402 +掘 jue 402 +浙 zhe 402 +蓬 peng 402 +挫 cuo 401 +溢 yi 401 +览 lan 401 +慎 shen 400 +梓 zi 400 +巡 xun 400 +绮 qi 399 +逮 dai 399 +徽 hui 398 +瞒 man 398 +谨 jin 398 +寇 kou 397 +葵 kui 397 +率 shuai 396 +绵 mian 396 +舜 shun 396 +佟 tong 395 +匪 fei 395 +昕 xin 393 +矛 mao 392 +苔 tai 392 +翰 han 391 +荐 jian 391 +蹬 deng 391 +墩 dun 390 +晾 liang 390 +耻 chi 389 +茜 qian 389 +觅 mi 389 +貌 mao 389 +冈 gang 387 +硫 liu 387 +纲 gang 387 +闽 min 387 +诱 you 384 +侵 qin 383 +哑 ya 382 +抑 yi 382 +削 xiao 381 +寝 qin 381 +掰 bai 381 +蔚 wei 381 +柏 bo 380 +熟 shou 380 +迦 jia 380 +掂 dian 379 +淤 yu 379 +痣 zhi 379 +颤 chan 379 +捆 kun 378 +腕 wan 378 +贡 gong 378 +昔 xi 377 +猎 lie 377 +刁 diao 376 +澜 lan 376 +污 wu 375 +援 yuan 373 +掀 xian 372 +泳 yong 372 +萱 xuan 370 +逊 xun 370 +俏 qiao 369 +愣 leng 369 +臀 tun 369 +娱 yu 367 +悔 hui 367 +矫 jiao 367 +挠 nao 366 +玫 mei 366 +蓄 xu 366 +卒 zu 365 +绊 ban 365 +镁 mei 365 +瑛 ying 364 +侍 shi 363 +循 xun 363 +旱 han 363 +概 gai 363 +馋 chan 362 +妥 tuo 361 +悉 xi 361 +绪 xu 361 +汀 ting 360 +甄 zhen 360 +唆 suo 359 +颐 yi 359 +扰 rao 358 +掩 yan 358 +迅 xun 358 +呕 ou 357 +垒 lei 357 +敖 ao 357 +酮 tong 357 +菁 jing 356 +钰 yu 356 +廿 nian 355 +璇 xuan 354 +畴 chou 354 +胤 yin 354 +蛙 wa 354 +茗 ming 353 +旬 xun 351 +朴 piao 351 +窍 qiao 351 +芙 fu 351 +蘸 zhan 351 +扽 den 350 +梯 ti 350 +炭 tan 349 +雇 gu 349 +瞅 chou 348 +茵 yin 348 +勃 bo 347 +庵 an 347 +糕 gao 347 +诀 jue 347 +锭 ding 347 +沥 li 346 +欺 qi 344 +乐 yue 343 +匀 yun 343 +碘 dian 343 +称 chen 343 +菱 ling 343 +侨 qiao 341 +籽 zi 341 +膝 xi 341 +莎 sha 341 +莎 suo 341 +雌 ci 341 +峡 xia 340 +眨 zha 340 +酿 niang 340 +泊 bo 339 +绒 rong 339 +臻 zhen 339 +隋 sui 339 +秉 bing 338 +胺 an 338 +衬 chen 338 +僵 jiang 337 +呵 a 337 +杉 shan 337 +祛 qu 336 +伽 ga 335 +纽 niu 335 +掺 chan 334 +斐 fei 334 +迫 po 334 +岑 cen 332 +玖 jiu 331 +娄 lou 331 +遣 qian 331 +溟 ming 330 +贩 fan 330 +陡 dou 330 +萝 luo 329 +乏 fa 328 +仨 sa 328 +婴 ying 328 +滞 zhi 328 +蕴 yun 328 +镶 xiang 328 +冶 ye 326 +嫖 piao 326 +厌 yan 325 +嗜 shi 325 +肆 si 324 +淳 chun 323 +嗒 da 322 +阑 lan 321 +叽 ji 320 +塞 se 320 +璐 lu 320 +黔 qian 320 +摁 en 319 +嵌 qian 318 +廊 lang 318 +彻 che 318 +椅 yi 318 +疗 liao 318 +禽 qin 318 +魁 kui 318 +挽 wan 317 +滋 zi 317 +焰 yan 317 +袍 pao 317 +栗 li 316 +睁 zheng 316 +稠 chou 316 +髓 sui 316 +呛 qiang 315 +札 zha 315 +呜 wu 314 +斥 chi 314 +渗 shen 314 +纬 wei 314 +螺 luo 314 +咳 ke 313 +峻 jun 313 +笋 sun 313 +魅 mei 313 +涯 ya 312 +晏 yan 311 +桓 huan 311 +没 mo 311 +靳 jin 311 +描 miao 310 +诊 zhen 310 +呗 bai 309 +瑾 jin 309 +藻 zao 309 +刨 pao 308 +捻 nian 308 +沽 gu 308 +蚕 can 308 +传 zhuan 307 +寂 ji 307 +沫 mo 307 +镍 nie 307 +厨 chu 306 +叮 ding 306 +慰 wei 306 +芦 lu 306 +芷 zhi 306 +丈 zhang 305 +寡 gua 305 +踪 zong 305 +荫 yin 304 +嗅 xiu 301 +嫣 yan 301 +矩 ju 301 +祠 ci 301 +雍 yong 301 +募 mu 300 +覃 qin 300 +榕 rong 299 +爵 jue 299 +袄 ao 299 +裔 yi 299 +杠 gang 298 +秤 cheng 298 +钮 niu 297 +役 yi 296 +烙 lao 296 +贸 mao 296 +匡 kuang 295 +契 qi 295 +疆 jiang 295 +窥 kui 295 +眸 mou 294 +裘 qiu 294 +簿 bu 293 +浊 zhuo 292 +淹 yan 292 +盏 zhan 292 +贬 bian 292 +劣 lie 291 +拴 shuan 291 +棠 tang 291 +宛 wan 290 +棕 zong 290 +沛 pei 290 +芹 qin 290 +苟 gou 290 +泣 qi 289 +殊 shu 288 +琛 chen 288 +箫 xiao 288 +椰 ye 287 +瞬 shun 287 +驶 shi 287 +噪 zao 286 +履 lv 286 +崽 zai 286 +皖 wan 286 +蚀 shi 286 +誉 yu 286 +倦 juan 285 +婧 jing 285 +庸 yong 285 +析 xi 285 +畏 wei 285 +哪 ne 284 +撩 liao 284 +湛 zhan 284 +熔 rong 284 +祈 qi 284 +芭 ba 284 +陕 shan 284 +恼 nao 283 +腺 xian 283 +邑 yi 283 +掷 zhi 282 +肤 fu 282 +厢 xiang 281 +差 chai 281 +毫 hao 281 +洽 qia 281 +烨 ye 280 +珂 ke 279 +靴 xue 279 +浑 hun 278 +滇 dian 278 +督 du 278 +衍 yan 278 +衔 xian 278 +铬 ge 278 +奋 fen 277 +痘 dou 277 +艇 ting 277 +巾 jin 276 +瞥 pie 276 +究 jiu 276 +簇 cu 276 +砌 qi 275 +酚 fen 275 +佰 bai 274 +腌 a 274 +蝉 chan 274 +寅 yin 273 +晟 sheng 273 +梵 fan 273 +苇 wei 273 +堕 duo 272 +桦 hua 272 +涮 shuan 272 +祺 qi 272 +膊 bo 272 +茬 cha 272 +伙 huo 271 +筛 shai 271 +胞 bao 271 +霄 xiao 271 +匠 jiang 270 +揣 chuai 270 +棺 guan 270 +淌 tang 270 +纤 qian 270 +障 zhang 270 +询 xun 269 +剁 duo 268 +剃 ti 268 +巩 gong 268 +酯 zhi 268 +妄 wang 267 +娴 xian 267 +恁 nen 267 +厘 li 266 +栈 zhan 266 +肽 tai 266 +曝 bao 263 +朔 shuo 263 +肘 zhou 263 +驳 bo 263 +搽 cha 262 +澈 che 262 +狱 yu 262 +粟 su 262 +吏 li 261 +吖 ya 261 +屑 xie 261 +皂 zao 261 +脓 nong 261 +襄 xiang 261 +扳 ban 260 +汞 gong 260 +涧 jian 260 +烛 zhu 260 +绞 jiao 260 +毓 yu 259 +绢 juan 259 +埠 bu 258 +袜 wa 258 +佘 she 257 +淇 qi 257 +刹 sha 256 +卤 lu 256 +遵 zun 256 +憨 han 255 +羹 geng 255 +鳖 bie 255 +唷 yo 254 +斧 fu 254 +赣 gan 254 +弩 nu 253 +柒 qi 253 +筐 kuang 253 +圭 gui 252 +撰 zhuan 252 +棱 leng 252 +甭 beng 252 +菇 gu 252 +违 wei 252 +剖 pou 251 +址 zhi 251 +坨 tuo 251 +瞿 qu 251 +纺 fang 251 +见 xian 251 +哨 shao 250 +梢 shao 250 +潇 xiao 250 +疤 ba 250 +舅 jiu 250 +卞 bian 249 +秃 tu 249 +窃 qie 249 +肃 su 249 +蚁 yi 249 +拢 long 248 +瞟 piao 248 +碍 ai 248 +寓 yu 247 +蛤 ha 247 +卜 bo 246 +旨 zhi 246 +辱 ru 246 +凋 diao 245 +滤 lv 245 +矢 shi 245 +励 li 244 +啪 pa 244 +愤 fen 244 +镑 bang 244 +槐 huai 243 +虑 lv 243 +谂 shen 243 +霆 ting 243 +妓 ji 242 +眯 mi 242 +诛 zhu 242 +侣 lv 241 +崎 qi 241 +煜 yu 241 +腥 xing 240 +蝇 ying 240 +鸽 ge 240 +噗 pu 239 +懿 yi 239 +泓 hong 239 +砚 yan 239 +纤 xian 239 +缀 zhui 239 +蛆 qu 239 +诵 song 239 +锯 ju 239 +阙 que 239 +焖 men 238 +苹 ping 238 +谛 di 238 +嗟 jie 237 +嘟 du 237 +弧 hu 237 +捶 chui 237 +煌 huang 237 +缕 lv 237 +缪 miu 237 +钵 bo 237 +阜 fu 237 +椒 jiao 236 +拙 zhuo 235 +粽 zong 235 +耸 song 235 +钝 dun 234 +陌 mo 234 +栖 qi 232 +蛾 e 232 +鸦 ya 232 +黏 nian 232 +尬 ga 231 +茄 jia 231 +蹄 ti 231 +彰 zhang 230 +脊 ji 230 +酬 chou 230 +铀 you 230 +韧 ren 230 +巳 si 229 +瀚 han 229 +翎 ling 229 +楷 kai 228 +锥 zhui 228 +凿 zao 227 +昼 zhou 227 +砼 tong 227 +臧 zang 227 +锰 meng 227 +鞠 ju 227 +敛 lian 226 +歼 jian 226 +茧 jian 226 +擒 qin 225 +疮 chuang 225 +俭 jian 224 +峪 yu 224 +淼 miao 224 +胚 pei 224 +羡 xian 223 +戎 rong 222 +涓 juan 222 +鳞 lin 222 +惧 ju 221 +撬 qiao 221 +昧 mei 220 +纶 lun 220 +釉 you 220 +睬 cai 218 +说 shui 218 +说 yue 218 +锈 xiu 218 +喏 nuo 217 +犁 li 217 +绯 fei 217 +驿 yi 217 +橘 ju 216 +漾 yang 216 +竿 gan 216 +纠 jiu 216 +莺 ying 216 +奚 xi 215 +桔 ju 215 +甸 dian 215 +糙 cao 215 +褐 he 215 +郊 jiao 215 +殇 shang 214 +牟 mou 214 +罕 han 214 +冢 zhong 213 +旷 kuang 213 +毋 wu 213 +猿 yuan 213 +辊 gun 213 +魄 po 213 +璧 bi 212 +畀 bi 212 +纷 fen 212 +啼 ti 211 +喀 ka 211 +烹 peng 211 +笙 sheng 211 +遁 dun 211 +嗣 si 210 +姝 shu 210 +娣 di 210 +韶 shao 210 +厦 xia 209 +盔 kui 209 +腮 sai 209 +藉 jie 209 +衷 zhong 209 +铮 zheng 209 +哗 hua 208 +挟 xie 208 +辖 xia 208 +镖 biao 208 +鸥 ou 208 +枉 wang 207 +迭 die 207 +钗 chai 207 +钞 chao 207 +喧 xuan 206 +鲸 jing 206 +麓 lu 206 +嗷 ao 205 +戊 wu 205 +曜 yao 205 +筝 zheng 205 +诏 zhao 205 +梭 suo 204 +漠 mo 204 +乞 qi 203 +垢 gou 203 +恕 shu 203 +扒 pa 203 +轿 jiao 203 +栾 luan 202 +芮 rui 202 +嚷 rang 201 +捎 shao 201 +褪 tui 201 +钳 qian 201 +仑 lun 200 +宵 xiao 200 +肇 zhao 200 +苞 bao 200 +镀 du 200 +陆 liu 200 +驼 tuo 200 +巍 wei 199 +焱 yan 199 +癖 pi 199 +邬 wu 199 +叩 kou 198 +庐 lu 198 +毙 bi 198 +爻 yao 198 +珏 jue 198 +嗔 chen 197 +孽 nie 197 +茄 qie 197 +诈 zha 197 +饥 ji 197 +鸾 luan 197 +絮 xu 196 +蕙 hui 196 +岱 dai 195 +褚 chu 195 +豁 huo 195 +俯 fu 194 +括 kuo 194 +摞 luo 194 +瀑 pu 194 +婶 shen 193 +灏 hao 193 +骄 jiao 193 +偿 chang 192 +炯 jiong 192 +硪 wo 192 +堰 yan 191 +岐 qi 191 +祯 zhen 191 +匣 xia 190 +悼 dao 190 +渍 zi 190 +骤 zhou 190 +檀 tan 189 +汶 wen 189 +焚 fen 189 +窟 ku 189 +荃 quan 189 +呐 ne 188 +浣 huan 188 +谏 jian 188 +韬 tao 188 +唑 zuo 187 +檐 yan 187 +藩 fan 187 +钊 zhao 187 +榆 yu 186 +靶 ba 186 +嚎 hao 185 +瓢 piao 185 +坯 pi 184 +懵 meng 184 +擅 shan 184 +灼 zhuo 184 +肢 zhi 184 +鹭 lu 184 +仟 qian 183 +倘 tang 183 +卉 hui 183 +桢 zhen 183 +玺 xi 183 +玻 bo 183 +胥 xu 183 +嵩 song 182 +摧 cui 182 +仆 pu 181 +尻 kao 181 +溴 xiu 181 +睛 jing 181 +褒 bao 181 +谊 yi 181 +辟 bi 180 +辟 pi 180 +鬓 bin 180 +黯 an 180 +擎 qing 179 +溃 kui 179 +怔 zheng 178 +砷 shen 178 +聋 long 178 +躁 zao 178 +卯 mao 177 +噜 lu 177 +涝 lao 177 +谎 huang 177 +颁 ban 177 +俐 li 176 +圹 kuang 176 +捌 ba 176 +璋 zhang 176 +谣 yao 176 +钒 fan 176 +嘀 di 175 +弋 yi 175 +忒 te 175 +昱 yu 175 +沸 fei 175 +疲 pi 175 +硒 xi 175 +脖 bo 175 +蛊 gu 175 +躬 gong 175 +铂 bo 175 +伶 ling 174 +睹 du 174 +葡 pu 174 +垣 yuan 173 +扼 e 173 +裳 shang 173 +遛 liu 172 +阉 yan 172 +哆 duo 171 +濑 lai 171 +笃 du 171 +趾 zhi 171 +霁 ji 171 +洼 wa 170 +痔 zhi 170 +瘫 tan 170 +箍 gu 170 +囡 nan 169 +烘 hong 169 +瘀 yu 169 +钨 wu 169 +隙 xi 169 +馍 mo 169 +捋 lv 168 +榨 zha 168 +辜 gu 168 +什 shi 167 +噤 jin 167 +碾 nian 167 +缅 mian 167 +葆 bao 167 +饵 er 167 +戟 ji 166 +癫 dian 166 +蝎 xie 166 +蹿 cuan 166 +鞘 qiao 166 +麒 qi 166 +亥 hai 165 +拭 shi 165 +椎 zhui 165 +瘪 bie 165 +盅 zhong 165 +胁 xie 165 +襟 jin 165 +兀 wu 164 +囚 qiu 164 +昵 ni 164 +泊 po 164 +绛 jiang 164 +贻 yi 164 +轶 yi 164 +铵 an 164 +宸 chen 163 +晔 ye 163 +殴 ou 163 +缚 fu 163 +蓓 bei 163 +谐 xie 163 +钼 mu 163 +铎 duo 163 +锚 mao 163 +恬 tian 162 +愧 kui 162 +樵 qiao 162 +涡 wo 162 +贮 zhu 162 +岔 cha 161 +稚 zhi 161 +脐 qi 161 +苷 gan 161 +丞 cheng 160 +和 huo 160 +嗄 a 160 +坞 wu 160 +尉 wei 160 +熵 shang 160 +翊 yi 160 +孚 fu 159 +恺 kai 159 +熄 xi 159 +玥 yue 159 +硝 xiao 159 +艮 gen 159 +怯 qie 158 +珉 min 158 +腊 la 158 +腊 xi 158 +倭 wo 157 +大 dai 157 +渭 wei 157 +跺 duo 157 +赎 shu 156 +坂 ban 155 +邝 kuang 155 +陇 long 155 +屿 yu 154 +瘸 que 154 +瞻 zhan 154 +践 jian 154 +饲 si 154 +勉 mian 153 +卅 sa 153 +嘻 xi 153 +忒 tui 153 +械 xie 153 +湝 jie 153 +绫 ling 153 +荔 li 153 +虱 shi 153 +仄 ze 152 +寰 huan 152 +畜 chu 152 +痞 pi 152 +盛 cheng 152 +绰 chao 152 +绰 chuo 152 +肛 gang 152 +啄 zhuo 151 +炕 kang 151 +禧 xi 151 +竭 jie 151 +踱 duo 151 +厦 sha 150 +泠 ling 150 +瘟 wen 150 +荻 di 150 +荼 tu 150 +匝 za 149 +匿 ni 149 +晤 wu 149 +漪 yi 149 +缆 lan 149 +蔓 man 149 +蔓 wan 149 +冼 xian 148 +厕 ce 148 +疫 yi 148 +窖 jiao 148 +蔫 nian 148 +驮 tuo 148 +凳 deng 147 +咚 dong 147 +暑 shu 147 +睦 mu 147 +祀 si 147 +笺 jian 147 +莞 guan 147 +骥 ji 147 +亢 kang 146 +倔 jue 146 +削 xue 146 +剔 ti 146 +枭 xiao 146 +樽 zun 146 +礁 jiao 146 +颊 jia 146 +鸠 jiu 146 +婵 chan 145 +渚 zhu 145 +霓 ni 145 +喆 zhe 144 +歆 xin 144 +蒿 hao 144 +讷 ne 144 +丕 pi 143 +噎 ye 143 +涤 di 143 +渺 miao 143 +乾 gan 142 +寮 liao 142 +抿 min 142 +撼 han 142 +炽 chi 142 +烯 xi 142 +琨 kun 142 +町 ding 142 +绸 chou 142 +茸 rong 142 +嘲 chao 141 +噬 shi 141 +妒 du 141 +峥 zheng 141 +搔 sao 141 +漱 shu 141 +糠 kang 141 +缇 ti 141 +薯 shu 141 +雏 chu 141 +萎 wei 140 +仂 le 139 +卟 bu 139 +壬 ren 139 +彝 yi 139 +悍 han 139 +憎 zeng 139 +歉 qian 139 +秧 yang 139 +粹 cui 139 +胰 yi 139 +蕉 jiao 139 +歧 qi 138 +糯 nuo 138 +食 si 138 +鲨 sha 138 +寐 mei 137 +憾 han 137 +撵 nian 137 +涕 ti 137 +荀 xun 137 +蒽 en 137 +谬 miu 137 +驹 ju 137 +奢 she 136 +弛 chi 136 +獒 ao 136 +绾 wan 136 +藉 ji 136 +叛 pan 135 +夯 hang 135 +扎 za 135 +拈 nian 135 +溯 su 135 +狭 xia 135 +璞 pu 135 +肋 le 135 +莽 mang 135 +咋 zha 134 +桨 jiang 134 +疣 you 134 +窘 jiong 134 +绩 ji 134 +绷 beng 134 +酉 you 134 +靡 mi 134 +伯 bai 133 +勘 kan 133 +抡 lun 133 +拘 ju 133 +汐 xi 133 +泗 si 133 +谅 liang 133 +辙 zhe 133 +锄 chu 133 +鳍 qi 133 +剌 la 132 +啤 pi 132 +墅 shu 132 +暇 xia 132 +汲 ji 132 +簪 zan 132 +舵 duo 132 +隅 yu 132 +叭 ba 131 +忻 xin 131 +瀛 ying 131 +琐 suo 131 +苓 ling 131 +杵 chu 130 +枢 shu 130 +栅 shan 130 +棘 ji 130 +眩 xuan 130 +硼 peng 130 +筠 jun 130 +逍 xiao 130 +凛 lin 129 +厥 jue 129 +姣 jiao 129 +绥 sui 129 +羌 qiang 129 +诞 dan 129 +辫 bian 129 +醛 quan 129 +丐 gai 128 +仇 qiu 128 +娼 chang 128 +悄 qiao 128 +毗 pi 128 +珀 po 128 +笠 li 128 +脲 niao 128 +逞 cheng 128 +咖 ka 127 +炙 zhi 127 +癣 xuan 127 +糜 mi 127 +舀 yao 127 +锂 li 127 +镉 ge 127 +咔 ka 126 +唠 lao 126 +圃 pu 126 +栖 xi 126 +琰 yan 126 +凄 qi 125 +匙 shi 125 +啧 ze 125 +姗 shan 125 +梧 wu 125 +毯 tan 125 +沼 zhao 125 +琅 lang 125 +颅 lu 125 +颓 tui 125 +唬 hu 124 +疝 shan 124 +疹 zhen 124 +皋 gao 124 +稼 jia 124 +茫 mang 124 +萃 cui 124 +虏 lu 124 +螨 man 124 +骊 li 124 +庶 shu 123 +怂 song 123 +撅 jue 123 +晗 han 123 +钴 gu 123 +孜 zi 122 +弈 yi 122 +柚 you 122 +毡 zhan 122 +煨 wei 122 +蔽 bi 122 +隶 li 122 +骇 hai 122 +偕 xie 121 +恃 shi 121 +旻 min 121 +综 zeng 121 +综 zong 121 +翡 fei 121 +骞 qian 121 +鹃 juan 121 +凰 huang 120 +勒 lei 120 +哌 pai 120 +垄 long 120 +孑 jie 120 +宿 xiu 120 +帛 bo 120 +樟 zhang 120 +炊 chui 120 +诃 he 120 +馥 fu 120 +咂 za 119 +挎 kua 119 +濮 pu 119 +犀 xi 119 +珑 long 119 +瓮 weng 119 +磐 pan 119 +罡 gang 119 +苛 ke 119 +莘 xin 119 +霏 fei 119 +骁 xiao 119 +夭 yao 118 +攥 zuan 118 +柑 gan 118 +滔 tao 118 +炬 ju 118 +貂 diao 118 +闺 gui 118 +吱 zhi 117 +囤 tun 117 +惩 cheng 117 +洙 zhu 117 +狸 li 117 +眷 juan 117 +竺 zhu 117 +缪 miao 117 +舆 yu 117 +谗 chan 117 +邸 di 117 +嬉 xi 116 +搀 chan 116 +椿 chun 116 +泾 jing 116 +瘘 lou 116 +膳 shan 116 +觞 shang 116 +轧 zha 116 +髻 ji 116 +啖 dan 115 +沅 yuan 115 +矶 ji 115 +秽 hui 115 +戆 gang 114 +旌 jing 114 +暧 ai 114 +滢 ying 114 +濠 hao 114 +燮 xie 114 +裆 dang 114 +躯 qu 114 +锣 luo 114 +锻 duan 114 +啜 chuo 113 +弼 bi 113 +恪 ke 113 +灞 ba 113 +畦 qi 113 +痹 bi 113 +胯 kua 113 +傈 li 112 +叟 sou 112 +摹 mo 112 +番 pan 112 +癸 gui 112 +禛 zhen 112 +茉 mo 112 +醚 mi 112 +锺 zhong 112 +匾 bian 111 +吠 fei 111 +氰 qing 111 +腚 ding 111 +蓟 ji 111 +酣 han 111 +墟 xu 110 +妤 yu 110 +嫔 pin 110 +孓 jue 110 +抒 shu 110 +璃 li 110 +痂 jia 110 +羚 ling 110 +膘 biao 110 +豚 tun 110 +铠 kai 110 +锛 ben 110 +隽 jun 110 +亓 qi 109 +嘱 zhu 109 +峦 luan 109 +斟 zhen 109 +湄 mei 109 +潞 lu 109 +芋 yu 109 +蛹 yong 109 +蹙 cu 109 +锕 a 109 +镭 lei 109 +骅 hua 109 +鲤 li 109 +嚼 jue 108 +柿 shi 108 +澎 peng 108 +煦 xu 108 +珺 jun 108 +顷 qing 108 +吮 shun 107 +垛 duo 107 +曳 ye 107 +濂 lian 107 +煽 shan 107 +肋 lei 107 +蛟 jiao 107 +蜷 quan 107 +酌 zhuo 107 +吁 yu 106 +宙 zhou 106 +揩 kai 106 +渤 bo 106 +璟 jing 106 +芜 wu 106 +顽 wan 106 +骐 qi 106 +嗳 ai 105 +噌 ceng 105 +戌 xu 105 +擂 lei 105 +柬 jian 105 +槌 chui 105 +汕 shan 105 +涔 cen 105 +琉 liu 105 +糗 qiu 105 +缎 duan 105 +苘 qing 105 +崴 wai 104 +徙 xi 104 +拗 ao 104 +攸 you 104 +柠 ning 104 +艰 jian 104 +蔷 qiang 104 +鸢 yuan 104 +佣 yong 103 +敝 bi 103 +晦 hui 103 +珈 jia 103 +砰 peng 103 +缨 ying 103 +铛 dang 103 +嗑 ke 102 +嗡 weng 102 +囿 you 102 +埔 pu 102 +恍 huang 102 +磬 qing 102 +筷 kuai 102 +粱 liang 102 +褶 zhe 102 +泯 min 101 +漳 zhang 101 +禀 bing 101 +绚 xuan 101 +羲 xi 101 +聆 ling 101 +谕 yu 101 +侄 zhi 100 +侦 zhen 100 +峨 e 100 +廓 kuo 100 +攒 cuan 100 +昀 yun 100 +檬 meng 100 +沂 yi 100 +涟 lian 100 +琢 zuo 100 +蔗 zhe 100 +讼 song 100 +呱 gu 99 +嗓 sang 99 +掳 lu 99 +曙 shu 99 +萦 ying 99 +蔺 lin 99 +铨 quan 99 +鹊 que 99 +熹 xi 98 +犟 jiang 98 +甬 yong 98 +诡 gui 98 +踮 dian 98 +辄 zhe 98 +佯 yang 97 +倡 chang 97 +吭 keng 97 +弑 shi 97 +戍 shu 97 +撂 liao 97 +沦 lun 97 +罔 wang 97 +蕃 fan 97 +郴 chen 97 +佛 fu 96 +厮 si 96 +壑 he 96 +壤 rang 96 +橱 chu 96 +灸 jiu 96 +烷 wan 96 +狙 ju 96 +赦 she 96 +嬴 ying 95 +橡 xiang 95 +蚌 bang 95 +剐 gua 94 +咎 jiu 94 +巽 xun 94 +懈 xie 94 +莓 mei 94 +菡 han 94 +鄢 yan 94 +锝 de 94 +垦 ken 93 +姊 zi 93 +孬 nao 93 +庇 bi 93 +愉 yu 93 +濡 ru 93 +烽 feng 93 +箐 qing 93 +篱 li 93 +讳 hui 93 +骶 di 93 +麽 mo 93 +咕 gu 92 +唰 shua 92 +坳 ao 92 +懋 mao 92 +沣 feng 92 +烁 shuo 92 +珅 shen 92 +珞 luo 92 +睑 jian 92 +聿 yu 92 +蟒 mang 92 +釜 fu 92 +钜 ju 92 +飒 sa 92 +喈 jie 91 +恢 hui 91 +敞 chang 91 +榭 xie 91 +焙 bei 91 +牡 mu 91 +犊 du 91 +篷 peng 91 +簧 huang 91 +膛 tang 91 +膨 peng 91 +迩 er 91 +呤 ling 90 +杞 qi 90 +棣 di 90 +铐 kao 90 +闰 run 90 +坷 ke 89 +慨 kai 89 +扈 hu 89 +歹 dai 89 +烩 hui 89 +砣 tuo 89 +祢 mi 89 +萤 ying 89 +僚 liao 88 +掬 ju 88 +芊 qian 88 +跋 ba 88 +铉 xuan 88 +隘 ai 88 +鳃 sai 88 +鳌 ao 88 +嘶 si 87 +惫 bei 87 +桀 jie 87 +畸 ji 87 +篆 zhuan 87 +郢 ying 87 +鄙 bi 87 +颦 pin 87 +驭 yu 87 +驯 xun 87 +叨 dao 86 +噩 e 86 +宕 dang 86 +榻 ta 86 +汾 fen 86 +烃 ting 86 +袂 mei 86 +钡 bei 86 +陋 lou 86 +隼 sun 86 +馊 sou 86 +乂 yi 85 +伢 ya 85 +劢 mai 85 +哺 bu 85 +宦 huan 85 +捺 na 85 +朽 xiu 85 +渥 wo 85 +瑰 gui 85 +钥 yao 85 +馈 kui 85 +鼬 you 85 +僻 pi 84 +冕 mian 84 +刎 wen 84 +嘈 cao 84 +嚣 xiao 84 +彳 chi 84 +祚 zuo 84 +耘 yun 84 +舫 fang 84 +遐 xia 84 +酰 xian 84 +伎 ji 83 +佚 yi 83 +俪 li 83 +喳 zha 83 +嗖 sou 83 +屙 e 83 +戋 jian 83 +敕 chi 83 +瑕 xia 83 +篓 lou 83 +诲 hui 83 +鳄 e 83 +偎 wei 82 +剟 duo 82 +挚 zhi 82 +捍 han 82 +汛 xun 82 +燎 liao 82 +筵 yan 82 +蹈 dao 82 +吆 yao 81 +幔 man 81 +幡 fan 81 +揖 yi 81 +暄 xuan 81 +秩 zhi 81 +缁 zi 81 +翦 jian 81 +蜕 tui 81 +讽 feng 81 +谟 mo 81 +赃 zang 81 +颌 he 81 +亍 chu 80 +侩 kuai 80 +俑 yong 80 +吡 bi 80 +掖 ye 80 +昶 chang 80 +槛 kan 80 +沏 qi 80 +纂 zuan 80 +腌 yan 80 +蹇 jian 80 +霾 mai 80 +尢 you 79 +岫 xiu 79 +淦 gan 79 +溥 pu 79 +秆 gan 79 +箕 ji 79 +箩 luo 79 +绽 zhan 79 +衙 ya 79 +靛 dian 79 +颍 ying 79 +鲲 kun 79 +龋 qu 79 +偃 yan 78 +叱 chi 78 +唧 ji 78 +噔 deng 78 +淬 cui 78 +舸 ge 78 +谌 chen 78 +霭 ai 78 +嗝 ge 77 +姥 lao 77 +怠 dai 77 +晰 xi 77 +淞 song 77 +蚝 hao 77 +衢 qu 77 +讥 ji 77 +喱 li 76 +夬 guai 76 +婿 xu 76 +擀 gan 76 +栀 zhi 76 +沱 tuo 76 +涎 xian 76 +绅 shen 76 +迸 beng 76 +扉 fei 75 +氦 hai 75 +淀 dian 75 +漕 cao 75 +筠 yun 75 +荪 sun 75 +葭 jia 75 +袅 niao 75 +裱 biao 75 +谥 shi 75 +钺 yue 75 +饺 jiao 75 +孵 fu 74 +屹 yi 74 +岙 ao 74 +幂 mi 74 +戮 lu 74 +朦 meng 74 +榄 lan 74 +殖 zhi 74 +洵 xun 74 +畿 ji 74 +矾 fan 74 +碴 cha 74 +稽 ji 74 +谍 die 74 +妨 fang 73 +恳 ken 73 +澍 shu 73 +皿 min 73 +羟 qiang 73 +羿 yi 73 +吁 xu 72 +咿 yi 72 +孢 bao 72 +尕 ga 72 +怖 bu 72 +抻 chen 72 +濯 zhuo 72 +盹 dun 72 +矜 jin 72 +腑 fu 72 +臼 jiu 72 +舷 xian 72 +荟 hui 72 +萘 nai 72 +萼 e 72 +虔 qian 72 +逵 kui 72 +锑 ti 72 +飨 xiang 72 +鬃 zong 72 +侑 you 71 +啐 cui 71 +喙 hui 71 +夔 kui 71 +撸 lu 71 +杳 yao 71 +潼 tong 71 +煊 xuan 71 +疱 pao 71 +筏 fa 71 +绡 xiao 71 +郦 li 71 +饷 xiang 71 +偈 ji 70 +啡 fei 70 +楣 mei 70 +汰 tai 70 +篡 cuan 70 +舶 bo 70 +荚 jia 70 +褂 gua 70 +诣 yi 70 +诫 jie 70 +镐 gao 70 +嫡 di 69 +宓 mi 69 +惭 can 69 +椀 wan 69 +殆 dai 69 +潩 yi 69 +焯 chao 69 +痢 li 69 +穹 qiong 69 +讹 e 69 +谒 ye 69 +赘 zhui 69 +颚 e 69 +吝 lin 68 +枷 jia 68 +楔 xie 68 +氓 mang 68 +痧 sha 68 +眶 kuang 68 +祗 zhi 68 +蟾 chan 68 +诬 wu 68 +铆 mao 68 +雉 zhi 68 +咨 zi 67 +婢 bi 67 +帷 wei 67 +榴 liu 67 +琏 lian 67 +璜 huang 67 +蜗 wo 67 +螯 ao 67 +赊 she 67 +铣 xi 67 +髯 ran 67 +剜 wan 66 +唾 tuo 66 +圳 zhen 66 +娑 suo 66 +忱 chen 66 +梃 ting 66 +膈 ge 66 +芥 jie 66 +辇 nian 66 +骡 luo 66 +厝 cuo 65 +啷 lang 65 +巅 dian 65 +晁 chao 65 +榛 zhen 65 +澶 chan 65 +瓤 rang 65 +痨 lao 65 +睫 jie 65 +蔬 shu 65 +轧 ya 65 +铿 keng 65 +阪 ban 65 +仝 tong 64 +哏 gen 64 +嘣 beng 64 +塾 shu 64 +壕 hao 64 +戾 li 64 +昙 tan 64 +曝 pu 64 +溺 ni 64 +牲 sheng 64 +皎 jiao 64 +翀 chong 64 +苒 ran 64 +蛰 zhe 64 +跷 qiao 64 +陨 yun 64 +颉 jie 64 +鳝 shan 64 +亘 gen 63 +勐 meng 63 +嗪 qin 63 +琀 han 63 +缔 di 63 +翌 yi 63 +耨 nou 63 +胫 jing 63 +蘑 mo 63 +鸳 yuan 63 +圩 wei 62 +堇 jin 62 +夙 su 62 +恣 zi 62 +槎 cha 62 +泌 mi 62 +砺 li 62 +竣 jun 62 +羁 ji 62 +耽 dan 62 +荧 ying 62 +葳 wei 62 +衾 qin 62 +谯 qiao 62 +辐 fu 62 +邈 miao 62 +颞 nie 62 +骸 hai 62 +俘 fu 61 +冽 lie 61 +忿 fen 61 +惶 huang 61 +拄 zhu 61 +掣 che 61 +棹 zhao 61 +椴 duan 61 +瀹 yue 61 +焗 ju 61 +熠 yi 61 +琬 wan 61 +睐 lai 61 +砾 li 61 +祆 xian 61 +臊 sao 61 +蘼 mi 61 +蛛 zhu 61 +赈 zhen 61 +踝 huai 61 +锉 cuo 61 +镔 bin 61 +咣 guang 60 +塬 yuan 60 +岷 min 60 +嵘 rong 60 +瓒 zan 60 +篁 huang 60 +耆 qi 60 +芍 shao 60 +蔑 mie 60 +蚜 ya 60 +蝗 huang 60 +贿 hui 60 +遏 e 60 +钚 bu 60 +铰 jiao 60 +鳗 man 60 +咻 xiu 59 +娉 ping 59 +彧 yu 59 +慵 yong 59 +昇 sheng 59 +滗 bi 59 +漩 xuan 59 +琮 cong 59 +祐 you 59 +绉 zhou 59 +缮 shan 59 +羯 jie 59 +舛 chuan 59 +蘅 heng 59 +跛 bo 59 +颢 hao 59 +鹫 jiu 59 +屉 ti 58 +殃 yang 58 +殉 xun 58 +浚 jun 58 +狩 shou 58 +猷 you 58 +玟 wen 58 +祷 dao 58 +翳 yi 58 +耦 ou 58 +肴 yao 58 +蛀 zhu 58 +谧 mi 58 +郅 zhi 58 +銮 luan 58 +锆 gao 58 +锗 zhe 58 +锟 kun 58 +锴 kai 58 +陛 bi 58 +剿 jiao 57 +啮 nie 57 +奠 dian 57 +姘 pin 57 +孺 ru 57 +恙 yang 57 +愕 e 57 +椽 chuan 57 +浏 liu 57 +漓 li 57 +璨 can 57 +痿 wei 57 +蝠 fu 57 +谙 an 57 +谤 bang 57 +趑 zi 57 +镛 yong 57 +俸 feng 56 +倌 guan 56 +匈 xiong 56 +哽 geng 56 +噙 qin 56 +囔 nang 56 +壅 yong 56 +惰 duo 56 +慑 she 56 +摒 bing 56 +斛 hu 56 +浠 xi 56 +煸 bian 56 +翩 pian 56 +谰 lan 56 +郜 gao 56 +闾 lv 56 +隽 juan 56 +韭 jiu 56 +骋 cheng 56 +黍 shu 56 +嗽 sou 55 +峭 qiao 55 +怵 chu 55 +桅 wei 55 +殁 mo 55 +牒 die 55 +狡 jiao 55 +瑙 nao 55 +盂 yu 55 +硌 ge 55 +箔 bo 55 +粲 can 55 +胧 long 55 +芡 qian 55 +蒯 kuai 55 +贲 ben 55 +跤 jiao 55 +迄 qi 55 +媳 xi 54 +寥 liao 54 +弁 bian 54 +怅 chang 54 +悖 bei 54 +槿 jin 54 +溏 tang 54 +瑄 xuan 54 +瞽 gu 54 +箴 zhen 54 +藜 li 54 +辕 yuan 54 +酪 lao 54 +钕 nv 54 +镬 huo 54 +佗 tuo 53 +吒 zha 53 +哮 xiao 53 +啉 lin 53 +嗫 nie 53 +宥 you 53 +恹 yan 53 +愀 qiao 53 +拇 mu 53 +桉 an 53 +桡 rao 53 +瓯 ou 53 +疵 ci 53 +睢 sui 53 +蔼 ai 53 +蚤 zao 53 +讵 ju 53 +阖 he 53 +龛 kan 53 +仞 ren 52 +墒 shang 52 +嫉 ji 52 +寞 mo 52 +峙 zhi 52 +攘 rang 52 +楹 ying 52 +渎 du 52 +熨 yun 52 +绶 shou 52 +耙 ba 52 +腱 jian 52 +膀 bang 52 +螟 ming 52 +谚 yan 52 +蹋 ta 52 +阕 que 52 +髂 qia 52 +鹆 yu 52 +嗤 chi 51 +噘 jue 51 +埂 geng 51 +妲 da 51 +幌 huang 51 +棰 chui 51 +澧 li 51 +矗 chu 51 +缃 xiang 51 +腋 ye 51 +莆 pu 51 +蓑 suo 51 +藐 miao 51 +蜇 zhe 51 +谪 zhe 51 +跖 zhi 51 +踞 ju 51 +锹 qiao 51 +阐 chan 51 +哓 xiao 50 +忞 min 50 +悯 min 50 +檫 cha 50 +氡 dong 50 +泅 qiu 50 +涣 huan 50 +渲 xuan 50 +滟 yan 50 +痈 yong 50 +粕 po 50 +绎 yi 50 +缙 jin 50 +蜒 yan 50 +谴 qian 50 +迂 yu 50 +遽 ju 50 +镂 lou 50 +镯 zhuo 50 +傣 dai 49 +兢 jing 49 +刈 yi 49 +拯 zheng 49 +欤 yu 49 +沤 ou 49 +燊 shen 49 +窒 zhi 49 +箸 zhu 49 +粑 ba 49 +绦 tao 49 +苄 bian 49 +蓼 liao 49 +蕨 jue 49 +觑 qu 49 +辔 pei 49 +钎 qian 49 +镰 lian 49 +雒 luo 49 +伫 zhu 48 +佃 dian 48 +啶 ding 48 +垚 yao 48 +奂 huan 48 +娲 wa 48 +崧 song 48 +庾 yu 48 +怛 da 48 +氖 nai 48 +汊 cha 48 +沮 ju 48 +泷 long 48 +璀 cui 48 +璎 ying 48 +癞 lai 48 +绀 gan 48 +蔻 kou 48 +跚 shan 48 +锶 si 48 +镗 tang 48 +骰 tou 48 +髁 ke 48 +傩 nuo 47 +圻 qi 47 +晌 shang 47 +泮 pan 47 +泸 lu 47 +湜 shi 47 +濒 bin 47 +珪 gui 47 +甯 ning 47 +祉 zhi 47 +稷 ji 47 +茴 hui 47 +荇 xing 47 +荜 bi 47 +钯 ba 47 +钽 tan 47 +隧 sui 47 +馁 nei 47 +馕 nang 47 +垃 la 46 +嫦 chang 46 +忏 chan 46 +揆 kui 46 +杈 cha 46 +栩 xu 46 +椤 luo 46 +橹 lu 46 +滘 jiao 46 +璁 cong 46 +疙 ge 46 +祜 hu 46 +纣 zhou 46 +腓 fei 46 +腴 yu 46 +蟀 shuai 46 +诠 quan 46 +邰 tai 46 +霈 pei 46 +侮 wu 45 +僦 jiu 45 +刍 chu 45 +嚯 huo 45 +嵇 ji 45 +徊 huai 45 +扪 men 45 +橇 qiao 45 +汴 bian 45 +琇 xiu 45 +琶 pa 45 +盎 ang 45 +睨 ni 45 +缉 ji 45 +誊 teng 45 +谮 zen 45 +赭 zhe 45 +邺 ye 45 +髋 kuan 45 +佸 huo 44 +侗 dong 44 +倏 shu 44 +劭 shao 44 +匮 kui 44 +嶂 zhang 44 +彗 hui 44 +忖 cun 44 +暝 ming 44 +氐 di 44 +琥 hu 44 +皑 ai 44 +砧 zhen 44 +翱 ao 44 +腩 nan 44 +腭 e 44 +轲 ke 44 +铊 ta 44 +铌 ni 44 +铡 zha 44 +韫 yun 44 +驽 nu 44 +嘁 qi 43 +埭 dai 43 +妊 ren 43 +娆 rao 43 +惺 xing 43 +愠 yun 43 +枳 zhi 43 +浔 xun 43 +湍 tuan 43 +祟 sui 43 +缈 miao 43 +羔 gao 43 +膻 shan 43 +芠 wen 43 +莅 li 43 +螭 chi 43 +讫 qi 43 +踵 zhong 43 +酋 qiu 43 +钏 chuan 43 +铋 bi 43 +驷 si 43 +冗 rong 42 +呶 nao 42 +哞 mou 42 +帏 wei 42 +昴 mao 42 +畈 fan 42 +碇 ding 42 +肱 gong 42 +膺 ying 42 +苕 shao 42 +虬 qiu 42 +诒 yi 42 +诧 cha 42 +醌 kun 42 +铖 cheng 42 +锷 e 42 +鲫 ji 42 +乒 ping 41 +亟 ji 41 +俎 zu 41 +匕 bi 41 +奄 yan 41 +妫 gui 41 +恽 yun 41 +撷 xie 41 +桁 heng 41 +濞 bi 41 +炀 yang 41 +皙 xi 41 +莜 you 41 +菀 wan 41 +菩 pu 41 +蔸 dou 41 +谶 chen 41 +辍 chuo 41 +阈 yu 41 +魇 yan 41 +俨 yan 40 +僖 xi 40 +堑 qian 40 +婊 biao 40 +惕 ti 40 +揸 zha 40 +猩 xing 40 +础 chu 40 +磋 cuo 40 +胄 zhou 40 +芩 qin 40 +苡 yi 40 +葶 ting 40 +衿 jin 40 +蹩 bie 40 +蹴 cu 40 +迳 jing 40 +凫 fu 39 +匆 cong 39 +喋 die 39 +垠 yin 39 +垭 ya 39 +掸 dan 39 +斫 zhuo 39 +晞 xi 39 +殒 yun 39 +殓 lian 39 +毂 gu 39 +炝 qiang 39 +碌 lu 39 +禺 yu 39 +稗 bai 39 +窿 long 39 +轸 zhen 39 +遒 qiu 39 +邕 yong 39 +郗 xi 39 +铟 yin 39 +镱 yi 39 +陂 bei 39 +颀 qi 39 +骈 pian 39 +偌 ruo 38 +嘹 liao 38 +噫 yi 38 +徇 xun 38 +忝 tian 38 +忤 wu 38 +洱 er 38 +潍 wei 38 +稔 ren 38 +箬 ruo 38 +羸 lei 38 +裾 ju 38 +诽 fei 38 +譬 pi 38 +酵 jiao 38 +鞅 yang 38 +馔 zhuan 38 +骠 biao 38 +佺 quan 37 +僭 jian 37 +坍 tan 37 +埙 xun 37 +妩 wu 37 +恸 tong 37 +挞 ta 37 +涸 he 37 +淖 nao 37 +澹 dan 37 +爰 yuan 37 +猝 cu 37 +瑗 yuan 37 +矬 cuo 37 +纾 shu 37 +肟 wo 37 +荞 qiao 37 +萜 tie 37 +蠹 du 37 +诤 zheng 37 +诳 kuang 37 +轼 shi 37 +锵 qiang 37 +鬟 huan 37 +厩 jiu 36 +啾 jiu 36 +喹 kui 36 +墉 yong 36 +悸 ji 36 +慷 kang 36 +斡 wo 36 +旄 mao 36 +柘 zhe 36 +梆 bang 36 +氩 ya 36 +珥 er 36 +甑 zeng 36 +祼 guan 36 +脘 wan 36 +葩 pa 36 +藓 xian 36 +虢 guo 36 +褥 ru 36 +訾 zi 36 +铑 lao 36 +阡 qian 36 +俚 li 35 +哂 shen 35 +哐 kuang 35 +哔 bi 35 +噼 pi 35 +姹 cha 35 +徕 lai 35 +憩 qi 35 +挹 yi 35 +攫 jue 35 +椋 liang 35 +榫 sun 35 +樾 yue 35 +殡 bin 35 +氛 fen 35 +泱 yang 35 +洮 tao 35 +漉 lu 35 +烬 jin 35 +煅 duan 35 +狎 xia 35 +獾 huan 35 +璘 lin 35 +畲 she 35 +痫 xian 35 +皤 po 35 +羧 suo 35 +覃 tan 35 +醯 xi 35 +铳 chong 35 +雹 bao 35 +靥 ye 35 +鲛 jiao 35 +倬 zhuo 34 +儋 dan 34 +嘭 peng 34 +圪 ge 34 +圮 pi 34 +婺 wu 34 +屾 shen 34 +崛 jue 34 +悭 qian 34 +懦 nuo 34 +擢 zhuo 34 +枋 fang 34 +椁 guo 34 +榔 lang 34 +洹 huan 34 +涪 fu 34 +爨 cuan 34 +爪 zhao 34 +痤 cuo 34 +眦 zi 34 +窸 xi 34 +舐 shi 34 +苻 fu 34 +蜥 xi 34 +豺 chai 34 +邯 han 34 +颏 ke 34 +骧 xiang 34 +佥 qian 33 +侥 jiao 33 +勖 xu 33 +唵 an 33 +帜 zhi 33 +樯 qiang 33 +汆 cuan 33 +滁 chu 33 +瘴 zhang 33 +癜 dian 33 +砬 la 33 +祓 fu 33 +篙 gao 33 +纨 wan 33 +缄 jian 33 +缰 jiang 33 +耒 lei 33 +舂 chong 33 +芃 peng 33 +苴 ju 33 +茑 niao 33 +菂 di 33 +菅 jian 33 +菟 tu 33 +蜿 wan 33 +褰 qian 33 +诌 zhou 33 +诰 gao 33 +谑 xue 33 +谔 e 33 +谝 pian 33 +谲 jue 33 +赉 lai 33 +逻 luo 33 +邋 la 33 +酝 yun 33 +鲷 diao 33 +麝 she 33 +凼 dang 32 +垸 yuan 32 +屐 ji 32 +庹 tuo 32 +彘 zhi 32 +揶 ye 32 +椐 ju 32 +浒 hu 32 +溧 li 32 +滂 pang 32 +玑 ji 32 +玳 dai 32 +籁 lai 32 +粳 jing 32 +缶 fou 32 +臆 yi 32 +蚬 xian 32 +蟠 pan 32 +觋 xi 32 +诩 xu 32 +遨 ao 32 +邃 sui 32 +邛 qiong 32 +铢 zhu 32 +镌 juan 32 +霰 xian 32 +骢 cong 32 +佤 wa 31 +侈 chi 31 +叵 po 31 +恻 ce 31 +惮 dan 31 +搪 tang 31 +杩 ma 31 +栊 long 31 +桧 gui 31 +楸 qiu 31 +涞 lai 31 +湮 yan 31 +潴 zhu 31 +牝 pin 31 +猥 wei 31 +珐 fa 31 +皴 cun 31 +綦 qi 31 +缭 liao 31 +罹 li 31 +胍 gua 31 +芪 qi 31 +菘 song 31 +蚴 you 31 +觐 jin 31 +谳 yan 31 +赂 lu 31 +赍 ji 31 +赡 shan 31 +酊 ding 31 +阱 jing 31 +鹜 wu 31 +亵 xie 30 +佞 ning 30 +偓 wo 30 +嗬 he 30 +妣 bi 30 +姻 yin 30 +娓 wei 30 +帚 zhou 30 +惘 wang 30 +桠 ya 30 +殚 dan 30 +沓 da 30 +浼 mei 30 +渌 lu 30 +滦 luan 30 +炆 wen 30 +煋 xing 30 +瑀 yu 30 +疚 jiu 30 +痉 jing 30 +碓 dui 30 +笏 hu 30 +籼 xian 30 +绺 liu 30 +绻 quan 30 +缂 ke 30 +肼 jing 30 +腈 jing 30 +莒 ju 30 +菠 bo 30 +蓦 mo 30 +蕈 xun 30 +薨 hong 30 +蘖 nie 30 +蚧 jie 30 +蜈 wu 30 +蟆 ma 30 +蠡 li 30 +豕 shi 30 +酽 yan 30 +铒 er 30 +颧 quan 30 +黢 qu 30 +咛 ning 29 +哧 chi 29 +姒 si 29 +婠 wan 29 +嫒 ai 29 +怆 chuang 29 +憬 jing 29 +掇 duo 29 +搡 sang 29 +旎 ni 29 +旮 ga 29 +昉 fang 29 +殛 ji 29 +洇 yin 29 +淄 zi 29 +淅 xi 29 +湃 pai 29 +溱 qin 29 +爿 pan 29 +琚 ju 29 +瑨 jin 29 +砜 feng 29 +笈 ji 29 +簟 dian 29 +罄 qing 29 +苌 chang 29 +茱 zhu 29 +莳 shi 29 +薏 yi 29 +觊 ji 29 +讶 ya 29 +蹑 nie 29 +逯 lu 29 +郾 yan 29 +铄 shuo 29 +铕 you 29 +铧 hua 29 +阗 tian 29 +鲟 xun 29 +仵 wu 28 +剽 piao 28 +哝 nong 28 +唏 xi 28 +啬 se 28 +埸 yi 28 +孛 bei 28 +崮 gu 28 +嶷 yi 28 +戛 jia 28 +潋 lian 28 +燧 sui 28 +狷 juan 28 +瑚 hu 28 +甾 zai 28 +疃 tuan 28 +瞑 ming 28 +窠 ke 28 +脯 pu 28 +苫 shan 28 +茆 mao 28 +茔 ying 28 +莘 shen 28 +菏 he 28 +萸 yu 28 +蝌 ke 28 +诹 zou 28 +谩 man 28 +蹶 jue 28 +逑 qiu 28 +钤 qian 28 +镲 cha 28 +镳 biao 28 +阂 he 28 +鲈 lu 28 +鹞 yao 28 +鼢 fen 28 +亶 dan 27 +侏 zhu 27 +呖 li 27 +唳 li 27 +塍 cheng 27 +屼 wu 27 +岘 xian 27 +徨 huang 27 +搴 qian 27 +杲 gao 27 +椪 peng 27 +沭 shu 27 +洌 lie 27 +睾 gao 27 +瞰 kan 27 +砭 bian 27 +硎 xing 27 +碉 diao 27 +缜 zhen 27 +胭 yan 27 +腠 cou 27 +菽 shu 27 +萄 tao 27 +蕤 rui 27 +薜 bi 27 +衮 gun 27 +讦 jie 27 +豸 zhi 27 +趸 dun 27 +迤 yi 27 +郧 yun 27 +鄄 juan 27 +钿 dian 27 +铍 pi 27 +骛 wu 27 +骜 ao 27 +鲠 geng 27 +鸩 zhen 27 +鹂 li 27 +麸 fu 27 +黜 chu 27 +呋 fu 26 +嗥 hao 26 +囤 dun 26 +圯 yi 26 +嫫 mo 26 +嬛 huan 26 +峒 dong 26 +嵴 ji 26 +幄 wo 26 +弭 mi 26 +徘 pai 26 +恚 hui 26 +悌 ti 26 +惦 dian 26 +戢 ji 26 +扦 qian 26 +掼 guan 26 +楝 lian 26 +楫 ji 26 +楮 chu 26 +橄 gan 26 +湟 huang 26 +焓 han 26 +猗 yi 26 +玷 dian 26 +疽 ju 26 +瘿 ying 26 +磡 kan 26 +莼 chun 26 +蕞 zui 26 +蝈 guo 26 +蟑 zhang 26 +蠕 ru 26 +衲 na 26 +觇 chan 26 +诓 kuang 26 +谀 yu 26 +赳 jiu 26 +醮 jiao 26 +醴 li 26 +铱 yi 26 +镓 jia 26 +馏 liu 26 +馒 man 26 +鹋 miao 26 +麂 ji 26 +乓 pang 25 +亳 bo 25 +墀 chi 25 +幛 zhang 25 +庑 wu 25 +庖 pao 25 +惇 dun 25 +愍 min 25 +擤 xing 25 +斓 lan 25 +枰 ping 25 +槟 bin 25 +檄 xi 25 +沌 dun 25 +泐 le 25 +獭 ta 25 +玎 ding 25 +珣 xun 25 +珲 hui 25 +瑷 ai 25 +疡 yang 25 +眄 mian 25 +瞋 chen 25 +砥 di 25 +硐 dong 25 +秸 jie 25 +紊 wen 25 +缤 bin 25 +缥 piao 25 +翕 xi 25 +翮 he 25 +肓 huang 25 +芗 xiang 25 +茕 qiong 25 +葫 hu 25 +葺 qi 25 +蚓 yin 25 +蜓 ting 25 +蝴 hu 25 +螂 lang 25 +讣 fu 25 +豢 huan 25 +赓 geng 25 +蹰 chu 25 +轭 e 25 +逄 pang 25 +邳 pi 25 +铈 shi 25 +镧 lan 25 +雎 ju 25 +骓 zhui 25 +鬻 yu 25 +鲧 gun 25 +鸸 er 25 +倜 ti 24 +倮 luo 24 +劼 jie 24 +劾 he 24 +呻 shen 24 +哙 kuai 24 +哚 duo 24 +嫚 man 24 +嫱 qiang 24 +寤 wu 24 +岬 jia 24 +岿 kui 24 +庠 xiang 24 +徉 yang 24 +恂 xun 24 +恫 dong 24 +悒 yi 24 +悚 song 24 +惋 wan 24 +惬 qie 24 +揿 qin 24 +沔 mian 24 +泫 xuan 24 +洄 hui 24 +瀵 fen 24 +狞 ning 24 +瑁 mao 24 +痱 fei 24 +绠 geng 24 +缢 yi 24 +耱 mo 24 +脍 kuai 24 +茁 zhuo 24 +蒌 lou 24 +蓁 zhen 24 +薮 sou 24 +蠲 juan 24 +谄 chan 24 +跻 ji 24 +郯 tan 24 +闳 hong 24 +霹 pi 24 +饬 chi 24 +鲑 gui 24 +冏 jiong 23 +刳 ku 23 +剀 kai 23 +嗌 ai 23 +怿 yi 23 +抟 tuan 23 +曩 nang 23 +槊 shuo 23 +橐 tuo 23 +汜 si 23 +洧 wei 23 +涘 si 23 +淙 cong 23 +滃 weng 23 +潢 huang 23 +焐 wu 23 +狈 bei 23 +獠 liao 23 +畹 wan 23 +瞩 zhu 23 +窣 su 23 +竦 song 23 +笆 ba 23 +糍 ci 23 +胳 ge 23 +膦 lin 23 +衅 xin 23 +袒 tan 23 +酞 tai 23 +锲 qie 23 +鎏 liu 23 +饯 jian 23 +饴 yi 23 +驺 zou 23 +鸬 lu 23 +鼾 han 23 +佶 ji 22 +俦 chou 22 +凇 song 22 +刿 gui 22 +啭 zhuan 22 +嚅 ru 22 +埵 duo 22 +媪 ao 22 +嵛 yu 22 +廪 lin 22 +挛 luan 22 +旖 yi 22 +桄 guang 22 +氪 ke 22 +涐 e 22 +潺 chan 22 +焜 kun 22 +熘 liu 22 +牯 gu 22 +牺 xi 22 +犇 ben 22 +獐 zhang 22 +玠 jie 22 +琵 pi 22 +瓠 hu 22 +甥 sheng 22 +疖 jie 22 +痪 huan 22 +眇 miao 22 +砀 dang 22 +筌 quan 22 +篦 bi 22 +篾 mie 22 +簋 gui 22 +缦 man 22 +纛 dao 22 +臌 gu 22 +苋 xian 22 +苧 ning 22 +菰 gu 22 +萁 qi 22 +葑 feng 22 +蒇 chan 22 +蚂 ma 22 +蜃 shen 22 +襦 ru 22 +迨 dai 22 +酐 gan 22 +醪 lao 22 +镒 yi 22 +闼 ta 22 +陟 zhi 22 +骟 shan 22 +鲻 zi 22 +麾 hui 22 +黻 fu 22 +鼐 nai 22 +侔 mou 21 +吽 hong 21 +咀 ju 21 +咫 zhi 21 +喑 yin 21 +垟 yang 21 +婀 e 21 +峁 mao 21 +忉 dao 21 +憭 liao 21 +挝 wo 21 +曷 he 21 +枥 li 21 +栉 zhi 21 +栎 li 21 +桎 zhi 21 +桤 qi 21 +樘 tang 21 +滏 fu 21 +滹 hu 21 +烜 xuan 21 +牍 du 21 +瘙 sao 21 +矸 gan 21 +祇 qi 21 +箧 qie 21 +缬 xie 21 +胗 zhen 21 +苎 zhu 21 +荏 ren 21 +薅 hao 21 +蛴 qi 21 +蝽 chun 21 +袢 pan 21 +谠 dang 21 +踊 yong 21 +钹 bo 21 +镝 di 21 +镫 deng 21 +顼 xu 21 +鲢 lian 21 +鳐 yao 21 +鹄 gu 21 +鼋 yuan 21 +龌 wo 21 +儆 jing 20 +卮 zhi 20 +厣 yan 20 +呓 yi 20 +咆 pao 20 +咐 fu 20 +咤 zha 20 +圾 ji 20 +坭 ni 20 +垡 fa 20 +埕 cheng 20 +夐 xiong 20 +娈 luan 20 +媾 gou 20 +嫪 lao 20 +孪 luan 20 +徂 cu 20 +徼 jiao 20 +怙 hu 20 +惚 hu 20 +愫 su 20 +拊 fu 20 +柞 zha 20 +柰 nai 20 +棂 ling 20 +毐 ai 20 +氘 dao 20 +氲 yun 20 +汔 qi 20 +洺 ming 20 +淝 fei 20 +溷 hun 20 +滓 zi 20 +炷 zhu 20 +琯 guan 20 +瑭 tang 20 +璠 fan 20 +盱 xu 20 +眭 sui 20 +穰 rang 20 +窈 yao 20 +笳 jia 20 +糅 rou 20 +罘 fu 20 +脩 xiu 20 +芈 mi 20 +芣 fu 20 +芫 yan 20 +蕲 qi 20 +蛎 li 20 +蛩 qiong 20 +觌 di 20 +觥 gong 20 +赙 fu 20 +踌 chou 20 +蹼 pu 20 +辗 nian 20 +邴 bing 20 +邾 zhu 20 +鄱 po 20 +铪 ha 20 +铯 se 20 +锏 jian 20 +闩 shuan 20 +阒 qu 20 +霪 yin 20 +鞑 da 20 +鬣 lie 20 +鹳 guan 20 +佽 ci 19 +兖 yan 19 +卣 you 19 +嗵 tong 19 +垩 e 19 +奁 lian 19 +妗 jin 19 +崒 zu 19 +帼 guo 19 +彖 tuan 19 +怃 wu 19 +怄 ou 19 +戣 kui 19 +抉 jue 19 +挈 qie 19 +旃 zhan 19 +昝 zan 19 +栝 gua 19 +殄 tian 19 +殳 shu 19 +溉 gai 19 +熜 cong 19 +猾 hua 19 +疥 jie 19 +碛 qi 19 +笞 chi 19 +箪 dan 19 +篪 chi 19 +簸 bo 19 +纫 ren 19 +缗 min 19 +耋 die 19 +耧 lou 19 +耷 da 19 +肪 fang 19 +胬 nu 19 +胱 guang 19 +腆 tian 19 +腙 zong 19 +苜 mu 19 +蓥 ying 19 +蘩 fan 19 +蜱 pi 19 +蝾 rong 19 +觎 yu 19 +诅 zu 19 +赁 lin 19 +赅 gai 19 +躔 chan 19 +郏 jia 19 +酹 lei 19 +铗 jia 19 +霎 sha 19 +饕 tao 19 +骝 liu 19 +髦 mao 19 +鲇 nian 19 +鲵 ni 19 +鳅 qiu 19 +鳓 le 19 +鳙 yong 19 +鹑 chun 19 +佼 jiao 18 +儇 xuan 18 +吩 fen 18 +咙 long 18 +嚄 huo 18 +坼 che 18 +嬬 ru 18 +孖 ma 18 +嵬 wei 18 +帙 zhi 18 +彷 pang 18 +憧 chong 18 +懊 ao 18 +捩 lie 18 +掾 yuan 18 +摈 bin 18 +杓 biao 18 +楦 xuan 18 +槲 hu 18 +歙 she 18 +氚 chuan 18 +汨 mi 18 +涿 zhuo 18 +濋 chu 18 +燚 yi 18 +牖 you 18 +猖 chang 18 +玦 jue 18 +瘠 ji 18 +碣 jie 18 +禳 rang 18 +稣 su 18 +笫 zi 18 +笄 ji 18 +筮 shi 18 +粼 lin 18 +绌 chu 18 +绱 shang 18 +缟 gao 18 +翥 zhu 18 +耄 mao 18 +耜 si 18 +聃 dan 18 +蛭 zhi 18 +蜚 fei 18 +讪 shan 18 +讴 ou 18 +诔 lei 18 +诟 gou 18 +貉 hao 18 +蹊 qi 18 +锔 ju 18 +镣 liao 18 +颔 han 18 +魉 liang 18 +鲆 ping 18 +鸯 yang 18 +鹚 ci 18 +黼 fu 18 +黾 min 18 +龊 chuo 18 +仃 ding 17 +傕 jue 17 +僮 tong 17 +剞 ji 17 +嚭 pi 17 +墘 qian 17 +娌 li 17 +嬿 yan 17 +岜 ba 17 +峄 yi 17 +崭 zhan 17 +弢 tao 17 +忸 niu 17 +悃 kun 17 +悴 cui 17 +戗 qiang 17 +暹 xian 17 +栲 kao 17 +桕 jiu 17 +椭 tuo 17 +槁 gao 17 +洑 fu 17 +洳 ru 17 +瓴 ling 17 +痼 gu 17 +瘁 cui 17 +眍 kou 17 +竽 yu 17 +笥 si 17 +笪 da 17 +箨 tuo 17 +篌 hou 17 +绔 ku 17 +罴 pi 17 +舄 xi 17 +茏 long 17 +藁 gao 17 +蚩 chi 17 +衽 ren 17 +褟 ta 17 +谖 xuan 17 +贽 zhi 17 +赜 ze 17 +赝 yan 17 +趔 lie 17 +轫 ren 17 +迢 tiao 17 +逋 bu 17 +邂 xie 17 +邗 han 17 +郄 qie 17 +鄞 yin 17 +醺 xun 17 +钇 yi 17 +鋆 yun 17 +錾 zan 17 +锨 xian 17 +镞 zu 17 +陲 chui 17 +隹 zhui 17 +骺 hou 17 +鱿 you 17 +鲎 hou 17 +鸵 tuo 17 +鹗 e 17 +鹬 yu 17 +黝 you 17 +伈 xin 16 +俳 pai 16 +倨 ju 16 +勰 xie 16 +匐 fu 16 +呙 guo 16 +咄 duo 16 +圉 yu 16 +垆 lu 16 +埝 nian 16 +姮 heng 16 +岢 ke 16 +徜 chang 16 +忮 zhi 16 +怩 ni 16 +怼 dui 16 +戕 qiang 16 +戡 kan 16 +扃 jiong 16 +捃 jun 16 +杼 zhu 16 +欸 ai 16 +洎 ji 16 +洚 jiang 16 +漈 ji 16 +炅 jiong 16 +煳 hu 16 +犍 jian 16 +珙 gong 16 +瓘 guan 16 +町 ting 16 +纡 yu 16 +绂 fu 16 +绋 fu 16 +罂 ying 16 +翟 di 16 +肫 zhun 16 +芨 ji 16 +苣 ju 16 +茭 jiao 16 +荩 jin 16 +荭 hong 16 +蒉 kui 16 +蘧 qu 16 +蛔 hui 16 +蜩 tiao 16 +蟮 shan 16 +蠃 luo 16 +衄 nv 16 +衩 cha 16 +詈 li 16 +诂 gu 16 +谡 su 16 +豨 xi 16 +赧 nan 16 +邨 cun 16 +郇 huan 16 +鄣 zhang 16 +醅 pei 16 +钍 tu 16 +镪 qiang 16 +阆 lang 16 +隈 wei 16 +隳 hui 16 +鹾 cuo 16 +黥 qing 16 +佻 tiao 15 +侪 chai 15 +僳 su 15 +兕 si 15 +嘧 mi 15 +圬 wu 15 +屣 xi 15 +廋 sou 15 +徭 yao 15 +戬 jian 15 +拶 za 15 +摭 zhi 15 +柁 duo 15 +柝 tuo 15 +槭 qi 15 +樗 chu 15 +溻 ta 15 +猡 luo 15 +玢 bin 15 +璈 ao 15 +璩 qu 15 +疬 li 15 +疳 gan 15 +瘗 yi 15 +皈 gui 15 +磴 deng 15 +粿 guo 15 +绁 xie 15 +绐 dai 15 +绗 hang 15 +繇 yao 15 +缯 zeng 15 +肮 ang 15 +膑 bin 15 +芾 fei 15 +茀 fu 15 +葙 xiang 15 +蓍 shi 15 +蔟 cu 15 +薹 tai 15 +虻 meng 15 +蚶 han 15 +蛏 cheng 15 +蝮 fu 15 +裟 sha 15 +褛 lv 15 +诋 di 15 +貅 xiu 15 +蹂 rou 15 +蹉 cuo 15 +逶 wei 15 +遢 ta 15 +钋 po 15 +钌 liao 15 +铙 nao 15 +铥 diu 15 +铼 lai 15 +锇 e 15 +鏊 ao 15 +阊 chang 15 +陬 zou 15 +鞣 rou 15 +顸 han 15 +飧 sun 15 +馄 hun 15 +骖 can 15 +魃 ba 15 +鹦 ying 15 +黠 xia 15 +龇 zi 15 +龠 yue 15 +伥 chang 14 +伲 ni 14 +侂 tuo 14 +俣 yu 14 +俶 chu 14 +僰 bo 14 +刭 jing 14 +匦 gui 14 +厍 she 14 +囟 xin 14 +埇 yong 14 +妪 yu 14 +嵋 mei 14 +幢 chuang 14 +忑 te 14 +忡 chong 14 +悢 liang 14 +愔 yin 14 +掭 tian 14 +擞 sou 14 +晷 gui 14 +杪 miao 14 +柢 di 14 +柩 jiu 14 +榷 que 14 +殂 cu 14 +殍 piao 14 +殪 yi 14 +氆 pu 14 +氇 lu 14 +涑 su 14 +溦 wei 14 +潆 ying 14 +牦 mao 14 +狒 fei 14 +甪 lu 14 +痊 quan 14 +瘩 da 14 +瞠 cheng 14 +碜 chen 14 +碲 di 14 +磔 zhe 14 +秫 shu 14 +篝 gou 14 +罅 xia 14 +脒 mi 14 +艏 shou 14 +茌 chi 14 +莠 you 14 +蒹 jian 14 +蓖 bi 14 +蓠 li 14 +蝼 lou 14 +谫 jian 14 +豳 bin 14 +貘 mo 14 +趿 ta 14 +逅 hou 14 +逦 li 14 +郓 yun 14 +酎 zhou 14 +酩 ming 14 +钣 ban 14 +铘 ye 14 +闱 wei 14 +驸 fu 14 +髡 kun 14 +鬘 man 14 +鲳 chang 14 +鳎 ta 14 +鸱 chi 14 +鹈 ti 14 +麋 mi 14 +伉 kang 13 +伾 pi 13 +佾 yi 13 +倥 kong 13 +偁 cheng 13 +呔 dai 13 +咴 hui 13 +嘌 piao 13 +囱 cong 13 +垏 lv 13 +妯 zhou 13 +嬖 bi 13 +尴 gan 13 +庥 xiu 13 +彀 gou 13 +慝 te 13 +憔 qiao 13 +抔 pou 13 +拮 jie 13 +攽 ban 13 +旒 liu 13 +曌 zhao 13 +曛 xun 13 +枸 gou 13 +柊 zhong 13 +栅 zha 13 +楂 zha 13 +檩 lin 13 +氤 yin 13 +沩 wei 13 +湔 jian 13 +湫 jiao 13 +溲 sou 13 +犷 guang 13 +瘃 zhu 13 +瘕 jia 13 +瘢 ban 13 +盍 he 13 +眈 dan 13 +碚 bei 13 +簌 su 13 +粞 xi 13 +纭 yun 13 +缡 li 13 +胝 zhi 13 +胴 dong 13 +腼 mian 13 +舻 lu 13 +芰 ji 13 +茚 yin 13 +萩 qiu 13 +葸 xi 13 +虺 hui 13 +袪 qu 13 +觏 gou 13 +觚 gu 13 +诜 shen 13 +谵 zhan 13 +貊 mo 13 +跄 qiang 13 +跗 fu 13 +躞 xie 13 +遄 chuan 13 +酆 feng 13 +锢 gu 13 +镄 fei 13 +镊 nie 13 +隰 xi 13 +雩 yu 13 +靓 jing 13 +韪 wei 13 +颟 man 13 +飑 biao 13 +饔 yong 13 +骘 zhi 13 +髀 bi 13 +髌 bin 13 +髭 zi 13 +鬲 ge 13 +魈 xiao 13 +鲋 fu 13 +鳕 xue 13 +伺 ci 12 +倓 tan 12 +傥 tang 12 +刖 yue 12 +匏 pao 12 +嚆 hao 12 +坜 li 12 +坩 gan 12 +垓 gai 12 +垴 nao 12 +堞 die 12 +娩 mian 12 +媲 pi 12 +孀 shuang 12 +屦 ju 12 +岣 gou 12 +嵊 sheng 12 +廛 chan 12 +廨 xie 12 +懔 lin 12 +捭 bai 12 +旆 pei 12 +暾 tun 12 +杷 pa 12 +枧 jian 12 +桴 fu 12 +毖 bi 12 +氅 chang 12 +汩 gu 12 +汹 xiong 12 +浈 zhen 12 +浯 wu 12 +淆 xiao 12 +溽 ru 12 +炘 xin 12 +烊 yang 12 +燔 fan 12 +狍 pao 12 +猊 ni 12 +猬 wei 12 +猺 yao 12 +獬 xie 12 +璺 wen 12 +甦 su 12 +畋 tian 12 +痦 wu 12 +癍 ban 12 +瞭 liao 12 +礴 bo 12 +秣 mo 12 +箜 kong 12 +篑 kui 12 +篚 fei 12 +缣 jian 12 +耩 jiang 12 +聩 kui 12 +苤 pie 12 +莨 lang 12 +薤 xie 12 +蛄 gu 12 +蛉 ling 12 +蜞 qi 12 +蝰 kui 12 +蠊 lian 12 +谆 zhun 12 +赀 zi 12 +趱 zan 12 +跆 tai 12 +踅 xue 12 +躇 chu 12 +辂 lu 12 +遴 lin 12 +醍 ti 12 +钪 kang 12 +钭 tou 12 +钲 zheng 12 +鞨 he 12 +颛 zhuan 12 +馗 kui 12 +鲂 fang 12 +鳏 guan 12 +鹪 jiao 12 +鼯 wu 12 +乩 ji 11 +俅 qiu 11 +劓 yi 11 +嗍 suo 11 +嘤 ying 11 +噍 jiao 11 +噱 jue 11 +嚏 ti 11 +囵 lun 11 +垌 dong 11 +埘 shi 11 +堋 peng 11 +奭 shi 11 +嬗 shan 11 +屺 qi 11 +峤 jiao 11 +崂 lao 11 +崆 kong 11 +崦 yan 11 +崾 yao 11 +嶙 lin 11 +忪 song 11 +怍 zuo 11 +悫 que 11 +悱 fei 11 +戽 hu 11 +掊 pou 11 +掎 ji 11 +枞 cong 11 +橛 jue 11 +沆 hang 11 +泺 luo 11 +泚 ci 11 +洸 guang 11 +湣 min 11 +灈 qu 11 +猱 nao 11 +玹 xuan 11 +琊 ya 11 +眚 sheng 11 +矧 shen 11 +砻 long 11 +礅 dun 11 +秕 bi 11 +穑 se 11 +窕 tiao 11 +竑 hong 11 +籴 di 11 +缑 gou 11 +缛 ru 11 +耔 zi 11 +胪 lu 11 +臃 yong 11 +芴 wu 11 +茯 fu 11 +蒈 kai 11 +蓐 ru 11 +藿 huo 11 +蜘 zhi 11 +袱 fu 11 +裥 jian 11 +褊 bian 11 +訇 hong 11 +诎 qu 11 +趺 fu 11 +轱 gu 11 +郃 he 11 +醢 hai 11 +钬 huo 11 +铷 ru 11 +镨 pu 11 +陔 gai 11 +隍 huang 11 +雠 chou 11 +鞯 jian 11 +饩 xi 11 +餮 tie 11 +馐 xiu 11 +骷 ku 11 +鼍 tuo 11 +鼙 pi 11 +仳 pi 10 +侁 shen 10 +偬 zong 10 +偲 cai 10 +儳 chan 10 +剡 shan 10 +唁 yan 10 +啜 chuai 10 +啻 chi 10 +喟 kui 10 +嗐 hai 10 +囹 ling 10 +埌 lang 10 +埒 lie 10 +夼 kuang 10 +媱 yao 10 +媵 ying 10 +巯 qiu 10 +幞 fu 10 +庤 zhi 10 +弶 jiang 10 +怦 peng 10 +悛 quan 10 +惆 chou 10 +愆 qian 10 +愦 kui 10 +搠 shuo 10 +撄 ying 10 +擐 huan 10 +敫 jiao 10 +晫 zhuo 10 +椟 du 10 +榈 lv 10 +榧 fei 10 +槃 pan 10 +橼 yuan 10 +殣 jin 10 +浥 yi 10 +淠 pi 10 +湴 ban 10 +犰 qiu 10 +甍 meng 10 +痍 yi 10 +瘰 luo 10 +矍 jue 10 +硖 xia 10 +禤 xuan 10 +稹 zhen 10 +粜 tiao 10 +缱 qian 10 +缳 huan 10 +缵 zuan 10 +耥 tang 10 +臬 nie 10 +舣 yi 10 +苁 cong 10 +苈 li 10 +苾 bi 10 +荦 luo 10 +莸 you 10 +蕗 lu 10 +蚺 ran 10 +螅 xi 10 +螵 piao 10 +蠓 meng 10 +诮 qiao 10 +豇 jiang 10 +赕 dan 10 +赟 yun 10 +跎 tuo 10 +跣 xian 10 +跹 xian 10 +踺 jian 10 +躅 zhu 10 +辘 lu 10 +辚 lin 10 +逖 ti 10 +遑 huang 10 +遘 gou 10 +郤 xi 10 +酃 ling 10 +镦 dui 10 +闶 kang 10 +阃 kun 10 +阄 jiu 10 +阍 hun 10 +雳 li 10 +骼 ge 10 +魍 wang 10 +鲽 die 10 +鸨 bao 10 +鸷 zhi 10 +齑 ji 10 +侉 kua 9 +偰 xie 9 +偾 fen 9 +傒 xi 9 +僬 jiao 9 +匍 pu 9 +唿 hu 9 +圜 huan 9 +夥 huo 9 +婪 lan 9 +嫄 yuan 9 +孱 can 9 +峋 xun 9 +崀 lang 9 +恿 yong 9 +悆 yu 9 +悻 xing 9 +惴 zhui 9 +抨 peng 9 +搐 chu 9 +柙 xia 9 +榉 ju 9 +榖 gu 9 +檠 qing 9 +氍 qu 9 +氙 xian 9 +泔 gan 9 +泞 ning 9 +湓 pen 9 +濉 sui 9 +瀣 xie 9 +煟 wei 9 +犒 kao 9 +狰 zheng 9 +狳 yu 9 +琤 cheng 9 +甓 pi 9 +畚 ben 9 +疔 ding 9 +疠 li 9 +瞌 ke 9 +瞢 meng 9 +砟 zha 9 +礞 meng 9 +窨 xun 9 +笕 jian 9 +筘 kou 9 +纥 ge 9 +絷 zhi 9 +绨 ti 9 +罾 zeng 9 +羼 chan 9 +耠 huo 9 +胂 shen 9 +舾 xi 9 +艉 wei 9 +艋 meng 9 +艿 nai 9 +芏 du 9 +茼 tong 9 +莛 ting 9 +菖 chang 9 +萋 qi 9 +蕖 qu 9 +蚋 rui 9 +蚰 you 9 +螃 pang 9 +螬 cao 9 +螳 tang 9 +蟋 xi 9 +裎 cheng 9 +褡 da 9 +襞 bi 9 +觯 zhi 9 +觳 hu 9 +诙 hui 9 +謇 jian 9 +踉 liang 9 +轵 zhi 9 +迺 nai 9 +酗 xu 9 +酡 tuo 9 +酤 gu 9 +铞 diao 9 +铴 tang 9 +鋈 wu 9 +镆 mo 9 +镏 liu 9 +阌 wen 9 +陉 xing 9 +靼 da 9 +飓 ju 9 +饨 tun 9 +餍 yan 9 +魑 chi 9 +鲞 xiang 9 +鲼 fen 9 +鳢 li 9 +鸫 dong 9 +麈 zhu 9 +黟 yi 9 +龅 bao 9 +仉 zhang 8 +伋 ji 8 +俵 biao 8 +傧 bin 8 +勍 qing 8 +吲 yin 8 +呣 m 8 +咝 si 8 +嗉 su 8 +垵 an 8 +埚 guo 8 +埼 qi 8 +埽 sao 8 +堍 tu 8 +堙 yin 8 +堠 hou 8 +墁 man 8 +姤 gou 8 +姽 gui 8 +媸 chi 8 +孥 nu 8 +孳 zi 8 +宬 cheng 8 +尥 liao 8 +岌 ji 8 +嵎 yu 8 +嵯 cuo 8 +庋 gui 8 +忾 kai 8 +悝 kui 8 +惔 tan 8 +慊 qian 8 +懑 men 8 +搛 jian 8 +摽 biao 8 +撺 cuan 8 +旯 la 8 +旸 yang 8 +晢 zhe 8 +曈 tong 8 +枇 pi 8 +柃 ling 8 +柽 cheng 8 +栌 lu 8 +栻 shi 8 +棐 fei 8 +棻 fen 8 +毳 cui 8 +浃 jia 8 +浞 zhuo 8 +涠 wei 8 +涫 guan 8 +淏 hao 8 +湉 tian 8 +滆 ge 8 +滠 she 8 +漭 mang 8 +燠 yu 8 +牮 jian 8 +猁 li 8 +玡 ya 8 +珧 yao 8 +瓿 bu 8 +畤 zhi 8 +疴 ke 8 +痄 zha 8 +瘌 la 8 +瘳 chou 8 +癔 yi 8 +睚 ya 8 +睽 kui 8 +砒 pi 8 +禊 xi 8 +稞 ke 8 +穸 xi 8 +笾 bian 8 +箅 bi 8 +籀 zhou 8 +糁 san 8 +绲 gun 8 +縻 mi 8 +缫 sao 8 +罟 gu 8 +罽 ji 8 +翾 xuan 8 +肄 yi 8 +舲 ling 8 +芑 qi 8 +芟 shan 8 +芤 kou 8 +茳 jiang 8 +荛 rao 8 +蓊 weng 8 +蔌 su 8 +虮 ji 8 +虿 chai 8 +蚱 zha 8 +蛘 yang 8 +蛸 shao 8 +蝻 nan 8 +螋 sou 8 +袤 mao 8 +裉 ken 8 +裨 bi 8 +褫 chi 8 +褴 lan 8 +豌 wan 8 +趄 ju 8 +跂 qi 8 +蹚 tang 8 +郐 kuai 8 +鄜 fu 8 +钆 ga 8 +锃 zeng 8 +镢 jue 8 +阋 xi 8 +靺 mo 8 +飕 sou 8 +驵 zang 8 +鬈 quan 8 +鬯 chang 8 +鳔 biao 8 +黹 zhi 8 +龆 tiao 8 +仫 mu 7 +佖 bi 7 +佝 gou 7 +偻 lou 7 +傺 chi 7 +儡 lei 7 +劬 qu 7 +啕 tao 7 +嚓 ca 7 +囷 qun 7 +圄 yu 7 +圊 qing 7 +坫 dian 7 +垕 hou 7 +垤 die 7 +垧 shang 7 +埴 zhi 7 +堉 yu 7 +塄 leng 7 +墼 ji 7 +姞 ji 7 +媄 mei 7 +崟 yin 7 +帑 tang 7 +帻 ze 7 +廒 ao 7 +弸 peng 7 +忭 bian 7 +怏 yang 7 +憷 chu 7 +掮 qian 7 +揄 yu 7 +攮 nang 7 +昃 ze 7 +昺 bing 7 +朐 qu 7 +枨 cheng 7 +栳 lao 7 +桫 suo 7 +桷 jue 7 +棬 quan 7 +楗 jian 7 +樨 xi 7 +櫆 kui 7 +毽 jian 7 +沚 zhi 7 +洘 kao 7 +洿 wu 7 +浡 bo 7 +渫 xie 7 +渼 mei 7 +溘 ke 7 +澂 cheng 7 +澌 si 7 +炻 shi 7 +烝 zheng 7 +煺 tui 7 +狃 niu 7 +狺 yin 7 +獗 jue 7 +獯 xun 7 +疸 dan 7 +瘛 chi 7 +盥 guan 7 +矞 yu 7 +砑 ya 7 +秭 zi 7 +稃 fu 7 +窅 yao 7 +筢 pa 7 +篼 dou 7 +纰 pi 7 +缌 si 7 +缧 lei 7 +胛 jia 7 +芄 wan 7 +苊 e 7 +荠 ji 7 +荬 mai 7 +莴 wo 7 +菥 xi 7 +葚 ren 7 +葜 qia 7 +蒎 pai 7 +蒨 qian 7 +虓 xiao 7 +蚪 dou 7 +蜴 yi 7 +蜻 qing 7 +螈 yuan 7 +螫 shi 7 +蟪 hui 7 +襻 pan 7 +诨 hun 7 +诿 wei 7 +贶 kuang 7 +趼 jian 7 +跸 bi 7 +躜 zuan 7 +辎 zi 7 +遹 yu 7 +邙 mang 7 +郫 pi 7 +郸 dan 7 +鄯 shan 7 +钐 shan 7 +钫 fang 7 +铩 sha 7 +鍪 mou 7 +鏖 ao 7 +靬 qian 7 +鞫 ju 7 +颡 sang 7 +馘 guo 7 +骒 ke 7 +髹 xiu 7 +鲩 huan 7 +鲴 gu 7 +鲺 shi 7 +鹇 xian 7 +鹘 gu 7 +鹣 jian 7 +鹩 liao 7 +黉 hong 7 +黧 li 7 +龉 yu 7 +侘 cha 6 +倻 ye 6 +劁 qiao 6 +劂 jue 6 +卺 jin 6 +吣 qin 6 +喾 ku 6 +囫 hu 6 +坌 ben 6 +坻 chi 6 +垙 guang 6 +垿 xu 6 +奘 zang 6 +妁 shuo 6 +娠 shen 6 +嫘 lei 6 +嫭 hu 6 +宄 gui 6 +尜 ga 6 +岽 dong 6 +崃 lai 6 +崤 xiao 6 +崶 feng 6 +嶝 deng 6 +帔 pei 6 +弇 yan 6 +悰 cong 6 +惛 hun 6 +愎 bi 6 +戥 deng 6 +扆 yi 6 +摅 shu 6 +晡 bu 6 +栴 zhan 6 +桊 juan 6 +棤 cuo 6 +棼 fen 6 +槠 zhu 6 +橥 zhu 6 +檗 bo 6 +沄 yun 6 +泖 mao 6 +溆 xu 6 +漤 lan 6 +漯 luo 6 +潲 shao 6 +潸 shan 6 +瀍 chan 6 +烔 tong 6 +狻 suan 6 +瑢 rong 6 +瑧 zhen 6 +畛 zhen 6 +疰 zhu 6 +瘐 yu 6 +盦 an 6 +睃 suo 6 +砝 fa 6 +硗 qiao 6 +磻 pan 6 +禋 yin 6 +秬 ju 6 +窭 ju 6 +窳 yu 6 +笱 gou 6 +筇 qiong 6 +筚 bi 6 +箢 yuan 6 +粝 li 6 +缒 zhui 6 +罍 lei 6 +罨 yan 6 +羑 you 6 +耖 chao 6 +耢 lao 6 +耪 pang 6 +耵 ding 6 +胼 pian 6 +脬 pao 6 +腧 shu 6 +臾 yu 6 +舁 yu 6 +舴 ze 6 +艽 jiao 6 +茛 gen 6 +荥 xing 6 +莶 xian 6 +菪 dang 6 +萣 ding 6 +蒴 shuo 6 +蓿 xu 6 +蕺 ji 6 +蕻 hong 6 +蛐 qu 6 +蜢 meng 6 +蜣 qiang 6 +蟛 peng 6 +蠖 huo 6 +袈 jia 6 +裒 pou 6 +褓 bao 6 +褕 yu 6 +诖 gua 6 +诼 zhuo 6 +谇 sui 6 +赇 qiu 6 +踔 chuo 6 +踣 bo 6 +踯 zhi 6 +蹒 pan 6 +逡 qun 6 +逭 huan 6 +邠 bin 6 +郛 fu 6 +鄹 zou 6 +酲 cheng 6 +銎 qiong 6 +锍 liu 6 +锒 lang 6 +锘 nuo 6 +镥 lu 6 +阼 zuo 6 +陞 sheng 6 +隗 kui 6 +雱 pang 6 +鞮 di 6 +饪 ren 6 +饫 yu 6 +馑 jin 6 +鲭 qing 6 +鳟 zun 6 +鸪 gu 6 +鹕 hu 6 +鹛 mei 6 +黩 du 6 +黪 can 6 +鼹 yan 6 +龃 ju 6 +丏 mian 5 +伧 cang 5 +伭 xian 5 +侹 ting 5 +俟 qi 5 +倧 zong 5 +傃 su 5 +剋 ke 5 +哳 zha 5 +哿 ge 5 +唢 suo 5 +嗾 sou 5 +垲 kai 5 +埆 que 5 +埤 pi 5 +堨 ye 5 +墦 fan 5 +夤 yin 5 +娀 song 5 +婞 xing 5 +嫠 li 5 +岈 ya 5 +岖 qu 5 +崌 ju 5 +崞 guo 5 +嵝 lou 5 +嵫 zi 5 +忐 tan 5 +恝 jia 5 +捽 zuo 5 +搦 nuo 5 +摛 chi 5 +敉 mi 5 +旰 gan 5 +暲 zhang 5 +朓 tiao 5 +梏 gu 5 +棨 qi 5 +椹 shen 5 +榇 chen 5 +榱 cui 5 +橦 tong 5 +毵 san 5 +毹 shu 5 +浬 li 5 +湎 mian 5 +溍 jin 5 +滉 huang 5 +漶 huan 5 +漷 huo 5 +澥 xie 5 +烀 hu 5 +燏 yu 5 +爇 ruo 5 +牁 ke 5 +牂 zang 5 +狨 rong 5 +狲 sun 5 +猇 xiao 5 +猰 ya 5 +獍 jing 5 +玕 gan 5 +玘 qi 5 +珋 liu 5 +珛 xiu 5 +珽 ting 5 +珷 wu 5 +瘊 hou 5 +癃 long 5 +皞 hao 5 +眢 yuan 5 +瞍 sou 5 +砗 che 5 +砘 dun 5 +碹 xuan 5 +礓 jiang 5 +祧 tiao 5 +禔 zhi 5 +禚 zhuo 5 +稑 lu 5 +窬 yu 5 +筅 xian 5 +箖 lin 5 +箦 ze 5 +簏 lu 5 +簠 fu 5 +籥 yue 5 +粢 zi 5 +羰 tang 5 +胙 zuo 5 +脔 luan 5 +脞 cuo 5 +腽 wa 5 +舳 zhu 5 +艄 shao 5 +艟 chong 5 +艴 fu 5 +苠 min 5 +荽 sui 5 +菔 fu 5 +蚍 pi 5 +蚣 gong 5 +蚯 qiu 5 +蛞 kuo 5 +蛱 jia 5 +蜊 li 5 +蜮 yu 5 +蝙 bian 5 +螽 zhong 5 +衃 pei 5 +裢 lian 5 +襁 qiang 5 +跏 jia 5 +踬 zhi 5 +踽 ju 5 +蹀 die 5 +辏 cou 5 +迓 ya 5 +邶 bei 5 +醵 ju 5 +釐 li 5 +铽 te 5 +锞 ke 5 +锱 zi 5 +锸 cha 5 +镘 man 5 +镩 cuan 5 +陴 pi 5 +鞒 qiao 5 +鞲 gou 5 +饽 bo 5 +馓 san 5 +髅 lou 5 +鲅 ba 5 +鲐 tai 5 +鲥 shi 5 +鲮 ling 5 +鳜 gui 5 +鸲 qu 5 +鸶 si 5 +鸹 gua 5 +鹨 liu 5 +麹 qu 5 +鼗 tao 5 +鼩 qu 5 +龀 chen 5 +伛 yu 4 +佁 yi 4 +俍 liang 4 +俜 ping 4 +倞 jing 4 +偡 zhan 4 +偭 mian 4 +僔 zun 4 +刽 gui 4 +劄 zha 4 +劐 huo 4 +虒 si 4 +哃 tong 4 +唣 zao 4 +唼 sha 4 +喤 huang 4 +嘏 gu 4 +堧 ruan 4 +塱 lang 4 +奡 ao 4 +尪 wang 4 +岵 hu 4 +崁 kan 4 +嶒 ceng 4 +巉 chan 4 +帡 ping 4 +帨 shui 4 +廑 jin 4 +怫 fu 4 +恧 nv 4 +慬 qin 4 +憙 xi 4 +憺 dan 4 +扺 zhi 4 +捯 dao 4 +掞 shan 4 +揠 ya 4 +攉 huo 4 +敔 yu 4 +斝 jia 4 +晱 shan 4 +暌 kui 4 +杌 wu 4 +枘 rui 4 +枵 xiao 4 +梣 cen 4 +棫 yu 4 +椎 chui 4 +槔 gao 4 +檎 qin 4 +檞 jie 4 +氾 fan 4 +汧 qian 4 +汭 rui 4 +洨 xiao 4 +涴 wo 4 +淯 yu 4 +渟 ting 4 +溞 sao 4 +潏 yu 4 +潦 lao 4 +澉 gan 4 +澪 ling 4 +瀼 rang 4 +烺 lang 4 +燹 xian 4 +犏 pian 4 +犴 an 4 +犸 ma 4 +狁 yun 4 +猢 hu 4 +猹 cha 4 +珦 xiang 4 +瑱 zhen 4 +瓞 die 4 +甏 beng 4 +甡 shen 4 +畯 jun 4 +瘥 chai 4 +瘵 zhai 4 +癀 huang 4 +皛 xiao 4 +眙 yi 4 +矻 ku 4 +硭 mang 4 +磙 gun 4 +禒 xian 4 +禘 di 4 +笤 tiao 4 +筥 ju 4 +篥 li 4 +簖 duan 4 +簰 pai 4 +糇 hou 4 +纩 kuang 4 +縠 hu 4 +縢 teng 4 +繄 yi 4 +羝 di 4 +翃 hong 4 +聍 ning 4 +肷 qian 4 +胨 dong 4 +胩 ka 4 +胲 hai 4 +脶 luo 4 +膂 lv 4 +舯 zhong 4 +艚 cao 4 +茈 ci 4 +茺 chong 4 +荖 lao 4 +莰 kan 4 +萆 bi 4 +萑 huan 4 +蒗 lang 4 +蓰 xi 4 +薷 ru 4 +虼 ge 4 +蚨 fu 4 +蛲 nao 4 +蛳 si 4 +蜉 fu 4 +蜾 guo 4 +蝓 yu 4 +蝤 qiu 4 +螗 tang 4 +蟊 mao 4 +蠋 zhu 4 +袗 zhen 4 +袼 ge 4 +裣 lian 4 +襜 chan 4 +觫 su 4 +诘 ji 4 +谿 xi 4 +貔 pi 4 +贳 shi 4 +赆 jin 4 +跽 ji 4 +蹁 pian 4 +蹯 fan 4 +躏 lin 4 +躐 lie 4 +轳 lu 4 +轹 li 4 +辁 quan 4 +辋 wang 4 +迮 ze 4 +邡 fang 4 +邽 gui 4 +郿 mei 4 +酢 cu 4 +醐 hu 4 +醑 xu 4 +钔 men 4 +锖 qiang 4 +锪 huo 4 +锫 pei 4 +锾 huan 4 +镕 rong 4 +霅 zha 4 +骱 jie 4 +髫 tiao 4 +鬏 jiu 4 +鲔 wei 4 +鲕 er 4 +鲚 ji 4 +鲣 jian 4 +鲦 tiao 4 +鳊 bian 4 +鹉 wu 4 +鹌 an 4 +鹎 bei 4 +齁 hou 4 +龈 ken 4 +仡 ge 3 +佴 er 3 +俙 xi 3 +僇 lu 3 +冁 chan 3 +剕 fei 3 +匜 yi 3 +匼 ke 3 +啁 zhao 3 +噀 xun 3 +垞 cha 3 +埯 an 3 +塝 bang 3 +墈 kan 3 +墚 liang 3 +墡 shan 3 +墣 pu 3 +姱 kua 3 +嫽 liao 3 +岠 ju 3 +岍 qian 3 +崚 leng 3 +嶲 xi 3 +廙 yi 3 +怊 chao 3 +慆 tao 3 +慥 zao 3 +扂 dian 3 +抃 bian 3 +挓 zha 3 +搋 chuai 3 +搌 zhan 3 +擘 bai 3 +斠 jiao 3 +昳 die 3 +枍 yi 3 +柈 ban 3 +椠 qian 3 +楙 mao 3 +楯 dun 3 +榍 xie 3 +檑 lei 3 +歃 sha 3 +泃 ju 3 +洈 wei 3 +洫 xu 3 +洭 kuang 3 +洴 ping 3 +浭 geng 3 +湑 xu 3 +湲 yuan 3 +滍 zhi 3 +潽 pu 3 +澼 pi 3 +濩 huo 3 +炟 da 3 +炱 tai 3 +焘 dao 3 +煃 kui 3 +煓 tuan 3 +牚 cheng 3 +牿 gu 3 +犄 ji 3 +狯 kuai 3 +猕 mi 3 +珰 dang 3 +珹 cheng 3 +琲 bei 3 +璆 qiu 3 +瓖 xiang 3 +甗 yan 3 +畎 quan 3 +痃 xuan 3 +瘅 dan 3 +癯 qu 3 +皦 jiao 3 +皲 jun 3 +盉 he 3 +睥 pi 3 +瞀 mao 3 +砹 ai 3 +硇 nao 3 +祊 beng 3 +祏 shi 3 +祲 jin 3 +稆 lv 3 +稂 lang 3 +稙 zhi 3 +窀 zhun 3 +笊 zhao 3 +笸 po 3 +簃 yi 3 +糈 xu 3 +糨 jiang 3 +缪 mou 3 +罱 lan 3 +羖 gu 3 +羕 yang 3 +翯 he 3 +聱 ao 3 +肭 na 3 +舢 shan 3 +艨 meng 3 +荑 ti 3 +荨 xun 3 +荸 bi 3 +菉 lu 3 +菼 tan 3 +萏 dan 3 +蒐 sou 3 +蒡 bang 3 +蒺 ji 3 +蔹 lian 3 +薸 piao 3 +藟 lei 3 +蛑 mou 3 +蝣 you 3 +蝥 mao 3 +蠼 qu 3 +裛 yi 3 +裰 duo 3 +褙 bei 3 +觖 jue 3 +讧 hong 3 +趵 bao 3 +踟 chi 3 +辿 chan 3 +迕 wu 3 +邘 yu 3 +鄠 hu 3 +鄫 zeng 3 +酴 tu 3 +酺 pu 3 +醭 bu 3 +铹 lao 3 +锎 kai 3 +锓 qin 3 +锩 juan 3 +锬 tan 3 +锼 sou 3 +锿 ai 3 +镅 mei 3 +鐾 bei 3 +靰 wu 3 +鞡 la 3 +鞴 bei 3 +馇 cha 3 +髑 du 3 +鲒 jie 3 +鲱 fei 3 +鳘 min 3 +鸺 xiu 3 +鹁 bo 3 +鹧 zhe 3 +鹱 hu 3 +麇 jun 3 +鼷 xi 3 +鼽 qiu 3 +儦 biao 2 +冱 hu 2 +哢 long 2 +唪 feng 2 +喁 yong 2 +嚚 yin 2 +埏 shan 2 +堌 gu 2 +堎 leng 2 +堽 gang 2 +奓 zha 2 +妘 yun 2 +姈 ling 2 +婌 shu 2 +婍 qi 2 +婘 quan 2 +媓 huang 2 +嫕 yi 2 +嫜 zhang 2 +宧 yi 2 +岊 jie 2 +崿 e 2 +嶓 bo 2 +幪 meng 2 +庳 bi 2 +弨 chao 2 +徛 ji 2 +忺 xian 2 +惎 ji 2 +惝 chang 2 +戤 gai 2 +掴 guai 2 +揳 xie 2 +摴 chu 2 +撖 han 2 +撙 zun 2 +斶 chu 2 +旐 zhao 2 +旿 wu 2 +昄 ban 2 +晪 tian 2 +朏 fei 2 +杙 yi 2 +枲 xi 2 +桲 po 2 +梠 lv 2 +梼 tao 2 +棪 yan 2 +椑 bei 2 +椓 zhuo 2 +楩 pian 2 +槱 you 2 +欻 chua 2 +毌 guan 2 +毪 mu 2 +氕 pie 2 +汫 jing 2 +沇 yan 2 +沘 bi 2 +泂 jiong 2 +泇 jia 2 +泙 ping 2 +洢 yi 2 +淴 hu 2 +渑 mian 2 +漼 cui 2 +潟 xi 2 +潾 lin 2 +澭 yong 2 +熇 he 2 +爚 yue 2 +爝 jue 2 +牻 mang 2 +牾 wu 2 +犨 chou 2 +狴 bi 2 +玓 di 2 +玭 pin 2 +玶 ping 2 +珖 guang 2 +珵 cheng 2 +琭 lu 2 +瑆 xing 2 +瑑 zhuan 2 +瑳 cuo 2 +疢 chen 2 +瘼 mo 2 +皕 bi 2 +眊 mao 2 +眵 chi 2 +瞵 lin 2 +矰 zeng 2 +砮 nu 2 +碥 bian 2 +磉 sang 2 +磲 qu 2 +礌 lei 2 +祕 mi 2 +穄 ji 2 +穜 zhong 2 +窊 wa 2 +筦 guan 2 +筲 shao 2 +筻 gang 2 +糌 zan 2 +罶 liu 2 +翛 xiao 2 +耇 gou 2 +耰 you 2 +肸 xi 2 +脎 sa 2 +臑 nao 2 +芼 mao 2 +荄 gai 2 +荮 zhou 2 +莙 jun 2 +莩 fu 2 +菝 ba 2 +葎 lv 2 +蒄 guan 2 +蒻 ruo 2 +蓣 yu 2 +蔀 bu 2 +蕹 weng 2 +薁 yu 2 +薿 ni 2 +藦 mo 2 +藨 biao 2 +蘘 rang 2 +蜍 chu 2 +蜎 yuan 2 +蟥 huang 2 +衎 kan 2 +衒 xuan 2 +裼 ti 2 +褯 jie 2 +觱 bi 2 +觿 xi 2 +貆 huan 2 +跐 ci 2 +跬 kui 2 +踦 yi 2 +轷 hu 2 +轺 yao 2 +轾 zhi 2 +邲 bi 2 +郈 hou 2 +郪 qi 2 +鄗 hao 2 +鄚 mao 2 +酏 yi 2 +酾 shai 2 +醨 li 2 +钷 po 2 +铫 diao 2 +镎 na 2 +镤 pu 2 +阚 han 2 +陑 er 2 +陧 nie 2 +霨 wei 2 +靸 sa 2 +鞬 jian 2 +颃 hang 2 +骣 chan 2 +魋 tui 2 +鲡 li 2 +鲰 zou 2 +鳇 huang 2 +麀 you 2 +麑 ni 2 +鼱 jing 2 +龢 he 2 +冔 xu 1 +凘 si 1 +呇 qi 1 +咇 bie 1 +咡 er 1 +咥 xi 1 +咺 xuan 1 +嘬 chuai 1 +噂 zun 1 +坉 tun 1 +坒 bi 1 +坰 jiong 1 +堐 ya 1 +塥 ge 1 +娵 ju 1 +婻 nan 1 +嬥 tiao 1 +寁 zan 1 +峗 wei 1 +峘 huan 1 +峛 li 1 +峿 yu 1 +嵲 nie 1 +嶍 xi 1 +嶟 zun 1 +巇 xi 1 +帱 chou 1 +庱 cheng 1 +恓 xi 1 +惙 chuo 1 +愐 mian 1 +扊 yan 1 +挲 sa 1 +揕 zhen 1 +搒 bang 1 +旴 xu 1 +旵 chan 1 +昈 hu 1 +昒 hu 1 +昡 xuan 1 +昤 ling 1 +昫 xu 1 +晅 xuan 1 +晊 zhi 1 +晙 jun 1 +暵 han 1 +暿 xi 1 +朳 ba 1 +朸 li 1 +杻 chou 1 +枅 ji 1 +栒 xun 1 +桯 ting 1 +桹 lang 1 +梌 tu 1 +梴 chan 1 +椸 yi 1 +楒 si 1 +楪 ye 1 +榑 fu 1 +橑 lao 1 +橞 hui 1 +欂 bo 1 +歅 yin 1 +氿 gui 1 +汋 zhuo 1 +㳇 fu 1 +沺 tian 1 +泜 zhi 1 +浟 you 1 +浰 lian 1 +涍 xiao 1 +淜 ping 1 +淟 tian 1 +渰 yan 1 +溠 zha 1 +溹 suo 1 +滫 xiu 1 +漹 yan 1 +澴 huan 1 +澽 ju 1 +瀌 biao 1 +瀔 gu 1 +瀱 ji 1 +烠 hui 1 +烻 yan 1 +焌 jun 1 +焞 tun 1 +煁 chen 1 +熛 biao 1 +熥 teng 1 +爔 xi 1 +爟 guan 1 +犋 ju 1 +猃 xian 1 +猞 she 1 +猯 tuan 1 +玃 jue 1 +玒 hong 1 +玿 shao 1 +珇 zu 1 +珌 bi 1 +珒 jin 1 +珝 xu 1 +珫 chong 1 +琄 xuan 1 +琈 fu 1 +琫 beng 1 +瑂 mei 1 +瑃 chun 1 +瑔 quan 1 +璪 zao 1 +璱 se 1 +璲 sui 1 +瓀 ruan 1 +瘭 biao 1 +癗 lei 1 +癿 qie 1 +皭 jiao 1 +睎 xi 1 +瞫 shen 1 +砆 fu 1 +砫 zhu 1 +硿 kong 1 +碃 qing 1 +碨 wei 1 +磏 lian 1 +礵 shuang 1 +稌 tu 1 +穟 sui 1 +竘 qu 1 +笯 nu 1 +簉 zao 1 +糒 bei 1 +缲 qiao 1 +羱 yuan 1 +翂 fen 1 +耏 nai 1 +胈 ba 1 +胠 qu 1 +腯 tu 1 +膙 jiang 1 +舠 dao 1 +舥 pa 1 +舭 bi 1 +艅 yu 1 +艎 huang 1 +荁 huan 1 +莝 cuo 1 +荓 ping 1 +莿 ci 1 +萳 nan 1 +萹 bian 1 +葖 tu 1 +葴 zhen 1 +蒟 ju 1 +蒱 pu 1 +蓂 ming 1 +蓇 gu 1 +蓏 luo 1 +蔊 han 1 +薢 xie 1 +虷 han 1 +蚆 ba 1 +蝘 yan 1 +蟫 yin 1 +襚 sui 1 +襫 shi 1 +觜 zi 1 +訄 qiu 1 +谼 hong 1 +跞 li 1 +跱 zhi 1 +踒 wo 1 +踶 di 1 +蹅 cha 1 +蹐 ji 1 +蹜 su 1 +逴 chuo 1 +邿 shi 1 +郚 wu 1 +鄀 ruo 1 +鄃 shu 1 +鄅 yu 1 +鄑 zi 1 +鄘 yong 1 +酅 xi 1 +阽 dian 1 +陎 shu 1 +雊 gou 1 +靿 yao 1 +鞁 bei 1 +鞳 ta 1 +颥 ru 1 +馧 yun 1 +鬒 zhen 1 +鬷 zong 1 +魆 xu 1 +鸰 ling 1 +鼒 zi 1 +鼫 shi 1 +齉 nang 1 +乸 na 0 +𠅤 xi 0 +亸 duo 0 +亹 wei 0 +伣 qian 0 +㑇 zhou 0 +㑊 yi 0 +侴 chou 0 +俫 lai 0 +倕 chui 0 +倴 ben 0 +𫢸 dan 0 +傀 gui 0 +傉 nu 0 +僎 zhuan 0 +儴 rang 0 +冮 gang 0 +凓 li 0 +𠙶 ou 0 +刬 chan 0 +剅 lou 0 +𪟝 ji 0 +勔 mian 0 +勚 yi 0 +勠 lu 0 +卬 ang 0 +厖 pang 0 +厾 du 0 +叆 ai 0 +叇 dai 0 +叕 zhuo 0 +叚 jia 0 +呒 wu 0 +㕮 fu 0 +呲 ci 0 +呷 ga 0 +咉 yang 0 +咍 hai 0 +哕 hui 0 +哱 bo 0 +唛 ma 0 +唝 gong 0 +𠳐 bang 0 +啰 luo 0 +啴 chan 0 +啫 zhe 0 +嗞 zi 0 +嗲 die 0 +𫫇 e 0 +嘚 de 0 +嘡 tang 0 +噇 chuang 0 +㘎 han 0 +圌 chuan 0 +圐 ku 0 +圙 lve 0 +圢 ting 0 +圫 yu 0 +圲 qian 0 +坋 ben 0 +𫭟 ou 0 +𫭢 lun 0 +坥 qu 0 +坬 gua 0 +坽 ling 0 +垈 dai 0 +垍 ji 0 +垎 he 0 +垯 da 0 +垱 dang 0 +鿍 gang 0 +垺 fu 0 +垾 han 0 +埗 bu 0 +埫 chong 0 +堃 kun 0 +㙍 duo 0 +𫭼 lao 0 +𫮃 shan 0 +埪 kong 0 +堲 ci 0 +堼 heng 0 +堾 chun 0 +塅 duan 0 +塆 wan 0 +㙘 yao 0 +𡎚 pian 0 +𪣻 lou 0 +墐 jin 0 +墕 yan 0 +㙦 xie 0 +𡐓 kang 0 +𪤗 liao 0 +壸 kun 0 +妧 wan 0 +妭 ba 0 +𫰛 xing 0 +姶 e 0 +㛃 jie 0 +㛚 tong 0 +婤 chou 0 +婫 kun 0 +婳 hua 0 +婼 chuo 0 +媂 di 0 +媆 ruan 0 +媖 ying 0 +媞 shi 0 +媭 xu 0 +㛹 pian 0 +嬷 ma 0 +孅 qian 0 +尨 mang 0 +屃 xi 0 +𫵷 li 0 +岞 zuo 0 +岨 qu 0 +峂 tong 0 +峃 xue 0 +㟃 si 0 +峣 yao 0 +峧 jiao 0 +𪨰 qu 0 +峱 nao 0 +崄 xian 0 +𪨶 she 0 +崡 han 0 +嵁 kan 0 +嵅 han 0 +嵖 cha 0 +嵚 qin 0 +𫶇 die 0 +嶅 ao 0 +㠇 jiu 0 +嶦 zhan 0 +㠓 meng 0 +𪩘 yan 0 +幖 biao 0 +庼 qing 0 +𫷷 xin 0 +廆 gui 0 +弆 ju 0 +𫸩 kou 0 +彟 yue 0 +徵 zheng 0 +忳 tun 0 +㤘 zhou 0 +恔 jiao 0 +悈 jie 0 +㥄 ling 0 +愃 xuan 0 +愭 qi 0 +慭 yin 0 +憕 cheng 0 +戭 yan 0 +扅 yi 0 +扞 gan 0 +㧐 song 0 +㧑 hui 0 +拃 zha 0 +拤 qia 0 +㧟 kuai 0 +挦 xian 0 +掠 lve 0 +掺 can 0 +摏 chong 0 +擿 ti 0 +敩 xiao 0 +旞 sui 0 +𬀩 wei 0 +𬀪 xian 0 +昣 zhen 0 +昪 bian 0 +昽 long 0 +晐 gai 0 +晟 cheng 0 +㫰 lang 0 +暅 geng 0 +暕 jian 0 +㬊 huan 0 +㬎 xian 0 +暶 xuan 0 +㬚 che 0 +杄 qian 0 +杕 di 0 +杧 mang 0 +㭎 gang 0 +枹 bao 0 +柖 shao 0 +柷 chu 0 +栐 yong 0 +㭕 qu 0 +栟 ben 0 +𬂩 jia 0 +梽 zhi 0 +梾 lai 0 +梿 lian 0 +棁 zhuo 0 +棓 bang 0 +棽 shen 0 +椆 chou 0 +𬃊 zhi 0 +榃 tan 0 +榅 wen 0 +榰 zhi 0 +槚 jia 0 +槜 zui 0 +𣗋 dang 0 +㮾 lang 0 +㰀 li 0 +欹 yi 0 +汈 diao 0 +𬇕 wan 0 +沨 feng 0 +𣲗 wei 0 +𣲘 wu 0 +𬇙 pei 0 +洓 se 0 +洣 mi 0 +浉 shi 0 +浍 hui 0 +浐 chan 0 +浕 jin 0 +㳘 chong 0 +㳚 xu 0 +浛 han 0 +浲 feng 0 +涄 ping 0 +涢 yun 0 +𬇹 guo 0 +溇 lou 0 +㴔 xi 0 +𣸣 fen 0 +溁 ying 0 +溚 ta 0 +溵 yin 0 +滧 yao 0 +滪 yu 0 +漖 jiao 0 +漦 chi 0 +漴 zhuang 0 +漻 liao 0 +漋 long 0 +潖 pa 0 +潵 sa 0 +澛 lu 0 +㵐 jue 0 +炌 kai 0 +炔 gui 0 +𬉼 ou 0 +炣 ke 0 +㶲 yong 0 +𬊈 xun 0 +烶 ting 0 +焆 juan 0 +𬊤 chan 0 +煴 yun 0 +熻 xi 0 +燋 jiao 0 +㸆 kao 0 +㸌 huo 0 +𬌗 he 0 +牤 mang 0 +牥 fang 0 +狉 pi 0 +狝 xian 0 +猄 jing 0 +㺄 yu 0 +獴 meng 0 +玙 yu 0 +玚 chang 0 +玞 fu 0 +玤 bang 0 +玱 qiang 0 +玼 ci 0 +𬍛 li 0 +珕 li 0 +珢 yin 0 +珩 hang 0 +𬍡 dang 0 +𬍤 xun 0 +珸 wu 0 +琎 jin 0 +琔 dian 0 +琟 wei 0 +琡 chu 0 +瑅 ti 0 +瑓 lian 0 +瑖 duan 0 +瑝 huang 0 +𤧛 di 0 +瑬 liu 0 +㻬 tu 0 +𫞩 men 0 +璒 deng 0 +璥 jing 0 +璬 jiao 0 +璮 tan 0 +𤩽 huan 0 +𤫉 xie 0 +瓻 chi 0 +㽏 gan 0 +畖 wa 0 +略 lve 0 +畬 she 0 +疁 liu 0 +疍 dan 0 +疐 zhi 0 +疟 nve 0 +疭 zong 0 +痓 chi 0 +瘆 shen 0 +㿠 huang 0 +盷 tian 0 +𪾢 xian 0 +眬 long 0 +睄 shao 0 +䁖 lou 0 +矼 gang 0 +砄 jue 0 +砉 huo 0 +砠 ju 0 +砵 bo 0 +硁 keng 0 +𬒈 que 0 +硊 wei 0 +硍 xian 0 +硔 hong 0 +硙 wei 0 +硚 qiao 0 +鿎 ta 0 +䂮 lve 0 +碈 min 0 +碏 que 0 +䃅 di 0 +𬒔 geng 0 +碡 du 0 +碶 qi 0 +䃎 zha 0 +𬒗 lan 0 +磜 qi 0 +𥔲 e 0 +𥕢 cao 0 +磹 tan 0 +𥖨 zao 0 +祃 ma 0 +祋 dui 0 +祎 yi 0 +祾 ling 0 +秾 nong 0 +䅟 can 0 +穙 pu 0 +窎 diao 0 +竫 jing 0 +笮 ze 0 +筀 gui 0 +筜 dang 0 +筤 lang 0 +筶 gao 0 +筼 yun 0 +𬕂 gong 0 +箓 lu 0 +篯 jian 0 +簕 le 0 +簝 liao 0 +𥻗 cha 0 +糵 nie 0 +𬘓 xun 0 +纮 hong 0 +纴 ren 0 +纻 zhu 0 +纼 zhen 0 +𬘘 dan 0 +䌹 jiong 0 +絜 jie 0 +绖 die 0 +𬘡 yin 0 +绤 xi 0 +𫄧 yan 0 +𫄨 chi 0 +𬘩 ting 0 +𬘫 huan 0 +綮 qi 0 +绹 tao 0 +𫟅 liang 0 +𬘬 qian 0 +𬘭 lin 0 +𬘯 zhun 0 +缊 yun 0 +缐 xian 0 +缞 cui 0 +𬙂 yan 0 +𫄷 yi 0 +𦈡 xu 0 +𫄸 xun 0 +𬙊 mo 0 +𬙋 xiang 0 +羓 ba 0 +翈 xia 0 +䎃 ran 0 +翙 hui 0 +翚 hui 0 +翷 lin 0 +䎖 zeng 0 +𦒍 tong 0 +耑 duan 0 +耤 ji 0 +聒 gua 0 +䏝 zhuan 0 +胣 chi 0 +䏡 shi 0 +𦙶 gu 0 +脟 lie 0 +䏲 die 0 +脿 biao 0 +腒 ju 0 +腘 guo 0 +䐃 jun 0 +腨 shuan 0 +𦝼 lv 0 +臜 za 0 +芎 qiong 0 +芘 pi 0 +苉 pi 0 +𫇭 wei 0 +茋 zhi 0 +茓 xue 0 +𦭜 zhi 0 +茽 zhong 0 +荙 da 0 +𬜬 man 0 +茝 chai 0 +䓖 qiong 0 +𦰡 na 0 +𬜯 liang 0 +菍 nie 0 +菹 ju 0 +萚 tuo 0 +䓛 qu 0 +䓨 ying 0 +䓫 qi 0 +䓬 zhuo 0 +葰 sui 0 +蓢 lang 0 +蔃 qiang 0 +蔈 biao 0 +蕰 wen 0 +𬞟 pin 0 +薳 wei 0 +藠 jiao 0 +𬟁 yi 0 +虐 nve 0 +虤 yan 0 +虸 zi 0 +蚄 fang 0 +蚲 ping 0 +蛃 bing 0 +𬟽 dong 0 +蜐 jie 0 +蝲 la 0 +䗖 di 0 +螠 yi 0 +螣 te 0 +䗛 xiu 0 +螱 wei 0 +蟏 xiao 0 +䗪 zhe 0 +䗴 ting 0 +衠 zhun 0 +袆 hui 0 +袯 bo 0 +袷 jia 0 +裈 kun 0 +𫌀 ji 0 +襕 lan 0 +觃 yan 0 +觟 hua 0 +觭 ji 0 +訚 yin 0 +讱 ren 0 +𬣙 xu 0 +讻 xiong 0 +䜣 xin 0 +𬣞 zhu 0 +詟 zhe 0 +诇 xiong 0 +诐 bi 0 +𬣡 jian 0 +𫍣 tong 0 +𬣳 hen 0 +谞 xu 0 +𫍯 xian 0 +𫍲 xiao 0 +𬤇 yin 0 +𬤊 shi 0 +𬤝 hui 0 +𫍽 xuan 0 +豉 shi 0 +豮 fen 0 +䝙 chu 0 +赑 bi 0 +赒 zhou 0 +赗 feng 0 +赪 cheng 0 +趯 ti 0 +𧿹 mu 0 +跶 da 0 +蹢 di 0 +蹽 liao 0 +蹾 dun 0 +轪 dai 0 +𫐄 yue 0 +𬨂 qi 0 +辀 zhou 0 +𨐈 guang 0 +辌 liang 0 +𫐐 ni 0 +辒 wen 0 +𫐓 bu 0 +𬨎 you 0 +𫟦 sui 0 +遆 ti 0 +𨙸 qi 0 +䢺 chu 0 +𨚕 bian 0 +䢼 gong 0 +𬩽 xun 0 +𫑡 meng 0 +鄌 tang 0 +䣘 tang 0 +酂 cuo 0 +𨟠 quan 0 +酦 po 0 +𬪩 nong 0 +醾 mi 0 +钖 yang 0 +𬬩 yi 0 +钘 xing 0 +𫓧 fu 0 +𬬭 lun 0 +𬬮 chang 0 +𬬱 jin 0 +䥽 po 0 +𬬸 shu 0 +𬬹 shen 0 +𬬻 lu 0 +𬬿 zhao 0 +𬭁 mu 0 +铏 xing 0 +铚 zhi 0 +铤 ding 0 +𫓯 ji 0 +𫟹 hong 0 +铻 wu 0 +锊 lve 0 +鿏 mai 0 +𨱇 qiu 0 +𫓶 xuan 0 +𫟼 da 0 +𬭊 du 0 +𬭎 hong 0 +锜 qi 0 +锧 zhi 0 +𫓹 ji 0 +𬭚 chun 0 +𬭛 bo 0 +锳 ying 0 +锽 huang 0 +镃 zi 0 +𬭤 hou 0 +镈 bo 0 +镋 tang 0 +𨱏 da 0 +𬭩 weng 0 +镚 beng 0 +镠 liu 0 +𬭬 wei 0 +𬭯 pie 0 +𨭉 ban 0 +镡 chan 0 +䦃 zhuo 0 +𨱑 huang 0 +𨱔 zun 0 +𫔍 fan 0 +𫔎 jue 0 +𬭳 xi 0 +𬭶 hei 0 +𬭸 lin 0 +镮 huan 0 +𬭼 sui 0 +镴 la 0 +镵 chan 0 +闿 kai 0 +阇 du 0 +阏 e 0 +𬮱 yin 0 +阘 da 0 +𫔶 nie 0 +𬮿 qi 0 +𬯀 ji 0 +𨺙 ni 0 +隃 shu 0 +𬯎 tui 0 +隩 ao 0 +隺 hu 0 +靽 ban 0 +鞔 man 0 +鞧 qiu 0 +韂 chan 0 +韨 fu 0 +𫠆 kui 0 +𬱖 di 0 +颋 ting 0 +颎 jiong 0 +𫖮 yi 0 +𫖯 fu 0 +𬱟 wei 0 +𫖳 yun 0 +颙 yong 0 +飏 yang 0 +飐 zhan 0 +飔 si 0 +飗 liu 0 +饧 tang 0 +饳 duo 0 +饸 he 0 +饹 le 0 +饻 xi 0 +𫗧 su 0 +馃 guo 0 +馉 gu 0 +馌 ye 0 +𫗴 zhan 0 +馝 bi 0 +馞 bo 0 +驲 ri 0 +𫘜 wen 0 +𫘝 jue 0 +骀 dai 0 +𫠊 xuan 0 +𬳵 pi 0 +𬳶 jiong 0 +骃 yin 0 +骉 biao 0 +𬳽 shen 0 +骍 xing 0 +骎 qin 0 +𬳿 tu 0 +骕 su 0 +𫘦 tao 0 +𫘧 lu 0 +𬴂 fei 0 +骙 kui 0 +𫘨 ti 0 +𬴃 huo 0 +𫘪 yuan 0 +𫘬 xi 0 +𬴊 lin 0 +骦 shuang 0 +髃 yu 0 +髎 liao 0 +髢 di 0 +髽 zhua 0 +鬶 gui 0 +鱽 dao 0 +鱾 ji 0 +鲀 tun 0 +鲃 ba 0 +鲉 you 0 +鲊 zha 0 +鲌 ba 0 +鲏 pi 0 +䲟 yin 0 +𬶋 ju 0 +𬶍 tuo 0 +鲖 tong 0 +鲗 zei 0 +鲘 hou 0 +鲙 kuai 0 +鲝 zha 0 +𩽾 an 0 +𫚕 shi 0 +𫚖 ci 0 +𬶏 wei 0 +𬶐 zhao 0 +鲪 jun 0 +鲬 yong 0 +𩾃 mian 0 +鲯 qi 0 +鲹 shen 0 +鲿 chang 0 +鳀 ti 0 +鳁 wen 0 +鳂 wei 0 +鳈 quan 0 +鳉 jiang 0 +䲠 chun 0 +𬶟 la 0 +𬶠 lian 0 +𬶨 ji 0 +鲾 bi 0 +鳑 pang 0 +鳒 jian 0 +䲢 teng 0 +鳚 wei 0 +鳛 xi 0 +𩾌 kang 0 +𬶭 ji 0 +𬶮 xi 0 +鳠 hu 0 +鳡 gan 0 +鳣 zhan 0 +鳤 guan 0 +𫚭 lie 0 +鸤 shi 0 +鸧 cang 0 +䴓 shi 0 +鸮 xiao 0 +鸻 heng 0 +鸼 zhou 0 +䴔 jiao 0 +䴕 lie 0 +鹀 wu 0 +𫛭 kuang 0 +𬷕 bu 0 +鹍 kun 0 +鹐 qian 0 +鹒 geng 0 +鹔 su 0 +䴖 jing 0 +鹖 he 0 +鹙 qiu 0 +䴗 ju 0 +𬸘 yan 0 +𬸚 yue 0 +鹝 yi 0 +鹟 weng 0 +鹠 liu 0 +鹡 ji 0 +鹢 yi 0 +䴘 ti 0 +𬸣 xian 0 +𬸦 zhuo 0 +𬸪 fan 0 +鹮 huan 0 +鹯 zhan 0 +䴙 pi 0 +鹲 meng 0 +鹴 shuang 0 +麖 jing 0 +黇 tian 0 +黡 yan 0 +齇 zha 0 +龁 he 0 +龂 yin 0 +𬹼 xie 0 +𫠜 ni 0 +𬺈 yi 0 +𬺓 chu 0 +䶮 yan 0 diff --git a/openless-all/app/android/assets/pinyin_dictionary_meta.json b/openless-all/app/android/assets/pinyin_dictionary_meta.json new file mode 100644 index 000000000..1791e644a --- /dev/null +++ b/openless-all/app/android/assets/pinyin_dictionary_meta.json @@ -0,0 +1,18 @@ +{ + "format_version": 1, + "generated": "2026-09-25", + "chars": { + "file": "pinyin_chars.tsv", + "character_count": 8105, + "row_count": 8225, + "generator": "scripts/generate-pinyin-characters.mjs" + }, + "phrases": { + "file": "pinyin_phrases.tsv", + "row_count": 2006, + "corpus_limit": 2000, + "whitelist_added": 6, + "blacklist_removed": 1, + "generator": "scripts/generate-pinyin-phrases.mjs" + } +} diff --git a/openless-all/app/android/assets/pinyin_phrases.LICENSE.txt b/openless-all/app/android/assets/pinyin_phrases.LICENSE.txt new file mode 100644 index 000000000..fd4cb9f7d --- /dev/null +++ b/openless-all/app/android/assets/pinyin_phrases.LICENSE.txt @@ -0,0 +1,51 @@ +pinyin_phrases.tsv — attribution, source pinning, and license +================================================================= + +Sources: + +1. rime/rime-pinyin-simp — pinyin_simp.dict.yaml (base candidate pool and + usage-frequency weight; multi-character rows only — see + pinyin_chars.LICENSE.txt for this same file's single-character rows) + https://github.com/rime/rime-pinyin-simp + Commit: 0c6861ef7420ee780270ca6d993d18d4101049d0 + Downloaded: 2026-09-25 + SHA-256: e341598343a0f0f2035bb1aafc34a7f3bb7887deeecb3f60796262aaa2983e6b + License: Apache License 2.0. Full text: + https://github.com/rime/rime-pinyin-simp/blob/master/LICENSE + +2. mozillazg/pinyin-data — kMandarin_8105.txt, used only as a character-set + membership filter (rejects any candidate phrase containing a character + outside 《通用规范汉字表》, i.e. traditional-only/rare/PUA characters) + https://github.com/mozillazg/pinyin-data + Commit: 923b108dc5d45dee061324c011b478fb649f8b73 + Downloaded: 2026-09-25 + SHA-256: 1b1546f6e190c0f1cbf23e3bb436b41c3ace16f852be4daebc2c7295636628a4 + License: MIT License, Copyright (c) 2016 mozillazg. Full text: + https://github.com/mozillazg/pinyin-data/blob/master/LICENSE + +3. mozillazg/phrase-pinyin-data — pinyin.txt, authoritative word-level + readings used to correct a phrase's pinyin when it disagrees with rime's + own per-character reading (resolves polyphones-in-context) + https://github.com/mozillazg/phrase-pinyin-data + Commit: cee0ed6e6e4898580cafd2bd5e3723e20b214aa0 + Downloaded: 2026-09-25 + SHA-256: dcc769607c220b312fea3e71cb63421298b4b891b1f7356a95ab58f2c96fff81 + License: MIT License, Copyright (c) 2017 mozillazg. Full text: + https://github.com/mozillazg/phrase-pinyin-data/blob/master/LICENSE + +Generation: this repo's scripts/generate-pinyin-phrases.mjs — see that +script's own header comment for the exact re-fetch/regenerate commands and +selection/ranking rules (2-4 character simplified-only words, top ~2000 by +usage weight, plus a small manually curated domain-term whitelist confirmed +with the user on 2026-09-25 — see the WHITELIST constant in that script — and +a small blacklist of corpus noise found by spot-checking the output, e.g. +"回复日期", a forum/IME-corpus UI-template phrase rather than real vocabulary +— see the BLACKLIST constant). LitePinyinRepository.kt additionally ranks +the whitelisted words above an ordinary same-abbreviation phrase at query +time (its own PRIORITY_PHRASES constant), not something this generator's +own output encodes. +Output rows are tab-separated "phrasefull_pinyinabbreviation +weight", pinyin lowercase ASCII with ü normalized to v and tone marks +stripped, abbreviation = first letter of each syllable. No runtime code in +OpenLess downloads, modifies, or redistributes any of the three original +sources — only this generated, filtered derivative ships in the app. diff --git a/openless-all/app/android/assets/pinyin_phrases.tsv b/openless-all/app/android/assets/pinyin_phrases.tsv new file mode 100644 index 000000000..326f0b8b1 --- /dev/null +++ b/openless-all/app/android/assets/pinyin_phrases.tsv @@ -0,0 +1,2006 @@ +一个 yi ge yg 373855 +没有 mei you my 325559 +我们 wo men wm 322329 +可以 ke yi ky 304372 +什么 shen me sm 295403 +这个 zhe ge zg 253424 +呵呵 he he hh 249662 +现在 xian zai xz 246263 +自己 zi ji zj 239007 +就是 jiu shi js 211267 +不是 bu shi bs 173509 +怎么 zen me zm 173077 +还是 hai shi hs 166934 +他们 ta men tm 156264 +如果 ru guo rg 155100 +知道 zhi dao zd 144465 +时候 shi hou sh 143389 +问题 wen ti wt 137270 +这样 zhe yang zy 131084 +已经 yi jing yj 129784 +哈哈 ha ha hh 121640 +因为 yin wei yw 121338 +公司 gong si gs 118498 +那个 na ge ng 117323 +今天 jin tian jt 109961 +工作 gong zuo gz 107925 +中国 zhong guo zg 106588 +但是 dan shi ds 105663 +不过 bu guo bg 103577 +可能 ke neng kn 100901 +应该 ying gai yg 97684 +所以 suo yi sy 95995 +你们 ni men nm 95399 +一下 yi xia yx 94335 +时间 shi jian sj 92935 +都是 dou shi ds 91784 +还有 hai you hy 86132 +进行 jin xing jx 84537 +觉得 jue de jd 83907 +需要 xu yao xy 82765 +不能 bu neng bn 80727 +开始 kai shi ks 78154 +东西 dong xi dx 77090 +那么 na me nm 76230 +这么 zhe me zm 74446 +喜欢 xi huan xh 72704 +比较 bi jiao bj 72343 +然后 ran hou rh 69601 +一些 yi xie yx 69428 +不会 bu hui bh 68739 +大家 da jia dj 67803 +很多 hen duo hd 66548 +看到 kan dao kd 65642 +不要 bu yao by 65478 +一样 yi yang yy 64748 +这些 zhe xie zx 62997 +也是 ye shi ys 62372 +感觉 gan jue gj 61600 +看看 kan kan kk 59859 +其实 qi shi qs 59636 +出来 chu lai cl 59118 +朋友 peng you py 58897 +明天 ming tian mt 58168 +以后 yi hou yh 58095 +只是 zhi shi zs 58095 +这里 zhe li zl 57305 +而且 er qie eq 56949 +系统 xi tong xt 56454 +通过 tong guo tg 56313 +一定 yi ding yd 56103 +使用 shi yong sy 55824 +一起 yi qi yq 55755 +企业 qi ye qy 54722 +或者 huo zhe hz 54518 +发展 fa zhan fz 54045 +市场 shi chang sc 54045 +一直 yi zhi yz 53242 +北京 bei jing bj 53176 +其他 qi ta qt 52912 +好像 hao xiang hx 52780 +产品 chan pin cp 51481 +这种 zhe zhong zz 51161 +事情 shi qing sq 51097 +的人 de ren dr 50970 +为什么 wei shen me wsm 50907 +发现 fa xian fx 50590 +只有 zhi you zy 50276 +地方 di fang df 50276 +希望 xi wang xw 50276 +情况 qing kuang qk 50151 +要求 yao qiu yq 49284 +电话 dian hua dh 49100 +信息 xin xi xx 48673 +多少 duo shao ds 47891 +提供 ti gong tg 47772 +一般 yi ban yb 47653 +谢谢 xie xie xx 47594 +回来 hui lai hl 47004 +主要 zhu yao zy 46887 +不错 bu cuo bc 46770 +生活 sheng huo sh 46770 +上海 shang hai sh 46654 +目前 mu qian mq 45167 +直接 zhi jie zj 44386 +所有 suo you sy 44275 +晚上 wan shang ws 43945 +技术 ji shu js 43401 +方面 fang mian fm 43347 +出现 chu xian cx 43293 +虽然 sui ran sr 43131 +为了 wei le wl 43078 +支持 zhi chi zc 43078 +价格 jia ge jg 42438 +美国 mei guo mg 42227 +是不是 shi bu shi sbs 42122 +我的 wo de wd 41860 +选择 xuan ze xz 41445 +以前 yi qian yq 41394 +项目 xiang mu xm 41136 +非常 fei chang fc 41085 +里面 li mian lm 40932 +别人 bie ren br 40576 +国家 guo jia gj 40475 +以及 yi ji yj 39676 +你的 ni de nd 39676 +同时 tong shi ts 39627 +对于 dui yu dy 39578 +两个 liang ge lg 39430 +准备 zhun bei zb 39430 +管理 guan li gl 39332 +一点 yi dian yd 39136 +肯定 ken ding kd 38942 +这是 zhe shi zs 38748 +昨天 zuo tian zt 38603 +认为 ren wei rw 38603 +服务 fu wu fw 38555 +最后 zui hou zh 38459 +成为 cheng wei cw 38459 +网站 wang zhan wz 38411 +作为 zuo wei zw 37936 +哪里 na li nl 37936 +起来 qi lai ql 37936 +之后 zhi hou zh 37747 +继续 ji xu jx 37700 +手机 shou ji sj 37653 +真的 zhen de zd 37606 +不同 bu tong bt 37559 +用户 yong hu yh 37419 +由于 you yu yy 37419 +结果 jie guo jg 37326 +估计 gu ji gj 37233 +最近 zui jin zj 36818 +影响 ying xiang yx 36726 +学校 xue xiao xx 36498 +孩子 hai zi hz 36407 +好的 hao de hd 36136 +设计 she ji sj 36001 +任务 ren wu rw 35912 +关系 guan xi gx 35644 +部分 bu fen bf 35467 +活动 huo dong hd 35379 +必须 bi xu bx 34984 +网络 wang luo wl 34723 +研究 yan jiu yj 34680 +如何 ru he rh 34508 +当然 dang ran dr 34508 +那些 nei xie nx 34379 +功能 gong neng gn 34251 +可是 ke shi ks 34208 +学生 xue sheng xs 33996 +数据 shu ju sj 33827 +告诉 gao su gs 33658 +给我 gei wo gw 33658 +方式 fang shi fs 33616 +内容 nei rong nr 33241 +一次 yi ci yc 33200 +那里 na li nl 33200 +女人 nv ren nr 33076 +是否 shi fou sf 33035 +电脑 dian nao dn 33035 +意思 yi si ys 32504 +联系 lian xi lx 32464 +游戏 you xi yx 32383 +老师 lao shi ls 32262 +只要 zhi yao zy 32102 +特别 te bie tb 32102 +个人 ge ren gr 32022 +文件 wen jian wj 31862 +有点 you dian yd 31823 +能够 neng gou ng 31823 +软件 ruan jian rj 31744 +几个 ji ge jg 31704 +过去 guo qu gq 31665 +学习 xue xi xx 31468 +社会 she hui sh 31390 +过来 guo lai gl 31233 +给你 gei ni gn 31117 +男人 nan ren nr 31039 +了解 liao jie lj 31001 +只能 zhi neng zn 30962 +吃饭 chi fan cf 30885 +世界 shi jie sj 30808 +不好 bu hao bh 30541 +不用 bu yong by 30503 +的话 de hua dh 30389 +表示 biao shi bs 30351 +嘿嘿 hei hei hh 30087 +发生 fa sheng fs 29900 +得到 de dao dd 29752 +同学 tong xue tx 29715 +基本 ji ben jb 29715 +处理 chu li cl 29715 +老婆 lao po lp 29641 +照片 zhao pian zp 29567 +客户 ke hu kh 29530 +方法 fang fa ff 29383 +完成 wan cheng wc 29347 +关于 guan yu gy 28983 +有些 you xie yx 28983 +当时 dang shi ds 28947 +刚才 gang cai gc 28875 +实现 shi xian sx 28767 +原来 yuan lai yl 28696 +包括 bao kuo bk 28660 +解决 jie jue jj 28553 +中的 zhong de zd 28517 +一种 yi zhong yz 28059 +反正 fan zheng fz 27816 +政府 zheng fu zf 27677 +根据 gen ju gj 27677 +重要 zhong yao zy 27643 +成功 cheng gong cg 27437 +考虑 kao lv kl 27403 +下载 xia zai xz 27334 +能力 neng li nl 27266 +过程 guo cheng gc 27165 +他的 ta de td 27030 +分析 fen xi fx 27030 +机会 ji hui jh 26962 +下午 xia wu xw 26828 +提高 ti gao tg 26728 +认识 ren shi rs 26695 +注意 zhu yi zy 26662 +介绍 jie shao js 26562 +之前 zhi qian zq 26463 +存在 cun zai cz 26430 +日本 ri ben rb 26430 +进入 jin ru jr 26430 +其中 qi zhong qz 26299 +今年 jin nian jn 26200 +增加 zeng jia zj 26070 +记者 ji zhe jz 26005 +出去 chu qu cq 25973 +无法 wu fa wf 25973 +国内 guo nei gn 25779 +决定 jue ding jd 25683 +人家 ren jia rj 25556 +相关 xiang guan xg 25428 +完全 wan quan wq 25334 +单位 dan wei dw 25176 +组织 zu zhi zz 25176 +有人 you ren yr 25051 +测试 ce shi cs 24989 +还要 hai yao hy 24989 +业务 ye wu yw 24958 +任何 ren he rh 24926 +后来 hou lai hl 24926 +正在 zheng zai zz 24926 +专业 zhuan ye zy 24772 +之间 zhi jian zj 24556 +每天 mei tian mt 24404 +除了 chu le cl 24283 +建议 jian yi jy 24252 +论坛 lun tan lt 24162 +投资 tou zi tz 24132 +具有 ju you jy 24042 +空间 kong jian kj 23982 +计划 ji hua jh 23982 +不行 bu xing bx 23714 +原因 yuan yin yy 23567 +开发 kai fa kf 23508 +建立 jian li jl 23362 +名字 ming zi mz 23304 +参加 can jia cj 23246 +回家 hui jia hj 23246 +人员 ren yuan ry 23159 +全部 quan bu qb 23131 +回去 hui qu hq 23044 +睡觉 shui jiao sj 23044 +建设 jian she js 23016 +显示 xian shi xs 22987 +甚至 shen zhi sz 22901 +最好 zui hao zh 22844 +我国 wo guo wg 22844 +找到 zhao dao zd 22844 +获得 huo de hd 22787 +到了 dao le dl 22702 +这次 zhe ci zc 22646 +部门 bu men bm 22646 +人们 ren men rm 22618 +标准 biao zhun bz 22561 +经济 jing ji jj 22505 +控制 kong zhi kz 22393 +网上 wang shang ws 22365 +程序 cheng xu cx 22310 +简单 jian dan jd 22254 +让我 rang wo rw 22143 +一切 yi qie yq 22116 +效果 xiao guo xg 22006 +本来 ben lai bl 22006 +上班 shang ban sb 21869 +哪个 na ge ng 21733 +城市 cheng shi cs 21733 +真是 zhen shi zs 21679 +也许 ye xu yx 21545 +好了 hao le hl 21545 +电影 dian ying dy 21545 +达到 da dao dd 21518 +具体 ju ti jt 21384 +速度 su du sd 21384 +负责 fu ze fz 21357 +环境 huan jing hj 21251 +小时 xiao shi xs 21172 +经常 jing chang jc 21172 +银行 yin hang yh 21172 +办法 ban fa bf 21119 +消息 xiao xi xx 21040 +提出 ti chu tc 21014 +生产 sheng chan sc 21014 +申请 shen qing sq 20988 +大学 da xue dx 20962 +看来 kan lai kl 20962 +说话 shuo hua sh 20962 +因此 yin ci yc 20936 +我是 wo shi ws 20858 +来说 lai shuo ls 20754 +你好 ni hao nh 20728 +相信 xiang xin xx 20728 +许多 xu duo xd 20728 +说明 shuo ming sm 20702 +作用 zuo yong zy 20651 +安全 an quan aq 20599 +各种 ge zhong gz 20574 +终于 zhong yu zy 20574 +真正 zhen zheng zz 20548 +记得 ji de jd 20497 +全国 quan guo qg 20471 +正常 zheng chang zc 20446 +调整 tiao zheng tz 20420 +操作 cao zuo cz 20395 +领导 ling dao ld 20395 +没事 mei shi ms 20370 +资料 zi liao zl 20370 +采用 cai yong cy 20370 +下来 xia lai xl 20319 +努力 nu li nl 20294 +另外 ling wai lw 20294 +修改 xiu gai xg 20268 +国际 guo ji gj 20243 +有关 you guan yg 20243 +行业 hang ye hy 20243 +以上 yi shang ys 20218 +图片 tu pian tp 20218 +接受 jie shou js 20218 +容易 rong yi ry 20142 +才能 cai neng cn 20142 +有没有 you mei you ymy 20142 +设置 she zhi sz 20067 +妈妈 ma ma mm 20017 +产生 chan sheng cs 19968 +以为 yi wei yw 19968 +明白 ming bai mb 19968 +上面 shang mian sm 19794 +历史 li shi ls 19769 +合作 he zuo hz 19647 +目标 mu biao mb 19598 +代表 dai biao db 19525 +我也 wo ye wy 19500 +需求 xu qiu xq 19500 +总是 zong shi zs 19476 +这一 zhe yi zy 19452 +第一 di yi dy 19403 +安装 an zhuang az 19379 +广告 guang gao gg 19307 +按照 an zhao az 19307 +那样 na yang ny 19307 +股票 gu piao gp 19235 +也有 ye you yy 19187 +也不 ye bu yb 19139 +文化 wen hua wh 19115 +不断 bu duan bd 19068 +确定 que ding qd 19020 +服务器 fu wu qi fwq 18997 +好好 hao hao hh 18973 +销售 xiao shou xs 18926 +左右 zuo you zy 18902 +麻烦 ma fan mf 18902 +中心 zhong xin zx 18879 +应用 ying yong yy 18808 +表现 biao xian bx 18785 +如此 ru ci rc 18761 +经验 jing yan jy 18761 +不少 bu shao bs 18691 +根本 gen ben gb 18691 +关注 guan zhu gz 18622 +文章 wen zhang wz 18598 +经过 jing guo jg 18598 +差不多 cha bu duo cbd 18575 +地址 di zhi dz 18483 +条件 tiao jian tj 18437 +比如 bi ru br 18414 +都有 dou you dy 18391 +确实 que shi qs 18345 +形成 xing cheng xc 18299 +整个 zheng ge zg 18299 +帮助 bang zhu bz 18231 +家里 jia li jl 18208 +精神 jing shen js 18163 +身体 shen ti st 18163 +水平 shui ping sp 18118 +资源 zi yuan zy 18118 +马上 ma shang ms 18050 +似乎 si hu sh 17983 +休息 xiu xi xx 17938 +香港 xiang gang xg 17916 +突然 tu ran tr 17871 +一天 yi tian yt 17849 +刚刚 gang gang gg 17849 +是个 shi ge sg 17849 +明显 ming xian mx 17805 +收到 shou dao sd 17782 +并且 bing qie bq 17738 +教育 jiao yu jy 17716 +行为 xing wei xw 17716 +带来 dai lai dl 17650 +要是 yao shi ys 17628 +造成 zao cheng zc 17606 +上的 shang de sd 17540 +清楚 qing chu qc 17540 +房子 fang zi fz 17475 +深圳 shen zhen sz 17475 +利用 li yong ly 17431 +就会 jiu hui jh 17431 +大概 da gai dg 17410 +重新 chong xin cx 17410 +安排 an pai ap 17388 +理解 li jie lj 17345 +资金 zi jin zj 17280 +不到 bu dao bd 17258 +先生 xian sheng xs 17215 +变化 bian hua bh 17215 +拥有 yong you yy 17173 +推荐 tui jian tj 17173 +博客 bo ke bk 17151 +状态 zhuang tai zt 17109 +保证 bao zheng bz 17087 +美女 mei nv mn 17045 +难道 nan dao nd 17045 +干嘛 gan ma gm 17002 +品牌 pin pai pp 16876 +打开 da kai dk 16876 +改变 gai bian gb 16876 +不想 bu xiang bx 16834 +有个 you ge yg 16750 +比赛 bi sai bs 16729 +保持 bao chi bc 16708 +超过 chao guo cg 16708 +地区 di qu dq 16667 +下面 xia mian xm 16646 +广州 guang zhou gz 16646 +楼主 lou zhu lz 16625 +工程 gong cheng gc 16604 +位置 wei zhi wz 16584 +视频 shi pin sp 16584 +回答 hui da hd 16563 +看见 kan jian kj 16522 +自然 zi ran zr 16522 +方便 fang bian fb 16501 +快乐 kuai le kl 16481 +还在 hai zai hz 16481 +规定 gui ding gd 16419 +分钟 fen zhong fz 16399 +声音 sheng yin sy 16399 +心里 xin li xl 16399 +幸福 xing fu xf 16358 +音乐 yin yue yy 16358 +设备 she bei sb 16338 +媒体 mei ti mt 16256 +那是 na shi ns 16256 +漂亮 piao liang pl 16216 +更新 geng xin gx 16196 +老板 lao ban lb 16196 +基金 ji jin jj 16176 +实在 shi zai sz 16176 +密码 mi ma mm 16075 +慢慢 man man mm 16075 +第一次 di yi ci dyc 16075 +导致 dao zhi dz 16035 +很好 hen hao hh 15995 +讨论 tao lun tl 15975 +上网 shang wang sw 15935 +版本 ban ben bb 15935 +至少 zhi shao zs 15916 +基础 ji chu jc 15896 +通知 tong zhi tz 15896 +以下 yi xia yx 15856 +结婚 jie hun jh 15836 +结束 jie shu js 15836 +未来 wei lai wl 15797 +每个 mei ge mg 15679 +我想 wo xiang wx 15660 +检查 jian cha jc 15621 +还没 hai mei hm 15621 +曾经 ceng jing cj 15582 +治疗 zhi liao zl 15582 +随便 sui bian sb 15582 +医院 yi yuan yy 15543 +离开 li kai lk 15543 +上次 shang ci sc 15524 +集团 ji tuan jt 15524 +严重 yan zhong yz 15504 +发表 fa biao fb 15504 +相当 xiang dang xd 15504 +怎么样 zen me yang zmy 15408 +不仅 bu jin bj 15370 +衣服 yi fu yf 15351 +属于 shu yu sy 15332 +实际 shi ji sj 15312 +购买 gou mai gm 15293 +政策 zheng ce zc 15274 +注册 zhu ce zc 15236 +更加 geng jia gj 15198 +居然 ju ran jr 15142 +这边 zhe bian zb 15142 +加强 jia qiang jq 15104 +报告 bao gao bg 15104 +有效 you xiao yx 15104 +保护 bao hu bh 15066 +并不 bing bu bb 15048 +目的 mu di md 15048 +我就 wo jiu wj 15010 +记录 ji lu jl 15010 +质量 zhi liang zl 14991 +坚持 jian chi jc 14954 +新的 xin de xd 14954 +而已 er yi ey 14806 +机器 ji qi jq 14732 +郁闷 yu men ym 14732 +宝宝 bao bao bb 14714 +实施 shi shi ss 14714 +模式 mo shi ms 14714 +好多 hao duo hd 14695 +是啊 shi a sa 14695 +便宜 pian yi py 14659 +怎样 zen yang zy 14659 +让人 rang ren rr 14659 +关键 guan jian gj 14622 +后面 hou mian hm 14622 +培训 pei xun px 14586 +故事 gu shi gs 14550 +意见 yi jian yj 14532 +爱情 ai qing aq 14477 +都会 du hui dh 14477 +看了 kan le kl 14441 +台湾 tai wan tw 14405 +发布 fa bu fb 14387 +方案 fang an fa 14370 +每次 mei ci mc 14370 +大量 da liang dl 14352 +同样 tong yang ty 14316 +到底 dao di dd 14298 +永远 yong yuan yy 14280 +老公 lao gong lg 14280 +受到 shou dao sd 14262 +看着 kan zhe kz 14227 +绝对 jue dui jd 14227 +来了 lai le ll 14174 +于是 yu shi ys 14121 +感情 gan qing gq 14121 +太多 tai duo td 14103 +愿意 yuan yi yy 14068 +对方 dui fang df 14051 +压力 ya li yl 14033 +随着 sui zhe sz 14033 +网友 wang you wy 14016 +机构 ji gou jg 13998 +不管 bu guan bg 13963 +考试 kao shi ks 13946 +交流 jiao liu jl 13929 +之一 zhi yi zy 13911 +毕业 bi ye by 13911 +价值 jia zhi jz 13894 +免费 mian fei mf 13894 +一年 yi nian yn 13825 +平台 ping tai pt 13808 +成本 cheng ben cb 13808 +配置 pei zhi pz 13808 +几乎 ji hu jh 13791 +生命 sheng ming sm 13791 +制度 zhi du zd 13756 +首先 shou xian sx 13756 +变成 bian cheng bc 13739 +她们 ta men tm 13722 +升级 sheng ji sj 13705 +打算 da suan ds 13705 +一家 yi jia yj 13688 +稳定 wen ding wd 13688 +工资 gong zi gz 13671 +帖子 tie zi tz 13654 +专家 zhuan jia zj 13637 +那就 na jiu nj 13637 +即使 ji shi js 13603 +现场 xian chang xc 13586 +重点 zhong dian zd 13586 +交易 jiao yi jy 13552 +错误 cuo wu cw 13552 +参与 can yu cy 13485 +材料 cai liao cl 13485 +传统 chuan tong ct 13434 +同事 tong shi ts 13434 +调查 diao cha dc 13434 +她的 ta de td 13401 +结构 jie gou jg 13384 +费用 fei yong fy 13384 +厉害 li hai lh 13368 +而是 er shi es 13368 +尤其 you qi yq 13318 +作品 zuo pin zp 13301 +天天 tian tian tt 13284 +样子 yang zi yz 13284 +第二 di er de 13284 +父母 fu mu fm 13268 +确认 que ren qr 13268 +加入 jia ru jr 13251 +眼睛 yan jing yj 13218 +引起 yin qi yq 13202 +更多 geng duo gd 13186 +心情 xin qing xq 13169 +身上 shen shang ss 13169 +家庭 jia ting jt 13153 +最终 zui zhong zz 13136 +面对 mian dui md 13120 +一位 yi wei yw 13104 +分别 fen bie fb 13104 +它们 ta men tm 13087 +习惯 xi guan xg 13071 +来自 lai zi lz 13071 +不够 bu gou bg 13038 +事件 shi jian sj 13022 +推出 tui chu tc 13022 +知识 zhi shi zs 13022 +范围 fan wei fw 13022 +你是 ni shi ns 13006 +去年 qu nian qn 13006 +新闻 xin wen xw 13006 +自动 zi dong zd 13006 +垃圾 la ji lj 12941 +早上 zao shang zs 12909 +意义 yi yi yy 12893 +还好 hai hao hh 12877 +积极 ji ji jj 12845 +适合 shi he sh 12829 +全面 quan mian qm 12781 +执行 zhi xing zx 12781 +人生 ren sheng rs 12765 +正式 zheng shi zs 12749 +进一步 jin yi bu jyb 12749 +满足 man zu mz 12702 +运行 yun xing yx 12670 +收入 shou ru sr 12639 +不知 bu zhi bz 12607 +十分 shi fen sf 12607 +聊天 liao tian lt 12591 +代码 dai ma dm 12576 +遇到 yu dao yd 12576 +其它 qi ta qt 12560 +优势 you shi ys 12544 +等等 deng deng dd 12544 +哈哈哈 ha ha ha hhh 12529 +去了 qu le ql 12513 +取得 qu de qd 12513 +及时 ji shi js 12420 +方向 fang xiang fx 12420 +不乖 bu guai bg 12358 +不再 bu zai bz 12343 +邮件 you jian yj 12312 +姐姐 jie jie jj 12297 +系列 xi lie xl 12297 +再次 zai ci zc 12266 +普通 pu tong pt 12266 +经历 jing li jl 12266 +解释 jie shi js 12266 +您的 nin de nd 12251 +会议 hui yi hy 12236 +领域 ling yu ly 12236 +风险 feng xian fx 12236 +统一 tong yi ty 12220 +全球 quan qiu qq 12205 +形式 xing shi xs 12205 +思想 si xiang sx 12205 +无论 wu lun wl 12205 +农民 nong min nm 12190 +几天 ji tian jt 12190 +员工 yuan gong yg 12190 +短信 duan xin dx 12144 +进去 jin qu jq 12099 +增长 zeng zhang zz 12069 +本人 ben ren br 12054 +现实 xian shi xs 12054 +所谓 suo wei sw 12024 +不然 bu ran br 11994 +法律 fa lv fl 11964 +开心 kai xin kx 11934 +就要 jiu yao jy 11920 +移动 yi dong yd 11905 +域名 yu ming ym 11890 +农村 nong cun nc 11875 +合同 he tong ht 11875 +开展 kai zhan kz 11875 +时代 shi dai sd 11875 +同意 tong yi ty 11860 +工具 gong ju gj 11860 +想到 xiang dao xd 11845 +汽车 qi che qc 11845 +中午 zhong wu zw 11831 +女孩 nv hai nh 11831 +想法 xiang fa xf 11816 +自由 zi you zy 11801 +儿子 er zi ez 11787 +电视 dian shi ds 11787 +文字 wen zi wz 11728 +欢迎 huan ying hy 11728 +无聊 wu liao wl 11684 +一会 yi hui yh 11670 +担心 dan xin dx 11641 +听说 ting shuo ts 11626 +共同 gong tong gt 11597 +现象 xian xiang xx 11597 +下去 xia qu xq 11583 +事实 shi shi ss 11583 +理论 li lun ll 11568 +三个 san ge sg 11554 +人民 ren min rm 11554 +一张 yi zhang yz 11539 +前面 qian mian qm 11511 +减少 jian shao js 11482 +的事 de shi ds 11482 +宣传 xuan chuan xc 11468 +链接 lian jie lj 11439 +竟然 jing ran jr 11439 +感到 gan dao gd 11425 +力量 li liang ll 11411 +消费者 xiao fei zhe xfz 11411 +马甲 ma jia mj 11397 +办公室 ban gong shi bgs 11368 +阶段 jie duan jd 11368 +健康 jian kang jk 11354 +回复 hui fu hf 11354 +语言 yu yan yy 11354 +会有 hui you hy 11326 +放在 fang zai fz 11326 +是在 shi zai sz 11326 +也就 ye jiu yj 11312 +页面 ye mian ym 11312 +证明 zheng ming zm 11284 +医生 yi sheng ys 11269 +对象 dui xiang dx 11269 +周末 zhou mo zm 11241 +针对 zhen dui zd 11241 +要不 yao bu yb 11227 +仍然 reng ran rr 11213 +数量 shu liang sl 11199 +运动 yun dong yd 11186 +我在 wo zai wz 11172 +报道 bao dao bd 11172 +让你 rang ni rn 11172 +都在 dou zai dz 11172 +失败 shi bai sb 11144 +貌似 mao si ms 11130 +旅游 lv you ly 11088 +相对 xiang dui xd 11088 +下次 xia ci xc 11047 +号码 hao ma hm 11047 +毕竟 bi jing bj 11047 +责任 ze ren zr 11033 +期间 qi jian qj 11019 +没什么 mei shen me msm 11019 +一条 yi tiao yt 11006 +不敢 bu gan bg 10978 +促进 cu jin cj 10978 +恢复 hui fu hf 10978 +算了 suan le sl 10978 +长期 chang qi cq 10978 +不如 bu ru br 10965 +删除 shan chu sc 10965 +心理 xin li xl 10937 +忘记 wang ji wj 10937 +输入 shu ru sr 10924 +再说 zai shuo zs 10897 +试试 shi shi ss 10897 +制作 zhi zuo zz 10869 +可惜 ke xi kx 10856 +享受 xiang shou xs 10815 +下班 xia ban xb 10802 +成了 cheng le cl 10802 +经营 jing ying jy 10802 +都要 dou yao dy 10775 +很快 hen kuai hk 10721 +结合 jie he jh 10721 +维护 wei hu wh 10721 +最大 zui da zd 10708 +身边 shen bian sb 10708 +作者 zuo zhe zz 10681 +南京 nan jing nj 10668 +否则 fou ze fz 10668 +股市 gu shi gs 10668 +母亲 mu qin mq 10642 +登录 deng lu dl 10628 +也会 ye hui yh 10615 +尽管 jin guan jg 10615 +最新 zui xin zx 10602 +加上 jia shang js 10602 +外面 wai mian wm 10602 +搜索 sou suo ss 10602 +有限公司 you xian gong si yxgs 10602 +本身 ben shen bs 10602 +问问 wen wen ww 10602 +听到 ting dao td 10589 +可爱 ke ai ka 10575 +利益 li yi ly 10562 +平时 ping shi ps 10562 +做好 zuo hao zh 10549 +很大 hen da hd 10549 +查询 cha xun cx 10536 +科技 ke ji kj 10536 +内部 nei bu nb 10523 +英语 ying yu yy 10523 +也要 ye yao yy 10510 +认真 ren zhen rz 10510 +哥哥 ge ge gg 10497 +完善 wan shan ws 10497 +宝贝 bao bei bb 10497 +很难 hen nan hn 10484 +杭州 hang zhou hz 10484 +计算 ji suan js 10484 +父亲 fu qin fq 10445 +访问 fang wen fw 10445 +我要 wo yao wy 10393 +因素 yin su ys 10367 +就像 jiu xiang jx 10367 +上午 shang wu sw 10354 +明确 ming que mq 10341 +真实 zhen shi zs 10328 +反映 fan ying fy 10315 +感受 gan shou gs 10315 +符合 fu he fh 10315 +美丽 mei li ml 10302 +越来越 yue lai yue yly 10289 +一边 yi bian yb 10277 +日子 ri zi rz 10277 +启动 qi dong qd 10251 +兴趣 xing qu xq 10238 +数据库 shu ju ku sjk 10238 +感谢 gan xie gx 10226 +打电话 da dian hua ddh 10226 +都不 dou bu db 10213 +国外 guo wai gw 10200 +往往 wang wang ww 10200 +我不 wo bu wb 10187 +特点 te dian td 10187 +必要 bi yao by 10162 +放弃 fang qi fq 10162 +熟悉 shu xi sx 10149 +社区 she qu sq 10149 +专门 zhuan men zm 10137 +计算机 ji suan ji jsj 10137 +政治 zheng zhi zz 10124 +一份 yi fen yf 10112 +电子 dian zi dz 10099 +会员 hui yuan hy 10086 +复杂 fu za fz 10086 +妹妹 mei mei mm 10074 +依然 yi ran yr 10061 +区域 qu yu qy 10061 +星期 xing qi xq 10061 +程度 cheng du cd 10061 +整体 zheng ti zt 10036 +唯一 wei yi wy 9999 +集中 ji zhong jz 9986 +投入 tou ru tr 9974 +对了 dui le dl 9961 +类型 lei xing lx 9961 +限制 xian zhi xz 9961 +帮我 bang wo bw 9937 +正确 zheng que zq 9937 +统计 tong ji tj 9937 +韩国 han guo hg 9937 +做了 zuo le zl 9924 +咱们 zan men zm 9900 +上市 shang shi ss 9887 +关心 guan xin gx 9887 +采取 cai qu cq 9863 +规划 gui hua gh 9850 +装备 zhuang bei zb 9838 +在线 zai xian zx 9814 +年轻 nian qing nq 9814 +爸爸 ba ba bb 9814 +很少 hen shao hs 9789 +不得 bu de bd 9777 +良好 liang hao lh 9753 +暂时 zan shi zs 9740 +沟通 gou tong gt 9740 +别的 bie de bd 9728 +成绩 cheng ji cj 9716 +帮忙 bang mang bm 9704 +最高 zui gao zg 9692 +双方 shuang fang sf 9692 +小心 xiao xin xx 9680 +数字 shu zi sz 9680 +类似 lei si ls 9680 +附近 fu jin fj 9680 +买了 mai le ml 9668 +各位 ge wei gw 9668 +来看 lai kan lk 9668 +点击 dian ji dj 9668 +人类 ren lei rl 9656 +失去 shi qu sq 9656 +女儿 nv er ne 9656 +举行 ju xing jx 9620 +人物 ren wu rw 9620 +是一种 shi yi zhong syz 9620 +成都 cheng du cd 9608 +提升 ti sheng ts 9608 +科学 ke xue kx 9608 +巨大 ju da jd 9584 +及其 ji qi jq 9572 +态度 tai du td 9572 +成立 cheng li cl 9572 +颜色 yan se ys 9560 +评价 ping jia pj 9536 +风格 feng ge fg 9513 +兄弟 xiong di xd 9477 +困难 kun nan kn 9453 +产业 chan ye cy 9442 +更多的 geng duo de gdd 9442 +没人 mei ren mr 9430 +教师 jiao shi js 9418 +连接 lian jie lj 9418 +中文 zhong wen zw 9371 +丰富 feng fu ff 9371 +值得 zhi de zd 9371 +反应 fan ying fy 9371 +概念 gai nian gn 9348 +请问 qing wen qw 9336 +当地 dang di dd 9325 +人的 ren de rd 9313 +有了 you le yl 9313 +观点 guan dian gd 9313 +面前 mian qian mq 9313 +配合 pei he ph 9302 +连续 lian xu lx 9290 +主题 zhu ti zt 9278 +独立 du li dl 9255 +那种 na zhong nz 9255 +中国人 zhong guo ren zgr 9244 +同志 tong zhi tz 9232 +代理 dai li dl 9221 +降低 jiang di jd 9221 +职业 zhi ye zy 9198 +从来 cong lai cl 9186 +评论 ping lun pl 9175 +即可 ji ke jk 9163 +相应 xiang ying xy 9163 +女生 nv sheng ns 9152 +月份 yue fen yf 9152 +经理 jing li jl 9152 +原则 yuan ze yz 9129 +性能 xing neng xn 9129 +综合 zong he zh 9129 +从而 cong er ce 9095 +当年 dang nian dn 9095 +改革 gai ge gg 9095 +英文 ying wen yw 9073 +每年 mei nian mn 9050 +痛苦 tong ku tk 9050 +艺术 yi shu ys 9050 +协议 xie yi xy 9039 +意识 yi shi ys 9039 +消费 xiao fei xf 8983 +英国 ying guo yg 8983 +节目 jie mu jm 8960 +上来 shang lai sl 8949 +商品 shang pin sp 8938 +还能 hai neng hn 8938 +那边 na bian nb 8927 +提示 ti shi ts 8905 +合理 he li hl 8893 +用于 yong yu yy 8882 +应当 ying dang yd 8871 +形象 xing xiang xx 8849 +等待 deng dai dd 8849 +行动 xing dong xd 8849 +最大的 zui da de zdd 8816 +干什么 gan shen me gsm 8816 +中间 zhong jian zj 8805 +它的 ta de td 8805 +来到 lai dao ld 8805 +理由 li you ly 8805 +背景 bei jing bj 8805 +就在 jiu zai jz 8794 +翻译 fan yi fy 8794 +事业 shi ye sy 8783 +发挥 fa hui fh 8783 +回到 hui dao hd 8783 +持续 chi xu cx 8783 +制定 zhi ding zd 8772 +老大 lao da ld 8772 +追求 zhui qiu zq 8772 +既然 ji ran jr 8761 +留下 liu xia lx 8761 +皮肤 pi fu pf 8761 +不好意思 bu hao yi si bhys 8751 +公布 gong bu gb 8751 +不太 bu tai bt 8729 +大部分 da bu fen dbf 8729 +办理 ban li bl 8718 +合适 he shi hs 8718 +就算 jiu suan js 8718 +成熟 cheng shu cs 8718 +多了 duo le dl 8707 +当前 dang qian dq 8707 +教授 jiao shou js 8707 +添加 tian jia tj 8707 +竞争 jing zheng jz 8707 +女性 nv xing nx 8664 +看过 kan guo kg 8653 +加油 jia you jy 8642 +不懂 bu dong bd 8621 +创新 chuang xin cx 8621 +旁边 pang bian pb 8621 +高兴 gao xing gx 8621 +内存 nei cun nc 8610 +辛苦 xin ku xk 8610 +见到 jian dao jd 8588 +使得 shi de sd 8578 +互联网 hu lian wang hlw 8567 +规范 gui fan gf 8567 +你要 ni yao ny 8546 +正是 zheng shi zs 8546 +重庆 chong qing cq 8546 +硬盘 ying pan yp 8535 +做到 zuo dao zd 8514 +攻击 gong ji gj 8514 +详细 xiang xi xx 8503 +天涯 tian ya ty 8482 +人才 ren cai rc 8461 +笔记本 bi ji ben bjb 8461 +可怜 ke lian kl 8450 +观众 guan zhong gz 8450 +走了 zou le zl 8450 +有时 you shi ys 8440 +又是 you shi ys 8429 +才是 cai shi cs 8429 +寻找 xun zhao xz 8419 +大盘 da pan dp 8398 +主动 zhu dong zd 8387 +或许 huo xu hx 8387 +指导 zhi dao zd 8377 +特殊 te shu ts 8377 +病毒 bing du bd 8377 +下降 xia jiang xj 8367 +改善 gai shan gs 8367 +迅速 xun su xs 8367 +比例 bi li bl 8356 +商业 shang ye sy 8346 +好久 hao jiu hj 8346 +措施 cuo shi cs 8335 +更是 geng shi gs 8325 +上去 shang qu sq 8315 +就能 jiu neng jn 8315 +房间 fang jian fj 8315 +明年 ming nian mn 8315 +经典 jing dian jd 8315 +优秀 you xiu yx 8304 +不去 bu qu bq 8294 +紧张 jin zhang jz 8294 +奇怪 qi guai qg 8273 +群众 qun zhong qz 8273 +有限 you xian yx 8263 +处于 chu yu cy 8253 +提前 ti qian tq 8253 +倒是 dao shi ds 8242 +投资者 tou zi zhe tzz 8242 +不足 bu zu bz 8232 +好吧 hao ba hb 8222 +的确 di que dq 8222 +伤害 shang hai sh 8212 +以来 yi lai yl 8201 +嘻嘻 xi xi xx 8201 +期待 qi dai qd 8201 +不可 bu ke bk 8191 +带着 dai zhe dz 8191 +规模 gui mo gm 8181 +表达 biao da bd 8161 +据说 ju shuo js 8140 +尽量 jin liang jl 8120 +并不是 bing bu shi bbs 8120 +酒店 jiu dian jd 8120 +接口 jie kou jk 8110 +美元 mei yuan my 8090 +角度 jiao du jd 8090 +动作 dong zuo dz 8080 +技能 ji neng jn 8080 +不了 bu liao bl 8070 +判断 pan duan pd 8070 +小说 xiao shuo xs 8070 +广东 guang dong gd 8060 +自身 zi shen zs 8060 +规则 gui ze gz 8050 +区别 qu bie qb 8040 +核心 he xin hx 8040 +德国 de guo dg 8029 +整理 zheng li zl 8029 +进来 jin lai jl 8029 +体现 ti xian tx 8019 +体系 ti xi tx 8019 +你说 ni shuo ns 8019 +手段 shou duan sd 8019 +轻松 qing song qs 8019 +参考 can kao ck 8010 +有时候 you shi hou ysh 8010 +至于 zhi yu zy 8010 +邮箱 you xiang yx 8010 +出口 chu kou ck 8000 +战略 zhan lue zl 8000 +掌握 zhang wo zw 8000 +放心 fang xin fx 7990 +帐号 zhang hao zh 7980 +一段 yi duan yd 7970 +好看 hao kan hk 7970 +让他 rang ta rt 7960 +创造 chuang zao cz 7950 +时期 shi qi sq 7950 +接触 jie chu jc 7930 +避免 bi mian bm 7930 +实行 shi xing sx 7920 +思考 si kao sk 7920 +变得 bian de bd 7910 +推广 tui guang tg 7910 +也没 ye mei ym 7900 +充分 chong fen cf 7891 +心中 xin zhong xz 7891 +自我 zi wo zw 7891 +召开 zhao kai zk 7881 +中央 zhong yang zy 7861 +土地 tu di td 7832 +格式 ge shi gs 7832 +留言 liu yan ly 7822 +站在 zhan zai zz 7822 +培养 pei yang py 7812 +距离 ju li jl 7812 +两人 liang ren lr 7803 +名称 ming cheng mc 7803 +欧洲 ou zhou oz 7803 +加班 jia ban jb 7793 +阳光 yang guang yg 7793 +吸引 xi yin xy 7783 +小姐 xiao jie xj 7773 +一句 yi ju yj 7764 +快速 kuai su ks 7764 +想起 xiang qi xq 7764 +我说 wo shuo ws 7754 +支付 zhi fu zf 7754 +强调 qiang diao qd 7744 +基本上 ji ben shang jbs 7735 +之类 zhi lei zl 7725 +武汉 wu han wh 7725 +一旦 yi dan yd 7716 +不在 bu zai bz 7716 +现代 xian dai xd 7716 +附件 fu jian fj 7716 +资产 zi chan zc 7706 +推进 tui jin tj 7696 +推动 tui dong td 7687 +理想 li xiang lx 7677 +人士 ren shi rs 7658 +飞机 fei ji fj 7658 +今日 jin ri jr 7649 +在于 zai yu zy 7649 +论文 lun wen lw 7649 +主任 zhu ren zr 7620 +保障 bao zhang bz 7620 +涉及 she ji sj 7620 +地位 di wei dw 7592 +此次 ci ci cc 7592 +网页 wang ye wy 7582 +给他 gei ta gt 7563 +忘了 wang le wl 7554 +编辑 bian ji bj 7554 +一场 yi chang yc 7544 +建筑 jian zhu jz 7544 +拒绝 ju jue jj 7544 +想想 xiang xiang xx 7535 +就行 jiu xing jx 7526 +小孩 xiao hai xh 7516 +指出 zhi chu zc 7516 +逐渐 zhu jian zj 7516 +团队 tuan dui td 7507 +尽快 jin kuai jk 7507 +一块 yi kuai yk 7498 +出了 chu le cl 7498 +利润 li run lr 7488 +文档 wen dang wd 7488 +民族 min zu mz 7488 +上涨 shang zhang sz 7479 +不大 bu da bd 7479 +你就 ni jiu nj 7479 +广大 guang da gd 7479 +强烈 qiang lie ql 7479 +承担 cheng dan cd 7479 +通常 tong chang tc 7479 +大小 da xiao dx 7470 +就不 jiu bu jb 7470 +就有 jiu you jy 7470 +干吗 gan ma gm 7470 +平均 ping jun pj 7470 +作出 zuo chu zc 7460 +各个 ge ge gg 7460 +公开 gong kai gk 7451 +突破 tu po tp 7451 +一片 yi pian yp 7442 +战士 zhan shi zs 7442 +赚钱 zhuan qian zq 7432 +有着 you zhe yz 7423 +舒服 shu fu sf 7423 +在家 zai jia zj 7405 +咨询 zi xun zx 7395 +完美 wan mei wm 7395 +法国 fa guo fg 7395 +大陆 da lu dl 7386 +不多 bu duo bd 7368 +充满 chong man cm 7368 +就好 jiu hao jh 7340 +正好 zheng hao zh 7340 +坐在 zuo zai zz 7331 +天气 tian qi tq 7331 +机制 ji zhi jz 7322 +患者 huan zhe hz 7313 +上述 shang shu ss 7304 +保存 bao cun bc 7304 +允许 yun xu yx 7304 +超级 chao ji cj 7304 +从事 cong shi cs 7295 +刺激 ci ji cj 7295 +状况 zhuang kuang zk 7277 +天津 tian jin tj 7268 +缺乏 que fa qf 7268 +负责人 fu ze ren fzr 7268 +做出 zuo chu zc 7259 +地图 di tu dt 7249 +基于 ji yu jy 7249 +如下 ru xia rx 7249 +宿舍 su she ss 7249 +老子 lao zi lz 7249 +怀疑 huai yi hy 7222 +为主 wei zhu wz 7204 +大型 da xing dx 7204 +强大 qiang da qd 7204 +很有 hen you hy 7204 +实力 shi li sl 7195 +拿到 na dao nd 7187 +流程 liu cheng lc 7187 +电信 dian xin dx 7187 +定义 ding yi dy 7178 +拍摄 pai she ps 7178 +难以 nan yi ny 7178 +两天 liang tian lt 7169 +今晚 jin wan jw 7169 +过了 guo le gl 7169 +这位 zhe wei zw 7151 +总结 zong jie zj 7142 +高速 gao su gs 7142 +开放 kai fang kf 7133 +重大 zhong da zd 7133 +观察 guan cha gc 7124 +赶紧 gan jin gj 7124 +表明 biao ming bm 7098 +提交 ti jiao tj 7080 +一只 yi zhi yz 7071 +微软 wei ruan wr 7062 +成员 cheng yuan cy 7062 +目录 mu lu ml 7062 +成长 cheng zhang cz 7053 +有空 you kong yk 7053 +矛盾 mao dun md 7045 +某些 mou xie mx 7036 +确保 que bao qb 7036 +实践 shi jian sj 7027 +相比 xiang bi xb 7027 +会不会 hui bu hui hbh 7010 +联合 lian he lh 7010 +晓得 xiao de xd 7001 +取消 qu xiao qx 6992 +权利 quan li ql 6983 +一半 yi ban yb 6975 +门口 men kou mk 6975 +发送 fa song fs 6966 +提醒 ti xing tx 6957 +一套 yi tao yt 6949 +参数 can shu cs 6949 +味道 wei dao wd 6949 +玩家 wan jia wj 6949 +角色 jue se js 6940 +上升 shang sheng ss 6931 +显得 xian de xd 6914 +深入 shen ru sr 6906 +适应 shi ying sy 6906 +高级 gao ji gj 6906 +想要 xiang yao xy 6888 +什么时候 shen me shi hou smsh 6871 +描述 miao shu ms 6871 +立即 li ji lj 6863 +路上 lu shang ls 6863 +损失 sun shi ss 6854 +训练 xun lian xl 6854 +重视 zhong shi zs 6854 +一名 yi ming ym 6846 +彻底 che di cd 6846 +令人 ling ren lr 6837 +印象 yin xiang yx 6829 +浪费 lang fei lf 6829 +学院 xue yuan xy 6820 +著名 zhu ming zm 6812 +固定 gu ding gd 6803 +扩大 kuo da kd 6803 +身份 shen fen sf 6803 +道理 dao li dl 6803 +商务 shang wu sw 6795 +实际上 shi ji shang sjs 6795 +一致 yi zhi yz 6786 +发出 fa chu fc 6786 +承认 cheng ren cr 6786 +展示 zhan shi zs 6778 +查看 cha kan ck 6769 +英雄 ying xiong yx 6752 +说了 shuo le sl 6752 +制造 zhi zao zz 6744 +消失 xiao shi xs 6744 +百度 bai du bd 6744 +相同 xiang tong xt 6744 +万元 wan yuan wy 6736 +之外 zhi wai zw 6736 +面试 mian shi ms 6736 +之中 zhi zhong zz 6710 +学会 xue hui xh 6710 +交通 jiao tong jt 6694 +仅仅 jin jin jj 6694 +警察 jing cha jc 6694 +特色 te se ts 6669 +跟我 gen wo gw 6669 +答案 da an da 6660 +表情 biao qing bq 6660 +最多 zui duo zd 6644 +清除 qing chu qc 6644 +读书 du shu ds 6636 +两年 liang nian ln 6627 +众多 zhong duo zd 6627 +感动 gan dong gd 6627 +女子 nv zi nz 6603 +收费 shou fei sf 6594 +不说 bu shuo bs 6586 +实验 shi yan sy 6586 +都能 dou neng dn 6586 +争取 zheng qu zq 6578 +找个 zhao ge zg 6578 +世纪 shi ji sj 6570 +洗澡 xi zao xz 6570 +高度 gao du gd 6570 +哪些 na xie nx 6562 +有所 you suo ys 6562 +组成 zu cheng zc 6553 +周围 zhou wei zw 6545 +太阳 tai yang ty 6545 +转载 zhuan zai zz 6545 +近期 jin qi jq 6537 +黑色 hei se hs 6537 +年龄 nian ling nl 6529 +面临 mian lin ml 6529 +性格 xing ge xg 6521 +满意 man yi my 6513 +趋势 qu shi qs 6496 +顺利 shun li sl 6496 +个月 ge yue gy 6488 +导演 dao yan dy 6488 +检测 jian ce jc 6488 +位于 wei yu wy 6480 +多个 duo ge dg 6480 +鼓励 gu li gl 6480 +上课 shang ke sk 6464 +始终 shi zhong sz 6464 +时尚 shi shang ss 6456 +为啥 wei sha ws 6448 +增强 zeng qiang zq 6448 +是我 shi wo sw 6448 +将会 jiang hui jh 6440 +女朋友 nv peng you npy 6432 +看法 kan fa kf 6432 +贷款 dai kuan dk 6432 +级别 ji bie jb 6416 +还会 hai hui hh 6416 +人民币 ren min bi rmb 6408 +情绪 qing xu qx 6408 +一声 yi sheng ys 6392 +打击 da ji dj 6392 +指标 zhi biao zb 6392 +楼上 lou shang ls 6392 +突出 tu chu tc 6392 +记忆 ji yi jy 6384 +不对 bu dui bd 6376 +具备 ju bei jb 6376 +宣布 xuan bu xb 6376 +很久 hen jiu hj 6376 +体验 ti yan ty 6368 +而言 er yan ey 6368 +农业 nong ye ny 6360 +禁止 jin zhi jz 6360 +一生 yi sheng ys 6344 +工业 gong ye gy 6337 +指定 zhi ding zd 6337 +对我 dui wo dw 6329 +模块 mo kuai mk 6321 +我还 wo hai wh 6313 +能否 neng fou nf 6305 +生气 sheng qi sq 6289 +头发 tou fa tf 6282 +对不起 dui bu qi dbq 6282 +收购 shou gou sg 6282 +生日 sheng ri sr 6282 +多次 duo ci dc 6266 +效率 xiao lv xl 6266 +老人 lao ren lr 6266 +严格 yan ge yg 6258 +定位 ding wei dw 6250 +补充 bu chong bc 6250 +队伍 dui wu dw 6250 +做的 zuo de zd 6242 +房价 fang jia fj 6242 +早点 zao dian zd 6242 +首次 shou ci sc 6242 +多年 duo nian dn 6235 +天下 tian xia tx 6235 +生意 sheng yi sy 6235 +市民 shi min sm 6227 +很多人 hen duo ren hdr 6227 +搞定 gao ding gd 6227 +欣赏 xin shang xs 6219 +死了 si le sl 6219 +回忆 hui yi hy 6211 +帮你 bang ni bn 6211 +采访 cai fang cf 6211 +完了 wan le wl 6196 +武器 wu qi wq 6196 +教学 jiao xue jx 6188 +危险 wei xian wx 6173 +常常 chang chang cc 6173 +把握 ba wo bw 6173 +课程 ke cheng kc 6173 +预计 yu ji yj 6173 +大学生 da xue sheng dxs 6165 +举办 ju ban jb 6157 +年代 nian dai nd 6157 +跟你 gen ni gn 6157 +果然 guo ran gr 6150 +话题 hua ti ht 6150 +将来 jiang lai jl 6142 +返回 fan hui fh 6142 +直到 zhi dao zd 6127 +晚安 wan an wa 6119 +信号 xin hao xh 6112 +而不是 er bu shi ebs 6104 +保险 bao xian bx 6096 +信心 xin xin xx 6096 +加大 jia da jd 6096 +多久 duo jiu dj 6096 +报价 bao jia bj 6096 +第三 di san ds 6096 +一是 yi shi ys 6089 +妻子 qi zi qz 6066 +都没 dou mei dm 6066 +仔细 zi xi zx 6059 +几点 ji dian jd 6059 +只好 zhi hao zh 6059 +明星 ming xing mx 6059 +界面 jie mian jm 6059 +运用 yun yong yy 6059 +梦想 meng xiang mx 6051 +最佳 zui jia zj 6043 +反而 fan er fe 6036 +半年 ban nian bn 6028 +完整 wan zheng wz 6028 +属性 shu xing sx 6028 +模型 mo xing mx 6028 +说法 shuo fa sf 6021 +透露 tou lu tl 6021 +女孩子 nv hai zi nhz 6013 +渠道 qu dao qd 6013 +画面 hua mian hm 6013 +分享 fen xiang fx 6006 +当初 dang chu dc 6006 +怎么办 zen me ban zmb 6006 +创建 chuang jian cj 5998 +回头 hui tou ht 5998 +家人 jia ren jr 5998 +报名 bao ming bm 5998 +讨厌 tao yan ty 5998 +动物 dong wu dw 5991 +昨晚 zuo wan zw 5991 +你在 ni zai nz 5983 +分配 fen pei fp 5976 +影片 ying pian yp 5976 +足够 zu gou zg 5976 +加快 jia kuai jk 5969 +部队 bu dui bd 5969 +有钱 you qian yq 5961 +高手 gao shou gs 5961 +专用 zhuan yong zy 5954 +普遍 pu bian pb 5954 +逐步 zhu bu zb 5954 +今后 jin hou jh 5946 +和谐 he xie hx 5946 +四川 si chuan sc 5946 +脸上 lian shang ls 5946 +无所谓 wu suo wei wsw 5939 +驱动 qu dong qd 5939 +随时 sui shi ss 5932 +一把 yi ba yb 5924 +广泛 guang fan gf 5924 +挑战 tiao zhan tz 5924 +快递 kuai di kd 5917 +死亡 si wang sw 5917 +伟大 wei da wd 5909 +上传 shang chuan sc 5902 +两次 liang ci lc 5902 +他说 ta shuo ts 5902 +发行 fa xing fx 5902 +组合 zu he zh 5895 +协调 xie tiao xt 5887 +展开 zhan kai zk 5887 +道路 dao lu dl 5887 +一件 yi jian yj 5880 +网吧 wang ba wb 5880 +阅读 yue du yd 5873 +开会 kai hui kh 5865 +浙江 zhe jiang zj 5865 +见面 jian mian jm 5865 +一部 yi bu yb 5858 +先进 xian jin xj 5851 +娱乐 yu le yl 5851 +杂志 za zhi zz 5843 +说过 shuo guo sg 5843 +防止 fang zhi fz 5843 +命令 ming ling ml 5836 +落实 luo shi ls 5836 +表演 biao yan by 5836 +表面 biao mian bm 5829 +较大 jiao da jd 5829 +邀请 yao qing yq 5829 +镜头 jing tou jt 5829 +共享 gong xiang gx 5822 +下周 xia zhou xz 5814 +大哥 da ge dg 5814 +小区 xiao qu xq 5814 +接近 jie jin jj 5814 +流行 liu xing lx 5814 +算是 suan shi ss 5814 +保留 bao liu bl 5807 +即将 ji jiang jj 5807 +热情 re qing rq 5807 +然而 ran er re 5807 +优化 you hua yh 5793 +尝试 chang shi cs 5793 +食品 shi pin sp 5793 +疯狂 feng kuang fk 5771 +半天 ban tian bt 5764 +采购 cai gou cg 5764 +作业 zuo ye zy 5757 +聪明 cong ming cm 5757 +那时 na shi ns 5757 +付款 fu kuan fk 5750 +奖励 jiang li jl 5750 +用来 yong lai yl 5750 +白色 bai se bs 5750 +精彩 jing cai jc 5750 +大大 da da dd 5742 +男生 nan sheng ns 5742 +简直 jian zhi jz 5742 +食物 shi wu sw 5742 +个性 ge xing gx 5735 +最好的 zui hao de zhd 5728 +心态 xin tai xt 5728 +所在 suo zai sz 5728 +看不到 kan bu dao kbd 5728 +至今 zhi jin zj 5728 +准确 zhun que zq 5721 +发言 fa yan fy 5721 +关闭 guan bi gb 5714 +必然 bi ran br 5714 +照顾 zhao gu zg 5714 +本地 ben di bd 5707 +美好 mei hao mh 5707 +转移 zhuan yi zy 5707 +出发 chu fa cf 5678 +现有 xian you xy 5678 +面积 mian ji mj 5671 +各类 ge lei gl 5664 +奥运 ao yun ay 5664 +批准 pi zhun pz 5664 +此时 ci shi cs 5664 +破坏 po huai ph 5664 +法师 fa shi fs 5657 +营销 ying xiao yx 5657 +贡献 gong xian gx 5657 +二是 er shi es 5650 +股份 gu fen gf 5650 +出差 chu chai cc 5643 +吃了 chi le cl 5643 +如今 ru jin rj 5643 +全市 quan shi qs 5636 +还不 hai bu hb 5636 +日期 ri qi rq 5629 +工作人员 gong zuo ren yuan gzry 5622 +要么 yao me ym 5622 +几年 ji nian jn 5615 +引用 yin yong yy 5615 +战争 zhan zheng zz 5615 +特征 te zheng tz 5615 +道德 dao de dd 5608 +什么样 shen me yang smy 5601 +金融 jin rong jr 5601 +冠军 guan jun gj 5594 +家长 jia zhang jz 5594 +动力 dong li dl 5580 +包含 bao han bh 5580 +球员 qiu yuan qy 5580 +不得不 bu de bu bdb 5573 +红色 hong se hs 5573 +细节 xi jie xj 5573 +年度 nian du nd 5566 +见过 jian guo jg 5566 +大多数 da duo shu dds 5559 +品种 pin zhong pz 5552 +没关系 mei guan xi mgx 5552 +更好 geng hao gh 5545 +看起来 kan qi lai kql 5545 +本次 ben ci bc 5539 +空调 kong tiao kt 5539 +反对 fan dui fd 5532 +委员会 wei yuan hui wyh 5532 +和你 he ni hn 5525 +我都 wo dou wd 5525 +公告 gong gao gg 5518 +写的 xie de xd 5518 +跟着 gen zhe gz 5518 +你也 ni ye ny 5511 +社会主义 she hui zhu yi shzy 5511 +策略 ce lue cl 5504 +做法 zuo fa zf 5497 +输出 shu chu sc 5497 +弟弟 di di dd 5490 +相互 xiang hu xh 5490 +分类 fen lei fl 5484 +多种 duo zhong dz 5484 +供应 gong ying gy 5477 +收益 shou yi sy 5477 +人口 ren kou rk 5470 +绿色 lv se ls 5470 +创业 chuang ye cy 5463 +多么 duo me dm 5463 +山东 shan dong sd 5456 +厂商 chang shang cs 5450 +屏幕 ping mu pm 5450 +无奈 wu nai wn 5450 +生成 sheng cheng sc 5443 +监督 jian du jd 5443 +称为 cheng wei cw 5443 +好象 hao xiang hx 5436 +显然 xian ran xr 5436 +到处 dao chu dc 5429 +对手 dui shou ds 5429 +平衡 ping heng ph 5429 +电池 dian chi dc 5429 +一会儿 yi hui er yhe 5422 +为何 wei he wh 5422 +打印 da yin dy 5422 +当中 dang zhong dz 5416 +高中 gao zhong gz 5416 +主席 zhu xi zx 5409 +互相 hu xiang hx 5409 +害怕 hai pa hp 5409 +俄罗斯 e luo si els 5402 +却是 que shi qs 5402 +想象 xiang xiang xx 5402 +但我 dan wo dw 5395 +各地 ge di gd 5395 +律师 lv shi ls 5395 +早就 zao jiu zj 5395 +窗口 chuang kou ck 5395 +读者 du zhe dz 5395 +两种 liang zhong lz 5389 +转换 zhuan huan zh 5389 +一项 yi xiang yx 5382 +装修 zhuang xiu zx 5382 +付出 fu chu fc 5369 +我去 wo qu wq 5369 +本站 ben zhan bz 5369 +激情 ji qing jq 5362 +进步 jin bu jb 5362 +例如 li ru lr 5355 +财务 cai wu cw 5355 +除非 chu fei cf 5355 +收藏 shou cang sc 5349 +整合 zheng he zh 5342 +白天 bai tian bt 5342 +石头 shi tou st 5342 +两个人 liang ge ren lgr 5335 +复制 fu zhi fz 5335 +内心 nei xin nx 5329 +哪儿 na er ne 5329 +构成 gou cheng gc 5322 +包装 bao zhuang bz 5315 +利于 li yu ly 5309 +放假 fang jia fj 5302 +淘宝 tao bao tb 5302 +究竟 jiu jing jj 5302 +体会 ti hui th 5296 +变态 bian tai bt 5296 +重复 chong fu cf 5296 +承诺 cheng nuo cn 5289 +男朋友 nan peng you npy 5289 +中华 zhong hua zh 5282 +印度 yin du yd 5282 +有事 you shi ys 5282 +被人 bei ren br 5282 +青年 qing nian qn 5276 +停止 ting zhi tz 5269 +太大 tai da td 5269 +干部 gan bu gb 5269 +物质 wu zhi wz 5269 +四个 si ge sg 5250 +好处 hao chu hc 5250 +官方 guan fang gf 5250 +歌曲 ge qu gq 5250 +车辆 che liang cl 5243 +异常 yi chang yc 5236 +顺便 shun bian sb 5236 +授权 shou quan sq 5230 +居民 ju min jm 5223 +签名 qian ming qm 5223 +力度 li du ld 5217 +预测 yu ce yc 5217 +业绩 ye ji yj 5210 +地说 de shuo ds 5210 +那天 na tian nt 5210 +函数 han shu hs 5204 +祝福 zhu fu zf 5204 +试验 shi yan sy 5204 +几次 ji ci jc 5197 +尊重 zun zhong zz 5197 +接着 jie zhe jz 5197 +日志 ri zhi rz 5197 +相机 xiang ji xj 5197 +标志 biao zhi bz 5191 +不算 bu suan bs 5185 +改进 gai jin gj 5185 +这款 zhe kuan zk 5185 +也能 ye neng yn 5178 +了吗 le ma lm 5178 +合法 he fa hf 5172 +挺好 ting hao th 5172 +上线 shang xian sx 5165 +不见 bu jian bj 5165 +他是 ta shi ts 5165 +后果 hou guo hg 5165 +房地产 fang di chan fdc 5165 +提到 ti dao td 5165 +硬件 ying jian yj 5159 +好友 hao you hy 5152 +引导 yin dao yd 5146 +等级 deng ji dj 5146 +行情 hang qing hq 5146 +这就 zhe jiu zj 5140 +一路 yi lu yl 5133 +病人 bing ren br 5133 +协会 xie hui xh 5127 +演员 yan yuan yy 5127 +碰到 peng dao pd 5127 +好玩 hao wan hw 5120 +发票 fa piao fp 5114 +联盟 lian meng lm 5114 +背后 bei hou bh 5114 +打造 da zao dz 5108 +温度 wen du wd 5108 +不怕 bu pa bp 5101 +案件 an jian aj 5101 +动态 dong tai dt 5095 +西安 xi an xa 5095 +儿童 er tong et 5089 +博士 bo shi bs 5089 +恶心 e xin ex 5089 +色彩 se cai sc 5089 +多少钱 duo shao qian dsq 5076 +说的 shuo de sd 5076 +深刻 shen ke sk 5070 +男子 nan zi nz 5070 +订单 ding dan dd 5070 +不但 bu dan bd 5063 +并非 bing fei bf 5063 +考生 kao sheng ks 5063 +获取 huo qu hq 5063 +就业 jiu ye jy 5057 +素质 su zhi sz 5057 +基地 ji di jd 5051 +小学 xiao xue xx 5051 +丈夫 zhang fu zf 5044 +三年 san nian sn 5044 +改造 gai zao gz 5044 +片子 pian zi pz 5044 +看你 kan ni kn 5044 +进口 jin kou jk 5044 +优惠 you hui yh 5038 +多大 duo da dd 5032 +成果 cheng guo cg 5032 +人体 ren ti rt 5019 +秘密 mi mi mm 5019 +一批 yi pi yp 5013 +意味着 yi wei zhe ywz 5013 +维持 wei chi wc 5013 +联想 lian xiang lx 5013 +进程 jin cheng jc 5013 +主板 zhu ban zb 5007 +权限 quan xian qx 5007 +预期 yu qi yq 5007 +本网 ben wang bw 5001 +要去 yao qu yq 5001 +亚洲 ya zhou yz 4988 +又有 you you yy 4988 +地点 di dian dd 4988 +才会 cai hui ch 4988 +工厂 gong chang gc 4982 +适当 shi dang sd 4976 +机场 ji chang jc 4969 +这几天 zhe ji tian zjt 4969 +金额 jin e je 4969 +副本 fu ben fb 4963 +显卡 xian ka xk 4963 +简历 jian li jl 4963 +轻轻 qing qing qq 4963 +探讨 tan tao tt 4957 +这么多 zhe me duo zmd 4957 +官员 guan yuan gy 4951 +依据 yi ju yj 4939 +偶尔 ou er oe 4939 +纷纷 fen fen ff 4939 +全新 quan xin qx 4932 +型号 xing hao xh 4932 +指数 zhi shu zs 4932 +立刻 li ke lk 4932 +一句话 yi ju hua yjh 4926 +我家 wo jia wj 4926 +排名 pai ming pm 4926 +法院 fa yuan fy 4926 +仿佛 fang fu ff 4920 +招聘 zhao pin zp 4920 +集体 ji ti jt 4920 +审核 shen he sh 4914 +来源 lai yuan ly 4914 +比我 bi wo bw 4914 +寂寞 ji mo jm 4908 +资本 zi ben zb 4902 +频道 pin dao pd 4902 +临时 lin shi ls 4890 +一看 yi kan yk 4883 +客人 ke ren kr 4883 +大约 da yue dy 4877 +空气 kong qi kq 4877 +无线 wu xian wx 4871 +要不要 yao bu yao yby 4871 +也在 ye zai yz 4865 +生存 sheng cun sc 4859 +没有什么 mei you shen me mysm 4853 +清晰 qing xi qx 4853 +独特 du te dt 4847 +评估 ping gu pg 4847 +岁的 sui de sd 4841 +管理员 guan li yuan gly 4841 +行政 xing zheng xz 4841 +剩下 sheng xia sx 4835 +资格 zi ge zg 4835 +伤心 shang xin sx 4829 +列表 lie biao lb 4829 +彼此 bi ci bc 4829 +情感 qing gan qg 4829 +报纸 bao zhi bz 4829 +劳动 lao dong ld 4823 +各项 ge xiang gx 4823 +在此 zai ci zc 4823 +播放 bo fang bf 4823 +苏州 su zhou sz 4823 +加工 jia gong jg 4817 +演出 yan chu yc 4817 +传播 chuan bo cb 4811 +实习 shi xi sx 4811 +开通 kai tong kt 4811 +手术 shou shu ss 4811 +北京市 bei jing shi bjs 4805 +或是 huo shi hs 4805 +难得 nan de nd 4805 +不久 bu jiu bj 4799 +人数 ren shu rs 4799 +最低 zui di zd 4799 +投诉 tou su ts 4799 +高校 gao xiao gx 4799 +魅力 mei li ml 4799 +机关 ji guan jg 4793 +某个 mou ge mg 4793 +球队 qiu dui qd 4793 +登记 deng ji dj 4793 +说是 shuo shi ss 4793 +除去 chu qu cq 4787 +收集 shou ji sj 4781 +厦门 xia men xm 4775 +黄金 huang jin hj 4775 +是指 shi zhi sz 4769 +理念 li nian ln 4769 +设立 she li sl 4769 +那就是 na jiu shi njs 4769 +下跌 xia die xd 4757 +和我 he wo hw 4757 +青岛 qing dao qd 4757 +文明 wen ming wm 4751 +专辑 zhuan ji zj 4745 +地震 di zhen dz 4745 +思路 si lu sl 4745 +答应 da ying dy 4745 +恐怖 kong bu kb 4740 +喝酒 he jiu hj 4734 +很高 hen gao hg 4734 +担任 dan ren dr 4734 +研发 yan fa yf 4734 +接到 jie dao jd 4722 +没法 mei fa mf 4722 +火车 huo che hc 4722 +这时 zhe shi zs 4722 +地上 di shang ds 4716 +对应 dui ying dy 4716 +起码 qi ma qm 4716 +锻炼 duan lian dl 4716 +验证 yan zheng yz 4716 +分手 fen shou fs 4710 +天空 tian kong tk 4710 +中学 zhong xue zx 4704 +出台 chu tai ct 4704 +把我 ba wo bw 4704 +西方 xi fang xf 4704 +将在 jiang zai jz 4698 +总体 zong ti zt 4698 +江苏 jiang su js 4693 +出生 chu sheng cs 4687 +备份 bei fen bf 4687 +账户 zhang hu zh 4687 +后悔 hou hui hh 4681 +婚姻 hun yin hy 4681 +每月 mei yue my 4681 +稍微 shao wei sw 4681 +老是 lao shi ls 4681 +各自 ge zi gz 4675 +财富 cai fu cf 4675 +全省 quan sheng qs 4669 +典型 dian xing dx 4669 +干净 gan jing gj 4669 +智慧 zhi hui zh 4669 +观念 guan nian gn 4663 +物品 wu pin wp 4652 +积分 ji fen jf 4652 +职工 zhi gong zg 4652 +请您 qing nin qn 4652 +广场 guang chang gc 4646 +插件 cha jian cj 4646 +搞笑 gao xiao gx 4646 +退出 tui chu tc 4646 +胜利 sheng li sl 4640 +逻辑 luo ji lj 4640 +每个人 mei ge ren mgr 4629 +键盘 jian pan jp 4629 +兴奋 xing fen xf 4623 +您好 nin hao nh 4623 +现金 xian jin xj 4623 +东方 dong fang df 4617 +则是 ze shi zs 4617 +大众 da zhong dz 4617 +输入法 shu ru fa srf 2665 +候选词 hou xuan ci hxc 500 +剪贴板 jian tie ban jtb 138 +供应商 gong ying shang gys 3398 +物料 wu liao wl 1226 +主管 zhu guan zg 3764 diff --git a/openless-all/app/android/frontend/components/AndroidPermissionsPanel.tsx b/openless-all/app/android/frontend/components/AndroidPermissionsPanel.tsx index 086f145ee..015a628fd 100644 --- a/openless-all/app/android/frontend/components/AndroidPermissionsPanel.tsx +++ b/openless-all/app/android/frontend/components/AndroidPermissionsPanel.tsx @@ -66,7 +66,12 @@ function persistAndroidOverlayPrefs(patch: Partial): Promise + Thread(task, "openless-english-candidate").apply { isDaemon = true } + } + + private class Node { + val children = HashMap() + // Filled in frequency-descending insertion order (see ensureLoaded()), + // so simply capping at NODE_TOP_N already keeps the top entries with + // no per-insert sort or eviction needed. + val topWords = ArrayList(NODE_TOP_N) + } + + private data class Entry(val word: String, val baseWeight: Int) + + private val root = Node() + private val indexedWords = HashSet() + // Words long-pressed away via forgetCustomWord() — the trie itself has + // no live-removal (only insertion; see insert()/walk()), so this is a + // cheap post-filter instead of rebuilding the index. Only ever touched + // from executor's thread, same as indexedWords/loaded. + private val excludedWords = HashSet() + private var loaded = false + + /** Off the caller's thread; posts [callback] back to the main looper. */ + fun queryTopN(prefix: String, limit: Int = 3, callback: (List) -> Unit) { + val normalized = prefix.trim().lowercase() + if (normalized.isEmpty()) { + callback(emptyList()) + return + } + executor.execute { + ensureLoaded() + val candidates = walk(normalized) + val ranked = candidates + .asSequence() + .filter { it.word !in excludedWords } + .sortedByDescending { it.baseWeight + userFrequency.score(it.word) } + .map { it.word } + .distinct() + .take(limit) + .toList() + Handler(Looper.getMainLooper()).post { callback(ranked) } + } + } + + /** True only for a word the user's own typing added (see recordCommit()) — the bundled base dictionary isn't user-removable. */ + fun isCustomWord(word: String): Boolean = userFrequency.hasCustomWord(word) + + /** + * "Forget" a word this keyboard only knows because the user typed it + * once (long-press on an English candidate — see + * OpenLessImeService.forgetEnglishCandidate()). Un-marks it as custom + * (so a future cold start's ensureLoaded() won't re-index it) and hides + * it from this session's own suggestions immediately, without needing + * to rebuild the trie. + */ + fun forgetCustomWord(word: String) { + val normalized = word.trim().lowercase() + if (normalized.isEmpty()) return + executor.execute { + userFrequency.removeCustomWord(normalized) + excludedWords.add(normalized) + } + } + + /** + * Call once a word is actually committed (typed to completion and + * followed by a boundary, or tapped from the candidate bar) — boosts its + * ranking for next time, and — if it wasn't already indexed at all — + * inserts it as an implicit custom word so future prefixes of it can + * find it, which is what makes "user custom words" work without a + * separate explicit add step. + */ + fun recordCommit(word: String) { + val normalized = word.trim().lowercase() + if (normalized.length < 2) return + executor.execute { + ensureLoaded() + userFrequency.record(normalized) + if (normalized !in indexedWords) { + userFrequency.addCustomWord(normalized) + insert(normalized, CUSTOM_WORD_BASE_WEIGHT) + } + } + } + + fun shutdown() = executor.shutdownNow() + + private fun ensureLoaded() { + if (loaded) return + synchronized(this) { + if (loaded) return + runCatching { + appContext.assets.open("english-frequency.tsv").bufferedReader().useLines { lines -> + lines.forEach { line -> + val parts = line.split('\t', limit = 2) + val word = parts.getOrNull(0) ?: return@forEach + val weight = parts.getOrNull(1)?.toIntOrNull() ?: 0 + // Frequencies in the source file span many orders of + // magnitude (millions down to hundreds); dividing + // into log-ish buckets keeps the ranking formula's + // baseWeight comparable in scale to userFrequency's + // own score() range instead of always drowning it + // out for the small handful of extremely common words. + if (word.isNotEmpty() && weight > 0) insert(word, bucketWeight(weight)) + } + } + } + // User-added custom words from a previous session need to be + // re-indexed on every fresh load too, not just remembered as a + // set — the trie itself is rebuilt from scratch each process. + runCatching { + userFrequency.customWords().forEach { word -> + if (word !in indexedWords) insert(word, CUSTOM_WORD_BASE_WEIGHT) + } + } + loaded = true + } + } + + private fun insert(word: String, baseWeight: Int) { + if (!indexedWords.add(word)) return + val entry = Entry(word, baseWeight) + var node = root + for (character in word) { + node = node.children.getOrPut(character) { Node() } + if (node.topWords.size < NODE_TOP_N) node.topWords += entry + } + } + + private fun walk(prefix: String): List { + var node = root + for (character in prefix) { + node = node.children[character] ?: return emptyList() + } + return node.topWords + } + + /** Roughly log-scaled: millions -> ~140, thousands -> ~90, hundreds -> ~60. */ + private fun bucketWeight(rawCount: Int): Int = (kotlin.math.ln(rawCount.toDouble()) * 10).toInt() + + private companion object { + const val NODE_TOP_N = 20 + const val CUSTOM_WORD_BASE_WEIGHT = 70 + } +} diff --git a/openless-all/app/android/kotlin/EnglishUserFrequency.kt b/openless-all/app/android/kotlin/EnglishUserFrequency.kt new file mode 100644 index 000000000..ce9b85b5f --- /dev/null +++ b/openless-all/app/android/kotlin/EnglishUserFrequency.kt @@ -0,0 +1,126 @@ +package com.openless.app + +import android.content.Context +import kotlin.math.exp +import kotlin.math.ln + +/** + * Local-only English word usage store — same shape and scoring formula as + * StrokeUserFrequency (log-scaled hit count + exponential recency decay), + * just without that class's per-context keying, since a typed word's + * ranking here doesn't depend on what preceded it. + * + * Also holds the small, separate set of explicitly user-known words that + * aren't in the bundled base dictionary at all (recordCommit() adds a word + * here the first time it's committed if EnglishCandidateProvider doesn't + * already index it — see that class) — this is the data layer for + * "user custom words" (add/delete/exists/frequency/last-used). Delete is + * wired up as a long-press on an English candidate — see + * EnglishCandidateProvider.forgetCustomWord()/isCustomWord() and + * OpenLessImeService.forgetEnglishCandidate(). + */ +internal class EnglishUserFrequency(context: Context) { + private val preferences = context.applicationContext.getSharedPreferences(STORE, Context.MODE_PRIVATE) + private val customWordsPrefs = context.applicationContext.getSharedPreferences(CUSTOM_STORE, Context.MODE_PRIVATE) + + // Tracks entry count without paying preferences.all's full-map-copy cost + // on every record() — see trimIfNeeded(). -1 means "not yet known", + // resolved (once) the first time it's actually needed. + @Volatile + private var approxSize = -1 + + fun score(word: String, now: Long = System.currentTimeMillis()): Double { + val value = preferences.getString(key(word), null) ?: return 0.0 + val parts = value.split(',') + val count = parts.getOrNull(0)?.toDoubleOrNull() ?: return 0.0 + val last = parts.getOrNull(1)?.toLongOrNull() ?: return 0.0 + val ageDays = ((now - last).coerceAtLeast(0L)).toDouble() / MILLIS_PER_DAY + return ln(1.0 + count) * COUNT_WEIGHT + exp(-ageDays / RECENCY_HALF_LIFE_DAYS) * RECENCY_WEIGHT + } + + fun record(word: String) { + if (word.isBlank()) return + val entryKey = key(word) + val isNewEntry = !preferences.contains(entryKey) + val old = preferences.getString(entryKey, null)?.split(',') + val count = (old?.getOrNull(0)?.toIntOrNull() ?: 0) + 1 + val now = System.currentTimeMillis() + preferences.edit().putString(entryKey, "$count,$now").apply() + // Re-recording an existing word (by far the common case) never + // touches preferences.all at all now — only a genuinely new word + // can push the count past MAX_ENTRIES, so that's the only case + // worth checking. + if (isNewEntry) { + val size = resolvedSize() + 1 + approxSize = size + if (size > MAX_ENTRIES) trimIfNeeded() + } + } + + fun clear() { + preferences.edit().clear().apply() + approxSize = 0 + } + + fun size(): Int = resolvedSize() + + fun capacity(): Int = MAX_ENTRIES + + private fun resolvedSize(): Int { + val cached = approxSize + if (cached >= 0) return cached + val size = preferences.all.size + approxSize = size + return size + } + + // --- User custom words (section 十二: Add/Delete/Exists/Frequency/LastUsed) --- + // Frequency/LastUsed for a custom word are the same record()/score() above, + // keyed by the same normalized word — a custom word is simply a word this + // set says should be indexed even though the base dictionary doesn't have it. + + fun addCustomWord(rawWord: String): Boolean { + val word = normalize(rawWord) + if (word.isEmpty() || word.length > MAX_CUSTOM_WORD_LENGTH) return false + if (customWordsPrefs.contains(word)) return false + customWordsPrefs.edit().putBoolean(word, true).apply() + return true + } + + fun removeCustomWord(rawWord: String) { + customWordsPrefs.edit().remove(normalize(rawWord)).apply() + } + + fun hasCustomWord(rawWord: String): Boolean = customWordsPrefs.contains(normalize(rawWord)) + + fun customWords(): Set = customWordsPrefs.all.keys + + private fun normalize(word: String): String = word.trim().lowercase() + + private fun key(word: String) = normalize(word) + + private fun trimIfNeeded() { + val all = preferences.all + approxSize = all.size + if (all.size <= MAX_ENTRIES) return + val removeCount = all.size - MAX_ENTRIES + val oldest = all.entries + .sortedBy { it.value.toString().substringAfterLast(',').toLongOrNull() ?: 0L } + .take(removeCount) + preferences.edit().apply { + oldest.forEach { remove(it.key) } + }.apply() + approxSize = all.size - oldest.size + } + + private companion object { + const val STORE = "openless_english_frequency" + const val CUSTOM_STORE = "openless_english_custom_words" + const val MAX_ENTRIES = 3000 + const val MAX_CUSTOM_WORD_LENGTH = 40 + const val MILLIS_PER_DAY = 86_400_000L + const val RECENCY_HALF_LIFE_DAYS = 30.0 + const val COUNT_WEIGHT = 80.0 + const val RECENCY_WEIGHT = 24.0 + } +} diff --git a/openless-all/app/android/kotlin/ImeLearningPolicy.kt b/openless-all/app/android/kotlin/ImeLearningPolicy.kt new file mode 100644 index 000000000..b7eac23c4 --- /dev/null +++ b/openless-all/app/android/kotlin/ImeLearningPolicy.kt @@ -0,0 +1,34 @@ +package com.openless.app + +import android.content.Context +import android.text.InputType +import android.view.inputmethod.EditorInfo + +internal object ImeLearningPolicy { + const val PRIVACY_VERSION = 1 + private val stores = listOf("openless_english_frequency", "openless_english_custom_words", + "openless_pinyin_frequency", "openless_pinyin_learned_phrases") + + fun isPassword(inputType: Int): Boolean { + val kind = inputType and InputType.TYPE_MASK_CLASS + val variation = inputType and InputType.TYPE_MASK_VARIATION + return (kind == InputType.TYPE_CLASS_TEXT && variation in listOf( + InputType.TYPE_TEXT_VARIATION_PASSWORD, InputType.TYPE_TEXT_VARIATION_WEB_PASSWORD, + InputType.TYPE_TEXT_VARIATION_VISIBLE_PASSWORD)) || + (kind == InputType.TYPE_CLASS_NUMBER && variation == InputType.TYPE_NUMBER_VARIATION_PASSWORD) + } + + fun allowsLearning(inputType: Int, imeOptions: Int): Boolean = + inputType != InputType.TYPE_NULL && !isPassword(inputType) && + imeOptions and EditorInfo.IME_FLAG_NO_PERSONALIZED_LEARNING == 0 + + /** Runs before any candidate executor starts; a failed clear must be retried. */ + fun migrate(context: Context): Boolean = runCatching { + val marker = context.getSharedPreferences("openless_learning_privacy", Context.MODE_PRIVATE) + if (marker.getInt("version", 0) >= PRIVACY_VERSION) return true + for (name in stores) { + if (!context.getSharedPreferences(name, Context.MODE_PRIVATE).edit().clear().commit()) return false + } + marker.edit().putInt("version", PRIVACY_VERSION).commit() + }.getOrDefault(false) +} diff --git a/openless-all/app/android/kotlin/LitePinyinController.kt b/openless-all/app/android/kotlin/LitePinyinController.kt new file mode 100644 index 000000000..f884f4a58 --- /dev/null +++ b/openless-all/app/android/kotlin/LitePinyinController.kt @@ -0,0 +1,186 @@ +package com.openless.app + +import android.content.Context + +/** + * Owns the Pinyin-mode encoding buffer AND its candidate query pipeline on + * the English keyboard panel (see OpenLessImeService.LatinInputMode) — + * phases 3+4 of the lite-pinyin plan (docs/pinyin-lite/phase-0-audit.md): + * single-character full-pinyin lookup merged with high-frequency + * abbreviation-phrase lookup (LitePinyinRepository.query() does the actual + * merging/ranking; this class just owns the buffer and epoch around it). + * + * Deliberately does not touch currentInputConnection or any View — callers + * get results through the [onCandidates] callback and decide what to do + * with them (render a row, commit a character, etc.) — see the lite-pinyin + * plan's 5.2 ("不让 View 直接查询词库或持有 Repository"). + */ +internal class LitePinyinController(context: Context) { + private val repository = LitePinyinRepository(context) + private val encoding = StringBuilder() + private var learningAllowed = false + fun setLearningAllowed(allowed: Boolean) { + learningAllowed = allowed + clear() + resetAssociationContext() + } + fun shutdown() = repository.shutdown() + + // --- Two-step combo learning (见 LitePinyinLearnedPhrases 的文档注释) --- + // "上次直接打拼音上屏的是什么" — separate from confirmedText above + // (which accumulates without bound until a panel/session boundary): + // this only ever remembers the ONE immediately-preceding commit, and is + // cleared at the same boundaries so a chain never spans a mode switch. + private var lastCommitEncoding: String? = null + private var lastCommitText: String? = null + + // Independent from englishCandidateQueryEpoch/strokeQueryEpoch/ + // phraseQueryEpoch by design (plan 8.3: "不得复用笔画 epoch 变量,避免 + // 不同输入模式互相影响") — bumped on every state change so a slow + // background query for an encoding the user has since edited or + // cleared can never overwrite what's on screen now. + private var queryEpoch = 0L + + fun currentEncoding(): String = encoding.toString() + + fun isEmpty(): Boolean = encoding.isEmpty() + + fun preloadAsync() = repository.preloadAsync() + + /** + * Record frequency under the candidate's real sourceKey, then drop only + * that source encoding from the buffer (full clear when the pick matched + * the whole buffer; first-syllable consume when it matched the leading + * syllable — including multi-character phrases keyed on that syllable). + */ + fun commitSelection(text: String, onCandidates: (List) -> Unit) { + val currentEncoding = encoding.toString() + val sourceKey = repository.resolveSourceKey(currentEncoding, text) + if (learningAllowed) repository.recordSelection(sourceKey, text) + if (sourceKey.length >= currentEncoding.length) { + clear() + onCandidates(emptyList()) + return + } + encoding.delete(0, sourceKey.length) + query(onCandidates) + } + + /** + * Call with the encoding still intact — i.e. before clear() — for every + * DIRECT pinyin/abbreviation commit (not an association pick — see + * OpenLessImeService.selectPinyinAssociation(), which doesn't call this + * in this first version). If this immediately follows another such + * commit (nothing else broke the chain — see resetAssociationContext() + * for what counts as "broke"), records the two-step combo with + * LitePinyinLearnedPhrases so a real habit (not a one-off) eventually + * surfaces on its own — see that class's own doc comment. + */ + fun observeCommitForLearning(text: String) { + if (!learningAllowed) return + val currentEncoding = encoding.toString() + // A single full-pinyin syllable ("guo") contributes only its first + // letter, matching how a real abbreviation is built one letter per + // character — an already-abbreviation-shaped encoding ("zg", one + // letter per character in `text`) is used as-is. + val abbreviation = if (currentEncoding.length == text.length) currentEncoding else currentEncoding.take(1) + val previousAbbreviation = lastCommitEncoding + val previousText = lastCommitText + if (previousAbbreviation != null && previousText != null) { + repository.observeSequence(previousAbbreviation + abbreviation, previousText + text) + } + lastCommitEncoding = abbreviation + lastCommitText = text + } + + /** Letters only — OpenLessImeService is responsible for routing non-letter keys elsewhere. */ + fun appendLetter(char: Char, onCandidates: (List) -> Unit) { + if (!char.isLetter()) return + encoding.append(char.lowercaseChar()) + query(onCandidates) + } + + /** @return true if a character was actually removed (false when the encoding was already empty, meaning the caller should fall back to normal backspace). */ + fun backspace(onCandidates: (List) -> Unit): Boolean { + if (encoding.isEmpty()) return false + encoding.deleteCharAt(encoding.length - 1) + query(onCandidates) + return true + } + + /** Consume only the first valid syllable, then query the remaining buffer. */ + fun consumeFirstSyllable(onCandidates: (List) -> Unit): Boolean { + if (encoding.isEmpty()) return false + val firstSyllable = repository.segmentEncoding(encoding.toString()).firstOrNull() + val consumeLength = firstSyllable?.length ?: encoding.length + encoding.delete(0, consumeLength) + query(onCandidates) + return true + } + + /** Mode switch, panel switch, new input session, or a space press — never carries a half-typed encoding across any of these (plan 3.2 point 8/9). Deliberately does NOT touch the association context below — that's meant to survive individual commits/mode toggles, only reset at true panel-switch/session boundaries (see resetAssociationContext()). */ + fun clear() { + encoding.clear() + queryEpoch++ + } + + // --- Post-commit association (取最后几个字联想下一个词,与笔画面板共用同一份联想词库+调频) --- + // Deliberately NOT the same buffer/epoch as the encoding above: this one + // survives every individual candidate commit (that's the entire point — + // it's what lets one word's commit suggest the next), only reset at + // panel-switch/new-session boundaries, mirroring + // StrokeInputController's own confirmedText exactly (same + // MAX_ASSOCIATION_CONTEXT cap, cleared at the same two call sites: + // selectInputMode()'s mode-switch reset and onStartInput()'s session + // reset — see resetAssociationContext()). + private val confirmedText = StringBuilder() + private var associationEpoch = 0L + + /** Feeds just-committed on-screen text into the association context — call after every pinyin commit (a fresh candidate or an association suffix alike). */ + fun recordCommittedText(text: String) { + if (!learningAllowed) return + confirmedText.append(text) + val overflow = confirmedText.length - StrokeInputController.MAX_ASSOCIATION_CONTEXT + if (overflow > 0) confirmedText.delete(0, overflow) + } + + /** + * Association candidates for whatever's currently in the shared + * context. [phraseRepository] is passed in rather than owned here — the + * caller hands over StrokeInputController's own instance (see that + * class's phraseRepository doc comment for why this is the SAME + * ~220k-entry index Stroke mode uses, not a second copy), so usage + * recorded from either input mode benefits both. + */ + fun queryAssociations(phraseRepository: StrokePhraseRepository, onResults: (List) -> Unit) { + val context = confirmedText.toString() + if (context.isEmpty()) { + onResults(emptyList()) + return + } + val epoch = ++associationEpoch + phraseRepository.searchAsync(context) { result -> + if (epoch == associationEpoch) onResults(result) + } + } + + /** Panel switch or new input session only — an individual commit must NOT call this (see this section's own doc comment above). Also breaks the two-step combo-learning chain (lastCommitEncoding/Text) for the same reason. */ + fun resetAssociationContext() { + confirmedText.clear() + associationEpoch++ + lastCommitEncoding = null + lastCommitText = null + } + + private fun query(onCandidates: (List) -> Unit) { + if (encoding.isEmpty()) { + queryEpoch++ + onCandidates(emptyList()) + return + } + val epoch = ++queryEpoch + repository.query(encoding.toString(), personalize = learningAllowed) { results -> + if (epoch == queryEpoch) onCandidates(results) + } + } +} diff --git a/openless-all/app/android/kotlin/LitePinyinLearnedPhrases.kt b/openless-all/app/android/kotlin/LitePinyinLearnedPhrases.kt new file mode 100644 index 000000000..46ac7e89e --- /dev/null +++ b/openless-all/app/android/kotlin/LitePinyinLearnedPhrases.kt @@ -0,0 +1,73 @@ +package com.openless.app + +import android.content.Context + +/** + * Learns a multi-character "combo" phrase purely from the user's own + * consecutive typing pattern — no manual whitelist entry required. If the + * user types encoding "zg" and picks 中国, then immediately types "rm" and + * picks 人民, this records the pair (combined encoding "zgrm" -> combined + * text 中国人民) with a hit count; once that exact combo has been observed + * [PROMOTION_THRESHOLD] times, it "graduates" — LitePinyinRepository.query() + * starts surfacing it (pinned first) whenever the user types "zgrm" from + * scratch, the same effect a manually-curated whitelist entry would have, + * except it only ever contains combos this specific user actually built up + * themselves. + * + * Deliberately its own small store, not folded into LitePinyinUserFrequency + * — that one ranks/reorders candidates already present in the static + * dictionary; this one's job is to conjure a candidate that was NEVER in + * the dictionary at all, gated by a hit-count threshold rather than a + * continuous score. + */ +internal class LitePinyinLearnedPhrases(context: Context) { + private val preferences = context.applicationContext.getSharedPreferences(STORE, Context.MODE_PRIVATE) + + /** + * Call once per observed two-step sequence (see + * LitePinyinController.observeCommitForLearning()). Only overwrites the + * stored hit count when [text] matches what's already stored for + * [combinedEncoding] — a different text combo landing on the same + * encoding starts its own count from zero rather than inheriting an + * unrelated combo's progress. + */ + fun observeSequence(combinedEncoding: String, text: String) { + if (combinedEncoding.length < 2 || text.length < 2) return + val existing = preferences.getString(combinedEncoding, null)?.split(',') + val count = if (existing?.getOrNull(0) == text) (existing.getOrNull(1)?.toIntOrNull() ?: 0) + 1 else 1 + preferences.edit().putString(combinedEncoding, "$text,$count").apply() + } + + /** The learned text for this exact encoding, only once it has crossed the promotion threshold — null otherwise (including "seen once or twice but not yet promoted", which is deliberately invisible to callers). */ + fun promoted(encoding: String): String? { + val stored = preferences.getString(encoding, null)?.split(',') ?: return null + val text = stored.getOrNull(0) ?: return null + val count = stored.getOrNull(1)?.toIntOrNull() ?: 0 + return text.takeIf { count >= PROMOTION_THRESHOLD } + } + + /** How many combos have crossed the promotion threshold and are actively surfacing — for the settings page's "简拼优选" count (see OpenLessKeyboardSettingsActivity). */ + fun promotedCount(): Int = preferences.all.values.count { value -> + val count = (value as? String)?.split(',')?.getOrNull(1)?.toIntOrNull() ?: 0 + count >= PROMOTION_THRESHOLD + } + + /** Every stored combo (promoted or not) as a plain encoding->"text,count" map, for the settings page's export/import feature — see OpenLessSettingsExport. */ + fun exportAll(): Map { + @Suppress("UNCHECKED_CAST") + return preferences.all as Map + } + + /** Replaces the entire store with [entries] — an import wholesale-restores a backup rather than merging with whatever's already on this device. */ + fun importAll(entries: Map) { + check(preferences.edit().clear().apply { entries.forEach { (key, value) -> putString(key, value) } }.commit()) { "Pinyin learning save failed" } + } + + private companion object { + const val STORE = "openless_pinyin_learned_phrases" + // Chosen so one coincidental repeat (2 total) doesn't graduate a + // combo, but a real habit (3 total) does — easy to retune, not + // derived from any measurement. + const val PROMOTION_THRESHOLD = 3 + } +} diff --git a/openless-all/app/android/kotlin/LitePinyinRepository.kt b/openless-all/app/android/kotlin/LitePinyinRepository.kt new file mode 100644 index 000000000..24bcd7d85 --- /dev/null +++ b/openless-all/app/android/kotlin/LitePinyinRepository.kt @@ -0,0 +1,265 @@ +package com.openless.app + +import android.content.Context +import android.os.Handler +import android.os.Looper +import java.util.LinkedHashMap +import java.util.concurrent.Executors + +/** + * Full-pinyin single-character AND high-frequency-abbreviation lookup for + * Pinyin mode (phases 3-5 of the lite-pinyin plan — + * docs/pinyin-lite/phase-0-audit.md). Same background-load-off-a-dedicated- + * thread shape as EnglishCandidateProvider/StrokePhraseRepository, but plain + * exact-match Maps instead of a trie — ~5000 characters and ~2000 phrases is + * small enough that a prefix index buys nothing (plan 8.1: "当前规模下无需 + * SQLite,也无需大型 Trie"). + * + * Reads two android/assets files, each one row per entry: + * pinyin_chars.tsv: characterpinyinweight + * pinyin_phrases.tsv: phrasefull_pinyinabbreviationweight + * (see scripts/generate-pinyin-characters.mjs / generate-pinyin-phrases.mjs + * and their .LICENSE.txt files for where these come from). + */ +internal class LitePinyinRepository(context: Context) { + private val appContext = context.applicationContext + private val userFrequency = LitePinyinUserFrequency(context) + private val learnedPhrases = LitePinyinLearnedPhrases(context) + private val executor = Executors.newSingleThreadExecutor { task -> + Thread(task, "openless-pinyin-candidate").apply { isDaemon = true } + } + + private data class Entry(val text: String, val weight: Int) + + // pinyin -> its characters; abbreviation -> its phrases. Each list is + // sorted by weight descending once at load time (mirrors + // EnglishCandidateProvider's Node.topWords: sort once on load, not per + // query). + private val charIndex = HashMap>() + private val syllableSet = HashSet() + private val abbreviationIndex = HashMap>() + + // Caches only the STATIC merge (char entries then phrase entries, each + // already weight-sorted) — same split as StrokePhraseRepository's own + // cache: the user-frequency re-rank below is cheap (at most ~40 + // entries) and depends on data that changes independently of this, so + // it's deliberately redone on every query rather than cached, which + // sidesteps plan 8.2's "用户词频变化后,使相关缓存失效" requirement + // entirely instead of having to implement invalidation. + private val mergeCache = object : LinkedHashMap>(CACHE_SIZE, .75f, true) { + override fun removeEldestEntry(eldest: MutableMap.MutableEntry>?) = size > CACHE_SIZE + } + + @Volatile + private var loaded = false + + /** + * Off the caller's thread; posts [callback] back to the main looper. + * Exact full-key matches are followed by the first syllable's matches + * when the encoding contains multiple syllables. Ranks by + * plan 3.5's five tiers, in order: + * 0. a personally-learned two-commit combo for this exact encoding + * (LitePinyinLearnedPhrases — see observeSequence()) — not from + * the static dictionary at all, so it's pinned first outright + * rather than woven into the weight-based tiers below. + * 1. a candidate the user picked before for this exact encoding + * (LitePinyinUserFrequency — see recordSelection()) + * 2. exact single-character full-pinyin matches + * 3. exact abbreviation-phrase matches (only once the encoding is + * >= 2 letters — plan 3.4) + * 4/5. each tier's own static weight / original order + * Tiers 2 and 3 are never weight-interleaved with each other even + * though pinyin_chars.tsv and pinyin_phrases.tsv share a comparable + * weight scale (both ultimately from rime-pinyin-simp) — a phrase + * never outranks a character on raw weight alone, only via tier 0/1. + */ + fun query(encoding: String, limit: Int = 20, personalize: Boolean = true, callback: (List) -> Unit) { + val normalized = encoding.trim().lowercase() + if (normalized.isEmpty()) { + callback(emptyList()) + return + } + executor.execute { + ensureLoaded() + val raw = candidateEntries(normalized) + fun rankEntries(entries: List): List { + val previouslySelected = entries + .filter { personalize && userFrequency.score(it.sourceKey, it.entry.text) > 0.0 } + .sortedByDescending { userFrequency.score(it.sourceKey, it.entry.text) } + val rest = entries.filterNot { entry -> + previouslySelected.any { it.entry.text == entry.entry.text } + } + return (previouslySelected + rest).map { it.entry.text } + } + var ranked = ( + rankEntries(raw.filter { it.sourceKey == normalized }) + + rankEntries(raw.filter { it.sourceKey != normalized }) + ).distinct() + if (personalize) learnedPhrases.promoted(normalized)?.let { learned -> ranked = listOf(learned) + ranked.filterNot { it == learned } } + Handler(Looper.getMainLooper()).post { callback(ranked.take(limit)) } + } + } + + /** Greedy left-to-right segmentation using the loaded single-character syllables. */ + fun segmentEncoding(encoding: String): List { + ensureLoaded() + val normalized = encoding.trim().lowercase() + if (normalized.isEmpty() || syllableSet.isEmpty()) return emptyList() + val segments = mutableListOf() + var offset = 0 + while (offset < normalized.length) { + val maxLength = minOf(MAX_SYLLABLE_LENGTH, normalized.length - offset) + var match: String? = null + for (length in maxLength downTo 1) { + val candidate = normalized.substring(offset, offset + length) + if (syllableSet.contains(candidate)) { + match = candidate + break + } + } + if (match == null) break + segments += match + offset += match.length + } + return segments + } + + /** Call once a candidate is actually committed (a candidate tap — see OpenLessImeService.selectPinyinCandidate()) — off the caller's thread. */ + fun recordSelection(encoding: String, text: String) { + val normalized = encoding.trim().lowercase() + if (normalized.isEmpty() || text.isEmpty()) return + executor.execute { userFrequency.record(normalized, text) } + } + + /** + * Which encoding key produced [text] for the current buffer — mirrors + * [candidateEntries] priority (exact full encoding first, then first + * syllable). Learned / unknown picks fall back to the full encoding. + */ + fun resolveSourceKey(encoding: String, text: String): String { + ensureLoaded() + val normalized = encoding.trim().lowercase() + if (normalized.isEmpty() || text.isEmpty()) return normalized + if (mergedEntries(normalized).any { it.text == text }) return normalized + if (learnedPhrases.promoted(normalized) == text) return normalized + val firstSyllable = segmentEncoding(normalized).firstOrNull() + if ( + firstSyllable != null && + firstSyllable.length < normalized.length && + mergedEntries(firstSyllable).any { it.text == text } + ) { + return firstSyllable + } + return normalized + } + + /** Call after two direct pinyin commits land back-to-back — see LitePinyinController.observeCommitForLearning() — off the caller's thread. */ + fun observeSequence(combinedEncoding: String, text: String) { + val normalized = combinedEncoding.trim().lowercase() + if (normalized.isEmpty() || text.isEmpty()) return + executor.execute { learnedPhrases.observeSequence(normalized, text) } + } + + /** Warms both indexes off the caller's thread without waiting for a query — mirrors StrokePhraseRepository.preloadAsync(); call once, e.g. when the Pinyin panel first becomes reachable. */ + fun preloadAsync() { + executor.execute { ensureLoaded() } + } + + fun shutdown() = executor.shutdownNow() + + private fun mergedEntries(normalized: String): List { + synchronized(mergeCache) { mergeCache[normalized] }?.let { return it } + val charMatches = charIndex[normalized].orEmpty() + val phraseMatches = if (normalized.length >= 2) abbreviationIndex[normalized].orEmpty() else emptyList() + val merged = charMatches + phraseMatches + synchronized(mergeCache) { mergeCache[normalized] = merged } + return merged + } + + private data class CandidateEntry(val sourceKey: String, val entry: Entry) + + private fun candidateEntries(normalized: String): List { + val exact = mergedEntries(normalized).map { CandidateEntry(normalized, it) } + val firstSyllable = segmentEncoding(normalized).firstOrNull() + if (firstSyllable == null || firstSyllable.length >= normalized.length) return exact + val first = mergedEntries(firstSyllable).map { CandidateEntry(firstSyllable, it) } + return (exact + first).distinctBy { it.entry.text } + } + + private fun ensureLoaded() { + if (loaded) return + synchronized(this) { + if (loaded) return + loadChars() + loadPhrases() + loaded = true + } + } + + private fun loadChars() { + val grouped = HashMap>() + runCatching { + appContext.assets.open("pinyin_chars.tsv").bufferedReader().useLines { lines -> + lines.forEach { line -> + val parts = line.split('\t') + if (parts.size < 3) return@forEach + val char = parts[0] + val pinyin = parts[1].trim().lowercase() + val weight = parts[2].toIntOrNull() ?: 0 + if (char.isEmpty() || pinyin.isEmpty()) return@forEach + grouped.getOrPut(pinyin) { mutableListOf() }.add(Entry(char, weight)) + } + } + } + grouped.forEach { (pinyin, entries) -> charIndex[pinyin] = entries.sortedByDescending { it.weight } } + syllableSet.clear() + syllableSet.addAll(charIndex.keys) + } + + private fun loadPhrases() { + val grouped = HashMap>() + runCatching { + appContext.assets.open("pinyin_phrases.tsv").bufferedReader().useLines { lines -> + lines.forEach { line -> + val parts = line.split('\t') + if (parts.size < 4) return@forEach + val phrase = parts[0] + val abbreviation = parts[2] + val weight = parts[3].toIntOrNull() ?: 0 + if (phrase.isEmpty() || abbreviation.isEmpty()) return@forEach + grouped.getOrPut(abbreviation) { mutableListOf() }.add(Entry(phrase, weight)) + } + } + } + // Within one abbreviation's own phrase list only (never crosses into + // the char tier above it) — a manually curated domain term (see + // PRIORITY_PHRASES) sorts before any ordinary corpus-ranked phrase + // sharing that same code, regardless of either one's raw weight; + // ties within each of those two groups still fall back to weight. + grouped.forEach { (abbreviation, entries) -> + abbreviationIndex[abbreviation] = entries.sortedWith( + compareByDescending { it.text in PRIORITY_PHRASES }.thenByDescending { it.weight }, + ) + } + } + + private companion object { + const val MAX_SYLLABLE_LENGTH = 6 + + // Same size as StrokePhraseRepository's own cache — this one is + // keyed by the exact (short) encoding string rather than a rolling + // suffix, so cardinality is naturally bounded by realistic typing + // patterns without needing a larger budget. + const val CACHE_SIZE = 256 + + // Mirrors generate-pinyin-phrases.mjs's own WHITELIST word list + // (kept as a separate small constant here, not a 5th TSV column, + // since it only ever affects in-app ranking, never the asset's + // own membership or generation) — a whitelisted domain term the + // user confirmed on 2026-09-25 outranks a same-abbreviation + // ordinary corpus phrase on sight, since it was deliberately added + // because the generic frequency corpus has no reason to rank it + // highly on its own. + val PRIORITY_PHRASES = setOf("输入法", "候选词", "剪贴板", "供应商", "物料", "主管") + } +} diff --git a/openless-all/app/android/kotlin/LitePinyinUserFrequency.kt b/openless-all/app/android/kotlin/LitePinyinUserFrequency.kt new file mode 100644 index 000000000..ced1de566 --- /dev/null +++ b/openless-all/app/android/kotlin/LitePinyinUserFrequency.kt @@ -0,0 +1,49 @@ +package com.openless.app + +import android.content.Context +import kotlin.math.exp +import kotlin.math.ln + +/** + * Local-only Pinyin-mode selection-history store — phase 5 of the lite-pinyin + * plan (docs/pinyin-lite/phase-0-audit.md). Same shape and scoring formula as + * EnglishUserFrequency/StrokeUserFrequency (log-scaled hit count + exponential + * recency decay), keyed by "编码 + 候选文本" per plan 8.4 — a selection only + * boosts that exact encoding's ranking, not the candidate text in general + * (picking 国 after typing "guo" shouldn't also boost it for an unrelated + * encoding that happens to also resolve to 国). + */ +internal class LitePinyinUserFrequency(context: Context) { + private val preferences = context.applicationContext.getSharedPreferences(STORE, Context.MODE_PRIVATE) + + fun score(encoding: String, text: String, now: Long = System.currentTimeMillis()): Double { + val value = preferences.getString(key(encoding, text), null) ?: return 0.0 + val parts = value.split(',') + val count = parts.getOrNull(0)?.toDoubleOrNull() ?: return 0.0 + val last = parts.getOrNull(1)?.toLongOrNull() ?: return 0.0 + val ageDays = ((now - last).coerceAtLeast(0L)).toDouble() / MILLIS_PER_DAY + return ln(1.0 + count) * COUNT_WEIGHT + exp(-ageDays / RECENCY_HALF_LIFE_DAYS) * RECENCY_WEIGHT + } + + fun record(encoding: String, text: String) { + if (encoding.isBlank() || text.isBlank()) return + val entryKey = key(encoding, text) + val old = preferences.getString(entryKey, null)?.split(',') + val count = (old?.getOrNull(0)?.toIntOrNull() ?: 0) + 1 + val now = System.currentTimeMillis() + preferences.edit().putString(entryKey, "$count,$now").apply() + } + + /** Clearing this app's data (plan 16's own acceptance criterion: "清除应用数据可恢复默认排序") already wipes this preferences file with everything else — this is only for an explicit in-app reset, not currently wired to any UI. */ + fun clear() = preferences.edit().clear().apply() + + private fun key(encoding: String, text: String) = "${encoding.trim().lowercase()}|$text" + + private companion object { + const val STORE = "openless_pinyin_frequency" + const val MILLIS_PER_DAY = 86_400_000L + const val RECENCY_HALF_LIFE_DAYS = 30.0 + const val COUNT_WEIGHT = 80.0 + const val RECENCY_WEIGHT = 24.0 + } +} diff --git a/openless-all/app/android/kotlin/OpenLessApplication.kt b/openless-all/app/android/kotlin/OpenLessApplication.kt index 3228e5881..d31b1532e 100644 --- a/openless-all/app/android/kotlin/OpenLessApplication.kt +++ b/openless-all/app/android/kotlin/OpenLessApplication.kt @@ -1,9 +1,11 @@ package com.openless.app +import android.Manifest import android.app.Activity import android.app.ActivityManager import android.app.Application import android.content.Intent +import android.content.pm.PackageManager import android.os.Build import android.os.Bundle import android.os.PowerManager @@ -16,17 +18,55 @@ class OpenLessApplication : Application() { override fun onCreate() { super.onCreate() OpenLessAppContext.initialize(this) + // #region agent log + // Baseline JNI Context so overlay/dictation notifies work even when + // neither RuntimeService (IME) nor OverlayService has started yet. + runCatching { + OpenLessNative.nativeRegisterActivityContext(this) + Log.i( + "OpenLessDbg58c22b", + """{"sessionId":"58c22b","hypothesisId":"A","location":"OpenLessApplication.onCreate","message":"registered application context baseline","data":{"hasCtx":${OpenLessNative.nativeHasRegisteredActivityContext()}},"timestamp":${System.currentTimeMillis()}}""", + ) + }.onFailure { error -> + Log.w( + "OpenLessDbg58c22b", + """{"sessionId":"58c22b","hypothesisId":"A","location":"OpenLessApplication.onCreate","message":"application register failed","data":{"error":"${error.message}"},"timestamp":${System.currentTimeMillis()}}""", + ) + } + // #endregion if (isMainProcess()) { OpenLessShizukuBridge.initialize() } + recordProcessRestart() registerActivityLifecycleCallbacks( object : ActivityLifecycleCallbacks { - override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) = - Unit + override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) { + // Registering the with_android_env() JNI Context used + // to happen here for whichever MainActivity-family + // instance was created — moved to + // OpenLessRuntimeService.onCreate()/onDestroy() instead + // (see OpenLessNative.nativeRegisterActivityContext()'s + // doc comment): a Service that starts/stops in lockstep + // with the IME being active is a far more stable + // registrant than an Activity that spends nearly its + // whole life backgrounded via moveTaskToBack() and can + // be reclaimed by the OS at any point during that. + } override fun onActivityStarted(activity: Activity) { - if (activity.javaClass.name.endsWith("MainActivity")) { + // Must use `is MainActivity` — launcher opens + // OpenLessBackendWarmupActivity (subclass), whose class name + // does NOT end with "MainActivity", so endsWith() never fired + // overlay show/hide or permission prompts. + if (activity is MainActivity) { + // #region agent log + Log.i( + "OpenLessDbg58c22b", + """{"sessionId":"58c22b","hypothesisId":"F","location":"OpenLessApplication.onActivityStarted","message":"main-family started","data":{"cls":"${activity.javaClass.simpleName}","hasCtx":${runCatching { OpenLessNative.nativeHasRegisteredActivityContext() }.getOrDefault(false)}},"timestamp":${System.currentTimeMillis()}}""", + ) + // #endregion maybeRequestBatteryOptimizationExemption(activity) + maybeRequestNotificationPermission(activity) maybeHideOverlayOnForeground() } } @@ -53,7 +93,13 @@ class OpenLessApplication : Application() { } override fun onActivityStopped(activity: Activity) { - if (activity.javaClass.name.endsWith("MainActivity")) { + if (activity is MainActivity) { + // #region agent log + Log.i( + "OpenLessDbg58c22b", + """{"sessionId":"58c22b","hypothesisId":"F","location":"OpenLessApplication.onActivityStopped","message":"main-family stopped — maybe show overlay","data":{"cls":"${activity.javaClass.simpleName}","trigger":"${configuredOverlayTriggerMode()}"},"timestamp":${System.currentTimeMillis()}}""", + ) + // #endregion maybeShowOverlayOnBackground() } } @@ -61,7 +107,11 @@ class OpenLessApplication : Application() { override fun onActivitySaveInstanceState(activity: Activity, outState: Bundle) = Unit - override fun onActivityDestroyed(activity: Activity) = Unit + override fun onActivityDestroyed(activity: Activity) { + // Unregistering the with_android_env() JNI Context used + // to happen here — see onActivityCreated() above and + // OpenLessRuntimeService, which now owns this instead. + } } ) } @@ -73,8 +123,20 @@ class OpenLessApplication : Application() { return } if (!canDrawOverlays()) { + // #region agent log + Log.w( + "OpenLessDbg58c22b", + """{"sessionId":"58c22b","hypothesisId":"B","location":"OpenLessApplication.maybeShowOverlayOnBackground","message":"skip show — canDrawOverlays false","timestamp":${System.currentTimeMillis()}}""", + ) + // #endregion return } + // #region agent log + Log.i( + "OpenLessDbg58c22b", + """{"sessionId":"58c22b","hypothesisId":"B","location":"OpenLessApplication.maybeShowOverlayOnBackground","message":"sending ACTION_SHOW","data":{"trigger":"$configured"},"timestamp":${System.currentTimeMillis()}}""", + ) + // #endregion sendOverlayAction(OpenLessOverlayService.ACTION_SHOW) } @@ -196,10 +258,17 @@ class OpenLessApplication : Application() { private fun maybeRequestBatteryOptimizationExemption(activity: Activity) { if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) return val power = getSystemService(POWER_SERVICE) as? PowerManager ?: return + // Re-checked against live system state every time, not gated by a + // one-shot "already prompted" flag: some OEMs (OnePlus/ColorOS + // observed) silently revert this exemption back to "optimized" on + // their own, and a one-time flag would then never prompt again even + // though the app is no longer actually exempt. if (power.isIgnoringBatteryOptimizations(packageName)) return val prefs = getSharedPreferences("openless_runtime", MODE_PRIVATE) - if (prefs.getBoolean("battery_optimization_prompted", false)) return - prefs.edit().putBoolean("battery_optimization_prompted", true).apply() + val now = System.currentTimeMillis() + val lastPrompt = prefs.getLong("battery_optimization_prompted_at", 0L) + if (now >= lastPrompt && now - lastPrompt < BATTERY_PROMPT_COOLDOWN_MS) return + prefs.edit().putLong("battery_optimization_prompted_at", now).apply() runCatching { activity.startActivity( Intent( @@ -212,11 +281,94 @@ class OpenLessApplication : Application() { } } + // POST_NOTIFICATIONS (API 33+) is never auto-granted, and + // OpenLessBackendWarmupActivity only asks for it when the settings UI is + // opened explicitly — a user who only ever invokes the IME via the + // keyboard switcher, without ever tapping the launcher icon, could go + // through that path forever without the request ever firing. Asking + // here too, on any real (launcher) app open, catches that case. + private fun maybeRequestNotificationPermission(activity: Activity) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) return + if (activity.checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS) == + PackageManager.PERMISSION_GRANTED + ) { + return + } + runCatching { + activity.requestPermissions(arrayOf(Manifest.permission.POST_NOTIFICATIONS), REQUEST_POST_NOTIFICATIONS) + }.onFailure { error -> + Log.w(TAG, "notification permission request failed", error) + } + } + private fun isMainProcess(): Boolean { val processName = currentProcessName() ?: return true return processName == packageName } + // OpenLessApplication.onCreate() runs once per OS process this + // application object is forked into — not just the main one — so this + // is the one place both the main process (IME + Tauri backend) and the + // separate ":accessibility" process (OpenLessAccessibilityService) can + // each be counted under their own key. Any other/unexpected process + // name is intentionally left unrecorded rather than guessed at. + private fun recordProcessRestart() { + val processKey = when (currentProcessName() ?: packageName) { + packageName -> OpenLessProcessRestartStats.MAIN + "$packageName:accessibility" -> OpenLessProcessRestartStats.ACCESSIBILITY + else -> return + } + // Runs before this run's own recordStart() below, so a fresh + // build's first start isn't immediately wiped by its own reset. + if (processKey == OpenLessProcessRestartStats.MAIN) { + resetRestartStatsOnVersionBump() + } + OpenLessProcessRestartStats(this, processKey).recordStart() + if (processKey == OpenLessProcessRestartStats.MAIN) { + recordUncleanShutdownIfAny() + } + } + + // Best-effort "did the previous main-process session end cleanly" + // check: OpenLessImeService.onDestroy() clears "session_alive" on any + // ordinary teardown (keyboard switched away from, app force-stopped). + // An abrupt process kill — native crash, OOM — skips onDestroy() + // entirely and leaves it set, so finding it still set here means the + // previous run did not end cleanly. Can't tell a crash apart from a + // deliberate force-stop this way, but both are worth surfacing. + private fun recordUncleanShutdownIfAny() { + val prefs = getSharedPreferences("openless_runtime", MODE_PRIVATE) + if (prefs.getBoolean("session_alive", false)) { + OpenLessProcessRestartStats(this, "unclean").recordStart() + } + prefs.edit().putBoolean("session_alive", true).apply() + } + + // A fresh install/build makes every one of these counts so far + // meaningless to keep — they're tracking whether *this* build has been + // getting killed/crashing, not history from whatever was installed + // before. Compares against OpenLessBuildInfo.VERSION (bumped by hand + // before each debug build during this testing cycle) rather than the + // real app version, since that's what actually changes between the + // installs being compared. + private fun resetRestartStatsOnVersionBump() { + val prefs = getSharedPreferences("openless_runtime", MODE_PRIVATE) + val lastVersion = prefs.getString("last_seen_build_version", null) + if (lastVersion != OpenLessBuildInfo.VERSION) { + for (category in ALL_RESTART_CATEGORIES) { + OpenLessProcessRestartStats(this, category).resetToday() + } + // Wall-clock timestamp of the moment this build's counters were + // last zeroed — shown on the keyboard settings screen so a + // screenshot of the restart-cause counts also carries "counting + // since when", not just the raw totals. + prefs.edit() + .putString("last_seen_build_version", OpenLessBuildInfo.VERSION) + .putLong("build_first_seen_wall_time", System.currentTimeMillis()) + .apply() + } + } + private fun currentProcessName(): String? { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { return Application.getProcessName() @@ -228,5 +380,46 @@ class OpenLessApplication : Application() { companion object { private const val TAG = "OpenLessApplication" + private const val BATTERY_PROMPT_COOLDOWN_MS = 3L * 24 * 60 * 60 * 1000 + private const val REQUEST_POST_NOTIFICATIONS = 9103 + + // Kept in sync by hand with every restart-cause key actually used + // across the app (OpenLessRuntimeService's "sticky"/"actkill"/its + // "actkill_" sub-categories/"rtexit", + // OpenLessBackendWarmupActivity's "warmup", OpenLessImeService's + // "mictap", and this file's own MAIN/ACCESSIBILITY/"unclean") — + // resetRestartStatsOnVersionBump() needs the full list to zero + // everything out on a fresh build. The actkill_* entries were + // missing here for a while: "actkill" itself got zeroed on every + // version bump but its sub-categories didn't, so they kept + // accumulating across bumps within the same day while the total + // reset out from under them — e.g. actkill_finishing reading + // higher than actkill itself, which should never happen since + // both are only ever incremented together (OpenLessRuntimeService). + // actkill_self was one of the four sub-categories added along with + // this fix, then removed again shortly after (along with the + // recreate()-based settings-reload escalation it tracked, see + // OpenLessBackendWarmupActivity's onDestroy()) — this app never + // calls Activity.recreate() on itself any more, so that reason can + // no longer occur. + private val ALL_RESTART_CATEGORIES = listOf( + OpenLessProcessRestartStats.MAIN, + OpenLessProcessRestartStats.ACCESSIBILITY, + "sticky", + "warmup", + "mictap", + "actkill", + "actkill_config", + "actkill_finishing", + "actkill_os", + "rtexit", + "unclean", + "heartbeat", + // OpenLessBackendWarmupActivity.restartProcessAsLastResort() — + // the "main" webview window's Tauri-side label got permanently + // stuck (see its doc comment) and this self-triggered kill was + // the only way out short of the user force-stopping manually. + "stuckwindow", + ) } } diff --git a/openless-all/app/android/kotlin/OpenLessBackendWarmupActivity.kt b/openless-all/app/android/kotlin/OpenLessBackendWarmupActivity.kt index 803888c9d..15b40e39d 100644 --- a/openless-all/app/android/kotlin/OpenLessBackendWarmupActivity.kt +++ b/openless-all/app/android/kotlin/OpenLessBackendWarmupActivity.kt @@ -1,17 +1,82 @@ package com.openless.app +import android.Manifest +import android.content.pm.PackageManager +import android.os.Build import android.os.Bundle import android.os.Handler import android.os.Looper import android.content.Context import android.content.Intent -/** Starts the Tauri/Rust runtime without presenting the settings UI. */ +/** + * Starts the Tauri/Rust runtime — usually invisibly (warmup/recovery), but + * also the app's actual launcher target (the manifest's LAUNCHER + * intent-filter lives here, not on bare MainActivity — see + * merge-android-overlay-manifest.mjs's moveLauncherIntentFilterToWarmupActivity() + * for why: a direct launcher tap used to open untracked plain MainActivity, + * a second Tauri host whose WebView never got anything attached). + */ class OpenLessBackendWarmupActivity : MainActivity() { // Activity 实例化阶段尚未 attach Context,不能访问 Activity.mainLooper。 private val warmupHandler = Handler(Looper.getMainLooper()) - private val sendToBackground = Runnable { - if (!settingsRequested && !isFinishing && !isDestroyed) { + // How long sendToBackground() has already spent polling for the WebView + // to finish its first load, this warmup cycle — reset in onCreate() + // right before the first postDelayed(). Not reset anywhere else: this + // Activity's onCreate() only ever runs once per singleTask instance, and + // sendToBackground only ever runs once per instance too (onNewIntent() + // cancels the pending one instead of letting a second cycle start). + private var webViewReadyWaitElapsedMs = 0L + // lateinit + assigned in init{}, not a plain `val = Runnable { ... }`: + // the lambda below reschedules itself by referencing this same property + // (needed so warmupHandler.removeCallbacks(sendToBackground) elsewhere + // in this class always cancels the exact instance that was posted), and + // Kotlin can't resolve that self-reference inside a single property + // initializer expression (fails with "Variable must be initialized" / + // a recursive type-checking error) — splitting the declaration from the + // assignment gives the lambda a property that already exists to close over. + private lateinit var sendToBackground: Runnable + + init { + sendToBackground = Runnable { + if (settingsRequested || qaRequested || isFinishing || isDestroyed) return@Runnable + // On-device logs showed a genuinely black settings page whose window + // itself was drawn and focused fine, but whose WebView had never + // rendered a single frame — no renderer process ever spawned, no + // crash either. The likely cause: this Runnable used to fire + // unconditionally 180ms after onCreate(), which is well within the + // time a cold Tauri/WebView init can still be in flight (the exact + // race the overridePendingTransition/moveTaskToBack comment below + // already worried about for HWUI's worker pool) — backgrounding the + // window mid-init apparently can wedge the WebView itself, not just + // its renderer process, and since this Activity is singleTask and + // never recreated, every later "genuine settings open" just reopens + // the same permanently-wedged instance. Polling WebView.progress + // (a plain getter, no WebViewClient override needed — Tauri/Wry + // installs its own client for the JS<->Rust bridge and overriding it + // here would break that) instead of trusting a fixed delay lets a + // slow cold start finish before this window ever gets backgrounded. + // The wait is capped (WEBVIEW_READY_MAX_WAIT_MS) so a WebView that + // never reaches 100 for some unrelated reason can't keep the silent + // warmup path visible forever. + // webViewRef is still null until onWebViewCreate() fires — on a + // cold process start that hasn't happened yet by the time this + // first runs, which is exactly the case most in need of waiting, + // not a "nothing to wait for" free pass. (An earlier version of + // this check read `webView == null || ...`, treating "not + // created yet" as ready — that let a cold start still hit the + // exact bug this polling was meant to fix.) + val webView = webViewRef + val webViewReady = webView != null && webView.progress >= 100 + if (!webViewReady && webViewReadyWaitElapsedMs < WEBVIEW_READY_MAX_WAIT_MS) { + webViewReadyWaitElapsedMs += WEBVIEW_READY_POLL_INTERVAL_MS + warmupHandler.postDelayed(sendToBackground, WEBVIEW_READY_POLL_INTERVAL_MS) + return@Runnable + } + android.util.Log.i( + "OpenLessBackendWarmupActivity", + "sendToBackground firing waitedMs=$webViewReadyWaitElapsedMs webViewReady=$webViewReady", + ) // Tauri/Rust runtime is owned by this Activity. Keep it alive as the // single UI/runtime host, but never relaunch the editor's package here: // a package launch intent only knows that app's launcher Activity, which @@ -21,15 +86,348 @@ class OpenLessBackendWarmupActivity : MainActivity() { // Activity/window that requested the IME. overridePendingTransition(0, 0) moveTaskToBack(true) - OpenLessImeService.requestInputPanelAfterWarmup(260L) + // Only bother trying to restore the panel if it was actually the + // thing on screen right before this Activity stole focus (see + // restoreInputPanelAfterWarmup's own doc comment) — a warmup + // triggered by an unrelated app's onStartInput(), or by the + // periodic backend heartbeat while nothing was being typed into, + // has nothing to restore. + if (restoreInputPanelAfterWarmup) { + restoreInputPanelAfterWarmup = false + OpenLessImeService.requestInputPanelAfterWarmup(260L) + } } } + private var settingsRequested = false + // QA swipe opens this same Tauri host (never bare MainActivity). Unlike + // settings, back should only hide the host — not finishAndRemoveTask — + // so the process-lifetime WebView stays intact for the next gesture. + private var qaRequested = false + private var webViewRef: android.webkit.WebView? = null + + // See onCreate()'s settingsRequested branch and onWebViewCreate() below. + // + // Restarts the whole process on the very first stuck instance, not just + // finishing it — on-device testing found that once Wry/Tauri's Android + // CreateWebView dispatch silently fails once, the "main" window label it + // partially registered can never be freed again: destroy()/close() only + // clear tauri-runtime-wry's own internal window reference, while Tauri's + // higher-level WindowManager only drops a window's label in response to + // a genuine platform WindowEvent::Destroyed — which never fires for a + // WebView that was never actually created. Every later + // ensureMainWebviewWindow() call then hits a permanent "a webview with + // label `main` already exists" error, with no way to clear it from here + // (that removal logic is private to the tauri crate). Confirmed by + // repeated identical failures across many retries in one test session, + // recovering only after the whole process was killed and relaunched — + // restartProcessAsLastResort() mirrors that manual recovery + // automatically instead of leaving the user stuck. (An earlier version + // of this only restarted after 2 consecutive failures; user feedback was + // that a single stuck instance already reads as broken, so this is now + // unconditional — restartProcessAsLastResort()'s own cooldown is what + // guards against a restart loop.) + private val webViewCreationWatchdog = Runnable { + if (webViewRef != null || isFinishing || isDestroyed) return@Runnable + android.util.Log.w( + "OpenLessBackendWarmupActivity", + "webView never created within ${WEBVIEW_CREATION_WATCHDOG_MS}ms; restarting stuck instance " + + "activityHash=${System.identityHashCode(this)}", + ) + // restartProcessAsLastResort() can decline (its own cooldown) — + // still finish this stuck instance either way, so the next Logo tap + // at least gets a fresh retry instead of staring at this same dead + // black window. + if (!restartProcessAsLastResort(applicationContext)) { + finishAndRemoveTask() + } + } + + // See requestSettingsReattach()/maybePerformPendingSettingsReattach()/ + // performWebViewReattach() below. settingsOpenRequestId increments on + // every settings-open (cold onCreate() or warm onNewIntent()), purely + // for correlating log lines — only the onNewIntent() (warm reuse) path + // ever sets pendingSettingsReattach, since a fresh onCreate() already + // gets a genuinely fresh WebView/Surface pairing with nothing to fix. + private var settingsOpenRequestId = 0L + private var pendingSettingsReattach = false + private var reattachedForRequestId = -1L + private var activityResumed = false + + // WryActivity's own hook, fired once when the WebView is first created + // for this Activity instance. + override fun onWebViewCreate(webView: android.webkit.WebView) { + super.onWebViewCreate(webView) + webViewRef = webView + warmupHandler.removeCallbacks(webViewCreationWatchdog) + android.util.Log.i( + "OpenLessBackendWarmupActivity", + "onWebViewCreate webView=${System.identityHashCode(webView)} activityHash=${System.identityHashCode(this)} " + + "isAttachedToWindow=${webView.isAttachedToWindow}", + ) + // Diagnostic only — confirms a reattach (see performWebViewReattach()) + // actually ran a real detach/attach cycle rather than just having + // its code path executed, and doubles as one of the triggers that + // re-checks maybePerformPendingSettingsReattach()'s conditions, + // since a reattach's own addView() causes this to fire too. + webView.addOnAttachStateChangeListener(object : android.view.View.OnAttachStateChangeListener { + override fun onViewAttachedToWindow(v: android.view.View) { + android.util.Log.i( + "OpenLessBackendWarmupActivity", + "webView attached requestId=$settingsOpenRequestId webView=${System.identityHashCode(v)} " + + "windowVisibility=${v.windowVisibility} hasWindowFocus=${hasWindowFocus()} " + + "parent=${System.identityHashCode(v.parent)}", + ) + maybePerformPendingSettingsReattach("onViewAttachedToWindow") + } + + override fun onViewDetachedFromWindow(v: android.view.View) { + android.util.Log.i( + "OpenLessBackendWarmupActivity", + "webView detached requestId=$settingsOpenRequestId webView=${System.identityHashCode(v)}", + ) + } + }) + } + + /** + * Re-mounts the already-alive WebView onto the Activity's own content + * view: removes it from its parent, then — on the parent's own next + * Choreographer animation frame (the parent, not the WebView, since it + * stays attached to the window the whole time; posting on the + * just-detached WebView itself would queue into a run-queue that only + * flushes once *something else* reattaches it, i.e. never) — adds it + * back at the same index with the same LayoutParams, restoring scroll + * position and focus. This is a controlled experiment targeting a + * specific, evidence-backed correlation, not a proven fix: on-device + * logs show this Activity's outer Window Surface gets torn down + * (BLASTBufferQueue disconnect → destroySurface → NO_SURFACE) every + * time sendToBackground() calls moveTaskToBack() and a fresh Surface + * gets allocated the next time this singleTask instance is brought + * back via onNewIntent() — while the Activity, WebView, renderer + * process, and Rust runtime all stay alive throughout (confirmed via + * dumpsys: mHasSurface=true, isReadyForDisplay()=true, + * mDrawState=HAS_DRAWN by the time the black screen is reported, and + * the WebView renderer process itself never frozen or gone). The + * failure correlates with this WebView being reused across that + * Surface teardown/recreate cycle without ever going through a real + * View detach/attach of its own — Android does not document a + * guarantee that forcing one causes Chromium to recomposite onto the + * new Surface, so this needs real-device verification over many + * background/foreground cycles, not just a build that compiles. + * + * Deliberately NOT: reload()ing the page (resets JS/app state and + * doesn't address a Surface-binding problem), any postVisualStateCallback + * / WebView.progress based "did it actually render" detection (both + * proved unreliable — see the git history this replaced), or + * recreate()ing the Activity (walks WryActivity through a real + * onDestroy()/onCreate() cycle, notifying Rust of an Activity+WebView + * destroy/recreate it doesn't actually need, and this project has + * separately hit real JNI/Context staleness bugs around exactly that + * kind of Activity churn before). + */ + private fun performWebViewReattach(webView: android.webkit.WebView, requestId: Long) { + val parent = webView.parent as? android.view.ViewGroup + if (parent == null) { + android.util.Log.w( + "OpenLessBackendWarmupActivity", + "reattach skipped requestId=$requestId: webView has no ViewGroup parent", + ) + return + } + val index = parent.indexOfChild(webView) + if (index < 0) { + android.util.Log.w( + "OpenLessBackendWarmupActivity", + "reattach skipped requestId=$requestId: webView not found among its own parent's children", + ) + return + } + val layoutParams = webView.layoutParams + val hadFocus = webView.hasFocus() + val scrollX = webView.scrollX + val scrollY = webView.scrollY + android.util.Log.i( + "OpenLessBackendWarmupActivity", + "reattach removeView requestId=$requestId webView=${System.identityHashCode(webView)} " + + "parent=${System.identityHashCode(parent)} index=$index hadFocus=$hadFocus", + ) + parent.removeView(webView) + parent.postOnAnimation { + if (isFinishing || isDestroyed) { + android.util.Log.w( + "OpenLessBackendWarmupActivity", + "reattach addView skipped requestId=$requestId: activity finishing/destroyed", + ) + return@postOnAnimation + } + parent.addView(webView, index, layoutParams) + webView.scrollTo(scrollX, scrollY) + if (hadFocus) webView.requestFocus() + // Wry's own onResume()/onPause() overrides already call this — + // see WryActivity.kt — so this is redundant insurance, not the + // thing actually expected to matter here; the cross-frame + // detach/attach above is. + webView.onResume() + webView.requestLayout() + webView.invalidate() + parent.requestLayout() + parent.invalidate() + android.util.Log.i( + "OpenLessBackendWarmupActivity", + "reattach addView complete requestId=$requestId webView=${System.identityHashCode(webView)}", + ) + } + } + + /** + * Arms a pending reattach for the current settingsOpenRequestId and + * immediately checks whether it can run right away — a settings + * request arriving while the Activity is already resumed/focused + * shouldn't wait for a lifecycle callback that isn't coming again. + */ + private fun requestSettingsReattach() { + settingsOpenRequestId += 1 + pendingSettingsReattach = true + android.util.Log.i( + "OpenLessBackendWarmupActivity", + "requestSettingsReattach requestId=$settingsOpenRequestId", + ) + maybePerformPendingSettingsReattach("requestSettingsReattach") + } + + /** + * Only actually reattaches once every one of these is true, checked + * fresh on each call from whichever lifecycle/attach callback fires + * next (onResume/onWindowFocusChanged/the WebView's own attach + * listener) — no fixed delay, no polling, just re-evaluating real + * state every time something relevant changes: + * - the Activity is resumed and has window focus (not just RESUMED — + * window focus is what actually implies the Surface is usable); + * - the WebView is attached to its window and that window is visible; + * - the Activity isn't finishing/destroyed. + * reattachedForRequestId makes this idempotent per + * settingsOpenRequestId — onResume() and onWindowFocusChanged(true) + * can both fire for the same settings-open, and only the first one to + * see every condition satisfied should act. + */ + private fun maybePerformPendingSettingsReattach(trigger: String) { + if (!pendingSettingsReattach) return + val requestId = settingsOpenRequestId + val webView = webViewRef + // Logged unconditionally (not just once conditions are all met) — + // the earlier version of this function returned silently on the + // first failing check, which made it impossible to tell from logs + // alone which of the five conditions was the blocker on a given + // trigger. + android.util.Log.i( + "OpenLessBackendWarmupActivity", + "maybePerformPendingSettingsReattach trigger=$trigger requestId=$requestId " + + "reattachedForRequestId=$reattachedForRequestId isFinishing=$isFinishing isDestroyed=$isDestroyed " + + "activityResumed=$activityResumed hasWindowFocus=${hasWindowFocus()} " + + "webView=${webView?.let { System.identityHashCode(it) }} " + + "webViewAttached=${webView?.isAttachedToWindow} webViewWindowVisibility=${webView?.windowVisibility}", + ) + if (reattachedForRequestId == requestId) return + if (isFinishing || isDestroyed) return + if (!activityResumed || !hasWindowFocus()) return + if (webView == null) return + if (!webView.isAttachedToWindow || webView.windowVisibility != android.view.View.VISIBLE) return + reattachedForRequestId = requestId + pendingSettingsReattach = false + android.util.Log.i( + "OpenLessBackendWarmupActivity", + "reattach conditions met trigger=$trigger requestId=$requestId webView=${System.identityHashCode(webView)}", + ) + performWebViewReattach(webView, requestId) + } override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) + // Idempotent no-op when Wry's own cold-start CreateWebView already + // fired for this instance (the common, first-ever-Activity case) — + // only does real work for a fresh instance created after the + // previous one was truly destroyed, where Wry's android_setup() + // never sends CreateWebView on its own (no WEBVIEW_ATTRIBUTES entry + // for a never-seen-before activity_id). See + // OpenLessNative.nativeEnsureMainWebviewWindow()'s doc comment. + runCatching { OpenLessNative.nativeEnsureMainWebviewWindow() } + .onSuccess { ok -> android.util.Log.i("OpenLessBackendWarmupActivity", "ensureMainWebviewWindow ok=$ok") } + .onFailure { error -> android.util.Log.w("OpenLessBackendWarmupActivity", "ensure main webview window failed", error) } activeInstance = java.lang.ref.WeakReference(this) - settingsRequested = intent.getBooleanExtra(EXTRA_SHOW_SETTINGS, false) + // with_android_env()'s JNI Context registration is no longer this + // Activity's concern at all — see + // OpenLessNative.nativeRegisterActivityContext()'s doc comment: + // OpenLessRuntimeService registers itself instead, since it's a + // far more stable registrant than an Activity that spends nearly + // its whole life backgrounded via moveTaskToBack(). + // + // A direct launcher-icon tap arrives here as a plain ACTION_MAIN/ + // CATEGORY_LAUNCHER intent (no EXTRA_SHOW_SETTINGS) — treated the + // same as an explicit settings request: the user tapped the icon + // expecting to see the app, not an invisible warmup that vanishes + // 180ms later. + val launchedFromLauncher = intent.action == Intent.ACTION_MAIN && + intent.hasCategory(Intent.CATEGORY_LAUNCHER) + // settingsOpenPending covers a cold-start race: openSettings()/ + // openSettingsIfRunning() set it synchronously before ever calling + // startActivity(), so it is already true here even if this onCreate() + // actually happened to be triggered by a concurrent, unrelated + // ensureBackendReady() warmup racing to create the same singleTask + // instance first (observed on-device: a silent warmup and a + // launcher-icon tap landing within ~10ms of each other after the + // OS killed the process in the background) — the alternative, + // reading only this Intent's own extras, depends on onNewIntent() + // winning that race, which is not guaranteed. + settingsRequested = launchedFromLauncher || intent.getBooleanExtra(EXTRA_SHOW_SETTINGS, false) || settingsOpenPending + qaRequested = intent.getBooleanExtra(EXTRA_SHOW_QA, false) || qaOpenPending + android.util.Log.i( + "OpenLessBackendWarmupActivity", + "onCreate settingsRequested=$settingsRequested qaRequested=$qaRequested launchedFromLauncher=$launchedFromLauncher " + + "hasExtra=${intent.getBooleanExtra(EXTRA_SHOW_SETTINGS, false)} settingsOpenPending=$settingsOpenPending " + + "qaOpenPending=$qaOpenPending activityHash=${System.identityHashCode(this)}", + ) + settingsOpenPending = false + qaOpenPending = false + // Only when visibly opened for settings: a permission dialog here + // during the invisible warmup path would get dragged to the + // background along with this Activity by sendToBackground() 180ms + // later, before the user could ever answer it. Android never + // auto-requests POST_NOTIFICATIONS (API 33+) — without asking + // explicitly at least once, OpenLessRuntimeService's foreground + // notification stays silently blocked and "Manage notifications" + // shows as a fixed, non-interactive "don't allow" in Settings, + // since there is nothing granted to manage. + if (settingsRequested || qaRequested) { + if (settingsRequested) { + requestNotificationPermissionIfNeeded() + } + // No reattach requested here (see requestSettingsReattach()): + // a fresh onCreate() means a fresh WebView on a fresh Window + // Surface, the exact pairing that's already proven to render + // correctly (a cold app launch always recovers) — nothing to + // fix. Only bumped for log correlation. + settingsOpenRequestId += 1 + android.util.Log.i( + "OpenLessBackendWarmupActivity", + "foreground open via cold onCreate settings=$settingsRequested qa=$qaRequested " + + "requestId=$settingsOpenRequestId — no reattach needed", + ) + // Safety net: nativeEnsureMainWebviewWindow() above can report + // success while the actual WebView creation still silently + // never reaches this Activity (observed on-device even with a + // genuinely unique activity_id — some deeper Tauri/Wry window- + // creation dispatch issue not yet root-caused). Left alone, + // this Activity stays alive forever with webViewRef == null: a + // permanent black screen, and every later openSettings() call + // just redelivers to this same broken instance via + // onNewIntent() (never onCreate() again), so it can never + // recover on its own. Finishing it here instead — cancelled by + // onWebViewCreate() the moment a real WebView does show up — + // means the next Logo tap creates a genuinely fresh instance + // (with its own new activity_id) rather than being stuck. + warmupHandler.postDelayed(webViewCreationWatchdog, WEBVIEW_CREATION_WATCHDOG_MS) + } // 不再修改窗口透明度或触摸属性。主 Activity 必须以正常窗口完成 // Tauri/WebView 初始化,完成后仅退到后台,避免留下黑色/空白窗口状态。 @@ -43,6 +441,28 @@ class OpenLessBackendWarmupActivity : MainActivity() { @Suppress("DEPRECATION", "MissingSuperCall") override fun onBackPressed() { + overridePendingTransition(0, 0) + if (settingsRequested) { + // Trial change, being verified on-device for a HWUI/native-mutex + // regression before trusting it: closing settings now finishes + // this instance outright instead of moveTaskToBack()-hiding it. + // The old worry (see below) was specifically about backgrounding + // *mid cold-start init* — sendToBackground()'s own early-return + // (`if (settingsRequested) return@Runnable`) already means + // moveTaskToBack() was never reachable here while settings was + // open anyway, so this isn't adding a new code path so much as + // replacing the one genuinely reachable one. Now that a fresh + // rebuild reliably gets a real WebView (activity_id collision + // fixed, see ensureMainWebviewWindow's call site above, with + // webViewCreationWatchdog as a backup), a clean finish + rebuild + // is simpler than reattaching a WebView across a Surface that + // moveTaskToBack() tore down. + finishAndRemoveTask() + return + } + if (qaRequested) { + qaRequested = false + } // This Activity is the single, process-lifetime Tauri/Rust host and must // never actually finish() while the process is alive: finishing destroys // the window Surface (unlike moveTaskToBack, which only hides it), and @@ -50,24 +470,133 @@ class OpenLessBackendWarmupActivity : MainActivity() { // "destroyed mutex" abort. The default back behavior would finish() this // Activity once there is no more back-stack, so always background it // instead — skipping super.onBackPressed() is intentional here. - overridePendingTransition(0, 0) moveTaskToBack(true) } override fun onNewIntent(intent: Intent) { super.onNewIntent(intent) setIntent(intent) - if (intent.getBooleanExtra(EXTRA_SHOW_SETTINGS, false)) { - settingsRequested = true + val launchedFromLauncher = intent.action == Intent.ACTION_MAIN && + intent.hasCategory(Intent.CATEGORY_LAUNCHER) + val showSettings = launchedFromLauncher || intent.getBooleanExtra(EXTRA_SHOW_SETTINGS, false) || settingsOpenPending + val showQa = intent.getBooleanExtra(EXTRA_SHOW_QA, false) || qaOpenPending + if (showSettings || showQa) { + if (showSettings) { + settingsRequested = true + settingsOpenPending = false + } + if (showQa) { + qaRequested = true + qaOpenPending = false + } + android.util.Log.i( + "OpenLessBackendWarmupActivity", + "onNewIntent settingsRequested=$settingsRequested qaRequested=$qaRequested " + + "launchedFromLauncher=$launchedFromLauncher " + + "hasSettingsExtra=${intent.getBooleanExtra(EXTRA_SHOW_SETTINGS, false)} " + + "hasQaExtra=${intent.getBooleanExtra(EXTRA_SHOW_QA, false)}", + ) warmupHandler.removeCallbacks(sendToBackground) + // Covers openSettingsIfRunning()/openForQaIfRunning() bringing an + // already-alive instance forward, and the launcher icon being + // tapped again while this Activity is already alive (singleTask + // redelivers via onNewIntent instead of a fresh onCreate) — either + // way, onCreate()'s own call to this never runs again for those + // cases, so this is the only other place a visible moment happens. + if (showSettings) { + requestNotificationPermissionIfNeeded() + } + // The common case in practice: this singleTask instance's + // WebView has likely been sitting on a torn-down Window + // Surface since whenever sendToBackground() last backgrounded + // it — see performWebViewReattach()'s doc comment. + requestSettingsReattach() } } + private fun requestNotificationPermissionIfNeeded() { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) return + if (checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS) == PackageManager.PERMISSION_GRANTED) return + requestPermissions(arrayOf(Manifest.permission.POST_NOTIFICATIONS), REQUEST_POST_NOTIFICATIONS) + } + + override fun onRequestPermissionsResult( + requestCode: Int, + permissions: Array, + grantResults: IntArray, + ) { + super.onRequestPermissionsResult(requestCode, permissions, grantResults) + if (requestCode != REQUEST_POST_NOTIFICATIONS) return + val granted = grantResults.isNotEmpty() && grantResults[0] == PackageManager.PERMISSION_GRANTED + android.util.Log.i("OpenLessBackendWarmupActivity", "POST_NOTIFICATIONS result granted=$granted") + } + + override fun onResume() { + super.onResume() + activityResumed = true + android.util.Log.i( + "OpenLessBackendWarmupActivity", + "onResume requestId=$settingsOpenRequestId settingsRequested=$settingsRequested " + + "webView=${webViewRef?.let { System.identityHashCode(it) }}", + ) + maybePerformPendingSettingsReattach("onResume") + } + + override fun onWindowFocusChanged(hasFocus: Boolean) { + super.onWindowFocusChanged(hasFocus) + android.util.Log.i( + "OpenLessBackendWarmupActivity", + "onWindowFocusChanged hasFocus=$hasFocus requestId=$settingsOpenRequestId", + ) + if (hasFocus) maybePerformPendingSettingsReattach("onWindowFocusChanged") + } + + override fun onPause() { + super.onPause() + activityResumed = false + android.util.Log.i( + "OpenLessBackendWarmupActivity", + "onPause settingsRequested=$settingsRequested isChangingConfigurations=$isChangingConfigurations", + ) + } + + override fun onStop() { + super.onStop() + android.util.Log.i( + "OpenLessBackendWarmupActivity", + "onStop settingsRequested=$settingsRequested isChangingConfigurations=$isChangingConfigurations isFinishing=$isFinishing", + ) + } + override fun onDestroy() { warmupHandler.removeCallbacks(sendToBackground) + warmupHandler.removeCallbacks(webViewCreationWatchdog) if (activeInstance?.get() === this) { activeInstance = null } + // onBackPressed() always backgrounds rather than finishing (see + // above), and the old stuck-WebView recreate() escalation is gone + // (see performWebViewReattach()'s doc comment) — but + // webViewCreationWatchdog above now *does* call + // finishAndRemoveTask() deliberately, for a narrower reason: this + // instance's WebView silently never got created at all. That still + // reports as "finishing" below, same as any other unexpected + // isFinishing; it isn't split into its own category since it's + // rare enough that OpenLessKeyboardSettingsActivity's restart-stats + // table doesn't need a fourth bucket for it yet. + val reason = when { + isChangingConfigurations -> "config" + isFinishing -> "finishing" + else -> "os" + } + android.util.Log.w( + "OpenLessBackendWarmupActivity", + "onDestroy reason=$reason settingsRequested=$settingsRequested", + ) + // Routed through the service rather than calling ensureBackendReady() + // directly here: the service is what decides whether/when to + // relaunch, this Activity dying is just one input to that decision. + OpenLessRuntimeService.notifyRuntimeActivityDestroyed(applicationContext, reason) super.onDestroy() } @@ -76,21 +605,67 @@ class OpenLessBackendWarmupActivity : MainActivity() { private var activeInstance: java.lang.ref.WeakReference? = null private const val EXTRA_SHOW_SETTINGS = "com.openless.app.extra.SHOW_SETTINGS" + private const val EXTRA_SHOW_QA = "com.openless.app.extra.SHOW_QA" + // Same literal wry's WryActivity.kt uses for its own private + // ACTIVITY_ID_KEY (top-level `private val`, file-scoped in Kotlin — + // not visible here even though it's the same package, hence the + // duplicated literal rather than a shared reference). + private const val WRY_ACTIVITY_ID_KEY = "__wryActivityId" + private const val REQUEST_POST_NOTIFICATIONS = 9102 + private const val WEBVIEW_READY_POLL_INTERVAL_MS = 60L + private const val WEBVIEW_READY_MAX_WAIT_MS = 4000L + // Successful onWebViewCreate() has always landed within ~300ms on + // device even under load; this is generous headroom before treating + // it as genuinely stuck rather than just slow. + private const val WEBVIEW_CREATION_WATCHDOG_MS = 4000L + + private const val LAST_PROCESS_RESTART_KEY = "last_stuck_process_restart_wall_time" + // Purely a crash-loop guard in case this same stuck state somehow + // recurs immediately after relaunch — restartProcessAsLastResort() + // itself has no way to know whether the relaunch actually fixed + // anything, only that it happened. + private const val PROCESS_RESTART_COOLDOWN_MS = 60_000L - /** The single Tauri host is still alive even while its task is in the background. */ + // Set synchronously by openSettings()/openSettingsIfRunning() BEFORE + // startActivity() is ever called, and consumed by onCreate()/ + // onNewIntent() — a settings request is "in flight" the instant one + // of those functions is called, not only once its Intent happens to + // be delivered. Closes a race observed on-device: a concurrent, + // unrelated ensureBackendReady() warmup can create/reuse this same + // singleTask instance a few milliseconds earlier (e.g. right after + // the OS killed the process in the background and the user's tap + // triggers a cold start), and depending purely on whose Intent + // reaches onCreate()/onNewIntent() first left the 180ms + // sendToBackground() timer free to fire before the real settings + // request ever got a chance to cancel it — the window would flash + // and vanish instead of staying open. + @Volatile + private var settingsOpenPending = false + @Volatile + private var qaOpenPending = false + + /** The single Tauri host is usable only when its WebView still exists. */ fun isRunning(): Boolean { val activity = activeInstance?.get() ?: return false - return !activity.isFinishing && !activity.isDestroyed + return !activity.isFinishing && !activity.isDestroyed && activity.webViewRef != null } /** Bring the existing Tauri host forward instead of creating a black second host. */ fun openSettingsIfRunning(context: Context): Boolean { val activity = activeInstance?.get() ?: return false - if (activity.isFinishing || activity.isDestroyed) return false + if (activity.isFinishing || activity.isDestroyed || activity.webViewRef == null) return false + android.util.Log.i("OpenLessBackendWarmupActivity", "openSettingsIfRunning: reusing live instance") + settingsOpenPending = true + // Also applied directly to the live instance right here, not + // left to onNewIntent() delivery timing: this is the common + // case (host already running) and the one most exposed to the + // race described above, since the instance — and its pending + // sendToBackground() timer — already exist by the time this runs. + activity.settingsRequested = true + activity.warmupHandler.removeCallbacks(activity.sendToBackground) context.startActivity(Intent(context, OpenLessBackendWarmupActivity::class.java).apply { putExtra(EXTRA_SHOW_SETTINGS, true) addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) - addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP) addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP) addFlags(Intent.FLAG_ACTIVITY_NO_ANIMATION) }) @@ -106,19 +681,103 @@ class OpenLessBackendWarmupActivity : MainActivity() { * an untracked second host and re-run Tauri/Rust setup from scratch. */ fun openSettings(context: Context) { + settingsOpenPending = true if (openSettingsIfRunning(context)) return + // No live instance (activeInstance is null, or the one we have is + // finishing/destroyed/never got a WebView) — treat all of those + // uniformly as "the previous host is gone, start fresh". A fresh + // Activity's onCreate() now calls + // OpenLessNative.nativeEnsureMainWebviewWindow() itself (see + // above), which is what actually makes this reliable — no + // separate finish-then-relaunch dance needed here anymore. + android.util.Log.i("OpenLessBackendWarmupActivity", "openSettings: no live instance, starting fresh") context.startActivity(Intent(context, OpenLessBackendWarmupActivity::class.java).apply { putExtra(EXTRA_SHOW_SETTINGS, true) + // Explicit __wryActivityId, matching what WryActivity's own + // startActivity(cls) helper does — WITHOUT this, WryActivity. + // onCreate()'s `intent.extras?.getInt(ACTIVITY_ID_KEY) ?: hashCode()` + // silently resolves to 0 for *every* Intent here, because + // Bundle.getInt() returns 0 (not null) for a missing key once + // extras is non-null (which it always is once EXTRA_SHOW_SETTINGS + // is set above) — so every rebuilt instance collided on the same + // activity_id=0, racing its dying predecessor's async cleanup for + // that same id and intermittently losing (confirmed via a wry + // patch: android_setup()/InnerWebView::new() both logged + // activity_id=0 for every fresh rebuild). A real random id per + // launch makes each instance's Wry-side records genuinely its own. + putExtra(WRY_ACTIVITY_ID_KEY, kotlin.random.Random.nextInt()) addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) addFlags(Intent.FLAG_ACTIVITY_NO_ANIMATION) }) } + /** Bring the existing Tauri host forward for the embedded mobile QA panel. */ + fun openForQaIfRunning(context: Context): Boolean { + val activity = activeInstance?.get() ?: return false + if (activity.isFinishing || activity.isDestroyed || activity.webViewRef == null) return false + android.util.Log.i("OpenLessBackendWarmupActivity", "openForQaIfRunning: reusing live instance") + qaOpenPending = true + activity.qaRequested = true + activity.warmupHandler.removeCallbacks(activity.sendToBackground) + context.startActivity(Intent(context, OpenLessBackendWarmupActivity::class.java).apply { + putExtra(EXTRA_SHOW_QA, true) + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP) + addFlags(Intent.FLAG_ACTIVITY_REORDER_TO_FRONT) + addFlags(Intent.FLAG_ACTIVITY_NO_ANIMATION) + }) + return true + } + + /** + * Open the embedded QA UI on the single tracked Tauri host. Never + * starts bare MainActivity — that produced a second blank white host + * stacked in front of the real QA panel. + */ + @JvmStatic + fun openForQa(context: Context) { + qaOpenPending = true + if (openForQaIfRunning(context)) return + android.util.Log.i("OpenLessBackendWarmupActivity", "openForQa: no live instance, starting fresh") + context.startActivity(Intent(context, OpenLessBackendWarmupActivity::class.java).apply { + putExtra(EXTRA_SHOW_QA, true) + putExtra(WRY_ACTIVITY_ID_KEY, kotlin.random.Random.nextInt()) + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + addFlags(Intent.FLAG_ACTIVITY_NO_ANIMATION) + }) + } + + /** Compact live lifecycle snapshot shown in native keyboard settings. */ + fun debugSnapshot(): String { + val activity = activeInstance?.get() + ?: return "host=none activityContext=${nativeActivityContextState()}" + val webView = activity.webViewRef + return "host=present resumed=${activity.activityResumed} focus=${activity.hasWindowFocus()} " + + "finishing=${activity.isFinishing} destroyed=${activity.isDestroyed} " + + "webview=${webView != null} attached=${webView?.isAttachedToWindow ?: false} " + + "visible=${webView?.windowVisibility == android.view.View.VISIBLE} " + + "progress=${webView?.progress ?: -1} activityContext=${nativeActivityContextState()}" + } + + private fun nativeActivityContextState(): String = + runCatching { OpenLessNative.nativeHasRegisteredActivityContext().toString() } + .getOrElse { "error" } + private const val BACKEND_WARMUP_ATTEMPT_KEY = "backend_warmup_attempt_wall_time" private const val BACKEND_WARMUP_RETRY_DELAY_MS = 30_000L @Volatile private var lastWarmupAttemptElapsed = 0L + // Snapshot of OpenLessImeService.isInputPanelCurrentlyShown() taken + // right before this Activity steals the foreground (see the + // postDelayed block below) — consumed once by sendToBackground() to + // decide whether requestInputPanelAfterWarmup() is even worth + // calling. Not reset here on a false read: sendToBackground() only + // acts on it when true, and always clears it back to false itself + // right after, so a stale true never lingers across warmup cycles. + @Volatile + private var restoreInputPanelAfterWarmup = false + /** * Warms the Tauri/Rust backend if it isn't ready yet, from whatever * Context happens to notice first — not just the IME service reacting @@ -140,28 +799,125 @@ class OpenLessBackendWarmupActivity : MainActivity() { val now = android.os.SystemClock.elapsedRealtime() if (now - lastWarmupAttemptElapsed < 5_000L) return if (isRunning()) return - try { + val backendError = try { OpenLessNative.requireBackendContract() + null } catch (error: Throwable) { - val runtimePrefs = context.getSharedPreferences("openless_runtime", Context.MODE_PRIVATE) - val wallNow = System.currentTimeMillis() - val lastAttempt = runtimePrefs.getLong(BACKEND_WARMUP_ATTEMPT_KEY, 0L) - if (wallNow >= lastAttempt && wallNow - lastAttempt < BACKEND_WARMUP_RETRY_DELAY_MS) return - lastWarmupAttemptElapsed = now - runtimePrefs.edit().putLong(BACKEND_WARMUP_ATTEMPT_KEY, wallNow).apply() - android.util.Log.i("OpenLessBackendWarmupActivity", "backend is not ready; launching warmup", error) - Handler(Looper.getMainLooper()).postDelayed({ - runCatching { - context.startActivity(Intent(context, OpenLessBackendWarmupActivity::class.java).apply { - addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) - addFlags(Intent.FLAG_ACTIVITY_EXCLUDE_FROM_RECENTS) - addFlags(Intent.FLAG_ACTIVITY_NO_ANIMATION) - }) - }.onFailure { launchError -> - android.util.Log.w("OpenLessBackendWarmupActivity", "failed to launch warmup", launchError) - } - }, 120L) + error + } + if (backendError != null) { + launchWarmup(context, now, backendError) + return } + // A healthy Rust backend does not require a visible Activity. + // In particular, do not turn a missing Activity Context into a + // foreground Activity launch: that steals focus from Dialer, + // Camera, Alipay, etc. The next explicit settings/launcher open + // will register a fresh Activity Context when a UI is actually + // needed. + if (!OpenLessNative.nativeHasRegisteredActivityContext()) { + android.util.Log.i( + "OpenLessBackendWarmupActivity", + "backend healthy but no Activity context; leaving UI closed", + ) + } + } + + private fun launchWarmup(context: Context, nowElapsed: Long, cause: Throwable) { + val runtimePrefs = context.getSharedPreferences("openless_runtime", Context.MODE_PRIVATE) + val wallNow = System.currentTimeMillis() + val lastAttempt = runtimePrefs.getLong(BACKEND_WARMUP_ATTEMPT_KEY, 0L) + if (wallNow >= lastAttempt && wallNow - lastAttempt < BACKEND_WARMUP_RETRY_DELAY_MS) return + lastWarmupAttemptElapsed = nowElapsed + runtimePrefs.edit().putLong(BACKEND_WARMUP_ATTEMPT_KEY, wallNow).apply() + android.util.Log.i("OpenLessBackendWarmupActivity", "backend is not ready; launching warmup", cause) + OpenLessProcessRestartStats(context, "warmup").recordStart() + Handler(Looper.getMainLooper()).postDelayed({ + // Re-checked here, not just by the caller 120ms ago: something + // else (a launcher-icon tap, a Logo tap) can have already + // created/resumed the host in the meantime. Without this, + // this launch still fires and delivers a no-extras Intent to + // that same singleTask instance — harmless by itself, but it + // was one of the two ingredients (together with imprecise + // settingsRequested timing) behind an on-device black-screen + // race: a genuine settings-open request and this silent + // warmup landing within milliseconds of each other right + // after the OS killed the process in the background. + if (isRunning()) return@postDelayed + // Snapshot taken right here, the last possible moment before + // this launch steals the foreground — see + // restoreInputPanelAfterWarmup's own doc comment. + restoreInputPanelAfterWarmup = OpenLessImeService.isInputPanelCurrentlyShown() + runCatching { + context.startActivity(Intent(context, OpenLessBackendWarmupActivity::class.java).apply { + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + addFlags(Intent.FLAG_ACTIVITY_EXCLUDE_FROM_RECENTS) + addFlags(Intent.FLAG_ACTIVITY_NO_ANIMATION) + }) + }.onFailure { launchError -> + restoreInputPanelAfterWarmup = false + android.util.Log.w("OpenLessBackendWarmupActivity", "failed to launch warmup", launchError) + } + }, 120L) + } + + /** + * Last-resort recovery for the permanently-stuck "main" window + * label state (see webViewCreationWatchdog's doc comment) — Tauri's + * AppHandle.restart() is desktop-only (spawns a new process via + * Command::new(current_exe), meaningless on Android, and would just + * exit(0) the process with nothing to bring it back), so this does + * the Android-native equivalent directly: schedule an alarm to + * relaunch the launcher Activity a moment after this process is + * gone, then kill the process outright. OpenLessRuntimeService's + * dictation/IME state is lost same as any other process kill (the + * same thing already happens whenever the OS reclaims this process + * in the background) — the alternative is leaving the user stuck on + * a black settings screen with no way back except doing this + * exact same recovery manually. + */ + /** @return true if the process was actually restarted, false if skipped (cooldown). */ + private fun restartProcessAsLastResort(context: Context): Boolean { + val runtimePrefs = context.getSharedPreferences("openless_runtime", Context.MODE_PRIVATE) + val wallNow = System.currentTimeMillis() + val lastRestart = runtimePrefs.getLong(LAST_PROCESS_RESTART_KEY, 0L) + if (wallNow >= lastRestart && wallNow - lastRestart < PROCESS_RESTART_COOLDOWN_MS) { + android.util.Log.w( + "OpenLessBackendWarmupActivity", + "stuck main window label but process restart is on cooldown; finishing instance only", + ) + return false + } + runtimePrefs.edit().putLong(LAST_PROCESS_RESTART_KEY, wallNow).apply() + android.util.Log.e( + "OpenLessBackendWarmupActivity", + "main window label permanently stuck; restarting process", + ) + OpenLessProcessRestartStats(context, "stuckwindow").recordStart() + val launchIntent = context.packageManager.getLaunchIntentForPackage(context.packageName) + ?.apply { addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP) } + if (launchIntent != null) { + val pendingIntent = android.app.PendingIntent.getActivity( + context, + 0, + launchIntent, + android.app.PendingIntent.FLAG_IMMUTABLE or android.app.PendingIntent.FLAG_CANCEL_CURRENT, + ) + // Plain set(), not setExactAndAllowWhileIdle(): API 31+ gates + // exact alarms behind the SCHEDULE_EXACT_ALARM permission, + // which this app doesn't otherwise need — relaunch timing + // within a second or two either way is fine here. + val alarmManager = context.getSystemService(Context.ALARM_SERVICE) as android.app.AlarmManager + alarmManager.set( + android.app.AlarmManager.ELAPSED_REALTIME, + android.os.SystemClock.elapsedRealtime() + 500L, + pendingIntent, + ) + } else { + android.util.Log.e("OpenLessBackendWarmupActivity", "no launch intent found; process will not auto-relaunch") + } + android.os.Process.killProcess(android.os.Process.myPid()) + return true } } } diff --git a/openless-all/app/android/kotlin/OpenLessBuildInfo.kt b/openless-all/app/android/kotlin/OpenLessBuildInfo.kt new file mode 100644 index 000000000..e0b5cf5e4 --- /dev/null +++ b/openless-all/app/android/kotlin/OpenLessBuildInfo.kt @@ -0,0 +1,13 @@ +package com.openless.app + +/** + * Manually bumped by 0.01 before each debug build during this iterative + * on-device testing cycle — not the app's real release version — so the + * keyboard settings screen can confirm at a glance which build is actually + * installed. Also doubles as the "did a new build get installed" signal + * OpenLessApplication uses to reset the "unclean shutdown" stat, since a + * count from a previous build isn't meaningful to keep comparing against. + */ +object OpenLessBuildInfo { + const val VERSION = "1.79" +} diff --git a/openless-all/app/android/kotlin/OpenLessClipboardHistory.kt b/openless-all/app/android/kotlin/OpenLessClipboardHistory.kt index c96a8fd8b..f299419a3 100644 --- a/openless-all/app/android/kotlin/OpenLessClipboardHistory.kt +++ b/openless-all/app/android/kotlin/OpenLessClipboardHistory.kt @@ -111,4 +111,11 @@ object OpenLessClipboardHistory { val current = load(context).filterNot { it.text == text } save(context, current) } + + /** Removes every non-favorited entry — the history browser's "清空" (Clear) tab. Favorited entries are kept. */ + @Synchronized + fun clearNonFavorites(context: Context) { + val current = load(context).filter { it.favorite } + save(context, current) + } } diff --git a/openless-all/app/android/kotlin/OpenLessContentWriter.kt b/openless-all/app/android/kotlin/OpenLessContentWriter.kt index 457f068fa..9361ed416 100644 --- a/openless-all/app/android/kotlin/OpenLessContentWriter.kt +++ b/openless-all/app/android/kotlin/OpenLessContentWriter.kt @@ -1,9 +1,15 @@ package com.openless.app import android.content.Context +import android.content.ContentValues import android.net.Uri +import android.os.Build +import android.os.Environment +import android.provider.MediaStore import android.util.Log import androidx.annotation.Keep +import java.io.File +import java.io.IOException /** * Writes bytes to a SAF content:// URI via ContentResolver. @@ -35,4 +41,66 @@ object OpenLessContentWriter { false } } -} + + /** + * Writes an app-generated export into Downloads without a SAF picker. + * + * Android 10+ uses MediaStore public Downloads (no storage permission). + * Older Android writes to the app-specific external Downloads directory + * instead — public Downloads would need WRITE_EXTERNAL_STORAGE which this + * app does not declare. + */ + @Keep + @JvmStatic + fun writePublicDownload(context: Context, fileName: String, bytes: ByteArray): String { + val safeName = fileName + .substringAfterLast('/') + .substringAfterLast('\\') + .trim() + .takeIf { it.isNotEmpty() && it != "." && it != ".." } + ?: "openless-error.log" + @Suppress("DEPRECATION") + val displayFile = File( + Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS), + safeName, + ) + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + val values = ContentValues().apply { + put(MediaStore.MediaColumns.DISPLAY_NAME, safeName) + put(MediaStore.MediaColumns.MIME_TYPE, "text/plain") + put(MediaStore.MediaColumns.RELATIVE_PATH, Environment.DIRECTORY_DOWNLOADS) + } + val resolver = context.contentResolver + val uri = resolver.insert(MediaStore.Downloads.EXTERNAL_CONTENT_URI, values) + ?: throw IOException("MediaStore failed to create Downloads item") + try { + resolver.openOutputStream(uri)?.use { output -> + output.write(bytes) + output.flush() + } ?: throw IOException("MediaStore returned no output stream") + } catch (error: Throwable) { + resolver.delete(uri, null, null) + throw error + } + Log.i(TAG, "wrote ${bytes.size} bytes to public Downloads: $displayFile") + return displayFile.absolutePath + } + + // Pre-Android 10: public Downloads needs WRITE_EXTERNAL_STORAGE which + // this app does not declare. Write to app-specific external Downloads + // instead (no runtime permission; still a real on-device .log path). + val appDownloads = context.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS) + ?: throw IOException("app-specific Downloads directory unavailable") + if (!appDownloads.exists() && !appDownloads.mkdirs()) { + throw IOException("failed to create app-specific Downloads directory") + } + val appFile = File(appDownloads, safeName) + appFile.outputStream().use { output -> + output.write(bytes) + output.flush() + } + Log.i(TAG, "wrote ${bytes.size} bytes to app Downloads (pre-Q): $appFile") + return appFile.absolutePath + } +} \ No newline at end of file diff --git a/openless-all/app/android/kotlin/OpenLessImeService.kt b/openless-all/app/android/kotlin/OpenLessImeService.kt index 297bd2eb5..1e2c84a69 100644 --- a/openless-all/app/android/kotlin/OpenLessImeService.kt +++ b/openless-all/app/android/kotlin/OpenLessImeService.kt @@ -10,6 +10,7 @@ import android.graphics.LinearGradient import android.graphics.Paint import android.graphics.Path import android.graphics.Shader +import android.graphics.Typeface import android.graphics.drawable.GradientDrawable import android.icu.text.Transliterator import android.inputmethodservice.InputMethodService @@ -28,34 +29,201 @@ import android.view.inputmethod.InputMethodManager import android.widget.FrameLayout import android.widget.LinearLayout import android.widget.TextView +import android.widget.Toast /** Minimal system IME surface. Voice transport is intentionally added in a later phase. */ class OpenLessImeService : InputMethodService(), OpenLessOverlayBridge.OverlayStateListener { - private enum class InputMode { VOICE, STROKE, CLIPBOARD, ENGLISH } + internal enum class InputMode { VOICE, STROKE, CLIPBOARD, ENGLISH } private enum class ShiftState { OFF, SHIFT_ONCE, CAPS_LOCK } + // Mirrors iOS's own ABC/123/#+= three-layer model exactly (see + // buildKeyboardView()) instead of the old two-state symbolMode boolean, + // which only ever had room for one merged symbol page. + private enum class EnglishLayer { LETTERS, NUMBERS, SYMBOLS } + // The English QWERTY panel's letter-input semantics — ENGLISH commits + // each letter immediately (see commitEnglishChar()), PINYIN instead + // routes letters into LitePinyinController's own encoding buffer and + // only ever commits a chosen candidate. Long-press space toggles this + // (see buildKeyboardView()'s spaceButton); the panel/View tree itself + // is never rebuilt into a second layout for PINYIN — see + // docs/pinyin-lite/phase-0-audit.md section 7. + internal enum class LatinInputMode { ENGLISH, PINYIN } + + private var lastCancelledImeRequest = 0L + private var imeRequestId = 0L + private var imeSessionId: String? = null + private var pendingCloudArm = false + private var pendingImeStop = false + private var cloudNoteDestination: Pair? = null + + private fun sendImeCommand(action: String): Boolean = try { + val request = org.json.JSONObject().apply { + put("action", action) + put("requestId", imeRequestId) + imeSessionId?.let { put("sessionId", it) } + put("raw", rawModeArmed) + put("quickNote", quickNoteArmed) + put("cloud", cloudNoteArmed) + } + val response = org.json.JSONObject(OpenLessNative.nativeImeCommand(request.toString())) + check(response.optBoolean("ok")) { response.optString("error", "IME command failed") } + true + } catch (_: Throwable) { + setState("error", ui("语音操作失败,请重试", "Voice operation failed; please retry")) + false + } + + private fun cancelImeSession() { + if (imeRequestId != 0L) { lastCancelledImeRequest = imeRequestId; sendImeCommand("cancel") } + imeRequestId = 0L + imeSessionId = null + pendingImeStop = false + cloudNoteDestination = null + pendingCloudArm = false + } + + private fun stopImeSession() { + if (imeSessionId == null) pendingImeStop = true + else if (!sendImeCommand("stop")) { recording = false; processing = false } + } + + private fun onImeSessionEvent(json: String) { + val event = runCatching { org.json.JSONObject(json) }.getOrNull() ?: return + if (event.optLong("requestId") != imeRequestId || imeRequestId == 0L) { + if (event.optLong("requestId") == lastCancelledImeRequest && event.optString("error") == "Persistence") { + Toast.makeText(this, ui("录音清理失败,重新打开应用后重试", "Recording cleanup failed; reopen the app to retry"), Toast.LENGTH_LONG).show() + } + return + } + when (event.optString("kind")) { + "started" -> { + imeSessionId = event.getString("sessionId") + if (pendingCloudArm) { pendingCloudArm = false; cloudNoteArmed = true } + if (cloudNoteArmed && !sendImeCommand("cloud")) { cancelImeSession(); return } + if (pendingImeStop) { pendingImeStop = false; stopImeSession() } + } + "completed" -> { + if (event.optString("sessionId") != imeSessionId) return + val destination = cloudNoteDestination + imeSessionId = null + recording = false + processing = false + cloudNoteArmed = false + if (event.optBoolean("cloud")) submitCloudNoteText(event.optString("text"), destination, imeRequestId) + else if (event.optBoolean("quickNote")) setState("done", ui("笔记已记录", "Note saved")) + else commitImeText(event.optString("text")) + } + "error" -> { + recording = false + processing = false + imeSessionId = null + pendingImeStop = false + pendingCloudArm = false + setState("error", ui("听写失败,请重试", "Dictation failed; please retry")) + } + } + } private var sessionEpoch = 0L - private var recording = false + // Guards the delayed "settle back to Tap to speak" callback — see + // scheduleRevertToIdle(). + private var statusRevertToken = 0L + internal var recording = false private var processing = false - private var inputMode = InputMode.VOICE - private var symbolMode = false - private var strokeNumberMode = false - private var numberSymbolMode = false - private var symbolPageIndex = 0 - private val numberPanelSymbolPages = listOf( - listOf("、", "。", ",", ";", ":", "?", "!", "…", "—", "~", "·", "("), - listOf(")", "《", "》", "“", "”", "‘", "’", "【", "】", "「", "」", "¥"), - listOf("%", "#", "&", "*", "=", "/", "\\", "<", ">", "^", "_", "|"), - ) - private var punctuationGroupIndex = 0 - private val punctuationGroups = listOf( - listOf(",", "°", "?", "!", "~"), - listOf(".", "、", ";", ":", "\""), - listOf("(", ")", "[", "]", "-"), - listOf("@", "#", "$", "%", "&"), - listOf("*", "+", "=", "/", "_"), - ) - private var traditionalOutput = false + // Armed by the mic button's swipe-up gesture while recording is still + // in progress (see onCreateInputView()'s voice-panel branch) — recording + // itself keeps going, this only changes what happens once it eventually + // stops (skip the LLM polish step). Reset whenever a fresh recording + // starts or is cancelled, so it never leaks into a later utterance. + private var rawModeArmed = false + set(value) { + if (value) pendingCloudArm = false + if (value && cloudNoteArmed) { cloudNoteArmed = false; if (cloudNoteArmed) return } + field = value + voiceButton?.rawModeActive = value + // The RAW gesture changes the mode while the recording prompt is + // already visible. Refresh the text color immediately in both + // directions so yellow cannot leak into the next normal session. + status?.setTextColor(recordingAccentColor()) + } + // Armed by the mic button's swipe-left gesture while recording is still + // in progress, or set directly by its idle-swipe-left start — mirrors + // rawModeArmed exactly (see that field's own doc comment), except the + // eventual stop calls nativeStopDictationAsQuickNote() instead of the + // normal/raw stop: the dictation ends, gets archived as a standalone + // note, and is never inserted into the input field (see + // quickNoteDictation()). + private var quickNoteArmed = false + set(value) { + if (value) pendingCloudArm = false + if (value && cloudNoteArmed) { cloudNoteArmed = false; if (cloudNoteArmed) return } + field = value + voiceButton?.quickNoteActive = value + status?.setTextColor(recordingAccentColor()) + } + // The Core session owns retention; this field only displays the accepted destination. + private var cloudNoteArmed = false + set(value) { + if (!value) pendingCloudArm = false + if (value && imeRequestId != 0L && imeSessionId == null) { + pendingCloudArm = true + updateStatus(ui("正在准备云笔记", "Preparing cloud note")) + return + } + if (field == value) return + val previous = field + field = value + if (imeSessionId != null && !sendImeCommand("cloud")) { field = previous; return } + if (value) { + rawModeArmed = false + quickNoteArmed = false + val prefs = getSharedPreferences("openless_ime_ui", MODE_PRIVATE) + cloudNoteDestination = (prefs.getString("key_cloud_note_webhook_url", "") ?: "").trim() to + (prefs.getString("key_cloud_note_webhook_token", "") ?: "").trim() + } + voiceButton?.cloudNoteActive = value + status?.setTextColor(recordingAccentColor()) + } + + /** Status text color while a recording prompt is showing — orange for an armed Raw stop, green for an armed Quick notes stop, red for an armed Cloud notes submit, normal otherwise. Single source of truth for rawModeArmed/quickNoteArmed/cloudNoteArmed's setters and updateStatus() alike, so the three gestures can never disagree on which one currently owns the color. */ + private fun recordingAccentColor(): Int = when { + state == "speaking" && rawModeArmed -> LINK_COLOR_RECORDING_RAW + state == "speaking" && quickNoteArmed -> LINK_COLOR_QUICK_NOTE + state == "speaking" && cloudNoteArmed -> LINK_COLOR_CLOUD_NOTE + else -> statusNormalColor + } + internal var inputMode = InputMode.VOICE + private var englishLayer = EnglishLayer.LETTERS + // Persisted across sessions the same way inputMode is (see + // restoreLatinInputMode()/saveLatinInputMode()) — long-pressing space + // toggles it (see buildKeyboardView()'s spaceButton). The space key's + // own small hint label always names the mode long-press would switch + // TO, not the current one — i.e. it reads "拼音" while latinInputMode + // is ENGLISH, and "英文" while it's PINYIN. + private var latinInputMode = LatinInputMode.ENGLISH + private val litePinyinController by lazy { LitePinyinController(this) } + // The word currently being typed on the English keyboard — appended to + // per letter, trimmed per backspace, cleared at every word boundary + // (space/return/punctuation/candidate tap/mode or panel switch). Never + // touches currentInputConnection itself: every letter is still + // committed immediately, same as before this feature: this buffer only + // tracks what to re-query/replace for candidates. + private val englishComposingWord = StringBuilder() + private var englishCandidateRow: LinearLayout? = null + // The row+divider bar as a whole (shown/hidden together when the + // suggestions setting is toggled) and its overflow "▼" button — see + // buildEnglishCandidateBar(). + private var englishCandidateBarContainer: View? = null + private var englishExpandCandidatesButton: View? = null + private var englishCandidateQueryEpoch = 0L + // Lazy, not eager: the base dictionary/trie only gets built the first + // time the English keyboard is actually opened (see + // EnglishCandidateProvider.ensureLoaded()), so switching to English + // never pays for it until that mode is first used, and other modes + // never pay for it at all. Held as the raw Lazy (not just its .value) + // so onDestroy() below can skip shutdown() when it was never touched. + private val englishCandidateProviderLazy = lazy { EnglishCandidateProvider(this) } + private val englishCandidateProvider get() = englishCandidateProviderLazy.value + internal var traditionalOutput = false // Clipboard panel state: whether ← → ↑ ↓ extend the selection (like // holding Shift on a physical keyboard) instead of just moving the // cursor, whether the history browser sub-panel is showing instead of @@ -73,12 +241,56 @@ class OpenLessImeService : InputMethodService(), OpenLessOverlayBridge.OverlaySt private var state = "idle" private var currentMessage = "点击开始说话" private var status: TextView? = null + private var voiceRawHint: TextView? = null + private var statusNormalColor = Color.GRAY + // Single shared top-level preview bubble for every key across the + // English and stroke keyboards (see wrapWithKeyPreviewOverlay()) — reset + // on every panel rebuild since it lives inside that panel's own root + // View tree, same lifecycle as voiceButton/status below. Null in panels + // that never show a key preview (voice, clipboard, edit); callers use + // the safe-call operator so that's a no-op rather than a crash. + private var keyPreviewOverlay: KeyPreviewOverlay? = null + // Last height_dp:raise_dp baked into the cached input view — compared in + // onStartInputView so leaving keyboard settings and refocusing rebuilds + // when the footprint prefs changed. + private var appliedKeyboardFootprintKey: String? = null + // LETTERS layer's swipe-up-bearing keys (buildEnglishCharKey()'s + // wrapper -> its symbol — row1's digits, row2's @#$%&-+(), row3's + // :;'.,!?), so a swipe-armed drag can retarget across a row the same + // way a plain tap-drag already retargets across letters. Cleared and + // repopulated once per LETTERS render (see addEnglishCharRow()'s own + // row1 call, which always runs first — row2/row3 add straight into + // that same fresh map), since a shift toggle etc. throws the whole + // view tree away via refreshInputView() and would otherwise leave + // stale View keys behind. + private val englishSwipeSymbols = HashMap() private var voiceButton: VoiceButton? = null // Silence-detection for the main voice panel: if the mic capture never // reports a meaningful level for a while after recording starts, the // audio link is probably broken upstream (muted mic, dead capture // session, etc.) even though the UI otherwise looks like it's recording. private var voiceLinkWarning: TextView? = null + // Small always-on status dot next to the Logo (every panel's header + // rebuilds this via buildBrandView()) — a glance-able "is the backend + // voice link actually alive right now" signal, for the symptom where + // tapping the mic silently did nothing (no waveform movement) because + // the link had quietly dropped, then recovered on its own moments + // later. Reassigned on every panel rebuild, same pattern as + // voiceButton/status below. + private var backendLinkIndicator: View? = null + private var backendLinkPulseAnimator: android.animation.ObjectAnimator? = null + // Updated only by the heartbeat check (see startBackendHeartbeat()), + // not by the normal recording/processing/done states — those already + // take priority in updateBackendLinkIndicator(). False means the most + // recent heartbeat found isBackendReady() false. + private var backendLinkHealthy = true + private val backendHeartbeatHandler = Handler(Looper.getMainLooper()) + private val backendHeartbeatRunnable = object : Runnable { + override fun run() { + runBackendHeartbeatCheck() + backendHeartbeatHandler.postDelayed(this, BACKEND_HEARTBEAT_INTERVAL_MS) + } + } private var recordingStartedAtMs = 0L private var maxObservedLevelThisSession = 0f // The exact text this dictation session committed, so the undo/redo @@ -87,6 +299,10 @@ class OpenLessImeService : InputMethodService(), OpenLessOverlayBridge.OverlaySt private var lastDictationEpoch: Long = -1 private var dictationTextUndone = false private var editingDictationResult = false + // Set right before deleteBackward() edits the field, consumed by the + // very next invalidateDictationResultIfTextChanged() call — see + // deleteBackward()'s comment. + private var selfInitiatedTextChange = false // True from the moment the edit mic starts recording until its result // (or a cancel) resolves — independent of editingDictationResult, which // only tracks which PANEL is currently shown. Stopping the edit mic @@ -100,19 +316,12 @@ class OpenLessImeService : InputMethodService(), OpenLessOverlayBridge.OverlaySt // or (if nothing was selected) the whole last dictation result. private var editingOriginalText: String? = null private var editingReplacesWholeResult = false - // True only for the clipboard swipe-left "add correction" flow: the - // spoken result never touches currentInputConnection, it only becomes a - // correction rule. False for the normal "edit dictation result" flow. - private var editingForClipboardCorrection = false - // Whether finishEditWithSpokenReplacement() should record a correction - // rule at all. Defaults true (matches the original always-record - // behavior); the edit panel's checkbox lets the user opt out per-edit - // for edits that are just rewording, not an actual misrecognition worth - // remembering — otherwise every edit silently accumulates a rule, which - // was the reported problem (too many unwanted rules piling up). Always - // true and hidden for the clipboard-correction flow, where recording the - // rule is the entire point of the action. - private var addCorrectionRuleForEdit = true + // Whether finishEditWithSpokenReplacement() should write the spoken + // replacement to the global Dictionary. Defaults false — the edit + // panel's checkbox is an opt-in per-edit, not an opt-out, since most + // edits are just rewording rather than an actual new/misrecognized word + // worth remembering; ticking it is what makes the write happen at all. + private var addToDictionaryForEdit = false private var undoRedoButton: TextView? = null private var editResultButton: TextView? = null // The row holding undoRedoButton/editResultButton, toggled as a whole so @@ -131,26 +340,17 @@ class OpenLessImeService : InputMethodService(), OpenLessOverlayBridge.OverlaySt null } } - private val strokeRepository by lazy { StrokeInputRepository(this) } - private val phraseRepository by lazy { StrokePhraseRepository(this) } - private var strokeCode = "" - private var strokeQueryEpoch = 0L - // In-memory word-segmentation buffer: characters the user has marked with - // 分词 while composing a multi-character word. Never touches the actual - // input connection until the assembled word (or its final character) is - // committed — see segmentStroke()/commitWord(). - private val wordSegments = mutableListOf() - private var lastStrokeCandidates: List = emptyList() - private var confirmedText = "" - private var phraseQueryEpoch = 0L - private var strokePreview: TextView? = null - private var strokeCandidates: LinearLayout? = null + // Owns everything specific to the stroke panel — encode entry, the + // 字候选/联想候选 pipeline, and the number/symbol sub-panel — split out of + // this class into its own file; see StrokeInputController's own doc + // comment for the shared-vs-owned boundary. + private val strokeController by lazy { StrokeInputController(this) } // Single source of truth for the encode row's blue text, reused as-is // (not a new similar blue) for the selected/first candidate. A property, // not a val, since it must track the live system theme, not whatever it // resolved to when the service was first created. - private val strokeEncodeAccentColor: Int + internal val strokeEncodeAccentColor: Int get() = tone(Color.rgb(120, 190, 255), Color.rgb(20, 110, 220)) /** @@ -175,25 +375,25 @@ class OpenLessImeService : InputMethodService(), OpenLessOverlayBridge.OverlaySt } /** Picks `dark` or `light` for the current system theme — the one place every themed color in this file goes through. */ - private fun tone(dark: Int, light: Int): Int = if (isDarkTheme) dark else light + internal fun tone(dark: Int, light: Int): Int = if (isDarkTheme) dark else light // Mirrors whatever's currently in strokeCandidates (word/stroke matches // or phrase associations) as plain (label, action) pairs, so the "show // more" overlay can replay the exact same set without duplicating the // stroke-match vs. association branching logic. - private var candidateOverlayEntries: List Unit>> = emptyList() + internal var candidateOverlayEntries: List Unit>> = emptyList() - private fun ui(zh: String, en: String) = if (englishUi) en else zh + internal fun ui(zh: String, en: String) = if (englishUi) en else zh - private fun outputScript(text: String): String { + internal fun outputScript(text: String): String { if (!traditionalOutput) return text return runCatching { simplifiedToTraditional.transliterate(text) }.getOrDefault(text) } - // The 5th stroke has no plain-text glyph in the encode preview — it's + // The 5th stroke has no plain-text glyph in the encode preview — it's // drawn as the same shape as the "5" key's own icon (an ImageSpan), so // the preview and the key read as the same stroke instead of the bare // "乙" character. - private fun displayStrokeCode(code: String): CharSequence { + internal fun displayStrokeCode(code: String): CharSequence { val builder = android.text.SpannableStringBuilder() code.forEach { stroke -> when (stroke) { @@ -230,15 +430,25 @@ class OpenLessImeService : InputMethodService(), OpenLessOverlayBridge.OverlaySt } return object : android.graphics.drawable.Drawable() { override fun draw(canvas: Canvas) { - val unit = minOf(bounds.width(), bounds.height()) / 100f + // The path's ink only ever spanned y=[34.67, 68] of its + // original 100-unit design space (a bare third of the box). + // Rescaled + repositioned here so the ink fills the box with + // a small, even margin top and bottom — matching how a + // plain character glyph (e.g. the "丨" stroke's own) sits + // within its line — instead of either that ~33%-tall island + // or (an earlier attempt) flush against the bottom edge with + // all the blank space pushed to the top. + val inkTop = 34.67f + val inkBottom = 68f + val marginFraction = 0.12f + val boxHeight = bounds.height().toFloat() + val unit = (boxHeight * (1f - 2f * marginFraction)) / (inkBottom - inkTop) + val topPixels = bounds.top + boxHeight * marginFraction val cx = bounds.left + bounds.width() / 2f - val top = bounds.top.toFloat() - // Mirrors the "5" key's own icon shape, including its top - // trimmed by 1/6 (bottom unchanged): 28 + (68-28)/6 ≈ 34.67. val path = Path().apply { - moveTo(cx + 1f * unit, top + 34.67f * unit) - lineTo(cx - 14f * unit, top + 68f * unit) - lineTo(cx + 16f * unit, top + 68f * unit) + moveTo(cx + 1f * unit, topPixels) + lineTo(cx - 14f * unit, topPixels + (inkBottom - inkTop) * unit) + lineTo(cx + 16f * unit, topPixels + (inkBottom - inkTop) * unit) } canvas.drawPath(path, glyphPaint) } @@ -258,7 +468,7 @@ class OpenLessImeService : InputMethodService(), OpenLessOverlayBridge.OverlaySt } } - private fun toggleScriptPreference() { + internal fun toggleScriptPreference() { traditionalOutput = !traditionalOutput getSharedPreferences("openless_ime_ui", MODE_PRIVATE).edit() .putBoolean("stroke_traditional_output", traditionalOutput) @@ -275,13 +485,63 @@ class OpenLessImeService : InputMethodService(), OpenLessOverlayBridge.OverlaySt } } - private fun saveInputMode(mode: InputMode) { + internal fun saveInputMode(mode: InputMode) { getSharedPreferences("openless_ime_ui", MODE_PRIVATE).edit() .putString("input_mode", mode.name.lowercase()) .apply() } - private fun refreshLanguage() { + private fun restoreLatinInputMode() { + latinInputMode = when (getSharedPreferences("openless_ime_ui", MODE_PRIVATE).getString("latin_input_mode", "english")) { + "pinyin" -> LatinInputMode.PINYIN + else -> LatinInputMode.ENGLISH + } + } + + private fun saveLatinInputMode(mode: LatinInputMode) { + getSharedPreferences("openless_ime_ui", MODE_PRIVATE).edit() + .putString("latin_input_mode", mode.name.lowercase()) + .apply() + } + + /** + * Long-press-space entry point (see buildKeyboardView()'s spaceButton). + * Per the lite-pinyin plan's 2026-09-25 revision: switching modes never + * saves a half-typed pinyin encoding (matches englishComposingWord's own + * "clear, don't carry over" behavior at every other boundary), and never + * commits anything on its own — only a candidate tap or the English + * path's own commitEnglishChar() ever calls commitText(). + */ + private fun toggleLatinInputMode() { + englishComposingWord.clear() + englishCandidateQueryEpoch++ + litePinyinController.clear() + latinInputMode = if (latinInputMode == LatinInputMode.ENGLISH) LatinInputMode.PINYIN else LatinInputMode.ENGLISH + saveLatinInputMode(latinInputMode) + refreshLatinCandidateBar() + performKeyHaptic() + refreshInputView() + } + + /** + * Populates/hides the shared candidate bar for whichever of + * English/Pinyin mode is current — used both on a cold panel build and + * right after toggleLatinInputMode() switches modes. Pinyin's own bar + * visibility isn't gated by englishSuggestionsEnabled() (a separate, + * English-word-completion-specific preference); its candidates are core + * to the mode, not an optional suggestion feature. + */ + private fun refreshLatinCandidateBar() { + if (latinInputMode == LatinInputMode.PINYIN) { + litePinyinController.preloadAsync() + englishCandidateBarContainer?.visibility = View.VISIBLE + renderPinyinCandidates(emptyList()) + } else { + updateEnglishCandidates() + } + } + + internal fun refreshLanguage() { val locale = getSharedPreferences("openless_ime_ui", MODE_PRIVATE) .getString("locale", null) ?: resources.configuration.locales[0].toLanguageTag() englishUi = !locale.startsWith("zh", ignoreCase = true) @@ -296,44 +556,97 @@ class OpenLessImeService : InputMethodService(), OpenLessOverlayBridge.OverlaySt } } + private var learningMigrationComplete = false + internal fun personalizedLearningAllowed(): Boolean { + val editor = currentInputEditorInfo ?: return false + return learningMigrationComplete && ImeLearningPolicy.allowsLearning(editor.inputType, editor.imeOptions) + } + override fun onCreate() { super.onCreate() + learningMigrationComplete = ImeLearningPolicy.migrate(this) restoreInputMode() restoreScriptPreference() + restoreLatinInputMode() activeInstance = java.lang.ref.WeakReference(this) OpenLessOverlayBridge.imeListener = this - OpenLessOverlayBridge.imeTextListener = ::commitImeText + OpenLessOverlayBridge.imeSessionListener = ::onImeSessionEvent startRuntimeService() - // Load the offline stroke dictionary while the IME is idle, so the - // first stroke key does not pay the asset parsing cost. - strokeRepository.preloadAsync() + // Load the offline stroke dictionary and the (much larger, ~220k- + // phrase) association dictionary while the IME is idle — the latter + // used to load lazily on whichever word's commit was the first to + // ever need an association, which is exactly the moment a user is + // sitting there waiting for the candidate row to update. + strokeController.preloadAsync() clipboardManager.addPrimaryClipChangedListener(clipboardHistoryListener) + backendHeartbeatHandler.post(backendHeartbeatRunnable) } override fun onDestroy() { + cancelImeSession() if (activeInstance?.get() === this) { activeInstance = null } if (OpenLessOverlayBridge.imeListener === this) { OpenLessOverlayBridge.imeListener = null + OpenLessOverlayBridge.imeSessionListener = null } - if (OpenLessOverlayBridge.imeTextListener != null) { - OpenLessOverlayBridge.imeTextListener = null - } + backendHeartbeatHandler.removeCallbacks(backendHeartbeatRunnable) + backendLinkPulseAnimator?.cancel() clipboardManager.removePrimaryClipChangedListener(clipboardHistoryListener) stopRuntimeService() - strokeRepository.shutdown() - phraseRepository.shutdown() + strokeController.shutdown() + if (englishCandidateProviderLazy.isInitialized()) englishCandidateProviderLazy.value.shutdown() + litePinyinController.shutdown() + // Marks this as a clean end-of-session for + // OpenLessApplication.recordUncleanShutdownIfAny() — an abrupt + // process kill (native crash, OOM) never reaches this line, which + // is exactly the "unclean" case that helper is trying to detect. + getSharedPreferences("openless_runtime", MODE_PRIVATE).edit().putBoolean("session_alive", false).apply() super.onDestroy() } - // Every panel is a fixed 300dp height by design; keep Android's own - // fullscreen-extract heuristic from ever engaging regardless of host - // app/orientation quirks (the actual measured-height bug turned out to - // be unrelated — see SwipeModeContainer.onMeasure — but this is still - // the standard, harmless precaution most custom keyboards apply). + // Panel content height comes from keyboard settings (stretch); optional + // raise adds empty space below so the whole keyboard sits higher without + // stretching keys. Keep Android's fullscreen-extract heuristic off. override fun onEvaluateFullscreenMode(): Boolean = false + /** Key-panel height in px (prefs `keyboard_height_dp`, default 300). */ + internal fun keyboardPanelHeightPx(): Int = panelHeightPx(this) + + internal fun keyboardPanelHeightDp(): Int = panelHeightDp(this) + + /** Empty space below the key panel that lifts keys (prefs `keyboard_raise_dp`). */ + internal fun keyboardRaiseHeightPx(): Int = raiseHeightPx(this) + + /** + * Appends a bottom spacer so the key panel sits higher on screen. + * Always wraps (raise may be 0) so later pref changes can grow the + * spacer without rebuilding the whole IME view tree. + */ + private fun applyKeyboardRaise(content: View): View { + val panelPx = keyboardPanelHeightPx() + val raisePx = keyboardRaiseHeightPx() + return RaisedKeyboardHost(this).apply { + orientation = LinearLayout.VERTICAL + layoutParams = ViewGroup.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + panelPx + raisePx, + ) + addView( + content, + LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, panelPx), + ) + addView( + View(this@OpenLessImeService).apply { + setBackgroundColor(tone(Color.rgb(48, 48, 48), Color.rgb(242, 242, 246))) + visibility = if (raisePx > 0) View.VISIBLE else View.GONE + }, + LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, raisePx), + ) + } + } + override fun onCreateInputView(): View { refreshLanguage() startRuntimeService() @@ -345,14 +658,28 @@ class OpenLessImeService : InputMethodService(), OpenLessOverlayBridge.OverlaySt // this ordering fixes. The original edit-result flow only ever // triggered from Voice mode, so this ordering issue never showed up // before the clipboard flow started reusing the same panel. - if (editingDictationResult) return buildEditPanel() - if (inputMode == InputMode.ENGLISH) return buildKeyboardView() - if (inputMode == InputMode.STROKE) return if (strokeNumberMode) buildStrokeNumberView() else buildStrokeView() - if (inputMode == InputMode.CLIPBOARD) return if (clipboardHistoryMode) buildClipboardHistoryView() else buildClipboardView() + val content = when { + editingDictationResult -> buildEditPanel() + inputMode == InputMode.ENGLISH -> wrapWithKeyPreviewOverlay(buildKeyboardView()) + inputMode == InputMode.STROKE -> wrapWithKeyPreviewOverlay( + if (strokeController.strokeNumberMode) strokeController.buildStrokeNumberView() + else strokeController.buildStrokeView(), + ) + inputMode == InputMode.CLIPBOARD -> + if (clipboardHistoryMode) buildClipboardHistoryView() else buildClipboardView() + else -> buildVoiceInputView() + } + val view = applyKeyboardRaise(content) + appliedKeyboardFootprintKey = keyboardFootprintKey() + return view + } + + /** Voice panel + floating dictation-result overlay (former onCreateInputView body). */ + private fun buildVoiceInputView(): View { val panel = SwipeModeContainer(this) { direction -> swipeInputMode(direction) }.apply { orientation = LinearLayout.VERTICAL - layoutParams = ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, dp(300)) - minimumHeight = dp(300) + layoutParams = ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, keyboardPanelHeightPx()) + minimumHeight = keyboardPanelHeightPx() setPadding(dp(16), dp(8), dp(16), dp(4)) setBackgroundColor(tone(Color.rgb(48, 48, 48), Color.rgb(242, 242, 246))) clipChildren = false @@ -362,11 +689,13 @@ class OpenLessImeService : InputMethodService(), OpenLessOverlayBridge.OverlaySt ViewGroup.LayoutParams.MATCH_PARENT, dp(38), )) + statusNormalColor = tone(Color.rgb(190, 190, 190), Color.rgb(110, 110, 115)) status = TextView(this).apply { text = displayStatus(currentMessage) textSize = 16f + if (englishUi) typeface = Typeface.create("sans-serif-medium", Typeface.NORMAL) gravity = android.view.Gravity.CENTER - setTextColor(tone(Color.rgb(190, 190, 190), Color.rgb(110, 110, 115))) + setTextColor(statusNormalColor) setPadding(0, dp(6), 0, dp(4)) } panel.addView(status, LinearLayout.LayoutParams( @@ -378,27 +707,231 @@ class OpenLessImeService : InputMethodService(), OpenLessOverlayBridge.OverlaySt isClickable = true setOnClickListener { toggleDictation() } contentDescription = ui("OpenLess 语音听写", "OpenLess dictation") + // All four swipe gestures follow the same pattern: a LIVE + // boolean re-evaluated every ACTION_MOVE (not a one-way latch), + // a matching live visual on VoiceButton (idle capsule orange for + // swipe-up-to-raw / green for swipe-left-to-quick-notes / red for + // swipe-right-to-cloud-notes, waveform light red for swipe-down-to- + // cancel — all four ease back the moment the finger drops back + // below their own threshold), a single haptic tick on every + // crossing in either direction, and the actual action (cancel / + // enter raw mode / enter quick-notes mode / enter cloud-notes mode) + // only committed on release, gated on whichever one is still + // active at that instant. Nothing commits mid-drag — swipe down + // used to cancel the moment the threshold was first crossed + // during the move, but that gave no chance to back out of an + // accidental trigger, same reasoning as the swipe-up threshold's + // own move-to-release change earlier. + // + // requestDisallowInterceptTouchEvent is still claimed + // unconditionally on DOWN (not only once a threshold is + // crossed), matching buildEnglishCharKey()'s same fix earlier + // this session: without it, a downward drag here is + // indistinguishable from the enclosing SwipeModeContainer's own + // "long downward drag dismisses the keyboard" gesture, and that + // ancestor can steal the sequence before our own dp(30) check + // ever sees the full distance. + var downX = 0f + var downY = 0f + var swipeUpActive = false + var swipeDownActive = false + // Same shape as swipeUpActive (no `recording` gate on the MOVE + // check — armable both from idle and mid-recording), just on the + // horizontal axis — see cloudNoteArmed's own doc comment for what + // release does in each case. + var swipeRightActive = false + // Same shape again, mirrored to the other horizontal direction — + // see quickNoteArmed's own doc comment for what release does. + var swipeLeftActive = false + setOnTouchListener { view, event -> + when (event.actionMasked) { + MotionEvent.ACTION_DOWN -> { + downX = event.x + downY = event.y + swipeUpActive = false + swipeDownActive = false + swipeRightActive = false + swipeLeftActive = false + view.parent?.requestDisallowInterceptTouchEvent(true) + } + MotionEvent.ACTION_MOVE -> { + if (recording) { + val active = event.y - downY >= dp(30) + if (active != swipeDownActive) { + swipeDownActive = active + voiceButton?.armedForCancel = active + performKeyHaptic() + } + } + val active = downY - event.y >= dp(30) + if (active != swipeUpActive) { + swipeUpActive = active + voiceButton?.armedForRawSwipe = active + // One tick every time the threshold is + // crossed, either direction — entering AND + // leaving the armed zone both get felt, not + // just the eventual release. + performKeyHaptic() + } + val activeRight = event.x - downX >= dp(30) + if (activeRight != swipeRightActive) { + swipeRightActive = activeRight + voiceButton?.armedForCloudNote = activeRight + performKeyHaptic() + } + val activeLeft = downX - event.x >= dp(30) + if (activeLeft != swipeLeftActive) { + swipeLeftActive = activeLeft + voiceButton?.armedForQuickNote = activeLeft + performKeyHaptic() + } + } + MotionEvent.ACTION_UP -> { + if (swipeDownActive) { + performDoubleKeyHaptic() + cancelDictation() + } else if (swipeUpActive) { + if (recording && !rawModeArmed) { + rawModeArmed = true + performDoubleKeyHaptic() + updateStatus(currentMessage) + } else if (!recording && !processing) { + toggleDictation() + if (recording) { + rawModeArmed = true + performDoubleKeyHaptic() + updateStatus(currentMessage) + } + } + } else if (swipeRightActive) { + if (recording && !cloudNoteArmed) { + cloudNoteArmed = true + performDoubleKeyHaptic() + updateStatus(currentMessage) + } else if (!recording && !processing) { + toggleDictation(initialCloud = true) + if (recording) { + cloudNoteArmed = true + performDoubleKeyHaptic() + updateStatus(currentMessage) + } + } + } else if (swipeLeftActive) { + if (recording && !quickNoteArmed) { + quickNoteArmed = true + performDoubleKeyHaptic() + updateStatus(currentMessage) + } else if (!recording && !processing) { + toggleDictation() + if (recording) { + quickNoteArmed = true + performDoubleKeyHaptic() + updateStatus(currentMessage) + } + } + } + swipeUpActive = false + swipeDownActive = false + swipeRightActive = false + swipeLeftActive = false + voiceButton?.armedForRawSwipe = false + voiceButton?.armedForCancel = false + voiceButton?.armedForQuickNote = false + voiceButton?.armedForCloudNote = false + } + MotionEvent.ACTION_CANCEL -> { + swipeUpActive = false + swipeDownActive = false + swipeRightActive = false + swipeLeftActive = false + voiceButton?.armedForRawSwipe = false + voiceButton?.armedForCancel = false + voiceButton?.armedForQuickNote = false + voiceButton?.armedForCloudNote = false + } + } + false + } } - val buttonHolder = LinearLayout(this).apply { - orientation = LinearLayout.VERTICAL - gravity = android.view.Gravity.CENTER + // FrameLayout, not LinearLayout: voiceButton is centered purely by + // its own Gravity.CENTER, independent of whatever else shares this + // holder — a LinearLayout would instead center the *whole stack* of + // children as one block, which pushed the mic icon itself upward + // (away from the status line above) once voiceRawHint became a + // permanently-participating sibling instead of an occasional one. + // voiceRawHint/voiceLinkWarning use that same CENTER anchor plus a + // fixed topMargin, so they sit a fixed distance below the mic + // without perturbing its own position at all. + val buttonHolder = FrameLayout(this).apply { setBackgroundColor(Color.TRANSPARENT) clipChildren = false clipToPadding = false } - buttonHolder.addView(voiceButton!!, LinearLayout.LayoutParams(dp(176), dp(72))) + buttonHolder.addView(voiceButton!!, FrameLayout.LayoutParams(dp(176), dp(72), android.view.Gravity.CENTER)) + + // Small companion hint — deliberately muted (low size + alpha) so it + // doesn't compete with "Tap to speak" for attention, but + // discoverable enough that a user notices the three swipe gestures + // exist at all. Sits just above the footer row (right above the + // Return key, which is horizontally centered there the same way + // this hint is) rather than directly under the mic — out of the way + // of the mic capsule and its own waveform/pill animations, but still + // close enough to read while a hand hovers over the mic. Tap or + // long-press explains what Raw mode actually does via a Toast, + // rather than building a dedicated tooltip bubble for a single + // one-off explanation. Hidden entirely while recording/thinking in + // an ordinary (non-Raw) dictation — see rawModeHintVisible() — + // since at that point it's neither teaching a still-relevant + // gesture nor confirming anything, just noise. + val rawModeTooltip = ui("Raw模式,语音原样转写,不做AI润色整理", "Raw mode, verbatim transcription without AI polishing") + voiceRawHint = TextView(this).apply { + text = rawModeHintText() + textSize = 11f + if (englishUi) typeface = Typeface.create("sans-serif-medium", Typeface.NORMAL) + gravity = android.view.Gravity.CENTER + setTextColor(rawModeHintColor()) + visibility = if (rawModeHintVisible()) View.VISIBLE else View.GONE + isClickable = true + setOnClickListener { Toast.makeText(this@OpenLessImeService, rawModeTooltip, Toast.LENGTH_SHORT).show() } + setOnLongClickListener { + Toast.makeText(this@OpenLessImeService, rawModeTooltip, Toast.LENGTH_SHORT).show() + true + } + } + buttonHolder.addView( + voiceRawHint, + FrameLayout.LayoutParams( + ViewGroup.LayoutParams.WRAP_CONTENT, + ViewGroup.LayoutParams.WRAP_CONTENT, + android.view.Gravity.BOTTOM or android.view.Gravity.CENTER_HORIZONTAL, + ).apply { + bottomMargin = dp(6) + }, + ) voiceLinkWarning = TextView(this).apply { text = ui("检测到麦克风无声音,点击重启应用", "No mic audio detected — tap to restart the app") textSize = 16f - setTypeface(typeface, android.graphics.Typeface.BOLD) + if (englishUi) { + typeface = Typeface.create("sans-serif-medium", Typeface.NORMAL) + } else { + setTypeface(typeface, android.graphics.Typeface.BOLD) + } gravity = android.view.Gravity.CENTER setTextColor(Color.rgb(255, 90, 90)) - setPadding(dp(12), dp(8), dp(12), 0) + setPadding(dp(12), 0, dp(12), 0) visibility = View.GONE isClickable = true setOnClickListener { restartApp() } } - buttonHolder.addView(voiceLinkWarning, LinearLayout.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT)) + buttonHolder.addView( + voiceLinkWarning, + FrameLayout.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT, android.view.Gravity.CENTER).apply { + // Below where the Raw hint sits, so the two never overlap on + // the rare occasion both are visible at once (Raw-armed + // recording with a simultaneous mic-silence warning). + topMargin = dp(70) + }, + ) panel.addView(buttonHolder, LinearLayout.LayoutParams( ViewGroup.LayoutParams.MATCH_PARENT, 0, @@ -415,7 +948,7 @@ class OpenLessImeService : InputMethodService(), OpenLessOverlayBridge.OverlaySt // them can never nudge the mic (or anything else) out of its // original centered position, regardless of visibility state. val overlayHost = FrameLayout(this).apply { - layoutParams = ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, dp(300)) + layoutParams = ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, keyboardPanelHeightPx()) } overlayHost.addView(panel, FrameLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT)) overlayHost.addView(buildDictationResultOverlay(), FrameLayout.LayoutParams( @@ -437,7 +970,7 @@ class OpenLessImeService : InputMethodService(), OpenLessOverlayBridge.OverlaySt gravity = android.view.Gravity.CENTER_VERTICAL } header.addView(buildBrandView(), LinearLayout.LayoutParams(0, dp(38), 1f)) - header.addView(buildModeToggle(), LinearLayout.LayoutParams(dp(165), dp(38))) + header.addView(buildModeToggle(), LinearLayout.LayoutParams(dp(240), dp(38))) return header } @@ -451,7 +984,7 @@ class OpenLessImeService : InputMethodService(), OpenLessOverlayBridge.OverlaySt view.background = GradientDrawable().apply { shape = GradientDrawable.RECTANGLE cornerRadius = (height / 2).toFloat() - setColor(tone(Color.rgb(54, 54, 54), Color.rgb(225, 225, 228))) + setColor(tone(Color.rgb(60, 60, 60), Color.rgb(225, 225, 228))) } } @@ -476,7 +1009,11 @@ class OpenLessImeService : InputMethodService(), OpenLessOverlayBridge.OverlaySt val returnLabel = if (englishUi) "Return" else "换行" val returnButton = keyboardKey(returnLabel, 1f, action = { sendEnterKey() }).apply { textSize = if (englishUi) 18f else 20f - if (!englishUi) setTypeface(typeface, android.graphics.Typeface.BOLD) + if (englishUi) { + typeface = Typeface.create("sans-serif-medium", Typeface.NORMAL) + } else { + setTypeface(typeface, android.graphics.Typeface.BOLD) + } contentDescription = ui("回车", "Return") layoutParams = LinearLayout.LayoutParams(dp(120), footerButtonHeight) flattenFooterButton(this, footerButtonHeight) @@ -484,15 +1021,9 @@ class OpenLessImeService : InputMethodService(), OpenLessOverlayBridge.OverlaySt val backspaceButton = keyboardKey( "⌫", 1f, - action = { - currentInputConnection?.deleteSurroundingText(1, 0) - invalidateDictationResultIfTextChanged() - }, + action = { deleteBackward() }, repeatOnLongPress = true, - repeatAction = { - currentInputConnection?.deleteSurroundingText(1, 0) - invalidateDictationResultIfTextChanged() - }, + repeatAction = { deleteBackward() }, ).apply { textSize = 22f contentDescription = ui("退格", "Backspace") @@ -540,27 +1071,44 @@ class OpenLessImeService : InputMethodService(), OpenLessOverlayBridge.OverlaySt return row } - private fun refreshInputView() { + internal fun refreshInputView() { setInputView(onCreateInputView()) } /** - * Same as refreshInputView(), but slides the freshly built panel in from - * the side matching [slideDirection] (+1 = from the right, -1 = from the - * left) — used for swipe-triggered mode switches so the transition - * reads as a continuation of the finger's drag, not an instant swap. - * The starting offset uses the screen width rather than the new view's - * own (not yet measured at this point) width. + * Same as refreshInputView(), but slides the freshly built panel's + * content in from the side matching [slideDirection] (+1 = from the + * right, -1 = from the left) — used for swipe-triggered mode switches + * and toggle-segment taps so the transition reads as a continuation of + * the gesture, not an instant swap. Only the content BELOW the header + * slides; the header itself (every panel builder addView()'s it first, + * so it's always childAt(0) — Logo, mode toggle, backend-link + * indicator) stays anchored in place across the transition instead of + * sliding off and back with the rest, which previously read as the + * Logo "jumping" on every switch. The starting offset uses the screen + * width rather than each child's own (not yet measured at this point) + * width. */ private fun refreshInputView(slideDirection: Int) { val newView = onCreateInputView() setInputView(newView) - newView.translationX = resources.displayMetrics.widthPixels.toFloat() * slideDirection - newView.animate() - .translationX(0f) - .setDuration(180L) - .setInterpolator(android.view.animation.DecelerateInterpolator()) - .start() + val offset = resources.displayMetrics.widthPixels.toFloat() * slideDirection + // RaisedKeyboardHost wraps [panel, raiseSpacer] — slide the panel's + // own children (skip header at 0), not the raise spacer. + val container = when (newView) { + is RaisedKeyboardHost -> newView.getChildAt(0) as? ViewGroup + is ViewGroup -> newView + else -> null + } ?: return + for (index in 1 until container.childCount) { + val child = container.getChildAt(index) + child.translationX = offset + child.animate() + .translationX(0f) + .setDuration(180L) + .setInterpolator(android.view.animation.DecelerateInterpolator()) + .start() + } } /** @@ -579,8 +1127,8 @@ class OpenLessImeService : InputMethodService(), OpenLessOverlayBridge.OverlaySt // risking a taller/"fullscreen"-looking measurement. val root = SwipeModeContainer(this) { direction -> swipeInputMode(direction) }.apply { orientation = LinearLayout.VERTICAL - layoutParams = ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, dp(300)) - minimumHeight = dp(300) + layoutParams = ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, keyboardPanelHeightPx()) + minimumHeight = keyboardPanelHeightPx() setPadding(dp(16), dp(8), dp(16), dp(10)) setBackgroundColor(tone(Color.rgb(48, 48, 48), Color.rgb(242, 242, 246))) clipChildren = false @@ -620,46 +1168,43 @@ class OpenLessImeService : InputMethodService(), OpenLessOverlayBridge.OverlaySt topMargin = dp(14) }) - // Hidden for the clipboard swipe-left flow, where recording a - // correction rule is the entire point of the action, not an - // optional side effect of fixing dictated text in place. Shown for - // the normal edit flow so an edit that's just rewording (not an - // actual misrecognition) doesn't silently pile up an unwanted rule. - if (!editingForClipboardCorrection) { - val correctionToggleRow = LinearLayout(this).apply { - gravity = android.view.Gravity.CENTER_VERTICAL - isClickable = true - setOnClickListener { - addCorrectionRuleForEdit = !addCorrectionRuleForEdit - refreshInputView() - } - } - correctionToggleRow.addView( - TextView(this).apply { - text = if (addCorrectionRuleForEdit) "☑" else "☐" - textSize = 16f - setTextColor(if (addCorrectionRuleForEdit) strokeEncodeAccentColor else tone(Color.rgb(140, 140, 140), Color.rgb(150, 150, 154))) - }, - LinearLayout.LayoutParams(dp(22), ViewGroup.LayoutParams.WRAP_CONTENT).apply { marginEnd = dp(6) }, - ) - correctionToggleRow.addView( - TextView(this).apply { - text = ui("同时加入纠错规则(下次自动改正)", "Also add as a correction rule") - textSize = 12f - setTextColor(tone(Color.rgb(180, 180, 180), Color.rgb(120, 120, 125))) - }, - LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f), - ) - root.addView( - correctionToggleRow, - LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT).apply { topMargin = dp(8) }, - ) - } - // Flexible filler mirrors the empty middle area in the reference, // pushing the divider + mic row down to the bottom of the panel. root.addView(View(this), LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, 0, 1f)) + // Placed low in the panel, just above the bottom divider — not + // right under the text chip — and sized 1.5x (checkbox glyph and + // label both) so it reads as a deliberate decision, not a small + // afterthought easy to miss/mis-tap. + val correctionToggleRow = LinearLayout(this).apply { + gravity = android.view.Gravity.CENTER_VERTICAL + isClickable = true + setOnClickListener { + addToDictionaryForEdit = !addToDictionaryForEdit + refreshInputView() + } + } + correctionToggleRow.addView( + TextView(this).apply { + text = if (addToDictionaryForEdit) "☑" else "☐" + textSize = 24f + setTextColor(if (addToDictionaryForEdit) strokeEncodeAccentColor else tone(Color.rgb(140, 140, 140), Color.rgb(150, 150, 154))) + }, + LinearLayout.LayoutParams(dp(33), ViewGroup.LayoutParams.WRAP_CONTENT).apply { marginEnd = dp(9) }, + ) + correctionToggleRow.addView( + TextView(this).apply { + text = ui("加入到字典中", "Add to dictionary") + textSize = 18f + setTextColor(tone(Color.rgb(180, 180, 180), Color.rgb(120, 120, 125))) + }, + LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f), + ) + root.addView( + correctionToggleRow, + LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT).apply { bottomMargin = dp(10) }, + ) + root.addView(buildDivider(), LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, dp(1)).apply { bottomMargin = dp(10) }) @@ -681,10 +1226,11 @@ class OpenLessImeService : InputMethodService(), OpenLessOverlayBridge.OverlaySt setTypeface(typeface, android.graphics.Typeface.BOLD) setTextColor(tone(Color.WHITE, Color.rgb(30, 30, 34))) } + statusNormalColor = tone(Color.rgb(160, 160, 160), Color.rgb(120, 120, 125)) status = TextView(this).apply { text = displayStatus(currentMessage) textSize = 12f - setTextColor(tone(Color.rgb(160, 160, 160), Color.rgb(120, 120, 125))) + setTextColor(statusNormalColor) setPadding(0, dp(2), 0, 0) } textStack.addView(title, LinearLayout.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT)) @@ -699,18 +1245,36 @@ class OpenLessImeService : InputMethodService(), OpenLessOverlayBridge.OverlaySt private fun buildDivider(): View = View(this).apply { setBackgroundColor(tone(Color.rgb(68, 68, 68), Color.rgb(215, 215, 218))) } - private fun buildModeToggle(): View = ModeToggle(this, inputMode, isDarkTheme) { selected -> selectInputMode(selected) } + // Tapping a toggle segment now slides the panel the same way a + // left/right swipe does (same refreshInputView(slideDirection) + // mechanics) — direction follows the segments' own left-to-right + // InputMode.entries order (see ModeToggle.onDraw()), so tapping a + // segment to the right slides in from the right, matching what a + // swipe in that direction would already do. Tapping the + // already-selected segment has no direction to slide from. + internal fun buildModeToggle(): View = ModeToggle(this, inputMode, isDarkTheme) { selected -> + val direction = when { + selected.ordinal > inputMode.ordinal -> 1 + selected.ordinal < inputMode.ordinal -> -1 + else -> null + } + selectInputMode(selected, slideDirection = direction) + } /** * White-on-transparent wordmark used in every panel header, replacing - * the old text label. Wrapped in a FrameLayout so it can be rendered at - * a fixed half-size intrinsic box (start-aligned, vertically centered) - * regardless of how tall the header row around it is — the existing - * call sites all pass their own LinearLayout.LayoutParams for that row. + * the old text label, plus the backend-link status dot right after it. + * A plain horizontal LinearLayout (not a FrameLayout) so the dot just + * lands after the logo/text in sequence with a fixed gap, regardless of + * which of the two logo/text branches below actually renders — the + * existing call sites all pass their own LinearLayout.LayoutParams for + * this whole row. */ - private fun buildBrandView(): View { + internal fun buildBrandView(): View { val bitmap = brandLogoBitmap - val wrapper = FrameLayout(this).apply { + val wrapper = LinearLayout(this).apply { + orientation = LinearLayout.HORIZONTAL + gravity = android.view.Gravity.START or android.view.Gravity.CENTER_VERTICAL isClickable = true contentDescription = ui("打开 OpenLess 设置", "Open OpenLess settings") setOnClickListener { openSettings() } @@ -738,19 +1302,43 @@ class OpenLessImeService : InputMethodService(), OpenLessOverlayBridge.OverlaySt if (!isDarkTheme) { colorFilter = android.graphics.PorterDuffColorFilter(Color.rgb(30, 30, 34), android.graphics.PorterDuff.Mode.SRC_IN) } - }, FrameLayout.LayoutParams(widthPx, heightPx, android.view.Gravity.START or android.view.Gravity.CENTER_VERTICAL)) + }, LinearLayout.LayoutParams(widthPx, heightPx)) } else { wrapper.addView(TextView(this).apply { text = "OpenLess" textSize = 18f setTypeface(typeface, android.graphics.Typeface.BOLD) setTextColor(tone(Color.WHITE, Color.rgb(30, 30, 34))) - }, FrameLayout.LayoutParams( - ViewGroup.LayoutParams.WRAP_CONTENT, - ViewGroup.LayoutParams.WRAP_CONTENT, - android.view.Gravity.START or android.view.Gravity.CENTER_VERTICAL, - )) + }, LinearLayout.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT)) + } + // Status dot: see backendLinkIndicator's field comment. Reassigned + // here on every panel rebuild (buildBrandView() is shared by every + // panel's own header), then immediately colored so it never shows a + // stale state (e.g. still green right after a rebuild that happened + // while actually recording) — visible everywhere per product + // request, not just the Voice panel. + backendLinkIndicator = View(this).apply { + background = GradientDrawable().apply { + shape = GradientDrawable.OVAL + setColor(LINK_COLOR_READY) + } } + // Continuous breathing pulse so the dot reads as "alive" rather + // than a static badge — cancels whatever animator was running on + // the previous panel's now-discarded indicator first, since each + // rebuild creates a brand new View instance. + backendLinkPulseAnimator?.cancel() + backendLinkPulseAnimator = android.animation.ObjectAnimator.ofFloat(backendLinkIndicator, View.ALPHA, 1f, 0.35f).apply { + duration = BACKEND_LINK_READY_PULSE_DURATION_MS + repeatMode = android.animation.ValueAnimator.REVERSE + repeatCount = android.animation.ValueAnimator.INFINITE + start() + } + wrapper.addView( + backendLinkIndicator, + LinearLayout.LayoutParams(dp(12), dp(12)).apply { marginStart = dp(5) }, + ) + updateBackendLinkIndicator() return wrapper } @@ -764,21 +1352,18 @@ class OpenLessImeService : InputMethodService(), OpenLessOverlayBridge.OverlaySt editingOriginalText = null inputMode = selected saveInputMode(selected) - symbolMode = false - strokeNumberMode = false - numberSymbolMode = false - symbolPageIndex = 0 - punctuationGroupIndex = 0 + englishLayer = EnglishLayer.LETTERS + englishComposingWord.clear() + englishCandidateQueryEpoch++ + litePinyinController.clear() + litePinyinController.resetAssociationContext() + strokeController.resetForModeSwitch() clipboardSelectionMode = false clipboardSelectionAnchor = -1 clipboardSelectionActive = -1 clipboardHistoryMode = false clipboardHistoryCategory = OpenLessClipboardHistory.Category.ALL shiftState = ShiftState.OFF - strokeCode = "" - strokeQueryEpoch++ - confirmedText = "" - phraseQueryEpoch++ if (slideDirection != null) refreshInputView(slideDirection) else refreshInputView() } @@ -789,17 +1374,49 @@ class OpenLessImeService : InputMethodService(), OpenLessOverlayBridge.OverlaySt * The new panel slides in from the side matching the ordinal direction * (not necessarily the raw finger direction — see SwipeModeContainer). */ - private fun swipeInputMode(direction: Int) { + internal fun swipeInputMode(direction: Int) { val modes = InputMode.entries val next = modes[(inputMode.ordinal + direction).coerceIn(0, modes.lastIndex)] if (next != inputMode) selectInputMode(next, slideDirection = direction) } + /** + * English ABC/123/#+= keyboard — key relationships (rows, per-row key + * count, relative widths, and the 123/ABC/#+= switching logic) follow + * iOS 17's own English keyboard; colors, key surface, and every other + * visual detail are OpenLess's existing key styling (roundedButton()/ + * tone()), matching the stroke panel. See EnglishLayer for the ABC/123/ + * #+= state and README's "英文键盘 iOS 17 布局" entry for the full + * rationale. + */ + /** + * Wraps a panel root in a plain FrameLayout with a KeyPreviewOverlay as + * its second (top-drawn) child, so every key preview bubble in that + * panel renders above the whole keyboard — unclipped by the panel's own + * clipChildren/candidate-row/mode-toggle bounds — without taking part in + * its layout at all. The wrapper takes on the panel's own layoutParams + * (every panel already sets its own fixed MATCH_PARENT x keyboardPanelHeightPx()) so + * this changes nothing about the panel's measured size; the panel then + * fills the wrapper exactly as it used to fill the IME window directly. + */ + private fun wrapWithKeyPreviewOverlay(content: View): View { + val host = FrameLayout(this).apply { + layoutParams = content.layoutParams ?: ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, keyboardPanelHeightPx()) + clipChildren = false + clipToPadding = false + } + host.addView(content, FrameLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT)) + val overlay = KeyPreviewOverlay(this, isDarkTheme) + keyPreviewOverlay = overlay + host.addView(overlay, FrameLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT)) + return host + } + private fun buildKeyboardView(): View { val root = SwipeModeContainer(this) { direction -> swipeInputMode(direction) }.apply { orientation = LinearLayout.VERTICAL - layoutParams = ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, dp(300)) - minimumHeight = dp(300) + layoutParams = ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, keyboardPanelHeightPx()) + minimumHeight = keyboardPanelHeightPx() setPadding(dp(8), dp(8), dp(8), dp(8)) setBackgroundColor(tone(Color.rgb(48, 48, 48), Color.rgb(242, 242, 246))) } @@ -807,7 +1424,7 @@ class OpenLessImeService : InputMethodService(), OpenLessOverlayBridge.OverlaySt gravity = android.view.Gravity.CENTER_VERTICAL } header.addView(buildBrandView(), LinearLayout.LayoutParams(0, dp(38), 1f)) - header.addView(buildModeToggle(), LinearLayout.LayoutParams(dp(165), dp(38))) + header.addView(buildModeToggle(), LinearLayout.LayoutParams(dp(240), dp(38))) // This panel's own root padding (8dp) is narrower than the voice panel's // (16dp), which it needs for its body rows. Compensate with margins so // the header/toggle still land at the same canonical 16dp/8dp inset as @@ -818,454 +1435,299 @@ class OpenLessImeService : InputMethodService(), OpenLessOverlayBridge.OverlaySt marginEnd = dp(8) }) - // 保持和 Typeless 类似的五排结构:数字、字母三排、底部功能排。 - addKeyboardRow(root, listOf("1", "2", "3", "4", "5", "6", "7", "8", "9", "0")) - if (symbolMode) { - addKeyboardRow(root, listOf("-", "/", ":", ";", "(", ")", "$", "&", "@", "\"")) - addKeyboardRow(root, listOf(".", ",", "?", "!", "'", "#", "%", "*", "+", "=")) - addKeyboardRow(root, listOf("[", "]", "{", "}", "_", "\\", "|", "~", "<", ">")) - } else { - addKeyboardRow(root, listOf("q", "w", "e", "r", "t", "y", "u", "i", "o", "p")) - addKeyboardRow(root, listOf("a", "s", "d", "f", "g", "h", "j", "k", "l")) - addKeyboardRow(root, listOf("⇧", "z", "x", "c", "v", "b", "n", "m", "⌫")) + root.addView(buildEnglishCandidateBar(), LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT)) + refreshLatinCandidateBar() + + when (englishLayer) { + EnglishLayer.LETTERS -> { + addEnglishCharRow( + root, + listOf("q", "w", "e", "r", "t", "y", "u", "i", "o", "p"), + swipeSymbols = listOf("1", "2", "3", "4", "5", "6", "7", "8", "9", "0"), + ) + // iOS insets this row by half a key on each side (9 keys + // spanning the same width as the 10-key rows above/below); + // 0.5f spacers on either side of 1f-weight letter keys + // reproduce that without a second, differently-measured row. + val row2 = LinearLayout(this).apply { gravity = android.view.Gravity.CENTER } + row2.addView(View(this), LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.MATCH_PARENT, 0.5f)) + listOf("a", "s", "d", "f", "g", "h", "j", "k", "l") + .zip(listOf("@", "#", "$", "%", "&", "-", "+", "(", ")")) + .forEach { (key, symbol) -> row2.addView(buildEnglishCharKey(key, 1f, swipeSymbol = symbol)) } + row2.addView(View(this), LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.MATCH_PARENT, 0.5f)) + root.addView(row2, LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, 0, 1f)) + + val row3 = LinearLayout(this).apply { gravity = android.view.Gravity.CENTER } + row3.addView(keyboardKey("⇧", 1.5f, action = { cycleEnglishShift() })) + listOf("z", "x", "c", "v", "b", "n", "m") + .zip(listOf(":", ";", "'", ".", ",", "!", "?")) + .forEach { (key, symbol) -> row3.addView(buildEnglishCharKey(key, 1f, swipeSymbol = symbol)) } + row3.addView( + keyboardKey("⌫", 1.5f, action = { englishDeleteBackward() }, repeatOnLongPress = true, repeatAction = { englishDeleteBackward() }).apply { + // Same cherry red as the stroke panel's own + // right-side action rail, matching the Return key + // right next to it. + background = roundedButton(Color.rgb(153, 26, 40), dp(5)) + setTextColor(Color.WHITE) + }, + ) + root.addView(row3, LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, 0, 1f)) + } + EnglishLayer.NUMBERS -> { + addEnglishCharRow(root, listOf("1", "2", "3", "4", "5", "6", "7", "8", "9", "0")) + addEnglishCharRow(root, listOf("-", "/", ":", ";", "(", ")", "$", "&", "@", "\"")) + addEnglishSymbolActionRow(root, listOf(".", ",", "?", "!", "'")) + } + EnglishLayer.SYMBOLS -> { + addEnglishCharRow(root, listOf("[", "]", "{", "}", "#", "%", "^", "*", "+", "=")) + addEnglishCharRow(root, listOf("_", "\\", "|", "~", "<", ">", "€", "£", "¥", "•")) + addEnglishSymbolActionRow(root, listOf(".", ",", "?", "!", "'")) + } } val bottom = LinearLayout(this).apply { gravity = android.view.Gravity.CENTER_VERTICAL } - val modeButton = keyboardKey(if (symbolMode) "ABC" else ui("符号", "#+="), 1f, action = { - symbolMode = !symbolMode - shiftState = ShiftState.OFF - refreshInputView() - }) - val spaceButton = keyboardKey("", 2.7f, action = { + // Same sans-serif-medium as the letter grid (buildEnglishCharKey()'s + // letterView), applied after the fact rather than as a keyboardKey() + // parameter — keyboardKey() is shared by every panel's keys, and + // only these two English bottom-row labels are meant to match the + // letters' new weight for now. Size/color/position left exactly as + // keyboardKey() already set them. + val modeButton = keyboardKey(if (englishLayer == EnglishLayer.LETTERS) "123" else "ABC", 1.3f, action = { + handleEnglishBottomModeToggle() + }).apply { typeface = Typeface.create("sans-serif-medium", Typeface.NORMAL) } + // "Space" stays keyboardKey()'s own plain centered label (own + // View, own vertical centering, untouched by the hint) — per + // real-device feedback, side-by-side (hint right-aligned next to + // "Space", both 12sp) reads much better than the two stacked-line + // approaches tried first (a single two-line SpannableString label, + // and before that a padding-pushed overlay), both of which fought + // this row's actual (tighter than assumed) real height. + val spaceButton = keyboardKey("Space", 5f, action = { + // Space never commits a pinyin candidate (see + // toggleLatinInputMode()'s own doc comment) — a short press + // always just inserts a literal space, in both modes, and + // abandons whatever was mid-composition. This is deliberate: + // it's a software keyboard, not a physical one where "space + // selects the first candidate" is a decades-old muscle-memory + // convention worth preserving. + finalizeEnglishComposingWord() + litePinyinController.clear() currentInputConnection?.commitText(" ", 1) - }) - val returnButton = keyboardKey("Return", 1.35f, action = { - sendEnterKey() - }) + }, longPressAction = { toggleLatinInputMode() }).apply { + // Same size as Return (17sp — keyboardKey()'s own special case + // for that label), per real-device feedback. + textSize = 17f + } + // Own View, overlaid on top of spaceButton (not inside its own + // text) — left-aligned, vertically centered, non-interactive so + // touches still reach the key beneath it. Shows BOTH labels + // stacked (拼音 always on top, 英文 always on the bottom — fixed + // position, per user request) rather than just naming the target + // mode — whichever is currently active is cherry red, the other + // gray, so long-press's effect is legible at a glance either way. + val spaceHint = TextView(this).apply { + val pinyinLabel = ui("拼音", "PINYIN") + val englishLabel = ui("英文", "ENGLISH") + val hintText = "$pinyinLabel\n$englishLabel" + val englishStart = hintText.length - englishLabel.length + val activeColor = if (isDarkTheme) Color.rgb(190, 45, 60) else Color.rgb(153, 26, 40) + val inactiveColor = tone(Color.rgb(150, 150, 150), Color.rgb(140, 140, 145)) + text = android.text.SpannableString(hintText).apply { + setSpan( + android.text.style.ForegroundColorSpan(if (latinInputMode == LatinInputMode.PINYIN) activeColor else inactiveColor), + 0, pinyinLabel.length, android.text.Spannable.SPAN_EXCLUSIVE_EXCLUSIVE, + ) + setSpan( + android.text.style.ForegroundColorSpan(if (latinInputMode == LatinInputMode.ENGLISH) activeColor else inactiveColor), + englishStart, hintText.length, android.text.Spannable.SPAN_EXCLUSIVE_EXCLUSIVE, + ) + } + textSize = 12f + gravity = android.view.Gravity.CENTER + setTypeface(typeface, android.graphics.Typeface.BOLD) + isClickable = false + // The actual bug behind every earlier "text is just missing" + // attempt at a FrameLayout overlay on a keyboardKey(): that key + // has its own non-zero elevation+translationZ (dp(5)+dp(1), see + // keyboardKey()'s own body) for its raised-surface look, and on + // API 21+ ViewGroup draws children in Z order — not insertion + // order — whenever their Z values differ. spaceButton's Z was + // silently winning and painting straight over this View despite + // being added to the FrameLayout first. A higher Z here forces + // this View to actually draw on top. + translationZ = dp(10).toFloat() + } + val spaceWrapper = FrameLayout(this).apply { + addView(spaceButton, FrameLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT)) + addView( + spaceHint, + FrameLayout.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT, android.view.Gravity.CENTER_VERTICAL or android.view.Gravity.START).apply { + marginStart = dp(14) + }, + ) + layoutParams = LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.MATCH_PARENT, 5f) + } + val returnButton = keyboardKey("Return", 1.7f, action = { + // Plan 3.3: "Enter 有候选时提交第一候选,无编码时使用原有回车行为" + // — reuses candidateOverlayEntries (already the current row's + // (label, action) pairs, pinyin or English, set by whichever + // render*Candidates() last ran) rather than a second field + // duplicating the same list. + val firstPinyinCandidate = candidateOverlayEntries.firstOrNull() + if (latinInputMode == LatinInputMode.PINYIN && firstPinyinCandidate != null) { + firstPinyinCandidate.second.invoke() + } else { + finalizeEnglishComposingWord() + sendEnterKey() + } + }).apply { + typeface = Typeface.create("sans-serif-medium", Typeface.NORMAL) + // Same cherry red as the stroke panel's own right-side action + // rail (Color.rgb(153, 26, 40)), per product request; fixed + // white text regardless of theme since keyboardKey()'s own + // default text color reads poorly against red in light theme. + background = roundedButton(Color.rgb(153, 26, 40), dp(5)) + setTextColor(Color.WHITE) + } bottom.addView(modeButton) - bottom.addView(spaceButton) + bottom.addView(spaceWrapper) bottom.addView(returnButton) root.addView(bottom, LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, 0, 1f)) return root } - private fun buildStrokeView(): View { - // Matches the punctuation rail's own 0.16f width share below (body's - // "0.16f/0.65f/0.19f" split) so a downward drag anywhere on the rail - // is excluded from swipe-to-dismiss and left entirely to SwipeRail. - val root = SwipeModeContainer(this, verticalDismissExclusionRatio = 0.16f) { direction -> swipeInputMode(direction) }.apply { - orientation = LinearLayout.VERTICAL - layoutParams = ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, dp(300)) - minimumHeight = dp(300) - setPadding(dp(4), dp(3), dp(4), dp(3)) - setBackgroundColor(tone(Color.rgb(48, 48, 48), Color.rgb(242, 242, 246))) + /** + * A plain character row (digits/symbols, no shift-sensitivity) built + * from buildEnglishCharKey() — key preview, no drag-to-adjacent-key + * distinction from a letter row since neither cares about shift. + * + * @param swipeSymbols Same size as [keys] when given (the QWERTY top + * row's own q..p -> 1..0 mapping) — forwarded 1:1 as each key's own + * swipe-up symbol. Left null for every other row (NUMBERS/SYMBOLS + * layers are already digits/symbols themselves; nothing to swipe up + * to). Clears englishSwipeSymbols first — this is always the first + * row built for a fresh LETTERS render (see buildKeyboardView()), so + * row2/row3's own direct buildEnglishCharKey() calls right after can + * add straight into the same now-fresh map without re-clearing it. + */ + private fun addEnglishCharRow(parent: LinearLayout, keys: List, swipeSymbols: List? = null) { + if (swipeSymbols != null) englishSwipeSymbols.clear() + val row = LinearLayout(this).apply { gravity = android.view.Gravity.CENTER } + keys.forEachIndexed { index, key -> + row.addView(buildEnglishCharKey(key, 1f, swipeSymbol = swipeSymbols?.getOrNull(index))) } - val header = LinearLayout(this).apply { gravity = android.view.Gravity.CENTER_VERTICAL } - header.addView(buildBrandView(), LinearLayout.LayoutParams(0, dp(38), 1f)) - header.addView(buildModeToggle(), LinearLayout.LayoutParams(dp(165), dp(38))) - // Stroke mode's root padding is much tighter (4dp/3dp) to fit its dense - // grid. Compensate with margins so the header/toggle still land at the - // same canonical 16dp/8dp inset as every other panel. - root.addView(header, LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, dp(38)).apply { - marginStart = dp(12) - marginEnd = dp(12) - topMargin = dp(5) - }) + parent.addView(row, LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, 0, 1f)) + } - // Stroke mode follows the reference layout: a compact stroke row, - // candidate row, punctuation column, stroke grid, and action rail. - // Encode + candidate rows are fixed-height (24dp + 36dp = 60dp, same - // total as before this pass) and never resize with content — only - // the candidate list scrolls horizontally — so the stroke keys below - // never move. Both rows share one rounded background (an existing - // panel color, not a new one) so they read as a single continuous - // strip rather than two separate cards. - val top = LinearLayout(this).apply { - orientation = LinearLayout.VERTICAL - background = buildEncodeAreaBackground() - } - val strokeRow = LinearLayout(this).apply { gravity = android.view.Gravity.CENTER_VERTICAL } - strokePreview = TextView(this).apply { - text = "" - textSize = 16.5f - setTextColor(strokeEncodeAccentColor) - gravity = android.view.Gravity.CENTER_VERTICAL - setSingleLine(true) - setPadding(dp(10), 0, 0, 0) - } - strokeRow.addView(strokePreview, LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.MATCH_PARENT, 1f)) - // Clear-code button: a 40x30dp hit target with a small glyph, not a - // heavy independent button — tapping it is the same clearStrokes() - // already wired to the action rail's "清除" key. - strokeRow.addView( - TextView(this).apply { - text = "✕" - textSize = 13f - gravity = android.view.Gravity.CENTER - setTextColor(tone(Color.rgb(150, 150, 150), Color.rgb(130, 130, 135))) - contentDescription = ui("清除笔画编码", "Clear stroke code") - setOnClickListener { clearStrokes() } + /** + * 123 page's row 3 (`#+= . , ? ! ' ⌫`) and #+= page's row 3 + * (`123 . , ? ! ' ⌫`) — same shape, only the leading toggle + * key's label/action differs, so both layers share this one builder. + */ + private fun addEnglishSymbolActionRow(parent: LinearLayout, middleKeys: List) { + val row = LinearLayout(this).apply { gravity = android.view.Gravity.CENTER } + val toggleLabel = if (englishLayer == EnglishLayer.NUMBERS) "#+=" else "123" + row.addView(keyboardKey(toggleLabel, 1.5f, action = { handleEnglishRow3ModeToggle() })) + middleKeys.forEach { row.addView(buildEnglishCharKey(it, 1f)) } + row.addView( + keyboardKey("⌫", 1.5f, action = { englishDeleteBackward() }, repeatOnLongPress = true, repeatAction = { englishDeleteBackward() }).apply { + background = roundedButton(Color.rgb(153, 26, 40), dp(5)) + setTextColor(Color.WHITE) }, - LinearLayout.LayoutParams(dp(40), ViewGroup.LayoutParams.MATCH_PARENT), ) - top.addView(strokeRow, LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, dp(24))) - - val candidateRow = LinearLayout(this).apply { gravity = android.view.Gravity.CENTER_VERTICAL } - // A plain setOnTouchListener on the ScrollView never actually fires - // here: each candidate is its own clickable keyboardKey() view, so - // it claims ACTION_DOWN before the ScrollView's own onTouchEvent - // ever runs. Overriding onInterceptTouchEvent instead runs at the - // right point in the dispatch chain — before any child gets a - // chance to claim the touch — so it reliably blocks - // SwipeModeContainer's mode-switch gesture from stealing a drag - // that starts on top of a candidate button. - val candidatesScroll = object : android.widget.HorizontalScrollView(this) { - override fun onInterceptTouchEvent(ev: android.view.MotionEvent): Boolean { - if (ev.actionMasked == android.view.MotionEvent.ACTION_DOWN) { - parent?.requestDisallowInterceptTouchEvent(true) - } - return super.onInterceptTouchEvent(ev) - } - }.apply { - isHorizontalScrollBarEnabled = false - isFillViewport = false - overScrollMode = View.OVER_SCROLL_NEVER - strokeCandidates = LinearLayout(context).apply { gravity = android.view.Gravity.CENTER_VERTICAL } - strokeCandidates?.orientation = LinearLayout.HORIZONTAL - addView(strokeCandidates, ViewGroup.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.MATCH_PARENT)) - } - candidateRow.addView(candidatesScroll, LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.MATCH_PARENT, 1f)) - // "Show more" — opens the full candidate/association list in a - // floating overlay instead of growing this row or the panel height. - val expandCandidatesButton = TextView(this).apply { - text = "▾" - textSize = 14f - gravity = android.view.Gravity.CENTER - setTextColor(tone(Color.rgb(180, 180, 180), Color.rgb(130, 130, 135))) - contentDescription = ui("展开更多候选", "Show more candidates") - } - expandCandidatesButton.setOnClickListener { showCandidateOverlay(expandCandidatesButton) } - candidateRow.addView(expandCandidatesButton, LinearLayout.LayoutParams(dp(28), ViewGroup.LayoutParams.MATCH_PARENT)) - top.addView(candidateRow, LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, dp(36))) - root.addView(top, LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, dp(60))) - - val body = LinearLayout(this).apply { gravity = android.view.Gravity.CENTER } - // Swiping the rail up/down cycles through punctuationGroups instead of - // scrolling — one swipe always advances exactly one group. - val punctuation = SwipeRail(this) { direction -> - val count = punctuationGroups.size - punctuationGroupIndex = ((punctuationGroupIndex + direction) % count + count) % count - refreshInputView() - }.apply { - orientation = LinearLayout.VERTICAL - gravity = android.view.Gravity.CENTER - // Zero vertical padding so the rail's own top/bottom edges land - // exactly on the grid/actions columns' top/bottom edges (all - // three share the same MATCH_PARENT body height) — horizontal - // padding is kept since it only insets key width, not row - // position. - setPadding(dp(2), 0, dp(2), 0) - background = roundedButton(tone(Color.rgb(45, 45, 45), Color.rgb(230, 230, 234)), dp(4)) - } - punctuationGroups[punctuationGroupIndex].forEachIndexed { index, mark -> - punctuation.addView(keyboardKey(mark, 1f, action = { currentInputConnection?.commitText(mark, 1) }).apply { - textSize = 18f - // The rail is one connected key surface; separators provide the only visual split. - background = GradientDrawable().apply { setColor(Color.TRANSPARENT) } - layoutParams = LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, 0, 1f) - }) - if (index < 4) { - punctuation.addView(View(this).apply { - setBackgroundColor(tone(Color.rgb(28, 28, 28), Color.rgb(205, 205, 210))) - }, LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, dp(1))) - } + parent.addView(row, LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, 0, 1f)) + } + + /** Bottom-row toggle: always jumps straight to/from Letters, regardless of which of Numbers/Symbols was showing (matches iOS's own "ABC" key). */ + private fun handleEnglishBottomModeToggle() { + englishLayer = if (englishLayer == EnglishLayer.LETTERS) EnglishLayer.NUMBERS else EnglishLayer.LETTERS + shiftState = ShiftState.OFF + finalizeEnglishComposingWord() + refreshInputView() + } + + /** Row-3 toggle: only ever switches between Numbers and Symbols, never touches Letters (matches iOS's own "#+="/"123" key). */ + private fun handleEnglishRow3ModeToggle() { + englishLayer = if (englishLayer == EnglishLayer.NUMBERS) EnglishLayer.SYMBOLS else EnglishLayer.NUMBERS + refreshInputView() + } + + private fun cycleEnglishShift() { + // Cycles lowercase -> capitalize-next -> caps-lock -> lowercase. + shiftState = when (shiftState) { + ShiftState.OFF -> ShiftState.SHIFT_ONCE + ShiftState.SHIFT_ONCE -> ShiftState.CAPS_LOCK + ShiftState.CAPS_LOCK -> ShiftState.OFF } - // Match the reference proportions: both side rails occupy the same share of the panel. - body.addView(punctuation, LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.MATCH_PARENT, 0.16f)) + refreshInputView() + } - val grid = LinearLayout(this).apply { orientation = LinearLayout.VERTICAL; gravity = android.view.Gravity.CENTER } - val strokeRows = listOf( - listOf("1\n一" to "h", "2\n丨" to "s", "3\n丿" to "p"), - listOf("4\n丶" to "n", "5\n乙" to "z", "6\n通配" to "*"), - listOf("7\n分词" to " ", "8\n:" to ":", "9\n;" to ";"), - listOf("繁" to "script", "🎙" to "voice", "符号" to "symbols"), - ) - strokeRows.forEach { rowItems -> - val row = LinearLayout(this).apply { gravity = android.view.Gravity.CENTER } - rowItems.forEach { (label, code) -> - val key = if (code == "script") keyboardKey(label, 1f, action = { - toggleScriptPreference() - }, graphicCode = "script").apply { - if (traditionalOutput) { - background = roundedButton(tone(Color.rgb(112, 78, 92), Color.rgb(232, 205, 213)), dp(5)) - } - } else if (code == "voice") keyboardKey("0", 1f, action = { - currentInputConnection?.commitText(" ", 1) - }, swipeUpAction = { - currentInputConnection?.commitText("0", 1) - }, swipePreview = "0", microphoneIcon = true).apply { - setOnLongClickListener { - inputMode = InputMode.VOICE - saveInputMode(inputMode) - clearStrokes() - refreshInputView() - if (!recording) toggleDictation() - true - } - } else { - val swipeDigit = label.substringBefore("\n").takeIf { it.length == 1 && it[0].isDigit() } - keyboardKey(label, 1f, action = { - when (code) { - "symbols" -> { - strokeNumberMode = true - numberSymbolMode = true - symbolPageIndex = 0 - refreshInputView() - } - " " -> segmentStroke() - else -> if (code in listOf("h", "s", "p", "n", "z", "*")) appendStroke(code) else currentInputConnection?.commitText(code, 1) - } - }, swipeUpAction = swipeDigit?.let { digit -> - { currentInputConnection?.commitText(digit, 1) } - }, swipePreview = swipeDigit, strokeIconCode = code.takeIf { - it in listOf("h", "s", "p", "n", "z") - }, graphicCode = code.takeIf { - it in listOf("*", ":", ";", " ", "symbols") - }) - } - key.textSize = if (code == "voice") 10f else 17f - // dp(1) on every side gives a uniform ~2dp gap both ways - // (keys stay clearly separated) and puts as much of the - // reclaimed margin as possible into visible key size, not - // new padding. The action rail's vertical margin is kept at - // the same dp(1)/dp(1) below so row top/bottom edges still - // land exactly together across both columns. - key.layoutParams = LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.MATCH_PARENT, 1f).apply { - setMargins(dp(1), dp(1), dp(1), dp(1)) - } - row.addView(key) - } - grid.addView(row, LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, 0, 1f)) - } - body.addView(grid, LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.MATCH_PARENT, 0.65f)) - - val actions = LinearLayout(this).apply { orientation = LinearLayout.VERTICAL; gravity = android.view.Gravity.CENTER } - listOf("←" to { deleteStroke() }, "↵" to { sendEnterKey() }, "清除" to { clearStrokes() }, "123" to { - strokeNumberMode = true - numberSymbolMode = false - symbolPageIndex = 0 - refreshInputView() - }).forEach { (label, action) -> - actions.addView(keyboardKey(label, 1f, action, repeatOnLongPress = label == "←", repeatAction = action, - graphicActionCode = label).apply { - textSize = if (label == "←" || label == "↵") 30f else 17f - // Vertical margin matches the grid keys' dp(1)/dp(1) exactly - // so every row's top/bottom edge lines up across both - // columns; horizontal margin is independent (single column). - layoutParams = LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, 0, 1f).apply { - setMargins(dp(1), dp(1), dp(1), dp(1)) - } - background = roundedButton(Color.rgb(153, 26, 40), dp(5)) - }) + /** Backspace on the English keyboard: same field edit as everywhere else, plus keeping englishComposingWord (and therefore the candidate bar) in sync. In Pinyin mode, deletes from the (never-committed) encoding instead — falls back to the normal field edit only once the encoding is already empty (plan 3.3: "编码为空时 Backspace 恢复现有普通删除"). */ + private fun englishDeleteBackward() { + if (latinInputMode == LatinInputMode.PINYIN && litePinyinController.backspace { renderPinyinCandidates(it) }) { + return } - body.addView(actions, LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.MATCH_PARENT, 0.19f)) - root.addView(body, LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, 0, 1f)) - // Rebuilds caused by switching back from the numeric panel must restore - // both the visible code and its candidates from the retained buffer. - if (strokeCode.isNotEmpty()) { - strokePreview?.text = displayStrokeCode(strokeCode) - refreshStrokeCandidates(strokeCode) - } - return root - } - - /** Numeric/symbol quick panel; pending stroke input is intentionally preserved. */ - private fun buildStrokeNumberView(): View { - refreshLanguage() - val root = SwipeModeContainer(this) { direction -> swipeInputMode(direction) }.apply { - orientation = LinearLayout.VERTICAL - layoutParams = ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, dp(300)) - minimumHeight = dp(300) - setPadding(dp(8), dp(8), dp(8), dp(8)) - setBackgroundColor(tone(Color.rgb(48, 48, 48), Color.rgb(242, 242, 246))) - } - val header = LinearLayout(this).apply { gravity = android.view.Gravity.CENTER_VERTICAL } - header.addView(buildBrandView().apply { setPadding(dp(8), 0, 0, 0) }, LinearLayout.LayoutParams(0, dp(38), 1f)) - header.addView(buildModeToggle(), LinearLayout.LayoutParams(dp(165), dp(38))) - // This panel's own root padding (8dp) is narrower than the voice panel's - // (16dp). Compensate with margins so the header/toggle still land at the - // same canonical 16dp/8dp inset as every other panel. - root.addView(header, LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, dp(38)).apply { - marginStart = dp(8) - marginEnd = dp(8) - }) - - // Backspace/Enter/Voice/Return/Symbols stay in Chinese regardless of UI - // language: they're functional keys on the number/symbol panel, not - // content the user is composing. - val rows = if (numberSymbolMode) { - val page = numberPanelSymbolPages[symbolPageIndex.coerceIn(numberPanelSymbolPages.indices)] - listOf( - page.subList(0, 4) + "退格", - page.subList(4, 8) + "回车", - page.subList(8, 12) + "语音", - listOf("▲", "${symbolPageIndex + 1}/${numberPanelSymbolPages.size}", "▼", "数字", "返回"), - ) - } else { - listOf( - listOf("@", "1", "2", "3", "退格"), - listOf(":", "4", "5", "6", "回车"), - listOf(",", "7", "8", "9", "语音"), - // "0" sits directly under "8", flanked by +/. — the same - // layout convention as a phone dial pad's "* 0 #" row. - listOf("+", "0", ".", "符号", "返回"), - ) - } - val body = LinearLayout(this).apply { orientation = LinearLayout.VERTICAL } - rows.forEachIndexed { rowIndex, rowItems -> - val row = LinearLayout(this).apply { gravity = android.view.Gravity.CENTER } - rowItems.forEachIndexed { index, label -> - val isAction = index == rowItems.lastIndex - val action: () -> Unit = when { - rowIndex == 0 && isAction -> ({ currentInputConnection?.deleteSurroundingText(1, 0) }) - rowIndex == 1 && isAction -> ({ sendEnterKey() }) - rowIndex == 2 && isAction -> ({ - inputMode = InputMode.VOICE - saveInputMode(inputMode) - strokeNumberMode = false - numberSymbolMode = false - symbolPageIndex = 0 - refreshInputView() - }) - // Symbol-page navigation only replaces row 3's first three - // cells (previously "+ - .") while in symbol mode; the - // middle cell is just a page indicator, not clickable. - rowIndex == 3 && index == 0 && numberSymbolMode -> ({ - symbolPageIndex = (symbolPageIndex - 1 + numberPanelSymbolPages.size) % numberPanelSymbolPages.size - refreshInputView() - }) - rowIndex == 3 && index == 1 && numberSymbolMode -> ({}) - rowIndex == 3 && index == 2 && numberSymbolMode -> ({ - symbolPageIndex = (symbolPageIndex + 1) % numberPanelSymbolPages.size - refreshInputView() - }) - rowIndex == 3 && index == 3 -> ({ - numberSymbolMode = !numberSymbolMode - symbolPageIndex = 0 - refreshInputView() - }) - rowIndex == 3 && isAction -> ({ - strokeNumberMode = false - numberSymbolMode = false - symbolPageIndex = 0 - refreshInputView() - }) - else -> ({ currentInputConnection?.commitText(label, 1) }) - } - row.addView(keyboardKey(label, 1f, action, repeatOnLongPress = rowIndex == 0 && isAction, repeatAction = action).apply { - textSize = if (isAction) 15f else 20f - if (isAction) background = roundedButton(Color.rgb(153, 26, 40), dp(7)) - }) - } - body.addView(row, LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, 0, 1f)) - } - root.addView(body, LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, 0, 1f)) - return root - } - - private fun appendStroke(stroke: String) { - if (strokeCode.length >= 32) return - if (strokeCode.isEmpty()) { - phraseQueryEpoch++ - lastStrokeCandidates = emptyList() - renderCandidateRow(emptyList()) - } - strokeCode += stroke - updateStrokePreview() - refreshStrokeCandidates(strokeCode) - } - - /** Marks the current character as a word segment without committing it yet. */ - private fun segmentStroke() { - if (strokeCode.isEmpty()) return - val candidate = lastStrokeCandidates.firstOrNull() ?: return - wordSegments.add(candidate) - strokeCode = "" - strokeQueryEpoch++ - lastStrokeCandidates = emptyList() - updateStrokePreview() - renderCandidateRow(emptyList()) - } - - private fun updateStrokePreview() { - // Plain "+" concatenation on a CharSequence would call toString() and - // drop the ImageSpan the 5th stroke relies on — TextUtils.concat() - // preserves spans across both pieces. - strokePreview?.text = android.text.TextUtils.concat(wordSegments.joinToString(""), displayStrokeCode(strokeCode)) - } - - private fun refreshStrokeCandidates(code: String) { - val query = ++strokeQueryEpoch - strokeRepository.searchAsync(code) { result -> - if (query != strokeQueryEpoch || inputMode != InputMode.STROKE) return@searchAsync - lastStrokeCandidates = result - renderCandidateRow(result) + deleteBackward() + if (englishComposingWord.isNotEmpty()) { + englishComposingWord.deleteCharAt(englishComposingWord.length - 1) } + updateEnglishCandidates() } /** - * Renders the stroke candidate row: a leading "commit the whole word" - * button for any segments marked via 分词 (if present), followed by the - * single-character candidates for the character currently being typed. + * The selected/first candidate is marked by color+weight only (the same + * red as the right-hand action rail's ←/↵/清除/123 keys, bold) — no + * size, background, border or shadow change, so it can't shift + * candidate width/spacing or row height. A real else branch (not just + * skipping the isFirst==false case) matters once populateCandidateRow() + * can reuse a view that used to be first-candidate for one that isn't. */ - private fun renderCandidateRow(strokeMatches: List) { - strokeCandidates?.removeAllViews() - val overlayEntries = mutableListOf Unit>>() - // The very first candidate shown — whichever one that is — is - // highlighted in the same red as the right-hand action rail, since - // it's what a bare space/enter would commit. - var firstCandidate = true - if (wordSegments.isNotEmpty()) { - val word = wordSegments.joinToString("") - val displayWord = outputScript(word) - val wordWidth = dp((displayWord.codePointCount(0, displayWord.length) * 22 + 16).coerceAtLeast(46)) - strokeCandidates?.addView(candidateItemView(displayWord, firstCandidate) { commitWord(word) }, LinearLayout.LayoutParams(wordWidth, ViewGroup.LayoutParams.MATCH_PARENT)) - overlayEntries.add(displayWord to { commitWord(word) }) - firstCandidate = false - } - strokeMatches.forEach { candidate -> - val displayCandidate = outputScript(candidate) - strokeCandidates?.addView(candidateItemView(displayCandidate, firstCandidate) { commitStrokeCandidate(candidate) }, LinearLayout.LayoutParams(dp(38), ViewGroup.LayoutParams.MATCH_PARENT)) - overlayEntries.add(displayCandidate to { commitStrokeCandidate(candidate) }) - firstCandidate = false + internal fun styleCandidateFirstState(view: TextView, isFirst: Boolean) { + if (isFirst) { + // Same red as the action rail's own background + // (Color.rgb(153, 26, 40)), brightened a touch for dark + // theme only — same hue, just a bit lighter so it reads + // more clearly against a dark panel; light theme keeps the + // exact action-rail red. + view.setTextColor(if (isDarkTheme) Color.rgb(190, 45, 60) else Color.rgb(153, 26, 40)) + view.setTypeface(view.typeface, android.graphics.Typeface.BOLD) + } else { + view.setTextColor(tone(Color.rgb(245, 245, 245), Color.rgb(30, 30, 34))) + view.setTypeface(android.graphics.Typeface.DEFAULT, android.graphics.Typeface.NORMAL) } - candidateOverlayEntries = overlayEntries } /** * Plain-text candidate item — no independent keycap background, just the * label, matching a stroke candidate bar rather than a row of separate * buttons. Height always comes from the parent row (MATCH_PARENT) so it - * can never itself grow the fixed 36dp candidate row. The selected/first - * candidate is marked by color+weight only (the encode row's own light - * blue, bold) — no size, background, border or shadow change, so it - * can't shift candidate width/spacing or row height. + * can never itself grow the fixed 36dp candidate row. */ - private fun candidateItemView(label: String, isFirst: Boolean, action: () -> Unit): TextView { - return keyboardKey(label, 1f, action = action).apply { + internal fun candidateItemView( + label: String, + isFirst: Boolean, + onLongPress: (() -> Unit)? = null, + action: () -> Unit, + ): TextView { + return keyboardKey(label, 1f, action = action, longPressAction = onLongPress).apply { + // Was 20f/dp(9); briefly tried 17f/dp(12) to fix a clipped-glyph + // report on a Xiaomi MIUI device (that OEM's default system + // font renders visibly wider per character than stock Android + // at the same sp), but 20sp reads noticeably better across + // devices — settled on keeping the original text size and + // widening padding a bit instead (dp(9) -> dp(11)), which still + // gives MIUI's wider glyphs a bit more breathing room without + // shrinking the text everyone else sees. textSize = 20f setSingleLine(true) maxLines = 1 background = null elevation = 0f translationZ = 0f - setPadding(dp(9), 0, dp(9), 0) - if (isFirst) { - setTextColor(strokeEncodeAccentColor) - setTypeface(typeface, android.graphics.Typeface.BOLD) - } + setPadding(dp(11), 0, dp(11), 0) + styleCandidateFirstState(this, isFirst) } } @@ -1276,7 +1738,7 @@ class OpenLessImeService : InputMethodService(), OpenLessOverlayBridge.OverlaySt * the existing panel without resizing or displacing it, matching "不允许 * 推动下方按键或改变键盘高度". */ - private fun showCandidateOverlay(anchor: View) { + internal fun showCandidateOverlay(anchor: View) { if (candidateOverlayEntries.isEmpty()) return var activePopup: android.widget.PopupWindow? = null val content = LinearLayout(this).apply { @@ -1330,42 +1792,6 @@ class OpenLessImeService : InputMethodService(), OpenLessOverlayBridge.OverlaySt popup.showAsDropDown(anchor, -anchor.left, dp(2)) } - private fun deleteStroke() { - if (strokeCode.isNotEmpty()) { - strokeCode = strokeCode.dropLast(1) - strokeQueryEpoch++ - updateStrokePreview() - if (strokeCode.isNotEmpty()) { - appendStroke("") - } else { - lastStrokeCandidates = emptyList() - renderCandidateRow(emptyList()) - } - } else if (wordSegments.isNotEmpty()) { - wordSegments.removeAt(wordSegments.lastIndex) - updateStrokePreview() - renderCandidateRow(emptyList()) - } else { - currentInputConnection?.deleteSurroundingText(1, 0) - if (confirmedText.isNotEmpty()) { - confirmedText = confirmedText.dropLast(1) - phraseQueryEpoch++ - refreshAssociations() - } else { - strokeCandidates?.removeAllViews() - } - } - } - - private fun clearStrokes() { - strokeCode = "" - strokeQueryEpoch++ - wordSegments.clear() - lastStrokeCandidates = emptyList() - strokePreview?.text = "" - strokeCandidates?.removeAllViews() - } - /** * Sends a cursor-movement key. Left/right while clipboardSelectionMode * is on extend the selection directly via InputConnection.setSelection() @@ -1409,8 +1835,8 @@ class OpenLessImeService : InputMethodService(), OpenLessOverlayBridge.OverlaySt private fun buildClipboardView(): View { val root = SwipeModeContainer(this) { direction -> swipeInputMode(direction) }.apply { orientation = LinearLayout.VERTICAL - layoutParams = ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, dp(300)) - minimumHeight = dp(300) + layoutParams = ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, keyboardPanelHeightPx()) + minimumHeight = keyboardPanelHeightPx() setPadding(dp(8), dp(8), dp(8), dp(8)) setBackgroundColor(tone(Color.rgb(48, 48, 48), Color.rgb(242, 242, 246))) } @@ -1419,6 +1845,59 @@ class OpenLessImeService : InputMethodService(), OpenLessOverlayBridge.OverlaySt marginEnd = dp(8) }) + // Most-recent-three-clips quick-tap strip (was four rows — reduced + // per user request, the freed space redistributed evenly across the + // remaining three so RECENT_CLIPS_COLUMN_HEIGHT_DP's own total is + // unchanged; see that constant's doc comment for the exact math). + // The rows use circled Unicode digits (①②③) for their prefixes; the + // glyphs come from Android's normal font fallback rather than a + // hand-drawn canvas shape. Flat gray now (was white prefix + cherry + // red content, before that a blue accent) — per user request, no + // color distinction between the prefix and the clip text anymore. + // The action grid below shrinks by the strip's fixed height + // automatically (LinearLayout's own weight=1f on `grid` further + // down already absorbs however much space this strip doesn't use — + // no separate shrink logic needed). Thin divider lines separate the + // rows rather than a colored background, matching the plain-divider + // treatment already used for the English candidate bar. + val circledIndices = listOf('①', '②', '③') + val recentClipTextColor = tone(Color.rgb(190, 190, 190), Color.rgb(140, 140, 145)) + fun recentClipRow(index: Int, entry: ClipboardEntry?): TextView = TextView(this).apply { + text = entry?.let { "${circledIndices[index]} ${it.text}" } ?: "" + textSize = 16f + maxLines = 1 + ellipsize = android.text.TextUtils.TruncateAt.END + gravity = android.view.Gravity.CENTER_VERTICAL + setTextColor(recentClipTextColor) + setPadding(dp(10), 0, dp(10), 0) + if (entry != null) { + isClickable = true + setOnClickListener { + currentInputConnection?.commitText(entry.text, 1) + OpenLessClipboardHistory.recordCopy(this@OpenLessImeService, entry.text) + refreshInputView() + } + } + } + // distinctBy(text): recordCopy() already dedupes going forward (an + // existing entry is moved to the front instead of a second row being + // added), but this guards the display itself against any duplicate + // already sitting in an existing history file (e.g. one written + // before that dedup existed) rather than trusting the stored data. + val recentClips = OpenLessClipboardHistory.load(this).distinctBy { it.text } + val recentClipsColumn = LinearLayout(this).apply { orientation = LinearLayout.VERTICAL } + recentClipsColumn.addView(buildDivider(), LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, dp(1))) + for (index in 0 until 3) { + recentClipsColumn.addView( + recentClipRow(index, recentClips.getOrNull(index)), + LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, 0, 1f), + ) + recentClipsColumn.addView(buildDivider(), LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, dp(1))) + } + // Added to root below the action grid, not here — per user request, + // the keyboard/action grid sits above the recent-clips strip now + // (was the other way around). + // Arrow keys and the Select toggle work as one unit: while selection // mode is on, the arrows extend the selection instead of just moving // the cursor, so they share the same active-highlight background as @@ -1432,8 +1911,8 @@ class OpenLessImeService : InputMethodService(), OpenLessOverlayBridge.OverlaySt // roundedButton() with the same rose highlight the stroke panel's // own "繁" toggle uses when active) — only the label/icon size and // grid spacing are new, not the button's edge style or palette. - fun quickActionLabel(label: String, action: () -> Unit, textSizeSp: Float, highlighted: Boolean = false) = - keyboardKey(label, 1f, action = action).apply { + fun quickActionLabel(label: String, action: () -> Unit, textSizeSp: Float, highlighted: Boolean = false, midDivider: Boolean = false) = + keyboardKey(label, 1f, action = action, midDivider = midDivider).apply { textSize = textSizeSp typeface = mediumTypeface setTextColor(softWhite) @@ -1447,6 +1926,13 @@ class OpenLessImeService : InputMethodService(), OpenLessOverlayBridge.OverlaySt highlighted: Boolean = false, rotationDegrees: Float = 0f, repeatOnLongPress: Boolean = false, + // Unlike the stroke panel's always-dark-red action keys (white + // in both themes, StrokeActionView's own default), these icons + // normally sit on a normal or rose key that flips with the + // theme — pass an explicit color for a caller whose key is red + // regardless of theme (e.g. backspaceKey below), same reasoning + // as keyboardKey()'s own graphicIconColor doc comment. + iconColor: Int = tone(Color.WHITE, Color.rgb(30, 30, 34)), ) = keyboardKey( "", 1f, @@ -1458,9 +1944,7 @@ class OpenLessImeService : InputMethodService(), OpenLessOverlayBridge.OverlaySt // View itself — rotating the whole View would also distort its // rectangular background in a non-square cell. graphicRotation = rotationDegrees, - // Unlike the stroke panel's always-dark-red action keys, these - // icons sit on a normal or rose key that flips with the theme. - graphicIconColor = tone(Color.WHITE, Color.rgb(30, 30, 34)), + graphicIconColor = iconColor, ).apply { if (highlighted) background = roundedButton(tone(Color.rgb(112, 78, 92), Color.rgb(232, 205, 213)), dp(5)) attachPressScale(this) @@ -1475,7 +1959,7 @@ class OpenLessImeService : InputMethodService(), OpenLessOverlayBridge.OverlaySt val grid = LinearLayout(this).apply { orientation = LinearLayout.VERTICAL } val row1 = LinearLayout(this).apply { gravity = android.view.Gravity.CENTER } - val selectKey = quickActionLabel(ui("选择", "Select"), { + val selectKey = quickActionLabel(ui("选择", "Range"), { clipboardSelectionMode = !clipboardSelectionMode clipboardSelectionAnchor = -1 clipboardSelectionActive = -1 @@ -1494,7 +1978,7 @@ class OpenLessImeService : InputMethodService(), OpenLessOverlayBridge.OverlaySt }) val row2 = LinearLayout(this).apply { gravity = android.view.Gravity.CENTER } - val selectAllKey = quickActionLabel(ui("全选", "Select All"), { + val selectAllKey = quickActionLabel(ui("全选", "Select"), { currentInputConnection?.performContextMenuAction(android.R.id.selectAll) }, 20f) val copyKey = quickActionLabel(ui("复制", "Copy"), { @@ -1503,17 +1987,43 @@ class OpenLessImeService : InputMethodService(), OpenLessOverlayBridge.OverlaySt val pasteKey = quickActionLabel(ui("粘贴", "Paste"), { currentInputConnection?.performContextMenuAction(android.R.id.paste) }, 20f) - // "粘贴板" is three characters where the others are two, so it gets a - // slightly smaller size to avoid crowding/clipping in the same cell width. - val clipboardKey = quickActionLabel(ui("粘贴板", "Clipboard"), { + // Two lines ("History" / "Dict") since this button now opens both + // the clipboard history browser (tap) and the voice-correction flow + // for whatever's selected in the real input field (long-press) — + // labeled "Dict" since the long-press flow writes to the global + // Dictionary now, not a correction rule. + // quickActionLabel()->keyboardKey()'s own '\n' handling shrinks only + // the label's first character (built for single-char rows like + // "1\n!"), which would leave just one letter undersized here, so the + // spanned text it sets is overwritten with a plain two-line string + // right after construction. + // Same cherry red as the stroke panel's own right-side action rail + // (Color.rgb(153, 26, 40)) — per product request, so these two keys + // read as the equivalent "action" keys on this panel. + val clipboardKey = quickActionLabel(ui("历史\n字典", "History\nDict"), { clipboardHistoryMode = true refreshInputView() - }, 18f) + }, 17f, midDivider = true).apply { + text = ui("历史\n字典", "History\nDict") + background = roundedButton(Color.rgb(153, 26, 40), dp(5)) + // quickActionLabel()'s own softWhite text color reads fine on a + // normal/rose key but not on this now-red one (dark gray in + // light theme, on dark red) — fixed white regardless of theme, + // same reasoning as backspaceKey's iconColor override below. + setTextColor(Color.WHITE) + setOnLongClickListener { + openSelectedTextCorrectionViaVoice() + true + } + } val backspaceKey = quickActionIcon( "backspace-icon", - { currentInputConnection?.deleteSurroundingText(1, 0) }, + { deleteBackward() }, repeatOnLongPress = true, - ) + iconColor = Color.WHITE, + ).apply { + background = roundedButton(Color.rgb(153, 26, 40), dp(5)) + } for (key in listOf(selectAllKey, copyKey, pasteKey, clipboardKey, backspaceKey)) { row2.addView(key, cell()) } @@ -1524,6 +2034,9 @@ class OpenLessImeService : InputMethodService(), OpenLessOverlayBridge.OverlaySt root.addView(grid, LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, 0, 1f).apply { topMargin = dp(8) }) + root.addView(recentClipsColumn, LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, dp(RECENT_CLIPS_COLUMN_HEIGHT_DP)).apply { + topMargin = dp(8) + }) return root } @@ -1534,13 +2047,26 @@ class OpenLessImeService : InputMethodService(), OpenLessOverlayBridge.OverlaySt * direction-pad panel. */ private fun buildClipboardHistoryView(): View { + // Assigned once `scroll` itself is built, further down — captured + // by reference here so SwipeModeContainer's verticalDismissAllowed + // lambda reads whatever scroll position exists at actual touch + // time (the lambda only ever runs from a real user gesture, well + // after this whole view is fully constructed). + var historyScroll: android.widget.ScrollView? = null // Rows handle their own left/right swipe (favorite / correction // rule); the panel-switch swipe would otherwise compete for the - // exact same gesture. - val root = SwipeModeContainer(this, horizontalSwipeEnabled = false) { direction -> swipeInputMode(direction) }.apply { + // exact same gesture. A downward drag only dismisses the keyboard + // once the list below is already scrolled to its own top — per + // user request, so scrolling up through history doesn't get + // mistaken for "swipe down to hide" partway through. + val root = SwipeModeContainer( + this, + horizontalSwipeEnabled = false, + verticalDismissAllowed = { (historyScroll?.scrollY ?: 0) == 0 }, + ) { direction -> swipeInputMode(direction) }.apply { orientation = LinearLayout.VERTICAL - layoutParams = ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, dp(300)) - minimumHeight = dp(300) + layoutParams = ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, keyboardPanelHeightPx()) + minimumHeight = keyboardPanelHeightPx() setPadding(dp(16), dp(8), dp(16), dp(8)) setBackgroundColor(tone(Color.rgb(48, 48, 48), Color.rgb(242, 242, 246))) clipChildren = false @@ -1566,13 +2092,36 @@ class OpenLessImeService : InputMethodService(), OpenLessOverlayBridge.OverlaySt textSize = 14f gravity = android.view.Gravity.CENTER setTextColor(if (selected) Color.rgb(153, 26, 40) else tone(Color.rgb(190, 190, 190), Color.rgb(140, 140, 145))) - setTypeface(typeface, if (selected) android.graphics.Typeface.BOLD else android.graphics.Typeface.NORMAL) + if (englishUi) { + typeface = Typeface.create("sans-serif-medium", if (selected) Typeface.BOLD else Typeface.NORMAL) + } else { + setTypeface(typeface, if (selected) android.graphics.Typeface.BOLD else android.graphics.Typeface.NORMAL) + } setOnClickListener { clipboardHistoryCategory = category refreshInputView() } }, LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.MATCH_PARENT, 1f)) } + // Not a category filter like the five tabs above — an action: + // deletes every non-favorited history entry outright (favorites are + // kept). Never shows "selected" styling since it isn't one of + // clipboardHistoryCategory's own values. No confirmation step per + // the request as given; only this app's own local history is + // affected, not anything external. + tabsRow.addView(TextView(this).apply { + text = ui("清空", "Clear") + textSize = 14f + gravity = android.view.Gravity.CENTER + setTextColor(tone(Color.rgb(190, 190, 190), Color.rgb(140, 140, 145))) + if (englishUi) { + typeface = Typeface.create("sans-serif-medium", Typeface.NORMAL) + } + setOnClickListener { + OpenLessClipboardHistory.clearNonFavorites(this@OpenLessImeService) + refreshInputView() + } + }, LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.MATCH_PARENT, 1f)) tabsRow.addView(flatCircleButton("✕", 26, ui("关闭粘贴板", "Close clipboard")) { clipboardHistoryMode = false refreshInputView() @@ -1588,19 +2137,19 @@ class OpenLessImeService : InputMethodService(), OpenLessOverlayBridge.OverlaySt val listContainer = LinearLayout(this).apply { orientation = LinearLayout.VERTICAL } val entries = OpenLessClipboardHistory.filter(OpenLessClipboardHistory.load(this), clipboardHistoryCategory) - // Correction rules live in the Rust backend (CORE_BACKEND), which is + // The Dictionary lives in the Rust backend (CORE_BACKEND), which is // only registered once mobile_runtime::run()'s setup() has actually // executed — not guaranteed just because the IME is showing. Without - // this, nativeCorrectionRulePatterns()/nativeAddCorrectionRule() below + // this, nativeVocabularyPhrases()/nativeAddVocabularyWord() below // silently no-op against an unregistered backend: the star-like icon - // never appears and Save in the correction-rule Activity does nothing, - // with no visible error either place. + // never appears and the swipe zone's "add" never takes, with no + // visible error either place. ensureBackendReady() // Fetched once per panel build, not per row: a native round trip // per row would be wasted work when a single JSON snapshot already - // answers "does this text have a rule" for all of them. - val correctionPatterns: Set = try { - val array = org.json.JSONArray(OpenLessNative.nativeCorrectionRulePatterns()) + // answers "is this text already in the dictionary" for all of them. + val vocabularyPhrases: Set = try { + val array = org.json.JSONArray(OpenLessNative.nativeVocabularyPhrases()) (0 until array.length()).mapTo(mutableSetOf()) { array.getString(it) } } catch (error: Exception) { emptySet() @@ -1609,6 +2158,7 @@ class OpenLessImeService : InputMethodService(), OpenLessOverlayBridge.OverlaySt listContainer.addView(TextView(this).apply { text = ui("暂无粘贴板记录", "No clipboard history yet") textSize = 14f + if (englishUi) typeface = Typeface.create("sans-serif-medium", Typeface.NORMAL) gravity = android.view.Gravity.CENTER setTextColor(tone(Color.rgb(140, 140, 140), Color.rgb(140, 140, 145))) setPadding(0, dp(20), 0, 0) @@ -1676,7 +2226,7 @@ class OpenLessImeService : InputMethodService(), OpenLessOverlayBridge.OverlaySt ) // After the weighted text view, not before it, so this lands // at the row's far right edge instead of crowding the star. - if (correctionPatterns.contains(entry.text)) { + if (vocabularyPhrases.contains(entry.text)) { row.addView( TextView(this).apply { text = "✎" @@ -1709,11 +2259,11 @@ class OpenLessImeService : InputMethodService(), OpenLessOverlayBridge.OverlaySt favoriteZone = favoriteZone, correctionZone = correctionZone, isFavorite = { entry.favorite }, - hasCorrectionRule = { correctionPatterns.contains(entry.text) }, + hasCorrectionRule = { vocabularyPhrases.contains(entry.text) }, addLabel = ui("加入收藏", "Add"), removeLabel = ui("取消收藏", "Remove"), - addCorrectionLabel = ui("加入纠错规则", "Add correction"), - removeCorrectionLabel = ui("移除纠错规则", "Remove correction"), + addCorrectionLabel = ui("加入词典", "Add to dictionary"), + removeCorrectionLabel = ui("移出词典", "Remove from dictionary"), onSuppressClick = { suppressRowClick = true }, onToggleFavorite = { OpenLessClipboardHistory.toggleFavorite(this, entry.text) @@ -1723,9 +2273,12 @@ class OpenLessImeService : InputMethodService(), OpenLessOverlayBridge.OverlaySt OpenLessClipboardHistory.delete(this, entry.text) refreshInputView() }, - onAddCorrection = { openCorrectionRuleViaVoice(entry.text) }, + onAddCorrection = { + runNativeAction("加入词典") { OpenLessNative.nativeAddVocabularyWord(entry.text) } + refreshInputView() + }, onRemoveCorrection = { - OpenLessNative.nativeRemoveCorrectionRule(entry.text) + OpenLessNative.nativeRemoveVocabularyWord(entry.text) refreshInputView() }, ) @@ -1740,6 +2293,7 @@ class OpenLessImeService : InputMethodService(), OpenLessOverlayBridge.OverlaySt overScrollMode = View.OVER_SCROLL_NEVER addView(listContainer, ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT)) } + historyScroll = scroll root.addView(scroll, LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, 0, 1f)) return root } @@ -1858,131 +2412,7 @@ class OpenLessImeService : InputMethodService(), OpenLessOverlayBridge.OverlaySt } } - /** - * Clipboard swipe-left "add correction" action, only reached when this - * text has no rule yet (once one exists, the same swipe instead removes - * it directly via onRemoveCorrection, no prompt). Reuses the dictation - * "edit result" mechanism's speak-to-replace mic (openEditDictationResult - * / finishEditWithSpokenReplacement) instead of any separate window. - * - * A dedicated Activity was tried for the "type the correct wording" step - * first. On a real device it reproduced a native crash: SIGSEGV in - * HWUI's RenderThread (WebViewFunctorManager::destroyFunctor, abort - * message "FORTIFY: pthread_mutex_lock called on a destroyed mutex"), - * whenever that Activity's window appeared while - * OpenLessBackendWarmupActivity's WebView-hosting window was mid - * teardown in the background — confirmed via on-device tombstones on two - * different phones, not timing-tunable away since Android decides when - * to reclaim a backgrounded WebView's hardware layer, not app code. This - * panel never opens a second window at all, so that whole crash class - * does not apply, and it reuses machinery already proven here for - * exactly this purpose (recording a correction rule from spoken text). - * - * editingReplacesWholeResult stays false and editingForClipboardCorrection - * is set so finishEditWithSpokenReplacement() skips touching - * currentInputConnection entirely — wrongText is an arbitrary clipboard - * entry, not necessarily anything currently focused in any app. - */ - private fun openCorrectionRuleViaVoice(wrongText: String) { - editingOriginalText = wrongText - editingReplacesWholeResult = false - editingForClipboardCorrection = true - addCorrectionRuleForEdit = true - editingDictationResult = true - awaitingEditReplacement = true - refreshInputView() - toggleDictation() - } - - /** Commits the current character together with any segments already marked via 分词. */ - private fun commitStrokeCandidate(candidate: String) { - // Picking anything other than the top-ranked result is a correction - // — learn it, so this code favors `candidate` from now on. Picking - // the top result needs no recording: it's already where it should be. - if (OpenLessAndroidPreferences.strokeUsageEnabled(this) && - strokeCode.isNotEmpty() && candidate != lastStrokeCandidates.firstOrNull() - ) { - strokeRepository.recordPersonalPick(strokeCode, candidate) - } - commitWord((wordSegments + candidate).joinToString("")) - } - - private fun commitWord(word: String) { - if (word.isEmpty() || isSensitiveField(currentInputEditorInfo)) return - val connection = currentInputConnection ?: return - val contextBeforeCommit = confirmedText.takeLast(MAX_ASSOCIATION_CONTEXT) - if (!connection.commitText(outputScript(word), 1)) return - if (OpenLessAndroidPreferences.strokeUsageEnabled(this)) { - phraseRepository.recordUsage(contextBeforeCommit, word) - } - confirmedText = (confirmedText + word).takeLast(MAX_ASSOCIATION_CONTEXT) - clearStrokes() - refreshAssociations() - } - - private fun refreshAssociations() { - if (!OpenLessAndroidPreferences.strokeAssociationEnabled(this)) { - strokeCandidates?.removeAllViews() - candidateOverlayEntries = emptyList() - return - } - val context = confirmedText.takeLast(MAX_ASSOCIATION_CONTEXT) - val query = ++phraseQueryEpoch - if (context.isEmpty()) return - phraseRepository.searchAsync(context) { result -> - if (query != phraseQueryEpoch || inputMode != InputMode.STROKE || confirmedText.takeLast(MAX_ASSOCIATION_CONTEXT) != context) return@searchAsync - strokeCandidates?.removeAllViews() - val overlayEntries = mutableListOf Unit>>() - result.forEachIndexed { index, candidate -> - val matchedPrefix = candidate.matchedPrefix.ifEmpty { context } - val displayText = outputScript(candidate.text) - val candidateWidth = dp((displayText.codePointCount(0, displayText.length) * 22 + 16).coerceAtLeast(46)) - val commit = { commitAssociation(candidate.text, matchedPrefix) } - strokeCandidates?.addView(candidateItemView(displayText, index == 0, commit), LinearLayout.LayoutParams(candidateWidth, ViewGroup.LayoutParams.MATCH_PARENT)) - overlayEntries.add(displayText to commit) - } - candidateOverlayEntries = overlayEntries - } - } - - private fun commitAssociation(displayText: String, matchedContext: String) { - if (isSensitiveField(currentInputEditorInfo) || !displayText.startsWith(matchedContext)) return - val suffix = displayText.removePrefix(matchedContext) - val connection = currentInputConnection ?: return - if (suffix.isNotEmpty() && !connection.commitText(outputScript(suffix), 1)) return - if (OpenLessAndroidPreferences.strokeUsageEnabled(this)) { - phraseRepository.recordUsage(matchedContext, displayText) - } - confirmedText = (confirmedText + suffix).takeLast(MAX_ASSOCIATION_CONTEXT) - clearStrokes() - refreshAssociations() - } - - private fun addKeyboardRow(parent: LinearLayout, keys: List) { - val row = LinearLayout(this).apply { - gravity = android.view.Gravity.CENTER - } - keys.forEach { key -> - // The key's identity (passed to handleKeyboardKey) always stays - // lowercase; only the displayed label follows shiftState, so the - // keyboard visibly shows what tapping it will actually type. - val displayLabel = if (shiftState != ShiftState.OFF && key.length == 1 && key[0].isLetter()) { - key.uppercase() - } else { - key - } - row.addView(keyboardKey( - displayLabel, - 1f, - { handleKeyboardKey(key) }, - repeatOnLongPress = key == "⌫", - repeatAction = { currentInputConnection?.deleteSurroundingText(1, 0) }, - )) - } - parent.addView(row, LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, 0, 1f)) - } - - private fun keyboardKey( + internal fun keyboardKey( label: String, weight: Float, action: () -> Unit = {}, @@ -1990,6 +2420,17 @@ class OpenLessImeService : InputMethodService(), OpenLessOverlayBridge.OverlaySt repeatAction: (() -> Unit)? = null, swipeUpAction: (() -> Unit)? = null, swipePreview: String? = null, + // A single-shot delayed action, separate from + // repeatOnLongPress/repeatAction — used by the voice/mic key, whose + // long-press (switch to Voice mode) needs a longer fuse than the + // system's fixed ~500ms long-click timeout so a swipe-up gesture + // that pauses briefly before moving isn't preempted by it (see + // ACTION_MOVE below, which cancels this the moment the swipe-up + // threshold is crossed). setOnLongClickListener's timeout isn't + // adjustable per-view, so this schedules/cancels its own Handler + // callback instead of relying on it. + longPressAction: (() -> Unit)? = null, + longPressDelayMs: Long = 500L, microphoneIcon: Boolean = false, strokeIconCode: String? = null, graphicCode: String? = null, @@ -2004,6 +2445,11 @@ class OpenLessImeService : InputMethodService(), OpenLessOverlayBridge.OverlaySt // panel's direction/undo icons — which sit on a normal or rose key, // not red — pass the theme-appropriate color explicitly. graphicIconColor: Int? = null, + // Draws a thin horizontal divider between the two lines of a + // two-line label (e.g. the clipboard panel's "History"/"Dict" + // key, which does two unrelated things depending on tap vs. + // long-press) — a plain '\n' alone read as one cramped label. + midDivider: Boolean = false, ): TextView { val keyView = when { microphoneIcon -> MicrophoneKeyView(this, isDarkTheme) @@ -2016,6 +2462,7 @@ class OpenLessImeService : InputMethodService(), OpenLessOverlayBridge.OverlaySt } label == "←" || label == "↵" -> ActionSymbolView(this, label, isDarkTheme) label == "⇧" -> ShiftKeyView(this, shiftState, isDarkTheme) + midDivider -> MidDividerTextView(this, tone(Color.rgb(100, 100, 100), Color.rgb(205, 205, 209))) else -> TextView(this) } return keyView.apply { @@ -2035,9 +2482,31 @@ class OpenLessImeService : InputMethodService(), OpenLessOverlayBridge.OverlaySt elevation = dp(5).toFloat() translationZ = dp(1).toFloat() contentDescription = label.ifBlank { ui("空格", "Space") } + // Retargetable like buildEnglishCharKey()'s tap preview: any + // sibling key built with its own swipeUpAction/swipePreview + // carries that pair here as its tag, so a swipe-up gesture that + // drags sideways onto a different key (e.g. across the stroke + // grid's digit shortcuts) can look up and commit THAT key's own + // digit instead of the one originally pressed. + tag = if (swipeUpAction != null && swipePreview != null) Pair(swipeUpAction, swipePreview) else null var suppressNextClick = false + var downX = 0f var downY = 0f - var swipePopup: android.widget.PopupWindow? = null + var swipePreviewShown = false + // A one-way latch, same as swipePreviewShown: once the finger + // has dragged sideways far enough from where it started, the + // gesture is treated as a deliberate retarget-drag for the + // rest of this touch sequence, not re-checked drag-by-drag. + // Before that latch trips, trackedView never changes even if + // the finger's raw position happens to land inside another + // key's real hit bounds — a plain swipe straight up (whose own + // vertical travel easily reaches a neighboring row) must not + // silently relabel itself just because it crossed into that + // row's territory with no real sideways intent. + var horizontalDragArmed = false + var trackedView: View = this + @Suppress("UNCHECKED_CAST") + fun swipeSpecOf(view: View): Pair<() -> Unit, String>? = view.tag as? Pair<() -> Unit, String> setOnClickListener { if (suppressNextClick) { suppressNextClick = false @@ -2063,10 +2532,66 @@ class OpenLessImeService : InputMethodService(), OpenLessOverlayBridge.OverlaySt true } } + val longPressHandler = if (longPressAction != null) Handler(Looper.getMainLooper()) else null + var longPressRunnable: Runnable? = null + + // Hit-tests every swipe-capable sibling in this key's own row + // group, climbing to the row's own parent so a drag can + // retarget across rows too — same approach as + // buildEnglishCharKey()'s findTrackTargetAt, just keyed off a + // Pair(swipeUpAction, swipePreview) tag instead of a char tag. + // + // Vertical slop is deliberately tiny here (unlike the English + // version's generous dp(24)): the grid's rows sit almost flush + // against each other (~1dp margin, each row only ~1/4 of the + // grid's own height), so a large slop makes adjacent rows' + // hit zones overlap deeply — a plain straight swipe UP (whose + // whole point is to travel well past the pressed key, still + // within the same key's column) would then immediately + // "retarget" to the row above just from that expected vertical + // travel, with no real leftward/rightward drag at all. A + // near-zero vertical slop means only a finger that has + // genuinely reached a different row's real bounds retargets; + // once the finger is past every row (still swiping straight + // up), no row matches and findSwipeTargetAt falls back to + // returning trackedView — i.e. the original key stays selected + // for a simple swipe, exactly matching a single-key swipe-up's + // intent. + fun findSwipeTargetAt(rawX: Float, rawY: Float): View { + val row = parent as? ViewGroup ?: return this + val group = row.parent as? ViewGroup + val candidateRows = if (group != null) { + (0 until group.childCount).mapNotNull { index -> + (group.getChildAt(index) as? ViewGroup)?.takeIf { candidate -> + (0 until candidate.childCount).any { candidate.getChildAt(it).tag is Pair<*, *> } + } + } + } else { + listOf(row) + } + for (candidateRow in candidateRows) { + for (index in 0 until candidateRow.childCount) { + val sibling = candidateRow.getChildAt(index) + if (sibling.tag !is Pair<*, *>) continue + val loc = IntArray(2) + sibling.getLocationOnScreen(loc) + if (rawX >= loc[0] - dp(6) && rawX < loc[0] + sibling.width + dp(6) && + rawY >= loc[1] - dp(2) && rawY < loc[1] + sibling.height + dp(2) + ) { + return sibling + } + } + } + return trackedView + } + setOnTouchListener { view, event -> when (event.actionMasked) { MotionEvent.ACTION_DOWN -> { + downX = event.x downY = event.y + horizontalDragArmed = false + trackedView = view view.animate() .scaleX(0.97f) .scaleY(0.97f) @@ -2075,48 +2600,95 @@ class OpenLessImeService : InputMethodService(), OpenLessOverlayBridge.OverlaySt .setDuration(65L) .start() performKeyHaptic() + if (longPressHandler != null && longPressAction != null) { + val runnable = Runnable { + longPressAction.invoke() + suppressNextClick = true + } + longPressRunnable = runnable + longPressHandler.postDelayed(runnable, longPressDelayMs) + } } MotionEvent.ACTION_MOVE -> { - if (swipePopup == null && swipeUpAction != null && swipePreview != null && downY - event.y >= dp(10)) { - val preview = TextView(this@OpenLessImeService).apply { - text = swipePreview - textSize = 22f - gravity = android.view.Gravity.CENTER - setTextColor(Color.WHITE) - background = GradientDrawable().apply { - shape = GradientDrawable.RECTANGLE - cornerRadius = dp(10).toFloat() - setColor(Color.argb(205, 65, 65, 65)) - setStroke(dp(1), Color.rgb(105, 105, 105)) - } + if (swipeUpAction != null && swipePreview != null) { + if (!swipePreviewShown && downY - event.y >= dp(10)) { + // Armed only once the initial swipe-up + // actually clears the threshold, not on + // every key's plain DOWN (that would also + // swallow the panel-switch swipe that + // legitimately starts on top of a + // non-swiping key) — so dragging across the + // grid to retarget isn't stolen by + // SwipeModeContainer's own swipe intercept + // mid-gesture. + view.parent?.requestDisallowInterceptTouchEvent(true) + // Cancels the voice/mic key's own delayed + // longPressAction (switch to Voice mode) the + // moment a swipe-up is recognized — a no-op + // for every other key (longPressRunnable is + // only ever set when longPressAction != null). + longPressRunnable?.let { longPressHandler?.removeCallbacks(it) } + swipePreviewShown = true } - swipePopup = android.widget.PopupWindow( - preview, - dp(64), - dp(40), - false, - ).apply { - isClippingEnabled = false - elevation = dp(6).toFloat() - showAtLocation( - view.rootView, - android.view.Gravity.TOP or android.view.Gravity.CENTER_HORIZONTAL, - 0, - dp(8), - ) + if (swipePreviewShown) { + // Retargeting itself is gated behind a + // second, separate threshold: the finger + // must have moved at least dp(10) + // horizontally from where it first went + // down, not merely landed inside another + // key's hit bounds. A plain swipe straight + // up travels well past the pressed key's + // own row by design (that vertical distance + // is the whole gesture), which — with no + // sideways intent at all — would otherwise + // still land inside a neighboring row's real + // bounds and silently relabel the bubble + // (e.g. "0" swiped straight up briefly + // reading as "8", the key directly above + // it). Once this latches on, though, any + // further drag — down, sideways, or across + // rows — keeps retargeting and following + // the finger for the rest of the gesture; + // release always commits whichever key is + // currently tracked (see ACTION_UP). + if (!horizontalDragArmed && kotlin.math.abs(event.x - downX) >= dp(10)) { + horizontalDragArmed = true + } + if (horizontalDragArmed) { + val target = findSwipeTargetAt(event.rawX, event.rawY) + if (target !== trackedView) { + trackedView = target + // Same haptic as a normal key press, + // so retargeting onto a new key + // reads as landing on that key, not + // just a silent label swap. + performKeyHaptic() + } + } + val spec = swipeSpecOf(trackedView) + if (spec != null) { + // Pinned to the tracked key's own actual + // on-screen position, a fixed gap above + // it (see KeyPreviewOverlay.showSwipePreview()) — + // not to the finger's current height. + keyPreviewOverlay?.showSwipePreview(spec.second, trackedView) + } } } } MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> { - if (event.actionMasked == MotionEvent.ACTION_UP && swipeUpAction != null && downY - event.y >= dp(10)) { - swipeUpAction.invoke() + if (event.actionMasked == MotionEvent.ACTION_UP && swipePreviewShown) { + swipeSpecOf(trackedView)?.first?.invoke() suppressNextClick = true } + longPressRunnable?.let { longPressHandler?.removeCallbacks(it) } repeatHandler?.let { handler -> repeatRunnable?.let { handler.removeCallbacks(it) } } - swipePopup?.dismiss() - swipePopup = null + if (swipePreviewShown) { + keyPreviewOverlay?.hide() + swipePreviewShown = false + } view.animate() .scaleX(1f) .scaleY(1f) @@ -2158,40 +2730,610 @@ class OpenLessImeService : InputMethodService(), OpenLessOverlayBridge.OverlaySt @Suppress("DEPRECATION") vibrator.vibrate(durationMs) } + }.onFailure { error -> + android.util.Log.w("OpenLessImeService", "key haptic failed durationMs=$durationMs amplitude=$amplitude", error) } } - private fun handleKeyboardKey(key: String) { - when (key) { - "⌫" -> currentInputConnection?.deleteSurroundingText(1, 0) - "⇧" -> { - // Cycles lowercase -> capitalize-next -> caps-lock -> lowercase. - shiftState = when (shiftState) { - ShiftState.OFF -> ShiftState.SHIFT_ONCE - ShiftState.SHIFT_ONCE -> ShiftState.CAPS_LOCK - ShiftState.CAPS_LOCK -> ShiftState.OFF + // Two short ticks instead of one — used for the mic button's swipe + // gestures (cancel-recording, arm raw mode) so they read as distinctly + // different from a plain key press's single tick. The gap between them + // reuses the same user-configurable repeat interval as key-repeat + // haptics, since that's already tuned to feel like separate pulses + // rather than one long buzz. + private fun performDoubleKeyHaptic() { + performKeyHaptic() + android.os.Handler(Looper.getMainLooper()).postDelayed({ performKeyHaptic() }, keyRepeatIntervalMs()) + } + + /** + * A single ABC/123/#+= character-emitting key with an iOS-style press + * preview: a bubble above the key showing the character that will + * actually be committed (respecting shift for letters), with an arrow + * pointing at the key (KeyPreviewBubbleView, plain Canvas drawing — no + * new dependency). Dragging onto a horizontally adjacent sibling in the + * same row re-targets the preview and the eventual commit to that key + * instead (a "sloppy key", same idea as most predictive keyboards). + * + * @param swipeSymbol Any key that has one — top-row digits (q..p -> + * 1..0), row2's @#$%&-+() and row3's :;'.,!? — swiping up commits + * this symbol instead of the letter, same interaction as the stroke + * panel's own swipe-up keys (see keyboardKey()'s swipeUpAction/ + * swipePreview). Shown as a small standalone hint pinned to the very + * top of the key, NOT a second line of the letter's own text (that + * was the first version of this — a single TextView with "symbol\n + * letter" and block-centered gravity, which visibly shoved the + * letter down off-center by about half the hint line's height). + * Two independent views layered in a FrameLayout instead: the letter + * stays exactly where it always was, completely unaffected by + * whether a swipe-up hint exists at all. + * + * This is a dedicated builder, not a keyboardKey() variant, precisely + * so none of this touch handling can affect any other panel's keys. + */ + private fun buildEnglishCharKey(baseChar: String, weight: Float, swipeSymbol: String? = null): View { + fun charFor(view: View): String { + val base = view.tag as? String ?: return "" + return if (shiftState != ShiftState.OFF && base.length == 1 && base[0].isLetter()) base.uppercase() else base + } + val letterView = TextView(this).apply { + textSize = 22f + typeface = Typeface.create("sans-serif-medium", Typeface.NORMAL) + gravity = android.view.Gravity.CENTER + setTextColor(tone(Color.rgb(245, 245, 245), Color.rgb(30, 30, 34))) + isClickable = false + // A small nudge off dead-center, down toward the digit/symbol + // hint's baseline — a plain translationY (not padding) so it's + // a pure render-time shift that doesn't touch this view's + // measured layout at all. + translationY = dp(LETTER_VERTICAL_NUDGE_DP).toFloat() + } + return FrameLayout(this).apply { + tag = baseChar + if (swipeSymbol != null) englishSwipeSymbols[this] = swipeSymbol + letterView.text = charFor(this) + addView(letterView, FrameLayout.LayoutParams(FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.MATCH_PARENT)) + if (swipeSymbol != null) { + addView( + TextView(this@OpenLessImeService).apply { + text = swipeSymbol + textSize = SWIPE_SYMBOL_HINT_TEXT_SIZE_SP + gravity = android.view.Gravity.TOP or android.view.Gravity.CENTER_HORIZONTAL + // Muted/secondary, not the same bright/near-black + // tone as the letter — a small top-corner hint + // should read as secondary at a glance, not + // compete with the actual letter for attention. + // (Tried matching the swipe-preview bubble's red + // here — read as less legible at this size than + // the plain gray, so reverted.) + setTextColor(tone(Color.rgb(150, 150, 150), Color.rgb(140, 140, 145))) + setPadding(0, dp(SWIPE_SYMBOL_HINT_TOP_PADDING_DP), 0, 0) + isClickable = false + }, + FrameLayout.LayoutParams(FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.WRAP_CONTENT, android.view.Gravity.TOP), + ) + } + background = roundedButton(tone(Color.rgb(52, 52, 54), Color.rgb(255, 255, 255)), dp(5)) + elevation = dp(5).toFloat() + translationZ = dp(1).toFloat() + contentDescription = charFor(this) + var trackedView: View = this + var downY = 0f + var swipePreviewShown = false + + // Shown via the shared top-level KeyPreviewOverlay (see + // wrapWithKeyPreviewOverlay()) instead of a per-key PopupWindow — + // a single overlay reused by every key means dragging onto a + // different key just repositions the same bubble instead of + // tearing one popup down and standing up another. + fun showBubbleFor(target: View) { + keyPreviewOverlay?.showTapPreview(charFor(target), target) + } + + // Hit-tests every char-key row in this key's own row group (not + // just its immediate row siblings) by their actual on-screen + // bounds — a little slop added on both axes, since a real + // finger drifts while sliding. Climbing to the row's own parent + // and collecting every child row that has at least one + // String-tagged (char) key lets a vertical drag retarget across + // rows (e.g. row2's "a" up to row1's "q") the same way a + // horizontal drag already retargets within one row — the + // candidate bar, header, and bottom row (space/return/mode + // toggle) never have any String-tagged children, so they're + // simply never included. Functional keys within an included row + // (shift/backspace, tag == null) are still never matched. + fun findTrackTargetAt(rawX: Float, rawY: Float): View { + val row = parent as? ViewGroup ?: return this + val group = row.parent as? ViewGroup + val candidateRows = if (group != null) { + (0 until group.childCount).mapNotNull { index -> + (group.getChildAt(index) as? ViewGroup)?.takeIf { candidate -> + (0 until candidate.childCount).any { candidate.getChildAt(it).tag is String } + } + } + } else { + listOf(row) } - refreshInputView() + for (candidateRow in candidateRows) { + for (index in 0 until candidateRow.childCount) { + val sibling = candidateRow.getChildAt(index) + if (sibling.tag !is String) continue + val loc = IntArray(2) + sibling.getLocationOnScreen(loc) + if (rawX >= loc[0] - dp(6) && rawX < loc[0] + sibling.width + dp(6) && + rawY >= loc[1] - dp(24) && rawY < loc[1] + sibling.height + dp(24) + ) { + return sibling + } + } + } + return trackedView } - "ABC" -> { - symbolMode = false - shiftState = ShiftState.OFF - refreshInputView() + + // Same idea as findTrackTargetAt above, but only among this + // row's own swipe-symbol-bearing keys (englishSwipeSymbols) — + // once a swipe is armed, sliding sideways still retargets which + // symbol release will commit, and showSwipePreview() re-anchors + // to that key so the bubble visibly follows the finger instead + // of freezing above the key the gesture started on. A generous + // vertical band since an armed drag has already moved well + // above the row. + fun findSwipeTargetAt(rawX: Float, rawY: Float): View { + val row = parent as? ViewGroup ?: return trackedView + for (index in 0 until row.childCount) { + val sibling = row.getChildAt(index) + if (sibling !in englishSwipeSymbols) continue + val loc = IntArray(2) + sibling.getLocationOnScreen(loc) + if (rawX >= loc[0] - dp(6) && rawX < loc[0] + sibling.width + dp(6) && + rawY >= loc[1] - dp(60) && rawY < loc[1] + sibling.height + dp(24) + ) { + return sibling + } + } + return trackedView } - else -> { - val text = if (shiftState != ShiftState.OFF && key.length == 1) key.uppercase() else key - currentInputConnection?.commitText(text, 1) - // Caps-lock stays on for every letter; a one-shot shift only - // capitalizes the single letter that was just typed. + + setOnTouchListener { view, event -> + when (event.actionMasked) { + MotionEvent.ACTION_DOWN -> { + // Without this, a same-row drag to an adjacent key + // (a clearly horizontal drag, same shape as a + // panel-switch swipe) gets stolen mid-gesture by the + // enclosing SwipeModeContainer's own horizontal-swipe + // intercept once it crosses touchSlop — the key would + // stop receiving MOVE/UP entirely, so the bubble + // never follows the finger and nothing ever commits + // on release. Claimed unconditionally on DOWN, not + // only once a drag is detected: any movement that + // starts on a key belongs to that key's own + // press/retarget/commit handling, never to a + // panel-switch gesture. + view.parent?.requestDisallowInterceptTouchEvent(true) + trackedView = view + downY = event.y + swipePreviewShown = false + view.animate().scaleX(0.97f).scaleY(0.97f).translationZ(dp(3).toFloat()).alpha(0.90f).setDuration(65L).start() + performKeyHaptic() + showBubbleFor(view) + } + MotionEvent.ACTION_MOVE -> { + // Swipe-up-for-symbol (swipeSymbol != null; the + // digit row, and now row2/row3's punctuation) — + // same dp(10) arm threshold as keyboardKey()'s own + // swipe handling. Once armed, sliding sideways + // still retargets across the row's other + // symbol-bearing keys via findSwipeTargetAt (the + // bubble follows the finger to whichever symbol is + // now under it), separate from findTrackTargetAt's + // own retargeting used for the un-armed tap case. + // + // Live, not a one-way latch: armed is re-evaluated + // against the finger's *current* height every move, + // not just the first time it crosses the threshold + // — dragging back down below it un-arms and falls + // through to the normal tap-preview/retarget path + // below, giving a way to back out of the symbol + // mid-gesture without lifting the finger. + if (swipeSymbol != null) { + val armed = downY - event.y >= dp(10) + if (armed != swipePreviewShown) { + view.parent?.requestDisallowInterceptTouchEvent(true) + swipePreviewShown = armed + } + if (swipePreviewShown) { + val target = findSwipeTargetAt(event.rawX, event.rawY) + if (target !== trackedView) trackedView = target + val symbol = englishSwipeSymbols[trackedView] ?: swipeSymbol + keyPreviewOverlay?.showSwipePreview(symbol, trackedView) + return@setOnTouchListener true + } + } + val target = findTrackTargetAt(event.rawX, event.rawY) + if (target !== trackedView) trackedView = target + showBubbleFor(trackedView) + } + MotionEvent.ACTION_UP -> { + keyPreviewOverlay?.hide() + view.animate().scaleX(1f).scaleY(1f).translationZ(0f).alpha(1f).setDuration(90L).start() + if (swipeSymbol != null && swipePreviewShown) { + commitEnglishChar(englishSwipeSymbols[trackedView] ?: swipeSymbol) + } else { + commitEnglishChar(charFor(trackedView)) + } + } + MotionEvent.ACTION_CANCEL -> { + keyPreviewOverlay?.hide() + view.animate().scaleX(1f).scaleY(1f).translationZ(0f).alpha(1f).setDuration(90L).start() + } + } + true + } + layoutParams = LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.MATCH_PARENT, weight).apply { + setMargins(dp(3), dp(3), dp(3), dp(3)) + } + } + } + + private fun commitEnglishChar(char: String) { + if (char.isEmpty()) return + if (latinInputMode == LatinInputMode.PINYIN && !ImeLearningPolicy.isPassword(currentInputEditorInfo?.inputType ?: 0)) { + if (char.length == 1 && char[0].isLetter()) { + // Never calls commitText() — a letter only feeds the + // encoding buffer in Pinyin mode; see + // LitePinyinController's own doc comment. + litePinyinController.appendLetter(char[0]) { renderPinyinCandidates(it) } if (shiftState == ShiftState.SHIFT_ONCE) { shiftState = ShiftState.OFF refreshInputView() } + return + } + // A digit/punctuation typed mid-encoding (e.g. from the 123/ + // symbols layer) abandons the encoding the same way space does + // (plan 3.2 point 9) rather than leaving it stranded, then + // falls through to commit the symbol itself normally below. + litePinyinController.clear() + renderPinyinCandidates(emptyList()) + } + if (currentInputConnection?.commitText(char, 1) != true) return + if (personalizedLearningAllowed() && char.length == 1 && char[0].isLetter()) { + englishComposingWord.append(char.lowercase()) + updateEnglishCandidates() + } else { + // Digits/punctuation end whatever word was being tracked, same + // as space/return already do — "it's" typed via the symbols + // page still finalizes cleanly, just with no candidate query + // benefit for that particular boundary. + finalizeEnglishComposingWord() + } + if (shiftState == ShiftState.SHIFT_ONCE) { + shiftState = ShiftState.OFF + refreshInputView() + } + } + + /** Ends the current word: records it for user-frequency learning (if long enough to be a real word) and clears the tracking buffer. */ + private fun finalizeEnglishComposingWord() { + if (englishComposingWord.isNotEmpty()) { + val word = englishComposingWord.toString() + if (personalizedLearningAllowed() && englishSuggestionsEnabled() && word.length >= 2) { + englishCandidateProvider.recordCommit(word) } + englishComposingWord.clear() } + updateEnglishCandidates() } - private fun sendEnterKey() { + private fun englishSuggestionsEnabled(): Boolean = + getSharedPreferences("openless_ime_ui", MODE_PRIVATE).getBoolean("english_suggestions_enabled", true) + + /** + * Same presentation as the stroke panel's own candidate row: a + * HorizontalScrollView (so as many words as actually exist can be + * scrolled through, not a fixed 3-slot row) with the same + * touch-intercept override and the same overflow-triggered "▼ show + * more" button opening showCandidateOverlay() — the exact mechanism + * and candidateItemView() styling the stroke panel itself uses, reused + * as-is rather than a parallel implementation. + */ + private fun buildEnglishCandidateBar(): View { + // Outer vertical wrapper — divider, the actual bar, divider — so + // hiding it (see updateEnglishCandidates()) when suggestions are + // off takes both border lines with it instead of leaving two empty + // lines with nothing between them. + val wrapper = LinearLayout(this).apply { orientation = LinearLayout.VERTICAL } + englishCandidateBarContainer = wrapper + wrapper.addView( + buildDivider(), + LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, dp(1)).apply { + marginStart = dp(1) + marginEnd = dp(1) + }, + ) + val container = LinearLayout(this).apply { gravity = android.view.Gravity.CENTER_VERTICAL } + wrapper.addView(container, LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, dp(36))) + wrapper.addView( + buildDivider(), + LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, dp(1)).apply { + marginStart = dp(1) + marginEnd = dp(1) + bottomMargin = dp(2) + }, + ) + val scroll = object : android.widget.HorizontalScrollView(this) { + override fun onInterceptTouchEvent(ev: MotionEvent): Boolean { + if (ev.actionMasked == MotionEvent.ACTION_DOWN) { + parent?.requestDisallowInterceptTouchEvent(true) + } + return super.onInterceptTouchEvent(ev) + } + }.apply { + isHorizontalScrollBarEnabled = false + isFillViewport = false + overScrollMode = View.OVER_SCROLL_NEVER + } + val row = LinearLayout(this).apply { gravity = android.view.Gravity.CENTER_VERTICAL } + englishCandidateRow = row + scroll.addView(row, ViewGroup.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.MATCH_PARENT)) + container.addView(scroll, LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.MATCH_PARENT, 1f)) + val expandButton = StrokeActionView( + this, + "triangle-down", + iconColor = tone(Color.rgb(180, 180, 180), Color.rgb(130, 130, 135)), + ).apply { + contentDescription = ui("展开更多候选", "Show more candidates") + visibility = View.GONE + setOnClickListener { showCandidateOverlay(this) } + } + englishExpandCandidatesButton = expandButton + container.addView(expandButton, LinearLayout.LayoutParams(dp(28), ViewGroup.LayoutParams.MATCH_PARENT)) + // Same idea as the stroke candidate row's own listener: only shown + // once the candidates actually overflow the visible scroll width. + scroll.viewTreeObserver.addOnGlobalLayoutListener { + expandButton.visibility = if (row.width > scroll.width) View.VISIBLE else View.GONE + } + return wrapper + } + + /** + * Re-queries EnglishCandidateProvider for the current englishComposingWord + * prefix (or clears the bar if suggestions are off / nothing is being + * typed) — called after every letter, backspace, and word boundary. + * englishCandidateQueryEpoch discards a stale async result that comes + * back after a faster subsequent keystroke already moved the prefix on. + */ + private fun updateEnglishCandidates() { + val epoch = ++englishCandidateQueryEpoch + if (englishCandidateRow == null) return + if (!personalizedLearningAllowed() || !englishSuggestionsEnabled()) { + renderEnglishCandidates(emptyList()) + englishCandidateBarContainer?.visibility = View.GONE + return + } + englishCandidateBarContainer?.visibility = View.VISIBLE + val prefix = englishComposingWord.toString() + if (prefix.isEmpty()) { + renderEnglishCandidates(emptyList()) + return + } + englishCandidateProvider.queryTopN(prefix, ENGLISH_CANDIDATE_QUERY_LIMIT) { results -> + if (epoch == englishCandidateQueryEpoch) renderEnglishCandidates(results) + } + } + + private fun renderEnglishCandidates(words: List) { + val row = englishCandidateRow ?: return + row.removeAllViews() + val overlayEntries = mutableListOf Unit>>() + words.forEachIndexed { index, word -> + row.addView( + candidateItemView( + word, + isFirst = index == 0, + action = { selectEnglishCandidate(word) }, + onLongPress = { forgetEnglishCandidate(word) }, + ), + LinearLayout.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.MATCH_PARENT), + ) + overlayEntries.add(word to { selectEnglishCandidate(word) }) + } + // Shared with the stroke panel's own candidate row (only one of the + // two is ever visible at a time) — see showCandidateOverlay(). + candidateOverlayEntries = overlayEntries + if (words.isEmpty()) englishExpandCandidatesButton?.visibility = View.GONE + } + + /** + * Tapping a candidate replaces the just-typed prefix with the full word + * plus a trailing space. Verifies the actual field content matches + * englishComposingWord (trying progressively shorter suffixes, same + * defensive technique as invalidateDictationResultIfTextChanged()) + * before deleting, rather than trusting the buffer's length blindly — + * without this, any drift between the two left the old prefix letters + * sitting in front of the inserted word instead of being replaced by it. + */ + private fun selectEnglishCandidate(word: String) { + if (!personalizedLearningAllowed()) return + val connection = currentInputConnection ?: return + val typed = englishComposingWord.toString() + for (length in typed.length downTo 1) { + val actual = connection.getTextBeforeCursor(length, 0)?.toString() + if (actual != null && actual.equals(typed.takeLast(length), ignoreCase = true)) { + connection.deleteSurroundingText(length, 0) + break + } + } + if (!connection.commitText("$word ", 1)) return + englishCandidateProvider.recordCommit(word) + englishComposingWord.clear() + updateEnglishCandidates() + performKeyHaptic() + } + + /** + * Long-press on an English candidate — only does anything for a word + * the user's own typing taught this keyboard (see + * EnglishCandidateProvider.isCustomWord()); the bundled base dictionary + * isn't user-removable, so a long-press on one of those is a no-op + * (silent, not an error toast — a base-dictionary word appearing in the + * candidate row is completely ordinary, not something to explain away). + */ + private fun forgetEnglishCandidate(word: String) { + if (!englishCandidateProvider.isCustomWord(word)) return + englishCandidateProvider.forgetCustomWord(word) + performKeyHaptic() + Toast.makeText(this, ui("已移除“$word”", "Removed \"$word\""), Toast.LENGTH_SHORT).show() + updateEnglishCandidates() + } + + /** + * Renders LitePinyinController's own candidate callback into the same + * candidateItemView()/englishCandidateRow the English candidates use + * (plan 5.3's Option B — see docs/pinyin-lite/phase-0-audit.md section + * 4) — a non-clickable encoding label ("guo") is prepended when there's + * a non-empty encoding, per phase-0 audit section 5, adding no new row + * or container so the fixed 300dp panel height is never touched. + */ + private fun renderPinyinCandidates(candidates: List) { + val row = englishCandidateRow ?: return + row.removeAllViews() + val encoding = litePinyinController.currentEncoding() + if (encoding.isNotEmpty()) { + val label = TextView(this).apply { + text = encoding + textSize = 16f + setSingleLine(true) + gravity = android.view.Gravity.CENTER + setTextColor(tone(Color.rgb(180, 180, 180), Color.rgb(120, 120, 125))) + setPadding(dp(9), 0, dp(9), 0) + isClickable = false + } + row.addView(label, LinearLayout.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.MATCH_PARENT)) + } + val overlayEntries = mutableListOf Unit>>() + candidates.forEachIndexed { index, char -> + row.addView( + candidateItemView( + char, + isFirst = index == 0, + action = { selectPinyinCandidate(char) }, + ), + LinearLayout.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.MATCH_PARENT), + ) + overlayEntries.add(char to { selectPinyinCandidate(char) }) + } + // Shared with the English/stroke candidate rows (only one of the + // three is ever visible at a time) — see showCandidateOverlay(). + candidateOverlayEntries = overlayEntries + if (candidates.isEmpty()) englishExpandCandidatesButton?.visibility = View.GONE + } + + /** Candidate tap is the ONLY way a pinyin candidate reaches the field — see toggleLatinInputMode()'s doc comment on why Space deliberately never does this. */ + private fun selectPinyinCandidate(char: String) { + if (ImeLearningPolicy.isPassword(currentInputEditorInfo?.inputType ?: 0)) return + if (currentInputConnection?.commitText(char, 1) != true) return + // observeCommitForLearning needs the encoding still intact; commitSelection + // then records frequency under the real sourceKey and consumes that key + // (full buffer or first syllable) before refreshing the candidate row. + litePinyinController.observeCommitForLearning(char) + litePinyinController.commitSelection(char) { renderPinyinCandidates(it) } + litePinyinController.recordCommittedText(char) + performKeyHaptic() + refreshPinyinAssociations() + } + + /** + * Post-commit "what word comes next" suggestions — reuses + * StrokeInputController's own phraseRepository instance (same ~220k- + * entry index, same user-frequency store Stroke mode already tunes) so + * a word committed via Pinyin can suggest a continuation the same way + * Stroke's own commits do, and vice versa. Gated by the same + * strokeAssociationEnabled preference Stroke's own refreshAssociations() + * checks — this is explicitly the same feature, not a parallel one. + */ + private fun refreshPinyinAssociations() { + if (!personalizedLearningAllowed() || !OpenLessAndroidPreferences.strokeAssociationEnabled(this)) { + renderPinyinAssociations(emptyList()) + return + } + litePinyinController.queryAssociations(strokeController.phraseRepository) { results -> + // isEmpty(): guards against the case where the user already + // started typing a fresh encoding by the time this async result + // lands — associationEpoch alone only protects against a STALE + // association query, not against a completely different + // encoding query having taken over the candidate row since. + if (latinInputMode == LatinInputMode.PINYIN && litePinyinController.isEmpty()) renderPinyinAssociations(results) + } + } + + /** Same row/candidateItemView() machinery as renderPinyinCandidates(), but no encoding label (there's no in-progress encoding here) and suffix-based commit (see selectPinyinAssociation()) instead of committing the whole label — mirrors StrokeInputController.refreshAssociations() exactly. */ + private fun renderPinyinAssociations(candidates: List) { + val row = englishCandidateRow ?: return + row.removeAllViews() + val overlayEntries = mutableListOf Unit>>() + candidates.forEachIndexed { index, candidate -> + val displayText = outputScript(candidate.text) + row.addView( + candidateItemView( + displayText, + isFirst = index == 0, + action = { selectPinyinAssociation(candidate) }, + ), + LinearLayout.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.MATCH_PARENT), + ) + overlayEntries.add(displayText to { selectPinyinAssociation(candidate) }) + } + candidateOverlayEntries = overlayEntries + if (candidates.isEmpty()) englishExpandCandidatesButton?.visibility = View.GONE + } + + /** + * Only the part of the association candidate not already on screen gets + * committed — matches StrokeInputController.commitAssociation() exactly + * (same StrokePhraseRepository.Candidate type). candidate.matchedPrefix + * is always populated by StrokePhraseRepository.find() before a + * candidate is ever returned from searchAsync(), so it's trusted + * directly here rather than re-deriving the query context. + */ + private fun selectPinyinAssociation(candidate: StrokePhraseRepository.Candidate) { + if (isSensitiveField(currentInputEditorInfo) || !candidate.text.startsWith(candidate.matchedPrefix)) return + val suffix = candidate.text.removePrefix(candidate.matchedPrefix) + val connection = currentInputConnection ?: return + if (suffix.isNotEmpty() && !connection.commitText(outputScript(suffix), 1)) return + if (personalizedLearningAllowed() && OpenLessAndroidPreferences.strokeUsageEnabled(this)) { + strokeController.phraseRepository.recordUsage(candidate.matchedPrefix, candidate.text) + } + litePinyinController.recordCommittedText(suffix) + performKeyHaptic() + refreshPinyinAssociations() + } + + /** + * Backspace, everywhere it's wired up in this service. deleteSurroundingText() + * alone only ever removes characters relative to the cursor position — it does + * not reliably consume an active selection (behavior varies by target app), so + * when there's a real OS-level selection this commits an empty string instead, + * which every InputConnection implementation replaces the selection with. + */ + internal fun deleteBackward() { + val connection = currentInputConnection ?: return + val selected = connection.getSelectedText(0) + // Consumed by the very next invalidateDictationResultIfTextChanged() + // call (from onUpdateSelection(), which this delete triggers) so it + // skips clearing lastDictationText — deleting via our own backspace + // (plain or select-all-then-backspace, both land here) should stay + // undoable, unlike text disappearing for some other reason (e.g. the + // host app clearing the field itself after sending). + selfInitiatedTextChange = true + if (!selected.isNullOrEmpty()) { + connection.commitText("", 1) + } else { + connection.deleteSurroundingText(1, 0) + } + } + + internal fun sendEnterKey() { currentInputConnection?.sendKeyEvent(android.view.KeyEvent( android.view.KeyEvent.ACTION_DOWN, android.view.KeyEvent.KEYCODE_ENTER, @@ -2203,20 +3345,26 @@ class OpenLessImeService : InputMethodService(), OpenLessOverlayBridge.OverlaySt } override fun onStartInput(attribute: EditorInfo?, restarting: Boolean) { + cancelImeSession() super.onStartInput(attribute, restarting) restoreInputMode() restoreScriptPreference() + restoreLatinInputMode() refreshLanguage() - strokeNumberMode = false - numberSymbolMode = false - symbolPageIndex = 0 + strokeController.resetForNewInputSession() + englishLayer = EnglishLayer.LETTERS + englishComposingWord.clear() + englishCandidateQueryEpoch++ + litePinyinController.clear() + litePinyinController.resetAssociationContext() + litePinyinController.setLearningAllowed(personalizedLearningAllowed()) + if (ImeLearningPolicy.isPassword(attribute?.inputType ?: 0)) { + inputMode = InputMode.ENGLISH + latinInputMode = LatinInputMode.ENGLISH + } + renderEnglishCandidates(emptyList()) startRuntimeService() sessionEpoch++ - confirmedText = "" - phraseQueryEpoch++ - strokeCode = "" - wordSegments.clear() - lastStrokeCandidates = emptyList() recording = false processing = false // A dictation result belongs to the editor it was typed into; carrying @@ -2233,9 +3381,31 @@ class OpenLessImeService : InputMethodService(), OpenLessOverlayBridge.OverlaySt } } + override fun onStartInputView(info: EditorInfo?, restarting: Boolean) { + super.onStartInputView(info, restarting) + // InputMethodService caches onCreateInputView()'s result across hide/ + // show. Keyboard settings write height/raise prefs while that cached + // view is still the old footprint — rebuild when they diverge so the + // next focus actually shows what the settings preview promised. + val key = keyboardFootprintKey() + if (appliedKeyboardFootprintKey != key) { + appliedKeyboardFootprintKey = key + refreshInputView() + } + } + + private fun keyboardFootprintKey(): String = + "${keyboardPanelHeightDp()}:${raiseHeightDp(this)}:$inputMode:$latinInputMode:${personalizedLearningAllowed()}" + override fun onFinishInput() { + cancelImeSession() + englishCandidateQueryEpoch++ + englishComposingWord.clear() + litePinyinController.clear() + litePinyinController.resetAssociationContext() + strokeController.resetForNewInputSession() if (recording) { - runNativeAction("取消听写") { OpenLessNative.nativeCancelDictation() } + cancelImeSession() } recording = false processing = false @@ -2248,13 +3418,15 @@ class OpenLessImeService : InputMethodService(), OpenLessOverlayBridge.OverlaySt } /** - * Detects the committed dictation text getting edited or deleted some - * other way (backspace, selecting and typing over it, etc.) — not just - * through our own undo button — so the undo/redo/edit controls don't - * keep pointing at text that's no longer actually there. Called both - * from onUpdateSelection() (covers the host app's own keyboard/gestures) - * and directly after our own footer backspace key (some hosts, e.g. - * WeCom, don't report onUpdateSelection promptly per keystroke). + * Detects the committed dictation text getting cleared by something + * OTHER than our own backspace key or undo button — e.g. the host app + * clearing the field itself after sending — so the undo/redo/edit + * controls don't keep pointing at text that's no longer actually there. + * Deleting via our own backspace (plain or select-all-then-backspace, + * see deleteBackward()) is deliberately NOT treated as a reason to hide + * these: that deletion is itself undoable (the undo button just + * re-commits lastDictationText), so hiding it would strand an + * accidental full erase with no way back. * * Only clears once NONE of the dictated span remains — a single * backspace only shrinks it by one character, which should still leave @@ -2265,6 +3437,10 @@ class OpenLessImeService : InputMethodService(), OpenLessOverlayBridge.OverlaySt * still matches there, some of the utterance is still present. */ private fun invalidateDictationResultIfTextChanged() { + if (selfInitiatedTextChange) { + selfInitiatedTextChange = false + return + } val text = lastDictationText ?: return if (dictationTextUndone) return val connection = currentInputConnection ?: return @@ -2287,7 +3463,8 @@ class OpenLessImeService : InputMethodService(), OpenLessOverlayBridge.OverlaySt invalidateDictationResultIfTextChanged() } - private fun toggleDictation() { + internal fun toggleDictation(initialCloud: Boolean = false) { + if (processing) return if (isSensitiveField(currentInputEditorInfo)) { updateStatus("敏感字段,禁止听写") return @@ -2296,6 +3473,10 @@ class OpenLessImeService : InputMethodService(), OpenLessOverlayBridge.OverlaySt updateStatus("请先授予麦克风权限") return } + if (recording && quickNoteArmed) { + quickNoteDictation() + return + } if (recording) { recording = false processing = true @@ -2309,29 +3490,185 @@ class OpenLessImeService : InputMethodService(), OpenLessOverlayBridge.OverlaySt refreshInputView() } setState("thinking", "正在思考") - runNativeAction("停止听写") { OpenLessNative.nativeStopDictationForIme() } + stopImeSession() } else { + if (!isBackendReady()) { + if (!awaitingBackendReadyRecheck) { + backendRecheckStartedAtMs = android.os.SystemClock.elapsedRealtime() + android.util.Log.w("OpenLessImeService", "backend not ready at mic tap; starting recovery watch") + OpenLessProcessRestartStats(this, "mictap").recordStart() + } + setState("error", ui("服务尚未就绪", "Service not ready yet")) + voiceLinkWarning?.text = ui("服务尚未就绪,点击重启应用", "Service not ready — tap to restart the app") + voiceLinkWarning?.visibility = View.VISIBLE + ensureBackendReady() + scheduleBackendReadyRecheck() + return + } recording = true processing = false + rawModeArmed = false + quickNoteArmed = false + cloudNoteArmed = false // The actual start of a new recording attempt — reset the - // silence watch here, not in onCapsuleStateChanged's "recording" - // branch: that branch only reset it when `recording` was still - // false at the time, but this line already flips it true before - // the native "recording" callback ever arrives, so that reset - // was structurally unreachable on every normal tap-to-start. + // silence watch and fire the start haptic here, not in + // onCapsuleStateChanged's "recording" branch: that branch only + // acted when `recording` was still false at the time, but this + // line already flips it true before the native "recording" + // callback ever arrives, so both were structurally unreachable + // on every normal tap-to-start (silently never firing). recordingStartedAtMs = android.os.SystemClock.elapsedRealtime() maxObservedLevelThisSession = 0f voiceLinkWarning?.visibility = View.GONE - setState("speaking", "再次点击结束") - runNativeAction("开始听写") { OpenLessNative.nativeStartDictationForIme() } + performKeyHaptic() + // A genuinely new utterance (not the correction/edit sub-flow, + // which already hides these via editingDictationResult) makes + // the previous result's undo/redo/edit controls stale the + // moment recording starts, not just once the new result + // replaces them — leaving them up mid-recording risked an undo + // tap acting on the wrong utterance. + if (!editingDictationResult) { + lastDictationText = null + } + setState("speaking", "再次点击结束 · 下划取消") + imeRequestId = 0L + imeSessionId = null + cloudNoteArmed = initialCloud + imeRequestId = nextImeRequest.incrementAndGet() + pendingImeStop = false + if (!sendImeCommand("start")) { recording = false; processing = false } + } + } + + private fun cancelDictation() { + recording = false + processing = false + rawModeArmed = false + quickNoteArmed = false + cloudNoteArmed = false + invalidateSession("已取消") + cancelImeSession() + } + + private fun quickNoteDictation() { + recording = false + processing = true + setState("thinking", ui("正在保存笔记", "Saving note")) + stopImeSession() + } + + // Also requires a registered Activity Context (see + // OpenLessNative.nativeHasRegisteredActivityContext()'s doc comment), + // not just a running backend: the Rust backend can stay healthy long + // after its last Activity is destroyed, but every dictation/waveform + // status notification silently fails without one — recording would + // otherwise start with zero visible feedback (no waveform, no red + // warning, nothing), looking exactly like the mic tap did nothing. + private fun isBackendReady(): Boolean = try { + OpenLessNative.requireBackendContract() + OpenLessNative.nativeHasRegisteredActivityContext() + } catch (error: Throwable) { + false + } + + /** + * Runs every BACKEND_HEARTBEAT_INTERVAL_MS for the service's whole + * lifetime (started in onCreate(), stopped in onDestroy()) — not just + * while actively recording — so backendLinkIndicator can catch the link + * having quietly dropped *before* the user taps the mic and gets no + * waveform, instead of only reacting after a failed tap (see "mictap"). + * On a bad reading it also proactively calls ensureBackendReady() the + * same way a failed mic tap already does, so the self-recovery this + * symptom relies on gets a head start instead of waiting for the next + * tap. + */ + private fun runBackendHeartbeatCheck() { + val ready = isBackendReady() + backendLinkHealthy = ready + lastHeartbeatElapsedRealtime = android.os.SystemClock.elapsedRealtime() + lastHeartbeatReady = ready + // Always repaint the currently mounted indicator. Previously this was + // skipped while recording/processing, and a recovery could remain + // visually stale until the user switched panels and rebuilt the View. + updateBackendLinkIndicator() + if (!ready) { + OpenLessProcessRestartStats(this, "heartbeat").recordStart() + ensureBackendReady() } } - private fun cancelDictation() { - recording = false - processing = false - invalidateSession("已取消") - runNativeAction("取消听写") { OpenLessNative.nativeCancelDictation() } + /** + * Ready/recording/processing take priority (mirrors the overlay's own + * state machine) — the heartbeat's own reading (backendLinkHealthy) + * only shows through while otherwise idle, as a distinct color rather + * than silently agreeing with "ready". Not gated on an actual + * connectivity check (that would need a new ACCESS_NETWORK_STATE + * permission the manifest doesn't declare yet): isBackendReady() + * false is already the same signal "mictap" reacts to, and is directly + * what the user described as the link being "disconnected". + */ + private fun updateBackendLinkIndicator() { + val color = when { + // Matches VoiceButton's own waveform color for each armed + // gesture exactly (rawWaveformColor's when block) — the + // breathing dot and the waveform should never disagree about + // which mode a recording is currently armed for. + recording && rawModeArmed -> LINK_COLOR_RECORDING_RAW + recording && quickNoteArmed -> LINK_COLOR_QUICK_NOTE + recording && cloudNoteArmed -> LINK_COLOR_CLOUD_NOTE + recording -> LINK_COLOR_RECORDING + processing -> LINK_COLOR_PROCESSING + !backendLinkHealthy -> LINK_COLOR_ISSUE + else -> LINK_COLOR_READY + } + (backendLinkIndicator?.background as? GradientDrawable)?.setColor(color) + backendLinkPulseAnimator?.duration = + if (color == LINK_COLOR_READY) BACKEND_LINK_READY_PULSE_DURATION_MS else BACKEND_LINK_PULSE_DURATION_MS + } + + // Guards scheduleBackendReadyRecheck() so a second mic tap while a + // recheck loop is already running doesn't stack a duplicate one. + private var awaitingBackendReadyRecheck = false + + // elapsedRealtime() at the moment "not ready" was first detected, so the + // eventual recovery (or timeout) log can report how long it actually took. + private var backendRecheckStartedAtMs = 0L + + /** + * Polls isBackendReady() roughly once a second after showing the + * "service not ready" warning, since nothing else pushes a "the + * backend just finished warming up" event into this service — without + * this, the warning stayed stuck until some unrelated action (another + * mic tap, switching panels) happened to rebuild/reset it, even though + * the backend may have become ready seconds earlier in the background. + * Capped at ~60s of retries rather than polling forever if the backend + * genuinely never recovers. Raised from an earlier 20s cap after a real + * device recovery was observed taking ~31s (a double cold-start: the + * warmup Activity got destroyed once mid-warmup, then succeeded on a + * second attempt), which exceeded that cap and left the warning stuck. + */ + private fun scheduleBackendReadyRecheck(attempt: Int = 0) { + if (attempt == 0) { + if (awaitingBackendReadyRecheck) return + awaitingBackendReadyRecheck = true + } + if (isBackendReady()) { + awaitingBackendReadyRecheck = false + val elapsedMs = android.os.SystemClock.elapsedRealtime() - backendRecheckStartedAtMs + android.util.Log.i("OpenLessImeService", "backend recovered after ${elapsedMs}ms (attempt=$attempt)") + voiceLinkWarning?.visibility = View.GONE + setState("idle", ui("点击开始说话", "Tap to speak")) + return + } + if (attempt >= 60) { + awaitingBackendReadyRecheck = false + val elapsedMs = android.os.SystemClock.elapsedRealtime() - backendRecheckStartedAtMs + android.util.Log.w("OpenLessImeService", "backend still not ready after ${elapsedMs}ms; giving up recheck loop") + return + } + android.os.Handler(android.os.Looper.getMainLooper()).postDelayed({ + scheduleBackendReadyRecheck(attempt + 1) + }, 1000L) } private fun runNativeAction(action: String, call: () -> Unit) { @@ -2345,6 +3682,19 @@ class OpenLessImeService : InputMethodService(), OpenLessOverlayBridge.OverlaySt } } + /** + * `message ?: fallback` only substitutes for a literal null — a native + * capsule-state event carrying a blank-but-non-null message (empty + * string, or whitespace) would slip through that check unchanged and + * land the status line on invisible/blank text, with nothing left to + * ever correct it (see onCapsuleStateChanged()'s "idle" case, which + * this guards against overwriting setState()'s own delayed "done" -> + * "idle" revert with a blank message that then never gets fixed, since + * state has already moved off "done" by the time that revert's own + * guard checks it). + */ + private fun String?.orDefault(fallback: String): String = this?.takeIf { it.isNotBlank() } ?: fallback + override fun onCapsuleStateChanged(state: String, message: String?, level: Float) { voiceButton?.audioLevel = level.coerceIn(0f, 1f) when (state) { @@ -2354,6 +3704,7 @@ class OpenLessImeService : InputMethodService(), OpenLessOverlayBridge.OverlaySt recordingStartedAtMs = android.os.SystemClock.elapsedRealtime() maxObservedLevelThisSession = 0f voiceLinkWarning?.visibility = View.GONE + performKeyHaptic() } recording = true processing = false @@ -2362,16 +3713,29 @@ class OpenLessImeService : InputMethodService(), OpenLessOverlayBridge.OverlaySt if (maxObservedLevelThisSession >= SILENCE_LEVEL_THRESHOLD) { voiceLinkWarning?.visibility = View.GONE } else if (elapsedMs > SILENCE_CHECK_DELAY_MS) { + // This only runs once an actual recording session is + // underway, so it can't overlap with the "backend not + // ready" case in toggleDictation() (that one returns + // before a session ever starts) — but set this widget's + // own text explicitly anyway rather than trusting + // whatever the other case last left it as. + voiceLinkWarning?.text = ui("检测到麦克风无声音,点击重启应用", "No mic audio detected — tap to restart the app") voiceLinkWarning?.visibility = View.VISIBLE } - setState("speaking", "再次点击结束") + setState("speaking", "再次点击结束 · 下划取消") } "transcribing" -> { + // Guarded by the same "was still recording" check as + // "polishing" below, since either one can be the first to + // fire after recording actually stops — only whichever + // gets there first should vibrate. + if (recording) performKeyHaptic() recording = false processing = true setState("thinking", "正在思考") } "polishing" -> { + if (recording) performKeyHaptic() recording = false processing = true setState("thinking", "正在思考") @@ -2379,49 +3743,31 @@ class OpenLessImeService : InputMethodService(), OpenLessOverlayBridge.OverlaySt "done" -> { recording = false processing = false - setState("done", message ?: "已完成") + setState("done", message.orDefault("已完成")) + performKeyHaptic() } "cancelled" -> { recording = false processing = false - setState("idle", message ?: "已取消") + setState("idle", message.orDefault("已取消")) } "error" -> { recording = false processing = false - setState("error", message ?: "识别失败") + setState("error", message.orDefault("识别失败")) } "idle" -> if (!recording) { - setState("idle", message ?: "点击开始说话") + setState("idle", message.orDefault("点击开始说话")) } } } - private fun commitTestText() { - val attribute = currentInputEditorInfo - if (isSensitiveField(attribute)) { - updateStatus("敏感字段,禁止上屏") - return - } - val connection = currentInputConnection - if (connection == null) { - updateStatus("没有有效输入连接") - return - } - val epoch = sessionEpoch - if (epoch != sessionEpoch || !connection.commitText(TEST_TEXT, 1)) { - updateStatus("输入连接已失效") - return - } - updateStatus("已上屏") - } - private fun invalidateSession(message: String) { sessionEpoch++ updateStatus(message) } - private fun isSensitiveField(attribute: EditorInfo?): Boolean { + internal fun isSensitiveField(attribute: EditorInfo?): Boolean { val inputType = attribute?.inputType ?: return false val variation = inputType and InputType.TYPE_MASK_VARIATION return (inputType and InputType.TYPE_MASK_CLASS) == InputType.TYPE_CLASS_NUMBER || @@ -2433,9 +3779,107 @@ class OpenLessImeService : InputMethodService(), OpenLessOverlayBridge.OverlaySt private fun updateStatus(message: String) { currentMessage = message status?.text = displayStatus(message) + status?.setTextColor(recordingAccentColor()) + voiceRawHint?.text = rawModeHintText() + voiceRawHint?.setTextColor(rawModeHintColor()) + voiceRawHint?.visibility = if (rawModeHintVisible()) View.VISIBLE else View.GONE voiceButton?.isRecording = recording voiceButton?.isProcessing = processing updateDictationResultControls() + updateBackendLinkIndicator() + } + + /** + * Swipe-up-for-Raw / swipe-left-for-Quick-notes / swipe-right-for- + * Cloud-notes discoverability hint while idle; once a recording is + * actually armed into one of those three modes (live through both + * recording and the following "thinking"/整理 step for Raw and Cloud + * notes — quick note never reaches "thinking", see + * quickNoteDictation()), the row repurposes itself to confirm whichever + * one is armed instead. Recording-or-thinking with none armed (an + * ordinary dictation) never reaches this text at all — see + * rawModeHintVisible(), which hides the row entirely for that case. + */ + private fun rawModeHintText(): String { + return when { + (state == "speaking" || state == "thinking") && rawModeArmed -> ui("原样转写", "Raw Mode") + (state == "speaking" || state == "thinking") && cloudNoteArmed -> ui("云笔记", "Cloud notes") + state == "speaking" && quickNoteArmed -> ui("速记模式", "Quick notes") + else -> ui("上划RAW · 左划速记 · 右划云笔记", "Up: Raw · Left: Quick notes · Right: Cloud notes") + } + } + + /** Same orange/green/red as the status line's own Raw/Quick-notes/Cloud-notes coloring and every other indicator for each mode (VoiceButton's armed pill/waveform) — muted gray otherwise. */ + private fun rawModeHintColor(): Int { + return when { + (state == "speaking" || state == "thinking") && rawModeArmed -> LINK_COLOR_RECORDING_RAW + (state == "speaking" || state == "thinking") && cloudNoteArmed -> LINK_COLOR_CLOUD_NOTE + state == "speaking" && quickNoteArmed -> LINK_COLOR_QUICK_NOTE + else -> Color.argb((0.8f * 255).toInt(), 0xB0, 0xB0, 0xB0) + } + } + + /** + * Hidden while actively recording or thinking in an ordinary dictation + * (none of Raw/Quick-notes/Cloud-notes armed) — at that point it's neither teaching + * a still-relevant gesture (idle) nor confirming an active one, just a + * stray label under the mic. Visible the rest of the time: idle + * (teaches all three gestures) and recording/thinking once one of them + * is actually armed (confirms it). + */ + private fun rawModeHintVisible(): Boolean { + return !((state == "speaking" || state == "thinking") && !rawModeArmed && !quickNoteArmed && !cloudNoteArmed) + } + + /** The request owns its destination; delayed network callbacks cannot affect a later recording. */ + private fun submitCloudNoteText(text: String, destination: Pair?, requestId: Long) { + if (text.isBlank()) { + recording = false + processing = false + setState("error", "没有识别到文字") + return + } + val (url, token) = destination ?: ("" to "") + recording = false + processing = false + if (url.isEmpty() || token.isEmpty()) { + setState("error", "请先在设置中填写云笔记的地址/Token") + return + } + setState("thinking", "正在提交云笔记") + Thread { + val mainHandler = android.os.Handler(Looper.getMainLooper()) + var connection: java.net.HttpURLConnection? = null + try { + val body = org.json.JSONObject().apply { + put("token", token) + put("content", text) + put("client", "input_method") + }.toString() + connection = (java.net.URL(url).openConnection() as java.net.HttpURLConnection).apply { + requestMethod = "POST" + setRequestProperty("Content-Type", "application/json; charset=utf-8") + doOutput = true + connectTimeout = 8000 + readTimeout = 8000 + } + connection.outputStream.use { it.write(body.toByteArray(Charsets.UTF_8)) } + val code = connection.responseCode + mainHandler.post { + if (requestId != imeRequestId) return@post + if (code in 200..299) { + setState("done", "已提交云笔记", QUICK_NOTE_CONFIRMATION_DELAY_MS) + } else { + setState("error", "云笔记提交失败($code)") + } + } + } catch (error: Throwable) { + android.util.Log.w("OpenLessImeService", "cloud note webhook submit failed: ${error.javaClass.simpleName}") + mainHandler.post { + if (requestId == imeRequestId) setState("error", "云笔记提交失败,请检查网络") + } + } finally { connection?.disconnect() } + }.start() } private fun commitImeText(text: String) { @@ -2472,7 +3916,7 @@ class OpenLessImeService : InputMethodService(), OpenLessOverlayBridge.OverlaySt setTextColor(tone(Color.WHITE, Color.rgb(40, 40, 44))) background = GradientDrawable().apply { shape = GradientDrawable.OVAL - setColor(tone(Color.rgb(54, 54, 54), Color.rgb(225, 225, 228))) + setColor(tone(Color.rgb(60, 60, 60), Color.rgb(225, 225, 228))) } contentDescription = description setOnClickListener { action() } @@ -2516,10 +3960,12 @@ class OpenLessImeService : InputMethodService(), OpenLessOverlayBridge.OverlaySt return } updateStatus("已撤销") + scheduleRevertToIdle() } else { if (!connection.commitText(text, 1)) return dictationTextUndone = false updateStatus("已上屏") + scheduleRevertToIdle() } } @@ -2542,8 +3988,11 @@ class OpenLessImeService : InputMethodService(), OpenLessOverlayBridge.OverlaySt editingOriginalText = whole editingReplacesWholeResult = true } - editingForClipboardCorrection = false - addCorrectionRuleForEdit = true + // Starts unchecked every time — adding to the Dictionary is an + // opt-in the user ticks deliberately, not a guess based on how much + // of the result was selected. The user can still flip the checkbox + // themselves before finishing. + addToDictionaryForEdit = false editingDictationResult = true awaitingEditReplacement = true refreshInputView() @@ -2553,73 +4002,80 @@ class OpenLessImeService : InputMethodService(), OpenLessOverlayBridge.OverlaySt toggleDictation() } + /** + * Long-press "History"/"历史" in the clipboard panel: same mechanism as + * openEditDictationResult()'s selected-text branch (replaces the + * selection in the real input field with the spoken correction) — but + * with no whole-last-result fallback. Without an actual selection + * there's nothing this gesture can reasonably act on, so it surfaces a + * hint instead of guessing. + */ + private fun openSelectedTextCorrectionViaVoice() { + val selected = currentInputConnection?.getSelectedText(0)?.toString()?.takeIf { it.isNotEmpty() } + if (selected == null) { + Toast.makeText(this, ui("请先选中字词", "Please select word"), Toast.LENGTH_SHORT).show() + return + } + editingOriginalText = selected + editingReplacesWholeResult = false + addToDictionaryForEdit = false + editingDictationResult = true + awaitingEditReplacement = true + refreshInputView() + toggleDictation() + } + /** Backs out of the edit sub-view without applying any correction. */ private fun closeEditDictationResult() { if (recording) { - runNativeAction("取消听写") { OpenLessNative.nativeCancelDictation() } + cancelImeSession() } recording = false processing = false editingDictationResult = false awaitingEditReplacement = false editingOriginalText = null - editingForClipboardCorrection = false setState("done", "已上屏") refreshInputView() } /** - * Applies the freshly spoken replacement for editingOriginalText. + * Applies the freshly spoken replacement for editingOriginalText: swaps + * it into the input field (relying on InputConnection.commitText's + * standard "replace the active selection" behavior when there is a real + * OS selection, or an explicit delete+insert when we fell back to the + * whole last result). * - * Normal "edit dictation result" flow: swaps it into the input field - * (relying on InputConnection.commitText's standard "replace the active - * selection" behavior when there is a real OS selection, or an explicit - * delete+insert when we fell back to the whole last result). Clipboard - * swipe-left "add correction" flow (editingForClipboardCorrection): - * never touches currentInputConnection at all, since editingOriginalText - * there is an arbitrary clipboard entry, not necessarily anything - * currently focused anywhere — only recording a correction rule applies. - * - * Either way, recording the change as a correction rule is gated on - * addCorrectionRuleForEdit — unconditional for the clipboard flow (that - * is the entire point of swiping), opt-out via the edit panel's checkbox - * for the normal flow (every edit used to silently add one, which piled - * up rules for edits that were just rewording, not misrecognitions). + * Remembering the corrected word/phrase in the global Dictionary + * (nativeAddVocabularyWord — the same store the desktop Dictionary UI + * reads/writes; corrections themselves are hand-maintained only, see + * native_bridge.rs's spawn_add_vocabulary_word()) is gated on + * addToDictionaryForEdit, opt-out via the edit panel's checkbox (every + * edit used to silently add one, which piled up entries for edits that + * were just rewording, not an actual new/misrecognized word). */ private fun finishEditWithSpokenReplacement(text: String) { recording = false processing = false val original = editingOriginalText val replacesWhole = editingReplacesWholeResult - val forClipboardCorrection = editingForClipboardCorrection - val shouldAddRule = addCorrectionRuleForEdit + val shouldAddToDictionary = addToDictionaryForEdit editingDictationResult = false awaitingEditReplacement = false editingOriginalText = null - editingForClipboardCorrection = false if (text.isBlank() || original == null) { setState("done", "已上屏") refreshInputView() return } - if (forClipboardCorrection) { - if (shouldAddRule && text != original) { - runNativeAction("记录纠错") { - OpenLessNative.nativeAddCorrectionRule(original, text) - } - } - setState("done", ui("已加入纠错规则", "Correction rule added")) - refreshInputView() - return - } val connection = currentInputConnection if (connection != null) { if (replacesWhole) connection.deleteSurroundingText(original.length, 0) connection.commitText(text, 1) } - if (shouldAddRule && text != original) { - runNativeAction("记录纠错") { - OpenLessNative.nativeAddCorrectionRule(original, text) + if (shouldAddToDictionary && text != original) { + runNativeAction("加入词典") { + OpenLessNative.nativeAddVocabularyWord(text) } } // A sub-span correction leaves the surrounding text's exact new @@ -2632,18 +4088,55 @@ class OpenLessImeService : InputMethodService(), OpenLessOverlayBridge.OverlaySt refreshInputView() } - private fun setState(nextState: String, message: String) { + private fun setState(nextState: String, message: String, revertDelayMs: Long = DONE_TO_IDLE_DELAY_MS) { state = nextState updateStatus(message) + // A completed commit/edit (see the various setState("done", "已 + // 上屏") call sites) used to just sit there until the next recording + // started; the status line and the Raw hint below it should instead + // settle back to the same ready state as a fresh session shortly + // after, so it's visibly clear another dictation can start right + // away — see scheduleRevertToIdle(). revertDelayMs only ever + // differs from the default for quickNoteDictation()'s own shorter + // "笔记已记录" confirmation (QUICK_NOTE_CONFIRMATION_DELAY_MS). + if (nextState == "done") scheduleRevertToIdle(revertDelayMs) + } + + /** + * Shows "点击开始说话"/"Tap to speak" again DONE_TO_IDLE_DELAY_MS after + * whatever transient confirmation is currently up (已上屏/已完成 via + * setState()'s "done" branch, or 已撤销/已上屏 from + * toggleUndoRedoDictation()'s direct updateStatus() calls, which don't + * go through setState() at all since undo/redo doesn't change `state`). + * A monotonic token, not a state-string comparison, guards the delayed + * callback: every call here bumps it, so only the most recently + * scheduled revert actually fires — tapping undo right after a commit + * (before the commit's own revert would have fired) correctly restarts + * the 2-second countdown from the undo instead of the two racing and + * the earlier one winning. sessionEpoch is still checked too, for a + * genuinely new input session in the meantime. + */ + private fun scheduleRevertToIdle(delayMs: Long = DONE_TO_IDLE_DELAY_MS) { + val epoch = sessionEpoch + val token = ++statusRevertToken + android.os.Handler(Looper.getMainLooper()).postDelayed({ + if (sessionEpoch == epoch && statusRevertToken == token) { + setState("idle", ui("点击开始说话", "Tap to speak")) + } + }, delayMs) } private fun displayStatus(message: String): String { if (!englishUi) return message return when (message) { "点击开始说话" -> "Tap to speak" - "再次点击结束" -> "Tap again to finish" + "再次点击结束 · 下划取消" -> "Tap again to finish · Swipe down to cancel" "正在思考" -> "Thinking" "已完成", "已上屏" -> "Done" + "笔记已记录" -> "Note saved" + "正在提交云笔记" -> "Submitting" + "已提交云笔记" -> "Cloud notes submitted" + "请先在设置中填写云笔记的地址/Token" -> "Fill in the Cloud notes URL/token in settings first" "已取消" -> "Cancelled" "敏感字段,已禁用听写", "敏感字段,禁止听写", "敏感字段,禁止上屏" -> "Dictation disabled in this field" "请先授予麦克风权限" -> "Microphone permission required" @@ -2663,7 +4156,7 @@ class OpenLessImeService : InputMethodService(), OpenLessOverlayBridge.OverlaySt } } - private fun dp(value: Int): Int = (value * resources.displayMetrics.density).toInt() + internal fun dp(value: Int): Int = (value * resources.displayMetrics.density).toInt() /** * Purely visual 0.98x press-scale via StateListAnimator, which reacts to @@ -2688,7 +4181,7 @@ class OpenLessImeService : InputMethodService(), OpenLessOverlayBridge.OverlaySt } } - private fun roundedButton(color: Int, radius: Int): android.graphics.drawable.Drawable { + internal fun roundedButton(color: Int, radius: Int): android.graphics.drawable.Drawable { val lowerEdge = GradientDrawable().apply { shape = GradientDrawable.RECTANGLE cornerRadius = radius.toFloat() @@ -2716,7 +4209,7 @@ class OpenLessImeService : InputMethodService(), OpenLessOverlayBridge.OverlaySt // rounded rect a touch lighter than the panel, plus a hairline divider // baked in at the fixed 24dp encode/candidate boundary. Since `top`'s // height is always exactly dp(60), this offset never drifts. - private fun buildEncodeAreaBackground(): android.graphics.drawable.Drawable { + internal fun buildEncodeAreaBackground(): android.graphics.drawable.Drawable { val panel = GradientDrawable().apply { shape = GradientDrawable.RECTANGLE cornerRadius = dp(6).toFloat() @@ -2818,7 +4311,7 @@ class OpenLessImeService : InputMethodService(), OpenLessOverlayBridge.OverlaySt * swipe anywhere in the column advances exactly one group, rather than * being absorbed as a press/drag on one key. */ - private class SwipeRail( + internal class SwipeRail( context: android.content.Context, private val onSwipe: (Int) -> Unit, ) : LinearLayout(context) { @@ -2870,6 +4363,35 @@ class OpenLessImeService : InputMethodService(), OpenLessOverlayBridge.OverlaySt } } + /** + * Outer IME host for stretch+raise: key panel on top + empty lift strip + * below. Forces EXACT total height the same way [SwipeModeContainer] + * forces the key-panel height (setInputView discards LayoutParams). + * Re-reads prefs on every measure so a settings change applies on the + * next layout even before a full view rebuild. + */ + private class RaisedKeyboardHost( + context: android.content.Context, + ) : LinearLayout(context) { + override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { + val panel = panelHeightPx(context) + val raise = raiseHeightPx(context) + if (childCount >= 1) { + getChildAt(0).layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, panel) + } + if (childCount >= 2) { + val spacer = getChildAt(1) + spacer.layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, raise) + spacer.visibility = if (raise > 0) View.VISIBLE else View.GONE + } + val exactHeightSpec = android.view.View.MeasureSpec.makeMeasureSpec( + panel + raise, + android.view.View.MeasureSpec.EXACTLY, + ) + super.onMeasure(widthMeasureSpec, exactHeightSpec) + } + } + /** * Horizontal swipe-to-switch-mode, applied to every panel's root * container: a left/right drag anywhere that isn't already claimed by a @@ -2878,7 +4400,7 @@ class OpenLessImeService : InputMethodService(), OpenLessOverlayBridge.OverlaySt * anywhere on the panel dismisses the keyboard entirely, same as any * other IME's own hide gesture. */ - private class SwipeModeContainer( + internal class SwipeModeContainer( context: android.content.Context, // Fraction of the container's width, measured from the left edge, // where a downward drag never triggers dismiss — the stroke panel's @@ -2897,6 +4419,15 @@ class OpenLessImeService : InputMethodService(), OpenLessOverlayBridge.OverlaySt // race noted above for the punctuation rail — simplest to just not // compete for horizontal drags at all inside that sub-panel. private val horizontalSwipeEnabled: Boolean = true, + // Checked once, at the moment a drag first looks vertical enough to + // possibly claim (see onInterceptTouchEvent's ACTION_MOVE branch) — + // returning false there lets the gesture fall through to a nested + // scrollable child instead (e.g. the clipboard history list), so a + // downward drag only dismisses the keyboard once that child is + // already scrolled to its own top, the same "pull past the top to + // dismiss" convention most scrollable sheets use. Defaults to + // "always allowed" for every panel that has no such nested scroll. + private val verticalDismissAllowed: () -> Boolean = { true }, private val onSwipe: (Int) -> Unit, ) : LinearLayout(context) { private var startX = 0f @@ -2905,13 +4436,16 @@ class OpenLessImeService : InputMethodService(), OpenLessOverlayBridge.OverlaySt private var interceptingVertical = false private var verticalDismissBlockedForGesture = false private val touchSlop = android.view.ViewConfiguration.get(context).scaledTouchSlop - // Small enough to still claim a horizontal drag early (so a vertical - // scroll elsewhere doesn't accidentally get treated as a mode swipe - // partway through), but committing the actual mode switch needs a - // much bigger, deliberate drag — the system touchSlop alone made - // this trigger on almost any stray sideways touch. - private val commitThreshold = (100 * resources.displayMetrics.density).toInt() private val dismissThreshold = (120 * resources.displayMetrics.density).toInt() + // Committing an actual mode switch needs a much bigger, deliberate + // drag than the touchSlop-based early claim above (which only + // exists so a vertical scroll elsewhere doesn't get mistaken for a + // mode swipe partway through) — a fraction of the panel's own width + // rather than a fixed dp figure, so the required drag scales with + // the device instead of feeling different on a small vs. large + // screen. Computed against `width` at commit time (onTouchEvent), + // not here at construction, since the view isn't laid out yet. + private val commitThresholdFraction = 1f / 3f init { excludeFromSystemGestures(this) @@ -2922,14 +4456,15 @@ class OpenLessImeService : InputMethodService(), OpenLessOverlayBridge.OverlaySt // discarding the fixed-height LayoutParams every panel builder sets // on its root — confirmed via a live device: the attached root's // own layoutParams.height read back as WRAP_CONTENT (-2), not the - // 300dp we set. Under a generous (near-fullscreen) WRAP_CONTENT/ - // AT_MOST measure spec from the system, any 0dp/weight=1 flexible - // child (the spacer used by several panels) happily expands to fill - // that huge bound instead of a real 300dp. Forcing an EXACTLY - // 300dp height spec here — regardless of what the parent asks for — - // makes every panel's height genuinely fixed instead of accidental. + // configured height we set. Under a generous (near-fullscreen) + // WRAP_CONTENT/AT_MOST measure spec from the system, any 0dp/weight=1 + // flexible child (the spacer used by several panels) happily expands + // to fill that huge bound instead of the real configured height. + // Forcing an EXACTLY height spec here — regardless of what the parent + // asks for — makes every panel's height genuinely fixed instead of + // accidental. override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { - val fixedHeight = (300 * resources.displayMetrics.density).toInt() + val fixedHeight = panelHeightPx(context) val exactHeightSpec = android.view.View.MeasureSpec.makeMeasureSpec(fixedHeight, android.view.View.MeasureSpec.EXACTLY) super.onMeasure(widthMeasureSpec, exactHeightSpec) } @@ -2958,9 +4493,11 @@ class OpenLessImeService : InputMethodService(), OpenLessOverlayBridge.OverlaySt // A downward drag anywhere not already claimed by a // nested vertical gesture (the candidate scroll) or // excluded by touch-down position (the punctuation rail) - // dismisses the keyboard. + // dismisses the keyboard — unless verticalDismissAllowed() + // says a nested scrollable child still has room to + // scroll up first (the clipboard history list). if (!interceptingHorizontal && !interceptingVertical && !verticalDismissBlockedForGesture && - dy > touchSlop && dy > kotlin.math.abs(dx) * 1.5f + dy > touchSlop && dy > kotlin.math.abs(dx) * 1.5f && verticalDismissAllowed() ) { interceptingVertical = true parent?.requestDisallowInterceptTouchEvent(true) @@ -2978,7 +4515,7 @@ class OpenLessImeService : InputMethodService(), OpenLessOverlayBridge.OverlaySt val dy = event.y - startY if (interceptingVertical && dy > dismissThreshold) { post { (context as? OpenLessImeService)?.hideKeyboardPanel() } - } else if (kotlin.math.abs(dx) > commitThreshold) { + } else if (kotlin.math.abs(dx) > width * commitThresholdFraction) { // Swiping left (finger moves toward the start, dx < 0) // steps toward Voice; swiping right steps toward // English. (This is inverted from the raw dx sign — on- @@ -3006,13 +4543,28 @@ class OpenLessImeService : InputMethodService(), OpenLessOverlayBridge.OverlaySt // rebuilt on every panel refresh, so a live theme switch just means // a fresh instance with the other branch's colors. private val trackColor = if (darkTheme) Color.rgb(28, 28, 28) else Color.rgb(222, 222, 226) - private val dividerColor = if (darkTheme) Color.rgb(58, 58, 58) else Color.rgb(200, 200, 204) - // Matches the outer panel's own background exactly (see - // OpenLessImeService's panel-root tone(48,48,48 / 242,242,246)), so - // the selected toggle segment reads as continuous with the panel - // beneath it. - private val panelBackgroundColor = if (darkTheme) Color.rgb(48, 48, 48) else Color.rgb(242, 242, 246) + // Weakened from a fully-opaque line to a near-invisible one — the + // floating selected pill below is meant to be the only thing the + // user reads as "current mode", not these dividers, but the layout + // math that positions the four icons/labels still uses the same + // segmentWidth split, so the dividers stay (very faintly) for + // continuity rather than being deleted outright. + private val dividerColor = run { + val base = if (darkTheme) Color.rgb(58, 58, 58) else Color.rgb(200, 200, 204) + Color.argb(28, Color.red(base), Color.green(base), Color.blue(base)) + } + // Same surface color as every ordinary key's own background + // (roundedButton(tone(Color.rgb(52, 52, 54), Color.WHITE), ...) — + // see buildEnglishCharKey()/keyboardKey()) — reusing it here ties + // the selected pill into the keyboard's existing "raised key" + // visual language instead of introducing a new floating-pill color. + private val selectedPillColor = if (darkTheme) Color.rgb(52, 52, 54) else Color.WHITE private val iconColor = if (darkTheme) Color.rgb(240, 240, 240) else Color.rgb(50, 50, 54) + // Unselected icons/labels dim slightly (not a color or hue change) + // so the fully-opaque selected one reads as the one thing actively + // "on" — a light touch, not the kind of visible recolor the spec + // asks not to introduce. + private val dimmedIconColor = Color.argb(178, Color.red(iconColor), Color.green(iconColor), Color.blue(iconColor)) private val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply { strokeCap = Paint.Cap.ROUND strokeJoin = Paint.Join.ROUND @@ -3025,7 +4577,44 @@ class OpenLessImeService : InputMethodService(), OpenLessOverlayBridge.OverlaySt } private val textBounds = android.graphics.Rect() private val chevronPath = Path() - private val trackClipPath = Path() + + // Continuous [0, modes.size - 1] position of the selected pill, in + // segment units — settles on the selected index, but slides through + // fractional values mid-animation. This view is a fresh instance on + // every panel rebuild (see the class doc above), so a real mode + // switch is detected by comparing against lastRenderedMode (a + // companion field, surviving across those rebuilds) rather than any + // state this instance could hold itself; an unrelated rebuild that + // leaves the mode unchanged just settles here with no animation. + private var indicatorPosition: Float + private var indicatorAnimator: android.animation.ValueAnimator? = null + + init { + val modes = InputMode.entries + val targetIndex = modes.indexOf(selectedMode).coerceAtLeast(0).toFloat() + val previousMode = lastRenderedMode + if (previousMode != null && previousMode != selectedMode) { + val startIndex = modes.indexOf(previousMode).coerceAtLeast(0).toFloat() + indicatorPosition = startIndex + indicatorAnimator = android.animation.ValueAnimator.ofFloat(startIndex, targetIndex).apply { + duration = 180L + interpolator = android.view.animation.DecelerateInterpolator() + addUpdateListener { + indicatorPosition = it.animatedValue as Float + invalidate() + } + start() + } + } else { + indicatorPosition = targetIndex + } + lastRenderedMode = selectedMode + } + + override fun onDetachedFromWindow() { + indicatorAnimator?.cancel() + super.onDetachedFromWindow() + } // Every proportion below is measured off the reference toggle design // and expressed in units of the pill height `h`. @@ -3048,30 +4637,39 @@ class OpenLessImeService : InputMethodService(), OpenLessOverlayBridge.OverlaySt canvas.drawLine(x, h * 0.2f, x, h * 0.8f, paint) } - // The selected segment is filled with the exact same color as - // the panel body below (not a lighter floating pill), and drawn - // with sharp corners — no rounding, no border, no shadow, no - // gap — so it reads as one continuous surface with the panel - // rather than a separate highlighted control sitting on top of - // it. Clipped to the track's own rounded outline so a sharp - // corner at the first/last segment doesn't poke past the - // track's curve — the track's existing shape provides the only - // rounding here, not a second one on the highlight itself. + // Floating rounded pill instead of the old sharp, edge-to-edge + // highlight: inset on every side so it reads as its own raised + // surface hovering over the track rather than a panel-colored + // cutout flush with the track's shape. Insets alone keep it + // well inside the track's rounded corners even at the first/ + // last segment, so no extra clip path is needed the way the + // sharp-cornered version required one. + // Proportional again, not fixed dp — carries forward the exact + // ratio the last explicit dp spec settled on (5dp inset / 38dp + // track height, 53dp pill / 60dp segment width at the 240dp + // outer track width in use then), so the pill keeps this same + // look if the outer track's own width/height are ever tuned + // again, instead of needing a matching manual dp recompute. + val pillInsetV = h * (5f / 38f) + val pillWidth = segmentWidth * (53f / 60f) + val pillCenterX = segmentWidth * (indicatorPosition + 0.5f) + val pillLeft = pillCenterX - pillWidth / 2f + val pillRight = pillCenterX + pillWidth / 2f + val pillTop = pillInsetV + val pillBottom = h - pillInsetV + paint.color = selectedPillColor + canvas.drawRoundRect(pillLeft, pillTop, pillRight, pillBottom, (pillBottom - pillTop) / 2f, (pillBottom - pillTop) / 2f, paint) + val selectedIndex = modes.indexOf(selectedMode).coerceAtLeast(0) - val segmentLeft = segmentWidth * selectedIndex - val segmentRight = segmentWidth * (selectedIndex + 1) - trackClipPath.reset() - trackClipPath.addRoundRect(0f, 0f, w, h, h / 2f, h / 2f, Path.Direction.CW) - canvas.save() - canvas.clipPath(trackClipPath) - paint.color = panelBackgroundColor - canvas.drawRect(segmentLeft, 0f, segmentRight, h, paint) - canvas.restore() - - paint.color = iconColor + fun colorFor(index: Int) = if (index == selectedIndex) iconColor else dimmedIconColor + + paint.color = colorFor(0) drawWaveform(canvas, segmentWidth * 0.5f, centerY, h) - drawLabel(canvas, "笔画", segmentWidth * 1.5f, centerY, h * 0.34f, extraBold = true) + textPaint.color = colorFor(1) + drawLabel(canvas, "笔画", segmentWidth * 1.5f, centerY, h * 0.38f, extraBold = true) + paint.color = colorFor(2) drawCursorBrackets(canvas, segmentWidth * 2.5f, centerY, h) + textPaint.color = colorFor(3) drawLabel(canvas, "EN", segmentWidth * 3.5f, centerY, h * 0.28f) } @@ -3132,6 +4730,14 @@ class OpenLessImeService : InputMethodService(), OpenLessOverlayBridge.OverlaySt return true } + private companion object { + // Survives across this view's per-rebuild instances (see the + // class doc above) so a fresh instance can tell "the mode + // actually changed since last drawn" from "this rebuild is for + // some unrelated reason" and only animate the former. + @Volatile + private var lastRenderedMode: InputMode? = null + } } /** Central stroke keys use a canvas glyph so their proportions do not depend on a font. */ @@ -3189,7 +4795,7 @@ class OpenLessImeService : InputMethodService(), OpenLessOverlayBridge.OverlaySt * white, in both themes) and, undecorated, on the clipboard panel's * normal/highlighted keys (icon needs to flip with the theme there). */ - private class StrokeActionView( + internal class StrokeActionView( context: android.content.Context, private val actionCode: String, private val iconRotation: Float = 0f, @@ -3207,6 +4813,10 @@ class OpenLessImeService : InputMethodService(), OpenLessOverlayBridge.OverlaySt textAlign = Paint.Align.CENTER typeface = android.graphics.Typeface.create("sans-serif", android.graphics.Typeface.NORMAL) } + private val fillPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + color = iconColor + style = Paint.Style.FILL + } override fun onDraw(canvas: Canvas) { val u = minOf(width, height).coerceAtLeast(1) / 100f @@ -3214,6 +4824,22 @@ class OpenLessImeService : InputMethodService(), OpenLessOverlayBridge.OverlaySt val cy = height / 2f paint.strokeWidth = 5.2f * u when (actionCode) { + "triangle-down" -> { + // Fixed absolute size, independent of this button's own + // (roughly 28x36dp, non-square) cell — about the same + // visual footprint as a single candidate glyph + // (candidateItemView's 20sp text), not scaled to the + // fixed-100-unit icon canvas the other cases share. + val triangleSize = 16f * resources.displayMetrics.density + val topY = cy - triangleSize / 2f + val path = Path().apply { + moveTo(cx - triangleSize / 2f, topY) + lineTo(cx + triangleSize / 2f, topY) + lineTo(cx, topY + triangleSize) + close() + } + canvas.drawPath(path, fillPaint) + } "←" -> { canvas.drawLine(cx - 23f * u, cy, cx + 23f * u, cy, paint) canvas.drawLine(cx - 23f * u, cy, cx - 8f * u, cy - 12f * u, paint) @@ -3355,6 +4981,280 @@ class OpenLessImeService : InputMethodService(), OpenLessOverlayBridge.OverlaySt } } + /** + * Two-line label (via a plain "\n") with a thin horizontal divider + * drawn between the lines — the divider just needs the vertical + * midpoint of the view, which lands between the two centered lines of + * text closely enough without measuring actual text layout bounds. + */ + /** + * The one key-press preview bubble shared by every keyboard (English + * and stroke) — a short, wide "floating keycap": a rounded rect with a + * short stubby arrow, soft light-gray body and near-black text, no + * accent color. Always sized/positioned by its owner (KeyPreviewOverlay) + * rather than by itself; this class only draws. Plain Canvas drawing, + * matching every other custom key glyph in this file (ShiftKeyView, + * ActionSymbolView, etc.) — no new dependency. + */ + private class KeyPreviewBubbleView( + context: android.content.Context, + darkTheme: Boolean, + ) : View(context) { + var label: String = "" + set(value) { + field = value + invalidate() + } + // Non-null (the stroke keyboard's swipe-up digit preview) matches + // the actual pressed key's own font size (TextView.getTextSize(), + // already resolved to raw pixels, so no sp->px conversion is + // needed here) instead of the default size derived from the + // bubble's own height, which the English tap preview still relies on. + var fixedTextSizePx: Float? = null + set(value) { + field = value + invalidate() + } + private val bodyPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + // Lighter than the stroke encode/candidate row's own card + // background in both themes (buildEncodeAreaBackground(): + // 58,58,58 dark / 228,228,232 light) rather than darker — a + // darker-than-the-card bubble read as low-contrast/blended-in; + // sitting a step lighter than that card makes the bubble read + // as its own raised surface instead. + color = if (darkTheme) Color.rgb(96, 96, 100) else Color.rgb(246, 246, 249) + } + private val borderPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + style = Paint.Style.STROKE + strokeWidth = 2f + // Dark theme: a lighter outline than the body for definition + // against a similarly-dark panel. Light theme: a slightly + // darker outline than the now near-white body, since a lighter + // outline there would have no contrast against anything. + color = if (darkTheme) Color.rgb(128, 128, 133) else Color.rgb(210, 210, 215) + } + // Same red as the right-hand action rail's ←/↵/清除/123 keys and + // the stroke candidate row's own selected/first candidate + // (candidateItemView()), brightened for dark theme by the same + // amount as candidateItemView()'s own dark-theme branch — the two + // stay in sync since they're tuned together. + private val textPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + color = if (darkTheme) Color.rgb(190, 45, 60) else Color.rgb(153, 26, 40) + textAlign = Paint.Align.CENTER + typeface = android.graphics.Typeface.DEFAULT_BOLD + // FILL_AND_STROKE over DEFAULT_BOLD's own fill adds a touch more + // weight than the typeface alone — there's no heavier built-in + // weight to switch to without bundling a custom font. + style = Paint.Style.FILL_AND_STROKE + strokeWidth = 1.4f + } + private val outline = Path() + + private fun dp(value: Int): Float = value * resources.displayMetrics.density + + override fun onDraw(canvas: Canvas) { + super.onDraw(canvas) + val w = width.toFloat() + val h = height.toFloat() + if (w <= 0f || h <= 0f) return + val arrowHeight = dp(ARROW_HEIGHT_DP) + val arrowHalfWidth = dp(ARROW_WIDTH_DP) / 2f + val bodyBottom = h - arrowHeight + // Also capped by the flat run left on either side of the arrow + // notch (w/2 - arrowHalfWidth) — at the narrowest bubble widths + // this is tighter than bodyBottom/2, and skipping it would let + // the bottom-corner arcs overrun the notch and self-intersect. + val radius = dp(CORNER_RADIUS_DP) + .coerceAtMost(bodyBottom / 2f) + .coerceAtMost((w / 2f - arrowHalfWidth).coerceAtLeast(0f)) + val cx = w / 2f + // One continuous outline (all four corners rounded to the same + // radius, short triangular notch at the bottom center) so a + // stroked border never shows a seam where the arrow meets the + // body. + outline.reset() + outline.moveTo(radius, 0f) + outline.lineTo(w - radius, 0f) + outline.arcTo(android.graphics.RectF(w - 2f * radius, 0f, w, 2f * radius), -90f, 90f) + outline.lineTo(w, bodyBottom - radius) + outline.arcTo(android.graphics.RectF(w - 2f * radius, bodyBottom - 2f * radius, w, bodyBottom), 0f, 90f) + outline.lineTo(cx + arrowHalfWidth, bodyBottom) + outline.lineTo(cx, h) + outline.lineTo(cx - arrowHalfWidth, bodyBottom) + outline.lineTo(radius, bodyBottom) + outline.arcTo(android.graphics.RectF(0f, bodyBottom - 2f * radius, 2f * radius, bodyBottom), 90f, 90f) + outline.lineTo(0f, radius) + outline.arcTo(android.graphics.RectF(0f, 0f, 2f * radius, 2f * radius), 180f, 90f) + outline.close() + canvas.drawPath(outline, bodyPaint) + canvas.drawPath(outline, borderPaint) + textPaint.textSize = fixedTextSizePx ?: (bodyBottom * 0.5f) + val metrics = textPaint.fontMetrics + val textY = bodyBottom / 2f - (metrics.ascent + metrics.descent) / 2f + canvas.drawText(label, cx, textY, textPaint) + } + + companion object { + const val ARROW_HEIGHT_DP = 12 + const val ARROW_WIDTH_DP = 22 + const val CORNER_RADIUS_DP = 4 + const val BODY_HEIGHT_DP = 48 + const val TOTAL_HEIGHT_DP = BODY_HEIGHT_DP + ARROW_HEIGHT_DP + } + } + + /** + * Single top-level overlay hosting the one KeyPreviewBubbleView shared + * by every key in a panel (see wrapWithKeyPreviewOverlay()). Added as + * the last (topmost-drawn) child of the FrameLayout that wraps a + * panel's own root, so the bubble renders above the whole keyboard — + * unaffected by the panel's own clipChildren/candidate-row/mode-toggle + * bounds — without taking part in that panel's layout: it never + * changes size, position, or measurement of anything else, and it + * never intercepts touch (dispatchTouchEvent always returns false, so + * every gesture falls straight through to whatever key is underneath). + */ + private class KeyPreviewOverlay( + context: android.content.Context, + darkTheme: Boolean, + ) : FrameLayout(context) { + private val bubble = KeyPreviewBubbleView(context, darkTheme).apply { + alpha = 0f + visibility = View.GONE + } + + init { + isClickable = false + isFocusable = false + clipChildren = false + clipToPadding = false + addView(bubble, LayoutParams(0, 0)) + } + + // A pure visual layer: never claims a touch sequence, regardless of + // where the bubble is currently drawn, so a stroke swipe or an + // English drag-retarget already in progress is never disturbed by + // the preview appearing on top of it. + override fun onInterceptTouchEvent(ev: MotionEvent): Boolean = false + override fun dispatchTouchEvent(ev: MotionEvent): Boolean = false + + private fun dp(value: Int): Int = (value * resources.displayMetrics.density).toInt() + + private fun relativeLocation(target: View): IntArray { + val targetLoc = IntArray(2) + target.getLocationOnScreen(targetLoc) + val selfLoc = IntArray(2) + getLocationOnScreen(selfLoc) + return intArrayOf(targetLoc[0] - selfLoc[0], targetLoc[1] - selfLoc[1]) + } + + private fun placeBubble(centerX: Int, top: Int, bubbleWidth: Int, bubbleHeight: Int) { + val maxLeft = (width - bubbleWidth - dp(4)).coerceAtLeast(dp(4)) + val left = (centerX - bubbleWidth / 2).coerceIn(dp(4), maxLeft) + val clampedTop = top.coerceAtLeast(dp(2)) + val params = bubble.layoutParams as LayoutParams + params.width = bubbleWidth + params.height = bubbleHeight + params.leftMargin = left + params.topMargin = clampedTop + bubble.layoutParams = params + } + + /** + * TAP preview (English/number/symbol keys): centered directly above + * the anchor key, sized relative to it, with the default + * proportional-to-body text size (fixedTextSizePx left null). + */ + fun showTapPreview(text: String, anchor: View) { + val loc = relativeLocation(anchor) + val bubbleWidth = (anchor.width * BUBBLE_WIDTH_RATIO).toInt().coerceAtLeast(dp(40)) + val bubbleHeight = dp(KeyPreviewBubbleView.TOTAL_HEIGHT_DP) + bubble.fixedTextSizePx = null + bubble.label = text + val centerX = loc[0] + anchor.width / 2 + val top = loc[1] - bubbleHeight - dp(TAP_GAP_DP) + placeBubble(centerX, top, bubbleWidth, bubbleHeight) + show() + } + + /** + * SWIPE preview (stroke digit swipe-up): pinned to the actual + * anchor key's own on-screen position, same as showTapPreview — + * not to the finger's current height. It used to float a fixed + * distance above the raw touch point instead, which looked right + * for a straight vertical swipe on one key but drifted away from + * the real key as soon as a drag retargeted sideways or across + * rows (the bubble's X snapped to the new key while its Y stayed + * tied to wherever the finger happened to be). + * + * Text size is a fixed constant here, not the tracked key's own + * face text size — the "0" digit's key is the microphone icon key, + * whose own face text is much smaller than every other digit key's + * (10sp vs 17sp, since it's mostly an icon), so sizing off the key + * made "0"'s bubble read noticeably smaller than the other nine. + */ + fun showSwipePreview(text: String, anchor: View) { + val loc = relativeLocation(anchor) + val bubbleWidth = (anchor.width * BUBBLE_WIDTH_RATIO).toInt().coerceAtLeast(dp(40)) + val bubbleHeight = dp(KeyPreviewBubbleView.TOTAL_HEIGHT_DP) + bubble.fixedTextSizePx = dp(SWIPE_TEXT_SIZE_DP).toFloat() + bubble.label = text + val centerX = loc[0] + anchor.width / 2 + // 20dp further above the key than the tap preview's own gap — + // a separate constant so this doesn't also shift + // showTapPreview's gap for the English/number keys. + val top = loc[1] - bubbleHeight - dp(SWIPE_GAP_DP) + placeBubble(centerX, top, bubbleWidth, bubbleHeight) + show() + } + + // No fade animation: a fast tap's down-to-up gap is often shorter + // than any fade would take, so an animated show/hide left the + // bubble stuck mid-fade — either never reaching full opacity + // (looked "too transparent to read") or reversing before it ever + // became visible at all. Popping straight to alpha=1 (matching the + // pre-refactor PopupWindow, which never animated either) + // guarantees the bubble is fully opaque the instant it shows, + // regardless of tap speed. + private fun show() { + bubble.animate().cancel() + bubble.alpha = 1f + bubble.scaleX = 1f + bubble.scaleY = 1f + bubble.visibility = View.VISIBLE + } + + fun hide() { + bubble.animate().cancel() + bubble.visibility = View.GONE + } + + private companion object { + const val BUBBLE_WIDTH_RATIO = 1.2f + const val TAP_GAP_DP = 3 + const val SWIPE_GAP_DP = TAP_GAP_DP + 20 + // Bigger than the default proportional size the tap preview + // falls back to (bodyBottom * 0.5, ~24dp-equivalent here) and, + // being a flat constant rather than derived from any one key's + // own face size, the same for every digit including "0". + const val SWIPE_TEXT_SIZE_DP = 22 + } + } + + private class MidDividerTextView( + context: android.content.Context, + private val dividerColor: Int, + ) : TextView(context) { + private val dividerPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { strokeWidth = 1.5f } + + override fun onDraw(canvas: Canvas) { + super.onDraw(canvas) + dividerPaint.color = dividerColor + val marginX = width * 0.22f + canvas.drawLine(marginX, height / 2f, width - marginX, height / 2f, dividerPaint) + } + } + private class ActionSymbolView( context: android.content.Context, private val symbol: String, @@ -3513,8 +5413,25 @@ class OpenLessImeService : InputMethodService(), OpenLessOverlayBridge.OverlaySt private class VoiceButton(context: android.content.Context, private val darkTheme: Boolean) : View(context) { private val paint = Paint(Paint.ANTI_ALIAS_FLAG) - private val idlePillColor = if (darkTheme) Color.rgb(54, 54, 54) else Color.rgb(225, 225, 228) + // Panel's own dark-theme background is rgb(48,48,48); this reads + // 12/255 lighter (tried 6 — too subtle to tell apart from the panel + // — then 24, which read as too strong; 12 is the settled value). + private val idlePillColor = if (darkTheme) Color.rgb(60, 60, 60) else Color.rgb(225, 225, 228) private val idleIconColor = if (darkTheme) Color.WHITE else Color.rgb(60, 60, 64) + // Match the raw-mode recording indicator exactly, so the swipe-up + // preview and the resulting raw recording state use one consistent + // orange-yellow accent. + private val swipeArmedPillColor = LINK_COLOR_RECORDING_RAW + // Same idea, for the swipe-left-to-Note gesture's own idle-pill + // preview — see armedForQuickNote/pillArmedAmountGreen below. + private val quickNoteArmedPillColor = LINK_COLOR_QUICK_NOTE + // Same idea again, for the swipe-right-to-Cloud-notes gesture's own + // idle-pill preview — see armedForCloudNote/pillArmedAmountRed below. + private val cloudNoteArmedPillColor = LINK_COLOR_CLOUD_NOTE + // Light red, blended into the waveform bars while a swipe-down-to- + // cancel gesture is past its commit threshold — same value in both + // themes, mirroring swipeArmedPillColor's own choice. + private val cancelArmedWaveformColor = Color.rgb(255, 150, 150) private val waveformColor = if (darkTheme) Color.rgb(222, 222, 222) else Color.rgb(70, 70, 74) private val processingDotColors = if (darkTheme) { intArrayOf( @@ -3540,12 +5457,124 @@ class OpenLessImeService : InputMethodService(), OpenLessOverlayBridge.OverlaySt invalidate() } + var rawModeActive: Boolean = false + set(value) { + field = value + invalidate() + } + + var quickNoteActive: Boolean = false + set(value) { + field = value + invalidate() + } + + var cloudNoteActive: Boolean = false + set(value) { + field = value + invalidate() + } + var audioLevel: Float = 0f set(value) { field = value invalidate() } + // Live "would releasing now enter raw mode?" indicator — the + // setter only starts an ease toward the target (see + // pillArmedAmount below); it doesn't jump straight there, so + // rapid on/off toggling right at the dp(30) threshold doesn't + // flicker instantly between the two colors. + var armedForRawSwipe: Boolean = false + set(value) { + if (field == value) return + field = value + pillArmedAnimator?.cancel() + pillArmedAnimator = android.animation.ValueAnimator.ofFloat(pillArmedAmount, if (value) 1f else 0f).apply { + duration = 120L + addUpdateListener { pillArmedAmount = it.animatedValue as Float } + start() + } + } + private var pillArmedAmount: Float = 0f + set(value) { + field = value + invalidate() + } + private var pillArmedAnimator: android.animation.ValueAnimator? = null + + // Same pattern as armedForRawSwipe/pillArmedAmount above, for the + // swipe-left-to-Note idle-pill preview. + var armedForQuickNote: Boolean = false + set(value) { + if (field == value) return + field = value + pillArmedAnimatorGreen?.cancel() + pillArmedAnimatorGreen = android.animation.ValueAnimator.ofFloat(pillArmedAmountGreen, if (value) 1f else 0f).apply { + duration = 120L + addUpdateListener { pillArmedAmountGreen = it.animatedValue as Float } + start() + } + } + private var pillArmedAmountGreen: Float = 0f + set(value) { + field = value + invalidate() + } + private var pillArmedAnimatorGreen: android.animation.ValueAnimator? = null + + // Same pattern again, for the swipe-right-to-Cloud-notes idle-pill + // preview. + var armedForCloudNote: Boolean = false + set(value) { + if (field == value) return + field = value + pillArmedAnimatorRed?.cancel() + pillArmedAnimatorRed = android.animation.ValueAnimator.ofFloat(pillArmedAmountRed, if (value) 1f else 0f).apply { + duration = 120L + addUpdateListener { pillArmedAmountRed = it.animatedValue as Float } + start() + } + } + private var pillArmedAmountRed: Float = 0f + set(value) { + field = value + invalidate() + } + private var pillArmedAnimatorRed: android.animation.ValueAnimator? = null + + // Same pattern as armedForRawSwipe/pillArmedAmount above, applied + // to the recording waveform instead of the idle capsule — swipe + // DOWN past the threshold while recording tints the waveform bars + // light red instead of the capsule turning green. + var armedForCancel: Boolean = false + set(value) { + if (field == value) return + field = value + waveformCancelAnimator?.cancel() + waveformCancelAnimator = android.animation.ValueAnimator.ofFloat(waveformCancelAmount, if (value) 1f else 0f).apply { + duration = 120L + addUpdateListener { waveformCancelAmount = it.animatedValue as Float } + start() + } + } + private var waveformCancelAmount: Float = 0f + set(value) { + field = value + invalidate() + } + private var waveformCancelAnimator: android.animation.ValueAnimator? = null + + private fun lerpColor(from: Int, to: Int, amount: Float): Int { + val t = amount.coerceIn(0f, 1f) + return Color.rgb( + (Color.red(from) + (Color.red(to) - Color.red(from)) * t).toInt(), + (Color.green(from) + (Color.green(to) - Color.green(from)) * t).toInt(), + (Color.blue(from) + (Color.blue(to) - Color.blue(from)) * t).toInt(), + ) + } + private var phase = 0f private val animator = object : Runnable { override fun run() { @@ -3562,6 +5591,10 @@ class OpenLessImeService : InputMethodService(), OpenLessOverlayBridge.OverlaySt override fun onDetachedFromWindow() { removeCallbacks(animator) + pillArmedAnimator?.cancel() + pillArmedAnimatorGreen?.cancel() + pillArmedAnimatorRed?.cancel() + waveformCancelAnimator?.cancel() super.onDetachedFromWindow() } @@ -3586,7 +5619,16 @@ class OpenLessImeService : InputMethodService(), OpenLessOverlayBridge.OverlaySt val radius = pillHeight * 0.5f // 录音/思考状态只显示动画,完全移除胶囊背景;待机状态保留话筒按钮。 if (!isRecording && !isProcessing) { - paint.color = idlePillColor + val pillColor = lerpColor( + lerpColor( + lerpColor(idlePillColor, swipeArmedPillColor, pillArmedAmount), + quickNoteArmedPillColor, + pillArmedAmountGreen, + ), + cloudNoteArmedPillColor, + pillArmedAmountRed, + ) + paint.color = pillColor canvas.drawRoundRect(left, top, right, bottom, radius, radius, paint) } @@ -3616,7 +5658,9 @@ class OpenLessImeService : InputMethodService(), OpenLessOverlayBridge.OverlaySt return } if (isRecording) { - // Monochrome waveform: quiet input stays compact while speech + // The normal waveform is monochrome; RAW recording uses the + // same orange-yellow accent as the RAW indicator and the + // swipe-up preview. Quiet input stays compact while speech // expands the bars clearly with the live microphone level. // Longer bar group (17 vs the original 9) with a genuine // left-flowing traveling wave — as `phase` advances, the sine @@ -3630,6 +5674,13 @@ class OpenLessImeService : InputMethodService(), OpenLessOverlayBridge.OverlaySt val envelopeCenter = (barCount - 1) / 2f val gap = width * 0.86f / (barCount - 1) val startX = centerX - gap * (barCount - 1) / 2f + val rawWaveformColor = when { + rawModeActive -> LINK_COLOR_RECORDING_RAW + quickNoteActive -> LINK_COLOR_QUICK_NOTE + cloudNoteActive -> LINK_COLOR_CLOUD_NOTE + else -> waveformColor + } + val currentWaveformColor = lerpColor(rawWaveformColor, cancelArmedWaveformColor, waveformCancelAmount) for (index in 0 until barCount) { val x = startX + index * gap val distanceFromCenter = kotlin.math.abs(index - envelopeCenter) / envelopeCenter @@ -3639,7 +5690,7 @@ class OpenLessImeService : InputMethodService(), OpenLessOverlayBridge.OverlaySt ).toFloat() val halfHeight = minOf(height * 0.95f, dp(66).toFloat()) * (0.035f + live * 0.965f) * envelope * flow - paint.color = waveformColor + paint.color = currentWaveformColor paint.strokeWidth = dp(3).toFloat() canvas.drawLine(x, centerY - halfHeight, x, centerY + halfHeight, paint) } @@ -3667,10 +5718,123 @@ class OpenLessImeService : InputMethodService(), OpenLessOverlayBridge.OverlaySt } companion object { - private const val TEST_TEXT = "OpenLess IME 测试上屏" - private const val MAX_ASSOCIATION_CONTEXT = 8 + private val nextImeRequest = java.util.concurrent.atomic.AtomicLong() + const val PREF_KEYBOARD_HEIGHT_DP = "keyboard_height_dp" + const val DEFAULT_KEYBOARD_HEIGHT_DP = 300 + const val MIN_KEYBOARD_HEIGHT_DP = 220 + const val MAX_KEYBOARD_HEIGHT_DP = 420 + const val PREF_KEYBOARD_RAISE_DP = "keyboard_raise_dp" + const val DEFAULT_KEYBOARD_RAISE_DP = 0 + const val MIN_KEYBOARD_RAISE_DP = 0 + const val MAX_KEYBOARD_RAISE_DP = 120 + + fun panelHeightDp(context: android.content.Context): Int { + return context.getSharedPreferences("openless_ime_ui", android.content.Context.MODE_PRIVATE) + .getInt(PREF_KEYBOARD_HEIGHT_DP, DEFAULT_KEYBOARD_HEIGHT_DP) + .coerceIn(MIN_KEYBOARD_HEIGHT_DP, MAX_KEYBOARD_HEIGHT_DP) + } + + fun panelHeightPx(context: android.content.Context): Int { + return (panelHeightDp(context) * context.resources.displayMetrics.density).toInt() + } + + fun raiseHeightDp(context: android.content.Context): Int { + return context.getSharedPreferences("openless_ime_ui", android.content.Context.MODE_PRIVATE) + .getInt(PREF_KEYBOARD_RAISE_DP, DEFAULT_KEYBOARD_RAISE_DP) + .coerceIn(MIN_KEYBOARD_RAISE_DP, MAX_KEYBOARD_RAISE_DP) + } + + fun raiseHeightPx(context: android.content.Context): Int { + return (raiseHeightDp(context) * context.resources.displayMetrics.density).toInt() + } + private const val SILENCE_LEVEL_THRESHOLD = 0.02f private const val SILENCE_CHECK_DELAY_MS = 3000L + private const val BACKEND_HEARTBEAT_INTERVAL_MS = 6000L + // How long a transient confirmation (已上屏/已完成/已撤销) stays up + // before settling back to "点击开始说话" — see scheduleRevertToIdle(). + private const val DONE_TO_IDLE_DELAY_MS = 2000L + // quickNoteDictation()'s own "笔记已记录" confirmation — shorter + // than DONE_TO_IDLE_DELAY_MS per product request, since there's no + // undo/redo/edit row to give the user time to read (quick note + // never inserts anything). + private const val QUICK_NOTE_CONFIRMATION_DELAY_MS = 1000L + // Not a hard "show exactly N" cap — the candidate bar is a + // HorizontalScrollView (see buildEnglishCandidateBar()), so this + // just bounds how many the provider bothers ranking/returning per + // keystroke; comfortably more than can fit on screen at once so + // scrolling actually reveals more real options. + private const val ENGLISH_CANDIDATE_QUERY_LIMIT = 10 + // buildClipboardView()'s "recent clips" strip. Originally 92dp for + // 4 equal-weight rows + 5 divider lines (dp(1) each), raised ~1.2x + // to 109dp per user request: (92-5)/4=21.75dp/row * 1.2 = 26.1, + // 26.1*4+5=109.4 -> 109. Then reduced from 4 rows to 3 (also per + // user request) WITHOUT changing this total — 3 rows only need 4 + // dividers, not 5, so the freed divider's dp plus the freed row's + // own share both land back on the remaining 3: (109-4)/3=35dp/row, + // up from 26dp. The button grid below needs no matching change — + // it's already LinearLayout.LayoutParams(.., 0, 1f), i.e. "whatever + // space is left after this strip's fixed height," so it grows or + // shrinks on its own the moment this constant or the row count + // changes. + private const val RECENT_CLIPS_COLUMN_HEIGHT_DP = 109 + // Swipe-up symbol hint, any LETTERS-layer row (buildEnglishCharKey()) + // — its own independent TextView pinned to the key's top edge, so + // these are absolute sizes, not a ratio of the 22sp letter below + // it. Small and close to the top by design; tune these two numbers + // directly. Shared by row1's digits and row2/row3's punctuation so + // all three rows read as one consistent hint style. + private const val SWIPE_SYMBOL_HINT_TEXT_SIZE_SP = 11f + private const val SWIPE_SYMBOL_HINT_TOP_PADDING_DP = 2 + // Pure render-time offset (View.translationY), not part of the + // letter TextView's own measured layout — see buildEnglishCharKey(). + private const val LETTER_VERTICAL_NUDGE_DP = 2 + // Same hue family as OpenLessOverlayService's OverlayVisualState + // (recording/processing), plus a light-green "ready" and an amber + // "link issue" that overlay doesn't have. + private val LINK_COLOR_READY = Color.rgb(134, 239, 172) + private val LINK_COLOR_RECORDING = Color.rgb(244, 63, 94) + // Recording with rawModeArmed set (mic swipe-up: skip LLM polish on + // stop) — distinct orange so glancing at the dot tells the two + // recording states apart without reading any text. + private val LINK_COLOR_RECORDING_RAW = Color.rgb(249, 115, 22) + // Recording with quickNoteArmed set (mic swipe-left: end the + // dictation and archive it as a standalone note instead of + // inserting) — green, the same treatment LINK_COLOR_RECORDING_RAW + // gets for its own swipe-up gesture. + private val LINK_COLOR_QUICK_NOTE = Color.rgb(34, 197, 94) + // Recording with cloudNoteArmed set (mic swipe-right: end the dictation + // and POST the verbatim transcript to the configured webhook + // instead of inserting anything) — red, the third accent alongside + // LINK_COLOR_RECORDING_RAW's orange and LINK_COLOR_QUICK_NOTE's + // green. + private val LINK_COLOR_CLOUD_NOTE = Color.rgb(239, 68, 68) + private val LINK_COLOR_PROCESSING = Color.rgb(56, 189, 248) + private val LINK_COLOR_ISSUE = Color.rgb(250, 204, 21) + // Ready is the state the indicator sits in almost all the time, so + // its pulse is slower/gentler ("breathing") than the other, more + // urgent states — which keep the original brisker cadence. + private const val BACKEND_LINK_PULSE_DURATION_MS = 900L + private const val BACKEND_LINK_READY_PULSE_DURATION_MS = 1800L + + @Volatile + private var lastHeartbeatElapsedRealtime = 0L + @Volatile + private var lastHeartbeatReady: Boolean? = null + + /** Runtime/heartbeat state shown in native keyboard settings for diagnosis. */ + fun backendDebugSnapshot(): String { + val service = activeInstance?.get() + ?: return "ime=none heartbeat=none" + val heartbeat = lastHeartbeatReady?.let { if (it) "ready" else "not-ready" } ?: "none" + val ageMs = if (lastHeartbeatElapsedRealtime == 0L) { + -1L + } else { + (android.os.SystemClock.elapsedRealtime() - lastHeartbeatElapsedRealtime).coerceAtLeast(0L) + } + return "ime=present recording=${service.recording} processing=${service.processing} " + + "backend=${heartbeat} ageMs=$ageMs indicator=${service.backendLinkIndicator != null}" + } /** * Opts a view out of Android's system gesture navigation (back/home @@ -3690,12 +5854,41 @@ class OpenLessImeService : InputMethodService(), OpenLessOverlayBridge.OverlaySt @Volatile private var activeInstance: java.lang.ref.WeakReference? = null - /** Re-open the IME after the one-time backend Activity gives focus back. */ + /** + * Whether the input panel is actually on screen right now — checked by + * OpenLessBackendWarmupActivity.launchWarmup() right before it steals + * the foreground, so it knows afterward whether restoring the panel is + * even meaningful (see requestInputPanelAfterWarmup()'s doc comment). + */ + fun isInputPanelCurrentlyShown(): Boolean { + val service = activeInstance?.get() ?: return false + return service.isInputViewShown + } + + /** + * Re-open the IME after the one-time backend Activity gives focus + * back — but only when there is still a real, keyboard-wanting field + * focused at fire time. Without this check, a warmup silently + * triggered by an unrelated onStartInput() (e.g. Camera/Dialer + * momentarily focusing their own non-text-entry views) or by the + * periodic backend heartbeat could force the panel open over + * whatever app the user has since switched to — up to ~500ms after + * the original trigger (120ms launch delay + 180ms self-background + + * this call's own delay), plenty of time to have left the field, the + * app, or even backgrounded to the home screen entirely. Callers are + * additionally expected to only invoke this at all when the panel + * was visible right before the warmup Activity took focus (see + * isInputPanelCurrentlyShown()) — this is the second, independent + * check against the state at the moment of firing. + */ fun requestInputPanelAfterWarmup(delayMs: Long = 260L) { val service = activeInstance?.get() ?: return android.os.Handler(android.os.Looper.getMainLooper()).postDelayed({ if (activeInstance?.get() === service) { - service.requestShowSelf(InputMethodManager.SHOW_IMPLICIT) + val attribute = service.currentInputEditorInfo + if (attribute != null && attribute.inputType != InputType.TYPE_NULL) { + service.requestShowSelf(InputMethodManager.SHOW_IMPLICIT) + } } }, delayMs) } diff --git a/openless-all/app/android/kotlin/OpenLessKeyboardSettingsActivity.kt b/openless-all/app/android/kotlin/OpenLessKeyboardSettingsActivity.kt index b83236410..ddf95f7ea 100644 --- a/openless-all/app/android/kotlin/OpenLessKeyboardSettingsActivity.kt +++ b/openless-all/app/android/kotlin/OpenLessKeyboardSettingsActivity.kt @@ -2,16 +2,25 @@ package com.openless.app import android.app.Activity import android.content.Context +import android.content.res.Configuration import android.graphics.Color import android.os.Bundle +import android.os.Handler +import android.os.Looper import android.view.Gravity import android.view.View import android.view.ViewGroup +import android.widget.EditText +import android.widget.FrameLayout import android.widget.LinearLayout import android.widget.ScrollView import android.widget.SeekBar import android.widget.Switch import android.widget.TextView +import android.widget.Toast +import androidx.core.view.ViewCompat +import androidx.core.view.WindowCompat +import androidx.core.view.WindowInsetsCompat /** * Full-screen native settings window opened by long-pressing the OpenLess @@ -22,6 +31,17 @@ import android.widget.TextView */ class OpenLessKeyboardSettingsActivity : Activity() { private val prefs by lazy { getSharedPreferences("openless_ime_ui", Context.MODE_PRIVATE) } + + // Export/import (see OpenLessSettingsExport): plain Activity, so this + // uses the classic startActivityForResult()/onActivityResult() pair + // rather than androidx.activity's ActivityResultContracts, which needs + // ComponentActivity. pendingExportJson bridges the gap between building + // the export JSON (at the moment the user picks categories) and + // actually having somewhere to write it (only known once + // ACTION_CREATE_DOCUMENT's picker returns a Uri, an async round trip). + private var pendingExportJson: String? = null + private val requestCodeExportSave = 4201 + private val requestCodeImportOpen = 4202 private val englishUi by lazy { val locale = prefs.getString("locale", null) ?: resources.configuration.locales[0].toLanguageTag() !locale.startsWith("zh", ignoreCase = true) @@ -30,17 +50,37 @@ class OpenLessKeyboardSettingsActivity : Activity() { private fun ui(zh: String, en: String) = if (englishUi) en else zh private fun dp(value: Int): Int = (value * resources.displayMetrics.density).toInt() + /** Same "which theme is the keyboard actually showing" logic as OpenLessImeService.isDarkTheme, so this screen matches whatever the user is looking at when they long-press the Logo to get here. */ + private val isDarkTheme: Boolean + get() = when (prefs.getString("theme_mode", null)) { + "light" -> false + "dark" -> true + else -> (resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK) != Configuration.UI_MODE_NIGHT_NO + } + + private fun tone(dark: Int, light: Int): Int = if (isDarkTheme) dark else light + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) + // Explicit rather than relying on targetSdk 36's implicit + // edge-to-edge enforcement — guarantees the IME-inset listener in + // buildContent() actually fires so the "云笔记提交" URL/Token fields + // stay reachable once the keyboard covers part of the screen. + WindowCompat.setDecorFitsSystemWindows(window, false) setContentView(buildContent()) + // Some OEM ROMs only wire up the WindowInsets/IME dispatch chain + // correctly once a WindowInsetsControllerCompat has actually been + // instantiated for this window — never used for show()/hide() here, + // just created as the standard companion call to + // setDecorFitsSystemWindows(false) above. + WindowCompat.getInsetsController(window, window.decorView) } private fun buildContent(): View { val root = LinearLayout(this).apply { orientation = LinearLayout.VERTICAL - setBackgroundColor(Color.rgb(30, 30, 30)) + setBackgroundColor(tone(Color.rgb(30, 30, 30), Color.rgb(245, 245, 247))) } - val header = LinearLayout(this).apply { gravity = Gravity.CENTER_VERTICAL setPadding(0, dp(8), dp(16), dp(8)) @@ -49,7 +89,7 @@ class OpenLessKeyboardSettingsActivity : Activity() { TextView(this).apply { text = "←" textSize = 22f - setTextColor(Color.WHITE) + setTextColor(tone(Color.WHITE, Color.rgb(30, 30, 34))) gravity = Gravity.CENTER setPadding(dp(16), dp(8), dp(16), dp(8)) isClickable = true @@ -61,7 +101,7 @@ class OpenLessKeyboardSettingsActivity : Activity() { text = ui("键盘设置", "Keyboard settings") textSize = 18f setTypeface(typeface, android.graphics.Typeface.BOLD) - setTextColor(Color.WHITE) + setTextColor(tone(Color.WHITE, Color.rgb(30, 30, 34))) }, ) root.addView(header, LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT)) @@ -74,6 +114,135 @@ class OpenLessKeyboardSettingsActivity : Activity() { scroll.addView(content, ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT)) root.addView(scroll, LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, 0, 1f)) + // Live 1:1 footprint + its controlling sliders stay docked under the + // scrollable settings so dragging always updates a visible silhouette + // without scrolling the form away. + var heightDp = prefs.getInt( + OpenLessImeService.PREF_KEYBOARD_HEIGHT_DP, + OpenLessImeService.DEFAULT_KEYBOARD_HEIGHT_DP, + ).coerceIn(OpenLessImeService.MIN_KEYBOARD_HEIGHT_DP, OpenLessImeService.MAX_KEYBOARD_HEIGHT_DP) + var raiseDp = prefs.getInt( + OpenLessImeService.PREF_KEYBOARD_RAISE_DP, + OpenLessImeService.DEFAULT_KEYBOARD_RAISE_DP, + ).coerceIn(OpenLessImeService.MIN_KEYBOARD_RAISE_DP, OpenLessImeService.MAX_KEYBOARD_RAISE_DP) + val footprint = buildKeyboardFootprintPreview() + fun refreshFootprint() = footprint.setSizes(heightDp, raiseDp) + + val appearanceDock = LinearLayout(this).apply { + orientation = LinearLayout.VERTICAL + setPadding(dp(20), dp(8), dp(20), dp(4)) + setBackgroundColor(tone(Color.rgb(30, 30, 30), Color.rgb(245, 245, 247))) + } + appearanceDock.addView(sectionLabel(ui("键盘外观(下方为 1:1 预览)", "Keyboard appearance (1:1 preview below)"))) + appearanceDock.addView( + sliderRow( + label = ui("按键区高度(拉伸)", "Key area height (stretch)"), + min = OpenLessImeService.MIN_KEYBOARD_HEIGHT_DP, + max = OpenLessImeService.MAX_KEYBOARD_HEIGHT_DP, + current = heightDp, + onChange = { value -> + heightDp = value + prefs.edit().putInt(OpenLessImeService.PREF_KEYBOARD_HEIGHT_DP, value).apply() + refreshFootprint() + }, + ), + ) + appearanceDock.addView( + sliderRow( + label = ui("整体抬高(底部留白)", "Raise (bottom gap)"), + min = OpenLessImeService.MIN_KEYBOARD_RAISE_DP, + max = OpenLessImeService.MAX_KEYBOARD_RAISE_DP, + current = raiseDp, + onChange = { value -> + raiseDp = value + prefs.edit().putInt(OpenLessImeService.PREF_KEYBOARD_RAISE_DP, value).apply() + refreshFootprint() + }, + ), + ) + root.addView( + appearanceDock, + LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT), + ) + root.addView( + footprint.root, + LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT), + ) + refreshFootprint() + + // Paired with onCreate()'s setDecorFitsSystemWindows(false): now that + // the window draws edge-to-edge, this restores the padding the + // system used to apply automatically (status bar / nav bar / cutouts) + // AND — the actual point of going edge-to-edge here — adds the IME's + // own height as bottom padding whenever it's taller than the nav bar, + // so `scroll` (layout_weight=1) genuinely loses that much height + // while the keyboard is up — targetSdk 36 otherwise neutralizes the + // manifest's windowSoftInputMode="adjustResize" (the window no + // longer physically shrinks on its own), which is exactly why the + // 云笔记提交 URL/Token fields were unreachable once the keyboard + // covered them. Shrinking the viewport alone isn't enough on its + // own, though: if a field already had focus before the keyboard + // finished animating in, nothing re-triggers ScrollView's normal + // "bring the focused child into view" behavior on a pure padding + // change (that behavior only fires at the moment focus is first + // requested) — so once the inset is actually nonzero, explicitly + // scroll whatever's currently focused into the new, smaller + // viewport instead of leaving it wherever it happened to sit before. + ViewCompat.setOnApplyWindowInsetsListener(root) { view, insets -> + val systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars()) + val imeBottom = insets.getInsets(WindowInsetsCompat.Type.ime()).bottom + view.setPadding(systemBars.left, systemBars.top, systemBars.right, maxOf(systemBars.bottom, imeBottom)) + if (imeBottom > 0) { + val focused = view.findFocus() + if (focused != null) { + scroll.post { + val rect = android.graphics.Rect() + focused.getDrawingRect(rect) + content.offsetDescendantRectToMyCoords(focused, rect) + scroll.smoothScrollTo(0, rect.bottom - scroll.height + dp(16)) + } + } + } + insets + } + + // Gates the "进程重启统计(今天)" section further down (see its own + // guard) — off by default, since those counters are only meaningful + // for diagnosing a specific problem, not everyday reading. Read once + // here, up top, rather than re-reading prefs at the exact point it's + // used, so this row and the section it controls can never disagree + // within a single render of this page. + content.addView(sectionLabel(ui("主设置", "Main settings"))) + val debugFeaturesEnabled = prefs.getBoolean("key_debug_features_enabled", false) + val debugRow = LinearLayout(this).apply { gravity = Gravity.CENTER_VERTICAL } + debugRow.addView( + TextView(this).apply { + text = ui("调试功能", "Debug features") + textSize = 15f + setTextColor(tone(Color.rgb(220, 220, 220), Color.rgb(40, 40, 44))) + }, + LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f), + ) + debugRow.addView( + Switch(this).apply { + isChecked = debugFeaturesEnabled + setOnCheckedChangeListener { _, checked -> + prefs.edit().putBoolean("key_debug_features_enabled", checked).apply() + // The restart-stats section's own visibility is decided + // once, above, when this page was built — rebuild it so + // toggling here shows/hides it immediately instead of + // only taking effect the next time this page opens. + setContentView(buildContent()) + } + }, + ) + content.addView( + debugRow, + LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT).apply { + bottomMargin = dp(14) + }, + ) + content.addView(sectionLabel(ui("震动反馈", "Haptic feedback"))) val enabledRow = LinearLayout(this).apply { gravity = Gravity.CENTER_VERTICAL } @@ -81,7 +250,7 @@ class OpenLessKeyboardSettingsActivity : Activity() { TextView(this).apply { text = ui("按键震动", "Key vibration") textSize = 15f - setTextColor(Color.rgb(220, 220, 220)) + setTextColor(tone(Color.rgb(220, 220, 220), Color.rgb(40, 40, 44))) }, LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f), ) @@ -98,18 +267,43 @@ class OpenLessKeyboardSettingsActivity : Activity() { }, ) + content.addView(sectionLabel(ui("英文键盘", "English keyboard"))) + val englishSuggestionsRow = LinearLayout(this).apply { gravity = Gravity.CENTER_VERTICAL } + englishSuggestionsRow.addView( + TextView(this).apply { + text = ui("英文单词提示", "English word suggestions") + textSize = 15f + setTextColor(tone(Color.rgb(220, 220, 220), Color.rgb(40, 40, 44))) + }, + LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f), + ) + englishSuggestionsRow.addView( + Switch(this).apply { + isChecked = prefs.getBoolean("english_suggestions_enabled", true) + setOnCheckedChangeListener { _, checked -> prefs.edit().putBoolean("english_suggestions_enabled", checked).apply() } + }, + ) + content.addView( + englishSuggestionsRow, + LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT).apply { + bottomMargin = dp(14) + }, + ) + // Amplitude's 255 ceiling is Android's own VibrationEffect max, not a // choice made here — the hardware/API can't go any stronger than - // that regardless of what this slider allows. Duration's ceiling is - // ours, so it's the one raised for a more noticeable pulse. + // that regardless of what this slider allows. Duration's ceiling + // started at 500ms, halved to 250, then halved again to 125 so the + // same slider width covers a quarter of the original range, for + // finer-grained adjustment. var currentAmplitude = prefs.getInt("key_haptic_amplitude", 55).coerceIn(1, 255) - var currentDurationMs = prefs.getLong("key_haptic_duration_ms", 12L).toInt().coerceIn(1, 500) + var currentDurationMs = prefs.getLong("key_haptic_duration_ms", 12L).toInt().coerceIn(1, 125) // No separate test button — letting go of either slider fires one // vibration with the values as they now stand, so adjusting and // feeling the result is a single motion. content.addView( sliderRow( - label = ui("震动强度(255 已是系统上限)", "Intensity (255 is the platform max)"), + label = ui("震动强度(系统上限)", "Intensity (platform ceiling)"), min = 1, max = 255, current = currentAmplitude, @@ -124,7 +318,7 @@ class OpenLessKeyboardSettingsActivity : Activity() { sliderRow( label = ui("震动时长", "Duration"), min = 1, - max = 500, + max = 125, current = currentDurationMs, onChange = { value -> currentDurationMs = value @@ -134,22 +328,411 @@ class OpenLessKeyboardSettingsActivity : Activity() { ), ) + content.addView(sectionLabel(ui("后台运行", "Background"))) + // OEM background-task killers (observed on-device: this app's own + // settings Activity gets reclaimed by the system 16+ times/day even + // with a foreground service running) largely ignore that + // protection but do respect the standard "ignore battery + // optimizations" exemption — offering a direct link to it here is + // the most effective single thing a user can do about the restart + // counts below. isIgnoringBatteryOptimizations() re-reads live each + // time this screen builds, so returning here after granting it in + // system settings shows the up-to-date state without extra wiring. + val powerManager = getSystemService(Context.POWER_SERVICE) as android.os.PowerManager + if (powerManager.isIgnoringBatteryOptimizations(packageName)) { + content.addView( + TextView(this).apply { + text = ui("已加入电池优化白名单", "Already exempt from battery optimization") + textSize = 13f + setTextColor(tone(Color.rgb(134, 239, 172), Color.rgb(21, 128, 61))) + }, + LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT).apply { + bottomMargin = dp(14) + }, + ) + } else { + content.addView( + TextView(this).apply { + text = ui( + "系统电量管理可能频繁回收键盘的后台进程,导致设置页偶尔黑屏或响应变慢。加入电池优化白名单可以减少这种情况——效果因系统而异。", + "The system's battery manager may repeatedly reclaim the keyboard's background process, occasionally causing a black settings screen or slow responses. Exempting it from battery optimization can reduce this — effectiveness varies by device.", + ) + textSize = 13f + setTextColor(tone(Color.rgb(200, 200, 200), Color.rgb(70, 70, 75))) + }, + LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT).apply { + bottomMargin = dp(8) + }, + ) + content.addView( + TextView(this).apply { + text = ui("去允许后台活动 →", "Allow background activity →") + textSize = 15f + setTypeface(typeface, android.graphics.Typeface.BOLD) + setTextColor(tone(Color.rgb(94, 234, 212), Color.rgb(15, 118, 110))) + isClickable = true + setOnClickListener { + val direct = android.content.Intent(android.provider.Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS).apply { + data = android.net.Uri.parse("package:$packageName") + } + val fallback = android.content.Intent(android.provider.Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply { + data = android.net.Uri.parse("package:$packageName") + } + runCatching { startActivity(direct) } + // A handful of heavily customized OEM systems + // block or silently no-op this specific system + // intent — the general app-details screen at + // least lands the user in the right area, one + // tap further from the actual toggle, instead + // of nothing happening on tap. + .onFailure { runCatching { startActivity(fallback) } } + } + }, + LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT).apply { + bottomMargin = dp(14) + }, + ) + } + + val monospace = android.graphics.Typeface.MONOSPACE + // Diagnostic-only — hidden unless "调试功能" above is on (see that + // switch's own comment). These counters help debug a specific + // problem; they're noise for everyday reading otherwise. + if (debugFeaturesEnabled) { + content.addView(sectionLabel(ui("进程重启统计(今天)", "Process restarts (today)"))) + // Short, purposefully un-translated keys (not meant to be pretty — + // meant to be pasted into a screenshot and read back verbatim). + // All reset to 0 whenever OpenLessBuildInfo.VERSION changes (see + // OpenLessApplication.resetRestartStatsOnVersionBump()), so these + // are always "since this build was installed", not lifetime totals: + // main/access - raw restarts of the main / :accessibility process + // sticky - OpenLessRuntimeService.onStartCommand() got a + // null Intent: Android's own restart-after-death + // signal for a START_STICKY service, the strongest + // evidence the whole process was actually killed + // warmup - OpenLessBackendWarmupActivity.ensureBackendReady() + // found the backend not registered and launched + // the warmup Activity + // mictap - user tapped the mic and toggleDictation() found + // the backend not ready (the user-visible symptom) + // actkill - OpenLessBackendWarmupActivity.onDestroy() fired, + // total across all reasons below (doesn't + // necessarily mean the process itself died) + // actkill_config - onDestroy() from a configuration change + // (rotation/density/locale) — expected, harmless + // actkill_finishing - isFinishing was true (unexpected; this + // Activity never calls finish() on itself + // deliberately) + // actkill_os - none of the above: the only sub-category that + // is actually the system reclaiming this task + // rtexit - Tauri's RunEvent::Exit actually fired despite + // ExitRequested being prevented (see + // mobile_runtime.rs) — should stay at 0 if that fix + // is holding + // unclean - previous main-process session never reached + // OpenLessImeService.onDestroy() (best-effort + // crash/force-stop signal, can't tell those apart) + // Chinese gloss for each key — just enough to read at a glance + // without cross-referencing the doc comment above. + val restartCategories = listOf( + Triple(OpenLessProcessRestartStats.MAIN, "main", "主进程"), + Triple(OpenLessProcessRestartStats.ACCESSIBILITY, "access", "无障碍进程"), + Triple("sticky", "sticky", "系统杀后恢复"), + Triple("warmup", "warmup", "后端唤醒"), + Triple("mictap", "mictap", "点击时未就绪"), + Triple("actkill", "actkill", "界面被回收(合计)"), + Triple("actkill_config", " ├config", "· 配置变化(无害)"), + Triple("actkill_finishing", " ├finish", "· isFinishing(异常)"), + Triple("actkill_os", " └os", "· 真正被系统回收"), + Triple("rtexit", "rtexit", "后端异常退出"), + Triple("unclean", "unclean", "上次异常退出"), + Triple("heartbeat", "heartbeat", "心跳自愈"), + Triple("stuckwindow", "stuckwindow", "设置窗口卡死自重启"), + ) + for ((key, label, gloss) in restartCategories) { + content.addView( + TextView(this).apply { + val count = OpenLessProcessRestartStats(this@OpenLessKeyboardSettingsActivity, key).today() + text = label.padEnd(10) + count.toString().padEnd(4) + gloss + textSize = 13f + typeface = monospace + setTextColor(tone(Color.rgb(200, 200, 200), Color.rgb(70, 70, 75))) + }, + ) + } + content.addView(View(this), LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, dp(14))) + } + + // "个人偏好数据": read-only counters for the two local, on-device-only + // learning stores that make repeated input steadily rank better + // (笔画/拼音 both benefit — see each row's own comment). No toggle + // here — matches this section's existing convention of pure + // display; the underlying preferences (strokeUsageEnabled etc.) + // live in the app's own WebView settings, not this native page. content.addView(sectionLabel(ui("个人偏好数据", "Personal preference data"))) + // 笔画输入的个人调频数据:记录"这个笔画码你选过哪个字",越用越靠前 + // 排序,不影响词库本身,也不会同步到云端。 val personalFrequency = StrokeUserFrequency(this) content.addView( TextView(this).apply { text = ui( - "已记录 ${personalFrequency.size()} / ${personalFrequency.capacity()} 条", - "${personalFrequency.size()} / ${personalFrequency.capacity()} entries recorded", + "笔画调频:已记录 ${personalFrequency.size()} / ${personalFrequency.capacity()} 条", + "Stroke ranking: ${personalFrequency.size()} / ${personalFrequency.capacity()} entries recorded", ) textSize = 14f - setTextColor(Color.rgb(200, 200, 200)) + setTextColor(tone(Color.rgb(200, 200, 200), Color.rgb(70, 70, 75))) + }, + ) + // 简拼优选:不是每次输入都记一条,是"连续两次直接打拼音上屏"的组合 + // (比如先打 zg 选中国,紧接着打 rm 选人民)达到 3 次后才计入这里—— + // 见 LitePinyinLearnedPhrases 的文档注释。这里只显示已经达标、正在 + // 生效的组合数,未达标的候选不计入(避免这个数字本身产生误导)。 + val learnedPhrases = LitePinyinLearnedPhrases(this) + content.addView( + TextView(this).apply { + text = ui( + "简拼优选:已生效 ${learnedPhrases.promotedCount()} 条", + "Pinyin combo learning: ${learnedPhrases.promotedCount()} promoted", + ) + textSize = 14f + setTextColor(tone(Color.rgb(200, 200, 200), Color.rgb(70, 70, 75))) + setPadding(0, 0, 0, dp(14)) + }, + ) + + // 话筒右划进入"云笔记":录音经 LLM 整理后的文字(跟正常上屏用的是 + // 同一条润色流程——见 OpenLessImeService.handleImeTextReady())会以 + // JSON POST 到这里配置的地址,不插入任何输入框,也不在本机留存。 + // 两项都填了才会真的提交——留空时只会在状态栏提示去设置里补上,不会 + // 静默失败。 + content.addView(sectionLabel(ui("云笔记提交", "Cloud notes webhook"))) + content.addView( + TextView(this).apply { + text = ui( + "话筒右划进入「云笔记」模式:录音经过整理后的文字会提交到下面的地址,不插入输入框,也不保存在本机。", + "Swipe the mic right to enter Cloud notes mode: the polished transcript is POSTed to the address below instead of being inserted — nothing is kept on this device either.", + ) + textSize = 12f + setTextColor(tone(Color.rgb(150, 150, 150), Color.rgb(110, 110, 115))) + setPadding(0, 0, 0, dp(10)) + }, + ) + content.addView( + textFieldRow( + label = ui("提交地址", "Submit URL"), + hint = "https://example.com/capture_ingest.php", + initial = prefs.getString("key_cloud_note_webhook_url", "") ?: "", + onChange = { prefs.edit().putString("key_cloud_note_webhook_url", it).apply() }, + ), + ) + content.addView( + textFieldRow( + label = "Token", + hint = ui("输入法专用 Token", "IME-only token"), + initial = prefs.getString("key_cloud_note_webhook_token", "") ?: "", + isSecret = true, + onChange = { prefs.edit().putString("key_cloud_note_webhook_token", it).apply() }, + ), + ) + + // "导出/导入配置": bundles everything OpenLessSettingsExport knows how + // to read (see that object's own Category enum) into one JSON file, + // or restores one — for moving personal settings to another device + // without retyping everything. Plain JSON, no encryption, by + // explicit product decision — the warning text below is the only + // protection against an accidentally-shared file containing a + // Cloud notes token or (if that category is checked) a raw ASR/LLM + // API key. + content.addView(sectionLabel(ui("导出 / 导入配置", "Export / Import settings"))) + content.addView( + TextView(this).apply { + text = ui( + "把云笔记地址、震动参数、笔画调频、简拼优选、ASR/LLM/风格包的选择(以及可选的 API Key)打包成一个文件,方便迁移到另一台设备。文件是明文 JSON——如果勾选了 API Key,文件里会有裸的密钥,请妥善保管。", + "Bundles the Cloud notes address, haptic settings, stroke ranking, pinyin combo learning, and ASR/LLM/style pack selection (plus optional API keys) into one file for moving to another device. The file is plain JSON — including API keys leaves them in the file in plaintext, so keep it safe.", + ) + textSize = 12f + setTextColor(tone(Color.rgb(150, 150, 150), Color.rgb(110, 110, 115))) + setPadding(0, 0, 0, dp(10)) + }, + ) + val exportImportRow = LinearLayout(this).apply { gravity = Gravity.CENTER_VERTICAL } + exportImportRow.addView( + TextView(this).apply { + text = ui("导出配置 →", "Export settings →") + textSize = 15f + setTypeface(typeface, android.graphics.Typeface.BOLD) + setTextColor(tone(Color.rgb(94, 234, 212), Color.rgb(15, 118, 110))) + isClickable = true + setOnClickListener { showExportCategoryDialog() } + }, + LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f), + ) + exportImportRow.addView( + TextView(this).apply { + text = ui("导入配置 →", "Import settings →") + textSize = 15f + setTypeface(typeface, android.graphics.Typeface.BOLD) + setTextColor(tone(Color.rgb(94, 234, 212), Color.rgb(15, 118, 110))) + isClickable = true + setOnClickListener { launchImportPicker() } + }, + LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f), + ) + content.addView( + exportImportRow, + LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT).apply { + bottomMargin = dp(16) + }, + ) + + // build_first_seen_wall_time is written by OpenLessApplication's + // resetRestartStatsOnVersionBump() at the exact moment it last + // zeroed the restart-cause counters below — i.e. "counting since + // when" for whatever counts are on screen right now, so a + // screenshot of this page carries both together. + val buildFirstSeenAt = getSharedPreferences("openless_runtime", Context.MODE_PRIVATE) + .getLong("build_first_seen_wall_time", 0L) + val installedAtText = if (buildFirstSeenAt > 0L) { + android.text.format.DateFormat.format("yyyy-MM-dd HH:mm", buildFirstSeenAt) + } else { + "?" + } + content.addView( + TextView(this).apply { + text = "${ui("构建版本", "Build")} ${OpenLessBuildInfo.VERSION} ${ui("安装于", "installed")} $installedAtText" + textSize = 11f + typeface = monospace + setTextColor(tone(Color.rgb(120, 120, 120), Color.rgb(150, 150, 155))) + }, + LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT).apply { + topMargin = dp(16) }, ) return root } + private fun showExportCategoryDialog() { + val categories = OpenLessSettingsExport.Category.entries.toTypedArray() + val labels = categories.map { ui(it.labelZh, it.labelEn) }.toTypedArray() + val checked = BooleanArray(categories.size) { true } + android.app.AlertDialog.Builder(this) + .setTitle(ui("选择要导出的内容", "Choose what to export")) + .setMultiChoiceItems(labels, checked) { _, which, isChecked -> checked[which] = isChecked } + .setPositiveButton(ui("导出", "Export")) { _, _ -> + val selected = categories.filterIndexed { index, _ -> checked[index] }.toSet() + if (selected.isEmpty()) { + Toast.makeText(this, ui("没有选择任何内容", "Nothing selected"), Toast.LENGTH_SHORT).show() + return@setPositiveButton + } + Thread { + val exported = runCatching { OpenLessSettingsExport.export(this, selected) } + runOnUiThread { + if (isFinishing || isDestroyed) return@runOnUiThread + exported.onSuccess { json -> + pendingExportJson = json + val timestamp = android.text.format.DateFormat.format("yyyyMMdd-HHmm", System.currentTimeMillis()) + val intent = android.content.Intent(android.content.Intent.ACTION_CREATE_DOCUMENT).apply { + addCategory(android.content.Intent.CATEGORY_OPENABLE) + type = "application/json" + putExtra(android.content.Intent.EXTRA_TITLE, "openless-settings-$timestamp.json") + } + runCatching { startActivityForResult(intent, requestCodeExportSave) }.onFailure { + pendingExportJson = null + Toast.makeText(this, ui("找不到文件管理器", "No file manager available"), Toast.LENGTH_LONG).show() + } + }.onFailure { + Toast.makeText(this, ui("导出失败,请检查服务和凭据存储", "Export failed; check the service and credential store"), Toast.LENGTH_LONG).show() + } + } + }.start() + } + .setNegativeButton(ui("取消", "Cancel"), null) + .show() + } + + private fun launchImportPicker() { + val intent = android.content.Intent(android.content.Intent.ACTION_OPEN_DOCUMENT).apply { + addCategory(android.content.Intent.CATEGORY_OPENABLE) + type = "application/json" + } + runCatching { startActivityForResult(intent, requestCodeImportOpen) } + .onFailure { Toast.makeText(this, ui("找不到文件选择器", "No file picker available"), Toast.LENGTH_SHORT).show() } + } + + override fun onActivityResult(requestCode: Int, resultCode: Int, data: android.content.Intent?) { + super.onActivityResult(requestCode, resultCode, data) + if (resultCode != RESULT_OK || data == null) return + when (requestCode) { + requestCodeExportSave -> { + val uri = data.data + val json = pendingExportJson + pendingExportJson = null + if (uri == null || json == null) return + val wrote = runCatching { + checkNotNull(contentResolver.openOutputStream(uri)).use { it.write(json.toByteArray(Charsets.UTF_8)) } + }.isSuccess + Toast.makeText( + this, + if (wrote) ui("已导出", "Exported") else ui("导出失败", "Export failed"), + Toast.LENGTH_SHORT, + ).show() + } + requestCodeImportOpen -> { + val uri = data.data ?: return + Thread { + val json = OpenLessContentReader.readBytes(this, uri.toString(), 4 * 1024 * 1024)?.toString(Charsets.UTF_8) + runOnUiThread { + if (isFinishing || isDestroyed) return@runOnUiThread + if (json.isNullOrBlank()) { + Toast.makeText(this, ui("读取文件失败或文件超过 4 MiB", "Read failed or file exceeds 4 MiB"), Toast.LENGTH_SHORT).show() + } else showImportCategoryDialog(json) + } + }.start() + } + } + } + + private fun showImportCategoryDialog(json: String) { + val present = OpenLessSettingsExport.categoriesPresent(json) + if (present.isEmpty()) { + Toast.makeText(this, ui("这不是一个有效的配置文件", "Not a valid settings file"), Toast.LENGTH_SHORT).show() + return + } + val categories = present.toList() + val labels = categories.map { ui(it.labelZh, it.labelEn) }.toTypedArray() + val checked = BooleanArray(categories.size) { true } + android.app.AlertDialog.Builder(this) + .setTitle(ui("选择要导入的内容", "Choose what to import")) + .setMultiChoiceItems(labels, checked) { _, which, isChecked -> checked[which] = isChecked } + .setPositiveButton(ui("导入", "Import")) { _, _ -> + val selected = categories.filterIndexed { index, _ -> checked[index] }.toSet() + if (selected.isEmpty()) { + Toast.makeText(this, ui("没有选择任何内容", "Nothing selected"), Toast.LENGTH_SHORT).show() + return@setPositiveButton + } + Thread { + val imported = runCatching { OpenLessSettingsExport.import(this, json, selected) } + runOnUiThread { + if (isFinishing || isDestroyed) return@runOnUiThread + val result = imported.getOrNull() + val message = if (result == null) ui("配置文件无效,导入失败", "Invalid settings file; import failed") + else buildString { + append(ui("已导入 ${result.applied.size} 项", "Imported ${result.applied.size} categories")) + for ((category, reason) in result.errors) { + append('\n').append(ui(category.labelZh, category.labelEn)).append(": ").append(reason) + } + } + android.app.AlertDialog.Builder(this).setMessage(message) + .setPositiveButton(android.R.string.ok, null).show() + setContentView(buildContent()) + } + }.start() + } + .setNegativeButton(ui("取消", "Cancel"), null) + .show() + } + /** Fires a one-shot vibration with the sliders' current (already-saved) values, so a change is felt immediately. */ private fun fireTestVibration(amplitude: Int, durationMs: Int) { runCatching { @@ -171,10 +754,100 @@ class OpenLessKeyboardSettingsActivity : Activity() { private fun sectionLabel(text: String): View = TextView(this).apply { this.text = text textSize = 12f - setTextColor(Color.rgb(150, 150, 150)) + setTextColor(tone(Color.rgb(150, 150, 150), Color.rgb(110, 110, 115))) setPadding(0, 0, 0, dp(8)) } + /** + * Full-width 1:1 IME footprint at the bottom of settings. Key-area block + * uses the stretch height; raise strip is empty lift space below keys. + */ + private fun buildKeyboardFootprintPreview(): KeyboardFootprintPreview { + val caption = TextView(this).apply { + textSize = 11f + gravity = Gravity.CENTER + setTextColor(tone(Color.rgb(160, 160, 160), Color.rgb(100, 100, 105))) + setPadding(0, dp(6), 0, dp(4)) + } + val keysBlock = LinearLayout(this).apply { + orientation = LinearLayout.VERTICAL + gravity = Gravity.CENTER + setBackgroundColor(tone(Color.rgb(48, 48, 48), Color.rgb(242, 242, 246))) + setPadding(dp(12), dp(10), dp(12), dp(10)) + // Three fake key rows so stretch is visually obvious. + repeat(3) { rowIndex -> + val row = LinearLayout(this@OpenLessKeyboardSettingsActivity).apply { + orientation = LinearLayout.HORIZONTAL + gravity = Gravity.CENTER + } + repeat(10) { + row.addView( + View(this@OpenLessKeyboardSettingsActivity).apply { + setBackgroundColor(tone(Color.rgb(70, 70, 70), Color.rgb(255, 255, 255))) + }, + LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.MATCH_PARENT, 1f).apply { + marginStart = dp(2) + marginEnd = dp(2) + }, + ) + } + addView( + row, + LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, 0, 1f).apply { + if (rowIndex > 0) topMargin = dp(6) + }, + ) + } + } + val raiseBlock = FrameLayout(this).apply { + setBackgroundColor(tone(Color.rgb(36, 36, 36), Color.rgb(220, 220, 224))) + addView( + TextView(this@OpenLessKeyboardSettingsActivity).apply { + text = ui("抬高", "Raise") + textSize = 11f + gravity = Gravity.CENTER + setTextColor(tone(Color.rgb(140, 140, 140), Color.rgb(120, 120, 125))) + }, + FrameLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT, + ), + ) + } + val root = LinearLayout(this).apply { + orientation = LinearLayout.VERTICAL + setBackgroundColor(tone(Color.rgb(22, 22, 22), Color.rgb(230, 230, 234))) + addView(caption, LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT)) + addView(keysBlock, LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, dp(OpenLessImeService.DEFAULT_KEYBOARD_HEIGHT_DP))) + addView(raiseBlock, LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, 0)) + } + return KeyboardFootprintPreview(root, caption, keysBlock, raiseBlock) + } + + private inner class KeyboardFootprintPreview( + val root: LinearLayout, + private val caption: TextView, + private val keysBlock: View, + private val raiseBlock: View, + ) { + fun setSizes(heightDp: Int, raiseDp: Int) { + keysBlock.layoutParams = LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + dp(heightDp), + ) + raiseBlock.layoutParams = LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + dp(raiseDp), + ) + raiseBlock.visibility = if (raiseDp > 0) View.VISIBLE else View.GONE + caption.text = ui( + "实时预览 · 按键区 ${heightDp}dp · 抬高 ${raiseDp}dp · 共 ${heightDp + raiseDp}dp", + "Live preview · keys ${heightDp}dp · raise ${raiseDp}dp · total ${heightDp + raiseDp}dp", + ) + root.requestLayout() + } + } + /** One labeled slider row. Reusable as more settings rows get added here. */ private fun sliderRow(label: String, min: Int, max: Int, current: Int, onChange: (Int) -> Unit, onRelease: (() -> Unit)? = null): View { // Computed before building the SeekBar itself, since inside that @@ -189,20 +862,23 @@ class OpenLessKeyboardSettingsActivity : Activity() { bottomMargin = dp(14) } } - row.addView( - TextView(this).apply { - text = label - textSize = 14f - setTextColor(Color.rgb(200, 200, 200)) - }, - ) + val labelView = TextView(this).apply { + text = "$label · Max $max Set:$current" + textSize = 14f + setTextColor(tone(Color.rgb(200, 200, 200), Color.rgb(70, 70, 75))) + } + row.addView(labelView) row.addView( SeekBar(this).apply { this.max = range this.progress = initialProgress setOnSeekBarChangeListener(object : SeekBar.OnSeekBarChangeListener { override fun onProgressChanged(seekBar: SeekBar?, progress: Int, fromUser: Boolean) { - if (fromUser) onChange(progress + min) + if (fromUser) { + val value = progress + min + labelView.text = "$label · Max $max Set:$value" + onChange(value) + } } override fun onStartTrackingTouch(seekBar: SeekBar?) = Unit override fun onStopTrackingTouch(seekBar: SeekBar?) { @@ -213,4 +889,41 @@ class OpenLessKeyboardSettingsActivity : Activity() { ) return row } + + /** One labeled single-line text field, auto-saving on every keystroke (matches every other row on this page — no separate save button). Used by the "云笔记提交" section for its URL/token; reusable for any future free-text setting. */ + private fun textFieldRow(label: String, hint: String, initial: String, isSecret: Boolean = false, onChange: (String) -> Unit): View { + val row = LinearLayout(this).apply { + orientation = LinearLayout.VERTICAL + layoutParams = LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT).apply { + bottomMargin = dp(14) + } + } + row.addView( + TextView(this).apply { + text = label + textSize = 14f + setTextColor(tone(Color.rgb(200, 200, 200), Color.rgb(70, 70, 75))) + setPadding(0, 0, 0, dp(4)) + }, + ) + row.addView( + EditText(this).apply { + setText(initial) + this.hint = hint + textSize = 14f + isSingleLine = true + if (isSecret) { + inputType = android.text.InputType.TYPE_CLASS_TEXT or android.text.InputType.TYPE_TEXT_VARIATION_PASSWORD + } + setTextColor(tone(Color.WHITE, Color.rgb(30, 30, 34))) + setHintTextColor(tone(Color.rgb(110, 110, 115), Color.rgb(170, 170, 175))) + addTextChangedListener(object : android.text.TextWatcher { + override fun afterTextChanged(s: android.text.Editable?) = onChange(s?.toString().orEmpty()) + override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) = Unit + override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) = Unit + }) + }, + ) + return row + } } diff --git a/openless-all/app/android/kotlin/OpenLessNative.kt b/openless-all/app/android/kotlin/OpenLessNative.kt index e4731a6bd..f22114b7a 100644 --- a/openless-all/app/android/kotlin/OpenLessNative.kt +++ b/openless-all/app/android/kotlin/OpenLessNative.kt @@ -14,13 +14,13 @@ object OpenLessNative { @JvmStatic external fun nativeStartDictation() - @JvmStatic external fun nativeStartDictationForIme() @JvmStatic external fun nativeStartDictationWithTranslation(translation: Boolean) @JvmStatic external fun nativeStopDictation() - @JvmStatic external fun nativeStopDictationForIme() + + @JvmStatic external fun nativeStopDictationWithTranslation(translation: Boolean) @@ -28,6 +28,15 @@ object OpenLessNative { @JvmStatic external fun nativeCancelDictation() + // Adds a word/phrase straight to the global Dictionary (same store + // add_vocab exposes to the desktop UI) instead of a CorrectionRule — + // see native_bridge.rs's spawn_add_vocabulary_word() doc comment for + // why the IME's own edit/correction flows moved to this. The older + // CorrectionRule bindings below are kept too — upstream/beta's own + // code paths still use them; this file just adds an alternative for + // this branch's own IME flows, not a replacement. + @JvmStatic external fun nativeAddVocabularyWord(phrase: String) + /** Records a hand-corrected span from the IME's "edit result" flow into the shared correction dictionary. */ @JvmStatic external fun nativeAddCorrectionRule(pattern: String, replacement: String) @@ -37,7 +46,48 @@ object OpenLessNative { /** Removes every correction rule whose pattern exactly matches — the clipboard swipe-left "remove" action. */ @JvmStatic external fun nativeRemoveCorrectionRule(pattern: String) + /** JSON array of every existing Dictionary entry's phrase — same purpose as nativeCorrectionRulePatterns(), for the clipboard swipe-left zone now that adding writes to the Dictionary instead. */ + @JvmStatic external fun nativeVocabularyPhrases(): String + + /** Removes every Dictionary entry whose phrase exactly matches — the clipboard swipe-left "remove" action's counterpart to nativeAddVocabularyWord(). */ + @JvmStatic external fun nativeRemoveVocabularyWord(phrase: String) + + /** + * Registers this Context as the one with_android_env() (every + * Rust->Kotlin JNI call, including dictation/waveform capsule updates) + * routes through — replaces whatever was registered before, since a + * GlobalRef stays valid for the registrant's whole lifecycle. Despite + * the name, this was never actually Activity-specific on the Rust side + * (register_active_activity() in jni.rs just stores a generic JObject), + * and no longer requires one here either: OpenLessRuntimeService now + * registers itself, not OpenLessBackendWarmupActivity — a foreground + * Service that starts/stops in lockstep with the IME being active is a + * far more stable registrant than an Activity that spends nearly its + * entire life backgrounded via moveTaskToBack() and can be reclaimed by + * the OS at any point during that (see OpenLessRuntimeService.onCreate() + * for the current registrant, and OpenLessProcessRestartStats' "actkill" + * history for what depending on the Activity instead used to cost). + * Call from onCreate(); pair with nativeUnregisterActivityContext() in + * onDestroy(). + */ + @JvmStatic external fun nativeRegisterActivityContext(context: android.content.Context) + + /** Clears the registration from nativeRegisterActivityContext() — only takes effect if `context` is still the currently-registered one. */ + @JvmStatic external fun nativeUnregisterActivityContext(context: android.content.Context) + + /** + * True once some registrant (see nativeRegisterActivityContext()) has + * called it and nothing has unregistered since. The Rust backend can + * stay perfectly healthy for a long time after its last registered + * Context is gone (that's the whole point of Phase 1/2's recovery + * design) — requireBackendContract() alone can't see that gap, since it + * only checks whether the backend itself is running, not whether + * there's still something around for it to notify. + */ + @JvmStatic external fun nativeHasRegisteredActivityContext(): Boolean + @JvmStatic external fun nativeBackendSnapshot(): String + @JvmStatic external fun nativeImeCommand(json: String): String @JvmStatic fun requireBackendContract() { @@ -53,21 +103,21 @@ object OpenLessNative { @JvmStatic external fun nativeSwitchStylePack() - @JvmStatic external fun nativeOpenQaFromOverlay() - @JvmStatic external fun nativeFinalizeQaFromOverlay() - @JvmStatic external fun nativeGetOverlayTriggerMode(): String - - @JvmStatic external fun nativeCanDrawOverlays(context: android.content.Context): Boolean - - @JvmStatic external fun nativeShowOverlay(context: android.content.Context) - - @JvmStatic external fun nativeHideOverlay(context: android.content.Context) - - @JvmStatic external fun nativeIsOverlayVisible(): Boolean - - @JvmStatic external fun nativeNotifyOverlayPermissionChanged(context: android.content.Context) - @JvmStatic external fun nativeNotifyOverlayDestroyed() + + /** + * Idempotent — safe to call from every OpenLessBackendWarmupActivity.onCreate(). + * No-ops when the "main" WebviewWindow already exists; otherwise rebuilds it, + * attaching to whichever Activity instance most recently registered with Tao + * (this one). See android/native_bridge.rs's ensure_main_webview_window() doc + * comment for why a fresh Activity instance never gets onWebViewCreate() + * without this — the settings-reopen black screen fix. + */ + @JvmStatic external fun nativeEnsureMainWebviewWindow(): Boolean + + /** Explicit, user-selected plaintext transfer; result envelopes carry failures. */ + @JvmStatic external fun nativeExportProviderSettings(includeCredentials: Boolean): String + @JvmStatic external fun nativeImportProviderSettings(json: String, selection: Boolean, credentials: Boolean): String } diff --git a/openless-all/app/android/kotlin/OpenLessOverlayBridge.kt b/openless-all/app/android/kotlin/OpenLessOverlayBridge.kt index 56cd5848e..35b502166 100644 --- a/openless-all/app/android/kotlin/OpenLessOverlayBridge.kt +++ b/openless-all/app/android/kotlin/OpenLessOverlayBridge.kt @@ -11,7 +11,7 @@ object OpenLessOverlayBridge { @Volatile var listener: OverlayStateListener? = null @Volatile var imeListener: OverlayStateListener? = null - @Volatile var imeTextListener: ((String) -> Unit)? = null + @Volatile var imeSessionListener: ((String) -> Unit)? = null interface OverlayStateListener { fun onCapsuleStateChanged(state: String, message: String?, level: Float) @@ -28,10 +28,8 @@ object OpenLessOverlayBridge { @Keep @JvmStatic - fun onImeTextReady(text: String) { - mainHandler.post { - imeTextListener?.invoke(text) - } + fun onImeSessionEvent(json: String) { + mainHandler.post { imeSessionListener?.invoke(json) } } @Keep diff --git a/openless-all/app/android/kotlin/OpenLessOverlayService.kt b/openless-all/app/android/kotlin/OpenLessOverlayService.kt index 8775797ed..2df770e4b 100644 --- a/openless-all/app/android/kotlin/OpenLessOverlayService.kt +++ b/openless-all/app/android/kotlin/OpenLessOverlayService.kt @@ -29,7 +29,25 @@ class OpenLessOverlayService : Service(), OpenLessOverlayBridge.OverlayStateList private var windowManager: WindowManager? = null private var rootView: FrameLayout? = null private var layoutParams: WindowManager.LayoutParams? = null + // Custom setter, not a plain field: tryPromoteRecordingForeground() + // posts the FOREGROUND_SERVICE_TYPE_MICROPHONE "录音中" notification + // Android requires while actually recording in the background, but + // there used to be exactly one place that tore it back down + // (ACTION_HIDE, i.e. only when the whole overlay is dismissed) — every + // other path that ends a recording (onCapsuleStateChanged()'s + // "transcribing"/"polishing"/"done"/"error"/"cancelled"/"idle" + // branches, stopRecordingFromOverlay(), the error branches in + // beginDictationFromOverlay()/stopRecordingFromOverlay()) just flipped + // this flag to false and left the notification up — which is exactly + // the "stuck showing 录音中 even when nothing is recording" bug this + // catches structurally: demoting on every true->false transition here + // means no call site can forget to do it, present or future. private var recording = false + set(value) { + val wasRecording = field + field = value + if (wasRecording && !value) demoteFromRecordingForeground() + } private var processing = false private var keyboardVisible = false private var armed = false @@ -49,6 +67,23 @@ class OpenLessOverlayService : Service(), OpenLessOverlayBridge.OverlayStateList override fun onCreate() { super.onCreate() + // #region agent log + // Overlay mode must register its own Service Context: RuntimeService only + // lives while the IME is active, so pure-floating-window dictation otherwise + // hits "no live Android Activity context registered" on every capsule notify. + runCatching { + OpenLessNative.nativeRegisterActivityContext(this) + android.util.Log.i( + "OpenLessDbg58c22b", + """{"sessionId":"58c22b","hypothesisId":"A","location":"OpenLessOverlayService.onCreate","message":"registered overlay service context","data":{"hasCtx":${OpenLessNative.nativeHasRegisteredActivityContext()}},"timestamp":${System.currentTimeMillis()}}""", + ) + }.onFailure { error -> + android.util.Log.w( + "OpenLessDbg58c22b", + """{"sessionId":"58c22b","hypothesisId":"A","location":"OpenLessOverlayService.onCreate","message":"overlay register failed","data":{"error":"${error.message}"},"timestamp":${System.currentTimeMillis()}}""", + ) + } + // #endregion try { OpenLessNative.requireBackendContract() } catch (error: Throwable) { @@ -77,11 +112,7 @@ class OpenLessOverlayService : Service(), OpenLessOverlayBridge.OverlayStateList } ACTION_HIDE -> { hideOverlay() - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { - stopForeground(STOP_FOREGROUND_REMOVE) - } else { - @Suppress("DEPRECATION") stopForeground(true) - } + demoteFromRecordingForeground() stopSelf(startId) } ACTION_REPLACE_OVERLAY -> replaceOverlay() @@ -102,6 +133,14 @@ class OpenLessOverlayService : Service(), OpenLessOverlayBridge.OverlayStateList } // 系统杀死前台服务时也会走到这里:同步原生 OVERLAY_VISIBLE=false,避免状态永久残留为 true。 runCatching { OpenLessNative.nativeNotifyOverlayDestroyed() } + // #region agent log + android.util.Log.i( + "OpenLessDbg58c22b", + """{"sessionId":"58c22b","hypothesisId":"A","location":"OpenLessOverlayService.onDestroy","message":"unregistering overlay service context","timestamp":${System.currentTimeMillis()}}""", + ) + runCatching { OpenLessNative.nativeUnregisterActivityContext(this) } + runCatching { OpenLessNative.nativeRegisterActivityContext(applicationContext) } + // #endregion super.onDestroy() } @@ -635,18 +674,6 @@ class OpenLessOverlayService : Service(), OpenLessOverlayBridge.OverlayStateList } } - private fun openQaFromOverlay() { - try { - Log.i(TAG, "open QA from overlay") - OpenLessNative.nativeOpenQaFromOverlay() - setArmed(false) - } catch (error: Throwable) { - Log.w(TAG, "open QA bridge unavailable", error) - applyVisualState(OverlayVisualState.Error) - showToast("问答服务未就绪,请打开 OpenLess 后重试") - } - } - private fun finalizeQaFromOverlay() { try { Log.i(TAG, "finalize QA from overlay") @@ -687,6 +714,13 @@ class OpenLessOverlayService : Service(), OpenLessOverlayBridge.OverlayStateList applyVisualState(OverlayVisualState.Recording) } catch (error: Throwable) { Log.w(TAG, "start dictation bridge unavailable", error) + // recording was never set true on this path (the native start + // call itself threw), so the `recording` setter's own + // true->false demote never fires — but tryPromoteRecordingForeground() + // already posted the notification before this function was even + // called, so it needs tearing down here explicitly or it's + // orphaned with nothing left to ever clear it. + demoteFromRecordingForeground() recording = false processing = false applyVisualState(OverlayVisualState.Error) @@ -722,6 +756,15 @@ class OpenLessOverlayService : Service(), OpenLessOverlayBridge.OverlayStateList } } + /** Tears down the mic foreground-service notification — see `recording`'s own setter for why every recording-ends path routes through this one place. Safe to call even when not currently foreground-promoted (stopForeground() no-ops then). */ + private fun demoteFromRecordingForeground() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + stopForeground(STOP_FOREGROUND_REMOVE) + } else { + @Suppress("DEPRECATION") stopForeground(true) + } + } + private fun stopQuickNoteFromOverlay() { try { recording = false diff --git a/openless-all/app/android/kotlin/OpenLessProcessRestartStats.kt b/openless-all/app/android/kotlin/OpenLessProcessRestartStats.kt new file mode 100644 index 000000000..a06d6b489 --- /dev/null +++ b/openless-all/app/android/kotlin/OpenLessProcessRestartStats.kt @@ -0,0 +1,68 @@ +package com.openless.app + +import android.content.Context +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale + +/** + * Today's count of how many times one specific OS process has (re)started + * — a quick, adb-free signal the user can check in Settings for whether + * the background-kill mitigations (Phase 1/2/3) are actually helping. A + * cold process start looks the same here whether it's the first launch of + * the day or the OS/OEM force-killing and restarting it — that ambiguity + * doesn't matter for this purpose. + * + * Only ever tracks *today*, and only for the currently-installed build: + * OpenLessApplication resets every category back to 0 whenever + * OpenLessBuildInfo.VERSION changes, since a count from a previous build + * (or a previous day) isn't meaningful to keep comparing against. + * + * [MAIN] (default process, hosts OpenLessImeService + the Tauri backend) and + * [ACCESSIBILITY] (the `:accessibility` process declared in the manifest) + * are tracked separately, since the crash/kill debugging that motivated this + * counter treated them as two distinct, independently-restartable processes + * sharing one SharedPreferences file — recordStart()/today() only ever + * touch keys under their own [processKey] prefix. + */ +class OpenLessProcessRestartStats(context: Context, private val processKey: String) { + private val preferences = context.applicationContext.getSharedPreferences(STORE, Context.MODE_PRIVATE) + + fun recordStart() { + val key = todayKey() + preferences.edit().putInt(key, preferences.getInt(key, 0) + 1).apply() + prune() + } + + fun today(): Int = preferences.getInt(todayKey(), 0) + + /** Zeroes today's count — used when a version bump makes it no longer meaningful to compare against. */ + fun resetToday() { + preferences.edit().remove(todayKey()).apply() + } + + /** Drops any key under this processKey other than today's — yesterday's (or an older build's) count has nothing left worth showing. */ + private fun prune() { + val keep = todayKey() + val prefix = "$processKey|" + val edit = preferences.edit() + var changed = false + for (key in preferences.all.keys) { + if (!key.startsWith(prefix)) continue // never touch the other process's keys + if (key != keep) { + edit.remove(key) + changed = true + } + } + if (changed) edit.apply() + } + + private fun todayKey(): String = "$processKey|${FORMAT.format(Date())}" + + companion object { + const val MAIN = "main" + const val ACCESSIBILITY = "accessibility" + private const val STORE = "openless_process_restart_stats" + private val FORMAT = SimpleDateFormat("yyyy-MM-dd", Locale.US) + } +} diff --git a/openless-all/app/android/kotlin/OpenLessRuntimeService.kt b/openless-all/app/android/kotlin/OpenLessRuntimeService.kt index e5cc3e06b..7c003c94e 100644 --- a/openless-all/app/android/kotlin/OpenLessRuntimeService.kt +++ b/openless-all/app/android/kotlin/OpenLessRuntimeService.kt @@ -14,7 +14,76 @@ import android.util.Log class OpenLessRuntimeService : Service() { override fun onBind(intent: Intent?): IBinder? = null + // Registers this Service (not an Activity) as the Context every + // Rust->Kotlin JNI call routes through — see + // OpenLessNative.nativeRegisterActivityContext()'s doc comment for why. + // This Service starts in onCreate()/onCreateInputView() and is stopped + // in OpenLessImeService.onDestroy(), so the registration stays valid + // for as long as the IME itself is alive, independent of whether + // OpenLessBackendWarmupActivity (background warmup / settings host) + // exists, is backgrounded, or has been reclaimed by the OS. + override fun onCreate() { + super.onCreate() + // #region agent log + runCatching { + OpenLessNative.nativeRegisterActivityContext(this) + android.util.Log.i( + "OpenLessDbg58c22b", + """{"sessionId":"58c22b","hypothesisId":"A","location":"OpenLessRuntimeService.onCreate","message":"registered runtime service context","data":{"hasCtx":${OpenLessNative.nativeHasRegisteredActivityContext()}},"timestamp":${System.currentTimeMillis()}}""", + ) + } + .onFailure { error -> + Log.w(TAG, "register runtime service context failed", error) + android.util.Log.w( + "OpenLessDbg58c22b", + """{"sessionId":"58c22b","hypothesisId":"A","location":"OpenLessRuntimeService.onCreate","message":"register failed","data":{"error":"${error.message}"},"timestamp":${System.currentTimeMillis()}}""", + ) + } + // #endregion + } + + override fun onDestroy() { + // #region agent log + android.util.Log.i( + "OpenLessDbg58c22b", + """{"sessionId":"58c22b","hypothesisId":"A","location":"OpenLessRuntimeService.onDestroy","message":"unregistering runtime service context","data":{"hasCtxBefore":${runCatching { OpenLessNative.nativeHasRegisteredActivityContext() }.getOrDefault(false)}},"timestamp":${System.currentTimeMillis()}}""", + ) + // #endregion + runCatching { OpenLessNative.nativeUnregisterActivityContext(this) } + // Re-baseline to Application so settings / overlay / mic IPC keep working + // after the user switches away from the OpenLess IME. + runCatching { OpenLessNative.nativeRegisterActivityContext(applicationContext) } + super.onDestroy() + } + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + // A null intent here is the OS's own restart-after-death signal for + // a START_STICKY service (documented Service.onStartCommand() + // contract) — the strongest available evidence that the whole + // process was actually killed, as opposed to any of the other + // signals below which can also fire while the process survives. + if (intent == null) { + OpenLessProcessRestartStats(this, "sticky").recordStart() + } + if (intent?.action == ACTION_RUNTIME_ACTIVITY_DESTROYED) { + // reason disambiguates *why* onDestroy() fired — added because + // the plain "actkill" total conflated at least three different + // causes (self-triggered recreate() for a stuck WebView, a + // config change, and genuine unexplained OS reclamation) into + // one number, making it impossible to tell from the stats + // screen alone whether "actkill" was ever actually the OS + // killing something. Recorded as its own sub-category + // alongside (not instead of) the original "actkill" so + // existing dashboards/screenshots keep meaning the same thing. + val reason = intent.getStringExtra(EXTRA_DESTROY_REASON) ?: "unknown" + Log.w(TAG, "runtime host activity was destroyed (reason=$reason); re-checking backend") + OpenLessProcessRestartStats(this, "actkill").recordStart() + OpenLessProcessRestartStats(this, "actkill_$reason").recordStart() + } + if (intent?.action == ACTION_RUNTIME_EXITED) { + Log.w(TAG, "Tauri RunEvent::Exit fired; recording and re-checking backend") + OpenLessProcessRestartStats(this, "rtexit").recordStart() + } try { val notification = buildNotification() if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { @@ -35,26 +104,30 @@ class OpenLessRuntimeService : Service() { stopSelf(startId) return START_NOT_STICKY } - // START_STICKY means the system can restart this service on its own — - // e.g. after killing it under memory pressure — with no IME - // interaction involved at all. Piggyback the backend warmup check on - // every start (including those system-triggered restarts) so a cold - // backend has a chance to finish warming up (and the foreground- - // stealing Activity that requires goes away) before the user is next - // looking at some other app's text field, instead of only ever - // reacting to that tap. - OpenLessBackendWarmupActivity.ensureBackendReady(this) + // START_STICKY keeps the backend service alive, but it must not launch + // a visible Tauri Activity merely because the service restarted or a + // previous Activity was destroyed. Doing so steals the foreground from + // the app the user is currently using and, after Wry has destroyed the + // old WebView, can create an empty black Activity. UI warmup remains + // explicitly initiated by the launcher/settings or the IME path when + // an input interaction actually requires it. return START_STICKY } private fun buildNotification(): Notification { - val channelId = "openless_ime_runtime" + // Renamed from "openless_ime_runtime" (not just lowered the same + // channel's importance): once a channel is created, its importance + // is user-owned from then on — createNotificationChannel() with a + // different importance on an EXISTING channel id is a documented + // no-op, so an already-installed app would never actually see this + // get quieter without a fresh channel id forcing a fresh channel. + val channelId = "openless_ime_runtime_v2" if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { getSystemService(NotificationManager::class.java).createNotificationChannel( NotificationChannel( channelId, "OpenLess 输入法服务", - NotificationManager.IMPORTANCE_LOW, + NotificationManager.IMPORTANCE_MIN, ) ) } @@ -79,5 +152,37 @@ class OpenLessRuntimeService : Service() { companion object { private const val NOTIFICATION_ID = 42002 private const val TAG = "OpenLessRuntimeService" + private const val ACTION_RUNTIME_ACTIVITY_DESTROYED = "com.openless.app.action.RUNTIME_ACTIVITY_DESTROYED" + private const val EXTRA_DESTROY_REASON = "reason" + + // String literal duplicated on the Rust side (mobile_runtime.rs's + // RunEvent::Exit handler) rather than shared as a constant — Rust + // calls this Service by fully-qualified class/action name through + // the generic start_service_action() JNI helper, the same way + // native_bridge.rs already targets OpenLessOverlayService. + private const val ACTION_RUNTIME_EXITED = "com.openless.app.action.RUNTIME_EXITED" + + /** + * Called from OpenLessBackendWarmupActivity.onDestroy() — this + * service is the supervisor that decides whether/when to relaunch + * the runtime host Activity (via ensureBackendReady(), already run + * unconditionally at the end of onStartCommand()), not the dying + * Activity deciding for itself. The action is only for logging here + * today; onStartCommand() already re-checks on every start + * regardless of why it was started. + * + * @param reason one of "config" (isChangingConfigurations was true — + * a rotation/density/locale change, not a kill), "finishing" + * (isFinishing was true — unexpected, this Activity never calls + * finish() on itself deliberately), or "os" (none of the above — + * the only case that's actually the system reclaiming this task). + */ + fun notifyRuntimeActivityDestroyed(context: android.content.Context, reason: String) { + context.startService( + Intent(context, OpenLessRuntimeService::class.java) + .setAction(ACTION_RUNTIME_ACTIVITY_DESTROYED) + .putExtra(EXTRA_DESTROY_REASON, reason), + ) + } } } diff --git a/openless-all/app/android/kotlin/OpenLessSettingsExport.kt b/openless-all/app/android/kotlin/OpenLessSettingsExport.kt new file mode 100644 index 000000000..a959dd6ac --- /dev/null +++ b/openless-all/app/android/kotlin/OpenLessSettingsExport.kt @@ -0,0 +1,154 @@ +package com.openless.app + +import android.content.Context +import org.json.JSONObject + +/** + * Coordinates the keyboard settings page's "导出/导入配置" feature — collects + * whichever of the seven categories the user selected into one JSON object + * for export, and applies whichever categories are both present in an + * imported file and still checked. Deliberately its own file rather than + * folded into OpenLessKeyboardSettingsActivity, which is already large and + * mostly UI-building code — this is a self-contained + * read-everything/write-everything concern with no View dependencies. + * + * Plain, unencrypted JSON, by explicit product decision — a Cloud notes + * token or (if CREDENTIALS is selected) an ASR/LLM API key ends up in the + * exported file exactly as stored on this device. The settings page's own + * export button carries a one-line warning about this; this class itself + * makes no attempt to protect the file's contents. + */ +internal object OpenLessSettingsExport { + const val VERSION = 2 + private const val PREFS_STORE = "openless_ime_ui" + + /** One category = one row in the export/import checkbox dialog. Order here is the order shown. */ + enum class Category(val key: String, val labelZh: String, val labelEn: String) { + CLOUD_NOTES("cloudNotes", "云笔记地址 / Token", "Cloud notes URL / token"), + HAPTIC("haptic", "震动反馈", "Haptic feedback"), + STROKE_FREQUENCY("strokeFrequency", "笔画调频", "Stroke ranking"), + PINYIN_LEARNED_PHRASES("pinyinLearnedPhrases", "简拼优选", "Pinyin combo learning"), + PROVIDER_SELECTION("providerSelection", "ASR / LLM / 风格包 选择", "ASR / LLM / style pack selection"), + CREDENTIALS("credentials", "ASR / LLM API Key(明文,敏感)", "ASR / LLM API keys (plaintext, sensitive)"), + } + + fun export(context: Context, selected: Set): String { + val app = context.applicationContext + val root = JSONObject() + root.put("openlessSettingsExportVersion", VERSION) + root.put("learningPrivacyVersion", ImeLearningPolicy.PRIVACY_VERSION) + check(ImeLearningPolicy.migrate(app)) { "Learning data cleanup failed" } + root.put( + "exportedAt", + java.text.SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ssXXX", java.util.Locale.US).format(java.util.Date()), + ) + val prefs = app.getSharedPreferences(PREFS_STORE, Context.MODE_PRIVATE) + + if (Category.CLOUD_NOTES in selected) { + root.put( + Category.CLOUD_NOTES.key, + JSONObject().apply { + put("webhookUrl", prefs.getString("key_cloud_note_webhook_url", "") ?: "") + put("webhookToken", prefs.getString("key_cloud_note_webhook_token", "") ?: "") + }, + ) + } + if (Category.HAPTIC in selected) { + root.put( + Category.HAPTIC.key, + JSONObject().apply { + put("enabled", prefs.getBoolean("key_haptic_enabled", true)) + put("amplitude", prefs.getInt("key_haptic_amplitude", 55)) + put("durationMs", prefs.getLong("key_haptic_duration_ms", 12L)) + }, + ) + } + if (Category.STROKE_FREQUENCY in selected) { + root.put(Category.STROKE_FREQUENCY.key, JSONObject(StrokeUserFrequency(app).exportAll())) + } + if (Category.PINYIN_LEARNED_PHRASES in selected) { + root.put(Category.PINYIN_LEARNED_PHRASES.key, JSONObject(LitePinyinLearnedPhrases(app).exportAll())) + } + if (Category.PROVIDER_SELECTION in selected || Category.CREDENTIALS in selected) { + val response = JSONObject(OpenLessNative.nativeExportProviderSettings(Category.CREDENTIALS in selected)) + check(response.optBoolean("ok")) { response.optString("error", "Provider export failed") } + val payload = response.getJSONObject("payload") + if (Category.PROVIDER_SELECTION in selected) root.put(Category.PROVIDER_SELECTION.key, payload.getJSONObject("providerSelection")) + if (Category.CREDENTIALS in selected) root.put(Category.CREDENTIALS.key, payload.getJSONObject("credentials")) + } + return root.toString(2) + } + + /** Which categories are actually present in a previously-exported [json] — for the import dialog's checkbox list, which only ever offers what the file really has. Empty (not a throw) for unparseable input. */ + fun categoriesPresent(json: String): Set { + val root = runCatching { JSONObject(json) }.getOrNull() ?: return emptySet() + if (root.optInt("openlessSettingsExportVersion", 0) !in 1..VERSION) return emptySet() + return Category.entries.filter { root.opt(it.key) is JSONObject }.toSet() + } + + data class ImportResult(val applied: Set, val errors: Map) + + fun import(context: Context, json: String, selected: Set): ImportResult { + val app = context.applicationContext + val root = JSONObject(json) + require(root.getInt("openlessSettingsExportVersion") in 1..VERSION) { "Unsupported settings version" } + val prefs = app.getSharedPreferences(PREFS_STORE, Context.MODE_PRIVATE) + val applied = mutableSetOf() + val errors = mutableMapOf() + val requested = selected.filter { root.has(it.key) }.toSet() + // Validate the shape of every requested category before any writes. + requested.forEach { root.getJSONObject(it.key) } + for (category in requested - setOf(Category.PROVIDER_SELECTION, Category.CREDENTIALS)) { + runCatching { + val value = root.getJSONObject(category.key) + when (category) { + Category.CLOUD_NOTES -> { + val url = value.getString("webhookUrl") + val token = value.getString("webhookToken") + require(url.isBlank() || java.net.URI(url).scheme in listOf("http", "https")) { "Invalid webhook URL" } + check(prefs.edit().putString("key_cloud_note_webhook_url", url).putString("key_cloud_note_webhook_token", token).commit()) { "Settings save failed" } + } + Category.HAPTIC -> { + val enabled = value.getBoolean("enabled") + val amplitude = value.getInt("amplitude") + val duration = value.getLong("durationMs") + require(amplitude in 1..255 && duration in 1..1000) { "Invalid haptic settings" } + check(prefs.edit().putBoolean("key_haptic_enabled", enabled).putInt("key_haptic_amplitude", amplitude).putLong("key_haptic_duration_ms", duration).commit()) { "Settings save failed" } + } + Category.STROKE_FREQUENCY -> StrokeUserFrequency(app).importAll(value.toStringMap()) + Category.PINYIN_LEARNED_PHRASES -> { + require(root.optInt("learningPrivacyVersion", 0) >= ImeLearningPolicy.PRIVACY_VERSION) { "Legacy learned phrases cannot be safely restored" } + check(ImeLearningPolicy.migrate(app)) { "Learning data cleanup failed" } + LitePinyinLearnedPhrases(app).importAll(value.toStringMap()) + } + else -> Unit + } + applied.add(category) + }.onFailure { errors[category] = it.message ?: "Import failed" } + } + val providerCategories = requested intersect setOf(Category.PROVIDER_SELECTION, Category.CREDENTIALS) + if (providerCategories.isNotEmpty()) { + runCatching { + val response = JSONObject(OpenLessNative.nativeImportProviderSettings(json, + Category.PROVIDER_SELECTION in requested, Category.CREDENTIALS in requested)) + val successful = response.getJSONArray("applied") + for (i in 0 until successful.length()) Category.entries.find { it.key == successful.getString(i) }?.let { applied.add(it) } + val failed = response.getJSONObject("errors") + for (category in providerCategories) if (failed.has(category.key)) errors[category] = failed.getString(category.key) + }.onFailure { for (category in providerCategories) errors[category] = "Provider import failed" } + } + return ImportResult(applied, errors) + } + + private fun JSONObject.toStringMap(): Map { + val map = mutableMapOf() + val iterator = keys() + while (iterator.hasNext()) { + val key = iterator.next() + val value = get(key) + require(value is String) { "Invalid learning entry" } + map[key] = value + } + return map + } +} diff --git a/openless-all/app/android/kotlin/StrokeInputController.kt b/openless-all/app/android/kotlin/StrokeInputController.kt new file mode 100644 index 000000000..d5666f3b1 --- /dev/null +++ b/openless-all/app/android/kotlin/StrokeInputController.kt @@ -0,0 +1,665 @@ +package com.openless.app + +import android.graphics.Color +import android.graphics.drawable.GradientDrawable +import android.view.Gravity +import android.view.View +import android.view.ViewGroup +import android.widget.LinearLayout +import android.widget.TextView + +/** + * Everything specific to the 笔画 (stroke) input panel — encode entry, the + * 字候选/联想候选 query+render+commit pipeline, and the number/symbol sub-panel + * — split out of OpenLessImeService.kt, which otherwise hosted every panel's + * build/query/render/commit logic in one class; this alone was a large, + * mostly self-contained chunk of it (see the "OpenLessImeService.kt 拆分" + * item this was pulled out of). + * + * Shared machinery — candidateItemView()/styleCandidateFirstState() + * (English's own candidate row uses these too), showCandidateOverlay()/ + * candidateOverlayEntries, keyboardKey(), theme/layout helpers, and + * InputConnection access — stays on [service] and is called back into + * explicitly rather than duplicated here. This class owns exactly the + * stroke-specific state the two old inline reset blocks + * (selectInputMode()/onStartInput()) used to touch — see + * resetForModeSwitch()/resetForNewInputSession(), which preserve each call + * site's own exact field list rather than merging them into one generic + * reset. + */ +internal class StrokeInputController(private val service: OpenLessImeService) { + private val strokeRepository by lazy { StrokeInputRepository(service) } + // internal, not private: Pinyin mode's own post-commit association + // (LitePinyinController) reuses this exact instance rather than loading + // a second copy of the same ~220k-entry phrase index — association data + // is keyed by confirmed on-screen Chinese text, not by how it was + // typed, so it's equally valid for either input mode. + internal val phraseRepository by lazy { StrokePhraseRepository(service) } + + private var strokeCode = "" + private var strokeQueryEpoch = 0L + private val wordSegments = mutableListOf() + private var lastStrokeCandidates: List = emptyList() + private var confirmedText = "" + private var phraseQueryEpoch = 0L + private var strokePreview: TextView? = null + private var clearStrokeButton: TextView? = null + private var strokeCandidates: LinearLayout? = null + + // Read by OpenLessImeService's own panel dispatcher (onCreateInputView()) + // to choose between buildStrokeView()/buildStrokeNumberView() — the only + // piece of stroke state anything outside this class still touches. + var strokeNumberMode = false + private var numberSymbolMode = false + private var symbolPageIndex = 0 + private val numberPanelSymbolPages = listOf( + listOf("、", "。", ",", ";", ":", "?", "!", "…", "—", "~", "·", "("), + listOf(")", "《", "》", "“", "”", "‘", "’", "【", "】", "「", "」", "¥"), + listOf("%", "#", "&", "*", "=", "/", "\\", "<", ">", "^", "_", "|"), + ) + private var punctuationGroupIndex = 0 + private val punctuationGroups = listOf( + listOf(",", "°", "?", "!", "~"), + listOf(".", "、", ";", ":", "\""), + listOf("(", ")", "[", "]", "-"), + listOf("@", "#", "$", "%", "&"), + listOf("*", "+", "=", "/", "_"), + ) + + /** Warm the offline stroke index and phrase-association trie while the IME is idle — see OpenLessImeService.onCreate(). */ + fun preloadAsync() { + strokeRepository.preloadAsync() + phraseRepository.preloadAsync() + } + + fun shutdown() { + strokeRepository.shutdown() + phraseRepository.shutdown() + } + + /** Matches selectInputMode()'s own old inline reset exactly — mode-switch also resets the number/symbol sub-panel and the punctuation rail's page, which resetForNewInputSession() below does not. */ + fun resetForModeSwitch() { + strokeNumberMode = false + numberSymbolMode = false + symbolPageIndex = 0 + punctuationGroupIndex = 0 + strokeCode = "" + strokeQueryEpoch++ + confirmedText = "" + phraseQueryEpoch++ + } + + /** Matches onStartInput()'s own old inline reset exactly — a fresh input session also drops any in-progress word segments/candidates, but leaves the punctuation rail's page alone. */ + fun resetForNewInputSession() { + strokeNumberMode = false + numberSymbolMode = false + symbolPageIndex = 0 + confirmedText = "" + phraseQueryEpoch++ + strokeCode = "" + wordSegments.clear() + lastStrokeCandidates = emptyList() + updateClearStrokeButtonVisibility() + } + + fun buildStrokeView(): View { + // Matches the punctuation rail's own 0.16f width share below (body's + // "0.16f/0.65f/0.19f" split) so a downward drag anywhere on the rail + // is excluded from swipe-to-dismiss and left entirely to SwipeRail. + val root = OpenLessImeService.SwipeModeContainer(service, verticalDismissExclusionRatio = 0.16f) { direction -> service.swipeInputMode(direction) }.apply { + orientation = LinearLayout.VERTICAL + layoutParams = ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, service.keyboardPanelHeightPx()) + minimumHeight = service.keyboardPanelHeightPx() + setPadding(service.dp(4), service.dp(3), service.dp(4), service.dp(3)) + setBackgroundColor(service.tone(Color.rgb(48, 48, 48), Color.rgb(242, 242, 246))) + } + val header = LinearLayout(service).apply { gravity = Gravity.CENTER_VERTICAL } + header.addView(service.buildBrandView(), LinearLayout.LayoutParams(0, service.dp(38), 1f)) + header.addView(service.buildModeToggle(), LinearLayout.LayoutParams(service.dp(240), service.dp(38))) + // Stroke mode's root padding is much tighter (4dp/3dp) to fit its dense + // grid. Compensate with margins so the header/toggle still land at the + // same canonical 16dp/8dp inset as every other panel. + root.addView(header, LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, service.dp(38)).apply { + marginStart = service.dp(12) + marginEnd = service.dp(12) + topMargin = service.dp(5) + }) + + // Stroke mode follows the reference layout: a compact stroke row, + // candidate row, punctuation column, stroke grid, and action rail. + // Encode + candidate rows are fixed-height (24dp + 36dp = 60dp, same + // total as before this pass) and never resize with content — only + // the candidate list scrolls horizontally — so the stroke keys below + // never move. Both rows share one rounded background (an existing + // panel color, not a new one) so they read as a single continuous + // strip rather than two separate cards. + val top = LinearLayout(service).apply { + orientation = LinearLayout.VERTICAL + background = service.buildEncodeAreaBackground() + } + val strokeRow = LinearLayout(service).apply { gravity = Gravity.CENTER_VERTICAL } + strokePreview = TextView(service).apply { + text = "" + textSize = 16.5f + setTextColor(service.strokeEncodeAccentColor) + gravity = Gravity.CENTER_VERTICAL + setSingleLine(true) + setPadding(service.dp(10), 0, 0, 0) + } + strokeRow.addView(strokePreview, LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.MATCH_PARENT, 1f)) + // Clear-code button: a 40x30dp hit target with a small glyph, not a + // heavy independent button — tapping it is the same clearStrokes() + // already wired to the action rail's "清除" key. + clearStrokeButton = TextView(service).apply { + text = "✕" + textSize = 13f + gravity = Gravity.CENTER + setTextColor(service.tone(Color.rgb(150, 150, 150), Color.rgb(130, 130, 135))) + contentDescription = service.ui("清除笔画编码", "Clear stroke code") + setOnClickListener { clearStrokes() } + } + strokeRow.addView(clearStrokeButton, LinearLayout.LayoutParams(service.dp(40), ViewGroup.LayoutParams.MATCH_PARENT)) + updateClearStrokeButtonVisibility() + top.addView(strokeRow, LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, service.dp(24))) + + val candidateRow = LinearLayout(service).apply { gravity = Gravity.CENTER_VERTICAL } + // A plain setOnTouchListener on the ScrollView never actually fires + // here: each candidate is its own clickable keyboardKey() view, so + // it claims ACTION_DOWN before the ScrollView's own onTouchEvent + // ever runs. Overriding onInterceptTouchEvent instead runs at the + // right point in the dispatch chain — before any child gets a + // chance to claim the touch — so it reliably blocks + // SwipeModeContainer's mode-switch gesture from stealing a drag + // that starts on top of a candidate button. + val candidatesScroll = object : android.widget.HorizontalScrollView(service) { + override fun onInterceptTouchEvent(ev: android.view.MotionEvent): Boolean { + if (ev.actionMasked == android.view.MotionEvent.ACTION_DOWN) { + parent?.requestDisallowInterceptTouchEvent(true) + } + return super.onInterceptTouchEvent(ev) + } + }.apply { + isHorizontalScrollBarEnabled = false + isFillViewport = false + overScrollMode = View.OVER_SCROLL_NEVER + strokeCandidates = LinearLayout(context).apply { gravity = Gravity.CENTER_VERTICAL } + strokeCandidates?.orientation = LinearLayout.HORIZONTAL + addView(strokeCandidates, ViewGroup.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.MATCH_PARENT)) + } + candidateRow.addView(candidatesScroll, LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.MATCH_PARENT, 1f)) + // "Show more" — opens the full candidate/association list in a + // floating overlay instead of growing this row or the panel height. + val expandCandidatesButton = OpenLessImeService.StrokeActionView( + service, + "triangle-down", + iconColor = service.tone(Color.rgb(180, 180, 180), Color.rgb(130, 130, 135)), + ).apply { + contentDescription = service.ui("展开更多候选", "Show more candidates") + } + expandCandidatesButton.visibility = View.GONE + expandCandidatesButton.setOnClickListener { service.showCandidateOverlay(expandCandidatesButton) } + candidateRow.addView(expandCandidatesButton, LinearLayout.LayoutParams(service.dp(28), ViewGroup.LayoutParams.MATCH_PARENT)) + // Only shown once the candidates actually overflow the visible + // scroll width — otherwise it sat there whether or not there was + // anything more to show, which read as an odd stray control. + // renderCandidateRow()/refreshAssociations() both just repopulate + // strokeCandidates and let layout happen, so a global layout + // listener (fires after every layout pass, including the one + // triggered by add/removeAllViews) is what re-checks this instead + // of hooking every candidate-population call site individually. + candidatesScroll.viewTreeObserver.addOnGlobalLayoutListener { + val candidates = strokeCandidates + expandCandidatesButton.visibility = + if (candidates != null && candidates.width > candidatesScroll.width) View.VISIBLE else View.GONE + } + top.addView(candidateRow, LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, service.dp(36))) + root.addView(top, LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, service.dp(60))) + + val body = LinearLayout(service).apply { gravity = Gravity.CENTER } + // Swiping the rail up/down cycles through punctuationGroups instead of + // scrolling — one swipe always advances exactly one group. + val punctuation = OpenLessImeService.SwipeRail(service) { direction -> + val count = punctuationGroups.size + punctuationGroupIndex = ((punctuationGroupIndex + direction) % count + count) % count + service.refreshInputView() + }.apply { + orientation = LinearLayout.VERTICAL + gravity = Gravity.CENTER + // Zero vertical padding so the rail's own top/bottom edges land + // exactly on the grid/actions columns' top/bottom edges (all + // three share the same MATCH_PARENT body height) — horizontal + // padding is kept since it only insets key width, not row + // position. + setPadding(service.dp(2), 0, service.dp(2), 0) + background = service.roundedButton(service.tone(Color.rgb(45, 45, 45), Color.rgb(230, 230, 234)), service.dp(4)) + } + punctuationGroups[punctuationGroupIndex].forEachIndexed { index, mark -> + punctuation.addView(service.keyboardKey(mark, 1f, action = { service.currentInputConnection?.commitText(mark, 1) }).apply { + textSize = 18f + // The rail is one connected key surface; separators provide the only visual split. + background = GradientDrawable().apply { setColor(Color.TRANSPARENT) } + layoutParams = LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, 0, 1f) + }) + if (index < 4) { + punctuation.addView(View(service).apply { + setBackgroundColor(service.tone(Color.rgb(28, 28, 28), Color.rgb(205, 205, 210))) + }, LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, service.dp(1))) + } + } + // Match the reference proportions: both side rails occupy the same share of the panel. + body.addView(punctuation, LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.MATCH_PARENT, 0.16f)) + + val grid = LinearLayout(service).apply { orientation = LinearLayout.VERTICAL; gravity = Gravity.CENTER } + val strokeRows = listOf( + listOf("1\n一" to "h", "2\n丨" to "s", "3\n丿" to "p"), + listOf("4\n丶" to "n", "5\n乙" to "z", "6\n通配" to "*"), + listOf("7\n分词" to " ", "8\n:" to ":", "9\n;" to ";"), + listOf("繁" to "script", "🎙" to "voice", "符号" to "symbols"), + ) + strokeRows.forEach { rowItems -> + val row = LinearLayout(service).apply { gravity = Gravity.CENTER } + rowItems.forEach { (label, code) -> + val key = if (code == "script") service.keyboardKey(label, 1f, action = { + service.toggleScriptPreference() + }, graphicCode = "script").apply { + if (service.traditionalOutput) { + background = service.roundedButton(service.tone(Color.rgb(112, 78, 92), Color.rgb(232, 205, 213)), service.dp(5)) + } + } else if (code == "voice") service.keyboardKey("0", 1f, action = { + service.currentInputConnection?.commitText(" ", 1) + }, swipeUpAction = { + service.currentInputConnection?.commitText("0", 1) + }, swipePreview = "0", microphoneIcon = true, longPressDelayMs = 900L, longPressAction = { + service.inputMode = OpenLessImeService.InputMode.VOICE + service.saveInputMode(service.inputMode) + clearStrokes() + service.refreshInputView() + if (!service.recording) service.toggleDictation() + }) else { + val swipeDigit = label.substringBefore("\n").takeIf { it.length == 1 && it[0].isDigit() } + service.keyboardKey(label, 1f, action = { + when (code) { + "symbols" -> { + strokeNumberMode = true + numberSymbolMode = true + symbolPageIndex = 0 + service.refreshInputView() + } + " " -> segmentStroke() + else -> if (code in listOf("h", "s", "p", "n", "z", "*")) appendStroke(code) else service.currentInputConnection?.commitText(code, 1) + } + }, swipeUpAction = swipeDigit?.let { digit -> + { service.currentInputConnection?.commitText(digit, 1) } + }, swipePreview = swipeDigit, strokeIconCode = code.takeIf { + it in listOf("h", "s", "p", "n", "z") + }, graphicCode = code.takeIf { + it in listOf("*", ":", ";", " ", "symbols") + }) + } + key.textSize = if (code == "voice") 10f else 17f + // dp(1) on every side gives a uniform ~2dp gap both ways + // (keys stay clearly separated) and puts as much of the + // reclaimed margin as possible into visible key size, not + // new padding. The action rail's vertical margin is kept at + // the same dp(1)/dp(1) below so row top/bottom edges still + // land exactly together across both columns. + key.layoutParams = LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.MATCH_PARENT, 1f).apply { + setMargins(service.dp(1), service.dp(1), service.dp(1), service.dp(1)) + } + row.addView(key) + } + grid.addView(row, LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, 0, 1f)) + } + body.addView(grid, LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.MATCH_PARENT, 0.65f)) + + val actions = LinearLayout(service).apply { orientation = LinearLayout.VERTICAL; gravity = Gravity.CENTER } + listOf("←" to { deleteStroke() }, "↵" to { service.sendEnterKey() }, "清除" to { clearStrokes() }, "123" to { + strokeNumberMode = true + numberSymbolMode = false + symbolPageIndex = 0 + service.refreshInputView() + }).forEach { (label, action) -> + actions.addView(service.keyboardKey(label, 1f, action, repeatOnLongPress = label == "←", repeatAction = action, + graphicActionCode = label).apply { + textSize = if (label == "←" || label == "↵") 30f else 17f + // Vertical margin matches the grid keys' dp(1)/dp(1) exactly + // so every row's top/bottom edge lines up across both + // columns; horizontal margin is independent (single column). + layoutParams = LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, 0, 1f).apply { + setMargins(service.dp(1), service.dp(1), service.dp(1), service.dp(1)) + } + background = service.roundedButton(Color.rgb(153, 26, 40), service.dp(5)) + }) + } + body.addView(actions, LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.MATCH_PARENT, 0.19f)) + root.addView(body, LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, 0, 1f)) + // Rebuilds caused by switching back from the numeric panel must restore + // both the visible code and its candidates from the retained buffer. + if (strokeCode.isNotEmpty()) { + strokePreview?.text = service.displayStrokeCode(strokeCode) + refreshStrokeCandidates(strokeCode) + } + return root + } + + fun buildStrokeNumberView(): View { + service.refreshLanguage() + val root = OpenLessImeService.SwipeModeContainer(service) { direction -> service.swipeInputMode(direction) }.apply { + orientation = LinearLayout.VERTICAL + layoutParams = ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, service.keyboardPanelHeightPx()) + minimumHeight = service.keyboardPanelHeightPx() + setPadding(service.dp(8), service.dp(8), service.dp(8), service.dp(8)) + setBackgroundColor(service.tone(Color.rgb(48, 48, 48), Color.rgb(242, 242, 246))) + } + val header = LinearLayout(service).apply { gravity = Gravity.CENTER_VERTICAL } + header.addView(service.buildBrandView().apply { setPadding(service.dp(8), 0, 0, 0) }, LinearLayout.LayoutParams(0, service.dp(38), 1f)) + header.addView(service.buildModeToggle(), LinearLayout.LayoutParams(service.dp(240), service.dp(38))) + // This panel's own root padding (8dp) is narrower than the voice panel's + // (16dp). Compensate with margins so the header/toggle still land at the + // same canonical 16dp/8dp inset as every other panel. + root.addView(header, LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, service.dp(38)).apply { + marginStart = service.dp(8) + marginEnd = service.dp(8) + }) + + // Backspace/Enter/Voice/Return/Symbols stay in Chinese regardless of UI + // language: they're functional keys on the number/symbol panel, not + // content the user is composing. + val rows = if (numberSymbolMode) { + val page = numberPanelSymbolPages[symbolPageIndex.coerceIn(numberPanelSymbolPages.indices)] + listOf( + page.subList(0, 4) + "退格", + page.subList(4, 8) + "回车", + page.subList(8, 12) + "语音", + listOf("▲", "${symbolPageIndex + 1}/${numberPanelSymbolPages.size}", "▼", "数字", "返回"), + ) + } else { + listOf( + listOf("@", "1", "2", "3", "退格"), + listOf(":", "4", "5", "6", "回车"), + listOf(",", "7", "8", "9", "语音"), + // "0" sits directly under "8", flanked by +/. — the same + // layout convention as a phone dial pad's "* 0 #" row. + listOf("+", "0", ".", "符号", "返回"), + ) + } + val body = LinearLayout(service).apply { orientation = LinearLayout.VERTICAL } + rows.forEachIndexed { rowIndex, rowItems -> + val row = LinearLayout(service).apply { gravity = Gravity.CENTER } + rowItems.forEachIndexed { index, label -> + val isAction = index == rowItems.lastIndex + val action: () -> Unit = when { + rowIndex == 0 && isAction -> ({ service.deleteBackward() }) + rowIndex == 1 && isAction -> ({ service.sendEnterKey() }) + rowIndex == 2 && isAction -> ({ + service.inputMode = OpenLessImeService.InputMode.VOICE + service.saveInputMode(service.inputMode) + strokeNumberMode = false + numberSymbolMode = false + symbolPageIndex = 0 + service.refreshInputView() + }) + // Symbol-page navigation only replaces row 3's first three + // cells (previously "+ - .") while in symbol mode; the + // middle cell is just a page indicator, not clickable. + rowIndex == 3 && index == 0 && numberSymbolMode -> ({ + symbolPageIndex = (symbolPageIndex - 1 + numberPanelSymbolPages.size) % numberPanelSymbolPages.size + service.refreshInputView() + }) + rowIndex == 3 && index == 1 && numberSymbolMode -> ({}) + rowIndex == 3 && index == 2 && numberSymbolMode -> ({ + symbolPageIndex = (symbolPageIndex + 1) % numberPanelSymbolPages.size + service.refreshInputView() + }) + rowIndex == 3 && index == 3 -> ({ + numberSymbolMode = !numberSymbolMode + symbolPageIndex = 0 + service.refreshInputView() + }) + rowIndex == 3 && isAction -> ({ + strokeNumberMode = false + numberSymbolMode = false + symbolPageIndex = 0 + service.refreshInputView() + }) + else -> ({ service.currentInputConnection?.commitText(label, 1) }) + } + row.addView(service.keyboardKey(label, 1f, action, repeatOnLongPress = rowIndex == 0 && isAction, repeatAction = action).apply { + textSize = if (isAction) 15f else 20f + if (isAction) background = service.roundedButton(Color.rgb(153, 26, 40), service.dp(7)) + }) + } + body.addView(row, LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, 0, 1f)) + } + root.addView(body, LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, 0, 1f)) + return root + } + + private fun appendStroke(stroke: String) { + if (strokeCode.length >= 32) return + if (strokeCode.isEmpty()) { + phraseQueryEpoch++ + lastStrokeCandidates = emptyList() + renderCandidateRow(emptyList()) + } + strokeCode += stroke + updateStrokePreview() + refreshStrokeCandidates(strokeCode) + } + + /** Marks the current character as a word segment without committing it yet. */ + private fun segmentStroke() { + if (strokeCode.isEmpty()) return + val candidate = lastStrokeCandidates.firstOrNull() ?: return + wordSegments.add(candidate) + strokeCode = "" + strokeQueryEpoch++ + lastStrokeCandidates = emptyList() + updateStrokePreview() + renderCandidateRow(emptyList()) + } + + private fun updateStrokePreview() { + // Plain "+" concatenation on a CharSequence would call toString() and + // drop the ImageSpan the 5th stroke relies on — TextUtils.concat() + // preserves spans across both pieces. + strokePreview?.text = android.text.TextUtils.concat(wordSegments.joinToString(""), service.displayStrokeCode(strokeCode)) + updateClearStrokeButtonVisibility() + } + + /** Only shows the encode row's "✕" once there's actually something to clear — otherwise it just sat there doing nothing. */ + private fun updateClearStrokeButtonVisibility() { + clearStrokeButton?.visibility = if (strokeCode.isEmpty() && wordSegments.isEmpty()) View.GONE else View.VISIBLE + } + + private fun refreshStrokeCandidates(code: String) { + val query = ++strokeQueryEpoch + if (service.isSensitiveField(service.currentInputEditorInfo)) { + renderCandidateRow(emptyList()) + return + } + strokeRepository.searchAsync(code) { result -> + if (query != strokeQueryEpoch || service.inputMode != OpenLessImeService.InputMode.STROKE) return@searchAsync + lastStrokeCandidates = result + renderCandidateRow(result) + } + } + + /** + * Renders the stroke candidate row: a leading "commit the whole word" + * button for any segments marked via 分词 (if present), followed by the + * single-character candidates for the character currently being typed. + */ + private fun renderCandidateRow(strokeMatches: List) { + val specs = mutableListOf() + // The very first candidate shown — whichever one that is — is + // highlighted in the same red as the right-hand action rail, since + // it's what a bare space/enter would commit. + var firstCandidate = true + if (wordSegments.isNotEmpty()) { + val word = wordSegments.joinToString("") + val displayWord = service.outputScript(word) + // Matches candidateItemView()'s own dp(11)-per-side padding + // (2*11=22) plus ~22dp per glyph at its 20sp text size. + val wordWidth = service.dp((displayWord.codePointCount(0, displayWord.length) * 22 + 22).coerceAtLeast(50)) + specs.add(CandidateSpec(displayWord, firstCandidate, wordWidth) { commitWord(word) }) + firstCandidate = false + } + strokeMatches.forEach { candidate -> + val displayCandidate = service.outputScript(candidate) + // Was dp(38); briefly widened to dp(46) alongside + // candidateItemView()'s own now-reverted padding/text-size + // experiment (see that function's comment) — settled on dp(45). + specs.add(CandidateSpec(displayCandidate, firstCandidate, service.dp(45)) { commitStrokeCandidate(candidate) }) + firstCandidate = false + } + populateCandidateRow(specs) + service.candidateOverlayEntries = specs.map { it.label to it.action } + } + + /** One candidate slot's content, independent of whatever View (if any) ends up showing it — see populateCandidateRow(). */ + private data class CandidateSpec(val label: String, val isFirst: Boolean, val widthPx: Int, val action: () -> Unit) + + /** + * Repopulates strokeCandidates with [specs] by reusing existing child + * views in place — retexting/rewidthing/rebinding the click target of + * whatever's already sitting at each index — instead of this row's old + * removeAllViews()-then-addView()-every-candidate approach. Rebuilding + * up to MAX_CANDIDATES real keyboardKey()-backed Views (a background + * drawable allocated then immediately discarded, plus a full touch- + * listener closure with long-press/swipe-retarget plumbing this row + * never uses) on literally every keystroke is the actual source of + * candidate-row lag on a slow device, not the async dictionary lookup + * feeding it — this call site is on the hot path (every stroke and + * every phrase-association refresh), so only the surplus or shortfall + * between the previous and new candidate count now creates or removes + * a View at all. + */ + private fun populateCandidateRow(specs: List) { + val row = strokeCandidates ?: return + specs.forEachIndexed { index, spec -> + when (val existing = row.getChildAt(index)) { + is TextView -> { + existing.text = spec.label + existing.setOnClickListener { spec.action() } + service.styleCandidateFirstState(existing, spec.isFirst) + val params = existing.layoutParams as LinearLayout.LayoutParams + if (params.width != spec.widthPx) { + params.width = spec.widthPx + existing.layoutParams = params + } + } + else -> row.addView( + service.candidateItemView(spec.label, spec.isFirst, action = spec.action), + LinearLayout.LayoutParams(spec.widthPx, ViewGroup.LayoutParams.MATCH_PARENT), + ) + } + } + while (row.childCount > specs.size) { + row.removeViewAt(row.childCount - 1) + } + } + + /** Commits the current character together with any segments already marked via 分词. */ + private fun commitStrokeCandidate(candidate: String) { + val code = strokeCode + val correction = candidate != lastStrokeCandidates.firstOrNull() + if (commitWord((wordSegments + candidate).joinToString("")) && correction && + service.personalizedLearningAllowed() && OpenLessAndroidPreferences.strokeUsageEnabled(service) && code.isNotEmpty()) { + strokeRepository.recordPersonalPick(code, candidate) + } + } + + private fun commitWord(word: String): Boolean { + if (word.isEmpty() || service.isSensitiveField(service.currentInputEditorInfo)) return false + val connection = service.currentInputConnection ?: return false + val contextBeforeCommit = confirmedText.takeLast(MAX_ASSOCIATION_CONTEXT) + if (!connection.commitText(service.outputScript(word), 1)) return false + if (service.personalizedLearningAllowed() && OpenLessAndroidPreferences.strokeUsageEnabled(service)) { + phraseRepository.recordUsage(contextBeforeCommit, word) + } + if (service.personalizedLearningAllowed()) confirmedText = (confirmedText + word).takeLast(MAX_ASSOCIATION_CONTEXT) + clearStrokes() + refreshAssociations() + return true + } + + private fun refreshAssociations() { + if (!service.personalizedLearningAllowed() || !OpenLessAndroidPreferences.strokeAssociationEnabled(service)) { + populateCandidateRow(emptyList()) + service.candidateOverlayEntries = emptyList() + return + } + val context = confirmedText.takeLast(MAX_ASSOCIATION_CONTEXT) + val query = ++phraseQueryEpoch + if (context.isEmpty()) return + phraseRepository.searchAsync(context) { result -> + if (query != phraseQueryEpoch || service.inputMode != OpenLessImeService.InputMode.STROKE || confirmedText.takeLast(MAX_ASSOCIATION_CONTEXT) != context) return@searchAsync + val specs = result.mapIndexed { index, candidate -> + val matchedPrefix = candidate.matchedPrefix.ifEmpty { context } + val displayText = service.outputScript(candidate.text) + val candidateWidth = service.dp((displayText.codePointCount(0, displayText.length) * 22 + 16).coerceAtLeast(46)) + CandidateSpec(displayText, index == 0, candidateWidth) { commitAssociation(candidate.text, matchedPrefix) } + } + populateCandidateRow(specs) + service.candidateOverlayEntries = specs.map { it.label to it.action } + } + } + + private fun commitAssociation(displayText: String, matchedContext: String) { + if (service.isSensitiveField(service.currentInputEditorInfo) || !displayText.startsWith(matchedContext)) return + val suffix = displayText.removePrefix(matchedContext) + val connection = service.currentInputConnection ?: return + if (suffix.isNotEmpty() && !connection.commitText(service.outputScript(suffix), 1)) return + if (service.personalizedLearningAllowed() && OpenLessAndroidPreferences.strokeUsageEnabled(service)) { + phraseRepository.recordUsage(matchedContext, displayText) + } + confirmedText = (confirmedText + suffix).takeLast(MAX_ASSOCIATION_CONTEXT) + clearStrokes() + refreshAssociations() + } + + private fun deleteStroke() { + if (strokeCode.isNotEmpty()) { + strokeCode = strokeCode.dropLast(1) + strokeQueryEpoch++ + updateStrokePreview() + if (strokeCode.isNotEmpty()) { + appendStroke("") + } else { + lastStrokeCandidates = emptyList() + renderCandidateRow(emptyList()) + } + } else if (wordSegments.isNotEmpty()) { + wordSegments.removeAt(wordSegments.lastIndex) + updateStrokePreview() + renderCandidateRow(emptyList()) + } else { + service.deleteBackward() + if (confirmedText.isNotEmpty()) { + confirmedText = confirmedText.dropLast(1) + phraseQueryEpoch++ + refreshAssociations() + } else { + strokeCandidates?.removeAllViews() + } + } + } + + private fun clearStrokes() { + strokeCode = "" + strokeQueryEpoch++ + wordSegments.clear() + lastStrokeCandidates = emptyList() + strokePreview?.text = "" + strokeCandidates?.removeAllViews() + updateClearStrokeButtonVisibility() + } + + // internal, not private: LitePinyinController's own association context + // buffer reuses the same cap for consistency (see phraseRepository's own + // doc comment on why the two share one StrokePhraseRepository instance). + internal companion object { + const val MAX_ASSOCIATION_CONTEXT = 8 + } +} diff --git a/openless-all/app/android/kotlin/StrokePhraseRepository.kt b/openless-all/app/android/kotlin/StrokePhraseRepository.kt index 7e2911664..7a90611f9 100644 --- a/openless-all/app/android/kotlin/StrokePhraseRepository.kt +++ b/openless-all/app/android/kotlin/StrokePhraseRepository.kt @@ -25,15 +25,42 @@ internal class StrokePhraseRepository(context: Context) { } private var loaded = false + /** + * Warm the trie while the IME is idle, same reasoning as + * StrokeInputRepository.preloadAsync() — this dictionary is ~220k + * phrases (vs. stroke.dict.tsv's much smaller character list), and + * every insert() does a linear dedupe-check + a re-sort of up to + * NODE_TOP_N candidates at every one of a phrase's up to 8 node + * levels, so building it cold has a real, measurable cost (see the + * "phrase index ready" log below). Left uncalled until now, so this + * cost used to land on whichever word's commit happened to be the + * first one to ever need an association in this repository's + * lifetime, instead of on IME startup where the user isn't yet + * waiting on a result. + */ + fun preloadAsync() { + executor.execute { ensureLoaded() } + } + fun searchAsync(prefix: String, callback: (List) -> Unit) { if (prefix.isEmpty()) return callback(emptyList()) executor.execute { + val startedAt = android.os.SystemClock.elapsedRealtime() ensureLoaded() - val result = (synchronized(cache) { cache[prefix] } ?: findLongestSuffix(prefix).also { - synchronized(cache) { cache[prefix] = it } - }).sortedWith(compareByDescending { + val result = findLongestSuffix(prefix).sortedWith(compareByDescending { it.baseWeight + userFrequency.score(it.matchedPrefix.ifEmpty { prefix }, it.text).toInt() }) + // Temporary-diagnostic-grade, not removed after: cheap (one + // log line per commit) and directly answers "is this slow + // because of the cold trie build, the query itself, or is it + // not finding anything at all" without guessing — see + // openless-all/app/android README for how to read it via + // logcat while reproducing a report of slow/missing + // associations. + android.util.Log.d( + "OpenLessPhrase", + "query prefix=\"$prefix\" results=${result.size} elapsed=${android.os.SystemClock.elapsedRealtime() - startedAt}ms", + ) Handler(Looper.getMainLooper()).post { callback(result) } } } @@ -49,36 +76,79 @@ internal class StrokePhraseRepository(context: Context) { if (loaded) return synchronized(this) { if (loaded) return + val startedAt = android.os.SystemClock.elapsedRealtime() + var entryCount = 0 runCatching { appContext.assets.open("phrases.dict.tsv").bufferedReader().useLines { lines -> lines.forEach { line -> val parts = line.split('\t', limit = 2) val phrase = parts.getOrNull(0) ?: return@forEach val weight = parts.getOrNull(1)?.toIntOrNull() ?: 0 - if (phrase.length in 2..8 && weight > 0) insert(Candidate(phrase, weight, "")) + if (phrase.length in 2..8 && weight > 0) { + insert(Candidate(phrase, weight, "")) + entryCount++ + } } } } + // Every node touched during insert() only appended (see below); + // this single pass is where dedupe + sort + trim-to-top-N + // actually happens now, once per node regardless of how many + // times that node was touched while loading — insert() used to + // do a full re-sort of the node's list on every single touch, + // ~220k times across up to 8 node levels each. + finalizeNode(root) loaded = true + android.util.Log.i( + "OpenLessPhrase", + "phrase index ready entries=$entryCount elapsed=${android.os.SystemClock.elapsedRealtime() - startedAt}ms", + ) } } + /** Appends only — no dedupe/sort/trim here, see finalizeNode(). */ private fun insert(candidate: Candidate) { var node = root candidate.text.forEach { character -> node = node.children.getOrPut(character) { Node() } - if (node.top.none { it.text == candidate.text }) { - node.top += candidate - node.top.sortByDescending { it.baseWeight } - if (node.top.size > NODE_TOP_N) node.top.removeAt(node.top.lastIndex) - } + node.top += candidate + } + } + + /** One-time post-load pass: dedupe (keep first-seen, matching insert()'s old none{} check), sort, trim to NODE_TOP_N, then recurse. Trie depth is capped at 8 (phrase length), so recursion depth is never a concern. */ + private fun finalizeNode(node: Node) { + if (node.top.size > 1) { + val finalized = node.top.distinctBy { it.text }.sortedByDescending { it.baseWeight }.take(NODE_TOP_N) + node.top.clear() + node.top.addAll(finalized) } + node.children.values.forEach { finalizeNode(it) } } + /** + * Memoized per suffix length actually tried, not per full (rolling) + * caller-supplied prefix — findLongestSuffix() below tries decreasing + * suffix lengths of `prefix` until one matches, and during continuous + * typing the full rolling prefix is different on almost every call + * while its trailing few characters (the substrings actually looked up + * here) recur constantly, so caching at this level is what actually + * gets hit. Behavior is unchanged either way — this only memoizes each + * find() call findLongestSuffix() would have made anyway, in the same + * longest-first order. + */ private fun find(prefix: String): List { + synchronized(cache) { cache[prefix] }?.let { return it } var node = root - prefix.forEach { character -> node = node.children[character] ?: return emptyList() } - return node.top.map { it.copy(matchedPrefix = prefix) } + for (character in prefix) { + node = node.children[character] ?: run { + val empty = emptyList() + synchronized(cache) { cache[prefix] = empty } + return empty + } + } + val result = node.top.map { it.copy(matchedPrefix = prefix) } + synchronized(cache) { cache[prefix] = result } + return result } private fun findLongestSuffix(prefix: String): List { @@ -91,7 +161,11 @@ internal class StrokePhraseRepository(context: Context) { private companion object { const val NODE_TOP_N = 12 - const val CACHE_SIZE = 64 + // Bumped from 64 now that the cache is keyed by short trailing + // substrings (bounded cardinality — common 1-3 character endings) + // instead of the full rolling context, so it holds far more useful + // distinct entries for the same memory budget. + const val CACHE_SIZE = 256 } private val userFrequency = StrokeUserFrequency(context) diff --git a/openless-all/app/android/kotlin/StrokeUserFrequency.kt b/openless-all/app/android/kotlin/StrokeUserFrequency.kt index 6b9c52518..ee6d1d7a9 100644 --- a/openless-all/app/android/kotlin/StrokeUserFrequency.kt +++ b/openless-all/app/android/kotlin/StrokeUserFrequency.kt @@ -12,6 +12,12 @@ import kotlin.math.ln internal class StrokeUserFrequency(context: Context) { private val preferences = context.applicationContext.getSharedPreferences(STORE, Context.MODE_PRIVATE) + // Tracks entry count without paying preferences.all's full-map-copy cost + // on every record() — see trimIfNeeded(). -1 means "not yet known", + // resolved (once) the first time it's actually needed. + @Volatile + private var approxSize = -1 + fun score(context: String, candidate: String, now: Long = System.currentTimeMillis()): Double { val value = preferences.getString(key(context, candidate), null) ?: return 0.0 val parts = value.split(',') @@ -24,24 +30,57 @@ internal class StrokeUserFrequency(context: Context) { fun record(context: String, candidate: String) { if (candidate.isBlank()) return val entryKey = key(context, candidate) + val isNewEntry = !preferences.contains(entryKey) val old = preferences.getString(entryKey, null)?.split(',') val count = (old?.getOrNull(0)?.toIntOrNull() ?: 0) + 1 val now = System.currentTimeMillis() preferences.edit().putString(entryKey, "$count,$now").apply() - trimIfNeeded() + // Re-recording an existing entry (by far the common case — the same + // handful of characters/phrases typed over and over) never touches + // preferences.all at all now. Only a genuinely new key can push the + // count past MAX_ENTRIES, so that's the only case worth checking. + if (isNewEntry) { + val size = resolvedSize() + 1 + approxSize = size + if (size > MAX_ENTRIES) trimIfNeeded() + } } - fun clear() = preferences.edit().clear().apply() + fun clear() { + preferences.edit().clear().apply() + approxSize = 0 + } /** Current entry count, for showing usage against [MAX_ENTRIES] in settings. */ - fun size(): Int = preferences.all.size + fun size(): Int = resolvedSize() fun capacity(): Int = MAX_ENTRIES + /** Every stored entry as a plain "contextcandidate"->"count,lastUsedMs" map, for the settings page's export/import feature — see OpenLessSettingsExport. */ + fun exportAll(): Map { + @Suppress("UNCHECKED_CAST") + return preferences.all as Map + } + + /** Replaces the entire store with [entries] — an import wholesale-restores a backup rather than merging with whatever's already on this device. */ + fun importAll(entries: Map) { + check(preferences.edit().clear().apply { entries.forEach { (key, value) -> putString(key, value) } }.commit()) { "Stroke learning save failed" } + approxSize = entries.size + } + private fun key(context: String, candidate: String) = "$context$SEPARATOR$candidate" + private fun resolvedSize(): Int { + val cached = approxSize + if (cached >= 0) return cached + val size = preferences.all.size + approxSize = size + return size + } + private fun trimIfNeeded() { val all = preferences.all + approxSize = all.size if (all.size <= MAX_ENTRIES) return val removeCount = all.size - MAX_ENTRIES val oldest = all.entries @@ -50,6 +89,7 @@ internal class StrokeUserFrequency(context: Context) { preferences.edit().apply { oldest.forEach { remove(it.key) } }.apply() + approxSize = all.size - oldest.size } private companion object { diff --git a/openless-all/app/android/kotlin/androidTest/ImeLearningMigrationTest.kt b/openless-all/app/android/kotlin/androidTest/ImeLearningMigrationTest.kt new file mode 100644 index 000000000..f0223d974 --- /dev/null +++ b/openless-all/app/android/kotlin/androidTest/ImeLearningMigrationTest.kt @@ -0,0 +1,45 @@ +package com.openless.app + +import android.content.Context +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import org.junit.Assert.* +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class ImeLearningMigrationTest { + @Test fun clearsOnlyLegacyAutomaticLearningAndRunsOnce() { + val context = InstrumentationRegistry.getInstrumentation().targetContext + val stores = listOf("openless_english_frequency", "openless_english_custom_words", "openless_pinyin_frequency", "openless_pinyin_learned_phrases") + val marker = context.getSharedPreferences("openless_learning_privacy", Context.MODE_PRIVATE) + assertTrue(marker.edit().clear().commit()) + val unrelated = context.getSharedPreferences("openless_clipboard_history", Context.MODE_PRIVATE) + assertTrue(unrelated.edit().putString("keep", "fixture").commit()) + for (name in stores) assertTrue(context.getSharedPreferences(name, Context.MODE_PRIVATE).edit().putString("legacy", "secret").commit()) + assertTrue(ImeLearningPolicy.migrate(context)) + for (name in stores) assertTrue(context.getSharedPreferences(name, Context.MODE_PRIVATE).all.isEmpty()) + assertEquals("fixture", unrelated.getString("keep", null)) + val learned = context.getSharedPreferences(stores.first(), Context.MODE_PRIVATE) + assertTrue(learned.edit().putString("new", "ordinary").commit()) + assertTrue(ImeLearningPolicy.migrate(context)) + assertEquals("ordinary", learned.getString("new", null)) + } + @Test fun invalidAndLegacyCategoriesDoNotOverwriteSettings() { + val context = InstrumentationRegistry.getInstrumentation().targetContext + val prefs = context.getSharedPreferences("openless_ime_ui", Context.MODE_PRIVATE) + assertTrue(prefs.edit().putInt("key_haptic_amplitude", 80).commit()) + val result = OpenLessSettingsExport.import(context, + """{"openlessSettingsExportVersion":2,"haptic":{"enabled":true,"amplitude":999,"durationMs":12}}""", + setOf(OpenLessSettingsExport.Category.HAPTIC)) + assertTrue(result.applied.isEmpty()) + assertTrue(result.errors.containsKey(OpenLessSettingsExport.Category.HAPTIC)) + assertEquals(80, prefs.getInt("key_haptic_amplitude", 0)) + val legacy = OpenLessSettingsExport.import(context, + """{"openlessSettingsExportVersion":1,"pinyinLearnedPhrases":{"mm":"legacy,3"}}""", + setOf(OpenLessSettingsExport.Category.PINYIN_LEARNED_PHRASES)) + assertTrue(legacy.applied.isEmpty()) + assertTrue(legacy.errors.isNotEmpty()) + } + +} diff --git a/openless-all/app/android/kotlin/test/ImeLearningPolicyTest.kt b/openless-all/app/android/kotlin/test/ImeLearningPolicyTest.kt new file mode 100644 index 000000000..c3fbe7308 --- /dev/null +++ b/openless-all/app/android/kotlin/test/ImeLearningPolicyTest.kt @@ -0,0 +1,14 @@ +package com.openless.app +import org.junit.Assert.* +import org.junit.Test +class ImeLearningPolicyTest { + @Test fun passwordsAndPrivateEditorsNeverLearn() { + for (input in listOf(0x81, 0xe1, 0x91, 0x12)) { + assertTrue(ImeLearningPolicy.isPassword(input)) + assertFalse(ImeLearningPolicy.allowsLearning(input, 0)) + } + assertTrue(ImeLearningPolicy.allowsLearning(1, 0)) + assertFalse(ImeLearningPolicy.allowsLearning(1, 0x1000000)) + assertFalse(ImeLearningPolicy.allowsLearning(0, 0)) + } +} diff --git a/openless-all/app/crates/openless-core/src/api.rs b/openless-all/app/crates/openless-core/src/api.rs index 4d41ffc8c..25cfeb478 100644 --- a/openless-all/app/crates/openless-core/src/api.rs +++ b/openless-all/app/crates/openless-core/src/api.rs @@ -1,3 +1,4 @@ +mod cloud_note; use std::collections::HashMap; use std::sync::atomic::{AtomicU64, AtomicU8, Ordering}; use std::sync::{Arc, Mutex, RwLock}; @@ -1609,6 +1610,7 @@ struct MutableState { /// are still being captured. The accepted request is applied before finish /// by every stop entry; no Host latch survives into the next session. dictation_translation_requested: Option, + dictation_destination_changed: bool, credentials: CredentialsStatus, transcripts: HashMap, silence_monitor: Option, @@ -2657,6 +2659,7 @@ impl OpenLessBackend { dictation_context: None, dictation_start_output_target: None, dictation_translation_requested: None, + dictation_destination_changed: false, credentials: CredentialsStatus::default(), transcripts: HashMap::new(), silence_monitor: None, @@ -3514,6 +3517,9 @@ impl OpenLessBackend { // Native status warms only through the already-bound sync gate. A // pending journal therefore uses the Host's read-only loader before // startup recovery/auto-sync tries to capture credentials. + if !self.snapshot().running { + self.recover_cloud_notes()?; + } let before = self.get_preferences(); let warmed = self.deps.credential_store.status(before).await; if let Some(sync) = &self.encrypted_sync { @@ -4292,7 +4298,7 @@ impl OpenLessBackend { Ok(result) } - async fn refresh_and_publish_credentials(&self) -> Result { + pub async fn refresh_and_publish_credentials(&self) -> Result { let status = self .deps .credential_store @@ -5288,6 +5294,7 @@ impl OpenLessBackend { }; state.dictation_start_output_target = Some(output_target); state.dictation_translation_requested = None; + state.dictation_destination_changed = false; self.events.publish( Some(session_id), BackendEventKind::DictationStateChanged(state.dictation.clone()), @@ -5320,6 +5327,12 @@ impl OpenLessBackend { resources: starting_resources, inserter, } = reservation; + if options.output_target == DictationOutputTarget::CloudNote { + if let Err(error) = self.set_dictation_cloud_note(session_id, true) { + self.reset_dictation_session(session_id); + return Err(error); + } + } let context = match self .capture_dictation_context(&options, DictationContextPurpose::Dictation) .await @@ -5361,6 +5374,10 @@ impl OpenLessBackend { ), } }); + let context = match state.dictation_start_output_target { + Some(target) => Arc::new(context.with_output_target(target)), + None => context, + }; state.dictation.translation_active = context.polish.translation_active; state.dictation_context = Some(Arc::clone(&context)); state.dictation_start_output_target = None; @@ -5526,6 +5543,7 @@ impl OpenLessBackend { if !started { let _ = self.cancel_session_adapters(session_id).await; self.reconcile_aborted_recording_draft(session_id, &context); + self.cleanup_cloud_note(session_id, true)?; return Err(BackendError::new( BackendErrorCode::Cancelled, "dictation session was cancelled while the engine was starting", @@ -5572,7 +5590,7 @@ impl OpenLessBackend { .await } - async fn stop_dictation_session_with_options( + pub async fn stop_dictation_session_with_options( &self, mut expected_session_id: Option, options: DictationStopOptions, @@ -5612,11 +5630,15 @@ impl OpenLessBackend { })?; let target = output_target.unwrap_or_else(|| match options.quick_note { Some(true) => DictationOutputTarget::QuickNote, - Some(false) => DictationOutputTarget::ForegroundApp, + Some(false) + if captured.output_target != DictationOutputTarget::CloudNote => + { + DictationOutputTarget::ForegroundApp + } None if captured.output_target == DictationOutputTarget::Undecided => { DictationOutputTarget::ForegroundApp } - None => captured.output_target, + None | Some(false) => captured.output_target, }); let targeted = captured.with_output_target(target); let context = match options.translation_requested { @@ -5625,11 +5647,19 @@ impl OpenLessBackend { } None => Arc::new(targeted), }; + let context = match options.raw_requested { + Some(true) => Arc::new(context.with_raw_requested(true)), + _ => context, + }; let context_changed = - state.dictation_translation_requested.take().is_some() + std::mem::take(&mut state.dictation_destination_changed) + || state.dictation_translation_requested.take().is_some() || state.dictation_context.as_ref().is_some_and(|previous| { previous.polish.translation_active != context.polish.translation_active + || previous.polish.mode != context.polish.mode + || previous.polish.style_system_prompt + != context.polish.style_system_prompt || previous.output_target != context.output_target }); state.dictation_context = Some(Arc::clone(&context)); @@ -5761,6 +5791,17 @@ impl OpenLessBackend { } }; + if context.output_target == DictationOutputTarget::CloudNote && engine_result.polish_failed + { + let error = + BackendError::new(BackendErrorCode::Provider, "cloud note polishing failed"); + self.mark_dictation_failed(session_id, &error); + let _ = self.hide_dictation_feedback(session_id); + self.reset_dictation_session(session_id); + self.cleanup_cloud_note(session_id, true)?; + return Err(error); + } + if engine_result.raw_text.trim().is_empty() { let error = BackendError::new( BackendErrorCode::Provider, @@ -5941,6 +5982,7 @@ impl OpenLessBackend { // transcript, silence detector or physical-hotkey generation. self.reset_dictation_session(session_id); self.persist_completed_dictation(&context, &result, insert_outcome, &engine_result); + self.cleanup_cloud_note(session_id, true)?; host_result?; Ok(result) } @@ -5954,6 +5996,11 @@ impl OpenLessBackend { } fn persist_recording_started(&self, context: &DictationContext, session_id: SessionId) { + if context.output_target == DictationOutputTarget::CloudNote + || self.is_cloud_note(session_id) + { + return; + } let preferences = self.get_preferences(); let front_app = crate::shared_types::split_front_app_opt(context.polish.front_app.as_deref()); @@ -6066,7 +6113,10 @@ impl OpenLessBackend { insert_outcome: Option, engine_result: &crate::ports::EngineResult, ) { - if context.output_target == DictationOutputTarget::Qa { + if context.output_target == DictationOutputTarget::Qa + || context.output_target == DictationOutputTarget::CloudNote + || self.is_cloud_note(result.session_id) + { self.remove_recording_draft(result.session_id); return; } @@ -6160,6 +6210,11 @@ impl OpenLessBackend { asr_call_label: Option, llm_call_label: Option, ) { + if context.output_target == DictationOutputTarget::CloudNote + || self.is_cloud_note(session_id) + { + return; + } let preferences = self.get_preferences(); let front_app = crate::shared_types::split_front_app_opt(context.polish.front_app.as_deref()); @@ -6253,6 +6308,9 @@ impl OpenLessBackend { hotkey.terminal(std::time::Instant::now()); self.phase_changed.notify_waiters(); drop(state); + if let Err(error) = self.cleanup_cloud_note(session_id, false) { + log::warn!("{error}"); + } self.voice_sessions.release(session_id); } @@ -6449,7 +6507,8 @@ impl OpenLessBackend { let first_error = cancel_result .err() .or_else(|| host_result.err()) - .or_else(|| history_result.err()); + .or_else(|| history_result.err()) + .or_else(|| self.cleanup_cloud_note(active, false).err()); match first_error { Some(error) => Err(error), None => Ok(()), @@ -10838,6 +10897,135 @@ mod tests { let _ = std::fs::remove_dir_all(data_dir); } + #[tokio::test] + async fn cloud_note_destination_can_be_revoked_and_cleanup_failure_is_visible() { + let data_dir = std::env::temp_dir().join(format!( + "openless-cloud-destination-{}", + uuid::Uuid::new_v4() + )); + let engine = Arc::new(crate::testing::FixtureDictationEngine::successful( + "raw", "polished", + )); + let backend = backend_with_dictation_engine(data_dir.clone(), engine.clone()); + backend.start().await.unwrap(); + let id = backend.start_dictation().await.unwrap(); + backend.set_dictation_cloud_note(id, true).unwrap(); + backend.set_dictation_cloud_note(id, false).unwrap(); + backend.stop_dictation_session(id).await.unwrap(); + assert_eq!(backend.list_history().unwrap().len(), 1); + assert_eq!( + engine.contexts().last().unwrap().output_target, + DictationOutputTarget::ForegroundApp + ); + let cloud = backend + .start_dictation_with_options(DictationStartOptions { + output_target: DictationOutputTarget::CloudNote, + ..Default::default() + }) + .await + .unwrap(); + let blocked_path = data_dir.join("recordings").join(format!("{cloud}.wav")); + std::fs::create_dir_all(&blocked_path).unwrap(); + assert_eq!( + backend + .stop_dictation_session(cloud) + .await + .unwrap_err() + .code, + BackendErrorCode::Persistence + ); + assert_eq!(backend.list_history().unwrap().len(), 1); + assert!(data_dir + .join("cloud-note-cleanup") + .join(cloud.to_string()) + .exists()); + std::fs::remove_dir(&blocked_path).unwrap(); + backend.shutdown().await.unwrap(); + backend.start().await.unwrap(); + assert!(!data_dir + .join("cloud-note-cleanup") + .join(cloud.to_string()) + .exists()); + backend.shutdown().await.unwrap(); + std::fs::remove_dir_all(data_dir).unwrap(); + } + + #[tokio::test] + async fn cloud_notes_never_retain_history_or_audio_on_success_failure_or_cancel() { + for outcome in ["success", "failure", "polish_failure", "empty", "cancel"] { + let data_dir = + std::env::temp_dir().join(format!("openless-cloud-note-{}", uuid::Uuid::new_v4())); + let transcription = if outcome == "failure" { + crate::testing::FixtureTranscriptionEngine::failing(BackendError::new( + BackendErrorCode::Provider, + "fixture", + )) + } else { + crate::testing::FixtureTranscriptionEngine::successful( + if outcome == "empty" { + "" + } else { + "private text" + }, + 1000, + ) + }; + let engine = crate::PipelineDictationEngine::new( + Arc::new(crate::ExternalAudioRecorder::with_recordings_directory( + data_dir.join("recordings"), + )), + Arc::new(transcription), + Arc::new(if outcome == "polish_failure" { + crate::testing::FixtureTextPolisher::failing(BackendError::new( + BackendErrorCode::Provider, + "fixture polish", + )) + } else { + crate::testing::FixtureTextPolisher::successful("polished private text") + }), + ); + let backend = backend_with_dictation_engine(data_dir.clone(), Arc::new(engine)); + backend.start().await.unwrap(); + let mut prefs = backend.get_preferences(); + prefs.record_audio_for_debug = true; + backend.set_preferences(prefs).unwrap(); + let id = backend.start_external_dictation().await.unwrap(); + backend.feed_external_pcm(id, &vec![1; 32000]).unwrap(); + backend.set_dictation_cloud_note(id, true).unwrap(); + if outcome == "cancel" { + backend.cancel_dictation(Some(id)).await.unwrap(); + } else { + let result = backend.stop_dictation_session(id).await; + assert_eq!(result.is_ok(), outcome == "success"); + if let Ok(result) = result { + assert_eq!(result.polished_text, "polished private text"); + } + } + assert!(backend.list_history().unwrap().is_empty(), "{outcome}"); + assert!( + !data_dir + .join("recordings") + .join(format!("{id}.wav")) + .exists(), + "{outcome}" + ); + assert!(backend.set_dictation_cloud_note(id, true).is_err()); + backend.shutdown().await.unwrap(); + // Crash recovery may only remove recordings with an explicit cleanup marker. + std::fs::create_dir_all(data_dir.join("cloud-note-cleanup")).unwrap(); + std::fs::write(data_dir.join("cloud-note-cleanup").join(id.to_string()), "").unwrap(); + let private_wav = data_dir.join("recordings").join(format!("{id}.wav")); + std::fs::write(&private_wav, "private").unwrap(); + let retained = data_dir.join("recordings/ordinary.wav"); + std::fs::write(&retained, "ordinary").unwrap(); + backend.start().await.unwrap(); + assert!(!private_wav.exists()); + assert!(retained.exists()); + backend.shutdown().await.unwrap(); + std::fs::remove_dir_all(data_dir).unwrap(); + } + } + #[tokio::test] async fn external_audio_saves_failed_recordings_for_history_retry_and_prunes_successful_audio() { diff --git a/openless-all/app/crates/openless-core/src/api/cloud_note.rs b/openless-all/app/crates/openless-core/src/api/cloud_note.rs new file mode 100644 index 000000000..01ae7989d --- /dev/null +++ b/openless-all/app/crates/openless-core/src/api/cloud_note.rs @@ -0,0 +1,108 @@ +use super::*; + +fn io_error(error: std::io::Error) -> BackendError { + BackendError::new( + BackendErrorCode::Persistence, + format!("cloud note cleanup: {error}"), + ) +} +fn remove_if_present(path: &std::path::Path) -> Result<(), BackendError> { + match std::fs::remove_file(path) { + Ok(()) => Ok(()), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(error) => Err(io_error(error)), + } +} +impl OpenLessBackend { + fn cloud_note_marker(&self, id: SessionId) -> std::path::PathBuf { + self.config + .data_dir + .join("cloud-note-cleanup") + .join(id.to_string()) + } + pub(super) fn is_cloud_note(&self, id: SessionId) -> bool { + self.cloud_note_marker(id).is_file() + } + + /// Select the destination before finalization; stale UI gestures cannot retarget a new session. + pub fn set_dictation_cloud_note( + &self, + id: SessionId, + enabled: bool, + ) -> Result<(), BackendError> { + let mut state = self.state.write().expect("backend state lock poisoned"); + ensure_active_session(&state, id)?; + if !matches!( + state.dictation.phase, + DictationPhase::Starting | DictationPhase::Recording + ) { + return Err(BackendError::new( + BackendErrorCode::Busy, + "dictation destination is already frozen", + )); + } + let marker = self.cloud_note_marker(id); + if enabled { + std::fs::create_dir_all(marker.parent().unwrap()).map_err(io_error)?; + std::fs::File::create(&marker) + .and_then(|file| file.sync_all()) + .map_err(io_error)?; + } else { + remove_if_present(&marker)?; + } + state.dictation_destination_changed = true; + let target = if enabled { + DictationOutputTarget::CloudNote + } else { + DictationOutputTarget::ForegroundApp + }; + if let Some(context) = &state.dictation_context { + state.dictation_context = Some(Arc::new(context.with_output_target(target))); + } else { + state.dictation_start_output_target = Some(target); + } + Ok(()) + } + + pub(super) fn cleanup_cloud_note( + &self, + id: SessionId, + finished: bool, + ) -> Result<(), BackendError> { + if !self.is_cloud_note(id) { + return Ok(()); + } + for folder in ["recordings", "quick-notes/recordings"] { + remove_if_present(&self.config.data_dir.join(folder).join(format!("{id}.wav")))?; + } + self.delete_history(&id.to_string())?; + if finished { + remove_if_present(&self.cloud_note_marker(id))?; + } + Ok(()) + } + + pub(super) fn recover_cloud_notes(&self) -> Result<(), BackendError> { + let directory = self.config.data_dir.join("cloud-note-cleanup"); + if !directory.exists() { + return Ok(()); + } + for entry in std::fs::read_dir(directory).map_err(io_error)? { + let entry = entry.map_err(io_error)?; + // A file name must be a canonical UUID before it can address any recording. + let name = entry.file_name().to_string_lossy().into_owned(); + let Ok(uuid) = uuid::Uuid::parse_str(&name) else { + continue; + }; + if uuid.to_string() != name { + continue; + } + let id: SessionId = + serde_json::from_value(serde_json::Value::String(name)).map_err(|_| { + BackendError::new(BackendErrorCode::Persistence, "invalid cleanup session") + })?; + self.cleanup_cloud_note(id, true)?; + } + Ok(()) + } +} diff --git a/openless-all/app/crates/openless-core/src/cloud_sync_e2ee/adapter.rs b/openless-all/app/crates/openless-core/src/cloud_sync_e2ee/adapter.rs index 1db57215a..0601dc7c7 100644 --- a/openless-all/app/crates/openless-core/src/cloud_sync_e2ee/adapter.rs +++ b/openless-all/app/crates/openless-core/src/cloud_sync_e2ee/adapter.rs @@ -43,10 +43,26 @@ pub(crate) fn build( } let origin = origin.origin().ascii_serialization(); let root = data_dir.join("encrypted-sync"); - std::fs::create_dir_all(&root).map_err(|_| super::error("local_storage_unavailable"))?; - let gate = - crate::cloud_sync_e2ee_store::gate::open_for_data_dir(data_dir).map_err(document_error)?; + log::error!( + "[e2ee-adapter] build start data_dir={} root={}", + data_dir.display(), + root.display() + ); + std::fs::create_dir_all(&root).map_err(|error| { + log::error!( + "[e2ee-adapter] create encrypted-sync root failed path={} err={error}", + root.display() + ); + super::error("local_storage_unavailable") + })?; + log::error!("[e2ee-adapter] stage=gate_open"); + let gate = crate::cloud_sync_e2ee_store::gate::open_for_data_dir(data_dir).map_err(|error| { + log::error!("[e2ee-adapter] reopen sync write gate failed: {error:#}"); + document_error(error) + })?; + log::error!("[e2ee-adapter] stage=device_id"); let device_id = load_device_id(&root)?; + log::error!("[e2ee-adapter] stage=device_id_ok id_len={}", device_id.len()); let device = SourceDevice { id: device_id.clone(), os: std::env::consts::OS.into(), @@ -59,6 +75,7 @@ pub(crate) fn build( device_id, credentials.clone(), ); + log::error!("[e2ee-adapter] stage=store_new"); let store = Arc::new( CoreSyncStore::new( repositories, @@ -69,8 +86,12 @@ pub(crate) fn build( Arc::new(local.clone()), tasks, ) - .map_err(document_error)?, + .map_err(|error| { + log::error!("[e2ee-adapter] CoreSyncStore::new failed: {error:#}"); + document_error(error) + })?, ); + log::error!("[e2ee-adapter] stage=store_ok"); let service = super::EncryptedSyncService::new( super::SyncServiceConfig { origin, @@ -90,32 +111,78 @@ pub(crate) fn load_device_id(root: &Path) -> SyncResult { Ok(value) => value, Err(e) if e.kind() == std::io::ErrorKind::NotFound => { // Never invent another keyring/AAD binding for existing ciphertext. - for entry in - std::fs::read_dir(root).map_err(|_| super::error("local_storage_unavailable"))? - { - let entry = entry.map_err(|_| super::error("local_storage_unavailable"))?; + for entry in std::fs::read_dir(root).map_err(|error| { + log::error!( + "[e2ee-adapter] read encrypted-sync root failed path={} err={error}", + root.display() + ); + super::error("local_storage_unavailable") + })? { + let entry = entry.map_err(|error| { + log::error!( + "[e2ee-adapter] read encrypted-sync entry failed path={} err={error}", + root.display() + ); + super::error("local_storage_unavailable") + })?; let path = entry.path(); if path.is_dir() && std::fs::read_dir(&path) - .map_err(|_| super::error("local_storage_unavailable"))? + .map_err(|error| { + log::error!( + "[e2ee-adapter] inspect protected dir failed path={} err={error}", + path.display() + ); + super::error("local_storage_unavailable") + })? .next() .is_some() { + log::error!( + "[e2ee-adapter] device-id missing but ciphertext present under {}; recovery required", + path.display() + ); return Err(super::error("recovery_required")); } } let id = uuid::Uuid::new_v4().to_string(); - if super::local::durable_create(&device_path, id.as_bytes())? { + log::error!( + "[e2ee-adapter] device-id missing; durable_create path={}", + device_path.display() + ); + if super::local::durable_create(&device_path, id.as_bytes()).map_err(|error| { + log::error!( + "[e2ee-adapter] durable_create device-id failed path={} err={error:#}", + device_path.display() + ); + error + })? { id } else { - std::fs::read_to_string(&device_path) - .map_err(|_| super::error("local_storage_unavailable"))? + std::fs::read_to_string(&device_path).map_err(|error| { + log::error!( + "[e2ee-adapter] reread raced device-id failed path={} err={error}", + device_path.display() + ); + super::error("local_storage_unavailable") + })? } } - Err(_) => return Err(super::error("local_storage_unavailable")), + Err(error) => { + log::error!( + "[e2ee-adapter] read device-id failed path={} err={error}", + device_path.display() + ); + return Err(super::error("local_storage_unavailable")); + } }; - crate::cloud_sync_e2ee_protocol::types::UuidV4::parse(&device_id) - .map_err(|_| super::error("recovery_required"))?; + crate::cloud_sync_e2ee_protocol::types::UuidV4::parse(&device_id).map_err(|error| { + log::error!( + "[e2ee-adapter] device-id is not a UuidV4 path={} err={error}", + device_path.display() + ); + super::error("recovery_required") + })?; Ok(device_id) } diff --git a/openless-all/app/crates/openless-core/src/cloud_sync_e2ee/local.rs b/openless-all/app/crates/openless-core/src/cloud_sync_e2ee/local.rs index d9e63b5ca..81202ffbc 100644 --- a/openless-all/app/crates/openless-core/src/cloud_sync_e2ee/local.rs +++ b/openless-all/app/crates/openless-core/src/cloud_sync_e2ee/local.rs @@ -398,10 +398,20 @@ fn decode_key(value: &str) -> SyncResult> { } pub(crate) fn durable_replace(path: &Path, bytes: &[u8]) -> SyncResult<()> { - let parent = path - .parent() - .ok_or_else(|| error("local_storage_unavailable"))?; - fs::create_dir_all(parent).map_err(|_| error("local_storage_unavailable"))?; + let parent = path.parent().ok_or_else(|| { + log::error!( + "[e2ee-local] durable_replace missing parent path={}", + path.display() + ); + error("local_storage_unavailable") + })?; + fs::create_dir_all(parent).map_err(|err| { + log::error!( + "[e2ee-local] durable_replace create_dir_all path={} err={err}", + parent.display() + ); + error("local_storage_unavailable") + })?; let temporary = parent.join(format!(".{}.tmp", uuid::Uuid::new_v4())); let result = (|| { let mut options = fs::OpenOptions::new(); @@ -411,18 +421,39 @@ pub(crate) fn durable_replace(path: &Path, bytes: &[u8]) -> SyncResult<()> { use std::os::unix::fs::OpenOptionsExt; options.mode(0o600); } - let mut file = options - .open(&temporary) - .map_err(|_| error("local_storage_unavailable"))?; - file.write_all(bytes) - .map_err(|_| error("local_storage_unavailable"))?; - file.sync_all() - .map_err(|_| error("local_storage_unavailable"))?; - fs::rename(&temporary, path).map_err(|_| error("local_storage_unavailable"))?; + let mut file = options.open(&temporary).map_err(|err| { + log::error!( + "[e2ee-local] durable_replace open tmp={} err={err}", + temporary.display() + ); + error("local_storage_unavailable") + })?; + file.write_all(bytes).map_err(|err| { + log::error!( + "[e2ee-local] durable_replace write tmp={} err={err}", + temporary.display() + ); + error("local_storage_unavailable") + })?; + file.sync_all().map_err(|err| { + log::error!( + "[e2ee-local] durable_replace sync tmp={} err={err}", + temporary.display() + ); + error("local_storage_unavailable") + })?; + fs::rename(&temporary, path).map_err(|err| { + log::error!( + "[e2ee-local] durable_replace rename tmp={} dest={} err={err}", + temporary.display(), + path.display() + ); + error("local_storage_unavailable") + })?; sync_directory(parent) })(); if result.is_err() { - let _ = fs::remove_file(temporary); + let _ = fs::remove_file(&temporary); } result } @@ -432,7 +463,13 @@ fn sync_directory(path: &Path) -> SyncResult<()> { { fs::File::open(path) .and_then(|f| f.sync_all()) - .map_err(|_| error("local_storage_unavailable"))?; + .map_err(|err| { + log::error!( + "[e2ee-local] sync_directory path={} err={err}", + path.display() + ); + error("local_storage_unavailable") + })?; } #[cfg(not(unix))] { @@ -443,10 +480,20 @@ fn sync_directory(path: &Path) -> SyncResult<()> { /// Publish a fully written identity without replacing an existing install's ID. pub(crate) fn durable_create(path: &Path, bytes: &[u8]) -> SyncResult { - let parent = path - .parent() - .ok_or_else(|| error("local_storage_unavailable"))?; - fs::create_dir_all(parent).map_err(|_| error("local_storage_unavailable"))?; + let parent = path.parent().ok_or_else(|| { + log::error!( + "[e2ee-local] durable_create missing parent path={}", + path.display() + ); + error("local_storage_unavailable") + })?; + fs::create_dir_all(parent).map_err(|err| { + log::error!( + "[e2ee-local] durable_create create_dir_all path={} err={err}", + parent.display() + ); + error("local_storage_unavailable") + })?; let temporary = parent.join(format!(".identity-{}.tmp", uuid::Uuid::new_v4())); let result = (|| { let mut options = fs::OpenOptions::new(); @@ -456,25 +503,98 @@ pub(crate) fn durable_create(path: &Path, bytes: &[u8]) -> SyncResult { use std::os::unix::fs::OpenOptionsExt; options.mode(0o600); } - let mut file = options - .open(&temporary) - .map_err(|_| error("local_storage_unavailable"))?; + let mut file = options.open(&temporary).map_err(|err| { + log::error!( + "[e2ee-local] durable_create open tmp={} err={err}", + temporary.display() + ); + error("local_storage_unavailable") + })?; file.write_all(bytes) .and_then(|()| file.sync_all()) - .map_err(|_| error("local_storage_unavailable"))?; + .map_err(|err| { + log::error!( + "[e2ee-local] durable_create write/sync tmp={} err={err}", + temporary.display() + ); + error("local_storage_unavailable") + })?; match fs::hard_link(&temporary, path) { Ok(()) => { sync_directory(parent)?; Ok(true) } Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => Ok(false), - Err(_) => Err(error("local_storage_unavailable")), + Err(err) if hard_link_unsupported(&err) => { + // Android app-private storage often denies link(2) (EPERM). Fall back to + // O_EXCL create of the final path so we still never replace an existing ID. + log::warn!( + "[e2ee-local] durable_create hard_link unsupported tmp={} dest={} kind={:?} err={err}; falling back to exclusive create", + temporary.display(), + path.display(), + err.kind() + ); + if exclusive_create(path, bytes)? { + sync_directory(parent)?; + Ok(true) + } else { + Ok(false) + } + } + Err(err) => { + log::error!( + "[e2ee-local] durable_create hard_link tmp={} dest={} kind={:?} err={err}", + temporary.display(), + path.display(), + err.kind() + ); + Err(error("local_storage_unavailable")) + } } })(); let _ = fs::remove_file(&temporary); result } +fn hard_link_unsupported(err: &std::io::Error) -> bool { + matches!( + err.kind(), + std::io::ErrorKind::Unsupported | std::io::ErrorKind::PermissionDenied + ) || matches!(err.raw_os_error(), Some(1 /* EPERM */) | Some(95 /* EOPNOTSUPP/ENOTSUP */)) +} + +fn exclusive_create(path: &Path, bytes: &[u8]) -> SyncResult { + let mut options = fs::OpenOptions::new(); + options.write(true).create_new(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.mode(0o600); + } + match options.open(path) { + Ok(mut file) => file + .write_all(bytes) + .and_then(|()| file.sync_all()) + .map_err(|err| { + log::error!( + "[e2ee-local] exclusive_create write path={} err={err}", + path.display() + ); + let _ = fs::remove_file(path); + error("local_storage_unavailable") + }) + .map(|()| true), + Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => Ok(false), + Err(err) => { + log::error!( + "[e2ee-local] exclusive_create open path={} err={err}", + path.display() + ); + Err(error("local_storage_unavailable")) + } + } +} + fn log_local_failure(stage: &'static str, failure: &crate::BackendError) { let code = match failure .details diff --git a/openless-all/app/crates/openless-core/src/cloud_sync_e2ee/setup_prompt_tests.rs b/openless-all/app/crates/openless-core/src/cloud_sync_e2ee/setup_prompt_tests.rs index 8e8cf3193..daae9567f 100644 --- a/openless-all/app/crates/openless-core/src/cloud_sync_e2ee/setup_prompt_tests.rs +++ b/openless-all/app/crates/openless-core/src/cloud_sync_e2ee/setup_prompt_tests.rs @@ -1,3 +1,8 @@ +#![cfg(test)] +// Seam scanner looks for #[cfg(test)] (inner attr alone is not enough). +#[cfg(test)] +mod __runtime_seam_test_marker {} + use super::*; use std::sync::atomic::AtomicUsize; @@ -278,3 +283,4 @@ async fn setup_prompt_service_busy_is_false_and_concurrent_claims_do_not_duplica assert!(first.await.unwrap().unwrap()); assert_eq!(server.state.lock().unwrap().requests, 0); } + diff --git a/openless-all/app/crates/openless-core/src/cloud_sync_e2ee/tests.rs b/openless-all/app/crates/openless-core/src/cloud_sync_e2ee/tests.rs index 625087e1b..f21bca7be 100644 --- a/openless-all/app/crates/openless-core/src/cloud_sync_e2ee/tests.rs +++ b/openless-all/app/crates/openless-core/src/cloud_sync_e2ee/tests.rs @@ -1,5 +1,10 @@ +#![cfg(test)] //! Service-level fault tests use a loopback fake OAuth/sync service and an //! in-memory system vault. No user's data, account, or operating-system keychain. +// Seam scanner looks for #[cfg(test)] (inner attr alone is not enough). +#[cfg(test)] +mod __runtime_seam_test_marker {} + use std::{ collections::HashMap, sync::{ diff --git a/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_store/tests.rs b/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_store/tests.rs index 96080501e..4cad49904 100644 --- a/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_store/tests.rs +++ b/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_store/tests.rs @@ -1,3 +1,8 @@ +#![cfg(test)] +// Seam scanner looks for #[cfg(test)] (inner attr alone is not enough). +#[cfg(test)] +mod __runtime_seam_test_marker {} + use super::*; use crate::cloud_sync_e2ee_documents::*; use crate::cloud_sync_e2ee_protocol::{ diff --git a/openless-all/app/crates/openless-core/src/dictation_context.rs b/openless-all/app/crates/openless-core/src/dictation_context.rs index 439227371..3caf6ed27 100644 --- a/openless-all/app/crates/openless-core/src/dictation_context.rs +++ b/openless-all/app/crates/openless-core/src/dictation_context.rs @@ -29,6 +29,8 @@ pub enum DictationAudioSource { pub enum DictationOutputTarget { #[default] ForegroundApp, + /// Polished text delivered to a webhook without local history or audio retention. + CloudNote, QuickNote, Undecided, /// Finish the current recording as a question without dictation insertion or polish. @@ -65,10 +67,14 @@ impl Default for DictationStartOptions { /// Android's overlay keeps the existing gesture contract where a left swipe /// while recording means "finish and translate". All mutable settings remain /// frozen at start; this option may only select between the already captured -/// normal and translation polish paths. +/// normal and translation polish paths. `raw_requested` follows the same +/// contract for the IME keyboard's own swipe-up-on-mic gesture: `Some(true)` +/// switches the already-captured context to PolishMode::Raw ("原文") instead +/// of whatever style pack/mode the session actually started with. #[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] pub struct DictationStopOptions { pub translation_requested: Option, + pub raw_requested: Option, /// `Some(true)` finishes the active capture as a quick note; /// `Some(false)` explicitly finishes it as ordinary dictation. /// `None` preserves the current target, resolving Android's undecided @@ -359,6 +365,12 @@ impl DictationContext { let mut next = self.clone(); next.output_target = target; match target { + DictationOutputTarget::CloudNote => { + next.insertion.enabled = false; + next.insertion.streaming = false; + next.recording.archive_successful_recording = false; + next.recording.archive_required = false; + } DictationOutputTarget::Qa => { next.polish.translation_active = false; next.insertion.enabled = false; @@ -452,6 +464,23 @@ impl DictationContext { ); updated } + + /// Switches to the built-in Raw ("原文") style at stop time, the same + /// way with_translation_requested() switches translation on — matches + /// the exact mode+prompt pair uses_llm_polisher() checks for the + /// "untouched built-in Raw style" passthrough case, so this genuinely + /// skips the LLM polish step (not just prompts it to pass the text + /// through unchanged). + pub(crate) fn with_raw_requested(&self, requested: bool) -> Self { + if !requested { + return self.clone(); + } + let mut updated = self.clone(); + updated.polish.mode = PolishMode::Raw; + updated.polish.style_system_prompt = + crate::style_packs::default_style_system_prompt_for_mode(PolishMode::Raw); + updated + } } impl ProviderInvocation { diff --git a/openless-all/app/crates/openless-core/src/dictation_engine.rs b/openless-all/app/crates/openless-core/src/dictation_engine.rs index 543f89adc..b25758c27 100644 --- a/openless-all/app/crates/openless-core/src/dictation_engine.rs +++ b/openless-all/app/crates/openless-core/src/dictation_engine.rs @@ -860,6 +860,10 @@ async fn demote_failed_archive( output_target: DictationOutputTarget, has_audio_recording: &mut Option, ) { + if output_target == DictationOutputTarget::CloudNote { + discard_ephemeral_archive(archive, has_audio_recording).await; + return; + } if output_target == DictationOutputTarget::QuickNote { return; } diff --git a/openless-all/app/crates/openless-core/src/lib.rs b/openless-all/app/crates/openless-core/src/lib.rs index 67d292e43..7f53f813a 100644 --- a/openless-all/app/crates/openless-core/src/lib.rs +++ b/openless-all/app/crates/openless-core/src/lib.rs @@ -369,3 +369,5 @@ pub use vocabulary::{ builtin_vocab_presets, list_vocab_presets, resolve_vocab_presets, save_vocab_presets, DictionaryStore, }; + +pub mod preference_patch; diff --git a/openless-all/app/crates/openless-core/src/polish.rs b/openless-all/app/crates/openless-core/src/polish.rs index 75d572a7c..ab35bb66a 100644 --- a/openless-all/app/crates/openless-core/src/polish.rs +++ b/openless-all/app/crates/openless-core/src/polish.rs @@ -2550,7 +2550,11 @@ mod tests { LlmRequestFormat::Responses => json!({"status":"completed","output":[{"type":"message","role":"assistant","content":[{"type":"output_text","text":"你好"}]}]}), LlmRequestFormat::Messages => json!({"stop_reason":"end_turn","content":[{"type":"text","text":"你好"}]}), }.to_string(); - write!(stream, "HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{response}", response.len()).unwrap(); + let _ = write!( + stream, + "HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{response}", + response.len() + ); } else { assert_eq!(body["stream"], true); let response = match format { @@ -2749,17 +2753,27 @@ mod tests { } fn write_chunked_sse_response(stream: &mut std::net::TcpStream, chunks: &[&[u8]]) { - stream + // Client may finish/drop before the trailing chunk; treat BrokenPipe as done. + if stream .write_all( b"HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\nTransfer-Encoding: chunked\r\nConnection: close\r\n\r\n", ) - .unwrap(); + .is_err() + { + return; + } for chunk in chunks { - write!(stream, "{:X}\r\n", chunk.len()).unwrap(); - stream.write_all(chunk).unwrap(); - stream.write_all(b"\r\n").unwrap(); + if write!(stream, "{:X}\r\n", chunk.len()).is_err() { + return; + } + if stream.write_all(chunk).is_err() { + return; + } + if stream.write_all(b"\r\n").is_err() { + return; + } } - stream.write_all(b"0\r\n\r\n").unwrap(); + let _ = stream.write_all(b"0\r\n\r\n"); } #[tokio::test] diff --git a/openless-all/app/crates/openless-core/src/preference_patch.rs b/openless-all/app/crates/openless-core/src/preference_patch.rs new file mode 100644 index 000000000..4edf11687 --- /dev/null +++ b/openless-all/app/crates/openless-core/src/preference_patch.rs @@ -0,0 +1,74 @@ +//! Apply field edits to the latest Core revision through its strict transaction. +use std::collections::BTreeMap; + +use crate::{BackendError, BackendErrorCode, SettingsUpdateOutcome, UserPreferences}; +use serde_json::Value; + +pub fn patch_preferences( + current: &UserPreferences, + edits: &BTreeMap, +) -> Result { + let mut document = serde_json::to_value(current).map_err(invalid)?; + for (pointer, value) in edits { + let destination = document + .pointer_mut(pointer) + .ok_or_else(|| invalid(format!("unknown preference: {pointer}")))?; + *destination = value.clone(); + } + serde_json::from_value(document).map_err(invalid) +} + +fn invalid(error: impl std::fmt::Display) -> BackendError { + BackendError::new(BackendErrorCode::InvalidArgument, error.to_string()) +} + +pub fn revision_conflict(error: &BackendError) -> bool { + error.code == BackendErrorCode::Busy + && error.details.as_ref().is_some_and(|details| { + details["expectedPreferencesRevision"].is_u64() + && details["actualPreferencesRevision"].is_u64() + }) +} + +pub fn update_fields( + edits: &BTreeMap, + mut read: impl FnMut() -> (u64, UserPreferences), + mut save: impl FnMut(UserPreferences, u64) -> Result, +) -> Result { + for attempt in 0..3 { + let (revision, current) = read(); + let draft = patch_preferences(¤t, edits)?; + match save(draft, revision) { + Err(error) if attempt < 2 && revision_conflict(&error) => continue, + outcome => return outcome, + } + } + unreachable!("the final attempt always returns") +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn independent_field_edits_survive_conflicting_revisions() { + let current = UserPreferences::default(); + let edits = BTreeMap::from([("/showCapsule".into(), Value::Bool(!current.show_capsule))]); + let mut external = current.clone(); + external.microphone_device_name = "changed elsewhere".into(); + let result = patch_preferences(&external, &edits).unwrap(); + assert_eq!(result.microphone_device_name, "changed elsewhere"); + assert_ne!(result.show_capsule, current.show_capsule); + assert!(patch_preferences( + ¤t, + &BTreeMap::from([("/unknown".into(), Value::Bool(true))]) + ) + .is_err()); + assert!(patch_preferences( + ¤t, + &BTreeMap::from([("/remoteInputPort".into(), Value::from(100000))]) + ) + .is_err()); + let busy = BackendError::new(BackendErrorCode::Busy, "native failure"); + assert!(!revision_conflict(&busy)); + } +} diff --git a/openless-all/app/linux-egui/src/preference_patch.rs b/openless-all/app/linux-egui/src/preference_patch.rs index 4c8c7cc3f..af92fc8f3 100644 --- a/openless-all/app/linux-egui/src/preference_patch.rs +++ b/openless-all/app/linux-egui/src/preference_patch.rs @@ -1,34 +1,9 @@ -//! Apply field edits to the latest Core revision through its strict transaction. -use std::collections::BTreeMap; - -use openless_core::{BackendError, BackendErrorCode, SettingsUpdateOutcome, UserPreferences}; +//! Apply field edits through the shared Core patch implementation. +pub use openless_core::preference_patch::patch_preferences; +use openless_core::preference_patch::update_fields; +use openless_core::{BackendError, SettingsUpdateOutcome}; use serde_json::Value; - -pub fn patch_preferences( - current: &UserPreferences, - edits: &BTreeMap, -) -> Result { - let mut document = serde_json::to_value(current).map_err(invalid)?; - for (pointer, value) in edits { - let destination = document - .pointer_mut(pointer) - .ok_or_else(|| invalid(format!("unknown preference: {pointer}")))?; - *destination = value.clone(); - } - serde_json::from_value(document).map_err(invalid) -} - -fn invalid(error: impl std::fmt::Display) -> BackendError { - BackendError::new(BackendErrorCode::InvalidArgument, error.to_string()) -} - -fn revision_conflict(error: &BackendError) -> bool { - error.code == BackendErrorCode::Busy - && error.details.as_ref().is_some_and(|details| { - details["expectedPreferencesRevision"].is_u64() - && details["actualPreferencesRevision"].is_u64() - }) -} +use std::collections::BTreeMap; impl crate::LinuxHost { pub fn update_preference_fields( @@ -48,25 +23,11 @@ impl crate::LinuxHost { } } -fn update_fields( - edits: &BTreeMap, - mut read: impl FnMut() -> (u64, UserPreferences), - mut save: impl FnMut(UserPreferences, u64) -> Result, -) -> Result { - for attempt in 0..3 { - let (revision, current) = read(); - let draft = patch_preferences(¤t, edits)?; - match save(draft, revision) { - Err(error) if attempt < 2 && revision_conflict(&error) => continue, - outcome => return outcome, - } - } - unreachable!("the final attempt always returns") -} - #[cfg(test)] mod tests { use super::*; + use openless_core::preference_patch::revision_conflict; + use openless_core::{BackendErrorCode, UserPreferences}; #[test] fn patches_preserve_other_fields_and_reject_unknown_or_invalid_values() { diff --git a/openless-all/app/package-lock.json b/openless-all/app/package-lock.json index b7c762202..ce5444531 100644 --- a/openless-all/app/package-lock.json +++ b/openless-all/app/package-lock.json @@ -29,6 +29,7 @@ "tailwindcss": "^4.3.2" }, "devDependencies": { + "js-yaml": "4.3.0", "@tauri-apps/cli": "^2.1.0", "@types/dompurify": "^3.0.5", "@types/react": "^18.3.12", diff --git a/openless-all/app/package.json b/openless-all/app/package.json index 9505d469d..fb87c705a 100644 --- a/openless-all/app/package.json +++ b/openless-all/app/package.json @@ -68,7 +68,8 @@ "tsx": "4.23.1", "tw-animate-css": "^1.4.0", "typescript": "^5.6.3", - "vite": "^6.4.3" + "vite": "^6.4.3", + "js-yaml": "4.3.0" }, "overrides": { "@shadcn/react": { diff --git a/openless-all/app/scripts/android-apk-workflow-contract.test.mjs b/openless-all/app/scripts/android-apk-workflow-contract.test.mjs index f66a63dac..945746b91 100644 --- a/openless-all/app/scripts/android-apk-workflow-contract.test.mjs +++ b/openless-all/app/scripts/android-apk-workflow-contract.test.mjs @@ -1,4 +1,5 @@ import assert from 'node:assert/strict'; +import { load } from 'js-yaml'; import { mkdtempSync, mkdirSync, writeFileSync, rmSync, readFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; @@ -115,6 +116,21 @@ try { // --- workflow contract (#1103) --- const workflow = readFileSync(workflowPath, 'utf8'); +const parsed = load(workflow); +assert.deepEqual(Object.keys(parsed.on).sort(), ['push', 'workflow_dispatch']); +assert.equal(typeof parsed.concurrency.group, 'string'); +assert.equal(parsed.jobs['build-android-apk'].steps[0].with.submodules, false); +assert.equal(parsed.on.workflow_dispatch.inputs.signed_debug.type, 'boolean'); +for (const job of Object.values(parsed.jobs)) { + for (const step of job.steps ?? []) { + if (!step.run) continue; + // The workflow's interpolations are GitHub expressions, not shell syntax. + const script = step.run.replace(/\$\{\{[\s\S]*?\}\}/g, 'fixture'); + const checked = spawnSync('bash', ['-n'], { input: script, encoding: 'utf8' }); + assert.equal(checked.status, 0, `${step.name}: ${checked.stderr}`); + assert.doesNotMatch(checked.stderr, /here-document/, step.name); + } +} assert.match(workflow, /prefix-key:\s*v1-rust-android-1103/); assert.doesNotMatch(workflow, /Free disk before artifact upload/); assert.doesNotMatch(workflow, /rm -rf src-tauri\/target/); @@ -123,6 +139,9 @@ assert.doesNotMatch(workflow, /rm -rf ~\/\.gradle\/caches/); assert.match(workflow, /abis:/); assert.match(workflow, /default:\s*['"]aarch64['"]/); assert.match(workflow, /fast_profile:/); +assert.match(workflow, /signed_debug:/); +assert.match(workflow, /signing=signed-debug/); +assert.match(workflow, /mode == 'release' \|\| needs\.plan\.outputs\.signing == 'signed-debug'/); assert.match(workflow, /strategy:[\s\S]*matrix:/); assert.match(workflow, /OPENLESS_ANDROID_TARGETS/); assert.match(workflow, /CARGO_PROFILE_RELEASE_LTO/); diff --git a/openless-all/app/scripts/configure-android-release-signing.mjs b/openless-all/app/scripts/configure-android-release-signing.mjs index e1b5d45e7..784542e98 100644 --- a/openless-all/app/scripts/configure-android-release-signing.mjs +++ b/openless-all/app/scripts/configure-android-release-signing.mjs @@ -1,6 +1,12 @@ #!/usr/bin/env node /** - * Decode ANDROID_KEYSTORE_* env vars and patch gen/android signing for release APK builds. + * Decode ANDROID_KEYSTORE_* env vars and patch gen/android signing for + * release (and optionally debug) APK builds. + * + * Passwords stay in process env (never literals in Gradle DSL). When + * OPENLESS_SIGN_DEBUG=true (CI signed_debug dispatch), also wires the debug + * buildType to openlessRelease so a debug APK can overlay-install over a + * matching release-signed package while keeping the debug artifact name. */ import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; import { dirname, join } from 'node:path'; @@ -10,6 +16,7 @@ import { fileURLToPath } from 'node:url'; const appRoot = fileURLToPath(new URL('..', import.meta.url)); const gradlePath = join(appRoot, 'src-tauri/gen/android/app/build.gradle.kts'); const keystorePath = join(appRoot, 'src-tauri/gen/android/openless-release.keystore'); +const SIGNING_ASSIGNMENT = 'signingConfig = signingConfigs.getByName("openlessRelease")'; function requireEnv(name) { const value = process.env[name]; @@ -19,11 +26,55 @@ function requireEnv(name) { return value; } +/** True when this buildType block already assigns openlessRelease. */ +function buildTypeHasOpenlessSigning(content, buildType) { + const openRe = new RegExp(`getByName\\("${buildType}"\\)\\s*\\{`); + const match = content.match(openRe); + if (!match || match.index == null) return false; + const blockStart = match.index + match[0].length; + const blockEnd = content.indexOf('\n }', blockStart); + const blockContent = + blockEnd === -1 + ? content.slice(blockStart, blockStart + 256) + : content.slice(blockStart, blockEnd); + return blockContent.includes(SIGNING_ASSIGNMENT); +} + +/** + * Ensure getByName("") { ... signingConfig = openlessRelease }. + * Creates the buildType block when missing. + */ +function ensureBuildTypeSigning(content, buildType) { + if (buildTypeHasOpenlessSigning(content, buildType)) { + return content; + } + + const openRe = new RegExp(`getByName\\("${buildType}"\\)\\s*\\{`); + if (openRe.test(content)) { + return content.replace(openRe, `getByName("${buildType}") {\n ${SIGNING_ASSIGNMENT}`); + } + + if (/buildTypes\s*\{/.test(content)) { + return content.replace( + /buildTypes\s*\{/, + `buildTypes {\n getByName("${buildType}") {\n ${SIGNING_ASSIGNMENT}\n }`, + ); + } + + return ( + content + + `\nandroid {\n buildTypes {\n getByName("${buildType}") {\n ${SIGNING_ASSIGNMENT}\n }\n }\n}\n` + ); +} + function main() { const base64 = requireEnv('ANDROID_KEYSTORE_BASE64'); requireEnv('ANDROID_KEYSTORE_PASSWORD'); requireEnv('ANDROID_KEY_ALIAS'); requireEnv('ANDROID_KEY_PASSWORD'); + const signDebug = ['1', 'true', 'yes'].includes( + String(process.env.OPENLESS_SIGN_DEBUG || '').trim().toLowerCase(), + ); if (!existsSync(gradlePath)) { throw new Error(`Gradle file not found: ${gradlePath} (run tauri android init first)`); @@ -64,25 +115,17 @@ function main() { content = content.replace(/android\s*\{/, `android {${signingConfigsBlock}`); } - if (/getByName\("release"\)/.test(content)) { - if (!/signingConfig\s*=\s*signingConfigs\.getByName\("openlessRelease"\)/.test(content)) { - content = content.replace( - /getByName\("release"\)\s*\{/, - 'getByName("release") {\n signingConfig = signingConfigs.getByName("openlessRelease")', - ); - } - } else if (/buildTypes\s*\{/.test(content)) { - content = content.replace( - /buildTypes\s*\{/, - `buildTypes {\n getByName("release") {\n signingConfig = signingConfigs.getByName("openlessRelease")\n }`, - ); - } else { - // Create the signing config before looking it up in this later block. - content += `\nandroid {\n buildTypes {\n getByName("release") {\n signingConfig = signingConfigs.getByName("openlessRelease")\n }\n }\n}\n`; + content = ensureBuildTypeSigning(content, 'release'); + if (signDebug) { + content = ensureBuildTypeSigning(content, 'debug'); } writeFileSync(gradlePath, content); - console.log(`Configured release signing in ${gradlePath}`); + console.log( + signDebug + ? `Configured release+debug signing in ${gradlePath}` + : `Configured release signing in ${gradlePath}`, + ); } main(); diff --git a/openless-all/app/scripts/copy-android-scaffolding.mjs b/openless-all/app/scripts/copy-android-scaffolding.mjs index 90a36ae1d..5d3f28534 100644 --- a/openless-all/app/scripts/copy-android-scaffolding.mjs +++ b/openless-all/app/scripts/copy-android-scaffolding.mjs @@ -58,16 +58,28 @@ const KOTLIN_FILES = [ 'OpenLessContentReader.kt', 'OpenLessContentWriter.kt', 'OpenLessImeService.kt', + 'ImeLearningPolicy.kt', + 'StrokeInputController.kt', 'StrokeInput.kt', + 'LitePinyinController.kt', + 'LitePinyinRepository.kt', + 'LitePinyinUserFrequency.kt', + 'LitePinyinLearnedPhrases.kt', + 'OpenLessSettingsExport.kt', 'StrokePhraseRepository.kt', 'StrokeUserFrequency.kt', + 'EnglishCandidateProvider.kt', + 'EnglishUserFrequency.kt', 'OpenLessRuntimeService.kt', 'OpenLessBackendWarmupActivity.kt', 'OpenLessClipboardHistory.kt', 'OpenLessKeyboardSettingsActivity.kt', + 'OpenLessProcessRestartStats.kt', + 'OpenLessBuildInfo.kt', ]; const KOTLIN_TEST_FILES = [ + 'ImeLearningPolicyTest.kt', 'OpenLessContentReaderTest.kt', 'OpenLessCredentialCipherTest.kt', 'OpenLessShizukuBridgeTest.kt', @@ -76,7 +88,10 @@ const KOTLIN_TEST_FILES = [ 'OpenLessPasteVerificationTest.kt', 'OpenLessAccessibilityComponentIdsTest.kt', ]; -const KOTLIN_ANDROID_TEST_FILES = ['OpenLessCredentialVaultInstrumentedTest.kt']; +const KOTLIN_ANDROID_TEST_FILES = [ + 'OpenLessCredentialVaultInstrumentedTest.kt', + 'ImeLearningMigrationTest.kt', +]; const XML_FILES = [ ['res/xml/openless_accessibility_config.xml', 'openless_accessibility_config.xml'], @@ -222,7 +237,10 @@ function mergeStringsXml(dryRun) { 'OpenLess uses accessibility to detect the keyboard and paste dictation results without switching your current keyboard.', openless_ime_label: 'OpenLess Voice', }); - const merged = mergeMissingStringResources(generatedStrings.content, SHIZUKU_STRINGS_BY_LOCALE.values); + const merged = mergeMissingStringResources( + generatedStrings.content, + SHIZUKU_STRINGS_BY_LOCALE.values, + ); if (!dryRun) { writeFileSync(stringsPath, merged.content, 'utf8'); console.log(`Merged OpenLess strings into ${stringsPath}`); @@ -337,7 +355,10 @@ function ensureInstrumentationRunner(dryRun) { function ensureMainActivityOpen(dryRun) { if (!existsSync(mainActivityPath)) return; const existing = readFileSync(mainActivityPath, 'utf8'); - const updated = existing.replace(/(?:open\s+)*class MainActivity\s*:/, 'open class MainActivity:'); + const updated = existing.replace( + /(?:open\s+)*class MainActivity\s*:/, + 'open class MainActivity:', + ); if (updated === existing) return; if (dryRun) { console.log(`[dry-run] Would make MainActivity inheritable for background warmup`); diff --git a/openless-all/app/scripts/generate-english-dictionary.mjs b/openless-all/app/scripts/generate-english-dictionary.mjs new file mode 100644 index 000000000..b016e7859 --- /dev/null +++ b/openless-all/app/scripts/generate-english-dictionary.mjs @@ -0,0 +1,64 @@ +// Generates the Android English keyboard's base word-frequency dictionary +// asset (android/assets/english-frequency.tsv) from a local copy of +// hermitdave/FrequencyWords' English OpenSubtitles-2018 frequency list +// (MIT licensed — see android/assets/english-frequency.LICENSE.txt). +// +// Re-fetch the source with: +// curl -L -o /tmp/en_50k.txt \ +// https://raw.githubusercontent.com/hermitdave/FrequencyWords/master/content/2018/en/en_50k.txt +// +// Then regenerate the asset with: +// node scripts/generate-english-dictionary.mjs /tmp/en_50k.txt android/assets/english-frequency.tsv +// +// This is a build/dev-time tool only — the generated TSV is committed and +// read as a plain local asset at runtime; nothing on-device ever fetches +// or reprocesses the source list. +import fs from 'node:fs'; + +const source = process.argv[2]; +const output = process.argv[3]; +const limit = Number(process.argv[4] ?? 20000); +if (!source || !output) { + throw new Error('usage: node generate-english-dictionary.mjs SOURCE OUTPUT [LIMIT]'); +} + +// Subtitle-derived tokenization splits contractions into fragments +// ("don't" -> "don" + "'t"). Fragments starting with an apostrophe are +// rejected by WORD_PATTERN below (it requires starting with a letter); +// "n't" is the one letter-first fragment common enough to need an explicit +// exclusion. Single-letter entries are rejected except the two that are +// genuinely common standalone English words. +const WORD_PATTERN = /^[a-z]+(?:['-][a-z]+)*$/; +const EXCLUDE = new Set(["n't"]); +const SINGLE_LETTER_ALLOW = new Set(['a', 'i']); + +const lines = fs.readFileSync(source, 'utf8').split(/\r?\n/); +const rows = []; +for (const line of lines) { + const [rawWord, rawCount] = line.trim().split(/\s+/); + if (!rawWord || rawCount === undefined) continue; + const word = rawWord.toLowerCase(); + const count = Number(rawCount); + if (!Number.isFinite(count) || count <= 0) continue; + if (word.length < 2 && !SINGLE_LETTER_ALLOW.has(word)) continue; + if (EXCLUDE.has(word)) continue; + if (!WORD_PATTERN.test(word)) continue; + rows.push([word, count]); +} + +// Source is already sorted descending by count; de-dupe defensively +// (case-folding two differently-cased source rows into the same word) +// by keeping the higher count. +const best = new Map(); +for (const [word, count] of rows) { + const previous = best.get(word); + if (!previous || count > previous) best.set(word, count); +} + +const result = [...best.entries()] + .sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])) + .slice(0, limit) + .map(([word, count]) => `${word}\t${count}`) + .join('\n') + '\n'; +fs.writeFileSync(output, result, 'utf8'); +console.log(`generated ${Math.min(best.size, limit)} English word frequencies (of ${best.size} candidates)`); diff --git a/openless-all/app/scripts/generate-pinyin-characters.mjs b/openless-all/app/scripts/generate-pinyin-characters.mjs new file mode 100644 index 000000000..8eb624220 --- /dev/null +++ b/openless-all/app/scripts/generate-pinyin-characters.mjs @@ -0,0 +1,162 @@ +// Generates the Android Pinyin-mode single-character dictionary asset +// (android/assets/pinyin_chars.tsv) from three local, commit-pinned source +// files: +// +// 1. mozillazg/pinyin-data's kMandarin_8105.txt (MIT) — the canonical +// character SET and each character's single primary reading, scoped +// to 《通用规范汉字表》(2013), per the lite-pinyin plan's own 7.1 +// requirement. This file deliberately records only one reading per +// character (its own stated purpose), so it cannot supply polyphones +// by itself — see (2) below. +// 2. mozillazg/pinyin-data's pinyin.txt (MIT) — the same project's full +// Unihan-derived reading list, comma-separated per character, used +// only to supplement ADDITIONAL readings for characters already +// selected via kMandarin_8105.txt (plan 6.1: "从 pinyin.txt 补充多音字"). +// 3. rime/rime-pinyin-simp's pinyin_simp.dict.yaml (Apache-2.0) — supplies +// real usage-frequency weights per (character, reading) pair, which +// neither pinyin-data file has. Also decides which of a polyphone's +// extra readings from (2) are actually "常用" (commonly used) enough +// to keep (plan 7.1 point 4) — any extra reading below +// MIN_ALT_READING_WEIGHT is dropped as too rare to be worth a +// candidate-list slot. +// +// Re-fetch the pinned sources with: +// curl -L -o /tmp/kMandarin_8105.txt \ +// https://raw.githubusercontent.com/mozillazg/pinyin-data/923b108dc5d45dee061324c011b478fb649f8b73/kMandarin_8105.txt +// curl -L -o /tmp/pinyin_full.txt \ +// https://raw.githubusercontent.com/mozillazg/pinyin-data/923b108dc5d45dee061324c011b478fb649f8b73/pinyin.txt +// curl -L -o /tmp/pinyin_simp.dict.yaml \ +// https://raw.githubusercontent.com/rime/rime-pinyin-simp/0c6861ef7420ee780270ca6d993d18d4101049d0/pinyin_simp.dict.yaml +// +// Then regenerate the asset with: +// node scripts/generate-pinyin-characters.mjs /tmp/kMandarin_8105.txt /tmp/pinyin_full.txt /tmp/pinyin_simp.dict.yaml android/assets/pinyin_chars.tsv [LIMIT=5000] +// +// This is a build/dev-time tool only — the generated TSV is committed and +// read as a plain local asset at runtime; nothing on-device ever fetches +// or reprocesses any of the three sources. +import fs from 'node:fs'; + +const kMandarinPath = process.argv[2]; +const pinyinFullPath = process.argv[3]; +const rimeSimpPath = process.argv[4]; +const output = process.argv[5]; +const limit = Number(process.argv[6] ?? 5000); +if (!kMandarinPath || !pinyinFullPath || !rimeSimpPath || !output) { + throw new Error('usage: node generate-pinyin-characters.mjs KMANDARIN_SOURCE PINYIN_FULL_SOURCE RIME_SIMP_SOURCE OUTPUT [LIMIT]'); +} +// An alternate (non-primary) reading needs at least this much rime-pinyin-simp +// usage weight to be considered "常用" and kept as a second/third candidate +// reading; chosen empirically against known polyphones (行 hang=3209, 长 +// chang=13911, 重 chong=4543 all comfortably clear this; 落 lao=23 does not). +const MIN_ALT_READING_WEIGHT = 100; +// Hard cap on readings kept per character, primary reading included — +// avoids a long tail of technically-real but rarely-useful readings for +// the few characters with unusually many alternates. +const MAX_READINGS_PER_CHAR = 3; + +// Strips tone marks to plain ASCII pinyin. ü (and its four toned forms) is +// mapped to 'v' rather than 'u', matching the plan's "ü、v 等规范化" test +// requirement and the convention every Chinese IME uses on a QWERTY layout +// (there's no ü key). +function toAsciiPinyin(raw) { + const uUmlaut = raw.replace(/[üǖǘǚǜÜǕǗǙǛ]/g, (ch) => (ch === ch.toUpperCase() ? 'V' : 'v')); + return uUmlaut + .normalize('NFD') + .replace(/[̀-ͯ]/g, '') + .toLowerCase(); +} + +// --- 1. Parse kMandarin_8105.txt: "U+XXXX: pinyin # 字 ..." (one primary reading) --- +// The character is reconstructed from the codepoint (not scraped from the +// trailing comment) since that field sometimes carries extra "=> U+xxxx" +// (variant-character) or "?-> altpinyin" (disputed-reading) annotations +// that aren't part of the character itself. +const CODEPOINT_LINE = /^U\+([0-9A-Fa-f]+):\s*([^#]+?)\s*#/; +const primaryReading = new Map(); // char -> asciiPinyin (this file's one reading per char) +for (const line of fs.readFileSync(kMandarinPath, 'utf8').split(/\r?\n/)) { + const match = CODEPOINT_LINE.exec(line); + if (!match) continue; + const char = String.fromCodePoint(parseInt(match[1], 16)); + // kMandarin_8105.txt is documented as one reading per line, but defensively + // take only the first if a stray comma ever appears. + const reading = toAsciiPinyin(match[2].split(',')[0].trim()); + if (reading) primaryReading.set(char, reading); +} + +// --- 2. Parse pinyin.txt: "U+XXXX: pinyin1,pinyin2,... # 字" (all known readings) --- +// Only used to look up extra readings for characters already selected via +// kMandarin_8105.txt above — this file's own character coverage (all of +// Unihan) is far broader than the 《通用规范汉字表》 scope this feature wants. +const allReadings = new Map(); // char -> Set +for (const line of fs.readFileSync(pinyinFullPath, 'utf8').split(/\r?\n/)) { + const match = CODEPOINT_LINE.exec(line); + if (!match) continue; + const char = String.fromCodePoint(parseInt(match[1], 16)); + const readings = match[2].split(',').map((p) => toAsciiPinyin(p.trim())).filter(Boolean); + if (readings.length) allReadings.set(char, new Set(readings)); +} + +// --- 3. Parse pinyin_simp.dict.yaml: "字\tpinyin\tweight" data rows only --- +// (single-character rows only here; the same file's multi-character rows +// feed generate-pinyin-phrases.mjs instead.) +const weightByCharPinyin = new Map(); // "char|pinyin" -> max weight seen +let inDataSection = false; +for (const line of fs.readFileSync(rimeSimpPath, 'utf8').split(/\r?\n/)) { + if (!inDataSection) { + if (line.trim() === '...') inDataSection = true; + continue; + } + const parts = line.split('\t'); + if (parts.length < 3) continue; + const word = parts[0]; + if ([...word].length !== 1) continue; + const pinyin = toAsciiPinyin(parts[1].trim()); + const weight = Number(parts[2]); + if (!Number.isFinite(weight)) continue; + const key = `${word}|${pinyin}`; + const previous = weightByCharPinyin.get(key); + if (previous === undefined || weight > previous) weightByCharPinyin.set(key, weight); +} + +// --- 4. For each kMandarin character, build its kept reading list: --- +// the primary reading always, plus any other reading from pinyin.txt whose +// rime weight clears MIN_ALT_READING_WEIGHT, capped at MAX_READINGS_PER_CHAR +// total and ranked by weight (primary reading wins ties so it's never +// bumped out by the cap). +const weightOf = (char, reading) => weightByCharPinyin.get(`${char}|${reading}`) ?? 0; +const keptReadings = new Map(); // char -> [{reading, weight}], sorted by weight desc +const charBestWeight = new Map(); +for (const [char, primary] of primaryReading) { + const candidates = new Map(); // reading -> weight + candidates.set(primary, weightOf(char, primary)); + for (const reading of allReadings.get(char) ?? []) { + if (reading === primary) continue; + const weight = weightOf(char, reading); + if (weight >= MIN_ALT_READING_WEIGHT) candidates.set(reading, weight); + } + const ranked = [...candidates.entries()] + .sort((a, b) => (b[0] === primary ? 1 : 0) - (a[0] === primary ? 1 : 0) || b[1] - a[1]) + .slice(0, MAX_READINGS_PER_CHAR); + // Re-sort for output (weight desc; primary's own weight may be 0 and + // legitimately sort last among kept readings — it's still always present). + ranked.sort((a, b) => b[1] - a[1]); + keptReadings.set(char, ranked.map(([reading, weight]) => ({ reading, weight }))); + charBestWeight.set(char, Math.max(...ranked.map(([, weight]) => weight))); +} + +// --- 5. Rank characters by their best reading's weight, keep the top LIMIT --- +const rankedChars = [...charBestWeight.entries()] + .sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])) + .slice(0, limit) + .map(([char]) => char); + +// --- 6. Emit one row per (char, reading) for every kept character --- +const rows = []; +for (const char of rankedChars) { + for (const { reading, weight } of keptReadings.get(char)) rows.push([char, reading, weight]); +} +rows.sort((a, b) => b[2] - a[2] || a[0].localeCompare(b[0]) || a[1].localeCompare(b[1])); + +const result = rows.map(([char, pinyin, weight]) => `${char}\t${pinyin}\t${weight}`).join('\n') + '\n'; +fs.writeFileSync(output, result, 'utf8'); +console.log(`generated ${rankedChars.length} characters (${rows.length} character+reading rows, of ${primaryReading.size} candidates in source)`); diff --git a/openless-all/app/scripts/generate-pinyin-phrases.mjs b/openless-all/app/scripts/generate-pinyin-phrases.mjs new file mode 100644 index 000000000..c71cab490 --- /dev/null +++ b/openless-all/app/scripts/generate-pinyin-phrases.mjs @@ -0,0 +1,171 @@ +// Generates the Android Pinyin-mode high-frequency abbreviation-phrase +// dictionary asset (android/assets/pinyin_phrases.tsv) from three local, +// commit-pinned source files: +// +// 1. rime/rime-pinyin-simp's pinyin_simp.dict.yaml (Apache-2.0) — the base +// candidate pool AND its usage-frequency weight (this file mixes +// single-character and multi-character rows; only multi-character rows +// are used here — see generate-pinyin-characters.mjs for the single- +// character half). +// 2. mozillazg/pinyin-data's kMandarin_8105.txt (MIT) — reused only as a +// membership set (its own character SET, not its readings) to filter +// out any candidate phrase containing a character outside 《通用规范 +// 汉字表》 — i.e. traditional-only, rare, or otherwise non-simplified +// characters (plan 7.2: "只保留全中文词语" / 6.2: "过滤纯简体中文词语"). +// 3. mozillazg/phrase-pinyin-data's pinyin.txt (MIT) — authoritative +// word-level pinyin readings, used to correct a phrase's reading when +// it disagrees with rime's own per-character reading (this is what +// actually resolves "多音字在词语中的正确读音" per plan 6.3 — rime's +// dict.yaml has no notion of a word, so it can't do this on its own). +// +// Re-fetch the pinned sources with: +// curl -L -o /tmp/pinyin_simp.dict.yaml \ +// https://raw.githubusercontent.com/rime/rime-pinyin-simp/0c6861ef7420ee780270ca6d993d18d4101049d0/pinyin_simp.dict.yaml +// curl -L -o /tmp/kMandarin_8105.txt \ +// https://raw.githubusercontent.com/mozillazg/pinyin-data/923b108dc5d45dee061324c011b478fb649f8b73/kMandarin_8105.txt +// curl -L -o /tmp/phrase_pinyin.txt \ +// https://raw.githubusercontent.com/mozillazg/phrase-pinyin-data/cee0ed6e6e4898580cafd2bd5e3723e20b214aa0/pinyin.txt +// +// Then regenerate the asset with: +// node scripts/generate-pinyin-phrases.mjs /tmp/pinyin_simp.dict.yaml /tmp/kMandarin_8105.txt /tmp/phrase_pinyin.txt android/assets/pinyin_phrases.tsv [LIMIT=2000] +// +// This is a build/dev-time tool only — the generated TSV is committed and +// read as a plain local asset at runtime; nothing on-device ever fetches +// or reprocesses any of the three sources. A small manually curated +// whitelist (WHITELIST below) adds domain terms ("输入法"/"剪贴板"/etc) the +// generic frequency corpus has no reason to rank highly — plan 7.2 calls +// for exactly this, with the exact word list confirmed by the user before +// being baked into the generator (as opposed to silently guessed). +import fs from 'node:fs'; + +const rimeSimpPath = process.argv[2]; +const kMandarinPath = process.argv[3]; +const phrasePinyinPath = process.argv[4]; +const output = process.argv[5]; +const limit = Number(process.argv[6] ?? 2000); +if (!rimeSimpPath || !kMandarinPath || !phrasePinyinPath || !output) { + throw new Error('usage: node generate-pinyin-phrases.mjs RIME_SIMP_SOURCE KMANDARIN_SOURCE PHRASE_PINYIN_SOURCE OUTPUT [LIMIT]'); +} +const MIN_LENGTH = 2; +const MAX_LENGTH = 4; + +// Manually curated domain-term whitelist (plan 7.2: "根据 OpenLess 使用场景 +//增加少量人工白名单" — confirmed with the user on 2026-09-25, using the +// plan's own example list verbatim). These are ADDED on top of the +// corpus-ranked top LIMIT, not competed into it — a general-purpose +// frequency corpus has no reason to rank IME/ERP jargon highly, but they're +// exactly the kind of word a stroke-input user reaching for pinyin as a +// fallback is likely typing. Six of the twelve already clear the natural +// weight cutoff on their own (服务器/数据库/客户/订单/生产/审核) and are +// left for the corpus ranking to place normally; only the other six are +// actually injected here. Pinyin is taken from the rime source's own +// per-character weighting where the word exists there at all; 候选词 isn't +// in any of the three sources (it's IME-specific jargon), so its reading is +// hand-supplied — unambiguous, single-reading, no polyphone risk. +const WHITELIST = [ + { word: '输入法', pinyin: 'shu ru fa' }, + { word: '候选词', pinyin: 'hou xuan ci' }, + { word: '剪贴板', pinyin: 'jian tie ban' }, + { word: '供应商', pinyin: 'gong ying shang' }, + { word: '物料', pinyin: 'wu liao' }, + { word: '主管', pinyin: 'zhu guan' }, +]; +// Synthetic weight for a whitelist word this generator can't find any real +// corpus weight for (currently just 候选词) — deliberately low so it never +// outranks a genuinely common word, just guarantees presence in the list. +const WHITELIST_FALLBACK_WEIGHT = 500; + +// Manually curated corpus-noise blacklist — words that are valid simplified +// Chinese and technically common in the source corpus but aren't real +// day-to-day vocabulary (rime-pinyin-simp is derived from an old Android +// IME's usage corpus, which includes forum/UI boilerplate text). Found by +// spot-checking the generated top-2000 list, not an exhaustive audit — see +// docs/pinyin-lite/phase-0-audit.md's sibling phase notes. Removed from the +// candidate pool entirely (so it can't be re-surfaced by a future weight +// change) rather than just skipped at output time. +const BLACKLIST = new Set([ + '回复日期', // forum/IME-corpus UI template text, not a phrase anyone actually types — ranked #11 in the very first generation run despite that +]); + +function toAsciiPinyin(raw) { + const uUmlaut = raw.replace(/[üǖǘǚǜÜǕǗǙǛ]/g, (ch) => (ch === ch.toUpperCase() ? 'V' : 'v')); + return uUmlaut + .normalize('NFD') + .replace(/[̀-ͯ]/g, '') + .toLowerCase(); +} + +function abbreviate(syllables) { + return syllables.map((s) => s[0] ?? '').join(''); +} + +// --- 1. kMandarin_8105.txt character-set membership (readings not needed here) --- +const CODEPOINT_LINE = /^U\+([0-9A-Fa-f]+):/; +const simplifiedCharSet = new Set(); +for (const line of fs.readFileSync(kMandarinPath, 'utf8').split(/\r?\n/)) { + const match = CODEPOINT_LINE.exec(line); + if (match) simplifiedCharSet.add(String.fromCodePoint(parseInt(match[1], 16))); +} +const isAllSimplified = (word) => [...word].every((ch) => simplifiedCharSet.has(ch)); + +// --- 2. phrase-pinyin-data pinyin.txt: "词语: py1 py2 py3" authoritative readings --- +const authoritativeReading = new Map(); // word -> asciiPinyin[] +for (const line of fs.readFileSync(phrasePinyinPath, 'utf8').split(/\r?\n/)) { + const colon = line.indexOf(':'); + if (colon === -1 || line.startsWith('#')) continue; + const word = line.slice(0, colon).trim(); + const syllables = line.slice(colon + 1).trim().split(/\s+/).map(toAsciiPinyin).filter(Boolean); + if (word && syllables.length === [...word].length) authoritativeReading.set(word, syllables); +} + +// --- 3. rime_simp multi-character rows: base candidate pool + frequency weight --- +const candidates = new Map(); // word -> { syllables: string[], weight: number } +let inDataSection = false; +for (const line of fs.readFileSync(rimeSimpPath, 'utf8').split(/\r?\n/)) { + if (!inDataSection) { + if (line.trim() === '...') inDataSection = true; + continue; + } + const parts = line.split('\t'); + if (parts.length < 3) continue; + const word = parts[0]; + const length = [...word].length; + if (length < MIN_LENGTH || length > MAX_LENGTH) continue; + if (!isAllSimplified(word)) continue; + if (BLACKLIST.has(word)) continue; + const weight = Number(parts[2]); + if (!Number.isFinite(weight) || weight <= 0) continue; + const rimeSyllables = parts[1].trim().split(/\s+/).map(toAsciiPinyin).filter(Boolean); + if (rimeSyllables.length !== length) continue; + // Prefer the authoritative phrase-level reading when available (resolves + // polyphones-in-context rime's own per-character weighting can't see); + // fall back to rime's own reading otherwise. + const syllables = authoritativeReading.get(word) ?? rimeSyllables; + const previous = candidates.get(word); + if (!previous || weight > previous.weight) candidates.set(word, { syllables, weight }); +} + +// --- 4. Rank by weight, keep the top LIMIT --- +const rankedEntries = [...candidates.entries()] + .sort((a, b) => b[1].weight - a[1].weight || a[0].localeCompare(b[0])) + .slice(0, limit); +const ranked = new Map(rankedEntries); + +// --- 5. Add any whitelist word not already present, on top of the LIMIT cap --- +for (const { word, pinyin } of WHITELIST) { + if (ranked.has(word)) continue; + const syllables = pinyin.split(/\s+/).map(toAsciiPinyin); + const weight = candidates.get(word)?.weight ?? WHITELIST_FALLBACK_WEIGHT; + ranked.set(word, { syllables, weight }); +} + +const rows = [...ranked.entries()].map(([word, { syllables, weight }]) => { + const fullPinyin = syllables.join(' '); + const abbreviation = abbreviate(syllables); + return `${word}\t${fullPinyin}\t${abbreviation}\t${weight}`; +}); +fs.writeFileSync(output, rows.join('\n') + '\n', 'utf8'); + +const byLength = new Map(); +for (const [word] of ranked) byLength.set([...word].length, (byLength.get([...word].length) ?? 0) + 1); +console.log(`generated ${ranked.size} phrases (of ${candidates.size} candidates in source), by length: ${[...byLength.entries()].sort().map(([l, n]) => `${l}字=${n}`).join(', ')}`); diff --git a/openless-all/app/scripts/merge-android-overlay-manifest.mjs b/openless-all/app/scripts/merge-android-overlay-manifest.mjs index 72439b8dc..ead2c8308 100644 --- a/openless-all/app/scripts/merge-android-overlay-manifest.mjs +++ b/openless-all/app/scripts/merge-android-overlay-manifest.mjs @@ -13,6 +13,7 @@ const PERMISSIONS = [ 'android.permission.FOREGROUND_SERVICE_MICROPHONE', 'android.permission.FOREGROUND_SERVICE_SPECIAL_USE', 'android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS', + 'android.permission.POST_NOTIFICATIONS', ]; const APPLICATION_SNIPPET = ` @@ -73,6 +74,14 @@ const SERVICE_SNIPPETS = [ android:exported="false" android:excludeFromRecents="true" android:noHistory="false" + android:launchMode="singleTask" + android:theme="@style/Theme.openless" />`, // launcher intent-filter attached by moveLauncherIntentFilterToWarmupActivity() + // IME long-press Logo → native keyboard settings. Must be declared or + // startActivity(OpenLessKeyboardSettingsActivity) fails with ActivityNotFound. + ``, ]; @@ -161,6 +170,39 @@ function mergeApplicationChildren(manifestXml) { return { content, changed }; } +/** Move only the launcher filter, including repair of previously generated manifests. */ +function moveLauncherIntentFilterToWarmupActivity(manifestXml) { + const activities = /]*?)(?:\/>|>([\s\S]*?)<\/activity>)/g; + const filters = /]*>[\s\S]*?<\/intent-filter>/g; + const isLauncher = (filter) => + filter.includes('"android.intent.action.MAIN"') && + filter.includes('"android.intent.category.LAUNCHER"'); + const hosts = new Set([ + '.MainActivity', + '.OpenLessBackendWarmupActivity', + '.MicrophonePermissionActivity', + ]); + const nameOf = (attrs) => attrs.match(/android:name="([^"]+)"/)?.[1]; + let launcher; + for (const [, attrs, body = ''] of manifestXml.matchAll(activities)) { + if (hosts.has(nameOf(attrs))) + launcher ??= [...body.matchAll(filters)].map(([filter]) => filter).find(isLauncher); + } + if (!launcher) return { content: manifestXml, changed: false }; + const content = manifestXml.replace(activities, (original, attrs, body = '') => { + const name = nameOf(attrs); + if (!hosts.has(name)) return original; + const remaining = body.replace(filters, (filter) => (isLauncher(filter) ? '' : filter)).trim(); + if (name === '.OpenLessBackendWarmupActivity') { + attrs = attrs.replace(/android:exported="[^"]*"/, 'android:exported="true"').trimEnd(); + return `${remaining}${launcher}`; + } + if (remaining === body.trim()) return original; + return `${remaining}`; + }); + return { content, changed: content !== manifestXml }; +} + function main() { const { dryRun } = parseArgs(process.argv.slice(2)); @@ -173,7 +215,12 @@ function main() { let content = readFileSync(targetPath, 'utf8'); let changed = false; - for (const step of [mergePermissions, ensureApplicationName, mergeApplicationChildren]) { + for (const step of [ + mergePermissions, + ensureApplicationName, + mergeApplicationChildren, + moveLauncherIntentFilterToWarmupActivity, + ]) { const result = step(content); content = result.content; changed = changed || result.changed; diff --git a/openless-all/app/scripts/merge-android-overlay-manifest.test.mjs b/openless-all/app/scripts/merge-android-overlay-manifest.test.mjs new file mode 100644 index 000000000..2414fb1c5 --- /dev/null +++ b/openless-all/app/scripts/merge-android-overlay-manifest.test.mjs @@ -0,0 +1,48 @@ +import assert from 'node:assert/strict'; +import { mkdtempSync, mkdirSync, readFileSync, writeFileSync, copyFileSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { spawnSync } from 'node:child_process'; +const root = mkdtempSync(join(tmpdir(), 'openless-overlay-manifest-')); +try { + mkdirSync(join(root, 'scripts')); + const script = join(root, 'scripts/merge-android-overlay-manifest.mjs'); + copyFileSync(new URL('./merge-android-overlay-manifest.mjs', import.meta.url), script); + const directory = join(root, 'src-tauri/gen/android/app/src/main'); + mkdirSync(directory, { recursive: true }); + const manifest = join(directory, 'AndroidManifest.xml'); + const filter = + ''; + const other = ''; + for (const source of ['.MainActivity', '.MicrophonePermissionActivity']) { + writeFileSync( + manifest, + `${source === '.MainActivity' ? filter : ''}${other}${source === '.MicrophonePermissionActivity' ? `${filter}` : ''}`, + ); + const run = () => { + const result = spawnSync(process.execPath, [script], { encoding: 'utf8' }); + assert.equal(result.status, 0, result.stderr); + return readFileSync(manifest, 'utf8'); + }; + const first = run(); + assert.equal(run(), first, 'repeated merge must preserve the complete document'); + assert.equal(run(), first); + assert.ok(first.includes(other), 'unrelated filter must survive'); + const parsed = spawnSync( + process.platform === 'win32' ? 'python' : 'python3', + [ + '-c', + `import sys,json,xml.etree.ElementTree as E +ns='{http://schemas.android.com/apk/res/android}' +root=E.fromstring(sys.stdin.read()) +print(json.dumps([a.get(ns+'name') for a in root.findall('./application/activity') if any(c.get(ns+'name')=='android.intent.category.LAUNCHER' for c in a.findall('./intent-filter/category'))]))`, + ], + { input: first, encoding: 'utf8' }, + ); + assert.equal(parsed.status, 0, parsed.stderr); + assert.deepEqual(JSON.parse(parsed.stdout), ['.OpenLessBackendWarmupActivity']); + } +} finally { + rmSync(root, { recursive: true, force: true }); +} +console.log('overlay manifest merge tests passed'); diff --git a/openless-all/app/src-tauri/src/android/jni.rs b/openless-all/app/src-tauri/src/android/jni.rs index fed66b08b..fc09d5a4c 100644 --- a/openless-all/app/src-tauri/src/android/jni.rs +++ b/openless-all/app/src-tauri/src/android/jni.rs @@ -2,29 +2,90 @@ #[cfg(target_os = "android")] pub mod android { - use jni::objects::{JByteArray, JClass, JObject, JString, JValue}; + use std::sync::Mutex; + + use jni::objects::{GlobalRef, JByteArray, JClass, JObject, JString, JValue}; use jni::JNIEnv; use jni::JavaVM; + // Registered from OpenLessRuntimeService.onCreate() (replaced, not just + // set-once) and cleared from its onDestroy(). A GlobalRef stays valid + // for its registrant's *entire* lifecycle. Previously registered from + // OpenLessBackendWarmupActivity instead — a real Context, but one that + // spends nearly all its life backgrounded via moveTaskToBack() and can + // be reclaimed by the OS at any point during that, which eventually + // reproduced a milder version of the same failure mode as the two + // alternatives already ruled out before either of them: + // - ndk_context::android_context() is populated exactly once per + // process (see mobile_runtime::initialize_android_ndk_context_for_audio(), + // needed only so cpal can find *a* context) and goes stale once + // that first Activity is destroyed and a new one takes over in the + // same process, causing Android's CheckJNI to hard-abort the whole + // process on the next JNI call through it ("invalid global + // reference") — confirmed via an on-device tombstone. + // - tao::platform::android::prelude::main_android_context() is a + // *live* lookup, but tao only keeps an Activity in that map while + // it's resumed/foregrounded — empty the moment this Activity + // backgrounds itself, which made every notify_capsule_state() call + // fail (silently dropping dictation/waveform status updates) + // during completely ordinary, crash-free operation. + // A foreground Service that starts/stops in lockstep with the IME being + // active doesn't have either problem: it's never "backgrounded" the way + // an Activity is, and the OS is far less eager to reclaim it. + static ACTIVE_CONTEXT: Mutex> = Mutex::new(None); + + pub fn register_active_activity(env: &mut JNIEnv, activity: &JObject) -> Result<(), String> { + let global = env + .new_global_ref(activity) + .map_err(|error| format!("new_global_ref for Activity: {error}"))?; + let vm = env + .get_java_vm() + .map_err(|error| format!("get_java_vm: {error}"))?; + *ACTIVE_CONTEXT.lock().unwrap() = Some((vm, global)); + Ok(()) + } + + /// Only clears the slot if it still holds `activity` — guards against a + /// stray/late onDestroy() (e.g. from a superseded instance) wiping out + /// a newer Activity's just-registered context. + pub fn unregister_active_activity(env: &mut JNIEnv, activity: &JObject) { + let mut guard = ACTIVE_CONTEXT.lock().unwrap(); + let same = guard + .as_ref() + .map(|(_, global)| { + env.is_same_object(global.as_obj(), activity) + .unwrap_or(true) + }) + .unwrap_or(false); + if same { + *guard = None; + } + } + pub fn with_android_env( f: impl for<'local> FnOnce(&mut JNIEnv<'local>, &JObject<'local>) -> Result, ) -> Result { - let android_context = ndk_context::android_context(); - let vm = unsafe { - JavaVM::from_raw(android_context.vm().cast()) - .map_err(|error| format!("attach Android JVM: {error}"))? - }; + let guard = ACTIVE_CONTEXT.lock().unwrap(); + let (vm, global) = guard + .as_ref() + .ok_or_else(|| "no live Android Activity context registered".to_string())?; let mut env = vm .attach_current_thread() .map_err(|error| format!("attach Android thread: {error}"))?; - let raw_context = android_context.context() as jni::sys::jobject; - if raw_context.is_null() { - return Err("Android context not yet initialized".to_string()); - } - // SAFETY: raw_context is non-null and points to a valid Android Context object - // provided by tao/Tauri; the reference lifetime is valid for the duration of `f`. - let context = unsafe { JObject::from_raw(raw_context) }; - f(&mut env, &context) + let context = global.as_obj(); + f(&mut env, context) + } + + /// Whether an Activity Context is currently registered. Exposed to + /// Kotlin so ensureBackendReady() can tell "backend running but no + /// Activity left to notify" apart from "backend actually cold" — the + /// former survives a long time on its own once the backend has started + /// once (this Activity backgrounding/dying doesn't stop the Rust side + /// running), but leaves every notify_capsule_state() call permanently + /// failing (dictation/waveform status updates silently dropped) until + /// something relaunches this Activity again. + pub fn has_active_activity() -> bool { + ACTIVE_CONTEXT.lock().unwrap().is_some() } pub fn call_static_void( @@ -389,16 +450,14 @@ pub mod android { plaintext: &[u8], aad: &[u8], ) -> Result, AndroidKeystoreFailure> { - call_credential_vault_two_arrays("seal", plaintext, aad) - .map_err(classify_keystore_failure) + call_credential_vault_two_arrays("seal", plaintext, aad).map_err(classify_keystore_failure) } pub(crate) fn keystore_open( sealed: &[u8], aad: &[u8], ) -> Result, AndroidKeystoreFailure> { - call_credential_vault_two_arrays("open", sealed, aad) - .map_err(classify_keystore_failure) + call_credential_vault_two_arrays("open", sealed, aad).map_err(classify_keystore_failure) } pub(crate) fn keystore_delete_key() -> Result<(), AndroidKeystoreFailure> { @@ -750,7 +809,7 @@ pub mod android { ) } - pub fn notify_ime_text<'local>( + pub fn notify_ime_session_event<'local>( env: &mut JNIEnv<'local>, context: &JObject<'local>, text: &str, @@ -760,7 +819,7 @@ pub mod android { env, context, "com.openless.app.OpenLessOverlayBridge", - "onImeTextReady", + "onImeSessionEvent", "(Ljava/lang/String;)V", &[JValue::Object(&text_obj)], ) @@ -782,6 +841,22 @@ pub mod android { ) } + /// Bring the single tracked Tauri host (WarmupActivity) to the front for + /// the embedded mobile QA panel. Never starts bare MainActivity. + pub fn open_qa_host<'local>( + env: &mut JNIEnv<'local>, + context: &JObject<'local>, + ) -> Result<(), String> { + call_static_void_with_context_class( + env, + context, + "com.openless.app.OpenLessBackendWarmupActivity", + "openForQa", + "(Landroid/content/Context;)V", + &[JValue::Object(context)], + ) + } + pub fn accessibility_paste<'local>( env: &mut JNIEnv<'local>, context: &JObject<'local>, @@ -1185,4 +1260,38 @@ pub mod android { } }) } + + /// Write bytes to the user's public Downloads directory without opening + /// the Android document picker. + pub fn write_public_download(file_name: &str, bytes: &[u8]) -> Result { + with_android_env(|env, context| { + let class = load_context_class(env, context, "com.openless.app.OpenLessContentWriter")?; + let file_name_obj = jobject_str(env, file_name)?; + let bytes_array = env + .byte_array_from_slice(bytes) + .map_err(|error| format!("create byte array for public download: {error}"))?; + let bytes_obj = JObject::from(bytes_array); + let value = env + .call_static_method( + class, + "writePublicDownload", + "(Landroid/content/Context;Ljava/lang/String;[B)Ljava/lang/String;", + &[ + JValue::Object(context), + JValue::Object(&file_name_obj), + JValue::Object(&bytes_obj), + ], + ) + .and_then(|value| value.l()) + .map_err(|error| { + format!("call OpenLessContentWriter.writePublicDownload: {error}") + })?; + if value.is_null() { + return Err("Android public download path is empty".to_string()); + } + env.get_string(&JString::from(value)) + .map(|path| path.to_string_lossy().into_owned()) + .map_err(|error| format!("read Android public download path: {error}")) + }) + } } diff --git a/openless-all/app/src-tauri/src/android/mod.rs b/openless-all/app/src-tauri/src/android/mod.rs index 4771a4006..773ba0981 100644 --- a/openless-all/app/src-tauri/src/android/mod.rs +++ b/openless-all/app/src-tauri/src/android/mod.rs @@ -25,6 +25,8 @@ pub use native_bridge::{ refresh_overlay_layout, register_android_backend, register_android_coordinator, replace_overlay, show_overlay, }; +#[cfg(target_os = "android")] +pub use native_bridge::register_android_app_handle; pub use overlay::{ get_android_overlay_status, hide_android_overlay, refresh_android_overlay_if_visible, refresh_android_overlay_layout, replace_android_overlay, request_android_overlay_permission, diff --git a/openless-all/app/src-tauri/src/android/native_bridge.rs b/openless-all/app/src-tauri/src/android/native_bridge.rs index c5c1928ff..9d56ff1a3 100644 --- a/openless-all/app/src-tauri/src/android/native_bridge.rs +++ b/openless-all/app/src-tauri/src/android/native_bridge.rs @@ -12,11 +12,14 @@ use openless_core::{ }; use crate::coordinator::Coordinator; +use crate::persistence::CredentialsVault; use crate::types::{CapsulePayload, CapsuleState}; static COORDINATOR: OnceLock> = OnceLock::new(); static CORE_BACKEND: OnceLock> = OnceLock::new(); static OVERLAY_VISIBLE: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false); +#[cfg(target_os = "android")] +static APP_HANDLE: OnceLock = OnceLock::new(); #[derive(serde::Serialize)] #[serde(rename_all = "camelCase")] @@ -55,15 +58,261 @@ pub fn register_android_coordinator(coordinator: Arc) { let _ = COORDINATOR.set(coordinator); } +fn export_provider_settings_json(include_credentials: bool) -> String { + let result = (|| -> Result { + let backend = CORE_BACKEND.get().ok_or("backend unavailable")?; + let prefs = backend.get_preferences(); + let credentials = CredentialsVault::export_android_channels(include_credentials) + .map_err(|e| e.to_string())?; + let mut channels = credentials.clone(); + for slot in ["asr", "llm"] { + if let Some(channel) = channels[slot].as_object_mut() { + channel.remove("configuration"); + } + } + let selection = serde_json::json!({ + "activeAsrProvider": channels["asr"]["id"], "activeLlmProvider": channels["llm"]["id"], + "activeStylePackId": prefs.active_style_pack_id, + "selectionPolishStylePackId": prefs.selection_polish_style_pack_id, + "channels": channels, + }); + Ok(serde_json::json!({"providerSelection":selection,"credentials":credentials})) + })(); + match result { + Ok(payload) => serde_json::json!({"ok":true,"payload":payload}), + Err(error) => serde_json::json!({"ok":false,"error":error}), + } + .to_string() +} + +fn import_provider_settings_json(json: &str, selection: bool, credentials: bool) -> String { + let mut applied = Vec::new(); + let mut errors = serde_json::Map::new(); + let result = (|| -> Result<(), String> { + let root: serde_json::Value = + serde_json::from_str(json).map_err(|_| "invalid settings file")?; + let version = root["openlessSettingsExportVersion"] + .as_u64() + .ok_or("settings version missing")?; + if !matches!(version, 1 | 2) { + return Err("unsupported settings version".into()); + } + let backend = CORE_BACKEND.get().ok_or("backend unavailable")?; + let coord = COORDINATOR.get().ok_or("coordinator unavailable")?; + let prefs = &root["providerSelection"]; + let channels = if version == 1 { + CredentialsVault::legacy_android_channels( + prefs, + credentials.then_some(&root["credentials"]), + ) + } else { + Ok(if credentials { + root["credentials"].clone() + } else { + prefs["channels"].clone() + }) + } + .map_err(|e| e.to_string())?; + if version == 2 && !channels.is_object() { + return Err("channel identities are missing".into()); + } + if version == 2 && selection { + for (slot, field) in [("asr", "activeAsrProvider"), ("llm", "activeLlmProvider")] { + if prefs[field] != channels[slot]["id"] + || prefs["channels"][slot]["id"] != channels[slot]["id"] + || prefs["channels"][slot]["providerType"] != channels[slot]["providerType"] + { + return Err("selection and credential channel identities disagree".into()); + } + } + } + if !channels.is_null() { + CredentialsVault::import_android_channels(channels.clone(), selection, credentials) + .map_err(|e| e.to_string())?; + tauri::async_runtime::block_on(backend.refresh_and_publish_credentials()) + .map_err(|e| e.message)?; + } + if credentials { + applied.push("credentials"); + } + if selection { + let mut next = backend.get_preferences(); + if let Some(id) = channels["asr"]["id"].as_str() { + next.active_asr_provider = id.into(); + } + if let Some(id) = channels["llm"]["id"].as_str() { + next.active_llm_provider = id.into(); + } + let packs = backend + .list_style_packs(&next.active_style_pack_id) + .map_err(|e| e.message)?; + let mut missing_style = false; + for (key, field) in [ + ("activeStylePackId", &mut next.active_style_pack_id), + ( + "selectionPolishStylePackId", + &mut next.selection_polish_style_pack_id, + ), + ] { + if let Some(id) = prefs[key].as_str() { + if packs.iter().any(|pack| pack.id == id) { + *field = id.into(); + } else { + missing_style = true; + } + } + } + crate::commands::persist_strict_settings(coord, next)?; + if missing_style { + errors.insert( + "providerSelection".into(), + "channel selection restored; missing style packs were kept unchanged".into(), + ); + } else { + applied.push("providerSelection"); + } + } + Ok(()) + })(); + if let Err(error) = result { + for (selected, key) in [ + (selection, "providerSelection"), + (credentials, "credentials"), + ] { + if selected && !applied.contains(&key) { + errors.insert(key.into(), error.clone().into()); + } + } + } + serde_json::json!({"applied":applied,"errors":errors}).to_string() +} + pub fn register_android_backend(backend: Arc) { let _ = CORE_BACKEND.set(backend); } +#[cfg(target_os = "android")] +pub fn register_android_app_handle(app: tauri::AppHandle) { + let _ = APP_HANDLE.set(app); +} + +/// Rebuilds the "main" WebviewWindow for a fresh `OpenLessBackendWarmupActivity` +/// instance created after the OS truly destroyed the previous one (not a +/// config change) — the settings-reopen black screen this fixes. +/// +/// Wry's own `android_setup()` (src/android/mod.rs) only sends a +/// `CreateWebView` message for an Activity instance whose `activity_id` is +/// already a key in its `WEBVIEW_ATTRIBUTES` map — populated once, for +/// whichever Activity happened to be first. A real Activity destroy erases +/// that map entry (`main_pipe.rs`'s `OnDestroy` handler calls +/// `destroy_webview()`), and a brand new Activity instance gets its own, +/// never-seen-before `activity_id` (falls back to `hashCode()` — see +/// `WryActivity.kt`), so it never matches and `onWebViewCreate()` never +/// fires: a permanently black window, confirmed via on-device logcat. +/// +/// Calling this after the new Activity's `onCreate()` mirrors exactly how +/// the very first "main" window gets built at cold app start: Tao's +/// `Window::new()` on Android (`tao::platform_impl::android`) resolves the +/// target Activity via `next_available_activity()` — the first registered +/// Activity that doesn't have a window yet — which is precisely this fresh +/// instance (its own `onActivityCreate()` already registered it with +/// `window_created: false` by the time this runs). No explicit Activity +/// reference needs to cross the JNI boundary for this call. +#[cfg(target_os = "android")] +pub fn ensure_main_webview_window() -> Result<(), String> { + use tauri::Manager; + + let app = APP_HANDLE + .get() + .ok_or_else(|| "AppHandle not yet registered".to_string())?; + // This only ever runs from a fresh OpenLessBackendWarmupActivity.onCreate() + // (see its call site) — which Kotlin only reaches after openSettings() + // already decided no live instance exists (openSettingsIfRunning() + // failed). So any "main" record found here is necessarily stale: on-device + // logs showed Tauri's own WindowManager can keep a "main" entry around + // even after Wry's activity_id-keyed bookkeeping (WEBVIEW_ATTRIBUTES/ + // CONTEXTS/ACTIVITY_PROXY) has already been cleared for the dead Activity + // — trusting "already exists" as "nothing to do" here left the window + // permanently un-rebuilt for the new activity_id (silent black screen, + // no error, nothing to catch). Always destroy-then-rebuild instead + // (see below for why "destroy" alone isn't the whole fix). + if let Some(stale) = app.get_webview_window("main") { + // Both close() and destroy() are asynchronous despite their doc + // comments' wording — confirmed by reading tauri-runtime-wry's + // source: both just send a message through the tao event loop's + // proxy and return immediately (`self.context.proxy.send_event(...)`), + // they don't block on the event loop actually processing it. The + // label is only dropped from Tauri's own AppManager registry when a + // genuine platform WindowEvent::Destroyed later bubbles all the way + // up (AppManager::on_window_close, private to the tauri crate — this + // file has no other way to reach it). Firing destroy() and + // immediately calling build() right after, as before, raced that: + // on-device logs showed the label still present a line later. Give + // the event loop a short bounded window to actually catch up before + // giving up — this runs on the calling JNI thread (Android's UI + // thread, from onCreate()), so it must stay short even though it + // means blocking that thread briefly. + let destroyed = stale.destroy(); + log::info!( + "[android-native] ensure_main_webview_window: destroy() requested for stale main window ok={}", + destroyed.is_ok() + ); + const POLL_INTERVAL: std::time::Duration = std::time::Duration::from_millis(20); + const POLL_ATTEMPTS: u32 = 10; // ~200ms total worst case + let mut still_present = app.get_webview_window("main").is_some(); + let mut attempts = 0; + while still_present && attempts < POLL_ATTEMPTS { + std::thread::sleep(POLL_INTERVAL); + still_present = app.get_webview_window("main").is_some(); + attempts += 1; + } + if still_present { + // Most likely the same still-unexplained case where the stale + // window's webview was itself never actually created in the + // first place — there may be no real platform teardown for + // destroy() to ever complete, so no amount of waiting here + // would help. Surfaced as an error rather than silently + // continuing into a doomed build() call; the caller's own + // stuck-instance watchdog is what actually recovers from this. + log::warn!( + "[android-native] ensure_main_webview_window: main window record still present after {}ms; giving up", + POLL_ATTEMPTS * POLL_INTERVAL.as_millis() as u32 + ); + return Err("stale main window did not clear in time".to_string()); + } else if attempts > 0 { + log::info!( + "[android-native] ensure_main_webview_window: stale main window cleared after {}ms", + attempts * POLL_INTERVAL.as_millis() as u32 + ); + } + } + let result = + tauri::WebviewWindowBuilder::new(app, "main", tauri::WebviewUrl::App("index.html".into())) + .build(); + log::info!( + "[android-native] ensure_main_webview_window: build ok={}", + result.is_ok() + ); + result + .map(|_| ()) + .map_err(|error| format!("rebuild main webview window: {error}")) +} + pub fn notify_capsule_state(payload: &CapsulePayload) { #[cfg(target_os = "android")] { let state = capsule_state_name(payload.state); let message = payload.message.as_deref(); + // #region agent log + let has_ctx = crate::android::jni::android::has_active_activity(); + log::warn!( + "[OpenLessDbg58c22b] {{\"sessionId\":\"58c22b\",\"hypothesisId\":\"A\",\"location\":\"native_bridge::notify_capsule_state\",\"message\":\"notify attempt\",\"data\":{{\"state\":\"{state}\",\"hasCtx\":{has_ctx}}},\"timestamp\":{}}}", + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_millis()) + .unwrap_or(0) + ); + // #endregion if let Err(error) = crate::android::jni::android::with_android_env(|env, context| { crate::android::jni::android::notify_overlay_bridge( env, @@ -74,6 +323,15 @@ pub fn notify_capsule_state(payload: &CapsulePayload) { ) }) { log::warn!("[android-native] notify overlay bridge failed: {error}"); + // #region agent log + log::warn!( + "[OpenLessDbg58c22b] {{\"sessionId\":\"58c22b\",\"hypothesisId\":\"A\",\"location\":\"native_bridge::notify_capsule_state\",\"message\":\"notify failed\",\"data\":{{\"error\":\"{error}\",\"state\":\"{state}\"}},\"timestamp\":{}}}", + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_millis()) + .unwrap_or(0) + ); + // #endregion } } let _ = payload; @@ -189,17 +447,6 @@ pub fn notify_overlay_destroyed() { log::info!("[android-native] overlay service destroyed — OVERLAY_VISIBLE reset"); } -pub fn overlay_trigger_mode_name() -> &'static str { - let Some(coordinator) = COORDINATOR.get() else { - return "background"; - }; - match coordinator.android_overlay_trigger() { - crate::types::AndroidOverlayTrigger::Background => "background", - crate::types::AndroidOverlayTrigger::Keyboard => "keyboard", - crate::types::AndroidOverlayTrigger::Always => "always", - } -} - fn spawn_start_dictation(translation: bool) { let Some(backend) = CORE_BACKEND.get().cloned() else { log::warn!("[android-native] core backend unavailable"); @@ -220,45 +467,182 @@ fn spawn_start_dictation(translation: bool) { }); } -fn spawn_start_dictation_for_ime() { - let Some(backend) = CORE_BACKEND.get().cloned() else { - log::warn!("[android-native] core backend unavailable"); - return; - }; - tauri::async_runtime::spawn(async move { - if let Err(error) = start_core_dictation_for_ime(&backend).await { - log::warn!("[android-native] start_dictation_for_ime failed: {error}"); - } - }); +#[cfg(target_os = "android")] +static IME_REQUEST: std::sync::atomic::AtomicI64 = std::sync::atomic::AtomicI64::new(0); +#[cfg(target_os = "android")] +static IME_SESSION: std::sync::Mutex> = + std::sync::Mutex::new(None); + +#[cfg(target_os = "android")] +#[derive(serde::Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct ImeCommand { + action: String, + request_id: i64, + #[serde(default)] + session_id: Option, + #[serde(default)] + raw: bool, + #[serde(default)] + quick_note: bool, + #[serde(default)] + cloud: bool, } -fn spawn_stop_dictation() { - let Some(backend) = CORE_BACKEND.get().cloned() else { - log::warn!("[android-native] core backend unavailable"); - return; - }; - tauri::async_runtime::spawn(async move { - if let Err(error) = stop_core_dictation(&backend, None).await { - log::warn!("[android-native] stop_dictation failed: {error}"); +#[cfg(target_os = "android")] +fn notify_ime_session(value: serde_json::Value) { + if matches!(value["kind"].as_str(), Some("completed" | "error")) { + let mut owner = IME_SESSION.lock().unwrap(); + if owner + .as_ref() + .is_some_and(|(request, _)| Some(*request) == value["requestId"].as_i64()) + { + *owner = None; } + } + let _ = crate::android::jni::android::with_android_env(|env, context| { + crate::android::jni::android::notify_ime_session_event(env, context, &value.to_string()) }); } #[cfg(target_os = "android")] -fn spawn_stop_dictation_for_ime() { +fn ime_command(json: &str) -> Result<(), String> { + use std::sync::atomic::Ordering; + let command: ImeCommand = serde_json::from_str(json).map_err(|_| "invalid IME command")?; + let backend = CORE_BACKEND.get().cloned().ok_or("backend unavailable")?; + let request = command.request_id; + match command.action.as_str() { + "start" => { + IME_REQUEST.store(request, Ordering::SeqCst); + tauri::async_runtime::spawn(async move { + if IME_REQUEST.load(Ordering::SeqCst) != request { + return; + } + if let Err(error) = ensure_core_started(&backend).await { + notify_ime_session( + serde_json::json!({"kind":"error","requestId":request,"error":format!("{:?}",error.code)}), + ); + return; + } + let mut starting = + Box::pin(backend.start_dictation_with_options(DictationStartOptions { + insert_text: false, + output_target: if command.cloud { + openless_core::DictationOutputTarget::CloudNote + } else { + openless_core::DictationOutputTarget::ForegroundApp + }, + ..Default::default() + })); + // The first poll reserves the Core session before any async preparation. + // Acknowledge that identity now so gestures can classify a Starting session. + let initial = futures_util::poll!(&mut starting); + let reserved = match &initial { + std::task::Poll::Ready(Ok(id)) => Some(*id), + std::task::Poll::Pending => backend.snapshot().dictation.session_id, + _ => None, + }; + if let Some(id) = reserved { + *IME_SESSION.lock().unwrap() = Some((request, id)); + if IME_REQUEST.load(Ordering::SeqCst) == request { + notify_ime_session( + serde_json::json!({"kind":"started","requestId":request,"sessionId":id}), + ); + } else { + let _ = backend.cancel_dictation(Some(id)).await; + } + } + let result = match initial { + std::task::Poll::Ready(result) => result, + std::task::Poll::Pending => starting.await, + }; + match result { + Ok(id) if IME_REQUEST.load(Ordering::SeqCst) != request => { + let _ = backend.cancel_dictation(Some(id)).await; + } + Err(error) => notify_ime_session( + serde_json::json!({"kind":"error","requestId":request,"error":format!("{:?}",error.code)}), + ), + _ => {} + } + }); + } + "cloud" => { + if IME_REQUEST.load(Ordering::SeqCst) != request { + return Err("stale IME request".into()); + } + backend + .set_dictation_cloud_note( + command.session_id.ok_or("session missing")?, + command.cloud, + ) + .map_err(|e| e.message)?; + } + "stop" => { + let id = command.session_id.ok_or("session missing")?; + if IME_REQUEST.load(Ordering::SeqCst) != request { + return Err("stale IME request".into()); + } + let cloud = backend.dictation_output_target() + == Some(openless_core::DictationOutputTarget::CloudNote); + tauri::async_runtime::spawn(async move { + let result = backend + .stop_dictation_session_with_options( + Some(id), + DictationStopOptions { + raw_requested: Some(command.raw), + quick_note: if cloud { + None + } else { + Some(command.quick_note) + }, + ..Default::default() + }, + None, + ) + .await; + match result { + Ok(result) => notify_ime_session( + serde_json::json!({"kind":"completed","requestId":request,"sessionId":id,"cloud":cloud,"quickNote":command.quick_note,"text":result.polished_text}), + ), + Err(error) => notify_ime_session( + serde_json::json!({"kind":"error","requestId":request,"sessionId":id,"error":format!("{:?}",error.code)}), + ), + } + }); + } + "cancel" => { + let _ = IME_REQUEST.compare_exchange(request, 0, Ordering::SeqCst, Ordering::SeqCst); + let id = command.session_id.or_else(|| { + IME_SESSION + .lock() + .unwrap() + .filter(|(owner, _)| *owner == request) + .map(|(_, id)| id) + }); + if let Some(id) = id { + tauri::async_runtime::spawn(async move { + if let Err(error) = backend.cancel_dictation(Some(id)).await { + notify_ime_session( + serde_json::json!({"kind":"error","requestId":request,"sessionId":id,"error":format!("{:?}",error.code)}), + ); + } + }); + } + } + _ => return Err("unknown IME action".into()), + } + Ok(()) +} + +fn spawn_stop_dictation() { let Some(backend) = CORE_BACKEND.get().cloned() else { log::warn!("[android-native] core backend unavailable"); return; }; tauri::async_runtime::spawn(async move { - match stop_core_dictation(&backend, None).await { - Ok(result) => { - let text = result.polished_text; - let _ = crate::android::jni::android::with_android_env(|env, context| { - crate::android::jni::android::notify_ime_text(env, context, &text) - }); - } - Err(error) => log::warn!("[android-native] stop_dictation_for_ime failed: {error}"), + if let Err(error) = stop_core_dictation(&backend, None, None).await { + log::warn!("[android-native] stop_dictation failed: {error}"); } }); } @@ -269,7 +653,7 @@ fn spawn_stop_dictation_with_translation(translation: bool) { return; }; tauri::async_runtime::spawn(async move { - if let Err(error) = stop_core_dictation(&backend, Some(translation)).await { + if let Err(error) = stop_core_dictation(&backend, Some(translation), None).await { log::warn!("[android-native] stop_dictation_with_translation failed: {error}"); } }); @@ -299,10 +683,37 @@ fn spawn_cancel_dictation() { }); } +/// Records a spoken correction as a global Dictionary entry instead of a +/// CorrectionRule — the IME's "edit result"/clipboard-correction flows used +/// to auto-generate a pattern->replacement rule from every edit; that's now +/// hand-maintained only (desktop's Corrections settings page), and these +/// flows just remember the corrected word/phrase itself, the same +/// mechanism `add_vocab` (commands/dictionary.rs) exposes to the desktop +/// Dictionary UI. `add_vocabulary_if_absent` skips silently if the phrase +/// is already known, so repeatedly correcting the same word never piles up +/// duplicate entries. +fn spawn_add_vocabulary_word(phrase: String) { + let Some(backend) = CORE_BACKEND.get().cloned() else { + log::warn!("[android-native] core backend unavailable"); + return; + }; + if phrase.trim().is_empty() { + return; + } + tauri::async_runtime::spawn(async move { + if let Err(error) = backend.add_vocabulary_if_absent(phrase, None) { + log::warn!("[android-native] add_vocabulary_if_absent failed: {error}"); + } + }); +} + /// Records a correction rule from the IME's "edit result" flow, so a /// misrecognition the user just fixed by hand also gets fixed automatically /// for future dictations. Uses the same CorrectionRuleStore desktop's -/// Corrections settings page writes to. +/// Corrections settings page writes to. Kept alongside +/// spawn_add_vocabulary_word() above rather than replaced by it — this +/// branch's own IME flows write to the Dictionary now, but upstream/beta's +/// other call sites still rely on the CorrectionRule store. fn spawn_add_correction_rule(pattern: String, replacement: String) { let Some(backend) = CORE_BACKEND.get().cloned() else { log::warn!("[android-native] core backend unavailable"); @@ -359,9 +770,63 @@ fn correction_rule_patterns_json() -> String { } } +/// Every existing Dictionary entry's phrase — same purpose as +/// correction_rule_patterns_json() above, for the clipboard swipe-left +/// zone's "add"/"remove" label and the row's "✎" marker, now that adding +/// from the IME writes to the Dictionary instead of a CorrectionRule. +fn vocabulary_phrases_json() -> String { + let Some(backend) = CORE_BACKEND.get() else { + return "[]".to_string(); + }; + match backend.list_vocabulary() { + Ok(entries) => { + let phrases: Vec<&str> = entries.iter().map(|entry| entry.phrase.as_str()).collect(); + serde_json::to_string(&phrases).unwrap_or_else(|_| "[]".to_string()) + } + Err(error) => { + log::warn!("[android-native] list_vocabulary failed: {error}"); + "[]".to_string() + } + } +} + +/// Removes every Dictionary entry whose phrase exactly matches — the +/// clipboard swipe-left "remove" action's counterpart to +/// spawn_add_vocabulary_word() above. Idempotent: no match is a silent +/// no-op, same as remove_vocabulary(id) itself. +fn spawn_remove_vocabulary_word(phrase: String) { + let Some(backend) = CORE_BACKEND.get().cloned() else { + log::warn!("[android-native] core backend unavailable"); + return; + }; + if phrase.is_empty() { + return; + } + tauri::async_runtime::spawn(async move { + let ids: Vec = match backend.list_vocabulary() { + Ok(entries) => entries + .into_iter() + .filter(|entry| entry.phrase == phrase) + .map(|entry| entry.id) + .collect(), + Err(error) => { + log::warn!("[android-native] list_vocabulary for remove failed: {error}"); + return; + } + }; + for id in ids { + if let Err(error) = backend.remove_vocabulary(&id) { + log::warn!("[android-native] remove_vocabulary failed: {error}"); + } + } + }); +} + /// Removes every correction rule whose pattern exactly matches — the /// clipboard swipe-left "remove" action. Idempotent like the underlying -/// store's remove(id): no match is a silent no-op. +/// store's remove(id): no match is a silent no-op. Kept alongside +/// spawn_remove_vocabulary_word() above for the same reason +/// spawn_add_correction_rule() is kept alongside spawn_add_vocabulary_word(). fn spawn_remove_correction_rule(pattern: String) { let Some(backend) = CORE_BACKEND.get().cloned() else { log::warn!("[android-native] core backend unavailable"); @@ -412,25 +877,16 @@ async fn start_core_dictation( .map(|_| ()) } -async fn start_core_dictation_for_ime(backend: &OpenLessBackend) -> Result<(), BackendError> { - ensure_core_started(backend).await?; - backend - .start_dictation_with_options(DictationStartOptions { - insert_text: false, - ..DictationStartOptions::default() - }) - .await - .map(|_| ()) -} - async fn stop_core_dictation( backend: &OpenLessBackend, translation: Option, + raw: Option, ) -> Result { ensure_core_started(backend).await?; backend .stop_dictation_with_options(DictationStopOptions { translation_requested: translation, + raw_requested: raw, quick_note: Some(false), }) .await @@ -441,6 +897,7 @@ async fn stop_core_dictation_as_quick_note(backend: &OpenLessBackend) -> Result< backend .stop_dictation_with_options(DictationStopOptions { translation_requested: None, + raw_requested: None, quick_note: Some(true), }) .await @@ -462,19 +919,6 @@ fn spawn_switch_style_pack() { coordinator.switch_to_previous_style_pack(); } -fn spawn_open_qa_from_overlay() { - let Some(coordinator) = COORDINATOR.get().cloned() else { - log::warn!("[android-native] coordinator unavailable"); - return; - }; - log::info!("[android-native] open_qa_from_overlay requested"); - tauri::async_runtime::spawn(async move { - if let Err(error) = coordinator.open_qa_from_overlay().await { - log::warn!("[android-native] open_qa_from_overlay failed: {error}"); - } - }); -} - fn spawn_finalize_qa_from_overlay() { let Some(coordinator) = COORDINATOR.get().cloned() else { log::warn!("[android-native] coordinator unavailable"); @@ -518,19 +962,33 @@ mod jni_exports { } #[no_mangle] - pub unsafe extern "system" fn Java_com_openless_app_OpenLessNative_nativeStartDictation( - _env: *mut JNIEnv, + pub unsafe extern "system" fn Java_com_openless_app_OpenLessNative_nativeImeCommand( + env: *mut JNIEnv, _class: JClass, - ) { - spawn_start_dictation(false); + json: jstring, + ) -> jstring { + let Ok(mut env) = JniEnv::from_raw(env) else { + return std::ptr::null_mut(); + }; + let input = env + .get_string(&JString::from_raw(json)) + .map(|v| String::from(v)); + let result = input + .map_err(|_| "invalid JNI input".to_string()) + .and_then(|json| ime_command(&json)); + let response = match result { + Ok(()) => serde_json::json!({"ok":true}), + Err(error) => serde_json::json!({"ok":false,"error":error}), + }; + crate::android::jni::android::export_jstring(&mut env, &response.to_string()) } #[no_mangle] - pub unsafe extern "system" fn Java_com_openless_app_OpenLessNative_nativeStartDictationForIme( + pub unsafe extern "system" fn Java_com_openless_app_OpenLessNative_nativeStartDictation( _env: *mut JNIEnv, _class: JClass, ) { - spawn_start_dictation_for_ime(); + spawn_start_dictation(false); } #[no_mangle] @@ -550,14 +1008,6 @@ mod jni_exports { spawn_stop_dictation(); } - #[no_mangle] - pub unsafe extern "system" fn Java_com_openless_app_OpenLessNative_nativeStopDictationForIme( - _env: *mut JNIEnv, - _class: JClass, - ) { - spawn_stop_dictation_for_ime(); - } - #[no_mangle] pub unsafe extern "system" fn Java_com_openless_app_OpenLessNative_nativeStopDictationWithTranslation( _env: *mut JNIEnv, @@ -583,6 +1033,28 @@ mod jni_exports { spawn_cancel_dictation(); } + #[no_mangle] + pub unsafe extern "system" fn Java_com_openless_app_OpenLessNative_nativeAddVocabularyWord( + env: *mut JNIEnv, + _class: JClass, + phrase: jstring, + ) { + let mut jni_env = match JniEnv::from_raw(env) { + Ok(env) => env, + Err(error) => { + log::warn!( + "[android-native] attach JNI env for add_vocabulary_word failed: {error}" + ); + return; + } + }; + let phrase_str: String = jni_env + .get_string(&JString::from_raw(phrase)) + .map(|value| value.into()) + .unwrap_or_default(); + spawn_add_vocabulary_word(phrase_str); + } + #[no_mangle] pub unsafe extern "system" fn Java_com_openless_app_OpenLessNative_nativeAddCorrectionRule( env: *mut JNIEnv, @@ -622,6 +1094,40 @@ mod jni_exports { } } + #[no_mangle] + pub unsafe extern "system" fn Java_com_openless_app_OpenLessNative_nativeVocabularyPhrases( + env: *mut JNIEnv, + _class: JClass, + ) -> jstring { + let response = vocabulary_phrases_json(); + match JniEnv::from_raw(env) { + Ok(mut env) => crate::android::jni::android::export_jstring(&mut env, &response), + Err(_) => std::ptr::null_mut(), + } + } + + #[no_mangle] + pub unsafe extern "system" fn Java_com_openless_app_OpenLessNative_nativeRemoveVocabularyWord( + env: *mut JNIEnv, + _class: JClass, + phrase: jstring, + ) { + let mut jni_env = match JniEnv::from_raw(env) { + Ok(env) => env, + Err(error) => { + log::warn!( + "[android-native] attach JNI env for remove_vocabulary_word failed: {error}" + ); + return; + } + }; + let phrase_str: String = jni_env + .get_string(&JString::from_raw(phrase)) + .map(|value| value.into()) + .unwrap_or_default(); + spawn_remove_vocabulary_word(phrase_str); + } + #[no_mangle] pub unsafe extern "system" fn Java_com_openless_app_OpenLessNative_nativeRemoveCorrectionRule( env: *mut JNIEnv, @@ -664,14 +1170,6 @@ mod jni_exports { spawn_switch_style_pack(); } - #[no_mangle] - pub unsafe extern "system" fn Java_com_openless_app_OpenLessNative_nativeOpenQaFromOverlay( - _env: *mut JNIEnv, - _class: JClass, - ) { - spawn_open_qa_from_overlay(); - } - #[no_mangle] pub unsafe extern "system" fn Java_com_openless_app_OpenLessNative_nativeFinalizeQaFromOverlay( _env: *mut JNIEnv, @@ -680,82 +1178,116 @@ mod jni_exports { spawn_finalize_qa_from_overlay(); } + // Registered from OpenLessRuntimeService.onCreate()/onDestroy() (a + // foreground Service that starts/stops in lockstep with the IME being + // active) so with_android_env() (every Rust->Kotlin JNI call, including + // the dictation/waveform capsule notifications) has a Context valid for + // that Service's whole lifecycle. This was previously registered from + // OpenLessBackendWarmupActivity instead — an Activity that spends + // nearly all its life backgrounded via moveTaskToBack() and can be + // reclaimed by the OS at any point during that, which is exactly one of + // the two failure modes this mechanism was already built to avoid (see + // android::jni::android::ACTIVE_CONTEXT's doc comment) — it just hadn't + // been pointed at a registrant stable enough to actually avoid it. + // register_active_activity()/unregister_active_activity() only ever + // stored a generic JObject, so this parameter never actually needed to + // be an Activity specifically. #[no_mangle] - pub unsafe extern "system" fn Java_com_openless_app_OpenLessNative_nativeShowOverlay( + pub unsafe extern "system" fn Java_com_openless_app_OpenLessNative_nativeRegisterActivityContext( env: *mut JNIEnv, _class: JClass, context: JObject, ) { - let _ = with_jni_context(env, context, |env, context| { - show_overlay_with_context(env, context) + let result = with_jni_context(env, context, |env, context| { + crate::android::jni::android::register_active_activity(env, context) }); + if let Err(error) = result { + log::warn!("[android-native] register activity context failed: {error}"); + } } #[no_mangle] - pub unsafe extern "system" fn Java_com_openless_app_OpenLessNative_nativeHideOverlay( + pub unsafe extern "system" fn Java_com_openless_app_OpenLessNative_nativeUnregisterActivityContext( env: *mut JNIEnv, _class: JClass, context: JObject, ) { let _ = with_jni_context(env, context, |env, context| { - hide_overlay_with_context(env, context) + crate::android::jni::android::unregister_active_activity(env, context); + Ok(()) }); } + /// Lets ensureBackendReady() tell "backend healthy but no Activity + /// left to notify" apart from "backend actually cold" — see + /// android::jni::android::has_active_activity()'s doc comment. #[no_mangle] - pub unsafe extern "system" fn Java_com_openless_app_OpenLessNative_nativeCanDrawOverlays( - env: *mut JNIEnv, + pub unsafe extern "system" fn Java_com_openless_app_OpenLessNative_nativeHasRegisteredActivityContext( + _env: *mut JNIEnv, _class: JClass, - context: JObject, ) -> jboolean { - let visible = with_jni_context(env, context, |env, context| { - crate::android::jni::android::can_draw_overlays(env, context) - }) - .unwrap_or(false); - crate::android::jni::android::export_jboolean(visible) + crate::android::jni::android::has_active_activity() as jboolean } + /// 供 Kotlin overlay service 的 onDestroy() 调用,将 OVERLAY_VISIBLE 清除。 + /// 解决 OS 杀死服务时 Rust 端状态永久失同步的问题。 #[no_mangle] - pub unsafe extern "system" fn Java_com_openless_app_OpenLessNative_nativeIsOverlayVisible( + pub unsafe extern "system" fn Java_com_openless_app_OpenLessNative_nativeNotifyOverlayDestroyed( _env: *mut JNIEnv, _class: JClass, - ) -> jboolean { - crate::android::jni::android::export_jboolean(is_overlay_visible()) + ) { + notify_overlay_destroyed(); } + /// Called from OpenLessBackendWarmupActivity.onCreate() on every fresh + /// instance — see ensure_main_webview_window()'s doc comment for why + /// this is what actually fixes the settings-reopen black screen. #[no_mangle] - pub unsafe extern "system" fn Java_com_openless_app_OpenLessNative_nativeGetOverlayTriggerMode( - env: *mut JNIEnv, + pub unsafe extern "system" fn Java_com_openless_app_OpenLessNative_nativeEnsureMainWebviewWindow( + _env: *mut JNIEnv, _class: JClass, - ) -> jstring { - let mode = overlay_trigger_mode_name(); - match JniEnv::from_raw(env) { - Ok(mut env) => crate::android::jni::android::export_jstring(&mut env, mode), - Err(_) => std::ptr::null_mut(), + ) -> jboolean { + match super::ensure_main_webview_window() { + Ok(()) => crate::android::jni::android::export_jboolean(true), + Err(error) => { + log::warn!("[android-native] ensure_main_webview_window failed: {error}"); + crate::android::jni::android::export_jboolean(false) + } } } #[no_mangle] - pub unsafe extern "system" fn Java_com_openless_app_OpenLessNative_nativeNotifyOverlayPermissionChanged( + pub unsafe extern "system" fn Java_com_openless_app_OpenLessNative_nativeExportProviderSettings( env: *mut JNIEnv, _class: JClass, - context: JObject, - ) { - if overlay_trigger_mode_name() == "always" { - let _ = with_jni_context(env, context, |env, context| { - show_overlay_with_context(env, context) - }); + include_credentials: jboolean, + ) -> jstring { + let response = export_provider_settings_json(include_credentials != 0); + match JniEnv::from_raw(env) { + Ok(mut env) => crate::android::jni::android::export_jstring(&mut env, &response), + Err(_) => std::ptr::null_mut(), } } - /// 供 Kotlin overlay service 的 onDestroy() 调用,将 OVERLAY_VISIBLE 清除。 - /// 解决 OS 杀死服务时 Rust 端状态永久失同步的问题。 #[no_mangle] - pub unsafe extern "system" fn Java_com_openless_app_OpenLessNative_nativeNotifyOverlayDestroyed( - _env: *mut JNIEnv, + pub unsafe extern "system" fn Java_com_openless_app_OpenLessNative_nativeImportProviderSettings( + env: *mut JNIEnv, _class: JClass, - ) { - notify_overlay_destroyed(); + json: jstring, + selection: jboolean, + credentials: jboolean, + ) -> jstring { + let Ok(mut env) = JniEnv::from_raw(env) else { + return std::ptr::null_mut(); + }; + let json = env + .get_string(&JString::from_raw(json)) + .map(|value| String::from(value)); + let response = match json { + Ok(json) => import_provider_settings_json(&json, selection != 0, credentials != 0), + Err(_) => serde_json::json!({"applied":[],"errors":{"providerSelection":"invalid JNI input","credentials":"invalid JNI input"}}).to_string(), + }; + crate::android::jni::android::export_jstring(&mut env, &response) } } @@ -824,7 +1356,9 @@ mod tests { serde_json::from_str(&android_backend_snapshot_response(Some(&backend))).unwrap(); assert_eq!(ready["ok"], true); let translated_session = backend.snapshot().dictation.session_id.unwrap(); - stop_core_dictation(&backend, Some(true)).await.unwrap(); + stop_core_dictation(&backend, Some(true), None) + .await + .unwrap(); start_core_dictation(&backend, true).await.unwrap(); let cancelled_session = backend.snapshot().dictation.session_id.unwrap(); cancel_core_dictation(&backend).await.unwrap(); diff --git a/openless-all/app/src-tauri/src/android/overlay.rs b/openless-all/app/src-tauri/src/android/overlay.rs index 4b45b4559..b8bec83d7 100644 --- a/openless-all/app/src-tauri/src/android/overlay.rs +++ b/openless-all/app/src-tauri/src/android/overlay.rs @@ -105,10 +105,24 @@ mod android_impl { use crate::android::types::{AndroidOverlayPermissionState, AndroidOverlayStatus as Status}; pub fn get_android_overlay_status() -> AndroidOverlayStatus { - let granted = crate::android::jni::android::with_android_env(|env, context| { + let granted = match crate::android::jni::android::with_android_env(|env, context| { crate::android::jni::android::can_draw_overlays(env, context) - }) - .unwrap_or(false); + }) { + Ok(granted) => granted, + Err(error) => { + // #region agent log + log::warn!( + "[OpenLessDbg58c22b] {{\"sessionId\":\"58c22b\",\"hypothesisId\":\"A\",\"location\":\"overlay::get_android_overlay_status\",\"message\":\"status query failed\",\"data\":{{\"error\":\"{error}\"}},\"timestamp\":{}}}", + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_millis()) + .unwrap_or(0) + ); + // #endregion + log::warn!("[android-overlay] status query failed: {error}"); + false + } + }; Status { permission: if granted { AndroidOverlayPermissionState::Granted diff --git a/openless-all/app/src-tauri/src/commands/misc.rs b/openless-all/app/src-tauri/src/commands/misc.rs index 2c92bcba1..7b3a4b3cd 100644 --- a/openless-all/app/src-tauri/src/commands/misc.rs +++ b/openless-all/app/src-tauri/src/commands/misc.rs @@ -207,6 +207,17 @@ pub fn export_error_log(target_path: String) -> Result<(), String> { } } +/// Android:直接把当前会话日志写入公共 Downloads,绕过部分 ROM 上 +/// 无法正常弹出的 CREATE_DOCUMENT / SAF 保存对话框。 +#[cfg(target_os = "android")] +#[tauri::command] +pub fn export_error_log_to_downloads(file_name: String) -> Result { + let src = resolve_openless_log_path()?; + let bytes = std::fs::read(&src).map_err(|e| format!("读取日志失败:{e}"))?; + crate::android::jni::android::write_public_download(&file_name, &bytes) + .map_err(|e| format!("导出日志失败:{e}")) +} + fn resolve_openless_log_path() -> Result { let mut candidates = Vec::new(); #[cfg(target_os = "android")] diff --git a/openless-all/app/src-tauri/src/commands/settings.rs b/openless-all/app/src-tauri/src/commands/settings.rs index 08b3e8413..2e567456e 100644 --- a/openless-all/app/src-tauri/src/commands/settings.rs +++ b/openless-all/app/src-tauri/src/commands/settings.rs @@ -235,9 +235,14 @@ pub async fn set_settings( coord: CoordinatorState<'_>, app: AppHandle, mut prefs: UserPreferences, + edits: Option>, ) -> Result { // 捕获旧值用于远程输入服务的 diff(persist 后端口/开关变化时启停/重启)。 let remote_prev = coord.backend().get_preferences(); + if let Some(edits) = &edits { + prefs = openless_core::preference_patch::patch_preferences(&prefs, edits) + .map_err(|e| e.to_string())?; + } let packs = coord .backend() .list_style_packs(&prefs.active_style_pack_id) @@ -248,7 +253,11 @@ pub async fn set_settings( // 广播给所有 webview。issue #205:QaPanel 跑在独立 webview, // 没有 HotkeySettingsContext,必须靠事件感知录音键变化,否则面板可见时 // 用户改键会让浮窗里的 "{recordHotkey}" 文案一直停留在旧值。 - persist_settings_preserving_update_channel(&*coord, prefs)?; + if let Some(edits) = edits { + persist_setting_fields(&coord, &edits)?; + } else { + persist_settings_preserving_update_channel(&coord, prefs)?; + } let prefs = coord.backend().get_preferences(); // 保存即同步胶囊样式原子:下一次录音的入场帧就携带新样式,不依赖 emit_capsule // 主线程闭包的 ~30Hz 同步(Windows 主线程拥塞时闭包延迟 → 整场显示旧样式)。 @@ -299,8 +308,13 @@ pub async fn set_settings( pub async fn set_settings( coord: CoordinatorState<'_>, mut prefs: UserPreferences, + edits: Option>, ) -> Result { let previous = coord.backend().get_preferences(); + if let Some(edits) = &edits { + prefs = openless_core::preference_patch::patch_preferences(&prefs, edits) + .map_err(|e| e.to_string())?; + } let packs = coord .backend() .list_style_packs(&prefs.active_style_pack_id) @@ -308,7 +322,11 @@ pub async fn set_settings( sync_style_pack_preferences(&mut prefs, &packs); prefs.android_overlay_trigger = prefs.android_overlay_trigger.normalized(); invalidate_llm_tests_if_thinking_changed(&coord, &previous, &prefs).await?; - persist_settings_preserving_update_channel(&*coord, prefs)?; + if let Some(edits) = edits { + persist_setting_fields(&coord, &edits)?; + } else { + persist_settings_preserving_update_channel(&coord, prefs)?; + } let prefs = coord.backend().get_preferences(); // 保存即同步胶囊样式原子(Android 通知胶囊 payload 同源,见 emit_capsule)。 coord.sync_capsule_style_from_preferences(); @@ -772,3 +790,78 @@ pub(crate) fn replace_dictation_hotkey( .map(|_| ()) .map_err(settings_save_error) } + +#[derive(serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SettingsSnapshot { + pub preferences: UserPreferences, + pub revision: u64, +} + +#[tauri::command] +pub fn get_settings_snapshot(core: CoreState<'_>) -> Result { + settings_snapshot(&core) +} + +fn settings_snapshot(backend: &openless_core::OpenLessBackend) -> Result { + for _ in 0..3 { + let revision = backend.snapshot().preferences_revision; + let preferences = backend.get_preferences(); + if backend.snapshot().preferences_revision == revision { + return Ok(SettingsSnapshot { + preferences, + revision, + }); + } + } + Err("settings are changing; retry the read".into()) +} + +fn persist_setting_fields( + coord: &Coordinator, + edits: &std::collections::BTreeMap, +) -> Result<(), String> { + let _host_guard = coord.lock_settings_host(); + openless_core::preference_patch::update_fields( + edits, + || { + ( + coord.backend().snapshot().preferences_revision, + coord.backend().get_preferences(), + ) + }, + |mut prefs, revision| { + preserve_update_channel_preferences(&mut prefs, &coord.backend().get_preferences()); + coord.backend().update_settings( + prefs, + openless_core::SettingsUpdateOptions::SETTINGS_DOCUMENT.at_revision(revision), + &TauriSettingsRuntime::new(coord), + ) + }, + ) + .map(|_| ()) + .map_err(settings_save_error) +} + +#[cfg(not(mobile))] +#[tauri::command] +pub async fn update_setting_fields( + coord: CoordinatorState<'_>, + app: AppHandle, + edits: std::collections::BTreeMap, +) -> Result { + let prefs = coord.backend().get_preferences(); + set_settings(coord.clone(), app, prefs, Some(edits)).await?; + settings_snapshot(&coord.backend()) +} + +#[cfg(mobile)] +#[tauri::command] +pub async fn update_setting_fields( + coord: CoordinatorState<'_>, + edits: std::collections::BTreeMap, +) -> Result { + let prefs = coord.backend().get_preferences(); + set_settings(coord.clone(), prefs, Some(edits)).await?; + settings_snapshot(&coord.backend()) +} diff --git a/openless-all/app/src-tauri/src/core_adapters.rs b/openless-all/app/src-tauri/src/core_adapters.rs index 046819840..2e91b31dc 100644 --- a/openless-all/app/src-tauri/src/core_adapters.rs +++ b/openless-all/app/src-tauri/src/core_adapters.rs @@ -2624,9 +2624,9 @@ impl AudioRecorder for TauriAudioRecorder { preview.stop(); } } - let permanent_archive = !matches!( + let permanent_archive = matches!( context.output_target, - openless_core::DictationOutputTarget::ForegroundApp + openless_core::DictationOutputTarget::QuickNote | openless_core::DictationOutputTarget::Undecided ); // Undecided Android captures use the permanent quick-note spool // until the terminal tap/gesture classifies the session. diff --git a/openless-all/app/src-tauri/src/external_url.rs b/openless-all/app/src-tauri/src/external_url.rs index 0fdebeabf..aa492a2c8 100644 --- a/openless-all/app/src-tauri/src/external_url.rs +++ b/openless-all/app/src-tauri/src/external_url.rs @@ -12,56 +12,55 @@ pub fn open_external_url(url: &str) -> Result<(), String> { fn platform_open_external_url(url: &str) -> Result<(), String> { use jni::objects::{JObject, JValue}; - let android_context = ndk_context::android_context(); - let vm = unsafe { - jni::JavaVM::from_raw(android_context.vm().cast()) - .map_err(|error| format!("attach Android JVM: {error}"))? - }; - let mut env = vm - .attach_current_thread() - .map_err(|error| format!("attach Android thread: {error}"))?; - let context = unsafe { JObject::from_raw(android_context.context() as jni::sys::jobject) }; + // Routed through the shared registered-Activity Context (see + // android::jni::android::ACTIVE_CONTEXT's doc comment) rather than + // deriving a Context here directly — both alternatives tried for that + // (a once-only cached ndk_context registry, and tao's live-but- + // resumed-only tracked-Activity map) have real failure modes for a + // Context used outside the exact moment an Activity is both alive and + // foregrounded. + crate::android::jni::android::with_android_env(|env, context| { + let action = env + .new_string("android.intent.action.VIEW") + .map_err(|error| format!("create Intent action: {error}"))?; + let url = env + .new_string(url) + .map_err(|error| format!("create URL string: {error}"))?; + let uri = env + .call_static_method( + "android/net/Uri", + "parse", + "(Ljava/lang/String;)Landroid/net/Uri;", + &[JValue::Object(&JObject::from(url))], + ) + .and_then(|value| value.l()) + .map_err(|error| format!("parse URL into Android Uri: {error}"))?; + let intent = env + .new_object( + "android/content/Intent", + "(Ljava/lang/String;Landroid/net/Uri;)V", + &[JValue::Object(&JObject::from(action)), JValue::Object(&uri)], + ) + .map_err(|error| format!("create Android Intent: {error}"))?; - let action = env - .new_string("android.intent.action.VIEW") - .map_err(|error| format!("create Intent action: {error}"))?; - let url = env - .new_string(url) - .map_err(|error| format!("create URL string: {error}"))?; - let uri = env - .call_static_method( - "android/net/Uri", - "parse", - "(Ljava/lang/String;)Landroid/net/Uri;", - &[JValue::Object(&JObject::from(url))], + // Context may be an application context; NEW_TASK keeps startActivity valid there. + env.call_method( + &intent, + "addFlags", + "(I)Landroid/content/Intent;", + &[JValue::Int(0x10000000)], ) - .and_then(|value| value.l()) - .map_err(|error| format!("parse URL into Android Uri: {error}"))?; - let intent = env - .new_object( - "android/content/Intent", - "(Ljava/lang/String;Landroid/net/Uri;)V", - &[JValue::Object(&JObject::from(action)), JValue::Object(&uri)], + .map_err(|error| format!("set Android Intent flags: {error}"))?; + env.call_method( + context, + "startActivity", + "(Landroid/content/Intent;)V", + &[JValue::Object(&intent)], ) - .map_err(|error| format!("create Android Intent: {error}"))?; + .map_err(|error| format!("start Android URL activity: {error}"))?; - // Context may be an application context; NEW_TASK keeps startActivity valid there. - env.call_method( - &intent, - "addFlags", - "(I)Landroid/content/Intent;", - &[JValue::Int(0x10000000)], - ) - .map_err(|error| format!("set Android Intent flags: {error}"))?; - env.call_method( - &context, - "startActivity", - "(Landroid/content/Intent;)V", - &[JValue::Object(&intent)], - ) - .map_err(|error| format!("start Android URL activity: {error}"))?; - - Ok(()) + Ok(()) + }) } #[cfg(not(target_os = "android"))] diff --git a/openless-all/app/src-tauri/src/lib.rs b/openless-all/app/src-tauri/src/lib.rs index 36487b9e1..eb112dacd 100644 --- a/openless-all/app/src-tauri/src/lib.rs +++ b/openless-all/app/src-tauri/src/lib.rs @@ -212,6 +212,8 @@ macro_rules! app_invoke_handler_desktop { tauri::generate_handler![ commands::get_startup_snapshot, commands::get_settings, + commands::get_settings_snapshot, + commands::update_setting_fields, commands::get_default_style_system_prompts, commands::set_settings, commands::get_remote_input_status, @@ -484,6 +486,8 @@ macro_rules! app_invoke_handler_mobile { tauri::generate_handler![ $crate::commands::get_startup_snapshot, $crate::commands::get_settings, + $crate::commands::get_settings_snapshot, + $crate::commands::update_setting_fields, $crate::commands::get_default_style_system_prompts, $crate::commands::set_settings, $crate::commands::check_network, @@ -609,6 +613,8 @@ macro_rules! app_invoke_handler_mobile { $crate::commands::open_system_settings, $crate::commands::trigger_microphone_prompt, $crate::commands::export_error_log, + #[cfg(target_os = "android")] + $crate::commands::export_error_log_to_downloads, $crate::commands::get_update_channel, $crate::commands::set_update_channel, $crate::commands::fetch_latest_beta_release, @@ -2484,21 +2490,11 @@ pub(crate) fn set_qa_window_expanded(app: &AppHandle, expa pub(crate) fn show_qa_window(app: &AppHandle, content_kind: &str) { #[cfg(target_os = "android")] { - const FLAG_ACTIVITY_NEW_TASK: i32 = 0x10000000; - const FLAG_ACTIVITY_REORDER_TO_FRONT: i32 = 0x00020000; - const FLAG_ACTIVITY_SINGLE_TOP: i32 = 0x20000000; - let flags = - FLAG_ACTIVITY_NEW_TASK | FLAG_ACTIVITY_REORDER_TO_FRONT | FLAG_ACTIVITY_SINGLE_TOP; match crate::android::jni::android::with_android_env(|env, context| { - crate::android::jni::android::start_activity_class_with_flags( - env, - context, - "com.openless.app.MainActivity", - flags, - ) + crate::android::jni::android::open_qa_host(env, context) }) { - Ok(()) => log::info!("[qa] android requested MainActivity foreground for QA"), - Err(error) => log::warn!("[qa] android failed to foreground MainActivity: {error}"), + Ok(()) => log::info!("[qa] android requested WarmupActivity foreground for QA"), + Err(error) => log::warn!("[qa] android failed to foreground WarmupActivity: {error}"), } log::info!("[qa] android publish qa:state to main kind={content_kind}"); tauri_events::publish( diff --git a/openless-all/app/src-tauri/src/mobile_runtime.rs b/openless-all/app/src-tauri/src/mobile_runtime.rs index d840a1590..52489e8d6 100644 --- a/openless-all/app/src-tauri/src/mobile_runtime.rs +++ b/openless-all/app/src-tauri/src/mobile_runtime.rs @@ -50,6 +50,7 @@ pub fn run() { { crate::android::register_android_backend(core_backend); crate::android::register_android_coordinator(coordinator.clone()); + crate::android::register_android_app_handle(app.handle().clone()); coordinator.apply_android_overlay_on_startup(); } Ok(()) @@ -58,7 +59,37 @@ pub fn run() { .build(tauri::generate_context!()) .expect("error while building tauri mobile application") .run(|app, event| match event { + // Tao's Android backend treats "window destroyed" as "the whole + // app should exit" and calls std::process::exit() once this + // event isn't prevented (tauri-runtime-wry's on Destroyed + // handler, then tao::platform_impl::android exiting on + // ControlFlow::Exit). That exit() runs process-wide C++ static + // destructors (libhwui/libminikin included) while the + // Kotlin-side OpenLessImeService is still live in this same + // process, which produced repeated "destroyed mutex" native + // aborts unrelated to any Activity visibility. The backend must + // outlive this window, so always prevent the exit here. + RunEvent::ExitRequested { api, .. } => { + api.prevent_exit(); + } RunEvent::Exit => { + // With ExitRequested now always prevented above, reaching + // this point at all means something forced the exit despite + // that (or a future code path calls AppHandle::exit()/ + // restart() directly) — worth recording as its own distinct + // restart-cause bucket, separate from an OS-level process + // kill, since it means Tauri itself decided to tear down. + #[cfg(target_os = "android")] + { + let _ = crate::android::jni::android::with_android_env(|env, context| { + crate::android::jni::android::start_service_action( + env, + context, + "com.openless.app.OpenLessRuntimeService", + "com.openless.app.action.RUNTIME_EXITED", + ) + }); + } if let Some(coordinator) = app.try_state::>() { coordinator.stop_hotkey_listener(); let backend = coordinator.backend(); diff --git a/openless-all/app/src-tauri/src/persistence/credentials.rs b/openless-all/app/src-tauri/src/persistence/credentials.rs index 09733b304..a76875fe4 100644 --- a/openless-all/app/src-tauri/src/persistence/credentials.rs +++ b/openless-all/app/src-tauri/src/persistence/credentials.rs @@ -6060,3 +6060,5 @@ mod sync_capture_diagnostic_tests { } } } + +mod android_transfer; diff --git a/openless-all/app/src-tauri/src/persistence/credentials/android_transfer.rs b/openless-all/app/src-tauri/src/persistence/credentials/android_transfer.rs new file mode 100644 index 000000000..c9239e372 --- /dev/null +++ b/openless-all/app/src-tauri/src/persistence/credentials/android_transfer.rs @@ -0,0 +1,387 @@ +//! Explicitly user-selected, plaintext Android device transfer. No OAuth/sync secrets. +use super::*; + +#[derive(Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct Channel { + id: String, + provider_type: String, + name: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + configuration: Option, +} +#[derive(Serialize, Deserialize, Default)] +#[serde(deny_unknown_fields)] +struct Channels { + asr: Option, + llm: Option, +} + +fn export_channel( + id: &str, + entry: &T, + secrets: bool, +) -> Result { + let mut value = serde_json::to_value(entry)?; + let object = value.as_object_mut().context("invalid channel")?; + let name = object + .get("displayName") + .and_then(|v| v.as_str()) + .map(str::to_owned); + for key in [ + "providerType", + "displayName", + "order", + "enabled", + "lastTest", + ] { + object.remove(key); + } + Ok(Channel { + id: id.into(), + provider_type: channel_provider_type(id, entry).into(), + name, + configuration: secrets.then_some(value), + }) +} + +fn merge_channel( + map: &mut HashMap, + channel: &Channel, + secrets: bool, +) -> Result<()> { + anyhow::ensure!( + !channel.id.trim().is_empty() + && channel.id.len() <= 256 + && !channel.provider_type.trim().is_empty(), + "invalid channel identity" + ); + if let Some(existing) = map.get(&channel.id) { + anyhow::ensure!( + channel_provider_type(&channel.id, existing) == channel.provider_type, + "channel identity conflicts with the target device" + ); + } + let mut value = map + .get(&channel.id) + .map(serde_json::to_value) + .transpose()? + .unwrap_or_else(|| serde_json::json!({})); + if secrets { + let imported = channel + .configuration + .as_ref() + .context("credential configuration is missing")?; + anyhow::ensure!(imported.is_object(), "invalid credential configuration"); + // Decode to the actual entry type first; metadata comes only from the explicit identity. + let _: T = serde_json::from_value(imported.clone()).context("invalid credential fields")?; + for (key, field) in imported.as_object().unwrap() { + anyhow::ensure!( + ![ + "providerType", + "displayName", + "order", + "enabled", + "lastTest" + ] + .contains(&key.as_str()), + "metadata inside credentials is not allowed" + ); + value + .as_object_mut() + .unwrap() + .insert(key.clone(), field.clone()); + } + } + let object = value.as_object_mut().context("invalid channel")?; + object.insert("displayName".into(), serde_json::to_value(&channel.name)?); + object.insert("providerType".into(), channel.provider_type.clone().into()); + object.insert( + "order".into(), + map.get(&channel.id) + .and_then(|v| v.meta().order) + .unwrap_or(map.len() as u32) + .into(), + ); + object.insert( + "enabled".into(), + map.get(&channel.id) + .map(|v| v.meta().enabled) + .unwrap_or(true) + .into(), + ); + object.remove("lastTest"); + map.insert(channel.id.clone(), serde_json::from_value(value)?); + Ok(()) +} + +fn merge_channels( + root: &CredsRoot, + channels: &Channels, + activate: bool, + secrets: bool, +) -> Result { + for (channel, kind) in [ + ( + channels.asr.as_ref(), + openless_core::domains::ProviderKind::Asr, + ), + ( + channels.llm.as_ref(), + openless_core::domains::ProviderKind::Llm, + ), + ] { + if let Some(channel) = channel { + anyhow::ensure!( + openless_core::provider_rules::provider_descriptor(kind, &channel.provider_type) + .is_some(), + "unsupported provider type" + ); + } + } + let mut next = root.clone(); + if let Some(channel) = &channels.asr { + merge_channel(&mut next.providers.asr, channel, secrets)?; + } + if let Some(channel) = &channels.llm { + merge_channel(&mut next.providers.llm, channel, secrets)?; + } + if activate { + for (slot, channel) in [ + (openless_core::ProviderSlot::Asr, &channels.asr), + (openless_core::ProviderSlot::Llm, &channels.llm), + ] { + if let Some(channel) = channel { + let mut metadata = credential_metadata(&next); + metadata + .select_active_provider(slot, channel.id.clone()) + .map_err(anyhow::Error::new)?; + if metadata.revision() != next.metadata_revision { + apply_credential_metadata(&mut next, metadata)?; + } + } + } + } + // Reuse the existing provider/account validation without exposing sync secrets. + export_sync_credentials_root(&next)?; + Ok(next) +} + +impl CredentialsVault { + pub(crate) fn export_android_channels(secrets: bool) -> Result { + let _guard = credentials_lock().lock(); + let root = load_credentials_for_update()?; + let channels = Channels { + asr: root + .providers + .asr + .get(&root.active.asr) + .map(|v| export_channel(&root.active.asr, v, secrets)) + .transpose()?, + llm: root + .providers + .llm + .get(&root.active.llm) + .map(|v| export_channel(&root.active.llm, v, secrets)) + .transpose()?, + }; + serde_json::to_value(channels).map_err(Into::into) + } + pub(crate) fn import_android_channels( + value: serde_json::Value, + activate: bool, + secrets: bool, + ) -> Result<()> { + let channels: Channels = + serde_json::from_value(value).context("invalid channel transfer")?; + mutate_credentials(openless_core::credentials::ChangeOrigin::User, |root| { + *root = merge_channels(root, &channels, activate, secrets)?; + Ok(true) + }) + } +} + +impl CredentialsVault { + pub(crate) fn legacy_android_channels( + selection: &serde_json::Value, + credentials: Option<&serde_json::Value>, + ) -> Result { + let _guard = credentials_lock().lock(); + let root = load_credentials_for_update()?; + let mut channels = Channels::default(); + for (slot, field) in [("asr", "activeAsrProvider"), ("llm", "activeLlmProvider")] { + let Some(id) = selection.get(field).and_then(|v| v.as_str()) else { + anyhow::ensure!(credentials.is_none(), "legacy credentials have no channel identity; export again on the source device"); + continue; + }; + let mut channel = if slot == "asr" { + export_channel( + id, + root.providers + .asr + .get(id) + .context("legacy ASR channel is unknown; export again")?, + false, + )? + } else { + export_channel( + id, + root.providers + .llm + .get(id) + .context("legacy LLM channel is unknown; export again")?, + false, + )? + }; + if let Some(credentials) = credentials { + let mut configuration = serde_json::Map::new(); + let fields: &[(&str, &str)] = if slot == "asr" { + &[ + ("volcengineAppKey", "appKey"), + ("volcengineAccessKey", "accessKey"), + ("volcengineResourceId", "resourceId"), + ("volcengineService", "volcengineService"), + ("volcengineAuthMode", "authMode"), + ("volcengineApiKey", "volcengineApiKey"), + ("asrApiKey", "apiKey"), + ("asrEndpoint", "baseURL"), + ("asrModel", "model"), + ("asrVocabularyId", "vocabularyId"), + ("asrAdvancedConfig", "advancedConfig"), + ("xfyunAppId", "xfyunAppId"), + ("xfyunApiKey", "xfyunApiKey"), + ("tencentCloudAppId", "tencentCloudAppId"), + ("tencentCloudSecretId", "tencentCloudSecretId"), + ("tencentCloudSecretKey", "tencentCloudSecretKey"), + ] + } else { + &[ + ("arkApiKey", "apiKey"), + ("arkEndpoint", "baseURL"), + ("arkModelId", "model"), + ] + }; + for (from, to) in fields { + if let Some(value) = credentials.get(from).filter(|v| !v.is_null()) { + configuration.insert((*to).into(), value.clone()); + } + } + channel.configuration = Some(configuration.into()); + } + if slot == "asr" { + channels.asr = Some(channel); + } else { + channels.llm = Some(channel); + } + } + Ok(serde_json::to_value(channels)?) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn activating_imported_slots_is_repeatable_for_every_selection() { + for (asr, llm) in [(false, false), (true, false), (false, true), (true, true)] { + let channels = Channels { + asr: asr.then(|| Channel { + id: "imported-asr".into(), + provider_type: "openai-compatible".into(), + name: Some("Imported ASR".into()), + configuration: None, + }), + llm: llm.then(|| Channel { + id: "imported-llm".into(), + provider_type: "deepseek".into(), + name: Some("Imported LLM".into()), + configuration: None, + }), + }; + let root = CredsRoot::default(); + let restored = merge_channels(&root, &channels, true, false).unwrap(); + if asr { + assert_eq!(restored.active.asr, "imported-asr"); + } + if llm { + assert_eq!(restored.active.llm, "imported-llm"); + } + let repeated = merge_channels(&restored, &channels, true, false).unwrap(); + assert_eq!( + serde_json::to_value(&restored).unwrap(), + serde_json::to_value(&repeated).unwrap(), + "asr={asr}, llm={llm}" + ); + } + } + + #[test] + fn transfer_is_bound_to_identity_and_preserves_other_channels() { + let mut root = CredsRoot::default(); + root.providers.llm.insert( + "old".into(), + CredsLlmEntry { + channel: ChannelMeta { + providerType: Some("deepseek".into()), + ..Default::default() + }, + apiKey: Some("keep".into()), + ..Default::default() + }, + ); + root.active.llm = "old".into(); + let channels = Channels { + asr: None, + llm: Some(Channel { + id: "imported".into(), + provider_type: "openai-compatible".into(), + name: Some("Restored".into()), + configuration: Some( + serde_json::json!({"apiKey":"new", "baseURL":"https://example.com/v1", "model":"test"}), + ), + }), + }; + let restored = merge_channels(&root, &channels, true, true).unwrap(); + assert_eq!( + restored.providers.llm["old"].apiKey.as_deref(), + Some("keep") + ); + assert_eq!( + restored.providers.llm["imported"].apiKey.as_deref(), + Some("new") + ); + assert_eq!(restored.active.llm, "imported"); + assert_eq!( + merge_channels(&restored, &channels, true, true) + .unwrap() + .providers + .llm + .len(), + 2 + ); + let mut conflict = channels; + conflict.llm.as_mut().unwrap().id = "old".into(); + assert!(merge_channels(&root, &conflict, true, true).is_err()); + assert_eq!(root.providers.llm["old"].apiKey.as_deref(), Some("keep")); + let exported = export_channel("old", &root.providers.llm["old"], false).unwrap(); + assert!(exported.configuration.is_none()); + assert_eq!( + merge_channels( + &root, + &Channels { + asr: None, + llm: Some(exported) + }, + false, + false + ) + .unwrap() + .providers + .llm["old"] + .apiKey + .as_deref(), + Some("keep") + ); + } +} diff --git a/openless-all/app/src/components/Icon.tsx b/openless-all/app/src/components/Icon.tsx index be57cbfef..99d2c6fec 100644 --- a/openless-all/app/src/components/Icon.tsx +++ b/openless-all/app/src/components/Icon.tsx @@ -23,6 +23,7 @@ import { Hash, History, Info, + Keyboard, Languages, Link, ListFilter, @@ -64,6 +65,7 @@ export const ICONS: Record = { translate: Languages, selectionAsk: MessageSquareText, settings: Settings, + keyboard: Keyboard, help: CircleHelp, mic: Mic, search: Search, diff --git a/openless-all/app/src/components/SettingsModal.tsx b/openless-all/app/src/components/SettingsModal.tsx index 2330ac807..b9c48a574 100644 --- a/openless-all/app/src/components/SettingsModal.tsx +++ b/openless-all/app/src/components/SettingsModal.tsx @@ -18,6 +18,7 @@ import type { OS } from './WindowChrome'; import { AboutTab, GeneralTab, + InputMethodTab, ServicesTab, PrivacyTab, AdvancedTab, @@ -83,7 +84,7 @@ export function SettingsModal({ }); }; const supportsShortcuts = platformCaps?.supportsDesktopHotkey ?? os !== 'android'; - const sections = visibleSettingsSections(supportsShortcuts).map((item) => ({ + const sections = visibleSettingsSections(supportsShortcuts, platformCaps?.platform).map((item) => ({ ...item, title: t(`modal.sections.${item.id}`), description: t(`modal.descriptions.${item.id}`), @@ -150,6 +151,12 @@ export function SettingsModal({ if (!supportsShortcuts && section === 'shortcuts') setSection('general'); }, [supportsShortcuts, section]); + useEffect(() => { + if (platformCaps && platformCaps.platform !== 'android' && section === 'inputMethod') { + setSection('general'); + } + }, [platformCaps, section]); + useEffect(() => { mountedRef.current = true; if (!previousFocusRef.current && document.activeElement instanceof HTMLElement) { @@ -684,6 +691,7 @@ export function SettingsModal({ }} > {section === 'general' && } + {section === 'inputMethod' && } {section === 'shortcuts' && } {section === 'appearance' && } {section === 'services' && } diff --git a/openless-all/app/src/i18n/de.ts b/openless-all/app/src/i18n/de.ts index c5cdf0595..496d709e2 100644 --- a/openless-all/app/src/i18n/de.ts +++ b/openless-all/app/src/i18n/de.ts @@ -2099,6 +2099,13 @@ export const de: typeof zhCN = { notWindows: 'Nur unter Windows verfügbar.', }, }, + inputMethod: { + guidanceTitle: 'OpenLess-Eingabemethode', + enableSystemIme: + 'Aktiviere OpenLess Voice in Android → Tastatur / Eingabemethoden und wähle sie bei Bedarf aus.', + longPressLogo: + 'Halte im OpenLess-Keyboard das Logo oben links gedrückt, um die systemeigenen Tastatureinstellungen zu öffnen.', + }, advanced: { multimodalPipelineTitle: 'Multimodale Spracherkennung (experimentell)', multimodalPipelineTitleHint: @@ -2332,6 +2339,7 @@ export const de: typeof zhCN = { }, descriptions: { general: 'Mikrofon wählen, Aufnahme und Texteingabe anpassen oder dein Smartphone verbinden.', + inputMethod: 'Android-Overlay und Eingabemethode für die mobile Eingabe konfigurieren.', shortcuts: 'Kurzbefehle einrichten und Aktionen für ausgewählten Text festlegen.', services: 'Spracherkennungs- und Textverarbeitungsdienste wählen. Kanäle, lokale Modelle und Verbindungen verwalten.', @@ -2344,6 +2352,7 @@ export const de: typeof zhCN = { searchKeywords: { general: 'Mikrofon Aufnahme Eingabe Smartphone Ferneingabe LAN PIN Kapsel Stumm Start Autostart', + inputMethod: 'Eingabemethode IME Tastatur Overlay Android', shortcuts: 'Kurzbefehl Hotkey Taste Tastenkombination Auswahl Überarbeitung Sprachbearbeitung', services: 'ASR LLM API Kanal Modell Cloud Lokal Offline Netzwerk Proxy Marktplatz', @@ -2357,6 +2366,7 @@ export const de: typeof zhCN = { appearance: 'Darstellung und Sprache', shortcuts: 'Kurzbefehle und Auswahl', general: 'Aufnahme und Eingabe', + inputMethod: 'Eingabemethode', services: 'KI-Dienste und Modelle', privacy: 'Berechtigungen und Daten', advanced: 'Experimente und Erweiterungen', diff --git a/openless-all/app/src/i18n/en.ts b/openless-all/app/src/i18n/en.ts index 53dc51fd9..94f6b314d 100644 --- a/openless-all/app/src/i18n/en.ts +++ b/openless-all/app/src/i18n/en.ts @@ -2042,6 +2042,13 @@ export const en: typeof zhCN = { notWindows: 'Only available on Windows.', }, }, + inputMethod: { + guidanceTitle: 'OpenLess input method', + enableSystemIme: + 'In Android Settings → Keyboard / Input methods, enable OpenLess Voice and switch to it when needed.', + longPressLogo: + 'In the OpenLess keyboard, long-press the logo in the upper-left corner to open native keyboard settings.', + }, advanced: { multimodalPipelineTitle: 'Multimodal recognition pipeline', multimodalPipelineTitleHint: @@ -2269,6 +2276,7 @@ export const en: typeof zhCN = { }, descriptions: { general: 'Choose a microphone, adjust recording and text input, or connect your phone.', + inputMethod: 'Configure the Android overlay and input method for phone typing.', shortcuts: 'Set up shortcuts and choose what happens when you select text.', services: 'Choose speech recognition and text processing services. Manage channels, local models and connections.', @@ -2280,6 +2288,7 @@ export const en: typeof zhCN = { }, searchKeywords: { general: 'microphone recording input phone remote LAN PIN capsule mute startup autostart', + inputMethod: 'input method IME keyboard overlay Android', shortcuts: 'shortcut hotkey key combination selection polish voice editing', services: 'ASR LLM API channel model cloud local offline network proxy marketplace', appearance: 'theme dark light language font text size layout heatmap', @@ -2292,6 +2301,7 @@ export const en: typeof zhCN = { appearance: 'Appearance & language', shortcuts: 'Shortcuts & selection', general: 'Recording & input', + inputMethod: 'Input method', services: 'AI services & models', privacy: 'Permissions & data', advanced: 'Experiments & extensions', diff --git a/openless-all/app/src/i18n/es.ts b/openless-all/app/src/i18n/es.ts index 6861addde..a2b9ea240 100644 --- a/openless-all/app/src/i18n/es.ts +++ b/openless-all/app/src/i18n/es.ts @@ -2081,6 +2081,13 @@ export const es: typeof zhCN = { notWindows: 'Solo disponible en Windows.', }, }, + inputMethod: { + guidanceTitle: 'Método de entrada de OpenLess', + enableSystemIme: + 'En Ajustes de Android → Teclado / Métodos de entrada, activa OpenLess Voice y selecciónalo cuando lo necesites.', + longPressLogo: + 'En el teclado de OpenLess, mantén pulsado el logo de la esquina superior izquierda para abrir los ajustes nativos del teclado.', + }, advanced: { multimodalPipelineTitle: 'Reconocimiento multimodal (experimental)', multimodalPipelineTitleHint: @@ -2314,6 +2321,7 @@ export const es: typeof zhCN = { }, descriptions: { general: 'Elige un micrófono, ajusta la grabación y la entrada de texto o conecta tu móvil.', + inputMethod: 'Configura la superposición y el método de entrada de Android.', shortcuts: 'Configura atajos y elige qué sucede al seleccionar texto.', services: 'Elige servicios de reconocimiento de voz y procesamiento de texto. Administra canales, modelos locales y conexiones.', @@ -2327,6 +2335,7 @@ export const es: typeof zhCN = { searchKeywords: { general: 'micrófono grabación entrada teléfono remoto LAN PIN cápsula silenciar inicio automático', + inputMethod: 'método de entrada IME teclado superposición Android', shortcuts: 'atajo tecla combinación selección mejorar voz edición', services: 'ASR LLM API canal modelo nube local sin conexión red proxy catálogo', appearance: 'tema oscuro claro idioma fuente texto tamaño diseño mapa actividad', @@ -2339,6 +2348,7 @@ export const es: typeof zhCN = { appearance: 'Apariencia e idioma', shortcuts: 'Atajos y selección', general: 'Grabación y entrada', + inputMethod: 'Método de entrada', services: 'Servicios y modelos de IA', privacy: 'Permisos y datos', advanced: 'Experimentos y extensiones', diff --git a/openless-all/app/src/i18n/fr.ts b/openless-all/app/src/i18n/fr.ts index 8c58c9bb0..4d537c440 100644 --- a/openless-all/app/src/i18n/fr.ts +++ b/openless-all/app/src/i18n/fr.ts @@ -2111,6 +2111,13 @@ export const fr: typeof zhCN = { notWindows: 'Disponible uniquement sous Windows.', }, }, + inputMethod: { + guidanceTitle: 'Méthode de saisie OpenLess', + enableSystemIme: + 'Dans les paramètres Android → Clavier / Méthodes de saisie, activez OpenLess Voice et sélectionnez-le si nécessaire.', + longPressLogo: + 'Dans le clavier OpenLess, maintenez le logo en haut à gauche pour ouvrir les réglages natifs du clavier.', + }, advanced: { multimodalPipelineTitle: 'Reconnaissance multimodale (expérimentale)', multimodalPipelineTitleHint: @@ -2349,6 +2356,7 @@ export const fr: typeof zhCN = { descriptions: { general: 'Choisissez un microphone, réglez l’enregistrement et la saisie de texte, ou connectez votre téléphone.', + inputMethod: 'Configurez la fenêtre flottante et la méthode de saisie Android.', shortcuts: 'Configurez les raccourcis et choisissez l’action appliquée au texte sélectionné.', services: 'Choisissez les services de reconnaissance vocale et de traitement du texte. Gérez les canaux, modèles locaux et connexions.', @@ -2363,6 +2371,7 @@ export const fr: typeof zhCN = { searchKeywords: { general: 'microphone enregistrement saisie téléphone distant réseau local LAN PIN capsule muet démarrage automatique', + inputMethod: 'méthode de saisie IME clavier fenêtre flottante Android', shortcuts: 'raccourci touche combinaison sélection amélioration voix modification', services: 'ASR LLM API canal modèle cloud local hors ligne réseau proxy catalogue', appearance: 'thème sombre clair langue police texte taille disposition carte activité', @@ -2375,6 +2384,7 @@ export const fr: typeof zhCN = { appearance: 'Apparence et langue', shortcuts: 'Raccourcis et sélection', general: 'Enregistrement et saisie', + inputMethod: 'Méthode de saisie', services: 'Services et modèles d’IA', privacy: 'Autorisations et données', advanced: 'Expériences et extensions', diff --git a/openless-all/app/src/i18n/ja.ts b/openless-all/app/src/i18n/ja.ts index 0e631dba4..dd7a9e485 100644 --- a/openless-all/app/src/i18n/ja.ts +++ b/openless-all/app/src/i18n/ja.ts @@ -2015,6 +2015,13 @@ export const ja: typeof zhCN = { notWindows: 'Windows のみ利用可能。', }, }, + inputMethod: { + guidanceTitle: 'OpenLess 入力方法', + enableSystemIme: + 'Android の設定 → キーボード / 入力方法で OpenLess Voice を有効にし、必要に応じて切り替えてください。', + longPressLogo: + 'OpenLess キーボードの左上にあるロゴを長押しすると、端末のキーボード設定を開けます。', + }, advanced: { multimodalPipelineTitle: 'マルチモーダル認識パイプライン', multimodalPipelineTitleHint: @@ -2241,6 +2248,7 @@ export const ja: typeof zhCN = { }, descriptions: { general: 'マイク、録音方法、文字入力を設定し、スマートフォンからの入力を接続します。', + inputMethod: 'Android のオーバーレイと入力方法を設定します。', shortcuts: '各機能のショートカットと、テキスト選択後の操作を設定します。', services: '音声認識と文章処理のサービス、チャンネル、ローカルモデル、接続を管理します。', appearance: 'テーマ、レイアウト、表示言語を読みやすく調整します。', @@ -2250,6 +2258,7 @@ export const ja: typeof zhCN = { }, searchKeywords: { general: 'マイク 録音 入力 スマホ リモート LAN PIN カプセル ミュート 起動', + inputMethod: '入力方法 IME キーボード オーバーレイ Android', shortcuts: 'ショートカット ホットキー キー 選択 推敲 音声編集', services: 'ASR LLM API チャンネル モデル クラウド ローカル ネットワーク プロキシ マーケット', appearance: 'テーマ ダーク ライト 言語 フォント 文字 サイズ レイアウト ヒートマップ', @@ -2262,6 +2271,7 @@ export const ja: typeof zhCN = { appearance: '外観と言語', shortcuts: 'ショートカットと選択', general: '録音と入力', + inputMethod: '入力方法', services: 'AI サービスとモデル', privacy: '権限とデータ', advanced: '実験機能と拡張', diff --git a/openless-all/app/src/i18n/ko.ts b/openless-all/app/src/i18n/ko.ts index addac7f98..8f41bacff 100644 --- a/openless-all/app/src/i18n/ko.ts +++ b/openless-all/app/src/i18n/ko.ts @@ -1999,6 +1999,13 @@ export const ko: typeof zhCN = { notWindows: 'Windows 만 사용 가능.', }, }, + inputMethod: { + guidanceTitle: 'OpenLess 입력 방법', + enableSystemIme: + 'Android 설정 → 키보드 / 입력 방법에서 OpenLess Voice를 활성화하고 필요할 때 선택하세요.', + longPressLogo: + 'OpenLess 키보드의 왼쪽 위 로고를 길게 누르면 기본 키보드 설정을 열 수 있습니다.', + }, advanced: { multimodalPipelineTitle: '멀티모달 인식 파이프라인 ', multimodalPipelineTitleHint: @@ -2222,6 +2229,7 @@ export const ko: typeof zhCN = { }, descriptions: { general: '마이크, 녹음 방식, 텍스트 입력을 설정하고 휴대폰 입력을 연결합니다.', + inputMethod: 'Android 오버레이와 입력 방법을 설정합니다.', shortcuts: '기능별 단축키와 텍스트 선택 후 동작을 설정합니다.', services: '음성 인식과 텍스트 처리 서비스, 채널, 로컬 모델 및 연결을 관리합니다.', appearance: '테마, 페이지 배치, 인터페이스 언어를 편하게 읽도록 조정합니다.', @@ -2231,6 +2239,7 @@ export const ko: typeof zhCN = { }, searchKeywords: { general: '마이크 녹음 입력 휴대폰 원격 LAN PIN 캡슐 음소거 시작 자동시작', + inputMethod: '입력 방법 IME 키보드 오버레이 Android', shortcuts: '단축키 핫키 키 조합 선택 다듬기 음성 편집', services: 'ASR LLM API 채널 모델 클라우드 로컬 네트워크 프록시 마켓', appearance: '테마 다크 라이트 언어 글꼴 글자 크기 배치 레이아웃 히트맵', @@ -2243,6 +2252,7 @@ export const ko: typeof zhCN = { appearance: '모양 및 언어', shortcuts: '단축키 및 선택', general: '녹음 및 입력', + inputMethod: '입력 방법', services: 'AI 서비스 및 모델', privacy: '권한 및 데이터', advanced: '실험 기능 및 확장', diff --git a/openless-all/app/src/i18n/zh-CN.ts b/openless-all/app/src/i18n/zh-CN.ts index c402f18a5..4c1d8538b 100644 --- a/openless-all/app/src/i18n/zh-CN.ts +++ b/openless-all/app/src/i18n/zh-CN.ts @@ -1931,6 +1931,11 @@ export const zhCN = { notWindows: '仅 Windows 可用。', }, }, + inputMethod: { + guidanceTitle: 'OpenLess 输入法', + enableSystemIme: '请在系统设置 → 键盘 / 输入法中启用 OpenLess Voice,并在需要时切换到它。', + longPressLogo: '在 OpenLess 输入法键盘中长按左上角的 logo,可打开原生键盘设置。', + }, advanced: { multimodalPipelineTitle: '多模态识别管线', multimodalPipelineTitleHint: @@ -2146,6 +2151,7 @@ export const zhCN = { }, descriptions: { general: '选择麦克风、设置录音方式与文字输入,也可连接手机输入。', + inputMethod: '配置 Android 悬浮窗和输入法,快速开始手机端输入。', shortcuts: '设置各功能的触发方式,以及选中文字后的操作。', services: '选择语音识别与文字处理服务,管理渠道、本地模型和网络连接。', appearance: '调整主题、页面排版和界面语言,让阅读更舒服。', @@ -2155,6 +2161,7 @@ export const zhCN = { }, searchKeywords: { general: '麦克风 录音 输入 手机 远程 局域网 PIN 胶囊 静音 自启 开机', + inputMethod: '输入法 IME 键盘 悬浮窗 Android 安卓', shortcuts: '快捷键 热键 组合键 选区 润色 语音编辑', services: 'ASR LLM API 渠道 模型 云 本地 网络 代理 市场', appearance: '主题 深色 浅色 暗色 语言 字号 排版 布局 热力图', @@ -2167,6 +2174,7 @@ export const zhCN = { appearance: '外观与语言', shortcuts: '快捷键与选区', general: '录音与输入', + inputMethod: '输入方式', services: 'AI 服务与模型', privacy: '权限与数据', advanced: '实验与扩展', diff --git a/openless-all/app/src/i18n/zh-TW.ts b/openless-all/app/src/i18n/zh-TW.ts index 647b8ea32..60001ad25 100644 --- a/openless-all/app/src/i18n/zh-TW.ts +++ b/openless-all/app/src/i18n/zh-TW.ts @@ -1932,6 +1932,11 @@ export const zhTW: typeof zhCN = { notWindows: '僅 Windows 可用。', }, }, + inputMethod: { + guidanceTitle: 'OpenLess 輸入法', + enableSystemIme: '請在系統設定 → 鍵盤 / 輸入法中啟用 OpenLess Voice,需要時切換到它。', + longPressLogo: '在 OpenLess 輸入法鍵盤中長按左上角的 logo,可開啟原生鍵盤設定。', + }, advanced: { multimodalPipelineTitle: '多模態辨識管線', multimodalPipelineTitleHint: @@ -2146,6 +2151,7 @@ export const zhTW: typeof zhCN = { }, descriptions: { general: '選擇麥克風、設定錄音方式與文字輸入,也可連接手機輸入。', + inputMethod: '設定 Android 懸浮窗與輸入法,快速開始手機端輸入。', shortcuts: '設定各功能的觸發方式,以及選取文字後的操作。', services: '選擇語音辨識與文字處理服務,管理管道、本機模型和網路連線。', appearance: '調整主題、頁面排版和介面語言,讓閱讀更舒服。', @@ -2155,6 +2161,7 @@ export const zhTW: typeof zhCN = { }, searchKeywords: { general: '麥克風 錄音 輸入 手機 遠端 區域網路 PIN 膠囊 靜音 開機', + inputMethod: '輸入法 IME 鍵盤 懸浮窗 Android 安卓', shortcuts: '快捷鍵 熱鍵 組合鍵 選取 潤飾 語音編輯', services: 'ASR LLM API 管道 模型 雲端 本機 網路 代理 市場', appearance: '主題 深色 淺色 暗色 語言 字體 排版 版面 熱圖', @@ -2166,6 +2173,7 @@ export const zhTW: typeof zhCN = { appearance: '外觀與語言', shortcuts: '快捷鍵與選取文字', general: '通用', + inputMethod: '輸入方式', services: '服務', privacy: '隱私', advanced: '高階', diff --git a/openless-all/app/src/lib/ipc/index.ts b/openless-all/app/src/lib/ipc/index.ts index 00ccae5be..d5fa9e1c1 100644 --- a/openless-all/app/src/lib/ipc/index.ts +++ b/openless-all/app/src/lib/ipc/index.ts @@ -23,6 +23,8 @@ export { BACKEND_CONTRACT_VERSION, getStartupSnapshot, getSettings, + getSettingsSnapshot, + updateSettingFields, getDefaultStyleSystemPrompts, setSettings, } from './settings'; diff --git a/openless-all/app/src/lib/ipc/settings.ts b/openless-all/app/src/lib/ipc/settings.ts index cfb63bcab..818839850 100644 --- a/openless-all/app/src/lib/ipc/settings.ts +++ b/openless-all/app/src/lib/ipc/settings.ts @@ -34,3 +34,33 @@ export function setSettings(prefs: UserPreferences): Promise { return prefs; }); } + +export interface SettingsSnapshot { + preferences: UserPreferences; + revision: number; +} +let mockRevision = 0; +export function getSettingsSnapshot(): Promise { + return invokeOrMock('get_settings_snapshot', undefined, () => ({ + preferences: structuredClone(mockSettings), + revision: mockRevision, + })); +} +export function updateSettingFields(edits: Record): Promise { + return invokeOrMock('update_setting_fields', { edits }, () => { + const prefs = structuredClone(mockSettings); + for (const [path, value] of Object.entries(edits)) { + const keys = path + .slice(1) + .split('/') + .map((key) => key.replace(/~1/g, '/').replace(/~0/g, '~')); + let target = prefs as unknown as Record; + for (const key of keys.slice(0, -1)) target = target[key] as Record; + target[keys[keys.length - 1]] = value; + } + const thinkingChanged = mockSettings.llmThinkingEnabled !== prefs.llmThinkingEnabled; + mockSetSettings(prefs); + if (thinkingChanged) invalidateMockChannelTests('llm'); + return { preferences: prefs, revision: ++mockRevision }; + }); +} diff --git a/openless-all/app/src/lib/ipc/utils.ts b/openless-all/app/src/lib/ipc/utils.ts index 202b11876..ccee35c25 100644 --- a/openless-all/app/src/lib/ipc/utils.ts +++ b/openless-all/app/src/lib/ipc/utils.ts @@ -28,22 +28,27 @@ export async function openExternal(url: string): Promise { * 让用户选 save 路径并把当前会话日志(openless.log)复制过去。 * 浏览器开发模式下走 mock 不实际写盘。返回最终 save 的绝对路径,取消选择则返回 null。 * - * Android:省略 filters——部分 ROM 上 CREATE_DOCUMENT + EXTRA_MIME_TYPES 不稳定; - * 文件名已带 .log,足够标识类型。 + * Android:不打开 dialog,直接交给 MediaStore 写入公共 Downloads;部分 ROM 上 + * CREATE_DOCUMENT + EXTRA_MIME_TYPES 不稳定,且用户只需要一个可分享的日志文件。 */ export async function exportErrorLog(suggestedFileName: string): Promise { if (!isTauri) { return `~/Downloads/${suggestedFileName}`; } - const { save } = await import('@tauri-apps/plugin-dialog'); const isAndroid = typeof navigator !== 'undefined' && /Android/i.test(navigator.userAgent || ''); + if (isAndroid) { + return invokeOrMock( + 'export_error_log_to_downloads', + { fileName: suggestedFileName }, + () => `~/Downloads/${suggestedFileName}`, + ); + } + const { save } = await import('@tauri-apps/plugin-dialog'); const target = await save( - isAndroid - ? { defaultPath: suggestedFileName } - : { - defaultPath: suggestedFileName, - filters: [{ name: 'Log', extensions: ['log', 'txt'] }], - }, + { + defaultPath: suggestedFileName, + filters: [{ name: 'Log', extensions: ['log', 'txt'] }], + }, ); if (!target) return null; await invokeOrMock('export_error_log', { targetPath: target }, () => undefined); diff --git a/openless-all/app/src/pages/settings/DebugToolsSection.tsx b/openless-all/app/src/pages/settings/DebugToolsSection.tsx index a8934e6cc..ebdfe23da 100644 --- a/openless-all/app/src/pages/settings/DebugToolsSection.tsx +++ b/openless-all/app/src/pages/settings/DebugToolsSection.tsx @@ -210,7 +210,7 @@ export function DebugToolsSection() { title={exportMessage} > {t('modal.about.exportSuccess')} - {mobile && exportMessage ? `:${exportMessage}` : ''} + {exportMessage ? `:${exportMessage}` : ''} )} {exportStatus === 'err' && ( diff --git a/openless-all/app/src/pages/settings/PermissionsSection.tsx b/openless-all/app/src/pages/settings/PermissionsSection.tsx index 20948edce..5259247f4 100644 --- a/openless-all/app/src/pages/settings/PermissionsSection.tsx +++ b/openless-all/app/src/pages/settings/PermissionsSection.tsx @@ -251,7 +251,7 @@ export function PermissionsSection() { )} {platformCaps?.supportsOverlay && platformCaps.platform === 'android' && ( - + )} {windowsIme?.state !== 'notWindows' && platformCaps?.platform !== 'android' && ( diff --git a/openless-all/app/src/pages/settings/navigation.test.ts b/openless-all/app/src/pages/settings/navigation.test.ts index bf4babe4c..b4ae4b49d 100644 --- a/openless-all/app/src/pages/settings/navigation.test.ts +++ b/openless-all/app/src/pages/settings/navigation.test.ts @@ -68,6 +68,16 @@ assert.equal( true, 'desktop retains shortcuts', ); +assert.equal( + visibleSettingsSections(false, 'android').some((item) => item.id === 'inputMethod'), + true, + 'Android exposes input method settings', +); +assert.equal( + visibleSettingsSections(true, 'desktop').some((item) => item.id === 'inputMethod'), + false, + 'desktop hides Android input method settings', +); assert.equal( visibleAdvancedPages('desktop', 'win').some((item) => item.id === 'lessComputer'), true, diff --git a/openless-all/app/src/pages/settings/navigation.ts b/openless-all/app/src/pages/settings/navigation.ts index 57ccbf807..56e8d52aa 100644 --- a/openless-all/app/src/pages/settings/navigation.ts +++ b/openless-all/app/src/pages/settings/navigation.ts @@ -2,7 +2,14 @@ import type { OS } from '../../components/WindowChrome'; import type { PlatformKind } from '../../lib/types'; export type SettingsSectionId = - 'general' | 'shortcuts' | 'appearance' | 'services' | 'privacy' | 'advanced' | 'about'; + | 'general' + | 'inputMethod' + | 'shortcuts' + | 'appearance' + | 'services' + | 'privacy' + | 'advanced' + | 'about'; export const ADVANCED_PAGES = [ { id: 'lessComputer', icon: 'mac', titleKey: 'settings.codingAgent.title' }, @@ -30,6 +37,7 @@ export interface SettingsNavigationItem { export const SETTINGS_SECTIONS: SettingsNavigationItem[] = [ { id: 'general', icon: 'mic' }, + { id: 'inputMethod', icon: 'keyboard' }, { id: 'shortcuts', icon: 'bolt' }, { id: 'services', icon: 'cloud' }, { id: 'appearance', icon: 'settings' }, @@ -38,8 +46,15 @@ export const SETTINGS_SECTIONS: SettingsNavigationItem[] = [ { id: 'about', icon: 'info' }, ]; -export function visibleSettingsSections(supportsDesktopHotkey: boolean): SettingsNavigationItem[] { - return SETTINGS_SECTIONS.filter((item) => item.id !== 'shortcuts' || supportsDesktopHotkey); +export function visibleSettingsSections( + supportsDesktopHotkey: boolean, + platform?: PlatformKind, +): SettingsNavigationItem[] { + return SETTINGS_SECTIONS.filter((item) => { + if (item.id === 'shortcuts') return supportsDesktopHotkey; + if (item.id === 'inputMethod') return platform === 'android'; + return true; + }); } export interface SearchableSettingsSection extends SettingsNavigationItem { diff --git a/openless-all/app/src/pages/settings/tabs.tsx b/openless-all/app/src/pages/settings/tabs.tsx index a5f07a256..aabcc6fa8 100644 --- a/openless-all/app/src/pages/settings/tabs.tsx +++ b/openless-all/app/src/pages/settings/tabs.tsx @@ -3,6 +3,7 @@ import { useTranslation } from 'react-i18next'; import { useEffect, useRef, useState } from 'react'; +import { AndroidPermissionsPanel } from '@android/components/AndroidPermissionsPanel'; import { Icon } from '../../components/Icon'; import { RecordingInputSection } from './RecordingInputSection'; import { RemoteInputSection } from './RemoteInputSection'; @@ -29,6 +30,7 @@ import { getPlatformCapabilities } from '../../lib/platform'; import { listChannels } from '../../lib/ipc'; import type { PlatformCapabilities } from '../../lib/types'; import { useHotkeySettings } from '../../state/HotkeySettingsContext'; +import { Card } from '../_atoms'; import { availableServiceViews, resolveServiceView, @@ -61,6 +63,30 @@ export function GeneralTab() { ); } +export function InputMethodTab() { + const { t } = useTranslation(); + return ( + <> + + + + +
+ + {t('settings.inputMethod.guidanceTitle')} + +

+ {t('settings.inputMethod.enableSystemIme')} +

+

+ {t('settings.inputMethod.longPressLogo')} +

+
+
+ + ); +} + export function ShortcutsTab() { const platformCaps = usePlatformCaps(); if (!platformCaps?.supportsDesktopHotkey) return null; diff --git a/openless-all/app/src/state/HotkeySettingsContext.tsx b/openless-all/app/src/state/HotkeySettingsContext.tsx index a4900bd98..995cf2555 100644 --- a/openless-all/app/src/state/HotkeySettingsContext.tsx +++ b/openless-all/app/src/state/HotkeySettingsContext.tsx @@ -8,7 +8,7 @@ import { useState, type ReactNode, } from 'react'; -import { getHotkeyCapability, getSettings, isTauri, setSettings } from '../lib/ipc'; +import { getHotkeyCapability, getSettingsSnapshot, isTauri, updateSettingFields } from '../lib/ipc'; import type { HotkeyBinding, HotkeyCapability, UserPreferences } from '../lib/types'; import { applyThemeFromPreference } from '../lib/themeMode'; import { applyStackedLayoutFromPrefs } from '../lib/stackedLayout'; @@ -37,83 +37,52 @@ export function HotkeySettingsProvider({ children }: { children: ReactNode }) { const [capability, setCapability] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); - const persistQueueRef = useRef>(Promise.resolve()); const latestPrefsRef = useRef(null); - const persistedPrefsRef = useRef(null); - const writeGateRef = useRef(new PreferencesWriteGate()); + const persistQueueRef = useRef>(Promise.resolve()); + const gate = useRef(new PreferencesWriteGate()); + const readRequest = useRef | null>(null); + const readAgain = useRef(false); + + const applyPrefs = useCallback((value: UserPreferences) => { + latestPrefsRef.current = value; + setPrefs(value); + applyThemeFromPreference(value.themeMode ?? 'system'); + applyStackedLayoutFromPrefs(value.stackedRowLayout); + applyConservativeLayout(value.conservativeLayout === true); + }, []); + + // Events invalidate the snapshot; values alone cannot identify who wrote them. + const reloadPreferences = useCallback((): Promise => { + readAgain.current = true; + if (readRequest.current) return readRequest.current; + const task = (async () => { + do { + readAgain.current = false; + applyPrefs(gate.current.receiveIncoming(await getSettingsSnapshot())); + } while (readAgain.current); + })().finally(() => { + readRequest.current = null; + }); + readRequest.current = task; + return task; + }, [applyPrefs]); const refresh = useCallback(async () => { setLoading(true); setError(null); try { - const [prefsResult, capabilityResult] = await Promise.allSettled([ - getSettings(), - getHotkeyCapability(), - ]); - let nextError: string | null = null; - if (prefsResult.status === 'fulfilled') { - latestPrefsRef.current = prefsResult.value; - persistedPrefsRef.current = prefsResult.value; - setPrefs(prefsResult.value); - applyThemeFromPreference(prefsResult.value.themeMode ?? 'system'); - applyStackedLayoutFromPrefs(prefsResult.value.stackedRowLayout); - applyConservativeLayout(prefsResult.value.conservativeLayout === true); - } else { - console.error('[hotkey-settings] failed to load preferences', prefsResult.reason); - nextError = errorMessage(prefsResult.reason); - } - if (capabilityResult.status === 'fulfilled') { - setCapability(capabilityResult.value); - } else { - console.error( - '[hotkey-settings] failed to load hotkey capability', - capabilityResult.reason, - ); - nextError = errorMessage(capabilityResult.reason); - } - setError(nextError); + const [, nextCapability] = await Promise.all([reloadPreferences(), getHotkeyCapability()]); + setCapability(nextCapability); } catch (error) { - console.error('[hotkey-settings] failed to refresh hotkey settings', error); setError(errorMessage(error)); } finally { setLoading(false); } - }, []); - - const applyIncomingPrefs = useCallback((nextPrefs: UserPreferences) => { - latestPrefsRef.current = nextPrefs; - persistedPrefsRef.current = nextPrefs; - setPrefs(nextPrefs); - applyThemeFromPreference(nextPrefs.themeMode ?? 'system'); - applyStackedLayoutFromPrefs(nextPrefs.stackedRowLayout); - applyConservativeLayout(nextPrefs.conservativeLayout === true); - }, []); - - const queueSetSettings = useCallback( - (resolved: UserPreferences) => { - const finishWrite = writeGateRef.current.beginWrite(); - let savedPrefs: UserPreferences | null = null; - const task = persistQueueRef.current - .catch(() => undefined) - .then(async () => { - savedPrefs = await setSettings(resolved); - }) - .then(() => { - persistedPrefsRef.current = savedPrefs; - }) - .finally(() => { - if (finishWrite() && savedPrefs) applyIncomingPrefs(savedPrefs); - }); - persistQueueRef.current = task; - return task; - }, - [applyIncomingPrefs], - ); + }, [reloadPreferences]); useEffect(() => { void refresh(); }, [refresh]); - useEffect(() => { if (!isTauri) return; let cancelled = false; @@ -121,58 +90,48 @@ export function HotkeySettingsProvider({ children }: { children: ReactNode }) { void (async () => { try { const { listen } = await import('@tauri-apps/api/event'); - const handle = await listen('prefs:changed', (event) => { - const nextPrefs = event.payload; - if (!nextPrefs) return; - // 一次保存的广播先于其 IPC promise resolve 到达。若不拦截,较旧的 - // 排队保存会覆盖较新的乐观点击,让开关看起来「弹回去」。 - const applicable = writeGateRef.current.receiveIncoming(nextPrefs); - if (applicable) applyIncomingPrefs(applicable); + const stop = await listen('prefs:changed', () => { + void reloadPreferences().catch((error) => setError(errorMessage(error))); }); - if (cancelled) { - handle(); - } else { - unlisten = handle; - } + if (cancelled) stop(); + else unlisten = stop; } catch (error) { - console.warn('[settings] prefs:changed listener setup failed', error); + setError(errorMessage(error)); } })(); return () => { cancelled = true; unlisten?.(); }; - }, [applyIncomingPrefs]); - - useEffect(() => { - latestPrefsRef.current = prefs; - }, [prefs]); + }, [reloadPreferences]); const updatePrefs = useCallback( async (next: UserPreferences | ((current: UserPreferences) => UserPreferences)) => { - const current = latestPrefsRef.current; - if (!current) return; - const resolved = typeof next === 'function' ? next(current) : next; - if (resolved === current) return; - setPrefs(resolved); - latestPrefsRef.current = resolved; - applyStackedLayoutFromPrefs(resolved.stackedRowLayout); - applyConservativeLayout(resolved.conservativeLayout === true); - try { - await queueSetSettings(resolved); - persistedPrefsRef.current = resolved; - } catch (error) { - // 兜底(#904):保存失败必须回滚乐观状态并可见, - // 不能出现界面显示已切换、重启后回退的“假保存”。 - const fallback = persistedPrefsRef.current ?? current; - latestPrefsRef.current = fallback; - setPrefs(fallback); - console.error('[hotkey-settings] save failed, rolled back', error); - emitSaved('failed', errorMessage(error)); - throw error; + const previous = latestPrefsRef.current; + if (!previous) return; + const resolved = typeof next === 'function' ? next(previous) : next; + const write = gate.current.beginWrite(previous, resolved); + if (Object.keys(write.edits).length === 0) { + gate.current.finishWrite(write.id); + return; } + applyPrefs(gate.current.current()); + const task = persistQueueRef.current + .catch(() => undefined) + .then(async () => { + try { + const saved = await updateSettingFields(write.edits); + applyPrefs(gate.current.finishWrite(write.id, saved)); + } catch (error) { + applyPrefs(gate.current.finishWrite(write.id)); + emitSaved('failed', errorMessage(error)); + throw error; + } + }); + persistQueueRef.current = task; + await task; }, - [queueSetSettings], + [applyPrefs], ); const value = useMemo( diff --git a/openless-all/app/src/state/preferencesWriteGate.test.ts b/openless-all/app/src/state/preferencesWriteGate.test.ts index 48f4da03f..fa13b8fcd 100644 --- a/openless-all/app/src/state/preferencesWriteGate.test.ts +++ b/openless-all/app/src/state/preferencesWriteGate.test.ts @@ -1,39 +1,54 @@ -import { PreferencesWriteGate } from './preferencesWriteGate'; - -function assert(condition: boolean, message: string) { - if (!condition) throw new Error(message); -} - -const gate = new PreferencesWriteGate(); -assert(gate.shouldApplyIncoming(), 'remote preference events should apply while idle'); -assert(gate.receiveIncoming('idle') === 'idle', 'idle events should be returned immediately'); - -const finishFirst = gate.beginWrite(); -assert(!gate.shouldApplyIncoming(), 'an older event must not replace an optimistic local update'); -assert( - gate.receiveIncoming('latest') === null, - 'events during a write must not replace an optimistic local update', -); - -const finishSecond = gate.beginWrite(); -assert( - gate.receiveIncoming('old') === null, - 'a delayed older event must not be replayed after local writes finish', +const assert = { + equal(actual: unknown, expected: unknown) { + if (actual !== expected) throw new Error(`${actual} != ${expected}`); + }, + deepEqual(actual: unknown, expected: unknown) { + if (JSON.stringify(actual) !== JSON.stringify(expected)) + throw new Error(`${JSON.stringify(actual)} != ${JSON.stringify(expected)}`); + }, +}; +import { PreferencesWriteGate, preferenceEdits } from './preferencesWriteGate'; +const gate = new PreferencesWriteGate<{ theme: string; hotkey: { key: string; mode: string } }>(); +const prefs = { theme: 'light', hotkey: { key: 'A', mode: 'hold' } }; +gate.receiveIncoming({ preferences: prefs, revision: 1 }); +const first = gate.beginWrite(prefs, { ...prefs, theme: 'dark' }); +assert.deepEqual(first.edits, { '/theme': 'dark' }); +gate.finishWrite(first.id, { preferences: { ...prefs, theme: 'dark' }, revision: 2 }); +const second = gate.beginWrite(gate.current(), prefs); +gate.finishWrite(second.id, { preferences: prefs, revision: 3 }); +assert.equal( + gate.receiveIncoming({ preferences: { ...prefs, theme: 'dark' }, revision: 4 }).theme, + 'dark', ); -assert( - finishFirst() === false, - 'only the final queued write may apply its canonical preference payload', +assert.equal(gate.receiveIncoming({ preferences: prefs, revision: 2 }).theme, 'dark'); +const third = gate.beginWrite(gate.current(), { + ...gate.current(), + hotkey: { key: 'B', mode: 'hold' }, +}); +assert.deepEqual(third.edits, { '/hotkey/key': 'B' }); +assert.deepEqual( + gate.receiveIncoming({ + preferences: { ...prefs, hotkey: { key: 'A', mode: 'toggle' } }, + revision: 5, + }).hotkey, + { key: 'B', mode: 'toggle' }, ); -assert(!gate.shouldApplyIncoming(), 'all queued local writes must finish before events resume'); - -assert(finishSecond(), 'the final queued write must apply its canonical preference payload'); -assert( - gate.shouldApplyIncoming(), - 'remote preference events should resume after local writes finish', -); - -// Completion callbacks can be reached from defensive cleanup paths; they must be idempotent. -assert(finishSecond() === false, 'a completion callback must only release its event once'); -assert(gate.shouldApplyIncoming(), 'finishing one write twice must not corrupt the gate'); - +const fourth = gate.beginWrite(gate.current(), { ...gate.current(), theme: 'dark' }); +assert.deepEqual(gate.finishWrite(third.id), { + theme: 'dark', + hotkey: { key: 'A', mode: 'toggle' }, +}); +gate.finishWrite(fourth.id, { + preferences: { ...prefs, theme: 'dark', hotkey: { key: 'A', mode: 'toggle' } }, + revision: 6, +}); +assert.deepEqual(preferenceEdits({ items: [1] }, { items: [2] }), { '/items': [2] }); console.log('preference write gate tests passed'); + +const nullableGate = new PreferencesWriteGate<{ binding: { key: string } | null }>(); +nullableGate.receiveIncoming({ preferences: { binding: { key: 'A' } }, revision: 1 }); +const nullableWrite = nullableGate.beginWrite(nullableGate.current(), { binding: { key: 'B' } }); +assert.deepEqual(nullableGate.receiveIncoming({ preferences: { binding: null }, revision: 2 }), { + binding: null, +}); +assert.deepEqual(nullableGate.finishWrite(nullableWrite.id), { binding: null }); diff --git a/openless-all/app/src/state/preferencesWriteGate.ts b/openless-all/app/src/state/preferencesWriteGate.ts index 17c1ecb09..05f807869 100644 --- a/openless-all/app/src/state/preferencesWriteGate.ts +++ b/openless-all/app/src/state/preferencesWriteGate.ts @@ -1,29 +1,86 @@ -/** - * Prevents an older `prefs:changed` broadcast from overwriting an optimistic - * local edit while one or more local writes are queued. Events cannot be - * correlated to a particular write, so they are discarded instead of replayed - * after the queue drains; the latest local value is already optimistic. - */ -export class PreferencesWriteGate { - private pendingWrites = 0; +export interface PreferenceSnapshot { + preferences: T; + revision: number; +} +export type PreferenceEdits = Record; + +export function preferenceEdits(previous: unknown, next: unknown, path = ''): PreferenceEdits { + if (JSON.stringify(previous) === JSON.stringify(next)) return {}; + if ( + previous && + next && + typeof previous === 'object' && + typeof next === 'object' && + !Array.isArray(previous) && + !Array.isArray(next) + ) { + const before = previous as Record, + after = next as Record; + return Object.assign( + {}, + ...[...new Set([...Object.keys(before), ...Object.keys(after)])].map((key) => + preferenceEdits( + before[key], + after[key], + `${path}/${key.replace(/~/g, '~0').replace(/\//g, '~1')}`, + ), + ), + ); + } + return { [path]: next ?? null }; +} + +function applyEdits(value: T, edits: PreferenceEdits): T { + const result = structuredClone(value); + for (const [path, value] of Object.entries(edits)) { + const keys = path + .slice(1) + .split('/') + .map((key) => key.replace(/~1/g, '/').replace(/~0/g, '~')); + let target = result as Record; + let compatible = true; + for (const key of keys.slice(0, -1)) { + const child = target[key]; + if (!child || typeof child !== 'object' || Array.isArray(child)) { + compatible = false; + break; + } + target = child as Record; + } + // A concurrent removal of an optional object wins until the backend rejects this stale edit. + if (compatible) target[keys[keys.length - 1]] = structuredClone(value); + } + return result; +} + +/** Backend revisions order snapshots; pending edits belong to individual requests. */ +export class PreferencesWriteGate { + private saved: PreferenceSnapshot | null = null; + private nextId = 0; + private pending = new Map(); + + receiveIncoming(snapshot: PreferenceSnapshot): T { + if (!this.saved || snapshot.revision >= this.saved.revision) this.saved = snapshot; + return this.current(); + } - beginWrite(): () => boolean { - this.pendingWrites += 1; - let finished = false; - return () => { - if (finished) return false; - finished = true; - this.pendingWrites = Math.max(0, this.pendingWrites - 1); - return this.pendingWrites === 0; - }; + beginWrite(previous: T, next: T) { + const id = ++this.nextId; + const edits = preferenceEdits(previous, next); + this.pending.set(id, edits); + return { id, edits }; } - shouldApplyIncoming(): boolean { - return this.pendingWrites === 0; + finishWrite(id: number, snapshot?: PreferenceSnapshot): T { + if (snapshot) this.receiveIncoming(snapshot); + this.pending.delete(id); + return this.current(); } - receiveIncoming(value: T): T | null { - if (this.shouldApplyIncoming()) return value; - return null; + current(): T { + if (!this.saved) throw new Error('preferences not loaded'); + let value = this.saved.preferences; + for (const edits of this.pending.values()) value = applyEdits(value, edits); + return value; } }