diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml
index e7712ab3..7f5943ba 100644
--- a/.github/workflows/android.yml
+++ b/.github/workflows/android.yml
@@ -113,7 +113,7 @@ jobs:
strategy:
fail-fast: false
matrix:
- api-level: [29, 31]
+ api-level: [26, 29, 31, 34]
steps:
- name: Enable KVM group perms
diff --git a/CHANGELOG.md b/CHANGELOG.md
index d2cc5d11..57e16297 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -15,7 +15,7 @@ GitHub Release 的发布说明;版本与发布流程见 [docs/RELEASE.md](docs
### 输入
- 键盘:26 键拼音、九键拼音、英文 26 键、数字与符号;不提供英文九键。
- 拼音引擎:基于 librime 的全拼、简拼、显式分词、候选读取(上限 96 项)、用户学习和 OpenCC 简繁转换;内置约 90 万条 Rime Ice 词典记录(固定版本,含 8105 字表)。首次部署完整词典期间,高频快速词库即时提供候选。
-- 九键:输入时左栏变成「读法列表」——短输入列整条读法(`ni'hao`),长输入列首音节;点选即锁定,锁定的音节在继续输入时保持不变,退格先解锁。预编辑跟随首选词的读法(`9694264244326` → `wo'xiang'chi'fan`「我想吃饭」),数字刚好拼得出的词排在预测词之前。做法与取舍见 [九键参考](https://github.com/Slacker-LLC/openIME/blob/main/docs/NINE_KEY_REFERENCE.md)。
+- 九键:输入时左栏变成拼音列表,一个字一个拼音——每项是下一个字的一个音节(`ni`、`mi`),点选即锁定,列表随即移到下一个字;锁定的音节在继续输入时保持不变,退格先解锁。预编辑跟随首选词的读法(`9694264244326` → `wo'xiang'chi'fan`「我想吃饭」),数字刚好拼得出的词排在预测词之前。做法与取舍见 [九键参考](https://github.com/Slacker-LLC/openIME/blob/main/docs/NINE_KEY_REFERENCE.md)。
- 选词只覆盖一部分输入时只上屏该词,剩余输入继续作为预编辑;不会把没选过的整句写进用户词库。
- 空格提交首选候选,回车(确定)提交已输入的拼音原文;提交后统一清除拼音、候选与 Rime composition,随后删除键只处理目标输入框。上屏后的联想栏为「‹ 联想词 ∨」。
- 删除键:长按连续删除;上滑 ≥ 32dp 松手清空,清空后 5 秒内下滑撤回。清空与撤回在自绘、Compose、Web 等没有「全选」也没有完整 ExtractedText 的输入框里同样可用,提示统一为一个气泡,清空后顶栏显示「已清空 · 撤销」。
@@ -31,11 +31,27 @@ GitHub Release 的发布说明;版本与发布流程见 [docs/RELEASE.md](docs
- 强调色可选可自定义,界面里的强调元素统一从它推导;设置页分段控件、剪贴板清除按钮等可点区域保持 48dp 触控目标;安装引导页的卡片对读屏软件是一个整体,不再逐个朗读装饰元素。
- 手写目前只保留笔迹采集界面,识别引擎尚未接入,入口默认隐藏。
+### 兼容性
+- 物理键盘(平板、折叠屏键盘套、Chromebook、桌面模式)可以打中文:字母组成拼音,空格选首选,1–9 选候选,回车保留拼音,Esc 取消,`,` `.` 等输出全角标点,数字后的 `,` `.` `:` 保持 ASCII;快捷键、大写字母原样交给应用。
+- 终端、游戏、远程桌面这类没有文本类型(TYPE_NULL)的输入框:默认英文,字母立即送出,退格改用按键事件(此前什么也删不掉);日期时间输入框默认数字键盘。
+- 一次提交超大文本(大段粘贴)会分块,不会因超过 Binder 事务上限而崩溃。
+- 横屏不再进入全屏「提取」模式:应用自己的输入框不会被盖住,键盘始终是底部面板。
+- 横屏、平板和折叠屏上按键行宽度现在跟随内容区,不会再被裁掉一部分;布局在测量前就按可用宽度计算。
+- 系统字体放大到 200% 时按键字符不再变成省略号,「中/英」不再缺字:按键标签最多跟随系统字体放大到 1.3 倍,功能键标签会自动缩小以放入按键。
+- 竖屏、横屏、字体 130% / 200%、深色、小屏、窄屏、平板(竖 / 横)和折叠屏内屏都由 `scripts/display_matrix_regression.py` 逐一验证。
+
+### 稳定性
+- 一次按键处理失败不会让键盘退出;崩溃、原生崩溃和卡死(ANR)会记录在本机(只含异常类型和代码位置,不含输入内容),10 分钟内三次进入安全模式(关闭原生词库与语音预加载),「关于与数据」里可以复制诊断信息或退出安全模式。
+- librime 启动时进程原生崩溃会逐级自愈:清理编译产物、备份并重建用户词库、最后不再启动原生引擎。
+- 语音输入静音媒体音量期间进程意外退出,下次启动会恢复音量,并带两分钟看门狗。
+- 词库和九键解码器改在后台线程构建,冷启动不再卡住主线程。
+
### 隐私与安全
- 不声明 `INTERNET` 权限;`allowBackup=false`。
- 密码输入框不写入候选、剪贴板历史或日志(允许从剪贴板粘贴);语音 PCM 只在当前会话的内存缓冲区中处理,结束、取消或失败时清空。
- 「设置 → 关于与数据」可导出 / 合并导入用户数据,剪贴板历史不导出;卸载会清除本机全部数据,包括学习的用户词库。
- 测试用 Activity 与 E2E 接收器只存在于 debug 变体,release APK 不导出。
+- 以 GPL-3.0-only 许可证发布,见仓库根目录 `LICENSE`。
### 工程
- 版本号单一来源(根目录 `VERSION`),`versionCode = 主 × 10000 + 次 × 100 + 修订`。
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 9cdffe0d..aeebf175 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -55,4 +55,4 @@ PR 描述应包含:改动目的、影响范围、测试命令和结果、已
密码、剪贴板或录音。
`main` 受保护:只能通过 PR 合并,且 **Build and verify** 必须通过;PR 一律 squash 合并,
-合并后分支自动删除。API 29 / 31 兼容测试同样会在 PR 上运行,红了请先修再合并。
+合并后分支自动删除。API 26 / 29 / 31 / 34 兼容测试同样会在 PR 上运行,红了请先修再合并。
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 00000000..53d1f3d0
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,675 @@
+ GNU GENERAL PUBLIC LICENSE
+ Version 3, 29 June 2007
+
+ Copyright (C) 2007 Free Software Foundation, Inc.
+ Everyone is permitted to copy and distribute verbatim copies
+ of this license document, but changing it is not allowed.
+
+ Preamble
+
+ The GNU General Public License is a free, copyleft license for
+software and other kinds of works.
+
+ The licenses for most software and other practical works are designed
+to take away your freedom to share and change the works. By contrast,
+the GNU General Public License is intended to guarantee your freedom to
+share and change all versions of a program--to make sure it remains free
+software for all its users. We, the Free Software Foundation, use the
+GNU General Public License for most of our software; it applies also to
+any other work released this way by its authors. You can apply it to
+your programs, too.
+
+ When we speak of free software, we are referring to freedom, not
+price. Our General Public Licenses are designed to make sure that you
+have the freedom to distribute copies of free software (and charge for
+them if you wish), that you receive source code or can get it if you
+want it, that you can change the software or use pieces of it in new
+free programs, and that you know you can do these things.
+
+ To protect your rights, we need to prevent others from denying you
+these rights or asking you to surrender the rights. Therefore, you have
+certain responsibilities if you distribute copies of the software, or if
+you modify it: responsibilities to respect the freedom of others.
+
+ For example, if you distribute copies of such a program, whether
+gratis or for a fee, you must pass on to the recipients the same
+freedoms that you received. You must make sure that they, too, receive
+or can get the source code. And you must show them these terms so they
+know their rights.
+
+ Developers that use the GNU GPL protect your rights with two steps:
+(1) assert copyright on the software, and (2) offer you this License
+giving you legal permission to copy, distribute and/or modify it.
+
+ For the developers' and authors' protection, the GPL clearly explains
+that there is no warranty for this free software. For both users' and
+authors' sake, the GPL requires that modified versions be marked as
+changed, so that their problems will not be attributed erroneously to
+authors of previous versions.
+
+ Some devices are designed to deny users access to install or run
+modified versions of the software inside them, although the manufacturer
+can do so. This is fundamentally incompatible with the aim of
+protecting users' freedom to change the software. The systematic
+pattern of such abuse occurs in the area of products for individuals to
+use, which is precisely where it is most unacceptable. Therefore, we
+have designed this version of the GPL to prohibit the practice for those
+products. If such problems arise substantially in other domains, we
+stand ready to extend this provision to those domains in future versions
+of the GPL, as needed to protect the freedom of users.
+
+ Finally, every program is threatened constantly by software patents.
+States should not allow patents to restrict development and use of
+software on general-purpose computers, but in those that do, we wish to
+avoid the special danger that patents applied to a free program could
+make it effectively proprietary. To prevent this, the GPL assures that
+patents cannot be used to render the program non-free.
+
+ The precise terms and conditions for copying, distribution and
+modification follow.
+
+ TERMS AND CONDITIONS
+
+ 0. Definitions.
+
+ "This License" refers to version 3 of the GNU General Public License.
+
+ "Copyright" also means copyright-like laws that apply to other kinds of
+works, such as semiconductor masks.
+
+ "The Program" refers to any copyrightable work licensed under this
+License. Each licensee is addressed as "you". "Licensees" and
+"recipients" may be individuals or organizations.
+
+ To "modify" a work means to copy from or adapt all or part of the work
+in a fashion requiring copyright permission, other than the making of an
+exact copy. The resulting work is called a "modified version" of the
+earlier work or a work "based on" the earlier work.
+
+ A "covered work" means either the unmodified Program or a work based
+on the Program.
+
+ To "propagate" a work means to do anything with it that, without
+permission, would make you directly or secondarily liable for
+infringement under applicable copyright law, except executing it on a
+computer or modifying a private copy. Propagation includes copying,
+distribution (with or without modification), making available to the
+public, and in some countries other activities as well.
+
+ To "convey" a work means any kind of propagation that enables other
+parties to make or receive copies. Mere interaction with a user through
+a computer network, with no transfer of a copy, is not conveying.
+
+ An interactive user interface displays "Appropriate Legal Notices"
+to the extent that it includes a convenient and prominently visible
+feature that (1) displays an appropriate copyright notice, and (2)
+tells the user that there is no warranty for the work (except to the
+extent that warranties are provided), that licensees may convey the
+work under this License, and how to view a copy of this License. If
+the interface presents a list of user commands or options, such as a
+menu, a prominent item in the list meets this criterion.
+
+ 1. Source Code.
+
+ The "source code" for a work means the preferred form of the work
+for making modifications to it. "Object code" means any non-source
+form of a work.
+
+ A "Standard Interface" means an interface that either is an official
+standard defined by a recognized standards body, or, in the case of
+interfaces specified for a particular programming language, one that
+is widely used among developers working in that language.
+
+ The "System Libraries" of an executable work include anything, other
+than the work as a whole, that (a) is included in the normal form of
+packaging a Major Component, but which is not part of that Major
+Component, and (b) serves only to enable use of the work with that
+Major Component, or to implement a Standard Interface for which an
+implementation is available to the public in source code form. A
+"Major Component", in this context, means a major essential component
+(kernel, window system, and so on) of the specific operating system
+(if any) on which the executable work runs, or a compiler used to
+produce the work, or an object code interpreter used to run it.
+
+ The "Corresponding Source" for a work in object code form means all
+the source code needed to generate, install, and (for an executable
+work) run the object code and to modify the work, including scripts to
+control those activities. However, it does not include the work's
+System Libraries, or general-purpose tools or generally available free
+programs which are used unmodified in performing those activities but
+which are not part of the work. For example, Corresponding Source
+includes interface definition files associated with source files for
+the work, and the source code for shared libraries and dynamically
+linked subprograms that the work is specifically designed to require,
+such as by intimate data communication or control flow between those
+subprograms and other parts of the work.
+
+ The Corresponding Source need not include anything that users
+can regenerate automatically from other parts of the Corresponding
+Source.
+
+ The Corresponding Source for a work in source code form is that
+same work.
+
+ 2. Basic Permissions.
+
+ All rights granted under this License are granted for the term of
+copyright on the Program, and are irrevocable provided the stated
+conditions are met. This License explicitly affirms your unlimited
+permission to run the unmodified Program. The output from running a
+covered work is covered by this License only if the output, given its
+content, constitutes a covered work. This License acknowledges your
+rights of fair use or other equivalent, as provided by copyright law.
+
+ You may make, run and propagate covered works that you do not
+convey, without conditions so long as your license otherwise remains
+in force. You may convey covered works to others for the sole purpose
+of having them make modifications exclusively for you, or provide you
+with facilities for running those works, provided that you comply with
+the terms of this License in conveying all material for which you do
+not control copyright. Those thus making or running the covered works
+for you must do so exclusively on your behalf, under your direction
+and control, on terms that prohibit them from making any copies of
+your copyrighted material outside their relationship with you.
+
+ Conveying under any other circumstances is permitted solely under
+the conditions stated below. Sublicensing is not allowed; section 10
+makes it unnecessary.
+
+ 3. Protecting Users' Legal Rights From Anti-Circumvention Law.
+
+ No covered work shall be deemed part of an effective technological
+measure under any applicable law fulfilling obligations under article
+11 of the WIPO copyright treaty adopted on 20 December 1996, or
+similar laws prohibiting or restricting circumvention of such
+measures.
+
+ When you convey a covered work, you waive any legal power to forbid
+circumvention of technological measures to the extent such circumvention
+is effected by exercising rights under this License with respect to
+the covered work, and you disclaim any intention to limit operation or
+modification of the work as a means of enforcing, against the work's
+users, your or third parties' legal rights to forbid circumvention of
+technological measures.
+
+ 4. Conveying Verbatim Copies.
+
+ You may convey verbatim copies of the Program's source code as you
+receive it, in any medium, provided that you conspicuously and
+appropriately publish on each copy an appropriate copyright notice;
+keep intact all notices stating that this License and any
+non-permissive terms added in accord with section 7 apply to the code;
+keep intact all notices of the absence of any warranty; and give all
+recipients a copy of this License along with the Program.
+
+ You may charge any price or no price for each copy that you convey,
+and you may offer support or warranty protection for a fee.
+
+ 5. Conveying Modified Source Versions.
+
+ You may convey a work based on the Program, or the modifications to
+produce it from the Program, in the form of source code under the
+terms of section 4, provided that you also meet all of these conditions:
+
+ a) The work must carry prominent notices stating that you modified
+ it, and giving a relevant date.
+
+ b) The work must carry prominent notices stating that it is
+ released under this License and any conditions added under section
+ 7. This requirement modifies the requirement in section 4 to
+ "keep intact all notices".
+
+ c) You must license the entire work, as a whole, under this
+ License to anyone who comes into possession of a copy. This
+ License will therefore apply, along with any applicable section 7
+ additional terms, to the whole of the work, and all its parts,
+ regardless of how they are packaged. This License gives no
+ permission to license the work in any other way, but it does not
+ invalidate such permission if you have separately received it.
+
+ d) If the work has interactive user interfaces, each must display
+ Appropriate Legal Notices; however, if the Program has interactive
+ interfaces that do not display Appropriate Legal Notices, your
+ work need not make them do so.
+
+ A compilation of a covered work with other separate and independent
+works, which are not by their nature extensions of the covered work,
+and which are not combined with it such as to form a larger program,
+in or on a volume of a storage or distribution medium, is called an
+"aggregate" if the compilation and its resulting copyright are not
+used to limit the access or legal rights of the compilation's users
+beyond what the individual works permit. Inclusion of a covered work
+in an aggregate does not cause this License to apply to the other
+parts of the aggregate.
+
+ 6. Conveying Non-Source Forms.
+
+ You may convey a covered work in object code form under the terms
+of sections 4 and 5, provided that you also convey the
+machine-readable Corresponding Source under the terms of this License,
+in one of these ways:
+
+ a) Convey the object code in, or embodied in, a physical product
+ (including a physical distribution medium), accompanied by the
+ Corresponding Source fixed on a durable physical medium
+ customarily used for software interchange.
+
+ b) Convey the object code in, or embodied in, a physical product
+ (including a physical distribution medium), accompanied by a
+ written offer, valid for at least three years and valid for as
+ long as you offer spare parts or customer support for that product
+ model, to give anyone who possesses the object code either (1) a
+ copy of the Corresponding Source for all the software in the
+ product that is covered by this License, on a durable physical
+ medium customarily used for software interchange, for a price no
+ more than your reasonable cost of physically performing this
+ conveying of source, or (2) access to copy the
+ Corresponding Source from a network server at no charge.
+
+ c) Convey individual copies of the object code with a copy of the
+ written offer to provide the Corresponding Source. This
+ alternative is allowed only occasionally and noncommercially, and
+ only if you received the object code with such an offer, in accord
+ with subsection 6b.
+
+ d) Convey the object code by offering access from a designated
+ place (gratis or for a charge), and offer equivalent access to the
+ Corresponding Source in the same way through the same place at no
+ further charge. You need not require recipients to copy the
+ Corresponding Source along with the object code. If the place to
+ copy the object code is a network server, the Corresponding Source
+ may be on a different server (operated by you or a third party)
+ that supports equivalent copying facilities, provided you maintain
+ clear directions next to the object code saying where to find the
+ Corresponding Source. Regardless of what server hosts the
+ Corresponding Source, you remain obligated to ensure that it is
+ available for as long as needed to satisfy these requirements.
+
+ e) Convey the object code using peer-to-peer transmission, provided
+ you inform other peers where the object code and Corresponding
+ Source of the work are being offered to the general public at no
+ charge under subsection 6d.
+
+ A separable portion of the object code, whose source code is excluded
+from the Corresponding Source as a System Library, need not be
+included in conveying the object code work.
+
+ A "User Product" is either (1) a "consumer product", which means any
+tangible personal property which is normally used for personal, family,
+or household purposes, or (2) anything designed or sold for incorporation
+into a dwelling. In determining whether a product is a consumer product,
+doubtful cases shall be resolved in favor of coverage. For a particular
+product received by a particular user, "normally used" refers to a
+typical or common use of that class of product, regardless of the status
+of the particular user or of the way in which the particular user
+actually uses, or expects or is expected to use, the product. A product
+is a consumer product regardless of whether the product has substantial
+commercial, industrial or non-consumer uses, unless such uses represent
+the only significant mode of use of the product.
+
+ "Installation Information" for a User Product means any methods,
+procedures, authorization keys, or other information required to install
+and execute modified versions of a covered work in that User Product from
+a modified version of its Corresponding Source. The information must
+suffice to ensure that the continued functioning of the modified object
+code is in no case prevented or interfered with solely because
+modification has been made.
+
+ If you convey an object code work under this section in, or with, or
+specifically for use in, a User Product, and the conveying occurs as
+part of a transaction in which the right of possession and use of the
+User Product is transferred to the recipient in perpetuity or for a
+fixed term (regardless of how the transaction is characterized), the
+Corresponding Source conveyed under this section must be accompanied
+by the Installation Information. But this requirement does not apply
+if neither you nor any third party retains the ability to install
+modified object code on the User Product (for example, the work has
+been installed in ROM).
+
+ The requirement to provide Installation Information does not include a
+requirement to continue to provide support service, warranty, or updates
+for a work that has been modified or installed by the recipient, or for
+the User Product in which it has been modified or installed. Access to a
+network may be denied when the modification itself materially and
+adversely affects the operation of the network or violates the rules and
+protocols for communication across the network.
+
+ Corresponding Source conveyed, and Installation Information provided,
+in accord with this section must be in a format that is publicly
+documented (and with an implementation available to the public in
+source code form), and must require no special password or key for
+unpacking, reading or copying.
+
+ 7. Additional Terms.
+
+ "Additional permissions" are terms that supplement the terms of this
+License by making exceptions from one or more of its conditions.
+Additional permissions that are applicable to the entire Program shall
+be treated as though they were included in this License, to the extent
+that they are valid under applicable law. If additional permissions
+apply only to part of the Program, that part may be used separately
+under those permissions, but the entire Program remains governed by
+this License without regard to the additional permissions.
+
+ When you convey a copy of a covered work, you may at your option
+remove any additional permissions from that copy, or from any part of
+it. (Additional permissions may be written to require their own
+removal in certain cases when you modify the work.) You may place
+additional permissions on material, added by you to a covered work,
+for which you have or can give appropriate copyright permission.
+
+ Notwithstanding any other provision of this License, for material you
+add to a covered work, you may (if authorized by the copyright holders of
+that material) supplement the terms of this License with terms:
+
+ a) Disclaiming warranty or limiting liability differently from the
+ terms of sections 15 and 16 of this License; or
+
+ b) Requiring preservation of specified reasonable legal notices or
+ author attributions in that material or in the Appropriate Legal
+ Notices displayed by works containing it; or
+
+ c) Prohibiting misrepresentation of the origin of that material, or
+ requiring that modified versions of such material be marked in
+ reasonable ways as different from the original version; or
+
+ d) Limiting the use for publicity purposes of names of licensors or
+ authors of the material; or
+
+ e) Declining to grant rights under trademark law for use of some
+ trade names, trademarks, or service marks; or
+
+ f) Requiring indemnification of licensors and authors of that
+ material by anyone who conveys the material (or modified versions of
+ it) with contractual assumptions of liability to the recipient, for
+ any liability that these contractual assumptions directly impose on
+ those licensors and authors.
+
+ All other non-permissive additional terms are considered "further
+restrictions" within the meaning of section 10. If the Program as you
+received it, or any part of it, contains a notice stating that it is
+governed by this License along with a term that is a further
+restriction, you may remove that term. If a license document contains
+a further restriction but permits relicensing or conveying under this
+License, you may add to a covered work material governed by the terms
+of that license document, provided that the further restriction does
+not survive such relicensing or conveying.
+
+ If you add terms to a covered work in accord with this section, you
+must place, in the relevant source files, a statement of the
+additional terms that apply to those files, or a notice indicating
+where to find the applicable terms.
+
+ Additional terms, permissive or non-permissive, may be stated in the
+form of a separately written license, or stated as exceptions;
+the above requirements apply either way.
+
+ 8. Termination.
+
+ You may not propagate or modify a covered work except as expressly
+provided under this License. Any attempt otherwise to propagate or
+modify it is void, and will automatically terminate your rights under
+this License (including any patent licenses granted under the third
+paragraph of section 11).
+
+ However, if you cease all violation of this License, then your
+license from a particular copyright holder is reinstated (a)
+provisionally, unless and until the copyright holder explicitly and
+finally terminates your license, and (b) permanently, if the copyright
+holder fails to notify you of the violation by some reasonable means
+prior to 60 days after the cessation.
+
+ Moreover, your license from a particular copyright holder is
+reinstated permanently if the copyright holder notifies you of the
+violation by some reasonable means, this is the first time you have
+received notice of violation of this License (for any work) from that
+copyright holder, and you cure the violation prior to 30 days after
+your receipt of the notice.
+
+ Termination of your rights under this section does not terminate the
+licenses of parties who have received copies or rights from you under
+this License. If your rights have been terminated and not permanently
+reinstated, you do not qualify to receive new licenses for the same
+material under section 10.
+
+ 9. Acceptance Not Required for Having Copies.
+
+ You are not required to accept this License in order to receive or
+run a copy of the Program. Ancillary propagation of a covered work
+occurring solely as a consequence of using peer-to-peer transmission
+to receive a copy likewise does not require acceptance. However,
+nothing other than this License grants you permission to propagate or
+modify any covered work. These actions infringe copyright if you do
+not accept this License. Therefore, by modifying or propagating a
+covered work, you indicate your acceptance of this License to do so.
+
+ 10. Automatic Licensing of Downstream Recipients.
+
+ Each time you convey a covered work, the recipient automatically
+receives a license from the original licensors, to run, modify and
+propagate that work, subject to this License. You are not responsible
+for enforcing compliance by third parties with this License.
+
+ An "entity transaction" is a transaction transferring control of an
+organization, or substantially all assets of one, or subdividing an
+organization, or merging organizations. If propagation of a covered
+work results from an entity transaction, each party to that
+transaction who receives a copy of the work also receives whatever
+licenses to the work the party's predecessor in interest had or could
+give under the previous paragraph, plus a right to possession of the
+Corresponding Source of the work from the predecessor in interest, if
+the predecessor has it or can get it with reasonable efforts.
+
+ You may not impose any further restrictions on the exercise of the
+rights granted or affirmed under this License. For example, you may
+not impose a license fee, royalty, or other charge for exercise of
+rights granted under this License, and you may not initiate litigation
+(including a cross-claim or counterclaim in a lawsuit) alleging that
+any patent claim is infringed by making, using, selling, offering for
+sale, or importing the Program or any portion of it.
+
+ 11. Patents.
+
+ A "contributor" is a copyright holder who authorizes use under this
+License of the Program or a work on which the Program is based. The
+work thus licensed is called the contributor's "contributor version".
+
+ A contributor's "essential patent claims" are all patent claims
+owned or controlled by the contributor, whether already acquired or
+hereafter acquired, that would be infringed by some manner, permitted
+by this License, of making, using, or selling its contributor version,
+but do not include claims that would be infringed only as a
+consequence of further modification of the contributor version. For
+purposes of this definition, "control" includes the right to grant
+patent sublicenses in a manner consistent with the requirements of
+this License.
+
+ Each contributor grants you a non-exclusive, worldwide, royalty-free
+patent license under the contributor's essential patent claims, to
+make, use, sell, offer for sale, import and otherwise run, modify and
+propagate the contents of its contributor version.
+
+ In the following three paragraphs, a "patent license" is any express
+agreement or commitment, however denominated, not to enforce a patent
+(such as an express permission to practice a patent or covenant not to
+sue for patent infringement). To "grant" such a patent license to a
+party means to make such an agreement or commitment not to enforce a
+patent against the party.
+
+ If you convey a covered work, knowingly relying on a patent license,
+and the Corresponding Source of the work is not available for anyone
+to copy, free of charge and under the terms of this License, through a
+publicly available network server or other readily accessible means,
+then you must either (1) cause the Corresponding Source to be so
+available, or (2) arrange to deprive yourself of the benefit of the
+patent license for this particular work, or (3) arrange, in a manner
+consistent with the requirements of this License, to extend the patent
+license to downstream recipients. "Knowingly relying" means you have
+actual knowledge that, but for the patent license, your conveying the
+covered work in a country, or your recipient's use of the covered work
+in a country, would infringe one or more identifiable patents in that
+country that you have reason to believe are valid.
+
+ If, pursuant to or in connection with a single transaction or
+arrangement, you convey, or propagate by procuring conveyance of, a
+covered work, and grant a patent license to some of the parties
+receiving the covered work authorizing them to use, propagate, modify
+or convey a specific copy of the covered work, then the patent license
+you grant is automatically extended to all recipients of the covered
+work and works based on it.
+
+ A patent license is "discriminatory" if it does not include within
+the scope of its coverage, prohibits the exercise of, or is
+conditioned on the non-exercise of one or more of the rights that are
+specifically granted under this License. You may not convey a covered
+work if you are a party to an arrangement with a third party that is
+in the business of distributing software, under which you make payment
+to the third party based on the extent of your activity of conveying
+the work, and under which the third party grants, to any of the
+parties who would receive the covered work from you, a discriminatory
+patent license (a) in connection with copies of the covered work
+conveyed by you (or copies made from those copies), or (b) primarily
+for and in connection with specific products or compilations that
+contain the covered work, unless you entered into that arrangement,
+or that patent license was granted, prior to 28 March 2007.
+
+ Nothing in this License shall be construed as excluding or limiting
+any implied license or other defenses to infringement that may
+otherwise be available to you under applicable patent law.
+
+ 12. No Surrender of Others' Freedom.
+
+ If conditions are imposed on you (whether by court order, agreement or
+otherwise) that contradict the conditions of this License, they do not
+excuse you from the conditions of this License. If you cannot convey a
+covered work so as to satisfy simultaneously your obligations under this
+License and any other pertinent obligations, then as a consequence you may
+not convey it at all. For example, if you agree to terms that obligate you
+to collect a royalty for further conveying from those to whom you convey
+the Program, the only way you could satisfy both those terms and this
+License would be to refrain entirely from conveying the Program.
+
+ 13. Use with the GNU Affero General Public License.
+
+ Notwithstanding any other provision of this License, you have
+permission to link or combine any covered work with a work licensed
+under version 3 of the GNU Affero General Public License into a single
+combined work, and to convey the resulting work. The terms of this
+License will continue to apply to the part which is the covered work,
+but the special requirements of the GNU Affero General Public License,
+section 13, concerning interaction through a network will apply to the
+combination as such.
+
+ 14. Revised Versions of this License.
+
+ The Free Software Foundation may publish revised and/or new versions of
+the GNU General Public License from time to time. Such new versions will
+be similar in spirit to the present version, but may differ in detail to
+address new problems or concerns.
+
+ Each version is given a distinguishing version number. If the
+Program specifies that a certain numbered version of the GNU General
+Public License "or any later version" applies to it, you have the
+option of following the terms and conditions either of that numbered
+version or of any later version published by the Free Software
+Foundation. If the Program does not specify a version number of the
+GNU General Public License, you may choose any version ever published
+by the Free Software Foundation.
+
+ If the Program specifies that a proxy can decide which future
+versions of the GNU General Public License can be used, that proxy's
+public statement of acceptance of a version permanently authorizes you
+to choose that version for the Program.
+
+ Later license versions may give you additional or different
+permissions. However, no additional obligations are imposed on any
+author or copyright holder as a result of your choosing to follow a
+later version.
+
+ 15. Disclaimer of Warranty.
+
+ THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
+APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
+HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
+OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
+THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
+PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
+IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
+ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
+
+ 16. Limitation of Liability.
+
+ IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
+WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
+THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
+GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
+USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
+DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
+PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
+EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
+SUCH DAMAGES.
+
+ 17. Interpretation of Sections 15 and 16.
+
+ If the disclaimer of warranty and limitation of liability provided
+above cannot be given local legal effect according to their terms,
+reviewing courts shall apply local law that most closely approximates
+an absolute waiver of all civil liability in connection with the
+Program, unless a warranty or assumption of liability accompanies a
+copy of the Program in return for a fee.
+
+ END OF TERMS AND CONDITIONS
+
+ How to Apply These Terms to Your New Programs
+
+ If you develop a new program, and you want it to be of the greatest
+possible use to the public, the best way to achieve this is to make it
+free software which everyone can redistribute and change under these terms.
+
+ To do so, attach the following notices to the program. It is safest
+to attach them to the start of each source file to most effectively
+state the exclusion of warranty; and each file should have at least
+the "copyright" line and a pointer to where the full notice is found.
+
+
+ Copyright (C)
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with this program. If not, see .
+
+Also add information on how to contact you by electronic and paper mail.
+
+ If the program does terminal interaction, make it output a short
+notice like this when it starts in an interactive mode:
+
+ Copyright (C)
+ This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
+ This is free software, and you are welcome to redistribute it
+ under certain conditions; type `show c' for details.
+
+The hypothetical commands `show w' and `show c' should show the appropriate
+parts of the General Public License. Of course, your program's commands
+might be different; for a GUI interface, you would use an "about box".
+
+ You should also get your employer (if you work as a programmer) or school,
+if any, to sign a "copyright disclaimer" for the program, if necessary.
+For more information on this, and how to apply and follow the GNU GPL, see
+.
+
+ The GNU General Public License does not permit incorporating your program
+into proprietary programs. If your program is a subroutine library, you
+may consider it more useful to permit linking proprietary applications with
+the library. If this is what you want to do, use the GNU Lesser General
+Public License instead of this License. But first, please read
+.
+
diff --git a/README.md b/README.md
index 451192c7..28b1f958 100644
--- a/README.md
+++ b/README.md
@@ -192,5 +192,5 @@ bash scripts/verify_linux.sh emulator-5554
## 许可证
-主项目许可证尚未单独声明;公开仓库不等同于授予再分发或商业使用许可。第三方
-组件的许可证保留在各自目录中,详见 [docs/LICENSING.md](docs/LICENSING.md)。
+openIME 以 [GPL-3.0-only](LICENSE) 发布。第三方组件的许可证保留在各自目录中,
+详见 [docs/LICENSING.md](docs/LICENSING.md) 和 [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md)。
diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md
index 09dba908..7311c725 100644
--- a/THIRD_PARTY_NOTICES.md
+++ b/THIRD_PARTY_NOTICES.md
@@ -1,6 +1,6 @@
# 第三方资源与分发核对
-本文件记录 openIME 实际随源码或 APK 分发的主要第三方代码、数据和模型。主项目许可证仍由项目所有者决定,本文件不为 openIME 本身授予许可证。
+本文件记录 openIME 实际随源码或 APK 分发的主要第三方代码、数据和模型。openIME 本身的许可证见仓库根目录 `LICENSE`(GPL-3.0-only),本文件只记录第三方组件。
| 组件 | 仓库内位置 | 上游/来源 | 许可证 | 分发核对 |
|---|---|---|---|---|
@@ -19,7 +19,7 @@
- `app/src/main/assets/licenses/` 下的第三方许可文本必须继续随 APK 打包,包括 Rime Ice、librime、OpenCC、Snappy、sherpa-onnx、Paraformer 模型和 Fluent Emoji。
- 语音 runtime 与模型升级时,重新核对**具体版本/模型**的许可证,不要只沿用本文件旧结论。
- 内置词库来源或固定提交变化时,同步更新本文件、`docs/LICENSING.md` 和 APK 内许可证文件。
-- 主项目 `LICENSE` 在项目所有者决定前保持缺失;README 对主项目许可状态的现有表述保持不变。
+- 主项目 `LICENSE`(GPL-3.0-only)与 README、`docs/LICENSING.md` 保持一致;更换主项目许可证前先核对 Rime Ice 词典的 GPL-3.0-only 义务。
## 备注
diff --git a/app/src/androidTest/java/llc/slacker/openime/CompatibilityApiInstrumentedTest.kt b/app/src/androidTest/java/llc/slacker/openime/CompatibilityApiInstrumentedTest.kt
index a839b0ee..fc049b15 100644
--- a/app/src/androidTest/java/llc/slacker/openime/CompatibilityApiInstrumentedTest.kt
+++ b/app/src/androidTest/java/llc/slacker/openime/CompatibilityApiInstrumentedTest.kt
@@ -5,6 +5,7 @@ import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context
import android.content.pm.PackageManager
+import android.os.Build
import android.os.PersistableBundle
import android.view.View
import android.view.inputmethod.BaseInputConnection
@@ -22,6 +23,7 @@ class CompatibilityApiInstrumentedTest {
@Test
fun pPlusBackspaceUsesCodePointDeletionForUnicodeSafety() {
+ assumeTrue("code-point deletion needs API 28+; older levels use UTF-16 deletion", Build.VERSION.SDK_INT >= Build.VERSION_CODES.P)
val context = InstrumentationRegistry.getInstrumentation().targetContext
val connection = RecordingInputConnection(View(context))
val gateway = InputConnectionGateway(context, { connection })
@@ -34,6 +36,7 @@ class CompatibilityApiInstrumentedTest {
@Test
fun pPlusForwardDeleteUsesCodePointDeletionForUnicodeSafety() {
+ assumeTrue("code-point deletion needs API 28+; older levels use UTF-16 deletion", Build.VERSION.SDK_INT >= Build.VERSION_CODES.P)
val context = InstrumentationRegistry.getInstrumentation().targetContext
val connection = RecordingInputConnection(View(context))
val gateway = InputConnectionGateway(context, { connection })
@@ -48,7 +51,11 @@ class CompatibilityApiInstrumentedTest {
fun sensitiveClipboardIsNeverCapturedIntoPersistentHistory() {
val context = InstrumentationRegistry.getInstrumentation().targetContext
ClipboardHistoryRepository.clearAll(context)
- val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
+ // Before API 28 a ClipboardManager can only be created on a thread with a Looper.
+ lateinit var clipboard: ClipboardManager
+ InstrumentationRegistry.getInstrumentation().runOnMainSync {
+ clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
+ }
val clip = ClipData.newPlainText("secret", "compat-secret")
clip.description.extras = PersistableBundle().apply {
putBoolean(ClipboardSensitivityPolicy.SENSITIVE_KEY, true)
diff --git a/app/src/androidTest/java/llc/slacker/openime/DisplayEnvironmentInstrumentedTest.kt b/app/src/androidTest/java/llc/slacker/openime/DisplayEnvironmentInstrumentedTest.kt
new file mode 100644
index 00000000..2c5d59c9
--- /dev/null
+++ b/app/src/androidTest/java/llc/slacker/openime/DisplayEnvironmentInstrumentedTest.kt
@@ -0,0 +1,155 @@
+package llc.slacker.openime
+
+import android.content.Context
+import android.content.res.Configuration
+import android.view.View
+import android.view.ViewGroup
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import java.lang.reflect.Proxy
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNotNull
+import org.junit.Assert.assertTrue
+import org.junit.Test
+import org.junit.runner.RunWith
+
+/**
+ * The keyboard has to stay usable in every display environment, not just the
+ * default portrait phone: landscape (content box narrower than the window),
+ * large system fonts, and every layout.
+ */
+@RunWith(AndroidJUnit4::class)
+class DisplayEnvironmentInstrumentedTest {
+
+ private val listener: ImeKeyboardView.Listener = Proxy.newProxyInstance(
+ ImeKeyboardView.Listener::class.java.classLoader,
+ arrayOf(ImeKeyboardView.Listener::class.java),
+ ) { _, method, _ ->
+ when (method.returnType) {
+ java.lang.Boolean.TYPE -> false
+ java.lang.Integer.TYPE -> 0
+ else -> null
+ }
+ } as ImeKeyboardView.Listener
+
+ private fun configured(base: Context, change: Configuration.() -> Unit): Context =
+ base.createConfigurationContext(Configuration(base.resources.configuration).apply(change))
+
+ 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)))
+ }
+ }
+
+ /** Show a keyboard built from [contextFor] and hand it to [check] once the first layout is done. */
+ private fun withKeyboard(
+ mode: KeyboardMode,
+ contextFor: (Context) -> Context,
+ check: (ImeKeyboardView) -> Unit,
+ ) {
+ DirectActivityHarness(DebugKeyboardActivity::class.java).use { harness ->
+ harness.launch()
+ val keyboard = harness.awaitMain { activity ->
+ ImeKeyboardView(contextFor(activity), listener).also {
+ activity.findViewById(android.R.id.content).addView(
+ it,
+ ViewGroup.LayoutParams(
+ ViewGroup.LayoutParams.MATCH_PARENT,
+ ViewGroup.LayoutParams.WRAP_CONTENT,
+ ),
+ )
+ }
+ }
+ try {
+ harness.awaitMain {
+ keyboard.setMode(mode, notifyListener = false)
+ true
+ }
+ // First layout done: some visible key has a size.
+ harness.awaitMain {
+ val shown = allViews(keyboard).filterIsInstance().filter { it.isShown }.toList()
+ if (keyboard.width > 0 && shown.isNotEmpty() && shown.all { it.width > 0 }) true else null
+ }
+ // Geometry corrections are applied around the first layout; give
+ // any deferred relayout time to run before looking at sizes.
+ Thread.sleep(600)
+ harness.awaitMain {
+ check(keyboard)
+ true
+ }
+ } finally {
+ harness.awaitMain {
+ keyboard.shutdown()
+ (keyboard.parent as? ViewGroup)?.removeView(keyboard)
+ true
+ }
+ }
+ }
+ }
+
+ @Test
+ fun landscapeContentBoxKeepsEveryRowInsideIt() {
+ // A landscape configuration makes the reference scale follow the height,
+ // so the content box (padding on both sides) is narrower than the window.
+ // Rows used to keep the width they were measured with before that padding
+ // was applied: as wide as the window, clipped to the box.
+ for (mode in listOf(KeyboardMode.PINYIN_26, KeyboardMode.ENGLISH_26, KeyboardMode.PINYIN_9, KeyboardMode.DIGITS)) {
+ withKeyboard(
+ mode = mode,
+ contextFor = { base ->
+ configured(base) {
+ orientation = Configuration.ORIENTATION_LANDSCAPE
+ screenWidthDp = 914
+ screenHeightDp = 411
+ }
+ },
+ ) { keyboard ->
+ val body = keyboard.findViewWithTag("keyboard-body")
+ assertNotNull("keyboard body", body)
+ val content = body.width - body.paddingLeft - body.paddingRight
+ assertTrue("$mode must be laid out in a clamped content box (padding ${body.paddingLeft})", body.paddingLeft > 0)
+ for (index in 0 until body.childCount) {
+ val row = body.getChildAt(index)
+ assertTrue(
+ "$mode row $index is ${row.width}px wide inside a ${content}px content box",
+ row.width <= content + 1,
+ )
+ }
+ val toolbar = keyboard.findViewWithTag("toolbar-row")
+ if (toolbar != null) {
+ val toolbarContent = toolbar.width - toolbar.paddingLeft - toolbar.paddingRight
+ var used = 0
+ for (index in 0 until toolbar.childCount) {
+ val child = toolbar.getChildAt(index)
+ if (child.visibility == View.VISIBLE) used += child.width
+ }
+ assertTrue(
+ "$mode toolbar items need ${used}px but the content box is ${toolbarContent}px",
+ used <= toolbarContent + 1,
+ )
+ }
+ }
+ }
+ }
+
+ @Test
+ fun keyLabelsStayReadableAtEverySystemFontSize() {
+ // Key labels follow the system font only up to 1.3x; beyond that a key
+ // cannot grow, and "m" turned into an ellipsis while "中/英" lost a glyph.
+ for (fontScale in listOf(1.0f, 1.3f, 1.5f, 2.0f)) {
+ for (mode in listOf(KeyboardMode.PINYIN_26, KeyboardMode.ENGLISH_26, KeyboardMode.PINYIN_9, KeyboardMode.DIGITS)) {
+ withKeyboard(
+ mode = mode,
+ contextFor = { base -> configured(base) { this.fontScale = fontScale } },
+ ) { keyboard ->
+ val clipped = allViews(keyboard)
+ .filterIsInstance()
+ .filter { !it.mainLabelFits() }
+ .map { "'${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/VoiceMediaMuteRecoveryInstrumentedTest.kt b/app/src/androidTest/java/llc/slacker/openime/VoiceMediaMuteRecoveryInstrumentedTest.kt
new file mode 100644
index 00000000..d3272c39
--- /dev/null
+++ b/app/src/androidTest/java/llc/slacker/openime/VoiceMediaMuteRecoveryInstrumentedTest.kt
@@ -0,0 +1,45 @@
+package llc.slacker.openime
+
+import android.content.Context
+import android.media.AudioManager
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.platform.app.InstrumentationRegistry
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@RunWith(AndroidJUnit4::class)
+class VoiceMediaMuteRecoveryInstrumentedTest {
+
+ @Test
+ fun mediaVolumeComesBackAfterTheKeyboardProcessDiesWhileRecording() {
+ val context = InstrumentationRegistry.getInstrumentation().targetContext
+ val audio = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager
+ val stream = AudioManager.STREAM_MUSIC
+ val originalVolume = audio.getStreamVolume(stream)
+ val originalMuted = audio.isStreamMute(stream)
+ try {
+ if (originalMuted) audio.adjustStreamVolume(stream, AudioManager.ADJUST_UNMUTE, 0)
+ val volume = audio.getStreamMaxVolume(stream).coerceAtMost(6).coerceAtLeast(2)
+ audio.setStreamVolume(stream, volume, 0)
+
+ assertTrue(VoiceMediaMuteController(context).mute())
+ assertTrue("media must be silent while recording", audio.isStreamMute(stream) || audio.getStreamVolume(stream) == 0)
+
+ // The process dies here: the controller above is never asked to restore().
+ VoiceMediaMuteController.recoverAfterCrash(context)
+
+ assertFalse("media must not stay muted after an unclean exit", audio.isStreamMute(stream))
+ assertEquals(volume, audio.getStreamVolume(stream))
+ // The recovery is one-shot: a second start must not touch the volume again.
+ audio.setStreamVolume(stream, 1, 0)
+ VoiceMediaMuteController.recoverAfterCrash(context)
+ assertEquals(1, audio.getStreamVolume(stream))
+ } finally {
+ audio.setStreamVolume(stream, originalVolume, 0)
+ if (originalMuted) audio.adjustStreamVolume(stream, AudioManager.ADJUST_MUTE, 0)
+ }
+ }
+}
diff --git a/app/src/main/java/llc/slacker/openime/AboutDataActivity.kt b/app/src/main/java/llc/slacker/openime/AboutDataActivity.kt
index d0719b57..df19b8f7 100644
--- a/app/src/main/java/llc/slacker/openime/AboutDataActivity.kt
+++ b/app/src/main/java/llc/slacker/openime/AboutDataActivity.kt
@@ -20,6 +20,7 @@ class AboutDataActivity : Activity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
+ CrashGuard.install(this)
val content = LinearLayout(this).apply {
orientation = LinearLayout.VERTICAL
@@ -89,6 +90,46 @@ class AboutDataActivity : Activity() {
),
wrap().apply { topMargin = dp(ImeSpacingTokens.LG_DP) },
)
+ val safeMode = CrashGuard.isSafeMode(this@AboutDataActivity)
+ val lastReport = CrashGuard.lastReport(this@AboutDataActivity)
+ val diagnostics = infoCard(
+ title = "诊断",
+ body = when {
+ safeMode -> "输入法刚才多次异常退出,已临时关闭原生词库和语音预加载;约 10 分钟后自动恢复,也可以现在退出。"
+ lastReport != null -> "最近一次异常:" + lastReport.lineSequence().first().substringAfter("| ").substringBefore(" | thread")
+ else -> "没有异常记录。"
+ } + "\n诊断信息只含异常类型和代码位置,不含任何输入内容;只有你点“复制”才会离开这里。",
+ )
+ addView(diagnostics, wrap().apply { topMargin = dp(ImeSpacingTokens.LG_DP) })
+ if (lastReport != null || safeMode) {
+ val row = LinearLayout(this@AboutDataActivity).apply {
+ orientation = LinearLayout.HORIZONTAL
+ if (lastReport != null) {
+ addView(
+ SetupUi.secondaryButton(this@AboutDataActivity, "复制诊断信息") {
+ val clipboard = getSystemService(android.content.ClipboardManager::class.java)
+ clipboard.setPrimaryClip(android.content.ClipData.newPlainText("openIME diagnostics", lastReport))
+ Toast.makeText(this@AboutDataActivity, "已复制", Toast.LENGTH_SHORT).show()
+ },
+ LinearLayout.LayoutParams(0, dp(44), 1f).apply { marginEnd = dp(ImeSpacingTokens.SM_DP) },
+ )
+ }
+ if (safeMode) {
+ addView(
+ SetupUi.primaryButton(this@AboutDataActivity, "退出安全模式") {
+ CrashGuard.clearHistory(this@AboutDataActivity)
+ Toast.makeText(this@AboutDataActivity, "下次打开键盘时恢复完整功能", Toast.LENGTH_SHORT).show()
+ recreate()
+ },
+ LinearLayout.LayoutParams(0, dp(44), 1f),
+ )
+ }
+ }
+ diagnostics.addView(
+ row,
+ LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, dp(44)).apply { topMargin = dp(12); marginStart = dp(44) },
+ )
+ }
addView(TextView(this@AboutDataActivity).apply {
text = "openIME · 版本 " + versionName(); textSize = ImeTypographyTokens.SMALL_SP; gravity = android.view.Gravity.CENTER
setTextColor(getColor(R.color.setup_body))
diff --git a/app/src/main/java/llc/slacker/openime/CrashGuard.kt b/app/src/main/java/llc/slacker/openime/CrashGuard.kt
new file mode 100644
index 00000000..27a75d09
--- /dev/null
+++ b/app/src/main/java/llc/slacker/openime/CrashGuard.kt
@@ -0,0 +1,233 @@
+package llc.slacker.openime
+
+import android.content.Context
+import android.os.Build
+import android.util.Log
+import java.io.File
+
+/**
+ * Crash resilience for a keyboard that has to keep working inside other apps.
+ *
+ * An input method that dies is replaced by the system with another keyboard, so
+ * the goals are: remember what happened (locally; the app has no INTERNET
+ * permission), never turn one bad key press into a crash, and stop a crash loop
+ * from locking the user out of typing.
+ *
+ * What is stored never contains typed text: only the time, the app version, the
+ * exception types and stack frames.
+ */
+internal object CrashGuard {
+ private const val TAG = "OpenImeCrash"
+ private const val PREFS = "openime_crash_guard"
+ private const val KEY_TIMES = "crash_times"
+ private const val KEY_LAST = "last_report"
+ private const val KEY_HANDLED = "handled_count"
+ private const val KEY_EXIT_SEEN = "exit_seen"
+ private const val MAX_TIMES = 10
+ private const val MAX_FRAMES = 14
+
+ /** This many crashes within [LOOP_WINDOW_MS] switch the next start to safe mode. */
+ internal const val LOOP_CRASHES = 3
+ internal const val LOOP_WINDOW_MS = 10 * 60 * 1000L
+
+ @Volatile
+ private var installed = false
+
+ /** Idempotent; chains to whatever handler was installed before. */
+ fun install(context: Context) {
+ if (installed) return
+ installed = true
+ val app = context.applicationContext
+ val previous = Thread.getDefaultUncaughtExceptionHandler()
+ Thread.setDefaultUncaughtExceptionHandler { thread, throwable ->
+ runCatching { recordCrash(app, thread.name, throwable) }
+ previous?.uncaughtException(thread, throwable)
+ }
+ }
+
+ internal fun recordCrash(context: Context, threadName: String, throwable: Throwable, now: Long = System.currentTimeMillis()) {
+ val prefs = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
+ val times = parseTimes(prefs.getString(KEY_TIMES, null)) + now
+ prefs.edit()
+ .putString(KEY_TIMES, formatTimes(times.takeLast(MAX_TIMES)))
+ .putString(KEY_LAST, report(context, "crash", threadName, throwable, now))
+ .commit() // the process is about to die: no async apply()
+ }
+
+ /** An exception that was caught and survived; kept for diagnostics, not counted as a crash. */
+ fun recordHandled(context: Context, where: String, throwable: Throwable, now: Long = System.currentTimeMillis()) {
+ runCatching {
+ val prefs = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
+ prefs.edit()
+ .putInt(KEY_HANDLED, prefs.getInt(KEY_HANDLED, 0) + 1)
+ .putString(KEY_LAST, report(context, "handled in $where", Thread.currentThread().name, throwable, now))
+ .apply()
+ }
+ }
+
+ /** A native startup that never came back counts as a crash, even though no Java handler ran. */
+ fun recordNativeStartupCrash(context: Context, now: Long = System.currentTimeMillis()) {
+ val prefs = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
+ val times = parseTimes(prefs.getString(KEY_TIMES, null)) + now
+ prefs.edit()
+ .putString(KEY_TIMES, formatTimes(times.takeLast(MAX_TIMES)))
+ .putString(KEY_LAST, "${header(context, "native crash while starting librime", "local-rime-startup", now)}\n(no Java stack: the process died inside native code)")
+ .commit()
+ }
+
+ /**
+ * Android 11+ remembers why earlier processes died. Native crashes (no Java
+ * handler ever runs) and ANRs (the keyboard froze) of this app are folded
+ * into the crash history here, once each, so a freeze loop also ends in safe mode.
+ */
+ fun ingestProcessExitReasons(context: Context) {
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) return
+ runCatching {
+ val manager = context.getSystemService(Context.ACTIVITY_SERVICE) as android.app.ActivityManager
+ val prefs = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
+ val seen = prefs.getLong(KEY_EXIT_SEEN, 0L)
+ val bad = manager.getHistoricalProcessExitReasons(context.packageName, 0, 8).filter {
+ it.timestamp > seen && it.reason in countedExitReasons
+ }
+ if (bad.isEmpty()) return
+ val times = parseTimes(prefs.getString(KEY_TIMES, null)) + bad.map { it.timestamp }
+ val newest = bad.maxBy { it.timestamp }
+ prefs.edit()
+ .putString(KEY_TIMES, formatTimes(times.sorted().takeLast(MAX_TIMES)))
+ .putLong(KEY_EXIT_SEEN, newest.timestamp)
+ .putString(KEY_LAST, "${header(context, "previous process ended: ${exitReasonName(newest.reason)}", "process", newest.timestamp)}\n(reported by Android; no stack)")
+ .commit()
+ }
+ }
+
+ /**
+ * Whether the process before this one ended in a native crash. null when
+ * Android cannot say (before Android 11). A marker left by a startup that was
+ * merely killed (force stop, update, low memory) must not count as a crash.
+ */
+ fun previousExitWasNativeCrash(context: Context): Boolean? {
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) return null
+ return runCatching {
+ val manager = context.getSystemService(Context.ACTIVITY_SERVICE) as android.app.ActivityManager
+ val latest = manager.getHistoricalProcessExitReasons(context.packageName, 0, 1).firstOrNull()
+ ?: return@runCatching null
+ latest.reason == android.app.ApplicationExitInfo.REASON_CRASH_NATIVE
+ }.getOrNull()
+ }
+
+ /** Leave safe mode now, e.g. from the About screen. */
+ fun clearHistory(context: Context) {
+ context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit().remove(KEY_TIMES).commit()
+ }
+
+ private val countedExitReasons: Set by lazy {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
+ setOf(
+ android.app.ApplicationExitInfo.REASON_CRASH,
+ android.app.ApplicationExitInfo.REASON_CRASH_NATIVE,
+ android.app.ApplicationExitInfo.REASON_ANR,
+ android.app.ApplicationExitInfo.REASON_INITIALIZATION_FAILURE,
+ )
+ } else {
+ emptySet()
+ }
+ }
+
+ @android.annotation.SuppressLint("NewApi")
+ private fun exitReasonName(reason: Int): String = when (reason) {
+ android.app.ApplicationExitInfo.REASON_CRASH -> "crash"
+ android.app.ApplicationExitInfo.REASON_CRASH_NATIVE -> "native crash"
+ android.app.ApplicationExitInfo.REASON_ANR -> "not responding (ANR)"
+ android.app.ApplicationExitInfo.REASON_INITIALIZATION_FAILURE -> "initialisation failure"
+ else -> "reason $reason"
+ }
+
+ fun isSafeMode(context: Context, now: Long = System.currentTimeMillis()): Boolean =
+ inCrashLoop(parseTimes(context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).getString(KEY_TIMES, null)), now)
+
+ fun lastReport(context: Context): String? =
+ context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).getString(KEY_LAST, null)
+
+ internal fun inCrashLoop(times: List, now: Long): Boolean =
+ times.count { now - it in 0..LOOP_WINDOW_MS } >= LOOP_CRASHES
+
+ internal fun parseTimes(raw: String?): List =
+ raw.orEmpty().split(',').mapNotNull { it.trim().toLongOrNull() }
+
+ internal fun formatTimes(times: List): String = times.joinToString(",")
+
+ private fun header(context: Context, kind: String, thread: String, now: Long): String {
+ val version = runCatching {
+ context.packageManager.getPackageInfo(context.packageName, 0).versionName
+ }.getOrNull().orEmpty()
+ return "openIME $version | $kind | thread=$thread | at=$now | android=${Build.VERSION.SDK_INT} | device=${Build.MANUFACTURER} ${Build.MODEL}"
+ }
+
+ /** Exception types and stack frames only. Messages are left out on purpose: they can quote user text. */
+ internal fun report(context: Context, kind: String, thread: String, throwable: Throwable, now: Long): String =
+ buildString {
+ append(header(context, kind, thread, now)).append('\n')
+ var current: Throwable? = throwable
+ var depth = 0
+ while (current != null && depth < 4) {
+ append(if (depth == 0) "" else "caused by ").append(current.javaClass.name).append('\n')
+ current.stackTrace.take(MAX_FRAMES).forEach { append(" at ").append(it).append('\n') }
+ current = current.cause?.takeIf { it !== current }
+ depth++
+ }
+ }
+
+ internal fun log(message: String, throwable: Throwable? = null) {
+ runCatching { Log.e(TAG, message, throwable) }
+ }
+}
+
+/**
+ * Librime lives in native code: a segmentation fault there cannot be caught and
+ * kills the whole keyboard process, and with a damaged compiled dictionary or
+ * user database it would do so on every start. A marker file is written before
+ * native startup and removed once the engine proves itself, so a marker that is
+ * still there at the next start means the last start died natively. Each such
+ * death escalates: clear the compiled data, then set the user database aside,
+ * then run without the native engine.
+ */
+internal class RimeStartupRecovery(private val stateDir: File) {
+ enum class Action { NORMAL, CLEAN_BUILD, RESET_USER_DATA, SKIP_NATIVE }
+
+ private val marker = File(stateDir, ".rime-startup-pending")
+ private val failuresFile = File(stateDir, ".rime-startup-failures")
+
+ /** Call before native startup. Returns what to do and arms the marker unless native is skipped. */
+ fun begin(): Action {
+ stateDir.mkdirs()
+ if (marker.exists()) writeFailures(failures() + 1)
+ val action = when (failures()) {
+ 0 -> Action.NORMAL
+ 1 -> Action.CLEAN_BUILD
+ 2 -> Action.RESET_USER_DATA
+ else -> Action.SKIP_NATIVE
+ }
+ if (action == Action.SKIP_NATIVE) marker.delete() else marker.writeText("pending\n")
+ return action
+ }
+
+ /** The engine started and passed its health probe. */
+ fun succeeded() {
+ marker.delete()
+ failuresFile.delete()
+ }
+
+ /** Startup ended with a Java exception: not a crash, so it is not counted. */
+ fun failedWithoutCrash() {
+ marker.delete()
+ }
+
+ /** True when the previous native startup never finished. */
+ fun lastStartupDiedNatively(): Boolean = marker.exists()
+
+ fun failures(): Int = runCatching { failuresFile.readText().trim().toInt() }.getOrDefault(0)
+
+ private fun writeFailures(value: Int) {
+ failuresFile.writeText("$value\n")
+ }
+}
diff --git a/app/src/main/java/llc/slacker/openime/EditorInfoAdapter.kt b/app/src/main/java/llc/slacker/openime/EditorInfoAdapter.kt
index 15d70de5..191749f4 100644
--- a/app/src/main/java/llc/slacker/openime/EditorInfoAdapter.kt
+++ b/app/src/main/java/llc/slacker/openime/EditorInfoAdapter.kt
@@ -15,6 +15,12 @@ object EditorInfoAdapter {
URL,
PASSWORD,
MULTILINE,
+
+ /** Date / time fields: digits and separators. */
+ DATETIME,
+
+ /** TYPE_NULL: terminals, games, remote desktops. They take key events, not text editing. */
+ RAW_KEYS,
UNKNOWN,
}
@@ -23,6 +29,8 @@ object EditorInfoAdapter {
val cls = t and InputType.TYPE_MASK_CLASS
val variation = t and InputType.TYPE_MASK_VARIATION
return when {
+ info != null && t == InputType.TYPE_NULL -> EditorKind.RAW_KEYS
+ cls == InputType.TYPE_CLASS_DATETIME -> EditorKind.DATETIME
cls == InputType.TYPE_CLASS_TEXT && variation in setOf(
InputType.TYPE_TEXT_VARIATION_PASSWORD,
InputType.TYPE_TEXT_VARIATION_VISIBLE_PASSWORD,
@@ -52,10 +60,12 @@ object EditorInfoAdapter {
EditorKind.NUMBER,
EditorKind.DECIMAL,
EditorKind.PHONE,
+ EditorKind.DATETIME,
-> KeyboardMode.DIGITS
EditorKind.EMAIL,
EditorKind.URL,
EditorKind.PASSWORD,
+ EditorKind.RAW_KEYS,
-> KeyboardMode.ENGLISH_26
else -> KeyboardMode.PINYIN_26
}
@@ -75,6 +85,8 @@ object EditorInfoAdapter {
EditorKind.NUMBER,
EditorKind.DECIMAL,
EditorKind.PHONE,
+ EditorKind.DATETIME,
+ EditorKind.RAW_KEYS,
EditorKind.UNKNOWN,
-> return false
EditorKind.TEXT,
diff --git a/app/src/main/java/llc/slacker/openime/HardwareKeyPolicy.kt b/app/src/main/java/llc/slacker/openime/HardwareKeyPolicy.kt
new file mode 100644
index 00000000..a019b810
--- /dev/null
+++ b/app/src/main/java/llc/slacker/openime/HardwareKeyPolicy.kt
@@ -0,0 +1,107 @@
+package llc.slacker.openime
+
+import android.view.KeyEvent
+
+/** What a physical key should do while the keyboard is in Chinese pinyin mode. */
+internal sealed interface HardwareKeyAction {
+ /** Leave the key to the app. */
+ data object PassThrough : HardwareKeyAction
+
+ /** The key is ours and does nothing (e.g. a digit that selects no candidate). */
+ data object Consume : HardwareKeyAction
+
+ /** Commit what is being composed, then let the key reach the app. */
+ data object FinishCompositionThenPassThrough : HardwareKeyAction
+
+ data class Letter(val char: Char) : HardwareKeyAction
+ data object Backspace : HardwareKeyAction
+ data object Space : HardwareKeyAction
+ data object Enter : HardwareKeyAction
+ data object Cancel : HardwareKeyAction
+ data object Apostrophe : HardwareKeyAction
+ data class SelectCandidate(val index: Int) : HardwareKeyAction
+
+ /** Full-width punctuation; the first candidate is committed first when a composition is open. */
+ data class Punctuation(val text: String, val commitFirstCandidate: Boolean) : HardwareKeyAction
+}
+
+internal data class HardwareKey(
+ val keyCode: Int,
+ /** The character the key produces with the current layout and modifiers (0 for none). */
+ val unicode: Int,
+ val shift: Boolean = false,
+ val ctrl: Boolean = false,
+ val alt: Boolean = false,
+ val meta: Boolean = false,
+ val capsLock: Boolean = false,
+ val repeat: Boolean = false,
+)
+
+internal data class HardwareContext(
+ /** The keyboard is in the Chinese 26-key mode and the editor accepts composing. */
+ val pinyinMode: Boolean,
+ val composing: Boolean,
+ val candidateCount: Int,
+ /** The character before the cursor, when known: "3.14" must keep its ASCII dot. */
+ val charBeforeCursor: Char?,
+)
+
+/**
+ * Physical-keyboard typing for Chinese: letters compose pinyin, space picks the
+ * first candidate, 1-9 pick a candidate, Enter keeps the typed pinyin, Esc
+ * cancels. Everything else, and every shortcut, belongs to the app. Pure, so
+ * it can be tested without a device.
+ */
+internal object HardwareKeyPolicy {
+ private val modifierKeys = setOf(
+ KeyEvent.KEYCODE_SHIFT_LEFT, KeyEvent.KEYCODE_SHIFT_RIGHT,
+ KeyEvent.KEYCODE_CTRL_LEFT, KeyEvent.KEYCODE_CTRL_RIGHT,
+ KeyEvent.KEYCODE_ALT_LEFT, KeyEvent.KEYCODE_ALT_RIGHT,
+ KeyEvent.KEYCODE_META_LEFT, KeyEvent.KEYCODE_META_RIGHT,
+ KeyEvent.KEYCODE_CAPS_LOCK, KeyEvent.KEYCODE_NUM_LOCK, KeyEvent.KEYCODE_FUNCTION,
+ )
+
+ private val fullWidth = mapOf(
+ ',' to ",", '.' to "。", '?' to "?", '!' to "!", ';' to ";", ':' to ":",
+ '(' to "(", ')' to ")",
+ )
+
+ /** These keep their ASCII form right after a digit (3.14, 12:30, 1,000). */
+ private val asciiAfterDigit = setOf(',', '.', ':')
+
+ fun decide(key: HardwareKey, context: HardwareContext): HardwareKeyAction {
+ if (!context.pinyinMode) return HardwareKeyAction.PassThrough
+ if (key.keyCode in modifierKeys) return HardwareKeyAction.PassThrough
+ if (key.ctrl || key.alt || key.meta) return HardwareKeyAction.PassThrough
+
+ val composing = context.composing
+ when (key.keyCode) {
+ KeyEvent.KEYCODE_DEL -> return if (composing) HardwareKeyAction.Backspace else HardwareKeyAction.PassThrough
+ KeyEvent.KEYCODE_SPACE ->
+ return if (!composing) HardwareKeyAction.PassThrough
+ else if (key.repeat) HardwareKeyAction.Consume else HardwareKeyAction.Space
+ KeyEvent.KEYCODE_ENTER, KeyEvent.KEYCODE_NUMPAD_ENTER ->
+ return if (!composing) HardwareKeyAction.PassThrough
+ else if (key.repeat) HardwareKeyAction.Consume else HardwareKeyAction.Enter
+ KeyEvent.KEYCODE_ESCAPE ->
+ return if (composing && !key.repeat) HardwareKeyAction.Cancel else HardwareKeyAction.PassThrough
+ }
+
+ val char = key.unicode.takeIf { it in 0x20..0x7e }?.toChar() ?: return HardwareKeyAction.PassThrough
+ return when {
+ char in 'a'..'z' && !key.shift && !key.capsLock -> HardwareKeyAction.Letter(char)
+ // A capital letter is English: close the composition, then type it.
+ char in 'A'..'Z' -> if (composing) HardwareKeyAction.FinishCompositionThenPassThrough else HardwareKeyAction.PassThrough
+ char in '1'..'9' && composing ->
+ if (char - '1' < context.candidateCount) HardwareKeyAction.SelectCandidate(char - '1') else HardwareKeyAction.Consume
+ char == '\'' && composing -> HardwareKeyAction.Apostrophe
+ char in fullWidth -> {
+ val numeric = char in asciiAfterDigit && context.charBeforeCursor?.let { it in '0'..'9' } == true && !composing
+ if (numeric) HardwareKeyAction.PassThrough
+ else HardwareKeyAction.Punctuation(fullWidth.getValue(char), commitFirstCandidate = composing)
+ }
+ composing -> HardwareKeyAction.FinishCompositionThenPassThrough
+ else -> HardwareKeyAction.PassThrough
+ }
+ }
+}
diff --git a/app/src/main/java/llc/slacker/openime/ImeKeyView.kt b/app/src/main/java/llc/slacker/openime/ImeKeyView.kt
index 1d01df88..ce7a4360 100644
--- a/app/src/main/java/llc/slacker/openime/ImeKeyView.kt
+++ b/app/src/main/java/llc/slacker/openime/ImeKeyView.kt
@@ -36,6 +36,16 @@ class ImeKeyView(
private var touchFeedbackPending = false
private var touchGeneration = 0L
private val baseMainTextSize = mainTextSize
+ private val fitMain = fitMainText
+
+ /**
+ * Key labels follow the system font size only up to [MAX_LABEL_FONT_SCALE].
+ * A key has a fixed width, so at 200% a plain "m" no longer fits and became
+ * "…", and "中/英" lost its last glyph. Everything else (candidates, panels,
+ * settings) still follows the system setting in full.
+ */
+ private fun labelPx(sp: Float): Float =
+ sp * density * minOf(resources.configuration.fontScale, MAX_LABEL_FONT_SCALE)
override fun dispatchTouchEvent(event: MotionEvent): Boolean {
when (event.actionMasked) {
@@ -126,7 +136,7 @@ class ImeKeyView(
}
}.apply {
this.text = text
- textSize = mainTextSize
+ setTextSize(TypedValue.COMPLEX_UNIT_PX, labelPx(mainTextSize))
gravity = Gravity.CENTER
isAllCaps = false
includeFontPadding = false
@@ -136,10 +146,10 @@ class ImeKeyView(
// scales. Shrink within a controlled range instead of
// replacing the action with an ellipsis such as “中/…”.
setAutoSizeTextTypeUniformWithConfiguration(
- (mainTextSize * 0.68f).toInt().coerceAtLeast(10),
- mainTextSize.toInt().coerceAtLeast(12),
+ labelPx((mainTextSize * 0.68f).coerceAtLeast(10f)).toInt(),
+ labelPx(mainTextSize.coerceAtLeast(12f)).toInt(),
1,
- TypedValue.COMPLEX_UNIT_SP,
+ TypedValue.COMPLEX_UNIT_PX,
)
ellipsize = null
} else {
@@ -155,7 +165,7 @@ class ImeKeyView(
secondaryTextView = secondary?.takeIf { it.isNotEmpty() }?.let { sub ->
TextView(context).apply {
this.text = sub
- textSize = ImeTypographyTokens.CAPTION_SP
+ setTextSize(TypedValue.COMPLEX_UNIT_PX, labelPx(ImeTypographyTokens.CAPTION_SP))
gravity = Gravity.CENTER
isAllCaps = false
includeFontPadding = false
@@ -239,11 +249,34 @@ class ImeKeyView(
/** Update key typography without rebuilding the keyboard hierarchy. */
fun applyMainTextScale(scale: Float) {
+ val sp = baseMainTextSize * scale.coerceAtLeast(0.4f)
mainTextView?.apply {
- setAutoSizeTextTypeWithDefaults(TextView.AUTO_SIZE_TEXT_TYPE_NONE)
- textSize = baseMainTextSize * scale.coerceAtLeast(0.4f)
+ if (fitMain) {
+ // Function labels ("中/英", "完成") must keep shrinking to fit their key.
+ setAutoSizeTextTypeUniformWithConfiguration(
+ labelPx((sp * 0.68f).coerceAtLeast(10f)).toInt(),
+ labelPx(sp.coerceAtLeast(12f)).toInt(),
+ 1,
+ TypedValue.COMPLEX_UNIT_PX,
+ )
+ } else {
+ setAutoSizeTextTypeWithDefaults(TextView.AUTO_SIZE_TEXT_TYPE_NONE)
+ setTextSize(TypedValue.COMPLEX_UNIT_PX, labelPx(sp))
+ }
}
- secondaryTextView?.textSize = ImeTypographyTokens.CAPTION_SP * (toPx(100) / (100f * density))
+ secondaryTextView?.setTextSize(
+ TypedValue.COMPLEX_UNIT_PX,
+ labelPx(ImeTypographyTokens.CAPTION_SP) * (toPx(100) / (100f * density)),
+ )
+ }
+
+ /** True when the main label is fully visible (no ellipsis, nothing clipped). Used by tests. */
+ internal fun mainLabelFits(): Boolean {
+ val view = mainTextView ?: return true
+ if (view.visibility != View.VISIBLE || view.width == 0) return true
+ val layout = view.layout ?: return false
+ val available = view.width - view.paddingLeft - view.paddingRight
+ return layout.getEllipsisCount(0) == 0 && layout.getLineWidth(0) <= available + 0.5f
}
/**
@@ -273,4 +306,8 @@ class ImeKeyView(
}
private fun dp(value: Int): Int = toPx(value)
+
+ private companion object {
+ const val MAX_LABEL_FONT_SCALE = 1.3f
+ }
}
diff --git a/app/src/main/java/llc/slacker/openime/ImeKeyboardView.kt b/app/src/main/java/llc/slacker/openime/ImeKeyboardView.kt
index cbed6a9b..d5d1713b 100644
--- a/app/src/main/java/llc/slacker/openime/ImeKeyboardView.kt
+++ b/app/src/main/java/llc/slacker/openime/ImeKeyboardView.kt
@@ -896,6 +896,19 @@ open class ImeKeyboardView(
super.onMeasure(widthMeasureSpec, heightMeasureSpec)
return
}
+ // The content box (padding, row widths) follows the width the parent
+ // offers. Apply it here, before the children are measured: doing it from
+ // onSizeChanged changes paddings of views laid out later in the same
+ // pass and the framework then keeps their stale measurements.
+ val offeredWidth = MeasureSpec.getSize(widthMeasureSpec)
+ if (MeasureSpec.getMode(widthMeasureSpec) != MeasureSpec.UNSPECIFIED && offeredWidth > 0) {
+ applyingGeometryInMeasure = true
+ try {
+ updateResponsiveGeometry(offeredWidth)
+ } finally {
+ applyingGeometryInMeasure = false
+ }
+ }
val desiredHeight = dp(imeHeightDp())
val mode = MeasureSpec.getMode(heightMeasureSpec)
val size = MeasureSpec.getSize(heightMeasureSpec)
@@ -990,14 +1003,9 @@ open class ImeKeyboardView(
if (panel == Panel.NONE) renderModeBody() else renderPanel(panel)
}
applyTheme()
- // This runs from onSizeChanged, i.e. in the middle of a layout pass.
- // Rows rebuilt there are added after their parent was measured and
- // would stay at 0x0 (a blank keyboard on first show) until something
- // else happened to request a layout. Ask for a fresh pass.
- post {
- requestLayout()
- invalidate()
- }
+ // Rows rebuilt here are added after their parent was measured and
+ // would stay at 0x0 (a blank keyboard on first show) otherwise.
+ if (!applyingGeometryInMeasure) scheduleRelayout()
}
(mainDock.layoutParams as? FrameLayout.LayoutParams)?.let { params ->
@@ -1035,7 +1043,10 @@ open class ImeKeyboardView(
expandedPanel.setPadding(contentInsetPx, 0, contentInsetPx, 0)
candidateOverlay.setPadding(contentInsetPx, 0, contentInsetPx, 0)
topZone.setContentInset(contentInsetPx)
- requestLayout()
+ if (!applyingGeometryInMeasure) {
+ requestLayout()
+ scheduleRelayout()
+ }
return
}
val minimumInset = dp(0)
@@ -1059,7 +1070,38 @@ open class ImeKeyboardView(
expandedPanel.setPadding(contentInsetPx, 0, contentInsetPx, 0)
candidateOverlay.setPadding(contentInsetPx, 0, contentInsetPx, 0)
topZone.setContentInset(contentInsetPx)
- requestLayout()
+ if (!applyingGeometryInMeasure) {
+ requestLayout()
+ scheduleRelayout()
+ }
+ }
+
+ private var relayoutPosted = false
+ private var applyingGeometryInMeasure = false
+
+ /**
+ * updateResponsiveGeometry runs from onSizeChanged, i.e. inside a layout
+ * pass, and changes paddings and row widths of views that are laid out later
+ * in that same pass. Their own requestLayout() calls are lost: each view
+ * clears its force-layout flag when it finishes laying out, so the framework
+ * sees no pending request and never re-measures them (the landscape keyboard
+ * kept rows as wide as the whole window inside a clamped content box).
+ * requestLayout() on the root is not enough either, it marks only the root
+ * and its ancestors. So once the pass is over, flag the containers that
+ * were changed.
+ */
+ private fun scheduleRelayout() {
+ if (relayoutPosted) return
+ relayoutPosted = true
+ post {
+ relayoutPosted = false
+ keyboardBody.requestLayout()
+ if (::topZone.isInitialized) topZone.requestLayout()
+ expandedPanel.requestLayout()
+ candidateOverlay.requestLayout()
+ requestLayout()
+ invalidate()
+ }
}
private fun rescaleTopZone(view: View, ratio: Float) {
@@ -2945,6 +2987,27 @@ open class ImeKeyboardView(
keyPopupController.hide()
}
+ // --- physical keyboard: the same entry points a tap on the soft key reaches ---
+
+ /** Pinyin typing from a physical keyboard needs the plain 26-key surface: no panel, no voice. */
+ internal fun hardwareAccepts(): Boolean =
+ mode == KeyboardMode.PINYIN_26 && panel == Panel.NONE && !standalonePanel &&
+ !voicePanelController.active && !voiceGestureSession
+
+ internal fun hardwareIsComposing(): Boolean = composition.text.isNotEmpty()
+ internal fun hardwareCandidateCount(): Int = currentCandidates.size
+ internal fun hardwareLetter(char: Char) = onKeyTapped(char.toString())
+ internal fun hardwareBackspace() = performBackspaceOnce()
+ internal fun hardwareSpace() = commitFirstCandidateOrSpace()
+ internal fun hardwareApostrophe() = onPinyinSegment()
+ internal fun hardwareCancelComposition() = publishComposition("", emptyList())
+
+ internal fun hardwareSelectCandidate(index: Int): Boolean {
+ val candidate = currentCandidates.getOrNull(index) ?: return false
+ listener.onCandidateSelected(candidate)
+ return true
+ }
+
/**
* The key preview is a permanent child that only toggles visibility, so
* tests cannot detect it by counting children.
diff --git a/app/src/main/java/llc/slacker/openime/InputConnectionGateway.kt b/app/src/main/java/llc/slacker/openime/InputConnectionGateway.kt
index a7aec094..e584e14f 100644
--- a/app/src/main/java/llc/slacker/openime/InputConnectionGateway.kt
+++ b/app/src/main/java/llc/slacker/openime/InputConnectionGateway.kt
@@ -43,6 +43,14 @@ class InputConnectionGateway(
private val connection: () -> InputConnection?,
private val isPassword: () -> Boolean = { false },
private val nowMs: () -> Long = { System.nanoTime() / 1_000_000L },
+ /**
+ * Editors with no text type (terminals, games, remote desktops: TYPE_NULL).
+ * Their InputConnection is usually Android's BaseInputConnection in dummy
+ * mode, which edits a private buffer: deleteSurroundingText "succeeds" and does
+ * nothing. They only understand key events, so typing and deleting use those.
+ */
+ private val isRawKeys: () -> Boolean = { false },
+ private val keyEventsFor: (String) -> Array? = ::keyEventsForText,
) {
data class CursorSnapshot(
@@ -96,7 +104,12 @@ class InputConnectionGateway(
fun commitText(text: String) {
if (text.isEmpty()) return
invalidateClearUndo()
- connection()?.commitText(text, 1)
+ val ic = connection() ?: return
+ if (isRawKeys() && typeAsKeyEvents(ic, text)) return
+ // One Binder transaction carries about 1 MB: committing a huge paste or
+ // transcript in one call throws TransactionTooLargeException and takes the
+ // keyboard down with it. Chunk it, never splitting a surrogate pair.
+ chunksForCommit(text).forEach { chunk -> ic.commitText(chunk, 1) }
}
/**
@@ -126,6 +139,12 @@ class InputConnectionGateway(
}
}
+ /** The character before the cursor, or null when unknown (and always in password fields). */
+ fun charBeforeCursor(): Char? {
+ if (isPassword()) return null
+ return runCatching { connection()?.getTextBeforeCursor(1, 0)?.lastOrNull() }.getOrNull()
+ }
+
fun finishComposing() {
connection()?.finishComposingText()
}
@@ -150,14 +169,30 @@ class InputConnectionGateway(
@Volatile
private var knownSelectionEnd: Int = -1
- fun updateSelection(start: Int, end: Int) {
+ /** True once the editor itself has reported a selection (onUpdateSelection), not just its start-up values. */
+ @Volatile
+ private var selectionReportedByEditor = false
+
+ fun updateSelection(start: Int, end: Int, reportedByEditor: Boolean = false) {
knownSelectionStart = start
knownSelectionEnd = end
+ selectionReportedByEditor = reportedByEditor
+ }
+
+ private fun typeAsKeyEvents(ic: InputConnection, text: String): Boolean {
+ if (text.length > RAW_KEY_TEXT_MAX) return false
+ val events = keyEventsFor(text) ?: return false
+ events.forEach { ic.sendKeyEvent(it) }
+ return true
}
fun deleteBackwards() {
invalidateClearUndo()
val ic = connection() ?: return
+ if (isRawKeys()) {
+ sendKeyDownUp(ic, KeyEvent.KEYCODE_DEL)
+ return
+ }
if (deleteSelection()) return
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
@@ -197,6 +232,10 @@ class InputConnectionGateway(
}
return false
}
+ // Every call below is a synchronous Binder round trip into the app; a slow
+ // or stuck app makes each one wait, and Backspace used to make three. When
+ // the editor has told us the cursor is collapsed there is no selection to ask about.
+ if (selectionReportedByEditor && knownSelectionStart >= 0 && knownSelectionStart == knownSelectionEnd) return false
val selected = runCatching { ic.getSelectedText(0)?.toString().orEmpty() }.getOrDefault("")
if (selected.isNotEmpty()) {
if (knownSelectionStart >= 0 && knownSelectionEnd >= 0) {
@@ -240,6 +279,10 @@ class InputConnectionGateway(
fun deleteForwards() {
invalidateClearUndo()
val ic = connection() ?: return
+ if (isRawKeys()) {
+ sendKeyDownUp(ic, KeyEvent.KEYCODE_FORWARD_DEL)
+ return
+ }
if (deleteSelection()) return
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
val deleted = runCatching { ic.deleteSurroundingTextInCodePoints(0, 1) }.getOrDefault(false)
@@ -812,3 +855,26 @@ class InputConnectionGateway(
const val MAX_SURROUNDING_ROUNDS = 8
}
}
+
+internal const val COMMIT_CHUNK_CHARS = 32_000
+
+/** Pieces of at most [COMMIT_CHUNK_CHARS] UTF-16 units that never end between a surrogate pair. */
+internal fun chunksForCommit(text: String): List {
+ if (text.length <= COMMIT_CHUNK_CHARS) return listOf(text)
+ val chunks = ArrayList(text.length / COMMIT_CHUNK_CHARS + 1)
+ var start = 0
+ while (start < text.length) {
+ var end = minOf(start + COMMIT_CHUNK_CHARS, text.length)
+ if (end < text.length && Character.isHighSurrogate(text[end - 1])) end--
+ chunks += text.substring(start, end)
+ start = end
+ }
+ return chunks
+}
+
+private const val RAW_KEY_TEXT_MAX = 64
+
+/** Real key events for [text] when the virtual keyboard can type all of it (ASCII), else null. */
+internal fun keyEventsForText(text: String): Array? =
+ runCatching { android.view.KeyCharacterMap.load(android.view.KeyCharacterMap.VIRTUAL_KEYBOARD)?.getEvents(text.toCharArray()) }
+ .getOrNull()
diff --git a/app/src/main/java/llc/slacker/openime/InputMethodSubtypePolicy.kt b/app/src/main/java/llc/slacker/openime/InputMethodSubtypePolicy.kt
index 91917932..52c9950e 100644
--- a/app/src/main/java/llc/slacker/openime/InputMethodSubtypePolicy.kt
+++ b/app/src/main/java/llc/slacker/openime/InputMethodSubtypePolicy.kt
@@ -23,11 +23,13 @@ internal object InputMethodSubtypePolicy {
EditorInfoAdapter.EditorKind.NUMBER,
EditorInfoAdapter.EditorKind.DECIMAL,
EditorInfoAdapter.EditorKind.PHONE,
+ EditorInfoAdapter.EditorKind.DATETIME,
-> KeyboardMode.DIGITS
EditorInfoAdapter.EditorKind.EMAIL,
EditorInfoAdapter.EditorKind.URL,
EditorInfoAdapter.EditorKind.PASSWORD,
+ EditorInfoAdapter.EditorKind.RAW_KEYS,
-> KeyboardMode.ENGLISH_26
else -> when (language(subtypeLocale)) {
diff --git a/app/src/main/java/llc/slacker/openime/LocalVoiceImeService.kt b/app/src/main/java/llc/slacker/openime/LocalVoiceImeService.kt
index b984ad29..664cbbf5 100644
--- a/app/src/main/java/llc/slacker/openime/LocalVoiceImeService.kt
+++ b/app/src/main/java/llc/slacker/openime/LocalVoiceImeService.kt
@@ -38,7 +38,15 @@ class LocalVoiceImeService : InputMethodService(), ImeKeyboardView.Listener, Can
private var keyboardView: ImeKeyboardView? = null
private lateinit var gateway: InputConnectionGateway
- private lateinit var candidatePipeline: CandidatePipeline
+ /**
+ * Starts as an empty pipeline and is replaced by the real one once the lexicon
+ * and the nine-key decoder are built on a background thread (about 0.3 s on a
+ * fast host, several times that on a mid-range phone). Building them in
+ * onCreate froze the main thread at every cold start; until the swap, Rime
+ * alone supplies candidates.
+ */
+ @Volatile
+ private var candidatePipeline: CandidatePipeline = CandidatePipeline(CandidateEngine(linkedMapOf()))
private lateinit var candidateQueries: CandidateQueryCoordinator
private lateinit var rime: RimeEngine
private lateinit var voiceLifecycle: VoiceModelLifecycleManager
@@ -81,18 +89,57 @@ class LocalVoiceImeService : InputMethodService(), ImeKeyboardView.Listener, Can
(applicationInfo.flags and android.content.pm.ApplicationInfo.FLAG_DEBUGGABLE) != 0
}
+ /**
+ * One bad key press must not take the keyboard down: the system would swap
+ * in another keyboard and the user loses their place. A failure in a typing
+ * handler is recorded (no typed text), the half-finished composition is
+ * dropped, and the keyboard carries on.
+ */
+ @Volatile
+ private var injectedFailureForTest = false
+
+ private inline fun guarded(name: String, block: () -> Unit) {
+ try {
+ if (injectedFailureForTest) {
+ injectedFailureForTest = false
+ throw IllegalStateException("injected failure for test")
+ }
+ block()
+ } catch (failure: Exception) {
+ recoverFromHandledFailure(name, failure)
+ } catch (failure: StackOverflowError) {
+ recoverFromHandledFailure(name, failure)
+ }
+ }
+
+ private fun recoverFromHandledFailure(name: String, failure: Throwable) {
+ CrashGuard.log("handled failure in $name", failure)
+ CrashGuard.recordHandled(this, name, failure)
+ runCatching {
+ clearImeCompositionState(render = true)
+ gateway.cancelComposing()
+ }
+ }
+
override fun onCreate() {
super.onCreate()
+ CrashGuard.install(this)
+ CrashGuard.ingestProcessExitReasons(this)
+ VoiceMediaMuteController.recoverAfterCrash(this)
activeInstance = this
UserPhraseRepository.configure(this)
VoiceCorrectionRepository.configure(this)
voiceLifecycle = VoiceModelLifecycleManager(this)
- candidatePipeline = CandidatePipeline(CandidateEngine(PinyinLexicon.load(this)))
+ Thread({
+ runCatching { CandidatePipeline(CandidateEngine(PinyinLexicon.load(this))) }
+ .onSuccess { candidatePipeline = it }
+ .onFailure { Log.e(TAG, "lexicon/decoder initialisation failed; running on Rime only", it) }
+ }, "openime-lexicon").apply { isDaemon = true; start() }
rime = RimeEngine(this).also { it.start() }
candidateQueries = CandidateQueryCoordinator(
rime = rime,
mainHandler = mainHandler,
- fallbackCandidatesFor = candidatePipeline::nineKeyFallbackCandidatesFor,
+ fallbackCandidatesFor = { candidatePipeline.nineKeyFallbackCandidatesFor(it) },
maxInputLength = MAX_RIME_INPUT_LENGTH,
maxNineKeyPaths = MAX_RIME_NINE_KEY_PATHS,
maxCandidates = MAX_CANDIDATES,
@@ -101,6 +148,7 @@ class LocalVoiceImeService : InputMethodService(), ImeKeyboardView.Listener, Can
context = this,
connection = { currentInputConnection },
isPassword = { state.passwordField },
+ isRawKeys = { EditorInfoAdapter.kind(state.editorInfo) == EditorInfoAdapter.EditorKind.RAW_KEYS },
)
state = ImeState(
theme = ImeSettingsRepository.loadTheme(this),
@@ -191,6 +239,15 @@ class LocalVoiceImeService : InputMethodService(), ImeKeyboardView.Listener, Can
return true
}
+ override fun onEvaluateFullscreenMode(): Boolean {
+ // The framework default turns the whole screen into the IME in landscape:
+ // the app's editor is hidden behind an unthemed copy of the field (the
+ // extract view) and the conversation, search results or form the user is
+ // typing into disappear. Every mainstream keyboard stays a bottom panel
+ // in every orientation and lets the app resize or pan, so do the same.
+ return false
+ }
+
private fun ensureInputViewAfterFinish() {
if (keyboardView != null) return
// InputMethodService keeps the old view instance after
@@ -365,7 +422,8 @@ class LocalVoiceImeService : InputMethodService(), ImeKeyboardView.Listener, Can
floatingWindow.restore()
}
keyboardView?.refreshAuxiliaryContent()
- voiceLifecycle.onStartInputView()
+ // Safe mode (repeated crashes or freezes): do not preload the voice model.
+ if (!CrashGuard.isSafeMode(this)) voiceLifecycle.onStartInputView()
}
/** Re-render panels whose data may have been edited in a full-screen Activity. */
@@ -406,8 +464,8 @@ class LocalVoiceImeService : InputMethodService(), ImeKeyboardView.Listener, Can
candidatesStart: Int,
candidatesEnd: Int,
) {
- gateway.updateSelection(newSelStart, newSelEnd)
- refreshTextEditControls()
+ gateway.updateSelection(newSelStart, newSelEnd, reportedByEditor = true)
+ guarded("onUpdateSelection") { refreshTextEditControls() }
super.onUpdateSelection(
oldSelStart,
oldSelEnd,
@@ -557,6 +615,10 @@ class LocalVoiceImeService : InputMethodService(), ImeKeyboardView.Listener, Can
)
text.isNotBlank() && QuickPhraseRepository.load(this).any { it.text == text }
}.getOrDefault(false)
+ command == "fail-next" -> {
+ injectedFailureForTest = true
+ true
+ }
command == "bounds" -> {
Log.i(TAG, "BOUNDS\n${keyboardView?.normalizedBoundsReport().orEmpty()}")
true
@@ -641,7 +703,7 @@ class LocalVoiceImeService : InputMethodService(), ImeKeyboardView.Listener, Can
)
}
- override fun onCharacter(char: String) {
+ override fun onCharacter(char: String) = guarded("onCharacter") {
prepareForManualInput()
voiceCorrectionTracker.noteReplacementInput()
commitPendingComposition()
@@ -649,7 +711,7 @@ class LocalVoiceImeService : InputMethodService(), ImeKeyboardView.Listener, Can
gateway.commitText(char)
}
- override fun onBackspace() {
+ override fun onBackspace() = guarded("onBackspace") {
prepareForManualInput()
voiceCorrectionTracker.noteBackspace()
if (keyboardView?.deleteInlineEditorChar() == true) return
@@ -677,7 +739,7 @@ class LocalVoiceImeService : InputMethodService(), ImeKeyboardView.Listener, Can
}
}
- override fun onClearAll() {
+ override fun onClearAll() = guarded("onClearAll") {
prepareForManualInput()
// Invalidate every pending candidate/Rime path before touching the
// editor. Otherwise a late native result can restore the just-cleared
@@ -741,7 +803,7 @@ class LocalVoiceImeService : InputMethodService(), ImeKeyboardView.Listener, Can
if (floatingWindow.enabled) floatingWindow.reapply()
}
- override fun onSpace() {
+ override fun onSpace() = guarded("onSpace") {
prepareForManualInput()
if (keyboardView?.insertIntoInlineEditor(" ") == true) return
if (state.passwordField) {
@@ -892,7 +954,7 @@ class LocalVoiceImeService : InputMethodService(), ImeKeyboardView.Listener, Can
)
}
- override fun onEnter() {
+ override fun onEnter() = guarded("onEnter") {
prepareForManualInput()
if (lastComposition.isNotEmpty()) {
// Space picks the first word; Enter ("确定") keeps what was typed,
@@ -910,7 +972,7 @@ class LocalVoiceImeService : InputMethodService(), ImeKeyboardView.Listener, Can
}
}
- override fun onCompositionChanged(composition: String, candidates: List) {
+ override fun onCompositionChanged(composition: String, candidates: List) = guarded("onCompositionChanged") {
prepareForManualInput()
if (composition.isNotEmpty()) voiceCorrectionTracker.noteReplacementInput()
handleCompositionChanged(
@@ -925,7 +987,7 @@ class LocalVoiceImeService : InputMethodService(), ImeKeyboardView.Listener, Can
digitBuffer: String,
pinyinPaths: List,
candidates: List,
- ) {
+ ) = guarded("onNineKeyCompositionChanged") {
prepareForManualInput()
if (composition.isNotEmpty()) voiceCorrectionTracker.noteReplacementInput()
if (state.keyboardMode != KeyboardMode.PINYIN_9 || digitBuffer.isEmpty()) {
@@ -944,7 +1006,14 @@ class LocalVoiceImeService : InputMethodService(), ImeKeyboardView.Listener, Can
candidates: List,
rimeInputs: List,
) {
- if (state.passwordField) {
+ val directCommit = state.passwordField || (
+ // Terminals and games need each letter as it is typed; composing English
+ // there shows nothing until the word ends. Pinyin still composes.
+ EditorInfoAdapter.kind(state.editorInfo) == EditorInfoAdapter.EditorKind.RAW_KEYS &&
+ state.keyboardMode != KeyboardMode.PINYIN_26 &&
+ state.keyboardMode != KeyboardMode.PINYIN_9
+ )
+ if (directCommit) {
// Password fields never receive composing text, so the view's
// buffer is the only holder of pending input and renderState()
// empties it on every report. The buffer therefore contains
@@ -985,7 +1054,7 @@ class LocalVoiceImeService : InputMethodService(), ImeKeyboardView.Listener, Can
keyboardView?.renderState(state)
}
- override fun onCandidateSelected(candidate: String) {
+ override fun onCandidateSelected(candidate: String) = guarded("onCandidateSelected") {
prepareForManualInput()
if (state.passwordField) return
selectCandidate(candidate)
@@ -1078,7 +1147,7 @@ class LocalVoiceImeService : InputMethodService(), ImeKeyboardView.Listener, Can
* directly and chain to the next association set so a user can keep
* tapping: 你好 -> 呀 -> !
*/
- override fun onAssociationSelected(text: String) {
+ override fun onAssociationSelected(text: String) = guarded("onAssociationSelected") {
prepareForManualInput()
if (state.passwordField || text.isEmpty()) return
commitPendingComposition()
@@ -1144,14 +1213,14 @@ class LocalVoiceImeService : InputMethodService(), ImeKeyboardView.Listener, Can
state = state.copy(panel = if (open) Panel.CANDIDATE_EXPANDED else Panel.NONE)
}
- override fun onSymbolSelected(symbol: String) {
+ override fun onSymbolSelected(symbol: String) = guarded("onSymbolSelected") {
prepareForManualInput()
commitPendingComposition()
keyboardView?.clearAssociationCandidates()
gateway.commitText(symbol)
}
- override fun onEmojiSelected(emoji: String) {
+ override fun onEmojiSelected(emoji: String) = guarded("onEmojiSelected") {
prepareForManualInput()
commitPendingComposition()
keyboardView?.clearAssociationCandidates()
@@ -1159,7 +1228,7 @@ class LocalVoiceImeService : InputMethodService(), ImeKeyboardView.Listener, Can
gateway.commitText(emoji)
}
- override fun onTextEdit(action: String) {
+ override fun onTextEdit(action: String) = guarded("onTextEdit") {
if (action in setOf("select-all", "cut", "paste", "left", "right")) prepareForManualInput()
when (action) {
"select-all" -> if (!gateway.selectAll()) {
@@ -1215,9 +1284,79 @@ class LocalVoiceImeService : InputMethodService(), ImeKeyboardView.Listener, Can
if (keyCode == KeyEvent.KEYCODE_BACK && keyboardView?.closePanelToKeyboard() == true) {
return true
}
+ if (event != null && handleHardwareKey(event)) {
+ hardwareConsumed += keyCode
+ return true
+ }
return super.onKeyDown(keyCode, event)
}
+ override fun onKeyUp(keyCode: Int, event: KeyEvent?): Boolean {
+ // The matching up of a key we consumed must not reach the app on its own.
+ if (hardwareConsumed.remove(keyCode)) return true
+ return super.onKeyUp(keyCode, event)
+ }
+
+ private val hardwareConsumed = HashSet()
+
+ /**
+ * Pinyin typing from a physical keyboard (tablets, foldables with a keyboard
+ * cover, Chromebooks, desktop mode, emulators). Returns true when the key was
+ * ours; anything else, and every shortcut, goes on to the app unchanged.
+ */
+ private fun handleHardwareKey(event: KeyEvent): Boolean {
+ val view = keyboardView ?: return false
+ if (event.flags and KeyEvent.FLAG_SOFT_KEYBOARD != 0) return false
+ val kind = EditorInfoAdapter.kind(state.editorInfo)
+ val pinyinMode = isInputViewShown && view.hardwareAccepts() && !state.passwordField &&
+ EditorInfoAdapter.allowCandidates(kind) && kind != EditorInfoAdapter.EditorKind.RAW_KEYS
+ if (!pinyinMode) return false
+ val composing = view.hardwareIsComposing()
+ val unicode = event.unicodeChar
+ val needsCharBefore = !composing && unicode in HARDWARE_ASCII_AFTER_DIGIT
+ val action = HardwareKeyPolicy.decide(
+ HardwareKey(
+ keyCode = event.keyCode,
+ unicode = unicode,
+ shift = event.isShiftPressed,
+ ctrl = event.isCtrlPressed,
+ alt = event.isAltPressed,
+ meta = event.isMetaPressed,
+ capsLock = event.isCapsLockOn,
+ repeat = event.repeatCount > 0,
+ ),
+ HardwareContext(
+ pinyinMode = true,
+ composing = composing,
+ candidateCount = view.hardwareCandidateCount(),
+ charBeforeCursor = if (needsCharBefore) gateway.charBeforeCursor() else null,
+ ),
+ )
+ var consumed = true
+ guarded("hardwareKey") {
+ when (action) {
+ HardwareKeyAction.PassThrough -> consumed = false
+ HardwareKeyAction.Consume -> Unit
+ HardwareKeyAction.FinishCompositionThenPassThrough -> {
+ commitPendingComposition()
+ consumed = false
+ }
+ is HardwareKeyAction.Letter -> view.hardwareLetter(action.char)
+ HardwareKeyAction.Backspace -> view.hardwareBackspace()
+ HardwareKeyAction.Space -> view.hardwareSpace()
+ HardwareKeyAction.Enter -> onEnter()
+ HardwareKeyAction.Cancel -> view.hardwareCancelComposition()
+ HardwareKeyAction.Apostrophe -> view.hardwareApostrophe()
+ is HardwareKeyAction.SelectCandidate -> consumed = view.hardwareSelectCandidate(action.index)
+ is HardwareKeyAction.Punctuation -> {
+ if (action.commitFirstCandidate) commitFirstCandidate()
+ onCharacter(action.text)
+ }
+ }
+ }
+ return consumed
+ }
+
private fun updateComposition(next: String, candidates: List) {
lastComposition = next
state = state.copy(composition = next, candidates = candidates)
@@ -1612,3 +1751,6 @@ class LocalVoiceImeService : InputMethodService(), ImeKeyboardView.Listener, Can
var activeInstance: LocalVoiceImeService? = null
}
}
+
+/** Punctuation that stays ASCII right after a digit (3.14, 12:30, 1,000). */
+private val HARDWARE_ASCII_AFTER_DIGIT = setOf(','.code, '.'.code, ':'.code)
diff --git a/app/src/main/java/llc/slacker/openime/MainActivity.kt b/app/src/main/java/llc/slacker/openime/MainActivity.kt
index bef88017..0509ffe1 100644
--- a/app/src/main/java/llc/slacker/openime/MainActivity.kt
+++ b/app/src/main/java/llc/slacker/openime/MainActivity.kt
@@ -38,6 +38,7 @@ class MainActivity : Activity() {
override fun onCreate(savedInstanceState: Bundle?) {
appliedAppearance = ImeSettingsRepository.loadAppearance(this)
super.onCreate(savedInstanceState)
+ CrashGuard.install(this)
setContentView(R.layout.activity_main)
findViewById(R.id.main_scroll).setOnApplyWindowInsetsListener { view, insets ->
if (Build.VERSION.SDK_INT >= 30) {
diff --git a/app/src/main/java/llc/slacker/openime/NineKeyLocalDecoder.kt b/app/src/main/java/llc/slacker/openime/NineKeyLocalDecoder.kt
index 603880ff..a224ade3 100644
--- a/app/src/main/java/llc/slacker/openime/NineKeyLocalDecoder.kt
+++ b/app/src/main/java/llc/slacker/openime/NineKeyLocalDecoder.kt
@@ -41,9 +41,8 @@ internal class NineKeyLocalDecoder(
}
/**
- * One way to read the open digits as pinyin. [syllables] are in order;
- * [coversAll] tells whether they spell every digit (a whole reading) or only
- * the start of them (a first-syllable choice).
+ * One way to read the start of the open digits as pinyin. [coversAll] tells
+ * whether the syllable spells every open digit or only the first of them.
*/
data class Reading(
val syllables: List,
@@ -261,14 +260,12 @@ internal class NineKeyLocalDecoder(
}
/**
- * The readings offered in the left rail, best first.
- *
- * Short input lists whole readings (`ni'hao`, `mi'hao`, `ni'gao`), the way
- * the design shows them. Once a whole reading no longer fits the rail it
- * lists first syllables (`zhong`, `xiong`), and fixing one moves the list on
- * to the next position (the Baidu / rime-t9-shiyin behaviour). Either way a
- * tap fixes exactly what the item shows. Choices that would leave digits no
- * syllable can read are never offered.
+ * The readings offered in the left rail, best first: one syllable per item,
+ * for the next character only (`ni`, `mi`, ...). Fixing one moves the list on
+ * to the following character, the way Baidu and rime-t9-shiyin do it, so the
+ * user chooses the pinyin of one character at a time and never has to pick
+ * a whole phrase's spelling. A tap fixes exactly what the item shows, and
+ * choices that would leave digits no syllable can read are never offered.
*/
@Synchronized
fun readingOptions(digits: String, preferred: String?, limit: Int = MAX_SYLLABLE_OPTIONS): List {
@@ -281,19 +278,8 @@ internal class NineKeyLocalDecoder(
// when nothing else exists.
val all = syllablePaths(bounded, READING_BEAM)
val paths = all.filter { path -> path.syllables.none { it.length == 1 } }.ifEmpty { all }
- val best = paths.firstOrNull()
- if (best != null && best.syllables.joinToString("'").length <= WHOLE_READING_MAX_CHARS) {
- val ranked = paths.sortedWith(
- compareByDescending { it.syllables.joinToString("") == lead }
- .thenByDescending { it.score },
- )
- return ranked
- .map { Reading(it.syllables, coversAll = true) }
- .distinctBy { it.display }
- .take(limit)
- }
- // Long input: first syllables, each only if the rest can still be read.
+ // First syllables, each only if the rest can still be read.
val firsts = LinkedHashMap()
paths.forEach { path -> firsts.putIfAbsent(path.syllables.first(), path.score) }
syllableOptions(bounded, preferred, limit * 2).forEach { syllable ->
@@ -611,7 +597,6 @@ internal class NineKeyLocalDecoder(
const val MAX_DIGITS = 64
const val MAX_SYLLABLE_OPTIONS = 12
private const val READING_BEAM = 24
- private const val WHOLE_READING_MAX_CHARS = 14
private const val MAX_PHRASE_SYLLABLES = 6
private const val MAX_SYLLABLE_LENGTH = 6
private const val MAX_PATHS = 12
diff --git a/app/src/main/java/llc/slacker/openime/RimeEngine.kt b/app/src/main/java/llc/slacker/openime/RimeEngine.kt
index c020903e..8270130d 100644
--- a/app/src/main/java/llc/slacker/openime/RimeEngine.kt
+++ b/app/src/main/java/llc/slacker/openime/RimeEngine.kt
@@ -146,6 +146,41 @@ class RimeEngine(
copyAssetsIfNeeded(sharedDir)
if (!startupGate.isCurrent(generation)) return@execute
+ // A previous start that died inside native code leaves its marker
+ // behind; escalate (clear compiled data, set the user database
+ // aside, finally skip librime) instead of crashing in a loop.
+ val recovery = RimeStartupRecovery(File(context.filesDir, "$dataDirName-startup"))
+ if (recovery.lastStartupDiedNatively()) {
+ when (CrashGuard.previousExitWasNativeCrash(context)) {
+ // Killed by the user or the system while starting, not by librime.
+ false -> recovery.failedWithoutCrash()
+ // Android 11+ already recorded it in the exit history.
+ true -> Unit
+ // Older Android cannot say: assume the worst.
+ null -> CrashGuard.recordNativeStartupCrash(context)
+ }
+ }
+ val action = if (CrashGuard.isSafeMode(context)) {
+ recovery.failedWithoutCrash() // already counted above; do not count it every session
+ RimeStartupRecovery.Action.SKIP_NATIVE
+ } else {
+ recovery.begin()
+ }
+ when (action) {
+ RimeStartupRecovery.Action.NORMAL -> Unit
+ RimeStartupRecovery.Action.CLEAN_BUILD -> clearCompiledData(sharedDir, userDir)
+ RimeStartupRecovery.Action.RESET_USER_DATA -> {
+ clearCompiledData(sharedDir, userDir)
+ setUserDataAside(userDir)
+ }
+ RimeStartupRecovery.Action.SKIP_NATIVE -> {
+ errorMessage = "librime is off after repeated crashes"
+ startupGate.fail(generation)
+ Log.w(TAG, "librime skipped (safe mode); using the Kotlin fallback")
+ return@execute
+ }
+ }
+
// nativeStartup is internally serialized. Even if destroy races
// this call, nativeShutdown will either run after it or this
// stale worker will perform the same idempotent cleanup below.
@@ -179,10 +214,15 @@ class RimeEngine(
cleanupNative()
return@execute
}
+ recovery.succeeded()
errorMessage = ""
isReady = true
Log.i(TAG, "librime ready schema=$activeSchemaId")
} catch (throwable: Throwable) {
+ // A Java exception is not a native crash: lift the marker so it is not counted as one.
+ runCatching {
+ RimeStartupRecovery(File(context.filesDir, "${assetRoot.replace('/', '_')}-startup")).failedWithoutCrash()
+ }
if (nativeStartupReturned) cleanupNative()
if (startupGate.fail(generation)) {
isReady = false
@@ -573,6 +613,29 @@ class RimeEngine(
}
}
+ private fun clearCompiledData(sharedDir: File, userDir: File) {
+ listOf(File(sharedDir, "build"), File(userDir, "build")).forEach { build ->
+ if (build.isDirectory) {
+ deleteChildren(build)
+ build.delete()
+ }
+ }
+ Log.w(TAG, "cleared compiled librime data after a native startup failure")
+ }
+
+ /** Keep one backup of a user database that may be damaged and start with an empty one. */
+ private fun setUserDataAside(userDir: File) {
+ val backup = File(userDir.parentFile, "${userDir.name}.corrupt")
+ if (backup.exists()) {
+ deleteChildren(backup)
+ backup.delete()
+ }
+ if (userDir.renameTo(backup)) {
+ userDir.mkdirs()
+ Log.w(TAG, "user dictionary set aside as ${backup.name}")
+ }
+ }
+
private fun deleteChildren(directory: File) {
directory.listFiles().orEmpty().forEach { child ->
if (child.isDirectory) deleteChildren(child)
diff --git a/app/src/main/java/llc/slacker/openime/VoiceMediaMuteController.kt b/app/src/main/java/llc/slacker/openime/VoiceMediaMuteController.kt
index dd2ccc9a..40561f4c 100644
--- a/app/src/main/java/llc/slacker/openime/VoiceMediaMuteController.kt
+++ b/app/src/main/java/llc/slacker/openime/VoiceMediaMuteController.kt
@@ -3,6 +3,8 @@ package llc.slacker.openime
import android.content.Context
import android.media.AudioManager
import android.os.Build
+import android.os.Handler
+import android.os.Looper
import android.util.Log
/**
@@ -16,6 +18,27 @@ import android.util.Log
internal class VoiceMediaMuteController(context: Context) {
companion object {
private const val TAG = "OpenImeVoiceMedia"
+ private const val PREFS = "openime_voice_media_mute"
+
+ /** No recording session lasts this long; if nobody restored the volume by then, do it. */
+ private const val MAX_MUTE_MS = 2 * 60 * 1000L
+
+ /**
+ * The original volume is also written to disk while media is muted. If the
+ * keyboard process dies mid-recording (crash, low-memory kill, force stop)
+ * nothing in memory can undo the mute, and the user's music and video stay
+ * silent until they notice. The next start puts the volume back.
+ */
+ fun recoverAfterCrash(context: Context) {
+ val prefs = context.applicationContext.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
+ if (!prefs.getBoolean("pending", false)) return
+ val controller = VoiceMediaMuteController(context)
+ controller.snapshot = Snapshot(prefs.getInt("volume", -1), prefs.getBoolean("muted", false))
+ .takeIf { it.volume >= 0 }
+ controller.restore()
+ prefs.edit().clear().commit()
+ Log.w(TAG, "restored media volume after an unclean exit")
+ }
}
private data class Snapshot(
@@ -25,6 +48,10 @@ internal class VoiceMediaMuteController(context: Context) {
private val audioManager = context.applicationContext
.getSystemService(Context.AUDIO_SERVICE) as? AudioManager
+ private val appContext = context.applicationContext
+ private val prefs = appContext.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
+ private val handler = Handler(Looper.getMainLooper())
+ private val watchdog = Runnable { restore() }
private var snapshot: Snapshot? = null
@Synchronized
@@ -45,6 +72,13 @@ internal class VoiceMediaMuteController(context: Context) {
return false
}
snapshot = baseline
+ prefs.edit()
+ .putBoolean("pending", true)
+ .putInt("volume", baseline.volume)
+ .putBoolean("muted", baseline.muted)
+ .commit()
+ handler.removeCallbacks(watchdog)
+ handler.postDelayed(watchdog, MAX_MUTE_MS)
return runCatching {
if (!baseline.muted) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
@@ -73,6 +107,8 @@ internal class VoiceMediaMuteController(context: Context) {
fun restore() {
val baseline = snapshot ?: return
snapshot = null
+ handler.removeCallbacks(watchdog)
+ prefs.edit().clear().commit()
val manager = audioManager ?: return
runCatching {
// Restore the numeric volume without producing a volume beep, then
diff --git a/app/src/test/java/llc/slacker/openime/CandidatePipelineTest.kt b/app/src/test/java/llc/slacker/openime/CandidatePipelineTest.kt
index f99ac6f5..4a100c90 100644
--- a/app/src/test/java/llc/slacker/openime/CandidatePipelineTest.kt
+++ b/app/src/test/java/llc/slacker/openime/CandidatePipelineTest.kt
@@ -157,21 +157,29 @@ class CandidatePipelineTest {
}
@Test
- fun shortInputOffersWholeReadingsAndTheWordReadingLeads() {
+ fun everyRailItemIsOneCharactersPinyinAndTheWordReadingLeads() {
val readings = pipeline.nineKeyReadingsFor("64426", null)
- assertEquals("ni'hao", readings.first().display)
- assertTrue(readings.all { it.coversAll })
- assertTrue("mi'hao" in readings.map { it.display })
- assertTrue("ni'gao" in readings.map { it.display })
+ assertTrue(readings.all { it.syllables.size == 1 })
+ assertEquals("ni", readings.first().display)
+ assertTrue("mi" in readings.map { it.display })
+ // ni spells two of the five digits: choosing it moves on to the next character.
+ assertTrue(readings.none { it.coversAll })
// A lone vowel between syllables is a digit-grid artefact, not a reading.
assertTrue(readings.none { reading -> reading.syllables.any { it.length == 1 } })
assertEquals(readings.map { it.display }.distinct(), readings.map { it.display })
}
@Test
- fun readingPreviewLeadsWhenItIsOneOfTheReadings() {
- val readings = pipeline.nineKeyReadingsFor("64426", "migao")
- assertEquals("mi'gao", readings.first().display)
+ fun theSyllableOfTheShownPreviewLeads() {
+ assertEquals("mi", pipeline.nineKeyReadingsFor("64426", "migao").first().display)
+ assertEquals("ni", pipeline.nineKeyReadingsFor("64426", "nihao").first().display)
+ }
+
+ @Test
+ fun aSyllableThatSpellsAllTheDigitsCoversThem() {
+ val readings = pipeline.nineKeyReadingsFor("64", null)
+ assertTrue(readings.map { it.display }.containsAll(listOf("ni", "mi")))
+ assertTrue(readings.filter { it.display in setOf("ni", "mi") }.all { it.coversAll })
}
@Test
diff --git a/app/src/test/java/llc/slacker/openime/CrashResilienceTest.kt b/app/src/test/java/llc/slacker/openime/CrashResilienceTest.kt
new file mode 100644
index 00000000..7b5e5ac2
--- /dev/null
+++ b/app/src/test/java/llc/slacker/openime/CrashResilienceTest.kt
@@ -0,0 +1,99 @@
+package llc.slacker.openime
+
+import java.io.File
+import java.nio.file.Files
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+class CrashResilienceTest {
+
+ @Test
+ fun threeCrashesInTenMinutesIsACrashLoop() {
+ val now = 1_000_000_000L
+ val minute = 60_000L
+ assertFalse(CrashGuard.inCrashLoop(emptyList(), now))
+ assertFalse(CrashGuard.inCrashLoop(listOf(now - minute, now - 2 * minute), now))
+ assertTrue(CrashGuard.inCrashLoop(listOf(now - minute, now - 2 * minute, now - 3 * minute), now))
+ }
+
+ @Test
+ fun oldCrashesAgeOutSoSafeModeEnds() {
+ val now = 1_000_000_000L
+ val old = now - CrashGuard.LOOP_WINDOW_MS - 1
+ assertFalse(CrashGuard.inCrashLoop(listOf(old, old - 1, old - 2, now - 1), now))
+ }
+
+ @Test
+ fun crashTimesRoundTripAndIgnoreGarbage() {
+ assertEquals(listOf(1L, 22L, 333L), CrashGuard.parseTimes(CrashGuard.formatTimes(listOf(1L, 22L, 333L))))
+ assertEquals(listOf(5L), CrashGuard.parseTimes("x,5,,y"))
+ assertEquals(emptyList(), CrashGuard.parseTimes(null))
+ }
+
+ private fun tempDir(): File = Files.createTempDirectory("rime-recovery").toFile().also { it.deleteOnExit() }
+
+ @Test
+ fun aCleanStartupLeavesNoTraceAndNeverEscalates() {
+ val recovery = RimeStartupRecovery(tempDir())
+ repeat(5) {
+ assertEquals(RimeStartupRecovery.Action.NORMAL, recovery.begin())
+ recovery.succeeded()
+ }
+ assertEquals(0, recovery.failures())
+ assertFalse(recovery.lastStartupDiedNatively())
+ }
+
+ @Test
+ fun everyNativeDeathEscalatesUntilNativeIsSkipped() {
+ val dir = tempDir()
+ // Each begin() without succeeded() is a start that died inside native code.
+ assertEquals(RimeStartupRecovery.Action.NORMAL, RimeStartupRecovery(dir).begin())
+ assertEquals(RimeStartupRecovery.Action.CLEAN_BUILD, RimeStartupRecovery(dir).begin())
+ assertEquals(RimeStartupRecovery.Action.RESET_USER_DATA, RimeStartupRecovery(dir).begin())
+ assertEquals(RimeStartupRecovery.Action.SKIP_NATIVE, RimeStartupRecovery(dir).begin())
+ // Skipping disarms the marker, so skipped sessions are not counted again.
+ assertFalse(RimeStartupRecovery(dir).lastStartupDiedNatively())
+ }
+
+ @Test
+ fun aSuccessfulStartAfterTroubleResetsTheEscalation() {
+ val dir = tempDir()
+ RimeStartupRecovery(dir).begin()
+ assertEquals(RimeStartupRecovery.Action.CLEAN_BUILD, RimeStartupRecovery(dir).begin())
+ RimeStartupRecovery(dir).succeeded()
+ assertEquals(RimeStartupRecovery.Action.NORMAL, RimeStartupRecovery(dir).begin())
+ }
+
+ @Test
+ fun aJavaExceptionDuringStartupIsNotCountedAsACrash() {
+ val dir = tempDir()
+ val recovery = RimeStartupRecovery(dir)
+ recovery.begin()
+ recovery.failedWithoutCrash()
+ assertEquals(RimeStartupRecovery.Action.NORMAL, RimeStartupRecovery(dir).begin())
+ }
+
+ @Test
+ fun smallTextIsCommittedInOnePiece() {
+ assertEquals(listOf("你好"), chunksForCommit("你好"))
+ val limit = "a".repeat(COMMIT_CHUNK_CHARS)
+ assertEquals(listOf(limit), chunksForCommit(limit))
+ }
+
+ @Test
+ fun hugeTextIsChunkedLosslesslyAndNeverInsideASurrogatePair() {
+ val emoji = "😀" // one surrogate pair
+ // Put a pair across the first boundary: the high surrogate would be the last unit of a full chunk.
+ val text = "a".repeat(COMMIT_CHUNK_CHARS - 1) + emoji + "b".repeat(COMMIT_CHUNK_CHARS * 2)
+ val chunks = chunksForCommit(text)
+ assertEquals(text, chunks.joinToString(""))
+ assertTrue(chunks.all { it.length <= COMMIT_CHUNK_CHARS })
+ chunks.forEach { chunk ->
+ assertFalse("chunk ends with a lone high surrogate", Character.isHighSurrogate(chunk.last()))
+ assertFalse("chunk starts with a lone low surrogate", Character.isLowSurrogate(chunk.first()))
+ }
+ assertTrue(chunks.size >= 3)
+ }
+}
diff --git a/app/src/test/java/llc/slacker/openime/EditorInfoAdapterTest.kt b/app/src/test/java/llc/slacker/openime/EditorInfoAdapterTest.kt
index 44ee904b..d3e1c3f5 100644
--- a/app/src/test/java/llc/slacker/openime/EditorInfoAdapterTest.kt
+++ b/app/src/test/java/llc/slacker/openime/EditorInfoAdapterTest.kt
@@ -105,4 +105,27 @@ class EditorInfoAdapterTest {
assertTrue(EditorInfoAdapter.isPassword(numberPassword))
assertFalse(EditorInfoAdapter.allowCandidates(numberPassword))
}
+
+ @Test
+ fun editorsWithoutATextTypeAreRawKeyEditorsAndStartInEnglish() {
+ val kind = EditorInfoAdapter.kind(info(InputType.TYPE_NULL))
+ assertEquals(EditorInfoAdapter.EditorKind.RAW_KEYS, kind)
+ assertEquals(KeyboardMode.ENGLISH_26, EditorInfoAdapter.defaultKeyboardMode(kind))
+ assertEquals(KeyboardMode.ENGLISH_26, InputMethodSubtypePolicy.defaultKeyboardMode(kind, "zh_CN"))
+ assertFalse(EditorInfoAdapter.isPassword(kind))
+ assertFalse(EditorInfoAdapter.allowCandidates(kind).not())
+ }
+
+ @Test
+ fun noEditorAtAllIsStillUnknownNotRawKeys() {
+ assertEquals(EditorInfoAdapter.EditorKind.UNKNOWN, EditorInfoAdapter.kind(null))
+ }
+
+ @Test
+ fun dateAndTimeFieldsStartWithDigits() {
+ val kind = EditorInfoAdapter.kind(info(InputType.TYPE_CLASS_DATETIME or InputType.TYPE_DATETIME_VARIATION_DATE))
+ assertEquals(EditorInfoAdapter.EditorKind.DATETIME, kind)
+ assertEquals(KeyboardMode.DIGITS, EditorInfoAdapter.defaultKeyboardMode(kind))
+ assertEquals(KeyboardMode.DIGITS, InputMethodSubtypePolicy.defaultKeyboardMode(kind, "zh_CN"))
+ }
}
diff --git a/app/src/test/java/llc/slacker/openime/HardwareKeyPolicyTest.kt b/app/src/test/java/llc/slacker/openime/HardwareKeyPolicyTest.kt
new file mode 100644
index 00000000..01310c33
--- /dev/null
+++ b/app/src/test/java/llc/slacker/openime/HardwareKeyPolicyTest.kt
@@ -0,0 +1,105 @@
+package llc.slacker.openime
+
+import android.view.KeyEvent
+import org.junit.Assert.assertEquals
+import org.junit.Test
+
+class HardwareKeyPolicyTest {
+ private val typing = HardwareContext(pinyinMode = true, composing = true, candidateCount = 5, charBeforeCursor = null)
+ private val idle = typing.copy(composing = false, candidateCount = 0)
+
+ private fun key(code: Int, char: Char? = null, shift: Boolean = false, ctrl: Boolean = false, caps: Boolean = false, repeat: Boolean = false) =
+ HardwareKey(code, char?.code ?: 0, shift = shift, ctrl = ctrl, capsLock = caps, repeat = repeat)
+
+ private fun letter(c: Char) = key(KeyEvent.KEYCODE_A + (c - 'a'), c)
+
+ @Test
+ fun lettersComposePinyinWhetherOrNotAnythingIsOpen() {
+ assertEquals(HardwareKeyAction.Letter('n'), HardwareKeyPolicy.decide(letter('n'), idle))
+ assertEquals(HardwareKeyAction.Letter('i'), HardwareKeyPolicy.decide(letter('i'), typing))
+ }
+
+ @Test
+ fun otherModesAndShortcutsBelongToTheApp() {
+ assertEquals(HardwareKeyAction.PassThrough, HardwareKeyPolicy.decide(letter('a'), typing.copy(pinyinMode = false)))
+ assertEquals(HardwareKeyAction.PassThrough, HardwareKeyPolicy.decide(key(KeyEvent.KEYCODE_C, 'c', ctrl = true), typing))
+ assertEquals(HardwareKeyAction.PassThrough, HardwareKeyPolicy.decide(key(KeyEvent.KEYCODE_SHIFT_LEFT), typing))
+ }
+
+ @Test
+ fun capitalLettersAreEnglishAndCloseTheComposition() {
+ val a = key(KeyEvent.KEYCODE_A, 'A', shift = true)
+ assertEquals(HardwareKeyAction.FinishCompositionThenPassThrough, HardwareKeyPolicy.decide(a, typing))
+ assertEquals(HardwareKeyAction.PassThrough, HardwareKeyPolicy.decide(a, idle))
+ // Caps Lock types lower case keys as capitals too.
+ assertEquals(HardwareKeyAction.PassThrough, HardwareKeyPolicy.decide(key(KeyEvent.KEYCODE_A, 'A', caps = true), idle))
+ }
+
+ @Test
+ fun spaceEnterBackspaceAndEscapeOnlyActWhileComposing() {
+ for ((code, expected) in listOf(
+ KeyEvent.KEYCODE_SPACE to HardwareKeyAction.Space,
+ KeyEvent.KEYCODE_ENTER to HardwareKeyAction.Enter,
+ KeyEvent.KEYCODE_DEL to HardwareKeyAction.Backspace,
+ KeyEvent.KEYCODE_ESCAPE to HardwareKeyAction.Cancel,
+ )) {
+ assertEquals(expected, HardwareKeyPolicy.decide(key(code), typing))
+ assertEquals(HardwareKeyAction.PassThrough, HardwareKeyPolicy.decide(key(code), idle))
+ }
+ }
+
+ @Test
+ fun aHeldSpaceOrEnterDoesNotCommitRepeatedly() {
+ assertEquals(HardwareKeyAction.Consume, HardwareKeyPolicy.decide(key(KeyEvent.KEYCODE_SPACE, repeat = true), typing))
+ assertEquals(HardwareKeyAction.Consume, HardwareKeyPolicy.decide(key(KeyEvent.KEYCODE_ENTER, repeat = true), typing))
+ }
+
+ @Test
+ fun digitsPickCandidatesOnlyWhileComposing() {
+ assertEquals(HardwareKeyAction.SelectCandidate(0), HardwareKeyPolicy.decide(key(KeyEvent.KEYCODE_1, '1'), typing))
+ assertEquals(HardwareKeyAction.SelectCandidate(4), HardwareKeyPolicy.decide(key(KeyEvent.KEYCODE_5, '5'), typing))
+ assertEquals(HardwareKeyAction.Consume, HardwareKeyPolicy.decide(key(KeyEvent.KEYCODE_9, '9'), typing))
+ assertEquals(HardwareKeyAction.PassThrough, HardwareKeyPolicy.decide(key(KeyEvent.KEYCODE_1, '1'), idle))
+ }
+
+ @Test
+ fun apostropheSeparatesSyllablesOnlyWhileComposing() {
+ assertEquals(HardwareKeyAction.Apostrophe, HardwareKeyPolicy.decide(key(KeyEvent.KEYCODE_APOSTROPHE, '\''), typing))
+ assertEquals(HardwareKeyAction.PassThrough, HardwareKeyPolicy.decide(key(KeyEvent.KEYCODE_APOSTROPHE, '\''), idle))
+ }
+
+ @Test
+ fun punctuationIsFullWidthAndCommitsTheFirstCandidateWhenComposing() {
+ assertEquals(
+ HardwareKeyAction.Punctuation(",", commitFirstCandidate = true),
+ HardwareKeyPolicy.decide(key(KeyEvent.KEYCODE_COMMA, ','), typing),
+ )
+ assertEquals(
+ HardwareKeyAction.Punctuation("。", commitFirstCandidate = false),
+ HardwareKeyPolicy.decide(key(KeyEvent.KEYCODE_PERIOD, '.'), idle),
+ )
+ assertEquals(
+ HardwareKeyAction.Punctuation("?", commitFirstCandidate = false),
+ HardwareKeyPolicy.decide(key(KeyEvent.KEYCODE_SLASH, '?', shift = true), idle),
+ )
+ }
+
+ @Test
+ fun numbersKeepTheirAsciiPunctuation() {
+ val afterDigit = idle.copy(charBeforeCursor = '3')
+ assertEquals(HardwareKeyAction.PassThrough, HardwareKeyPolicy.decide(key(KeyEvent.KEYCODE_PERIOD, '.'), afterDigit))
+ assertEquals(HardwareKeyAction.PassThrough, HardwareKeyPolicy.decide(key(KeyEvent.KEYCODE_COMMA, ','), afterDigit))
+ // A question mark after a digit is still a question mark.
+ assertEquals(
+ HardwareKeyAction.Punctuation("?", commitFirstCandidate = false),
+ HardwareKeyPolicy.decide(key(KeyEvent.KEYCODE_SLASH, '?', shift = true), afterDigit),
+ )
+ }
+
+ @Test
+ fun anyOtherPrintableKeyClosesTheCompositionFirst() {
+ assertEquals(HardwareKeyAction.FinishCompositionThenPassThrough, HardwareKeyPolicy.decide(key(KeyEvent.KEYCODE_MINUS, '-'), typing))
+ assertEquals(HardwareKeyAction.PassThrough, HardwareKeyPolicy.decide(key(KeyEvent.KEYCODE_MINUS, '-'), idle))
+ assertEquals(HardwareKeyAction.PassThrough, HardwareKeyPolicy.decide(key(KeyEvent.KEYCODE_DPAD_LEFT), typing))
+ }
+}
diff --git a/app/src/test/java/llc/slacker/openime/InputConnectionGatewayTest.kt b/app/src/test/java/llc/slacker/openime/InputConnectionGatewayTest.kt
index 36484c73..7d55156f 100644
--- a/app/src/test/java/llc/slacker/openime/InputConnectionGatewayTest.kt
+++ b/app/src/test/java/llc/slacker/openime/InputConnectionGatewayTest.kt
@@ -62,7 +62,11 @@ class InputConnectionGatewayTest {
override fun getExtractedText(request: ExtractedTextRequest?, flags: Int): ExtractedText? =
extractedText
override fun getHandler(): Handler? = null
- override fun getSelectedText(flags: Int): CharSequence? = selectedText
+ var selectedTextCalls = 0
+ override fun getSelectedText(flags: Int): CharSequence? {
+ selectedTextCalls++
+ return selectedText
+ }
override fun getTextAfterCursor(length: Int, flags: Int): CharSequence? = afterText.take(length)
override fun getTextBeforeCursor(length: Int, flags: Int): CharSequence? = beforeText.takeLast(length)
override fun performContextMenuAction(id: Int): Boolean {
@@ -668,4 +672,70 @@ class InputConnectionGatewayTest {
assertTrue(gateway.clearAllText())
assertFalse(gateway.hasClearUndo())
}
+
+ // --- terminals, games and remote desktops: TYPE_NULL, key events only ---
+
+ private fun rawKeyGateway(fake: FakeInputConnection) = InputConnectionGateway(
+ context = null,
+ connection = { fake },
+ isRawKeys = { true },
+ // The JVM has no key character map; one synthetic event per character is enough to observe.
+ keyEventsFor = { text -> if (text.all { it.code < 0x80 }) Array(text.length) { KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_A) } else null },
+ )
+
+ @Test
+ fun rawKeyEditorsDeleteWithKeyEventsNotWithTheDummyConnectionsPrivateBuffer() {
+ // BaseInputConnection in dummy mode answers true to deleteSurroundingText and removes nothing.
+ val fake = FakeInputConnection(deleteSurroundingResult = true)
+ rawKeyGateway(fake).deleteBackwards()
+ assertEquals(listOf("key", "key"), fake.events) // DEL down + up, no delete* call
+ }
+
+ @Test
+ fun rawKeyEditorsForwardDeleteWithKeyEventsToo() {
+ val fake = FakeInputConnection()
+ rawKeyGateway(fake).deleteForwards()
+ assertEquals(listOf("key", "key"), fake.events)
+ }
+
+ @Test
+ fun rawKeyEditorsReceiveAsciiAsKeyEventsAndOtherTextAsCommit() {
+ val fake = FakeInputConnection()
+ val gateway = rawKeyGateway(fake)
+ gateway.commitText("ls")
+ assertEquals(listOf("key", "key"), fake.events)
+ fake.events.clear()
+ gateway.commitText("你好")
+ assertEquals(listOf("commit:你好"), fake.events)
+ }
+
+ @Test
+ fun ordinaryEditorsAreNotAffectedByTheRawKeyPath() {
+ val fake = FakeInputConnection()
+ InputConnectionGateway(null, { fake }).commitText("ls")
+ assertEquals(listOf("commit:ls"), fake.events)
+ }
+
+ @Test
+ fun backspaceDoesNotQueryTheAppWhenTheEditorReportedACollapsedCursor() {
+ val fake = FakeInputConnection(beforeText = "abc")
+ val gateway = InputConnectionGateway(null, { fake })
+ gateway.updateSelection(3, 3, reportedByEditor = true)
+ gateway.deleteBackwards()
+ assertEquals(0, fake.selectedTextCalls)
+ // One delete call (which flavour depends on the SDK level); nothing else was sent.
+ assertEquals(1, fake.events.size)
+ assertTrue(fake.events.single().startsWith("delete"))
+ }
+
+ @Test
+ fun backspaceStillAsksWhenOnlyTheStartUpSelectionIsKnown() {
+ // initialSelStart/End can be stale; only the editor's own reports are trusted.
+ val fake = FakeInputConnection(selectedText = "bc")
+ val gateway = InputConnectionGateway(null, { fake })
+ gateway.updateSelection(3, 3, reportedByEditor = false)
+ gateway.deleteBackwards()
+ assertEquals(1, fake.selectedTextCalls)
+ assertEquals(listOf("commit:"), fake.events)
+ }
}
diff --git a/docs/COMPATIBILITY.md b/docs/COMPATIBILITY.md
new file mode 100644
index 00000000..f30460b8
--- /dev/null
+++ b/docs/COMPATIBILITY.md
@@ -0,0 +1,53 @@
+# 输入环境兼容性
+
+键盘要在别人的应用里工作,所以「哪些环境、怎么处理、怎么验证」写在这里。改输入链路之前先看一遍。
+
+## 编辑器
+
+| 环境 | 处理 | 验证 |
+|---|---|---|
+| 普通 / 多行 / 搜索 / 聊天(EditText、WebView、Compose) | 拼音预编辑 + 候选;回车按 IME action 或原始回车 | `core_regression.sh`、`ImeTestLabActivity` |
+| 自绘 / Compose / Web,没有「全选」也没有 ExtractedText | 清空 / 撤回改用光标前后文本,答案长度等于请求长度时拒绝删除 | `InputConnectionGatewayTest`,`CustomEditorTestActivity` |
+| 密码(含可见密码、网页密码、数字密码) | 不组合、不学习、不进剪贴板历史、禁用语音 | `security_regression.ps1` |
+| 数字 / 电话 / 日期时间 | 起始键盘为数字 | `EditorInfoAdapterTest` |
+| 邮箱 / URL | 起始键盘为英文 | `EditorInfoAdapterTest` |
+| TYPE_NULL(终端、游戏、远程桌面) | 起始英文;每个字母立即以真实按键事件送出;退格 / 前删用按键事件(它们的 InputConnection 多半是 dummy 模式的 BaseInputConnection,`deleteSurroundingText` 返回 true 却什么也没删) | `InputConnectionGatewayTest` |
+| 无个性化学习标志(隐身模式) | 不学习、不记录剪贴板 | `PersonalizedLearningPolicy` |
+| 一次提交几十万字(大段粘贴、长语音) | 分块提交,每块不超过 32000 个字符且不拆代理对,避免超过 Binder 单次事务上限 | `CrashResilienceTest` |
+
+## 物理键盘(平板、折叠屏键盘套、Chromebook、桌面模式、模拟器)
+
+中文 26 键模式下:字母组成拼音,空格选首选,1–9 选候选,回车保留已输入拼音,Esc 取消,`'` 分词,退格删拼音;
+`, . ? ! ; : ( )` 输出全角标点(数字后的 `, . :` 保持 ASCII,3.14 不会变成 3。14);Ctrl / Alt / Meta 组合键、
+大写字母和其他按键原样交给应用(大写会先结束当前预编辑)。英文 / 九键 / 数字模式、密码框、TYPE_NULL 编辑器不接管。
+需要键盘面板可见(候选显示在面板上)。验证:`HardwareKeyPolicyTest`,`core_regression.sh` 040–043。
+
+## 显示环境
+
+横屏(不进入全屏提取模式,键盘是底部面板)、字体 130% / 200%(按键标签最多放大到 1.3 倍,功能键标签自动缩小)、
+深色、小屏、窄屏、平板竖 / 横、折叠屏内屏。验证:`scripts/display_matrix_regression.py`(断言底部面板且每个键都在窗口内)、
+`DisplayEnvironmentInstrumentedTest`。
+
+## Android 版本
+
+`minSdk` 26;CI 在 API 26(minSdk)、29、31、34 上运行全部仪器测试,本地另在 API 36 上运行;发布前必须通过的是 API 29 和 31。
+
+## 崩溃、卡死与冲突
+
+- 一次按键处理失败不会让键盘进程退出:记录(只含异常类型和代码位置,不含输入内容)、丢弃半成品预编辑、继续工作。
+ 验证:`core_regression.sh` 038(调试命令 `fail-next` 注入一次失败)。
+- 崩溃历史:Java 崩溃、原生崩溃和 ANR(Android 11+ 的进程退出记录)。10 分钟内 3 次进入**安全模式**:
+ 关闭 librime 和语音预加载,用内置词库继续输入,「设置 → 关于与数据 → 诊断」可复制诊断信息或退出安全模式。
+- librime 启动前写标记,通过健康检查后清除。留下标记且上个进程确实是原生崩溃时逐级处理:清理编译产物 →
+ 把用户词库改名备份并重建 → 不再启动原生引擎。被用户或系统强停的启动不计为崩溃。
+- 语音输入静音媒体音量时,原音量同时写入磁盘并有两分钟看门狗;进程在录音中途死掉,下次启动恢复,音乐 / 视频不会一直没声。
+ 验证:`VoiceMediaMuteRecoveryInstrumentedTest`。
+- 词库与九键解码器在后台线程构建,不再占用主线程(冷启动曾多占约 0.3 秒)。
+- 退格不再每次向应用发起三次同步 Binder 调用:编辑器已经报告光标是收起状态时,不再去问「选中了什么」。
+ 应用卡住时,每次调用都会让键盘跟着等。
+
+## 尚未覆盖
+
+- 九键模式下的物理键盘(字母直接交给应用);
+- 物理键盘用户隐藏键盘面板后的候选显示(需要独立的候选窗口);
+- 真机上的 OEM 差异(小米、OPPO、三星):目前只有模拟器与 CI 模拟器的结果。
diff --git a/docs/LICENSING.md b/docs/LICENSING.md
index 5a73bb59..b02bd2af 100644
--- a/docs/LICENSING.md
+++ b/docs/LICENSING.md
@@ -2,9 +2,12 @@
## 主项目
-`openIME` 当前尚未选择主项目许可证。仓库公开可见,但在添加明确许可证前,不能
-把“公开”理解为允许任意复制、再分发或商业使用。后续由项目所有者选择许可证后,
-应在仓库根目录增加标准 `LICENSE` 文件,并同步更新本页和 README。
+`openIME` 以 **GPL-3.0-only** 发布,全文见仓库根目录 `LICENSE`。
+
+选择它的原因:APK 内置的 Rime Ice 词典按 GPL-3.0-only 使用(见下),主项目采用同一份许可证,
+分发 APK 时整体的许可证状况没有歧义——不用争论词典数据与程序是「聚合」还是「衍生」。
+其余组件(librime、OpenCC、Snappy、sherpa-onnx、Paraformer 模型、Fluent Emoji)均为 BSD / Apache-2.0 / MIT,
+与 GPL-3.0 兼容。想改用别的许可证需要先去掉或替换 Rime Ice 词典。
## 已随仓库提供的第三方组件
diff --git a/docs/NINE_KEY_REFERENCE.md b/docs/NINE_KEY_REFERENCE.md
index 7f50892f..71282a2b 100644
--- a/docs/NINE_KEY_REFERENCE.md
+++ b/docs/NINE_KEY_REFERENCE.md
@@ -12,15 +12,16 @@
| [百度输入法九键说明](https://jingyan.baidu.com/article/19020a0a7ee4ab529c284246.html) | 官方使用说明 | 拼音键左侧是精确拼音;可上下滑动拼音列表更改拼音组成;按 1 手动分词 |
| [搜狗输入法帮助](https://shouji.sogou.com/wap/feedback/faqdetail?id=2004148&click_fr=3&platform=Android) | 官方帮助 | 直接上滑删除键清空、直接下滑撤回;多次清空只保留最后一次 |
| iOS 九宫格(「简体拼音十键」) | 系统输入法 | 拼音编码区 + 文字候选区分开;放弃单独的分词键 |
-| 《openIME 界面重构稿》设计稿 | 本项目设计依据 | 输入中左栏是整块面板,列整条读法(`ni'hao` / `mi'hao` / `ni'gao`),选中项为强调色胶囊;联想态为「‹ 词 … ∨」 |
+| 《openIME 界面重构稿》设计稿 | 本项目设计依据 | 输入中左栏是整块面板,选中项为强调色胶囊(版式沿用;每项的内容改为一个字的拼音,见下);联想态为「‹ 词 … ∨」 |
(豆包输入法、搜狗、微信键盘等闭源产品没有可读的实现,只能依据其公开使用说明。)
## 行为约定(openIME 的实现)
-1. **点选即锁定,所见即所锁。** 左栏列出「读法」:短输入列整条读法(`ni'hao`),
- 当整条读法放不下左栏时(> 14 个字符)改列首音节(`zhong`、`xiong`),锁定一个后列表移到下一位。
- 不提供会让剩余数字无法拼读的选项;孤立的 `a/o/e`、无元音的 `ng/m` 不当读法。
+1. **一个字一个拼音:左栏每项是下一个字的一个音节**(`ni`、`mi`),点选即锁定,所见即所锁;锁定后列表移到下一个字
+ (百度输入法、rime-t9-shiyin 的做法,也是九键「先选拼音再选字」的通行流程)。用户不用为整句选拼音,
+ 整词的读法在预编辑里看,要换字就从候选里选。不提供会让剩余数字无法拼读的选项;孤立的 `a/o/e`、无元音的 `ng/m`
+ 不当读法。早先按设计稿列整条读法(`ni'hao`),与「一个字一个拼音」冲突,已改掉。
2. **锁定的音节发给 Rime 时保持字母**(`xiong'486`)。luna_pinyin 方案同时接受字母和 2–9 数字,
所以 zhong / xiong 这类同数字的读法不会被重新混在一起,候选与所选读法一致。
3. **锁定的音节在继续打字时保持锁定**(用边界封住),退格先解锁最近锁定的音节 / 分词边界。
diff --git a/docs/README.md b/docs/README.md
index 986a2b2b..5de16fa3 100644
--- a/docs/README.md
+++ b/docs/README.md
@@ -14,6 +14,7 @@
- [TEST_ARCHITECTURE.md](TEST_ARCHITECTURE.md):自动化层级、debug harness 和 CI 门禁。
- [TEST_SOP.md](TEST_SOP.md):L0~L3 正式测试流程。
- [TEST_SOP_CHECKLIST.md](TEST_SOP_CHECKLIST.md):多设备与人工交互验收清单。
+- [COMPATIBILITY.md](COMPATIBILITY.md):输入环境兼容性:编辑器类型、物理键盘、显示环境、崩溃 / 卡死 / 冲突的处理与验证。
- [LICENSING.md](LICENSING.md):主项目与第三方组件许可证边界。
- [RELEASE.md](RELEASE.md):版本号规则(`VERSION`)、CHANGELOG、固定签名、arm64 正式包、标签发布、演练与回滚。
- [REPOSITORY.md](REPOSITORY.md):分支、合并、`main` 与标签保护、安全与依赖更新,以及如何重新应用这些设置。
diff --git a/docs/RELEASE.md b/docs/RELEASE.md
index 52b8f5a6..187cb7e1 100644
--- a/docs/RELEASE.md
+++ b/docs/RELEASE.md
@@ -132,7 +132,7 @@ OPENIME_REHEARSAL=1 OPENIME_SKIP_TESTS=1 scripts/release_build.sh # 需要上
- `android:allowBackup="false"` 保持不变。
- `THIRD_PARTY_NOTICES.md` 与 `app/src/main/assets/licenses/` 同步。
- 语音模型、词库或第三方 runtime 版本变化时重新核对对应许可证(见 [LICENSING.md](LICENSING.md))。
-- 主项目许可证:仓库尚未选择,见 LICENSING.md;公开发布前需要所有者决定。
+- 主项目许可证:`LICENSE`(GPL-3.0-only),说明见 LICENSING.md。
## 社交预览
diff --git a/docs/TEST_SOP.md b/docs/TEST_SOP.md
index 530df926..ae674602 100644
--- a/docs/TEST_SOP.md
+++ b/docs/TEST_SOP.md
@@ -79,6 +79,16 @@ L2 至少覆盖 320、360、390、412、432、600dp。390×296 仅是设计基
- 字体 100%、130%、150%,默认和放大显示。
- 横屏、竖屏、分屏、前后台、锁屏恢复、进程被杀后恢复。
+显示环境矩阵由脚本自动检查,不要手工代替:
+
+```bash
+python3 scripts/display_matrix_regression.py --serial
+```
+
+它在竖屏、横屏、字体 130% / 200%、深色、小屏、窄屏、平板(竖 / 横)、折叠屏内屏之间切换,
+断言键盘是底部面板(不是全屏提取模式),且每个按键和工具栏按钮都有尺寸、都在窗口内,
+并保存每个环境的截图供人工复核;运行结束会还原所有系统设置。
+
debug APK 提供 `ImeTestLabActivity`,包含普通、多行、密码、数字、电话、邮箱、URL、
搜索、聊天发送、Next/Done、已有 1 万字和预选文字替换输入框。release APK 不包含或导出
任何测试 Activity/Receiver。
diff --git a/docs/release-cert.sha256 b/docs/release-cert.sha256
index 4eb25b6e..4a73baa5 100644
--- a/docs/release-cert.sha256
+++ b/docs/release-cert.sha256
@@ -1 +1 @@
-unset
+22592ecc7eadcd168649e438f98b0d78563d098af99a1cc735c69d9cb7b1fe28
diff --git a/scripts/README.md b/scripts/README.md
index dbd3f3be..f325644f 100644
--- a/scripts/README.md
+++ b/scripts/README.md
@@ -15,6 +15,15 @@ bash scripts/setup_release_signing.sh # 一次性:生成发布密钥
bash scripts/apply_repo_settings.sh --dry-run # 查看将要应用的仓库规则(见 docs/REPOSITORY.md)
```
+## 显示环境矩阵
+
+需要一台装了 debug 版 openIME 并已设为默认输入法的设备或模拟器;脚本会改动并还原旋转、字体、
+分辨率和深色模式:
+
+```bash
+python3 scripts/display_matrix_regression.py --serial [用例名 ...]
+```
+
## 常用命令
```powershell
diff --git a/scripts/core_regression.sh b/scripts/core_regression.sh
index b8c9fd48..04ae7eb9 100755
--- a/scripts/core_regression.sh
+++ b/scripts/core_regression.sh
@@ -212,6 +212,14 @@ check "033 nine-key partial pick keeps the rest composing -> 你hao'ma" "你hao'
tap key-space
check '034 nine-key rest then space -> 你好吗' '你好吗' "$(editor_text)"
+start_real
+mode PINYIN_9 || say 'WARN: PINYIN_9 not reached'
+for key in 6 4 4 2 6; do tap "$key"; done
+tap '九键拼音ni'
+tap '九键拼音hao'
+tap key-space
+check '037 nine-key one pinyin per character: ni, then hao, then space -> 你好' '你好' "$(editor_text)"
+
start_real
mode PINYIN_26 || say 'WARN: PINYIN_26 not reached'
for key in n i h a o m a; do tap "$key"; done
@@ -220,6 +228,41 @@ check '035 26-key partial pick keeps the rest composing -> 你haoma' '你haoma'
tap key-space
check '036 26-key rest then space -> 你好吗' '你好吗' "$(editor_text)"
+start_real
+mode PINYIN_26 || say 'WARN: PINYIN_26 not reached'
+send fail-next
+tap n
+for key in n i h a o; do tap "$key"; done
+tap candidate-first-row
+check '038 a failure inside a key handler is contained, typing carries on -> 你好' '你好' "$(editor_text)"
+
+# Physical keyboard (KEYCODE_N=42 I=37 H=36 A=29 O=43 SPACE=62 1=8 COMMA=55 ENTER=66)
+hwkey() { adb_do shell input keyevent "$@" >/dev/null 2>&1; sleep 0.35; }
+
+start_real
+mode PINYIN_26 || say 'WARN: PINYIN_26 not reached'
+for code in 42 37 36 29 43; do hwkey "$code"; done
+hwkey 62
+check '040 physical keyboard: nihao + space -> 你好' '你好' "$(editor_text)"
+
+start_real
+mode PINYIN_26 || say 'WARN: PINYIN_26 not reached'
+for code in 42 37; do hwkey "$code"; done
+hwkey 8
+check '041 physical keyboard: ni + 1 picks the first candidate -> 你' '你' "$(editor_text)"
+
+start_real
+mode PINYIN_26 || say 'WARN: PINYIN_26 not reached'
+for code in 42 37 36 29 43; do hwkey "$code"; done
+hwkey 55
+check '042 physical keyboard: nihao + comma commits 你好 then a full-width comma' '你好,' "$(editor_text)"
+
+start_real
+mode PINYIN_26 || say 'WARN: PINYIN_26 not reached'
+hwkey 8
+hwkey 55
+check '043 physical keyboard: with nothing composing, 1 and comma stay ASCII' '1,' "$(editor_text)"
+
start_real
mode DIGITS || say 'WARN: DIGITS not reached'
for key in 1 2 3; do tap "$key"; done
diff --git a/scripts/display_matrix_regression.py b/scripts/display_matrix_regression.py
new file mode 100755
index 00000000..a9f5a33b
--- /dev/null
+++ b/scripts/display_matrix_regression.py
@@ -0,0 +1,205 @@
+#!/usr/bin/env python3
+"""Display-environment matrix for the live keyboard.
+
+For each environment (orientation, system font size, dark mode, phone / tablet /
+foldable / narrow windows) the debug input lab is opened, the keyboard is shown,
+and the script checks that
+
+ * the keyboard is a bottom panel, never the fullscreen "extract" mode;
+ * every key and toolbar button has a size and lies inside the keyboard window.
+
+It needs a debug build of openIME selected as the default input method on one
+device or emulator. Every setting it changes is reset afterwards, also on error.
+Screenshots go to --out so a human can look at them.
+
+ python3 scripts/display_matrix_regression.py [--serial SERIAL] [--out DIR] [case ...]
+"""
+
+from __future__ import annotations
+
+import argparse
+import re
+import subprocess
+import sys
+import time
+from pathlib import Path
+
+PKG = "llc.slacker.openime"
+LAB = f"{PKG}/.ImeTestLabActivity"
+RECEIVER = f"{PKG}/.E2ETestReceiver"
+ACTION = f"{PKG}.TEST_COMMAND"
+
+# name -> (wm size, wm density, user_rotation, font_scale, night)
+CASES: dict[str, tuple] = {
+ "portrait": (None, None, 0, "1.0", False),
+ "landscape": (None, None, 1, "1.0", False),
+ "portrait_font1.3": (None, None, 0, "1.3", False),
+ "portrait_font2.0": (None, None, 0, "2.0", False),
+ "landscape_font1.3": (None, None, 1, "1.3", False),
+ "dark": (None, None, 0, "1.0", True),
+ "small_phone_720x1280_d320": ("720x1280", "320", 0, "1.0", False),
+ "narrow_540x1200_d420": ("540x1200", "420", 0, "1.0", False),
+ "tablet_1600x2560_d280": ("1600x2560", "280", 0, "1.0", False),
+ "tablet_landscape": ("1600x2560", "280", 1, "1.0", False),
+ "foldable_inner_1840x2208_d420": ("1840x2208", "420", 0, "1.0", False),
+}
+
+KEYBOARD_ITEMS = ("key", "toolbar", "keyboard-selector", "clipboard-toolbar", "undo-toolbar", "keyboard-hide")
+
+
+class Device:
+ def __init__(self, serial: str | None):
+ self.base = ["adb"] + (["-s", serial] if serial else [])
+
+ def run(self, *args: str, binary: bool = False):
+ result = subprocess.run(self.base + list(args), capture_output=True)
+ return result.stdout if binary else result.stdout.decode("utf-8", "replace")
+
+ def shell(self, *args: str) -> str:
+ return self.run("shell", *args)
+
+
+def settle(seconds: float) -> None:
+ time.sleep(seconds)
+
+
+def reset(dev: Device) -> None:
+ dev.shell("wm", "size", "reset")
+ dev.shell("wm", "density", "reset")
+ dev.shell("settings", "put", "system", "font_scale", "1.0")
+ dev.shell("settings", "put", "system", "accelerometer_rotation", "0")
+ dev.shell("settings", "put", "system", "user_rotation", "0")
+ dev.shell("cmd", "uimode", "night", "no")
+ settle(2)
+
+
+def apply(dev: Device, case: tuple) -> None:
+ size, density, rotation, font, night = case
+ if size:
+ dev.shell("wm", "size", size)
+ if density:
+ dev.shell("wm", "density", density)
+ dev.shell("settings", "put", "system", "accelerometer_rotation", "0")
+ dev.shell("settings", "put", "system", "user_rotation", str(rotation))
+ dev.shell("settings", "put", "system", "font_scale", font)
+ dev.shell("cmd", "uimode", "night", "yes" if night else "no")
+ settle(2)
+
+
+def edit_fields(dev: Device) -> list[tuple[int, int]]:
+ dev.shell("uiautomator", "dump", "/sdcard/display_matrix.xml")
+ xml = dev.shell("cat", "/sdcard/display_matrix.xml")
+ pattern = r']*class="android.widget.EditText"[^>]*bounds="\[(\d+),(\d+)\]\[(\d+),(\d+)\]"'
+ centers = []
+ for match in re.finditer(pattern, xml):
+ x0, y0, x1, y1 = map(int, match.groups())
+ if x1 > x0 and y1 > y0:
+ centers.append(((x0 + x1) // 2, (y0 + y1) // 2))
+ return centers
+
+
+def ime_shown(dev: Device) -> bool:
+ return "mInputShown=true" in dev.shell("dumpsys", "input_method")
+
+
+def ensure_default_ime(dev: Device) -> None:
+ service = f"{PKG}/.LocalVoiceImeService"
+ if dev.shell("settings", "get", "secure", "default_input_method").strip() != service:
+ dev.shell("ime", "enable", "--user", "0", service)
+ dev.shell("ime", "set", "--user", "0", service)
+ settle(1)
+
+
+def show_ime(dev: Device) -> bool:
+ ensure_default_ime(dev)
+ # The lab and the IME service share one process: restart only the activity,
+ # a force-stop would kill the keyboard and the system may fall back to another IME.
+ # 0x10008000 = FLAG_ACTIVITY_NEW_TASK | FLAG_ACTIVITY_CLEAR_TASK
+ dev.shell("am", "start", "-n", LAB, "--es", "focus_id", "lab_single", "-f", "0x10008000")
+ settle(3)
+ if "ImeTestLabActivity" not in dev.shell("dumpsys", "activity", "activities").split("topResumedActivity=")[-1][:200]:
+ return False # something else is in front; the checks below would measure the wrong app
+ for _ in range(4):
+ if ime_shown(dev):
+ break
+ fields = edit_fields(dev)
+ if len(fields) >= 2:
+ # Moving the focus away and back makes the client request the IME again.
+ dev.shell("input", "tap", str(fields[1][0]), str(fields[1][1]))
+ settle(1)
+ dev.shell("input", "tap", str(fields[0][0]), str(fields[0][1]))
+ elif fields:
+ dev.shell("input", "tap", str(fields[0][0]), str(fields[0][1]))
+ settle(2)
+ settle(1.5)
+ return ime_shown(dev)
+
+
+def bounds(dev: Device) -> list[tuple[str, float, float, float, float]]:
+ dev.run("logcat", "-c")
+ dev.shell("am", "broadcast", "-n", RECEIVER, "-a", ACTION, "--es", "cmd", "bounds")
+ settle(1)
+ log = dev.run("logcat", "-d", "-s", "OpenIme:I")
+ items = []
+ for match in re.finditer(r"tag=([^|]*)\|desc=([^|]*)\|([-0-9.eE]+),([-0-9.eE]+),([-0-9.eE]+),([-0-9.eE]+)", log):
+ x, y, w, h = (float(value) for value in match.groups()[2:])
+ items.append((match.group(1), x, y, w, h))
+ return items
+
+
+def check(dev: Device, name: str, out: Path) -> bool:
+ problems: list[str] = []
+ if not show_ime(dev):
+ problems.append("the keyboard did not show")
+ else:
+ # The first inFullscreenMode= in the dump is the service's current state;
+ # the ones after it are history entries.
+ fullscreen = re.search(r"\binFullscreenMode=(true|false)", dev.shell("dumpsys", "input_method"))
+ if fullscreen and fullscreen.group(1) == "true":
+ problems.append("the keyboard is in fullscreen (extract) mode")
+ items = bounds(dev)
+ if not items:
+ problems.append("no bounds were reported")
+ for tag, x, y, w, h in items:
+ if not tag.startswith(KEYBOARD_ITEMS):
+ continue
+ if w <= 0.005 or h <= 0.005:
+ problems.append(f"{tag} has no size ({w:.3f}x{h:.3f})")
+ elif x < -0.002 or y < -0.002 or x + w > 1.002 or y + h > 1.002:
+ problems.append(f"{tag} leaves the window (x={x:.3f} w={w:.3f} y={y:.3f} h={h:.3f})")
+ out.mkdir(parents=True, exist_ok=True)
+ (out / f"{name}.png").write_bytes(dev.run("exec-out", "screencap", "-p", binary=True))
+ verdict = "FAIL" if problems else "PASS"
+ detail = f" :: {'; '.join(problems[:4])}" if problems else f" ({len(items)} items)"
+ print(f"{verdict} {name}{detail}")
+ return not problems
+
+
+def main() -> int:
+ parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
+ parser.add_argument("--serial")
+ parser.add_argument("--out", type=Path, default=Path(".local/test-runs/display-matrix"))
+ parser.add_argument("cases", nargs="*", help=f"subset of: {', '.join(CASES)}")
+ args = parser.parse_args()
+ unknown = [name for name in args.cases if name not in CASES]
+ if unknown:
+ parser.error(f"unknown case(s): {', '.join(unknown)}")
+
+ dev = Device(args.serial)
+ results: dict[str, bool] = {}
+ try:
+ for name, case in CASES.items():
+ if args.cases and name not in args.cases:
+ continue
+ reset(dev)
+ apply(dev, case)
+ results[name] = check(dev, name, args.out)
+ finally:
+ reset(dev)
+ failed = [name for name, ok in results.items() if not ok]
+ print(f"SUMMARY {len(results) - len(failed)} passed, {len(failed)} failed" + (f": {failed}" if failed else ""))
+ return 1 if failed else 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())