Compare commits
332
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
31ab1ff004 | ||
|
|
576dd58a6b | ||
|
|
a9c940d39f | ||
|
|
f6eb93027d | ||
|
|
e94bdf6583 | ||
|
|
6aa434dad5 | ||
|
|
222f691b78 | ||
|
|
436c0f06f6 | ||
|
|
4a9e22ea77 | ||
|
|
6d158ca9bd | ||
|
|
b35b1b53ab | ||
|
|
504c500e20 | ||
|
|
db53de1a7e | ||
|
|
d716d26864 | ||
|
|
8babd1d2bb | ||
|
|
53d7a9fb69 | ||
|
|
5989e0d4eb | ||
|
|
4d25d9ec69 | ||
|
|
4e822df5ed | ||
|
|
e0e657d5ee | ||
|
|
c1ebd208b1 | ||
|
|
a94e310cd7 | ||
|
|
f1e894e71f | ||
|
|
1055f03b46 | ||
|
|
9da25b0895 | ||
|
|
e962489520 | ||
|
|
cf00af7b42 | ||
|
|
7b39994715 | ||
|
|
11602c0138 | ||
|
|
591f298dc0 | ||
|
|
9dee236778 | ||
|
|
4b19278733 | ||
|
|
3fe55e8d0c | ||
|
|
3d4a9f288a | ||
|
|
d7d93b4cb0 | ||
|
|
6d4a37e484 | ||
|
|
3b9936e212 | ||
|
|
dc5972213e | ||
|
|
c07a713e86 | ||
|
|
bfa79946d4 | ||
|
|
0e2df1193d | ||
|
|
0e834fc854 | ||
|
|
4fe5509c24 | ||
|
|
cdd58fa9c4 | ||
|
|
541a70f126 | ||
|
|
3d8bc61912 | ||
|
|
9ced7444bd | ||
|
|
2fbf24a675 | ||
|
|
22d655b73b | ||
|
|
55612a3922 | ||
|
|
83a2a88b3c | ||
|
|
53c3c5c8ee | ||
|
|
e0bdaa3537 | ||
|
|
316cd27049 | ||
|
|
c40a752bcf | ||
|
|
df7b504fa2 | ||
|
|
6d4f2cdbae | ||
|
|
af9c63d267 | ||
|
|
c8f19f9c84 | ||
|
|
f9555fba85 | ||
|
|
900d191b8d | ||
|
|
0e03eba6a4 | ||
|
|
6de2f7656a | ||
|
|
5f7489d70e | ||
|
|
a60e3abc12 | ||
|
|
14955ef6c0 | ||
|
|
9ff2de3a1d | ||
|
|
f3c1121887 | ||
|
|
a861881ec4 | ||
|
|
b3ded77c23 | ||
|
|
464e91621e | ||
|
|
ec89d828d1 | ||
|
|
1142bf5ed1 | ||
|
|
53339b369a | ||
|
|
e5e61e131d | ||
|
|
33c320219a | ||
|
|
827f0e61a4 | ||
|
|
30c53451dc | ||
|
|
514901c12d | ||
|
|
59012f711b | ||
|
|
863ac93b25 | ||
|
|
d8e44c6692 | ||
|
|
62f552f87f | ||
|
|
77fbedc350 | ||
|
|
0a43cedea9 | ||
|
|
b06688f1db | ||
|
|
6505512c0a | ||
|
|
bbd02fc279 | ||
|
|
53c7494389 | ||
|
|
611bc5f58a | ||
|
|
659ab83f00 | ||
|
|
ed0824f335 | ||
|
|
f3d8d28cf7 | ||
|
|
f8ac95ceed | ||
|
|
46855af2dd | ||
|
|
0054a3a97a | ||
|
|
126a706422 | ||
|
|
579eccdfd1 | ||
|
|
0243fb78d4 | ||
|
|
d6b0c7cb20 | ||
|
|
37289391f8 | ||
|
|
bbc47dbd31 | ||
|
|
84f343ce5f | ||
|
|
aace581fb0 | ||
|
|
27116fa434 | ||
|
|
85818105ce | ||
|
|
74a46bd2e3 | ||
|
|
d0b8c5f2f7 | ||
|
|
4fce12249e | ||
|
|
9f04fc4304 | ||
|
|
0583b1bc31 | ||
|
|
b604fefbd4 | ||
|
|
620a4e77fa | ||
|
|
9980fb2b28 | ||
|
|
a9cc3c5737 | ||
|
|
a025624798 | ||
|
|
1d150cce85 | ||
|
|
b23948bed0 | ||
|
|
c905a47d6f | ||
|
|
afee5f612d | ||
|
|
8b5d15bdb6 | ||
|
|
5520d6015d | ||
|
|
4c938c7330 | ||
|
|
d9b7c22ecb | ||
|
|
d5d2feee36 | ||
|
|
5dc6408bb9 | ||
|
|
93cf3e359a | ||
|
|
e589ab303d | ||
|
|
40dac87dda | ||
|
|
766843fdbd | ||
|
|
4f860aa499 | ||
|
|
a4ac11a1c1 | ||
|
|
7d1c56e03b | ||
|
|
c09c40388b | ||
|
|
324be57c7a | ||
|
|
df8df67742 | ||
|
|
f2075c1f49 | ||
|
|
df20a21fb5 | ||
|
|
669d05b781 | ||
|
|
ff699f5079 | ||
|
|
3a07e94180 | ||
|
|
ce50bd2c24 | ||
|
|
798c6ee907 | ||
|
|
16699c4958 | ||
|
|
d546f31755 | ||
|
|
b8399b5949 | ||
|
|
b18d864145 | ||
|
|
1d548ddcda | ||
|
|
ec8d54f3a5 | ||
|
|
2bee0b52b0 | ||
|
|
06028cc352 | ||
|
|
866a3c37cd | ||
|
|
4460ca9277 | ||
|
|
3c1d3fd44c | ||
|
|
5556573986 | ||
|
|
f3ee2e0d7b | ||
|
|
4355ebddea | ||
|
|
5a9f52a069 | ||
|
|
97992bdb6b | ||
|
|
eb22521269 | ||
|
|
9f77fb469c | ||
|
|
7b981dd88a | ||
|
|
3611455357 | ||
|
|
31e8e31679 | ||
|
|
67b2e58fdf | ||
|
|
df9fc85f76 | ||
|
|
e60ae7aecc | ||
|
|
b55d220e28 | ||
|
|
a763592ace | ||
|
|
4eaee04255 | ||
|
|
f15f0bf9bc | ||
|
|
d8228fb315 | ||
|
|
aeb37458e8 | ||
|
|
44b58db58a | ||
|
|
a2350a6d5f | ||
|
|
0764bc269c | ||
|
|
6cdf111419 | ||
|
|
ef257c3167 | ||
|
|
405388e325 | ||
|
|
39a63f8d30 | ||
|
|
6e00697110 | ||
|
|
5d4888ab71 | ||
|
|
108feefac4 | ||
|
|
681ae96a9c | ||
|
|
95e657e704 | ||
|
|
98b2274e68 | ||
|
|
cb5fc0c605 | ||
|
|
b0b73d063b | ||
|
|
54e7b35e60 | ||
|
|
9e6a6b395c | ||
|
|
83ea5ddff7 | ||
|
|
e1223fdee5 | ||
|
|
c3cfef75bb | ||
|
|
c73dd532ff | ||
|
|
1f6c16c590 | ||
|
|
f2fbdff9a7 | ||
|
|
79fcd803a0 | ||
|
|
e85209eebb | ||
|
|
bb4bb583d5 | ||
|
|
4847d74d87 | ||
|
|
a776e9f608 | ||
|
|
82d3ca3953 | ||
|
|
005720575a | ||
|
|
29299b190e | ||
|
|
d0bb43c87c | ||
|
|
5e6dc76ae8 | ||
|
|
0d50bc82ca | ||
|
|
ac3f63f8c8 | ||
|
|
be1c372d99 | ||
|
|
c0e78371cb | ||
|
|
7b97440973 | ||
|
|
276b01113b | ||
|
|
27d5680d21 | ||
|
|
86a2217858 | ||
|
|
a0085802dd | ||
|
|
ae49853029 | ||
|
|
691b073e32 | ||
|
|
6c2cc57cdb | ||
|
|
219c50b7c2 | ||
|
|
85a922c01b | ||
|
|
b7ac6b8853 | ||
|
|
3c0ed16ee7 | ||
|
|
eacc65acc9 | ||
|
|
805d8b530a | ||
|
|
e4c6c7c72a | ||
|
|
cfdc094c30 | ||
|
|
b8f0833137 | ||
|
|
b5c0b13210 | ||
|
|
1794e53d1c | ||
|
|
2d382b11cd | ||
|
|
66c2291312 | ||
|
|
e4915a6904 | ||
|
|
28c314262e | ||
|
|
e5fa1b871f | ||
|
|
a84d473e2f | ||
|
|
9f92a8ade5 | ||
|
|
d2169c6174 | ||
|
|
92ee7fedbd | ||
|
|
647cf47cf2 | ||
|
|
3ff4f45166 | ||
|
|
c67aa2da48 | ||
|
|
e8c6389fde | ||
|
|
df6378395e | ||
|
|
8938fffd08 | ||
|
|
7a6f6b0863 | ||
|
|
ea16f48be2 | ||
|
|
351891714a | ||
|
|
5d8ab15c0e | ||
|
|
1f9d1dde01 | ||
|
|
48e41886ba | ||
|
|
020687a82b | ||
|
|
440e441b31 | ||
|
|
d3fc0c8005 | ||
|
|
b579732db6 | ||
|
|
46ac15a538 | ||
|
|
23b46f9310 | ||
|
|
8b907036db | ||
|
|
af11a94614 | ||
|
|
76d49b73d1 | ||
|
|
f10984622c | ||
|
|
557ef3f524 | ||
|
|
9e039863fa | ||
|
|
9854cc477d | ||
|
|
3b4a790e30 | ||
|
|
8633ec9f06 | ||
|
|
ffa7ed2661 | ||
|
|
60b0eacafd | ||
|
|
27b6910009 | ||
|
|
a0d6e3479f | ||
|
|
a7dbee847e | ||
|
|
642bdb13f0 | ||
|
|
c94ea09b59 | ||
|
|
e59ef08cf9 | ||
|
|
ca5596a732 | ||
|
|
2369dd2115 | ||
|
|
29733914a4 | ||
|
|
ab76798bff | ||
|
|
30180ab1e5 | ||
|
|
93941cd1ea | ||
|
|
b5e6f78910 | ||
|
|
f24877d53b | ||
|
|
3ed2377b3c | ||
|
|
a6b4bae2a0 | ||
|
|
a80dac7a7e | ||
|
|
f91a8558ea | ||
|
|
557cdb213d | ||
|
|
095d6e781e | ||
|
|
4834905b24 | ||
|
|
217cfe3d5f | ||
|
|
ae4e4e53f6 | ||
|
|
664fd8dcef | ||
|
|
b44e8d96b9 | ||
|
|
51ea890684 | ||
|
|
f413f8db55 | ||
|
|
cc2f266564 | ||
|
|
b7806951eb | ||
|
|
feeb49ebeb | ||
|
|
e642b0c18c | ||
|
|
4c14028c70 | ||
|
|
d12a920096 | ||
|
|
2a49c25e1b | ||
|
|
c09a21136c | ||
|
|
f5576540b4 | ||
|
|
09a3222b7b | ||
|
|
9f054aad28 | ||
|
|
acff289887 | ||
|
|
a7b3452e6d | ||
|
|
9bc45dc315 | ||
|
|
664fb2d9ed | ||
|
|
cf291b58ab | ||
|
|
df2a26701c | ||
|
|
00856eeaf7 | ||
|
|
61214a18fa | ||
|
|
91117986e4 | ||
|
|
33770d87a6 | ||
|
|
0ea41f5815 | ||
|
|
5a5b02f6fb | ||
|
|
5975827aea | ||
|
|
7faa604fd0 | ||
|
|
5ac0ea3c3b | ||
|
|
5d71c6a2ab | ||
|
|
e72621db94 | ||
|
|
e04995a84d | ||
|
|
67ed0479e4 | ||
|
|
a8a422c4e1 | ||
|
|
6323f89b5b | ||
|
|
01b5f980a5 | ||
|
|
63fe9a12a6 | ||
|
|
171f6ff59a | ||
|
|
0587f5a45f | ||
|
|
253913ad82 | ||
|
|
84de51c7b7 |
No files matched your search
+12
-30
@@ -1,36 +1,18 @@
|
||||
# ---> Android
|
||||
# Gradle files
|
||||
*.iml
|
||||
.gradle/
|
||||
build/
|
||||
|
||||
# Local configuration file (sdk path, etc)
|
||||
.kotlin/
|
||||
# 本机开发凭据(主人授权落地的测试用 key 等)——只在本机,绝不进仓库
|
||||
dev-secrets.local.md
|
||||
local.properties
|
||||
|
||||
# Log/OS Files
|
||||
*.log
|
||||
|
||||
# Android Studio generated files and folders
|
||||
.idea/
|
||||
.DS_Store
|
||||
captures/
|
||||
.externalNativeBuild/
|
||||
.cxx/
|
||||
*.apk
|
||||
output.json
|
||||
|
||||
# IntelliJ
|
||||
*.iml
|
||||
.idea/
|
||||
misc.xml
|
||||
deploymentTargetDropDown.xml
|
||||
render.experimental.xml
|
||||
|
||||
# Keystore files
|
||||
*.jks
|
||||
*.keystore
|
||||
|
||||
# Google Services (e.g. APIs or Firebase)
|
||||
google-services.json
|
||||
|
||||
# Android Profiling
|
||||
*.hprof
|
||||
|
||||
keystore.properties
|
||||
# 主人从手机丢进仓库根当素材用的图(例如换图标用的原图)——只用不存,绝不进仓库
|
||||
/IMG_*.png
|
||||
# 开发文档 —— **主人 2026-10-07 明确指令:整个文件夹禁止进入远程仓库**。
|
||||
# 它只在本机磁盘上(前后端协同留言板、进度交接、设计拆解都在里面),靠磁盘本身交接。
|
||||
# 凭据同样绝不写进去(那类东西在 dev-secrets.local.md)。
|
||||
devdocs/
|
||||
@@ -1,73 +0,0 @@
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
APPENDIX: How to apply the Apache License to your work.
|
||||
|
||||
To apply the Apache License to your work, attach the following boilerplate notice, with the fields enclosed by brackets "[]" replaced with your own identifying information. (Don't include the brackets!) The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a file or class name and description of purpose be included on the same "printed page" as the copyright notice for easier identification within third-party archives.
|
||||
|
||||
Copyright 2026 AskaEth
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
@@ -1,182 +0,0 @@
|
||||
# Cyrene for Android
|
||||
|
||||
昔涟在安卓设备上的载体 —— 随时随地与昔涟对话、操控 IoT 设备、接收实时通知。
|
||||
|
||||
## 概述
|
||||
|
||||
Cyrene for Android 是 [Cyrene(昔涟)](https://github.com/Cyrene/Cyrene) 项目的官方 Android 客户端。Cyrene 是一个开源的基于 LLM 平台的智能体,提供多人格对话、IoT 设备操控、记忆管理、自动化规则、知识库、语音交互等功能。
|
||||
|
||||
Android 客户端的目标是成为用户的**默认语音助手**,完全替代系统自带的助手(Google Assistant / Bixby 等),同时提供:
|
||||
|
||||
- **系统级语音助手** — 注册为 `VoiceInteractionService`,可通过长按 Home / 侧滑呼出
|
||||
- **语音唤醒** — 息屏热词唤醒、免提交互(类似 "Hey Google",使用 "昔涟" 等唤醒词)
|
||||
- **随时对话** — 通过文字或语音与昔涟交流
|
||||
- **IoT 控制** — 远程操控家中的智能设备
|
||||
- **实时通知** — 接收昔涟的主动消息、提醒、IoT 状态变更
|
||||
- **后台连接** — 即使 APP 在后台,仍保持通知推送与语音服务就绪
|
||||
|
||||
## 开发状态
|
||||
|
||||
> **当前阶段**:项目初始化,尚未开始正式开发。
|
||||
|
||||
根据主项目的[开发路线图](../docs/dev-plan/00-development-roadmap.md),Android 客户端计划在 **Phase 5(v1.5 → v2.0)** 开始开发,预计时间窗口为 **2027 Q2-Q3**。
|
||||
|
||||
## 技术栈 (规划)
|
||||
|
||||
| 层 | 技术 |
|
||||
|----|------|
|
||||
| 语言 | Kotlin |
|
||||
| UI 框架 | Jetpack Compose + Material Design 3 |
|
||||
| 架构 | MVVM + Repository |
|
||||
| 网络 | OkHttp / Retrofit + WebSocket |
|
||||
| 本地存储 | Room (SQLite) + DataStore |
|
||||
| 推送 | FCM (Firebase Cloud Messaging) |
|
||||
| 语音 | VoiceInteractionService + 热词唤醒 + STT + TTS |
|
||||
| 构建 | Gradle (Kotlin DSL) |
|
||||
|
||||
## 功能路线图
|
||||
|
||||
- [ ] 用户认证与登录
|
||||
- [ ] 实时文字对话 (WebSocket)
|
||||
- [ ] VoiceInteractionService 注册(替换系统语音助手)
|
||||
- [ ] 间接启动悬浮窗(VoiceInteractionSession 全屏覆盖层)
|
||||
- [ ] 热词唤醒(息屏 / 亮屏)+ 唤醒词自定义
|
||||
- [ ] 语音识别 (STT)
|
||||
- [ ] 语音合成 (TTS)
|
||||
- [ ] IoT 设备控制面板
|
||||
- [ ] 推送通知 (FCM)
|
||||
- [ ] 后台连接与通知
|
||||
- [ ] 锁屏 / 息屏语音交互
|
||||
- [ ] PWA 保底方案 (短期替代)
|
||||
|
||||
## 交互模式
|
||||
|
||||
APP 有两种界面呈现方式,根据启动来源自动切换:
|
||||
|
||||
| 启动方式 | 界面模式 | 说明 |
|
||||
|---------|---------|------|
|
||||
| 桌面图标 / 最近任务 | **全屏 Activity** | 常规 APP 模式,完整功能入口 |
|
||||
| 语音唤醒 / 长按 Home / 侧滑 / 长按电源键 / 耳机按键 | **悬浮窗 (VoiceInteractionSession)** | 全屏覆盖层,半透明背景透出底层 APP,不影响当前任务栈 |
|
||||
|
||||
悬浮窗模式的要点:
|
||||
- 借助 `VoiceInteractionSession` 系统窗口,不压入 Activity 返回栈
|
||||
- 对话结束后窗口收起,用户回到触发前的界面
|
||||
- 底层 APP 内容半透明可见(模糊遮罩),让用户保持上下文感知
|
||||
- 窗口高度自适应对话内容,类似 Google Assistant 的卡片式覆盖
|
||||
|
||||
## 设计规范
|
||||
|
||||
- **设计语言**:Material Design 3 (Material You)
|
||||
- **主题**:动态配色(Dynamic Color),跟随系统壁纸或手动选择主题色
|
||||
- **暗黑模式**:支持 Light / Dark 双主题,跟随系统或手动切换
|
||||
- **组件**:全面使用 `androidx.compose.material3` 组件库
|
||||
- **图标**:Material Icons + 自定义图标
|
||||
- **动效**:遵循 MD3 动效规范(过渡动画、涟漪效果、Shared Elements)
|
||||
- **字体**:系统默认字体(Roboto / Google Sans),支持动态字体缩放
|
||||
|
||||
## 项目结构 (规划)
|
||||
|
||||
```
|
||||
android/
|
||||
├── app/
|
||||
│ ├── src/main/
|
||||
│ │ ├── java/com/cyrene/app/
|
||||
│ │ │ ├── ui/ # Compose UI 层
|
||||
│ │ │ ├── viewmodel/ # ViewModel 层
|
||||
│ │ │ ├── repository/ # 数据仓库层
|
||||
│ │ │ ├── data/ # 数据模型 & API 接口
|
||||
│ │ │ ├── service/ # VoiceInteractionService & WebSocket & FCM
|
||||
│ │ │ ├── voice/ # 热词唤醒 (Hotword) & STT & TTS
|
||||
│ │ │ └── di/ # 依赖注入
|
||||
│ │ └── res/ # 资源文件
|
||||
│ └── build.gradle.kts
|
||||
├── build.gradle.kts
|
||||
├── settings.gradle.kts
|
||||
└── gradle.properties
|
||||
```
|
||||
|
||||
## 环境要求
|
||||
|
||||
- Android Studio Hedgehog (2023.1.1) 或更高版本
|
||||
- Kotlin 2.0+
|
||||
- JDK 17+
|
||||
- Android SDK (target: 34+, min: 26+)
|
||||
|
||||
## 网络配置(中国大陆用户)
|
||||
|
||||
Gradle、Google Maven、Maven Central 等服务器在国内访问缓慢或不可达,首次同步前需配置镜像。
|
||||
|
||||
### Gradle Wrapper
|
||||
|
||||
`gradle/wrapper/gradle-wrapper.properties`:
|
||||
|
||||
```properties
|
||||
# 将 services.gradle.org 替换为腾讯/阿里镜像
|
||||
distributionUrl=https\://mirrors.cloud.tencent.com/gradle/gradle-8.7-bin.zip
|
||||
# 或阿里: https\://mirrors.aliyun.com/macports/distfiles/gradle/gradle-8.7-bin.zip
|
||||
```
|
||||
|
||||
### 仓库镜像
|
||||
|
||||
`settings.gradle.kts` 顶部添加:
|
||||
|
||||
```kotlin
|
||||
pluginManagement {
|
||||
repositories {
|
||||
maven { url = uri("https://mirrors.cloud.tencent.com/gradle/plugins") }
|
||||
maven { url = uri("https://mirrors.tencent.com/nexus/repository/maven-public") }
|
||||
google()
|
||||
mavenCentral()
|
||||
gradlePluginPortal()
|
||||
}
|
||||
}
|
||||
dependencyResolutionManagement {
|
||||
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
|
||||
repositories {
|
||||
maven { url = uri("https://mirrors.tencent.com/nexus/repository/maven-public") }
|
||||
google()
|
||||
mavenCentral()
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Gradle 属性
|
||||
|
||||
`gradle.properties`:
|
||||
|
||||
```properties
|
||||
# HTTP 代理(如使用 Clash/V2Ray,通常不需要)
|
||||
# systemProp.http.proxyHost=127.0.0.1
|
||||
# systemProp.http.proxyPort=7890
|
||||
# systemProp.https.proxyHost=127.0.0.1
|
||||
# systemProp.https.proxyPort=7890
|
||||
```
|
||||
|
||||
### Android SDK Proxy
|
||||
|
||||
若 Android SDK Manager 也下载缓慢,可在 Android Studio 中设置:
|
||||
**Settings → Appearance & Behavior → System Settings → HTTP Proxy**
|
||||
|
||||
配置为 `mirrors.cloud.tencent.com` 或 `mirrors.neusoft.edu.cn`,端口 `80`。
|
||||
|
||||
## 快速开始
|
||||
|
||||
> 项目尚未包含可构建的源码,以下为后续开发的参考步骤。
|
||||
|
||||
1. 用 Android Studio 打开 `android/` 目录
|
||||
2. 按上述"网络配置"替换国内镜像
|
||||
3. 等待 Gradle 同步完成
|
||||
4. 启动主项目后端服务(参考[主项目 README](../README.md))
|
||||
5. 在 `local.properties` 中配置 `baseUrl` 指向 Gateway 地址
|
||||
6. 选择模拟器或设备,点击 Run
|
||||
|
||||
## 相关链接
|
||||
|
||||
- [Cyrene 主项目](../) — 后端服务、前端、部署文档
|
||||
- [开发路线图](../docs/dev-plan/00-development-roadmap.md)
|
||||
- [多平台接入方案](../docs/dev-plan/03-multi-platform-integration.md)
|
||||
- [语音系统计划](../docs/dev-plan/04-voice-system-plan.md)
|
||||
|
||||
## License
|
||||
|
||||
Apache-2.0
|
||||
+36
-75
@@ -1,58 +1,30 @@
|
||||
import java.util.Properties
|
||||
|
||||
plugins {
|
||||
alias(libs.plugins.android.application)
|
||||
alias(libs.plugins.kotlin.android)
|
||||
alias(libs.plugins.kotlin.compose)
|
||||
alias(libs.plugins.ksp)
|
||||
}
|
||||
|
||||
val keystorePropertiesFile = rootProject.file("keystore.properties")
|
||||
val keystoreProperties = Properties()
|
||||
if (keystorePropertiesFile.exists()) {
|
||||
keystoreProperties.load(keystorePropertiesFile.inputStream())
|
||||
alias(libs.plugins.kotlin.serialization)
|
||||
}
|
||||
|
||||
android {
|
||||
|
||||
signingConfigs {
|
||||
if (keystorePropertiesFile.exists()) {
|
||||
create("release") {
|
||||
storeFile = rootProject.file(keystoreProperties["storeFile"] as String)
|
||||
storePassword = keystoreProperties["storePassword"] as String
|
||||
keyAlias = keystoreProperties["keyAlias"] as String
|
||||
keyPassword = keystoreProperties["keyPassword"] as String
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
namespace = "top.yeij.cyrene"
|
||||
compileSdk = 36
|
||||
compileSdk = 37
|
||||
buildToolsVersion = "37.0.0"
|
||||
|
||||
defaultConfig {
|
||||
applicationId = "top.yeij.cyrene"
|
||||
minSdk = 26
|
||||
targetSdk = 36
|
||||
minSdk = 33
|
||||
targetSdk = 37
|
||||
versionCode = 1
|
||||
versionName = "0.1.0"
|
||||
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
if (keystorePropertiesFile.exists()) {
|
||||
signingConfig = signingConfigs.getByName("release")
|
||||
}
|
||||
isMinifyEnabled = true
|
||||
isMinifyEnabled = false
|
||||
proguardFiles(
|
||||
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||
"proguard-rules.pro"
|
||||
"proguard-rules.pro",
|
||||
)
|
||||
}
|
||||
debug {
|
||||
isMinifyEnabled = false
|
||||
}
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
@@ -60,58 +32,47 @@ android {
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
}
|
||||
|
||||
kotlinOptions {
|
||||
jvmTarget = "17"
|
||||
}
|
||||
|
||||
buildFeatures {
|
||||
compose = true
|
||||
buildConfig = true
|
||||
}
|
||||
}
|
||||
|
||||
kotlin {
|
||||
compilerOptions {
|
||||
jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17)
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
// Compose BOM
|
||||
val composeBom = platform(libs.compose.bom)
|
||||
implementation(composeBom)
|
||||
implementation(libs.compose.ui)
|
||||
implementation(libs.compose.ui.graphics)
|
||||
implementation(libs.compose.ui.tooling.preview)
|
||||
implementation(libs.compose.material3)
|
||||
implementation(libs.compose.material.icons)
|
||||
debugImplementation(libs.compose.ui.tooling)
|
||||
implementation(libs.androidx.core.ktx)
|
||||
implementation(libs.androidx.lifecycle.runtime.ktx)
|
||||
implementation(libs.androidx.lifecycle.viewmodel.compose)
|
||||
implementation(libs.androidx.activity.compose)
|
||||
|
||||
// Activity & Navigation
|
||||
implementation(libs.compose.activity)
|
||||
implementation(libs.compose.navigation)
|
||||
implementation(platform(libs.androidx.compose.bom))
|
||||
implementation(libs.androidx.compose.ui)
|
||||
implementation(libs.androidx.compose.ui.graphics)
|
||||
implementation(libs.androidx.compose.ui.tooling.preview)
|
||||
implementation(libs.androidx.compose.foundation)
|
||||
|
||||
// Lifecycle
|
||||
implementation(libs.lifecycle.runtime)
|
||||
implementation(libs.lifecycle.viewmodel)
|
||||
implementation(libs.miuix.ui)
|
||||
implementation(libs.miuix.icons)
|
||||
implementation(libs.miuix.preference)
|
||||
|
||||
// Room
|
||||
implementation(libs.room.runtime)
|
||||
implementation(libs.room.ktx)
|
||||
ksp(libs.room.compiler)
|
||||
|
||||
// Network
|
||||
implementation(libs.retrofit)
|
||||
implementation(libs.retrofit.gson)
|
||||
implementation(libs.retrofit.kotlinx.serialization)
|
||||
implementation(libs.okhttp)
|
||||
implementation(libs.okhttp.logging)
|
||||
implementation(libs.kotlinx.serialization.json)
|
||||
implementation(libs.kotlinx.coroutines.android)
|
||||
implementation(libs.androidx.security.crypto)
|
||||
implementation(libs.androidx.datastore.preferences)
|
||||
implementation(libs.coil.compose)
|
||||
implementation(libs.coil.network.okhttp)
|
||||
|
||||
// Koin DI
|
||||
implementation(libs.koin.android)
|
||||
implementation(libs.koin.compose)
|
||||
debugImplementation(libs.androidx.compose.ui.tooling)
|
||||
|
||||
// DataStore
|
||||
implementation(libs.datastore)
|
||||
|
||||
// Coroutines
|
||||
implementation(libs.coroutines)
|
||||
|
||||
// Core
|
||||
implementation(libs.core.ktx)
|
||||
|
||||
// Biometric
|
||||
implementation(libs.biometric)
|
||||
testImplementation(libs.junit)
|
||||
}
|
||||
|
||||
Vendored
+9
-26
@@ -1,27 +1,10 @@
|
||||
# Cyrene ProGuard Rules
|
||||
# kotlinx-serialization:保留 @Serializable 类的序列化器(M2 起启用)
|
||||
-keepattributes *Annotation*, InnerClasses
|
||||
-dontnote kotlinx.serialization.**
|
||||
|
||||
# Retrofit
|
||||
-keepattributes Signature
|
||||
-keepattributes *Annotation*
|
||||
-keep class top.yeij.cyrene.data.remote.dto.** { *; }
|
||||
-dontwarn retrofit2.**
|
||||
-keep class retrofit2.** { *; }
|
||||
|
||||
# Gson
|
||||
-keep class com.google.gson.** { *; }
|
||||
-keepattributes EnclosingMethod
|
||||
|
||||
# OkHttp
|
||||
-dontwarn okhttp3.**
|
||||
-dontwarn okio.**
|
||||
|
||||
# Room
|
||||
-keep class * extends androidx.room.RoomDatabase
|
||||
-dontwarn androidx.room.paging.**
|
||||
|
||||
# Koin
|
||||
-keep class org.koin.** { *; }
|
||||
|
||||
# Coroutines
|
||||
-keepnames class kotlinx.coroutines.internal.MainDispatcherFactory {}
|
||||
-keepnames class kotlinx.coroutines.CoroutineExceptionHandler {}
|
||||
-keepclassmembers class **$$serializer {
|
||||
*** descriptor;
|
||||
}
|
||||
-keepclasseswithmembers class ** {
|
||||
kotlinx.serialization.KSerializer serializer(...);
|
||||
}
|
||||
@@ -1,89 +1,94 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<!-- 网络 -->
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
<!--
|
||||
「后台保持连接」用到的三个权限(设置页那个开关,默认关):
|
||||
|
||||
<!-- 语音助手核心 -->
|
||||
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
|
||||
<uses-permission android:name="android.permission.CAPTURE_AUDIO_HOTWORD" />
|
||||
|
||||
<!-- 后台服务 -->
|
||||
- FOREGROUND_SERVICE:起前台服务本身。
|
||||
- FOREGROUND_SERVICE_DATA_SYNC:前台服务的类型权限(API 34+ 与 android:foregroundServiceType
|
||||
配合)。选 dataSync 是因为这条服务保的是一条**长连接**——保持与后端的实时同步,
|
||||
既不是 mediaPlayback 也不是 location,声明成别的类型才是说谎。
|
||||
- POST_NOTIFICATIONS:前台服务必须有一条通知,而 minSdk 33 ⇒ 它恒是运行时权限。
|
||||
用户拒绝时开关**打不开**(判据见 core.keepalive.shouldKeepAliveRun),本端不偷偷跑。
|
||||
-->
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MICROPHONE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
|
||||
|
||||
<!-- 推送 -->
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
|
||||
<!-- 锁屏交互 -->
|
||||
<uses-permission android:name="android.permission.USE_FULL_SCREEN_INTENT" />
|
||||
<!--
|
||||
「用户按住麦克风说话」用的麦克风权限(契约 v0.96 §3.15 ①)。
|
||||
|
||||
<!-- 查询其他应用(检查默认助手设置) -->
|
||||
<queries>
|
||||
<intent>
|
||||
<action android:name="android.service.voice.VoiceInteractionService" />
|
||||
</intent>
|
||||
</queries>
|
||||
它是**运行时权限**(minSdk 33 ⇒ 恒是):本端只在用户**第一次点麦克风**时申请,
|
||||
被拒之后不再重复申请(Android 不会二次弹框,重复申请等于点了没反应),
|
||||
改由真机设置页授权——文案见 `ui.chat.VOICE_PERMISSION_DENIED_MESSAGE`。
|
||||
没有这个权限时录音机直接起不来,本端如实说一句,绝不假装在录。
|
||||
-->
|
||||
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||
|
||||
<!--
|
||||
联网权限**显式**声明,不靠任何传递依赖。
|
||||
|
||||
事实(W52K 跑 `:app:processReleaseMainManifest` 核过):本端一直能联网,是靠
|
||||
`com.squareup.okhttp3:okhttp-android:5.5.0` 的清单把 `INTERNET` 合并进来的;而 debug 那边
|
||||
AGP 也注入同一份 ⇒ **本地怎么试都看不出来**。一旦换掉/排除那个依赖,release 会**静默失去**
|
||||
网络权限(不报错、只是所有请求都失败)。显式写在这里,从此与第三方清单无关。
|
||||
-->
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
|
||||
<application
|
||||
android:name=".CyreneApplication"
|
||||
android:allowBackup="true"
|
||||
android:name=".app.CyreneApplication"
|
||||
android:allowBackup="false"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
android:networkSecurityConfig="@xml/network_security_config"
|
||||
android:roundIcon="@mipmap/ic_launcher_round"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/Theme.Cyrene"
|
||||
android:usesCleartextTraffic="true">
|
||||
|
||||
<!-- 全屏主界面 -->
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
android:launchMode="singleTask"
|
||||
android:theme="@style/Theme.Cyrene">
|
||||
|
||||
<!--
|
||||
launchMode="singleTop":App 已经开着时从别的 App 分享过来,系统不再新建一个实例,
|
||||
改成回调 onNewIntent(见 MainActivity)。不写它就会起第二个 Activity 实例、
|
||||
两个实例各持一套 ViewModel / socket。
|
||||
-->
|
||||
<activity
|
||||
android:name=".app.MainActivity"
|
||||
android:exported="true"
|
||||
android:label="@string/app_name"
|
||||
android:launchMode="singleTop"
|
||||
android:windowSoftInputMode="adjustResize">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
|
||||
<!--
|
||||
分享入口:只声明本端真能落地的两类 MIME(纯文本 / 图片)。
|
||||
刻意不声明 */*——对话路径上今天没有「文件」这个表示(协同文档 02/F1 的三缺),
|
||||
声明了就等于给出一个点进去必然失败的入口。标签沿用应用名(活动不设自己的
|
||||
label / icon 时,分享菜单取的就是 Cyrene 的名字与启动图标)。
|
||||
-->
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.SEND" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<data android:mimeType="text/plain" />
|
||||
<data android:mimeType="image/*" />
|
||||
</intent-filter>
|
||||
|
||||
<!-- 多图分享只收图片:多条纯文本的 EXTRA_TEXT(SEND_MULTIPLE + text/plain)本端不接。 -->
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.SEND_MULTIPLE" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<data android:mimeType="image/*" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<!-- VoiceInteractionService:系统语音助手 -->
|
||||
<service
|
||||
android:name=".service.CyreneVoiceInteractionService"
|
||||
android:exported="true"
|
||||
android:permission="android.permission.BIND_VOICE_INTERACTION">
|
||||
|
||||
<meta-data
|
||||
android:name="android.voice_interaction"
|
||||
android:resource="@xml/voice_interaction_config" />
|
||||
|
||||
<intent-filter>
|
||||
<action android:name="android.service.voice.VoiceInteractionService" />
|
||||
</intent-filter>
|
||||
</service>
|
||||
|
||||
<!-- VoiceInteractionSessionService -->
|
||||
<service
|
||||
android:name=".service.CyreneSessionService"
|
||||
android:exported="true"
|
||||
android:permission="android.permission.BIND_VOICE_INTERACTION" />
|
||||
|
||||
<!-- AccessibilityService:读取屏幕内容 -->
|
||||
<service
|
||||
android:name=".service.CyreneAccessibilityService"
|
||||
android:exported="true"
|
||||
android:permission="android.permission.BIND_ACCESSIBILITY_SERVICE">
|
||||
<intent-filter>
|
||||
<action android:name="android.accessibilityservice.AccessibilityService" />
|
||||
</intent-filter>
|
||||
<meta-data
|
||||
android:name="android.accessibilityservice"
|
||||
android:resource="@xml/accessibility_config" />
|
||||
</service>
|
||||
|
||||
<!-- FileProvider:日志分享 -->
|
||||
<!--
|
||||
附件预览的「用其它应用打开 / 保存」出口:`cacheDir/documents/` 下的文件要交给
|
||||
系统里别的 App 读,就必须是 content:// 而不是 file://——API 24 起把 file:// 交出去
|
||||
会直接抛 FileUriExposedException(且 StrictMode 下同样致命)。
|
||||
路径范围见 res/xml/file_paths.xml:只暴露 cacheDir/documents/ 这一层,
|
||||
exported="false" + 按次授权(grantUriPermissions)保证这份缓存不对外常开。
|
||||
-->
|
||||
<provider
|
||||
android:name="androidx.core.content.FileProvider"
|
||||
android:authorities="${applicationId}.fileprovider"
|
||||
@@ -94,6 +99,21 @@
|
||||
android:resource="@xml/file_paths" />
|
||||
</provider>
|
||||
|
||||
<!--
|
||||
「后台保持连接」的前台服务(设置页开关控制,默认关)。
|
||||
|
||||
exported="false":它没有任何对外接口(onBind 返回 null),只由本进程 start/stop。
|
||||
|
||||
foregroundServiceType="dataSync":这条服务的职责是让进程活着,从而让那条**进程级共享**
|
||||
的实时连接(AppContainer.chatSocket)不被 Android 12+ 的 cached 进程冻结掐断——
|
||||
语义上就是「后台数据同步」。它不自己去建立 / 关闭任何连接,连接的主人是
|
||||
SessionsViewModel(见 KeepAliveService 的类注释)。
|
||||
-->
|
||||
<service
|
||||
android:name=".core.keepalive.KeepAliveService"
|
||||
android:exported="false"
|
||||
android:foregroundServiceType="dataSync" />
|
||||
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
@@ -1,100 +0,0 @@
|
||||
package top.yeij.cyrene
|
||||
|
||||
import android.app.Activity
|
||||
import android.app.Application
|
||||
import android.os.Bundle
|
||||
import android.util.Log
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.flow.firstOrNull
|
||||
import kotlinx.coroutines.launch
|
||||
import org.koin.android.ext.koin.androidContext
|
||||
import org.koin.core.context.GlobalContext
|
||||
import org.koin.core.context.startKoin
|
||||
import top.yeij.cyrene.data.local.PreferencesDataStore
|
||||
import top.yeij.cyrene.data.remote.AuthInterceptor
|
||||
import top.yeij.cyrene.data.remote.DynamicUrlInterceptor
|
||||
import top.yeij.cyrene.data.repository.ChatRepositoryImpl
|
||||
import top.yeij.cyrene.di.appModule
|
||||
import top.yeij.cyrene.util.NotificationHelper
|
||||
import top.yeij.cyrene.util.RuntimeLog
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
|
||||
class CyreneApplication : Application() {
|
||||
|
||||
private val initScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
private val activityCount = AtomicInteger(0)
|
||||
@Volatile
|
||||
private var notificationHelper: NotificationHelper? = null
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
RuntimeLog.general("app", "Application onCreate")
|
||||
|
||||
startKoin {
|
||||
androidContext(this@CyreneApplication)
|
||||
modules(appModule)
|
||||
}
|
||||
|
||||
// Track foreground/background state
|
||||
registerActivityLifecycleCallbacks(object : ActivityLifecycleCallbacks {
|
||||
override fun onActivityStarted(activity: Activity) {
|
||||
if (activityCount.incrementAndGet() == 1) {
|
||||
RuntimeLog.general("app", "App in foreground")
|
||||
notificationHelper?.cancelAll()
|
||||
getRepo()?.onAppForeground()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onActivityStopped(activity: Activity) {
|
||||
if (activityCount.decrementAndGet() == 0) {
|
||||
RuntimeLog.general("app", "App in background")
|
||||
getRepo()?.onAppBackground()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) {}
|
||||
override fun onActivityResumed(activity: Activity) {}
|
||||
override fun onActivityPaused(activity: Activity) {}
|
||||
override fun onActivitySaveInstanceState(activity: Activity, outState: Bundle) {}
|
||||
override fun onActivityDestroyed(activity: Activity) {}
|
||||
})
|
||||
|
||||
// Set up background notification callback once Koin is ready
|
||||
initScope.launch {
|
||||
val helper = NotificationHelper(this@CyreneApplication)
|
||||
notificationHelper = helper
|
||||
val repo = getRepo()
|
||||
repo?.setNotificationCallback { message ->
|
||||
helper.showMessageNotification(message)
|
||||
}
|
||||
}
|
||||
|
||||
initScope.launch {
|
||||
val koin = GlobalContext.get()
|
||||
val prefs: PreferencesDataStore = koin.get()
|
||||
val urlInterceptor: DynamicUrlInterceptor = koin.get()
|
||||
val authInterceptor: AuthInterceptor = koin.get()
|
||||
|
||||
prefs.baseUrl.firstOrNull()?.let { url ->
|
||||
if (url.isNotBlank()) urlInterceptor.baseUrl = url
|
||||
}
|
||||
prefs.token.firstOrNull()?.let { token ->
|
||||
authInterceptor.token = token
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun getRepo(): ChatRepositoryImpl? {
|
||||
return try {
|
||||
GlobalContext.get().get()
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "CyreneApp"
|
||||
}
|
||||
}
|
||||
@@ -1,78 +0,0 @@
|
||||
package top.yeij.cyrene
|
||||
|
||||
import android.content.ComponentName
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import android.provider.Settings
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.navigation.compose.rememberNavController
|
||||
import org.koin.compose.koinInject
|
||||
import top.yeij.cyrene.data.local.PreferencesDataStore
|
||||
import top.yeij.cyrene.service.CyreneVoiceInteractionService
|
||||
import top.yeij.cyrene.ui.navigation.CyreneNavGraph
|
||||
import top.yeij.cyrene.ui.navigation.Routes
|
||||
import top.yeij.cyrene.ui.theme.CyreneTheme
|
||||
import top.yeij.cyrene.util.Constants
|
||||
|
||||
class MainActivity : ComponentActivity() {
|
||||
|
||||
private val isDefaultAssistant = mutableStateOf(false)
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
enableEdgeToEdge()
|
||||
|
||||
isDefaultAssistant.value = checkIsDefaultAssistant()
|
||||
|
||||
setContent {
|
||||
val prefs: PreferencesDataStore = koinInject()
|
||||
val themeMode by prefs.themeMode.collectAsState(initial = null)
|
||||
val darkTheme = when (themeMode) {
|
||||
"light" -> false
|
||||
"dark" -> true
|
||||
else -> isSystemInDarkTheme()
|
||||
}
|
||||
|
||||
CyreneTheme(darkTheme = darkTheme) {
|
||||
val navController = rememberNavController()
|
||||
|
||||
CyreneNavGraph(
|
||||
navController = navController,
|
||||
startDestination = Routes.MAIN,
|
||||
isDefaultAssistant = isDefaultAssistant.value,
|
||||
onOpenAssistantSettings = { openAssistantSettings() },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
isDefaultAssistant.value = checkIsDefaultAssistant()
|
||||
}
|
||||
|
||||
override fun onNewIntent(intent: Intent) {
|
||||
super.onNewIntent(intent)
|
||||
setIntent(intent)
|
||||
}
|
||||
|
||||
private fun checkIsDefaultAssistant(): Boolean {
|
||||
// Standard Android check
|
||||
val flat = ComponentName(this, CyreneVoiceInteractionService::class.java).flattenToString()
|
||||
val current = Settings.Secure.getString(contentResolver, "voice_interaction_service")
|
||||
if (current == flat) return true
|
||||
// Fallback for COS and other custom OS: check persisted flag from service
|
||||
if (CyreneVoiceInteractionService.wasEverActive(this)) return true
|
||||
return false
|
||||
}
|
||||
|
||||
private fun openAssistantSettings() {
|
||||
startActivity(Intent(Settings.ACTION_VOICE_INPUT_SETTINGS))
|
||||
}
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
package top.yeij.cyrene.data.local
|
||||
|
||||
import android.content.Context
|
||||
import androidx.room.Database
|
||||
import androidx.room.Room
|
||||
import androidx.room.RoomDatabase
|
||||
import top.yeij.cyrene.data.local.dao.ConversationDao
|
||||
import top.yeij.cyrene.data.local.dao.MessageDao
|
||||
import top.yeij.cyrene.data.local.entity.ConversationEntity
|
||||
import top.yeij.cyrene.data.local.entity.MessageEntity
|
||||
|
||||
@Database(
|
||||
entities = [ConversationEntity::class, MessageEntity::class],
|
||||
version = 1,
|
||||
exportSchema = false,
|
||||
)
|
||||
abstract class AppDatabase : RoomDatabase() {
|
||||
|
||||
abstract fun conversationDao(): ConversationDao
|
||||
abstract fun messageDao(): MessageDao
|
||||
|
||||
companion object {
|
||||
@Volatile
|
||||
private var INSTANCE: AppDatabase? = null
|
||||
|
||||
fun getInstance(context: Context): AppDatabase {
|
||||
return INSTANCE ?: synchronized(this) {
|
||||
INSTANCE ?: Room.databaseBuilder(
|
||||
context.applicationContext,
|
||||
AppDatabase::class.java,
|
||||
"cyrene.db",
|
||||
)
|
||||
.fallbackToDestructiveMigration()
|
||||
.build()
|
||||
.also { INSTANCE = it }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,138 +0,0 @@
|
||||
package top.yeij.cyrene.data.local
|
||||
|
||||
import android.content.Context
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.datastore.preferences.core.booleanPreferencesKey
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import androidx.datastore.preferences.preferencesDataStore
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.map
|
||||
|
||||
private val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "cyrene_prefs")
|
||||
|
||||
class PreferencesDataStore(private val context: Context) {
|
||||
|
||||
companion object {
|
||||
private val KEY_TOKEN = stringPreferencesKey("jwt_token")
|
||||
private val KEY_REFRESH_TOKEN = stringPreferencesKey("refresh_token")
|
||||
private val KEY_BASE_URL = stringPreferencesKey("base_url")
|
||||
private val KEY_THEME = stringPreferencesKey("theme_mode")
|
||||
private val KEY_WAKE_WORD = stringPreferencesKey("wake_word")
|
||||
private val KEY_USERNAME = stringPreferencesKey("username")
|
||||
private val KEY_CLIENT_ID = stringPreferencesKey("client_id")
|
||||
private val KEY_DEVICE_NAME = stringPreferencesKey("device_name")
|
||||
private val KEY_CURRENT_SESSION_ID = stringPreferencesKey("current_session_id")
|
||||
private val KEY_DASHSCOPE_API_KEY = stringPreferencesKey("dashscope_api_key")
|
||||
private val KEY_DASHSCOPE_ENDPOINT = stringPreferencesKey("dashscope_endpoint")
|
||||
private val KEY_DASHSCOPE_MODEL = stringPreferencesKey("dashscope_model")
|
||||
private val KEY_LAST_CLEARED_TIMESTAMP = stringPreferencesKey("last_cleared_timestamp")
|
||||
private val KEY_PROFILE_USER_ID = stringPreferencesKey("profile_user_id")
|
||||
private val KEY_PROFILE_NICKNAME = stringPreferencesKey("profile_nickname")
|
||||
private val KEY_PROFILE_IS_ADMIN = stringPreferencesKey("profile_is_admin")
|
||||
private val KEY_PROFILE_CREATED_AT = stringPreferencesKey("profile_created_at")
|
||||
private val KEY_AUTO_SCREEN_CONTEXT = booleanPreferencesKey("auto_screen_context")
|
||||
}
|
||||
|
||||
val token: Flow<String?> = context.dataStore.data.map { it[KEY_TOKEN] }
|
||||
val refreshToken: Flow<String?> = context.dataStore.data.map { it[KEY_REFRESH_TOKEN] }
|
||||
val baseUrl: Flow<String?> = context.dataStore.data.map { it[KEY_BASE_URL] }
|
||||
val themeMode: Flow<String?> = context.dataStore.data.map { it[KEY_THEME] }
|
||||
val wakeWord: Flow<String?> = context.dataStore.data.map { it[KEY_WAKE_WORD] }
|
||||
val username: Flow<String?> = context.dataStore.data.map { it[KEY_USERNAME] }
|
||||
val clientId: Flow<String?> = context.dataStore.data.map { it[KEY_CLIENT_ID] }
|
||||
val deviceName: Flow<String?> = context.dataStore.data.map { it[KEY_DEVICE_NAME] }
|
||||
val currentSessionId: Flow<String?> = context.dataStore.data.map { it[KEY_CURRENT_SESSION_ID] }
|
||||
val dashScopeApiKey: Flow<String?> = context.dataStore.data.map { it[KEY_DASHSCOPE_API_KEY] }
|
||||
val dashScopeEndpoint: Flow<String?> = context.dataStore.data.map { it[KEY_DASHSCOPE_ENDPOINT] }
|
||||
val dashScopeModel: Flow<String?> = context.dataStore.data.map { it[KEY_DASHSCOPE_MODEL] }
|
||||
|
||||
suspend fun saveToken(token: String) {
|
||||
context.dataStore.edit { it[KEY_TOKEN] = token }
|
||||
}
|
||||
|
||||
suspend fun saveRefreshToken(token: String) {
|
||||
context.dataStore.edit { it[KEY_REFRESH_TOKEN] = token }
|
||||
}
|
||||
|
||||
suspend fun saveBaseUrl(url: String) {
|
||||
context.dataStore.edit { it[KEY_BASE_URL] = url }
|
||||
}
|
||||
|
||||
suspend fun saveThemeMode(mode: String) {
|
||||
context.dataStore.edit { it[KEY_THEME] = mode }
|
||||
}
|
||||
|
||||
suspend fun saveWakeWord(word: String) {
|
||||
context.dataStore.edit { it[KEY_WAKE_WORD] = word }
|
||||
}
|
||||
|
||||
suspend fun saveUsername(username: String) {
|
||||
context.dataStore.edit { it[KEY_USERNAME] = username }
|
||||
}
|
||||
|
||||
suspend fun saveDashScopeApiKey(key: String) {
|
||||
context.dataStore.edit { it[KEY_DASHSCOPE_API_KEY] = key }
|
||||
}
|
||||
|
||||
suspend fun saveDashScopeEndpoint(endpoint: String) {
|
||||
context.dataStore.edit { it[KEY_DASHSCOPE_ENDPOINT] = endpoint }
|
||||
}
|
||||
|
||||
suspend fun saveDashScopeModel(model: String) {
|
||||
context.dataStore.edit { it[KEY_DASHSCOPE_MODEL] = model }
|
||||
}
|
||||
|
||||
suspend fun saveClientId(id: String) {
|
||||
context.dataStore.edit { it[KEY_CLIENT_ID] = id }
|
||||
}
|
||||
|
||||
suspend fun saveDeviceName(name: String) {
|
||||
context.dataStore.edit { it[KEY_DEVICE_NAME] = name }
|
||||
}
|
||||
|
||||
suspend fun saveCurrentSessionId(id: String) {
|
||||
context.dataStore.edit { it[KEY_CURRENT_SESSION_ID] = id }
|
||||
}
|
||||
|
||||
val lastClearedTimestamp: Flow<String?> = context.dataStore.data.map { it[KEY_LAST_CLEARED_TIMESTAMP] }
|
||||
|
||||
suspend fun saveLastClearedTimestamp(timestamp: Long) {
|
||||
context.dataStore.edit { it[KEY_LAST_CLEARED_TIMESTAMP] = timestamp.toString() }
|
||||
}
|
||||
|
||||
// Cached profile for offline / local-first rendering
|
||||
val profileUserId: Flow<String?> = context.dataStore.data.map { it[KEY_PROFILE_USER_ID] }
|
||||
val profileNickname: Flow<String?> = context.dataStore.data.map { it[KEY_PROFILE_NICKNAME] }
|
||||
val profileIsAdmin: Flow<String?> = context.dataStore.data.map { it[KEY_PROFILE_IS_ADMIN] }
|
||||
val profileCreatedAt: Flow<String?> = context.dataStore.data.map { it[KEY_PROFILE_CREATED_AT] }
|
||||
|
||||
suspend fun saveProfileCache(userId: String, nickname: String, isAdmin: Boolean, createdAt: String) {
|
||||
context.dataStore.edit {
|
||||
it[KEY_PROFILE_USER_ID] = userId
|
||||
it[KEY_PROFILE_NICKNAME] = nickname
|
||||
it[KEY_PROFILE_IS_ADMIN] = isAdmin.toString()
|
||||
it[KEY_PROFILE_CREATED_AT] = createdAt
|
||||
}
|
||||
}
|
||||
|
||||
val autoScreenContext: Flow<Boolean> = context.dataStore.data.map { it[KEY_AUTO_SCREEN_CONTEXT] ?: false }
|
||||
|
||||
suspend fun saveAutoScreenContext(enabled: Boolean) {
|
||||
context.dataStore.edit { it[KEY_AUTO_SCREEN_CONTEXT] = enabled }
|
||||
}
|
||||
|
||||
suspend fun clearProfileCache() {
|
||||
context.dataStore.edit {
|
||||
it.remove(KEY_PROFILE_USER_ID)
|
||||
it.remove(KEY_PROFILE_NICKNAME)
|
||||
it.remove(KEY_PROFILE_IS_ADMIN)
|
||||
it.remove(KEY_PROFILE_CREATED_AT)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun clearAll() {
|
||||
context.dataStore.edit { it.clear() }
|
||||
}
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
package top.yeij.cyrene.data.local.dao
|
||||
|
||||
import androidx.room.Dao
|
||||
import androidx.room.Insert
|
||||
import androidx.room.OnConflictStrategy
|
||||
import androidx.room.Query
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import top.yeij.cyrene.data.local.entity.ConversationEntity
|
||||
|
||||
@Dao
|
||||
interface ConversationDao {
|
||||
|
||||
@Query("SELECT * FROM conversations ORDER BY updatedAt DESC")
|
||||
fun getAll(): Flow<List<ConversationEntity>>
|
||||
|
||||
@Query("SELECT * FROM conversations ORDER BY updatedAt DESC")
|
||||
suspend fun getAllSnapshot(): List<ConversationEntity>
|
||||
|
||||
@Query("SELECT * FROM conversations WHERE id = :id")
|
||||
suspend fun getById(id: String): ConversationEntity?
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
suspend fun upsert(conversation: ConversationEntity)
|
||||
|
||||
@Query("DELETE FROM conversations WHERE id = :id")
|
||||
suspend fun deleteById(id: String)
|
||||
|
||||
@Query("DELETE FROM conversations")
|
||||
suspend fun deleteAll()
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
package top.yeij.cyrene.data.local.dao
|
||||
|
||||
import androidx.room.Dao
|
||||
import androidx.room.Insert
|
||||
import androidx.room.OnConflictStrategy
|
||||
import androidx.room.Query
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import top.yeij.cyrene.data.local.entity.MessageEntity
|
||||
|
||||
@Dao
|
||||
interface MessageDao {
|
||||
|
||||
@Query("SELECT * FROM messages WHERE conversationId = :conversationId ORDER BY timestamp ASC")
|
||||
fun getByConversation(conversationId: String): Flow<List<MessageEntity>>
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
suspend fun upsert(message: MessageEntity)
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
suspend fun upsertAll(messages: List<MessageEntity>)
|
||||
|
||||
@Query("DELETE FROM messages WHERE conversationId = :conversationId")
|
||||
suspend fun deleteByConversation(conversationId: String)
|
||||
|
||||
@Query("UPDATE messages SET conversationId = :newId WHERE conversationId = :oldId")
|
||||
suspend fun migrateConversationId(oldId: String, newId: String)
|
||||
|
||||
@Query("DELETE FROM messages WHERE id = :id")
|
||||
suspend fun deleteById(id: String)
|
||||
|
||||
@Query("DELETE FROM messages")
|
||||
suspend fun deleteAll()
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
package top.yeij.cyrene.data.local.entity
|
||||
|
||||
import androidx.room.Entity
|
||||
import androidx.room.PrimaryKey
|
||||
|
||||
@Entity(tableName = "conversations")
|
||||
data class ConversationEntity(
|
||||
@PrimaryKey val id: String,
|
||||
val title: String,
|
||||
val lastMessage: String?,
|
||||
val lastMessageType: String?,
|
||||
val updatedAt: Long,
|
||||
val createdAt: Long,
|
||||
)
|
||||
@@ -1,27 +0,0 @@
|
||||
package top.yeij.cyrene.data.local.entity
|
||||
|
||||
import androidx.room.Entity
|
||||
import androidx.room.ForeignKey
|
||||
import androidx.room.Index
|
||||
import androidx.room.PrimaryKey
|
||||
|
||||
@Entity(
|
||||
tableName = "messages",
|
||||
foreignKeys = [
|
||||
ForeignKey(
|
||||
entity = ConversationEntity::class,
|
||||
parentColumns = ["id"],
|
||||
childColumns = ["conversationId"],
|
||||
onDelete = ForeignKey.CASCADE,
|
||||
),
|
||||
],
|
||||
indices = [Index("conversationId")],
|
||||
)
|
||||
data class MessageEntity(
|
||||
@PrimaryKey val id: String,
|
||||
val conversationId: String,
|
||||
val role: String,
|
||||
val content: String,
|
||||
val msgType: String,
|
||||
val timestamp: Long,
|
||||
)
|
||||
@@ -1,62 +0,0 @@
|
||||
package top.yeij.cyrene.data.remote
|
||||
|
||||
import retrofit2.Response
|
||||
import retrofit2.http.Body
|
||||
import retrofit2.http.DELETE
|
||||
import retrofit2.http.GET
|
||||
import retrofit2.http.POST
|
||||
import retrofit2.http.Path
|
||||
import retrofit2.http.Query
|
||||
import top.yeij.cyrene.data.remote.dto.AuthRequest
|
||||
import top.yeij.cyrene.data.remote.dto.AuthResponse
|
||||
import top.yeij.cyrene.data.remote.dto.ProfileResponse
|
||||
import top.yeij.cyrene.data.remote.dto.CreateSessionRequest
|
||||
import top.yeij.cyrene.data.remote.dto.DeviceDto
|
||||
import top.yeij.cyrene.data.remote.dto.IoTControlRequest
|
||||
import top.yeij.cyrene.data.remote.dto.MessagesListResponse
|
||||
import top.yeij.cyrene.data.remote.dto.RefreshTokenRequest
|
||||
import top.yeij.cyrene.data.remote.dto.SessionDto
|
||||
import top.yeij.cyrene.data.remote.dto.SessionsListResponse
|
||||
|
||||
interface ApiService {
|
||||
|
||||
// Auth
|
||||
@POST("api/v1/auth/login")
|
||||
suspend fun login(@Body request: AuthRequest): Response<AuthResponse>
|
||||
|
||||
@POST("api/v1/auth/refresh")
|
||||
suspend fun refreshToken(@Body request: RefreshTokenRequest): Response<AuthResponse>
|
||||
|
||||
@GET("api/v1/profile")
|
||||
suspend fun getProfile(): Response<ProfileResponse>
|
||||
|
||||
// Sessions
|
||||
@GET("api/v1/sessions")
|
||||
suspend fun getSessions(): Response<SessionsListResponse>
|
||||
|
||||
@POST("api/v1/sessions")
|
||||
suspend fun createSession(@Body request: CreateSessionRequest): Response<SessionDto>
|
||||
|
||||
@DELETE("api/v1/sessions/{id}")
|
||||
suspend fun deleteSession(@Path("id") id: String): Response<Unit>
|
||||
|
||||
@DELETE("api/v1/sessions/{id}/messages")
|
||||
suspend fun clearSessionMessages(@Path("id") sessionId: String): Response<Unit>
|
||||
|
||||
@GET("api/v1/sessions/{id}/messages")
|
||||
suspend fun getSessionMessages(
|
||||
@Path("id") sessionId: String,
|
||||
@Query("limit") limit: Int = 50,
|
||||
@Query("offset") offset: Int = 0,
|
||||
): Response<MessagesListResponse>
|
||||
|
||||
// IoT — 注意:网关 API 文档未列出 IoT 端点,需确认网关是否代理了 /api/v1/iot/*
|
||||
@GET("api/v1/iot/devices")
|
||||
suspend fun getDevices(): Response<List<DeviceDto>>
|
||||
|
||||
@POST("api/v1/iot/devices/{id}/control")
|
||||
suspend fun controlDevice(
|
||||
@Path("id") deviceId: String,
|
||||
@Body request: IoTControlRequest,
|
||||
): Response<DeviceDto>
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
package top.yeij.cyrene.data.remote
|
||||
|
||||
import okhttp3.Interceptor
|
||||
import okhttp3.Response
|
||||
|
||||
class AuthInterceptor : Interceptor {
|
||||
|
||||
@Volatile
|
||||
var token: String? = null
|
||||
|
||||
override fun intercept(chain: Interceptor.Chain): Response {
|
||||
val request = if (!token.isNullOrEmpty()) {
|
||||
chain.request().newBuilder()
|
||||
.addHeader("Authorization", "Bearer $token")
|
||||
.build()
|
||||
} else {
|
||||
chain.request()
|
||||
}
|
||||
return chain.proceed(request)
|
||||
}
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
package top.yeij.cyrene.data.remote
|
||||
|
||||
import android.util.Log
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
||||
import okhttp3.Interceptor
|
||||
import okhttp3.Response
|
||||
|
||||
class DynamicUrlInterceptor : Interceptor {
|
||||
|
||||
companion object {
|
||||
private const val TAG = "DynamicUrlInterceptor"
|
||||
}
|
||||
|
||||
@Volatile
|
||||
var baseUrl: String = "http://10.0.2.2:8080/"
|
||||
|
||||
override fun intercept(chain: Interceptor.Chain): Response {
|
||||
val originalRequest = chain.request()
|
||||
val originalUrl = originalRequest.url
|
||||
|
||||
val targetBase = baseUrl.trimEnd('/')
|
||||
val parsed = targetBase.toHttpUrlOrNull()
|
||||
if (parsed == null) {
|
||||
Log.e(TAG, "Invalid baseUrl: '$baseUrl'")
|
||||
return chain.proceed(chain.request())
|
||||
}
|
||||
|
||||
val newUrl = originalUrl.newBuilder()
|
||||
.scheme(parsed.scheme)
|
||||
.host(parsed.host)
|
||||
.port(parsed.port)
|
||||
.build()
|
||||
|
||||
Log.d(TAG, "Rewriting ${originalUrl} → $newUrl (base: $targetBase)")
|
||||
return chain.proceed(originalRequest.newBuilder().url(newUrl).build())
|
||||
}
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
package top.yeij.cyrene.data.remote
|
||||
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.logging.HttpLoggingInterceptor
|
||||
import retrofit2.Retrofit
|
||||
import retrofit2.converter.gson.GsonConverterFactory
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
object RetrofitClient {
|
||||
|
||||
fun provideOkHttpClient(
|
||||
authInterceptor: AuthInterceptor,
|
||||
dynamicUrlInterceptor: DynamicUrlInterceptor,
|
||||
tokenAuthenticator: TokenAuthenticator,
|
||||
): OkHttpClient {
|
||||
val logging = HttpLoggingInterceptor().apply {
|
||||
level = HttpLoggingInterceptor.Level.BODY
|
||||
}
|
||||
|
||||
return OkHttpClient.Builder()
|
||||
.addInterceptor(dynamicUrlInterceptor)
|
||||
.addInterceptor(authInterceptor)
|
||||
.authenticator(tokenAuthenticator)
|
||||
.addInterceptor(logging)
|
||||
.connectTimeout(30, TimeUnit.SECONDS)
|
||||
.readTimeout(60, TimeUnit.SECONDS)
|
||||
.writeTimeout(30, TimeUnit.SECONDS)
|
||||
.build()
|
||||
}
|
||||
|
||||
fun provideRetrofit(okHttpClient: OkHttpClient): Retrofit {
|
||||
return Retrofit.Builder()
|
||||
.baseUrl("http://localhost/")
|
||||
.client(okHttpClient)
|
||||
.addConverterFactory(GsonConverterFactory.create())
|
||||
.build()
|
||||
}
|
||||
}
|
||||
@@ -1,84 +0,0 @@
|
||||
package top.yeij.cyrene.data.remote
|
||||
|
||||
import com.google.gson.Gson
|
||||
import kotlinx.coroutines.flow.firstOrNull
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import okhttp3.Authenticator
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import okhttp3.Route
|
||||
import top.yeij.cyrene.data.local.PreferencesDataStore
|
||||
import top.yeij.cyrene.data.remote.dto.AuthResponse
|
||||
import top.yeij.cyrene.data.remote.dto.RefreshTokenRequest
|
||||
import android.util.Log
|
||||
|
||||
class TokenAuthenticator(
|
||||
private val authInterceptor: AuthInterceptor,
|
||||
private val preferencesDataStore: PreferencesDataStore,
|
||||
private val dynamicUrlInterceptor: DynamicUrlInterceptor,
|
||||
) : Authenticator {
|
||||
|
||||
private val gson = Gson()
|
||||
private val client = OkHttpClient()
|
||||
|
||||
override fun authenticate(route: Route?, response: okhttp3.Response): Request? {
|
||||
if (response.code != 401) return null
|
||||
|
||||
synchronized(this) {
|
||||
// Double-check: if token changed while we were waiting for lock, use it
|
||||
val currentToken = authInterceptor.token
|
||||
val requestAuthHeader = response.request.header("Authorization")
|
||||
if (requestAuthHeader != null && currentToken != null &&
|
||||
requestAuthHeader != "Bearer $currentToken"
|
||||
) {
|
||||
return response.request.newBuilder()
|
||||
.header("Authorization", "Bearer $currentToken")
|
||||
.build()
|
||||
}
|
||||
|
||||
// Try refresh
|
||||
return try {
|
||||
val refreshToken = runBlocking { preferencesDataStore.refreshToken.firstOrNull() }
|
||||
?: return null
|
||||
|
||||
val baseUrl = dynamicUrlInterceptor.baseUrl.trimEnd('/')
|
||||
val refreshUrl = "$baseUrl/api/v1/auth/refresh"
|
||||
|
||||
val jsonBody = gson.toJson(RefreshTokenRequest(refreshToken))
|
||||
val body = jsonBody.toRequestBody("application/json".toMediaType())
|
||||
|
||||
val refreshRequest = Request.Builder()
|
||||
.url(refreshUrl)
|
||||
.post(body)
|
||||
.build()
|
||||
|
||||
val refreshResponse = client.newCall(refreshRequest).execute()
|
||||
if (refreshResponse.isSuccessful) {
|
||||
val authResponse = gson.fromJson(
|
||||
refreshResponse.body?.string(),
|
||||
AuthResponse::class.java
|
||||
)
|
||||
authInterceptor.token = authResponse.token
|
||||
runBlocking {
|
||||
preferencesDataStore.saveToken(authResponse.token)
|
||||
authResponse.refreshToken?.let {
|
||||
preferencesDataStore.saveRefreshToken(it)
|
||||
}
|
||||
}
|
||||
Log.i("TokenAuthenticator", "Token refreshed successfully")
|
||||
response.request.newBuilder()
|
||||
.header("Authorization", "Bearer ${authResponse.token}")
|
||||
.build()
|
||||
} else {
|
||||
Log.w("TokenAuthenticator", "Token refresh failed: ${refreshResponse.code}")
|
||||
null
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e("TokenAuthenticator", "Token refresh error: ${e.message}", e)
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
package top.yeij.cyrene.data.remote.dto
|
||||
|
||||
import com.google.gson.annotations.SerializedName
|
||||
|
||||
data class AuthRequest(
|
||||
@SerializedName("username") val username: String,
|
||||
@SerializedName("password") val password: String,
|
||||
)
|
||||
|
||||
data class AuthResponse(
|
||||
@SerializedName("token") val token: String,
|
||||
@SerializedName("refresh_token") val refreshToken: String?,
|
||||
@SerializedName("username") val username: String?,
|
||||
@SerializedName("nickname") val nickname: String?,
|
||||
@SerializedName("user_id") val userId: String?,
|
||||
@SerializedName("expires") val expires: Long? = null,
|
||||
)
|
||||
|
||||
data class RefreshTokenRequest(
|
||||
@SerializedName("refresh_token") val refreshToken: String,
|
||||
)
|
||||
|
||||
data class ProfileResponse(
|
||||
@SerializedName("user_id") val userId: String,
|
||||
@SerializedName("username") val username: String,
|
||||
@SerializedName("nickname") val nickname: String?,
|
||||
@SerializedName("is_admin") val isAdmin: Boolean? = false,
|
||||
@SerializedName("created_at") val createdAt: String? = null,
|
||||
)
|
||||
@@ -1,26 +0,0 @@
|
||||
package top.yeij.cyrene.data.remote.dto
|
||||
|
||||
import com.google.gson.annotations.SerializedName
|
||||
|
||||
data class DeviceDto(
|
||||
@SerializedName("id") val id: String,
|
||||
@SerializedName("name") val name: String,
|
||||
@SerializedName("type") val type: String,
|
||||
@SerializedName("state") val state: DeviceState,
|
||||
@SerializedName("room") val room: String?,
|
||||
)
|
||||
|
||||
data class DeviceState(
|
||||
@SerializedName("power") val power: Boolean?,
|
||||
@SerializedName("brightness") val brightness: Int?,
|
||||
@SerializedName("temperature") val temperature: Float?,
|
||||
@SerializedName("humidity") val humidity: Float?,
|
||||
@SerializedName("colorTemp") val colorTemp: Int?,
|
||||
@SerializedName("locked") val locked: Boolean?,
|
||||
@SerializedName("open") val open: Boolean?,
|
||||
)
|
||||
|
||||
data class IoTControlRequest(
|
||||
@SerializedName("action") val action: String,
|
||||
@SerializedName("value") val value: Any? = null,
|
||||
)
|
||||
@@ -1,38 +0,0 @@
|
||||
package top.yeij.cyrene.data.remote.dto
|
||||
|
||||
import com.google.gson.annotations.SerializedName
|
||||
|
||||
// POST /api/v1/sessions — request body
|
||||
data class CreateSessionRequest(
|
||||
@SerializedName("session_id") val sessionId: String? = null,
|
||||
@SerializedName("title") val title: String = "新的对话",
|
||||
@SerializedName("is_main") val isMain: Boolean = false,
|
||||
)
|
||||
|
||||
// GET /api/v1/sessions — response wrapper
|
||||
data class SessionsListResponse(
|
||||
@SerializedName("sessions") val sessions: List<SessionDto>,
|
||||
)
|
||||
|
||||
data class SessionDto(
|
||||
@SerializedName("id") val id: String,
|
||||
@SerializedName("user_id") val userId: String?,
|
||||
@SerializedName("title") val title: String,
|
||||
@SerializedName("is_main") val isMain: Boolean?,
|
||||
@SerializedName("created_at") val createdAt: Long,
|
||||
@SerializedName("updated_at") val updatedAt: Long,
|
||||
)
|
||||
|
||||
// GET /api/v1/sessions/{id}/messages — response wrapper
|
||||
data class MessagesListResponse(
|
||||
@SerializedName("messages") val messages: List<SessionMessageDto>,
|
||||
)
|
||||
|
||||
data class SessionMessageDto(
|
||||
@SerializedName("id") val id: String,
|
||||
@SerializedName("session_id") val sessionId: String,
|
||||
@SerializedName("role") val role: String,
|
||||
@SerializedName("msg_type") val msgType: String?,
|
||||
@SerializedName("content") val content: String,
|
||||
@SerializedName("created_at") val createdAt: Long,
|
||||
)
|
||||
@@ -1,86 +0,0 @@
|
||||
package top.yeij.cyrene.data.remote.dto
|
||||
|
||||
import com.google.gson.annotations.SerializedName
|
||||
|
||||
// --- Client → Server ---
|
||||
|
||||
data class WSClientMessage(
|
||||
@SerializedName("type") val type: String,
|
||||
@SerializedName("session_id") val sessionId: String? = null,
|
||||
@SerializedName("mode") val mode: String? = null,
|
||||
@SerializedName("content") val content: String? = null,
|
||||
@SerializedName("audio_data") val audioData: String? = null,
|
||||
@SerializedName("attachments") val attachments: List<WSAttachment>? = null,
|
||||
@SerializedName("timestamp") val timestamp: Long? = null,
|
||||
@SerializedName("client_id") val clientId: String? = null,
|
||||
@SerializedName("device_name") val deviceName: String? = null,
|
||||
@SerializedName("user_agent") val userAgent: String? = null,
|
||||
)
|
||||
|
||||
data class WSAttachment(
|
||||
@SerializedName("type") val type: String,
|
||||
@SerializedName("url") val url: String? = null,
|
||||
@SerializedName("thumbnail_url") val thumbnailUrl: String? = null,
|
||||
@SerializedName("filename") val filename: String? = null,
|
||||
@SerializedName("width") val width: Int? = null,
|
||||
@SerializedName("height") val height: Int? = null,
|
||||
@SerializedName("size") val size: Long? = null,
|
||||
@SerializedName("description") val description: String? = null,
|
||||
)
|
||||
|
||||
// --- Server → Client ---
|
||||
|
||||
data class WSClientInfo(
|
||||
@SerializedName("client_id") val clientId: String? = null,
|
||||
@SerializedName("device_name") val deviceName: String? = null,
|
||||
)
|
||||
|
||||
data class WSServerMessage(
|
||||
@SerializedName("type") val type: String?,
|
||||
@SerializedName("message_id") val messageId: String? = null,
|
||||
@SerializedName("text") val text: String? = null,
|
||||
@SerializedName("content") val content: String? = null,
|
||||
@SerializedName("role") val role: String? = null,
|
||||
@SerializedName("msg_type") val msgType: String? = null,
|
||||
@SerializedName("session_id") val sessionId: String? = null,
|
||||
@SerializedName("error") val error: String? = null,
|
||||
@SerializedName("review_messages") val reviewMessages: List<WSReviewMessage>? = null,
|
||||
@SerializedName("messages") val messages: List<WSHistoryMessage>? = null,
|
||||
@SerializedName("multi_message") val multiMessages: List<WSMultiItem>? = null,
|
||||
@SerializedName("tool_progress") val toolProgress: WSToolProgress? = null,
|
||||
@SerializedName("thinking_status") val thinkingStatus: String? = null,
|
||||
@SerializedName("thinking_content") val thinkingContent: String? = null,
|
||||
@SerializedName("timestamp") val timestamp: Long? = null,
|
||||
@SerializedName("client_info") val clientInfo: WSClientInfo? = null,
|
||||
)
|
||||
|
||||
data class WSReviewMessage(
|
||||
@SerializedName("role") val role: String?,
|
||||
@SerializedName("text") val text: String?,
|
||||
@SerializedName("content") val content: String?,
|
||||
@SerializedName("msg_type") val msgType: String?,
|
||||
@SerializedName("delay_ms") val delayMs: Long? = 0,
|
||||
)
|
||||
|
||||
data class WSHistoryMessage(
|
||||
@SerializedName("id") val id: String?,
|
||||
@SerializedName("role") val role: String?,
|
||||
@SerializedName("content") val content: String?,
|
||||
@SerializedName("msg_type") val msgType: String?,
|
||||
@SerializedName("timestamp") val timestamp: Long?,
|
||||
@SerializedName("name") val name: String?,
|
||||
)
|
||||
|
||||
data class WSMultiItem(
|
||||
@SerializedName("role") val role: String?,
|
||||
@SerializedName("content") val content: String?,
|
||||
@SerializedName("msg_type") val msgType: String?,
|
||||
)
|
||||
|
||||
data class WSToolProgress(
|
||||
@SerializedName("tool_name") val toolName: String?,
|
||||
@SerializedName("status") val status: String?,
|
||||
@SerializedName("detail") val detail: String?,
|
||||
@SerializedName("message") val message: String?,
|
||||
@SerializedName("progress") val progress: Int? = null,
|
||||
)
|
||||
@@ -1,50 +0,0 @@
|
||||
package top.yeij.cyrene.data.repository
|
||||
|
||||
import top.yeij.cyrene.data.local.PreferencesDataStore
|
||||
import top.yeij.cyrene.data.remote.ApiService
|
||||
import top.yeij.cyrene.data.remote.AuthInterceptor
|
||||
import top.yeij.cyrene.data.remote.dto.AuthRequest
|
||||
import top.yeij.cyrene.domain.model.AuthResult
|
||||
import top.yeij.cyrene.domain.repository.AuthRepository
|
||||
import kotlinx.coroutines.flow.firstOrNull
|
||||
|
||||
class AuthRepositoryImpl(
|
||||
private val apiService: ApiService,
|
||||
private val preferencesDataStore: PreferencesDataStore,
|
||||
private val authInterceptor: AuthInterceptor,
|
||||
) : AuthRepository {
|
||||
|
||||
override suspend fun login(username: String, password: String): Result<AuthResult> {
|
||||
return try {
|
||||
val response = apiService.login(AuthRequest(username, password))
|
||||
if (response.isSuccessful) {
|
||||
val body = response.body()!!
|
||||
authInterceptor.token = body.token
|
||||
preferencesDataStore.saveToken(body.token)
|
||||
body.refreshToken?.let { preferencesDataStore.saveRefreshToken(it) }
|
||||
val displayName = body.nickname ?: body.username ?: body.userId ?: "开拓者"
|
||||
preferencesDataStore.saveUsername(displayName)
|
||||
Result.success(
|
||||
AuthResult(
|
||||
token = body.token,
|
||||
refreshToken = body.refreshToken,
|
||||
username = displayName,
|
||||
)
|
||||
)
|
||||
} else {
|
||||
Result.failure(Exception("登录失败: ${response.code()}"))
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun logout() {
|
||||
authInterceptor.token = null
|
||||
preferencesDataStore.clearAll()
|
||||
}
|
||||
|
||||
override suspend fun isLoggedIn(): Boolean {
|
||||
return preferencesDataStore.token.firstOrNull() != null
|
||||
}
|
||||
}
|
||||
@@ -1,692 +0,0 @@
|
||||
package top.yeij.cyrene.data.repository
|
||||
|
||||
import android.util.Log
|
||||
import kotlinx.coroutines.CoroutineExceptionHandler
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.firstOrNull
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
import top.yeij.cyrene.data.local.PreferencesDataStore
|
||||
import top.yeij.cyrene.data.local.dao.ConversationDao
|
||||
import top.yeij.cyrene.data.local.dao.MessageDao
|
||||
import top.yeij.cyrene.data.local.entity.ConversationEntity
|
||||
import top.yeij.cyrene.data.local.entity.MessageEntity
|
||||
import top.yeij.cyrene.data.remote.ApiService
|
||||
import top.yeij.cyrene.data.remote.dto.CreateSessionRequest
|
||||
import top.yeij.cyrene.data.remote.dto.WSServerMessage
|
||||
import top.yeij.cyrene.domain.model.Conversation
|
||||
import top.yeij.cyrene.domain.model.Message
|
||||
import top.yeij.cyrene.domain.repository.ChatRepository
|
||||
import top.yeij.cyrene.service.WebSocketService
|
||||
import top.yeij.cyrene.util.RuntimeLog
|
||||
import java.util.UUID
|
||||
|
||||
class ChatRepositoryImpl(
|
||||
private val conversationDao: ConversationDao,
|
||||
private val messageDao: MessageDao,
|
||||
private val webSocketService: WebSocketService,
|
||||
private val apiService: ApiService,
|
||||
private val preferencesDataStore: PreferencesDataStore,
|
||||
) : ChatRepository {
|
||||
|
||||
private val exceptionHandler = CoroutineExceptionHandler { _, e ->
|
||||
Log.e("ChatRepository", "Unhandled exception", e)
|
||||
}
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO + exceptionHandler)
|
||||
|
||||
private val _connectionState = MutableStateFlow(false)
|
||||
override val connectionState: StateFlow<Boolean> = _connectionState.asStateFlow()
|
||||
|
||||
override val connectionError: StateFlow<String?> = webSocketService.connectionError
|
||||
|
||||
private val _incomingMessages = MutableSharedFlow<Message>(extraBufferCapacity = 64)
|
||||
override fun observeMessages(): Flow<Message> = _incomingMessages
|
||||
|
||||
private val _messageClearEvents = MutableSharedFlow<Unit>(extraBufferCapacity = 4)
|
||||
override val messageClearEvents: Flow<Unit> = _messageClearEvents
|
||||
|
||||
private val _isAssistantStreaming = MutableStateFlow(false)
|
||||
override val isAssistantStreaming: StateFlow<Boolean> = _isAssistantStreaming.asStateFlow()
|
||||
|
||||
private var streamingContent = ""
|
||||
private var streamingMessageId: String? = null
|
||||
override var currentSessionId: String? = null
|
||||
|
||||
private var isAppInForeground = false
|
||||
private var onBackgroundNotification: ((Message) -> Unit)? = null
|
||||
private var historyRequested = false
|
||||
private val notifiedMessageIds = mutableSetOf<String>()
|
||||
|
||||
// Duplicate suppression: track items from review/multi_message to skip wrapping response
|
||||
private val recentParsedContents = mutableListOf<String>()
|
||||
private var lastParsedTime = 0L
|
||||
// Track last response to clean up if review/multi_message arrives after
|
||||
private var lastResponseId: String? = null
|
||||
private var lastResponseContent: String? = null
|
||||
private var lastResponseTime = 0L
|
||||
|
||||
fun setNotificationCallback(callback: ((Message) -> Unit)?) {
|
||||
onBackgroundNotification = callback
|
||||
}
|
||||
|
||||
override fun onAppForeground() {
|
||||
isAppInForeground = true
|
||||
notifiedMessageIds.clear()
|
||||
if (!_connectionState.value) {
|
||||
webSocketService.forceReconnect()
|
||||
}
|
||||
// Always request history on foreground to catch cross-device messages
|
||||
scope.launch {
|
||||
val sid = currentSessionId ?: return@launch
|
||||
RuntimeLog.general("app", "Foreground — requesting history for session=$sid")
|
||||
requestHistoryViaWs(sid)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onAppBackground() {
|
||||
isAppInForeground = false
|
||||
}
|
||||
|
||||
init {
|
||||
// Restore persisted session ID, then connect and load history
|
||||
scope.launch {
|
||||
val persistedSid = preferencesDataStore.currentSessionId.firstOrNull()
|
||||
if (!persistedSid.isNullOrBlank()) {
|
||||
currentSessionId = persistedSid
|
||||
}
|
||||
RuntimeLog.ws("init", "Connecting WebSocket session=$currentSessionId")
|
||||
webSocketService.connect(currentSessionId)
|
||||
loadConversationsFromServer()
|
||||
}
|
||||
scope.launch {
|
||||
webSocketService.isConnected.collect { connected ->
|
||||
_connectionState.value = connected
|
||||
RuntimeLog.ws("connection", "Connected=$connected")
|
||||
}
|
||||
}
|
||||
scope.launch {
|
||||
webSocketService.incomingMessages.collect { wsMsg ->
|
||||
try {
|
||||
RuntimeLog.ws("receive", "type=${wsMsg.type} msgId=${wsMsg.messageId ?: "-"}")
|
||||
handleServerMessage(wsMsg)
|
||||
} catch (e: Exception) {
|
||||
Log.e("ChatRepository", "Error handling ${wsMsg.type}: ${e.message}", e)
|
||||
RuntimeLog.ws("error", "Handle error type=${wsMsg.type}: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun getConversations(): Flow<List<Conversation>> {
|
||||
return conversationDao.getAll().map { entities ->
|
||||
entities.map { it.toDomain() }
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun getMessages(conversationId: String): Flow<List<Message>> {
|
||||
return messageDao.getByConversation(conversationId).map { entities ->
|
||||
entities.map { it.toDomain() }
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun deleteConversation(id: String) {
|
||||
conversationDao.deleteById(id)
|
||||
try { apiService.deleteSession(id) } catch (_: Exception) { }
|
||||
}
|
||||
|
||||
override suspend fun clearLocalMessages() {
|
||||
val now = System.currentTimeMillis()
|
||||
messageDao.deleteAll()
|
||||
preferencesDataStore.saveLastClearedTimestamp(now)
|
||||
|
||||
// Also clear server-side messages for all known sessions
|
||||
try {
|
||||
val sessions = conversationDao.getAllSnapshot()
|
||||
sessions.forEach { session ->
|
||||
try {
|
||||
apiService.clearSessionMessages(session.id)
|
||||
RuntimeLog.chat("clear", "Server messages cleared for session=${session.id}")
|
||||
} catch (_: Exception) { }
|
||||
}
|
||||
} catch (_: Exception) { }
|
||||
|
||||
_messageClearEvents.tryEmit(Unit)
|
||||
|
||||
RuntimeLog.chat("clear", "Local messages cleared, timestamp=$now")
|
||||
Log.i("ChatRepository", "Local messages cleared, timestamp: $now")
|
||||
}
|
||||
|
||||
private suspend fun changeSessionId(newId: String) {
|
||||
val oldId = currentSessionId
|
||||
if (oldId != null && oldId != newId) {
|
||||
messageDao.migrateConversationId(oldId, newId)
|
||||
conversationDao.deleteById(oldId)
|
||||
}
|
||||
currentSessionId = newId
|
||||
preferencesDataStore.saveCurrentSessionId(newId)
|
||||
}
|
||||
|
||||
override suspend fun connectWebSocket(sessionId: String?) {
|
||||
currentSessionId = sessionId
|
||||
webSocketService.connect(sessionId)
|
||||
}
|
||||
|
||||
override suspend fun reconnectWebSocket() {
|
||||
webSocketService.disconnect()
|
||||
webSocketService.connect(currentSessionId)
|
||||
}
|
||||
|
||||
override suspend fun ensureConnected() {
|
||||
if (_connectionState.value) return
|
||||
webSocketService.forceReconnect()
|
||||
}
|
||||
|
||||
override suspend fun sendMessage(content: String, sessionId: String?) {
|
||||
val messageId = UUID.randomUUID().toString()
|
||||
val now = System.currentTimeMillis()
|
||||
val sid = sessionId ?: currentSessionId ?: "default"
|
||||
if (currentSessionId == null) {
|
||||
currentSessionId = sid
|
||||
scope.launch { preferencesDataStore.saveCurrentSessionId(sid) }
|
||||
}
|
||||
|
||||
RuntimeLog.chat("send", "session=$sid msgId=$messageId content=${content.take(80)}")
|
||||
|
||||
conversationDao.upsert(
|
||||
ConversationEntity(
|
||||
id = sid,
|
||||
title = "对话",
|
||||
lastMessage = content,
|
||||
lastMessageType = "chat",
|
||||
updatedAt = now,
|
||||
createdAt = now,
|
||||
)
|
||||
)
|
||||
|
||||
messageDao.upsert(
|
||||
MessageEntity(
|
||||
id = messageId,
|
||||
conversationId = sid,
|
||||
role = "user",
|
||||
content = content,
|
||||
msgType = "chat",
|
||||
timestamp = now,
|
||||
)
|
||||
)
|
||||
|
||||
emitMessage(
|
||||
id = messageId,
|
||||
sessionId = sid,
|
||||
role = "user",
|
||||
content = content,
|
||||
msgType = "chat",
|
||||
timestamp = now,
|
||||
isStreaming = false,
|
||||
)
|
||||
|
||||
webSocketService.sendMessage(content, sid)
|
||||
}
|
||||
|
||||
override suspend fun loadConversationsFromServer() {
|
||||
try {
|
||||
val response = apiService.getSessions()
|
||||
if (response.isSuccessful) {
|
||||
response.body()?.sessions?.forEach { dto ->
|
||||
conversationDao.upsert(
|
||||
ConversationEntity(
|
||||
id = dto.id,
|
||||
title = dto.title,
|
||||
lastMessage = "",
|
||||
lastMessageType = "chat",
|
||||
updatedAt = dto.updatedAt,
|
||||
createdAt = dto.createdAt,
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
} catch (_: Exception) { }
|
||||
}
|
||||
|
||||
override suspend fun sendScreenContext(content: String) {
|
||||
webSocketService.sendScreenContext(content, currentSessionId)
|
||||
}
|
||||
|
||||
override suspend fun sendVoiceInput(audioBase64: String, mode: String) {
|
||||
webSocketService.sendVoiceInput(audioBase64, currentSessionId, mode)
|
||||
}
|
||||
|
||||
override suspend fun initializeSession(): String {
|
||||
// Try to find an existing main session on the server
|
||||
try {
|
||||
val response = apiService.getSessions()
|
||||
if (response.isSuccessful) {
|
||||
val sessions = response.body()?.sessions ?: emptyList()
|
||||
val mainSession = sessions.find { it.isMain == true }
|
||||
if (mainSession != null) {
|
||||
currentSessionId = mainSession.id
|
||||
preferencesDataStore.saveCurrentSessionId(mainSession.id)
|
||||
Log.i("ChatRepository", "Found main session: ${mainSession.id}")
|
||||
return mainSession.id
|
||||
}
|
||||
}
|
||||
} catch (_: Exception) { }
|
||||
|
||||
// No main session found on server, create one with deterministic ID
|
||||
val sessionId = "session_admin_main"
|
||||
try {
|
||||
apiService.createSession(
|
||||
CreateSessionRequest(
|
||||
sessionId = sessionId,
|
||||
title = "主对话",
|
||||
isMain = true,
|
||||
)
|
||||
)
|
||||
Log.i("ChatRepository", "Created main session: $sessionId")
|
||||
} catch (e: Exception) {
|
||||
Log.w("ChatRepository", "Failed to create main session: ${e.message}")
|
||||
}
|
||||
|
||||
currentSessionId = sessionId
|
||||
preferencesDataStore.saveCurrentSessionId(sessionId)
|
||||
return sessionId
|
||||
}
|
||||
|
||||
override suspend fun loadMessagesFromServer(sessionId: String): List<Message> {
|
||||
currentSessionId = sessionId
|
||||
return try {
|
||||
val response = apiService.getSessionMessages(sessionId)
|
||||
if (response.isSuccessful) {
|
||||
val messageDtos = response.body()?.messages ?: emptyList()
|
||||
val lastCleared = preferencesDataStore.lastClearedTimestamp.firstOrNull()
|
||||
?.toLongOrNull() ?: 0L
|
||||
val filteredDtos = messageDtos.filter { it.createdAt > lastCleared }
|
||||
ensureConversation(sessionId)
|
||||
filteredDtos.forEach { dto ->
|
||||
messageDao.upsert(
|
||||
MessageEntity(
|
||||
id = "db_${dto.id}",
|
||||
conversationId = sessionId,
|
||||
role = dto.role,
|
||||
content = dto.content,
|
||||
msgType = dto.msgType ?: "chat",
|
||||
timestamp = dto.createdAt,
|
||||
)
|
||||
)
|
||||
}
|
||||
RuntimeLog.http("loadMessages", "HTTP loaded ${filteredDtos.size} messages for session=$sessionId")
|
||||
filteredDtos.map { dto ->
|
||||
Message(
|
||||
id = "db_${dto.id}",
|
||||
conversationId = sessionId,
|
||||
role = dto.role,
|
||||
content = dto.content,
|
||||
msgType = dto.msgType ?: "chat",
|
||||
timestamp = dto.createdAt,
|
||||
)
|
||||
}.removeWrappingDuplicates()
|
||||
} else {
|
||||
RuntimeLog.http("loadMessages", "HTTP failed: ${response.code()} ${response.message()}, trying WS")
|
||||
requestHistoryViaWs(sessionId)
|
||||
emptyList()
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
RuntimeLog.http("loadMessages", "HTTP error: ${e.message}, trying WS")
|
||||
requestHistoryViaWs(sessionId)
|
||||
emptyList()
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun requestHistoryViaWs(sessionId: String) {
|
||||
// Wait up to 5s for WS to connect
|
||||
if (!webSocketService.isConnected.value) {
|
||||
withTimeoutOrNull(5000) {
|
||||
webSocketService.isConnected.first { it }
|
||||
}
|
||||
}
|
||||
webSocketService.requestHistory(sessionId)
|
||||
}
|
||||
|
||||
private suspend fun ensureConversation(sessionId: String, lastMessage: String = "") {
|
||||
val existing = conversationDao.getById(sessionId)
|
||||
if (existing == null) {
|
||||
val now = System.currentTimeMillis()
|
||||
conversationDao.upsert(
|
||||
ConversationEntity(
|
||||
id = sessionId,
|
||||
title = "对话",
|
||||
lastMessage = lastMessage,
|
||||
lastMessageType = "chat",
|
||||
updatedAt = now,
|
||||
createdAt = now,
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun handleServerMessage(wsMsg: WSServerMessage) {
|
||||
when (wsMsg.type) {
|
||||
"stream_start" -> {
|
||||
streamingContent = ""
|
||||
streamingMessageId = wsMsg.messageId ?: "stream_${System.currentTimeMillis()}"
|
||||
_isAssistantStreaming.value = true
|
||||
RuntimeLog.chat("stream", "Stream start msgId=$streamingMessageId")
|
||||
}
|
||||
|
||||
"stream_chunk" -> {
|
||||
val delta = wsMsg.content ?: wsMsg.text ?: return
|
||||
streamingContent += delta
|
||||
emitMessage(
|
||||
id = streamingMessageId ?: "s_${System.currentTimeMillis()}",
|
||||
sessionId = wsMsg.sessionId ?: currentSessionId ?: "default",
|
||||
role = "assistant",
|
||||
content = streamingContent,
|
||||
msgType = "chat",
|
||||
isStreaming = true,
|
||||
)
|
||||
}
|
||||
|
||||
"stream_end" -> {
|
||||
val msgId = wsMsg.messageId ?: streamingMessageId ?: "s_${System.currentTimeMillis()}"
|
||||
val content = streamingContent.ifEmpty { wsMsg.content ?: wsMsg.text ?: "" }
|
||||
streamingContent = ""
|
||||
streamingMessageId = null
|
||||
val sid = wsMsg.sessionId ?: currentSessionId ?: "default"
|
||||
if (currentSessionId == null || (wsMsg.sessionId != null && wsMsg.sessionId != currentSessionId)) {
|
||||
changeSessionId(sid)
|
||||
}
|
||||
val ts = wsMsg.timestamp ?: System.currentTimeMillis()
|
||||
|
||||
if (content.isNotBlank()) {
|
||||
ensureConversation(sid, content)
|
||||
messageDao.upsert(
|
||||
MessageEntity(
|
||||
id = msgId,
|
||||
conversationId = sid,
|
||||
role = "assistant",
|
||||
content = content,
|
||||
msgType = "chat",
|
||||
timestamp = ts,
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
emitMessage(id = msgId, sessionId = sid, role = "assistant", content = content, msgType = "chat", timestamp = ts, isStreaming = false, shouldNotify = true)
|
||||
_isAssistantStreaming.value = false
|
||||
RuntimeLog.chat("stream", "Stream end msgId=$msgId content=${content.take(80)}")
|
||||
}
|
||||
|
||||
"response" -> {
|
||||
val text = wsMsg.content ?: wsMsg.text ?: return
|
||||
val role = wsMsg.role ?: "assistant"
|
||||
val replyMsgType = wsMsg.msgType ?: "chat"
|
||||
val msgId = wsMsg.messageId ?: "r_${System.currentTimeMillis()}"
|
||||
val sid = wsMsg.sessionId ?: currentSessionId ?: "default"
|
||||
|
||||
// Suppress response if it wraps recently emitted review/multi_message items
|
||||
val timeSinceParsed = System.currentTimeMillis() - lastParsedTime
|
||||
if (timeSinceParsed < 3000 && recentParsedContents.isNotEmpty()) {
|
||||
val allContained = recentParsedContents.all { text.contains(it) }
|
||||
if (allContained) {
|
||||
RuntimeLog.chat("dedup", "Suppressed wrapping response, ${recentParsedContents.size} items already shown")
|
||||
recentParsedContents.clear()
|
||||
return
|
||||
}
|
||||
}
|
||||
recentParsedContents.clear()
|
||||
|
||||
if (currentSessionId == null || (wsMsg.sessionId != null && wsMsg.sessionId != currentSessionId)) {
|
||||
changeSessionId(sid)
|
||||
}
|
||||
val ts = wsMsg.timestamp ?: System.currentTimeMillis()
|
||||
|
||||
ensureConversation(sid, text)
|
||||
messageDao.upsert(
|
||||
MessageEntity(
|
||||
id = msgId,
|
||||
conversationId = sid,
|
||||
role = role,
|
||||
content = text,
|
||||
msgType = replyMsgType,
|
||||
timestamp = ts,
|
||||
)
|
||||
)
|
||||
|
||||
lastResponseId = msgId
|
||||
lastResponseContent = text
|
||||
lastResponseTime = System.currentTimeMillis()
|
||||
|
||||
emitMessage(id = msgId, sessionId = sid, role = role, content = text, msgType = replyMsgType, timestamp = ts, isStreaming = false, shouldNotify = true)
|
||||
RuntimeLog.chat("receive", "Response msgId=$msgId role=$role msgType=$replyMsgType content=${text.take(80)}")
|
||||
}
|
||||
|
||||
"review" -> {
|
||||
recentParsedContents.clear()
|
||||
wsMsg.reviewMessages?.forEach { review ->
|
||||
val text = review.content ?: review.text ?: return@forEach
|
||||
val role = review.role ?: "action"
|
||||
val rvMsgType = review.msgType ?: review.role ?: "action"
|
||||
val msgId = "rv_${System.currentTimeMillis()}_${review.hashCode()}"
|
||||
recentParsedContents.add(text)
|
||||
emitMessage(id = msgId, sessionId = wsMsg.sessionId ?: currentSessionId ?: "default", role = role, content = text, msgType = rvMsgType, isStreaming = false)
|
||||
}
|
||||
if (recentParsedContents.isNotEmpty()) lastParsedTime = System.currentTimeMillis()
|
||||
// Clean up wrapping response that arrived before this review
|
||||
cleanupWrappingResponse()
|
||||
}
|
||||
|
||||
"thinking" -> {
|
||||
val text = wsMsg.thinkingContent ?: wsMsg.content ?: wsMsg.text
|
||||
if (text != null) {
|
||||
emitMessage(
|
||||
id = wsMsg.messageId ?: "think_${System.currentTimeMillis()}",
|
||||
sessionId = wsMsg.sessionId ?: currentSessionId ?: "default",
|
||||
role = "assistant",
|
||||
content = text,
|
||||
msgType = "thinking",
|
||||
isStreaming = false,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
"tool_progress" -> {
|
||||
val detail = wsMsg.toolProgress?.message
|
||||
?: wsMsg.toolProgress?.detail
|
||||
?: wsMsg.toolProgress?.toolName
|
||||
?: "正在执行操作..."
|
||||
emitMessage(
|
||||
id = wsMsg.messageId ?: "tool_${System.currentTimeMillis()}",
|
||||
sessionId = wsMsg.sessionId ?: currentSessionId ?: "default",
|
||||
role = "system",
|
||||
content = detail,
|
||||
msgType = "tool_progress",
|
||||
isStreaming = false,
|
||||
)
|
||||
}
|
||||
|
||||
"error" -> {
|
||||
RuntimeLog.chat("error", "Server error: ${wsMsg.error ?: "未知错误"}")
|
||||
emitMessage(
|
||||
id = "err_${System.currentTimeMillis()}",
|
||||
sessionId = wsMsg.sessionId ?: currentSessionId ?: "default",
|
||||
role = "system",
|
||||
content = wsMsg.error ?: "未知错误",
|
||||
msgType = "system_info",
|
||||
isStreaming = false,
|
||||
)
|
||||
}
|
||||
|
||||
"voice_transcript" -> {
|
||||
val text = wsMsg.text ?: wsMsg.content ?: return
|
||||
val sid = wsMsg.sessionId ?: currentSessionId ?: "default"
|
||||
val ts = wsMsg.timestamp ?: System.currentTimeMillis()
|
||||
val msgId = wsMsg.messageId ?: "vt_${System.currentTimeMillis()}"
|
||||
ensureConversation(sid)
|
||||
messageDao.upsert(
|
||||
MessageEntity(
|
||||
id = msgId,
|
||||
conversationId = sid,
|
||||
role = "user",
|
||||
content = text,
|
||||
msgType = "chat",
|
||||
timestamp = ts,
|
||||
)
|
||||
)
|
||||
emitMessage(id = msgId, sessionId = sid, role = "user", content = text, msgType = "chat", timestamp = ts, isStreaming = false)
|
||||
}
|
||||
|
||||
"history_response" -> {
|
||||
val sid = wsMsg.sessionId ?: currentSessionId ?: "default"
|
||||
if (currentSessionId == null || (wsMsg.sessionId != null && wsMsg.sessionId != currentSessionId)) {
|
||||
changeSessionId(sid)
|
||||
}
|
||||
ensureConversation(sid)
|
||||
val messages = wsMsg.messages ?: return
|
||||
val messageList = messages.map { hist ->
|
||||
val msgId = hist.id ?: "hist_${System.currentTimeMillis()}_${hist.hashCode()}"
|
||||
Message(
|
||||
id = msgId,
|
||||
conversationId = sid,
|
||||
role = hist.role ?: "system",
|
||||
content = hist.content ?: "",
|
||||
msgType = hist.msgType ?: "chat",
|
||||
timestamp = hist.timestamp ?: System.currentTimeMillis(),
|
||||
)
|
||||
}
|
||||
val deduped = messageList.removeWrappingDuplicates()
|
||||
deduped.forEach { msg ->
|
||||
messageDao.upsert(
|
||||
MessageEntity(
|
||||
id = msg.id,
|
||||
conversationId = msg.conversationId,
|
||||
role = msg.role,
|
||||
content = msg.content,
|
||||
msgType = msg.msgType,
|
||||
timestamp = msg.timestamp,
|
||||
)
|
||||
)
|
||||
emitMessage(
|
||||
id = msg.id,
|
||||
sessionId = msg.conversationId,
|
||||
role = msg.role,
|
||||
content = msg.content,
|
||||
msgType = msg.msgType,
|
||||
timestamp = msg.timestamp,
|
||||
isStreaming = false,
|
||||
shouldNotify = false,
|
||||
)
|
||||
}
|
||||
RuntimeLog.chat("history", "Loaded ${deduped.size} messages from server history")
|
||||
}
|
||||
|
||||
"multi_message" -> {
|
||||
recentParsedContents.clear()
|
||||
wsMsg.multiMessages?.forEach { item ->
|
||||
val content = item.content ?: ""
|
||||
recentParsedContents.add(content)
|
||||
emitMessage(
|
||||
id = "mm_${System.currentTimeMillis()}_${item.hashCode()}",
|
||||
sessionId = wsMsg.sessionId ?: currentSessionId ?: "default",
|
||||
role = item.role ?: "assistant",
|
||||
content = content,
|
||||
msgType = item.msgType ?: "chat",
|
||||
timestamp = wsMsg.timestamp ?: System.currentTimeMillis(),
|
||||
isStreaming = false,
|
||||
)
|
||||
}
|
||||
if (recentParsedContents.isNotEmpty()) lastParsedTime = System.currentTimeMillis()
|
||||
cleanupWrappingResponse()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun cleanupWrappingResponse() {
|
||||
val respId = lastResponseId ?: return
|
||||
val respContent = lastResponseContent ?: return
|
||||
val timeSinceResponse = System.currentTimeMillis() - lastResponseTime
|
||||
if (timeSinceResponse > 5000 || recentParsedContents.size < 2) return
|
||||
val allContained = recentParsedContents.all { respContent.contains(it) }
|
||||
if (allContained) {
|
||||
messageDao.deleteById(respId)
|
||||
RuntimeLog.chat("dedup", "Cleaned up wrapping response from DB id=$respId")
|
||||
}
|
||||
}
|
||||
|
||||
private fun emitMessage(
|
||||
id: String,
|
||||
sessionId: String,
|
||||
role: String,
|
||||
content: String,
|
||||
msgType: String,
|
||||
isStreaming: Boolean = false,
|
||||
timestamp: Long = System.currentTimeMillis(),
|
||||
shouldNotify: Boolean = false,
|
||||
) {
|
||||
if (content.isBlank() && msgType == "chat") return
|
||||
val message = Message(
|
||||
id = id,
|
||||
conversationId = sessionId,
|
||||
role = role,
|
||||
content = content,
|
||||
msgType = msgType,
|
||||
timestamp = timestamp,
|
||||
isStreaming = isStreaming,
|
||||
)
|
||||
_incomingMessages.tryEmit(message)
|
||||
|
||||
if (shouldNotify && !isAppInForeground && role == "assistant" && !isStreaming) {
|
||||
if (notifiedMessageIds.add(id)) {
|
||||
onBackgroundNotification?.invoke(message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove wrapper messages whose content contains the content of 2+ other messages.
|
||||
* This handles the case where the server sends both a combined "response" and
|
||||
* parsed "review"/"multi_message" items for the same turn.
|
||||
*/
|
||||
private fun List<Message>.removeWrappingDuplicates(): List<Message> {
|
||||
if (size < 3) return this
|
||||
val toRemove = mutableSetOf<String>()
|
||||
for (msg in this) {
|
||||
val containedCount = count { other ->
|
||||
other.id != msg.id &&
|
||||
other.content.isNotBlank() &&
|
||||
other.content.length < msg.content.length &&
|
||||
msg.content.contains(other.content) &&
|
||||
kotlin.math.abs(other.timestamp - msg.timestamp) < 2000
|
||||
}
|
||||
if (containedCount >= 2) {
|
||||
toRemove.add(msg.id)
|
||||
}
|
||||
}
|
||||
return if (toRemove.isEmpty()) this else filter { it.id !in toRemove }
|
||||
}
|
||||
|
||||
private fun ConversationEntity.toDomain() = Conversation(
|
||||
id = id,
|
||||
title = title,
|
||||
lastMessage = lastMessage,
|
||||
lastMessageType = lastMessageType,
|
||||
updatedAt = updatedAt,
|
||||
createdAt = createdAt,
|
||||
)
|
||||
|
||||
private fun MessageEntity.toDomain() = Message(
|
||||
id = id,
|
||||
conversationId = conversationId,
|
||||
role = role,
|
||||
content = content,
|
||||
msgType = msgType,
|
||||
timestamp = timestamp,
|
||||
)
|
||||
}
|
||||
@@ -1,70 +0,0 @@
|
||||
package top.yeij.cyrene.data.repository
|
||||
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import top.yeij.cyrene.data.remote.ApiService
|
||||
import top.yeij.cyrene.data.remote.dto.IoTControlRequest
|
||||
import top.yeij.cyrene.domain.model.Device
|
||||
import top.yeij.cyrene.domain.model.DeviceState
|
||||
import top.yeij.cyrene.domain.model.DeviceType
|
||||
import top.yeij.cyrene.domain.repository.IoTRepository
|
||||
|
||||
class IoTRepositoryImpl(
|
||||
private val apiService: ApiService,
|
||||
private val webSocketService: top.yeij.cyrene.service.WebSocketService,
|
||||
) : IoTRepository {
|
||||
|
||||
private val _devices = MutableStateFlow<List<Device>>(emptyList())
|
||||
override fun getDevices(): Flow<List<Device>> = _devices.asStateFlow()
|
||||
|
||||
override suspend fun controlDevice(
|
||||
deviceId: String,
|
||||
action: String,
|
||||
value: Any?,
|
||||
): Result<Device> {
|
||||
return try {
|
||||
val response = apiService.controlDevice(deviceId, IoTControlRequest(action, value))
|
||||
if (response.isSuccessful) {
|
||||
val updated = response.body()!!.toDomain()
|
||||
_devices.value = _devices.value.map {
|
||||
if (it.id == deviceId) updated else it
|
||||
}
|
||||
Result.success(updated)
|
||||
} else {
|
||||
Result.failure(Exception("设备控制失败: ${response.code()}"))
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun refreshDevices() {
|
||||
try {
|
||||
val response = apiService.getDevices()
|
||||
if (response.isSuccessful) {
|
||||
_devices.value = response.body()!!.map { it.toDomain() }
|
||||
}
|
||||
} catch (_: Exception) { }
|
||||
}
|
||||
|
||||
private fun top.yeij.cyrene.data.remote.dto.DeviceDto.toDomain() = Device(
|
||||
id = id,
|
||||
name = name,
|
||||
type = try {
|
||||
DeviceType.valueOf(type.uppercase())
|
||||
} catch (_: Exception) {
|
||||
DeviceType.UNKNOWN
|
||||
},
|
||||
state = DeviceState(
|
||||
power = state.power,
|
||||
brightness = state.brightness,
|
||||
temperature = state.temperature,
|
||||
humidity = state.humidity,
|
||||
colorTemp = state.colorTemp,
|
||||
locked = state.locked,
|
||||
open = state.open,
|
||||
),
|
||||
room = room,
|
||||
)
|
||||
}
|
||||
@@ -1,80 +0,0 @@
|
||||
package top.yeij.cyrene.di
|
||||
|
||||
import org.koin.android.ext.koin.androidContext
|
||||
import org.koin.core.module.dsl.viewModel
|
||||
import org.koin.dsl.module
|
||||
import top.yeij.cyrene.data.local.AppDatabase
|
||||
import top.yeij.cyrene.data.local.PreferencesDataStore
|
||||
import top.yeij.cyrene.data.remote.ApiService
|
||||
import top.yeij.cyrene.data.remote.AuthInterceptor
|
||||
import top.yeij.cyrene.data.remote.DynamicUrlInterceptor
|
||||
import top.yeij.cyrene.data.remote.RetrofitClient
|
||||
import top.yeij.cyrene.data.remote.TokenAuthenticator
|
||||
import top.yeij.cyrene.data.repository.AuthRepositoryImpl
|
||||
import top.yeij.cyrene.data.repository.ChatRepositoryImpl
|
||||
import top.yeij.cyrene.data.repository.IoTRepositoryImpl
|
||||
import top.yeij.cyrene.domain.repository.AuthRepository
|
||||
import top.yeij.cyrene.domain.repository.ChatRepository
|
||||
import top.yeij.cyrene.domain.repository.IoTRepository
|
||||
import top.yeij.cyrene.domain.usecase.GetConversationsUseCase
|
||||
import top.yeij.cyrene.domain.usecase.LoginUseCase
|
||||
import top.yeij.cyrene.domain.usecase.SendMessageUseCase
|
||||
import top.yeij.cyrene.service.WebSocketService
|
||||
import top.yeij.cyrene.viewmodel.ChatViewModel
|
||||
import top.yeij.cyrene.viewmodel.IoTViewModel
|
||||
import top.yeij.cyrene.viewmodel.OverlayViewModel
|
||||
import top.yeij.cyrene.viewmodel.ProfileViewModel
|
||||
import top.yeij.cyrene.viewmodel.SettingsViewModel
|
||||
import top.yeij.cyrene.util.VoiceRecorder
|
||||
import top.yeij.cyrene.voice.stt.BackendSttProvider
|
||||
import top.yeij.cyrene.voice.stt.DashScopeSttService
|
||||
import top.yeij.cyrene.voice.stt.SpeechRecognizer
|
||||
import top.yeij.cyrene.voice.stt.SttManager
|
||||
import top.yeij.cyrene.voice.tts.TextToSpeechEngine
|
||||
|
||||
val appModule = module {
|
||||
|
||||
// DataStore
|
||||
single { PreferencesDataStore(androidContext()) }
|
||||
|
||||
// Database
|
||||
single { AppDatabase.getInstance(androidContext()) }
|
||||
single { get<AppDatabase>().conversationDao() }
|
||||
single { get<AppDatabase>().messageDao() }
|
||||
|
||||
// Network interceptors
|
||||
single { AuthInterceptor() }
|
||||
single { DynamicUrlInterceptor() }
|
||||
single { TokenAuthenticator(get(), get(), get()) }
|
||||
single { RetrofitClient.provideOkHttpClient(get(), get(), get()) }
|
||||
single { RetrofitClient.provideRetrofit(get()) }
|
||||
single { get<retrofit2.Retrofit>().create(ApiService::class.java) }
|
||||
|
||||
// WebSocket
|
||||
single { WebSocketService(get()) }
|
||||
|
||||
// Voice
|
||||
single { VoiceRecorder(androidContext()) }
|
||||
single { SpeechRecognizer(androidContext()) }
|
||||
single { TextToSpeechEngine(androidContext()) }
|
||||
single { DashScopeSttService(androidContext()) }
|
||||
single { BackendSttProvider(androidContext(), get()) }
|
||||
single { SttManager(get(), get(), get()) }
|
||||
|
||||
// Repositories
|
||||
single<AuthRepository> { AuthRepositoryImpl(get(), get(), get()) }
|
||||
single<ChatRepository> { ChatRepositoryImpl(get(), get(), get(), get(), get()) }
|
||||
single<IoTRepository> { IoTRepositoryImpl(get(), get()) }
|
||||
|
||||
// UseCases
|
||||
factory { LoginUseCase(get()) }
|
||||
factory { SendMessageUseCase(get()) }
|
||||
factory { GetConversationsUseCase(get()) }
|
||||
|
||||
// ViewModels
|
||||
viewModel { ChatViewModel(get(), get()) }
|
||||
viewModel { IoTViewModel(get()) }
|
||||
viewModel { OverlayViewModel(get(), get(), get()) }
|
||||
viewModel { ProfileViewModel(get(), get(), get()) }
|
||||
single { SettingsViewModel(get(), get(), get(), get(), get()) }
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
package top.yeij.cyrene.domain.model
|
||||
|
||||
data class AuthResult(
|
||||
val token: String,
|
||||
val refreshToken: String?,
|
||||
val username: String,
|
||||
)
|
||||
@@ -1,10 +0,0 @@
|
||||
package top.yeij.cyrene.domain.model
|
||||
|
||||
data class Conversation(
|
||||
val id: String,
|
||||
val title: String,
|
||||
val lastMessage: String?,
|
||||
val lastMessageType: String?,
|
||||
val updatedAt: Long,
|
||||
val createdAt: Long,
|
||||
)
|
||||
@@ -1,28 +0,0 @@
|
||||
package top.yeij.cyrene.domain.model
|
||||
|
||||
data class Device(
|
||||
val id: String,
|
||||
val name: String,
|
||||
val type: DeviceType,
|
||||
val state: DeviceState,
|
||||
val room: String?,
|
||||
)
|
||||
|
||||
enum class DeviceType {
|
||||
LIGHT,
|
||||
AC,
|
||||
CURTAIN,
|
||||
SENSOR,
|
||||
DOOR_LOCK,
|
||||
UNKNOWN,
|
||||
}
|
||||
|
||||
data class DeviceState(
|
||||
val power: Boolean? = null,
|
||||
val brightness: Int? = null,
|
||||
val temperature: Float? = null,
|
||||
val humidity: Float? = null,
|
||||
val colorTemp: Int? = null,
|
||||
val locked: Boolean? = null,
|
||||
val open: Boolean? = null,
|
||||
)
|
||||
@@ -1,11 +0,0 @@
|
||||
package top.yeij.cyrene.domain.model
|
||||
|
||||
data class Message(
|
||||
val id: String,
|
||||
val conversationId: String,
|
||||
val role: String,
|
||||
val content: String,
|
||||
val msgType: String,
|
||||
val timestamp: Long,
|
||||
val isStreaming: Boolean = false,
|
||||
)
|
||||
@@ -1,12 +0,0 @@
|
||||
package top.yeij.cyrene.domain.repository
|
||||
|
||||
import top.yeij.cyrene.domain.model.AuthResult
|
||||
|
||||
interface AuthRepository {
|
||||
|
||||
suspend fun login(username: String, password: String): Result<AuthResult>
|
||||
|
||||
suspend fun logout()
|
||||
|
||||
suspend fun isLoggedIn(): Boolean
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
package top.yeij.cyrene.domain.repository
|
||||
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import top.yeij.cyrene.domain.model.Conversation
|
||||
import top.yeij.cyrene.domain.model.Message
|
||||
|
||||
interface ChatRepository {
|
||||
|
||||
val connectionState: StateFlow<Boolean>
|
||||
val connectionError: StateFlow<String?>
|
||||
val isAssistantStreaming: StateFlow<Boolean>
|
||||
val messageClearEvents: Flow<Unit>
|
||||
var currentSessionId: String?
|
||||
|
||||
fun getConversations(): Flow<List<Conversation>>
|
||||
|
||||
suspend fun getMessages(conversationId: String): Flow<List<Message>>
|
||||
|
||||
suspend fun deleteConversation(id: String)
|
||||
|
||||
suspend fun connectWebSocket(sessionId: String?)
|
||||
|
||||
suspend fun sendMessage(content: String, sessionId: String?)
|
||||
|
||||
fun observeMessages(): Flow<Message>
|
||||
|
||||
suspend fun loadConversationsFromServer()
|
||||
|
||||
suspend fun loadMessagesFromServer(sessionId: String): List<Message>
|
||||
|
||||
suspend fun initializeSession(): String
|
||||
|
||||
suspend fun reconnectWebSocket()
|
||||
|
||||
suspend fun ensureConnected()
|
||||
|
||||
suspend fun sendScreenContext(content: String)
|
||||
|
||||
suspend fun sendVoiceInput(audioBase64: String, mode: String = "voice_msg")
|
||||
|
||||
suspend fun clearLocalMessages()
|
||||
|
||||
fun onAppForeground()
|
||||
fun onAppBackground()
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
package top.yeij.cyrene.domain.repository
|
||||
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import top.yeij.cyrene.domain.model.Device
|
||||
|
||||
interface IoTRepository {
|
||||
|
||||
fun getDevices(): Flow<List<Device>>
|
||||
|
||||
suspend fun controlDevice(deviceId: String, action: String, value: Any? = null): Result<Device>
|
||||
|
||||
suspend fun refreshDevices()
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
package top.yeij.cyrene.domain.usecase
|
||||
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import top.yeij.cyrene.domain.model.Conversation
|
||||
import top.yeij.cyrene.domain.repository.ChatRepository
|
||||
|
||||
class GetConversationsUseCase(
|
||||
private val chatRepository: ChatRepository,
|
||||
) {
|
||||
operator fun invoke(): Flow<List<Conversation>> = chatRepository.getConversations()
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
package top.yeij.cyrene.domain.usecase
|
||||
|
||||
import top.yeij.cyrene.domain.model.AuthResult
|
||||
import top.yeij.cyrene.domain.repository.AuthRepository
|
||||
|
||||
class LoginUseCase(
|
||||
private val authRepository: AuthRepository,
|
||||
) {
|
||||
suspend operator fun invoke(username: String, password: String): Result<AuthResult> {
|
||||
if (username.isBlank() || password.isBlank()) {
|
||||
return Result.failure(IllegalArgumentException("用户名和密码不能为空"))
|
||||
}
|
||||
return authRepository.login(username, password)
|
||||
}
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
package top.yeij.cyrene.domain.usecase
|
||||
|
||||
import top.yeij.cyrene.domain.repository.ChatRepository
|
||||
|
||||
class SendMessageUseCase(
|
||||
private val chatRepository: ChatRepository,
|
||||
) {
|
||||
suspend operator fun invoke(content: String, sessionId: String? = null) {
|
||||
if (content.isNotBlank()) {
|
||||
chatRepository.sendMessage(content, sessionId)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,67 +0,0 @@
|
||||
package top.yeij.cyrene.service
|
||||
|
||||
import android.accessibilityservice.AccessibilityService
|
||||
import android.view.accessibility.AccessibilityEvent
|
||||
import android.view.accessibility.AccessibilityNodeInfo
|
||||
|
||||
class CyreneAccessibilityService : AccessibilityService() {
|
||||
|
||||
companion object {
|
||||
@Volatile
|
||||
private var instance: CyreneAccessibilityService? = null
|
||||
|
||||
fun getScreenContent(): String {
|
||||
return instance?.captureScreenContent() ?: ""
|
||||
}
|
||||
|
||||
fun isRunning(): Boolean = instance != null
|
||||
}
|
||||
|
||||
override fun onServiceConnected() {
|
||||
super.onServiceConnected()
|
||||
instance = this
|
||||
}
|
||||
|
||||
override fun onAccessibilityEvent(event: AccessibilityEvent?) {}
|
||||
|
||||
override fun onInterrupt() {}
|
||||
|
||||
override fun onDestroy() {
|
||||
instance = null
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
private fun captureScreenContent(): String {
|
||||
val root = rootInActiveWindow ?: return ""
|
||||
return try {
|
||||
val sb = StringBuilder()
|
||||
collectNodeText(root, sb, depth = 0)
|
||||
sb.toString().trim()
|
||||
} finally {
|
||||
root.recycle()
|
||||
}
|
||||
}
|
||||
|
||||
private fun collectNodeText(node: AccessibilityNodeInfo, sb: StringBuilder, depth: Int) {
|
||||
// Skip the root decor view itself content, and limit depth
|
||||
if (depth > 16) return
|
||||
|
||||
val text = node.text?.toString()?.trim()
|
||||
val contentDesc = node.contentDescription?.toString()?.trim()
|
||||
|
||||
if (!text.isNullOrEmpty() && text != contentDesc) {
|
||||
if (sb.isNotEmpty()) sb.append("\n")
|
||||
sb.append(text)
|
||||
} else if (!contentDesc.isNullOrEmpty() && depth > 0) {
|
||||
// Only include content descriptions for non-root nodes
|
||||
if (sb.isNotEmpty()) sb.append("\n")
|
||||
sb.append(contentDesc)
|
||||
}
|
||||
|
||||
for (i in 0 until node.childCount) {
|
||||
val child = node.getChild(i) ?: continue
|
||||
collectNodeText(child, sb, depth + 1)
|
||||
child.recycle()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
package top.yeij.cyrene.service
|
||||
|
||||
import android.service.voice.VoiceInteractionService
|
||||
import android.util.Log
|
||||
|
||||
/**
|
||||
* Recognition service for always-on hotword detection.
|
||||
*
|
||||
* In API 36, AlwaysOnHotwordDetector was removed from the public SDK.
|
||||
* The system manages low-level hotword detection internally and invokes
|
||||
* the session service declared in voice_interaction_config.xml when triggered.
|
||||
* For custom hotword models (Porcupine / openWakeWord), integrate via
|
||||
* a foreground service with microphone capture instead.
|
||||
*/
|
||||
class CyreneRecognitionService : VoiceInteractionService() {
|
||||
|
||||
override fun onReady() {
|
||||
super.onReady()
|
||||
isActive = true
|
||||
Log.i(TAG, "Recognition service ready")
|
||||
}
|
||||
|
||||
override fun onShutdown() {
|
||||
isActive = false
|
||||
super.onShutdown()
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "CyreneRecognition"
|
||||
var isActive: Boolean = false
|
||||
private set
|
||||
}
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
package top.yeij.cyrene.service
|
||||
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import android.service.voice.VoiceInteractionSessionService
|
||||
import android.util.Log
|
||||
|
||||
class CyreneSessionService : VoiceInteractionSessionService() {
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
Log.i(TAG, "Session service created")
|
||||
}
|
||||
|
||||
override fun onNewSession(args: Bundle?): CyreneVoiceInteractionSession {
|
||||
return CyreneVoiceInteractionSession(this)
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
super.onDestroy()
|
||||
Log.i(TAG, "Session service destroyed")
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "CyreneSession"
|
||||
}
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
package top.yeij.cyrene.service
|
||||
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import android.service.voice.VoiceInteractionService
|
||||
import android.util.Log
|
||||
import top.yeij.cyrene.MainActivity
|
||||
import top.yeij.cyrene.util.Constants
|
||||
|
||||
class CyreneVoiceInteractionService : VoiceInteractionService() {
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
Log.i(TAG, "Service created")
|
||||
}
|
||||
|
||||
override fun onReady() {
|
||||
super.onReady()
|
||||
isActive = true
|
||||
getSharedPreferences(PREF_NAME, MODE_PRIVATE)
|
||||
.edit().putBoolean(KEY_WAS_ACTIVE, true).apply()
|
||||
Log.i(TAG, "Service ready")
|
||||
}
|
||||
|
||||
override fun onPrepareToShowSession(args: Bundle, showFlags: Int) {
|
||||
Log.i(TAG, "onPrepareToShowSession")
|
||||
}
|
||||
|
||||
override fun onShowSessionFailed(args: Bundle) {
|
||||
Log.e(TAG, "onShowSessionFailed")
|
||||
}
|
||||
|
||||
override fun onLaunchVoiceAssistFromKeyguard() {
|
||||
Log.i(TAG, "onLaunchVoiceAssistFromKeyguard")
|
||||
val intent = Intent(this, MainActivity::class.java).apply {
|
||||
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
putExtra(Constants.EXTRA_VOICE_ASSIST, true)
|
||||
}
|
||||
startActivity(intent)
|
||||
}
|
||||
|
||||
override fun onShutdown() {
|
||||
isActive = false
|
||||
super.onShutdown()
|
||||
Log.i(TAG, "Service shutdown")
|
||||
}
|
||||
|
||||
companion object {
|
||||
var isActive: Boolean = false
|
||||
private set
|
||||
private const val TAG = "CyreneVIS"
|
||||
private const val PREF_NAME = "cyrene_assistant"
|
||||
private const val KEY_WAS_ACTIVE = "was_assistant_active"
|
||||
|
||||
fun wasEverActive(context: android.content.Context): Boolean {
|
||||
return context.getSharedPreferences(PREF_NAME, android.content.Context.MODE_PRIVATE)
|
||||
.getBoolean(KEY_WAS_ACTIVE, false)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,138 +0,0 @@
|
||||
package top.yeij.cyrene.service
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import android.service.voice.VoiceInteractionSession
|
||||
import android.util.Log
|
||||
import android.view.View
|
||||
import android.view.WindowManager
|
||||
import androidx.compose.ui.platform.ComposeView
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import androidx.lifecycle.LifecycleRegistry
|
||||
import androidx.lifecycle.setViewTreeLifecycleOwner
|
||||
import androidx.savedstate.SavedStateRegistry
|
||||
import androidx.savedstate.SavedStateRegistryController
|
||||
import androidx.savedstate.SavedStateRegistryOwner
|
||||
import androidx.savedstate.setViewTreeSavedStateRegistryOwner
|
||||
import kotlinx.coroutines.flow.firstOrNull
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.koin.core.context.GlobalContext
|
||||
import top.yeij.cyrene.MainActivity
|
||||
import top.yeij.cyrene.data.local.PreferencesDataStore
|
||||
import top.yeij.cyrene.ui.overlay.OverlayContent
|
||||
import top.yeij.cyrene.ui.theme.CyreneTheme
|
||||
import top.yeij.cyrene.util.Constants
|
||||
import top.yeij.cyrene.util.RuntimeLog
|
||||
import top.yeij.cyrene.viewmodel.OverlayViewModel
|
||||
|
||||
class CyreneVoiceInteractionSession(context: Context) :
|
||||
VoiceInteractionSession(context), LifecycleOwner, SavedStateRegistryOwner {
|
||||
|
||||
private val lifecycleRegistry = LifecycleRegistry(this)
|
||||
override val lifecycle: Lifecycle get() = lifecycleRegistry
|
||||
|
||||
private val savedStateRegistryController = SavedStateRegistryController.create(this)
|
||||
override val savedStateRegistry: SavedStateRegistry get() = savedStateRegistryController.savedStateRegistry
|
||||
|
||||
// Resolved eagerly with fallback — lazy would silently crash composition on failure
|
||||
private var overlayViewModel: OverlayViewModel? = null
|
||||
private set
|
||||
|
||||
init {
|
||||
savedStateRegistryController.performAttach()
|
||||
savedStateRegistryController.performRestore(null)
|
||||
}
|
||||
|
||||
private fun resolveViewModel(): OverlayViewModel? {
|
||||
return try {
|
||||
GlobalContext.get().get<OverlayViewModel>()
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to resolve OverlayViewModel from Koin", e)
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreateContentView(): View {
|
||||
Log.d(TAG, "onCreateContentView called")
|
||||
RuntimeLog.general("overlay", "onCreateContentView")
|
||||
overlayViewModel = resolveViewModel()
|
||||
if (overlayViewModel == null) {
|
||||
Log.w(TAG, "ViewModel unavailable — overlay will be static")
|
||||
RuntimeLog.general("overlay", "ViewModel unavailable, overlay static")
|
||||
}
|
||||
|
||||
lifecycleRegistry.currentState = Lifecycle.State.CREATED
|
||||
val vm = overlayViewModel
|
||||
return ComposeView(context).apply {
|
||||
setViewTreeLifecycleOwner(this@CyreneVoiceInteractionSession)
|
||||
setViewTreeSavedStateRegistryOwner(this@CyreneVoiceInteractionSession)
|
||||
setContent {
|
||||
CyreneTheme {
|
||||
if (vm != null) {
|
||||
OverlayContent(
|
||||
onDismiss = { finish() },
|
||||
onNavigateToMain = {
|
||||
val intent = Intent(context, MainActivity::class.java).apply {
|
||||
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
}
|
||||
context.startActivity(intent)
|
||||
},
|
||||
viewModel = vm,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onShow(args: Bundle?, showFlags: Int) {
|
||||
super.onShow(args, showFlags)
|
||||
RuntimeLog.general("overlay", "onShow, vm=${overlayViewModel != null}")
|
||||
lifecycleRegistry.currentState = Lifecycle.State.STARTED
|
||||
|
||||
// Configure window: extend behind status bar, don't resize for IME
|
||||
configureWindow()
|
||||
|
||||
// Only read screen content if user enabled it in settings (default off)
|
||||
val autoScreenContext = try {
|
||||
val prefs: PreferencesDataStore = GlobalContext.get().get()
|
||||
runBlocking { prefs.autoScreenContext.firstOrNull() } ?: false
|
||||
} catch (_: Exception) {
|
||||
false
|
||||
}
|
||||
if (autoScreenContext) {
|
||||
val screenContent = CyreneAccessibilityService.getScreenContent()
|
||||
if (screenContent.isNotBlank()) {
|
||||
overlayViewModel?.sendScreenContext(screenContent)
|
||||
RuntimeLog.general("overlay", "Screen context sent, len=${screenContent.length}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun configureWindow() {
|
||||
try {
|
||||
val method = VoiceInteractionSession::class.java.getDeclaredMethod("getWindow")
|
||||
method.isAccessible = true
|
||||
val w = method.invoke(this) as? android.view.Window ?: return
|
||||
w.addFlags(WindowManager.LayoutParams.FLAG_TRANSLUCENT_STATUS)
|
||||
w.addFlags(WindowManager.LayoutParams.FLAG_TRANSLUCENT_NAVIGATION)
|
||||
w.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_NOTHING)
|
||||
Log.d(TAG, "Window configured: translucent status/nav, adjust nothing for IME")
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Failed to configure window: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
override fun onHide() {
|
||||
RuntimeLog.general("overlay", "onHide")
|
||||
lifecycleRegistry.currentState = Lifecycle.State.DESTROYED
|
||||
super.onHide()
|
||||
overlayViewModel?.finish()
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "CyreneVIS-Session"
|
||||
}
|
||||
}
|
||||
@@ -1,309 +0,0 @@
|
||||
package top.yeij.cyrene.service
|
||||
|
||||
import android.os.Build
|
||||
import android.util.Log
|
||||
import com.google.gson.Gson
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharedFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asSharedFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.firstOrNull
|
||||
import kotlinx.coroutines.launch
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
import okhttp3.WebSocket
|
||||
import okhttp3.WebSocketListener
|
||||
import top.yeij.cyrene.data.local.PreferencesDataStore
|
||||
import top.yeij.cyrene.data.remote.dto.WSClientMessage
|
||||
import top.yeij.cyrene.data.remote.dto.WSServerMessage
|
||||
import java.net.URLEncoder
|
||||
import java.util.concurrent.TimeUnit
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
|
||||
class WebSocketService(
|
||||
private val preferencesDataStore: PreferencesDataStore,
|
||||
) {
|
||||
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
private val httpClient = OkHttpClient.Builder()
|
||||
.readTimeout(0, TimeUnit.MILLISECONDS)
|
||||
.writeTimeout(0, TimeUnit.MILLISECONDS)
|
||||
.callTimeout(0, TimeUnit.MILLISECONDS)
|
||||
.pingInterval(25, TimeUnit.SECONDS)
|
||||
.build()
|
||||
private val gson = Gson()
|
||||
|
||||
private var webSocket: WebSocket? = null
|
||||
private var heartbeatJob: Job? = null
|
||||
private var reconnectJob: Job? = null
|
||||
private var shouldReconnect = true
|
||||
private var currentSessionId: String? = null
|
||||
private val connectionId = AtomicInteger(0)
|
||||
|
||||
private var clientId: String = ""
|
||||
private var deviceName: String = ""
|
||||
|
||||
private val _isConnected = MutableStateFlow(false)
|
||||
val isConnected: StateFlow<Boolean> = _isConnected.asStateFlow()
|
||||
|
||||
private val _connectionError = MutableStateFlow<String?>(null)
|
||||
val connectionError: StateFlow<String?> = _connectionError.asStateFlow()
|
||||
|
||||
private val _incomingMessages = MutableSharedFlow<WSServerMessage>(extraBufferCapacity = 64)
|
||||
val incomingMessages: SharedFlow<WSServerMessage> = _incomingMessages.asSharedFlow()
|
||||
|
||||
private suspend fun initClientIdentity() {
|
||||
clientId = preferencesDataStore.clientId.firstOrNull() ?: run {
|
||||
val id = "cl_" + System.currentTimeMillis().toString(36) + "_" +
|
||||
(100000..999999).random().toString(36)
|
||||
preferencesDataStore.saveClientId(id)
|
||||
id
|
||||
}
|
||||
deviceName = preferencesDataStore.deviceName.firstOrNull() ?: run {
|
||||
val name = "Android " + (Build.MODEL ?: "Device")
|
||||
preferencesDataStore.saveDeviceName(name)
|
||||
name
|
||||
}
|
||||
}
|
||||
|
||||
fun getClientId(): String = clientId
|
||||
fun getDeviceName(): String = deviceName
|
||||
|
||||
suspend fun connect(sessionId: String? = null) {
|
||||
currentSessionId = sessionId
|
||||
shouldReconnect = true
|
||||
|
||||
initClientIdentity()
|
||||
|
||||
val baseUrl = preferencesDataStore.baseUrl.firstOrNull()
|
||||
?: "http://10.0.2.2:8080"
|
||||
val token = preferencesDataStore.token.firstOrNull() ?: ""
|
||||
val wsBase = baseUrl
|
||||
.replace("https://", "wss://")
|
||||
.replace("http://", "ws://")
|
||||
.trimEnd('/')
|
||||
val urlBuilder = StringBuilder("$wsBase/ws/chat")
|
||||
val params = mutableListOf<String>()
|
||||
if (token.isNotBlank()) {
|
||||
params.add("token=" + encode(token))
|
||||
}
|
||||
sessionId?.let {
|
||||
if (it.isNotBlank()) params.add("session_id=" + encode(it))
|
||||
}
|
||||
if (clientId.isNotBlank()) {
|
||||
params.add("client_id=" + encode(clientId))
|
||||
}
|
||||
if (deviceName.isNotBlank()) {
|
||||
params.add("device_name=" + encode(deviceName))
|
||||
}
|
||||
if (params.isNotEmpty()) {
|
||||
urlBuilder.append("?")
|
||||
urlBuilder.append(params.joinToString("&"))
|
||||
}
|
||||
|
||||
val url = urlBuilder.toString()
|
||||
val connId = connectionId.incrementAndGet()
|
||||
Log.i(TAG, "[#$connId] Connecting to $url")
|
||||
|
||||
val request = Request.Builder()
|
||||
.url(url)
|
||||
.header("User-Agent", "Cyrene-Android/${Build.MODEL ?: "Device"}")
|
||||
.build()
|
||||
|
||||
// Close previous socket silently
|
||||
try { webSocket?.close(1000, "Reconnecting") } catch (_: Exception) { }
|
||||
cancelHeartbeat()
|
||||
|
||||
webSocket = httpClient.newWebSocket(request, object : WebSocketListener() {
|
||||
override fun onOpen(webSocket: WebSocket, response: Response) {
|
||||
if (connectionId.get() != connId) {
|
||||
Log.d(TAG, "[#$connId] onOpen ignored (stale)")
|
||||
return
|
||||
}
|
||||
Log.i(TAG, "[#$connId] Connected")
|
||||
_isConnected.value = true
|
||||
_connectionError.value = null
|
||||
startHeartbeat()
|
||||
}
|
||||
|
||||
override fun onMessage(webSocket: WebSocket, text: String) {
|
||||
if (connectionId.get() != connId) return
|
||||
try {
|
||||
val msg = gson.fromJson(text, WSServerMessage::class.java)
|
||||
_incomingMessages.tryEmit(msg)
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "[#$connId] Failed to parse message: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
override fun onClosing(webSocket: WebSocket, code: Int, reason: String) {
|
||||
if (connectionId.get() != connId) return
|
||||
Log.i(TAG, "[#$connId] Server closing: code=$code reason=$reason")
|
||||
_isConnected.value = false
|
||||
cancelHeartbeat()
|
||||
}
|
||||
|
||||
override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
|
||||
if (connectionId.get() != connId) return
|
||||
Log.i(TAG, "[#$connId] Closed: code=$code reason=$reason")
|
||||
_isConnected.value = false
|
||||
cancelHeartbeat()
|
||||
scheduleReconnect()
|
||||
}
|
||||
|
||||
override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
|
||||
if (connectionId.get() != connId) return
|
||||
val httpCode = response?.code
|
||||
Log.e(TAG, "[#$connId] Failure: ${t.message} (http=$httpCode)", t)
|
||||
_isConnected.value = false
|
||||
cancelHeartbeat()
|
||||
|
||||
val errorMsg = when (httpCode) {
|
||||
403 -> {
|
||||
Log.e(TAG, "[#$connId] WebSocket 403: 仅管理员用户可连接。请使用管理员账户登录。")
|
||||
"仅管理员用户可连接"
|
||||
}
|
||||
401 -> "认证失败,请重新登录"
|
||||
else -> null
|
||||
}
|
||||
if (errorMsg != null) {
|
||||
_connectionError.value = errorMsg
|
||||
}
|
||||
// onClosed will always follow, which triggers scheduleReconnect
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private fun encode(value: String): String = URLEncoder.encode(value, "UTF-8")
|
||||
|
||||
private fun buildMessage(
|
||||
type: String,
|
||||
sessionId: String? = null,
|
||||
mode: String? = null,
|
||||
content: String? = null,
|
||||
): WSClientMessage = WSClientMessage(
|
||||
type = type,
|
||||
sessionId = sessionId ?: currentSessionId,
|
||||
mode = mode,
|
||||
content = content,
|
||||
timestamp = System.currentTimeMillis(),
|
||||
clientId = clientId.ifBlank { null },
|
||||
deviceName = deviceName.ifBlank { null },
|
||||
userAgent = "Cyrene-Android/${Build.MODEL ?: "Device"}",
|
||||
)
|
||||
|
||||
fun sendMessage(content: String, sessionId: String? = null, mode: String = "text") {
|
||||
val msg = buildMessage("message", sessionId, mode, content)
|
||||
webSocket?.send(gson.toJson(msg))
|
||||
}
|
||||
|
||||
fun requestHistory(sessionId: String?) {
|
||||
val msg = buildMessage("history", sessionId)
|
||||
if (webSocket != null) {
|
||||
webSocket?.send(gson.toJson(msg))
|
||||
Log.i(TAG, "History requested for session=$sessionId")
|
||||
} else {
|
||||
Log.w(TAG, "Cannot request history: WebSocket is null")
|
||||
}
|
||||
}
|
||||
|
||||
fun sendPing() {
|
||||
val msg = buildMessage("ping")
|
||||
webSocket?.send(gson.toJson(msg))
|
||||
}
|
||||
|
||||
fun sendScreenContext(content: String, sessionId: String? = null) {
|
||||
val msg = buildMessage("message", sessionId, mode = "text", content = content)
|
||||
webSocket?.send(gson.toJson(msg))
|
||||
}
|
||||
|
||||
fun sendVoiceInput(audioBase64: String, sessionId: String? = null, mode: String = "voice_msg") {
|
||||
val msg = WSClientMessage(
|
||||
type = "voice_input",
|
||||
sessionId = sessionId ?: currentSessionId,
|
||||
mode = mode,
|
||||
audioData = audioBase64,
|
||||
timestamp = System.currentTimeMillis(),
|
||||
clientId = clientId.ifBlank { null },
|
||||
deviceName = deviceName.ifBlank { null },
|
||||
userAgent = "Cyrene-Android/${Build.MODEL ?: "Device"}",
|
||||
)
|
||||
webSocket?.send(gson.toJson(msg))
|
||||
}
|
||||
|
||||
fun forceReconnect() {
|
||||
shouldReconnect = true
|
||||
reconnectJob?.cancel()
|
||||
reconnectJob = null
|
||||
scope.launch {
|
||||
if (!_isConnected.value) {
|
||||
try {
|
||||
connect(currentSessionId)
|
||||
} catch (_: Exception) { }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun disconnect() {
|
||||
shouldReconnect = false
|
||||
reconnectJob?.cancel()
|
||||
reconnectJob = null
|
||||
cancelHeartbeat()
|
||||
try { webSocket?.close(1000, "User disconnected") } catch (_: Exception) { }
|
||||
webSocket = null
|
||||
_isConnected.value = false
|
||||
}
|
||||
|
||||
private fun startHeartbeat() {
|
||||
cancelHeartbeat()
|
||||
heartbeatJob = scope.launch {
|
||||
while (_isConnected.value) {
|
||||
delay(30_000)
|
||||
if (_isConnected.value) {
|
||||
sendPing()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun cancelHeartbeat() {
|
||||
heartbeatJob?.cancel()
|
||||
heartbeatJob = null
|
||||
}
|
||||
|
||||
private fun scheduleReconnect() {
|
||||
if (reconnectJob?.isActive == true || !shouldReconnect) return
|
||||
reconnectJob = scope.launch {
|
||||
var attempt = 0
|
||||
while (shouldReconnect && !_isConnected.value) {
|
||||
val delayMs = minOf(
|
||||
(Math.pow(2.0, attempt.toDouble()) * 1000).toLong(),
|
||||
30_000L
|
||||
)
|
||||
Log.i(TAG, "Reconnecting in ${delayMs}ms (attempt ${attempt + 1})")
|
||||
delay(delayMs)
|
||||
attempt++
|
||||
if (shouldReconnect && !_isConnected.value) {
|
||||
try {
|
||||
connect(currentSessionId)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Reconnect attempt $attempt failed: ${e.message}", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
Log.i(TAG, "Reconnect loop ended (connected=${_isConnected.value}, shouldReconnect=$shouldReconnect)")
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "CyreneWS"
|
||||
}
|
||||
}
|
||||
@@ -1,204 +0,0 @@
|
||||
package top.yeij.cyrene.ui.components
|
||||
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.ContentCopy
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalClipboardManager
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
|
||||
@Composable
|
||||
fun ChatBubble(
|
||||
content: String,
|
||||
role: String,
|
||||
msgType: String,
|
||||
timestamp: Long,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val isUser = role == "user"
|
||||
val formattedTime = SimpleDateFormat("HH:mm", Locale.getDefault()).format(Date(timestamp))
|
||||
|
||||
when (msgType) {
|
||||
"chat" -> ChatMessageBubble(content, isUser, formattedTime, modifier)
|
||||
"action" -> ActionMessage(content, modifier)
|
||||
"thinking" -> ThinkingBubble(content, modifier)
|
||||
"tool_progress" -> ToolProgressBubble(content, modifier)
|
||||
"system_info" -> SystemInfoBubble(content, modifier)
|
||||
else -> ChatMessageBubble(content, isUser, formattedTime, modifier)
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
private fun ChatMessageBubble(
|
||||
content: String,
|
||||
isUser: Boolean,
|
||||
time: String,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
var showMenu by remember { mutableStateOf(false) }
|
||||
val clipboardManager = LocalClipboardManager.current
|
||||
|
||||
Row(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 12.dp, vertical = 4.dp),
|
||||
horizontalArrangement = if (isUser) Arrangement.End else Arrangement.Start,
|
||||
) {
|
||||
Column(
|
||||
horizontalAlignment = if (isUser) Alignment.End else Alignment.Start,
|
||||
) {
|
||||
Box {
|
||||
Surface(
|
||||
shape = MaterialTheme.shapes.large,
|
||||
color = if (isUser)
|
||||
MaterialTheme.colorScheme.primary
|
||||
else
|
||||
MaterialTheme.colorScheme.surfaceVariant,
|
||||
shadowElevation = 2.dp,
|
||||
modifier = Modifier
|
||||
.widthIn(max = 300.dp)
|
||||
.combinedClickable(
|
||||
onClick = {},
|
||||
onLongClick = { showMenu = true },
|
||||
),
|
||||
) {
|
||||
Text(
|
||||
text = content,
|
||||
modifier = Modifier.padding(12.dp),
|
||||
color = if (isUser)
|
||||
MaterialTheme.colorScheme.onPrimary
|
||||
else
|
||||
MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
DropdownMenu(
|
||||
expanded = showMenu,
|
||||
onDismissRequest = { showMenu = false },
|
||||
) {
|
||||
DropdownMenuItem(
|
||||
text = { Text("复制") },
|
||||
leadingIcon = {
|
||||
Icon(Icons.Default.ContentCopy, contentDescription = null)
|
||||
},
|
||||
onClick = {
|
||||
showMenu = false
|
||||
clipboardManager.setText(AnnotatedString(content))
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
Text(
|
||||
text = time,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(top = 2.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ActionMessage(content: String, modifier: Modifier = Modifier) {
|
||||
Row(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 12.dp, vertical = 2.dp),
|
||||
horizontalArrangement = Arrangement.Start,
|
||||
) {
|
||||
Text(
|
||||
text = content,
|
||||
style = MaterialTheme.typography.bodyMedium.copy(
|
||||
fontStyle = androidx.compose.ui.text.font.FontStyle.Italic,
|
||||
),
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Start,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ThinkingBubble(content: String, modifier: Modifier = Modifier) {
|
||||
Box(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 12.dp, vertical = 2.dp)
|
||||
.background(
|
||||
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f),
|
||||
shape = MaterialTheme.shapes.medium,
|
||||
),
|
||||
) {
|
||||
Text(
|
||||
text = content,
|
||||
modifier = Modifier.padding(10.dp),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ToolProgressBubble(content: String, modifier: Modifier = Modifier) {
|
||||
Row(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 12.dp, vertical = 2.dp),
|
||||
horizontalArrangement = Arrangement.Start,
|
||||
) {
|
||||
Surface(
|
||||
shape = MaterialTheme.shapes.small,
|
||||
color = MaterialTheme.colorScheme.tertiaryContainer.copy(alpha = 0.6f),
|
||||
) {
|
||||
Text(
|
||||
text = content,
|
||||
modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onTertiaryContainer,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SystemInfoBubble(content: String, modifier: Modifier = Modifier) {
|
||||
Row(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 12.dp, vertical = 2.dp),
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
) {
|
||||
Text(
|
||||
text = content,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,98 +0,0 @@
|
||||
package top.yeij.cyrene.ui.components
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.AcUnit
|
||||
import androidx.compose.material.icons.filled.Curtains
|
||||
import androidx.compose.material.icons.filled.Lightbulb
|
||||
import androidx.compose.material.icons.filled.Lock
|
||||
import androidx.compose.material.icons.filled.Sensors
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import top.yeij.cyrene.domain.model.Device
|
||||
import top.yeij.cyrene.domain.model.DeviceType
|
||||
|
||||
@Composable
|
||||
fun DeviceCard(
|
||||
device: Device,
|
||||
onTogglePower: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Card(
|
||||
modifier = modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 6.dp),
|
||||
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = device.type.toIcon(),
|
||||
contentDescription = device.type.name,
|
||||
tint = if (device.state.power == true)
|
||||
MaterialTheme.colorScheme.primary
|
||||
else
|
||||
MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Column {
|
||||
Text(
|
||||
text = device.name,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
)
|
||||
device.room?.let {
|
||||
Text(
|
||||
text = it,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
device.state.temperature?.let { temp ->
|
||||
Text(
|
||||
text = "${"%.1f".format(temp)}°C",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (device.state.power != null) {
|
||||
Switch(
|
||||
checked = device.state.power,
|
||||
onCheckedChange = { onTogglePower() },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun DeviceType.toIcon() = when (this) {
|
||||
DeviceType.LIGHT -> Icons.Filled.Lightbulb
|
||||
DeviceType.AC -> Icons.Filled.AcUnit
|
||||
DeviceType.CURTAIN -> Icons.Filled.Curtains
|
||||
DeviceType.SENSOR -> Icons.Filled.Sensors
|
||||
DeviceType.DOOR_LOCK -> Icons.Filled.Lock
|
||||
DeviceType.UNKNOWN -> Icons.Filled.Sensors
|
||||
}
|
||||
@@ -1,94 +0,0 @@
|
||||
package top.yeij.cyrene.ui.components
|
||||
|
||||
import androidx.compose.animation.core.RepeatMode
|
||||
import androidx.compose.animation.core.animateFloat
|
||||
import androidx.compose.animation.core.infiniteRepeatable
|
||||
import androidx.compose.animation.core.rememberInfiniteTransition
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Circle
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
enum class CyreneStatus {
|
||||
ONLINE,
|
||||
THINKING,
|
||||
SPEAKING,
|
||||
OFFLINE,
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun StatusIndicator(
|
||||
status: CyreneStatus,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Row(
|
||||
modifier = modifier,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
when (status) {
|
||||
CyreneStatus.ONLINE -> {
|
||||
Icon(
|
||||
Icons.Filled.Circle,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(8.dp),
|
||||
tint = Color(0xFF4CAF50),
|
||||
)
|
||||
Text("昔涟", style = MaterialTheme.typography.labelLarge)
|
||||
}
|
||||
CyreneStatus.THINKING -> {
|
||||
PulsingDot(Color(0xFFFFA726))
|
||||
Text("思考中…", style = MaterialTheme.typography.labelLarge)
|
||||
}
|
||||
CyreneStatus.SPEAKING -> {
|
||||
PulsingDot(Color(0xFF42A5F5))
|
||||
Text("正在说话…", style = MaterialTheme.typography.labelLarge)
|
||||
}
|
||||
CyreneStatus.OFFLINE -> {
|
||||
Icon(
|
||||
Icons.Filled.Circle,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(8.dp),
|
||||
tint = Color(0xFF9E9E9E),
|
||||
)
|
||||
Text("昔涟 · 离线", style = MaterialTheme.typography.labelLarge)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PulsingDot(color: Color) {
|
||||
val infiniteTransition = rememberInfiniteTransition(label = "pulse")
|
||||
val alpha by infiniteTransition.animateFloat(
|
||||
initialValue = 0.3f,
|
||||
targetValue = 1f,
|
||||
animationSpec = infiniteRepeatable(
|
||||
animation = tween(600),
|
||||
repeatMode = RepeatMode.Reverse,
|
||||
),
|
||||
label = "pulse_alpha",
|
||||
)
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(8.dp)
|
||||
.alpha(alpha)
|
||||
.background(color, CircleShape),
|
||||
)
|
||||
}
|
||||
@@ -1,77 +0,0 @@
|
||||
package top.yeij.cyrene.ui.components
|
||||
|
||||
import androidx.compose.animation.core.RepeatMode
|
||||
import androidx.compose.animation.core.animateFloat
|
||||
import androidx.compose.animation.core.infiniteRepeatable
|
||||
import androidx.compose.animation.core.rememberInfiniteTransition
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
@Composable
|
||||
fun TypingIndicator(modifier: Modifier = Modifier) {
|
||||
val infiniteTransition = rememberInfiniteTransition(label = "typing")
|
||||
|
||||
Row(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 12.dp, vertical = 4.dp),
|
||||
horizontalArrangement = Arrangement.Start,
|
||||
) {
|
||||
Surface(
|
||||
shape = MaterialTheme.shapes.large,
|
||||
color = MaterialTheme.colorScheme.surfaceVariant,
|
||||
shadowElevation = 1.dp,
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = 14.dp, vertical = 10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
Text(
|
||||
text = "昔涟正在输入",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
repeat(3) { index ->
|
||||
val alpha by infiniteTransition.animateFloat(
|
||||
initialValue = 0.2f,
|
||||
targetValue = 1f,
|
||||
animationSpec = infiniteRepeatable(
|
||||
animation = tween(
|
||||
durationMillis = 400,
|
||||
delayMillis = index * 200,
|
||||
),
|
||||
repeatMode = RepeatMode.Reverse,
|
||||
),
|
||||
label = "dot_$index",
|
||||
)
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(5.dp)
|
||||
.alpha(alpha)
|
||||
.background(
|
||||
MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
CircleShape,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,160 +0,0 @@
|
||||
package top.yeij.cyrene.ui.navigation
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.statusBarsPadding
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.Chat
|
||||
import androidx.compose.material.icons.filled.DevicesOther
|
||||
import androidx.compose.material.icons.filled.Person
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.NavigationRail
|
||||
import androidx.compose.material3.NavigationRailItem
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.navigation.NavHostController
|
||||
import androidx.navigation.compose.NavHost
|
||||
import androidx.navigation.compose.composable
|
||||
import top.yeij.cyrene.ui.screens.chat.ChatScreen
|
||||
import top.yeij.cyrene.ui.screens.iot.IoTScreen
|
||||
import top.yeij.cyrene.ui.screens.login.LoginScreen
|
||||
import top.yeij.cyrene.ui.screens.about.AboutScreen
|
||||
import top.yeij.cyrene.ui.screens.profile.ProfileScreen
|
||||
import top.yeij.cyrene.ui.screens.settings.SettingsScreen
|
||||
|
||||
object Routes {
|
||||
const val LOGIN = "login"
|
||||
const val MAIN = "main"
|
||||
const val CHAT = "chat"
|
||||
const val IOT = "iot"
|
||||
const val SETTINGS = "settings"
|
||||
const val ABOUT = "about"
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun CyreneNavGraph(
|
||||
navController: NavHostController,
|
||||
startDestination: String,
|
||||
isDefaultAssistant: Boolean,
|
||||
onOpenAssistantSettings: () -> Unit,
|
||||
) {
|
||||
NavHost(
|
||||
navController = navController,
|
||||
startDestination = startDestination,
|
||||
) {
|
||||
composable(Routes.LOGIN) {
|
||||
LoginScreen(
|
||||
onLoginSuccess = {
|
||||
navController.navigate(Routes.MAIN) {
|
||||
popUpTo(Routes.LOGIN) { inclusive = true }
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
composable(Routes.MAIN) {
|
||||
MainScreen(
|
||||
navController = navController,
|
||||
isDefaultAssistant = isDefaultAssistant,
|
||||
onOpenAssistantSettings = onOpenAssistantSettings,
|
||||
)
|
||||
}
|
||||
|
||||
composable(Routes.SETTINGS) {
|
||||
SettingsScreen(
|
||||
onBack = { navController.popBackStack() },
|
||||
)
|
||||
}
|
||||
|
||||
composable(Routes.ABOUT) {
|
||||
AboutScreen(
|
||||
onBack = { navController.popBackStack() },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data class BottomNavItem(
|
||||
val label: String,
|
||||
val icon: @Composable () -> Unit,
|
||||
val route: String,
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun MainScreen(
|
||||
navController: NavHostController,
|
||||
isDefaultAssistant: Boolean,
|
||||
onOpenAssistantSettings: () -> Unit,
|
||||
) {
|
||||
val items = listOf(
|
||||
BottomNavItem(
|
||||
label = "对话",
|
||||
icon = { Icon(Icons.AutoMirrored.Filled.Chat, contentDescription = "对话") },
|
||||
route = Routes.CHAT,
|
||||
),
|
||||
BottomNavItem(
|
||||
label = "设备",
|
||||
icon = { Icon(Icons.Filled.DevicesOther, contentDescription = "设备") },
|
||||
route = Routes.IOT,
|
||||
),
|
||||
BottomNavItem(
|
||||
label = "我的",
|
||||
icon = { Icon(Icons.Filled.Person, contentDescription = "我的") },
|
||||
route = "profile",
|
||||
),
|
||||
)
|
||||
|
||||
var selectedTab by rememberSaveable { mutableIntStateOf(0) }
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.statusBarsPadding(),
|
||||
) {
|
||||
NavigationRail {
|
||||
items.forEachIndexed { index, item ->
|
||||
NavigationRailItem(
|
||||
selected = selectedTab == index,
|
||||
onClick = { selectedTab = index },
|
||||
icon = item.icon,
|
||||
label = { Text(item.label) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.fillMaxHeight()
|
||||
.background(MaterialTheme.colorScheme.background),
|
||||
) {
|
||||
when (selectedTab) {
|
||||
0 -> ChatScreen()
|
||||
1 -> IoTScreen()
|
||||
2 -> ProfileScreen(
|
||||
onNavigateToSettings = { navController.navigate(Routes.SETTINGS) },
|
||||
onNavigateToAbout = { navController.navigate(Routes.ABOUT) },
|
||||
onLogout = {
|
||||
navController.navigate(Routes.LOGIN) {
|
||||
popUpTo(Routes.MAIN) { inclusive = true }
|
||||
}
|
||||
},
|
||||
onNavigateToLogin = {
|
||||
navController.navigate(Routes.LOGIN)
|
||||
},
|
||||
isDefaultAssistant = isDefaultAssistant,
|
||||
onOpenAssistantSettings = onOpenAssistantSettings,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,600 +0,0 @@
|
||||
package top.yeij.cyrene.ui.overlay
|
||||
|
||||
import android.content.res.Configuration
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.slideInVertically
|
||||
import androidx.compose.animation.slideOutVertically
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.imePadding
|
||||
import androidx.compose.foundation.layout.offset
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.OpenInNew
|
||||
import androidx.compose.material.icons.automirrored.filled.Send
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.filled.KeyboardVoice
|
||||
import androidx.compose.material.icons.filled.Lock
|
||||
import androidx.compose.material.icons.filled.Mic
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.IntOffset
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kotlinx.coroutines.delay
|
||||
import org.koin.compose.koinInject
|
||||
import top.yeij.cyrene.domain.model.Message
|
||||
import top.yeij.cyrene.ui.components.ChatBubble
|
||||
import top.yeij.cyrene.ui.components.TypingIndicator
|
||||
import top.yeij.cyrene.util.RecordState
|
||||
import top.yeij.cyrene.viewmodel.OverlayState
|
||||
import top.yeij.cyrene.viewmodel.OverlayViewModel
|
||||
import kotlin.math.min
|
||||
|
||||
@Composable
|
||||
private fun AnimatedChatBubble(
|
||||
message: Message,
|
||||
animIndex: Int,
|
||||
) {
|
||||
var visible by remember { mutableStateOf(false) }
|
||||
LaunchedEffect(Unit) {
|
||||
delay(min(animIndex, 10) * 60L)
|
||||
visible = true
|
||||
}
|
||||
AnimatedVisibility(
|
||||
visible = visible,
|
||||
enter = fadeIn(animationSpec = androidx.compose.animation.core.tween(300)) +
|
||||
slideInVertically(
|
||||
animationSpec = androidx.compose.animation.core.tween(300),
|
||||
initialOffsetY = { it / 4 },
|
||||
),
|
||||
) {
|
||||
ChatBubble(
|
||||
content = message.content,
|
||||
role = message.role,
|
||||
msgType = message.msgType,
|
||||
timestamp = message.timestamp,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun OverlayContent(
|
||||
onDismiss: () -> Unit,
|
||||
onNavigateToMain: () -> Unit,
|
||||
viewModel: OverlayViewModel = koinInject(),
|
||||
) {
|
||||
val state by viewModel.state.collectAsState()
|
||||
val messages by viewModel.messages.collectAsState()
|
||||
val inputText by viewModel.inputText.collectAsState()
|
||||
val recordState by viewModel.voiceRecordState.collectAsState()
|
||||
val recordDurationMs by viewModel.voiceRecordDurationMs.collectAsState()
|
||||
val animIndex by viewModel.messageAnimIndex.collectAsState()
|
||||
val listState = rememberLazyListState()
|
||||
val isProcessing = state == OverlayState.PROCESSING
|
||||
val recordSec = recordDurationMs / 1000f
|
||||
val isRecording = recordState == RecordState.RECORDING
|
||||
val isLocked = recordState == RecordState.LOCKED
|
||||
|
||||
// Animated "昔涟正在输入..." dots
|
||||
val typingDots = remember { mutableStateOf("") }
|
||||
LaunchedEffect(isProcessing) {
|
||||
if (isProcessing) {
|
||||
val dots = arrayOf("", ".", "..", "...")
|
||||
var i = 0
|
||||
while (true) {
|
||||
typingDots.value = dots[i % 4]
|
||||
i++
|
||||
delay(400)
|
||||
}
|
||||
} else {
|
||||
typingDots.value = ""
|
||||
}
|
||||
}
|
||||
|
||||
val configuration = LocalConfiguration.current
|
||||
val isLandscape = configuration.orientation == Configuration.ORIENTATION_LANDSCAPE
|
||||
|
||||
// Manual status bar height — Compose WindowInsets may not work in VoiceInteractionSession
|
||||
val context = androidx.compose.ui.platform.LocalContext.current
|
||||
val statusBarHeight = remember {
|
||||
val resourceId = context.resources.getIdentifier("status_bar_height", "dimen", "android")
|
||||
if (resourceId > 0) context.resources.getDimensionPixelSize(resourceId) else 0
|
||||
}
|
||||
val statusBarPaddingDp = with(androidx.compose.ui.platform.LocalDensity.current) {
|
||||
statusBarHeight.toDp()
|
||||
}
|
||||
|
||||
// Manual nav bar height
|
||||
val navBarHeight = remember {
|
||||
val resourceId = context.resources.getIdentifier("navigation_bar_height", "dimen", "android")
|
||||
if (resourceId > 0) context.resources.getDimensionPixelSize(resourceId) else 0
|
||||
}
|
||||
val navBarPaddingDp = with(androidx.compose.ui.platform.LocalDensity.current) {
|
||||
navBarHeight.toDp()
|
||||
}
|
||||
|
||||
LaunchedEffect(messages.size) {
|
||||
if (messages.isNotEmpty()) {
|
||||
listState.animateScrollToItem(messages.size - 1)
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(state) {
|
||||
if (state == OverlayState.IDLE) {
|
||||
viewModel.finish()
|
||||
onDismiss()
|
||||
}
|
||||
}
|
||||
|
||||
AnimatedVisibility(
|
||||
visible = state != OverlayState.IDLE,
|
||||
enter = fadeIn() + slideInVertically { it },
|
||||
exit = fadeOut() + slideOutVertically { it },
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(top = statusBarPaddingDp, bottom = navBarPaddingDp),
|
||||
) {
|
||||
if (isLandscape) {
|
||||
LandscapeContent(
|
||||
state = state,
|
||||
messages = messages,
|
||||
inputText = inputText,
|
||||
isProcessing = isProcessing,
|
||||
listState = listState,
|
||||
recordSec = recordSec,
|
||||
isRecording = isRecording,
|
||||
isLocked = isLocked,
|
||||
typingDots = typingDots.value,
|
||||
animIndex = animIndex,
|
||||
onDismiss = onDismiss,
|
||||
onNavigateToMain = onNavigateToMain,
|
||||
viewModel = viewModel,
|
||||
)
|
||||
} else {
|
||||
PortraitContent(
|
||||
state = state,
|
||||
messages = messages,
|
||||
inputText = inputText,
|
||||
isProcessing = isProcessing,
|
||||
listState = listState,
|
||||
recordSec = recordSec,
|
||||
isRecording = isRecording,
|
||||
isLocked = isLocked,
|
||||
typingDots = typingDots.value,
|
||||
animIndex = animIndex,
|
||||
onDismiss = onDismiss,
|
||||
onNavigateToMain = onNavigateToMain,
|
||||
viewModel = viewModel,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("LongParameterList")
|
||||
@Composable
|
||||
private fun PortraitContent(
|
||||
state: OverlayState,
|
||||
messages: List<Message>,
|
||||
inputText: String,
|
||||
isProcessing: Boolean,
|
||||
listState: androidx.compose.foundation.lazy.LazyListState,
|
||||
recordSec: Float,
|
||||
isRecording: Boolean,
|
||||
isLocked: Boolean,
|
||||
typingDots: String,
|
||||
animIndex: Map<String, Int>,
|
||||
onDismiss: () -> Unit,
|
||||
onNavigateToMain: () -> Unit,
|
||||
viewModel: OverlayViewModel,
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.clickable(
|
||||
indication = null,
|
||||
interactionSource = remember { MutableInteractionSource() },
|
||||
) { /* consume clicks */ },
|
||||
) {
|
||||
// Messages + top bar stay fixed at top
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
MessageTopBar(onDismiss = onDismiss, onNavigateToMain = onNavigateToMain)
|
||||
|
||||
LazyColumn(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.weight(1f),
|
||||
state = listState,
|
||||
) {
|
||||
if (messages.isNotEmpty()) {
|
||||
items(messages, key = { it.id }) { message ->
|
||||
AnimatedChatBubble(
|
||||
message = message,
|
||||
animIndex = animIndex[message.id] ?: 0,
|
||||
)
|
||||
}
|
||||
}
|
||||
if (isProcessing) {
|
||||
item(key = "typing_indicator") {
|
||||
TypingIndicator()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Input area at bottom, imePadding pushes it above full-screen IME
|
||||
InputArea(
|
||||
state = state,
|
||||
inputText = inputText,
|
||||
viewModel = viewModel,
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomCenter)
|
||||
.fillMaxWidth()
|
||||
.imePadding(),
|
||||
recordSec = recordSec,
|
||||
isRecording = isRecording,
|
||||
isLocked = isLocked,
|
||||
typingDots = typingDots,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("LongParameterList")
|
||||
@Composable
|
||||
private fun LandscapeContent(
|
||||
state: OverlayState,
|
||||
messages: List<Message>,
|
||||
inputText: String,
|
||||
isProcessing: Boolean,
|
||||
listState: androidx.compose.foundation.lazy.LazyListState,
|
||||
recordSec: Float,
|
||||
isRecording: Boolean,
|
||||
isLocked: Boolean,
|
||||
typingDots: String,
|
||||
animIndex: Map<String, Int>,
|
||||
onDismiss: () -> Unit,
|
||||
onNavigateToMain: () -> Unit,
|
||||
viewModel: OverlayViewModel,
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.clickable(
|
||||
indication = null,
|
||||
interactionSource = remember { MutableInteractionSource() },
|
||||
) { /* consume clicks */ },
|
||||
) {
|
||||
// Messages + top bar on the left, stay fixed
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
MessageTopBar(onDismiss = onDismiss, onNavigateToMain = onNavigateToMain)
|
||||
LazyColumn(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.weight(1f),
|
||||
state = listState,
|
||||
) {
|
||||
if (messages.isNotEmpty()) {
|
||||
items(messages, key = { it.id }) { message ->
|
||||
AnimatedChatBubble(
|
||||
message = message,
|
||||
animIndex = animIndex[message.id] ?: 0,
|
||||
)
|
||||
}
|
||||
}
|
||||
if (isProcessing) {
|
||||
item(key = "typing_indicator") {
|
||||
TypingIndicator()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.width(1.dp)
|
||||
.fillMaxHeight()
|
||||
.background(MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.3f))
|
||||
)
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.fillMaxHeight(),
|
||||
contentAlignment = Alignment.BottomCenter,
|
||||
) {
|
||||
InputArea(
|
||||
state = state,
|
||||
inputText = inputText,
|
||||
viewModel = viewModel,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.imePadding(),
|
||||
recordSec = recordSec,
|
||||
isRecording = isRecording,
|
||||
isLocked = isLocked,
|
||||
typingDots = typingDots,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MessageTopBar(
|
||||
onDismiss: () -> Unit,
|
||||
onNavigateToMain: () -> Unit,
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(MaterialTheme.colorScheme.surface.copy(alpha = 0.3f))
|
||||
.padding(horizontal = 8.dp, vertical = 4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
IconButton(onClick = onDismiss) {
|
||||
Icon(Icons.Filled.Close, contentDescription = "关闭")
|
||||
}
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
IconButton(onClick = onNavigateToMain) {
|
||||
Icon(
|
||||
Icons.AutoMirrored.Filled.OpenInNew,
|
||||
contentDescription = "进入主界面",
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun InputArea(
|
||||
state: OverlayState,
|
||||
inputText: String,
|
||||
viewModel: OverlayViewModel,
|
||||
modifier: Modifier = Modifier,
|
||||
recordSec: Float = 0f,
|
||||
isRecording: Boolean = false,
|
||||
isLocked: Boolean = false,
|
||||
typingDots: String = "",
|
||||
) {
|
||||
// Gesture tracking state — local to InputArea
|
||||
var isDragging by remember { mutableStateOf(false) }
|
||||
var dragOffsetX by remember { mutableStateOf(0f) }
|
||||
var dragOffsetY by remember { mutableStateOf(0f) }
|
||||
val inCancelZone = isDragging && dragOffsetY < -120f
|
||||
val inLockZone = isDragging && dragOffsetX > 60f
|
||||
val isProcessing = state == OverlayState.PROCESSING
|
||||
|
||||
Surface(
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(topStart = 24.dp, topEnd = 24.dp),
|
||||
shadowElevation = 8.dp,
|
||||
color = MaterialTheme.colorScheme.surface,
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(12.dp),
|
||||
) {
|
||||
// "昔涟正在输入..." indicator
|
||||
if (isProcessing && typingDots.isNotEmpty()) {
|
||||
Text(
|
||||
text = "昔涟正在输入$typingDots",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(bottom = 4.dp),
|
||||
textAlign = TextAlign.Start,
|
||||
)
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
if (isRecording && isDragging) {
|
||||
// Recording with drag — show recording indicator
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
.background(
|
||||
if (inCancelZone) MaterialTheme.colorScheme.errorContainer
|
||||
else MaterialTheme.colorScheme.surfaceVariant
|
||||
)
|
||||
.padding(horizontal = 16.dp, vertical = 14.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(
|
||||
text = when {
|
||||
inCancelZone -> "松手取消"
|
||||
inLockZone -> "松手录音"
|
||||
else -> "%.1f\" 上滑取消 右滑松手".format(recordSec)
|
||||
},
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = if (inCancelZone) MaterialTheme.colorScheme.error
|
||||
else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.padding(start = 8.dp)
|
||||
.size(48.dp)
|
||||
.clip(CircleShape)
|
||||
.background(MaterialTheme.colorScheme.primary)
|
||||
.offset { IntOffset(dragOffsetX.toInt(), dragOffsetY.toInt()) },
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Icon(
|
||||
Icons.Filled.Mic,
|
||||
contentDescription = "录音中",
|
||||
tint = MaterialTheme.colorScheme.onPrimary,
|
||||
)
|
||||
}
|
||||
} else if (isLocked) {
|
||||
// Locked (hands-free) mode
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
.background(MaterialTheme.colorScheme.primaryContainer)
|
||||
.padding(horizontal = 16.dp, vertical = 14.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(
|
||||
Icons.Filled.Lock,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(16.dp),
|
||||
tint = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(
|
||||
text = "%.1f\" 松手录音中 — 点击结束".format(recordSec),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||
)
|
||||
}
|
||||
}
|
||||
IconButton(onClick = { viewModel.finishRecord() }) {
|
||||
Icon(
|
||||
Icons.AutoMirrored.Filled.Send,
|
||||
contentDescription = "发送",
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
// Normal input mode
|
||||
OutlinedTextField(
|
||||
value = inputText,
|
||||
onValueChange = { viewModel.onInputChanged(it) },
|
||||
placeholder = { Text("输入消息...") },
|
||||
modifier = Modifier.weight(1f),
|
||||
maxLines = 3,
|
||||
shape = MaterialTheme.shapes.medium,
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
|
||||
if (inputText.isNotBlank()) {
|
||||
IconButton(
|
||||
onClick = { viewModel.sendText() },
|
||||
enabled = !isProcessing,
|
||||
) {
|
||||
Icon(
|
||||
Icons.AutoMirrored.Filled.Send,
|
||||
contentDescription = "发送",
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(48.dp)
|
||||
.pointerInput(Unit) {
|
||||
detectDragGesturesAfterLongPress(
|
||||
onDragStart = { _ ->
|
||||
isDragging = true
|
||||
dragOffsetX = 0f
|
||||
dragOffsetY = 0f
|
||||
viewModel.startRecord()
|
||||
},
|
||||
onDrag = { change, dragAmount ->
|
||||
change.consume()
|
||||
dragOffsetX += dragAmount.x
|
||||
dragOffsetY += dragAmount.y
|
||||
},
|
||||
onDragEnd = {
|
||||
isDragging = false
|
||||
when {
|
||||
dragOffsetY < -120f -> viewModel.cancelRecord()
|
||||
dragOffsetX > 60f -> viewModel.lockRecord()
|
||||
else -> viewModel.finishRecord()
|
||||
}
|
||||
dragOffsetX = 0f
|
||||
dragOffsetY = 0f
|
||||
},
|
||||
onDragCancel = {
|
||||
isDragging = false
|
||||
viewModel.cancelRecord()
|
||||
dragOffsetX = 0f
|
||||
dragOffsetY = 0f
|
||||
},
|
||||
)
|
||||
},
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Icon(
|
||||
Icons.Filled.KeyboardVoice,
|
||||
contentDescription = "按住录音",
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Status hint
|
||||
val hint = when {
|
||||
isLocked -> ""
|
||||
isRecording && isDragging -> ""
|
||||
else -> when (state) {
|
||||
OverlayState.LISTENING -> "我在听..."
|
||||
OverlayState.PROCESSING -> "思考中..."
|
||||
OverlayState.SPEAKING -> "正在说话..."
|
||||
OverlayState.WAITING -> "长按麦克风开始说话"
|
||||
OverlayState.IDLE -> ""
|
||||
}
|
||||
}
|
||||
if (hint.isNotEmpty()) {
|
||||
Text(
|
||||
text = hint,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(top = 4.dp),
|
||||
textAlign = TextAlign.Center,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,191 +0,0 @@
|
||||
package top.yeij.cyrene.ui.screens.about
|
||||
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.automirrored.filled.OpenInNew
|
||||
import androidx.compose.material.icons.filled.Code
|
||||
import androidx.compose.material.icons.filled.Info
|
||||
import androidx.compose.material.icons.filled.Person
|
||||
import androidx.compose.material3.CenterAlignedTopAppBar
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.ListItem
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun AboutScreen(onBack: () -> Unit) {
|
||||
val context = LocalContext.current
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
CenterAlignedTopAppBar(
|
||||
title = { Text("关于") },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onBack) {
|
||||
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "返回")
|
||||
}
|
||||
},
|
||||
)
|
||||
},
|
||||
) { padding ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding)
|
||||
.verticalScroll(rememberScrollState()),
|
||||
) {
|
||||
Text(
|
||||
text = "Cyrene",
|
||||
style = MaterialTheme.typography.headlineLarge.copy(
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = 28.sp,
|
||||
),
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(top = 24.dp),
|
||||
)
|
||||
Text(
|
||||
text = "昔涟",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
Text(
|
||||
text = "v0.1.0",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f),
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(top = 4.dp),
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
HorizontalDivider()
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
Text(
|
||||
text = "信息",
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
)
|
||||
|
||||
ListItem(
|
||||
headlineContent = { Text("简介") },
|
||||
leadingContent = {
|
||||
Icon(Icons.Filled.Info, contentDescription = null)
|
||||
},
|
||||
supportingContent = {
|
||||
Text(
|
||||
text = "Cyrene 是一款基于 Android 的智能语音助手,支持实时对话、IoT 设备管理和语音交互。",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
ListItem(
|
||||
headlineContent = { Text("开发者") },
|
||||
leadingContent = {
|
||||
Icon(Icons.Filled.Person, contentDescription = null)
|
||||
},
|
||||
supportingContent = { Text("AskaEth") },
|
||||
)
|
||||
|
||||
HorizontalDivider(modifier = Modifier.padding(horizontal = 16.dp))
|
||||
|
||||
ListItem(
|
||||
headlineContent = { Text("源代码") },
|
||||
leadingContent = {
|
||||
Icon(Icons.Filled.Code, contentDescription = null)
|
||||
},
|
||||
supportingContent = { Text("git.yeij.top/AskaEth/Cyrene-For-Android") },
|
||||
trailingContent = {
|
||||
Icon(
|
||||
Icons.AutoMirrored.Filled.OpenInNew,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(20.dp),
|
||||
)
|
||||
},
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable {
|
||||
val intent = Intent(Intent.ACTION_VIEW).apply {
|
||||
data = Uri.parse("https://git.yeij.top/AskaEth/Cyrene-For-Android")
|
||||
}
|
||||
context.startActivity(intent)
|
||||
},
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
HorizontalDivider()
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
Text(
|
||||
text = "技术栈",
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
)
|
||||
|
||||
val techStack = listOf(
|
||||
"Kotlin" to "主语言",
|
||||
"Jetpack Compose" to "UI 框架",
|
||||
"Material Design 3" to "设计系统",
|
||||
"Room" to "本地数据库",
|
||||
"OkHttp + Retrofit" to "网络请求",
|
||||
"WebSocket" to "实时通信",
|
||||
"Koin" to "依赖注入",
|
||||
)
|
||||
techStack.forEach { (name, desc) ->
|
||||
ListItem(
|
||||
headlineContent = { Text(name) },
|
||||
supportingContent = { Text(desc) },
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
HorizontalDivider()
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
Text(
|
||||
text = "© 2026 AskaEth. All rights reserved.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f),
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 16.dp),
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(32.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,370 +0,0 @@
|
||||
package top.yeij.cyrene.ui.screens.chat
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.slideInVertically
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||
import androidx.compose.foundation.layout.offset
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.Send
|
||||
import androidx.compose.material.icons.filled.KeyboardVoice
|
||||
import androidx.compose.material.icons.filled.Lock
|
||||
import androidx.compose.material.icons.filled.Mic
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.layout.onGloballyPositioned
|
||||
import androidx.compose.ui.layout.positionInRoot
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.IntOffset
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kotlinx.coroutines.delay
|
||||
import org.koin.androidx.compose.koinViewModel
|
||||
import top.yeij.cyrene.domain.model.Message
|
||||
import top.yeij.cyrene.ui.components.ChatBubble
|
||||
import top.yeij.cyrene.ui.components.CyreneStatus
|
||||
import top.yeij.cyrene.ui.components.StatusIndicator
|
||||
import top.yeij.cyrene.ui.components.TypingIndicator
|
||||
import top.yeij.cyrene.util.RecordState
|
||||
import top.yeij.cyrene.viewmodel.ChatViewModel
|
||||
import kotlin.math.min
|
||||
|
||||
@Composable
|
||||
private fun AnimatedChatBubble(
|
||||
message: Message,
|
||||
animIndex: Int,
|
||||
) {
|
||||
var visible by remember { mutableStateOf(false) }
|
||||
LaunchedEffect(Unit) {
|
||||
delay(min(animIndex, 10) * 60L)
|
||||
visible = true
|
||||
}
|
||||
AnimatedVisibility(
|
||||
visible = visible,
|
||||
enter = fadeIn(animationSpec = androidx.compose.animation.core.tween(300)) +
|
||||
slideInVertically(
|
||||
animationSpec = androidx.compose.animation.core.tween(300),
|
||||
initialOffsetY = { it / 4 },
|
||||
),
|
||||
) {
|
||||
ChatBubble(
|
||||
content = message.content,
|
||||
role = message.role,
|
||||
msgType = message.msgType,
|
||||
timestamp = message.timestamp,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun ChatScreen(
|
||||
viewModel: ChatViewModel = koinViewModel(),
|
||||
) {
|
||||
val messages by viewModel.currentMessages.collectAsState()
|
||||
val inputText by viewModel.inputText.collectAsState()
|
||||
val isStreaming by viewModel.isStreaming.collectAsState()
|
||||
val isConnected by viewModel.isConnected.collectAsState()
|
||||
val isRefreshing by viewModel.isRefreshing.collectAsState()
|
||||
val recordState by viewModel.voiceRecordState.collectAsState()
|
||||
val recordDurationMs by viewModel.voiceRecordDurationMs.collectAsState()
|
||||
val animIndex by viewModel.messageAnimIndex.collectAsState()
|
||||
val listState = rememberLazyListState()
|
||||
|
||||
// Gesture tracking state
|
||||
var isDragging by remember { mutableStateOf(false) }
|
||||
var dragOffsetX by remember { mutableStateOf(0f) }
|
||||
var dragOffsetY by remember { mutableStateOf(0f) }
|
||||
var recordButtonY by remember { mutableStateOf(0f) }
|
||||
|
||||
val recordSec = recordDurationMs / 1000f
|
||||
val isRecording = recordState == RecordState.RECORDING
|
||||
val isLocked = recordState == RecordState.LOCKED
|
||||
val inCancelZone = isDragging && dragOffsetY < -120f
|
||||
val inLockZone = isDragging && dragOffsetX > 60f
|
||||
|
||||
LaunchedEffect(messages.size, isStreaming) {
|
||||
if (messages.isNotEmpty()) {
|
||||
val targetIndex = if (isStreaming) messages.size else messages.size - 1
|
||||
listState.animateScrollToItem(targetIndex)
|
||||
}
|
||||
}
|
||||
|
||||
// Animated "昔涟正在输入..." dots
|
||||
val typingDots = remember { mutableStateOf("") }
|
||||
LaunchedEffect(isStreaming) {
|
||||
if (isStreaming) {
|
||||
val dots = arrayOf("", ".", "..", "...")
|
||||
var i = 0
|
||||
while (true) {
|
||||
typingDots.value = dots[i % 4]
|
||||
i++
|
||||
delay(400)
|
||||
}
|
||||
} else {
|
||||
typingDots.value = ""
|
||||
}
|
||||
}
|
||||
|
||||
val status = when {
|
||||
isStreaming -> CyreneStatus.THINKING
|
||||
isConnected -> CyreneStatus.ONLINE
|
||||
else -> CyreneStatus.OFFLINE
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
StatusIndicator(status = status)
|
||||
}
|
||||
},
|
||||
bottomBar = {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.navigationBarsPadding(),
|
||||
) {
|
||||
// "昔涟正在输入..." indicator
|
||||
if (isStreaming) {
|
||||
Text(
|
||||
text = "昔涟正在输入${typingDots.value}",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 2.dp),
|
||||
textAlign = TextAlign.Start,
|
||||
)
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 12.dp, vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
if (isRecording && isDragging) {
|
||||
// Recording state with drag — show recording indicator
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
.background(
|
||||
if (inCancelZone) MaterialTheme.colorScheme.errorContainer
|
||||
else MaterialTheme.colorScheme.surfaceVariant
|
||||
)
|
||||
.padding(horizontal = 16.dp, vertical = 14.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(
|
||||
text = when {
|
||||
inCancelZone -> "松手取消"
|
||||
inLockZone -> "松手录音"
|
||||
else -> "%.1f\" 上滑取消 右滑松手".format(recordSec)
|
||||
},
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = if (inCancelZone) MaterialTheme.colorScheme.error
|
||||
else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
// Record button (drag anchor)
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.padding(start = 8.dp)
|
||||
.size(48.dp)
|
||||
.clip(CircleShape)
|
||||
.background(MaterialTheme.colorScheme.primary)
|
||||
.offset { IntOffset(dragOffsetX.toInt(), dragOffsetY.toInt()) },
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Icon(
|
||||
Icons.Filled.Mic,
|
||||
contentDescription = "录音中",
|
||||
tint = MaterialTheme.colorScheme.onPrimary,
|
||||
)
|
||||
}
|
||||
} else if (isLocked) {
|
||||
// Locked (hands-free) mode
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
.background(MaterialTheme.colorScheme.primaryContainer)
|
||||
.padding(horizontal = 16.dp, vertical = 14.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(
|
||||
Icons.Filled.Lock,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(16.dp),
|
||||
tint = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(
|
||||
text = "%.1f\" 松手录音中 — 点击结束".format(recordSec),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||
)
|
||||
}
|
||||
}
|
||||
IconButton(onClick = { viewModel.finishRecord() }) {
|
||||
Icon(
|
||||
Icons.AutoMirrored.Filled.Send,
|
||||
contentDescription = "发送",
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
// Normal input mode
|
||||
OutlinedTextField(
|
||||
value = inputText,
|
||||
onValueChange = { viewModel.onInputChanged(it) },
|
||||
placeholder = { Text("输入消息...") },
|
||||
modifier = Modifier.weight(1f),
|
||||
maxLines = 4,
|
||||
shape = MaterialTheme.shapes.medium,
|
||||
)
|
||||
// Voice record button with long-press gesture
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.padding(start = 4.dp)
|
||||
.size(48.dp)
|
||||
.onGloballyPositioned { recordButtonY = it.positionInRoot().y }
|
||||
.pointerInput(Unit) {
|
||||
detectDragGesturesAfterLongPress(
|
||||
onDragStart = { offset ->
|
||||
isDragging = true
|
||||
dragOffsetX = 0f
|
||||
dragOffsetY = 0f
|
||||
viewModel.startRecord()
|
||||
},
|
||||
onDrag = { change, dragAmount ->
|
||||
change.consume()
|
||||
dragOffsetX += dragAmount.x
|
||||
dragOffsetY += dragAmount.y
|
||||
},
|
||||
onDragEnd = {
|
||||
isDragging = false
|
||||
when {
|
||||
dragOffsetY < -120f -> viewModel.cancelRecord()
|
||||
dragOffsetX > 60f -> viewModel.lockRecord()
|
||||
else -> viewModel.finishRecord()
|
||||
}
|
||||
dragOffsetX = 0f
|
||||
dragOffsetY = 0f
|
||||
},
|
||||
onDragCancel = {
|
||||
isDragging = false
|
||||
viewModel.cancelRecord()
|
||||
dragOffsetX = 0f
|
||||
dragOffsetY = 0f
|
||||
},
|
||||
)
|
||||
},
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Icon(
|
||||
Icons.Filled.KeyboardVoice,
|
||||
contentDescription = "按住录音",
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
// Send button (only when text present)
|
||||
if (inputText.isNotBlank()) {
|
||||
IconButton(
|
||||
onClick = { viewModel.sendMessage() },
|
||||
enabled = !isStreaming,
|
||||
) {
|
||||
if (isStreaming) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(24.dp),
|
||||
strokeWidth = 2.dp,
|
||||
)
|
||||
} else {
|
||||
Icon(Icons.AutoMirrored.Filled.Send, contentDescription = "发送")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
) { padding ->
|
||||
PullToRefreshBox(
|
||||
isRefreshing = isRefreshing,
|
||||
onRefresh = { viewModel.refreshMessages() },
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding),
|
||||
) {
|
||||
if (messages.isEmpty() && !isStreaming) {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(
|
||||
text = "开始和昔涟对话吧",
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
state = listState,
|
||||
) {
|
||||
items(messages, key = { it.id }) { message ->
|
||||
AnimatedChatBubble(
|
||||
message = message,
|
||||
animIndex = animIndex[message.id] ?: 0,
|
||||
)
|
||||
}
|
||||
if (isStreaming) {
|
||||
item(key = "typing_indicator") {
|
||||
TypingIndicator()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,59 +0,0 @@
|
||||
package top.yeij.cyrene.ui.screens.iot
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import org.koin.androidx.compose.koinViewModel
|
||||
import top.yeij.cyrene.ui.components.DeviceCard
|
||||
import top.yeij.cyrene.viewmodel.IoTViewModel
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun IoTScreen(
|
||||
viewModel: IoTViewModel = koinViewModel(),
|
||||
) {
|
||||
val devices by viewModel.devices.collectAsState()
|
||||
val isLoading by viewModel.isLoading.collectAsState()
|
||||
|
||||
PullToRefreshBox(
|
||||
isRefreshing = isLoading,
|
||||
onRefresh = { viewModel.refreshDevices() },
|
||||
modifier = Modifier.background(MaterialTheme.colorScheme.background),
|
||||
) {
|
||||
if (devices.isEmpty() && !isLoading) {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize().padding(32.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(
|
||||
text = "暂无设备数据\n请确认已连接到服务器",
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
LazyColumn {
|
||||
items(devices, key = { it.id }) { device ->
|
||||
DeviceCard(
|
||||
device = device,
|
||||
onTogglePower = { viewModel.togglePower(device) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,165 +0,0 @@
|
||||
package top.yeij.cyrene.ui.screens.login
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Person
|
||||
import androidx.compose.material.icons.filled.Visibility
|
||||
import androidx.compose.material.icons.filled.VisibilityOff
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.SnackbarHost
|
||||
import androidx.compose.material3.SnackbarHostState
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||
import androidx.compose.ui.text.input.VisualTransformation
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kotlinx.coroutines.launch
|
||||
import org.koin.compose.koinInject
|
||||
import top.yeij.cyrene.domain.usecase.LoginUseCase
|
||||
import top.yeij.cyrene.viewmodel.SettingsViewModel
|
||||
|
||||
@Composable
|
||||
fun LoginScreen(
|
||||
onLoginSuccess: () -> Unit,
|
||||
loginUseCase: LoginUseCase = koinInject(),
|
||||
settingsViewModel: SettingsViewModel = koinInject(),
|
||||
) {
|
||||
var username by remember { mutableStateOf("") }
|
||||
var password by remember { mutableStateOf("") }
|
||||
var showPassword by remember { mutableStateOf(false) }
|
||||
var isLoading by remember { mutableStateOf(false) }
|
||||
val snackbarHostState = remember { SnackbarHostState() }
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
Scaffold(
|
||||
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||
) { padding ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding)
|
||||
.padding(horizontal = 32.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Spacer(modifier = Modifier.height(80.dp))
|
||||
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Person,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(64.dp),
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
Text(
|
||||
text = "Cyrene",
|
||||
style = MaterialTheme.typography.displayLarge,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
Text(
|
||||
text = "昔涟",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(48.dp))
|
||||
|
||||
OutlinedTextField(
|
||||
value = username,
|
||||
onValueChange = { username = it },
|
||||
label = { Text("用户名") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
|
||||
singleLine = true,
|
||||
shape = MaterialTheme.shapes.medium,
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
OutlinedTextField(
|
||||
value = password,
|
||||
onValueChange = { password = it },
|
||||
label = { Text("密码") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = MaterialTheme.shapes.medium,
|
||||
visualTransformation = if (showPassword)
|
||||
VisualTransformation.None
|
||||
else
|
||||
PasswordVisualTransformation(),
|
||||
keyboardOptions = KeyboardOptions(
|
||||
keyboardType = KeyboardType.Password,
|
||||
imeAction = ImeAction.Done,
|
||||
),
|
||||
singleLine = true,
|
||||
trailingIcon = {
|
||||
IconButton(onClick = { showPassword = !showPassword }) {
|
||||
Icon(
|
||||
if (showPassword) Icons.Filled.VisibilityOff else Icons.Filled.Visibility,
|
||||
contentDescription = if (showPassword) "隐藏密码" else "显示密码",
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
|
||||
Button(
|
||||
onClick = {
|
||||
isLoading = true
|
||||
scope.launch {
|
||||
loginUseCase(username, password).fold(
|
||||
onSuccess = {
|
||||
isLoading = false
|
||||
onLoginSuccess()
|
||||
},
|
||||
onFailure = { error ->
|
||||
isLoading = false
|
||||
scope.launch {
|
||||
snackbarHostState.showSnackbar(
|
||||
error.message ?: "登录失败",
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth().height(50.dp),
|
||||
enabled = username.isNotBlank() && password.isNotBlank() && !isLoading,
|
||||
) {
|
||||
if (isLoading) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(20.dp),
|
||||
strokeWidth = 2.dp,
|
||||
color = MaterialTheme.colorScheme.onPrimary,
|
||||
)
|
||||
} else {
|
||||
Text("登录")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,394 +0,0 @@
|
||||
package top.yeij.cyrene.ui.screens.profile
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ExitToApp
|
||||
import androidx.compose.material.icons.automirrored.filled.Help
|
||||
import androidx.compose.material.icons.filled.AdminPanelSettings
|
||||
import androidx.compose.material.icons.filled.CalendarMonth
|
||||
import androidx.compose.material.icons.filled.ChevronRight
|
||||
import androidx.compose.material.icons.filled.Circle
|
||||
import androidx.compose.material.icons.filled.Info
|
||||
import androidx.compose.material.icons.filled.Notifications
|
||||
import androidx.compose.material.icons.filled.Person
|
||||
import androidx.compose.material.icons.filled.Settings
|
||||
import androidx.compose.material.icons.filled.Tag
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.ListItem
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import org.koin.compose.koinInject
|
||||
import top.yeij.cyrene.viewmodel.ProfileViewModel
|
||||
|
||||
@Composable
|
||||
fun ProfileScreen(
|
||||
onNavigateToSettings: () -> Unit,
|
||||
onLogout: () -> Unit,
|
||||
onNavigateToLogin: () -> Unit,
|
||||
onNavigateToAbout: () -> Unit = {},
|
||||
isDefaultAssistant: Boolean = false,
|
||||
onOpenAssistantSettings: () -> Unit = {},
|
||||
profileViewModel: ProfileViewModel = koinInject(),
|
||||
) {
|
||||
val profile by profileViewModel.profile.collectAsState()
|
||||
var showLogoutDialog by remember { mutableStateOf(false) }
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
profileViewModel.fetchFreshProfile()
|
||||
}
|
||||
|
||||
if (showLogoutDialog) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { showLogoutDialog = false },
|
||||
title = { Text("退出登录") },
|
||||
text = { Text("确定要退出登录吗?") },
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = {
|
||||
showLogoutDialog = false
|
||||
profileViewModel.logout()
|
||||
onLogout()
|
||||
},
|
||||
colors = ButtonDefaults.textButtonColors(
|
||||
contentColor = MaterialTheme.colorScheme.error,
|
||||
),
|
||||
) {
|
||||
Text("退出")
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { showLogoutDialog = false }) {
|
||||
Text("取消")
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(MaterialTheme.colorScheme.background)
|
||||
.verticalScroll(rememberScrollState()),
|
||||
) {
|
||||
// Profile header
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(24.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
// Avatar
|
||||
val initials = if (profile.nickname.isNotBlank()) {
|
||||
profile.nickname.take(1)
|
||||
} else if (profile.username.isNotBlank()) {
|
||||
profile.username.take(1)
|
||||
} else {
|
||||
"?"
|
||||
}
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(80.dp)
|
||||
.clip(CircleShape)
|
||||
.background(MaterialTheme.colorScheme.primaryContainer),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(
|
||||
text = initials,
|
||||
style = MaterialTheme.typography.headlineLarge,
|
||||
color = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
if (profile.isLoading) {
|
||||
CircularProgressIndicator(modifier = Modifier.size(24.dp))
|
||||
} else if (!profile.isLoggedIn) {
|
||||
Text(
|
||||
text = "未登录",
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.clickable { onNavigateToLogin() },
|
||||
)
|
||||
Text(
|
||||
text = "点击登录以查看个人信息",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
} else {
|
||||
// Nickname
|
||||
Text(
|
||||
text = profile.nickname.ifEmpty { profile.username },
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.onBackground,
|
||||
)
|
||||
// Username
|
||||
if (profile.nickname.isNotBlank() && profile.nickname != profile.username) {
|
||||
Text(
|
||||
text = "@${profile.username}",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
// Admin badge
|
||||
if (profile.isAdmin) {
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Surface(
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
color = MaterialTheme.colorScheme.primaryContainer,
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Icon(
|
||||
Icons.Filled.AdminPanelSettings,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(14.dp),
|
||||
tint = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||
)
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
Text(
|
||||
text = "管理员",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
HorizontalDivider()
|
||||
|
||||
if (profile.isLoggedIn) {
|
||||
// User info card
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
Text(
|
||||
text = "账号信息",
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
)
|
||||
|
||||
ProfileInfoCard(
|
||||
items = listOf(
|
||||
ProfileInfoItem(Icons.Filled.Tag, "用户 ID", profile.userId),
|
||||
ProfileInfoItem(Icons.Filled.CalendarMonth, "注册时间", profile.createdAt.ifEmpty { "未知" }),
|
||||
),
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
HorizontalDivider()
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
// Assistant status
|
||||
Text(
|
||||
text = "助手",
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
)
|
||||
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp),
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
color = if (isDefaultAssistant)
|
||||
Color(0xFF4CAF50).copy(alpha = 0.1f)
|
||||
else
|
||||
MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.5f),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Icon(
|
||||
Icons.Filled.Circle,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(10.dp),
|
||||
tint = if (isDefaultAssistant) Color(0xFF4CAF50) else Color(0xFFFF5722),
|
||||
)
|
||||
Spacer(modifier = Modifier.width(12.dp))
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = if (isDefaultAssistant) "已设为默认助手" else "未设为默认助手",
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
if (!isDefaultAssistant) {
|
||||
Text(
|
||||
text = "设为默认助手后,长按电源键或Home键即可呼出昔涟",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
if (!isDefaultAssistant) {
|
||||
TextButton(onClick = onOpenAssistantSettings) {
|
||||
Text("去设置")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
HorizontalDivider()
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
// Menu items
|
||||
Text(
|
||||
text = "其他",
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
)
|
||||
|
||||
ListItem(
|
||||
headlineContent = { Text("设置") },
|
||||
leadingContent = { Icon(Icons.Filled.Settings, contentDescription = null) },
|
||||
trailingContent = { Icon(Icons.Filled.ChevronRight, contentDescription = null) },
|
||||
modifier = Modifier.clickable { onNavigateToSettings() },
|
||||
)
|
||||
|
||||
ListItem(
|
||||
headlineContent = { Text("提醒") },
|
||||
leadingContent = { Icon(Icons.Filled.Notifications, contentDescription = null) },
|
||||
trailingContent = { Icon(Icons.Filled.ChevronRight, contentDescription = null) },
|
||||
)
|
||||
|
||||
HorizontalDivider(modifier = Modifier.padding(horizontal = 16.dp))
|
||||
|
||||
ListItem(
|
||||
headlineContent = { Text("关于") },
|
||||
leadingContent = { Icon(Icons.Filled.Info, contentDescription = null) },
|
||||
supportingContent = { Text("Cyrene v0.1.0") },
|
||||
modifier = Modifier.clickable { onNavigateToAbout() },
|
||||
)
|
||||
|
||||
ListItem(
|
||||
headlineContent = { Text("使用帮助") },
|
||||
leadingContent = { Icon(Icons.AutoMirrored.Filled.Help, contentDescription = null) },
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
// Logout
|
||||
if (profile.isLoggedIn) {
|
||||
ListItem(
|
||||
headlineContent = {
|
||||
Text(
|
||||
text = "退出登录",
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
},
|
||||
leadingContent = {
|
||||
Icon(
|
||||
Icons.AutoMirrored.Filled.ExitToApp,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
},
|
||||
modifier = Modifier.clickable { showLogoutDialog = true },
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(32.dp))
|
||||
}
|
||||
}
|
||||
|
||||
private data class ProfileInfoItem(
|
||||
val icon: ImageVector,
|
||||
val label: String,
|
||||
val value: String,
|
||||
)
|
||||
|
||||
@Composable
|
||||
private fun ProfileInfoCard(items: List<ProfileInfoItem>) {
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp),
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f),
|
||||
) {
|
||||
Column {
|
||||
items.forEachIndexed { index, item ->
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Icon(
|
||||
item.icon,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(20.dp),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Spacer(modifier = Modifier.width(12.dp))
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = item.label,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Text(
|
||||
text = item.value,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
if (index < items.size - 1) {
|
||||
HorizontalDivider(
|
||||
modifier = Modifier.padding(horizontal = 16.dp),
|
||||
color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,578 +0,0 @@
|
||||
package top.yeij.cyrene.ui.screens.settings
|
||||
|
||||
import android.widget.Toast
|
||||
import androidx.biometric.BiometricManager
|
||||
import androidx.biometric.BiometricPrompt
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.Check
|
||||
import androidx.compose.material.icons.filled.DarkMode
|
||||
import androidx.compose.material.icons.filled.DeleteForever
|
||||
import androidx.compose.material.icons.filled.LightMode
|
||||
import androidx.compose.material.icons.filled.Palette
|
||||
import androidx.compose.material.icons.filled.SettingsBrightness
|
||||
import androidx.compose.material.icons.filled.Share
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.CenterAlignedTopAppBar
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.FilledTonalIconButton
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.ListItem
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Tab
|
||||
import androidx.compose.material3.TabRow
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.fragment.app.FragmentActivity
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kotlinx.coroutines.launch
|
||||
import org.koin.compose.koinInject
|
||||
import top.yeij.cyrene.util.LogCategory
|
||||
import top.yeij.cyrene.util.RuntimeLog
|
||||
import top.yeij.cyrene.viewmodel.SettingsViewModel
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun SettingsScreen(
|
||||
onBack: () -> Unit,
|
||||
viewModel: SettingsViewModel = koinInject(),
|
||||
) {
|
||||
val baseUrl by viewModel.baseUrl.collectAsState()
|
||||
val themeMode by viewModel.themeMode.collectAsState()
|
||||
val wakeWord by viewModel.wakeWord.collectAsState()
|
||||
val dashScopeApiKey by viewModel.dashScopeApiKey.collectAsState()
|
||||
val dashScopeEndpoint by viewModel.dashScopeEndpoint.collectAsState()
|
||||
val dashScopeModel by viewModel.dashScopeModel.collectAsState()
|
||||
val autoScreenContext by viewModel.autoScreenContext.collectAsState()
|
||||
val context = LocalContext.current
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
fun sanitizeUrl(raw: String): String? {
|
||||
var url = raw.trim()
|
||||
if (url.isEmpty()) return null
|
||||
val hasScheme = url.contains("://")
|
||||
if (!hasScheme) url = "http://$url"
|
||||
return try {
|
||||
val parsed = java.net.URL(url)
|
||||
val host = parsed.host ?: return null
|
||||
if (host.isEmpty()) return null
|
||||
val scheme = parsed.protocol
|
||||
val port = if (parsed.port > 0) ":${parsed.port}" else ""
|
||||
val path = parsed.path?.trimEnd('/') ?: ""
|
||||
val query = parsed.query?.let { "?$it" } ?: ""
|
||||
"$scheme://$host$port$path$query"
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
CenterAlignedTopAppBar(
|
||||
title = { Text("设置") },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onBack) {
|
||||
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "返回")
|
||||
}
|
||||
},
|
||||
)
|
||||
},
|
||||
) { padding ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding)
|
||||
.verticalScroll(rememberScrollState()),
|
||||
) {
|
||||
// Server
|
||||
Text(
|
||||
text = "服务器",
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.padding(16.dp),
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = baseUrl,
|
||||
onValueChange = { viewModel.updateBaseUrlInput(it) },
|
||||
label = { Text("服务器地址") },
|
||||
placeholder = { Text("http://192.168.1.x:8080") },
|
||||
singleLine = true,
|
||||
shape = MaterialTheme.shapes.medium,
|
||||
keyboardOptions = KeyboardOptions(
|
||||
keyboardType = KeyboardType.Uri,
|
||||
imeAction = ImeAction.Done,
|
||||
),
|
||||
keyboardActions = KeyboardActions(
|
||||
onDone = {
|
||||
scope.launch {
|
||||
val sanitized = sanitizeUrl(baseUrl)
|
||||
if (sanitized != null) {
|
||||
viewModel.saveBaseUrl(sanitized)
|
||||
Toast.makeText(context, "地址已保存", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
},
|
||||
),
|
||||
trailingIcon = {
|
||||
FilledTonalIconButton(onClick = {
|
||||
val sanitized = sanitizeUrl(baseUrl)
|
||||
if (sanitized != null) {
|
||||
viewModel.saveBaseUrl(sanitized)
|
||||
Toast.makeText(context, "地址已保存", Toast.LENGTH_SHORT).show()
|
||||
} else {
|
||||
Toast.makeText(context, "地址格式无效", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}) {
|
||||
Icon(Icons.Filled.Check, contentDescription = "确认")
|
||||
}
|
||||
},
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp),
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
HorizontalDivider()
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
// Appearance
|
||||
Text(
|
||||
text = "外观",
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.padding(16.dp),
|
||||
)
|
||||
|
||||
val themeLabel = when (themeMode) {
|
||||
"light" -> "浅色模式"
|
||||
"dark" -> "深色模式"
|
||||
else -> "跟随系统"
|
||||
}
|
||||
val themeIcon = when (themeMode) {
|
||||
"light" -> Icons.Filled.LightMode
|
||||
"dark" -> Icons.Filled.DarkMode
|
||||
else -> Icons.Filled.SettingsBrightness
|
||||
}
|
||||
|
||||
ListItem(
|
||||
headlineContent = { Text("主题") },
|
||||
supportingContent = { Text(themeLabel) },
|
||||
leadingContent = { Icon(themeIcon, contentDescription = null) },
|
||||
modifier = Modifier.clickable {
|
||||
val next = when (themeMode) {
|
||||
"light" -> "dark"
|
||||
"dark" -> "auto"
|
||||
else -> "light"
|
||||
}
|
||||
viewModel.saveThemeMode(next)
|
||||
},
|
||||
)
|
||||
|
||||
ListItem(
|
||||
headlineContent = { Text("主题色") },
|
||||
supportingContent = { Text("昔涟紫") },
|
||||
leadingContent = { Icon(Icons.Filled.Palette, contentDescription = null) },
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
HorizontalDivider()
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
// Voice
|
||||
Text(
|
||||
text = "语音",
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.padding(16.dp),
|
||||
)
|
||||
|
||||
OutlinedTextField(
|
||||
value = wakeWord,
|
||||
onValueChange = { viewModel.saveWakeWord(it) },
|
||||
label = { Text("唤醒词") },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp),
|
||||
shape = MaterialTheme.shapes.medium,
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
ListItem(
|
||||
headlineContent = { Text("自动读取屏幕内容") },
|
||||
supportingContent = { Text("电源键唤起助手时自动截取当前屏幕内容并加入对话上下文") },
|
||||
trailingContent = {
|
||||
androidx.compose.material3.Switch(
|
||||
checked = autoScreenContext,
|
||||
onCheckedChange = { viewModel.saveAutoScreenContext(it) },
|
||||
)
|
||||
},
|
||||
modifier = Modifier.clickable { viewModel.saveAutoScreenContext(!autoScreenContext) },
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
HorizontalDivider()
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
// DashScope STT
|
||||
Text(
|
||||
text = "语音识别 (DashScope)",
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.padding(16.dp),
|
||||
)
|
||||
|
||||
OutlinedTextField(
|
||||
value = dashScopeApiKey,
|
||||
onValueChange = { viewModel.updateDashScopeApiKeyInput(it) },
|
||||
label = { Text("API Key") },
|
||||
placeholder = { Text("sk-xxxxxxxxxxxxxxxx") },
|
||||
singleLine = true,
|
||||
shape = MaterialTheme.shapes.medium,
|
||||
keyboardOptions = KeyboardOptions(
|
||||
keyboardType = KeyboardType.Password,
|
||||
imeAction = ImeAction.Done,
|
||||
),
|
||||
keyboardActions = KeyboardActions(
|
||||
onDone = {
|
||||
scope.launch {
|
||||
viewModel.saveDashScopeApiKey(dashScopeApiKey)
|
||||
Toast.makeText(context, "API Key 已保存", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
},
|
||||
),
|
||||
trailingIcon = {
|
||||
FilledTonalIconButton(onClick = {
|
||||
scope.launch {
|
||||
viewModel.saveDashScopeApiKey(dashScopeApiKey)
|
||||
Toast.makeText(context, "API Key 已保存", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}) {
|
||||
Icon(Icons.Filled.Check, contentDescription = "保存")
|
||||
}
|
||||
},
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp),
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
OutlinedTextField(
|
||||
value = dashScopeEndpoint,
|
||||
onValueChange = { viewModel.updateDashScopeEndpointInput(it) },
|
||||
label = { Text("WebSocket 端点") },
|
||||
placeholder = { Text("wss://dashscope.aliyuncs.com/api-ws/v1/inference") },
|
||||
singleLine = true,
|
||||
shape = MaterialTheme.shapes.medium,
|
||||
keyboardOptions = KeyboardOptions(
|
||||
keyboardType = KeyboardType.Uri,
|
||||
imeAction = ImeAction.Done,
|
||||
),
|
||||
keyboardActions = KeyboardActions(
|
||||
onDone = {
|
||||
scope.launch {
|
||||
viewModel.saveDashScopeEndpoint(dashScopeEndpoint)
|
||||
Toast.makeText(context, "端点已保存", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
},
|
||||
),
|
||||
trailingIcon = {
|
||||
FilledTonalIconButton(onClick = {
|
||||
scope.launch {
|
||||
viewModel.saveDashScopeEndpoint(dashScopeEndpoint)
|
||||
Toast.makeText(context, "端点已保存", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}) {
|
||||
Icon(Icons.Filled.Check, contentDescription = "保存")
|
||||
}
|
||||
},
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp),
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
OutlinedTextField(
|
||||
value = dashScopeModel,
|
||||
onValueChange = { viewModel.updateDashScopeModelInput(it) },
|
||||
label = { Text("模型") },
|
||||
placeholder = { Text("fun-asr-realtime") },
|
||||
singleLine = true,
|
||||
shape = MaterialTheme.shapes.medium,
|
||||
keyboardOptions = KeyboardOptions(
|
||||
imeAction = ImeAction.Done,
|
||||
),
|
||||
keyboardActions = KeyboardActions(
|
||||
onDone = {
|
||||
scope.launch {
|
||||
viewModel.saveDashScopeModel(dashScopeModel)
|
||||
Toast.makeText(context, "模型已保存", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
},
|
||||
),
|
||||
trailingIcon = {
|
||||
FilledTonalIconButton(onClick = {
|
||||
scope.launch {
|
||||
viewModel.saveDashScopeModel(dashScopeModel)
|
||||
Toast.makeText(context, "模型已保存", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}) {
|
||||
Icon(Icons.Filled.Check, contentDescription = "保存")
|
||||
}
|
||||
},
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp),
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
Text(
|
||||
text = "未配置 API Key 时,语音输入将自动使用后端服务处理。",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(horizontal = 16.dp),
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
HorizontalDivider()
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
// Data management
|
||||
Text(
|
||||
text = "数据",
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.padding(16.dp),
|
||||
)
|
||||
|
||||
var showClearDialog by remember { mutableStateOf(false) }
|
||||
ListItem(
|
||||
headlineContent = { Text("清空本地消息记录") },
|
||||
supportingContent = { Text("仅清除本地数据库,服务器消息仍保留。下次加载将只获取清除时间之后的消息。") },
|
||||
leadingContent = { Icon(Icons.Filled.DeleteForever, contentDescription = null, tint = MaterialTheme.colorScheme.error) },
|
||||
modifier = Modifier.clickable { showClearDialog = true },
|
||||
)
|
||||
|
||||
if (showClearDialog) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { showClearDialog = false },
|
||||
title = { Text("确认清空") },
|
||||
text = { Text("将清空所有本地消息记录。服务器上的消息不会被删除,但下次加载历史时将只获取本次清除之后的消息。") },
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = {
|
||||
showClearDialog = false
|
||||
val activity = context as? FragmentActivity
|
||||
if (activity == null) {
|
||||
viewModel.clearLocalMessages()
|
||||
Toast.makeText(context, "本地消息已清空", Toast.LENGTH_SHORT).show()
|
||||
return@TextButton
|
||||
}
|
||||
val biometricManager = BiometricManager.from(context)
|
||||
val canAuth = when (biometricManager.canAuthenticate(
|
||||
BiometricManager.Authenticators.BIOMETRIC_STRONG or
|
||||
BiometricManager.Authenticators.DEVICE_CREDENTIAL
|
||||
)) {
|
||||
BiometricManager.BIOMETRIC_SUCCESS -> true
|
||||
else -> false
|
||||
}
|
||||
if (!canAuth) {
|
||||
viewModel.clearLocalMessages()
|
||||
Toast.makeText(context, "本地消息已清空", Toast.LENGTH_SHORT).show()
|
||||
return@TextButton
|
||||
}
|
||||
val prompt = BiometricPrompt(
|
||||
activity,
|
||||
ContextCompat.getMainExecutor(context),
|
||||
object : BiometricPrompt.AuthenticationCallback() {
|
||||
override fun onAuthenticationSucceeded(
|
||||
result: BiometricPrompt.AuthenticationResult,
|
||||
) {
|
||||
viewModel.clearLocalMessages()
|
||||
Toast.makeText(context, "本地消息已清空", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
|
||||
override fun onAuthenticationFailed() {
|
||||
Toast.makeText(context, "验证失败", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
|
||||
override fun onAuthenticationError(
|
||||
errorCode: Int,
|
||||
errString: CharSequence,
|
||||
) {
|
||||
Toast.makeText(context, "验证取消", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
},
|
||||
)
|
||||
prompt.authenticate(
|
||||
BiometricPrompt.PromptInfo.Builder()
|
||||
.setTitle("身份验证")
|
||||
.setSubtitle("需要验证身份后才能清空消息记录")
|
||||
.setAllowedAuthenticators(
|
||||
BiometricManager.Authenticators.BIOMETRIC_STRONG or
|
||||
BiometricManager.Authenticators.DEVICE_CREDENTIAL
|
||||
)
|
||||
.build(),
|
||||
)
|
||||
},
|
||||
colors = ButtonDefaults.textButtonColors(contentColor = MaterialTheme.colorScheme.error),
|
||||
) {
|
||||
Text("清空")
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { showClearDialog = false }) {
|
||||
Text("取消")
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
HorizontalDivider()
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
// Runtime logs
|
||||
Text(
|
||||
text = "运行日志",
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.padding(16.dp),
|
||||
)
|
||||
|
||||
val logEntries by RuntimeLog.entries.collectAsState()
|
||||
var selectedTab by remember { mutableStateOf(0) }
|
||||
val tabs = listOf("全部") + LogCategory.entries.map { it.label }
|
||||
val allCategories = LogCategory.entries.toList()
|
||||
|
||||
// Scroll to bottom
|
||||
val scrollState = rememberScrollState()
|
||||
androidx.compose.runtime.LaunchedEffect(logEntries.size, selectedTab) {
|
||||
if (logEntries.isNotEmpty()) {
|
||||
scrollState.animateScrollTo(scrollState.maxValue)
|
||||
}
|
||||
}
|
||||
|
||||
TabRow(selectedTabIndex = selectedTab) {
|
||||
tabs.forEachIndexed { index, label ->
|
||||
Tab(
|
||||
selected = selectedTab == index,
|
||||
onClick = { selectedTab = index },
|
||||
text = { Text(label, maxLines = 1) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val filteredLogs = if (selectedTab == 0) {
|
||||
logEntries
|
||||
} else {
|
||||
val cat = allCategories.getOrNull(selectedTab - 1)
|
||||
if (cat != null) logEntries.filter { it.category == cat } else emptyList()
|
||||
}
|
||||
|
||||
val currentCategory = if (selectedTab == 0) null else allCategories.getOrNull(selectedTab - 1)
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
OutlinedButton(
|
||||
onClick = {
|
||||
val file = RuntimeLog.exportToFile(context, currentCategory)
|
||||
RuntimeLog.shareFile(context, file)
|
||||
},
|
||||
) {
|
||||
Icon(Icons.Filled.Share, contentDescription = null, modifier = Modifier.size(16.dp))
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
Text(if (currentCategory != null) "导出${currentCategory.label}" else "导出全部")
|
||||
}
|
||||
OutlinedButton(
|
||||
onClick = {
|
||||
val file = RuntimeLog.exportAllAsZip(context)
|
||||
RuntimeLog.shareFile(context, file)
|
||||
},
|
||||
) {
|
||||
Icon(Icons.Filled.Share, contentDescription = null, modifier = Modifier.size(16.dp))
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
Text("打包全部")
|
||||
}
|
||||
OutlinedButton(
|
||||
onClick = { RuntimeLog.clear() },
|
||||
colors = ButtonDefaults.outlinedButtonColors(contentColor = MaterialTheme.colorScheme.error),
|
||||
) {
|
||||
Text("清空")
|
||||
}
|
||||
}
|
||||
|
||||
if (filteredLogs.isEmpty()) {
|
||||
Text(
|
||||
text = "暂无${tabs[selectedTab]}日志",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
)
|
||||
} else {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp)
|
||||
.weight(1f, fill = false),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.verticalScroll(scrollState),
|
||||
) {
|
||||
filteredLogs.takeLast(500).forEach { entry ->
|
||||
Text(
|
||||
text = entry.formatted(),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
fontFamily = androidx.compose.ui.text.font.FontFamily.Monospace,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(vertical = 1.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(80.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,58 +0,0 @@
|
||||
package top.yeij.cyrene.ui.theme
|
||||
|
||||
import androidx.compose.ui.graphics.Color
|
||||
|
||||
// Light theme
|
||||
val LightPrimary = Color(0xFF6D3BC0)
|
||||
val LightOnPrimary = Color(0xFFFFFFFF)
|
||||
val LightPrimaryContainer = Color(0xFFEEDCFF)
|
||||
val LightOnPrimaryContainer = Color(0xFF250058)
|
||||
val LightSecondary = Color(0xFF625B71)
|
||||
val LightOnSecondary = Color(0xFFFFFFFF)
|
||||
val LightSecondaryContainer = Color(0xFFE8DEF8)
|
||||
val LightOnSecondaryContainer = Color(0xFF1E192B)
|
||||
val LightTertiary = Color(0xFF7E5260)
|
||||
val LightOnTertiary = Color(0xFFFFFFFF)
|
||||
val LightTertiaryContainer = Color(0xFFFFD9E3)
|
||||
val LightOnTertiaryContainer = Color(0xFF31101D)
|
||||
val LightBackground = Color(0xFFFFFBFF)
|
||||
val LightOnBackground = Color(0xFF1C1B1F)
|
||||
val LightSurface = Color(0xFFFFFBFF)
|
||||
val LightOnSurface = Color(0xFF1C1B1F)
|
||||
val LightSurfaceVariant = Color(0xFFE7E0EC)
|
||||
val LightOnSurfaceVariant = Color(0xFF49454F)
|
||||
val LightError = Color(0xFFBA1A1A)
|
||||
val LightOutline = Color(0xFF79747E)
|
||||
val LightOutlineVariant = Color(0xFFCAC4D0)
|
||||
|
||||
// Dark theme
|
||||
val DarkPrimary = Color(0xFFD3BBFF)
|
||||
val DarkOnPrimary = Color(0xFF3D0089)
|
||||
val DarkPrimaryContainer = Color(0xFF541BA6)
|
||||
val DarkOnPrimaryContainer = Color(0xFFEEDCFF)
|
||||
val DarkSecondary = Color(0xFFCBC2DC)
|
||||
val DarkOnSecondary = Color(0xFF332D41)
|
||||
val DarkSecondaryContainer = Color(0xFF4A4458)
|
||||
val DarkOnSecondaryContainer = Color(0xFFE8DEF8)
|
||||
val DarkTertiary = Color(0xFFEFB8C8)
|
||||
val DarkOnTertiary = Color(0xFF4A2532)
|
||||
val DarkTertiaryContainer = Color(0xFF633B48)
|
||||
val DarkOnTertiaryContainer = Color(0xFFFFD9E3)
|
||||
val DarkBackground = Color(0xFF1C1B1F)
|
||||
val DarkOnBackground = Color(0xFFE6E1E5)
|
||||
val DarkSurface = Color(0xFF1C1B1F)
|
||||
val DarkOnSurface = Color(0xFFE6E1E5)
|
||||
val DarkSurfaceVariant = Color(0xFF49454F)
|
||||
val DarkOnSurfaceVariant = Color(0xFFCAC4D0)
|
||||
val DarkError = Color(0xFFFFB4AB)
|
||||
val DarkOutline = Color(0xFF938F99)
|
||||
val DarkOutlineVariant = Color(0xFF49454F)
|
||||
|
||||
// Preset seed colors for manual theme selection
|
||||
val SeedColors = mapOf(
|
||||
"default" to 0xFF6D3BC0, // Lavender
|
||||
"sakura" to 0xFFFFB4C8, // Pink
|
||||
"ocean" to 0xFF6BA4FF, // Blue
|
||||
"forest" to 0xFF6BCF7C, // Green
|
||||
"sunset" to 0xFFFF9E6B, // Orange
|
||||
)
|
||||
@@ -1,13 +0,0 @@
|
||||
package top.yeij.cyrene.ui.theme
|
||||
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Shapes
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
val CyreneShapes = Shapes(
|
||||
extraSmall = RoundedCornerShape(8.dp),
|
||||
small = RoundedCornerShape(12.dp),
|
||||
medium = RoundedCornerShape(16.dp),
|
||||
large = RoundedCornerShape(24.dp),
|
||||
extraLarge = RoundedCornerShape(32.dp),
|
||||
)
|
||||
@@ -1,101 +0,0 @@
|
||||
package top.yeij.cyrene.ui.theme
|
||||
|
||||
import android.app.Activity
|
||||
import android.os.Build
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.darkColorScheme
|
||||
import androidx.compose.material3.dynamicDarkColorScheme
|
||||
import androidx.compose.material3.dynamicLightColorScheme
|
||||
import androidx.compose.material3.lightColorScheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.SideEffect
|
||||
import androidx.compose.ui.graphics.toArgb
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalView
|
||||
import androidx.core.view.WindowCompat
|
||||
|
||||
private val LightColorScheme = lightColorScheme(
|
||||
primary = LightPrimary,
|
||||
onPrimary = LightOnPrimary,
|
||||
primaryContainer = LightPrimaryContainer,
|
||||
onPrimaryContainer = LightOnPrimaryContainer,
|
||||
secondary = LightSecondary,
|
||||
onSecondary = LightOnSecondary,
|
||||
secondaryContainer = LightSecondaryContainer,
|
||||
onSecondaryContainer = LightOnSecondaryContainer,
|
||||
tertiary = LightTertiary,
|
||||
onTertiary = LightOnTertiary,
|
||||
tertiaryContainer = LightTertiaryContainer,
|
||||
onTertiaryContainer = LightOnTertiaryContainer,
|
||||
background = LightBackground,
|
||||
onBackground = LightOnBackground,
|
||||
surface = LightSurface,
|
||||
onSurface = LightOnSurface,
|
||||
surfaceVariant = LightSurfaceVariant,
|
||||
onSurfaceVariant = LightOnSurfaceVariant,
|
||||
error = LightError,
|
||||
outline = LightOutline,
|
||||
outlineVariant = LightOutlineVariant,
|
||||
)
|
||||
|
||||
private val DarkColorScheme = darkColorScheme(
|
||||
primary = DarkPrimary,
|
||||
onPrimary = DarkOnPrimary,
|
||||
primaryContainer = DarkPrimaryContainer,
|
||||
onPrimaryContainer = DarkOnPrimaryContainer,
|
||||
secondary = DarkSecondary,
|
||||
onSecondary = DarkOnSecondary,
|
||||
secondaryContainer = DarkSecondaryContainer,
|
||||
onSecondaryContainer = DarkOnSecondaryContainer,
|
||||
tertiary = DarkTertiary,
|
||||
onTertiary = DarkOnTertiary,
|
||||
tertiaryContainer = DarkTertiaryContainer,
|
||||
onTertiaryContainer = DarkOnTertiaryContainer,
|
||||
background = DarkBackground,
|
||||
onBackground = DarkOnBackground,
|
||||
surface = DarkSurface,
|
||||
onSurface = DarkOnSurface,
|
||||
surfaceVariant = DarkSurfaceVariant,
|
||||
onSurfaceVariant = DarkOnSurfaceVariant,
|
||||
error = DarkError,
|
||||
outline = DarkOutline,
|
||||
outlineVariant = DarkOutlineVariant,
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun CyreneTheme(
|
||||
darkTheme: Boolean = isSystemInDarkTheme(),
|
||||
dynamicColor: Boolean = true,
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
val colorScheme = when {
|
||||
dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
|
||||
val context = LocalContext.current
|
||||
if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
|
||||
}
|
||||
darkTheme -> DarkColorScheme
|
||||
else -> LightColorScheme
|
||||
}
|
||||
|
||||
val view = LocalView.current
|
||||
if (!view.isInEditMode) {
|
||||
SideEffect {
|
||||
val window = (view.context as Activity).window
|
||||
window.statusBarColor = colorScheme.background.toArgb()
|
||||
window.navigationBarColor = colorScheme.background.toArgb()
|
||||
window.decorView.setBackgroundColor(colorScheme.background.toArgb())
|
||||
WindowCompat.getInsetsController(window, view).apply {
|
||||
isAppearanceLightStatusBars = !darkTheme
|
||||
isAppearanceLightNavigationBars = !darkTheme
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
MaterialTheme(
|
||||
colorScheme = colorScheme,
|
||||
typography = CyreneTypography,
|
||||
shapes = CyreneShapes,
|
||||
content = content,
|
||||
)
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
package top.yeij.cyrene.ui.theme
|
||||
|
||||
import androidx.compose.material3.Typography
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.sp
|
||||
|
||||
val CyreneTypography = Typography(
|
||||
displayLarge = TextStyle(
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontSize = 57.sp,
|
||||
lineHeight = 64.sp,
|
||||
),
|
||||
headlineMedium = TextStyle(
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontSize = 28.sp,
|
||||
lineHeight = 36.sp,
|
||||
),
|
||||
titleLarge = TextStyle(
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontSize = 22.sp,
|
||||
lineHeight = 28.sp,
|
||||
),
|
||||
titleMedium = TextStyle(
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontSize = 16.sp,
|
||||
lineHeight = 24.sp,
|
||||
),
|
||||
bodyLarge = TextStyle(
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontSize = 16.sp,
|
||||
lineHeight = 24.sp,
|
||||
),
|
||||
bodyMedium = TextStyle(
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontSize = 14.sp,
|
||||
lineHeight = 20.sp,
|
||||
),
|
||||
labelLarge = TextStyle(
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontSize = 14.sp,
|
||||
lineHeight = 20.sp,
|
||||
),
|
||||
labelMedium = TextStyle(
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontSize = 12.sp,
|
||||
lineHeight = 16.sp,
|
||||
),
|
||||
labelSmall = TextStyle(
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontSize = 11.sp,
|
||||
lineHeight = 16.sp,
|
||||
),
|
||||
)
|
||||
@@ -1,15 +0,0 @@
|
||||
package top.yeij.cyrene.util
|
||||
|
||||
object Constants {
|
||||
const val DEFAULT_WAKE_WORD = "昔涟"
|
||||
const val SILENCE_TIMEOUT_MS = 10_000L
|
||||
const val WS_RECONNECT_MAX_ATTEMPTS = 5
|
||||
const val WS_PING_INTERVAL_SECONDS = 30L
|
||||
const val HOTWORD_INACTIVITY_TIMEOUT_MINUTES = 10L
|
||||
const val OVERLAY_ANIM_DURATION_MS = 300
|
||||
const val STT_SILENCE_THRESHOLD_MS = 1500L
|
||||
|
||||
// Intent extras
|
||||
const val EXTRA_VOICE_ASSIST = "voice_assist"
|
||||
const val EXTRA_START_LISTENING = "start_listening"
|
||||
}
|
||||
@@ -1,68 +0,0 @@
|
||||
package top.yeij.cyrene.util
|
||||
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Build
|
||||
import androidx.core.app.NotificationCompat
|
||||
import top.yeij.cyrene.MainActivity
|
||||
import top.yeij.cyrene.domain.model.Message
|
||||
|
||||
class NotificationHelper(private val context: Context) {
|
||||
|
||||
private val notificationManager = context.getSystemService(NotificationManager::class.java)
|
||||
|
||||
init {
|
||||
createChannel()
|
||||
}
|
||||
|
||||
private fun createChannel() {
|
||||
val channel = NotificationChannel(
|
||||
CHANNEL_ID,
|
||||
"消息通知",
|
||||
NotificationManager.IMPORTANCE_HIGH,
|
||||
).apply {
|
||||
description = "昔涟的新消息通知"
|
||||
enableVibration(true)
|
||||
setShowBadge(true)
|
||||
}
|
||||
notificationManager.createNotificationChannel(channel)
|
||||
}
|
||||
|
||||
fun showMessageNotification(message: Message) {
|
||||
val intent = Intent(context, MainActivity::class.java).apply {
|
||||
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
|
||||
}
|
||||
val pendingIntent = PendingIntent.getActivity(
|
||||
context, 0, intent,
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
||||
)
|
||||
|
||||
val preview = if (message.content.length > 50) {
|
||||
message.content.take(50) + "..."
|
||||
} else {
|
||||
message.content
|
||||
}
|
||||
|
||||
val notification = NotificationCompat.Builder(context, CHANNEL_ID)
|
||||
.setSmallIcon(android.R.drawable.ic_dialog_info)
|
||||
.setContentTitle("昔涟")
|
||||
.setContentText(preview)
|
||||
.setAutoCancel(true)
|
||||
.setContentIntent(pendingIntent)
|
||||
.setPriority(NotificationCompat.PRIORITY_HIGH)
|
||||
.build()
|
||||
|
||||
notificationManager.notify(message.id.hashCode(), notification)
|
||||
}
|
||||
|
||||
fun cancelAll() {
|
||||
notificationManager.cancelAll()
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val CHANNEL_ID = "cyrene_messages"
|
||||
}
|
||||
}
|
||||
@@ -1,126 +0,0 @@
|
||||
package top.yeij.cyrene.util
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import androidx.core.content.FileProvider
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import java.io.File
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
import java.util.zip.ZipEntry
|
||||
import java.util.zip.ZipOutputStream
|
||||
|
||||
enum class LogCategory(val label: String) {
|
||||
CHAT("聊天"),
|
||||
WS("WebSocket"),
|
||||
STT("语音识别"),
|
||||
GENERAL("通用"),
|
||||
HTTP("网络"),
|
||||
VOICE("语音"),
|
||||
}
|
||||
|
||||
data class LogEntry(
|
||||
val timestamp: Long,
|
||||
val category: LogCategory,
|
||||
val tag: String,
|
||||
val message: String,
|
||||
) {
|
||||
fun formatted(): String {
|
||||
val sdf = SimpleDateFormat("HH:mm:ss.SSS", Locale.getDefault())
|
||||
return "[${sdf.format(Date(timestamp))}] [${category.name}] [$tag] $message"
|
||||
}
|
||||
}
|
||||
|
||||
object RuntimeLog {
|
||||
|
||||
private const val MAX_ENTRIES = 2000
|
||||
|
||||
private val _entries = MutableStateFlow<List<LogEntry>>(emptyList())
|
||||
val entries: StateFlow<List<LogEntry>> = _entries.asStateFlow()
|
||||
|
||||
private val buffer = ArrayDeque<LogEntry>(MAX_ENTRIES)
|
||||
|
||||
@Synchronized
|
||||
fun log(category: LogCategory, tag: String, message: String) {
|
||||
val entry = LogEntry(System.currentTimeMillis(), category, tag, message)
|
||||
if (buffer.size >= MAX_ENTRIES) {
|
||||
buffer.removeFirst()
|
||||
}
|
||||
buffer.addLast(entry)
|
||||
_entries.value = buffer.toList()
|
||||
}
|
||||
|
||||
fun chat(tag: String, message: String) = log(LogCategory.CHAT, tag, message)
|
||||
fun ws(tag: String, message: String) = log(LogCategory.WS, tag, message)
|
||||
fun stt(tag: String, message: String) = log(LogCategory.STT, tag, message)
|
||||
fun general(tag: String, message: String) = log(LogCategory.GENERAL, tag, message)
|
||||
fun http(tag: String, message: String) = log(LogCategory.HTTP, tag, message)
|
||||
fun voice(tag: String, message: String) = log(LogCategory.VOICE, tag, message)
|
||||
|
||||
@Synchronized
|
||||
fun getByCategory(category: LogCategory): List<LogEntry> {
|
||||
return buffer.filter { it.category == category }
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun exportAsText(category: LogCategory): String {
|
||||
val entries = buffer.filter { it.category == category }
|
||||
val header = "=== Cyrene ${category.label}日志 导出时间: ${
|
||||
SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.getDefault()).format(Date())
|
||||
} ===\n\n"
|
||||
return header + entries.joinToString("\n") { it.formatted() }
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun exportAllAsText(): String {
|
||||
val header = "=== Cyrene 全部日志 导出时间: ${
|
||||
SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.getDefault()).format(Date())
|
||||
} ===\n\n"
|
||||
val byCategory = buffer.groupBy { it.category }
|
||||
return header + byCategory.entries.joinToString("\n\n") { (cat, entries) ->
|
||||
"--- ${cat.label} (${cat.name}) ---\n" + entries.joinToString("\n") { it.formatted() }
|
||||
}
|
||||
}
|
||||
|
||||
fun exportToFile(context: Context, category: LogCategory? = null): File {
|
||||
val text = if (category != null) exportAsText(category) else exportAllAsText()
|
||||
val label = category?.name?.lowercase() ?: "all"
|
||||
val dateStr = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.getDefault()).format(Date())
|
||||
val file = File(context.cacheDir, "cyrene_log_${label}_$dateStr.txt")
|
||||
file.writeText(text)
|
||||
return file
|
||||
}
|
||||
|
||||
fun exportAllAsZip(context: Context): File {
|
||||
val dateStr = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.getDefault()).format(Date())
|
||||
val zipFile = File(context.cacheDir, "cyrene_logs_$dateStr.zip")
|
||||
ZipOutputStream(zipFile.outputStream().buffered()).use { zos ->
|
||||
LogCategory.entries.forEach { cat ->
|
||||
val text = exportAsText(cat)
|
||||
zos.putNextEntry(ZipEntry("${cat.name.lowercase()}.txt"))
|
||||
zos.write(text.toByteArray(Charsets.UTF_8))
|
||||
zos.closeEntry()
|
||||
}
|
||||
}
|
||||
return zipFile
|
||||
}
|
||||
|
||||
fun shareFile(context: Context, file: File) {
|
||||
val uri = FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", file)
|
||||
val intent = Intent(Intent.ACTION_SEND).apply {
|
||||
type = if (file.extension == "zip") "application/zip" else "text/plain"
|
||||
putExtra(Intent.EXTRA_STREAM, uri)
|
||||
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||
}
|
||||
context.startActivity(Intent.createChooser(intent, "导出日志"))
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun clear() {
|
||||
buffer.clear()
|
||||
_entries.value = emptyList()
|
||||
}
|
||||
}
|
||||
@@ -1,158 +0,0 @@
|
||||
package top.yeij.cyrene.util
|
||||
|
||||
import android.content.Context
|
||||
import android.media.MediaRecorder
|
||||
import android.os.Build
|
||||
import android.util.Base64
|
||||
import android.util.Log
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import java.io.File
|
||||
|
||||
enum class RecordState {
|
||||
IDLE,
|
||||
RECORDING,
|
||||
LOCKED, // hands-free mode
|
||||
CANCELLING,
|
||||
}
|
||||
|
||||
class VoiceRecorder(private val context: Context) {
|
||||
|
||||
private var recorder: MediaRecorder? = null
|
||||
private var outputFile: File? = null
|
||||
private var timerJob: Job? = null
|
||||
|
||||
private val _state = MutableStateFlow(RecordState.IDLE)
|
||||
val state: StateFlow<RecordState> = _state.asStateFlow()
|
||||
|
||||
private val _durationMs = MutableStateFlow(0L)
|
||||
val durationMs: StateFlow<Long> = _durationMs.asStateFlow()
|
||||
|
||||
fun start() {
|
||||
if (_state.value != RecordState.IDLE) return
|
||||
try {
|
||||
outputFile = File(context.cacheDir, "voice_${System.currentTimeMillis()}.aac")
|
||||
outputFile?.delete()
|
||||
|
||||
recorder = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
MediaRecorder(context)
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
MediaRecorder()
|
||||
}
|
||||
|
||||
recorder?.apply {
|
||||
setAudioSource(MediaRecorder.AudioSource.MIC)
|
||||
setOutputFormat(MediaRecorder.OutputFormat.AAC_ADTS)
|
||||
setAudioEncoder(MediaRecorder.AudioEncoder.AAC)
|
||||
setAudioSamplingRate(16000)
|
||||
setAudioEncodingBitRate(32000)
|
||||
setAudioChannels(1)
|
||||
setOutputFile(outputFile?.absolutePath)
|
||||
prepare()
|
||||
start()
|
||||
}
|
||||
|
||||
_state.value = RecordState.RECORDING
|
||||
startTimer()
|
||||
Log.i(TAG, "Recording started: ${outputFile?.absolutePath}")
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to start recording: ${e.message}", e)
|
||||
cleanup()
|
||||
}
|
||||
}
|
||||
|
||||
fun lock() {
|
||||
if (_state.value == RecordState.RECORDING) {
|
||||
_state.value = RecordState.LOCKED
|
||||
}
|
||||
}
|
||||
|
||||
fun stop(): File? {
|
||||
if (_state.value == RecordState.IDLE) return null
|
||||
val file = outputFile
|
||||
try {
|
||||
recorder?.apply {
|
||||
try { stop() } catch (_: Exception) { }
|
||||
try { release() } catch (_: Exception) { }
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error stopping recorder: ${e.message}", e)
|
||||
}
|
||||
recorder = null
|
||||
cancelTimer()
|
||||
_state.value = RecordState.IDLE
|
||||
_durationMs.value = 0L
|
||||
Log.i(TAG, "Recording stopped: ${file?.absolutePath}, size=${file?.length()}")
|
||||
return if (file != null && file.exists() && file.length() > 0) file else null
|
||||
}
|
||||
|
||||
fun cancel() {
|
||||
val file = outputFile
|
||||
try {
|
||||
recorder?.apply {
|
||||
try { stop() } catch (_: Exception) { }
|
||||
try { release() } catch (_: Exception) { }
|
||||
}
|
||||
} catch (e: Exception) { }
|
||||
recorder = null
|
||||
file?.delete()
|
||||
cancelTimer()
|
||||
_state.value = RecordState.IDLE
|
||||
_durationMs.value = 0L
|
||||
Log.i(TAG, "Recording cancelled")
|
||||
}
|
||||
|
||||
fun cleanup() {
|
||||
try { recorder?.release() } catch (_: Exception) { }
|
||||
recorder = null
|
||||
outputFile?.delete()
|
||||
outputFile = null
|
||||
cancelTimer()
|
||||
_state.value = RecordState.IDLE
|
||||
_durationMs.value = 0L
|
||||
}
|
||||
|
||||
fun getBase64(): String? {
|
||||
val file = outputFile ?: return null
|
||||
if (!file.exists()) return null
|
||||
return try {
|
||||
val bytes = file.readBytes()
|
||||
Base64.encodeToString(bytes, Base64.NO_WRAP)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to encode audio: ${e.message}", e)
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
fun deleteFile() {
|
||||
outputFile?.delete()
|
||||
outputFile = null
|
||||
}
|
||||
|
||||
private fun startTimer() {
|
||||
cancelTimer()
|
||||
val startTime = System.currentTimeMillis()
|
||||
timerJob = CoroutineScope(Dispatchers.Main).launch {
|
||||
while (true) {
|
||||
delay(100)
|
||||
_durationMs.value = System.currentTimeMillis() - startTime
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun cancelTimer() {
|
||||
timerJob?.cancel()
|
||||
timerJob = null
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "VoiceRecorder"
|
||||
}
|
||||
}
|
||||
@@ -1,281 +0,0 @@
|
||||
package top.yeij.cyrene.viewmodel
|
||||
|
||||
import android.util.Log
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import top.yeij.cyrene.domain.model.Conversation
|
||||
import top.yeij.cyrene.domain.model.Message
|
||||
import top.yeij.cyrene.domain.repository.ChatRepository
|
||||
import top.yeij.cyrene.util.RecordState
|
||||
import top.yeij.cyrene.util.RuntimeLog
|
||||
import top.yeij.cyrene.util.VoiceRecorder
|
||||
|
||||
private fun List<Message>.deduplicate(): List<Message> {
|
||||
if (isEmpty()) return this
|
||||
val result = mutableListOf(this[0])
|
||||
for (i in 1 until size) {
|
||||
val prev = result.last()
|
||||
val curr = this[i]
|
||||
val isDuplicate = curr.id == prev.id ||
|
||||
(curr.role == prev.role && curr.content == prev.content && curr.msgType == prev.msgType)
|
||||
if (!isDuplicate) {
|
||||
result.add(curr)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
private fun List<Message>.removeWrappingDuplicates(): List<Message> {
|
||||
if (size < 3) return this
|
||||
val toRemove = mutableSetOf<String>()
|
||||
for (msg in this) {
|
||||
val containedCount = count { other ->
|
||||
other.id != msg.id &&
|
||||
other.content.isNotBlank() &&
|
||||
other.content.length < msg.content.length &&
|
||||
msg.content.contains(other.content) &&
|
||||
kotlin.math.abs(other.timestamp - msg.timestamp) < 2000
|
||||
}
|
||||
if (containedCount >= 2) {
|
||||
toRemove.add(msg.id)
|
||||
}
|
||||
}
|
||||
return if (toRemove.isEmpty()) this else filter { it.id !in toRemove }
|
||||
}
|
||||
|
||||
class ChatViewModel(
|
||||
private val chatRepository: ChatRepository,
|
||||
private val voiceRecorder: VoiceRecorder,
|
||||
) : ViewModel() {
|
||||
|
||||
val isConnected: StateFlow<Boolean> = chatRepository.connectionState
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), false)
|
||||
|
||||
val connectionError: StateFlow<String?> = chatRepository.connectionError
|
||||
|
||||
val conversations: StateFlow<List<Conversation>> = chatRepository.getConversations()
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList())
|
||||
|
||||
private val _currentMessages = MutableStateFlow<List<Message>>(emptyList())
|
||||
val currentMessages: StateFlow<List<Message>> = _currentMessages.asStateFlow()
|
||||
|
||||
private val _inputText = MutableStateFlow("")
|
||||
val inputText: StateFlow<String> = _inputText.asStateFlow()
|
||||
|
||||
private val _isSending = MutableStateFlow(false)
|
||||
val isStreaming: StateFlow<Boolean> = kotlinx.coroutines.flow.combine(
|
||||
_isSending,
|
||||
chatRepository.isAssistantStreaming,
|
||||
) { sending, assistant -> sending || assistant }
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), false)
|
||||
|
||||
private val _isRefreshing = MutableStateFlow(false)
|
||||
val isRefreshing: StateFlow<Boolean> = _isRefreshing.asStateFlow()
|
||||
|
||||
// Voice recording state
|
||||
val voiceRecordState: StateFlow<RecordState> = voiceRecorder.state
|
||||
val voiceRecordDurationMs: StateFlow<Long> = voiceRecorder.durationMs
|
||||
|
||||
// Animation ordering for message bubbles
|
||||
private var animCounter = 0
|
||||
private val _messageAnimIndex = MutableStateFlow<Map<String, Int>>(emptyMap())
|
||||
val messageAnimIndex: StateFlow<Map<String, Int>> = _messageAnimIndex.asStateFlow()
|
||||
|
||||
private var currentSessionId: String? = null
|
||||
private var dbObserverJob: Job? = null
|
||||
|
||||
init {
|
||||
// Phase 1: find/create main session, reconnect WS, load server history
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
val sessionId = chatRepository.initializeSession()
|
||||
currentSessionId = sessionId
|
||||
chatRepository.currentSessionId = sessionId
|
||||
chatRepository.ensureConnected()
|
||||
loadMessagesFromDb(sessionId)
|
||||
val serverMessages = chatRepository.loadMessagesFromServer(sessionId)
|
||||
if (serverMessages.isNotEmpty()) {
|
||||
val serverIds = serverMessages.map { it.id }.toSet()
|
||||
_currentMessages.update { current ->
|
||||
val localOnly = current.filter { it.id !in serverIds }
|
||||
(serverMessages + localOnly)
|
||||
.sortedBy { it.timestamp }
|
||||
.deduplicate()
|
||||
.removeWrappingDuplicates()
|
||||
}
|
||||
}
|
||||
} catch (_: Exception) { }
|
||||
}
|
||||
|
||||
// Observe incoming live messages with atomic dedup
|
||||
viewModelScope.launch {
|
||||
chatRepository.observeMessages().collect { message ->
|
||||
try {
|
||||
_currentMessages.update { list ->
|
||||
val updated = list.toMutableList()
|
||||
val existingIdx = updated.indexOfLast { it.id == message.id }
|
||||
if (existingIdx >= 0) {
|
||||
updated[existingIdx] = message
|
||||
} else {
|
||||
val isDup = updated.any {
|
||||
it.role == message.role && it.content == message.content && it.msgType == message.msgType
|
||||
}
|
||||
if (!isDup) {
|
||||
updated.add(message)
|
||||
val idx = _messageAnimIndex.value.toMutableMap()
|
||||
idx[message.id] = animCounter++
|
||||
_messageAnimIndex.value = idx
|
||||
}
|
||||
}
|
||||
updated.deduplicate()
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e("ChatViewModel", "Error processing message: ${e.message}", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Observe message clear events
|
||||
viewModelScope.launch {
|
||||
chatRepository.messageClearEvents.collect {
|
||||
_currentMessages.value = emptyList()
|
||||
_messageAnimIndex.value = emptyMap()
|
||||
animCounter = 0
|
||||
}
|
||||
}
|
||||
// Reset user-side sending state when server starts responding
|
||||
viewModelScope.launch {
|
||||
chatRepository.isAssistantStreaming.collect { streaming ->
|
||||
if (streaming) _isSending.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Voice recording (WeChat-style gesture) ---
|
||||
|
||||
fun startRecord() {
|
||||
voiceRecorder.start()
|
||||
}
|
||||
|
||||
fun lockRecord() {
|
||||
voiceRecorder.lock()
|
||||
}
|
||||
|
||||
fun finishRecord() {
|
||||
val file = voiceRecorder.stop() ?: return
|
||||
val base64 = voiceRecorder.getBase64()
|
||||
voiceRecorder.deleteFile()
|
||||
if (base64.isNullOrBlank()) return
|
||||
|
||||
viewModelScope.launch {
|
||||
chatRepository.sendVoiceInput(base64, "voice_msg")
|
||||
RuntimeLog.chat("voice", "Voice message sent, duration=${file.length()}")
|
||||
}
|
||||
}
|
||||
|
||||
fun cancelRecord() {
|
||||
voiceRecorder.cancel()
|
||||
}
|
||||
|
||||
private fun loadMessagesFromDb(sessionId: String) {
|
||||
dbObserverJob?.cancel()
|
||||
dbObserverJob = viewModelScope.launch {
|
||||
try {
|
||||
chatRepository.getMessages(sessionId).collect { messages ->
|
||||
_currentMessages.update { current ->
|
||||
val live = current.associateBy { it.id }
|
||||
val db = messages.associateBy { it.id }
|
||||
(db + live).values
|
||||
.sortedBy { it.timestamp }
|
||||
.deduplicate()
|
||||
.removeWrappingDuplicates()
|
||||
}
|
||||
val idx = _messageAnimIndex.value.toMutableMap()
|
||||
messages.forEach { m ->
|
||||
if (m.id !in idx) idx[m.id] = animCounter++
|
||||
}
|
||||
_messageAnimIndex.value = idx
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e("ChatViewModel", "Error loading messages: ${e.message}", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun onInputChanged(text: String) {
|
||||
_inputText.value = text
|
||||
}
|
||||
|
||||
fun sendMessage() {
|
||||
val text = _inputText.value.trim()
|
||||
if (text.isEmpty()) return
|
||||
|
||||
_inputText.value = ""
|
||||
_isSending.value = true
|
||||
val sid = currentSessionId
|
||||
|
||||
viewModelScope.launch {
|
||||
chatRepository.sendMessage(text, sid)
|
||||
}
|
||||
}
|
||||
|
||||
fun switchSession(sessionId: String) {
|
||||
currentSessionId = sessionId
|
||||
chatRepository.currentSessionId = sessionId
|
||||
_currentMessages.value = emptyList()
|
||||
_messageAnimIndex.value = emptyMap()
|
||||
animCounter = 0
|
||||
viewModelScope.launch {
|
||||
chatRepository.connectWebSocket(sessionId)
|
||||
chatRepository.loadMessagesFromServer(sessionId)
|
||||
}
|
||||
loadMessagesFromDb(sessionId)
|
||||
}
|
||||
|
||||
fun refreshMessages() {
|
||||
val sid = currentSessionId ?: return
|
||||
viewModelScope.launch {
|
||||
_isRefreshing.value = true
|
||||
try {
|
||||
if (!isConnected.value) {
|
||||
chatRepository.ensureConnected()
|
||||
}
|
||||
val serverMessages = chatRepository.loadMessagesFromServer(sid)
|
||||
if (serverMessages.isNotEmpty()) {
|
||||
val serverIds = serverMessages.map { it.id }.toSet()
|
||||
_currentMessages.update { current ->
|
||||
val localOnly = current.filter { it.id !in serverIds }
|
||||
(serverMessages + localOnly)
|
||||
.sortedBy { it.timestamp }
|
||||
.deduplicate()
|
||||
.removeWrappingDuplicates()
|
||||
}
|
||||
}
|
||||
} catch (_: Exception) { }
|
||||
_isRefreshing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
fun deleteConversation(id: String) {
|
||||
viewModelScope.launch {
|
||||
chatRepository.deleteConversation(id)
|
||||
}
|
||||
}
|
||||
|
||||
fun clearLocalMessages() {
|
||||
viewModelScope.launch {
|
||||
chatRepository.clearLocalMessages()
|
||||
_currentMessages.value = emptyList()
|
||||
_messageAnimIndex.value = emptyMap()
|
||||
animCounter = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,59 +0,0 @@
|
||||
package top.yeij.cyrene.viewmodel
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
import top.yeij.cyrene.domain.model.Device
|
||||
import top.yeij.cyrene.domain.repository.IoTRepository
|
||||
|
||||
class IoTViewModel(
|
||||
private val ioTRepository: IoTRepository,
|
||||
) : ViewModel() {
|
||||
|
||||
val devices: StateFlow<List<Device>> = ioTRepository.getDevices()
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList())
|
||||
|
||||
private val _isLoading = MutableStateFlow(false)
|
||||
val isLoading: StateFlow<Boolean> = _isLoading.asStateFlow()
|
||||
|
||||
private val _error = MutableStateFlow<String?>(null)
|
||||
val error: StateFlow<String?> = _error.asStateFlow()
|
||||
|
||||
init {
|
||||
refreshDevices()
|
||||
}
|
||||
|
||||
fun refreshDevices() {
|
||||
viewModelScope.launch {
|
||||
_isLoading.value = true
|
||||
ioTRepository.refreshDevices()
|
||||
_isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
fun controlDevice(deviceId: String, action: String, value: Any? = null) {
|
||||
viewModelScope.launch {
|
||||
_isLoading.value = true
|
||||
_error.value = null
|
||||
ioTRepository.controlDevice(deviceId, action, value).fold(
|
||||
onSuccess = { },
|
||||
onFailure = { _error.value = it.message },
|
||||
)
|
||||
_isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
fun togglePower(device: Device) {
|
||||
val action = if (device.state.power == true) "off" else "on"
|
||||
controlDevice(device.id, action)
|
||||
}
|
||||
|
||||
fun clearError() {
|
||||
_error.value = null
|
||||
}
|
||||
}
|
||||
@@ -1,237 +0,0 @@
|
||||
package top.yeij.cyrene.viewmodel
|
||||
|
||||
import android.util.Log
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import top.yeij.cyrene.domain.model.Message
|
||||
import top.yeij.cyrene.domain.repository.ChatRepository
|
||||
import top.yeij.cyrene.util.Constants
|
||||
import top.yeij.cyrene.util.RecordState
|
||||
import top.yeij.cyrene.util.VoiceRecorder
|
||||
import top.yeij.cyrene.voice.tts.TextToSpeechEngine
|
||||
|
||||
private fun List<Message>.deduplicate(): List<Message> {
|
||||
if (isEmpty()) return this
|
||||
val result = mutableListOf(this[0])
|
||||
for (i in 1 until size) {
|
||||
val prev = result.last()
|
||||
val curr = this[i]
|
||||
val isDuplicate = curr.id == prev.id ||
|
||||
(curr.role == prev.role && curr.content == prev.content && curr.msgType == prev.msgType)
|
||||
if (!isDuplicate) {
|
||||
result.add(curr)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
private fun List<Message>.removeWrappingDuplicates(): List<Message> {
|
||||
if (size < 3) return this
|
||||
val toRemove = mutableSetOf<String>()
|
||||
for (msg in this) {
|
||||
val containedCount = count { other ->
|
||||
other.id != msg.id &&
|
||||
other.content.isNotBlank() &&
|
||||
other.content.length < msg.content.length &&
|
||||
msg.content.contains(other.content) &&
|
||||
kotlin.math.abs(other.timestamp - msg.timestamp) < 2000
|
||||
}
|
||||
if (containedCount >= 2) {
|
||||
toRemove.add(msg.id)
|
||||
}
|
||||
}
|
||||
return if (toRemove.isEmpty()) this else filter { it.id !in toRemove }
|
||||
}
|
||||
|
||||
enum class OverlayState {
|
||||
IDLE,
|
||||
LISTENING,
|
||||
PROCESSING,
|
||||
SPEAKING,
|
||||
WAITING,
|
||||
}
|
||||
|
||||
class OverlayViewModel(
|
||||
private val chatRepository: ChatRepository,
|
||||
private val voiceRecorder: VoiceRecorder,
|
||||
private val ttsEngine: TextToSpeechEngine,
|
||||
) : ViewModel() {
|
||||
|
||||
private val _state = MutableStateFlow(OverlayState.WAITING)
|
||||
val state: StateFlow<OverlayState> = _state.asStateFlow()
|
||||
|
||||
private val _messages = MutableStateFlow<List<Message>>(emptyList())
|
||||
val messages: StateFlow<List<Message>> = _messages.asStateFlow()
|
||||
|
||||
private val _inputText = MutableStateFlow("")
|
||||
val inputText: StateFlow<String> = _inputText.asStateFlow()
|
||||
|
||||
val voiceRecordState: StateFlow<RecordState> = voiceRecorder.state
|
||||
val voiceRecordDurationMs: StateFlow<Long> = voiceRecorder.durationMs
|
||||
|
||||
// Animation ordering for message bubbles
|
||||
private var animCounter = 0
|
||||
private val _messageAnimIndex = MutableStateFlow<Map<String, Int>>(emptyMap())
|
||||
val messageAnimIndex: StateFlow<Map<String, Int>> = _messageAnimIndex.asStateFlow()
|
||||
|
||||
private var silenceTimer: Job? = null
|
||||
private var lastAssistantMessageId: String? = null
|
||||
|
||||
init {
|
||||
viewModelScope.launch {
|
||||
chatRepository.observeMessages().collect { message ->
|
||||
_messages.update { list ->
|
||||
val updated = list.toMutableList()
|
||||
val idx = updated.indexOfLast { it.id == message.id }
|
||||
if (idx >= 0) {
|
||||
updated[idx] = message
|
||||
} else {
|
||||
val isDup = updated.any {
|
||||
it.role == message.role && it.content == message.content && it.msgType == message.msgType
|
||||
}
|
||||
if (!isDup) {
|
||||
updated.add(message)
|
||||
val animIdx = _messageAnimIndex.value.toMutableMap()
|
||||
animIdx[message.id] = animCounter++
|
||||
_messageAnimIndex.value = animIdx
|
||||
}
|
||||
}
|
||||
updated.deduplicate()
|
||||
}
|
||||
|
||||
if (message.role == "assistant" && !message.isStreaming && message.msgType == "chat") {
|
||||
if (message.id != lastAssistantMessageId && message.content.isNotBlank()) {
|
||||
lastAssistantMessageId = message.id
|
||||
speakResponse(message.content)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
viewModelScope.launch {
|
||||
ttsEngine.onDone.collect {
|
||||
if (_state.value == OverlayState.SPEAKING) {
|
||||
setWaiting()
|
||||
}
|
||||
}
|
||||
}
|
||||
viewModelScope.launch {
|
||||
chatRepository.messageClearEvents.collect {
|
||||
_messages.value = emptyList()
|
||||
_messageAnimIndex.value = emptyMap()
|
||||
animCounter = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun onInputChanged(text: String) {
|
||||
_inputText.value = text
|
||||
}
|
||||
|
||||
fun sendText() {
|
||||
val text = _inputText.value.trim()
|
||||
if (text.isEmpty()) return
|
||||
_inputText.value = ""
|
||||
|
||||
_state.value = OverlayState.PROCESSING
|
||||
cancelSilenceTimer()
|
||||
viewModelScope.launch {
|
||||
chatRepository.sendMessage(text, null)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Voice recording (WeChat-style gesture) ---
|
||||
|
||||
fun startRecord() {
|
||||
voiceRecorder.start()
|
||||
_state.value = OverlayState.LISTENING
|
||||
cancelSilenceTimer()
|
||||
}
|
||||
|
||||
fun lockRecord() {
|
||||
voiceRecorder.lock()
|
||||
}
|
||||
|
||||
fun finishRecord() {
|
||||
val file = voiceRecorder.stop() ?: return
|
||||
val base64 = voiceRecorder.getBase64()
|
||||
voiceRecorder.deleteFile()
|
||||
if (base64.isNullOrBlank()) return
|
||||
|
||||
_state.value = OverlayState.PROCESSING
|
||||
viewModelScope.launch {
|
||||
chatRepository.sendVoiceInput(base64, "voice_msg")
|
||||
}
|
||||
}
|
||||
|
||||
fun cancelRecord() {
|
||||
voiceRecorder.cancel()
|
||||
setWaiting()
|
||||
}
|
||||
|
||||
fun cancelCurrentAction() {
|
||||
if (voiceRecorder.state.value == RecordState.LOCKED) {
|
||||
voiceRecorder.cancel()
|
||||
setWaiting()
|
||||
}
|
||||
}
|
||||
|
||||
private fun speakResponse(text: String) {
|
||||
if (text.isBlank()) return
|
||||
_state.value = OverlayState.SPEAKING
|
||||
ttsEngine.speak(text)
|
||||
}
|
||||
|
||||
private fun setWaiting() {
|
||||
_state.value = OverlayState.WAITING
|
||||
startSilenceTimer()
|
||||
}
|
||||
|
||||
fun stopSpeaking() {
|
||||
ttsEngine.stop()
|
||||
if (_state.value == OverlayState.SPEAKING) {
|
||||
setWaiting()
|
||||
}
|
||||
}
|
||||
|
||||
fun sendScreenContext(content: String) {
|
||||
if (content.isBlank()) return
|
||||
viewModelScope.launch {
|
||||
chatRepository.sendScreenContext(content)
|
||||
}
|
||||
}
|
||||
|
||||
fun finish() {
|
||||
_state.value = OverlayState.IDLE
|
||||
cancelSilenceTimer()
|
||||
voiceRecorder.cancel()
|
||||
ttsEngine.stop()
|
||||
}
|
||||
|
||||
private fun startSilenceTimer() {
|
||||
cancelSilenceTimer()
|
||||
silenceTimer = viewModelScope.launch {
|
||||
delay(Constants.SILENCE_TIMEOUT_MS)
|
||||
if (_state.value == OverlayState.WAITING) {
|
||||
_state.value = OverlayState.IDLE
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun cancelSilenceTimer() {
|
||||
silenceTimer?.cancel()
|
||||
silenceTimer = null
|
||||
}
|
||||
|
||||
override fun onCleared() {
|
||||
voiceRecorder.cancel()
|
||||
ttsEngine.shutdown()
|
||||
super.onCleared()
|
||||
}
|
||||
}
|
||||
@@ -1,120 +0,0 @@
|
||||
package top.yeij.cyrene.viewmodel
|
||||
|
||||
import android.util.Log
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.firstOrNull
|
||||
import kotlinx.coroutines.launch
|
||||
import top.yeij.cyrene.data.local.PreferencesDataStore
|
||||
import top.yeij.cyrene.data.remote.ApiService
|
||||
import top.yeij.cyrene.domain.repository.AuthRepository
|
||||
|
||||
data class ProfileState(
|
||||
val userId: String = "",
|
||||
val username: String = "",
|
||||
val nickname: String = "",
|
||||
val isAdmin: Boolean = false,
|
||||
val createdAt: String = "",
|
||||
val isLoading: Boolean = false,
|
||||
val isLoggedIn: Boolean = false,
|
||||
)
|
||||
|
||||
class ProfileViewModel(
|
||||
private val apiService: ApiService,
|
||||
private val authRepository: AuthRepository,
|
||||
private val prefs: PreferencesDataStore,
|
||||
) : ViewModel() {
|
||||
|
||||
private val _profile = MutableStateFlow(ProfileState())
|
||||
val profile: StateFlow<ProfileState> = _profile.asStateFlow()
|
||||
|
||||
private var loadedFromCache = false
|
||||
|
||||
init {
|
||||
viewModelScope.launch {
|
||||
_profile.value = _profile.value.copy(isLoggedIn = authRepository.isLoggedIn())
|
||||
if (_profile.value.isLoggedIn) {
|
||||
loadCachedProfile()
|
||||
fetchFreshProfile()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Show cached profile immediately for instant UI.
|
||||
*/
|
||||
private suspend fun loadCachedProfile() {
|
||||
val userId = prefs.profileUserId.firstOrNull() ?: return
|
||||
val nickname = prefs.profileNickname.firstOrNull() ?: ""
|
||||
val isAdmin = prefs.profileIsAdmin.firstOrNull()?.toBoolean() ?: false
|
||||
val createdAt = prefs.profileCreatedAt.firstOrNull() ?: ""
|
||||
val username = prefs.username.firstOrNull() ?: ""
|
||||
|
||||
_profile.value = ProfileState(
|
||||
userId = userId,
|
||||
username = username,
|
||||
nickname = nickname,
|
||||
isAdmin = isAdmin,
|
||||
createdAt = createdAt,
|
||||
isLoggedIn = true,
|
||||
)
|
||||
loadedFromCache = true
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch fresh profile from server. On success, update cache and UI.
|
||||
* On failure, keep showing cached data — no error, no UI disruption.
|
||||
*/
|
||||
fun fetchFreshProfile() {
|
||||
viewModelScope.launch {
|
||||
if (!loadedFromCache) {
|
||||
_profile.value = _profile.value.copy(isLoading = true)
|
||||
}
|
||||
try {
|
||||
val response = apiService.getProfile()
|
||||
if (response.isSuccessful) {
|
||||
val body = response.body()
|
||||
if (body != null) {
|
||||
val dateStr = body.createdAt?.take(10) ?: ""
|
||||
val nickname = body.nickname ?: body.username
|
||||
|
||||
_profile.value = ProfileState(
|
||||
userId = body.userId,
|
||||
username = body.username,
|
||||
nickname = nickname,
|
||||
isAdmin = body.isAdmin == true,
|
||||
createdAt = dateStr,
|
||||
isLoading = false,
|
||||
isLoggedIn = true,
|
||||
)
|
||||
loadedFromCache = true
|
||||
|
||||
// Update local cache
|
||||
prefs.saveProfileCache(body.userId, nickname, body.isAdmin == true, dateStr)
|
||||
}
|
||||
} else if (!loadedFromCache) {
|
||||
_profile.value = _profile.value.copy(isLoading = false)
|
||||
}
|
||||
// On error with cache already shown: silently ignore
|
||||
} catch (e: Exception) {
|
||||
Log.w("ProfileVM", "Failed to fetch fresh profile: ${e.message}")
|
||||
if (!loadedFromCache) {
|
||||
_profile.value = _profile.value.copy(isLoading = false)
|
||||
}
|
||||
// If cached data is showing, keep it silently
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun logout() {
|
||||
viewModelScope.launch {
|
||||
authRepository.logout()
|
||||
prefs.clearProfileCache()
|
||||
_profile.value = ProfileState()
|
||||
loadedFromCache = false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,180 +0,0 @@
|
||||
package top.yeij.cyrene.viewmodel
|
||||
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.launch
|
||||
import top.yeij.cyrene.data.local.PreferencesDataStore
|
||||
import top.yeij.cyrene.data.remote.DynamicUrlInterceptor
|
||||
import top.yeij.cyrene.domain.repository.AuthRepository
|
||||
import top.yeij.cyrene.domain.repository.ChatRepository
|
||||
import top.yeij.cyrene.voice.stt.SttManager
|
||||
|
||||
class SettingsViewModel(
|
||||
private val authRepository: AuthRepository,
|
||||
private val preferencesDataStore: PreferencesDataStore,
|
||||
private val dynamicUrlInterceptor: DynamicUrlInterceptor,
|
||||
private val chatRepository: ChatRepository,
|
||||
private val sttManager: SttManager,
|
||||
) {
|
||||
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
|
||||
|
||||
private val _baseUrl = MutableStateFlow("")
|
||||
val baseUrl: StateFlow<String> = _baseUrl.asStateFlow()
|
||||
|
||||
private val _themeMode = MutableStateFlow("auto")
|
||||
val themeMode: StateFlow<String> = _themeMode.asStateFlow()
|
||||
|
||||
private val _wakeWord = MutableStateFlow("昔涟")
|
||||
val wakeWord: StateFlow<String> = _wakeWord.asStateFlow()
|
||||
|
||||
private val _username = MutableStateFlow("")
|
||||
val username: StateFlow<String> = _username.asStateFlow()
|
||||
|
||||
private val _dashScopeApiKey = MutableStateFlow("")
|
||||
val dashScopeApiKey: StateFlow<String> = _dashScopeApiKey.asStateFlow()
|
||||
|
||||
private val _dashScopeEndpoint = MutableStateFlow("wss://dashscope.aliyuncs.com/api-ws/v1/inference")
|
||||
val dashScopeEndpoint: StateFlow<String> = _dashScopeEndpoint.asStateFlow()
|
||||
|
||||
private val _dashScopeModel = MutableStateFlow("fun-asr-realtime")
|
||||
val dashScopeModel: StateFlow<String> = _dashScopeModel.asStateFlow()
|
||||
|
||||
private val _autoScreenContext = MutableStateFlow(false)
|
||||
val autoScreenContext: StateFlow<Boolean> = _autoScreenContext.asStateFlow()
|
||||
|
||||
private val _isLoggedIn = MutableStateFlow(false)
|
||||
val isLoggedIn: StateFlow<Boolean> = _isLoggedIn.asStateFlow()
|
||||
|
||||
init {
|
||||
scope.launch {
|
||||
_isLoggedIn.value = authRepository.isLoggedIn()
|
||||
}
|
||||
// Single collector for all DataStore preferences — avoids subscriber explosion
|
||||
scope.launch {
|
||||
preferencesDataStore.autoScreenContext.collect { value ->
|
||||
_autoScreenContext.value = value
|
||||
}
|
||||
}
|
||||
scope.launch {
|
||||
combine(
|
||||
preferencesDataStore.baseUrl,
|
||||
preferencesDataStore.themeMode,
|
||||
preferencesDataStore.wakeWord,
|
||||
preferencesDataStore.username,
|
||||
combine(
|
||||
preferencesDataStore.dashScopeApiKey,
|
||||
preferencesDataStore.dashScopeEndpoint,
|
||||
preferencesDataStore.dashScopeModel,
|
||||
) { apiKey, endpoint, model ->
|
||||
Triple(apiKey, endpoint, model)
|
||||
},
|
||||
) { baseUrl, themeMode, wakeWord, username, dashScope ->
|
||||
baseUrl?.let { url ->
|
||||
if (url.isNotBlank()) {
|
||||
_baseUrl.value = url
|
||||
dynamicUrlInterceptor.baseUrl = url
|
||||
}
|
||||
}
|
||||
themeMode?.let { _themeMode.value = it }
|
||||
wakeWord?.let { word ->
|
||||
if (word.isNotBlank()) _wakeWord.value = word
|
||||
}
|
||||
username?.let { _username.value = it }
|
||||
val (apiKey, endpoint, model) = dashScope
|
||||
apiKey?.let { key ->
|
||||
if (key.isNotBlank()) _dashScopeApiKey.value = key
|
||||
sttManager.updateDashScopeApiKey(key)
|
||||
}
|
||||
endpoint?.let { ep ->
|
||||
if (ep.isNotBlank()) _dashScopeEndpoint.value = ep
|
||||
}
|
||||
model?.let { m ->
|
||||
if (m.isNotBlank()) _dashScopeModel.value = m
|
||||
}
|
||||
// Push full config to STT
|
||||
sttManager.configureDashScope(
|
||||
apiKey = _dashScopeApiKey.value,
|
||||
endpoint = _dashScopeEndpoint.value,
|
||||
model = _dashScopeModel.value,
|
||||
)
|
||||
}.collect { }
|
||||
}
|
||||
}
|
||||
|
||||
fun updateBaseUrlInput(url: String) {
|
||||
_baseUrl.value = url
|
||||
}
|
||||
|
||||
fun saveBaseUrl(url: String) {
|
||||
_baseUrl.value = url
|
||||
dynamicUrlInterceptor.baseUrl = url
|
||||
scope.launch {
|
||||
preferencesDataStore.saveBaseUrl(url)
|
||||
chatRepository.reconnectWebSocket()
|
||||
}
|
||||
}
|
||||
|
||||
fun saveThemeMode(mode: String) {
|
||||
_themeMode.value = mode
|
||||
scope.launch { preferencesDataStore.saveThemeMode(mode) }
|
||||
}
|
||||
|
||||
fun saveWakeWord(word: String) {
|
||||
_wakeWord.value = word
|
||||
scope.launch { preferencesDataStore.saveWakeWord(word) }
|
||||
}
|
||||
|
||||
fun updateDashScopeApiKeyInput(key: String) {
|
||||
_dashScopeApiKey.value = key
|
||||
}
|
||||
|
||||
fun saveDashScopeApiKey(key: String) {
|
||||
_dashScopeApiKey.value = key
|
||||
sttManager.updateDashScopeApiKey(key)
|
||||
scope.launch { preferencesDataStore.saveDashScopeApiKey(key) }
|
||||
}
|
||||
|
||||
fun updateDashScopeEndpointInput(endpoint: String) {
|
||||
_dashScopeEndpoint.value = endpoint
|
||||
}
|
||||
|
||||
fun saveDashScopeEndpoint(endpoint: String) {
|
||||
_dashScopeEndpoint.value = endpoint
|
||||
sttManager.configureDashScope(_dashScopeApiKey.value, endpoint, _dashScopeModel.value)
|
||||
scope.launch { preferencesDataStore.saveDashScopeEndpoint(endpoint) }
|
||||
}
|
||||
|
||||
fun updateDashScopeModelInput(model: String) {
|
||||
_dashScopeModel.value = model
|
||||
}
|
||||
|
||||
fun saveDashScopeModel(model: String) {
|
||||
_dashScopeModel.value = model
|
||||
sttManager.configureDashScope(_dashScopeApiKey.value, _dashScopeEndpoint.value, model)
|
||||
scope.launch { preferencesDataStore.saveDashScopeModel(model) }
|
||||
}
|
||||
|
||||
fun saveAutoScreenContext(enabled: Boolean) {
|
||||
_autoScreenContext.value = enabled
|
||||
scope.launch { preferencesDataStore.saveAutoScreenContext(enabled) }
|
||||
}
|
||||
|
||||
fun clearLocalMessages() {
|
||||
scope.launch {
|
||||
chatRepository.clearLocalMessages()
|
||||
}
|
||||
}
|
||||
|
||||
fun logout() {
|
||||
scope.launch {
|
||||
authRepository.logout()
|
||||
_isLoggedIn.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
package top.yeij.cyrene.voice.hotword
|
||||
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
|
||||
class HotwordDetector {
|
||||
|
||||
private val _isListening = MutableStateFlow(false)
|
||||
val isListening = _isListening.asStateFlow()
|
||||
|
||||
private val _onDetected = MutableStateFlow(false)
|
||||
val onDetected = _onDetected.asStateFlow()
|
||||
|
||||
fun startListening(wakeWord: String) {
|
||||
_isListening.value = true
|
||||
// Integrate system AlwaysOnHotwordDetector or Porcupine SDK here
|
||||
}
|
||||
|
||||
fun stopListening() {
|
||||
_isListening.value = false
|
||||
}
|
||||
|
||||
fun updateWakeWord(newWord: String) {
|
||||
stopListening()
|
||||
startListening(newWord)
|
||||
}
|
||||
}
|
||||
@@ -1,160 +0,0 @@
|
||||
package top.yeij.cyrene.voice.stt
|
||||
|
||||
import android.Manifest
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageManager
|
||||
import android.media.AudioFormat
|
||||
import android.media.AudioRecord
|
||||
import android.media.MediaRecorder
|
||||
import android.util.Base64
|
||||
import android.util.Log
|
||||
import androidx.core.content.ContextCompat
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharedFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asSharedFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
import top.yeij.cyrene.domain.repository.ChatRepository
|
||||
import java.io.ByteArrayOutputStream
|
||||
|
||||
class BackendSttProvider(
|
||||
private val context: Context,
|
||||
private val chatRepository: ChatRepository,
|
||||
) : SttProvider {
|
||||
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
private var audioRecord: AudioRecord? = null
|
||||
private var recordingJob: Job? = null
|
||||
private val audioBuffer = ByteArrayOutputStream()
|
||||
|
||||
private val _partialResult = MutableStateFlow("")
|
||||
override val partialResult: StateFlow<String> = _partialResult.asStateFlow()
|
||||
|
||||
private val _finalResult = MutableSharedFlow<SttResult>(extraBufferCapacity = 8)
|
||||
override val finalResult: SharedFlow<SttResult> = _finalResult.asSharedFlow()
|
||||
|
||||
private val _onError = MutableSharedFlow<String>(extraBufferCapacity = 8)
|
||||
override val onError: SharedFlow<String> = _onError.asSharedFlow()
|
||||
|
||||
private val _isListening = MutableStateFlow(false)
|
||||
override val isListening: StateFlow<Boolean> = _isListening.asStateFlow()
|
||||
|
||||
private val sampleRate = 16000
|
||||
private val audioFormat = AudioFormat.ENCODING_PCM_16BIT
|
||||
private val channelConfig = AudioFormat.CHANNEL_IN_MONO
|
||||
private val bufferSize: Int = AudioRecord.getMinBufferSize(sampleRate, channelConfig, audioFormat)
|
||||
.coerceAtLeast(3200)
|
||||
|
||||
override fun start() {
|
||||
if (ContextCompat.checkSelfPermission(context, Manifest.permission.RECORD_AUDIO)
|
||||
!= PackageManager.PERMISSION_GRANTED
|
||||
) {
|
||||
_onError.tryEmit("缺少录音权限")
|
||||
return
|
||||
}
|
||||
|
||||
cancel()
|
||||
_isListening.value = true
|
||||
_partialResult.value = ""
|
||||
audioBuffer.reset()
|
||||
|
||||
try {
|
||||
audioRecord = AudioRecord(
|
||||
MediaRecorder.AudioSource.VOICE_RECOGNITION,
|
||||
sampleRate,
|
||||
channelConfig,
|
||||
audioFormat,
|
||||
bufferSize,
|
||||
).also {
|
||||
if (it.state != AudioRecord.STATE_INITIALIZED) {
|
||||
Log.e(TAG, "AudioRecord init failed")
|
||||
_onError.tryEmit("麦克风初始化失败")
|
||||
_isListening.value = false
|
||||
return
|
||||
}
|
||||
it.startRecording()
|
||||
}
|
||||
|
||||
val readBuffer = ByteArray(bufferSize)
|
||||
recordingJob = scope.launch {
|
||||
while (isActive && _isListening.value) {
|
||||
val bytesRead = audioRecord?.read(readBuffer, 0, readBuffer.size) ?: -1
|
||||
if (bytesRead > 0) {
|
||||
audioBuffer.write(readBuffer, 0, bytesRead)
|
||||
} else if (bytesRead < 0) break
|
||||
}
|
||||
}
|
||||
} catch (e: SecurityException) {
|
||||
_onError.tryEmit("缺少录音权限")
|
||||
_isListening.value = false
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Recording error", e)
|
||||
_onError.tryEmit("录音失败: ${e.message}")
|
||||
_isListening.value = false
|
||||
}
|
||||
}
|
||||
|
||||
override fun stop() {
|
||||
if (!_isListening.value) return
|
||||
Log.d(TAG, "Stopping recording")
|
||||
stopRecording()
|
||||
|
||||
val audioBytes = audioBuffer.toByteArray()
|
||||
if (audioBytes.isEmpty()) {
|
||||
Log.w(TAG, "No audio data recorded")
|
||||
_isListening.value = false
|
||||
_onError.tryEmit("未录制到语音")
|
||||
return
|
||||
}
|
||||
|
||||
_isListening.value = false
|
||||
scope.launch {
|
||||
try {
|
||||
val base64 = Base64.encodeToString(audioBytes, Base64.NO_WRAP)
|
||||
chatRepository.sendVoiceInput(base64, "voice_msg")
|
||||
Log.d(TAG, "Sent ${audioBytes.size} bytes of audio to backend")
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to send voice input", e)
|
||||
_onError.tryEmit("发送语音失败: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun cancel() {
|
||||
if (!_isListening.value) return
|
||||
Log.d(TAG, "Cancelling recording")
|
||||
stopRecording()
|
||||
audioBuffer.reset()
|
||||
_isListening.value = false
|
||||
_partialResult.value = ""
|
||||
}
|
||||
|
||||
private fun stopRecording() {
|
||||
recordingJob?.cancel()
|
||||
recordingJob = null
|
||||
try {
|
||||
audioRecord?.stop()
|
||||
audioRecord?.release()
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Error releasing AudioRecord: ${e.message}")
|
||||
}
|
||||
audioRecord = null
|
||||
}
|
||||
|
||||
fun shutdown() {
|
||||
cancel()
|
||||
scope.cancel()
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "BackendSTT"
|
||||
}
|
||||
}
|
||||
@@ -1,355 +0,0 @@
|
||||
package top.yeij.cyrene.voice.stt
|
||||
|
||||
import android.Manifest
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageManager
|
||||
import android.media.AudioAttributes
|
||||
import android.media.AudioFormat
|
||||
import android.media.AudioRecord
|
||||
import android.media.MediaRecorder
|
||||
import android.util.Log
|
||||
import androidx.core.content.ContextCompat
|
||||
import com.google.gson.Gson
|
||||
import com.google.gson.annotations.SerializedName
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharedFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asSharedFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
import okhttp3.WebSocket
|
||||
import okhttp3.WebSocketListener
|
||||
import okio.ByteString
|
||||
import java.util.UUID
|
||||
import java.util.concurrent.TimeUnit
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
|
||||
class DashScopeSttService(
|
||||
private val context: Context,
|
||||
) : SttProvider {
|
||||
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
private val gson = Gson()
|
||||
private val connectionId = AtomicInteger(0)
|
||||
|
||||
private var webSocket: WebSocket? = null
|
||||
private var audioRecord: AudioRecord? = null
|
||||
private var recordingJob: Job? = null
|
||||
private var taskId: String = ""
|
||||
|
||||
private val _partialResult = MutableStateFlow("")
|
||||
override val partialResult: StateFlow<String> = _partialResult.asStateFlow()
|
||||
|
||||
private val _finalResult = MutableSharedFlow<SttResult>(extraBufferCapacity = 16)
|
||||
override val finalResult: SharedFlow<SttResult> = _finalResult.asSharedFlow()
|
||||
|
||||
private val _onError = MutableSharedFlow<String>(extraBufferCapacity = 8)
|
||||
override val onError: SharedFlow<String> = _onError.asSharedFlow()
|
||||
|
||||
private val _isListening = MutableStateFlow(false)
|
||||
override val isListening: StateFlow<Boolean> = _isListening.asStateFlow()
|
||||
|
||||
// Configurable via settings
|
||||
var apiKey: String = ""
|
||||
var endpoint: String = "wss://dashscope.aliyuncs.com/api-ws/v1/inference"
|
||||
var model: String = "fun-asr-realtime"
|
||||
var enableInterimResults: Boolean = true
|
||||
|
||||
private val sampleRate = 16000
|
||||
private val audioFormat = AudioFormat.ENCODING_PCM_16BIT
|
||||
private val channelConfig = AudioFormat.CHANNEL_IN_MONO
|
||||
private val bytesPerSample = 2
|
||||
private val chunkMs = 100
|
||||
private val bufferSize: Int = AudioRecord.getMinBufferSize(sampleRate, channelConfig, audioFormat)
|
||||
.coerceAtLeast(sampleRate * bytesPerSample * chunkMs / 1000)
|
||||
|
||||
override fun start() {
|
||||
if (apiKey.isBlank()) {
|
||||
Log.w(TAG, "DashScope API key not configured")
|
||||
_onError.tryEmit("请先配置 DashScope API Key")
|
||||
return
|
||||
}
|
||||
|
||||
if (ContextCompat.checkSelfPermission(context, Manifest.permission.RECORD_AUDIO)
|
||||
!= PackageManager.PERMISSION_GRANTED
|
||||
) {
|
||||
_onError.tryEmit("缺少录音权限")
|
||||
return
|
||||
}
|
||||
|
||||
cancel()
|
||||
taskId = UUID.randomUUID().toString()
|
||||
_isListening.value = true
|
||||
_partialResult.value = ""
|
||||
|
||||
connectWebSocket()
|
||||
startRecording()
|
||||
}
|
||||
|
||||
override fun stop() {
|
||||
if (!_isListening.value) return
|
||||
Log.d(TAG, "Stopping recognition, taskId=$taskId")
|
||||
stopRecording()
|
||||
sendFinishTask()
|
||||
}
|
||||
|
||||
override fun cancel() {
|
||||
if (!_isListening.value) return
|
||||
Log.d(TAG, "Cancelling recognition, taskId=$taskId")
|
||||
stopRecording()
|
||||
closeWebSocket()
|
||||
_isListening.value = false
|
||||
_partialResult.value = ""
|
||||
}
|
||||
|
||||
private fun connectWebSocket() {
|
||||
val client = OkHttpClient.Builder()
|
||||
.readTimeout(0, TimeUnit.MILLISECONDS)
|
||||
.writeTimeout(0, TimeUnit.MILLISECONDS)
|
||||
.callTimeout(0, TimeUnit.MILLISECONDS)
|
||||
.build()
|
||||
|
||||
val request = Request.Builder()
|
||||
.url(endpoint)
|
||||
.header("Authorization", "Bearer $apiKey")
|
||||
.build()
|
||||
|
||||
val connId = connectionId.incrementAndGet()
|
||||
Log.i(TAG, "[#$connId] Connecting to DashScope: $endpoint")
|
||||
|
||||
webSocket = client.newWebSocket(request, object : WebSocketListener() {
|
||||
override fun onOpen(webSocket: WebSocket, response: Response) {
|
||||
if (connectionId.get() != connId) return
|
||||
Log.i(TAG, "[#$connId] Connected, sending run-task")
|
||||
sendRunTask()
|
||||
}
|
||||
|
||||
override fun onMessage(webSocket: WebSocket, text: String) {
|
||||
if (connectionId.get() != connId) return
|
||||
handleServerMessage(text)
|
||||
}
|
||||
|
||||
override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
|
||||
if (connectionId.get() != connId) return
|
||||
Log.e(TAG, "[#$connId] WebSocket failure: ${t.message}", t)
|
||||
_isListening.value = false
|
||||
_onError.tryEmit("语音识别连接失败: ${t.message}")
|
||||
}
|
||||
|
||||
override fun onClosing(webSocket: WebSocket, code: Int, reason: String) {
|
||||
if (connectionId.get() != connId) return
|
||||
Log.d(TAG, "[#$connId] Server closing: $code $reason")
|
||||
}
|
||||
|
||||
override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
|
||||
if (connectionId.get() != connId) return
|
||||
Log.d(TAG, "[#$connId] Closed: $code $reason")
|
||||
_isListening.value = false
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private fun sendRunTask() {
|
||||
val msg = mapOf(
|
||||
"header" to mapOf(
|
||||
"action" to "run-task",
|
||||
"task_id" to taskId,
|
||||
"streaming" to "duplex",
|
||||
),
|
||||
"payload" to mapOf(
|
||||
"model" to model,
|
||||
"parameters" to mapOf(
|
||||
"format" to "pcm",
|
||||
"sample_rate" to sampleRate,
|
||||
),
|
||||
"input" to emptyMap<String, String>(),
|
||||
),
|
||||
)
|
||||
val json = gson.toJson(msg)
|
||||
Log.d(TAG, "Sending run-task: $json")
|
||||
webSocket?.send(json)
|
||||
}
|
||||
|
||||
private fun sendFinishTask() {
|
||||
val msg = mapOf(
|
||||
"header" to mapOf(
|
||||
"action" to "finish-task",
|
||||
"task_id" to taskId,
|
||||
),
|
||||
"payload" to emptyMap<String, String>(),
|
||||
)
|
||||
val json = gson.toJson(msg)
|
||||
Log.d(TAG, "Sending finish-task: $json")
|
||||
webSocket?.send(json)
|
||||
}
|
||||
|
||||
private fun handleServerMessage(text: String) {
|
||||
try {
|
||||
val response = gson.fromJson(text, DashScopeResponse::class.java)
|
||||
val header = response.header ?: return
|
||||
val event = header.event ?: return
|
||||
|
||||
when (event) {
|
||||
"result-generated" -> {
|
||||
val sentence = response.payload?.output?.sentence ?: return
|
||||
val sentenceText = sentence.text ?: return
|
||||
_partialResult.value = sentenceText
|
||||
|
||||
val sttResult = SttResult(
|
||||
text = sentenceText,
|
||||
isFinal = sentence.sentenceEnd ?: true,
|
||||
)
|
||||
if (sttResult.isFinal) {
|
||||
_finalResult.tryEmit(sttResult)
|
||||
} else if (enableInterimResults) {
|
||||
_finalResult.tryEmit(sttResult)
|
||||
}
|
||||
}
|
||||
|
||||
"task-finished" -> {
|
||||
Log.i(TAG, "Task finished: $taskId")
|
||||
// If no result-generated was received, emit what we have
|
||||
val partial = _partialResult.value
|
||||
if (partial.isNotBlank()) {
|
||||
_finalResult.tryEmit(SttResult(text = partial, isFinal = true))
|
||||
}
|
||||
closeWebSocket()
|
||||
_isListening.value = false
|
||||
}
|
||||
|
||||
"task-failed" -> {
|
||||
val error = header.errorMessage ?: "语音识别失败"
|
||||
Log.e(TAG, "Task failed: $error")
|
||||
_onError.tryEmit(error)
|
||||
closeWebSocket()
|
||||
_isListening.value = false
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Failed to parse server message: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
private fun startRecording() {
|
||||
try {
|
||||
audioRecord = AudioRecord(
|
||||
MediaRecorder.AudioSource.VOICE_RECOGNITION,
|
||||
sampleRate,
|
||||
channelConfig,
|
||||
audioFormat,
|
||||
bufferSize,
|
||||
).also {
|
||||
if (it.state != AudioRecord.STATE_INITIALIZED) {
|
||||
Log.e(TAG, "AudioRecord initialization failed")
|
||||
_onError.tryEmit("麦克风初始化失败")
|
||||
_isListening.value = false
|
||||
return
|
||||
}
|
||||
it.startRecording()
|
||||
}
|
||||
|
||||
Log.d(TAG, "Recording started, bufferSize=$bufferSize")
|
||||
val readBuffer = ByteArray(bufferSize)
|
||||
|
||||
recordingJob = scope.launch {
|
||||
while (isActive && _isListening.value) {
|
||||
val bytesRead = audioRecord?.read(readBuffer, 0, readBuffer.size) ?: -1
|
||||
if (bytesRead > 0) {
|
||||
val data = readBuffer.copyOf(bytesRead)
|
||||
webSocket?.let { ws ->
|
||||
try {
|
||||
ws.send(ByteString.of(*data))
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Failed to send audio: ${e.message}")
|
||||
}
|
||||
}
|
||||
} else if (bytesRead < 0) {
|
||||
Log.w(TAG, "AudioRecord read error: $bytesRead")
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e: SecurityException) {
|
||||
Log.e(TAG, "Missing RECORD_AUDIO permission", e)
|
||||
_onError.tryEmit("缺少录音权限")
|
||||
_isListening.value = false
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to start recording", e)
|
||||
_onError.tryEmit("录音启动失败: ${e.message}")
|
||||
_isListening.value = false
|
||||
}
|
||||
}
|
||||
|
||||
private fun stopRecording() {
|
||||
recordingJob?.cancel()
|
||||
recordingJob = null
|
||||
try {
|
||||
audioRecord?.stop()
|
||||
audioRecord?.release()
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Error releasing AudioRecord: ${e.message}")
|
||||
}
|
||||
audioRecord = null
|
||||
Log.d(TAG, "Recording stopped")
|
||||
}
|
||||
|
||||
private fun closeWebSocket() {
|
||||
try {
|
||||
webSocket?.close(1000, "Done")
|
||||
} catch (_: Exception) { }
|
||||
webSocket = null
|
||||
}
|
||||
|
||||
fun shutdown() {
|
||||
cancel()
|
||||
scope.cancel()
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "DashScopeSTT"
|
||||
}
|
||||
}
|
||||
|
||||
// --- JSON DTOs for DashScope WebSocket protocol ---
|
||||
|
||||
data class DashScopeResponse(
|
||||
@SerializedName("header") val header: DashScopeHeader?,
|
||||
@SerializedName("payload") val payload: DashScopePayload?,
|
||||
)
|
||||
|
||||
data class DashScopeHeader(
|
||||
@SerializedName("task_id") val taskId: String?,
|
||||
@SerializedName("event") val event: String?,
|
||||
@SerializedName("action") val action: String?,
|
||||
@SerializedName("error_message") val errorMessage: String?,
|
||||
)
|
||||
|
||||
data class DashScopePayload(
|
||||
@SerializedName("output") val output: DashScopeOutput?,
|
||||
@SerializedName("usage") val usage: Map<String, Any>?,
|
||||
)
|
||||
|
||||
data class DashScopeOutput(
|
||||
@SerializedName("sentence") val sentence: DashScopeSentence?,
|
||||
)
|
||||
|
||||
data class DashScopeSentence(
|
||||
@SerializedName("text") val text: String?,
|
||||
@SerializedName("sentence_end") val sentenceEnd: Boolean?,
|
||||
@SerializedName("sentence_id") val sentenceId: Int?,
|
||||
@SerializedName("begin_time") val beginTime: Long?,
|
||||
@SerializedName("end_time") val endTime: Long?,
|
||||
@SerializedName("words") val words: List<Map<String, Any>>?,
|
||||
@SerializedName("emo_tag") val emoTag: String?,
|
||||
)
|
||||
@@ -1,118 +0,0 @@
|
||||
package top.yeij.cyrene.voice.stt
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import android.speech.RecognitionListener
|
||||
import android.speech.RecognizerIntent
|
||||
import android.speech.SpeechRecognizer
|
||||
import android.util.Log
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharedFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asSharedFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
|
||||
class SpeechRecognizer(private val context: Context) : SttProvider {
|
||||
|
||||
private var recognizer: android.speech.SpeechRecognizer? = null
|
||||
|
||||
private val _isListening = MutableStateFlow(false)
|
||||
override val isListening: StateFlow<Boolean> = _isListening.asStateFlow()
|
||||
|
||||
private val _partialResult = MutableStateFlow("")
|
||||
override val partialResult: StateFlow<String> = _partialResult.asStateFlow()
|
||||
|
||||
private val _finalResult = MutableSharedFlow<SttResult>(extraBufferCapacity = 8)
|
||||
override val finalResult: SharedFlow<SttResult> = _finalResult.asSharedFlow()
|
||||
|
||||
private val _onError = MutableSharedFlow<String>(extraBufferCapacity = 8)
|
||||
override val onError: SharedFlow<String> = _onError.asSharedFlow()
|
||||
|
||||
override fun start() {
|
||||
if (!android.speech.SpeechRecognizer.isRecognitionAvailable(context)) {
|
||||
Log.w(TAG, "Speech recognition not available on this device")
|
||||
_onError.tryEmit("语音识别不可用")
|
||||
return
|
||||
}
|
||||
|
||||
cancel()
|
||||
recognizer = android.speech.SpeechRecognizer.createSpeechRecognizer(context).apply {
|
||||
setRecognitionListener(object : RecognitionListener {
|
||||
override fun onReadyForSpeech(params: Bundle?) {
|
||||
_isListening.value = true
|
||||
_partialResult.value = ""
|
||||
}
|
||||
|
||||
override fun onBeginningOfSpeech() {}
|
||||
|
||||
override fun onRmsChanged(rmsdB: Float) {}
|
||||
|
||||
override fun onBufferReceived(buffer: ByteArray?) {}
|
||||
|
||||
override fun onEndOfSpeech() {}
|
||||
|
||||
override fun onError(error: Int) {
|
||||
_isListening.value = false
|
||||
val msg = when (error) {
|
||||
android.speech.SpeechRecognizer.ERROR_NETWORK -> "网络连接失败"
|
||||
android.speech.SpeechRecognizer.ERROR_NETWORK_TIMEOUT -> "网络超时"
|
||||
android.speech.SpeechRecognizer.ERROR_AUDIO -> "音频录制错误"
|
||||
android.speech.SpeechRecognizer.ERROR_SERVER -> "服务器错误"
|
||||
android.speech.SpeechRecognizer.ERROR_CLIENT -> "客户端错误"
|
||||
android.speech.SpeechRecognizer.ERROR_SPEECH_TIMEOUT -> "未检测到语音"
|
||||
android.speech.SpeechRecognizer.ERROR_NO_MATCH -> "未能识别"
|
||||
android.speech.SpeechRecognizer.ERROR_RECOGNIZER_BUSY -> "语音引擎忙碌"
|
||||
android.speech.SpeechRecognizer.ERROR_INSUFFICIENT_PERMISSIONS -> "缺少录音权限"
|
||||
else -> "未知错误 ($error)"
|
||||
}
|
||||
Log.w(TAG, "Recognition error: $msg")
|
||||
_onError.tryEmit(msg)
|
||||
}
|
||||
|
||||
override fun onResults(results: Bundle?) {
|
||||
_isListening.value = false
|
||||
val matches = results?.getStringArrayList(android.speech.SpeechRecognizer.RESULTS_RECOGNITION)
|
||||
if (!matches.isNullOrEmpty()) {
|
||||
_finalResult.tryEmit(SttResult(text = matches[0], isFinal = true))
|
||||
}
|
||||
}
|
||||
|
||||
override fun onPartialResults(partialResults: Bundle?) {
|
||||
val matches = partialResults?.getStringArrayList(android.speech.SpeechRecognizer.RESULTS_RECOGNITION)
|
||||
if (!matches.isNullOrEmpty()) {
|
||||
_partialResult.value = matches[0]
|
||||
}
|
||||
}
|
||||
|
||||
override fun onEvent(eventType: Int, params: Bundle?) {}
|
||||
})
|
||||
|
||||
val intent = Intent(RecognizerIntent.ACTION_RECOGNIZE_SPEECH).apply {
|
||||
putExtra(RecognizerIntent.EXTRA_LANGUAGE_MODEL, RecognizerIntent.LANGUAGE_MODEL_FREE_FORM)
|
||||
putExtra(RecognizerIntent.EXTRA_LANGUAGE, "zh-CN")
|
||||
putExtra(RecognizerIntent.EXTRA_PARTIAL_RESULTS, true)
|
||||
putExtra(RecognizerIntent.EXTRA_MAX_RESULTS, 1)
|
||||
}
|
||||
startListening(intent)
|
||||
}
|
||||
}
|
||||
|
||||
override fun stop() {
|
||||
recognizer?.stopListening()
|
||||
_isListening.value = false
|
||||
}
|
||||
|
||||
override fun cancel() {
|
||||
recognizer?.cancel()
|
||||
recognizer?.destroy()
|
||||
recognizer = null
|
||||
_isListening.value = false
|
||||
_partialResult.value = ""
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "CyreneSTT"
|
||||
}
|
||||
}
|
||||
@@ -1,128 +0,0 @@
|
||||
package top.yeij.cyrene.voice.stt
|
||||
|
||||
import android.util.Log
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharedFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asSharedFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import top.yeij.cyrene.util.RuntimeLog
|
||||
|
||||
class SttManager(
|
||||
private val dashScopeService: DashScopeSttService,
|
||||
private val backendProvider: BackendSttProvider,
|
||||
private val systemRecognizer: SpeechRecognizer,
|
||||
) {
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
|
||||
|
||||
private val _isListening = MutableStateFlow(false)
|
||||
val isListening: StateFlow<Boolean> = _isListening.asStateFlow()
|
||||
|
||||
private val _partialResult = MutableStateFlow("")
|
||||
val partialResult: StateFlow<String> = _partialResult.asStateFlow()
|
||||
|
||||
private val _finalResult = MutableSharedFlow<SttResult>(extraBufferCapacity = 16)
|
||||
val finalResult: SharedFlow<SttResult> = _finalResult.asSharedFlow()
|
||||
|
||||
private val _onError = MutableSharedFlow<String>(extraBufferCapacity = 8)
|
||||
val onError: SharedFlow<String> = _onError.asSharedFlow()
|
||||
|
||||
private var activeProvider: SttProvider? = null
|
||||
private var dashScopeFailed = false
|
||||
|
||||
fun start() {
|
||||
cancel()
|
||||
|
||||
val provider = if (dashScopeService.apiKey.isNotBlank() && !dashScopeFailed) {
|
||||
Log.d(TAG, "Using DashScope STT")
|
||||
RuntimeLog.stt("start", "Using DashScope provider")
|
||||
dashScopeService.also { dashScopeFailed = false }
|
||||
} else {
|
||||
Log.d(TAG, "Using Backend STT (fallback)")
|
||||
RuntimeLog.stt("start", "Using Backend provider (fallback)")
|
||||
backendProvider
|
||||
}
|
||||
|
||||
activeProvider = provider
|
||||
_isListening.value = true
|
||||
_partialResult.value = ""
|
||||
collectFrom(provider)
|
||||
provider.start()
|
||||
}
|
||||
|
||||
fun stop() {
|
||||
RuntimeLog.stt("stop", "Stopping STT")
|
||||
activeProvider?.stop()
|
||||
_isListening.value = false
|
||||
}
|
||||
|
||||
fun cancel() {
|
||||
RuntimeLog.stt("cancel", "Cancelling STT")
|
||||
activeProvider?.cancel()
|
||||
activeProvider = null
|
||||
_isListening.value = false
|
||||
_partialResult.value = ""
|
||||
}
|
||||
|
||||
fun configureDashScope(apiKey: String, endpoint: String, model: String) {
|
||||
dashScopeService.apiKey = apiKey
|
||||
dashScopeService.endpoint = endpoint
|
||||
dashScopeService.model = model
|
||||
dashScopeFailed = false
|
||||
}
|
||||
|
||||
fun updateDashScopeApiKey(apiKey: String) {
|
||||
dashScopeService.apiKey = apiKey
|
||||
if (apiKey.isNotBlank()) dashScopeFailed = false
|
||||
}
|
||||
|
||||
fun shutdown() {
|
||||
cancel()
|
||||
dashScopeService.shutdown()
|
||||
backendProvider.shutdown()
|
||||
scope.cancel()
|
||||
}
|
||||
|
||||
private fun collectFrom(provider: SttProvider) {
|
||||
scope.launch {
|
||||
provider.partialResult.collect { text ->
|
||||
if (provider == activeProvider && text.isNotEmpty()) {
|
||||
_partialResult.value = text
|
||||
}
|
||||
}
|
||||
}
|
||||
scope.launch {
|
||||
provider.finalResult.collect { result ->
|
||||
if (provider == activeProvider) {
|
||||
RuntimeLog.stt("result", "Final result: isFinal=${result.isFinal} text=${result.text.take(80)}")
|
||||
_finalResult.tryEmit(result)
|
||||
}
|
||||
}
|
||||
}
|
||||
scope.launch {
|
||||
provider.onError.collect { error ->
|
||||
if (provider == activeProvider) {
|
||||
Log.w(TAG, "STT error: $error")
|
||||
RuntimeLog.stt("error", error)
|
||||
_onError.tryEmit(error)
|
||||
|
||||
if (provider == dashScopeService) {
|
||||
dashScopeFailed = true
|
||||
RuntimeLog.stt("fallback", "DashScope failed, will use backend next time")
|
||||
Log.i(TAG, "DashScope failed, will use backend next time")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "SttManager"
|
||||
}
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
package top.yeij.cyrene.voice.stt
|
||||
|
||||
import kotlinx.coroutines.flow.SharedFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
|
||||
enum class SttProviderType { DASHSCOPE, BACKEND, SYSTEM }
|
||||
|
||||
data class SttResult(
|
||||
val text: String,
|
||||
val isFinal: Boolean,
|
||||
val isError: Boolean = false,
|
||||
)
|
||||
|
||||
interface SttProvider {
|
||||
val partialResult: StateFlow<String>
|
||||
val finalResult: SharedFlow<SttResult>
|
||||
val onError: SharedFlow<String>
|
||||
val isListening: StateFlow<Boolean>
|
||||
|
||||
fun start()
|
||||
fun stop()
|
||||
fun cancel()
|
||||
}
|
||||
@@ -1,97 +0,0 @@
|
||||
package top.yeij.cyrene.voice.tts
|
||||
|
||||
import android.content.Context
|
||||
import android.media.AudioAttributes
|
||||
import android.media.AudioFocusRequest
|
||||
import android.media.AudioManager
|
||||
import android.os.Build
|
||||
import android.speech.tts.TextToSpeech
|
||||
import android.speech.tts.UtteranceProgressListener
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import java.util.Locale
|
||||
|
||||
class TextToSpeechEngine(private val context: Context) {
|
||||
|
||||
private var tts: TextToSpeech? = null
|
||||
private val audioManager = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager
|
||||
|
||||
private val _isSpeaking = MutableStateFlow(false)
|
||||
val isSpeaking = _isSpeaking.asStateFlow()
|
||||
|
||||
private val _onDone = MutableStateFlow(false)
|
||||
val onDone = _onDone.asStateFlow()
|
||||
|
||||
fun initialize(onReady: () -> Unit) {
|
||||
tts = TextToSpeech(context) { status ->
|
||||
if (status == TextToSpeech.SUCCESS) {
|
||||
tts?.language = Locale.CHINESE
|
||||
tts?.setSpeechRate(0.9f)
|
||||
tts?.setPitch(1.1f)
|
||||
|
||||
tts?.setOnUtteranceProgressListener(object : UtteranceProgressListener() {
|
||||
override fun onStart(utteranceId: String?) {
|
||||
requestAudioFocus()
|
||||
_isSpeaking.value = true
|
||||
}
|
||||
|
||||
override fun onDone(utteranceId: String?) {
|
||||
_isSpeaking.value = false
|
||||
_onDone.value = true
|
||||
abandonAudioFocus()
|
||||
}
|
||||
|
||||
override fun onError(utteranceId: String?) {
|
||||
_isSpeaking.value = false
|
||||
abandonAudioFocus()
|
||||
}
|
||||
})
|
||||
onReady()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun speak(text: String, utteranceId: String = System.currentTimeMillis().toString()) {
|
||||
tts?.speak(text, TextToSpeech.QUEUE_FLUSH, null, utteranceId)
|
||||
}
|
||||
|
||||
fun stop() {
|
||||
tts?.stop()
|
||||
_isSpeaking.value = false
|
||||
}
|
||||
|
||||
fun shutdown() {
|
||||
tts?.stop()
|
||||
tts?.shutdown()
|
||||
}
|
||||
|
||||
private fun requestAudioFocus() {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
val focusRequest = AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN_TRANSIENT_EXCLUSIVE)
|
||||
.setAudioAttributes(
|
||||
AudioAttributes.Builder()
|
||||
.setUsage(AudioAttributes.USAGE_ASSISTANT)
|
||||
.setContentType(AudioAttributes.CONTENT_TYPE_SPEECH)
|
||||
.build()
|
||||
)
|
||||
.build()
|
||||
audioManager.requestAudioFocus(focusRequest)
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
audioManager.requestAudioFocus(
|
||||
null,
|
||||
AudioManager.STREAM_MUSIC,
|
||||
AudioManager.AUDIOFOCUS_GAIN_TRANSIENT_EXCLUSIVE,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun abandonAudioFocus() {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
// Focus released with the request
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
audioManager.abandonAudioFocus(null)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,348 @@
|
||||
package top.yeij.cyrene.app
|
||||
|
||||
import android.content.Context
|
||||
import top.yeij.cyrene.core.auth.AuthManager
|
||||
import top.yeij.cyrene.core.auth.SessionStore
|
||||
import top.yeij.cyrene.core.notify.AppPresence
|
||||
import top.yeij.cyrene.core.notify.ProactiveNotifier
|
||||
import top.yeij.cyrene.core.notify.postProactiveNotification
|
||||
import top.yeij.cyrene.core.prefs.AppSettings
|
||||
import top.yeij.cyrene.core.prefs.EnumPreferences
|
||||
import top.yeij.cyrene.core.prefs.SessionPreferences
|
||||
import top.yeij.cyrene.core.session.ChatSessionSlots
|
||||
import top.yeij.cyrene.core.session.GoneSessions
|
||||
import top.yeij.cyrene.data.api.AdminApi
|
||||
import top.yeij.cyrene.data.api.ApiClientFactory
|
||||
import top.yeij.cyrene.data.api.ChatApi
|
||||
import top.yeij.cyrene.data.api.DocumentsApi
|
||||
import top.yeij.cyrene.data.api.MediaApi
|
||||
import top.yeij.cyrene.data.api.MetaApi
|
||||
import top.yeij.cyrene.data.api.PersonaApi
|
||||
import top.yeij.cyrene.data.api.SessionsApi
|
||||
import top.yeij.cyrene.data.api.UploadApi
|
||||
import top.yeij.cyrene.data.api.dto.SessionSummaryDto
|
||||
import top.yeij.cyrene.data.repository.AdminRepository
|
||||
import top.yeij.cyrene.data.repository.ChatRepository
|
||||
import top.yeij.cyrene.data.repository.DocumentRepository
|
||||
import top.yeij.cyrene.data.repository.MediaRepository
|
||||
import top.yeij.cyrene.data.repository.MessageRepository
|
||||
import top.yeij.cyrene.data.repository.MetaRepository
|
||||
import top.yeij.cyrene.data.repository.PersonaRepository
|
||||
import top.yeij.cyrene.data.repository.SessionRepository
|
||||
import top.yeij.cyrene.data.repository.UploadRepository
|
||||
import top.yeij.cyrene.data.ws.ChatSocket
|
||||
|
||||
/** 手写 DI 容器(D:不引 Hilt)。 */
|
||||
class AppContainer(context: Context) {
|
||||
|
||||
/** 应用上下文:给需要在 ViewModel 里读本地资源(图片解码等)的组件用。 */
|
||||
val appContext: Context = context.applicationContext
|
||||
|
||||
val sessionStore: SessionStore = SessionStore(context.applicationContext)
|
||||
val sessionPreferences: SessionPreferences = SessionPreferences(context.applicationContext)
|
||||
val enumPreferences: EnumPreferences = EnumPreferences(context.applicationContext)
|
||||
val appSettings: AppSettings = AppSettings(context.applicationContext)
|
||||
val authManager: AuthManager = AuthManager(sessionStore)
|
||||
|
||||
/**
|
||||
* 本端已知「服务端没有这个会话」的那几个 id(**进程级**,与 [chatSocket] 同一层)。
|
||||
*
|
||||
* 跨两个 VM 的唯一共享面:写它的是 `SessionsViewModel`(本端唯一的删除入口),读它的是
|
||||
* **每一个还活着的** `ChatViewModel`(它们在 [chatSessionSlots] 没回收之前都活着、都订阅着
|
||||
* 那条共享连接)。它拦下的是那一轮注定 `404 session_not_found` 的补拉 —— 理由与
|
||||
* 「为什么不持久化」见 [GoneSessions] 的类注释。
|
||||
*/
|
||||
val goneSessions: GoneSessions = GoneSessions()
|
||||
|
||||
/**
|
||||
* 「App 在不在前台」「聊天页显示着哪个会话」(**进程级**,与 [chatSocket] / [goneSessions]
|
||||
* 同一层):写它的是 `MainActivity`(`onStart` / `onStop`)与 `ChatScreen`(组合进出),
|
||||
* 读它的是 [proactiveNotifier]。为什么不引 `ProcessLifecycleOwner` 见 [AppPresence] 的类注释。
|
||||
*/
|
||||
val appPresence: AppPresence = AppPresence()
|
||||
|
||||
/** 「点通知进来要看主会话」那笔待落地的意图(写:`MainActivity`,消费:`ChatHomeScreen`)。 */
|
||||
val proactiveTapIntake: ProactiveTapIntake = ProactiveTapIntake()
|
||||
|
||||
/**
|
||||
* 「她主动说的话」的通知通路(W53N):判据、去重与落地都在里面,Android 那一步从这里注入。
|
||||
*
|
||||
* 它**不订阅任何东西**(帧由 [proactiveFrameIntake] 喂进来),因此与连接的所有权完全无关
|
||||
* ——本端那条共享连接的生死仍然只在登录态那一处表达。
|
||||
*/
|
||||
val proactiveNotifier: ProactiveNotifier = ProactiveNotifier(
|
||||
presence = appPresence,
|
||||
post = { text, notificationId ->
|
||||
// 「免打扰」**每次现读**(`AppSettings.current` 是那份热副本):开关一改,下一条通知就
|
||||
// 换一条渠道,不必重启 App。为什么不能在这里挂起读一次、以及那个毫秒级窗口是怎么回事,
|
||||
// 都写在 `AppSettings.current` 的 KDoc 里。
|
||||
postProactiveNotification(
|
||||
context = appContext,
|
||||
text = text,
|
||||
notificationId = notificationId,
|
||||
quiet = appSettings.current.value.proactiveQuiet,
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
/**
|
||||
* 会话槽位账本(**进程级**):本端为一个账号留着的聊天状态,一格一份 `ViewModelStore`。
|
||||
*
|
||||
* 它把 S4 那笔技术债收在一处:`ChatViewModel` 是按会话建的,此前访问过多少个会话就有多少份
|
||||
* 常驻内存(消息列表含内联 base64 图片)与多少份对共享连接的订阅;现在最多留
|
||||
* [top.yeij.cyrene.core.session.CHAT_SESSION_SLOT_KEEP] 格,超出的按「最后一次成为当前会话」
|
||||
* 从旧到新交出去(判据是纯函数 `core.session.chatSessionEvictions`)。
|
||||
*
|
||||
* 与 [goneSessions] 同一层、同一批读者(写它的是 `ChatScreen` 与 `SessionsViewModel`)。
|
||||
* 主会话 id 在这里注入,是为了让 `core.session` 那两层不认识 `ui.chat` 的那个常量。
|
||||
*/
|
||||
val chatSessionSlots: ChatSessionSlots = ChatSessionSlots(SessionSummaryDto.MAIN_SESSION_ID)
|
||||
|
||||
private var cachedBaseUrl: String? = null
|
||||
private var cachedSessionsApi: SessionsApi? = null
|
||||
|
||||
/** 会话/消息 REST 客户端;baseUrl 变化时自动重建。 */
|
||||
val sessionsApi: SessionsApi
|
||||
get() {
|
||||
val baseUrl = authManager.currentBaseUrl
|
||||
val cached = cachedSessionsApi
|
||||
if (cached != null && cachedBaseUrl == baseUrl) return cached
|
||||
val created = ApiClientFactory.create(
|
||||
baseUrl = baseUrl,
|
||||
service = SessionsApi::class.java,
|
||||
tokenProvider = authManager::currentToken,
|
||||
unauthorizedPolicy = authManager.unauthorizedPolicy,
|
||||
)
|
||||
cachedBaseUrl = baseUrl
|
||||
cachedSessionsApi = created
|
||||
return created
|
||||
}
|
||||
|
||||
val sessionRepository: SessionRepository get() = SessionRepository(sessionsApi)
|
||||
|
||||
val messageRepository: MessageRepository get() = MessageRepository(sessionsApi)
|
||||
|
||||
private var cachedChatApi: ChatApi? = null
|
||||
private var cachedChatBaseUrl: String? = null
|
||||
|
||||
/** 发送端点(`POST /api/v1/chat`);与 [sessionsApi] 同源(同一 baseUrl、同一 token/401 处理)。 */
|
||||
val chatApi: ChatApi
|
||||
get() {
|
||||
val baseUrl = authManager.currentBaseUrl
|
||||
val cached = cachedChatApi
|
||||
if (cached != null && cachedChatBaseUrl == baseUrl) return cached
|
||||
val created = ApiClientFactory.create(
|
||||
baseUrl = baseUrl,
|
||||
service = ChatApi::class.java,
|
||||
tokenProvider = authManager::currentToken,
|
||||
unauthorizedPolicy = authManager.unauthorizedPolicy,
|
||||
)
|
||||
cachedChatBaseUrl = baseUrl
|
||||
cachedChatApi = created
|
||||
return created
|
||||
}
|
||||
|
||||
val chatRepository: ChatRepository get() = ChatRepository(chatApi)
|
||||
|
||||
private var cachedUploadApi: UploadApi? = null
|
||||
private var cachedUploadBaseUrl: String? = null
|
||||
|
||||
/**
|
||||
* 图片上传端点(`POST /api/v1/upload`);与 [chatApi] 同源(同一 baseUrl、同一 token/401 处理)——
|
||||
* 上传的产物 URL 就是要交给 `POST /api/v1/chat` 的 `image` 字段的,两者必须在同一个后端上。
|
||||
*/
|
||||
val uploadApi: UploadApi
|
||||
get() {
|
||||
val baseUrl = authManager.currentBaseUrl
|
||||
val cached = cachedUploadApi
|
||||
if (cached != null && cachedUploadBaseUrl == baseUrl) return cached
|
||||
val created = ApiClientFactory.create(
|
||||
baseUrl = baseUrl,
|
||||
service = UploadApi::class.java,
|
||||
tokenProvider = authManager::currentToken,
|
||||
unauthorizedPolicy = authManager.unauthorizedPolicy,
|
||||
)
|
||||
cachedUploadBaseUrl = baseUrl
|
||||
cachedUploadApi = created
|
||||
return created
|
||||
}
|
||||
|
||||
val uploadRepository: UploadRepository get() = UploadRepository(uploadApi)
|
||||
|
||||
private var cachedDocumentsApi: DocumentsApi? = null
|
||||
private var cachedDocumentsBaseUrl: String? = null
|
||||
|
||||
/**
|
||||
* 文档存储端点(`POST /api/v1/documents` 上传、`GET /api/v1/documents/{name}` 取字节);
|
||||
* 与 [chatApi] **必须同源**(同一 baseUrl、同一 token/401 处理)——上传拿到的存储名就是要交给
|
||||
* 同一个后端的 `POST /api/v1/chat` 的 `documents[].name`,两条链路不在同一台机器上就必然 404。
|
||||
*
|
||||
* Bearer 与 401 续期不用在这里做额外事:[ApiClientFactory.create] 按 **OkHttpClient**
|
||||
* 挂 `AuthInterceptor`,文档读侧那点「与图片读侧刻意相反」的鉴权要求因此天然满足。
|
||||
*/
|
||||
val documentsApi: DocumentsApi
|
||||
get() {
|
||||
val baseUrl = authManager.currentBaseUrl
|
||||
val cached = cachedDocumentsApi
|
||||
if (cached != null && cachedDocumentsBaseUrl == baseUrl) return cached
|
||||
val created = ApiClientFactory.create(
|
||||
baseUrl = baseUrl,
|
||||
service = DocumentsApi::class.java,
|
||||
tokenProvider = authManager::currentToken,
|
||||
unauthorizedPolicy = authManager.unauthorizedPolicy,
|
||||
)
|
||||
cachedDocumentsBaseUrl = baseUrl
|
||||
cachedDocumentsApi = created
|
||||
return created
|
||||
}
|
||||
|
||||
val documentRepository: DocumentRepository get() = DocumentRepository(documentsApi)
|
||||
|
||||
private var cachedMediaApi: MediaApi? = null
|
||||
private var cachedMediaBaseUrl: String? = null
|
||||
|
||||
/**
|
||||
* 语音副本端点(`GET /api/v1/media/{name}`,契约 §1.2 **U19**);与 [documentsApi] **同一套门**:
|
||||
* `owner|admin` **必须 Bearer**(与图片读侧「名字即凭据」刻意相反 —— 语音是私密内容)。
|
||||
*
|
||||
* 同样与 [chatApi] 同源(同一 baseUrl、同一 token/401 处理):`voice.url` 是**服务端相对路径**,
|
||||
* 只有落在**同一台后端**上才取得到那份副本。
|
||||
*/
|
||||
val mediaApi: MediaApi
|
||||
get() {
|
||||
val baseUrl = authManager.currentBaseUrl
|
||||
val cached = cachedMediaApi
|
||||
if (cached != null && cachedMediaBaseUrl == baseUrl) return cached
|
||||
val created = ApiClientFactory.create(
|
||||
baseUrl = baseUrl,
|
||||
service = MediaApi::class.java,
|
||||
tokenProvider = authManager::currentToken,
|
||||
unauthorizedPolicy = authManager.unauthorizedPolicy,
|
||||
)
|
||||
cachedMediaBaseUrl = baseUrl
|
||||
cachedMediaApi = created
|
||||
return created
|
||||
}
|
||||
|
||||
val mediaRepository: MediaRepository get() = MediaRepository(mediaApi)
|
||||
|
||||
private var cachedAdminApi: AdminApi? = null
|
||||
private var cachedAdminBaseUrl: String? = null
|
||||
|
||||
/** 管理只读监控客户端;与 [sessionsApi] 同源(同一 baseUrl、同一 token/401 处理)。 */
|
||||
val adminApi: AdminApi
|
||||
get() {
|
||||
val baseUrl = authManager.currentBaseUrl
|
||||
val cached = cachedAdminApi
|
||||
if (cached != null && cachedAdminBaseUrl == baseUrl) return cached
|
||||
val created = ApiClientFactory.create(
|
||||
baseUrl = baseUrl,
|
||||
service = AdminApi::class.java,
|
||||
tokenProvider = authManager::currentToken,
|
||||
unauthorizedPolicy = authManager.unauthorizedPolicy,
|
||||
)
|
||||
cachedAdminBaseUrl = baseUrl
|
||||
cachedAdminApi = created
|
||||
return created
|
||||
}
|
||||
|
||||
val adminRepository: AdminRepository get() = AdminRepository(adminApi)
|
||||
|
||||
private var cachedMetaApi: MetaApi? = null
|
||||
private var cachedMetaBaseUrl: String? = null
|
||||
|
||||
/** 枚举权威表客户端;同样与 [sessionsApi] 同源(同一 baseUrl、同一 token/401 处理)。 */
|
||||
val metaApi: MetaApi
|
||||
get() {
|
||||
val baseUrl = authManager.currentBaseUrl
|
||||
val cached = cachedMetaApi
|
||||
if (cached != null && cachedMetaBaseUrl == baseUrl) return cached
|
||||
val created = ApiClientFactory.create(
|
||||
baseUrl = baseUrl,
|
||||
service = MetaApi::class.java,
|
||||
tokenProvider = authManager::currentToken,
|
||||
unauthorizedPolicy = authManager.unauthorizedPolicy,
|
||||
)
|
||||
cachedMetaBaseUrl = baseUrl
|
||||
cachedMetaApi = created
|
||||
return created
|
||||
}
|
||||
|
||||
val metaRepository: MetaRepository get() = MetaRepository(metaApi)
|
||||
|
||||
private var cachedPersonaApi: PersonaApi? = null
|
||||
private var cachedPersonaBaseUrl: String? = null
|
||||
|
||||
/** 用户侧角色目录(`GET /api/v1/personas`);与 [metaApi] 同源(同一 baseUrl、同一 token/401 处理)。 */
|
||||
val personaApi: PersonaApi
|
||||
get() {
|
||||
val baseUrl = authManager.currentBaseUrl
|
||||
val cached = cachedPersonaApi
|
||||
if (cached != null && cachedPersonaBaseUrl == baseUrl) return cached
|
||||
val created = ApiClientFactory.create(
|
||||
baseUrl = baseUrl,
|
||||
service = PersonaApi::class.java,
|
||||
tokenProvider = authManager::currentToken,
|
||||
unauthorizedPolicy = authManager.unauthorizedPolicy,
|
||||
)
|
||||
cachedPersonaBaseUrl = baseUrl
|
||||
cachedPersonaApi = created
|
||||
return created
|
||||
}
|
||||
|
||||
val personaRepository: PersonaRepository get() = PersonaRepository(personaApi)
|
||||
|
||||
/**
|
||||
* 进程内**唯一**的实时通道连接(`GET /ws/chat`):所有需要收下行帧的 ViewModel 共用这一条。
|
||||
*
|
||||
* ## 为什么可以共享(不是「省点资源」,是**逐帧等价**)
|
||||
* 服务端是**广播**:下行帧发给**每一条**已认证连接、**不按会话订阅**(见 [ChatSocket] 类注释
|
||||
* 「会话归属」)。于是改前「N 条连接,每条都收到全部帧 ⇒ 每个 VM 都收到全部帧 ⇒ 各 VM 靠
|
||||
* [top.yeij.cyrene.data.ws.WsEvent.isForSession] 收敛到自己的会话」与改后「一条连接收到全部帧
|
||||
* ⇒ **每个 VM 仍然收到全部帧**」是**同一个语义**:帧集合逐帧相同,过滤与事件分支一个字都不用改。
|
||||
* 这次变的只是「连接有几条」,不是「帧怎么走」。
|
||||
*
|
||||
* ## 为什么必须唯一
|
||||
* 每个 `ChatSocket` 都自带一个 `OkHttpClient`(各自的连接池 / ping / 重连循环)。改前
|
||||
* `ChatViewModel`(**每个会话一个 VM**)、[top.yeij.cyrene.ui.sessions.SessionsViewModel]、
|
||||
* [top.yeij.cyrene.ui.admin.AdminViewModel] 各自建了一条(`ChatViewModel.start()`、
|
||||
* 两个 VM 的 init)⇒ 访问 N 个会话 = N+2 条连接、N+2 个 `OkHttpClient`。现在这里就是唯一的
|
||||
* 持有者(`by lazy`,只此一份),调用方只订阅 [ChatSocket.events] / [ChatSocket.state]。
|
||||
*
|
||||
* **每个会话那个 VM 常驻的那一半**(S4)由 [chatSessionSlots] 收:本端最多留
|
||||
* [top.yeij.cyrene.core.session.CHAT_SESSION_SLOT_KEEP] 格,多出来的整格交出去。这一条与
|
||||
* 本段说的是**两件事**:连接只有一条,而订阅它的 VM 有几格 —— 前者与本类同一层,后者在账本里。
|
||||
*
|
||||
* ## 开与关只有一个主人
|
||||
* [top.yeij.cyrene.ui.sessions.SessionsViewModel] 的登录态订阅:`LoggedIn → connect()`、
|
||||
* `LoggedOut → close()`(为什么是它,见那个类的 KDoc)。`connect()` 幂等,baseUrl / token 都是
|
||||
* 每次重连现读 ⇒ 登出重登、换账号**不需要**重建本实例。**任何 ViewModel 都不许关它**——
|
||||
* 细节与理由写在 [ChatSocket.close]。
|
||||
*/
|
||||
val chatSocket: ChatSocket by lazy {
|
||||
ChatSocket(
|
||||
baseUrlProvider = { authManager.currentBaseUrl },
|
||||
tokenProvider = authManager::currentToken,
|
||||
onAuthRecover = authManager::recoverSocketAuth,
|
||||
onAuthFailed = authManager::onSessionExpired,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 「她主动说的话 ⇒ 弹通知」那条通路在**帧这一层的消费点**(W57,**进程级**):把 [chatSocket]
|
||||
* 上那两种帧交给 [proactiveNotifier]。构造即订阅,此后与进程同寿(没有 start / stop 的理由
|
||||
* 见 [ProactiveFrameIntake] 的类注释)。
|
||||
*
|
||||
* 改前这份订阅挂在 `SessionsViewModel` 的 `viewModelScope` 上(它随 Activity 一起结束),于是
|
||||
* 「从最近任务划掉 App、进程还被 `KeepAliveService` 撑着」那一档:帧到了 [chatSocket]、
|
||||
* 一个订阅者都没有,而 `events` 是 `replay = 0` 的 `SharedFlow` ⇒ 当场丢掉,通知永远不弹。
|
||||
*
|
||||
* 声明在这里(类的末尾)有一个顺序上的理由:它要同时用到 [proactiveNotifier] 与 [chatSocket],
|
||||
* 而 [chatSocket] 是本类最后声明的那个(Kotlin 按声明顺序初始化)。读 [ChatSocket.events] 会把
|
||||
* 那条连接对象提前建出来——本容器与它同时诞生、同时活着,这一步没有任何代价。
|
||||
*/
|
||||
val proactiveFrameIntake: ProactiveFrameIntake = ProactiveFrameIntake(
|
||||
events = chatSocket.events,
|
||||
notifier = proactiveNotifier,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package top.yeij.cyrene.app
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import kotlinx.coroutines.launch
|
||||
import top.yeij.cyrene.core.auth.SessionState
|
||||
import top.yeij.cyrene.core.keepalive.KeepAliveAction
|
||||
import top.yeij.cyrene.core.keepalive.KeepAliveService
|
||||
import top.yeij.cyrene.core.keepalive.keepAliveAction
|
||||
import top.yeij.cyrene.core.keepalive.notificationsAllowed
|
||||
import top.yeij.cyrene.core.prefs.LocalAppPrefs
|
||||
import top.yeij.cyrene.ui.chat.ChatHomeScreen
|
||||
import top.yeij.cyrene.ui.common.LoadingScreen
|
||||
import top.yeij.cyrene.ui.login.LoginScreen
|
||||
|
||||
@Composable
|
||||
fun CyreneApp(container: AppContainer) {
|
||||
val state by container.authManager.state.collectAsState()
|
||||
val prefs = LocalAppPrefs.current
|
||||
val context = LocalContext.current
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
container.authManager.bootstrap()
|
||||
}
|
||||
|
||||
/*
|
||||
* 后台保持连接:**全 App 唯一一处**决定那条前台服务的起停。
|
||||
*
|
||||
* 这里是合适的位置,因为它是「开关(App 偏好)与登录态碰头」的最小汇合点:偏好由
|
||||
* CyreneRoot 读出来经 LocalAppPrefs 下发,登录态本来就订阅在本函数里。判据本身是纯函数
|
||||
* (core.keepalive.keepAliveAction),两种状态各自变化都会把这一次重跑一遍,于是
|
||||
* 「启动时开关开着且已登录 ⇒ 服务在跑」「登出 ⇒ 停」「开关关掉 ⇒ 停」都不用另外写路径。
|
||||
*
|
||||
* 权限每次**现读**(不缓存在 remember 里):会话自举完成那一刻读到的就是当下的真实授权,
|
||||
* 而授权变化只有两条路——本端刚申请完(设置页那次写入会连带把这里重跑一遍)、或用户在系统
|
||||
* 设置里改过(下次启动必然重跑)。
|
||||
*/
|
||||
// ⚠️ key 里必须有「回到前台」这一位(W53S 登记的那一格):通知权限是**现读**的,而用户在系统
|
||||
// 设置里授权后回到 App 时,只有这个状态会变 —— 少了它,UI 会说「已开」而服务其实没起。
|
||||
val appInForeground by container.appPresence.appInForegroundFlow.collectAsState()
|
||||
LaunchedEffect(prefs.keepAliveEnabled, state, appInForeground) {
|
||||
when (
|
||||
keepAliveAction(
|
||||
enabled = prefs.keepAliveEnabled,
|
||||
sessionKnown = state !is SessionState.Unknown,
|
||||
loggedIn = state is SessionState.LoggedIn,
|
||||
notificationsAllowed = context.notificationsAllowed(),
|
||||
)
|
||||
) {
|
||||
KeepAliveAction.Start -> KeepAliveService.start(context)
|
||||
KeepAliveAction.Stop -> KeepAliveService.stop(context)
|
||||
KeepAliveAction.Leave -> Unit
|
||||
}
|
||||
}
|
||||
|
||||
when (state) {
|
||||
SessionState.Unknown -> LoadingScreen()
|
||||
SessionState.LoggedOut -> LoginScreen(container)
|
||||
is SessionState.LoggedIn -> ChatHomeScreen(
|
||||
container = container,
|
||||
// 登出要先把服务端的 refresh 授权吊销掉,所以是挂起的;UI 不因为它在主线程等待。
|
||||
onLogout = { scope.launch { container.authManager.logout() } },
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package top.yeij.cyrene.app
|
||||
|
||||
import android.app.Application
|
||||
|
||||
class CyreneApplication : Application() {
|
||||
|
||||
lateinit var container: AppContainer
|
||||
private set
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
container = AppContainer(this)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package top.yeij.cyrene.app
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Context
|
||||
import android.content.ContextWrapper
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.SideEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.platform.LocalView
|
||||
import androidx.core.view.WindowCompat
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import androidx.lifecycle.viewmodel.initializer
|
||||
import androidx.lifecycle.viewmodel.viewModelFactory
|
||||
import top.yeij.cyrene.core.prefs.AppPrefs
|
||||
import top.yeij.cyrene.core.prefs.LocalAppPrefs
|
||||
import top.yeij.cyrene.core.prefs.ThemeMode
|
||||
import top.yeij.cyrene.core.theme.CyreneTheme
|
||||
import top.yeij.cyrene.domain.enums.LocalEnumCatalog
|
||||
import top.yukonga.miuix.kmp.theme.ColorSchemeMode
|
||||
|
||||
/**
|
||||
* 应用根:读 App 偏好 → 决定 Miuix 主题模式 → 通过 [LocalAppPrefs] / [LocalEnumCatalog] 下发给渲染层,
|
||||
* 同时把系统栏图标明暗跟着主题走(否则浅色模式下白字白底看不见)。
|
||||
*/
|
||||
@Composable
|
||||
fun CyreneRoot(container: AppContainer) {
|
||||
val prefs by container.appSettings.data.collectAsState(initial = AppPrefs())
|
||||
val enumCatalogViewModel: EnumCatalogViewModel = viewModel(
|
||||
factory = viewModelFactory { initializer { EnumCatalogViewModel(container) } },
|
||||
)
|
||||
val catalog by enumCatalogViewModel.catalog.collectAsState()
|
||||
val darkTheme = when (prefs.themeMode) {
|
||||
ThemeMode.System -> isSystemInDarkTheme()
|
||||
ThemeMode.Light -> false
|
||||
ThemeMode.Dark -> true
|
||||
}
|
||||
|
||||
val view = LocalView.current
|
||||
if (!view.isInEditMode) {
|
||||
SideEffect {
|
||||
val window = view.context.findActivity()?.window ?: return@SideEffect
|
||||
WindowCompat.getInsetsController(window, view).apply {
|
||||
isAppearanceLightStatusBars = !darkTheme
|
||||
isAppearanceLightNavigationBars = !darkTheme
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
CompositionLocalProvider(
|
||||
LocalAppPrefs provides prefs,
|
||||
LocalEnumCatalog provides catalog,
|
||||
) {
|
||||
CyreneTheme(mode = prefs.themeMode.toColorSchemeMode()) {
|
||||
CyreneApp(container)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun ThemeMode.toColorSchemeMode(): ColorSchemeMode = when (this) {
|
||||
ThemeMode.System -> ColorSchemeMode.System
|
||||
ThemeMode.Light -> ColorSchemeMode.Light
|
||||
ThemeMode.Dark -> ColorSchemeMode.Dark
|
||||
}
|
||||
|
||||
private tailrec fun Context.findActivity(): Activity? = when (this) {
|
||||
is Activity -> this
|
||||
is ContextWrapper -> baseContext.findActivity()
|
||||
else -> null
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package top.yeij.cyrene.app
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.launch
|
||||
import top.yeij.cyrene.core.auth.SessionState
|
||||
import top.yeij.cyrene.domain.enums.EnumCatalog
|
||||
|
||||
/**
|
||||
* 枚举权威表(api-client §1.1)的应用级持有者:先用本地快照渲染,再拉一次权威表。
|
||||
*
|
||||
* - 登录 / 静默重登后拉取;**拉取失败保持现状**(快照或原样 id),绝不因此清空角标;
|
||||
* - 快照按 `baseUrl + username` 指纹认领:换服务器/账号即作废;
|
||||
* - 登出时落盘快照与内存标签一并清掉(此时界面只剩登录页,不会闪空角标)。
|
||||
*/
|
||||
class EnumCatalogViewModel(private val container: AppContainer) : ViewModel() {
|
||||
|
||||
private val _catalog = MutableStateFlow(EnumCatalog.Empty)
|
||||
val catalog: StateFlow<EnumCatalog> = _catalog.asStateFlow()
|
||||
|
||||
init {
|
||||
viewModelScope.launch {
|
||||
container.authManager.state.collect { state ->
|
||||
if (state == SessionState.LoggedOut) {
|
||||
container.enumPreferences.clear()
|
||||
_catalog.value = EnumCatalog.Empty
|
||||
return@collect
|
||||
}
|
||||
val fingerprint = fingerprint()
|
||||
// 先用快照渲染(没有就原样显示 id),拉取不阻塞首帧
|
||||
val snapshot = container.enumPreferences.data.first()
|
||||
_catalog.value = EnumCatalog.of(snapshot.tableFor(fingerprint))
|
||||
if (state is SessionState.LoggedIn) refresh(fingerprint)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun refresh(fingerprint: String) {
|
||||
viewModelScope.launch {
|
||||
container.metaRepository.enums().onSuccess { table ->
|
||||
container.enumPreferences.save(table, fingerprint)
|
||||
_catalog.value = EnumCatalog.of(table)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun fingerprint(): String =
|
||||
"${container.authManager.currentBaseUrl}|${container.sessionStore.load().username.orEmpty()}"
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package top.yeij.cyrene.app
|
||||
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
|
||||
class MainActivity : ComponentActivity() {
|
||||
|
||||
private val container: AppContainer get() = (application as CyreneApplication).container
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
enableEdgeToEdge()
|
||||
super.onCreate(savedInstanceState)
|
||||
// 冷启动的两步:先清掉上一进程遗留的分享缓存(它已经没有主了),再接这次带来的分享。
|
||||
// 顺序不能反——反过来会把刚拷进来的图一起删掉。
|
||||
ShareIntake.reset(this)
|
||||
// 只在**真正的冷启动**接 intent:Activity 因旋转 / 深色切换而重建时,系统会把原来那个
|
||||
// intent 再送一遍,照单全收就会把同一条分享发第二次。重建带 savedInstanceState,
|
||||
// 冷启动不带——这就是判据。
|
||||
if (savedInstanceState == null) {
|
||||
intent?.let {
|
||||
ShareIntake.accept(this, it)
|
||||
// 点她那条通知冷启动进来:记一笔「要看主会话」(见 ProactiveTapIntake)。
|
||||
// 与分享同一个判据:重建时系统会把同一个 intent 再送一遍,那时不能重复记账
|
||||
// (重复没有害处,但「冷启动才接」这条纪律只有一处,别在两处各写一半)。
|
||||
container.proactiveTapIntake.accept(it)
|
||||
}
|
||||
}
|
||||
setContent {
|
||||
CyreneRoot(container)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 本 App 只有**一个** Activity(`singleTop`),所以它的 start / stop 就是「App 有没有在被看」
|
||||
* ——这是「她主动说的话该不该弹通知」的两半之一(见 `core.notify.shouldNotifyProactive`)。
|
||||
*
|
||||
* 报的是**实例**而不是一个布尔量:旋转 / 系统深浅色切换会让新实例的 `onStart` 先于旧实例的
|
||||
* `onStop`,布尔量在那一刻必然被记成「后台」,于是用户正看着聊天页她却弹一条通知出来。
|
||||
* 集合由 [AppPresence] 自己持有,这里只报自己。
|
||||
*/
|
||||
override fun onStart() {
|
||||
super.onStart()
|
||||
container.appPresence.onActivityStarted(this)
|
||||
}
|
||||
|
||||
override fun onStop() {
|
||||
container.appPresence.onActivityStopped(this)
|
||||
super.onStop()
|
||||
}
|
||||
|
||||
/**
|
||||
* 热启动(`launchMode="singleTop"`):App 已经开着时系统不再新建实例,只回调这里。
|
||||
* 不接这一路的话,「从浏览器分享 → 回到已经开着的 Cyrene」会一点反应都没有,而
|
||||
* 「点她的通知 → 回到已经开着的 Cyrene(可能停在设置页)」同理。
|
||||
*/
|
||||
override fun onNewIntent(intent: Intent) {
|
||||
super.onNewIntent(intent)
|
||||
// 让 getIntent() 与新到的 intent 一致(系统不会自己更新它),否则后续读到的还是旧的那个。
|
||||
setIntent(intent)
|
||||
ShareIntake.accept(this, intent)
|
||||
container.proactiveTapIntake.accept(intent)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
package top.yeij.cyrene.app
|
||||
|
||||
import android.util.Log
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.flow.SharedFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import top.yeij.cyrene.BuildConfig
|
||||
import top.yeij.cyrene.core.notify.ProactiveNotifier
|
||||
import top.yeij.cyrene.data.ws.WsEvent
|
||||
|
||||
private const val LOG_TAG = "ProactiveFrameIntake"
|
||||
|
||||
/** 一帧「她主动说的话」剥出来的东西:[ProactiveNotifier.onProactiveFrame] 的四个入参 + 会话归属。 */
|
||||
internal data class ProactiveFrame(
|
||||
val sessionId: String?,
|
||||
val id: String?,
|
||||
val messageId: String?,
|
||||
val timestamp: String?,
|
||||
val content: String,
|
||||
)
|
||||
|
||||
/**
|
||||
* 这一帧算不算「她主动说的话」,以及那四个入参各取帧上的哪个键——**纯函数**(离线可穷举)。
|
||||
*
|
||||
* 只有两种帧是她主动说的话,它们对应服务端两条路(见 [ProactiveNotifier]):
|
||||
* 1. [WsEvent.ProactiveMessage](实时帧 `proactive_message`)——`id` 取落库行键 `data.id`,`messageId`
|
||||
* 取 IM 侧消息号,两者**不是同一个 id**(见那个事件的 KDoc);
|
||||
* 2. [WsEvent.MessagePersisted] **且** `proactive == true`(IM 桥那条路补发的落库帧)——这一档只有
|
||||
* 落库行的键(`message.id`),另外两个键帧上就没有,所以恒给 `null`,一个字都不许编。
|
||||
*
|
||||
* 其余帧一律 `null`:普通落库广播、流式增量、别的事件都不是「她主动找我说话」。判据错在这一层的
|
||||
* 两个方向都很难在真机上发现——要么她开口了却什么都不弹(要等下一次主动消息才看得见),要么普通
|
||||
* 消息也弹(每一次她正常回话都响)。
|
||||
*/
|
||||
internal fun proactiveFrameOf(event: WsEvent): ProactiveFrame? = when (event) {
|
||||
is WsEvent.ProactiveMessage -> ProactiveFrame(
|
||||
sessionId = event.sessionId,
|
||||
id = event.id,
|
||||
messageId = event.messageId,
|
||||
timestamp = event.timestamp,
|
||||
content = event.content,
|
||||
)
|
||||
|
||||
is WsEvent.MessagePersisted -> {
|
||||
if (!event.proactive) {
|
||||
// 普通落库广播(她回话、别的端发的消息都走这一帧):主人只批了 proactive 这一档。
|
||||
null
|
||||
} else {
|
||||
ProactiveFrame(
|
||||
sessionId = event.sessionId,
|
||||
id = event.message?.id,
|
||||
messageId = null,
|
||||
timestamp = null,
|
||||
content = event.message?.content.orEmpty(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
else -> null
|
||||
}
|
||||
|
||||
/**
|
||||
* 「她主动说的话 ⇒ 弹系统通知」那条通路的**帧消费点**(W57,**进程级**)。
|
||||
*
|
||||
* ## 为什么必须提到这一层(改前那一档是坏的)
|
||||
* 这条通路的判据与去重一直是对的,错的是**订阅挂在哪**。改前它是 `SessionsViewModel` 里那句
|
||||
* `viewModelScope.launch { socket.events.collect(::onEvent) }`。连接倒是进程级的——划掉 Activity
|
||||
* 之后它照样连着、照样在收帧——但 `viewModelScope` 随 Activity 一起结束,而 `ChatSocket.events`
|
||||
* 是 `replay = 0` 的 `SharedFlow`:**没有订阅者的 `tryEmit` 就是当场丢掉**(它不替将来的订阅者
|
||||
* 留任何东西)。于是「从最近任务把 App 划掉、进程还被前台服务撑着」那一档,她的话进了连接、
|
||||
* 然后没了。
|
||||
*
|
||||
* 本类因此只做一件事:**在进程级订那份帧流**,把 [proactiveFrameOf] 认出的帧交给 [ProactiveNotifier]。
|
||||
* 它不建连接、不关连接、不认识登录态——连接的所有权一个字节都没动,仍然只在登录态那一处表达
|
||||
* (`SessionsViewModel` 的 `LoggedIn → connect()` / `LoggedOut → close()`)。
|
||||
*
|
||||
* ## 为什么没有 start() / stop()
|
||||
* 它的生命周期**就是进程**:`AppContainer` 在 `Application.onCreate` 里构造它,此后一直活着。
|
||||
* 刻意不提供 `stop()` 正是这次要修的那个 bug——**唯一会想到去调 stop 的调用方(某个 ViewModel)
|
||||
* 会随 Activity 一起死**,那等于把这条通路又绑回 Activity 上;留一个可以被忘掉调用的 `start()`
|
||||
* 也只是多一种坏法(忘调 = 通知整个哑掉,且没有任何报错)。
|
||||
*
|
||||
* 它不需要停:帧只有连接活着才可能到,而连接在登出(含令牌失效那条经 `onAuthFailed` 的路径)时
|
||||
* 由登录态关掉。「登出期间收不到帧」这件事由**连接**保证,不靠这里取消订阅。去重账本也**刻意不**
|
||||
* 在这里清:它是 `ProactiveNotifier` 里的进程级账本,而换了账号之后同一条消息再推一次,仍是
|
||||
* 同一个人的同一句话,不该弹第二遍。
|
||||
*
|
||||
* ## 它现在是这条通路上唯一的帧消费点
|
||||
* 改前 `SessionsViewModel` 里那两处转交已经删掉(留两处就等于靠去重去替一次重复消费擦屁股,
|
||||
* 而且「谁在喂通知」会有两个答案)。所以这条订阅一旦死掉,通知就**整个**哑掉——兜底见 [deliver]。
|
||||
*
|
||||
* @param events 进程内唯一那条连接的帧流(`ChatSocket.events`)。
|
||||
* @param notifier 判据 + 去重 + 落地那一处。本类只喂帧,不替它判断任何事(那两个入参是它自己的
|
||||
* 事实与动作,见它的 KDoc)。
|
||||
* @param dispatcher 收集用的调度器;单测传 `Dispatchers.Unconfined`,让帧在同一次 `emit` 里走完。
|
||||
*/
|
||||
class ProactiveFrameIntake(
|
||||
events: SharedFlow<WsEvent>,
|
||||
private val notifier: ProactiveNotifier,
|
||||
dispatcher: CoroutineDispatcher = Dispatchers.Default,
|
||||
) {
|
||||
|
||||
/**
|
||||
* 订阅本身那个 scope:私有,没有第二个人拿得到它,也就没有人能取消这条订阅——本类的全部意义
|
||||
* 就在这里。`SupervisorJob` 只为 [deliver] 那条兜底留余地。
|
||||
*/
|
||||
private val scope = CoroutineScope(SupervisorJob() + dispatcher)
|
||||
|
||||
/** 构造即订阅(理由见类注释:启动时刻只有一个,留个能忘调的 `start()` 只会多一种坏法)。 */
|
||||
init {
|
||||
scope.launch { events.collect { event -> proactiveFrameOf(event)?.let(::deliver) } }
|
||||
}
|
||||
|
||||
private fun deliver(frame: ProactiveFrame) {
|
||||
try {
|
||||
notifier.onProactiveFrame(
|
||||
sessionId = frame.sessionId,
|
||||
id = frame.id,
|
||||
messageId = frame.messageId,
|
||||
timestamp = frame.timestamp,
|
||||
content = frame.content,
|
||||
)
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Throwable) {
|
||||
// 落地那一步(建渠道 / notify)万一抛,**这条订阅必须活下来**:它是进程级那唯一一处、
|
||||
// 不会再被重建,而不接住的话异常会打到线程的未捕获处理器(Android 上就是进程当场挂)。
|
||||
// 代价封顶在「这一条通知没弹」——去重账本已经把它记下了,不会为了补它再弹一次。
|
||||
if (BuildConfig.DEBUG) Log.d(LOG_TAG, "这条主动消息的通知没能落地", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package top.yeij.cyrene.app
|
||||
|
||||
import android.content.Intent
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import top.yeij.cyrene.core.notify.EXTRA_PROACTIVE_OPEN_MAIN_SESSION
|
||||
|
||||
/**
|
||||
* 「点她那条通知进来 ⇒ 要看主会话」这条**待落地的意图**(W53N)。
|
||||
*
|
||||
* ## 为什么它必须活在进程里,而不是直接在那次 Intent 里做完
|
||||
* 通知可能在**任何**时刻被点:App 还没起来(冷启动)、已经开着但停在设置页、甚至还没登录。
|
||||
* 而真正能「切到主会话」的只有一处——`ChatHomeScreen` 里那个 `SessionsViewModel`(`select()`),
|
||||
* 它要被组合出来才存在,登录态没定时**根本没有**。所以这里只记一笔「用户要求看主会话」,
|
||||
* 由 `ChatHomeScreen` 在能落地的那一刻取走(与 `ShareIntake` 同一种做法:待落地的意图活在
|
||||
* Compose 与 ViewModel 之外,消费方自己挑时机认领)。
|
||||
*
|
||||
* ## 为什么是 StateFlow 而不是一个布尔
|
||||
* 消费方(`ChatHomeScreen`)在 App 已经开着的时候**已经在组合里**:一个普通布尔量被改成 true
|
||||
* 不会叫醒它,那一次点击就会静默丢掉(用户点了通知,进来还停在设置页)。`StateFlow` 一改值就能
|
||||
* 让订着它的那段组合重跑判据。
|
||||
*
|
||||
* ## 不清理会怎样
|
||||
* 没人消费时它一直是 true(例如点了通知但登录失败)——代价是「重登之后落在主会话」。
|
||||
* 主会话本来就是默认落点(`resolveActiveSession` 在会话没了时也收敛到它),所以这里**刻意不**
|
||||
* 在登出时清掉:留一个不会做错事的意图,好过为了干净而多一条「谁负责清它」的路径。
|
||||
*/
|
||||
class ProactiveTapIntake {
|
||||
|
||||
private val _openMainSession = MutableStateFlow(false)
|
||||
|
||||
/** 是否有一笔待落地的「切到主会话」。消费方拿到 true 后必须调 [consume]。 */
|
||||
val openMainSession: StateFlow<Boolean> = _openMainSession.asStateFlow()
|
||||
|
||||
/**
|
||||
* 从一次启动 / 热启动的 Intent 里认领这条意图(`MainActivity` 的两条入口都走这里)。
|
||||
*
|
||||
* 判据是这个 extra 的**字面量 true**:别的 Intent(分享、桌面图标)里它缺席,什么都不做。
|
||||
*/
|
||||
fun accept(intent: Intent?) {
|
||||
if (intent?.getBooleanExtra(EXTRA_PROACTIVE_OPEN_MAIN_SESSION, false) == true) {
|
||||
requestMainSession()
|
||||
}
|
||||
}
|
||||
|
||||
/** 记下「用户要求看主会话」(与 `accept` 分开,是为了这一半能脱离 Android 单测)。 */
|
||||
fun requestMainSession() {
|
||||
_openMainSession.value = true
|
||||
}
|
||||
|
||||
/** 取走这笔意图(只有真正落地了「切到主会话 + 回到聊天页」的那一次才调)。 */
|
||||
fun consume() {
|
||||
_openMainSession.value = false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,436 @@
|
||||
package top.yeij.cyrene.app
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.provider.OpenableColumns
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import top.yeij.cyrene.domain.chat.DocumentAttachments
|
||||
import top.yeij.cyrene.domain.chat.DocumentDelivery
|
||||
import top.yeij.cyrene.domain.chat.DraftDocument
|
||||
import top.yeij.cyrene.domain.share.PendingShareHolder
|
||||
import top.yeij.cyrene.domain.share.ShareRules
|
||||
import top.yeij.cyrene.domain.share.SharedContent
|
||||
import top.yeij.cyrene.domain.share.SharedDocument
|
||||
import java.io.File
|
||||
import java.util.UUID
|
||||
|
||||
/**
|
||||
* 「分享到 App」的接收入口(`ACTION_SEND` / `ACTION_SEND_MULTIPLE`)。
|
||||
*
|
||||
* ## 它为什么是一个进程级单例
|
||||
* 分享进来时可能还没登录、ChatScreen 甚至还没被组合出来,而登录之后消费它的又是**另一个**
|
||||
* Compose 实例。内容因此必须待在一个不属于任何 UI / ViewModel 的地方——见
|
||||
* [PendingShareHolder]。不塞进 `AppContainer` 是因为那个文件同时被别的并行改动占用,
|
||||
* 这里没有任何依赖需要它:本对象只需要一个 `Context`(读 URI、写 cache)。
|
||||
*
|
||||
* ## 图片为什么在收到的那一刻就落盘
|
||||
* `content://` 的读授权只给到**接收它的那个 Activity / 任务**,登录(可能十几秒、还可能切页)
|
||||
* 之后再去读,拿到的多半是 `SecurityException` / 打不开。所以字节在这里立刻拷进 `cacheDir`,
|
||||
* 之后一律用本端自己的 `file://` 路径——**外部 Uri 只在这个方法里出现一次**。文件(非图片)同理。
|
||||
*
|
||||
* ## 分流:图片还是文件
|
||||
* 逐条 URI 看 `ContentResolver.getType`(判定见 [top.yeij.cyrene.domain.share.classifySharedStream]):
|
||||
* MIME 以 `image/` 开头走既有图片路,**其余一律走文档路**。此前一律当图片拷、当图片解码,
|
||||
* 一份 `.md` 因此只有两种结局——「图片处理失败」或者被当成一张读不出来的图。
|
||||
*
|
||||
* ⚠️ **Manifest 刻意不声明全通配或文本通配 MIME**:后端**明令拒绝** `text/html` / SVG / XML /
|
||||
* 压缩包 / 脚本,放宽声明会造出一堆「点进去必然失败」的入口。所以这里能接到的**文件**只有
|
||||
* 系统按 `text/plain` 投过来那些(`.txt` / `.md` / `.log` 这类文件管理器确实会报 `text/plain`);
|
||||
* 报成 `text/markdown` 之类的分享**根本不会**出现在 Cyrene 的分享菜单里。
|
||||
*
|
||||
* ⚠️ **provider 不报 MIME(`getType` 回 null)时按文档路走**,这是刻意的:本端不猜类型,
|
||||
* 猜错等于替用户把一个合规文件变成一次失败;代价是「图片但 provider 没报 MIME」会落到文档路、
|
||||
* 被服务端按 `415` 拒(有如实文案)。画廊 / 相机 / MediaStore 都会报图片那一类,风险面很小。
|
||||
*
|
||||
* ## 四个 extra 为什么都读「原始值」
|
||||
* `EXTRA_TEXT` / `EXTRA_SUBJECT` / `EXTRA_TITLE` / `EXTRA_STREAM` 一律用 [Intent.rawExtra] 取原始值,
|
||||
* 再由本文件按**形态**分派([shareStreamExtrasOf] / [shareTextExtrasOf])。两个理由:逐类型的
|
||||
* `getXxxExtra` 遇到别的形态只会往 logcat 打一句 `ClassCastException` 再把值扔掉(那正是「分享点下去
|
||||
* 什么都没发生」的来处),而本端还要知道「这个 extra 里**到底有没有躺着值**」——那件事纯函数看不到,
|
||||
* 只能由这里如实报进去(两个 `present` 参数)。
|
||||
*
|
||||
* ⚠️ **正文的回落链起点也在这里**:`EXTRA_TEXT` 读不出东西时,`EXTRA_SUBJECT` → `EXTRA_TITLE` 依次
|
||||
* 顶上([ShareRules.extract]),来源记进 `SharedContent.textSource`。三个都空 / 都读不出来时,这一次
|
||||
* 分享照样要往下走(走「读不进来」那条如实告知),**绝不静默**。
|
||||
*
|
||||
* ## 清理
|
||||
* 缓存副本只对**产生它的那个进程**有意义(持有者在内存里,进程没了就没人认领),所以
|
||||
* 进程启动时整目录删一次([reset])。运行期间不做细粒度回收:那些文件在发送失败被回滚到
|
||||
* 输入框之后**仍然要用**(用户可能过一会儿才点发送),提前删掉就会让预览变空图。
|
||||
* 目录因此以「一次进程生命周期内的分享份数」为界(图片 4 + 文件 5,另加 [stageTextAsDocument]
|
||||
* 为超长正文落下的那一份 `.txt`),另有系统的 cache 回收兜底。
|
||||
*/
|
||||
object ShareIntake {
|
||||
|
||||
private const val DIR_NAME = "share-intake"
|
||||
|
||||
private val holder = PendingShareHolder<SharedContent>()
|
||||
|
||||
/** 待落地的分享内容;消费侧(ChatScreen)订阅它。 */
|
||||
val pending: StateFlow<List<SharedContent>> = holder.pending
|
||||
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
|
||||
/** 进程启动时调用一次:把上一进程遗留的分享缓存清干净。 */
|
||||
fun reset(context: Context) {
|
||||
if (cleanedThisProcess) return
|
||||
cleanedThisProcess = true
|
||||
runCatching { shareDir(context).deleteRecursively() }
|
||||
}
|
||||
|
||||
/**
|
||||
* 「本进程已经清过一次」的标记。
|
||||
*
|
||||
* Activity 会因为旋转 / 分屏 / 深色切换而**重建**,重建就会再走一次 `onCreate`——
|
||||
* 那时清缓存会把用户刚分享进来、还摆在输入框里的那张图删掉(预览变空图、发送也没有字节)。
|
||||
* 缓存副本只对「产生它的进程」有意义,所以判据是**进程**而不是 Activity 实例。
|
||||
*/
|
||||
private var cleanedThisProcess = false
|
||||
|
||||
/**
|
||||
* 接收一个分享 Intent(冷启动在 `onCreate`、热启动在 `onNewIntent`)。
|
||||
*
|
||||
* 提取是纯函数([ShareRules.extract]),拷字节是重活,所以在 IO 线程做:`content://` 的读
|
||||
* 授权绑的是 Activity 而不是某一次调用栈,异步读一样有效。
|
||||
*/
|
||||
fun accept(context: Context, intent: Intent) {
|
||||
val appContext = context.applicationContext
|
||||
// 四个 extra 一律读**原始值**、按**形态**分派(见 [shareStreamExtrasOf] / [shareTextExtrasOf]):
|
||||
// 挨个 getXxxExtra 去问的话,遇到别的形态只会拿到一行 `ClassCastException` 加一个 `null`
|
||||
// ——那正是「分享进来什么都没发生」的来处。读不出形态时本端既不猜、也不闭嘴(见下面两个
|
||||
// `present`):那照样是一次「带着内容迹象却什么都没读进来」。
|
||||
val rawStream = intent.rawExtra(Intent.EXTRA_STREAM)
|
||||
val streamExtras = shareStreamExtrasOf(rawStream)
|
||||
val textExtras = shareTextExtrasOf(
|
||||
text = intent.rawExtra(Intent.EXTRA_TEXT),
|
||||
subject = intent.rawExtra(Intent.EXTRA_SUBJECT),
|
||||
title = intent.rawExtra(Intent.EXTRA_TITLE),
|
||||
)
|
||||
val extracted = ShareRules.extract(
|
||||
action = intent.action,
|
||||
// 正文走回落链:EXTRA_TEXT → EXTRA_SUBJECT → EXTRA_TITLE(有些发送方不填 EXTRA_TEXT,
|
||||
// 内容只在后两个里;只读第一个会让那次分享整条静默)。来源记在 `textSource` 里。
|
||||
text = textExtras.text,
|
||||
subject = textExtras.subject,
|
||||
title = textExtras.title,
|
||||
// 单值 / 列表两个字段都喂进去(本端不再按 action 猜该读哪个,见 [ShareRules.extract])。
|
||||
stream = streamExtras.uriForms.firstOrNull(),
|
||||
streams = streamExtras.uriForms,
|
||||
streamStrings = streamExtras.stringForms,
|
||||
// 「里面躺着值」这件事只有这里知道(纯函数看不到原始值):形态认不出的那种也算带着内容,
|
||||
// 否则「分享点下去什么都没发生」会从那条缝里回来。
|
||||
streamPresent = rawStream != null,
|
||||
textPresent = textExtras.present,
|
||||
clipUris = intent.clipUris(),
|
||||
// 分流就发生在这一处:读不到类型(null)也按文档路走,见类 KDoc。
|
||||
mimeOf = { uri ->
|
||||
runCatching { context.contentResolver.getType(Uri.parse(uri)) }.getOrNull()
|
||||
},
|
||||
)
|
||||
// 没什么值得往下走的就到此为止(判据是 [SharedContent.worthDelivering]:有可落地的内容,
|
||||
// 或者有丢件 / 读不进来要对用户说清的事)。整条分享里什么都没有时静默是对的;而
|
||||
// 「带着内容迹象却一条都没读进来」必须继续往下走——那条告知在下游 `shareNoticeFor` 里等它。
|
||||
if (!extracted.worthDelivering) return
|
||||
|
||||
scope.launch {
|
||||
val dir = shareDir(appContext).apply { mkdirs() }
|
||||
val copied = extracted.images.mapIndexedNotNull { index, uri ->
|
||||
copyToCache(appContext, dir, uri, index)
|
||||
}
|
||||
val documents = extracted.documents.mapIndexedNotNull { index, document ->
|
||||
copyDocumentToCache(appContext, dir, document, index)
|
||||
}
|
||||
holder.put(
|
||||
extracted.copy(
|
||||
images = copied,
|
||||
documents = documents,
|
||||
// 拷不动的那些如实计入「没发送」:宁可界面上说一句,也不要静默少一张。
|
||||
failedImages = extracted.failedImages + (extracted.images.size - copied.size),
|
||||
failedDocuments = extracted.failedDocuments + (extracted.documents.size - documents.size),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 取走一条待落地内容(原子出队,只有取到它的那个调用方会去发);队列为空回 `null`。
|
||||
*
|
||||
* 出队是**唯一的认领方式**,而它在这里是原子的:两个消费者同时来也只会各拿各的一条
|
||||
* (判据见 [PendingShareHolder.take])。而「**已经认领过了**」这件事此后记在认领它的那一格上
|
||||
* ——`ChatViewModel` 的 [top.yeij.cyrene.ui.chat.ChatUiState.shareConfirm]。那一格活在
|
||||
* `AppContainer` 那一层(`core.session.ChatSessionSlots` 的 `ViewModelStore`),比组合与
|
||||
* Activity 都活得久,所以转屏 / 深浅色切换重建之后**不会再取第二遍**,也不会把队列里下一条顶掉:
|
||||
* 认领的闸门在 `ChatViewModel.claimSharedContent` 里,且在调用本函数**之前**。
|
||||
*/
|
||||
fun take(): SharedContent? = holder.take()
|
||||
|
||||
private fun shareDir(context: Context): File = File(context.cacheDir, DIR_NAME)
|
||||
|
||||
/**
|
||||
* 把一段**分享进来的超长正文**落成 cache 里的 `.txt`,返回可当附件用的待发文档;写不进去回 `null`。
|
||||
*
|
||||
* ## 为什么走本对象
|
||||
* 落成附件的那份字节必须有个**本端的本地地址**才能进既有发送链(芯片要显示它、上传要按 URI
|
||||
* 现读字节、发送失败回滚后用户还可能要点开看):`DraftDocument` 只带引用不带字节。而写进
|
||||
* 「分享缓存」这个目录、跟着同一条清理规则(进程启动整目录删一次)是最省事也最一致的做法——
|
||||
* 与外部分享进来的文件**同一处落地、同一条生命周期**,不另造一个没人回收的目录。
|
||||
* 本函数只负责「落盘 + 报一个可用的引用」,**不判断**要不要转(那是
|
||||
* [DocumentDelivery.planSharedText] 的事)。
|
||||
*
|
||||
* 文件名用 [ShareRules.documentCacheFileName] 生成(**只由本端 token 与序号拼路径**,
|
||||
* 外部字符串一个字符都进不了文件名),但对**服务端**报的名字是
|
||||
* [DocumentDelivery.TEXT_DOCUMENT_FILE_NAME] ——服务端只取扩展名(白名单里的 `.txt`),
|
||||
* 其余部分会被内容寻址丢弃。
|
||||
*
|
||||
* @return 待发文档(含 `file://` 地址、显示名与**真实字节数**);任何一步失败都回 `null`,
|
||||
* 由调用方按「内容一字不丢」的原则退回内联正文(见 `ChatViewModel.sendShared`)。
|
||||
*/
|
||||
suspend fun stageTextAsDocument(context: Context, text: String): DraftDocument? = withContext(Dispatchers.IO) {
|
||||
val staged = runCatching { DocumentDelivery.textAsFile(text) }.getOrNull() ?: return@withContext null
|
||||
val dir = shareDir(context.applicationContext).apply { mkdirs() }
|
||||
val target = File(
|
||||
dir,
|
||||
ShareRules.documentCacheFileName(UUID.randomUUID().toString(), 0, staged.fileName),
|
||||
)
|
||||
try {
|
||||
target.writeBytes(staged.bytes)
|
||||
val size = target.length()
|
||||
if (size > 0L) {
|
||||
DraftDocument(
|
||||
uri = Uri.fromFile(target).toString(),
|
||||
displayName = staged.fileName,
|
||||
sizeBytes = size,
|
||||
)
|
||||
} else {
|
||||
target.delete()
|
||||
null
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
// 半截文件不能留下:它会被当成一份真附件去读,报出的错会指向错的地方。
|
||||
target.delete()
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 把一份非图片分享文件的字节拷进 cache,返回**本端自己的** `file://` 地址 + 显示名 + 真实字节数;
|
||||
* 失败回 `null`。
|
||||
*
|
||||
* 显示名必须先取(拷完之后外部 URI 就只在这个调用栈里了),取不到就回落 URI 最后一段——
|
||||
* 它是**上传部件的名字**,服务端只从中取扩展名(判定白名单),空名字则由仓库层兜
|
||||
* `document.txt`。
|
||||
*/
|
||||
private fun copyDocumentToCache(
|
||||
context: Context,
|
||||
dir: File,
|
||||
document: SharedDocument,
|
||||
index: Int,
|
||||
): SharedDocument? {
|
||||
val parsed = runCatching { Uri.parse(document.uri) }.getOrNull() ?: return null
|
||||
if (parsed.scheme.isNullOrBlank()) return null
|
||||
|
||||
val name = displayNameOf(context, parsed)
|
||||
val target = File(dir, ShareRules.documentCacheFileName(UUID.randomUUID().toString(), index, name))
|
||||
return try {
|
||||
val source = context.contentResolver.openInputStream(parsed) ?: return null
|
||||
source.use { input -> target.outputStream().use { output -> input.copyTo(output) } }
|
||||
val size = target.length()
|
||||
if (size > 0L) {
|
||||
document.copy(uri = Uri.fromFile(target).toString(), displayName = name, sizeBytes = size)
|
||||
} else {
|
||||
target.delete()
|
||||
null
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
// 半截文件不能留下:下一次发送会把它当一份真文件去读,报出的错会指向错的地方。
|
||||
target.delete()
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
/** provider 报的显示名优先;拿不到就回落到 URI 最后一段(都不给就留空,不猜扩展名)。 */
|
||||
private fun displayNameOf(context: Context, uri: Uri): String {
|
||||
val fromProvider = runCatching {
|
||||
context.contentResolver.query(uri, null, null, null, null)?.use { cursor ->
|
||||
if (!cursor.moveToFirst()) return@use null
|
||||
val index = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME)
|
||||
if (index < 0 || cursor.isNull(index)) null else cursor.getString(index)
|
||||
}
|
||||
}.getOrNull()
|
||||
return fromProvider?.takeIf { it.isNotBlank() }
|
||||
?: DocumentAttachments.fallbackDocumentName(uri.toString())
|
||||
}
|
||||
|
||||
/**
|
||||
* 把一个外部 URI 的字节拷进 cache,返回本端自己的 `file://` URI;失败回 `null`。
|
||||
*
|
||||
* 必须是 `file://` 而不是裸路径:`ContentResolver.openInputStream` 认 `file` scheme,
|
||||
* 但裸路径没有 scheme,会走到「找 content provider」那条路并抛 `FileNotFoundException`。
|
||||
*/
|
||||
private fun copyToCache(context: Context, dir: File, uri: String, index: Int): String? {
|
||||
val parsed = runCatching { Uri.parse(uri) }.getOrNull() ?: return null
|
||||
if (parsed.scheme.isNullOrBlank()) return null
|
||||
|
||||
val target = File(
|
||||
dir,
|
||||
ShareRules.cacheFileName(
|
||||
token = UUID.randomUUID().toString(),
|
||||
index = index,
|
||||
mime = runCatching { context.contentResolver.getType(parsed) }.getOrNull(),
|
||||
),
|
||||
)
|
||||
return try {
|
||||
val source = context.contentResolver.openInputStream(parsed) ?: return null
|
||||
source.use { input -> target.outputStream().use { output -> input.copyTo(output) } }
|
||||
if (target.length() > 0L) {
|
||||
Uri.fromFile(target).toString()
|
||||
} else {
|
||||
target.delete()
|
||||
null
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
// 半截文件不能留下:下一次发送会把它当一张真图去解码,报出的错会指向错的地方。
|
||||
target.delete()
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 一个 extra 的**原始值**(四个 extra 共用),取不到回 `null`。
|
||||
*
|
||||
* ## 为什么读原始值而不是 `getXxxExtra`
|
||||
* `Bundle.get` 在 API 33 起被打上废弃:它绕过类型安全。这里要的正是「绕过类型安全」——要判的
|
||||
* 就是「这个值到底是什么形态」。逐类型的 `getXxxExtra` 遇到别的形态只会往 logcat 打一句
|
||||
* `ClassCastException` 再把值扔掉(`String[]` 形态的 `EXTRA_STREAM` 就是这么静默丢掉的),而那
|
||||
* 正是「分享点下去什么都没发生」的来处。形态分派见 [shareStreamExtrasOf] / [shareTextExtrasOf]。
|
||||
*
|
||||
* ## 为什么要 `runCatching`
|
||||
* `Bundle.get` 第一件事是 `unparcel()`,它会解析别的进程给的字节:里面有个解析不出来的
|
||||
* `Parcelable` 时它抛 `BadParcelableException`(核对过 AOSP 的 `android/os/BaseBundle.java`:
|
||||
* defuse 关着时**直接抛出**,开着时也还要看 `Flags.deprecateBundleDefuse()` 那个 flag)。那是
|
||||
* `RuntimeException`,从 `accept` 冒出去就是 `onCreate` 里的一次崩溃。
|
||||
*
|
||||
* ⚠️ 与它相对的是「类型不符」那种:`Bundle.getCharSequence` 自己 `catch` 了 `ClassCastException`
|
||||
* 并回 `null`(同一个 AOSP 文件),所以那一档**不会**抛异常——但也**读不到值**,于是它只能靠
|
||||
* 本函数给出的原始值被记成「里面躺着值」(见 [ShareTextExtras.present]),不会被静默当成
|
||||
* 「什么都没分享」。两件事一起挡住:畸形 bundle 不当崩,读不出来的值不当没发生。
|
||||
*/
|
||||
@Suppress("DEPRECATION")
|
||||
private fun Intent.rawExtra(key: String): Any? = runCatching { extras?.get(key) }.getOrNull()
|
||||
|
||||
/**
|
||||
* `ClipData` 各条目的 URI(部分 App 只填它);纯文本条目的 URI 是 `null`。
|
||||
*
|
||||
* 这里刻意**保留**没有 URI 的条目(用 `null` 占位)而不是就地滤掉:条目本身就是「这次分享带着
|
||||
* 内容」的证据——只有纯文本条目时本端一个字节都读不到(本端不读 `ClipData` 里的文本),那种
|
||||
* 情形必须能说出口,见 [SharedContent.contentUnreadable]。
|
||||
*/
|
||||
private fun Intent.clipUris(): List<String?> {
|
||||
val clip = clipData ?: return emptyList()
|
||||
return (0 until clip.itemCount).map { clip.getItemAt(it).uri?.toString() }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* `EXTRA_STREAM` 一条**原始值** ⇒ 里面能读出来的地址,按形态分成两组。
|
||||
*
|
||||
* ## 为什么读原始值而不是挨个 `getXxxExtra` 去问
|
||||
* `EXTRA_STREAM` 在真实世界里有三种形态:规范推荐的 `Uri`(多选时 `ArrayList<Uri>`)、`String[]`、
|
||||
* `ArrayList<String>`。后两种(历史遗留 / 跨平台壳应用常见)此前**完全认不出**:`getParcelableExtra`
|
||||
* 对 `String[]` 只会往 logcat 打一句
|
||||
* `ClassCastException: Cannot cast [Ljava.lang.String; to android.net.Uri` 然后回 `null`,
|
||||
* `getParcelableArrayListExtra` 同理——于是整次分享「什么都没发生」,连一句告知都没有。读原始值再按
|
||||
* 类型分派是一次判完三种形态,顺带也不再往 logcat 里丢那两行噪声(`Bundle.get` 不做类型检查,
|
||||
* 正是这里需要的;`getXxxExtra` 那一套是查完再 cast,查不中就把值扔了)。
|
||||
*
|
||||
* ## 形态决定信任级别
|
||||
* - [uriForms]:`Uri`,以及数组 / 列表里的 `Uri`。对方是把 URI **对象**写进去的,这是规范路径;
|
||||
* 本端维持既有口径不动(不看 scheme)。
|
||||
* - [stringForms]:`String` / `CharSequence` 及其数组 / 列表。这是本端新开的一条读入口,所以开得窄
|
||||
* ——能不能用由 [ShareRules.stringStreamUri] 逐条判(只认 `content://`)。
|
||||
*
|
||||
* 认不出形态的原始值(别的 `Parcelable` / 数字 / 映射……)**不猜**:两边都不收。
|
||||
*/
|
||||
internal data class ShareStreamExtras(
|
||||
val uriForms: List<String>,
|
||||
val stringForms: List<String>,
|
||||
)
|
||||
|
||||
/** 见 [ShareStreamExtras]:把 `EXTRA_STREAM` 的原始值按形态摊平成两组地址(纯逻辑,可单测)。 */
|
||||
internal fun shareStreamExtrasOf(raw: Any?): ShareStreamExtras {
|
||||
val uriForms = mutableListOf<String>()
|
||||
val stringForms = mutableListOf<String>()
|
||||
|
||||
fun collect(value: Any?) {
|
||||
when (value) {
|
||||
null -> Unit
|
||||
is Uri -> uriForms += value.toString()
|
||||
// 含 String。`Spanned` 之类也走这一条:它照样要过 scheme 白名单,不是「因为它是
|
||||
// CharSequence 就信」。
|
||||
is CharSequence -> stringForms += value.toString()
|
||||
is Array<*> -> value.forEach { collect(it) }
|
||||
is Iterable<*> -> value.forEach { collect(it) }
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
collect(raw)
|
||||
|
||||
return ShareStreamExtras(uriForms = uriForms, stringForms = stringForms)
|
||||
}
|
||||
|
||||
/**
|
||||
* `EXTRA_TEXT` / `EXTRA_SUBJECT` / `EXTRA_TITLE` 三个**原始值** ⇒ 能当正文用的那几段。
|
||||
*
|
||||
* ## 为什么也读原始值(而不是 `getCharSequenceExtra` 挨个问)
|
||||
* 两个理由,第一个是「读得回来」,第二个是「读不回来也要知道有东西」:
|
||||
*
|
||||
* 1. `getCharSequenceExtra` 只回「读到 / 没读到」,而本端要按**形态**分派:值可能是 `String`、
|
||||
* `Spanned`(富文本分享的常态)、`SpannedString`……它们都是 `CharSequence`,但按 `String` 读就
|
||||
* 直接是 `null`(那正是「分享进来什么都没发生」的来处之一)。这里用 `as?` 一次判完:是
|
||||
* `CharSequence` 就收下,不是就当没有——**不猜、也不抛**。
|
||||
* 2. 「三个里**有没有躺着值**」这件事纯函数看不到(它只收到几个 `CharSequence?`),而
|
||||
* 「有值、只是本端一段都读不出来」与「用户什么都没分享」是**两件事**:前者必须能说出口,
|
||||
* 否则那三种 extra 一起落空就是一次静默。所以 [present] 由这里如实报进去
|
||||
* (见 [ShareRules.extract] 的 `textPresent`)。
|
||||
*
|
||||
* ## 它抛不抛异常(核对过 AOSP 源码,`sources/android-36.1/android/os/BaseBundle.java`)
|
||||
* - **类型不符不抛**:`Bundle.getCharSequence` 自己 `catch` 了 `ClassCastException`(`unparcel()` 之后
|
||||
* 直接 `(CharSequence) o`,失败就 `typeWarning` 记一句 log 再回 `null`)⇒ 所以
|
||||
* `intent.getCharSequenceExtra(...)` 不会因为「值不是 CharSequence」把 App 打崩,它只是**读不到**。
|
||||
* 那一档在这里落成 [present] 为真、[text] 为 `null`:既不崩、也不再静默。
|
||||
* - **畸形 bundle 会抛**:它内部的 `unparcel()` 在别的进程给的 bundle 解析不出来时抛
|
||||
* `BadParcelableException`(`RuntimeException`)。那一路由 [Intent.rawExtra] 的 `runCatching` 挡住
|
||||
* ——读不到就当没有,**绝不让异常从 `accept` 冒出去**(那会是 `onCreate` 里的一次崩溃)。
|
||||
*/
|
||||
internal data class ShareTextExtras(
|
||||
/** `EXTRA_TEXT` 读出来的那一段(读不回来是 `null`;空白**原样留着**,由纯函数判它算不算正文)。 */
|
||||
val text: String?,
|
||||
/** `EXTRA_SUBJECT` 读出来的那一段。 */
|
||||
val subject: String?,
|
||||
/** `EXTRA_TITLE` 读出来的那一段。 */
|
||||
val title: String?,
|
||||
/**
|
||||
* 三个 extra 里**至少一个躺着原始值**——含**读不出类型**的那一种,也含**只是一段空白**的那一种。
|
||||
*
|
||||
* 空白也算,是刻意的:它说的是「对方**给了**这个字段」,与「这个字段里有没有可用的正文」是两件
|
||||
* 事。而 `EXTRA_STREAM` 那边早就是这个口径(原始值只要非 null 就算 `streamPresent`),两个 extra
|
||||
* 用同一条判据,才不会出现「流的空值会说话、文本的空值却闭嘴」这种要靠运气才发现的差别。
|
||||
*/
|
||||
val present: Boolean,
|
||||
)
|
||||
|
||||
/** 见 [ShareTextExtras]:把三个文本 extra 的原始值读成能当正文用的那几段(纯逻辑,可单测)。 */
|
||||
internal fun shareTextExtrasOf(text: Any?, subject: Any?, title: Any?): ShareTextExtras = ShareTextExtras(
|
||||
text = (text as? CharSequence)?.toString(),
|
||||
subject = (subject as? CharSequence)?.toString(),
|
||||
title = (title as? CharSequence)?.toString(),
|
||||
// 读不出类型(`42`、别种 `Parcelable`……)也算「里面躺着值」:本端不猜它是什么意思,但不该因此
|
||||
// 把整次分享当成「什么都没发生」。`null`(连值都没有)才是真的没有。
|
||||
present = text != null || subject != null || title != null,
|
||||
)
|
||||
@@ -0,0 +1,254 @@
|
||||
package top.yeij.cyrene.core.auth
|
||||
|
||||
import top.yeij.cyrene.core.error.CyreneError
|
||||
import top.yeij.cyrene.data.api.ApiClientFactory
|
||||
import top.yeij.cyrene.data.api.ApiErrorEnvelope
|
||||
import top.yeij.cyrene.data.api.CyreneApi
|
||||
import top.yeij.cyrene.data.api.dto.LoginRequest
|
||||
import top.yeij.cyrene.data.api.dto.LogoutRequest
|
||||
import top.yeij.cyrene.data.api.dto.RefreshRequest
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
import java.io.IOException
|
||||
import java.net.ConnectException
|
||||
import java.net.SocketTimeoutException
|
||||
import java.net.UnknownHostException
|
||||
import javax.net.ssl.SSLException
|
||||
|
||||
sealed interface SessionState {
|
||||
data object Unknown : SessionState
|
||||
data object LoggedOut : SessionState
|
||||
data class LoggedIn(val baseUrl: String, val role: String) : SessionState
|
||||
}
|
||||
|
||||
/**
|
||||
* 会话生命周期:登录、静默恢复、续期、登出。
|
||||
*
|
||||
* B2 起密码**不再落盘**:长期凭据是 refresh token,访问令牌过期时用它续期,
|
||||
* 续期由 [TokenRefresher] 单飞协调(并发 401 只会真的刷一次——服务端轮换无宽限期)。
|
||||
*
|
||||
* @param apiFactory 认证接口的构造方式。默认走真实 Retrofit;单测注入假实现即可覆盖
|
||||
* 登录 / 迁移 / 登出路径,既不需要网络也不需要新增依赖。
|
||||
*/
|
||||
class AuthManager(
|
||||
private val store: SessionStorage,
|
||||
private val apiFactory: (String) -> CyreneApi = { ApiClientFactory.create(baseUrl = it) },
|
||||
) {
|
||||
|
||||
private val _state = MutableStateFlow<SessionState>(SessionState.Unknown)
|
||||
val state: StateFlow<SessionState> = _state.asStateFlow()
|
||||
|
||||
@Volatile
|
||||
var currentBaseUrl: String = ""
|
||||
private set
|
||||
|
||||
private val refresher = TokenRefresher(store, ::refreshCall)
|
||||
|
||||
/** 不带令牌的认证客户端(登录 / 刷新 / 登出),按 baseUrl 缓存。可被 OkHttp 线程读到。 */
|
||||
@Volatile
|
||||
private var authApi: CyreneApi? = null
|
||||
|
||||
@Volatile
|
||||
private var authApiBaseUrl: String? = null
|
||||
|
||||
/**
|
||||
* 401 处置:能续期就用新令牌重试一次,明确被拒才判终态。
|
||||
* 暂时性失败(网络 / 5xx)**绝不清会话**——见 B2 §6。
|
||||
*/
|
||||
val unauthorizedPolicy: UnauthorizedPolicy = object : UnauthorizedPolicy {
|
||||
override fun renew(usedToken: String): AuthRecovery = refresher.renew(usedToken)
|
||||
|
||||
override fun onSessionEnded() = onSessionExpired()
|
||||
}
|
||||
|
||||
/** tokenProvider 与 WS 握手用:临近过期时阻塞地先换令牌。 */
|
||||
fun currentToken(): String? = refresher.ensureToken()
|
||||
|
||||
/**
|
||||
* WS 握手 401 的一次性恢复:换一次令牌让上层重连。
|
||||
* 与 401 拦截器共用同一条单飞路径,因此 REST 与 WS 同时失效也只会打一次刷新。
|
||||
*/
|
||||
fun recoverSocketAuth(): AuthRecovery = refresher.renew(store.load().token)
|
||||
|
||||
/**
|
||||
* 启动时恢复会话(D12 + B2 迁移)。顺序即优先级:
|
||||
*
|
||||
* 1. **迁移**:没有 refresh token 却还留着旧版的明文密码 → 用旧凭据静默登录一次换 refresh token,
|
||||
* 成功后立刻把它抹掉;失败(例如离线)保留原值、**不登出**,下次启动再试。
|
||||
* 2. 已经有 refresh token → 任何残留的明文密码立刻抹掉。
|
||||
* 3. 访问令牌过期 → 用 refresh token 续期。密码没了以后,这是唯一的静默恢复途径;
|
||||
* 没有了它,隔一天再打开就要重新输密码(比旧版还差)。
|
||||
* 4. 仍然拿不到有效令牌 → 回登录页(username 仍预填)。
|
||||
*/
|
||||
suspend fun bootstrap() = withContext(Dispatchers.IO) {
|
||||
var session = store.load()
|
||||
currentBaseUrl = session.baseUrl
|
||||
|
||||
val legacyUser = session.username?.takeIf(String::isNotBlank)
|
||||
val legacyPassword = session.password?.takeIf(String::isNotBlank)
|
||||
if (session.refreshToken.isNullOrBlank() && legacyUser != null && legacyPassword != null) {
|
||||
doLogin(session.baseUrl, legacyUser, legacyPassword)
|
||||
session = store.load()
|
||||
} else if (!session.password.isNullOrBlank()) {
|
||||
store.purgePassword()
|
||||
session = store.load()
|
||||
}
|
||||
|
||||
if (!session.hasValidToken() && !session.refreshToken.isNullOrBlank()) {
|
||||
refresher.renew(session.token)
|
||||
session = store.load()
|
||||
}
|
||||
|
||||
_state.value = if (session.hasValidToken()) {
|
||||
SessionState.LoggedIn(session.baseUrl, session.role.orEmpty())
|
||||
} else {
|
||||
SessionState.LoggedOut
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun login(rawBaseUrl: String, username: String, password: String): CyreneError? =
|
||||
withContext(Dispatchers.IO) {
|
||||
doLogin(ApiClientFactory.normalizeBaseUrl(rawBaseUrl), username, password)
|
||||
}
|
||||
|
||||
/**
|
||||
* 主动登出:先尽力吊销服务端的 refresh 授权(否则本地「登出」之后那条授权还能活 30 天),
|
||||
* 再抹掉本地一切。吊销失败、超时、离线都不阻断本地清理,所以离线登出依然干净。
|
||||
*/
|
||||
suspend fun logout() = withContext(Dispatchers.IO) {
|
||||
val refreshToken = store.load().refreshToken?.takeIf(String::isNotBlank)
|
||||
if (refreshToken != null) {
|
||||
// 吊销请求不能拖住登出:给一个短上限,超时就当离线处理。
|
||||
withTimeoutOrNull(REVOKE_TIMEOUT_MS) {
|
||||
try {
|
||||
authApi(sessionBaseUrl()).logout(LogoutRequest(refreshToken))
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (_: Exception) {
|
||||
// 尽力而为:离线 / 服务端报错也必须把本地清干净。
|
||||
}
|
||||
}
|
||||
}
|
||||
store.clearAll()
|
||||
authApi = null
|
||||
authApiBaseUrl = null
|
||||
_state.value = SessionState.LoggedOut
|
||||
}
|
||||
|
||||
/** 会话终结(refresh 不可能或明确被拒):清令牌回登录页;username 保留以便预填。 */
|
||||
fun onSessionExpired() {
|
||||
store.clearSession()
|
||||
_state.value = SessionState.LoggedOut
|
||||
}
|
||||
|
||||
private suspend fun doLogin(url: String, username: String, password: String): CyreneError? {
|
||||
return try {
|
||||
val response = authApi(url).login(LoginRequest(username, password))
|
||||
val data = response.body()?.data
|
||||
when {
|
||||
response.isSuccessful && data != null -> {
|
||||
store.saveLogin(url, data.token, data.expiresAt, data.role, username, data.refreshToken)
|
||||
// B2 红线:不再写明文密码。拿到长期凭据后,把历史遗留的那一份也一并抹掉。
|
||||
if (!data.refreshToken.isNullOrBlank()) store.purgePassword()
|
||||
currentBaseUrl = url
|
||||
_state.value = SessionState.LoggedIn(url, data.role)
|
||||
null
|
||||
}
|
||||
|
||||
response.isSuccessful -> CyreneError.Local(CyreneError.LocalKind.Parse)
|
||||
|
||||
else -> {
|
||||
val parsed = parseError(response.errorBody()?.string())
|
||||
if (response.code() == HTTP_UNAUTHORIZED) {
|
||||
CyreneError.Unauthorized(parsed?.code.orEmpty(), parsed?.message.orEmpty())
|
||||
} else {
|
||||
CyreneError.Server(
|
||||
code = parsed?.code.orEmpty(),
|
||||
message = parsed?.message.orEmpty(),
|
||||
httpStatus = response.code(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
mapException(e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* [TokenRefresher] 注入的阻塞刷新调用。
|
||||
*
|
||||
* 它跑在 OkHttp 的调用线程上(拦截器 / tokenProvider),所以这里用 `runBlocking` 就地驱动
|
||||
* Retrofit 的挂起接口——那正是此刻需要的语义:同步等结果。
|
||||
*
|
||||
* 客户端**不带令牌**,镜像 [doLogin]:既不会让过期令牌搭车,也不会把刷新的 401 送回
|
||||
* 本类的过期处理路径(那两个客户端用的是 [NoUnauthorizedPolicy])。
|
||||
*/
|
||||
private fun refreshCall(refreshToken: String): RefreshCall {
|
||||
val baseUrl = sessionBaseUrl()
|
||||
if (baseUrl.isBlank()) return RefreshCall.Failed
|
||||
return try {
|
||||
val response = runBlocking { authApi(baseUrl).refresh(RefreshRequest(refreshToken)) }
|
||||
val data = response.body()?.data
|
||||
when {
|
||||
response.isSuccessful && data != null ->
|
||||
RefreshCall.Ok(data.token, data.expiresAt, data.refreshToken)
|
||||
|
||||
// 200 但解不动:不拿会话去赌,当暂时性失败。
|
||||
response.isSuccessful -> RefreshCall.Failed
|
||||
|
||||
// 该接口唯一的 401 就是 invalid_grant(未知 / 已吊销 / 已过期 / 已被用掉)。
|
||||
response.code() == HTTP_UNAUTHORIZED -> RefreshCall.Rejected
|
||||
|
||||
else -> RefreshCall.Failed
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
RefreshCall.Failed
|
||||
}
|
||||
}
|
||||
|
||||
private fun authApi(baseUrl: String): CyreneApi {
|
||||
val cached = authApi
|
||||
if (cached != null && authApiBaseUrl == baseUrl) return cached
|
||||
val created = apiFactory(baseUrl)
|
||||
authApi = created
|
||||
authApiBaseUrl = baseUrl
|
||||
return created
|
||||
}
|
||||
|
||||
private fun sessionBaseUrl(): String = currentBaseUrl.ifBlank { store.load().baseUrl }
|
||||
|
||||
private fun StoredSession.hasValidToken(): Boolean =
|
||||
!token.isNullOrBlank() && expiresAt * 1000 > System.currentTimeMillis()
|
||||
|
||||
private fun parseError(body: String?) = body
|
||||
?.takeIf { it.isNotBlank() }
|
||||
?.let {
|
||||
runCatching {
|
||||
ApiClientFactory.json.decodeFromString(ApiErrorEnvelope.serializer(), it).error
|
||||
}.getOrNull()
|
||||
}
|
||||
|
||||
private fun mapException(e: Throwable): CyreneError = when (e) {
|
||||
is UnknownHostException -> CyreneError.Local(CyreneError.LocalKind.Dns)
|
||||
is SSLException -> CyreneError.Local(CyreneError.LocalKind.Tls)
|
||||
is SocketTimeoutException -> CyreneError.Local(CyreneError.LocalKind.Timeout)
|
||||
is ConnectException -> CyreneError.Local(CyreneError.LocalKind.ConnectFailed)
|
||||
is java.net.UnknownServiceException -> CyreneError.Local(CyreneError.LocalKind.CleartextBlocked)
|
||||
is IOException -> CyreneError.Local(CyreneError.LocalKind.NoNetwork)
|
||||
else -> CyreneError.Local(CyreneError.LocalKind.Unknown)
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val HTTP_UNAUTHORIZED = 401
|
||||
|
||||
/** 吊销授权的最长等待:到点就当离线,本地照样清干净。 */
|
||||
const val REVOKE_TIMEOUT_MS = 3_000L
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
package top.yeij.cyrene.core.auth
|
||||
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import androidx.security.crypto.EncryptedSharedPreferences
|
||||
import androidx.security.crypto.MasterKey
|
||||
|
||||
data class StoredSession(
|
||||
val baseUrl: String,
|
||||
val token: String?,
|
||||
val expiresAt: Long,
|
||||
val role: String?,
|
||||
val username: String?,
|
||||
/** 长期凭据(B2);B2 之前登录的安装没有这一项,因此可能为 null。 */
|
||||
val refreshToken: String?,
|
||||
/**
|
||||
* **遗留字段**:B2 之前的版本把登录密码落在这里。
|
||||
* 只读用于一次性迁移,绝不再写入;见 [SessionStorage.purgePassword]。
|
||||
*/
|
||||
val password: String?,
|
||||
)
|
||||
|
||||
/**
|
||||
* 会话存储契约。[SessionStore] 是唯一的生产实现(EncryptedSharedPreferences);
|
||||
* 单元测试用内存假实现顶替,避免把 Android 的加密存储拖进 JVM 测试。
|
||||
*/
|
||||
interface SessionStorage {
|
||||
|
||||
fun load(): StoredSession
|
||||
|
||||
/** 登录成功:整份会话落盘。**不写密码**(B2 红线)。 */
|
||||
fun saveLogin(
|
||||
baseUrl: String,
|
||||
token: String,
|
||||
expiresAt: Long,
|
||||
role: String,
|
||||
username: String,
|
||||
refreshToken: String?,
|
||||
)
|
||||
|
||||
/**
|
||||
* 只替换令牌三元组,保留 baseUrl / username / role。
|
||||
* [refreshToken] 为 null 表示沿用原值——`AUTH_REFRESH_ROTATE_DISABLED=true` 时服务端不下发新的,
|
||||
* 此时旧凭据仍然有效,绝不能丢掉。
|
||||
*/
|
||||
fun updateTokens(token: String, expiresAt: Long, refreshToken: String?)
|
||||
|
||||
/**
|
||||
* 确定性抹掉历史遗留的明文密码。
|
||||
* 用 `commit()` 而不是 `apply()`:这是红线清除,必须在下一次读取之前就落到磁盘上。
|
||||
*/
|
||||
fun purgePassword()
|
||||
|
||||
/** 会话失效:清 token 与 refresh token,保留 username 以便登录页预填。 */
|
||||
fun clearSession()
|
||||
|
||||
/** 主动登出:整份清空。 */
|
||||
fun clearAll()
|
||||
}
|
||||
|
||||
/** JWT 与登录凭据的加密存储(红线:不落明文密码、不打日志)。 */
|
||||
class SessionStore(context: Context) : SessionStorage {
|
||||
|
||||
private val prefs: SharedPreferences = EncryptedSharedPreferences.create(
|
||||
context,
|
||||
FILE_NAME,
|
||||
MasterKey.Builder(context).setKeyScheme(MasterKey.KeyScheme.AES256_GCM).build(),
|
||||
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
|
||||
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
|
||||
)
|
||||
|
||||
override fun load(): StoredSession = StoredSession(
|
||||
baseUrl = prefs.getString(KEY_BASE_URL, "").orEmpty(),
|
||||
token = prefs.getString(KEY_TOKEN, null),
|
||||
expiresAt = prefs.getLong(KEY_EXPIRES_AT, 0L),
|
||||
role = prefs.getString(KEY_ROLE, null),
|
||||
username = prefs.getString(KEY_USERNAME, null),
|
||||
refreshToken = prefs.getString(KEY_REFRESH_TOKEN, null),
|
||||
// 只读:迁移路径靠它判断「这是升级前的安装」,不再写入。
|
||||
password = prefs.getString(KEY_PASSWORD, null),
|
||||
)
|
||||
|
||||
override fun saveLogin(
|
||||
baseUrl: String,
|
||||
token: String,
|
||||
expiresAt: Long,
|
||||
role: String,
|
||||
username: String,
|
||||
refreshToken: String?,
|
||||
) {
|
||||
val editor = prefs.edit()
|
||||
.putString(KEY_BASE_URL, baseUrl)
|
||||
.putString(KEY_TOKEN, token)
|
||||
.putLong(KEY_EXPIRES_AT, expiresAt)
|
||||
.putString(KEY_ROLE, role)
|
||||
.putString(KEY_USERNAME, username)
|
||||
if (!refreshToken.isNullOrBlank()) editor.putString(KEY_REFRESH_TOKEN, refreshToken)
|
||||
editor.apply()
|
||||
}
|
||||
|
||||
override fun updateTokens(token: String, expiresAt: Long, refreshToken: String?) {
|
||||
val editor = prefs.edit()
|
||||
.putString(KEY_TOKEN, token)
|
||||
.putLong(KEY_EXPIRES_AT, expiresAt)
|
||||
// 轮换关闭时服务端不下发新凭据,旧的那一份继续有效,必须保留。
|
||||
if (!refreshToken.isNullOrBlank()) editor.putString(KEY_REFRESH_TOKEN, refreshToken)
|
||||
editor.apply()
|
||||
}
|
||||
|
||||
override fun purgePassword() {
|
||||
prefs.edit().remove(KEY_PASSWORD).commit()
|
||||
}
|
||||
|
||||
override fun clearSession() {
|
||||
prefs.edit()
|
||||
.remove(KEY_TOKEN)
|
||||
.remove(KEY_EXPIRES_AT)
|
||||
.remove(KEY_ROLE)
|
||||
.remove(KEY_REFRESH_TOKEN)
|
||||
.apply()
|
||||
}
|
||||
|
||||
override fun clearAll() {
|
||||
prefs.edit().clear().apply()
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val FILE_NAME = "cyrene_session"
|
||||
const val KEY_BASE_URL = "base_url"
|
||||
const val KEY_TOKEN = "token"
|
||||
const val KEY_EXPIRES_AT = "expires_at"
|
||||
const val KEY_ROLE = "role"
|
||||
const val KEY_USERNAME = "username"
|
||||
const val KEY_REFRESH_TOKEN = "refresh_token"
|
||||
const val KEY_PASSWORD = "password"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
package top.yeij.cyrene.core.auth
|
||||
|
||||
/** 一次令牌续期的结果。 */
|
||||
sealed interface AuthRecovery {
|
||||
|
||||
/** 已拿到可用的访问令牌:可能是刚签发的,也可能是别的线程刚换好的。 */
|
||||
data class Renewed(val token: String) : AuthRecovery
|
||||
|
||||
/**
|
||||
* 无法续期:本地没有 refresh token,或被服务端以 `invalid_grant` 拒绝
|
||||
* (未知 / 已吊销 / 已过期 / 已被用掉,服务端把这四种情况折叠成一个码)。
|
||||
* 这是终态——必须重新登录。
|
||||
*/
|
||||
data object Unrecoverable : AuthRecovery
|
||||
|
||||
/** 暂时性失败:网络不通、5xx、响应解不动。会话可能仍然有效,稍后再试。 */
|
||||
data object Transient : AuthRecovery
|
||||
}
|
||||
|
||||
/**
|
||||
* [TokenRefresher] 注入的刷新调用结果。
|
||||
* 把 HTTP 细节挡在协调逻辑之外,单飞与代际守卫因此可以纯 JVM 单测。
|
||||
*/
|
||||
sealed interface RefreshCall {
|
||||
|
||||
/** 200:新的访问令牌;[refreshToken] 为 null 表示服务端关闭了轮换、旧凭据继续有效。 */
|
||||
data class Ok(val token: String, val expiresAt: Long, val refreshToken: String?) : RefreshCall
|
||||
|
||||
/** `401 invalid_grant`。 */
|
||||
data object Rejected : RefreshCall
|
||||
|
||||
/** 网络 / 5xx / 解析失败。 */
|
||||
data object Failed : RefreshCall
|
||||
}
|
||||
|
||||
/**
|
||||
* 单飞(single-flight)刷新协调器。
|
||||
*
|
||||
* ## 为什么必须单飞
|
||||
* 服务端 `RefreshStore.Rotate` 是**原子且无宽限期**的:同一个 refresh token 被第二次使用会立刻
|
||||
* 拿到 `invalid_grant`(防重放)。而本端并发 401 是常态——管理概览页两个请求并发、聊天页 REST 与
|
||||
* WS 同时起步。所以同一时刻只允许一个线程真正发刷新请求,其余线程在锁上等待后复用结果。
|
||||
*
|
||||
* ## 代际守卫
|
||||
* 拿到锁之后**重新读一次存储**:如果已存的访问令牌既不是自己手里那份([renew] 的 `staleToken`)、
|
||||
* 又还没到过期点,说明等在锁上的这段时间里别的线程已经换好了令牌——直接复用,绝不刷第二次。
|
||||
*
|
||||
* ## 线程模型
|
||||
* 全部逻辑跑在**调用方线程**上(OkHttp 的调用线程 / WS 重连协程),用 JVM 监视器锁串行化。
|
||||
* 因此这里没有、也不能用协程 `Mutex`——调用点根本没有 suspend 上下文。
|
||||
* 真实的刷新请求由调用方用**不带令牌**的客户端发出(见 `AuthManager.refreshCall`),
|
||||
* 不会递归回本类。
|
||||
*
|
||||
* @param store 会话读写;实现必须让写入立刻对下一次 [SessionStorage.load] 可见。
|
||||
* @param call 真正发刷新请求的阻塞调用。
|
||||
* @param now 当前毫秒时间戳,可注入以便单测。
|
||||
*/
|
||||
class TokenRefresher(
|
||||
private val store: SessionStorage,
|
||||
private val call: (refreshToken: String) -> RefreshCall,
|
||||
private val now: () -> Long = System::currentTimeMillis,
|
||||
) {
|
||||
|
||||
private val lock = Any()
|
||||
|
||||
/**
|
||||
* 已经确定无效的那个 refresh token。`invalid_grant` 对该值而言是**永久**结论,
|
||||
* 记下来可以让同一次失效里的其它并发 401 直接得到终态,不必各打一次注定失败的请求。
|
||||
* 值本身随机且每次轮换都变,因此不需要额外失效逻辑。
|
||||
*/
|
||||
private var rejectedRefreshToken: String? = null
|
||||
|
||||
/** 距过期不足 [SKEW_MS] 就算「该提前换令牌了」。 */
|
||||
fun isNearExpiry(session: StoredSession): Boolean =
|
||||
session.expiresAt * 1000 - now() <= SKEW_MS
|
||||
|
||||
/**
|
||||
* 阻塞地给出一个当下可用的访问令牌;临近过期时先走一次单飞刷新。
|
||||
* 供 OkHttp 的 tokenProvider 与 WS 握手使用(每次重连都会重新读)。
|
||||
*
|
||||
* 刷新失败时**仍然返回手里那份令牌**——它可能还没真的过期,401 交给兜底路径处理;
|
||||
* 在这里返回 null 会让整条请求链断掉,比让服务端来裁决更糟。
|
||||
*/
|
||||
fun ensureToken(): String? {
|
||||
val session = store.load()
|
||||
val token = session.token?.takeIf(String::isNotBlank) ?: return null
|
||||
if (!isNearExpiry(session)) return token
|
||||
return when (val recovery = renew(token)) {
|
||||
is AuthRecovery.Renewed -> recovery.token
|
||||
AuthRecovery.Unrecoverable, AuthRecovery.Transient -> token
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 单飞刷新:同一时刻只有一个线程真正发请求,其余线程在锁上等待并复用结果。
|
||||
*
|
||||
* @param staleToken 触发续期的那次请求实际携带的令牌;null 表示只知道「手上没有可用的令牌」。
|
||||
*/
|
||||
fun renew(staleToken: String?): AuthRecovery = synchronized(lock) {
|
||||
val session = store.load()
|
||||
|
||||
// 代际守卫:等在锁上的这段时间里,别的线程已经换好了令牌 → 复用,绝不用同一个 refresh token 打第二次。
|
||||
val current = session.token
|
||||
if (!current.isNullOrBlank() && current != staleToken && !isNearExpiry(session)) {
|
||||
return@synchronized AuthRecovery.Renewed(current)
|
||||
}
|
||||
|
||||
val refreshToken = session.refreshToken?.takeIf(String::isNotBlank)
|
||||
?: return@synchronized AuthRecovery.Unrecoverable
|
||||
if (refreshToken == rejectedRefreshToken) return@synchronized AuthRecovery.Unrecoverable
|
||||
|
||||
when (val result = call(refreshToken)) {
|
||||
is RefreshCall.Ok -> {
|
||||
rejectedRefreshToken = null
|
||||
store.updateTokens(result.token, result.expiresAt, result.refreshToken)
|
||||
AuthRecovery.Renewed(result.token)
|
||||
}
|
||||
|
||||
RefreshCall.Rejected -> {
|
||||
rejectedRefreshToken = refreshToken
|
||||
AuthRecovery.Unrecoverable
|
||||
}
|
||||
|
||||
RefreshCall.Failed -> AuthRecovery.Transient
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
/** 提前量:24h 的访问令牌留 60s 余量,避免踩着过期边界发请求。 */
|
||||
const val SKEW_MS = 60_000L
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package top.yeij.cyrene.core.auth
|
||||
|
||||
/**
|
||||
* 「带了令牌仍然 401」的处置策略。
|
||||
*
|
||||
* 由 `ApiClientFactory` 的拦截器在 **OkHttp 的调用线程上同步调用**,所以实现必须是
|
||||
* 阻塞且线程安全的——那里没有 suspend 上下文,协程 `Mutex` 帮不上忙。
|
||||
*/
|
||||
interface UnauthorizedPolicy {
|
||||
|
||||
/**
|
||||
* 尝试把被服务端拒绝的令牌换成可用的新令牌。
|
||||
*
|
||||
* @param usedToken 触发这次 401 的请求实际携带的访问令牌。
|
||||
* @return [AuthRecovery.Renewed] 时调用方会用新令牌重试一次;另外两种都不重试。
|
||||
*/
|
||||
fun renew(usedToken: String): AuthRecovery
|
||||
|
||||
/** 终态:续期不可能(没有 refresh token)或明确被拒(`invalid_grant`)——必须重新登录。 */
|
||||
fun onSessionEnded()
|
||||
}
|
||||
|
||||
/** 不带令牌的客户端(登录 / 刷新 / 登出)用:永不续期,也永不判终态。 */
|
||||
object NoUnauthorizedPolicy : UnauthorizedPolicy {
|
||||
|
||||
override fun renew(usedToken: String): AuthRecovery = AuthRecovery.Unrecoverable
|
||||
|
||||
override fun onSessionEnded() = Unit
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package top.yeij.cyrene.core.error
|
||||
|
||||
/**
|
||||
* 统一错误模型:区分「服务端有响应」与「本地/网络层失败」。
|
||||
* 约定(S1.1):已建立连接时用服务端 `error.message`,本地错误由客户端负责文案。
|
||||
*/
|
||||
sealed interface CyreneError {
|
||||
val userMessage: String
|
||||
|
||||
data class Server(
|
||||
val code: String,
|
||||
val message: String,
|
||||
val httpStatus: Int,
|
||||
) : CyreneError {
|
||||
override val userMessage: String
|
||||
get() = message.ifBlank { "服务端错误(HTTP $httpStatus)" }
|
||||
}
|
||||
|
||||
data class Unauthorized(
|
||||
val code: String,
|
||||
val message: String,
|
||||
) : CyreneError {
|
||||
override val userMessage: String
|
||||
get() = message.ifBlank { "用户名或密码不正确" }
|
||||
}
|
||||
|
||||
data class Local(val kind: LocalKind) : CyreneError {
|
||||
override val userMessage: String
|
||||
get() = when (kind) {
|
||||
LocalKind.InvalidUrl -> "服务器地址格式不对,示例:http://192.168.1.10:8080"
|
||||
LocalKind.NoNetwork -> "网络不可用,请检查连接"
|
||||
LocalKind.Dns -> "找不到服务器,请检查服务器地址"
|
||||
LocalKind.Tls -> "证书校验失败(自签证书暂不支持)"
|
||||
LocalKind.Timeout -> "连接超时,服务器没有响应"
|
||||
LocalKind.ConnectFailed -> "无法连接到服务器"
|
||||
LocalKind.CleartextBlocked -> "系统禁止明文 HTTP 访问"
|
||||
LocalKind.Parse -> "服务器返回的数据无法识别"
|
||||
LocalKind.Unknown -> "发生未知错误"
|
||||
}
|
||||
}
|
||||
|
||||
enum class LocalKind {
|
||||
InvalidUrl,
|
||||
NoNetwork,
|
||||
Dns,
|
||||
Tls,
|
||||
Timeout,
|
||||
ConnectFailed,
|
||||
CleartextBlocked,
|
||||
Parse,
|
||||
Unknown,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
package top.yeij.cyrene.core.keepalive
|
||||
|
||||
/**
|
||||
* 「后台保持连接」该不该跑(**纯函数**,UI 与测试共用)。
|
||||
*
|
||||
* 判据单独拎出来的理由与先例一致(`ui.sessions.silentRefreshStep`、`ui.chat.reconnectPull`):
|
||||
* 起服务那半边要 `Context`、要系统 API,在 JVM 单测里起不来;而「什么时候该跑」只有几个布尔,
|
||||
* 能穷举、能断言。糊进 UI 之后就再也说不清「登出之后到底还会不会留着一条前台服务」——
|
||||
* 那恰恰是这个功能最容易被写错的一半。
|
||||
*
|
||||
* 三个条件缺一不可,每一个都有自己的理由:
|
||||
* - [enabled]:用户的选择,**默认关**。没打开就什么都不该常驻。
|
||||
* - [loggedIn]:实时连接是登录态的产物(`SessionsViewModel` 在登出时关掉那条共享连接)。
|
||||
* 没有连接还留一条前台服务,就只剩「白占一条通知 + 一点电」。
|
||||
* - [notificationsAllowed]:`POST_NOTIFICATIONS` 被拒时前台服务**照样能跑**(系统不再拦它),
|
||||
* 但那就等于**背着用户常驻一个不可见的进程**。本功能卖的就是「我明说我在跑」,
|
||||
* 偷偷跑比不跑更糟——所以权限被拒判「不该跑」,由设置页那行字如实说出来。
|
||||
*
|
||||
* minSdk 33 ⇒ `POST_NOTIFICATIONS` 恒是运行时权限,不存在「低版本不用问」的那一档。
|
||||
*/
|
||||
internal fun shouldKeepAliveRun(
|
||||
enabled: Boolean,
|
||||
loggedIn: Boolean,
|
||||
notificationsAllowed: Boolean,
|
||||
): Boolean = enabled && loggedIn && notificationsAllowed
|
||||
|
||||
/** 这一刻该对那条前台服务做什么([keepAliveAction] 的取值)。 */
|
||||
internal enum class KeepAliveAction {
|
||||
/** 该在跑:起服务。重复起是安全的,见 [keepAliveAction]。 */
|
||||
Start,
|
||||
|
||||
/** 不该在跑:停服务。对一条没在跑的服务喊停同样是安全的。 */
|
||||
Stop,
|
||||
|
||||
/** 什么都不做:**此刻还不该有主张**(登录态未定,见 [keepAliveAction])。 */
|
||||
Leave,
|
||||
}
|
||||
|
||||
/**
|
||||
* 这一刻该对那条前台服务做什么(**纯函数**,判据本体是 [shouldKeepAliveRun])。
|
||||
*
|
||||
* ## 为什么不只回一个布尔
|
||||
* 只回「该不该跑」的话,调用方每次都得转成一次 `start` 或 `stop`,而**启动自举那一段**
|
||||
* (`SessionState.Unknown`)两种都不该做:冷启动时进程里根本没有服务,但**热启动**(Activity
|
||||
* 重建、进程还活着)时上一次那条还好端端跑着,此时若按「还没登录 ⇒ 停」收掉它,用户会看到
|
||||
* 「通知消失 → 自举完成 → 通知又回来」这一闪,而中间那段(App 刚被切到后台时)进程正好没有任何
|
||||
* 保护。所以那一档是 [KeepAliveAction.Leave]。
|
||||
*
|
||||
* ## 为什么**不**看一眼「现在跑着没有」
|
||||
* 那样能省掉几回重复调用,但代价是引入一份会漂移的进程内状态:系统完全可能**在进程还活着的前提下**
|
||||
* 把服务收掉而不走 `onDestroy`(比如用户在系统的「正在运行的应用」里按停),于是那份「跑着」的记忆
|
||||
* 停在 true,下一轮判据就会「Leave」——**该起的时候一声不响地没起**,而这正是本功能最不能出的错。
|
||||
* 重复调用则没有任何代价:`startForegroundService` 打到一条已经在跑的服务上只是刷新同一条
|
||||
* (同 id、静默渠道的)通知,`stopService` 打到没在跑的服务上是空操作。
|
||||
*
|
||||
* @param enabled 设置页那个开关(持久化在 `AppPrefs.keepAliveEnabled`)。
|
||||
* @param sessionKnown 登录态**已经定了**(`Unknown` 之外的一切)。
|
||||
* @param loggedIn 现在处于登录态。
|
||||
* @param notificationsAllowed `POST_NOTIFICATIONS` 已授权。
|
||||
*/
|
||||
internal fun keepAliveAction(
|
||||
enabled: Boolean,
|
||||
sessionKnown: Boolean,
|
||||
loggedIn: Boolean,
|
||||
notificationsAllowed: Boolean,
|
||||
): KeepAliveAction = when {
|
||||
// 开关关着:没有第二种解释,收掉。**不等登录态**——用户刚表达过「不要」,用不着等自举。
|
||||
!enabled -> KeepAliveAction.Stop
|
||||
|
||||
// 登录态未定(启动自举的那一段):这时谁都说不准该不该跑,别动。
|
||||
!sessionKnown -> KeepAliveAction.Leave
|
||||
|
||||
// 明确登出:连接都没了,留着没意义。开关本身不动(`AppSettings` 是 App 级的、不随登出清空,
|
||||
// 下次登录仍按用户当初的选择来)。这里读的是**登录态**而不是「用没用上连接」——本服务不碰连接
|
||||
// (见 [KeepAliveService]),它对连接的了解就只有「现在有没有一个登录着的用户」这一件事。
|
||||
!loggedIn -> KeepAliveAction.Stop
|
||||
|
||||
// 到这儿只剩「开关开着 + 已登录」这一种情形,跑不跑由通知权限说话。
|
||||
shouldKeepAliveRun(enabled, loggedIn, notificationsAllowed) -> KeepAliveAction.Start
|
||||
|
||||
else -> KeepAliveAction.Stop
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
package top.yeij.cyrene.core.keepalive
|
||||
|
||||
import android.Manifest
|
||||
import android.app.Notification
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.app.Service
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.content.pm.ServiceInfo
|
||||
import android.os.IBinder
|
||||
import android.util.Log
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.ServiceCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
import top.yeij.cyrene.BuildConfig
|
||||
import top.yeij.cyrene.app.MainActivity
|
||||
|
||||
/** 通知渠道 id。恒定不变:改了它等于换一条渠道,用户对旧渠道做的静音设置会失效。 */
|
||||
internal const val KEEP_ALIVE_CHANNEL_ID = "keep_alive"
|
||||
|
||||
/**
|
||||
* 渠道重要级。**只许是 LOW / MIN**([NotificationManager.IMPORTANCE_LOW] = 不响铃、不震动、
|
||||
* 不弹横幅):这是「保活」不是「提醒」,用户没要求被叫住。
|
||||
*
|
||||
* 注意重要级是**渠道创建时定死的**:应用后改不了(只有用户能改),所以这个值必须是最终值。
|
||||
*/
|
||||
internal const val KEEP_ALIVE_CHANNEL_IMPORTANCE = NotificationManager.IMPORTANCE_LOW
|
||||
|
||||
/** 通知 id。常驻的那一条始终用它,重复起服务只更新不叠加。 */
|
||||
internal const val KEEP_ALIVE_NOTIFICATION_ID = 1
|
||||
|
||||
private const val LOG_TAG = "KeepAliveService"
|
||||
|
||||
/**
|
||||
* `POST_NOTIFICATIONS` 授没授(minSdk 33 ⇒ 它恒是运行时权限)。
|
||||
*
|
||||
* 它既是 [shouldKeepAliveRun] 的入参,也是设置页那行字要说的事,所以两边共用这一处口径。
|
||||
*/
|
||||
internal fun Context.notificationsAllowed(): Boolean =
|
||||
ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) ==
|
||||
PackageManager.PERMISSION_GRANTED
|
||||
|
||||
/**
|
||||
* 「后台保持连接」的前台服务。
|
||||
*
|
||||
* ## 它的职责只有一条:让进程活着
|
||||
* 实时连接是**进程级共享**的那一条(`AppContainer.chatSocket`),所有权在 `SessionsViewModel`
|
||||
* ——登录连、登出关。所以本服务**绝不**去 `connect()` / `close()` 它,也**不订阅任何帧**:
|
||||
* 换了它的死活不等于换了连接的死活,两者各有各的主人。
|
||||
*
|
||||
* 它要挡的是 Android 12+ 对 cached 进程的冻结:进程一旦被冻,那条连接的读写就不再被调度,
|
||||
* 久了连接就断(消息不丢,服务端会落库,回前台靠 history + `after` 补洞恢复,但那就不是
|
||||
* 「保持连接」了)。前台服务把本进程从 cached 那一档里抬出来,这件事就到此为止。
|
||||
*
|
||||
* ## 起停只有一个主人
|
||||
* [keepAliveAction] 判、`CyreneApp` 那个 `LaunchedEffect` 执行(全 App 唯一一处):
|
||||
* 谁都不许自己在别处 start / stop 它,否则「登出后还留着一条服务」这类不一致没有一处说得清。
|
||||
*
|
||||
* ## 它被系统杀掉怎么办
|
||||
* [onStartCommand] 回 `START_STICKY`:被内存压力回收后系统会重建它。用户主动关开关走的是
|
||||
* [stop],那是显式停止,粘性不会把它拉回来。
|
||||
*/
|
||||
class KeepAliveService : Service() {
|
||||
|
||||
override fun onBind(intent: Intent?): IBinder? = null
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
createChannel()
|
||||
}
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
ServiceCompat.startForeground(
|
||||
this,
|
||||
KEEP_ALIVE_NOTIFICATION_ID,
|
||||
buildNotification(),
|
||||
ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC,
|
||||
)
|
||||
return START_STICKY
|
||||
}
|
||||
|
||||
/**
|
||||
* Android 15(API 35)起,`dataSync` 型前台服务在 24 小时内累计跑满 6 小时,系统会回调这里,
|
||||
* 并给几秒钟让应用自己收手——**不接管就会 ANR 崩掉整个 App**(本端 targetSdk 37,躲不掉)。
|
||||
*
|
||||
* 所以这里照办:`stopSelf` 收掉自己,进程回到普通后台状态。刻意**不绕**这条限制
|
||||
* (绕它要么换前台服务类型、要么加心跳,都超出这个功能的边界)。代价如实说:同一台设备
|
||||
* 24 小时内累计只能保活 6 小时;额度用完之后能不能再起、什么时候能再起由系统说了算,
|
||||
* 本端不替它猜(这条限制的真机表现留给验收)。
|
||||
*/
|
||||
override fun onTimeout(startId: Int, fgsType: Int) {
|
||||
stopSelf(startId)
|
||||
}
|
||||
|
||||
/**
|
||||
* 常驻通知。文案**刻意克制**:它只是为了让「我在后台跑着」这件事在系统里是可见的
|
||||
* (前台服务必须有一条通知,这是本功能对用户的代价,设置页那行字把它写明了)。
|
||||
*
|
||||
* `setSilent` + `setOngoing` + `setOnlyAlertOnce` + 渠道 LOW:不响、不震、不亮屏、划不掉。
|
||||
* `setOnlyAlertOnce` 是给**重复起服务**留的(判据不看「现在跑着没有」,见 [keepAliveAction]):
|
||||
* 第二次 `startForegroundService` 打到同一条通知上时,用户那边必须**一点动静都没有**。
|
||||
*/
|
||||
private fun buildNotification(): Notification {
|
||||
// 点一下回到 App:一条点不动的常驻通知会让人以为卡住了。
|
||||
val open = PendingIntent.getActivity(
|
||||
this,
|
||||
0,
|
||||
Intent(this, MainActivity::class.java),
|
||||
PendingIntent.FLAG_IMMUTABLE,
|
||||
)
|
||||
return NotificationCompat.Builder(this, KEEP_ALIVE_CHANNEL_ID)
|
||||
.setSmallIcon(android.R.drawable.stat_notify_sync)
|
||||
.setContentTitle("保持与昔涟的连接")
|
||||
.setContentText("Cyrene 正在后台维持与昔涟的连接")
|
||||
.setContentIntent(open)
|
||||
.setOngoing(true)
|
||||
.setSilent(true)
|
||||
.setOnlyAlertOnce(true)
|
||||
.setShowWhen(false)
|
||||
.setCategory(NotificationCompat.CATEGORY_SERVICE)
|
||||
.setPriority(NotificationCompat.PRIORITY_LOW)
|
||||
.build()
|
||||
}
|
||||
|
||||
private fun createChannel() {
|
||||
val channel = NotificationChannel(
|
||||
KEEP_ALIVE_CHANNEL_ID,
|
||||
"后台保持连接",
|
||||
KEEP_ALIVE_CHANNEL_IMPORTANCE,
|
||||
).apply {
|
||||
description = "开启「后台保持连接」期间常驻的通知:让系统不要冻结 Cyrene 的进程"
|
||||
setShowBadge(false)
|
||||
setSound(null, null)
|
||||
enableVibration(false)
|
||||
}
|
||||
getSystemService(NotificationManager::class.java).createNotificationChannel(channel)
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
fun start(context: Context) {
|
||||
// Android 12+ 对「从后台起前台服务」是受限的,系统直接抛异常;这里吞掉它,理由有两条:
|
||||
// ① 这个失败用户既看不懂也无从处置,而把异常放出去就是一次崩溃——崩溃比「这次没起成」
|
||||
// 严重得多;
|
||||
// ② 状态是自愈的:下一个判据触发点(开关 / 登录态变化、下次启动)会再试一次,
|
||||
// 而这次没起成除了「进程暂时少一层保护」之外不留任何后遗症。
|
||||
runCatching { ContextCompat.startForegroundService(context, intent(context)) }
|
||||
.onFailure {
|
||||
if (BuildConfig.DEBUG) Log.d(LOG_TAG, "系统拒绝了这次前台服务启动", it)
|
||||
}
|
||||
}
|
||||
|
||||
fun stop(context: Context) {
|
||||
context.stopService(intent(context))
|
||||
}
|
||||
|
||||
private fun intent(context: Context): Intent = Intent(context, KeepAliveService::class.java)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
package top.yeij.cyrene.core.notify
|
||||
|
||||
import java.util.Collections
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
|
||||
/**
|
||||
* 进程级的两个「我此刻在哪儿」的事实(W53N):**App 在不在前台**、**聊天页正显示哪个会话**。
|
||||
*
|
||||
* ## 为什么是这一层,而不是 ProcessLifecycleOwner
|
||||
* 本仓没有 `lifecycle-process` 依赖,而为一个「要不要弹通知」的判断新增依赖不值当(主人明令不加)。
|
||||
* 既有的 `MainActivity` 生命周期已经足够说清第一件事——本应用只有**一个** Activity
|
||||
* (`singleTop`),它的 `onStart` / `onStop` 就是「App 有没有在被看」。第二件事由 `ChatScreen`
|
||||
* 的组合报告(组合在 = 那一页在屏幕上,见 [onChatSurface])。两条都在手写 DI 容器
|
||||
* (`app.AppContainer`)那一层汇合——与那条共享连接、`goneSessions` 那几件进程级事实同一层。
|
||||
*
|
||||
* ## 为什么前台用「活着的 Activity 集合」而不是一个布尔
|
||||
* 配置变更(旋转 / 系统深浅色切换)时,**新实例的 `onStart` 会先于旧实例的 `onStop`**:
|
||||
* 一个布尔量在那里必然被旧实例的 `onStop` 记成「后台」,于是用户正看着聊天页,她一句话却弹出
|
||||
* 通知来。按实例登记就没有这个方向性问题——新实例先加进来、旧实例后移出去,集合始终非空。
|
||||
* 集合按 `ConcurrentHashMap` 语义(`Activity` 不重写 `equals` ⇒ 就是**同一实例**),
|
||||
* 且读写可能不在同一线程上(她的话那条通路是在**进程级订阅自己的调度器**上读它的,见
|
||||
* `ProactiveFrameIntake`),所以用并发容器而不是普通 `MutableSet`。
|
||||
*
|
||||
* ## 它**不是**「App 有没有活着」
|
||||
* 后台保活(前台服务)让进程活着、连接继续收帧,而本类在这里恒报「不在前台」——那正是通知要
|
||||
* 出现的那一档。两者是两件事,别把它们揉在一起。
|
||||
*/
|
||||
class AppPresence {
|
||||
|
||||
private val startedActivities: MutableSet<Any> =
|
||||
Collections.newSetFromMap(ConcurrentHashMap<Any, Boolean>())
|
||||
|
||||
/**
|
||||
* 聊天页此刻显示着的会话 id;`null` = 聊天页不在屏幕上(设置页 / 管理页 / 未登录 / 已销毁)。
|
||||
*
|
||||
* 由 [onChatSurface] 写、通知判据读。**刻意不带「前台」的含义**:App 退到后台时这段组合在
|
||||
* Android 上并不会被销毁,这个值会原样留着——前台那一半由 [appInForeground] 单独表达,
|
||||
* 两个事实的合取才是「这条消息此刻会不会正好被用户看到」(见 [shouldNotifyProactive])。
|
||||
*/
|
||||
@Volatile
|
||||
var chatSurfaceSessionId: String? = null
|
||||
private set
|
||||
|
||||
/** App 是否正在被看(有任何一个 Activity 处在 started 之后、stopped 之前)。 */
|
||||
val appInForeground: Boolean
|
||||
get() = startedActivities.isNotEmpty()
|
||||
|
||||
private val _appInForeground = MutableStateFlow(false)
|
||||
|
||||
/**
|
||||
* [appInForeground] 的**可订阅**形态(W53S 登记的那一格要它)。
|
||||
*
|
||||
* 为什么需要第二条出口:`CyreneApp` 那个「起 / 停前台服务」的 `LaunchedEffect` 的 key 里没有
|
||||
* 通知权限,于是**用户在系统设置里授权后回到 App** 时它不会重跑 —— UI 已经显示「已开」,
|
||||
* 服务却没起。权限本身必须**现读**(不该缓存成状态),能当 key 的是「刚好会在这件事发生时变化」
|
||||
* 的信号:**回到前台**(从系统设置返回必经 `onStart`)。所以这里把「在不在前台」补成一条流。
|
||||
* 判据本身仍然只读 [appInForeground] 那个布尔——两条出口算的是**同一份事实**(同一个集合)。
|
||||
*/
|
||||
val appInForegroundFlow: StateFlow<Boolean> = _appInForeground.asStateFlow()
|
||||
|
||||
/** 一个 Activity 进入「可见」这一档(`onStart`)。 */
|
||||
fun onActivityStarted(owner: Any) {
|
||||
startedActivities.add(owner)
|
||||
_appInForeground.value = appInForeground
|
||||
}
|
||||
|
||||
/**
|
||||
* 一个 Activity 离开「可见」这一档(`onStop`)。
|
||||
*
|
||||
* 只移除**报上来的那一个实例**:照抄成「一停就整集合清空」会把正在起的新实例一起算掉
|
||||
* (那条路径见类注释里的配置变更)。
|
||||
*/
|
||||
fun onActivityStopped(owner: Any) {
|
||||
startedActivities.remove(owner)
|
||||
_appInForeground.value = appInForeground
|
||||
}
|
||||
|
||||
/** 聊天页刚显示(或刚换成)[sessionId];`null` = 它离开了屏幕。 */
|
||||
fun onChatSurface(sessionId: String?) {
|
||||
chatSurfaceSessionId = sessionId
|
||||
}
|
||||
|
||||
/**
|
||||
* 聊天页此刻显示的是不是 [sessionId] 这个会话(等值比较,不是前缀:`web` ≠ `web:xxx`)。
|
||||
* `sessionId` 为空白时恒假——「没显示任何会话」不等于「显示了一个空 id 的会话」。
|
||||
*/
|
||||
fun isShowingChatSurface(sessionId: String): Boolean =
|
||||
sessionId.isNotBlank() && chatSurfaceSessionId == sessionId
|
||||
}
|
||||
@@ -0,0 +1,277 @@
|
||||
package top.yeij.cyrene.core.notify
|
||||
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import top.yeij.cyrene.app.MainActivity
|
||||
import top.yeij.cyrene.core.keepalive.notificationsAllowed
|
||||
|
||||
/**
|
||||
* 「她主动说的话」那条通知的渠道 id —— **弹横幅**的那一条([PROACTIVE_IMPORTANCE])。
|
||||
*
|
||||
* ⚠️ **这里是 `proactive_v2`,不是最初的 `proactive`**:渠道的**重要级一旦创建就改不了**
|
||||
* (Android 的设计 —— 只有用户能改,App 改不动)。2026-10-07 主人明确要求「要横幅」,而把重要级
|
||||
* 从 DEFAULT 提到 HIGH **对已经建过旧渠道的设备不生效** ⇒ 只能换一个新 id,让系统按新重要级
|
||||
* 重新建一条。旧渠道由 [ensureProactiveChannel] 顺手删掉(它的重要级改不动,留着只会让系统设置里
|
||||
* 多一条永远不再出现的条目)。
|
||||
*
|
||||
* **刻意与保活那条分成两个渠道**:保活那条是 LOW(不响不震、用户没要求被叫住),这一条是
|
||||
* [PROACTIVE_IMPORTANCE]。两者混在一条渠道里,就只能在「保活开始响铃」与「她的话不响铃」之间
|
||||
* 二选一——那正是「一个渠道只能是其中一件事」的由来。
|
||||
*
|
||||
* 与 [QUIET_PROACTIVE_CHANNEL_ID] **也是两条**,理由与上面那条一模一样:横幅由渠道重要级决定,
|
||||
* 而重要级建完就改不了 ⇒ 设置页那个「免打扰」开关**不可能**靠运行时改设置实现,只能按开关选一条
|
||||
* 渠道发(见 [proactiveChannelId])。
|
||||
*/
|
||||
internal const val PROACTIVE_CHANNEL_ID = "proactive_v2"
|
||||
|
||||
/**
|
||||
* 「她主动说的话」在**免打扰**下走的那条渠道 id:重要级是 [QUIET_PROACTIVE_IMPORTANCE]
|
||||
* (有提示音、通知栏有条目、**不弹横幅**)。
|
||||
*
|
||||
* 为「免打扰」单独建**第二条**渠道,是因为**重要级建完就改不了**(理由见 [PROACTIVE_CHANNEL_ID])
|
||||
* ——想「只关横幅」就只有换渠道这一条路。
|
||||
*
|
||||
* ⚠️ 刻意**不用** `setSilent(true)`:那是**完全静音**(连提示音都没),而用户要的语义是
|
||||
* 「只在通知栏提示,别在屏幕顶部弹横幅」——两者之间差着一个提示音,不是同一件事。
|
||||
*/
|
||||
internal const val QUIET_PROACTIVE_CHANNEL_ID = "proactive_quiet"
|
||||
|
||||
/**
|
||||
* 上一个渠道 id([PROACTIVE_CHANNEL_ID] 的前身)。**只在建渠道时用于删除**:它的重要级是
|
||||
* DEFAULT 且改不动,留着毫无用处。
|
||||
*/
|
||||
internal const val LEGACY_PROACTIVE_CHANNEL_ID = "proactive"
|
||||
|
||||
/**
|
||||
* 弹横幅那条渠道的重要级:**`HIGH`**(屏幕顶部弹横幅 + 提示音)。
|
||||
*
|
||||
* 这一条**是**来提醒人的 —— 她主动找我说话的那一刻,我显然不在跟她聊天(App 不在前台),
|
||||
* 正是最需要在屏幕顶部弹一下的场景。DEFAULT 只会响一声、安静躺在通知栏里,很容易错过
|
||||
* (主人 2026-10-07 的原话:「我在其他 APP 时没看到横幅」)。
|
||||
*
|
||||
* 想安静的用户可以在系统设置里单独把这条渠道调低、或关掉它的横幅 —— 那是用户的权利,
|
||||
* 不是我们对他的默认;应用内的那条捷径是设置页的「免打扰」,它把通知改发到
|
||||
* [QUIET_PROACTIVE_CHANNEL_ID]。
|
||||
*
|
||||
* ⚠️ 与保活那条同理:重要级在**建渠道**时定死,应用之后改不了(只有用户能改)⇒ **改这个常量
|
||||
* 对已建过渠道的设备不生效**,必须同时换 [PROACTIVE_CHANNEL_ID]。`ProactiveNotificationShapeTest`
|
||||
* 把这两个值钉住。
|
||||
*/
|
||||
internal const val PROACTIVE_IMPORTANCE = NotificationManager.IMPORTANCE_HIGH
|
||||
|
||||
/**
|
||||
* 免打扰那条渠道的重要级:**`DEFAULT`**(提示音 + 通知栏条目,**没有横幅**)。
|
||||
*
|
||||
* `HIGH`(4) 才是屏幕顶部弹横幅的门槛,所以 DEFAULT 正是「她的话照旧说、只是不盖住我正在做的事」
|
||||
* 那一档。
|
||||
*
|
||||
* ⚠️ 与上面那条同理:**改这个常量必须同时换 [QUIET_PROACTIVE_CHANNEL_ID]**,否则对已经建过它的
|
||||
* 设备一个字节都不生效。这个方向(往安静改)出错时用户的感受只是「开关按了没反应」——不会有任何
|
||||
* 报错,所以这条纪律只能靠常量与测试钉住。
|
||||
*/
|
||||
internal const val QUIET_PROACTIVE_IMPORTANCE = NotificationManager.IMPORTANCE_DEFAULT
|
||||
|
||||
/** 通知标题:**是谁在说话**。正文是她的原话(见 [ProactiveNotifier])。 */
|
||||
internal const val PROACTIVE_NOTIFICATION_TITLE = "昔涟"
|
||||
|
||||
/**
|
||||
* 渠道名(系统设置的渠道列表里那一行)。与通知标题分开是为了让它说清**这条渠道是什么**,
|
||||
* 而不是重复一遍说话人的名字。
|
||||
*/
|
||||
internal const val PROACTIVE_CHANNEL_NAME = "她主动说的话"
|
||||
|
||||
/**
|
||||
* 免打扰那条渠道的渠道名。**必须一眼看出与 [PROACTIVE_CHANNEL_NAME] 的区别**:系统设置的渠道列表
|
||||
* 里这两行会并排出现,用户就是靠这一行判断「哪条会弹横幅」的——同名的话两条渠道就白分了
|
||||
* (他只能点进去、一条条试着看)。
|
||||
*/
|
||||
internal const val QUIET_PROACTIVE_CHANNEL_NAME = "她主动说的话(免打扰:只在通知栏)"
|
||||
|
||||
/** 弹横幅那条渠道的描述(点进渠道详情才看得到)。 */
|
||||
internal const val PROACTIVE_CHANNEL_DESCRIPTION = "她主动找你说话时的通知"
|
||||
|
||||
/**
|
||||
* 免打扰那条渠道的描述。把「不弹横幅」与「还有提示音」都说清:用户点进来想知道的是**它到底静到
|
||||
* 什么程度**,而「免打扰」四个字很容易被读成「完全没有声音」——那是另一件事(也正是用户会去系统
|
||||
* 设置里翻、翻完还是不确定的那一件事)。
|
||||
*/
|
||||
internal const val QUIET_PROACTIVE_CHANNEL_DESCRIPTION =
|
||||
"她主动找你说话时的通知(只在通知栏提示、不弹横幅;仍有提示音)"
|
||||
|
||||
/**
|
||||
* 一条 proactive 渠道的完整定义:**id / 渠道名 / 重要级 / 描述一次给全**。
|
||||
*
|
||||
* 为什么要打包成一个值:这四样必须**成对地**跟着「免打扰」这个布尔走(换了 id 却没换重要级 =
|
||||
* 开关没生效;换了重要级却没换 id = 对老设备没生效)。散成四个各自 `if (quiet)` 就是四份口径,
|
||||
* 而它们分叉时的症状全都是「用户按了开关,什么都没变」——最难被发现的那种坏法。
|
||||
*/
|
||||
internal data class ProactiveChannelSpec(
|
||||
val id: String,
|
||||
val name: String,
|
||||
val importance: Int,
|
||||
val description: String,
|
||||
)
|
||||
|
||||
/**
|
||||
* 「免打扰」→ 这一次该用哪条渠道 —— **纯函数**(不含任何 Android 调用,两档可穷举)。
|
||||
*
|
||||
* @param quiet 设置页那个「免打扰」开关**当下的值**(`false` = 都弹,也是默认)。
|
||||
*/
|
||||
internal fun proactiveChannelSpec(quiet: Boolean): ProactiveChannelSpec = if (quiet) {
|
||||
ProactiveChannelSpec(
|
||||
id = QUIET_PROACTIVE_CHANNEL_ID,
|
||||
name = QUIET_PROACTIVE_CHANNEL_NAME,
|
||||
importance = QUIET_PROACTIVE_IMPORTANCE,
|
||||
description = QUIET_PROACTIVE_CHANNEL_DESCRIPTION,
|
||||
)
|
||||
} else {
|
||||
ProactiveChannelSpec(
|
||||
id = PROACTIVE_CHANNEL_ID,
|
||||
name = PROACTIVE_CHANNEL_NAME,
|
||||
importance = PROACTIVE_IMPORTANCE,
|
||||
description = PROACTIVE_CHANNEL_DESCRIPTION,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 发通知那一刻用哪条渠道 id —— **纯函数**,就是「同一个开关、两条渠道」这件事的全部落地形状。
|
||||
*
|
||||
* 单独再露一层是因为调用方要的正好只是这个 id(`NotificationCompat.Builder(context, ???)`),
|
||||
* 而测试要问的也只是这一个问题:「开关两档各发去哪条」。
|
||||
*/
|
||||
internal fun proactiveChannelId(quiet: Boolean): String = proactiveChannelSpec(quiet).id
|
||||
|
||||
/**
|
||||
* 点通知进来时那个「切到主会话」的 Intent extra。
|
||||
*
|
||||
* 它是本端**手写布尔路由**里的一次「带意图的冷启动 / 热启动」:`MainActivity` 读它 →
|
||||
* `ProactiveTapIntake` 记一笔 → `ChatHomeScreen` 落成一次 `select(主会话)`。刻意**不引导航库**
|
||||
* (本项目没有导航库,路由就是 `ChatHomeScreen` 里那几个布尔量)。
|
||||
*/
|
||||
internal const val EXTRA_PROACTIVE_OPEN_MAIN_SESSION = "top.yeij.cyrene.extra.PROACTIVE_OPEN_MAIN_SESSION"
|
||||
|
||||
/**
|
||||
* 弹一条「她主动说的话」的通知。
|
||||
*
|
||||
* 只做四件事:按 [quiet] 选渠道、建渠道(惰性,第一次真要用时才建)、拼通知、`notify`。**判据、
|
||||
* 去重与 id 的派生都不在这里**(那是 [ProactiveNotifier] 与 [proactiveNotificationId] 的事)
|
||||
* ——本函数被调用就意味着「这一次确实该弹,且这条消息的号已经定好了」,它只负责把号用在通知上。
|
||||
*
|
||||
* ## 为什么 id 是入参而不是这里恒定一个
|
||||
* 同 id 的 `notify` 是**替换**:恒用一个号就等于「后来的覆盖先来的」——她连说几句,用户只看到
|
||||
* 最后一句。入参那个号由 [proactiveNotificationId] 按消息派生(输入就是去重键),于是同一句
|
||||
* 无论到几次都落在同一条通知上,而不同的句子各占一条。
|
||||
*
|
||||
* ## 免打扰只换渠道,别的一个字节都不动
|
||||
* [quiet] 为真时整条通知除了「发去哪条渠道 / 7.x 的优先级」以外**逐字节不变**:同一个标题、同一
|
||||
* 段正文、同一个通知号、同一次点击行为。用户要的是「别在我正做事的时候盖住屏幕」,不是「别让我
|
||||
* 知道」——所以什么都不该少,只是她的话安静地躺在通知栏里。
|
||||
*
|
||||
* ## 没授通知权限时什么都不做
|
||||
* `minSdk 33` ⇒ `POST_NOTIFICATIONS` 恒是运行时权限,而系统在未授权时**静默丢弃**通知。与其发一条
|
||||
* 必然无声无息的东西,不如在这里就收手(判据复用保活那处 [notificationsAllowed],全 App 只有
|
||||
* 一份「授没授」的口径)。用户之后在设置页授权、或直接开通了保活那个开关,下一句话就能弹出来
|
||||
* ——本函数每次都**现读**权限,不缓存。
|
||||
*
|
||||
* ## 小图标用的是平台资源
|
||||
* 工程里没有为通知准备单色小图标(`@mipmap/ic_launcher` 是彩色的,拿它当通知小图标在通知栏上
|
||||
* 会显示成一块白斑)。所以用平台自带的 `stat_notify_chat`——与保活那条用 `stat_notify_sync` 是
|
||||
* 同一个做法。要换成本应用的图标时,只需换下面这一行。
|
||||
*
|
||||
* @param notificationId 这条消息的通知 id,同时也是它点击用的 request code(两者必须一起动:
|
||||
* 只换 id 不换 request code,点后到的那条通知会串到先到那条的 `PendingIntent` 上)。
|
||||
* @param quiet 「免打扰」开关**当下的值**:`true` ⇒ 发到 [QUIET_PROACTIVE_CHANNEL_ID](有提示音、
|
||||
* 不弹横幅);`false` ⇒ 发到 [PROACTIVE_CHANNEL_ID](弹横幅)。**刻意不给默认值**:调用方忘写
|
||||
* 这一位时症状是「开关按了没反应」,那种坏法没有任何报错——让编译器拦住它。
|
||||
* 值由调用方每次**现读**(见 `AppContainer` 里 `post` 那个 lambda),所以开关一改不必重启 App,
|
||||
* 下一条通知就走新渠道。
|
||||
*/
|
||||
internal fun postProactiveNotification(
|
||||
context: Context,
|
||||
text: String,
|
||||
notificationId: Int,
|
||||
quiet: Boolean,
|
||||
) {
|
||||
if (!context.notificationsAllowed()) return
|
||||
|
||||
// 选渠道与建渠道用的是**同一个** spec:两处各自 `if (quiet)` 就会有一条渠道永远建不出来,
|
||||
// 而它的症状是「开了免打扰之后她再也不通知我了」(Android 8+ 对不存在的渠道是静默丢弃)。
|
||||
val channel = proactiveChannelSpec(quiet)
|
||||
ensureProactiveChannel(context, channel)
|
||||
|
||||
// request code 随消息走(`proactiveNotificationId` 的下界远高于保活那条用的 `0`,所以不会
|
||||
// 撞上它):两个 Intent 都是「启动 MainActivity」、没有 action / data / categories ⇒
|
||||
// `Intent.filterEquals` 判它们**相等**,而 `PendingIntent` 的相等判据里**不含 extras**。
|
||||
// 同 request code 时系统直接复用**先建的那一个**,后建这枚的 extra 会被悄悄丢掉。
|
||||
val openApp = PendingIntent.getActivity(
|
||||
context,
|
||||
notificationId,
|
||||
Intent(context, MainActivity::class.java).putExtra(EXTRA_PROACTIVE_OPEN_MAIN_SESSION, true),
|
||||
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
|
||||
)
|
||||
|
||||
val notification = NotificationCompat.Builder(context, channel.id)
|
||||
.setSmallIcon(android.R.drawable.stat_notify_chat)
|
||||
.setContentTitle(PROACTIVE_NOTIFICATION_TITLE)
|
||||
.setContentText(text)
|
||||
// 展开态显示完整正文(`setContentText` 那一行放不下 120 字,而截断只解决「过长」,
|
||||
// 解决不了「一行的宽度」)。
|
||||
.setStyle(NotificationCompat.BigTextStyle().bigText(text))
|
||||
.setContentIntent(openApp)
|
||||
// 点掉即消失:这条通知说的是一次刚发生的事,用户点开就等于看到了。
|
||||
.setAutoCancel(true)
|
||||
.setCategory(NotificationCompat.CATEGORY_MESSAGE)
|
||||
// 横幅:Android 8+ 由**渠道重要级**决定(上面那条 spec 选出来的那条渠道),这一句管的是
|
||||
// 7.x 及更早(那边还没有渠道,优先级就是全部)。两处必须同向——免打扰那一档在这里也得
|
||||
// 降回 DEFAULT,否则老设备上「开着免打扰却照样弹横幅」。
|
||||
.setPriority(if (quiet) NotificationCompat.PRIORITY_DEFAULT else NotificationCompat.PRIORITY_HIGH)
|
||||
.build()
|
||||
|
||||
// 一事一条:号随消息走。撞号会发生,代价与改前一样是「后来的把先来的替换掉」,
|
||||
// 理由与概率写在 `proactiveNotificationId` 的 KDoc 里。
|
||||
NotificationManagerCompat.from(context).notify(notificationId, notification)
|
||||
}
|
||||
|
||||
/**
|
||||
* 建(或更新)**这一次要用的那一条**渠道。惰性创建:一个从没收到过她主动消息的用户,系统设置的
|
||||
* 渠道列表里不会平白多出一条「她的话」。
|
||||
*
|
||||
* ## 为什么只建「这一次要用的那一条」,而不是两条都建
|
||||
* 「免打扰」决定的是**下一条**通知发去哪条渠道,而渠道只需要在 `notify` 之前存在。两条都建的话,
|
||||
* 一个从没开过免打扰、将来也不打算开的用户,系统设置里会平白多出一条他从没见过的「她主动说的话
|
||||
* (免打扰:只在通知栏)」——那是我们替他做的选择,也算脏数据。所以这里只 ensure 被选中的那一条。
|
||||
*
|
||||
* ⚠️ 但**必须在 `notify` 之前** ensure:Android 8+ 上目标渠道不存在时,那条通知是被系统**静默
|
||||
* 丢弃**的(不报错、不打日志、只是什么都没有)。这正是「只建一条」这个省法的代价 —— 它靠
|
||||
* [postProactiveNotification] 里「选渠道」与「建渠道」用的是**同一个** [ProactiveChannelSpec]
|
||||
* 兜住(两处各自 `if (quiet)` 就会有一条渠道永远建不出来,而症状是「开了免打扰之后她再也不
|
||||
* 通知我了」——用户只会以为是她不说话了)。
|
||||
*
|
||||
* ## 另一条渠道**不删**
|
||||
* 它是「这一次没选」,不是「以后不再用」:用户来回切开关时,他在系统设置里对另一条调过的横幅 /
|
||||
* 静音设置得留着(删掉 = 把他的选择一起删掉,还得重新建一条空白的)。唯一会删的是
|
||||
* [LEGACY_PROACTIVE_CHANNEL_ID] —— 那条 id 本端**再也不会发**,而它的重要级又改不动,留着只会
|
||||
* 在系统设置里多一条永远不再出现的条目(用户可能在那儿调半天、却发现怎么调都没变化)。
|
||||
*
|
||||
* 重复调用是幂等的(同 id ⇒ 更新那条渠道,不会建出第二条)。**注意重要级只在第一次创建时生效**
|
||||
* ——它已经在 [PROACTIVE_IMPORTANCE] / [QUIET_PROACTIVE_IMPORTANCE] 上定死(渠道名与描述此后仍
|
||||
* 可以改,重要级改不了)。
|
||||
*
|
||||
* @param spec 这一次要用的那条渠道(由 [proactiveChannelSpec] 按「免打扰」开关选出来)。
|
||||
*/
|
||||
private fun ensureProactiveChannel(context: Context, spec: ProactiveChannelSpec) {
|
||||
val channel = NotificationChannel(spec.id, spec.name, spec.importance).apply {
|
||||
description = spec.description
|
||||
setShowBadge(true)
|
||||
}
|
||||
val manager = context.getSystemService(NotificationManager::class.java)
|
||||
manager.createNotificationChannel(channel)
|
||||
// 旧的 DEFAULT 渠道删掉:重要级改不动,留着只会在系统设置里多一条永远不再出现的条目
|
||||
// (用户可能在那儿调半天、却发现怎么调都没变化)。删一条不存在的渠道是幂等的。
|
||||
manager.deleteNotificationChannel(LEGACY_PROACTIVE_CHANNEL_ID)
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
package top.yeij.cyrene.core.notify
|
||||
|
||||
import top.yeij.cyrene.data.api.dto.SessionSummaryDto
|
||||
|
||||
/**
|
||||
* 一帧「她主动说的话」进到本类之后**发生了什么**(纯判定的结果,可单测)。
|
||||
*
|
||||
* 分成五档而不是一个布尔,是为了让「为什么这次没弹」在日志与测试里都能一眼看穿:这五条各自
|
||||
* 对应一个完全不同的理由,而在通知这一档上,「没弹」与「弹了」一样需要说得清。
|
||||
*/
|
||||
enum class ProactiveNotifyOutcome {
|
||||
|
||||
/** 弹了。 */
|
||||
Posted,
|
||||
|
||||
/** 用户正看着主会话(前台 ∧ 聊天页显示主会话):她的话就出现在他眼前,不打扰。 */
|
||||
SeenOnScreen,
|
||||
|
||||
/** 同一条已经到达过(实时帧与落库帧两条路,或服务端重推)。 */
|
||||
Duplicate,
|
||||
|
||||
/** 帧里没有可显示的正文(坏帧 / 落库失败的空帧)。 */
|
||||
NothingToSay,
|
||||
|
||||
/**
|
||||
* 不属主会话。她主动说的话落的是主会话(服务端 `history.MainSessionID`);帧上若明说了别的
|
||||
* 会话,那不是这件事,绝不弹(宁可漏,也不要把别处的消息说成是她在找我说话)。
|
||||
*/
|
||||
OtherSession,
|
||||
}
|
||||
|
||||
/**
|
||||
* 「她主动说的话」的通路(W53N):**判据 + 去重 + 落地**收在一处,Android 那一步由 [post] 注入。
|
||||
*
|
||||
* ## 谁把帧喂进来(W57 起)
|
||||
* 后端两条路(实时帧 `proactive_message`、落库帧 `message_persisted{proactive:true}`)在客户端
|
||||
* 汇于一处:**进程级**的帧消费点 `app.ProactiveFrameIntake` 订着那条共享连接,把这两种帧折成下面
|
||||
* [onProactiveFrame] 的四个入参喂过来(同一条消息无论走哪条路、到几次,都只经过这一处)。
|
||||
* 本类因此只是一个**纯逻辑 + 一个回调**,不订阅任何东西、不碰连接、不认识 Android——测试里传个
|
||||
* 假的 [post] 就能把它整条驱动起来。
|
||||
*
|
||||
* ## 它绝不碰连接
|
||||
* 连接的所有权在登录态(`SessionsViewModel` 那处 `LoggedIn → connect()`)。本类只在帧**已经到达
|
||||
* 之后**做判断——帧没到就没有任何事发生,它不会去重连、不会去补拉、不会去订阅。
|
||||
*
|
||||
* ## 一次帧到这里的顺序(每一步的顺序都有理由)
|
||||
* 1. [ProactiveNotifyOutcome.OtherSession]:不是主会话,连账本都不必碰;
|
||||
* 2. [ProactiveNotifyOutcome.NothingToSay]:先看有没有可显示的字。**这一步不记账**——同一句话的
|
||||
* 另一条路上正文可能是齐的(实时帧空、落库帧有字的形状漂移),记了就会把那条能弹的挡成重复;
|
||||
* 3. [ProactiveNotifyOutcome.Duplicate]:记账与查重。**先记账再判「会不会被看到」**,因为「用户
|
||||
* 正看着」那一次也算这条已经到达:他当场看过了,另一条路再补一帧时不该为一句**看过的话**弹
|
||||
* 通知(他这会儿可能已经切走了);
|
||||
* 4. [ProactiveNotifyOutcome.SeenOnScreen] / [ProactiveNotifyOutcome.Posted]:最后才问时机。
|
||||
*
|
||||
* @param presence 「App 在不在前台 / 聊天页显示着谁」,由 `MainActivity` 与 `ChatScreen` 报告。
|
||||
* @param post 真正的落地动作(Android 那边是建渠道 + `notify`)。第二个入参是**这条消息派生出来的
|
||||
* 通知 id**(见 [proactiveNotificationId]):落地侧必须用它,别再恒用一个号——恒用一个号时
|
||||
* `notify` 是替换,她连说几句就只剩最后一句。测试传一个记录用的假实现即可。
|
||||
* @param mainSessionId 主会话 id。默认取 [SessionSummaryDto.MAIN_SESSION_ID],测试可换。
|
||||
*/
|
||||
class ProactiveNotifier(
|
||||
private val presence: AppPresence,
|
||||
private val post: (text: String, notificationId: Int) -> Unit,
|
||||
private val mainSessionId: String = SessionSummaryDto.MAIN_SESSION_ID,
|
||||
private val recent: RecentProactiveNotices = RecentProactiveNotices(),
|
||||
) {
|
||||
|
||||
/**
|
||||
* 一帧到了(实时帧与落库帧**共用**这一处,所以同一条消息无论到几次都只弹一次)。
|
||||
*
|
||||
* @param sessionId 帧上的会话归属;`null` / 空白 = 帧壳没给(实时帧就是这样:服务端构造它时
|
||||
* 不设 `sessionId`)⇒ 按「无法判定」放行,由 [mainSessionId] 那条事实兜住归属。
|
||||
* @param id 落库行 id(实时帧的 `data.id` / 落库帧的 `message.id`)——两条路的公共键。
|
||||
* @param messageId IM 侧消息号(实时帧才有;落库失败那一档它是最后的服务端键)。
|
||||
* @param timestamp 服务端给的时间**字面量**(只作去重兜底,排序与本类无关)。
|
||||
* @param content 她的原话(原始文本,标记由 [proactiveNotificationText] 剥)。
|
||||
*/
|
||||
fun onProactiveFrame(
|
||||
sessionId: String?,
|
||||
id: String?,
|
||||
messageId: String?,
|
||||
timestamp: String?,
|
||||
content: String,
|
||||
): ProactiveNotifyOutcome {
|
||||
if (!sessionId.isNullOrBlank() && sessionId != mainSessionId) {
|
||||
return ProactiveNotifyOutcome.OtherSession
|
||||
}
|
||||
|
||||
val text = proactiveNotificationText(content) ?: return ProactiveNotifyOutcome.NothingToSay
|
||||
|
||||
val key = proactiveNotifyKey(id, messageId, timestamp, text)
|
||||
val firstArrival = recent.markIfNew(key)
|
||||
if (!firstArrival) return ProactiveNotifyOutcome.Duplicate
|
||||
|
||||
// 判据只回答「弹不弹」,「用户会不会正好看到」是它的反面——别在接线层另写一遍这个取反
|
||||
// (两份口径早晚会分叉,而这里分叉的代价是「她的话在用户眼前弹出来」)。
|
||||
val seenOnScreen = !shouldNotifyProactive(
|
||||
appInForeground = presence.appInForeground,
|
||||
viewingTargetSession = presence.isShowingChatSurface(mainSessionId),
|
||||
)
|
||||
if (seenOnScreen) return ProactiveNotifyOutcome.SeenOnScreen
|
||||
|
||||
// id 与去重键同源,正是为了让「同一条 ⇒ 同一个号」与「同一条 ⇒ 只弹一次」永远不会分叉。
|
||||
post(text, proactiveNotificationId(key))
|
||||
return ProactiveNotifyOutcome.Posted
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
package top.yeij.cyrene.core.notify
|
||||
|
||||
import kotlin.math.absoluteValue
|
||||
import top.yeij.cyrene.domain.parse.parse
|
||||
|
||||
/**
|
||||
* 「她主动说的话」该不该弹一条系统通知 —— 纯函数(W53N)。
|
||||
*
|
||||
* 主人对时机的原话是「**App 不在前台,或者不在聊天页**」。写成一个判据(而不是直接写成
|
||||
* `!viewing`)是为了让两个事实**各自**可见、可穷举、可测试:它们是两个方向完全不同的东西,
|
||||
* 合成一行以后「为什么这一档不弹」就再也说不清了。
|
||||
*
|
||||
* 语义(**判断标准只有一条:这条消息此刻会不会正好被用户看到**):
|
||||
*
|
||||
* | [appInForeground] | [viewingTargetSession] | 结论 | 场景 |
|
||||
* |---|---|---|---|
|
||||
* | true | true | **不弹** | 用户正看着主会话,她的话就出现在他眼前 |
|
||||
* | true | false | 弹 | 用户在看设置页 / 别的会话(甚至另一个会话的聊天页) |
|
||||
* | false | false | 弹 | App 退到后台 / 熄屏(进程被保活着,帧还在到) |
|
||||
* | false | true | 弹 | **Android 上真实存在**:Activity `onStop` 之后组合并没有销毁,
|
||||
* 聊天页仍「显示」着主会话。此时的用户根本看不见它,所以照弹 |
|
||||
*
|
||||
* 最后一行是「两个事实不许互相顶替」的那一半:只看聊天页显示着谁,就会在 App 退到后台以后
|
||||
* 静默漏掉**所有**她的话。
|
||||
*
|
||||
* @param appInForeground 此刻有没有 Activity 在「可见」这一档(见 [AppPresence.appInForeground])。
|
||||
* @param viewingTargetSession 聊天页此刻显示的是不是**她说这句话的那个会话**。注意它**不含**
|
||||
* 「前台」的含义(见 [AppPresence.chatSurfaceSessionId])——它只说「屏幕上摆着哪一页」。
|
||||
*/
|
||||
fun shouldNotifyProactive(
|
||||
appInForeground: Boolean,
|
||||
viewingTargetSession: Boolean,
|
||||
): Boolean = !(appInForeground && viewingTargetSession)
|
||||
|
||||
/**
|
||||
* 通知正文的长度上限(字符)。超出的部分截断加 `…`。
|
||||
*
|
||||
* 取值理由:通知在折叠态只显示一行(约 20~30 字),展开态(`BigTextStyle`)能显示四五行。
|
||||
* 这个上限是**展开态**的预算——正文不砍到一行那么短(那样会看不出她在说什么),但也不许整屏
|
||||
* 铺开。真正的长文在聊天页里一字不少。
|
||||
*/
|
||||
const val PROACTIVE_NOTIFICATION_MAX_CHARS = 120
|
||||
|
||||
/**
|
||||
* 去重账本的容量。同一条她的话最多两条路会到(实时帧 + 落库帧),容量只需要覆盖「最近一小段」;
|
||||
* 取 64 是给「她连说几句 + 两条路交错」留的余量,同时**有界**——进程活一整天也不会涨。
|
||||
*/
|
||||
const val RECENT_PROACTIVE_CAPACITY = 64
|
||||
|
||||
/**
|
||||
* 最近**已经到达过**的 proactive 消息的键(进程级、容量有限、先进先出淘汰)。
|
||||
*
|
||||
* ## 为什么必须有它
|
||||
* 同一条消息会走**两条**路到本端:实时帧 `proactive_message`(带**落库行** `id`)与落库帧
|
||||
* `message_persisted{proactive:true}`(带 `message.id`,同一个值)。不去重就是同一条消息弹两次。
|
||||
*
|
||||
* ## 为什么记的是「到达过」而不是「弹过」
|
||||
* 因为「用户正看着主会话」那一次也要记:他当场看过这条了,随后另一条路再补一帧时**不该**为一句
|
||||
* 已经看过的话弹通知(他这会儿可能已经切到别的会话了)。两个方向都要求同一件事:**先到的那一次
|
||||
* 说了算**。判据见 [ProactiveNotifier.onProactiveFrame]。
|
||||
*
|
||||
* 有界:超过容量按插入顺序丢掉最老的(`LinkedHashSet` 的迭代顺序就是插入顺序)。
|
||||
* 只活在本进程里,不持久化——重启之后最多重复弹一条「刚刚到达过、还没被看过」的话,那比
|
||||
* 为一个通知判据在磁盘上留一份状态诚实得多。
|
||||
*/
|
||||
class RecentProactiveNotices(private val capacity: Int = RECENT_PROACTIVE_CAPACITY) {
|
||||
|
||||
private val seen = LinkedHashSet<String>()
|
||||
|
||||
/**
|
||||
* 记下这个键,并回「它是不是**第一次**来」。返回 false = 这条已经到达过,调用方必须闭嘴。
|
||||
*
|
||||
* 空键恒回 false(「没有键」不是一个可去重的身份,绝不把它当成一条新消息的凭据)。
|
||||
*/
|
||||
@Synchronized
|
||||
fun markIfNew(key: String): Boolean {
|
||||
if (key.isBlank()) return false
|
||||
if (!seen.add(key)) return false
|
||||
while (seen.size > capacity.coerceAtLeast(1)) {
|
||||
val oldest = seen.firstOrNull() ?: break
|
||||
seen.remove(oldest)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/** 账本里现有多少个键(测试与诊断用)。 */
|
||||
@Synchronized
|
||||
fun size(): Int = seen.size
|
||||
}
|
||||
|
||||
/**
|
||||
* 一条 proactive 消息的去重键(纯函数)。
|
||||
*
|
||||
* 按**服务端给的 id 优先级**取,最后才退到正文:
|
||||
* 1. 落库行 id(实时帧的 `data.id` / 落库帧的 `message.id`)——两条路**唯一的公共键**,
|
||||
* 也是服务端唯一保证「同一条消息同一个值」的键;
|
||||
* 2. IM 侧消息号(只有实时帧有:落库失败那一档它仍然在,`id` 缺席);
|
||||
* 3. 时间字面量(同上,最后的服务端键);
|
||||
* 4. [fallback](调用方传**正文**)——服务端一个键都没给时的兜底。
|
||||
*
|
||||
* 第 4 条是刻意的取舍:它会让「没有 id、正文又一个字不差」的两条消息被并成一条(少弹一次),
|
||||
* 而反过来(不兜底 ⇒ 无键可查 ⇒ 每来一帧都弹)会把服务端重推的同一条话弹成两条。**少弹**
|
||||
* 是这两边更轻的那一侧——通知只是提醒,话本身在聊天页里一条都不少。
|
||||
*/
|
||||
fun proactiveNotifyKey(
|
||||
id: String?,
|
||||
messageId: String?,
|
||||
timestamp: String?,
|
||||
fallback: String,
|
||||
): String = listOf(id, messageId, timestamp)
|
||||
.firstOrNull { !it.isNullOrBlank() }
|
||||
?: fallback
|
||||
|
||||
/**
|
||||
* 派生 id 的取值**下界**(见 [proactiveNotificationId])。
|
||||
*
|
||||
* 通知 id 与 `PendingIntent` 的 request code 都是**全 App 共用**的命名空间,而它们现有的占用者
|
||||
* (保活那条常驻通知 `KEEP_ALIVE_NOTIFICATION_ID` = 1、它点击用的 request code = 0)全在低位。
|
||||
* 从远高于它们的地方起步,「派生 id 不撞既有 id」就是**结构性**成立的,不是靠「碰巧没撞上」。
|
||||
*/
|
||||
private const val PROACTIVE_NOTIFICATION_ID_FLOOR = 1_000
|
||||
|
||||
/**
|
||||
* 派生 id 的取值**宽度**(见 [proactiveNotificationId])。
|
||||
*
|
||||
* 取这么宽是为了让 id 空间尽量大(撞号的概率随宽度下降),同时**贴着 `Int.MAX_VALUE` 留出下界的
|
||||
* 位置**:「下界 + 宽度」一旦越过 `Int.MAX_VALUE`,末尾的 `.toInt()` 就会折回负数。
|
||||
*/
|
||||
private const val PROACTIVE_NOTIFICATION_ID_SPAN = 2_000_000_000
|
||||
|
||||
/**
|
||||
* 一条 proactive 消息对应的**通知 id(同时也是它点击用的 request code)**——纯函数,由去重键
|
||||
* [proactiveNotifyKey] 的输出派生。这就是「一事一条」的全部落地形状(W54A)。
|
||||
*
|
||||
* ## 为什么必须与去重键同源
|
||||
* 改前所有她的话共用一个固定 id,而 `notify` 遇同 id 是**替换**:她连说几句,用户只看到最后
|
||||
* 一句,前面那几句在他拿起手机之前就已经没了。按消息派生要回答的第一个问题是「凭什么是同一条」
|
||||
* ——那正是 [proactiveNotifyKey] 已经回答过的(实时帧与落库帧共用它)。**两处口径必须永远同源**:
|
||||
* 去重说「这是同一条」、派生说「这是同一条 ⇒ 同一个号」;分成两套,就会出现「认得出是同一条、
|
||||
* 却排出两条 id」的形状漂移。
|
||||
*
|
||||
* ## 它会撞号,后果如实说
|
||||
* `String.hashCode()` 只有 32 位,再折进 [PROACTIVE_NOTIFICATION_ID_SPAN] 这一段 ⇒ **不同消息
|
||||
* 派生出同一个 id 是会发生的**(约 2×10⁹ 的空间,按生日问题算,通知栏里装几十条时概率可忽略)。
|
||||
* 撞上的后果与改前**一模一样**:后来那条替换先来那条,用户少看见一条提醒。这是刻意接受的那一侧
|
||||
* ——通知只是「她找你了」这一下,她的话本身在主会话里一条不少;反过来要绝对不撞,就得在服务端
|
||||
* 唯一号之外再维护一套映射并持久化它,代价比「极少数情况下少一条提醒」大得多。
|
||||
*
|
||||
* ## 两个坑都在这里堵上
|
||||
* 1. **号必须为正**:通知 id 是一个有符号 `int`,而这个命名空间里正数是纪律(本仓保活那条常驻通知
|
||||
* 是 1;平台对前台服务的 id 更直接写明「must not be 0」)。而 `hashCode()` 可以是负的、甚至
|
||||
* 正好是 `Int.MIN_VALUE`——后者连 `abs` 都兜不住(在 int 域里 `abs(Int.MIN_VALUE)` 仍是它自己)。
|
||||
* 所以这里**先展宽到 `Long` 再取绝对值**:`Int.MIN_VALUE.toLong()` = 2147483648,取绝对值是安全
|
||||
* 的,再折进 [PROACTIVE_NOTIFICATION_ID_SPAN] 后落在正区间。
|
||||
* 2. **不撞既有 id**:见 [PROACTIVE_NOTIFICATION_ID_FLOOR]。
|
||||
*/
|
||||
internal fun proactiveNotificationId(key: String): Int {
|
||||
val magnitude = key.hashCode().toLong().absoluteValue
|
||||
return (PROACTIVE_NOTIFICATION_ID_FLOOR + magnitude % PROACTIVE_NOTIFICATION_ID_SPAN).toInt()
|
||||
}
|
||||
|
||||
/**
|
||||
* 通知正文:她的原话,**剥掉标记、折成一行、过长截断**;没有可显示的字时回 `null`。
|
||||
*
|
||||
* ## 为什么不直接发原始 `content`
|
||||
* 她的内容里带 `<p>` / `<action>` / `<narration>` 标记(服务端原样落库),直接放进通知会把标签
|
||||
* 露给用户(本仓在会话列表那一行上早就踩过同一件事,见 `ui.sessions.toPreviewText`)。
|
||||
* 这里**复用权威解析器** [parse] 而不是再写一份剥标签的正则:同一件事有两份口径早晚会分叉,
|
||||
* 而分叉的那一侧就是通知里露标签的那一侧。解析出的全部片段按原顺序拼回(旁白/动作也留着——
|
||||
* 那也是她说的话),片段之间补空格,免得「正文紧贴旁白」时两侧的字粘成一个词。
|
||||
*
|
||||
* 截断按 UTF-16 码元走,但**不劈开代理对**(emoji 这类字符由两个码元组成,劈开会显示成半个
|
||||
* 乱码方块):末尾正好落在高代理项上时整体后退一位。
|
||||
*/
|
||||
fun proactiveNotificationText(
|
||||
content: String,
|
||||
maxChars: Int = PROACTIVE_NOTIFICATION_MAX_CHARS,
|
||||
): String? {
|
||||
val flat = parse(content)
|
||||
.joinToString(" ") { bubble -> bubble.parts.joinToString(" ") { part -> part.text } }
|
||||
.replace(WHITESPACE_RE, " ")
|
||||
.trim()
|
||||
if (flat.isEmpty()) return null
|
||||
// maxChars <= 0 = 「不截断」(调用方明确要全文);其余情况只在真的超长时才动手。
|
||||
if (maxChars <= 0 || flat.length <= maxChars) return flat
|
||||
|
||||
var end = maxChars
|
||||
if (Character.isHighSurrogate(flat[end - 1])) end -= 1
|
||||
return flat.substring(0, end).trimEnd() + "…"
|
||||
}
|
||||
|
||||
private val WHITESPACE_RE = Regex("\\s+")
|
||||
@@ -0,0 +1,210 @@
|
||||
package top.yeij.cyrene.core.prefs
|
||||
|
||||
import android.content.Context
|
||||
import androidx.compose.runtime.staticCompositionLocalOf
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.datastore.preferences.core.booleanPreferencesKey
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import androidx.datastore.preferences.preferencesDataStore
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
|
||||
private val Context.appDataStore: DataStore<Preferences> by preferencesDataStore("cyrene_app")
|
||||
|
||||
enum class ThemeMode { System, Light, Dark }
|
||||
|
||||
/**
|
||||
* 她忙时发消息的行为(协同项 A2,契约 v0.56 §3.1 的 `interrupt.scope`;忙时并行规范 §2.2 的
|
||||
* `chat.parallel`)。
|
||||
*
|
||||
* 类型名与落盘键(`interrupt_mode`)都是**历史名字**:第三档「并行」接上来之后它管的不再只是打断,
|
||||
* 但改类型名 / 键名 / 既有档位的 `name` 都会让已存偏好读不出来并**静默回落**默认档——所以三个名字
|
||||
* 一个都不动(见 [toInterruptMode] 与 `InterruptModeTest` 的「落盘名稳定」那几条)。
|
||||
*
|
||||
* 一档同时表达**两个上行声明**:[wireScope] 与 [wireParallel]。三档各占一种组合——打断占前者、
|
||||
* 并行占后者、追加队列两个都不占,**没有任何一档同时给两个**。
|
||||
*
|
||||
* @property wireScope 上行 `interrupt` 帧的 `scope` 取值;`null` = **不发打断帧**。注意它不是
|
||||
* 「发一条省略 scope 的帧」——那在服务端是 `"all"`(连待处理队列一起清),是完全不同的语义。
|
||||
* @property wireParallel 这条 `chat` 要不要声明 `parallel:true`;`false` = **一个字节都不带**。
|
||||
*/
|
||||
enum class InterruptMode(val wireScope: String?, val wireParallel: Boolean) {
|
||||
|
||||
/**
|
||||
* 追加队列(默认):忙时只把消息排进队列,不打断也不并行。
|
||||
*
|
||||
* 该模式**两个声明都不发**(`interruptScopeForSend` 恒为「不发」、`parallelForSend` 恒为
|
||||
* `false`):默认档的线上请求与本偏好存在之前**逐字节相同**。
|
||||
*/
|
||||
AppendQueue(wireScope = null, wireParallel = false),
|
||||
|
||||
/**
|
||||
* 打断:只取消在跑回合、**保留**待处理队列(`scope:"current"`)。
|
||||
* 队列里的消息按编排器正常续跑规则继续执行,一条都不丢。
|
||||
*/
|
||||
Interrupt(wireScope = "current", wireParallel = false),
|
||||
|
||||
/**
|
||||
* 并行:忙时**不打断**在跑的那一路,本条**另起一路**同时处理(`chat.parallel=true`);
|
||||
* 服务端并发额度满时**不报错**,这一条落回排队。
|
||||
*
|
||||
* [wireScope] 为 `null` 不是因为「这一档没什么要发的东西」(它当然要发东西,发的是
|
||||
* `chat.parallel`),而是因为**并行与打断互斥**:这一档**绝不允许**产生 `interrupt` 帧——
|
||||
* 一产生就等于先把要并行的那一路砍掉。
|
||||
*
|
||||
* 代价:两路各算一遍 ⇒ 峰值算力 / token 近翻倍(主人 2026-10-05 裁决 4:不分摊、优先性能),
|
||||
* 所以设置页这一栏的 summary 把这句代价明说出来。
|
||||
*/
|
||||
Parallel(wireScope = null, wireParallel = true),
|
||||
}
|
||||
|
||||
/**
|
||||
* 落盘字符串 → 档位(`AppSettings` 的 `interrupt_mode` 键读取口;抽成顶层函数是为了能单测——
|
||||
* `AppSettings` 本体要 Android + DataStore,单测里起不来)。
|
||||
*
|
||||
* 缺省 / 旧版本遗留的未知值一律解成默认档(追加队列),绝不悄悄换成更激进的档位。
|
||||
*/
|
||||
fun String?.toInterruptMode(): InterruptMode = when (this) {
|
||||
InterruptMode.Interrupt.name -> InterruptMode.Interrupt
|
||||
InterruptMode.Parallel.name -> InterruptMode.Parallel
|
||||
else -> InterruptMode.AppendQueue
|
||||
}
|
||||
|
||||
/**
|
||||
* 「免打扰」([AppPrefs.proactiveQuiet])没有落盘值时的取值 —— **`false` = 都弹**(横幅 + 提示音
|
||||
* + 通知栏条目)。老安装、或从没碰过这个开关的用户走的都是它。
|
||||
*
|
||||
* 两条路必须落在**同一个**值上:[AppPrefs] 那个数据类的默认参数(进程刚起来、还没读到盘时用),
|
||||
* 与 [toProactiveQuiet](盘上没有这个键时用)。分叉的症状是「开关显示关着、行为却是安静的」
|
||||
* ——一个默认就安静的 App 会让人以为通知坏了。
|
||||
*/
|
||||
internal const val PROACTIVE_QUIET_DEFAULT = false
|
||||
|
||||
/**
|
||||
* 落盘布尔 → 免打扰开关(`AppSettings` 的 `proactive_quiet` 键读取口;抽成顶层函数是为了能单测
|
||||
* ——`AppSettings` 本体要 Android + DataStore,单测里起不来)。缺省 / 盘上没有这个键
|
||||
* ⇒ [PROACTIVE_QUIET_DEFAULT]。
|
||||
*/
|
||||
internal fun Boolean?.toProactiveQuiet(): Boolean = this ?: PROACTIVE_QUIET_DEFAULT
|
||||
|
||||
/**
|
||||
* App 级偏好(非机密)。
|
||||
*
|
||||
* @param themeMode 主题深浅
|
||||
* @param showNarration 是否显示 `<action>` / `<narration>` 旁白
|
||||
* @param thinkingExpanded 思维链卡片默认是否展开
|
||||
* @param interruptMode 她正忙时按发送的行为(默认追加队列)
|
||||
* @param keepAliveEnabled 后台保持连接(常驻一条前台服务,**默认关**;判据见
|
||||
* `core.keepalive.shouldKeepAliveRun`)
|
||||
* @param proactiveQuiet 免打扰:她主动找你说话**只在通知栏提示,不在屏幕顶部弹横幅**(**默认
|
||||
* `false` = 都弹**)。⚠️ 它的语义**不是静音**:提示音与通知栏条目照旧,只是不盖住用户正在做的
|
||||
* 事。落地形状是**换一条渠道**(见 `core.notify.proactiveChannelId`)——横幅由渠道重要级决定,
|
||||
* 而重要级建完就改不了,所以只能两条渠道切着用
|
||||
*/
|
||||
data class AppPrefs(
|
||||
val themeMode: ThemeMode = ThemeMode.System,
|
||||
val showNarration: Boolean = true,
|
||||
val thinkingExpanded: Boolean = true,
|
||||
val interruptMode: InterruptMode = InterruptMode.AppendQueue,
|
||||
val keepAliveEnabled: Boolean = false,
|
||||
val proactiveQuiet: Boolean = false,
|
||||
)
|
||||
|
||||
/** 全局注入 App 偏好,供聊天渲染等深层组件读取。 */
|
||||
val LocalAppPrefs = staticCompositionLocalOf { AppPrefs() }
|
||||
|
||||
class AppSettings(context: Context) {
|
||||
|
||||
private val store: DataStore<Preferences> = context.applicationContext.appDataStore
|
||||
|
||||
/**
|
||||
* 与 `AppSettings` 同寿的收集 scope —— 也就是**与进程同寿**(`AppContainer` 在
|
||||
* `Application.onCreate` 里建它)。刻意**不取消**:[current] 的下一个读者可以是任何时刻的
|
||||
* 任何一次通知落地,没有比进程结束更晚的时刻需要它。
|
||||
*/
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||
|
||||
val data: Flow<AppPrefs> = store.data.map { prefs ->
|
||||
AppPrefs(
|
||||
themeMode = prefs[KEY_THEME_MODE].toThemeMode(),
|
||||
showNarration = prefs[KEY_SHOW_NARRATION] ?: true,
|
||||
thinkingExpanded = prefs[KEY_THINKING_EXPANDED] ?: true,
|
||||
interruptMode = prefs[KEY_INTERRUPT_MODE].toInterruptMode(),
|
||||
keepAliveEnabled = prefs[KEY_KEEP_ALIVE] ?: false,
|
||||
proactiveQuiet = prefs[KEY_PROACTIVE_QUIET].toProactiveQuiet(),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* [data] 的**热副本**:进程里任何地方都读得到「当下是什么偏好」。
|
||||
*
|
||||
* ## 为什么需要它
|
||||
* 发通知那一刻**没有挂起上下文**:帧的落地是一次同步回调(`ProactiveNotifier.post` 不是
|
||||
* `suspend`,也不该是——它是连接那条已建立的流水线上的最后一跳),而 DataStore 的读取是挂起
|
||||
* 的。所以只能由这里常驻订阅、把最近一次读数摆在一个同步读得到的地方(`StateFlow.value`)。
|
||||
*
|
||||
* ## 为什么是 `Eagerly`
|
||||
* 它的读者(发通知那条路)随时可能来,而「等有人订阅了再开始读」意味着第一次落地时读到的是
|
||||
* 初始值。`Eagerly` 让这一次读盘在设置对象构造时就发生 —— 与 UI 那边(`CyreneRoot` /
|
||||
* `SettingsScreen` 各自 `collectAsState`)读的是同一份上游数据,没有多出一次磁盘 IO 的量级。
|
||||
*
|
||||
* ⚠️ 它有一个**毫秒级**的不准确窗口:进程刚起来、第一次读盘还没回来时,[StateFlow.value] 是
|
||||
* [AppPrefs] 的默认值(免打扰 = false ⇒ 弹横幅)。要撞上它,得在「本对象构造完 → DataStore
|
||||
* 第一次读完」之间就有一条她的主动消息走完建连、认证、收帧(`ProactiveFrameIntake`)——
|
||||
* 实际上到不了那么快。**代价封顶是「开了免打扰的用户在冷启动瞬间多看到一次横幅」**,
|
||||
* 而不是丢通知。
|
||||
*/
|
||||
val current: StateFlow<AppPrefs> = data.stateIn(scope, SharingStarted.Eagerly, AppPrefs())
|
||||
|
||||
suspend fun setThemeMode(mode: ThemeMode) {
|
||||
store.edit { it[KEY_THEME_MODE] = mode.name }
|
||||
}
|
||||
|
||||
suspend fun setShowNarration(enabled: Boolean) {
|
||||
store.edit { it[KEY_SHOW_NARRATION] = enabled }
|
||||
}
|
||||
|
||||
suspend fun setThinkingExpanded(enabled: Boolean) {
|
||||
store.edit { it[KEY_THINKING_EXPANDED] = enabled }
|
||||
}
|
||||
|
||||
suspend fun setInterruptMode(mode: InterruptMode) {
|
||||
store.edit { it[KEY_INTERRUPT_MODE] = mode.name }
|
||||
}
|
||||
|
||||
suspend fun setKeepAliveEnabled(enabled: Boolean) {
|
||||
store.edit { it[KEY_KEEP_ALIVE] = enabled }
|
||||
}
|
||||
|
||||
/**
|
||||
* 写「免打扰」。**写这一处就够了**:发通知那条路每次现读 [current](见它的 KDoc),所以这里
|
||||
* 一落盘、DataStore 一发出新值,**下一条**通知就走新渠道——不必重启 App,也不必在这里再去
|
||||
* 通知谁。
|
||||
*/
|
||||
suspend fun setProactiveQuiet(enabled: Boolean) {
|
||||
store.edit { it[KEY_PROACTIVE_QUIET] = enabled }
|
||||
}
|
||||
|
||||
private fun String?.toThemeMode(): ThemeMode = when (this) {
|
||||
ThemeMode.Light.name -> ThemeMode.Light
|
||||
ThemeMode.Dark.name -> ThemeMode.Dark
|
||||
else -> ThemeMode.System
|
||||
}
|
||||
|
||||
private companion object {
|
||||
val KEY_THEME_MODE = stringPreferencesKey("theme_mode")
|
||||
val KEY_SHOW_NARRATION = booleanPreferencesKey("show_narration")
|
||||
val KEY_THINKING_EXPANDED = booleanPreferencesKey("thinking_expanded")
|
||||
val KEY_INTERRUPT_MODE = stringPreferencesKey("interrupt_mode")
|
||||
val KEY_KEEP_ALIVE = booleanPreferencesKey("keep_alive_enabled")
|
||||
val KEY_PROACTIVE_QUIET = booleanPreferencesKey("proactive_quiet")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package top.yeij.cyrene.core.prefs
|
||||
|
||||
import android.content.Context
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import androidx.datastore.preferences.preferencesDataStore
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import top.yeij.cyrene.data.api.ApiClientFactory
|
||||
import top.yeij.cyrene.data.api.dto.EnumTableDto
|
||||
|
||||
private val Context.enumDataStore: DataStore<Preferences> by preferencesDataStore("cyrene_enums")
|
||||
|
||||
/**
|
||||
* 枚举权威表的本地回退快照(§1.1)。
|
||||
*
|
||||
* @param table 上次成功拉取的权威表;拉取失败时先用它渲染(绝不因此清空角标)
|
||||
* @param fingerprint `baseUrl + username`:换服务器/账号后快照作废
|
||||
*/
|
||||
data class EnumPrefs(
|
||||
val table: EnumTableDto? = null,
|
||||
val fingerprint: String = "",
|
||||
) {
|
||||
|
||||
/** 指纹匹配才认这份快照——绝不把别的服务器/账号的标签拿来用。 */
|
||||
fun tableFor(fingerprint: String): EnumTableDto? = table?.takeIf { this.fingerprint == fingerprint }
|
||||
}
|
||||
|
||||
class EnumPreferences(context: Context) {
|
||||
|
||||
private val json = ApiClientFactory.json
|
||||
|
||||
private val store: DataStore<Preferences> = context.applicationContext.enumDataStore
|
||||
|
||||
val data: Flow<EnumPrefs> = store.data.map { prefs ->
|
||||
EnumPrefs(
|
||||
table = prefs[KEY_TABLE].decodeTable(),
|
||||
fingerprint = prefs[KEY_FINGERPRINT].orEmpty(),
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun save(table: EnumTableDto, fingerprint: String) {
|
||||
store.edit { prefs ->
|
||||
prefs[KEY_TABLE] = json.encodeToString(EnumTableDto.serializer(), table)
|
||||
prefs[KEY_FINGERPRINT] = fingerprint
|
||||
}
|
||||
}
|
||||
|
||||
/** 登出 / 切换服务器时清空。 */
|
||||
suspend fun clear() {
|
||||
store.edit { it.clear() }
|
||||
}
|
||||
|
||||
private fun String?.decodeTable(): EnumTableDto? = this
|
||||
?.let { runCatching { json.decodeFromString(EnumTableDto.serializer(), it) }.getOrNull() }
|
||||
|
||||
private companion object {
|
||||
val KEY_TABLE = stringPreferencesKey("enum_table")
|
||||
val KEY_FINGERPRINT = stringPreferencesKey("enum_fingerprint")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
package top.yeij.cyrene.core.prefs
|
||||
|
||||
import android.content.Context
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import androidx.datastore.preferences.core.longPreferencesKey
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import androidx.datastore.preferences.preferencesDataStore
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.serialization.builtins.ListSerializer
|
||||
import top.yeij.cyrene.data.api.ApiClientFactory
|
||||
import top.yeij.cyrene.data.api.dto.SessionSummaryDto
|
||||
|
||||
private val Context.sessionDataStore: DataStore<Preferences> by preferencesDataStore("cyrene_sessions")
|
||||
|
||||
/**
|
||||
* 会话列表的本地回退快照与显示态(§3.1)。
|
||||
*
|
||||
* @param snapshot 上次成功拉取的会话列表;服务端不可用时用它先渲染
|
||||
* @param snapshotAt 快照时间(用于「同步于 N 分钟前」)
|
||||
* @param fingerprint [accountFingerprint]:换服务器/账号后快照作废
|
||||
* @param activeSessionId 上次停留的会话
|
||||
*/
|
||||
data class SessionPrefs(
|
||||
val snapshot: List<SessionSummaryDto> = emptyList(),
|
||||
val snapshotAt: Long = 0L,
|
||||
val fingerprint: String = "",
|
||||
val activeSessionId: String = SessionSummaryDto.MAIN_SESSION_ID,
|
||||
) {
|
||||
|
||||
/** 指纹匹配才认这份快照——绝不把别的服务器/账号的会话列表拿来用。 */
|
||||
fun snapshotFor(fingerprint: String): List<SessionSummaryDto> =
|
||||
snapshot.takeIf { this.fingerprint == fingerprint }.orEmpty()
|
||||
}
|
||||
|
||||
/**
|
||||
* 账号指纹(纯函数):`baseUrl + username`。回答的是「**这份本地状态属于哪个环境**」。
|
||||
*
|
||||
* 抽成一个函数的理由是**全仓只能有一份口径**:这类判据一旦各拼一套字符串,差一个分隔符
|
||||
* 就会在某个闸门上漏过去——而漏过去的后果是把别的服务器/账号的内容摆到屏幕上。
|
||||
* 现在的两个消费者是 [SessionPrefs.snapshotFor](会话快照作不作废)与聊天页的 VM key
|
||||
* (`chatViewModelKey`:那个 VM 是不是本环境的)。改口径就是改**两者**,不要就地另拼。
|
||||
*
|
||||
* 取用户名而不是角色:角色是同一个人在服务端被授的权限,账号身份由**服务器地址 + 用户名**
|
||||
* 共同确定;要让指纹对不上,只能是换了服务器或换了账号,那两件事都该让本地状态作废。
|
||||
*/
|
||||
internal fun accountFingerprint(baseUrl: String, username: String?): String =
|
||||
"$baseUrl|${username.orEmpty()}"
|
||||
|
||||
class SessionPreferences(context: Context) {
|
||||
|
||||
private val json = ApiClientFactory.json
|
||||
|
||||
private val store: DataStore<Preferences> = context.applicationContext.sessionDataStore
|
||||
|
||||
val data: Flow<SessionPrefs> = store.data.map { prefs ->
|
||||
SessionPrefs(
|
||||
snapshot = prefs[KEY_SNAPSHOT].decodeSessions(),
|
||||
snapshotAt = prefs[KEY_SNAPSHOT_AT] ?: 0L,
|
||||
fingerprint = prefs[KEY_FINGERPRINT].orEmpty(),
|
||||
activeSessionId = prefs[KEY_ACTIVE] ?: SessionSummaryDto.MAIN_SESSION_ID,
|
||||
)
|
||||
}
|
||||
|
||||
/** 拉取成功:整体替换快照(不做 merge,避免已删除的会话变成幽灵)。 */
|
||||
suspend fun saveSnapshot(sessions: List<SessionSummaryDto>, fingerprint: String) {
|
||||
store.edit { prefs ->
|
||||
prefs[KEY_SNAPSHOT] = json.encodeToString(SessionsSerializer, sessions)
|
||||
prefs[KEY_SNAPSHOT_AT] = System.currentTimeMillis()
|
||||
prefs[KEY_FINGERPRINT] = fingerprint
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun setActive(sessionId: String) {
|
||||
store.edit { prefs -> prefs[KEY_ACTIVE] = sessionId }
|
||||
}
|
||||
|
||||
/** 删除会话:必要时把 active 退回主会话,并同步从快照里剔除。 */
|
||||
suspend fun removeSession(sessionId: String) {
|
||||
store.edit { prefs ->
|
||||
if (prefs[KEY_ACTIVE] == sessionId) {
|
||||
prefs[KEY_ACTIVE] = SessionSummaryDto.MAIN_SESSION_ID
|
||||
}
|
||||
|
||||
val remaining = prefs[KEY_SNAPSHOT].decodeSessions().filterNot { it.id == sessionId }
|
||||
prefs[KEY_SNAPSHOT] = json.encodeToString(SessionsSerializer, remaining)
|
||||
}
|
||||
}
|
||||
|
||||
/** 登出时清空:快照、指纹、active 一起走,下次登录不会先闪一份旧列表。 */
|
||||
suspend fun clear() {
|
||||
store.edit { it.clear() }
|
||||
}
|
||||
|
||||
private fun String?.decodeSessions(): List<SessionSummaryDto> = this
|
||||
?.let { runCatching { json.decodeFromString(SessionsSerializer, it) }.getOrNull() }
|
||||
.orEmpty()
|
||||
|
||||
private companion object {
|
||||
val KEY_SNAPSHOT = stringPreferencesKey("sessions_snapshot")
|
||||
val KEY_SNAPSHOT_AT = longPreferencesKey("sessions_snapshot_at")
|
||||
val KEY_FINGERPRINT = stringPreferencesKey("sessions_fingerprint")
|
||||
val KEY_ACTIVE = stringPreferencesKey("active_session_id")
|
||||
|
||||
val SessionsSerializer = ListSerializer(SessionSummaryDto.serializer())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
package top.yeij.cyrene.core.session
|
||||
|
||||
/**
|
||||
* 本端**同时**为一个账号留几个会话槽位(N=3,[chatSessionEvictions] 的默认 `keep`)。
|
||||
*
|
||||
* 为什么是 3(**主人拍板的口径**):它要在「切回去时那几屏东西还在」与「不留着一堆没人再看的
|
||||
* 消息列表」之间取一个数。驱动这个数的是**内存**——每个槽位留着一整页已落库消息(次级会话里可能
|
||||
* 是内联 base64 图片),而 S4 登记的正是「访问过的会话一个都不回收」。不跟着会话数走的理由也在
|
||||
* 这里:它是内存上限,不是「用户可能有多少会话」。
|
||||
*/
|
||||
const val CHAT_SESSION_SLOT_KEEP = 3
|
||||
|
||||
/**
|
||||
* 一格槽位在**被判定那一刻**的样子([chatSessionEvictions] 的入参,**纯值**)。
|
||||
*
|
||||
* 三项都由调用方现读现喂,判据自己不看 VM、不读表、不碰时钟(先例:`ui.sessions.silentRefreshStep`
|
||||
* 只回结论,账本与时钟都由调用方喂)——所以它在 JVM 上可以穷举。
|
||||
*
|
||||
* @param key 槽位的稳定标识(`账号指纹|会话 id`)。它就是**结论的载体**:判据只把它原样列出来,
|
||||
* 「拿这个 key 去哪里找槽位」是调用方的事。
|
||||
* @param sessionId 会话 id。**它和 [key] 不是同一件事**:key 里还带着账号指纹(换账号之后同一个
|
||||
* 会话 id 是另一格),而这一位只回答一个问题——**它是不是主会话**(主会话永不回收,见
|
||||
* [chatSessionEvictions] 第 1 条)。两样都留着是刻意的:把「主会话」这件事换个说法搬出判据,
|
||||
* 它就再也钉不住了(那是本任务的红线之一)。
|
||||
* @param lastUsedAt **最后一次成为当前会话**的时刻(毫秒)。「最近」的口径就是它,理由见
|
||||
* [chatSessionEvictions]。`0` = 建出来还没打开过(组合被丢弃时留下的空槽位)。
|
||||
* @param holdsUnsentWork 这一格里还有**本端唯一一份**的内容(没发出去的草稿 / 没上路的发送)。
|
||||
* 它必须一路传到这里,是因为「回收」是一条**回不来**的动作:判据要在动手之前就能拦下
|
||||
* 「把用户还没发出去的字一起收掉」——那比「多留一格」严重得多(红线:不许静默丢内容)。
|
||||
*/
|
||||
internal data class ChatSessionUsage(
|
||||
val key: String,
|
||||
val sessionId: String,
|
||||
val lastUsedAt: Long,
|
||||
val holdsUnsentWork: Boolean,
|
||||
)
|
||||
|
||||
/**
|
||||
* 这一刻该交出去哪几格(**纯函数**,[ChatSessionSlots] 与测试共用)。返回**要回收的槽位 key**,
|
||||
* 最该走的在最前(最旧的最先);没有要回收的就返回空表。
|
||||
*
|
||||
* ## 「最近」的口径:最后一次**成为当前会话**的时刻
|
||||
* 三选一里选的它,理由是另外两个都答不对这个问题:
|
||||
* - 「最后一次收到这个会话的帧」**不是用户的活动**:共享连接上别的会话的帧一直在来(服务端不按
|
||||
* 会话订阅),一条后台广播就能把一个用户根本没再打开过的会话顶成「最近」。
|
||||
* - 「最后一次有新消息」同理,而且是**别人**的活动(她在次级会话里说一句,本端就要为它留一格)。
|
||||
* - 「最后一次成为当前会话」才是「用户切回去时希望看到什么」这件事本身:槽位存在的意义就是
|
||||
* 那一屏不用重新加载,而**用户看得见哪个会话**只有这一个时刻能表达。
|
||||
*
|
||||
* 它由 `ChatScreen` 在**组合到某个会话时**写入(那正是「它成了当前会话」这一刻),所以判据这边
|
||||
* 只需要比大小,不需要任何时钟:谁的时刻新,谁就留下。同一毫秒并列时按**账本里的先后**退回
|
||||
* ([usages] 的顺序 = 槽位被建出来的顺序,见 [ChatSessionSlots]),也就是「谁先建谁先走」。
|
||||
*
|
||||
* ## 判据与优先级(自上而下,先命中先返回)
|
||||
* 1. **主会话永不回收**:它是抽屉与主流程的锚——用户随时会退回它,而它那一格正是「一进 App
|
||||
* 就有东西看」的来处。这一条**不看** `keep`,也不占「最近」的名额:主会话就是主会话,
|
||||
* 不看它多久没被打开过。
|
||||
* 2. **屏幕上那一格永不回收**(`key == [currentKey]`):它的 VM 一死,屏幕上那一段就再也不会更新
|
||||
* (帧收不到、`viewModelScope` 已经取消 ⇒ 连发送都会静默不发),要等下一次重组才会自己修回来
|
||||
* —— 那是「回收」绝不能带来的一种状态。这一条与主会话同属「按身份豁免」,与时刻无关
|
||||
* ([currentKey] 那一格通常本来就是最新的,但不靠这件事成立)。
|
||||
* 3. **还有没交代完的事的槽位不回收**([ChatSessionUsage.holdsUnsentWork]):那些内容只在本端
|
||||
* 这一份(草稿、排队等连接的发送),回收掉就**回不来**了。这一条压过 `keep` 的语义——
|
||||
* 它宁可让本端多留一格,也不做一件不可逆的事。
|
||||
* 4. 其余按 [ChatSessionUsage.lastUsedAt] 从新到旧留 `keep` 格,**连上面三条占住的一起算**:
|
||||
* `keep` 是「本端同时保留几格」这个内存目标,所以被豁免的那些**占名额**——不然「最多 3 格」
|
||||
* 在豁免生效时会变成 3 + 豁免数,那个数就没有上限了。(真实使用里豁免的那些几乎总是
|
||||
* 「刚刚还在用的」,本来就在最近 3 个里,这条只在极端情况下才真的挤掉谁。)
|
||||
*
|
||||
* 判据里除了这些入参什么都不看,才能穷举、才能在 JVM 上单测(先例:`ui.chat.reconnectPull`、
|
||||
* `ui.sessions.silentRefreshStep`)。
|
||||
*
|
||||
* ## ⚠️ 它**只**回答「交出去哪几格」
|
||||
* 「交出去」具体是什么动作(清掉那份 `ViewModelStore`、于是这一格的 VM 随 `onCleared` 一起走)
|
||||
* 是调用方的事,而且**必须**是那份 store 自己的 `clear()`:连接是全 App 共享的那一条,它的生死
|
||||
* 只由登录态表达(主人是 `SessionsViewModel`),回收一格**绝不能**碰它——回收一格的状态与
|
||||
* 「别人的流、别人的未读」是两件事,这条判据里连一个字节的连接概念都没有。
|
||||
*
|
||||
* @param usages 账本里的全部槽位(顺序有意义,见「同一毫秒并列」那一句)。
|
||||
* @param mainSessionId 主会话的 id(由装配处注入,判据不认识那个常量本身)。
|
||||
* @param currentKey **此刻屏幕上**那一格的 key([ChatSessionSlots] 刚记完账的那一格)。
|
||||
* @param keep 本端**同时**保留几格(含主会话、屏幕上那一格与「还有没交代完的事」的那些),
|
||||
* 默认 [CHAT_SESSION_SLOT_KEEP]。
|
||||
*/
|
||||
internal fun chatSessionEvictions(
|
||||
usages: List<ChatSessionUsage>,
|
||||
mainSessionId: String,
|
||||
currentKey: String,
|
||||
keep: Int = CHAT_SESSION_SLOT_KEEP,
|
||||
): List<String> {
|
||||
val reclaimable = usages
|
||||
.filter {
|
||||
it.sessionId != mainSessionId && it.key != currentKey && !it.holdsUnsentWork
|
||||
}
|
||||
.sortedBy { it.lastUsedAt }
|
||||
val budget = keep - (usages.size - reclaimable.size)
|
||||
val surplus = reclaimable.size - budget
|
||||
if (surplus <= 0) return emptyList()
|
||||
return reclaimable.take(surplus).map { it.key }
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
package top.yeij.cyrene.core.session
|
||||
|
||||
import androidx.lifecycle.ViewModelStore
|
||||
import androidx.lifecycle.ViewModelStoreOwner
|
||||
|
||||
/**
|
||||
* 本端留着的**会话槽位**(`账号指纹|会话 id` → 那一格的聊天状态)。S4 的解药。
|
||||
*
|
||||
* ## 它回答的是哪一个问题
|
||||
* `ChatViewModel` 是**按会话**建的(`sessionId` 是构造参数),于是「切走 / 删会话都不回收」意味着
|
||||
* 访问过 N 个会话就常驻 N 份消息列表(含内联 base64 图片)、N 份对共享连接的订阅、以及**每次重连
|
||||
* 各自一轮** `after` 补拉。这里就是那个「N」的闸门:**一格一个 `ViewModelStore`**,超出的按
|
||||
* [chatSessionEvictions] 的结论交出整格。
|
||||
*
|
||||
* ## 为什么一格一个 `ViewModelStore`(而不是给 key 加一代号)
|
||||
* 回收的本体动作是「让那一格的 VM 真正死掉」:`ViewModelStore.clear()` 会走 `ViewModel.onCleared()`
|
||||
* 并取消 `viewModelScope`——于是**订阅、补拉、正在飞的那些请求一起停下**。此前每一格都塞在
|
||||
* Activity 级那个 store 里,而它**只有整体 `clear()`**,没有「按 key 交出一格」的入口:那种情况下
|
||||
* 只能让 key 换一代、把旧实例**留在 store 里**(它照样活着、照样收帧——那正是 S4 的另一半没修掉
|
||||
* 的样子)。一格一份 store 之后,`clear()` 的粒度就正好是「这一格」。
|
||||
*
|
||||
* ## 回收**不碰**共享连接
|
||||
* 连接是全 App 唯一的那一条(`AppContainer.chatSocket`),它的生死只由登录态表达
|
||||
* (主人是 `SessionsViewModel`)。本类碰的只有「自己那一格的 store」:掐掉的是这一格的订阅,
|
||||
* 别的会话的流、别的会话的未读、以及那条连接本身一个字节都不动。
|
||||
*
|
||||
* ## 谁碰它、什么时候(全部在主线程上)
|
||||
* - **`ChatScreen`**:组合到某个会话时 [slotFor] 取(或建)槽位——VM 必须有地方建,而槽位就是
|
||||
* 那一格的存放处,所以这一步没法挪到组合之后;组合完成之后 [onOpened] 记账 + 回收。
|
||||
* - **`SessionsViewModel`**:本端**唯一**的删除入口,删成功后 [reclaim](那一格跟着会话一起走);
|
||||
* 登出 / 换账号时 [clear](与快照 / 已读游标 / [GoneSessions] 同一档:换代之后本端什么都不知道)。
|
||||
* - 三者都在主线程上(组合、`viewModelScope` 的默认派发器),所以账本本身不加锁——它的每一个
|
||||
* 读写点都在同一个线程上。
|
||||
*
|
||||
* ## 为什么不持久化
|
||||
* 槽位里装的是**进程内存里的状态**(消息列表、草稿、流式缓冲),它没有可以落盘的形式,而它的
|
||||
* 内容全部另有来源(服务端的 `GET /messages` 那一页 + 服务端的幂等键)。重启之后本端重新拉,
|
||||
* 与 [GoneSessions] 「不持久化」是同一个理由。
|
||||
*/
|
||||
class ChatSessionSlots(private val mainSessionId: String) {
|
||||
|
||||
/**
|
||||
* 账本。用 `LinkedHashMap` 只为一件事:**同一毫秒并列**时 [chatSessionEvictions] 的稳定排序会
|
||||
* 退回这个插入顺序(先建出来的先走)。除此之外顺序不表达任何东西——「谁最近」一律看
|
||||
* [ChatSessionSlot] 里的 `lastUsedAt`。
|
||||
*/
|
||||
private val slots = linkedMapOf<String, ChatSessionSlot>()
|
||||
|
||||
/**
|
||||
* 取这个会话的槽位(没有就建一个)。**组合期间**调用。
|
||||
*
|
||||
* 它**不记账**:谁最近用过由 [onOpened] 写。所以就算这一段组合被丢弃(组合没走完),留下的也
|
||||
* 只是一个 `lastUsedAt` 还是 `0` 的空槽位,下一次回收就会把它扫掉(它必然是最旧的那一格)。
|
||||
*/
|
||||
fun slotFor(account: String, sessionId: String): ChatSessionSlot =
|
||||
slots.getOrPut(keyOf(account, sessionId)) { ChatSessionSlot(sessionId) }
|
||||
|
||||
/**
|
||||
* 这个会话**刚刚成为当前会话**:记下它是「最近」的那一格,然后按 [chatSessionEvictions] 回收。
|
||||
*
|
||||
* 记账与回收在同一处,是因为回收的判据就是这份账本本身(先例:`ui.sessions.silentRefreshStep`
|
||||
* 把「该做什么」与「账本走成什么样」一起回):拆开就会给出「判了却忘了推进账本」的机会,
|
||||
* 而那正好会让最该留下的那一格被当成最旧的收掉。同一处也才拿得到那个 key —— 判据要
|
||||
* **按身份**豁免的就是刚记完账这一格(`chatSessionEvictions` 的 `currentKey`)。
|
||||
*
|
||||
* @param holdsUnsentWork 现读的一格真相(见 [ChatSessionUsage.holdsUnsentWork])。收的是一个
|
||||
* **探针**而不是一个布尔快照,是因为回收发生在**别的**会话被打开的那一刻 —— 而那一刻正是
|
||||
* 用户可能刚在前一个会话里打了草稿、还没切走的时候。快照会把它读成「干净」,把没发出去的
|
||||
* 字一起回收掉(那是一条回不来的动作)。
|
||||
*/
|
||||
fun onOpened(
|
||||
account: String,
|
||||
sessionId: String,
|
||||
holdsUnsentWork: () -> Boolean,
|
||||
nowMillis: Long,
|
||||
) {
|
||||
val key = keyOf(account, sessionId)
|
||||
slotFor(account, sessionId).opened(holdsUnsentWork, nowMillis)
|
||||
sweep(currentKey = key)
|
||||
}
|
||||
|
||||
/**
|
||||
* 定点回收一格(删会话):把那份 store 交出去,那个 VM 随 `onCleared` 一起走,本类不再引用它。
|
||||
*
|
||||
* 与 [GoneSessions] 的关系:那是「本端已知它没了」这件事的**第一半**(让还活着的 VM 别再去发
|
||||
* 注定 404 的补拉),这里是**第二半**(那一格本来就该跟着会话一起消失)。两半分开写在两处,
|
||||
* 是因为它们回答的问题不同——一个答「还值不值得发请求」,一个答「还留不留这一格状态」,
|
||||
* 而后者要动 VM 的生命周期,不该被前者拽住(`ui.chat.reconnectPull` 只读前者)。
|
||||
*/
|
||||
fun reclaim(account: String, sessionId: String) {
|
||||
slots.remove(keyOf(account, sessionId))?.clear()
|
||||
}
|
||||
|
||||
/**
|
||||
* 换账号 / 登出:本端**一格都不留**。
|
||||
*
|
||||
* 与快照 / 已读游标 / [GoneSessions] 是同一档纪律(`SessionsViewModel` 的登出分支一处清完):
|
||||
* 槽位里的东西全部属于**上一个环境**,留着只会在下一次登录后变成一份没人认领的常驻内存
|
||||
* (而它带着的那个 VM 还在订阅连接、还会在重连时补拉)。这一条也顺带把 [ChatSessionSlot] 的
|
||||
* 那条「主会话永不回收」的例外收干净:换账号时连主会话那一格也不留。
|
||||
*
|
||||
* 代价说清楚:登出(含令牌失效那条路径)之后**本端未发出的草稿会跟着没了**。这与既有口径一致
|
||||
* ——登出时快照、已读游标、已删会话的结论都是当场清掉的(那些也都没落盘)。
|
||||
*/
|
||||
fun clear() {
|
||||
slots.values.forEach { it.clear() }
|
||||
slots.clear()
|
||||
}
|
||||
|
||||
/**
|
||||
* 按 [chatSessionEvictions] 的结论交出超出的那几格(「最近」的口径见那个函数的 KDoc)。
|
||||
*
|
||||
* @param currentKey 刚记完账、此刻在屏幕上那一格的 key(它按身份豁免,见判据第 2 条)。
|
||||
*/
|
||||
private fun sweep(currentKey: String) {
|
||||
val usages = slots.map { (key, slot) -> slot.usage(key) }
|
||||
chatSessionEvictions(usages, mainSessionId, currentKey)
|
||||
.forEach { key -> slots.remove(key)?.clear() }
|
||||
}
|
||||
|
||||
/** 与聊天页的 VM key 无关:这是**槽位**的身份,账号指纹在里面是为了换账号时天然分格。 */
|
||||
private fun keyOf(account: String, sessionId: String): String = "$account|$sessionId"
|
||||
}
|
||||
|
||||
/**
|
||||
* 一个会话的槽位:**它自己的那份 `ViewModelStore`**,外加这项回收策略要的两个记账位。
|
||||
*
|
||||
* 装在这里的 VM 由 `ChatScreen` 经它取出(`viewModel(viewModelStoreOwner = 槽位, key = <账号指纹|会话 id>)`):
|
||||
* key 仍然是 `chatViewModelKey` 那一份口径,只是现在**配着一份按会话分的 store** 用。
|
||||
*
|
||||
* 它**不持有** VM 的类型:本类只知道「怎么把这一格交出去」([clear])与「它还有没有没交代完的事」
|
||||
* ([opened] 收下的那个探针)。这样 `core.session` 不必认识 `ui.chat` 的任何类型,而两件事
|
||||
* ——「槽位怎么算最近」与「VM 里哪一位算没交代完」——各自待在自己那一层。
|
||||
*/
|
||||
class ChatSessionSlot internal constructor(
|
||||
/** 本槽位的会话 id(槽位身份的一半:账号指纹在 [ChatSessionSlots] 的账本 key 里)。 */
|
||||
val sessionId: String,
|
||||
) : ViewModelStoreOwner {
|
||||
|
||||
private val store = ViewModelStore()
|
||||
|
||||
override val viewModelStore: ViewModelStore
|
||||
get() = store
|
||||
|
||||
/**
|
||||
* 最后一次成为当前会话的时刻(毫秒);`0` = 建出来还没打开过。**只增不改**。
|
||||
*
|
||||
* 由 `ChatSessionSlots.onOpened` 写(那里是「它成了当前会话」的唯一表达处),本类不自己读时钟。
|
||||
* 不进 [usage] 以外的任何地方——「最近」的判据只认它,而判据只吃 [ChatSessionUsage]。
|
||||
*/
|
||||
private var lastUsedAt: Long = 0L
|
||||
|
||||
/**
|
||||
* 这一格里「还有没有没交代完的事」的**现读**探针(见 [ChatSessionSlot.opened] 的说明)。
|
||||
* 默认恒 `false`:还没打开过的一格当然没有。
|
||||
*/
|
||||
private var holdsUnsentWork: () -> Boolean = { false }
|
||||
|
||||
/** 这一格成了当前会话:记下该问谁「还有没有没交代完的事」,并更新「最近」这一位。 */
|
||||
internal fun opened(holdsUnsentWork: () -> Boolean, nowMillis: Long) {
|
||||
this.holdsUnsentWork = holdsUnsentWork
|
||||
lastUsedAt = nowMillis
|
||||
}
|
||||
|
||||
/** 交给判据的样子(判据不认识本类,见 [ChatSessionUsage])。 */
|
||||
internal fun usage(key: String): ChatSessionUsage = ChatSessionUsage(
|
||||
key = key,
|
||||
sessionId = sessionId,
|
||||
lastUsedAt = lastUsedAt,
|
||||
holdsUnsentWork = holdsUnsentWork(),
|
||||
)
|
||||
|
||||
/**
|
||||
* 交出这一格:`ViewModelStore.clear()` ⇒ 那一格的 `ViewModel.onCleared()` + `viewModelScope`
|
||||
* 取消(订阅、补拉、在飞的请求一起停),随后**松开探针**。
|
||||
*
|
||||
* 松开探针不能省:它捕获着那个 VM(否则就成了一份「已经交出去、却还被人拎着」的引用,
|
||||
* 那一格的消息列表会跟着这条引用活到进程结束——正是本类要断掉的那根线)。
|
||||
*/
|
||||
internal fun clear() {
|
||||
store.clear()
|
||||
holdsUnsentWork = { false }
|
||||
}
|
||||
}
|
||||
Loaded 100 of 410 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user