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())