diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 385df9d9..79b33aed 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -257,11 +257,11 @@ jobs: channel_flags=(--prerelease) latest_flag=--latest=false fi - # The full and lite APKs are the assets; their SHA-256 and the signing certificate + # The -llm and plain APKs are the assets; their SHA-256 and the signing certificate # are in the release notes, and the licenses ship inside the APK. gh release create "$GITHUB_REF_NAME" \ - release-files/openIME-*-arm64-release.apk \ - release-files/openIME-*-arm64-lite-release.apk \ + release-files/openIME-llm-v*.apk \ + release-files/openIME-v*.apk \ --repo "$GITHUB_REPOSITORY" \ --verify-tag \ --draft \ @@ -270,9 +270,9 @@ jobs: --notes-file release-files/RELEASE_NOTES.md names="$(gh release view "$GITHUB_REF_NAME" --repo "$GITHUB_REPOSITORY" --json assets --jq '.assets[].name' | sort)" - wanted="$(printf 'openIME-%s-arm64-lite-release.apk\nopenIME-%s-arm64-release.apk\n' "$GITHUB_REF_NAME" "$GITHUB_REF_NAME" | sort)" + wanted="$(printf 'openIME-%s.apk\nopenIME-llm-%s.apk\n' "$GITHUB_REF_NAME" "$GITHUB_REF_NAME" | sort)" if [[ "$names" != "$wanted" ]]; then - echo "::error::Expected the full and the lite APK as release assets; the draft was left unpublished." + echo "::error::Expected the -llm and the plain APK as release assets; the draft was left unpublished." echo "found:" echo "$names" exit 1 diff --git a/CHANGELOG.md b/CHANGELOG.md index 640a2e6e..080fe963 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,103 @@ GitHub Release 的发布说明;版本与发布流程见 [docs/RELEASE.md](docs ## [Unreleased] +## [1.0.0] - 2026-10-16 + +- 键盘与设置页统一蓝色强调色,加深工具栏图标并恢复原工具栏顺序;修复切换键盘与工具页网格偏左,窗口变宽或变窄时保持居中。语音模型页统一内容与卡片边距,增加可复制的官方下载地址及导入步骤。 + +- 短信验证码候选现在会自己刷新。此前顶栏那条「验证码 123456,点击输入」只在键盘弹出或设置变化时算一次,键盘开着时新收到的短信不会出现;现在注册短信内容变化的监听(`Telephony.Sms.CONTENT_URI`),200 毫秒防抖后重算一次候选,权限被收回或键盘收起时自动注销。取出验证码的规则也放宽到实际收到的各种写法:标签与验证码之间可以换行、中文冒号与全角数字都能认,数字可以带空格或连字符分组(`876 523`),英文标签(`code`、`passcode`、`OTP`、`verification code` 等)同样识别;同时收紧误报,电话、日期、订单号和「共/请于/有效期」这类状态句子里的数字不再当验证码。 + +- 官方语音模型包可以直接导入。sherpa-onnx 的模型发布是 `tar.bz2`,此前导入器只认 zip/tar/tar.gz,用户必须自己解压改名;现在按魔数(`BZh` 加 1–9)与扩展名(`.tar.bz2`、`.tbz2`、`.tbz`)都认,选择器的类型过滤补上 `application/x-bzip2`,解压走 Apache Commons Compress 的 `BZip2CompressorInputStream`(平台自带的 `java.util.zip` 只有 zip 和 gzip,没有 bzip2),路径穿越与解压体积上限两道检查对其他格式一样生效。 + +- 手写输入的入口暂时隐藏。笔画匹配的手写板还在实验阶段,笔画表和识别代码都留在包里,只是工具页与切换键盘页不再列出入口;需要时改一个开关就能放回来。 + +- 恢复豆包对齐前的按键布局:26 键第二行等宽、Shift/退格原宽度和五键底行;九键独立 0 键与原底行比例;笔画、数字页恢复原排列。键帽恢复统一 6 单位间距,工具栏恢复无常驻底色图标。保留词库、候选、设置、字母角标与手势修复。 + +- 九键的拼音组合补到参考实现的规模。参考每按一次键都对整条开放数字尾做全量格枚举,三处一起给出「组合」:左栏构造器(libshell.so `0x5ba1e0`)把音节、**首键字母**(每个字母连同 `add w8, w8, #0x20` 的大小写孪生各一条,`0x5ba608`–`0x5ba704`)和首位数字项 push 进同一个 vector,全程没有 `take`,只有 `reserve(新标记数 + 8)`(`0x5ba430`)——逐条解码 `syllable.dat` 的数:`64` 十项、`9426` 十八项、`5426` 十五项;**模糊音在数字层就扩充读法**,data3 每条记录带「达到这条读法所需的最小模糊掩码」,pass2(`0x5b962c`)按 `mask ⊆ 用户fuzzy` 放行,于是节点 `64` 在 n↔l 打开时多一条 `li`(data3[275],mask 0x0008)、节点 `426` 多出 `fang`(0x0010)、`hang`(0x0040),全开时 `9426` 的读法从 19 涨到 32、`5426` 从 18 涨到 30;候选侧同样是全量枚举,只有一条「单调最远前沿」剪枝,没有计数上限。我们这边左栏只列音节、字母仅在下一次按键时给、`readingOptions` 截 12 条(喂给 librime 的读数只取 6 条),而模糊音只传给了词表查询、解码一条都不用,所以 `li`、`xiang` 这类读法永远出不来,`64` 的左栏只有 3 条。现在照参考补齐:左栏对任意长度的开放尾都列出首键能起音节的字母(`i`/`u`/`v` 起不了音节,仍是死路不列;参考另推的大写孪生是九键英文用的,按已定范围不做),上限 12→48(参考不截断,这里是滚动列表的上界);解码器按参考 data3 的结构给每个音节建「模糊变体」条目——挂在原音节的数字节点下、带着所需规则集,查询时只有规则全开才放行——`64` 开 n↔l 后给出 `li` 与「里」,`9426` 给出 `xiang`/`zhang`/`zang`/`zan`/`zao`;路径上限 12→24,送 librime 的读数 6→12(参考无上限,我们每条读数一次 JNI 查询,保留上限但放宽)。左栏仍只收明文拼写(参考的左栏构造器只取 mask 为 0 的记录,`0x5ba378`),所以模糊读法落在候选与拆分里,不进左栏。另记一笔:`ENGINE-SPEC.md:222` 把「独立模糊展开路径 0x6c0508 族」记成九键的是错的,那一族属于五笔引擎(`oime_engine::wubi::Engine`,调用点 `0x6b394c`),按「他们错了,我们保持一致」处理,不照抄。 +- 数字、英文前后加空格:参考在「快捷格式输入」页上的第三个开关,标题与说明就是这条规则本身——`string/letter_digit_space_list_item_title`「数字、英文前后加空格」、`string/letter_digit_space_list_item_subtitle`「在语音与打字输入时,在中文和数字、英文间将增加空格」,这一行在参考里只有那个行 id(`enable_letter_digit_space_list_item_id`)——`res/cU.xml` 的偏好表里没有它的开关,整个 APK 也找不到 `enable_letter_digit_space` 这个键,所以它的默认值读不出来;按「个性化功能默认关闭」定成默认关。我们在唯一的落字通道(`InputConnectionGateway.commitText`)里补空格:落字前读一次光标左右的字符,一侧是汉字、另一侧是 ASCII 字母或数字时补一个空格;汉字侧的标点(`。`、`,`)不算汉字,空格本身也不会再加一个空格;语音上屏走同一条通道,与参考文案里的「在语音与打字输入时」一致。开关默认关,关着的时候这条通道只多两次读光标(都只在开关打开时才读)。 +- 双击空格键输入句号:参考的处理器在 `InputViewRoot$z.invoke`(classes.dex code_off 0x4c1764):空格抬起时 `now - lastSpaceTime` 与 `const-wide/16 0xc8` 比较,也就是 **200 毫秒**,命中就调 `KeyboardJni.commitForSpaceDoubleClick(!isPunct(charBeforeFirstSpace), true)`;判定用的标点是一张 31 个字符的表(空格、`,。?!;:`、两对引号、`()【】《》` 与半角 `,.?!;:"'()[]<>`),落在这些字后面的第二个空格仍是空格。我们照抄这套:服务记下第一个空格落下的时刻与它前面那个字符(落字前读),第二个空格落在 200 毫秒内且前面不是标点时,删掉那个空格写上「。」。存储项 `enable_double_click_space` 默认关(另见 `res/xq.xml` 的 `string/double_click_space_to_input_period`「双击空格键输入句号」)。 +- 算式出结果:光标前是一段写完的算式时,候选栏给出结果(打 `1+2*3` 出 `7`),点一下用结果换掉算式。参考这条在 native 里:`keyboard::InputModel::Impl::AssociateCalculator`(libkeyboard.so 0x26523c)、`GetCalculatorCandidate`(0x255ca4)与 `CommitCalculatorCand`(0x264594)——后者带一个 `cand_length`,正是「用结果换掉整段算式」这个动作。它自己的设置文案把「计算结果」列在联想的推荐内容里:「关闭联想后,将不会推荐诗词、计算结果、emoji表情。」(`string/associate_close_content`),所以这一层跟着「智能联想」走,没有单独的开关。我们读光标前最多 48 个字符里那一段由 `0-9.+-*/()×÷` 组成的尾巴,用一个小递归下降解析器求值(`+ - * /` 带优先级、一元负号、括号,全角数字与 `+-×÷()` 也认),写完才给结果:尾巴是运算符、没有运算符、括号不配、除以零都不出;结果是整数就照原样,否则最多六位小数、去掉末尾的零。显示上只给结果本身——参考的资源与 native 库里都没有「=」这个显示用字串。参考的算式文法与小数位数我们没逐字节还原,只按可见行为对齐。 +- 候选栏展示(剪贴板内容进候选栏):参考这一项的标题与说明是 `string/clipboard_candidate_bar_show_title`「候选栏展示」、`string/clipboard_candidate_bar_show_description`「可在候选栏快速使用剪切内容」,存储项 `enable_clipboard_candidate_bar_show` 在参考里是 `BooleanPreference`、`defaultValue="true"`(`res/cU.xml`,键 `0x7f110192`),也就是那边**默认开着**;我们这条默认关,按「个性化功能默认关闭」定,读剪切板这种事不替用户打开。我们原来只有工具栏上那条「复制内容提示」,现在多一条:开关打开、不是密码框、剪切板里有内容时,候选/联想行给出一条剪贴板候选,点一下就把复制的内容写进输入框(候选条按一行截断显示,落下去的是全文),用过就收起。两条互不影响,可以各自开关。 +- 使用耳机麦克风录音:参考的 `enable_bluetooth_input` 指的是耳机麦克风,不是外接键盘——`string/enable_bluetooth_input_show`「使用耳机麦克风录音」、`string/enable_bluetooth_input_show_sub`「默认使用手机录音。佩戴耳机时,使用耳机麦克风」。我们的 `VoiceAudioRouteManager` 在 Android 12 以上本来就会挑外接设备,只是没人管开关:现在由它决定——打开时按优先级取可用的耳机(BLE / 蓝牙 SCO / 有线 / USB),关着(默认)时取手机自带麦克风,且只在别的应用已经把录音改到耳机上时才动路由,本来就在手机麦克风上就不碰。Android 12 以下不碰路由(与原来的取舍一致:不为 SCO 付 500 毫秒的起录延迟),设置项写作「使用耳机麦克风录音」,说明照抄参考。外接键盘参考没有对应设置项,我们已有的实体键盘支持保持原样。 +- 首字母自动大写:参考把这条做成开关,不是默认行为——存储项 `enable_first_letter_capitalization` 是 `BooleanPreference`、默认 `false`(`res/cU.xml`),开关本体在它设置里的「快捷格式输入」页(`res/xq.xml`,页面标题 `string/key_auxiliary_input_title`「快捷格式输入」,行内标题 `string/first_letter_capitalization_title`「首字母自动大写」;`root_preferences.xml` 的「输入设置」分类里另有一份同样默认关闭的开关)。我们从 0.0.2-beta.1 起英文模式一律不自动大写,现在照参考补成开关,默认关,所以默认行为不变。打开后,英文 26 键按光标前的文本决定 Shift:输入框还空着、或光标前是句末标记(`.`、`!`、`?`、`…`、`。`、`!`、`?` 与换行;标记和光标之间的空格不算)时是大写一次,词中是小写,回看窗口 8 个字符。用户自己锁的 Caps Lock、以及要求全大写的输入框(`TYPE_TEXT_FLAG_CAP_CHARACTERS`)不动。跟着每一次改动文本的动作走:字母与符号上屏、退格、空格、回车与长按换行、候选上屏、进入新的输入框、切到英文 26 键。顺带把 Shift 键的无障碍描述换成参考自己的那句话——`string/caps_lock_guide_text`(0x7f1100de)「双击大写键锁定大写输入」。 +- 按键音量:参考自己的设置项 `key_sound_volume` 是 `IntPreference`、默认 `50`(`res/cU.xml`),就挨着总开关 `enable_key_sound`。我们原来没有音量这一档:自带采样固定按 1.0 播,系统点击音直接传 `-1`(系统默认音量)。现在「按键音效」下面多一条 0–100 的「按键音量」,默认 50,与参考一致,自带采样和系统点击音都按这个百分比播;键盘里的设置面板也补上了同一条滑块。 +- 诗句联想:打出来的拼音凑够一句诗的前三个音节,这一句就出现在候选栏里;整句打全时,紧跟着的那一句也一起出来(打 `chunmianbujuexiao` 出「春眠不觉晓」和「处处闻啼鸟」)。参考实现自带一份诗句词典(`sys_poem_gram`,类型 11010),里面每条线(LINE)是「行号+汉字+音节序列」,后面跟一组关联组(GROUP),记着这一句在自己那首诗里的下一句(`doubaoime-teardown/DECODE-BATCH-D.md` §4,逐字节走完整份 351,270 字节,5,966 行 + 10,542 组);它自己的设置文案把诗词列在联想推荐的内容里:「关闭联想后,将不会推荐诗词、计算结果、emoji表情。」。我们原来没有这一层,现在由 `scripts/import_doubao_data.py` 从那份表生成 `poem/lines.tsv`(4 字以上的 5,498 句,其中 5,042 句带下一句),拼音词表旁新增 `PoemIndex`:候选栏先给引擎自己的词,诗句跟在后面。三个音节是量出来的门槛——词表里最常用的 3000 个读音,两个音节就能对上诗句的有 380 个(12.7%,「春风」这种日常输入会一直冒诗句),三个音节只有 3 个(0.1%)。这一层跟着「智能联想」开关走,与参考把诗词算在联想的同一个开关里一致。参考那边的「计算结果」当时还没有对等物——本次「算式出结果」一条已补上;四字以下的短句和九键按数字查句都没有移植,句间标点仍留在表里没有用上。 +- 剪贴板列表也能拆词:长按任意一条进入「滑动选词」(参考自己的文案:列表上那句提示是「长按列表可进入拆词」`clipboard_history_word_seg_tip`,视图标题 `word_segmentation_title`「滑动选词」)。文字按词库切成词——每个位置取词表里最长的那个词(词表最长 8 字,「依法追究刑事责任」就是),字母数字连成一段,标点和空白是不能选的间隔——在词上滑动划出一段再点「添加到常用语」(`word_segmentation_add_to_common_phrase`,没选中时按钮变淡且不可点,对应参考的 `word_segmentation_add_to_common_phrase_bg_disabled`),随后面板落在常用语列表,那一段就在里面。参考这一页的成组颜色把词条分成可选与不可选两种(`ime_word_segmentation_item_bg_color`、`..._item_selected_bg_color`、`..._label_clickable_bg_color`、`..._label_un_clickable_bg_color`),我们的口径一样:词可以选,标点与空白只是间隔。参考另有「常用语数量已达上限」,我们的常用语本来不设上限,这条不移植。 +- 发送键长按换行:参考自己的设置文案写着这条手势(`enable_key_enter_sub_title`「长按「发送」可换行。开关支持App范围」,开关本体 `enable_key_enter_send_msg` 在 `res/cU.xml` 里默认关闭、标题「回车键发送消息」,拨动开关时的确认框也重复这句)。编辑器声明了发送动作、键面读「发送」时长按插入一个换行,走的是「换行」那一面同样的真 Enter 键事件(没有动作时的那条路径),不再是发送。合成中键面读「确定」,「搜索」「前往」「完成」这些动作参考没有这条手势,都保持原来的一次按压;键的无障碍描述跟着念出这条手势(「发送,长按换行」,与角标键「,上滑或长按输入X」同一写法)。 +- 修掉 0.0.10-beta.1 里浮动键盘整块收不到触摸的问题(卡片看着在,点什么都没反应)。浮动时服务在 `onComputeInsets` 里把内容顶和可见顶都报成卡片自己的下边缘,为的是让身后的应用既不 resize 也不 pan;但基类给触摸模式设的是 `TOUCHABLE_INSETS_VISIBLE`("the area inside of the visible insets can be touched",`ViewTreeObserver.InternalInsetsInfo`),可见顶等于整张卡片高度时,这块区域正好被剪成空集,卡片就一点可触摸区都不剩。现在这份报告按 AOSP 全屏分支的搭配补上 `TOUCHABLE_INSETS_FRAME`(`InputMethodService.onComputeInsets`:`info.contentInsets.top = info.visibleInsets.top = decor.getHeight(); info.setTouchableInsets(TOUCHABLE_INSETS_FRAME);`),整张卡片恢复可触摸,应用仍然不被 resize/pan;停靠键盘不走这条分支,行为不变。`FloatingInsetsInstrumentedTest` 固定这三项的值。 +- 顶栏的长按拖动现在在空白处也起手。浮动键盘原来只有按在图标上才能拖:手指落在图标之间(或空闲时那一行)时没有任何子视图消费这次按下,框架从此把整条手势直接派发给顶栏自己的 `onTouchEvent`,不再回头问拦截器(`ViewGroup.dispatchTouchEvent` 在没有触摸目标时 `intercepted = true`),而这里返回 `super` 的 false,长按成立了也没人接手。现在顶栏认领这条手势,吞掉按下、长按后在第一个移指样本上起手。停靠键盘的「键盘调节」则在真机上根本起不了手:它要求按在工具条行内,而行的位置取自 `getLocationInWindow`(窗口内坐标),拿去比的是 `MotionEvent.rawY`——"the original location of the event on the screen, before it had been adjusted for the containing window and views"。输入法窗口不在屏幕原点(`dumpsys window`:`frame=[0,1731][1080,2400]`,`gravity=BOTTOM`),行的窗口 y 是 0、手指的屏幕 y 约 1731,这个判断在真机上永远不成立。现在按屏幕坐标取行框,与候选项滑选(`KeyPopupController.trackChoiceTouch`)同一取法。 +- 「键盘调节」拖动时只做预览,松手才写一次。原来每个触摸样本都走一次 `setKeyboardHeightPercent`:一次拖动写上百次设置、整块键面重建上百次。现在行高和键盘高度跟着手指实时变,重建压到下一帧(一帧内多个样本只重建一次),松手(或手势被系统收走)时才写回一次:存下最终高度并通知一次。 +- 浮动键盘拖动只在卡片真的移动时才写窗口属性。原来每个触摸样本都写一次,包括取整后位移为 0 的抖动样本——一次写入就是一次窗口 relayout,60Hz 下就是每帧一次;拖动路径每条还要重读一次不透明度设置。现在位置、尺寸、不透明度都没变化时直接不写,不透明度沿用上次应用的值;真的移动仍然照写,边界仍然夹取。 +- 键面上残留的按下不再挡住之后的所有重建。`hasPressedKey()` 靠遍历 `isPressed` 判断「还有键占着这条手势」,`renderModeBody` 在此期间会把重建交给一个 40ms 的轮询。视图被移出层级时 `isPressed` 不会被清掉,于是被重建换下、或把手势吞掉的那一次按下,会让某个键永远看起来是按下的:此后每次重建(切键盘、转屏、关面板)都静默失效,轮询还一直跑着。现在 `dispatchTouchEvent` 记着手上有没有触摸流,没有时 `hasPressedKey()` 一律回答"没有",真手势期间的推迟不受影响,松手后那次被推迟的重建照常落地。 +- 参考实现自带一份「错音词」表(每个词标出某个字在词里该怎么读,如「阿房宫」的「阿」读 ē、「房」读 páng),共 6308 条。对照我们的词表:3390 条读音本来就一致,2852 条词我们表里根本没有(多为姓名和少见的专名),剩下 66 条是我们读错的——岑参(应读 cén shēn,我们表里是 cēn cān)、五行八作(wǔ háng bā zuō)、说客(shuō kè)这类。这 66 条现在生成一张 64 行的修正表 `pinyin_reading_fixes.tsv`,由拼音词库一并加载,所以读错的词现在能用正确读音打出来(打 `censhen` 出「岑参」)。修正行沿用该词自己的词频权重,不会盖过同读音的常用词;只写不一致的那几十条,读音相同的 3390 条不重复,我们表里没有的 2852 条属于「收词范围」而不是「读音错误」,这次不动。参考那份表的索引(每行一个音节 id 加两个计数字段)含义在它的 native 代码里,从数据无法判定,我们只用了内容能逐字节解码的部分。 +- 模糊音打开时,常用的六组(`z = zh`、`c = ch`、`s = sh`、`an = ang`、`en = eng`、`in = ing`)默认就是打开的,其余九组默认关闭。参考实现的模糊音设置页也是这个默认:总开关「模糊拼音」默认关闭,而它自己的偏好 XML 里这六组写着 `defaultValue=true`(`res/cU.xml` 即 `R.xml.settings_preferences`,键名 `kZEqualToZh`、`kCEqualToCh`、`kSEqualToSh`、`kAnEqualToAng`、`kEnEqualToEng`、`kInEqualToIng`),另外九组为 false——十五组与我们的开关一一对应。我们原来十五组全关,用户打开「启用模糊音」后还要自己一组组勾。总开关仍默认关闭,所以不碰这个设置的人不受影响。 +- 五笔四码打完只剩一个候选时立即上屏(参考设置的「四码唯一直接上屏」,默认打开)。参考实现的五笔设置页有四个开关,默认值就写在它自己的偏好 XML 里(`res/cU.xml` 即 `R.xml.settings_preferences`,由 `SettingsConfigNext.` 读取):五笔编码提示、五笔拼音混输、四码唯一直接上屏三项默认打开,第五码将首选词上屏默认关闭。我们的 `wubi86.schema.yaml` 原来没有设 `speller/auto_select`,而 librime 只有这个开关打开才会把「只此一个候选」的四码输入直接上屏(librime 1.17.0,`app/src/main/cpp/vendor/librime/src/rime/gear/speller.cc:161`),于是没有对手的五笔字一直留在候选栏等下一个键。参考自己的五笔码最长四码(一码 25 个、二码 617 个、三码 4809 个、四码 143749 个),所以这个开关不会提前触发。另外两项在 librime 里对不上:第五码上屏(`speller.cc:148`)只要 `max_code_length` 大于 0 就一定发生、不看 `auto_select`,参考那边默认关闭,两者拆不开;五笔拼音混输要一份五笔与拼音合一的表(rime-wubi 是单独一个方案),编码提示要候选注释。 +- 双拼方案的名称按参考实现的原文对齐:设置里的选项行叫「26键双拼方案」,两项分别是「小鹤双拼」与「自然码双拼」(参考 `resources.arsc` 里双拼方案名只有这两条字符串,没有单叫「自然码」的),原来我们写的是「双拼方案」和「自然码」。双拼方案本身不变,仍是这两个。 +- 联想(表情)栏的表换成参考实现自己的那一张。参考的 `follow_dict.dat` 是一棵按「刚上屏的词」查的 UTF-16 字典树,结点里存的就是该词对应的符号(`中国` → 🇨🇳、`火` → 🔥、`上` → ⬆️ ↑、`一` → 1️⃣ ①,`doubaoime-teardown/DECODE-BATCH-C.md` §3.1、§3.2),共 1 万多个词条、其中 5508 个带符号;我们原来是一张手写的 502 词表(开心 → 😊 这类),参考有联想的词(火、好、上班、下雨)上屏后一个角标都不出。现在这张表由 `scripts/import_doubao_data.py` 从参考数据生成(`emoji/follow-keywords.tsv`,5508 词、5616 个符号),手写表仍然先读,两边都有的 312 个词保留手写表里的表情。匹配规则没变(单字只整词匹配,多字词可匹配结尾),所以变的只是覆盖面;表的解析放在输入法启动时的后台线程(与英文词表同处),首次上屏不需要等它。这一栏现在的名字与参考实现一致,叫「智能联想」(参考设置页原文「智能联想」+「根据上文提供的文字、表情、符号等推荐」,`base.apk` 的 `resources.arsc`),说明也改成「选词后按上文给出接续词、表情和颜文字」——原来叫「表情联想」,但栏里早就同时有接续词和颜文字。 +- 模糊音的两条规则现在可以同时作用在同一个音节上。参考实现的音节表里,一条拼写带两个模糊位的行,恰好都是「一个声母组 + 一个韵母组」(`ch_c+eng_ong`、`n_l+in_ing`、`z_zh+an_ang` 等 15 组里的每一种组合),没有两个声母或两个韵母的组合;按参考自己的说法,输入的拼写就是「原音节套用整个掩码」的结果。也就是说同时打开「z = zh」和「an = ang」后,`zhang` 也应该能打出「赞」(zan),打开「n = l」和「in = ing」后,`ning` 应该能打出「林」(lin),打开「c = ch」和「en = eng」后,`cheng` 应该能打出「岑」(cen)。我们原来一条拼写只套用一条规则,这些打不出来。另外,模糊音的拼写本身不必是一个音节:打开「c = ch」后 `cuang` 应该是「床」(chuang),`cuang` 并不是一个音节,我们原来要求拼写必须是音节表里有的,这类拼法同样打不出来。 +- 模糊音补上参考实现有、我们没有的四组:`un = ong`、`ei = ui`、`huang = wang`、`eng = ong`。参考的音节表按 15 组经典模糊音判定(`f_syllable-variants.tsv` 掩码的 D3–D6 位),我们只做了常见的前 11 组,这几组打开后一个候选都不会变。四组的口径都按参考自己的表逐条核对:`un`/`ong`(`chun` → `chong`,含 `jiong`/`jiun`、`yong`/`yun` 这类)与参考的 35 条行、`eng`/`ong`(`deng` → `dong`,含 `yeng`、`weng`/`wong`)与参考的 37 条行完全一致;`ei = ui` 在参考里只覆盖 `fei`/`hui` 一对(`hei` 要同时打开「f = h」才跟着进来,参考自己那两行也是两个掩码位一起写的);`huang = wang` 是整对音节。设置页新增这四组开关,仍默认关闭。 +- 26 键上,自己反复选过的词现在排在该读音候选的最前面。参考实现的转换流水线第一步就是用户自己的词库(`ConvertByUserDict` 是 26 键流程的第一个调用点),排在系统词库、扩展词库、联系人、云端、整句和联想之前;我们的 26 键候选顺序由内置引擎给出,librime 的学习结果只能挂在引擎列表后面,于是反复选的词一直升不上来(打 shuli 选「数理」多少次,下一次首位仍是「梳理」)。现在 26 键把本地记录里该读音下选过的词放在候选栏最前,26 键上的一次选择也会记进这份记录;记录门槛仍是选够 3 次,密码框等不学习的位置不记也不显示。 +- 26 键上把 ü 写成 v 的拼法现在能打出对应的音节:参考实现的音节表把 `jv`/`jvan`/`jve`/`jvn` 当作 `ju`/`juan`/`jue`/`jun` 自己的拼写(`q`、`x`、`y` 三族同理,另有 `lve`/`nve` 对 `lue`/`nue`),这几条与模糊音设置无关(参考里它们的两个掩码字节都是 0),而我们的音节表只有规范拼写,于是打 `jv`、`xv`、`xvan`、`yvn`、`jve`、`qvn` 一个候选都没有——26 键上没有 ü 这个字母,v 就是它惯用的写法。现在这些拼法与规范拼法给出同一列候选。 +- 整句转换改为按参考实现的方式读词:参考对每个可达位置各自从音节表走一遍,每个位置只出这一处的音节边;我们原来是按整条切分路径读词,同一条读音会被跨过它的每条路径各查一次(`wohenhaoxiexie` 这类句子的切分路径有上千条)。现在从每个起始位置沿音节格走,一次音节边一次查词,走到音节格走不动为止。整句结果不变(14 个用例仍全部首位,随机 5 万个读音首选词一致仍为 50000/50000),一次查询 43–171µs,走整句时 758µs–1.38ms(一帧 16ms)。 +- 整句拼音打全时,候选栏第一位改为整句本身:像 `jintianxingqiwu` 这样的读音,词库里没有对应的整词,以前候选栏给的是「今天心情」这类只借了前半段的词,现在先给「今天星期五」(今天+星期五)。词库里真有这个词的读音不变(`pengke` 仍是「朋克」在前)。尾音还没打完的拼音(如 `jintianxingqiw`)不出整句,仍按已打全的音节给词。参考实现本来就是每条读音都过一遍整句转换、候选栏以整句打头。 +- 修掉拼音候选栏另外两类「词库里有词、却不出候选」的问题。一类是长读音切分被挤掉:`adelianandeliefu` 应切成 a+de+li+an+an+de+lie+fu(阿德里安安德烈夫),但同样这串字母还能切成音节更少的 a+de+lia+nan+de+lie+fu,单个字母本身也是合法音节(词表里就有字母词条),音节少的那条排在前面,把正确切分挤出候选路径之外,于是一个候选都没有。现在每个位置只保留「能接到词」的切分——查不到词继续的切分本来也不会产生候选,不该占位置。另一类是「以当前拼音开头的词」这个查询本身是坏的:按读音排序的词表里,以某个读音开头的词集中在中间一段,原来的二分查找把它们当成排在表尾处理,对任何读音都返回空区间,于是所有补全候选都不出(打 `ni` 只有单字,打 `jintian` 看不到「今天不是」这类词,全靠后面的 Rime 兜底才看着正常)。现在按读音的后继取区间。顺带两处排序:整串拼音能拼出的词(朋克)排在只是以它开头的词(朋克头)前面;先算整串读音、再算尾音未打完的片段,避免片段把查询预算吃光、用户真正打的那串反而没查到。随机 5 万个读音对照参考词典自己的排序,首选词一致 50000/50000(修复前补全候选全缺,只是恰好没影响这 5 万个读音的首选)。 +- 修掉拼音引擎里一类「词库明明有词、候选栏却一个都不出」的问题:一个读音可以切成多种音节(`luanan` 既是 luan+an,也是 lu+a+nan),切分时每个位置保留的候选路径原来按先来后到截前 4 条,切成更多音节、实际不会命中的切法能先把位置占满,真正的那条挤不进来,于是候选栏空白、空格把原拼音原样送进输入框。现在每个位置按音节数从少到多保留最好的路径,保留条数从 4 提到 16——参考实现本来就是枚举全部路径、按每条音节 ×0.99 递减打分后再排序,路径条数不设上限。随机取 4007 个读音对照参考词典自己的排序:修复前 36 个读音有词却无候选(如 `luanan` 的「乱按」「卵鞍」、`geleimiao` 的「格雷米奥」),修复后 0 个,首选词与参考词典一致 3969 → 4006/4006。 +- 设置页的键盘卡片新增「输入拼音显示」:默认仍是拼音留在键盘顶上的候选栏;选「显示在输入框」后,输入中的拼音直接写进应用自己的输入框,键盘上不再重复这一行,候选行照旧。密码框、数字/日期/电话这类字段,以及只吃按键事件的终端与游戏不写——否则拼音会进到密码里,或者落在一个只收数字的框里。参考皮肤里同一个设置(默认 0 = 显示在候选栏)也是这么分的。 +- 展开候选页补上参考皮肤给这一页自己的侧边留白(2.5 单位),列表卡片两侧各留出一点底色;原来这张卡片贴着屏幕两边。竖屏键区不加参考皮肤给键区的那道侧边留白——它会改动九键与 26 键的键位大小,竖屏布局保持原样。 +- 展开候选页改为参考皮肤自己给这一页的颜色:列表整张底是 40% 白(深色 6% 白)配 2 单位边框(浅色 6% 黑、深色 10% 白)、圆角 11,候选格之间的细线用同一条边框色;「全部/单字」这一组的胶囊底改为浅色 4% 黑、深色 6% 白,选中那格浅色仍是不透明白、深色改为压暗(24% 黑),未选文字改为参考的 40% 键文字色;列表右下角的退格键改用参考给这个键自己的实色(浅色 rgb(233,233,233)、深色 rgb(59,59,59),按下 rgb(193,193,193)、rgb(42,42,42))。原来整张列表是纯白配通用分隔线,分组胶囊用的是候选栏底色,在页面上看不出来,退格键是灰色功能键底。 +- 工具页与切换键盘页顶部的返回键改为参考皮肤自己的圆形键:一层浅色圆底(浅色 80% 白、深色 12% 白),按下换成参考给这一类键的按下色(浅色 6% 黑、深色 4% 白),图标用参考的按键图标色(浅色 85% 黑、深色纯白);原先是一个没有底色的箭头。圆的直径 40dp,触控区仍是 48dp。 +- 键盘字面按参考皮肤每一类键自己的字号绘制:26 键英文字母小写 20sp(参考的 `key_letter_english` 60 单位,大写仍是拼音的 19sp)、九键字母 16sp(`key_letter9` 48,`@#` 键仍是 18sp)、笔画键 20sp(`key_stroke` 60,「通配」14sp、「:」「;」「@#」18sp)、数字页与分屏数字格 18sp(`key_number9` 54)。原来这些键分别借用候选字号 18sp、符号字号 24sp 和字母字号 21sp。 +- 九键和笔画键盘底行的「123」与「中/英」两个键改为白色键:参考皮肤给这两个键写的是白键底(`cls_keyboard9_button_func_white_circle` 与 `cls_bk_white_circle`),与中间的「空格」和上面的字母键一致;原来画成了灰色功能键。左列的「符号」键仍是灰键,与参考一致。 +- 英文 26 键上的「中/英」键文字改为主题色:参考皮肤里这个键在英文键盘上指向中文,主字用主题色画(`keyboard_current_english`,深浅两套都是 95% 的主题色);中文键盘上的同一个键仍用普通功能键文字色,只有右上角小字变淡。 +- 切换键盘页里当前键盘那一格改为参考皮肤自己的选中样式:卡片底色和旁边几格一样,只有图标与文字变成主题色(参考皮肤给这一类只写了选中文字与选中图标为主题色,没有写选中底色;工具页的磁贴才是整格填主题色、文字变白)。原来是整格填主题色、图标与文字变白。 +- 数字页左下角的「返回」键改为参考皮肤自己的颜色:参考皮肤里这个键用主题色底(`cls_bk_theme_circle` 填 `theme_color`),按下换成白键的底色;原来我们把它画成了灰色键。(键上的字仍是我们的「返回」:参考皮肤用的是它自带的图标字体,这个字我们无法随包分发。) +- 按键按下时改为参考皮肤的对调效果:参考皮肤里白键按下的颜色就是灰键的底色(`keybutton_pushed_bk` 等于 `keybutton_func_bk`),灰键按下则变成白键的底色(`keybutton_func_pushed_bk` 等于 `keybutton_bk`),深浅两套皮肤都是这一对颜色对调;原来两种键按下都是各自加深一点。主题色键(确定)仍是自己的按下色。 +- 候选项按下时改为参考皮肤的候选底色(浅色是不透明白、深色是 8% 白),所有候选一格一样,第一个候选不再单独按主题色按压;候选栏右端的表情与展开键、符号页工具栏里的最近符号格也是同一个底色。原来是在各自的底色上加深一点,浅色下是按下去变暗。 +- 展开候选页的候选格按下改为参考皮肤自己给这一类的底色(浅色 6% 黑、深色 4% 黑,深浅都是压暗一点);原来用的是白色半透明,深色下会发亮。 +- 九键、笔画和数字页左侧栏的条目(符号、拼音读法、网址)按下改为白键的底色(参考这一类写的按下色就是 `keybutton_bk`,浅色白、深色 28% 白),与同一列旁边的键一致;原来是在灰色面板上加深一点。 +- 符号页底部的分类标签改为参考皮肤自己的样式:选中和按下是同一层浅灰胶囊(浅色 6% 黑、深色 9% 白,圆角胶囊),文字未选中 60%、选中 85%,都不再用主题色;原来选中项是主题色底的胶囊、未选中是次要文字色。 +- 主题色键(确定、发送、搜索)按下时改为白键的样子:参考皮肤里这个键按下换成「动作键按下底色」(浅色不透明白、深色 12% 白),键面文字同时转成深色(浅色 85% 黑);原来是把主题色压暗一点。工具页与切换键盘页里带主题色的磁贴仍是各自轻微的按下色。 +- 长按气泡里的符号按下时改为主题色底、字变白(参考这类的按下底就是主题色、按下文字为白);原来按下只是加深一点灰。 +- 每个键帽下沿改回参考皮肤的那条 1 单位投影线(浅色 30% 黑、深色 40% 黑,参考所有键类都写了 `shadow_y_offset="1"`);原来这条线取的是按键的按下色,浅色下几乎看不见,深色下会发白。 +- 打字时顶部的拼音行与候选行改按参考皮肤的比例分配(参考把这一条切成 20 : 36 并留 2 单位空隙):拼音行 21dp、候选行 37dp(原来 18 与 42)。整个顶栏高度不变,键盘位置不动。 +- 九键左侧栏统一为参考皮肤的那一个字号:符号、拼音读法和网址条目都按参考的 47 单位(约 15.5sp,原来符号按字形 20–24sp、读法 20sp);整栏面板也改按参考给这个列表自己的边距绘制(左右 2.5、上下 2.75 单位,原来四边统一 3 单位),与旁边的键位对齐。 +- 键帽圆角改为参考皮肤的 6 单位(原来 8):参考皮肤里白键、灰功能键、符号格、历史符号格和三个返回键写的都是 bkround 6。 +- 长按气泡改为参考皮肤自己的底色与圆角:浅色是不透明白、深色是实色灰(96,96,96),不再用键盘上那种半透明的白;圆角按参考的 8 单位(原来 12),并且去掉我们自加的描边,只留参考的那层阴影(原来是半透明底加一圈边,深色下能透出底下的键)。气泡里每个可选符号改用参考皮肤自己的字号(`fi_fusiyin_bubble_button` 60 单位,原为正文 14sp)。 +- 按下时显示的放大键位改为参考皮肤「按下提示」这一类自己的样式:圆角 12 单位(这一类在参考皮肤里是 `cls_touch_tip_button`,比长按气泡圆角更大),字号按参考的 87 单位(29sp,原来 28sp)。「分词」「空格」这类多字功能键在同一宽度里放不下这么大的字,仍用正文号。 +- 键帽边距改为按参考皮肤每一类按键自己的数值绘制:26 键是左右 2.25、上下 4.75 单位(原来所有键统一 6),九键、笔画和数字页是 2.5 与 2.75;26 键那一行的「分词」「退格」两个大键朝字母区一侧各留 8 单位。键位大小和键盘高度都不变,只是键帽在格子里按参考的比例收边。 +- 大系统字号下,26 键底行的「,。」「,」「。」几个标点键不再被省略号截断:参考皮肤给这几个键单独的字号(72 对 57 单位),我们让这个标记在键内收缩到合适大小。 +- 复制文字或收到验证码后的建议条改为参考皮肤的样式:不再是一整块带底色的胶囊,而是按候选字号显示的文字加一个来源小标(消息/剪贴板;参考皮肤里这个标是上标,约候选字号的 53%,贴着这一行的上沿,文字框也按参考的比例上下留白),右端是参考皮肤自己的关闭键;点一下仍然直接上屏,关闭后同一条内容不再出现(换新内容会重新出现)。系统输入法提供的自动填充建议仍用原来的返回键。 +- 拼音预编辑框宽度改为参考皮肤的一半页宽(参考皮肤的这个输入块最多占半页,右半留给旁边的控件),超出部分在框内左右滚动;左侧内边距改为参考的 5 单位;编辑时按参考皮肤变暗(浅色 8% 黑、深色 12% 白)。原先竖屏铺满整行、横屏用我们自己定的 160dp 上限,且编辑时没有按下反馈。 +- 悬浮键盘时工具栏改为显示一条提示(参考皮肤在悬浮键盘上显示的就是这条提示):圆角药丸底、左边是说明文字「轻点顶部把手可恢复默认键盘」、右边一个圆形关闭键;按关闭键本次悬浮期间不再出现,收起重开悬浮键盘会再次显示。 +- 工具页与切换键盘页改用参考皮肤的方格磁贴:整格就是一块圆角卡片(圆角 14,按下变深,当前键盘那一格填主题色、图标与文字变白),四列两行、同页磁贴等高,磁贴区域占页宽 88.9% 并留参考皮肤自己的四周留白;原先是 56dp 的图标小方块带底色、名称在方块外面。 +- 符号页的工具栏改为显示最近输入过的符号(参考皮肤在符号页工具栏里放的就是这条「历史符号列表」,可横向滑动,最右是关闭键):点一下即可再次上屏,关闭键收起这条列表(本次进入符号页不再出现,下次进入符号页会重新显示)。没输入过符号时不显示这条列表。 +- 符号页底部增加「123」与「退格」两个键(参考皮肤把这两个键放在符号那一行的两端):在符号页里可以直接退格,或一键切到数字键盘,不必先退出符号页。 +- 符号页的分类标签移到页面底部:一条可横向滑动的标签栏(参考皮肤把分类放在符号页最后一行、返回键右侧),符号格重新占满页宽;每行仍是 5 格以保证 48dp 的触控尺寸(参考皮肤每行 10 格)。 +- 空闲工具栏的按键顺序改为参考皮肤的顺序(剪贴板、表情、切换键盘…),整条图标栏改为可左右滑动:图标保持固定宽度,放不下时滑动查看,多余宽度留在图标栏内部;「收起」键仍在最右、不随滑动移动。 +- 笔画键盘对齐参考皮肤:九宫格补齐为三行九格(一 丨 丿 / 丶 折 通配 / @# :;),每格右上角印角标 1–9、上滑输入该数字;「通配」角标 6;新增的「:」「;」直接上屏对应标点,「@#」长按弹出参考皮肤自带的「/ - _ @ # . 7」。九键与笔画底行的空格键改按参考权重(45.5 : 134 : 45.5)加宽。 +- 数字键盘改为参考皮肤自己的数字页:左列是符号栏(占三行键高)加底部「返回」键,中间三列数字(1/4/7、2/5/8、3/6/9),每列最后一格分别是「符号」「0」「.」,右列是「退格」「空格」「发送」三键、发送键占两格高;列宽按参考权重 66 : 75 : 75 : 75 : 66。原来横排的「返回 空格 0」一行和右侧的「@」键取消,空格键与另外两个功能键一样是灰底。电话输入框里「符号」格改为拨号的「#」、「.」格改为「+」,「空格」仍是「*」。 + +- 26 键的 A、L 两个边键各加宽半个字母(与参考皮肤 15:10 的键宽一致),末行「分词」「退格」按同一比例加宽(原来是 1.25 倍)。 +- 九键与笔画键盘右侧一列改为「删除、重输、发送」三键,发送键占两格高;原先单独的「0」键取消,改印在空格键右上角,上滑空格输入 0(26 键空格不带角标;跟随「上滑输入数字」开关)。 +- 候选栏的候选间距收紧(每侧内边距 12dp → 8dp),同屏能多显示几个候选。 +- 26 键字母键角标改按参考产品逐键对齐:英文 a=- s=/ d=: f=; g=( h=) j=~ k=' l=" z=@ x=_ c=# v=& b=? n=! m=…,中文为同键位的全角形(d=: f=; g=( h=) j=~ k=“ l=” x=. v=、 b=? n=! m=…,a、s、z、c、m 两语相同);上滑仍是输入角标,长按改为只弹字母大小写两项(大写在前),与参考一致;逗号键新增上滑输入句号。 +- 九键左侧栏的拼音索引对齐参考产品:与常用符号共用同一个列表,每个读法一行、同一字号(不再逐条缩放),条目间是细线,当前读法不再高亮填色(读数由拼音串本身显示);只有换了数字才回到列表顶部,其余刷新保持滚动位置。 +- 26 键拼音候选排序:读音正好覆盖已输入全部字母的词,现在排在只匹配了前半段、尾部还差几个字母的候选前面(输入 dang 先出「当、党、档」,输入 dangdi 先出「当地」),不再被「切了一半」的高频词挤下去。 +- 九键左侧栏在网址、邮箱输入框里改为参考皮肤的网址栏(`.` `/` `www.` `.com` `.cn` `http` `wap.` `ftp:`),普通输入框仍是用自定义的标点。 +- 候选栏第一个候选改为主题色文字(中等字重、无底色),不再是一个带底色的方块。 +- 展开候选页对齐参考皮肤:顶部一排「全部 / 单字」筛选(「单字」只留单个汉字,默认「全部」);候选列表收进一张圆角卡片(圆角 11、描边 2);退格键浮在卡片右下角(占列表宽高各 25.2% / 20.2%,只圆右下角),点它删掉一个拼音字母、页面留在更短的候选列表上;网格里每个候选统一为平铺样式,不再把第一个填成强调色块。 +- 26 键底行补齐参考产品的两个符号键:空格键左右各加一键(中文是「,」和「。」,英文是「,」和「.」,网址与邮箱输入框里换成「/」和「.」),底行各键宽度改按参考皮肤的实际权重;英文下「句号逗号」合并键改为以「.」为主标签、角标「,」(原来主副颠倒),上滑或长按输入角标。 +- 九键左上角一格改为参考产品的「@#」键:主标签「@#」、角标仍是 1,点按与上滑都输入 1,长按弹出参考皮肤自带的符号列表「/ - _ 1 @ # .」;九键不再有「分词」键(参考产品只在 26 键那一行有分词键)。 +- 26 键「分词」键去掉自造的「@#/」角标与长按弹窗:参考皮肤的这个键只有一个文字标签。 +- 拼音预编辑串按参考皮肤分两色:用户已经选定的音节(九键左侧点选的读法)用主题色,剩下未转换的部分用普通键字色;字号同时从 14sp 调到参考的 13sp(comp_font 40)。 +- 候选条按参考皮肤排版:候选列表左右各留 4 单位内缩,列表与展开键之间加一条细分隔线(中间再隔 3 单位),展开键的按压底色改成参考的整圆胶囊形。 +- 空闲工具栏按键改为参考皮肤的样子:每个图标 40 单位宽、自带白色胶囊底与按压色(收起键同样),多余宽度留在图标与收起键之间,不再把图标拉伸铺满一行。工具栏改成可横向滑动之后,这一排图标一度丢掉了这层胶囊底和灰色字色(图标变成纯黑、没有底),现在都按参考皮肤恢复:图标是中灰(参考 `toolbar_idle_button_text`,深色下为半透明白)。 +- 键位上的角标改为参考皮肤自己的浅色(`keybutton_text_2`,浅色 40% 黑、深色 40% 白),不再用与主标签同一个字色;深色下角标原来偏亮,比主标签还显眼。 +- 候选栏与快捷输入条两端的小按钮(联想条的返回键、建议条的关闭键)改为参考皮肤的透明胶囊:只在按下时出现候选底色,图标用键字色;原来是常驻一层实心胶囊底。 +- 候选列表与展开键之间那条分隔线改用参考皮肤自己的颜色(`canditem_split`:浅色 12% 黑、深色 16% 白,原来是拿键帽描边色淡化出来的灰线,深浅两套下都比参考更实)。 +- 深色下第一个候选不再用主题色文字:参考皮肤这个颜色在深色皮肤里就是普通候选文字色(`first_cand_text_color` 深色为 95% 白,与 `canditem_text` 相同),第一个候选只靠字重区分;浅色下仍是主题色。 +- 空闲工具栏按键、工具页与切换键盘页的磁贴、设置页的行,按下时改用参考皮肤自己的按下色(浅色 6% 黑、深色 4% 白,都是压在所在页面底色上),深浅两套下按下都是「按暗」;原来按下色是在各自的底色上算出来的,深色下按下去反而变亮。联想条的候选格按下也改为候选底色(与候选栏一致)。 + ## [0.0.10-beta.1] - 2026-10-14 第十个测试版:对齐参考产品(横屏留白、单手模式、键盘调节),新增双拼、五笔、手写与自研拼音引擎,语音模型可自行导入,并新增不含语音模型的精简安装包。从 0.0.9-beta.1 覆盖安装即可,用户数据保留。 diff --git a/README.md b/README.md index 67afebc1..d3224ca3 100644 --- a/README.md +++ b/README.md @@ -80,11 +80,12 @@ The app has no `INTERNET` permission, so your text cannot leave the phone. ## Install 1. Open [Releases](https://github.com/Slacker-LLC/openIME/releases). - Download the latest `openIME-v*-arm64-release.apk`. + Download `openIME-llm-v*.apk` for the build that carries the offline voice + models, or `openIME-v*.apk` for the smaller build that has none. 2. Check the SHA-256 checksum that the release notes show: ```bash - sha256sum openIME-v*-arm64-release.apk + sha256sum openIME-llm-v*.apk ``` 3. Install the APK. diff --git a/README.zh-CN.md b/README.zh-CN.md index 5a0391bb..310793dd 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -59,12 +59,12 @@ openIME 是一款独立的 Android 系统输入法。拼音候选、用户词库 ## 下载与安装 -1. 在 [Releases](https://github.com/Slacker-LLC/openIME/releases) 下载最新的 `openIME-v*-arm64-release.apk`。 +1. 在 [Releases](https://github.com/Slacker-LLC/openIME/releases) 下载:带离线语音模型的 `openIME-llm-v*.apk`,或是不含模型的小体积版本 `openIME-v*.apk`。 发布包仅支持 `arm64-v8a` 设备(绝大多数近年的 Android 手机),系统要求 Android 8.0(API 26)及以上。 2. 对照发布说明里的 SHA-256 校验后再安装: ```bash - sha256sum openIME-v*-arm64-release.apk + sha256sum openIME-llm-v*.apk ``` 3. 打开 openIME,按引导启用输入法并切换到 openIME。 diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index 853e7f9b..8275b7c1 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -14,13 +14,14 @@ This file covers only the third-party components. | sherpa-onnx Android runtime | `app/libs/sherpa-onnx-1.13.6.aar` | https://github.com/k2-fsa/sherpa-onnx | Apache-2.0 | The APK contains `assets/licenses/sherpa-onnx-Apache-2.0.txt`. | | Streaming Paraformer bilingual (Chinese and English) INT8 model | `app/src/main/assets/models/voice/bilingual-paraformer/` | https://huggingface.co/csukuangfj/sherpa-onnx-streaming-paraformer-bilingual-zh-en | Apache-2.0 | The APK contains only `encoder.int8.onnx`, `decoder.int8.onnx` and `tokens.txt`. `assets/licenses/paraformer-model-Apache-2.0.txt` is included. `manifest.json` pins the model ID, the version and the file hashes. | | CT-Transformer punctuation INT8 model | `app/src/main/assets/models/voice/punctuation/` | https://modelscope.cn/models/iic/punc_ct-transformer_zh-cn-common-vocab272727-pytorch and https://github.com/k2-fsa/sherpa-onnx/releases/tag/punctuation-models | Apache-2.0 | We pin the `2024-04-12-int8` export. The manifest lists the file hashes. The APK contains `ct-transformer-Apache-2.0.txt`. | +| Apache Commons Compress | `org.apache.commons:commons-compress:1.27.1` (Maven) | https://commons.apache.org/proper/commons-compress/ | Apache-2.0 | Imported to read the bzip2 archives the official sherpa-onnx voice-model releases ship. The APK contains `assets/licenses/commons-compress-Apache-2.0.txt` (the artifact's own `META-INF/NOTICE.txt` and `META-INF/LICENSE.txt`). | | Microsoft Fluent Emoji | `app/src/main/assets/emoji/fluent/` | https://github.com/microsoft/fluentui-emoji | MIT | The APK has only the emoji images that the app uses. It contains `assets/licenses/fluent-emoji-MIT.txt`. | ## Checks before a release - Do not delete `LICENSE`, `COPYING`, `AUTHORS` or `NOTICE` files from vendored source directories. - Keep the license texts in `app/src/main/assets/licenses/` in the APK. - This includes Rime Ice, librime, OpenCC, Snappy, sherpa-onnx, the Paraformer model and Fluent Emoji. + This includes Rime Ice, librime, OpenCC, Snappy, sherpa-onnx, Apache Commons Compress, the Paraformer model and Fluent Emoji. - When you update the voice runtime or a model, check the license of the new version. Do not rely on the earlier result. - When the dictionary source or its pinned commit changes, update this file, [docs/LICENSING.md](docs/LICENSING.md) and the license files in the APK. diff --git a/VERSION b/VERSION index 7c859c86..3eefcb9d 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.0.10-beta.1 +1.0.0 diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 2f2257fd..80b8c015 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -72,7 +72,9 @@ android { ndkVersion = "27.0.12077973" defaultConfig { - applicationId = "llc.slacker.openime" + // A side-by-side development build (the owner's phone runs the .dev + // variant) passes -PopenimeAppIdSuffix=.dev; release builds never do. + applicationId = "llc.slacker.openime" + ((findProperty("openimeAppIdSuffix") as String?) ?: "") minSdk = 26 targetSdk = 36 testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" @@ -212,6 +214,10 @@ androidComponents { dependencies { implementation(files("libs/sherpa-onnx-1.13.6.aar")) + // The official sherpa-onnx voice-model archives ship as tar.bz2, and the + // platform ships no bzip2 decoder (`java.util.zip` has zip/gzip only), so + // the import needs one from here. + implementation("org.apache.commons:commons-compress:1.27.1") testImplementation("junit:junit:4.13.2") testImplementation("org.json:json:20260814") androidTestImplementation("androidx.test.ext:junit:1.3.0") diff --git a/app/src/androidTest/java/llc/slacker/openime/AuditInteractionInstrumentedTest.kt b/app/src/androidTest/java/llc/slacker/openime/AuditInteractionInstrumentedTest.kt index 433937b5..69abde83 100644 --- a/app/src/androidTest/java/llc/slacker/openime/AuditInteractionInstrumentedTest.kt +++ b/app/src/androidTest/java/llc/slacker/openime/AuditInteractionInstrumentedTest.kt @@ -32,9 +32,11 @@ import llc.slacker.openime.voice.VoiceRecognitionEvents import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNotSame import org.junit.Assert.assertNull import org.junit.Assert.assertSame import org.junit.Assert.assertTrue +import org.junit.Assume.assumeTrue import org.junit.Test import org.junit.runner.RunWith import java.lang.reflect.Proxy @@ -653,10 +655,11 @@ class AuditInteractionInstrumentedTest { keyboard.showPanel(Panel.TOOLS) val card = keyboard.findViewWithTag("tool:表情") assertTrue("Tool card must be keyboard-focusable", card.isFocusable) - // The column is the target; its icon tile mirrors the column's state. - val tile = (card as ViewGroup).getChildAt(0) - assertTrue("Tool tile must expose pressed and focus feedback", tile.background is StateListDrawable) - assertTrue("Tool tile must follow the card's pressed state", tile.isDuplicateParentStateEnabled) + // The whole cell is the reference's own tile: it carries the face and + // the press, and its glyph mirrors the tile's state. + assertTrue("Tool tile must expose pressed and focus feedback", card.background is StateListDrawable) + val icon = (card as ViewGroup).getChildAt(0) + assertTrue("Tool glyph must follow the tile's pressed state", icon.isDuplicateParentStateEnabled) assertEquals("表情", card.contentDescription) val label = card.getChildAt(1) assertTrue( @@ -919,7 +922,8 @@ class AuditInteractionInstrumentedTest { @Test fun modeSwitchDismissesLongPressChoicePopup() = withKeyboard { harness, _, keyboard -> harness.awaitMain { - // 1 is the nine-key segmentation key; its long press offers @ # /. + // The nine-key 1 cell is the @# key: its long press offers the + // reference's own symbol list. keyboard.setMode(KeyboardMode.PINYIN_9, notifyListener = false) val segment = keyboard.findViewWithTag("key-9:1") val baseline = keyboard.childCount @@ -1174,6 +1178,7 @@ class AuditInteractionInstrumentedTest { var downTime = 0L var released = false var spaceViewSurvivedRebuildPoll = false + var starvedPastLongPress = false lateinit var spaceKey: View val releaseDelayMs = minOf( 100L, @@ -1201,6 +1206,12 @@ class AuditInteractionInstrumentedTest { // Give the deferred row rebuild poll several main-loop turns, but // release before Android's own long-click can consume this tap. keyboard.postDelayed({ + // 机器卡顿时这个回调会晚到:一旦越过长按阈值,平台就不再把它当点击 + // (`View.onTouchEvent` 置了 `mHasPerformedLongPress` 后不再发 click), + // 这颗键按设计就是语音而空格。这条断言在那种时间线下没有意义,跳过而不是 + // 报错——否则 CI 越忙越"红"。 + starvedPastLongPress = + SystemClock.uptimeMillis() - downTime >= ViewConfiguration.getLongPressTimeout().toLong() spaceViewSurvivedRebuildPoll = keyboard.findViewWithTag("key-space") === spaceKey val point = keyPoint(keyboard, "key-space") @@ -1210,6 +1221,10 @@ class AuditInteractionInstrumentedTest { true } harness.awaitMain(timeoutMs = 3_000L) { if (released) true else null } + assumeTrue( + "The main thread ran past the long-press deadline, so this press is a voice hold, not a tap", + !starvedPastLongPress, + ) harness.awaitMain { assertTrue( "The Space view must remain attached while the rebuild poll sees the active touch", @@ -1222,6 +1237,65 @@ class AuditInteractionInstrumentedTest { assertEquals("A rebuild during the press must not swallow the space", 1, recorder.spaces) } + @Test + fun aPressLeftWithoutAFingerOnlyDefersWhileTheFingerIsStillDown() = withKeyboard { harness, _, keyboard -> + // Half by half: the deferral (see rebuildWhileSpaceIsHeldDoesNotSwallowTheSpace) + // must protect a real gesture and must not outlive it. A press left behind by + // an earlier gesture — the rebuild that swallowed it, or a view swapped out + // mid-gesture — used to defer every later rebuild and poll for it forever. + val releaseDelayMs = minOf( + 100L, + (ViewConfiguration.getLongPressTimeout().toLong() / 2L).coerceAtLeast(1L), + ) + lateinit var staleSpace: View + harness.awaitMain { + staleSpace = requireNotNull(keyboard.findViewWithTag("key-space")) + staleSpace.isPressed = true + true + } + harness.awaitMain { + keyboard.setMode(KeyboardMode.ENGLISH_26, notifyListener = false) + val rebuilt = keyboard.findViewWithTag("key-space") + assertNotNull("The mode switch must rebuild the rows of the new layout", rebuilt) + assertNotSame( + "A press with no finger on the screen must not defer this rebuild", + staleSpace, + rebuilt, + ) + assertFalse("The deferred rebuild must let go of the stale key", staleSpace.isAttachedToWindow) + true + } + + // A finger really on the screen: the deferral still holds, and the rebuild + // lands as soon as that finger lifts. + var released = false + var downTime = 0L + lateinit var heldSpace: View + harness.awaitMain { + val point = keyPoint(keyboard, "key-space") + heldSpace = requireNotNull(keyboard.findViewWithTag("key-space")) + downTime = SystemClock.uptimeMillis() + pointers(keyboard, downTime, MotionEvent.ACTION_DOWN, listOf(point)) + assertTrue("Space must own the active press", heldSpace.isPressed) + keyboard.setMode(KeyboardMode.PINYIN_26, notifyListener = false) + assertSame( + "A live finger must still defer the rebuild", + heldSpace, + keyboard.findViewWithTag("key-space"), + ) + keyboard.postDelayed({ + pointers(keyboard, downTime, MotionEvent.ACTION_UP, listOf(point)) + released = true + }, releaseDelayMs) + true + } + harness.awaitMain(timeoutMs = 3_000L) { if (released) true else null } + // The rebuild poll runs on a 40ms cadence; give it a few turns. + harness.awaitMain(timeoutMs = 3_000L) { + keyboard.findViewWithTag("key-space")?.takeIf { it !== heldSpace } + } + } + @Test fun backspaceTapDeletesExactlyOnceAndCancelDeletesNothing() = withKeyboard { harness, recorder, keyboard -> harness.awaitMain { diff --git a/app/src/androidTest/java/llc/slacker/openime/AutoCapitalizationServiceInstrumentedTest.kt b/app/src/androidTest/java/llc/slacker/openime/AutoCapitalizationServiceInstrumentedTest.kt new file mode 100644 index 00000000..977e9f9f --- /dev/null +++ b/app/src/androidTest/java/llc/slacker/openime/AutoCapitalizationServiceInstrumentedTest.kt @@ -0,0 +1,227 @@ +package llc.slacker.openime + +import android.content.Context +import android.content.ContextWrapper +import android.os.Handler +import android.os.Looper +import android.text.InputType +import android.view.inputmethod.EditorInfo +import android.view.inputmethod.InputConnection +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import llc.slacker.openime.candidate.CandidateQueryCoordinator +import llc.slacker.openime.core.ImeState +import llc.slacker.openime.core.KeyboardMode +import llc.slacker.openime.core.ShiftState +import llc.slacker.openime.data.ImeSettingsRepository +import llc.slacker.openime.editor.InputConnectionGateway +import llc.slacker.openime.rime.RimeEngine +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Test +import org.junit.runner.RunWith +import java.lang.reflect.Proxy + +/** + * 首字母自动大写在服务这一侧:开着的时候,句末标记之后的英文要 Shift 抬起;关着的 + * 时候(参考自己的默认)什么都不做。密码框、要求全大写的输入框、用户自己锁的 Caps + * Lock 都不动,读不到输入框文字时也不猜。 + */ +@RunWith(AndroidJUnit4::class) +class AutoCapitalizationServiceInstrumentedTest { + private val context get() = InstrumentationRegistry.getInstrumentation().targetContext + + @After + fun restoreTheSwitch() { + ImeSettingsRepository.saveFirstLetterCapitalization(context, false) + } + + private fun withService( + enabled: Boolean, + textBefore: () -> String?, + inputType: Int = InputType.TYPE_CLASS_TEXT, + passwordField: Boolean = false, + shiftState: ShiftState = ShiftState.LOWERCASE, + test: (LocalVoiceImeService) -> Unit, + ) { + InstrumentationRegistry.getInstrumentation().runOnMainSync { + val connection = Proxy.newProxyInstance( + InputConnection::class.java.classLoader, + arrayOf(InputConnection::class.java), + ) { _, method, args -> + when (method.name) { + "getTextBeforeCursor" -> textBefore() + "commitText", "deleteSurroundingText", "sendKeyEvent", "performEditorAction", + "finishComposingText", "setComposingText", "setComposingRegion", + "requestCursorUpdates", "closeConnection", + -> true + else -> if (method.returnType == Boolean::class.javaPrimitiveType) false else null + } + } as InputConnection + + val service = LocalVoiceImeService() + // The service is constructed directly instead of being started by the + // platform, so it has no base context yet. + ContextWrapper::class.java + .getDeclaredMethod("attachBaseContext", Context::class.java) + .apply { isAccessible = true } + .invoke(service, context) + + ImeSettingsRepository.saveFirstLetterCapitalization(context, enabled) + setField(service, "gateway", InputConnectionGateway(context, { connection }, { passwordField })) + // The backspace path clears the pre-edit, which goes through the + // engine and the candidate queries; neither is started in a bare + // service, so the test hands both a not-ready instance. + val rime = RimeEngine(context) + setField(service, "rime", rime) + setField( + service, + "candidateQueries", + CandidateQueryCoordinator( + rime = rime, + mainHandler = Handler(Looper.getMainLooper()), + fallbackCandidatesFor = { emptyList() }, + maxInputLength = 32, + maxNineKeyPaths = 8, + maxCandidates = 20, + ), + ) + setField( + service, + "state", + ImeState( + keyboardMode = KeyboardMode.ENGLISH_26, + passwordField = passwordField, + shiftState = shiftState, + editorInfo = EditorInfo().apply { this.inputType = inputType }, + ), + ) + test(service) + } + } + + private fun setField(service: LocalVoiceImeService, name: String, value: Any) { + LocalVoiceImeService::class.java.getDeclaredField(name).apply { + isAccessible = true + set(service, value) + } + } + + private fun shiftState(service: LocalVoiceImeService): ShiftState { + val field = LocalVoiceImeService::class.java.getDeclaredField("state").apply { isAccessible = true } + return (field.get(service) as ImeState).shiftState + } + + @Test + fun offItLeavesTheShiftKeyWhereTheEditorPutIt() { + withService(enabled = false, textBefore = { "Hello. " }) { service -> + service.onCharacter("a") + assertEquals(ShiftState.LOWERCASE, shiftState(service)) + } + } + + @Test + fun onAMarkAfterAWordTheNextLetterIsACapital() { + withService(enabled = true, textBefore = { "Hello. " }) { service -> + service.onCharacter("a") + assertEquals(ShiftState.SHIFT_ONCE, shiftState(service)) + } + withService(enabled = true, textBefore = { "Hello wor" }) { service -> + service.onCharacter("a") + assertEquals(ShiftState.LOWERCASE, shiftState(service)) + } + } + + @Test + fun theSwitchItselfTakesEffectOnTheFieldThatIsUp() { + var before = "Hello. " + withService(enabled = false, textBefore = { before }) { service -> + service.onCharacter("a") + assertEquals("关着的时候不动", ShiftState.LOWERCASE, shiftState(service)) + + // The panel writes the setting and calls this; the field is re-read. + ImeSettingsRepository.saveFirstLetterCapitalization(context, true) + service.onAutoCapitalizationChanged() + assertEquals(ShiftState.SHIFT_ONCE, shiftState(service)) + + // Turning it back off leaves the last decision alone: Shift is the + // user's from here on. + ImeSettingsRepository.saveFirstLetterCapitalization(context, false) + before = "Hello" + service.onAutoCapitalizationChanged() + assertEquals(ShiftState.SHIFT_ONCE, shiftState(service)) + } + } + + @Test + fun deletingTheMarkLowersTheShiftAgain() { + var before = "Hi. " + withService(enabled = true, textBefore = { before }) { service -> + service.onCharacter("a") + assertEquals(ShiftState.SHIFT_ONCE, shiftState(service)) + + before = "Hi" + service.onBackspace() + assertEquals(ShiftState.LOWERCASE, shiftState(service)) + } + } + + @Test + fun typingTheMarkItselfRaisesTheShift() { + var before = "Hello" + withService(enabled = true, textBefore = { before }) { service -> + before = "Hello." + service.onSymbolSelected(".") + assertEquals(ShiftState.SHIFT_ONCE, shiftState(service)) + } + } + + @Test + fun aSentenceMarkInAFieldThatOnlyWantsLowercaseIsStillRespected() { + withService( + enabled = true, + textBefore = { "Hello. " }, + inputType = InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_FLAG_CAP_CHARACTERS, + ) { service -> + service.onAutoCapitalizationChanged() + assertEquals( + "全大写输入框由它自己决定,这里不改", + ShiftState.LOWERCASE, + shiftState(service), + ) + } + } + + @Test + fun theUserSLockedCapsIsNeverReleased() { + withService( + enabled = true, + textBefore = { "Hello wor" }, + shiftState = ShiftState.CAPS_LOCK, + ) { service -> + service.onCharacter("a") + assertEquals(ShiftState.CAPS_LOCK, shiftState(service)) + } + } + + @Test + fun aPasswordFieldIsNeverRead() { + withService( + enabled = true, + textBefore = { "Hello. " }, + passwordField = true, + inputType = InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_PASSWORD, + ) { service -> + service.onAutoCapitalizationChanged() + assertEquals(ShiftState.LOWERCASE, shiftState(service)) + } + } + + @Test + fun anEditorThatCannotAnswerLeavesTheShiftAlone() { + withService(enabled = true, textBefore = { null }) { service -> + service.onAutoCapitalizationChanged() + assertEquals(ShiftState.LOWERCASE, shiftState(service)) + } + } +} diff --git a/app/src/androidTest/java/llc/slacker/openime/CandidatePresentationInstrumentedTest.kt b/app/src/androidTest/java/llc/slacker/openime/CandidatePresentationInstrumentedTest.kt index 349ab0b2..2195c0ff 100644 --- a/app/src/androidTest/java/llc/slacker/openime/CandidatePresentationInstrumentedTest.kt +++ b/app/src/androidTest/java/llc/slacker/openime/CandidatePresentationInstrumentedTest.kt @@ -3,11 +3,13 @@ package llc.slacker.openime import android.view.View import android.view.ViewGroup import android.widget.EditText -import android.widget.ScrollView +import android.widget.TextView import androidx.test.ext.junit.runners.AndroidJUnit4 import llc.slacker.openime.core.ImeState import llc.slacker.openime.keyboard.ImeKeyboardView import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull import org.junit.Assert.assertSame import org.junit.Assert.assertTrue import org.junit.Test @@ -50,15 +52,37 @@ class CandidatePresentationInstrumentedTest { keyboard.findViewWithTag("candidate-expand").performClick() val overlay = keyboard.findViewWithTag("candidate-overlay") assertEquals(View.GONE, keyboard.findViewWithTag("keyboard-body").visibility) - // The expanded overlay is just the scrolling grid (no header row). - val scroll = overlay.getChildAt(0) as ScrollView + // The expanded overlay is the filter row over the list card. + val card = overlay.findViewWithTag("candidate-expanded-card") + assertNotNull(card) keyboard.renderState(state) assertSame(first, keyboard.findViewWithTag("candidate-first-row")) - assertSame(scroll, overlay.getChildAt(0)) + assertSame(card, overlay.findViewWithTag("candidate-expanded-card")) + + // 单字 keeps single characters only; this list has none, so the + // grid empties while the page stays open. + filterChip(overlay, "单字").performClick() + assertEquals(View.VISIBLE, overlay.visibility) + assertNull(keyboard.findViewWithTag("candidate-grid-first")) + filterChip(overlay, "全部").performClick() + assertNotNull(keyboard.findViewWithTag("candidate-grid-first")) + keyboard.renderState(ImeState()) assertEquals(View.GONE, overlay.visibility) true } } } + + private fun filterChip(root: ViewGroup, label: String): View { + val found = mutableListOf() + fun walk(view: View) { + if (view.tag == "candidate-filter-chip" && (view as? TextView)?.text?.toString() == label) { + found.add(view) + } + if (view is ViewGroup) for (index in 0 until view.childCount) walk(view.getChildAt(index)) + } + walk(root) + return found.single() + } } diff --git a/app/src/androidTest/java/llc/slacker/openime/ClipboardRetentionInstrumentedTest.kt b/app/src/androidTest/java/llc/slacker/openime/ClipboardRetentionInstrumentedTest.kt index fb5c75cf..40a8308f 100644 --- a/app/src/androidTest/java/llc/slacker/openime/ClipboardRetentionInstrumentedTest.kt +++ b/app/src/androidTest/java/llc/slacker/openime/ClipboardRetentionInstrumentedTest.kt @@ -2,12 +2,15 @@ package llc.slacker.openime import android.content.ClipData import android.content.ClipboardManager +import android.content.ComponentName import android.content.Context import android.os.PersistableBundle +import android.provider.Settings import android.view.View import android.view.ViewGroup import android.view.inputmethod.BaseInputConnection import android.widget.TextView +import androidx.test.core.app.ApplicationProvider import androidx.test.ext.junit.runners.AndroidJUnit4 import llc.slacker.openime.core.KeyboardMode import llc.slacker.openime.core.Panel @@ -24,6 +27,7 @@ import org.junit.Assert.assertFalse import org.junit.Assert.assertNotNull import org.junit.Assert.assertNull import org.junit.Assert.assertTrue +import org.junit.Assume import org.junit.Before import org.junit.Test import org.junit.runner.RunWith @@ -72,8 +76,29 @@ class ClipboardRetentionInstrumentedTest { } } + /** + * The two "stale queued capture" tests below assert that nothing was + * written to the history. When this app is the user's active input method, + * its own service is bound in this process and its clipboard listener + * captures every copy — including the fixture these tests copy — by design + * (that is the history feature). The queued panel load is then + * indistinguishable from the listener, so those two tests need an + * emulator whose active IME is not this app; the harness runs them on CI, + * where the system IME is active. + */ + private fun assumeNoClipboardListenerOfThisApp() { + val context = ApplicationProvider.getApplicationContext() + val stored = Settings.Secure.getString(context.contentResolver, Settings.Secure.DEFAULT_INPUT_METHOD) + val active = stored?.let(ComponentName::unflattenFromString) + Assume.assumeFalse( + "this app is the active IME (${active?.flattenToShortString()}), so its own clipboard listener captures the fixture", + active == ComponentName(context, LocalVoiceImeService::class.java), + ) + } + @Test fun leavingClipboardBeforePostedLoadDoesNotCaptureStaleClip() { + assumeNoClipboardListenerOfThisApp() lateinit var keyboard: ImeKeyboardView harness.awaitMain { activity -> ClipboardHistoryRepository.clearAll(activity) @@ -118,6 +143,7 @@ class ClipboardRetentionInstrumentedTest { @Test fun closingClipboardBeforePostedLoadDoesNotCaptureStaleClip() { + assumeNoClipboardListenerOfThisApp() lateinit var keyboard: ImeKeyboardView harness.awaitMain { activity -> ClipboardHistoryRepository.clearAll(activity) diff --git a/app/src/androidTest/java/llc/slacker/openime/ClipboardWordSegmentationInstrumentedTest.kt b/app/src/androidTest/java/llc/slacker/openime/ClipboardWordSegmentationInstrumentedTest.kt new file mode 100644 index 00000000..fd444987 --- /dev/null +++ b/app/src/androidTest/java/llc/slacker/openime/ClipboardWordSegmentationInstrumentedTest.kt @@ -0,0 +1,255 @@ +package llc.slacker.openime + +import android.os.SystemClock +import android.view.MotionEvent +import android.view.View +import android.view.ViewGroup +import android.widget.TextView +import androidx.test.ext.junit.runners.AndroidJUnit4 +import llc.slacker.openime.core.KeyboardMode +import llc.slacker.openime.core.Panel +import llc.slacker.openime.core.ShiftState +import llc.slacker.openime.data.ClipboardHistoryRepository +import llc.slacker.openime.data.QuickPhraseRepository +import llc.slacker.openime.keyboard.ImeKeyboardView +import llc.slacker.openime.theme.ImeAppearance +import llc.slacker.openime.theme.ImeSurfacePolicy +import llc.slacker.openime.theme.ImeTheme +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith + +/** + * 拆词 on the clipboard panel: the reference enters it with a long press on the + * list (`clipboard_history_word_seg_tip`「长按列表可进入拆词」), selects words by + * sliding across them (`word_segmentation_title`「滑动选词」) and files the run + * into the quick phrases (`word_segmentation_add_to_common_phrase` + * 「添加到常用语」, disabled until a run is selected — + * `word_segmentation_add_to_common_phrase_bg_disabled`). + */ +@RunWith(AndroidJUnit4::class) +class ClipboardWordSegmentationInstrumentedTest { + + private lateinit var harness: DirectActivityHarness + + /** 今天/天气/不错 are rows of the phrase table; 天气不错 is not. */ + private val fixture = "今天天气不错,iPhone 15" + + @Before + fun launch() { + harness = DirectActivityHarness(DebugKeyboardActivity::class.java) + harness.launch() + } + + @After + fun close() { + harness.close() + } + + @Test + fun aLongPressOpensTheSplitAndTheSlideFilesTheRun() { + lateinit var keyboard: ImeKeyboardView + harness.awaitMain { activity -> + ClipboardHistoryRepository.clearAll(activity) + ClipboardHistoryRepository.add(activity, fixture) + keyboard = ImeKeyboardView(activity, NoopListener()) + activity.findViewById(android.R.id.content).addView( + keyboard, + ViewGroup.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT, + ), + ) + keyboard.showPanel(Panel.CLIPBOARD) + true + } + + val card = harness.awaitMain { + keyboard.findViewWithTag("clip-card")?.takeIf { it.width > 0 } + } + harness.awaitMain { assertTrue("the clipboard card must take a long press", card.performLongClick()); true } + + // The split is built off the main thread (the word tables are read for + // it), so the flow arrives a moment after the long press. + val flow = harness.awaitMain { + keyboard.findViewWithTag("word-seg-flow")?.takeIf { it.childCount > 0 } + } + harness.awaitMain { + assertEquals( + "the view carries the reference's own title", + "滑动选词", + keyboard.findViewWithTag("panel-section-title")?.text?.toString(), + ) + assertEquals( + listOf("今天", "天气", "不错", ",", "iPhone", " ", "15"), + (0 until flow.childCount).map { (flow.getChildAt(it) as TextView).text.toString() }, + ) + true + } + + val add = keyboard.findViewWithTag("word-seg-add") + harness.awaitMain { + assertNotNull(add) + assertFalse("nothing is selected yet", add!!.isEnabled) + assertEquals(ImeSurfacePolicy.DISABLED_ALPHA, add.alpha, 0.001f) + true + } + + // 滑动选词: press 今天, sweep to 不错, lift. + val downTime = SystemClock.uptimeMillis() + harness.awaitMain { + send(flow, MotionEvent.ACTION_DOWN, downTime, centreX(flow, 0), centreY(flow, 0)) + send(flow, MotionEvent.ACTION_MOVE, downTime, centreX(flow, 1), centreY(flow, 1)) + send(flow, MotionEvent.ACTION_MOVE, downTime, centreX(flow, 2), centreY(flow, 2)) + send(flow, MotionEvent.ACTION_UP, downTime, centreX(flow, 2), centreY(flow, 2)) + true + } + harness.awaitMain { + assertEquals("the run is the three words, not one", 1f, add!!.alpha, 0.001f) + assertTrue(add.isEnabled) + (0..2).forEach { index -> + assertTrue( + "row $index must read as selected", + (flow.getChildAt(index).tag as String).startsWith("word-token-selected:"), + ) + } + assertEquals( + "a separator is never part of the run", + "word-token-plain:3", + flow.getChildAt(3).tag as String, + ) + true + } + + harness.awaitMain { add!!.performClick(); true } + harness.awaitMain { activity -> + assertTrue( + "the run must land in the quick phrases", + QuickPhraseRepository.load(activity).any { it.text == "今天天气不错" }, + ) + assertEquals( + "the panel lands on the list the phrase went into", + "常用语", + keyboard.findViewWithTag("segment-selected")?.text?.toString(), + ) + // Leave the store as it was: the phrase this test added is its own. + QuickPhraseRepository.load(activity).filter { it.text == "今天天气不错" } + .forEach { QuickPhraseRepository.remove(activity, it.id) } + ClipboardHistoryRepository.clearAll(activity) + keyboard.shutdown() + true + } + } + + @Test + fun aLongEntryWrapsAndTheSlideCrossesTheLines() { + val long = "今天天气不错,".repeat(4).trimEnd(',') + lateinit var keyboard: ImeKeyboardView + harness.awaitMain { activity -> + ClipboardHistoryRepository.clearAll(activity) + ClipboardHistoryRepository.add(activity, long) + keyboard = ImeKeyboardView(activity, NoopListener()) + activity.findViewById(android.R.id.content).addView( + keyboard, + ViewGroup.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT, + ), + ) + keyboard.showPanel(Panel.CLIPBOARD) + true + } + val card = harness.awaitMain { + keyboard.findViewWithTag("clip-card")?.takeIf { it.width > 0 } + } + harness.awaitMain { assertTrue(card.performLongClick()); true } + val flow = harness.awaitMain { + keyboard.findViewWithTag("word-seg-flow")?.takeIf { it.childCount > 0 } + } + + val downTime = SystemClock.uptimeMillis() + harness.awaitMain { + val rows = (0 until flow.childCount).map { flow.getChildAt(it).top }.distinct().size + assertTrue("a long text must wrap onto more than one line, got $rows", rows > 1) + val last = flow.childCount - 1 + // 今天天气不错,今天天气不错,… — the last word is the final 不错. + assertEquals("不错", (flow.getChildAt(last) as TextView).text.toString()) + send(flow, MotionEvent.ACTION_DOWN, downTime, centreX(flow, 0), centreY(flow, 0)) + // Sweep down the lines, the way a finger crosses them. + (0..last).forEach { index -> + send(flow, MotionEvent.ACTION_MOVE, downTime, centreX(flow, index), centreY(flow, index)) + } + send(flow, MotionEvent.ACTION_UP, downTime, centreX(flow, last), centreY(flow, last)) + true + } + harness.awaitMain { + val add = keyboard.findViewWithTag("word-seg-add") + assertNotNull(add) + assertTrue("the whole text is one run", add!!.isEnabled) + add.performClick() + true + } + harness.awaitMain { activity -> + assertTrue( + "the whole text must be selectable across its lines", + QuickPhraseRepository.load(activity).any { it.text == long }, + ) + QuickPhraseRepository.load(activity).filter { it.text == long } + .forEach { QuickPhraseRepository.remove(activity, it.id) } + ClipboardHistoryRepository.clearAll(activity) + keyboard.shutdown() + true + } + } + + private fun centreX(flow: ViewGroup, index: Int): Float { + val child = flow.getChildAt(index) + return (child.left + child.right) / 2f + } + + private fun centreY(flow: ViewGroup, index: Int): Float { + val child = flow.getChildAt(index) + return (child.top + child.bottom) / 2f + } + + private fun send(view: View, action: Int, downTime: Long, x: Float, y: Float) { + val event = MotionEvent.obtain(downTime, SystemClock.uptimeMillis(), action, x, y, 0) + try { + view.dispatchTouchEvent(event) + } finally { + event.recycle() + } + } + + private class NoopListener : ImeKeyboardView.Listener { + override fun onModeChanged(mode: KeyboardMode) = Unit + override fun onPanelChanged(panel: Panel) = Unit + override fun onCharacter(char: String) = Unit + override fun onBackspace() = Unit + override fun onClearAll() = Unit + override fun onSpace() = Unit + override fun onFloatingKeyboardChanged(floating: Boolean) = Unit + override fun onFloatingKeyboardDragged(deltaX: Float, deltaY: Float) = Unit + override fun onVoiceToggle() = Unit + override fun onEnter() = Unit + override fun onCompositionChanged(composition: String, candidates: List) = Unit + override fun onCandidateSelected(candidate: String) = Unit + override fun onCompositionBackspace() = Unit + override fun onThemeChanged(theme: ImeTheme) = Unit + override fun onAppearanceChanged(appearance: ImeAppearance) = Unit + override fun onShiftStateChanged(state: ShiftState) = Unit + override fun onCandidateExpanded(open: Boolean) = Unit + override fun onSymbolSelected(symbol: String) = Unit + override fun onEmojiSelected(emoji: String) = Unit + override fun onTextEdit(action: String) = Unit + override fun onSoundChanged(enabled: Boolean) = Unit + override fun onHapticChanged(enabled: Boolean) = Unit + override fun onPopupChanged(enabled: Boolean) = Unit + override fun onFuzzyChanged(enabled: Boolean) = Unit + } +} diff --git a/app/src/androidTest/java/llc/slacker/openime/DisplayEnvironmentInstrumentedTest.kt b/app/src/androidTest/java/llc/slacker/openime/DisplayEnvironmentInstrumentedTest.kt index 35f2e4f2..42b06668 100644 --- a/app/src/androidTest/java/llc/slacker/openime/DisplayEnvironmentInstrumentedTest.kt +++ b/app/src/androidTest/java/llc/slacker/openime/DisplayEnvironmentInstrumentedTest.kt @@ -161,8 +161,11 @@ class DisplayEnvironmentInstrumentedTest { ) { keyboard -> val clipped = allViews(keyboard) .filterIsInstance() + // Only what the user can see counts: a key inside a panel + // that is closed keeps whatever layout it was last given. + .filter { it.isShown } .filter { !it.mainLabelFits() } - .map { "'${it.currentMainText}'" } + .map { "${it.tag}'${it.currentMainText}'" } .toList() assertEquals("labels cut off at font scale $fontScale in $mode", emptyList(), clipped) } diff --git a/app/src/androidTest/java/llc/slacker/openime/DoubleSpacePeriodServiceInstrumentedTest.kt b/app/src/androidTest/java/llc/slacker/openime/DoubleSpacePeriodServiceInstrumentedTest.kt new file mode 100644 index 00000000..bab17227 --- /dev/null +++ b/app/src/androidTest/java/llc/slacker/openime/DoubleSpacePeriodServiceInstrumentedTest.kt @@ -0,0 +1,162 @@ +package llc.slacker.openime + +import android.content.Context +import android.content.ContextWrapper +import android.os.Handler +import android.os.Looper +import android.os.SystemClock +import android.text.InputType +import android.view.inputmethod.EditorInfo +import android.view.inputmethod.InputConnection +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import llc.slacker.openime.candidate.CandidateQueryCoordinator +import llc.slacker.openime.core.ImeState +import llc.slacker.openime.core.KeyboardMode +import llc.slacker.openime.data.ImeSettingsRepository +import llc.slacker.openime.editor.InputConnectionGateway +import llc.slacker.openime.rime.RimeEngine +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Test +import org.junit.runner.RunWith +import java.lang.reflect.Proxy + +/** + * 双击空格键输入句号在服务这一侧:200 毫秒内的第二个空格把刚写下的空格换成 + * 「。」,落在标点后面或太慢的第二个空格仍是空格,开关关着(参考自己的默认) + * 从不换。参考的处理器是 `InputViewRoot$z.invoke`(classes.dex code_off + * 0x4c1764),窗口 `const-wide/16 0xc8`。 + */ +@RunWith(AndroidJUnit4::class) +class DoubleSpacePeriodServiceInstrumentedTest { + private val context get() = InstrumentationRegistry.getInstrumentation().targetContext + + @After + fun restoreTheSwitch() { + ImeSettingsRepository.saveDoubleSpacePeriod(context, false) + } + + private fun withService( + enabled: Boolean, + textBeforeCursor: String? = "好", + test: (LocalVoiceImeService, MutableList) -> Unit, + ) { + val events = mutableListOf() + InstrumentationRegistry.getInstrumentation().runOnMainSync { + val connection = Proxy.newProxyInstance( + InputConnection::class.java.classLoader, + arrayOf(InputConnection::class.java), + ) { _, method, args -> + when (method.name) { + "getTextBeforeCursor" -> textBeforeCursor + "commitText" -> { + events += "commit:${args?.firstOrNull()?.toString().orEmpty()}" + true + } + "deleteSurroundingTextInCodePoints", "deleteSurroundingText" -> { + events += "delete" + true + } + "sendKeyEvent" -> { + events += "key" + true + } + else -> if (method.returnType == Boolean::class.javaPrimitiveType) false else null + } + } as InputConnection + + val service = LocalVoiceImeService() + ContextWrapper::class.java + .getDeclaredMethod("attachBaseContext", Context::class.java) + .apply { isAccessible = true } + .invoke(service, context) + + ImeSettingsRepository.saveDoubleSpacePeriod(context, enabled) + setField(service, "gateway", InputConnectionGateway(context, { connection })) + val rime = RimeEngine(context) + setField(service, "rime", rime) + setField( + service, + "candidateQueries", + CandidateQueryCoordinator( + rime = rime, + mainHandler = Handler(Looper.getMainLooper()), + fallbackCandidatesFor = { emptyList() }, + maxInputLength = 32, + maxNineKeyPaths = 8, + maxCandidates = 20, + ), + ) + setField( + service, + "state", + ImeState( + keyboardMode = KeyboardMode.ENGLISH_26, + editorInfo = EditorInfo().apply { inputType = InputType.TYPE_CLASS_TEXT }, + ), + ) + test(service, events) + } + } + + private fun setField(service: LocalVoiceImeService, name: String, value: Any) { + LocalVoiceImeService::class.java.getDeclaredField(name).apply { + isAccessible = true + set(service, value) + } + } + + private fun readField(service: LocalVoiceImeService, name: String): Any? = + LocalVoiceImeService::class.java.getDeclaredField(name).apply { isAccessible = true }.get(service) + + private fun ageTheWindow(service: LocalVoiceImeService, millis: Long) { + setField(service, "lastSpaceAtMs", SystemClock.uptimeMillis() - millis) + } + + @Test + fun aSecondSpaceInsideTheWindowWritesThePeriod() = withService(enabled = true) { service, events -> + service.onSpace() + assertEquals(listOf("commit: "), events) + service.onSpace() + assertEquals("删掉那个空格,再把句号写上去", listOf("commit: ", "delete", "commit:。"), events) + } + + @Test + fun offTheSecondSpaceIsJustASpace() = withService(enabled = false) { service, events -> + service.onSpace() + service.onSpace() + assertEquals(listOf("commit: ", "commit: "), events) + } + + @Test + fun aSecondSpaceTooLateStaysASpace() = withService(enabled = true) { service, events -> + service.onSpace() + ageTheWindow(service, 500L) + service.onSpace() + assertEquals(listOf("commit: ", "commit: "), events) + } + + @Test + fun aSpaceAfterPunctuationStaysASpace() = withService(enabled = true) { service, events -> + // The character before the first space is what the reference gates on. + service.onSpace() + setField(service, "lastCharBeforeSpace", ',') + service.onSpace() + assertEquals(listOf("commit: ", "commit: "), events) + } + + @Test + fun anEditorThatCannotAnswerStaysASpace() = withService(enabled = true, textBeforeCursor = null) { service, events -> + service.onSpace() + service.onSpace() + assertEquals(listOf("commit: ", "commit: "), events) + } + + @Test + fun theFirstSpaceKeepsWhatWasBeforeIt() = withService(enabled = true, textBeforeCursor = "你") { service, _ -> + service.onSpace() + assertEquals('你', readField(service, "lastCharBeforeSpace")) + assertEquals(0L < (readField(service, "lastSpaceAtMs") as Long), true) + } +} diff --git a/app/src/androidTest/java/llc/slacker/openime/FloatingGameKeyboardInstrumentedTest.kt b/app/src/androidTest/java/llc/slacker/openime/FloatingGameKeyboardInstrumentedTest.kt index 5c74f518..fac3c89a 100644 --- a/app/src/androidTest/java/llc/slacker/openime/FloatingGameKeyboardInstrumentedTest.kt +++ b/app/src/androidTest/java/llc/slacker/openime/FloatingGameKeyboardInstrumentedTest.kt @@ -5,6 +5,7 @@ import android.os.Handler import android.os.Looper import android.os.SystemClock import android.util.DisplayMetrics +import android.widget.HorizontalScrollView import android.view.MotionEvent import android.view.View import android.view.ViewGroup @@ -77,10 +78,56 @@ class FloatingGameKeyboardInstrumentedTest { private fun View.visibleByTag(tag: String): Boolean = findViewWithTag(tag)?.visibility == View.VISIBLE + /** + * The floating keyboard meets the user with its one-time banner across the + * toolbar row (`input_toolbar_tip.xml:44-49`). A test that reads the toolbar + * or presses its icons settles the banner first, as the user does. + */ + private fun settleTip(harness: DirectActivityHarness, keyboard: ImeKeyboardView) { + harness.awaitMain { + keyboard.findViewWithTag("toolbar-tip-close")?.performClick() + true + } + } + + /** The icons live in the toolbar's own scrolling strip, so search the row's subtree. */ private fun View.toolbarIconByDescription(description: String): View? { val row = findViewWithTag("toolbar-row") ?: return null - return (0 until row.childCount).map(row::getChildAt) - .firstOrNull { it.contentDescription == description } + fun find(view: View): View? { + if (view.contentDescription == description) return view + if (view is ViewGroup) { + for (index in 0 until view.childCount) find(view.getChildAt(index))?.let { return it } + } + return null + } + return find(row) + } + + /** + * The idle row keeps our own order in the reference's scrolling strip. The + * reference's own row leads with the clipboard, then emoji, then the board + * selector (`input_toolbar_idle.xml:21-25`); the owner asked for our order + * back on 2026-10-16, so the order here is deliberate, not a drift. + */ + @Test + fun theIdleIconsKeepOurOrderInsideAScrollingStrip() = withKeyboard { harness, _, keyboard -> + harness.awaitMain { + val strip = keyboard.findViewWithTag("toolbar-icon-strip") ?: return@awaitMain null + val scroll = keyboard.findViewWithTag("toolbar-icon-scroll") + assertTrue( + "The idle row must scroll like the reference's own strip", + scroll is HorizontalScrollView, + ) + val labels = (0 until strip.childCount).map(strip::getChildAt) + .filter { it.visibility == View.VISIBLE && it.contentDescription != null } + .map { it.contentDescription.toString() } + assertEquals( + "The idle row keeps our own order, see docs/DECISIONS.md", + listOf("切换键盘", "剪贴板", "表情", "文本编辑", "撤销", "更多"), + labels, + ) + true + } } @Test @@ -92,6 +139,10 @@ class FloatingGameKeyboardInstrumentedTest { assertFalse("Docked toolbar has no quick-phrase shortcut", keyboard.visibleByTag("quick-phrase-toolbar")) keyboard.setFloatingWindowMode(true) + // The floating keyboard meets the user with its tip banner, which + // takes the toolbar row (`input_toolbar_tip.xml:44-49`); settling it + // gives the row back to the icons. + keyboard.findViewWithTag("toolbar-tip-close")?.performClick() assertTrue("Floating toolbar exposes quick phrases", keyboard.visibleByTag("quick-phrase-toolbar")) assertFalse(keyboard.visibleByTag("clipboard-toolbar")) assertFalse(keyboard.visibleByTag("undo-toolbar")) @@ -134,12 +185,15 @@ class FloatingGameKeyboardInstrumentedTest { @Test fun longPressOnTopZoneDragsTheFloatingKeyboardWithoutDockingOrClicking() = withKeyboard { harness, recorder, keyboard -> harness.awaitMain { keyboard.setFloatingWindowMode(true); true } + settleTip(harness, keyboard) val topZone = harness.awaitMain { keyboard.findViewWithTag("ime_toolbar") } val downTime = SystemClock.uptimeMillis() // Press on an icon, so a leaked click would be visible as a panel opening. val (x, y) = harness.awaitMain { val icon = keyboard.findViewWithTag("quick-phrase-toolbar") - val loc = IntArray(2).also { icon.getLocationInWindow(it) } + // The row is laid out once the banner has given it back. + if (icon?.width == 0) return@awaitMain null + val loc = IntArray(2).also { icon!!.getLocationInWindow(it) } val zoneLoc = IntArray(2).also { topZone.getLocationInWindow(it) } (loc[0] - zoneLoc[0] + icon.width / 2f) to (loc[1] - zoneLoc[1] + icon.height / 2f) } @@ -163,6 +217,49 @@ class FloatingGameKeyboardInstrumentedTest { } } + @Test + fun longPressOnTheBlankStripOfTheTopZoneDragsToo() = withKeyboard { harness, recorder, keyboard -> + harness.awaitMain { keyboard.setFloatingWindowMode(true); true } + settleTip(harness, keyboard) + val topZone = harness.awaitMain { keyboard.findViewWithTag("ime_toolbar") } + // 工具栏那一行自己就是顶栏的高度(`ImeTopZone` 给 toolbarRow 的高度就是 + // COMPOSED_TOP_ZONE_HEIGHT_DP),48dp 的图标条在它里面居中,上下各留一条空带。 + // 空带里没有任何子视图消费按下,而一条没有 touch target 的手势之后框架不再回头 + // 问拦截器:只能由顶栏自己认领(ImeTopZone.onTouchEvent)。 + val (x, y) = harness.awaitMain { + val icons = topZone.findViewWithTag("toolbar-icon-strip") + if (icons?.height == 0 || topZone.height == 0) return@awaitMain null + val zoneLocation = IntArray(2).also { topZone.getLocationInWindow(it) } + val iconsLocation = IntArray(2).also { icons.getLocationInWindow(it) } + val iconsBottom = iconsLocation[1] - zoneLocation[1] + icons.height + val strip = topZone.height - iconsBottom + if (strip < 4) return@awaitMain null + (topZone.width / 2f) to (iconsBottom + strip / 2f) + } + val downTime = SystemClock.uptimeMillis() + harness.awaitMain { touch(topZone, MotionEvent.ACTION_DOWN, downTime, x, y); true } + SystemClock.sleep(android.view.ViewConfiguration.getLongPressTimeout() + 250L) + harness.awaitMain { + touch(topZone, MotionEvent.ACTION_MOVE, downTime, x + 30f, y + 20f) + touch(topZone, MotionEvent.ACTION_MOVE, downTime, x + 90f, y + 60f) + touch(topZone, MotionEvent.ACTION_UP, downTime, x + 90f, y + 60f) + true + } + harness.awaitMain { + assertTrue("Long-press drag must move the window from blank top-zone space", recorder.drags.isNotEmpty()) + assertEquals( + "Drag deltas follow the finger after the grab point", + 60.0, + recorder.drags.sumOf { it.first.toDouble() }, + 1.0, + ) + assertEquals(40.0, recorder.drags.sumOf { it.second.toDouble() }, 1.0) + assertTrue("Releasing a drag must not dock the keyboard", recorder.floatingChanges.isEmpty()) + assertEquals("A drag on blank space must not open a panel", Panel.NONE, keyboard.currentPanel()) + true + } + } + @Test fun shortTapOnTopZoneStillClicksAndDoesNotDrag() = withKeyboard { harness, recorder, keyboard -> harness.awaitMain { keyboard.setFloatingWindowMode(true); true } diff --git a/app/src/androidTest/java/llc/slacker/openime/FloatingInsetsInstrumentedTest.kt b/app/src/androidTest/java/llc/slacker/openime/FloatingInsetsInstrumentedTest.kt new file mode 100644 index 00000000..db7cfddd --- /dev/null +++ b/app/src/androidTest/java/llc/slacker/openime/FloatingInsetsInstrumentedTest.kt @@ -0,0 +1,93 @@ +package llc.slacker.openime + +import android.content.Context +import android.content.ContextWrapper +import android.graphics.Rect +import android.inputmethodservice.InputMethodService +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith + +/** + * 浮动卡片向系统报告"身后被遮住多少":内容顶和可见顶都取卡片自己的下边缘,应用既不 resize + * 也不 pan;同一份报告还必须配 `TOUCHABLE_INSETS_FRAME`,否则整块卡片收不到触摸。 + * + * `super.onComputeInsets` 设的模式是 `TOUCHABLE_INSETS_VISIBLE`("the area inside of the + * visible insets can be touched",`ViewTreeObserver.InternalInsetsInfo`)。可见顶报成卡片 + * 整高时,那块区域就是"卡片 frame 从顶部内缩掉整个高度",一个空矩形——beta10 的浮动键盘 + * 因此整块点不动。AOSP 全屏分支做同一份报告时正是配 `TOUCHABLE_INSETS_FRAME`。 + */ +@RunWith(AndroidJUnit4::class) +class FloatingInsetsInstrumentedTest { + private val context get() = InstrumentationRegistry.getInstrumentation().targetContext + + private fun withService(test: (LocalVoiceImeService) -> Unit) { + InstrumentationRegistry.getInstrumentation().runOnMainSync { + val service = LocalVoiceImeService() + // The service is constructed directly instead of being started by the + // platform, so it has no base context yet. + ContextWrapper::class.java + .getDeclaredMethod("attachBaseContext", Context::class.java) + .apply { isAccessible = true } + .invoke(service, context) + test(service) + } + } + + @Test + fun aFloatingCardReportsItsOwnBottomEdgeAndStaysTouchableEverywhere() = withService { service -> + val outInsets = InputMethodService.Insets() + service.applyFloatingInsets(outInsets, CARD_HEIGHT_PX) + + assertEquals(CARD_HEIGHT_PX, outInsets.contentTopInsets) + assertEquals(CARD_HEIGHT_PX, outInsets.visibleTopInsets) + assertEquals( + "The whole card must keep answering touches", + InputMethodService.Insets.TOUCHABLE_INSETS_FRAME, + outInsets.touchableInsets, + ) + } + + @Test + fun aCardThatHasNoHeightYetLeavesTheBaseReportAlone() = withService { service -> + val outInsets = InputMethodService.Insets() + outInsets.contentTopInsets = BASE_CONTENT_TOP_INSETS + outInsets.visibleTopInsets = BASE_VISIBLE_TOP_INSETS + outInsets.touchableInsets = InputMethodService.Insets.TOUCHABLE_INSETS_VISIBLE + + service.applyFloatingInsets(outInsets, 0) + + assertEquals(BASE_CONTENT_TOP_INSETS, outInsets.contentTopInsets) + assertEquals(BASE_VISIBLE_TOP_INSETS, outInsets.visibleTopInsets) + assertEquals( + InputMethodService.Insets.TOUCHABLE_INSETS_VISIBLE, + outInsets.touchableInsets, + ) + } + + /** + * 报告为什么必须配 FRAME:默认模式下可触摸区就是"可见内缩之内的区域"( + * `ViewTreeObserver.InternalInsetsInfo.TOUCHABLE_INSETS_VISIBLE`),可见顶等于卡片 + * 高度时它被剪成空集,键盘一点反应都没有。这条把机制摆在修复旁边,别人就不能把 + * FRAME 那一行当冗余删掉。 + */ + @Test + fun theDefaultModeWouldLeaveTheCardWithNoTouchableArea() { + val touchable = Rect(0, 0, CARD_WIDTH_PX, CARD_HEIGHT_PX) + // 可见顶等于卡片高度时,可触摸区被剪成空集。四条边的 `Rect.inset` 是 API 32 + // 才有的,这条测试要在 API 26 的机器上跑,所以直接改边。 + touchable.top += CARD_HEIGHT_PX + + assertTrue("A full-height visible top leaves nothing touchable", touchable.isEmpty) + } + + private companion object { + const val CARD_WIDTH_PX = 1080 + const val CARD_HEIGHT_PX = 720 + const val BASE_CONTENT_TOP_INSETS = 111 + const val BASE_VISIBLE_TOP_INSETS = 222 + } +} diff --git a/app/src/androidTest/java/llc/slacker/openime/FloatingTipInstrumentedTest.kt b/app/src/androidTest/java/llc/slacker/openime/FloatingTipInstrumentedTest.kt new file mode 100644 index 00000000..f8c3c274 --- /dev/null +++ b/app/src/androidTest/java/llc/slacker/openime/FloatingTipInstrumentedTest.kt @@ -0,0 +1,137 @@ +package llc.slacker.openime + +import android.view.View +import android.view.ViewGroup +import android.widget.TextView +import androidx.test.ext.junit.runners.AndroidJUnit4 +import llc.slacker.openime.keyboard.ImeKeyboardView +import llc.slacker.openime.keyboard.ToolbarTipPolicy +import llc.slacker.openime.theme.ImeReferenceSizing +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import java.lang.reflect.Proxy + +/** + * The floating keyboard meets the user with the reference's tip banner in the + * toolbar row (`input_toolbar_tip.xml:44-49`), whose exit is our own handle tap + * rather than the reference's drag to the bottom edge. + */ +@RunWith(AndroidJUnit4::class) +class FloatingTipInstrumentedTest { + private val listener = Proxy.newProxyInstance( + ImeKeyboardView.Listener::class.java.classLoader, + arrayOf(ImeKeyboardView.Listener::class.java), + ) { _, method, _ -> if (method.returnType == java.lang.Boolean.TYPE) false else null } as ImeKeyboardView.Listener + + private fun withKeyboard(test: (DirectActivityHarness, ImeKeyboardView) -> Unit) { + DirectActivityHarness(DebugKeyboardActivity::class.java).use { harness -> + harness.launch() + val keyboard = harness.awaitMain { activity -> + ImeKeyboardView(activity, listener).also { + activity.findViewById(android.R.id.content).addView(it) + } + } + try { + harness.awaitMain { if (keyboard.width > 0) true else null } + test(harness, keyboard) + } finally { + harness.awaitMain { + keyboard.shutdown() + (keyboard.parent as? ViewGroup)?.removeView(keyboard) + true + } + } + } + } + + private fun tip(keyboard: ImeKeyboardView): View? = keyboard.findViewWithTag("toolbar-tip") + + /** Floats the keyboard and lets the banner take its place in the row. */ + private fun showFloatingTip(harness: DirectActivityHarness, keyboard: ImeKeyboardView) { + harness.awaitMain { keyboard.setFloatingWindowMode(true); true } + harness.awaitMain { if ((tip(keyboard)?.width ?: 0) > 0) true else null } + } + + @Test + fun theFloatingKeyboardOpensWithTheTipBanner() { + withKeyboard { harness, keyboard -> + assertEquals(View.GONE, harness.awaitMain { tip(keyboard)?.visibility }) + showFloatingTip(harness, keyboard) + assertEquals(View.VISIBLE, harness.awaitMain { tip(keyboard)?.visibility }) + val label = harness.awaitMain { keyboard.findViewWithTag("toolbar-tip-label") } + assertNotNull("The banner says what the handle does", label) + assertTrue( + "Our tip names our own way back to the docked keyboard", + label!!.text.toString().contains("把手"), + ) + assertNotNull( + "The banner keeps the reference's round close key", + harness.awaitMain { keyboard.findViewWithTag("toolbar-tip-close") }, + ) + } + } + + @Test + fun theCloseKeySettlesTheTipForTheStayAfloat() { + withKeyboard { harness, keyboard -> + harness.awaitMain { keyboard.setFloatingWindowMode(true); true } + harness.awaitMain { + keyboard.findViewWithTag("toolbar-tip-close")!!.performClick() + true + } + assertEquals(View.GONE, harness.awaitMain { tip(keyboard)?.visibility }) + // A later floating update keeps it away: the user has read it. + harness.awaitMain { keyboard.setFloatingWindowMode(true, compact = true); true } + assertEquals(View.GONE, harness.awaitMain { tip(keyboard)?.visibility }) + // Docking and floating again shows it once more. + harness.awaitMain { keyboard.setFloatingWindowMode(false); true } + harness.awaitMain { keyboard.setFloatingWindowMode(true); true } + assertEquals(View.VISIBLE, harness.awaitMain { tip(keyboard)?.visibility }) + } + } + + @Test + fun theTipTakesTheRowButNotTheCollectKey() { + withKeyboard { harness, keyboard -> + showFloatingTip(harness, keyboard) + assertEquals(View.VISIBLE, harness.awaitMain { tip(keyboard)?.visibility }) + val hide = harness.awaitMain { keyboard.findViewWithTag("keyboard-hide") } + assertNotNull("The collect-the-keyboard key stays reachable", hide) + assertEquals(View.VISIBLE, harness.awaitMain { hide!!.visibility }) + assertTrue( + "The banner is wider than a toolbar icon: it takes the row", + harness.awaitMain { (tip(keyboard)?.width ?: 0) > hide.width }, + ) + } + } + + @Test + fun theBannerKeepsTheReferencesOwnProportions() { + withKeyboard { harness, keyboard -> + showFloatingTip(harness, keyboard) + val strip = harness.awaitMain { tip(keyboard)!! } + val row = harness.awaitMain { keyboard.findViewWithTag("toolbar-row")!! } + val density = keyboard.resources.displayMetrics.density + assertEquals( + "The container keeps its own 3.333% padding", + (row.width * ToolbarTipPolicy.CONTAINER_PAD_FRACTION).toInt().toLong(), + strip.paddingLeft.toLong(), + ) + val lead = harness.awaitMain { keyboard.findViewWithTag("toolbar-tip-lead")!! } + assertEquals( + "`width_weight=\"0.03571\"` before the label", + (row.width * ToolbarTipPolicy.LEAD_WEIGHT).toInt().toLong(), + lead.width.toLong(), + ) + val close = harness.awaitMain { keyboard.findViewWithTag("toolbar-tip-close")!! } + assertEquals( + "The close key keeps the 48dp touch target", + (48 * density * ImeReferenceSizing.scale(keyboard.context, keyboard.width) + 0.5f).toInt().toLong(), + close.width.toLong(), + ) + } + } +} diff --git a/app/src/androidTest/java/llc/slacker/openime/FloatingWindowInstrumentedTest.kt b/app/src/androidTest/java/llc/slacker/openime/FloatingWindowInstrumentedTest.kt new file mode 100644 index 00000000..72320e39 --- /dev/null +++ b/app/src/androidTest/java/llc/slacker/openime/FloatingWindowInstrumentedTest.kt @@ -0,0 +1,84 @@ +package llc.slacker.openime + +import android.os.Handler +import android.os.Looper +import androidx.test.ext.junit.runners.AndroidJUnit4 +import llc.slacker.openime.floating.FloatingWindowController +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith + +/** + * The floating card's window attributes: a drag only writes them when the card + * really moves, because every write is a relayout of the window. + */ +@RunWith(AndroidJUnit4::class) +class FloatingWindowInstrumentedTest { + @Test + fun aDragOnlyWritesTheWindowWhenTheCardMoves() { + DirectActivityHarness(DebugKeyboardActivity::class.java).use { harness -> + harness.launch() + val logs = mutableListOf() + var opacityReads = 0 + val controller = harness.awaitMain { activity -> + FloatingWindowController( + resources = activity.resources, + mainHandler = Handler(Looper.getMainLooper()), + windowProvider = { activity.window }, + keyboardHeightPx = { 600 }, + floatingWidthPercent = { 88 }, + floatingOpacityPercent = { opacityReads++; 100 }, + debugLog = { logs += it }, + ).also { it.enable() } + } + try { + // enable() posts the first layout; wait for it to reach the window. + harness.awaitMain { activity -> + if (activity.window.attributes.width > 0) true else null + } + logs.clear() + val readsAfterEnable = opacityReads + val startX = harness.awaitMain { harnessActivity -> harnessActivity.window.attributes.x } + harness.awaitMain { + // Finger jitter: the delta truncates to 0, so the card does not + // move and the window must not be rewritten. + repeat(20) { controller.drag(0.4f, 0.4f) } + assertTrue( + "Sub-pixel drag samples must not write the window", + logs.none { it.startsWith(DRAG_LOG) }, + ) + assertEquals( + "A drag must not re-read the opacity from the settings", + readsAfterEnable, + opacityReads, + ) + // A real move writes once, and the next sub-pixel sample again + // writes nothing. + controller.drag(50f, 30f) + assertEquals( + "A real move must write the window exactly once", + 1, + logs.count { it.startsWith(DRAG_LOG) }, + ) + controller.drag(0.4f, 0f) + assertEquals(1, logs.count { it.startsWith(DRAG_LOG) }) + true + } + harness.awaitMain { activity -> + assertTrue( + "The written position must be the dragged one ($startX -> ${activity.window.attributes.x})", + activity.window.attributes.x > startX, + ) + true + } + } finally { + controller.restore() + } + } + } + + private companion object { + const val DRAG_LOG = "floating-window-drag" + } +} diff --git a/app/src/androidTest/java/llc/slacker/openime/FuzzyRulesInstrumentedTest.kt b/app/src/androidTest/java/llc/slacker/openime/FuzzyRulesInstrumentedTest.kt index 9ab17e94..e5f911d4 100644 --- a/app/src/androidTest/java/llc/slacker/openime/FuzzyRulesInstrumentedTest.kt +++ b/app/src/androidTest/java/llc/slacker/openime/FuzzyRulesInstrumentedTest.kt @@ -64,12 +64,20 @@ class FuzzyRulesInstrumentedTest { redeploy(rime) assertTrue("zi offers 知", "知" in rime.candidates("zi")) assertTrue("shan offers 上", "上" in rime.candidates("shan")) + // The four pairs the reference carries beyond the eleven: librime + // has to take their rules too, not only the Kotlin fallback. + assertTrue("eng = ong: dong offers 灯", "灯" in rime.candidates("dong").take(40)) + assertTrue("un = ong: chun offers 冲", "冲" in rime.candidates("chun").take(40)) + assertTrue("huang = wang: wang offers 黄", "黄" in rime.candidates("wang").take(40)) + assertTrue("ei = ui: hui offers 飞", "飞" in rime.candidates("hui").take(40)) - // Back to the shipped defaults: nothing fuzzy even with 模糊音 on. + // Back to the shipped defaults: the reference's own six pairs stay on + // (z = zh among them), and the nine it does not default to fall away. ImeSettingsRepository.saveFuzzyRules(context, FuzzyRule.DEFAULTS) redeploy(rime) assertEquals(FuzzyRule.rimeYaml(FuzzyRule.DEFAULTS), userRules.readText()) - assertFalse("defaults: zi does not offer 知", "知" in rime.candidates("zi").take(30)) + assertTrue("defaults: zi offers 知", "知" in rime.candidates("zi").take(30)) + assertFalse("defaults: hui does not offer 飞", "飞" in rime.candidates("hui").take(40)) // 模糊音 off: the plain schema, nothing fuzzy. ImeSettingsRepository.saveFuzzy(context, false) diff --git a/app/src/androidTest/java/llc/slacker/openime/KeyMarginsInstrumentedTest.kt b/app/src/androidTest/java/llc/slacker/openime/KeyMarginsInstrumentedTest.kt new file mode 100644 index 00000000..052a8f1b --- /dev/null +++ b/app/src/androidTest/java/llc/slacker/openime/KeyMarginsInstrumentedTest.kt @@ -0,0 +1,212 @@ +package llc.slacker.openime + +import android.graphics.Rect +import android.graphics.drawable.InsetDrawable +import android.view.View +import android.view.ViewGroup +import androidx.test.ext.junit.runners.AndroidJUnit4 +import llc.slacker.openime.core.ImeState +import llc.slacker.openime.core.KeyboardMode +import llc.slacker.openime.core.Panel +import llc.slacker.openime.keyboard.ImeKeyboardView +import llc.slacker.openime.theme.ImeGeometryTokens +import llc.slacker.openime.theme.ImeReferenceSizing +import llc.slacker.openime.voice.VoiceModelLifecycleState +import llc.slacker.openime.widget.ImeKeyView +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Test +import org.junit.runner.RunWith +import java.lang.reflect.Proxy + +/** Key caps keep their original uniform six-unit gap on every board. */ +@RunWith(AndroidJUnit4::class) +class KeyMarginsInstrumentedTest { + private val listener = Proxy.newProxyInstance( + ImeKeyboardView.Listener::class.java.classLoader, + arrayOf(ImeKeyboardView.Listener::class.java), + ) { _, method, _ -> + when (method.name) { + "voiceModelState" -> VoiceModelLifecycleState.COLD + else -> if (method.returnType == java.lang.Boolean.TYPE) false else null + } + } as ImeKeyboardView.Listener + + private fun withKeyboard(test: (DirectActivityHarness, ImeKeyboardView) -> Unit) { + DirectActivityHarness(DebugKeyboardActivity::class.java).use { harness -> + harness.launch() + val keyboard = harness.awaitMain { activity -> + ImeKeyboardView(activity, listener).also { + activity.findViewById(android.R.id.content).addView(it) + } + } + try { + harness.awaitMain { if (keyboard.width > 0) true else null } + test(harness, keyboard) + } finally { + harness.awaitMain { + keyboard.shutdown() + (keyboard.parent as? ViewGroup)?.removeView(keyboard) + true + } + } + } + } + + /** One reference unit in pixels, the measure `ImeKeyboardView.dp` uses. */ + private fun unitOf(keyboard: ImeKeyboardView): Float = + keyboard.resources.displayMetrics.density * ImeReferenceSizing.scale(keyboard.context, keyboard.width) + + /** The margins the cap is painted with: the background reports its insets as padding. */ + private fun capInsets(key: View): Rect = Rect().also { + (key.background as InsetDrawable).getPadding(it) + } + + private fun assertMargins( + keyboard: ImeKeyboardView, + tag: String, + left: Float, + top: Float, + right: Float, + bottom: Float, + ) { + val key = keyboard.findViewWithTag(tag) + assertNotNull("$tag must be on the board", key) + assertMargins(keyboard, key!!, tag, left, top, right, bottom) + } + + private fun assertMargins( + keyboard: ImeKeyboardView, + key: View, + label: String, + left: Float, + top: Float, + right: Float, + bottom: Float, + ) { + val unit = unitOf(keyboard) + fun pixels(margin: Float): Int = if (margin == 3f) kotlin.math.round(6 * unit).toInt() / 2 else Math.round(margin * unit) + val insets = capInsets(key) + assertEquals("$label keeps its left margin", pixels(left).toLong(), insets.left.toLong()) + assertEquals("$label keeps its top margin", pixels(top).toLong(), insets.top.toLong()) + assertEquals("$label keeps its right margin", pixels(right).toLong(), insets.right.toLong()) + assertEquals("$label keeps its bottom margin", pixels(bottom).toLong(), insets.bottom.toLong()) + } + + private fun allViews(root: View): Sequence = sequence { + yield(root) + if (root is ViewGroup) { + for (index in 0 until root.childCount) yieldAll(allViews(root.getChildAt(index))) + } + } + + @Test + fun theTwentySixKeyBoardKeepsTheReferenceKeyMargins() { + withKeyboard { harness, keyboard -> + harness.awaitMain { keyboard.setMode(KeyboardMode.PINYIN_26); true } + val x = ImeGeometryTokens.KEY_MARGIN_X_DP + val y = ImeGeometryTokens.KEY_MARGIN_Y_DP + listOf("key-space", "key:123", "key:mode", "key-enter", "key-punctuation", "key:z").forEach { tag -> + assertMargins(keyboard, tag, x, y, x, y) + } + } + } + + @Test + fun theBigSideKeysClearTheLetterColumnByTheirOwnMargin() { + withKeyboard { harness, keyboard -> + harness.awaitMain { keyboard.setMode(KeyboardMode.PINYIN_26); true } + val inner = ImeGeometryTokens.KEY_INNER_MARGIN_DP + val x = ImeGeometryTokens.KEY_MARGIN_X_DP + val y = ImeGeometryTokens.KEY_MARGIN_Y_DP + // 分词 on the left clears the letters to its right, 删除 on the right to its left. + assertMargins(keyboard, "key-shift", x, y, inner, y) + assertMargins(keyboard, "key-backspace", inner, y, x, y) + } + } + + @Test + fun theNineKeyFamilyKeepsItsTighterMargin() { + withKeyboard { harness, keyboard -> + harness.awaitMain { keyboard.setMode(KeyboardMode.PINYIN_9); true } + val x = ImeGeometryTokens.NINE_KEY_MARGIN_X_DP + val y = ImeGeometryTokens.NINE_KEY_MARGIN_Y_DP + listOf("key-space", "key:123", "key:mode", "key-enter", "key-9:2").forEach { tag -> + assertMargins(keyboard, tag, x, y, x, y) + } + } + } + + @Test + fun theStrokeAndNumberBoardsKeepTheNineKeyMargin() { + withKeyboard { harness, keyboard -> + val x = ImeGeometryTokens.NINE_KEY_MARGIN_X_DP + val y = ImeGeometryTokens.NINE_KEY_MARGIN_Y_DP + harness.awaitMain { keyboard.setMode(KeyboardMode.STROKE); true } + assertMargins(keyboard, "key-space", x, y, x, y) + harness.awaitMain { keyboard.setMode(KeyboardMode.DIGITS); true } + assertMargins(keyboard, "key:5", x, y, x, y) + } + } + + @Test + fun theExpandedCandidatesPageKeepsTheReferenceSideInset() { + withKeyboard { harness, keyboard -> + val density = keyboard.resources.displayMetrics.density + val scale = ImeReferenceSizing.scale(keyboard.context) + val centering = maxOf(0, (keyboard.width - (minOf(600f, 390f * scale) * density).toInt()) / 2) + // The page carries its own side figure whatever board opened it + // (`cls_input_kbd_morecand_inset_adaptive`, `style.xml:731-733`). + val expected = centering + Math.round(ImeGeometryTokens.MORE_CAND_SIDE_INSET_DP * density * scale) + harness.awaitMain { keyboard.setMode(KeyboardMode.PINYIN_26); true } + harness.awaitMain { + keyboard.renderState(ImeState(composition = "nihao", candidates = listOf("你好", "您好"))) + true + } + harness.awaitMain { keyboard.findViewWithTag("candidate-expand").performClick(); true } + val overlay = harness.awaitMain { + keyboard.findViewWithTag("candidate-overlay") + ?.takeIf { it.visibility == View.VISIBLE && it.paddingLeft > 0 } + } + assertNotNull("the expanded page opens", overlay) + assertEquals( + "the expanded page keeps the reference's side inset", + expected.toLong(), + overlay!!.paddingLeft.toLong(), + ) + assertEquals("the expanded page insets both sides equally", overlay.paddingLeft, overlay.paddingRight) + } + } + + @Test + fun aPanelKeepsTheKeyMarginWhateverBoardOpenedIt() { + withKeyboard { harness, keyboard -> + harness.awaitMain { keyboard.setMode(KeyboardMode.PINYIN_9); true } + harness.awaitMain { + keyboard.showPanel(Panel.SYMBOLS) + true + } + // A panel's keys are the reference's own 26-key key whatever board + // it was opened from (`cls_symbol_keyboard_button`, `style.xml:1332`). + // The page is laid out on the frame after it opens, so wait for its + // first key to have a size. + val symbol = harness.awaitMain { + allViews(keyboard) + .filterIsInstance() + .firstOrNull { + it.isShown && it.width > 0 && it.background is InsetDrawable && capInsets(it).left > 0 + } + } + assertNotNull("the symbol page carries keys", symbol) + assertMargins( + keyboard, + symbol!!, + "a symbol page key", + ImeGeometryTokens.KEY_MARGIN_X_DP, + ImeGeometryTokens.KEY_MARGIN_Y_DP, + ImeGeometryTokens.KEY_MARGIN_X_DP, + ImeGeometryTokens.KEY_MARGIN_Y_DP, + ) + } + } +} diff --git a/app/src/androidTest/java/llc/slacker/openime/KeySoundVolumeInstrumentedTest.kt b/app/src/androidTest/java/llc/slacker/openime/KeySoundVolumeInstrumentedTest.kt new file mode 100644 index 00000000..4830f499 --- /dev/null +++ b/app/src/androidTest/java/llc/slacker/openime/KeySoundVolumeInstrumentedTest.kt @@ -0,0 +1,34 @@ +package llc.slacker.openime + +import android.content.Context +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import llc.slacker.openime.data.ImeSettingsRepository +import org.junit.Assert.assertEquals +import org.junit.Test +import org.junit.runner.RunWith + +/** + * 按键音量 keeps the reference's own scale: `key_sound_volume` is an + * IntPreference with `defaultValue=50`, so the stored percent is 0-100 and the + * default is 50. + */ +@RunWith(AndroidJUnit4::class) +class KeySoundVolumeInstrumentedTest { + + private val context = InstrumentationRegistry.getInstrumentation().targetContext + + @Test + fun theVolumeDefaultsToHalfAndClampsWhatIsSaved() { + context.getSharedPreferences("ime_settings", Context.MODE_PRIVATE) + .edit().remove("key_sound_volume").commit() + assertEquals(50, ImeSettingsRepository.loadKeySoundVolume(context)) + + ImeSettingsRepository.saveKeySoundVolume(context, 120) + assertEquals(100, ImeSettingsRepository.loadKeySoundVolume(context)) + ImeSettingsRepository.saveKeySoundVolume(context, -5) + assertEquals(0, ImeSettingsRepository.loadKeySoundVolume(context)) + + ImeSettingsRepository.saveKeySoundVolume(context, ImeSettingsRepository.SOUND_VOLUME_DEFAULT) + } +} diff --git a/app/src/androidTest/java/llc/slacker/openime/KeyboardBottomRowInstrumentedTest.kt b/app/src/androidTest/java/llc/slacker/openime/KeyboardBottomRowInstrumentedTest.kt index 0f1fd8ed..cec1fe86 100644 --- a/app/src/androidTest/java/llc/slacker/openime/KeyboardBottomRowInstrumentedTest.kt +++ b/app/src/androidTest/java/llc/slacker/openime/KeyboardBottomRowInstrumentedTest.kt @@ -2,12 +2,17 @@ package llc.slacker.openime import android.view.View import android.view.ViewGroup +import android.view.inputmethod.EditorInfo import android.widget.TextView import androidx.test.ext.junit.runners.AndroidJUnit4 import llc.slacker.openime.core.KeyboardMode import llc.slacker.openime.keyboard.ImeKeyboardView +import llc.slacker.openime.keyboard.ProductionKeyPolicy import llc.slacker.openime.voice.VoiceModelLifecycleState import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull import org.junit.Assert.assertTrue import org.junit.Test import org.junit.runner.RunWith @@ -18,6 +23,8 @@ import java.lang.reflect.Proxy class KeyboardBottomRowInstrumentedTest { private class Recorder { val characters = mutableListOf() + var enters = 0 + var newlines = 0 val listener = Proxy.newProxyInstance( ImeKeyboardView.Listener::class.java.classLoader, arrayOf(ImeKeyboardView.Listener::class.java), @@ -25,6 +32,8 @@ class KeyboardBottomRowInstrumentedTest { when (method.name) { "voiceModelState" -> VoiceModelLifecycleState.COLD "onCharacter" -> { characters += args!![0] as String; null } + "onEnter" -> { enters++; null } + "onEnterNewline" -> { newlines++; null } else -> if (method.returnType == java.lang.Boolean.TYPE) false else null } } as ImeKeyboardView.Listener @@ -61,24 +70,100 @@ class KeyboardBottomRowInstrumentedTest { private fun settled(harness: DirectActivityHarness, keyboard: ImeKeyboardView, tag: String): View = harness.awaitMain { keyboard.findViewWithTag(tag)?.takeIf { it.width > 0 } } + /** The original five keys keep their widths and the space bar stays centred. */ @Test - fun twentySixKeySpaceIsCenteredAndTheRowMirrors() = withKeyboard { harness, _, keyboard -> + fun theBottomRowKeepsTheOriginalFiveKeyWidths() = withKeyboard { harness, _, keyboard -> harness.awaitMain { keyboard.setMode(KeyboardMode.PINYIN_26); true } val space = settled(harness, keyboard, "key-space") - val mode = settled(harness, keyboard, "key:mode") - val punctuation = settled(harness, keyboard, "key-punctuation") - val digits = settled(harness, keyboard, "key:123") - val enter = settled(harness, keyboard, "key-enter") + val weights = ProductionKeyPolicy.twentySixKeyBottomRowWeights() + val shares = listOf( + "key:123" to weights.leftOuter, + "key-punctuation" to weights.leftInner, + "key-space" to weights.space, + "key:mode" to weights.rightInner, + "key-enter" to weights.rightOuter, + ) harness.awaitMain { - val tolerance = 3f - assertEquals(keyboard.width / 2f, space.centerX(keyboard), tolerance) - assertEquals(mode.width.toFloat(), punctuation.width.toFloat(), tolerance) - assertEquals(digits.width.toFloat(), enter.width.toFloat(), tolerance) - assertTrue("the mode key must be narrower than the digits key", mode.width < digits.width) + val row = space.parent as ViewGroup + assertEquals("the bottom row has five keys", 5, row.childCount) + assertNull(keyboard.findViewWithTag("key-symbol-comma")) + assertNull(keyboard.findViewWithTag("key-symbol-period")) + val unit = row.width / shares.sumOf { it.second.toDouble() }.toFloat() + shares.forEach { (tag, weight) -> + val key = keyboard.findViewWithTag(tag) + assertNotNull("$tag must be in the bottom row", key) + assertEquals( + "$tag takes its share of the row", + weight * unit, + key!!.width.toFloat(), + 2f, + ) + } + val before = shares.takeWhile { it.first != "key-space" } + .sumOf { it.second.toDouble() }.toFloat() + assertEquals( + "the space bar stays centred", + (before + weights.space / 2f) * unit, + space.centerX(row), + 2f, + ) true } } + @Test + fun theSideColumnsKeepTheirOriginalKeysAndHeights() = withKeyboard { harness, _, keyboard -> + listOf(KeyboardMode.PINYIN_9, KeyboardMode.STROKE).forEach { mode -> + harness.awaitMain { keyboard.setMode(KeyboardMode.DIGITS); keyboard.setMode(mode); true } + settled(harness, keyboard, "key-space") + val ok = harness.awaitMain { + val zero = keyboard.findViewWithTag("key-9:0") + if (mode == KeyboardMode.PINYIN_9) assertNotNull("nine-key keeps its separate zero", zero) + else assertNull("stroke keeps its three-key side column", zero) + val retype = keyboard.findViewWithTag("key-retype") + val enter = keyboard.findViewWithTag("key-enter") + val backspace = keyboard.findViewWithTag("key-backspace") + assertNotNull("$mode retype", retype) + assertNotNull("$mode send", enter) + assertNotNull("$mode delete", backspace) + assertEquals("$mode delete matches retype", retype.height.toFloat(), backspace.height.toFloat(), 2f) + assertEquals("$mode send keeps the same height", retype.height.toFloat(), enter.height.toFloat(), 2f) + if (zero != null) assertEquals("zero matches the other keys", retype.height.toFloat(), zero.height.toFloat(), 2f) + true + } + check(ok) + } + } + + @Test + fun theIndentedLetterRowKeepsTheTopRowsKeyPitch() = withKeyboard { harness, _, keyboard -> + listOf(KeyboardMode.PINYIN_26, KeyboardMode.ENGLISH_26).forEach { mode -> + harness.awaitMain { keyboard.setMode(mode); true } + settled(harness, keyboard, "key:q") + harness.awaitMain { + val q = keyboard.findViewWithTag("key:q") + val w = keyboard.findViewWithTag("key:w") + val a = keyboard.findViewWithTag("key:a") + "asdfghjkl".forEach { letter -> + val key = keyboard.findViewWithTag("key:$letter") + assertEquals("$mode $letter keeps the top row's width", q.width.toFloat(), key.width.toFloat(), 2f) + } + assertEquals("$mode keeps the half-key indent", q.centerX(keyboard) + q.width / 2f, a.centerX(keyboard), 2f) + val s = keyboard.findViewWithTag("key:s") + assertEquals("$mode keeps the top row's pitch", w.centerX(keyboard) - q.centerX(keyboard), s.centerX(keyboard) - a.centerX(keyboard), 2f) + true + } + } + true + } + + /** + * The reference's merged key carries the full stop in its own language and + * the mark it hides in its corner: Chinese draws `text=","` with + * `ficon_text="。"` (`input_kbd_pinyin26.xml:145-152`), English draws + * `text="."` with `ficon_text=","` (`input_kbd_english26.xml:154-161`), so a + * long press commits the corner mark in both. + */ @Test fun commaKeyTapsCommaAndLongPressesPeriodInBothLanguages() = withKeyboard { harness, recorder, keyboard -> harness.awaitMain { keyboard.setMode(KeyboardMode.PINYIN_26); true } @@ -90,7 +175,45 @@ class KeyboardBottomRowInstrumentedTest { harness.awaitMain { keyboard.setMode(KeyboardMode.ENGLISH_26); true } val en = harness.awaitMain { keyboard.findViewWithTag("key-punctuation")?.takeIf { it.width > 0 } } harness.awaitMain { assertTrue(en.performClick()); assertTrue(en.performLongClick()); true } - assertEquals(listOf(",", "."), recorder.characters.toList()) + assertEquals(listOf(".", ","), recorder.characters.toList()) + } + + /** + * 发送键的长按是换行:参考自己的设置文案就是这句(`enable_key_enter_sub_title` + * 「长按「发送」可换行。开关支持App范围」)。只有「发送」这一面有长按——建立中的 + * 合成读「确定」,其余动作读它们自己的动作词,长按仍是各自那一次按压。 + */ + @Test + fun theSendKeyLongPressesToALineBreak() = withKeyboard { harness, recorder, keyboard -> + harness.awaitMain { + keyboard.editorOptionsOverride = EditorInfo.IME_ACTION_SEND + // Rebuild the 26-key rows so the enter key is created under the send face. + keyboard.setMode(KeyboardMode.PINYIN_9) + keyboard.setMode(KeyboardMode.PINYIN_26) + true + } + val send = settled(harness, keyboard, "key-enter") + harness.awaitMain { + assertEquals("发送,长按换行", send.contentDescription.toString()) + assertTrue("the send key must take a long press", send.performLongClick()) + assertEquals("a long press must not send", 0, recorder.enters) + assertEquals(1, recorder.newlines) + true + } + + harness.awaitMain { + keyboard.editorOptionsOverride = EditorInfo.IME_ACTION_SEARCH + keyboard.setMode(KeyboardMode.ENGLISH_26) + keyboard.setMode(KeyboardMode.PINYIN_26) + true + } + val search = settled(harness, keyboard, "key-enter") + harness.awaitMain { + assertEquals("搜索", search.contentDescription.toString()) + assertFalse("only 发送 takes a long press", search.performLongClick()) + assertEquals(1, recorder.newlines) + true + } } @Test diff --git a/app/src/androidTest/java/llc/slacker/openime/KeyboardHeightDragInstrumentedTest.kt b/app/src/androidTest/java/llc/slacker/openime/KeyboardHeightDragInstrumentedTest.kt new file mode 100644 index 00000000..5534691b --- /dev/null +++ b/app/src/androidTest/java/llc/slacker/openime/KeyboardHeightDragInstrumentedTest.kt @@ -0,0 +1,158 @@ +package llc.slacker.openime + +import android.os.SystemClock +import android.view.MotionEvent +import android.view.View +import android.view.ViewConfiguration +import android.view.ViewGroup +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import llc.slacker.openime.data.ImeSettingsRepository +import llc.slacker.openime.keyboard.ImeKeyboardView +import llc.slacker.openime.voice.VoiceModelLifecycleState +import org.junit.Assert.assertEquals +import org.junit.Assert.assertSame +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import java.lang.reflect.Proxy + +/** + * Docked 「键盘调节」: long-press the toolbar row, then drag up or down to set the + * keyboard height. The drag previews on every sample but writes the height once. + */ +@RunWith(AndroidJUnit4::class) +class KeyboardHeightDragInstrumentedTest { + private class Recorder { + lateinit var keyboard: ImeKeyboardView + /** Heights the keyboard reported as committed, in order. */ + val heights = mutableListOf() + val listener = Proxy.newProxyInstance( + ImeKeyboardView.Listener::class.java.classLoader, + arrayOf(ImeKeyboardView.Listener::class.java), + ) { _, method, args -> + when (method.name) { + "voiceModelState" -> VoiceModelLifecycleState.COLD + "onKeyboardHeightChanged" -> { heights += args!![0] as Int; null } + "onFloatingKeyboardChanged" -> { + keyboard.setFloatingWindowMode(args!![0] as Boolean) + null + } + else -> if (method.returnType == java.lang.Boolean.TYPE) false else null + } + } as ImeKeyboardView.Listener + } + + private fun withKeyboard( + test: (DirectActivityHarness, Recorder, ImeKeyboardView) -> Any?, + ) { + DirectActivityHarness(DebugKeyboardActivity::class.java).use { harness -> + harness.launch() + val recorder = Recorder() + val keyboard = harness.awaitMain { activity -> + ImeKeyboardView(activity, recorder.listener).also { + recorder.keyboard = it + activity.findViewById(android.R.id.content).addView(it) + } + } + try { + harness.awaitMain { if (keyboard.width > 0) true else null } + test(harness, recorder, keyboard) + } finally { + harness.awaitMain { + keyboard.shutdown() + (keyboard.parent as? ViewGroup)?.removeView(keyboard) + true + } + } + } + } + + private fun touch(view: View, action: Int, downTime: Long, x: Float, y: Float) { + val event = MotionEvent.obtain(downTime, SystemClock.uptimeMillis(), action, x, y, 0) + try { + view.dispatchTouchEvent(event) + } finally { + event.recycle() + } + } + + @Test + fun theHeightDragPreviewsEverySampleAndCommitsOnce() = withKeyboard { harness, recorder, keyboard -> + val context = InstrumentationRegistry.getInstrumentation().targetContext + val start = harness.awaitMain { + ImeSettingsRepository.loadKeyboardHeightPercent(context, landscape = false) + } + // Drag away from the clamp ceiling, so every sample counts and the + // direction of the expected change is known. + val step = if (start > 130) 20f else -20f + val samples = 12 + val downTime = SystemClock.uptimeMillis() + val zone = harness.awaitMain { keyboard.findViewWithTag("ime_toolbar") } + // Raw touch coordinates are screen coordinates ("the original location of + // the event on the screen, before it had been adjusted for the containing + // window", MotionEvent.getRawX), while the IME window does not sit at the + // screen origin: the press belongs at the row's own screen position, which + // is what ImeTopZone.isInsideToolbar gates the docked drag on. Synthetic + // events carry raw == x/y, so this test dispatches to the zone itself and + // presses where the row is on screen. + val (pressX, pressY) = harness.awaitMain { + val row = requireNotNull(keyboard.findViewWithTag("toolbar-row")) + check(row.height > 0) { "The toolbar row has not been laid out" } + val location = IntArray(2).also { row.getLocationOnScreen(it) } + (location[0] + row.width / 2f) to (location[1] + row.height / 2f) + } + val startHeight = harness.awaitMain { keyboard.desiredWindowHeightPx() } + val firstKey = harness.awaitMain { keyboard.findViewWithTag("key:a") } + harness.awaitMain { touch(zone, MotionEvent.ACTION_DOWN, downTime, pressX, pressY); true } + SystemClock.sleep(ViewConfiguration.getLongPressTimeout() + 250L) + val previewHeight = harness.awaitMain { + var y = pressY + repeat(samples) { + y += step + touch(zone, MotionEvent.ACTION_MOVE, downTime, pressX, y) + } + assertTrue( + "Dragging the toolbar must resize the keyboard while the finger moves", + keyboard.desiredWindowHeightPx() != startHeight, + ) + // One main-thread turn, [samples] samples: the preview coalesces the + // rebuild onto the next frame instead of rebuilding per sample. + assertSame( + "A drag must not rebuild the key rows sample by sample", + firstKey, + keyboard.findViewWithTag("key:a"), + ) + assertTrue("A drag must not report a height before it ends", recorder.heights.isEmpty()) + assertEquals( + "A drag must not persist a height before it ends", + start, + ImeSettingsRepository.loadKeyboardHeightPercent(context, landscape = false), + ) + checkNotNull(keyboard.desiredWindowHeightPx()) + } + harness.awaitMain { + touch(zone, MotionEvent.ACTION_UP, downTime, pressX, pressY + step * samples) + true + } + harness.awaitMain { + assertEquals("Releasing the drag reports the height exactly once", 1, recorder.heights.size) + val committed = recorder.heights.single() + assertEquals( + "Releasing the drag persists the height it reports", + committed, + ImeSettingsRepository.loadKeyboardHeightPercent(context, landscape = false), + ) + assertTrue( + "The drag must move the height the way the finger went ($start -> $committed)", + if (step < 0) committed > start else committed < start, + ) + assertEquals( + "The height that is persisted must be the one on screen", + previewHeight, + keyboard.desiredWindowHeightPx(), + ) + true + } + } +} diff --git a/app/src/androidTest/java/llc/slacker/openime/LauncherTileInstrumentedTest.kt b/app/src/androidTest/java/llc/slacker/openime/LauncherTileInstrumentedTest.kt new file mode 100644 index 00000000..c52e3b41 --- /dev/null +++ b/app/src/androidTest/java/llc/slacker/openime/LauncherTileInstrumentedTest.kt @@ -0,0 +1,212 @@ +package llc.slacker.openime + +import android.graphics.drawable.GradientDrawable +import android.graphics.drawable.StateListDrawable +import android.view.View +import android.view.ViewGroup +import android.widget.TextView +import androidx.test.ext.junit.runners.AndroidJUnit4 +import llc.slacker.openime.core.KeyboardMode +import llc.slacker.openime.core.Panel +import llc.slacker.openime.handwriting.HandwritingFeaturePolicy +import llc.slacker.openime.handwriting.StrokeMatchHandwritingProvider +import llc.slacker.openime.keyboard.ImeKeyboardView +import llc.slacker.openime.panel.TileGridPolicy +import llc.slacker.openime.theme.ImeAppearance +import llc.slacker.openime.theme.ImeReferenceSizing +import llc.slacker.openime.theme.ImeSurfacePolicy +import llc.slacker.openime.theme.ImeTheme +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import java.lang.reflect.Proxy + +/** + * The launcher pages — tools and the keyboard switcher — draw the reference's + * own tile grid (`input_settings.xml:13-45`, `input_switch_keyboard.xml:13-29`). + */ +@RunWith(AndroidJUnit4::class) +class LauncherTileInstrumentedTest { + private val listener = Proxy.newProxyInstance( + ImeKeyboardView.Listener::class.java.classLoader, + arrayOf(ImeKeyboardView.Listener::class.java), + ) { _, method, _ -> if (method.returnType == java.lang.Boolean.TYPE) false else null } as ImeKeyboardView.Listener + + private fun withKeyboard(test: (ImeKeyboardView) -> Unit) { + DirectActivityHarness(DebugKeyboardActivity::class.java).use { harness -> + harness.launch() + val keyboard = harness.awaitMain { activity -> + ImeKeyboardView(activity, listener).also { + activity.findViewById(android.R.id.content).addView(it) + } + } + try { + harness.awaitMain { if (keyboard.width > 0) true else null } + harness.awaitMain { + // The tile colours are the palette's, so pin the palette. + keyboard.applyPersistedSettings( + newTheme = ImeTheme.IOS, + newAppearance = ImeAppearance.LIGHT, + sound = false, + haptic = true, + popup = true, + fuzzy = false, + ) + true + } + harness.awaitMain { test(keyboard); true } + } finally { + harness.awaitMain { + keyboard.shutdown() + (keyboard.parent as? ViewGroup)?.removeView(keyboard) + true + } + } + } + } + + /** The default fill of a stateful rounded face. */ + private fun faceColor(view: View): Int? = + ((view.background as StateListDrawable).current as GradientDrawable).color?.defaultColor + + private fun faceRadius(view: View): Float = + ((view.background as StateListDrawable).current as GradientDrawable).cornerRadius + + @Test + fun theToolsPageIsTheReferencesFourByTwoGrid() { + withKeyboard { keyboard -> + keyboard.showPanel(Panel.TOOLS) + val grid = keyboard.findViewWithTag("tile-grid") + assertNotNull("The tools page must lay its tiles out as a grid", grid) + val rows = (grid as ViewGroup) + assertEquals("The reference's grid is two rows deep", TileGridPolicy.ROWS, rows.childCount) + // The reference fills a row with four tiles and leaves a short last + // row short (`input_settings.xml:13-45` packs its own buttons four + // to a row); the cells keep the grid's own column width either way. + assertEquals( + "The first row carries the reference's four columns", + TileGridPolicy.COLUMNS, + (rows.getChildAt(0) as ViewGroup).childCount, + ) + val cellWidths = (0 until rows.childCount).flatMap { index -> + val cells = rows.getChildAt(index) as ViewGroup + (0 until cells.childCount).map { cells.getChildAt(it).width } + } + assertEquals( + "Every cell keeps the page's one column width: $cellWidths", + 1, + cellWidths.distinct().size, + ) + // Every tile of a page is the same size, as `parent_average` makes it. + val sizes = (0 until rows.childCount).flatMap { row -> + val cells = rows.getChildAt(row) as ViewGroup + (0 until cells.childCount).map { column -> + val tile = (cells.getChildAt(column) as ViewGroup).getChildAt(0) + "${tile.width}x${tile.height}" + } + }.distinct() + assertEquals("One page's tiles share one size", 1, sizes.size) + } + } + + @Test + fun everyTileCarriesTheReferencesRoundedFace() { + withKeyboard { keyboard -> + keyboard.showPanel(Panel.TOOLS) + val tokens = ImeTheme.IOS.tokens(ImeAppearance.LIGHT) + val tile = keyboard.findViewWithTag("tool:表情")!! + assertTrue("A tile's face is a stateful card", tile.background is StateListDrawable) + assertEquals( + "The tile takes the card colour the reference fills it with", + tokens.toolCardBackground, + faceColor(tile), + ) + // The radius goes through the view's own `dp`: the reference's units + // scale with the page (`ImeKeyboardView.dp`), so the test measures + // the tile with the same reference scale. + assertEquals( + "`bkround=\"14\"` on cls_settings_button", + with(keyboard.resources.displayMetrics) { + density * ImeReferenceSizing.scale(keyboard.context, keyboard.width) * TileGridPolicy.TILE_RADIUS_DP + }, + faceRadius(tile), + 1f, + ) + } + } + + @Test + fun theChosenKeyboardKeepsTheCardAndAnswersInTheAccent() { + withKeyboard { keyboard -> + keyboard.showPanel(Panel.KEYBOARD_SELECT) + val tokens = ImeTheme.IOS.tokens(ImeAppearance.LIGHT) + val selected = keyboard.findViewWithTag("keyboard-choice-selected") + assertNotNull("The open keyboard's tile must be drawn selected", selected) + // `cls_settings_option_mode_switch_button` declares no selected + // fill: the chosen tile wears the same card as its neighbours + // (`setting_button_bk`, `style.xml:649-668`), where the settings + // page's `cls_settings_button` fills with the accent. + val others = keyboard.findViewWithTag("keyboard-choice") + assertNotNull("Other keyboards keep the plain tile", others) + assertEquals( + "The chosen tile keeps the card", + tokens.toolCardBackground, + faceColor(selected!!), + ) + assertEquals( + "Every tile of the page shares that face", + faceColor(others!!), + faceColor(selected), + ) + // The choice shows in the name: `selectedtextcolor` is + // `theme_color`, read here as the accent the palette derives on + // the card. + val selectedLabel = (selected as ViewGroup).getChildAt(1) as TextView + val plainLabel = (others as ViewGroup).getChildAt(1) as TextView + assertEquals( + "`selectedtextcolor=\"@color/theme_color\"` on cls_settings_option_mode_switch_button", + ImeSurfacePolicy.accentTextOn(tokens.primary, tokens.toolCardBackground), + selectedLabel.currentTextColor, + ) + assertEquals( + "The other names keep the tile's neutral label", + tokens.keySecondaryText, + plainLabel.currentTextColor, + ) + } + } + + @Test + fun theGridCarriesEveryKeyboardAndTheHandwritingEntry() { + withKeyboard { keyboard -> + keyboard.showPanel(Panel.KEYBOARD_SELECT) + val grid = keyboard.findViewWithTag("tile-grid") as ViewGroup + val labels = (0 until grid.childCount).flatMap { row -> + val cells = grid.getChildAt(row) as ViewGroup + (0 until cells.childCount).map { column -> + val tile = (cells.getChildAt(column) as ViewGroup).getChildAt(0) + (tile as ViewGroup).getChildAt(1)?.let { (it as TextView).text.toString() } + } + } + assertTrue("The switcher keeps the pinyin keyboards", labels.contains("拼音 26 键")) + // The handwriting entry appears only when the pad is offered and the + // strokes table is there; local builds ship the table but the pad is + // hidden for now (`HandwritingFeaturePolicy.ENTRY_OFFERED`). + assertEquals( + "The switcher carries the handwriting entry with its strokes table", + HandwritingFeaturePolicy.entryShown( + StrokeMatchHandwritingProvider.assetPresent(keyboard.context), + ), + labels.contains("手写输入"), + ) + keyboard.setMode(KeyboardMode.PINYIN_9, notifyListener = false) + keyboard.showPanel(Panel.KEYBOARD_SELECT) + assertNotNull( + "Switching keyboards moves the selected tile", + keyboard.findViewWithTag("keyboard-choice-selected"), + ) + } + } +} diff --git a/app/src/androidTest/java/llc/slacker/openime/NineKeyChineseInstrumentedTest.kt b/app/src/androidTest/java/llc/slacker/openime/NineKeyChineseInstrumentedTest.kt index 528652d3..3cf76b71 100644 --- a/app/src/androidTest/java/llc/slacker/openime/NineKeyChineseInstrumentedTest.kt +++ b/app/src/androidTest/java/llc/slacker/openime/NineKeyChineseInstrumentedTest.kt @@ -5,6 +5,7 @@ import android.view.ViewGroup import android.widget.TextView import androidx.test.ext.junit.runners.AndroidJUnit4 import org.junit.After +import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue import org.junit.Before @@ -125,6 +126,45 @@ class NineKeyChineseInstrumentedTest { } } + /** + * The reading list is the symbol rail's list with other items in it: one + * line per reading at one size (no per-item shrinking), a hairline between + * items, and no state drawn on any item — which reading is current shows in + * the pre-edit. The reference skin gives the reading item the symbol item's + * own style (`cls_pinyin9_filter_button` inherits + * `cls_vertical_symbol_list_button`) and draws the marks and the syllables + * at one size (`fi_vertical_symbol_list_text`, style.xml:112 and 1023-1032). + */ + @Test + fun theReadingRailIsAFlatListOfOneLineReadings() { + tapDigits("6464") + + val cells = harness.awaitMain { activity -> + val root = activity.findViewById(android.R.id.content) ?: return@awaitMain emptyList() + val content = findView(root) { it.tag == "nine-pinyin-panel" } as? ViewGroup + ?: return@awaitMain emptyList() + (0 until content.childCount).mapNotNull { content.getChildAt(it) as? TextView } + } + assertTrue("the rail must list readings: ${cells.map { it.text }}", cells.size >= 4) + assertTrue( + "every reading is one line: ${cells.map { it.maxLines }}", + cells.all { it.maxLines == 1 }, + ) + assertEquals( + "readings share one text size, so the list never reflows", + cells.map { it.textSize }.distinct().single(), + cells.first().textSize, + 0.01f, + ) + + val dividers = harness.awaitMain { activity -> + val root = activity.findViewById(android.R.id.content) ?: return@awaitMain -1 + val content = findView(root) { it.tag == "nine-pinyin-panel" } as? ViewGroup ?: return@awaitMain -1 + (0 until content.childCount).count { content.getChildAt(it).tag == "rail-divider" } + } + assertEquals("one hairline between neighbouring readings", cells.size - 1, dividers) + } + private fun assertComposition(expected: String) { val status = harness.awaitMain { activity -> currentStatus(activity).takeIf { it.startsWith("composition=$expected ") } diff --git a/app/src/androidTest/java/llc/slacker/openime/NumericKeyboardLayoutInstrumentedTest.kt b/app/src/androidTest/java/llc/slacker/openime/NumericKeyboardLayoutInstrumentedTest.kt new file mode 100644 index 00000000..20eb3f21 --- /dev/null +++ b/app/src/androidTest/java/llc/slacker/openime/NumericKeyboardLayoutInstrumentedTest.kt @@ -0,0 +1,87 @@ +package llc.slacker.openime + +import android.view.View +import android.view.ViewGroup +import android.widget.LinearLayout +import androidx.test.ext.junit.runners.AndroidJUnit4 +import llc.slacker.openime.editor.EditorInfoAdapter +import llc.slacker.openime.keyboard.NumericKeyboardRenderer +import llc.slacker.openime.widget.ImeKeyView +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith + +/** The original numeric arrangement must survive phone-field remapping. */ +@RunWith(AndroidJUnit4::class) +class NumericKeyboardLayoutInstrumentedTest { + @Test + fun phoneKeepsTheOriginalPositionsAndCommitsAllDialerSymbols() { + DirectActivityHarness(DebugKeyboardActivity::class.java).use { harness -> + harness.launch() + harness.awaitMain { context -> + val committed = mutableListOf() + var spaces = 0 + val body = LinearLayout(context).apply { orientation = LinearLayout.VERTICAL } + fun key(text: String, onTap: () -> Unit) = ImeKeyView(context, text).apply { + setOnClickListener { onTap() } + } + NumericKeyboardRenderer( + context = context, + keyboardBody = body, + toPx = { it }, + keyRowHeightDp = { 54 }, + nineBodyHeightDp = { 216 }, + createKey = { text, _, _, onTap -> key(text, onTap) }, + createBackspaceKey = { key("删除") {}.apply { tag = "key-backspace" } }, + createSpaceVoiceKey = { text, onTap -> key(text, onTap).apply { tag = "key-space" } }, + markSideKey = {}, + markWhiteKey = {}, + markAccentKey = {}, + onCommitCharacter = { committed += it }, + onFeedback = {}, + onShowSymbols = {}, + onReturnToText = {}, + onSpace = { spaces++ }, + onEnter = {}, + ).render(EditorInfoAdapter.EditorKind.PHONE, "确定") + + fun find(tag: String) = body.findViewWithTag(tag) + val star = find("key-space") + val zero = find("key:0") + val back = find("key:mode") + val bottom = star.parent as ViewGroup + assertEquals(back, bottom.getChildAt(0)) + assertEquals(star, bottom.getChildAt(1)) + assertEquals(zero, bottom.getChildAt(2)) + assertEquals("*", star.currentMainText) + assertEquals("+", find("key:.").currentMainText) + assertEquals("#", find("key:@").currentMainText) + assertTrue(star.performClick()) + assertTrue(find("key:.").performClick()) + assertTrue(find("key:@").performClick()) + assertEquals(listOf("*", "+", "#"), committed) + assertEquals(0, spaces) + + val actions = body.findViewWithTag("digits-actions") + assertEquals(4, actions.childCount) + assertEquals( + listOf("key-backspace", "key:.", "key:@", "key-enter"), + (0 until actions.childCount).map { actions.getChildAt(it).tag }, + ) + assertTrue((0 until actions.childCount).all { actions.getChildAt(it).layoutParams.height == 54 }) + val grid = body.findViewWithTag("digits-grid") + assertEquals(3, grid.childCount) + assertEquals( + listOf(listOf("1", "2", "3"), listOf("4", "5", "6"), listOf("7", "8", "9")), + (0 until grid.childCount).map { rowIndex -> + val row = grid.getChildAt(rowIndex) as ViewGroup + (0 until row.childCount).map { (row.getChildAt(it) as ImeKeyView).currentMainText } + }, + ) + assertEquals(View.GONE, body.findViewWithTag("digits-symbol-scroll").visibility) + true + } + } + } +} diff --git a/app/src/androidTest/java/llc/slacker/openime/PanelAlignmentInstrumentedTest.kt b/app/src/androidTest/java/llc/slacker/openime/PanelAlignmentInstrumentedTest.kt new file mode 100644 index 00000000..3dd498ce --- /dev/null +++ b/app/src/androidTest/java/llc/slacker/openime/PanelAlignmentInstrumentedTest.kt @@ -0,0 +1,145 @@ +package llc.slacker.openime + +import android.view.View +import android.view.ViewGroup +import android.widget.ImageView +import android.widget.LinearLayout +import androidx.test.ext.junit.runners.AndroidJUnit4 +import llc.slacker.openime.core.Panel +import llc.slacker.openime.keyboard.ImeKeyboardView +import llc.slacker.openime.keyboard.ImeTopZone +import llc.slacker.openime.setup.SetupUi +import llc.slacker.openime.theme.ImeAppearance +import llc.slacker.openime.theme.ImeSurfacePolicy +import llc.slacker.openime.theme.ImeTheme +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import java.lang.reflect.Proxy + +@RunWith(AndroidJUnit4::class) +class PanelAlignmentInstrumentedTest { + private fun withKeyboard(test: (DirectActivityHarness, ImeKeyboardView) -> Unit) { + DirectActivityHarness(DebugKeyboardActivity::class.java).use { harness -> + harness.launch() + val keyboard = harness.awaitMain { activity -> + val listener = Proxy.newProxyInstance( + ImeKeyboardView.Listener::class.java.classLoader, + arrayOf(ImeKeyboardView.Listener::class.java), + ) { _, method, _ -> if (method.returnType == java.lang.Boolean.TYPE) false else null } as ImeKeyboardView.Listener + ImeKeyboardView(activity, listener).also { + activity.findViewById(android.R.id.content).addView(it) + } + } + try { + harness.awaitMain { if (keyboard.width > 0) true else null } + test(harness, keyboard) + } finally { + harness.awaitMain { + keyboard.shutdown() + (keyboard.parent as? ViewGroup)?.removeView(keyboard) + true + } + } + } + } + + private fun center(view: View): Float { + val location = IntArray(2).also(view::getLocationOnScreen) + return location[0] + view.width / 2f + } + + @Test + fun launcherRowsStayCenteredWhenTheWindowChangesWidth() = withKeyboard { harness, keyboard -> + val fullWidth = keyboard.width + for (panel in listOf(Panel.KEYBOARD_SELECT, Panel.TOOLS)) { + harness.awaitMain { keyboard.showPanel(panel); true } + for (width in listOf(fullWidth, fullWidth * 3 / 4, fullWidth)) { + harness.awaitMain { + keyboard.layoutParams = keyboard.layoutParams.apply { this.width = width } + true + } + harness.awaitMain { + val grid = keyboard.findViewWithTag("tile-grid") + // The page slides in on its own translation; measure it at rest, + // or the row reads a few px off the window's centre. + val overlay = keyboard.findViewWithTag("panel-overlay") + if (keyboard.width != width || grid == null || grid.width <= 0 || grid.isLayoutRequested || + overlay?.translationX != 0f + ) { + null + } else { + val offset = keyRowOffset(keyboard, grid) + for (rowIndex in 0 until grid.childCount) { + val row = grid.getChildAt(rowIndex) as ViewGroup + val first = (row.getChildAt(0) as ViewGroup).getChildAt(0) + val last = (row.getChildAt(row.childCount - 1) as ViewGroup).getChildAt(0) + val left = IntArray(2).also(first::getLocationOnScreen)[0] + val right = IntArray(2).also(last::getLocationOnScreen)[0] + last.width + assertEquals( + "$panel row $rowIndex at width $width ($offset)", + center(keyboard), + (left + right) / 2f, + 2f, + ) + assertTrue("Tiles fit inside their grid", first.width > 0 && last.right <= last.parent.let { it as View }.width) + } + true + } + } + } + } + } + + /** A one-line read of where the grid sits, for an assertion failure. */ + private fun keyRowOffset(keyboard: ImeKeyboardView, grid: View): String { + fun box(view: View?): String = view?.let { + val at = IntArray(2).also(it::getLocationOnScreen)[0] + "$at+${it.width}p${it.paddingLeft}/${it.paddingRight}" + } ?: "gone" + return "keyboard=${box(keyboard)}" + + " overlay=${box(keyboard.findViewWithTag("panel-overlay"))}" + + " grid=${box(grid)}" + } + + @Test + fun toolbarKeepsTheOriginalOrderInNormalAndCompactModes() = withKeyboard { harness, keyboard -> + harness.awaitMain { + val top = keyboard.findViewWithTag("ime_toolbar") + ?: (0 until keyboard.childCount).map { keyboard.getChildAt(it) }.filterIsInstance().first() + val strip = keyboard.findViewWithTag("toolbar-icon-strip") + fun labels() = (0 until strip.childCount).map { strip.getChildAt(it) } + .filter { it.visibility == View.VISIBLE && it.contentDescription != null } + .map { it.contentDescription.toString() } + val normal = listOf("切换键盘", "剪贴板", "表情", "文本编辑", "撤销", "更多") + assertEquals(normal, labels()) + top.setCompactToolbar(true) + assertEquals(listOf("切换键盘", "常用语", "表情", "更多"), labels()) + top.setCompactToolbar(false) + assertEquals(normal, labels()) + true + } + } + + @Test + fun keyboardAndAppUseTheSameAccentAndToolbarIconsHaveFullerWeight() = withKeyboard { harness, keyboard -> + for (appearance in listOf(ImeAppearance.LIGHT, ImeAppearance.DARK)) { + harness.awaitMain { + keyboard.applyPersistedSettings(ImeTheme.IOS, appearance, false, false, false, false) + val tokens = ImeTheme.IOS.tokens(appearance) + val configuration = android.content.res.Configuration(keyboard.resources.configuration).apply { + uiMode = (uiMode and android.content.res.Configuration.UI_MODE_NIGHT_MASK.inv()) or + if (appearance == ImeAppearance.DARK) android.content.res.Configuration.UI_MODE_NIGHT_YES + else android.content.res.Configuration.UI_MODE_NIGHT_NO + } + val page = keyboard.context.createConfigurationContext(configuration) + assertEquals(SetupUi.accent(page), tokens.primary) + val icon = keyboard.findViewWithTag("keyboard-selector") + assertEquals(ImeSurfacePolicy.toolbarIcon(tokens), icon.imageTintList!!.defaultColor) + assertEquals(217, android.graphics.Color.alpha(icon.imageTintList!!.defaultColor)) + true + } + } + } +} diff --git a/app/src/androidTest/java/llc/slacker/openime/PinyinLocationInstrumentedTest.kt b/app/src/androidTest/java/llc/slacker/openime/PinyinLocationInstrumentedTest.kt new file mode 100644 index 00000000..363ae965 --- /dev/null +++ b/app/src/androidTest/java/llc/slacker/openime/PinyinLocationInstrumentedTest.kt @@ -0,0 +1,168 @@ +package llc.slacker.openime + +import android.os.SystemClock +import android.view.MotionEvent +import android.view.View +import android.view.ViewGroup +import android.widget.EditText +import androidx.test.ext.junit.runners.AndroidJUnit4 +import llc.slacker.openime.core.KeyboardMode +import llc.slacker.openime.core.PinyinLocation +import llc.slacker.openime.keyboard.ImeKeyboardView +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import java.lang.reflect.Proxy + +/** + * 「输入拼音显示」的两个取值在键盘这一侧的表现:候选栏那一档键盘顶上留预编辑 + * 行,输入框那一档不留——拼音交给应用自己的输入框去显示,候选行照旧。 + */ +@RunWith(AndroidJUnit4::class) +class PinyinLocationInstrumentedTest { + private val listener = Proxy.newProxyInstance( + ImeKeyboardView.Listener::class.java.classLoader, + arrayOf(ImeKeyboardView.Listener::class.java), + ) { _, method, _ -> if (method.returnType == java.lang.Boolean.TYPE) false else null } as ImeKeyboardView.Listener + + private fun withKeyboard(test: (DirectActivityHarness, ImeKeyboardView) -> Unit) { + DirectActivityHarness(DebugKeyboardActivity::class.java).use { harness -> + harness.launch() + val keyboard = harness.awaitMain { activity -> + ImeKeyboardView(activity, listener).also { + activity.findViewById(android.R.id.content).addView(it) + } + } + try { + harness.awaitMain { if (keyboard.width > 0) true else null } + test(harness, keyboard) + } finally { + harness.awaitMain { + keyboard.setPinyinLocation(PinyinLocation.CANDIDATE_BAR) + keyboard.shutdown() + (keyboard.parent as? ViewGroup)?.removeView(keyboard) + true + } + } + } + } + + private fun tap(harness: DirectActivityHarness, view: View) { + val downTime = SystemClock.uptimeMillis() + harness.awaitMain { + val event = MotionEvent.obtain( + downTime, SystemClock.uptimeMillis(), MotionEvent.ACTION_DOWN, + view.width / 2f, view.height / 2f, 0, + ) + try { view.dispatchTouchEvent(event) } finally { event.recycle() } + true + } + harness.awaitMain { + val event = MotionEvent.obtain( + downTime, SystemClock.uptimeMillis(), MotionEvent.ACTION_UP, + view.width / 2f, view.height / 2f, 0, + ) + try { view.dispatchTouchEvent(event) } finally { event.recycle() } + true + } + SystemClock.sleep(150) + } + + private fun type(harness: DirectActivityHarness, keyboard: ImeKeyboardView, keys: String) { + keys.forEach { letter -> + val key = harness.awaitMain { + keyboard.findViewWithTag("key:$letter")?.takeIf { it.width > 0 } + } + assertNotNull("The $letter key must be on the surface", key) + tap(harness, key!!) + } + } + + /** (预编辑行是否显示着字, 行里的字)。 */ + private fun probeEditor(keyboard: ImeKeyboardView): Pair { + val editor = keyboard.findViewWithTag("pinyin-composition-editor") + val text = editor?.text?.toString().orEmpty() + val showing = editor != null && editor.visibility == View.VISIBLE && text.isNotEmpty() + return showing to text + } + + private fun awaitEditorText( + harness: DirectActivityHarness, + keyboard: ImeKeyboardView, + expected: String, + ): String? = harness.awaitMain { + probeEditor(keyboard).takeIf { it.first && it.second == expected }?.second + } + + private fun editorStateNow( + harness: DirectActivityHarness, + keyboard: ImeKeyboardView, + ): Pair = harness.awaitMain { probeEditor(keyboard) } + + @Test + fun theEditorRowCarriesThePinyinOnlyInTheCandidateBarMode() { + withKeyboard { harness, keyboard -> + harness.awaitMain { keyboard.setMode(KeyboardMode.PINYIN_26); true } + type(harness, keyboard, "nihao") + assertEquals("候选栏:预编辑行显示拼音", "nihao", awaitEditorText(harness, keyboard, "nihao")) + + harness.awaitMain { keyboard.setPinyinLocation(PinyinLocation.INPUT_BOX); true } + val (showing, _) = editorStateNow(harness, keyboard) + assertFalse("输入框:键盘上不再重复这一行", showing) + val zone = harness.awaitMain { keyboard.findViewWithTag("ime_toolbar") } + assertTrue("候选行本身照旧占着位置", zone.height > 0) + + harness.awaitMain { keyboard.setPinyinLocation(PinyinLocation.CANDIDATE_BAR); true } + assertEquals("切回候选栏,拼音回到键盘上", "nihao", awaitEditorText(harness, keyboard, "nihao")) + } + } + + @Test + fun theSettingSurvivesARestartOfTheSurface() { + DirectActivityHarness(DebugKeyboardActivity::class.java).use { harness -> + harness.launch() + val first = harness.awaitMain { activity -> + ImeKeyboardView(activity, listener).also { + activity.findViewById(android.R.id.content).addView(it) + } + } + harness.awaitMain { if (first.width > 0) true else null } + harness.awaitMain { + first.setMode(KeyboardMode.PINYIN_26) + first.setPinyinLocation(PinyinLocation.INPUT_BOX) + true + } + harness.awaitMain { + first.shutdown() + (first.parent as? ViewGroup)?.removeView(first) + true + } + + val second = harness.awaitMain { activity -> + ImeKeyboardView(activity, listener).also { + activity.findViewById(android.R.id.content).addView(it) + } + } + try { + harness.awaitMain { if (second.width > 0) true else null } + harness.awaitMain { second.setMode(KeyboardMode.PINYIN_26); true } + type(harness, second, "nihao") + val (showing, _) = editorStateNow(harness, second) + assertFalse("重新建一次键盘,设置还在", showing) + // 打的字没丢:把它切回候选栏就能看见。 + harness.awaitMain { second.setPinyinLocation(PinyinLocation.CANDIDATE_BAR); true } + assertEquals("nihao", awaitEditorText(harness, second, "nihao")) + } finally { + harness.awaitMain { + second.setPinyinLocation(PinyinLocation.CANDIDATE_BAR) + second.shutdown() + (second.parent as? ViewGroup)?.removeView(second) + true + } + } + } + } +} diff --git a/app/src/androidTest/java/llc/slacker/openime/PinyinLocationServiceInstrumentedTest.kt b/app/src/androidTest/java/llc/slacker/openime/PinyinLocationServiceInstrumentedTest.kt new file mode 100644 index 00000000..cf5ca7f8 --- /dev/null +++ b/app/src/androidTest/java/llc/slacker/openime/PinyinLocationServiceInstrumentedTest.kt @@ -0,0 +1,168 @@ +package llc.slacker.openime + +import android.content.Context +import android.content.ContextWrapper +import android.text.InputType +import android.view.inputmethod.EditorInfo +import android.view.inputmethod.InputConnection +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import llc.slacker.openime.core.ImeState +import llc.slacker.openime.core.KeyboardMode +import llc.slacker.openime.core.PinyinLocation +import llc.slacker.openime.editor.InputConnectionGateway +import org.junit.Assert.assertEquals +import org.junit.Test +import org.junit.runner.RunWith +import java.lang.reflect.Proxy + +/** + * 「输入拼音显示」在服务这一侧:选输入框时拼音进应用自己的输入框,切档时在场的 + * 那段预编辑跟着搬(切回候选栏要把它收回),收不下的输入框一律不写。 + */ +@RunWith(AndroidJUnit4::class) +class PinyinLocationServiceInstrumentedTest { + private val context get() = InstrumentationRegistry.getInstrumentation().targetContext + + private fun withService( + pinyinLocation: PinyinLocation, + inputType: Int, + passwordField: Boolean = false, + events: MutableList, + test: (LocalVoiceImeService) -> Unit, + ) { + InstrumentationRegistry.getInstrumentation().runOnMainSync { + val connection = Proxy.newProxyInstance( + InputConnection::class.java.classLoader, + arrayOf(InputConnection::class.java), + ) { _, method, args -> + when (method.name) { + "setComposingText" -> { events += "compose:${args!![0]}"; true } + "finishComposingText" -> { events += "finish"; true } + "commitText" -> { events += "commit:${args!![0]}"; true } + else -> if (method.returnType == Boolean::class.javaPrimitiveType) false else null + } + } as InputConnection + + val service = LocalVoiceImeService() + // The service is constructed directly instead of being started by the + // platform, so it has no base context yet. + ContextWrapper::class.java + .getDeclaredMethod("attachBaseContext", Context::class.java) + .apply { isAccessible = true } + .invoke(service, context) + + setField(service, "gateway", InputConnectionGateway(null, { connection })) + setField(service, "lastComposition", "nihao") + setField( + service, + "state", + ImeState( + keyboardMode = KeyboardMode.PINYIN_26, + composition = "nihao", + passwordField = passwordField, + pinyinLocation = pinyinLocation, + editorInfo = EditorInfo().apply { this.inputType = inputType }, + ), + ) + test(service) + } + } + + private fun setField(service: LocalVoiceImeService, name: String, value: Any) { + LocalVoiceImeService::class.java.getDeclaredField(name).apply { + isAccessible = true + set(service, value) + } + } + + /** 打一个字母走的那条路(`updateComposition` 读设置与输入框类型决定写不写)。 */ + private fun typePinyin(service: LocalVoiceImeService, next: String) { + LocalVoiceImeService::class.java + .getDeclaredMethod("updateComposition", String::class.java, List::class.java) + .apply { isAccessible = true } + .invoke(service, next, emptyList()) + } + + @Test + fun typingPinyinEntersTheEditorOnlyInTheInputBoxMode() { + val events = mutableListOf() + withService(PinyinLocation.CANDIDATE_BAR, InputType.TYPE_CLASS_TEXT, events = events) { service -> + typePinyin(service, "nihao") + assertEquals("候选栏一档:拼音不上输入框", emptyList(), events) + } + + val editorEvents = mutableListOf() + withService(PinyinLocation.INPUT_BOX, InputType.TYPE_CLASS_TEXT, events = editorEvents) { service -> + typePinyin(service, "nihao") + assertEquals(listOf("compose:nihao"), editorEvents) + } + } + + @Test + fun fieldsThatCannotHoldPinyinKeepItOnTheKeyboard() { + listOf( + "数字框" to InputType.TYPE_CLASS_NUMBER, + "小数框" to (InputType.TYPE_CLASS_NUMBER or InputType.TYPE_NUMBER_FLAG_DECIMAL), + "电话框" to InputType.TYPE_CLASS_PHONE, + "日期框" to (InputType.TYPE_CLASS_DATETIME or InputType.TYPE_DATETIME_VARIATION_DATE), + "只吃按键的框" to InputType.TYPE_NULL, + ).forEach { (name, type) -> + val events = mutableListOf() + withService(PinyinLocation.INPUT_BOX, type, events = events) { service -> + typePinyin(service, "nihao") + assertEquals(name, emptyList(), events) + } + } + } + + @Test + fun aPasswordFieldNeverTakesTheComposition() { + val typed = mutableListOf() + withService( + PinyinLocation.INPUT_BOX, + InputType.TYPE_CLASS_TEXT, + passwordField = true, + events = typed, + ) { service -> typePinyin(service, "nihao") } + assertEquals("密码框不收拼音", emptyList(), typed) + + // 密码框有时报纯文本类型,只能靠这个标志挡住。收尾事件(finishComposingText) + // 不带字,允许;带字的那条通道不能走。 + val switched = mutableListOf() + withService( + PinyinLocation.CANDIDATE_BAR, + InputType.TYPE_CLASS_TEXT, + passwordField = true, + events = switched, + ) { service -> service.onPinyinLocationChanged(PinyinLocation.INPUT_BOX) } + assertEquals( + "密码框里切档也不写拼音", + emptyList(), + switched.filter { it.contains("nihao") }, + ) + } + + @Test + fun switchingTheSettingMovesTheCompositionInAndBackOut() { + val events = mutableListOf() + withService(PinyinLocation.CANDIDATE_BAR, InputType.TYPE_CLASS_TEXT, events = events) { service -> + service.onPinyinLocationChanged(PinyinLocation.INPUT_BOX) + assertEquals("在场的拼音搬进输入框", listOf("compose:nihao"), events) + + events.clear() + service.onPinyinLocationChanged(PinyinLocation.CANDIDATE_BAR) + assertEquals("切回候选栏把输入框里那段收回", listOf("compose:", "finish"), events) + } + } + + @Test + fun switchingWithNothingComposingTouchesNothing() { + val events = mutableListOf() + withService(PinyinLocation.CANDIDATE_BAR, InputType.TYPE_CLASS_TEXT, events = events) { service -> + setField(service, "lastComposition", "") + service.onPinyinLocationChanged(PinyinLocation.INPUT_BOX) + assertEquals(emptyList(), events) + } + } +} diff --git a/app/src/androidTest/java/llc/slacker/openime/PinyinLocationWiringInstrumentedTest.kt b/app/src/androidTest/java/llc/slacker/openime/PinyinLocationWiringInstrumentedTest.kt new file mode 100644 index 00000000..7350d7c3 --- /dev/null +++ b/app/src/androidTest/java/llc/slacker/openime/PinyinLocationWiringInstrumentedTest.kt @@ -0,0 +1,28 @@ +package llc.slacker.openime + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import llc.slacker.openime.core.PinyinLocation +import org.junit.Assert.assertEquals +import org.junit.Test +import org.junit.runner.RunWith + +/** + * 应用内设置页改这一项时,在场的输入法要收到通知:显示位置由服务决定写不写输入框, + * 不像双拼方案那样每次同步自读,只有键盘自己知道是不够的。 + */ +@RunWith(AndroidJUnit4::class) +class PinyinLocationWiringInstrumentedTest { + + @Test + fun theSettingsPageAnswersTheChange() { + val method = ImeSettingsActivity::class.java.getDeclaredMethod( + "onPinyinLocationChanged", + PinyinLocation::class.java, + ) + assertEquals( + "设置页要自己接这个回调,而不是用接口里的空实现", + ImeSettingsActivity::class.java, + method.declaringClass, + ) + } +} diff --git a/app/src/androidTest/java/llc/slacker/openime/PoemAssociationInstrumentedTest.kt b/app/src/androidTest/java/llc/slacker/openime/PoemAssociationInstrumentedTest.kt new file mode 100644 index 00000000..9d0bfd11 --- /dev/null +++ b/app/src/androidTest/java/llc/slacker/openime/PoemAssociationInstrumentedTest.kt @@ -0,0 +1,40 @@ +package llc.slacker.openime + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import llc.slacker.openime.candidate.PoemIndex +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith + +/** + * 诗句联想 against the shipped table: the reference's poetry dictionary + * (`sys_poem_gram`, `poem/lines.tsv`) has to be present in the APK and read + * back the way it was written. + */ +@RunWith(AndroidJUnit4::class) +class PoemAssociationInstrumentedTest { + + private val context = InstrumentationRegistry.getInstrumentation().targetContext + + @Test + fun theShippedTableCarriesTheReferencesLines() { + val index = PoemIndex.obtain(context) + assertTrue("the poem table must ship whole, got ${index.size}", index.size > 5_000) + + // The line spelled out brings the line that follows it in its poem. + assertEquals( + listOf("春眠不觉晓", "处处闻啼鸟"), + index.suggestionsFor("chunmianbujuexiao"), + ) + assertEquals( + listOf("海上生明月", "天涯共此时"), + index.suggestionsFor("haishangshengmingyue"), + ) + // Three syllables are enough; two are every day's typing. + assertEquals(listOf("春眠不觉晓"), index.suggestionsFor("chunmianbu")) + assertTrue(index.suggestionsFor("chunmian").isEmpty()) + assertTrue(index.suggestionsFor("haishang").isEmpty()) + } +} diff --git a/app/src/androidTest/java/llc/slacker/openime/PreEditWidthInstrumentedTest.kt b/app/src/androidTest/java/llc/slacker/openime/PreEditWidthInstrumentedTest.kt new file mode 100644 index 00000000..1b8e223e --- /dev/null +++ b/app/src/androidTest/java/llc/slacker/openime/PreEditWidthInstrumentedTest.kt @@ -0,0 +1,110 @@ +package llc.slacker.openime + +import android.os.SystemClock +import android.view.MotionEvent +import android.view.View +import android.view.ViewGroup +import android.widget.EditText +import androidx.test.ext.junit.runners.AndroidJUnit4 +import llc.slacker.openime.core.KeyboardMode +import llc.slacker.openime.keyboard.CompositionPolicy +import llc.slacker.openime.keyboard.ImeKeyboardView +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import java.lang.reflect.Proxy + +/** + * The pre-edit keeps the reference's own half of the page + * (`maxwidth="halfMaxWidth"`, `input_toolbar_typing.xml:6`) and scrolls inside + * it rather than running the full width of the candidate row. + */ +@RunWith(AndroidJUnit4::class) +class PreEditWidthInstrumentedTest { + private val listener = Proxy.newProxyInstance( + ImeKeyboardView.Listener::class.java.classLoader, + arrayOf(ImeKeyboardView.Listener::class.java), + ) { _, method, _ -> if (method.returnType == java.lang.Boolean.TYPE) false else null } as ImeKeyboardView.Listener + + private fun withKeyboard(test: (DirectActivityHarness, ImeKeyboardView) -> Unit) { + DirectActivityHarness(DebugKeyboardActivity::class.java).use { harness -> + harness.launch() + val keyboard = harness.awaitMain { activity -> + ImeKeyboardView(activity, listener).also { + activity.findViewById(android.R.id.content).addView(it) + } + } + try { + harness.awaitMain { if (keyboard.width > 0) true else null } + test(harness, keyboard) + } finally { + harness.awaitMain { + keyboard.shutdown() + (keyboard.parent as? ViewGroup)?.removeView(keyboard) + true + } + } + } + } + + private fun tap(harness: DirectActivityHarness, view: View) { + val downTime = SystemClock.uptimeMillis() + harness.awaitMain { + val event = MotionEvent.obtain( + downTime, SystemClock.uptimeMillis(), MotionEvent.ACTION_DOWN, + view.width / 2f, view.height / 2f, 0, + ) + try { view.dispatchTouchEvent(event) } finally { event.recycle() } + true + } + harness.awaitMain { + val event = MotionEvent.obtain( + downTime, SystemClock.uptimeMillis(), MotionEvent.ACTION_UP, + view.width / 2f, view.height / 2f, 0, + ) + try { view.dispatchTouchEvent(event) } finally { event.recycle() } + true + } + SystemClock.sleep(150) + } + + private fun type(harness: DirectActivityHarness, keyboard: ImeKeyboardView, keys: String) { + keys.forEach { letter -> + val key = harness.awaitMain { + keyboard.findViewWithTag("key:$letter")?.takeIf { it.width > 0 } + } + assertNotNull("The $letter key must be on the surface", key) + tap(harness, key!!) + } + } + + @Test + fun thePreEditStopsAtHalfThePage() { + withKeyboard { harness, keyboard -> + harness.awaitMain { keyboard.setMode(KeyboardMode.PINYIN_26); true } + type(harness, keyboard, "nihao") + val zone = harness.awaitMain { keyboard.findViewWithTag("ime_toolbar") } + val editor = harness.awaitMain { + keyboard.findViewWithTag("pinyin-composition-editor") + ?.takeIf { it.text.isNotEmpty() } + } + assertNotNull("Typing pinyin must open the pre-edit", editor) + val cap = CompositionPolicy.maxWidthPx(zone.width) + assertEquals("The pre-edit keeps the reference's half of the row", cap, editor!!.width) + assertEquals("The cap is the field's own limit too", cap, editor.maxWidth) + assertTrue( + "The other half of the row is left to the surface beside it", + editor.right <= (zone.width / 2) + 2, + ) + // The string scrolls inside the cap: it never widens the row. + type(harness, keyboard, "shijiede") + val longer = harness.awaitMain { + keyboard.findViewWithTag("pinyin-composition-editor") + ?.takeIf { it.text.length > 5 }?.width ?: -1 + } + assertEquals("A longer string keeps the same cap", cap, longer) + } + } +} diff --git a/app/src/androidTest/java/llc/slacker/openime/QuickInputRowInstrumentedTest.kt b/app/src/androidTest/java/llc/slacker/openime/QuickInputRowInstrumentedTest.kt new file mode 100644 index 00000000..fd2f1256 --- /dev/null +++ b/app/src/androidTest/java/llc/slacker/openime/QuickInputRowInstrumentedTest.kt @@ -0,0 +1,182 @@ +package llc.slacker.openime + +import android.content.ClipData +import android.content.ClipboardManager +import android.content.Context +import android.view.View +import android.view.ViewGroup +import android.view.inputmethod.EditorInfo +import android.widget.TextView +import androidx.test.ext.junit.runners.AndroidJUnit4 +import llc.slacker.openime.keyboard.ImeKeyboardView +import llc.slacker.openime.keyboard.QuickInputRowPolicy +import llc.slacker.openime.theme.ImeAppearance +import llc.slacker.openime.theme.ImeReferenceSizing +import llc.slacker.openime.theme.ImeTheme +import llc.slacker.openime.theme.ImeTypographyTokens +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import java.lang.reflect.Proxy + +/** + * The clipboard and message suggestions the app builds for itself wear the + * reference's quick-input rows (`input_toolbar_quickinput.xml:20-51`): flat + * text at the candidate font with the glyph that says where it came from, and + * the reference's own close key at the row's end. + */ +@RunWith(AndroidJUnit4::class) +class QuickInputRowInstrumentedTest { + private val characters = mutableListOf() + private val listener = Proxy.newProxyInstance( + ImeKeyboardView.Listener::class.java.classLoader, + arrayOf(ImeKeyboardView.Listener::class.java), + ) { _, method, args -> + if (method.name == "onCharacter") characters += args!![0] as String + if (method.returnType == java.lang.Boolean.TYPE) false else null + } as ImeKeyboardView.Listener + + private fun withClipboard( + text: String, + test: (DirectActivityHarness, ImeKeyboardView, (String) -> Unit) -> Unit, + ) { + DirectActivityHarness(DebugKeyboardActivity::class.java).use { harness -> + harness.launch() + val keyboard = harness.awaitMain { activity -> + ImeKeyboardView(activity, listener).also { + activity.findViewById(android.R.id.content).addView(it) + } + } + try { + harness.awaitMain { if (keyboard.width > 0) true else null } + val clipboard = harness.awaitMain { + keyboard.context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager + } + val copy: (String) -> Unit = { copied -> + harness.awaitMain { + clipboard.setPrimaryClip(ClipData.newPlainText("openime-test", copied)) + true + } + Unit + } + harness.awaitMain { + keyboard.applyPersistedSettings( + newTheme = ImeTheme.IOS, + newAppearance = ImeAppearance.LIGHT, + sound = false, + haptic = true, + popup = true, + fuzzy = false, + ) + // The quick-input rows belong to a real editor session; + // this view has none, so it declares the options an editor + // without the no-personalisation flag would give it. + keyboard.editorOptionsOverride = EditorInfo.IME_ACTION_NONE + true + } + copy(text) + harness.awaitMain { keyboard.refreshSuggestions(); true } + // Let the row place itself: the assertions measure a laid-out glyph. + harness.awaitMain { + val icon = keyboard.findViewWithTag("suggestion-icon") + if ((icon?.height ?: 0) > 0) true else null + } + test(harness, keyboard, copy) + } finally { + harness.awaitMain { + keyboard.shutdown() + (keyboard.parent as? ViewGroup)?.removeView(keyboard) + true + } + } + } + } + + @Test + fun theClipboardSuggestionIsTheReferencesOwnRow() { + withClipboard("hello from the clipboard") { harness, keyboard, _ -> + val row = harness.awaitMain { keyboard.findViewWithTag("suggestion-row") } + assertNotNull("A copied text must offer a row", row) + val label = harness.awaitMain { keyboard.findViewWithTag("suggestion-text") } + assertNotNull("The row carries the text itself", label) + // Every text outside a key cap is drawn at the reference scale's + // size, so the row reads at the candidate font as drawn. + assertEquals( + "The quick-input text reads at the candidate font", + ImeTypographyTokens.CANDIDATE_SP * + ImeReferenceSizing.scale(keyboard.context, keyboard.width), + harness.awaitMain { label!!.textSize / keyboard.resources.displayMetrics.scaledDensity }, + 0.5f, + ) + assertNotNull( + "and the glyph that says where it came from", + harness.awaitMain { keyboard.findViewWithTag("suggestion-icon") }, + ) + val badge = harness.awaitMain { keyboard.findViewWithTag("suggestion-icon")!! } + assertEquals( + "The glyph rides at `fi_toolbar_tip_superscript` against the candidate font", + (label!!.textSize * QuickInputRowPolicy.BADGE_FONT_RATIO + 0.5f).toInt().toLong(), + badge.height.toLong(), + ) + assertTrue( + "as a superscript above the text, not a centred icon", + badge.top - row!!.top < row.height / 2, + ) + val tokens = ImeTheme.IOS.tokens(ImeAppearance.LIGHT) + assertEquals( + "`canditem_text` over the row (`input_toolbar_quickinput.xml:40`)", + tokens.candidateText, + label.currentTextColor, + ) + val close = harness.awaitMain { keyboard.findViewWithTag("autofill-close") } + assertNotNull("The row ends with the reference's close key", close) + assertEquals(View.VISIBLE, harness.awaitMain { close!!.visibility }) + assertFalse( + "The strip's back control belongs to the system's chips", + harness.awaitMain { + keyboard.findViewWithTag("association-back")?.visibility == View.VISIBLE + }, + ) + } + } + + @Test + fun tappingTheRowTypesTheTextAndTheCloseKeyDropsIt() { + withClipboard("123456") { harness, keyboard, copy -> + characters.clear() + harness.awaitMain { + keyboard.findViewWithTag("suggestion-row")!!.performClick() + true + } + assertTrue("A tap types the copied text", characters.contains("123456")) + assertTrue( + "The row goes away with the tap", + harness.awaitMain { keyboard.findViewWithTag("suggestion-row") == null }, + ) + // A later copy is a new suggestion, so the row is back. + copy("654321") + harness.awaitMain { keyboard.refreshSuggestions(); true } + assertNotNull( + "A fresh copy brings the row back", + harness.awaitMain { keyboard.findViewWithTag("suggestion-row") }, + ) + harness.awaitMain { + keyboard.findViewWithTag("autofill-close")!!.performClick() + true + } + assertTrue( + "The close key drops the row for this text", + harness.awaitMain { keyboard.findViewWithTag("suggestion-row") == null }, + ) + harness.awaitMain { keyboard.refreshSuggestions(); true } + assertTrue( + "and keeps it away until the clipboard changes", + harness.awaitMain { keyboard.findViewWithTag("suggestion-row") == null }, + ) + } + } +} diff --git a/app/src/androidTest/java/llc/slacker/openime/SpaceCursorAndLetterHintsInstrumentedTest.kt b/app/src/androidTest/java/llc/slacker/openime/SpaceCursorAndLetterHintsInstrumentedTest.kt index 17961f56..6b2f007e 100644 --- a/app/src/androidTest/java/llc/slacker/openime/SpaceCursorAndLetterHintsInstrumentedTest.kt +++ b/app/src/androidTest/java/llc/slacker/openime/SpaceCursorAndLetterHintsInstrumentedTest.kt @@ -304,14 +304,16 @@ class SpaceCursorAndLetterHintsInstrumentedTest { check(harness.awaitMain { true }) } + // The reference product's mark map (LetterHintPolicy). private val chineseHints = mapOf( 'q' to "1", 'w' to "2", 'e' to "3", 'r' to "4", 't' to "5", 'y' to "6", 'u' to "7", 'i' to "8", - 'o' to "9", 'p' to "0", 'a' to "~", 's' to "!", 'd' to "@", 'f' to "#", 'g' to "¥", 'h' to "%", - 'j' to "&", 'k' to "*", 'l' to "?", 'z' to "(", 'x' to ")", 'c' to "-", 'v' to "_", - 'b' to ":", 'n' to ";", 'm' to "/", + 'o' to "9", 'p' to "0", 'a' to "-", 's' to "/", 'd' to ":", 'f' to ";", 'g' to "(", 'h' to ")", + 'j' to "~", 'k' to "“", 'l' to "”", 'z' to "@", 'x' to ".", 'c' to "#", 'v' to "、", + 'b' to "?", 'n' to "!", 'm' to "…", ) private val englishHints = chineseHints + mapOf( - 's' to "!", 'g' to "$", 'l' to "?", 'z' to "(", 'x' to ")", 'b' to ":", 'n' to ";", + 'd' to ":", 'f' to ";", 'g' to "(", 'h' to ")", 'j' to "~", 'k' to "'", 'l' to "\"", + 'x' to "_", 'v' to "&", 'b' to "?", 'n' to "!", ) @Test @@ -361,15 +363,17 @@ class SpaceCursorAndLetterHintsInstrumentedTest { } @Test - fun longPressOffersTheHintInAPopup() = withKeyboard { harness, recorder, keyboard -> + fun longPressOffersTheLetterInBothCases() = withKeyboard { harness, recorder, keyboard -> harness.awaitMain { keyboard.setMode(KeyboardMode.PINYIN_26); true } - listOf('w' to "2", 'd' to "@", 'k' to "*", 'v' to "_").forEach { (letter, hint) -> + listOf('w' to "W", 'd' to "D", 'k' to "K", 'v' to "V").forEach { (letter, upper) -> val key = settled(harness, keyboard, "key:$letter") + // The popup lists both cases, upper first; a tap on a cell types it. assertTrue(harness.awaitMain { key.performLongClick() }) - // The popup lists the other case, then the hint; a tap on its cell types it. - assertTrue("popup cell $hint", harness.awaitMain { chooseFromPopup(keyboard, hint) }) + assertTrue("popup lower cell $letter", harness.awaitMain { chooseFromPopup(keyboard, letter.toString()) }) + assertTrue(harness.awaitMain { key.performLongClick() }) + assertTrue("popup upper cell $upper", harness.awaitMain { chooseFromPopup(keyboard, upper) }) } - assertEquals(listOf("2", "@", "*", "_"), recorder.characters.toList()) + assertEquals(listOf("w", "W", "d", "D", "k", "K", "v", "V"), recorder.characters.toList()) } /** Taps the long-press popup cell showing [text]; false when no such cell is on screen. */ @@ -389,17 +393,54 @@ class SpaceCursorAndLetterHintsInstrumentedTest { assertTrue("a tap types the letter, not the hint: ${recorder.characters}", "1" !in recorder.characters) } + /** Keep the digit gesture without moving the original zero key onto the space bar. */ + @Test + fun spaceKeepsItsOriginalFaceAndRetainsTheZeroSwipe() = withKeyboard { harness, recorder, keyboard -> + listOf(KeyboardMode.PINYIN_9, KeyboardMode.STROKE).forEach { mode -> + harness.awaitMain { keyboard.setMode(KeyboardMode.DIGITS); keyboard.setMode(mode); true } + recorder.characters.clear() + recorder.spaces = 0 + val space = settled(harness, keyboard, "key-space") + harness.awaitMain { assertFalse("$mode space keeps its original face", "0" in texts(space)); true } + swipeUp(harness, space) + assertEquals("$mode up swipe on the space bar types 0", listOf("0"), recorder.characters.toList()) + recorder.characters.clear() + tap(harness, space) + assertEquals("$mode the tap still types one space", 1, recorder.spaces) + assertEquals("$mode a tap must not type the mark", emptyList(), recorder.characters.toList()) + } + } + + /** + * The merged key commits its own mark and hides the other in its corner: + * Chinese 逗号 over 句号, English the full stop over the comma + * (`input_kbd_english26.xml:154-161`, `input_kbd_pinyin26.xml:145-152`), so + * an up swipe types the corner mark — 句号 in Chinese, the comma in English. + */ + @Test + fun swipeUpOnTheCommaKeyTypesThePeriod() = withKeyboard { harness, recorder, keyboard -> + harness.awaitMain { keyboard.setMode(KeyboardMode.PINYIN_26); true } + swipeUp(harness, settled(harness, keyboard, "key-punctuation")) + assertEquals(listOf("。"), recorder.characters.toList()) + + recorder.characters.clear() + harness.awaitMain { keyboard.setMode(KeyboardMode.ENGLISH_26); true } + swipeUp(harness, settled(harness, keyboard, "key-punctuation")) + assertEquals(listOf(","), recorder.characters.toList()) + } + @Test - fun theSwipeSwitchTurnsOffSwipeButNotTheHints() = withKeyboard { harness, recorder, keyboard -> + fun theSwipeSwitchTurnsOffSwipeButKeepsTheLongPress() = withKeyboard { harness, recorder, keyboard -> ImeSettingsRepository.saveSwipeUpDigits(context, false) harness.awaitMain { keyboard.setMode(KeyboardMode.PINYIN_26); true } val q = settled(harness, keyboard, "key:q") swipeUp(harness, q) assertTrue("swipe is off: ${recorder.characters}", recorder.characters.isEmpty()) harness.awaitMain { assertTrue("1" in texts(q)); true } + // The long press offers the two cases; the digit is only on the swipe. assertTrue(harness.awaitMain { q.performLongClick() }) - assertTrue("popup cell 1", harness.awaitMain { chooseFromPopup(keyboard, "1") }) - assertEquals("long press still works", listOf("1"), recorder.characters.toList()) + assertTrue("popup cell q", harness.awaitMain { chooseFromPopup(keyboard, "q") }) + assertEquals("long press still works", listOf("q"), recorder.characters.toList()) } @Test @@ -442,7 +483,7 @@ class SpaceCursorAndLetterHintsInstrumentedTest { harness.awaitMain { keyboard.showPanel(Panel.SETTINGS); true } val expected = mapOf( "数字和符号提示" to ("开启" to { ImeSettingsRepository.loadLetterHints(context) }), - "表情联想" to ("开启" to { ImeSettingsRepository.loadEmojiAssociation(context) }), + "智能联想" to ("开启" to { ImeSettingsRepository.loadEmojiAssociation(context) }), "语音去语气词" to ("开启" to { ImeSettingsRepository.loadVoiceStripFillers(context) }), "标点用空格代替" to ("关闭" to { ImeSettingsRepository.loadVoicePunctuationAsSpace(context) }), ) diff --git a/app/src/androidTest/java/llc/slacker/openime/SymbolHistoryInstrumentedTest.kt b/app/src/androidTest/java/llc/slacker/openime/SymbolHistoryInstrumentedTest.kt new file mode 100644 index 00000000..bc387f6c --- /dev/null +++ b/app/src/androidTest/java/llc/slacker/openime/SymbolHistoryInstrumentedTest.kt @@ -0,0 +1,145 @@ +package llc.slacker.openime + +import android.view.View +import android.view.ViewGroup +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import llc.slacker.openime.core.Panel +import llc.slacker.openime.data.SymbolRecentRepository +import llc.slacker.openime.keyboard.ImeKeyboardView +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import java.lang.reflect.Proxy + +/** + * The symbol board's toolbar strip: the symbols the user has typed, as the + * reference lists them (`input_toolbar_quickinput.xml:4-18`). + */ +@RunWith(AndroidJUnit4::class) +class SymbolHistoryInstrumentedTest { + private val context = ApplicationProvider.getApplicationContext() + private val symbols = mutableListOf() + + @After + fun cleanup() { + SymbolRecentRepository.clear(context) + } + + private val listener = Proxy.newProxyInstance( + ImeKeyboardView.Listener::class.java.classLoader, + arrayOf(ImeKeyboardView.Listener::class.java), + ) { _, method, args -> + when (method.name) { + "onSymbolSelected" -> { symbols.add(args!![0] as String); null } + "onCharacter" -> { symbols.add(args!![0] as String); null } + else -> if (method.returnType == java.lang.Boolean.TYPE) false else null + } + } as ImeKeyboardView.Listener + + private fun withKeyboard(test: (ImeKeyboardView) -> Unit) { + // The history each test records before this call is the input it arranges; + // the @After cleanup empties the store between tests instead. + DirectActivityHarness(DebugKeyboardActivity::class.java).use { harness -> + harness.launch() + val keyboard = harness.awaitMain { activity -> + ImeKeyboardView(activity, listener).also { + activity.findViewById(android.R.id.content).addView(it) + } + } + try { + harness.awaitMain { if (keyboard.width > 0) true else null } + harness.awaitMain { test(keyboard); true } + } finally { + harness.awaitMain { + keyboard.shutdown() + (keyboard.parent as? ViewGroup)?.removeView(keyboard) + true + } + } + } + } + + @Test + fun theSymbolBoardListsTheSymbolsTheUserTyped() { + // The store outlives this class: another test that commits a symbol + // through the service leaves its own entry behind. + SymbolRecentRepository.clear(context) + SymbolRecentRepository.record(context, "★") + SymbolRecentRepository.record(context, "→") + withKeyboard { keyboard -> + keyboard.showPanel(Panel.SYMBOLS) + val strip = keyboard.findViewWithTag("recent-symbol-strip") + assertNotNull("The symbol board must carry the history strip", strip) + assertEquals(View.VISIBLE, strip!!.visibility) + // The row is the reference's own: lead inset, list, split, gap, close + // and tail (`input_toolbar_quickinput.xml:6-15`). + val row = keyboard.findViewWithTag("recent-symbol-row") as ViewGroup + assertEquals( + "Newest symbol first", + listOf("→", "★"), + (0 until row.childCount).map { row.getChildAt(it).contentDescription.toString().substringAfter(":") }, + ) + listOf("recent-symbol-lead", "recent-symbol-split", "recent-symbol-gap", "recent-symbol-close", "recent-symbol-tail") + .forEach { assertNotNull("$it must be part of the strip", keyboard.findViewWithTag(it)) } + } + } + + @Test + fun tappingAHistoryCellCommitsThroughTheSymbolPath() { + SymbolRecentRepository.record(context, "★") + withKeyboard { keyboard -> + keyboard.showPanel(Panel.SYMBOLS) + assertTrue( + "The cell must commit the symbol it shows", + keyboard.findTestTarget("最近符号:★")!!.performClick(), + ) + assertEquals(listOf("★"), symbols) + } + } + + @Test + fun theCloseKeyEndsTheStripUntilTheBoardIsOpenedAgain() { + SymbolRecentRepository.record(context, "★") + withKeyboard { keyboard -> + keyboard.showPanel(Panel.SYMBOLS) + val strip = keyboard.findViewWithTag("recent-symbol-strip")!! + assertEquals(View.VISIBLE, strip.visibility) + assertTrue(keyboard.findViewWithTag("recent-symbol-close")!!.performClick()) + assertEquals("The close key ends the strip", View.GONE, strip.visibility) + assertNotNull( + "The board itself stays open", + keyboard.findViewWithTag("symbol-strip-row"), + ) + keyboard.showPanel(Panel.SYMBOLS) + assertEquals("Opening the board again shows the strip", View.VISIBLE, strip.visibility) + } + } + + @Test + fun otherPanelsNeverCarryTheHistoryStrip() { + SymbolRecentRepository.record(context, "★") + withKeyboard { keyboard -> + keyboard.showPanel(Panel.SYMBOLS) + keyboard.showPanel(Panel.EMOJI) + assertEquals( + "The strip belongs to the symbol board's toolbar only", + View.GONE, + keyboard.findViewWithTag("recent-symbol-strip")!!.visibility, + ) + } + } + + @Test + fun theHistoryKeepsTheNewestFirstWithoutDuplicates() { + SymbolRecentRepository.clear(context) + SymbolRecentRepository.record(context, "★") + SymbolRecentRepository.record(context, "→") + SymbolRecentRepository.record(context, "★") + assertEquals(listOf("★", "→"), SymbolRecentRepository.load(context)) + } +} diff --git a/app/src/androidTest/java/llc/slacker/openime/VoiceModelActivityInstrumentedTest.kt b/app/src/androidTest/java/llc/slacker/openime/VoiceModelActivityInstrumentedTest.kt new file mode 100644 index 00000000..b874df3c --- /dev/null +++ b/app/src/androidTest/java/llc/slacker/openime/VoiceModelActivityInstrumentedTest.kt @@ -0,0 +1,104 @@ +package llc.slacker.openime + +import android.content.ClipboardManager +import android.view.ViewGroup +import android.widget.Button +import android.widget.LinearLayout +import android.widget.ScrollView +import android.widget.TextView +import androidx.test.ext.junit.runners.AndroidJUnit4 +import llc.slacker.openime.setup.SetupUi +import llc.slacker.openime.voice.VoiceModelDownloads +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import kotlin.math.abs + +@RunWith(AndroidJUnit4::class) +class VoiceModelActivityInstrumentedTest { + @Test + fun contentIsCenteredAndEveryCardSharesOneTextAndActionColumn() { + DirectActivityHarness(VoiceModelActivity::class.java).use { harness -> + harness.launch() + val scroll = harness.awaitMain { activity -> + val content = activity.window.decorView.findViewWithTag("voice-model-content") + if (content == null || content.width == 0 || content.isLayoutRequested) null + else content.parent as ScrollView + } + val fullWidth = harness.awaitMain { scroll.width } + // Exercise relayout as well as the device's current width. Run this on a + // tablet/landscape emulator too so the 600dp cap is exercised. + for (width in listOf(fullWidth, fullWidth * 3 / 4, fullWidth)) { + harness.awaitMain { + scroll.layoutParams = scroll.layoutParams.apply { this.width = width } + true + } + harness.awaitMain { activity -> + val content = scroll.findViewWithTag("voice-model-content") + if (scroll.width != width || content.isLayoutRequested || scroll.isLayoutRequested) { + return@awaitMain null + } + val availableWidth = scroll.width - scroll.paddingLeft - scroll.paddingRight + assertEquals(minOf(availableWidth, SetupUi.dp(activity, 600)), content.width) + assertTrue("Content has symmetric margins", abs( + (content.left - scroll.paddingLeft) - + (scroll.width - scroll.paddingRight - content.right), + ) <= 1) + val cards = (0 until content.childCount).map(content::getChildAt) + .filterIsInstance() + .filter { it.background != null } + assertTrue("Current model, hotwords, import and download cards exist", cards.size >= 6) + val pad = SetupUi.dp(activity, 16) + cards.forEach { card -> + assertEquals(pad, card.paddingLeft) + assertEquals(pad, card.paddingRight) + assertEquals(content.paddingLeft, card.left) + assertEquals(content.width - content.paddingRight, card.right) + for (index in 0 until card.childCount) { + val child = card.getChildAt(index) + assertEquals("Card text and actions start together", pad, child.left) + assertEquals("Card text and actions end together", card.width - pad, child.right) + } + } + true + } + } + } + } + + @Test + fun eachDownloadButtonCopiesItsDisplayedOfficialAddress() { + DirectActivityHarness(VoiceModelActivity::class.java).use { harness -> + harness.launch() + harness.awaitMain { activity -> if (activity.hasWindowFocus()) true else null } + val originalClip = harness.awaitMain { activity -> + activity.getSystemService(ClipboardManager::class.java) + }.primaryClip + try { + for (model in VoiceModelDownloads.models) { + harness.awaitMain { activity -> + val card = activity.window.decorView.findViewWithTag("voice-model-download-${model.id}") + ?: return@awaitMain null + val url = card.findViewWithTag("voice-model-url-${model.id}") + assertEquals(model.url, url.text.toString()) + assertTrue("URL can also be selected", url.isTextSelectable) + val button = (0 until card.childCount).map(card::getChildAt).filterIsInstance