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
-29
@@ -1,35 +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
|
||||
|
||||
# 主人从手机丢进仓库根当素材用的图(例如换图标用的原图)——只用不存,绝不进仓库
|
||||
/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.
|
||||
@@ -0,0 +1,78 @@
|
||||
plugins {
|
||||
alias(libs.plugins.android.application)
|
||||
alias(libs.plugins.kotlin.compose)
|
||||
alias(libs.plugins.kotlin.serialization)
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "top.yeij.cyrene"
|
||||
compileSdk = 37
|
||||
buildToolsVersion = "37.0.0"
|
||||
|
||||
defaultConfig {
|
||||
applicationId = "top.yeij.cyrene"
|
||||
minSdk = 33
|
||||
targetSdk = 37
|
||||
versionCode = 1
|
||||
versionName = "0.1.0"
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
isMinifyEnabled = false
|
||||
proguardFiles(
|
||||
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||
"proguard-rules.pro",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
}
|
||||
|
||||
buildFeatures {
|
||||
compose = true
|
||||
buildConfig = true
|
||||
}
|
||||
}
|
||||
|
||||
kotlin {
|
||||
compilerOptions {
|
||||
jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17)
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(libs.androidx.core.ktx)
|
||||
implementation(libs.androidx.lifecycle.runtime.ktx)
|
||||
implementation(libs.androidx.lifecycle.viewmodel.compose)
|
||||
implementation(libs.androidx.activity.compose)
|
||||
|
||||
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)
|
||||
|
||||
implementation(libs.miuix.ui)
|
||||
implementation(libs.miuix.icons)
|
||||
implementation(libs.miuix.preference)
|
||||
|
||||
implementation(libs.retrofit)
|
||||
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)
|
||||
|
||||
debugImplementation(libs.androidx.compose.ui.tooling)
|
||||
|
||||
testImplementation(libs.junit)
|
||||
}
|
||||
|
||||
Vendored
+10
@@ -0,0 +1,10 @@
|
||||
# kotlinx-serialization:保留 @Serializable 类的序列化器(M2 起启用)
|
||||
-keepattributes *Annotation*, InnerClasses
|
||||
-dontnote kotlinx.serialization.**
|
||||
|
||||
-keepclassmembers class **$$serializer {
|
||||
*** descriptor;
|
||||
}
|
||||
-keepclasseswithmembers class ** {
|
||||
kotlinx.serialization.KSerializer serializer(...);
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<!--
|
||||
「后台保持连接」用到的三个权限(设置页那个开关,默认关):
|
||||
|
||||
- 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_DATA_SYNC" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
|
||||
<!--
|
||||
「用户按住麦克风说话」用的麦克风权限(契约 v0.96 §3.15 ①)。
|
||||
|
||||
它是**运行时权限**(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=".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">
|
||||
|
||||
<!--
|
||||
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>
|
||||
|
||||
<!--
|
||||
附件预览的「用其它应用打开 / 保存」出口:`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"
|
||||
android:exported="false"
|
||||
android:grantUriPermissions="true">
|
||||
<meta-data
|
||||
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||
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>
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package top.yeij.cyrene.core.session
|
||||
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
|
||||
/**
|
||||
* 本端**已经知道服务端没有这个会话**的那几个会话 id(进程级、只在内存里、只增不减)。
|
||||
*
|
||||
* ## 它回答的是哪一个问题
|
||||
* 「这个 id 还值不值得我发一次请求」。`ChatViewModel` 是按会话建的,本端**同时**留着其中几格
|
||||
* (W52A 起是 `ChatSessionSlots` 的 N 格),它们都订阅同一条共享连接 ⇒ **每次重连各自会去补拉
|
||||
* 一轮**。会话在本端被删掉之后,那个 VM 自己并不知道(它没有别的渠道知道),于是**每一个**重连
|
||||
* 都发一次注定 `404 session_not_found` 的补拉,404 被静默吞掉(S7 登记的那一条)。
|
||||
*
|
||||
* 补拉**事前**就能判掉它,靠的就是这里:删除是**本端自己做的**,那一刻就已经知道结果了,
|
||||
* 没有任何理由等下一条请求回来才「发现」。于是这一位把「已知不存在」从「一次注定失败的往返」
|
||||
* 提前成「一次本地查询」——**不是**「重连时少拉一点」的省流量优化,而是不让一个注定失败的请求
|
||||
* 上路(它会占一条连接、写一行日志、并把 `sessionGone` 变成一个只能靠 404 才置得起来的位)。
|
||||
*
|
||||
* W52A 又给这件事加了一半:删会话时那一格**当场就被回收**(`ChatSessionSlots.reclaim`),所以
|
||||
* 大多数情况下这里拦的已经是一个不会再发生的补拉。它仍然留着,因为两件事回答的不是一个问题
|
||||
* ——这一位答「还值不值得发请求」,槽位答「还留不留这一格状态」,而**本端删掉一个会话**与
|
||||
* **那个会话在别处被删掉**(只能靠 404 发现)是两条不同的路,后者压根不经过这里。
|
||||
*
|
||||
* ## 为什么是进程级、为什么**不**持久化
|
||||
* - 进程级(挂在 `AppContainer` 上,与那条共享连接同一层):写它的是 `SessionsViewModel`
|
||||
* (本端唯一的删除入口),读它的是**别的** VM —— 两个 VM 之间没有别的共享面。
|
||||
* - **不持久化**:它只是「这一趟进程里已经知道的事」。重启之后本端的会话列表会重新从服务端拉
|
||||
* ([top.yeij.cyrene.core.prefs.SessionPreferences] 那份快照本来就会整体替换),「这个会话还在不在」
|
||||
* 以那一次服务端答复为准;把 id 落盘只会让一份陈旧记忆挡掉重启后的第一次拉取。
|
||||
* - **换账号要清**([clear]):id 是服务端铸的,理论上不会跨账号撞车,但这一位说的是「本端
|
||||
* 已经知道」,而换账号之后本端**什么都不知道** —— 留着上个环境的结论,是那种「差一个分隔符
|
||||
* 就漏过去」的同一类错误(先例:`accountFingerprint` 的注释)。同一处也清聊天页的槽位
|
||||
* (`ChatSessionSlots.clear`),两件事属同一档纪律。
|
||||
*/
|
||||
class GoneSessions {
|
||||
|
||||
private val ids = MutableStateFlow<Set<String>>(emptySet())
|
||||
|
||||
/** 记下「服务端已经没有这个会话了」。空串当无效值丢掉(那不是任何会话的 id)。 */
|
||||
fun mark(sessionId: String) {
|
||||
if (sessionId.isBlank()) return
|
||||
ids.update { it + sessionId }
|
||||
}
|
||||
|
||||
/** 本端是否**已经知道**它没了。只回答「知道不知道」,不回答「在不在」——不知道的一律 `false`。 */
|
||||
fun contains(sessionId: String): Boolean = sessionId in ids.value
|
||||
|
||||
/** 换账号 / 登出:本端重新回到「什么都不知道」。 */
|
||||
fun clear() {
|
||||
ids.value = emptySet()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package top.yeij.cyrene.core.theme
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import top.yukonga.miuix.kmp.theme.ColorSchemeMode
|
||||
import top.yukonga.miuix.kmp.theme.MiuixTheme
|
||||
import top.yukonga.miuix.kmp.theme.ThemeController
|
||||
|
||||
@Composable
|
||||
fun CyreneTheme(
|
||||
mode: ColorSchemeMode = ColorSchemeMode.System,
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
val controller = remember(mode) { ThemeController(mode) }
|
||||
MiuixTheme(controller = controller, content = content)
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package top.yeij.cyrene.data.api
|
||||
|
||||
import top.yeij.cyrene.data.api.dto.AdminBackthinkItemDto
|
||||
import top.yeij.cyrene.data.api.dto.AdminLogEntryDto
|
||||
import top.yeij.cyrene.data.api.dto.AdminMetricsDto
|
||||
import top.yeij.cyrene.data.api.dto.AdminTraceSpanDto
|
||||
import top.yeij.cyrene.data.api.dto.MemorySummaryDto
|
||||
import top.yeij.cyrene.data.api.dto.PersonaSummaryDto
|
||||
import retrofit2.Response
|
||||
import retrofit2.http.GET
|
||||
import retrofit2.http.Query
|
||||
|
||||
/**
|
||||
* 管理只读监控端点(api-client §2,全部 `GET`,无写操作)。
|
||||
*
|
||||
* RBAC 由服务端兜底(`viewer` 对 ②③ 档一律 403),客户端按 §2.0 权限矩阵**自行降级**,
|
||||
* 见 [top.yeij.cyrene.domain.admin.AdminAccess];这里只负责取数,不做权限判断。
|
||||
*/
|
||||
interface AdminApi {
|
||||
|
||||
/** 网关请求指标;`qps` 为近 1 秒窗口计数。 */
|
||||
@GET("api/v1/admin/observability/metrics")
|
||||
suspend fun metrics(): Response<ApiEnvelope<AdminMetricsDto>>
|
||||
|
||||
/** 最近 trace span,服务端按时间倒序(最新在前);`limit` 缺省 50、上限 500。 */
|
||||
@GET("api/v1/admin/observability/trace")
|
||||
suspend fun trace(@Query("limit") limit: Int? = null): Response<ApiEnvelope<List<AdminTraceSpanDto>>>
|
||||
|
||||
/**
|
||||
* 后端日志 tail(api-client §2「可观测性契约」,v0.35):`{"data":[{time,level,source,msg}]}`,
|
||||
* **最新在前**;`limit` 缺省 200、上限 1000(≤0 或非法回落缺省)。
|
||||
*
|
||||
* 不暴露契约里的 `?level=`:它是**最低等级**过滤,只在服务端裁剪一次结果——而界面上那排
|
||||
* 等级档要能在同一份数据上来回切换(契约 §3.12 也明确推送帧的过滤只能本地做),
|
||||
* 所以这里一律取全量、由客户端本地过滤(`ui/admin/AdminOverviewMerge.kt`)。
|
||||
*
|
||||
* RBAC 是 v0.40 的 `viewerAuth`:**viewer** 也读得到(owner / admin 本来就可以),
|
||||
* 与 `trace` / `metrics` 同档。
|
||||
*/
|
||||
@GET("api/v1/admin/observability/logs")
|
||||
suspend fun logs(@Query("limit") limit: Int? = null): Response<ApiEnvelope<List<AdminLogEntryDto>>>
|
||||
|
||||
/** 人格列表(含 admin 专属 `thinking`)。 */
|
||||
@GET("api/v1/admin/personas")
|
||||
suspend fun personas(): Response<ApiEnvelope<List<PersonaSummaryDto>>>
|
||||
|
||||
/** 私人记忆正文;`viewer` 会拿到 403。 */
|
||||
@GET("api/v1/admin/memories")
|
||||
suspend fun memories(): Response<ApiEnvelope<List<MemorySummaryDto>>>
|
||||
|
||||
/** 后台思考记录;`viewer` 会拿到 403。 */
|
||||
@GET("api/v1/admin/backthink")
|
||||
suspend fun backthink(): Response<ApiEnvelope<List<AdminBackthinkItemDto>>>
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
package top.yeij.cyrene.data.api
|
||||
|
||||
import top.yeij.cyrene.BuildConfig
|
||||
import top.yeij.cyrene.core.auth.AuthRecovery
|
||||
import top.yeij.cyrene.core.auth.NoUnauthorizedPolicy
|
||||
import top.yeij.cyrene.core.auth.UnauthorizedPolicy
|
||||
import kotlinx.serialization.json.Json
|
||||
import okhttp3.Interceptor
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
import okhttp3.logging.HttpLoggingInterceptor
|
||||
import retrofit2.Retrofit
|
||||
import retrofit2.converter.kotlinx.serialization.asConverterFactory
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
/**
|
||||
* **连接超时**(毫秒):解析 DNS + 建立 TCP 连接(以及 TLS 握手)这一段的预算,**全端共用这一个值**。
|
||||
*
|
||||
* ## 为什么从 10 秒缩到 2.5 秒(W45)
|
||||
* 它就是「**完全无网时快速进队列**」这条性质的唯一依据。W41 把这条性质寄托在一个 2.5 秒的探针上
|
||||
* (W41 那个探针 client 的 `callTimeout`),而那个探针**从不复用连接池**、每次都要现做一次冷握手
|
||||
* ——于是真机上出现了「探了,但请求一个字节都没写出去」,而它与「压根没探」在外部完全同形,推断
|
||||
* 不出是哪一档。W45 删掉探测、改成**直接试 REST** 之后,这条性质改由**连接超时**承担:连不上就在 2.5 秒
|
||||
* 内失败,消息进队列(内容一个字节都不丢,重发仍是同一个 `clientMsgId`)。
|
||||
*
|
||||
* 取值 2.5 秒:正常网络下一次 TCP 握手是毫秒到几十毫秒量级(局域网、ZeroTier / Tailscale 也远达
|
||||
* 不到秒级),2.5 秒是「人开始觉得卡」之前的量级,也与 W41 那 2.5 秒的探针预算**对齐**——换成直接
|
||||
* 试 REST 之后,「无网时用户最多等多久」因此没有变长。**绝不许**再回到 10 秒:那会让每一条离线
|
||||
* 消息的用户都白等十倍的时间,换来的只是「极端慢网下多一次成功的机会」。
|
||||
*
|
||||
* ## 它不管什么
|
||||
* 「连上了但服务端一直不回话」(半死的代理、接住连接却不回应)不是它的事——那是读 / 写超时
|
||||
* ([READ_TIMEOUT_SECONDS])与回落那一次投递自己的整通上限
|
||||
* ([top.yeij.cyrene.ui.chat.REST_FALLBACK_TIMEOUT_MILLIS])的事。两个闸各管一段,合起来才是
|
||||
* 「没网时用户最多等多久」。
|
||||
*/
|
||||
const val CONNECT_TIMEOUT_MILLIS = 2_500L
|
||||
|
||||
/** 读 / 写超时(秒):真请求(长文本、内联图片、文档字节)的耐心,与 [CONNECT_TIMEOUT_MILLIS] 分开。 */
|
||||
private const val READ_TIMEOUT_SECONDS = 60L
|
||||
|
||||
object ApiClientFactory {
|
||||
|
||||
val json: Json = Json {
|
||||
ignoreUnknownKeys = true
|
||||
explicitNulls = false
|
||||
coerceInputValues = true
|
||||
}
|
||||
|
||||
/** 登录 / 刷新 / 登出接口(不注入 token、不参与续期)。 */
|
||||
fun create(
|
||||
baseUrl: String,
|
||||
tokenProvider: () -> String? = { null },
|
||||
unauthorizedPolicy: UnauthorizedPolicy = NoUnauthorizedPolicy,
|
||||
): CyreneApi = create(baseUrl, CyreneApi::class.java, tokenProvider, unauthorizedPolicy)
|
||||
|
||||
/**
|
||||
* 通用入口:按需构建任意 Retrofit service。
|
||||
*
|
||||
* 超时**只有两个值**,都由文件顶上的常量解释:连接 [CONNECT_TIMEOUT_MILLIS](短,保证「完全
|
||||
* 无网时快速失败」)、读 / 写 [READ_TIMEOUT_SECONDS](长,真请求的耐心)。**刻意不设
|
||||
* `callTimeout`**:那会把文档字节下载与图片上传一并框住,而它们本来就是长请求;哪一次投递需要
|
||||
* 整通上限,就写在哪一次投递的调用点上(例:回落那一次的
|
||||
* [top.yeij.cyrene.ui.chat.REST_FALLBACK_TIMEOUT_MILLIS])。
|
||||
*
|
||||
* DEBUG 下挂 BASIC 日志(`logcat -s OkHttp`):`-->` 打在 `chain.proceed()` **之前**,所以
|
||||
* 「这条请求有没有发出去」与「死在连接还是死在回执」当场可分——回落那条路下一轮要靠它取证。
|
||||
* 不动 `Authorization`(BASIC 根本不打印任何头),级别**绝不许**再往上调:`/auth/refresh` 与
|
||||
* `/auth/logout` 把 refresh token 放在请求体里。
|
||||
*/
|
||||
fun <T : Any> create(
|
||||
baseUrl: String,
|
||||
service: Class<T>,
|
||||
tokenProvider: () -> String? = { null },
|
||||
unauthorizedPolicy: UnauthorizedPolicy = NoUnauthorizedPolicy,
|
||||
): T {
|
||||
val client = OkHttpClient.Builder()
|
||||
// 连接这一段刻意短(理由见 [CONNECT_TIMEOUT_MILLIS]):它是「无网时消息快速进队列」的
|
||||
// 唯一依据,所以它**不含**任何整通上限的意味。
|
||||
.connectTimeout(CONNECT_TIMEOUT_MILLIS, TimeUnit.MILLISECONDS)
|
||||
.readTimeout(READ_TIMEOUT_SECONDS, TimeUnit.SECONDS)
|
||||
.writeTimeout(READ_TIMEOUT_SECONDS, TimeUnit.SECONDS)
|
||||
.addInterceptor(AuthInterceptor(tokenProvider, unauthorizedPolicy))
|
||||
.apply {
|
||||
if (BuildConfig.DEBUG) {
|
||||
addInterceptor(
|
||||
HttpLoggingInterceptor().apply {
|
||||
// BASIC 是上限:`/auth/refresh` 与 `/auth/logout` 把 refresh token 放在**请求体**里,
|
||||
// 再高一级就会把凭据打进 logcat。
|
||||
level = HttpLoggingInterceptor.Level.BASIC
|
||||
redactHeader("Authorization")
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
.build()
|
||||
|
||||
return Retrofit.Builder()
|
||||
.baseUrl(normalizeBaseUrl(baseUrl))
|
||||
.client(client)
|
||||
.addConverterFactory(json.asConverterFactory("application/json".toMediaType()))
|
||||
.build()
|
||||
.create(service)
|
||||
}
|
||||
|
||||
/** Retrofit 要求 baseUrl 以 `/` 结尾;用户没写 scheme 时补 `http://`。 */
|
||||
fun normalizeBaseUrl(raw: String): String {
|
||||
var url = raw.trim()
|
||||
if (url.isEmpty()) return url
|
||||
if (!url.startsWith("http://") && !url.startsWith("https://")) {
|
||||
url = "http://$url"
|
||||
}
|
||||
if (!url.endsWith("/")) url += "/"
|
||||
return url
|
||||
}
|
||||
|
||||
/**
|
||||
* 注入 `X-API-Version` 与 `Authorization`,并兜住「**带了令牌仍然** 401」:
|
||||
* 先向策略要一次新令牌,拿到就用它**重试一次**;明确被拒才判终态。
|
||||
*
|
||||
* 为什么是应用层拦截器而不是 OkHttp `Authenticator`:
|
||||
* `Authenticator` 对**任何** 401 都会被调用(包括不带令牌的登录请求),而且它由
|
||||
* RetryAndFollowUp 在内部循环里驱动,重试次数(上限 20 次)不受本类控制——一个持续 401 的
|
||||
* 令牌会打出一串刷新风暴。应用层拦截器只在带令牌时介入,且重试严格一次
|
||||
* (OkHttp 允许应用层拦截器多次 `proceed()`,`calls != 1` 的检查只对网络拦截器生效)。
|
||||
*/
|
||||
private class AuthInterceptor(
|
||||
private val tokenProvider: () -> String?,
|
||||
private val unauthorizedPolicy: UnauthorizedPolicy,
|
||||
) : Interceptor {
|
||||
|
||||
override fun intercept(chain: Interceptor.Chain): Response {
|
||||
val token = tokenProvider()
|
||||
var response = chain.proceed(chain.request().withAuth(token))
|
||||
// 只有「带了 token 仍 401」才算会话被拒;登录/刷新本身不带 token,不受影响。
|
||||
if (response.code != HTTP_UNAUTHORIZED || token == null) return response
|
||||
|
||||
when (val recovery = unauthorizedPolicy.renew(token)) {
|
||||
is AuthRecovery.Renewed -> {
|
||||
response.close()
|
||||
response = chain.proceed(chain.request().withAuth(recovery.token))
|
||||
// 换上刚签发的令牌仍然 401:服务端根本不接受这个账号,再刷也没有意义,判终态。
|
||||
if (response.code == HTTP_UNAUTHORIZED) unauthorizedPolicy.onSessionEnded()
|
||||
}
|
||||
|
||||
AuthRecovery.Unrecoverable -> unauthorizedPolicy.onSessionEnded()
|
||||
|
||||
// 网络 / 5xx:会话可能还好着,原样把 401 交回去,绝不清会话。
|
||||
AuthRecovery.Transient -> Unit
|
||||
}
|
||||
return response
|
||||
}
|
||||
|
||||
private fun Request.withAuth(token: String?): Request = newBuilder()
|
||||
.header("X-API-Version", "1")
|
||||
.apply { if (token != null) header("Authorization", "Bearer $token") }
|
||||
.build()
|
||||
}
|
||||
|
||||
private const val HTTP_UNAUTHORIZED = 401
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package top.yeij.cyrene.data.api
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/** 后端统一信封:成功 `{data}`,失败 `{error:{code,message}}`。 */
|
||||
@Serializable
|
||||
data class ApiEnvelope<T>(
|
||||
val data: T? = null,
|
||||
val error: ApiError? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ApiErrorEnvelope(
|
||||
val error: ApiError? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ApiError(
|
||||
val code: String = "",
|
||||
val message: String = "",
|
||||
)
|
||||
@@ -0,0 +1,76 @@
|
||||
package top.yeij.cyrene.data.api
|
||||
|
||||
import top.yeij.cyrene.core.error.CyreneError
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.serialization.SerializationException
|
||||
import retrofit2.Response
|
||||
import java.io.IOException
|
||||
import java.net.ConnectException
|
||||
import java.net.SocketTimeoutException
|
||||
import java.net.UnknownHostException
|
||||
import javax.net.ssl.SSLException
|
||||
|
||||
/**
|
||||
* 统一错误映射(S1.1):已建连接用服务端 `error`,本地异常按类型分类。
|
||||
* 供所有 REST 仓库复用;不要在各个仓库里各写一份。
|
||||
*/
|
||||
object ApiErrorMapper {
|
||||
|
||||
/** 有 HTTP 响应:401 → [CyreneError.Unauthorized],其余 → [CyreneError.Server]。 */
|
||||
fun fromHttp(httpStatus: Int, errorBody: String?): CyreneError {
|
||||
val error = parseErrorEnvelope(errorBody)
|
||||
val code = error?.code.orEmpty()
|
||||
val message = error?.message.orEmpty()
|
||||
return if (httpStatus == 401) {
|
||||
CyreneError.Unauthorized(code = code, message = message)
|
||||
} else {
|
||||
CyreneError.Server(code = code, message = message, httpStatus = httpStatus)
|
||||
}
|
||||
}
|
||||
|
||||
/** 本地/网络异常 → [CyreneError.Local]。 */
|
||||
fun fromThrowable(throwable: Throwable): CyreneError = when (throwable) {
|
||||
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 SerializationException -> CyreneError.Local(CyreneError.LocalKind.Parse)
|
||||
is IOException -> CyreneError.Local(CyreneError.LocalKind.NoNetwork)
|
||||
else -> CyreneError.Local(CyreneError.LocalKind.Unknown)
|
||||
}
|
||||
|
||||
/** 解析 `{"error":{code,message}}`;非信封 / 空白 / 解析失败均返回 null。 */
|
||||
fun parseErrorEnvelope(errorBody: String?): ApiError? = errorBody
|
||||
?.takeIf { it.isNotBlank() }
|
||||
?.let {
|
||||
runCatching {
|
||||
ApiClientFactory.json.decodeFromString(ApiErrorEnvelope.serializer(), it).error
|
||||
}.getOrNull()
|
||||
}
|
||||
}
|
||||
|
||||
/** 把 [CyreneError] 塞进 Kotlin `Result` 的载体,便于 `Result<T>` 统一传递失败。 */
|
||||
class CyreneException(val error: CyreneError) : Exception(error.userMessage)
|
||||
|
||||
/**
|
||||
* 执行一次信封调用并把结果收敛成 `Result<T>`:
|
||||
* 成功且 `data != null` → success;成功但 `data == null` → Parse;否则走 [ApiErrorMapper]。
|
||||
*/
|
||||
suspend fun <T : Any> apiCall(block: suspend () -> Response<ApiEnvelope<T>>): Result<T> =
|
||||
try {
|
||||
val response = block()
|
||||
val data = response.body()?.data
|
||||
when {
|
||||
response.isSuccessful && data != null -> Result.success(data)
|
||||
response.isSuccessful -> Result.failure(CyreneException(CyreneError.Local(CyreneError.LocalKind.Parse)))
|
||||
else -> Result.failure(
|
||||
CyreneException(
|
||||
ApiErrorMapper.fromHttp(response.code(), response.errorBody()?.string()),
|
||||
),
|
||||
)
|
||||
}
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
Result.failure(CyreneException(ApiErrorMapper.fromThrowable(e)))
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package top.yeij.cyrene.data.api
|
||||
|
||||
import top.yeij.cyrene.data.api.dto.ChatAckDto
|
||||
import top.yeij.cyrene.data.api.dto.ChatRequest
|
||||
import retrofit2.Response
|
||||
import retrofit2.http.Body
|
||||
import retrofit2.http.POST
|
||||
|
||||
/**
|
||||
* 发送一条消息(`POST /api/v1/chat`,api-client §1 行 3)。权限 `owner|admin`。
|
||||
*
|
||||
* 与会话只读的 [SessionsApi]、管理面 [AdminApi] 分开:这是**写**路径,且它的失败面比只读端点
|
||||
* 复杂得多(`202` / `409` / `500` / `503` 都是编排器的真实回执,不是「请求错了」),
|
||||
* 放在一起会让只读端点的调用方以为错误处理是同一种。
|
||||
*
|
||||
* 返回原始 [Response](而不是直接返回信封):状态码本身是语义的一部分——
|
||||
* `202` 排队 与 `409` 排队满 必须能被调用方分开,折进 `Result` 前就先丢了。
|
||||
*/
|
||||
interface ChatApi {
|
||||
|
||||
@POST("api/v1/chat")
|
||||
suspend fun sendChat(@Body body: ChatRequest): Response<ApiEnvelope<ChatAckDto>>
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package top.yeij.cyrene.data.api
|
||||
|
||||
import top.yeij.cyrene.data.api.dto.LoginRequest
|
||||
import top.yeij.cyrene.data.api.dto.LoginResponse
|
||||
import top.yeij.cyrene.data.api.dto.LogoutRequest
|
||||
import top.yeij.cyrene.data.api.dto.LogoutResponse
|
||||
import top.yeij.cyrene.data.api.dto.RefreshRequest
|
||||
import top.yeij.cyrene.data.api.dto.RefreshResponse
|
||||
import retrofit2.Response
|
||||
import retrofit2.http.Body
|
||||
import retrofit2.http.POST
|
||||
|
||||
interface CyreneApi {
|
||||
|
||||
@POST("api/v1/auth/login")
|
||||
suspend fun login(@Body body: LoginRequest): Response<ApiEnvelope<LoginResponse>>
|
||||
|
||||
@POST("api/v1/auth/refresh")
|
||||
suspend fun refresh(@Body body: RefreshRequest): Response<ApiEnvelope<RefreshResponse>>
|
||||
|
||||
@POST("api/v1/auth/logout")
|
||||
suspend fun logout(@Body body: LogoutRequest): Response<ApiEnvelope<LogoutResponse>>
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package top.yeij.cyrene.data.api
|
||||
|
||||
import okhttp3.MultipartBody
|
||||
import okhttp3.ResponseBody
|
||||
import retrofit2.Response
|
||||
import retrofit2.http.GET
|
||||
import retrofit2.http.Multipart
|
||||
import retrofit2.http.POST
|
||||
import retrofit2.http.Part
|
||||
import retrofit2.http.Path
|
||||
import retrofit2.http.Streaming
|
||||
import top.yeij.cyrene.data.api.dto.DocumentUploadResponse
|
||||
|
||||
/**
|
||||
* 文档存储端点(契约 **v0.59 已发布(api-client.md §1.2「文档存储与摄取契约」)**,形状依据见 `02-前后端协同事项.md` 回执五 §一 S1)。
|
||||
* 权限 `owner|admin`。与图片链路([UploadApi] + `/api/v1/files/{name}`)**刻意分离**:
|
||||
*
|
||||
* | | 图片 | 文档 |
|
||||
* |---|---|---|
|
||||
* | 上传字段名 | `file` | `file`(同) |
|
||||
* | 上限 | 5 MiB | 20 MiB(`CHAT_DOCUMENT_MAX_BYTES` 默认,**与图片上限解耦**) |
|
||||
* | 读侧鉴权 | **不需要**(名字即凭据,供 `<img>`/provider 直接取) | **要 Bearer `owner|admin`** |
|
||||
* | 读侧响应 | 可内联渲染 | `Content-Disposition: attachment` + `nosniff`(**永不渲染**) |
|
||||
* | 成功载荷的 url | **绝对**同源地址,可直接当 `chat.image` | **相对**路径,客户端要的是 `name` |
|
||||
*
|
||||
* 为什么文档读侧要鉴权而图片不要:文档的摄取在**服务端**完成(正文进上下文),
|
||||
* 客户端不需要把一个「名字即凭据」的读侧打开;图片则必须被 `<img>` 与 provider 直接消费。
|
||||
*
|
||||
* ## 鉴权拦截器这一侧没问题的依据
|
||||
* Bearer 不是按接口挂的,而是**按 OkHttpClient** 挂的:[ApiClientFactory.create] 给每个
|
||||
* 客户端都装了 `AuthInterceptor`(`Authorization: Bearer <token>` + 401 单飞续期),
|
||||
* 因此只要 [DocumentRepository] 拿到的 `DocumentsApi` 是从 `ApiClientFactory.create` 建的
|
||||
* (`AppContainer` 里照 [UploadApi] 的写法),**两个端点都自带 Bearer**,不需要额外加头。
|
||||
*/
|
||||
interface DocumentsApi {
|
||||
|
||||
/**
|
||||
* 上传一份文档。
|
||||
*
|
||||
* @param file **部件名必须是 `file`**(服务端 `r.FormFile("file")`);**文件名必须带扩展名**,
|
||||
* 且该扩展名要落在服务端的 deny-by-default 白名单里——与图片不同,这里的名字
|
||||
* **真的参与裁决**(白名单扩展名 + 字节嗅探**合取**,两者都过才放行)。
|
||||
* 服务端随后丢弃该名字,按内容寻址重命名为 `<sha256[:32]><ext>`,所以名字里
|
||||
* 除了扩展名以外的内容不会被采用(也不会进路径)。
|
||||
*
|
||||
* 返回原始 [Response]:**`200` 不是错误**——同一份字节重复上传回 `200` + 同一个 `name` +
|
||||
* `deduped:true`,客户端必须当成功。状态码本身不承载额外语义(`413`/`415` 的区分在
|
||||
* `error.code` 里),所以仓库层可以直接走 [apiCall]。
|
||||
*/
|
||||
@Multipart
|
||||
@POST("api/v1/documents")
|
||||
suspend fun upload(@Part file: MultipartBody.Part): Response<ApiEnvelope<DocumentUploadResponse>>
|
||||
|
||||
/**
|
||||
* 取回一份文档的原始字节。
|
||||
*
|
||||
* 它**不是信封**(响应体就是文件本身),所以返回 [ResponseBody]:解码器只处理
|
||||
* `application/json`,这里必须自己读字节并在仓库层负责关闭。
|
||||
* [Streaming] 让 Retrofit 不把响应体一次性读进内存(单份最大 20 MiB,边界情况不小)。
|
||||
*
|
||||
* @param name 服务端存储名(`<32hex>.<ext>`)。形态非法 / 不存在服务端都回
|
||||
* `404 document_not_found`(不区分「非法」与「没有」),因此这里不做本地形态校验、
|
||||
* 也不拿它拼任何本地路径。
|
||||
*/
|
||||
@Streaming
|
||||
@GET("api/v1/documents/{name}")
|
||||
suspend fun download(@Path("name") name: String): Response<ResponseBody>
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package top.yeij.cyrene.data.api
|
||||
|
||||
import okhttp3.ResponseBody
|
||||
import retrofit2.Response
|
||||
import retrofit2.http.GET
|
||||
import retrofit2.http.Streaming
|
||||
import retrofit2.http.Url
|
||||
|
||||
/**
|
||||
* 语音副本的字节读取(契约 **v0.79 已实现**,api-client.md §1.2 **U19**
|
||||
* `GET /api/v1/media/{name}`)。
|
||||
*
|
||||
* ## 鉴权:**必须** Bearer `owner|admin`
|
||||
* 与 `GET /api/v1/documents/{name}` 同一套门,与图片读侧「128-bit 名即凭据」**刻意相反**
|
||||
* ——语音是私密内容,那个 URL 不能直接贴进 `<audio src>`。Bearer 不在本接口上做任何事:
|
||||
* [ApiClientFactory.create] 按 **OkHttpClient** 挂 `AuthInterceptor`,只要本接口的实例是从
|
||||
* 那里建的(`AppContainer.mediaApi` 的写法),请求就自带令牌、并享受 401 单飞续期。
|
||||
*
|
||||
* ## 为什么参数是一整条 URL 而不是 `@Path name`
|
||||
* 契约把相对路径 `/api/v1/media/<name>` 定成**服务端唯一的派生字段**(`voice.url`),并明写
|
||||
* 客户端**绝不自己拼路径**。所以调用方把**服务端给的那条路径接到当前服务器地址上**
|
||||
* (纯函数 `domain.chat.voiceMediaUrl`)再传进来——本接口一个字都不拼,只发出去。
|
||||
*
|
||||
* @see MediaRepository
|
||||
*/
|
||||
interface MediaApi {
|
||||
|
||||
/**
|
||||
* 取一条语音副本的字节。
|
||||
*
|
||||
* 响应**不是信封**(就是音频本身),所以返回 [ResponseBody] 由仓库层读字节并负责关闭。
|
||||
* [Streaming] 让 Retrofit 不把响应体一次性读进内存。
|
||||
*
|
||||
* ⚠️ 服务端的 `416`(`Range` 不可满足)是**标准库直写**的 `text/plain` + `Content-Range`,
|
||||
* **不套** `{"error":{…}}`(契约 §0.3 / §4.5)——本端不发 `Range`、也不拿 `error.code`
|
||||
* 去解析它,`ApiErrorMapper` 对这种非信封响应的处理是「说不出就不编」。
|
||||
*/
|
||||
@Streaming
|
||||
@GET
|
||||
suspend fun download(@Url url: String): Response<ResponseBody>
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package top.yeij.cyrene.data.api
|
||||
|
||||
import top.yeij.cyrene.data.api.dto.EnumTableDto
|
||||
import retrofit2.Response
|
||||
import retrofit2.http.GET
|
||||
|
||||
/**
|
||||
* 共享枚举权威表端点(api-client §1.1 / api-reference §3.7)。
|
||||
* 任意已认证角色可读(与 `GET /sessions` 同级),无 token 回 `401`;
|
||||
* 纯内存投影,没有其它错误码。按本工程惯例单独成接口。
|
||||
*/
|
||||
interface MetaApi {
|
||||
|
||||
@GET("api/v1/meta/enums")
|
||||
suspend fun enums(): Response<ApiEnvelope<EnumTableDto>>
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package top.yeij.cyrene.data.api
|
||||
|
||||
import top.yeij.cyrene.data.api.dto.UserPersonaDto
|
||||
import retrofit2.Response
|
||||
import retrofit2.http.GET
|
||||
|
||||
/**
|
||||
* **用户侧**角色目录(api-client §1 行 9)。任意已认证角色可读,无 token 才 `401`。
|
||||
*
|
||||
* 与 [AdminApi.personas] 分开是有意的:admin 端点每项多一个 `thinking`,而且整条路径要求
|
||||
* `viewer` 以上的管理档——拿它当用户侧接口用,普通用户身上会拿到 `403`。
|
||||
*/
|
||||
interface PersonaApi {
|
||||
|
||||
/** 角色列表;服务端顺序即权威顺序,客户端不重排。 */
|
||||
@GET("api/v1/personas")
|
||||
suspend fun personas(): Response<ApiEnvelope<List<UserPersonaDto>>>
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
package top.yeij.cyrene.data.api
|
||||
|
||||
import top.yeij.cyrene.data.api.dto.CreateSessionRequest
|
||||
import top.yeij.cyrene.data.api.dto.CreateSessionResponse
|
||||
import top.yeij.cyrene.data.api.dto.DeleteMessageResponse
|
||||
import top.yeij.cyrene.data.api.dto.DeleteSessionResponse
|
||||
import top.yeij.cyrene.data.api.dto.HistoryMessageDto
|
||||
import top.yeij.cyrene.data.api.dto.ReadSessionRequest
|
||||
import top.yeij.cyrene.data.api.dto.ReadSessionResponse
|
||||
import top.yeij.cyrene.data.api.dto.SessionSummaryDto
|
||||
import top.yeij.cyrene.data.api.dto.UpdateMessageRequest
|
||||
import top.yeij.cyrene.data.api.dto.UpdateSessionRequest
|
||||
import top.yeij.cyrene.data.api.dto.UpdateSessionResponse
|
||||
import retrofit2.Response
|
||||
import retrofit2.http.Body
|
||||
import retrofit2.http.DELETE
|
||||
import retrofit2.http.GET
|
||||
import retrofit2.http.PATCH
|
||||
import retrofit2.http.POST
|
||||
import retrofit2.http.Path
|
||||
import retrofit2.http.Query
|
||||
|
||||
/**
|
||||
* 会话与消息相关端点(api-reference §3.2/§3.4/§3.5/§3.6)。
|
||||
* 与 M1 的 `CyreneApi` 分开,避免互相污染。
|
||||
*/
|
||||
interface SessionsApi {
|
||||
|
||||
@GET("api/v1/sessions")
|
||||
suspend fun listSessions(): Response<ApiEnvelope<List<SessionSummaryDto>>>
|
||||
|
||||
/**
|
||||
* 新建次级会话,**完全不带 body**(= 不绑角色,跟随全局默认角色)。
|
||||
*
|
||||
* 这个无参重载是给「不选角色」这条路留的:`personaId` 为 null 时走它,线上请求与本端支持
|
||||
* 选角之前**逐字节相同**——不会因为多了一个可选字段而改变任何既有行为。
|
||||
* 服务端返回 `201 {data:{sessionId}}`。
|
||||
*/
|
||||
@POST("api/v1/sessions")
|
||||
suspend fun createSession(): Response<ApiEnvelope<CreateSessionResponse>>
|
||||
|
||||
/**
|
||||
* 新建会话并**在创建时绑定角色**(v0.42)。
|
||||
*
|
||||
* `personaId` 为**未知角色** → `400 bad_request`;非空时响应会回显它。
|
||||
* 会话角色**只在创建时写入、之后不可变**:`PATCH /sessions/{id}` 收到 `personaId` 一律 `400`,
|
||||
* 要换角色只能新开会话(见 [UpdateSessionRequest])。
|
||||
*/
|
||||
@POST("api/v1/sessions")
|
||||
suspend fun createSession(@Body body: CreateSessionRequest): Response<ApiEnvelope<CreateSessionResponse>>
|
||||
|
||||
/** 主会话 `web` 删除会返回 `403 forbidden`。 */
|
||||
@DELETE("api/v1/sessions/{id}")
|
||||
suspend fun deleteSession(@Path("id") sessionId: String): Response<ApiEnvelope<DeleteSessionResponse>>
|
||||
|
||||
/**
|
||||
* 重命名 / 置顶(`writeAuth`:owner 或 admin)。主会话与次级会话都能改。
|
||||
*
|
||||
* - 请求体至少要带一个字段;`{}` → 400。字段缺席即保留原值。
|
||||
* - `title` 会被 trim;空/全空白 → 400 `title is required`(措辞误导,实际含义是
|
||||
* **无法把标题清回自动命名**);超过 80 个字符 → 400。
|
||||
* - 两个字段同时给出时**先改名再置顶**:置顶失败时改名已经落库,不能当成「什么都没发生」。
|
||||
* - 响应是局部形状,见 [UpdateSessionResponse]。
|
||||
*/
|
||||
@PATCH("api/v1/sessions/{id}")
|
||||
suspend fun updateSession(
|
||||
@Path("id") sessionId: String,
|
||||
@Body body: UpdateSessionRequest,
|
||||
): Response<ApiEnvelope<UpdateSessionResponse>>
|
||||
|
||||
/**
|
||||
* 消息热分页,服务端按 `createdAt` 倒序(newest first)返回。
|
||||
*
|
||||
* 普通分页与**会话内内容搜索**共用这一个端点(契约 §1 行 6:`q` 是 messages 端点上的一个
|
||||
* 可选查询参数,不是另一个端点)。
|
||||
*
|
||||
* @param limit 默认 50、上限 200(由 [top.yeij.cyrene.data.repository.MessageRepository] 钳制)
|
||||
* @param before 上一页最旧消息的 `createdAt`(Unix 毫秒);null = 最新一页
|
||||
* @param platform 来源平台过滤;null = 全量
|
||||
* @param query `q`:**会话内内容搜索**(D2)。null / 空 = 未传,走普通分页——线上请求因此与本端
|
||||
* 支持搜索之前**逐字节相同**(Retrofit 会整个省掉 null 的查询参数)。非空时服务端按
|
||||
* 大小写不敏感的 ASCII LIKE 匹配 `content`(`%` / `_` / `\` 已转义为字面量),
|
||||
* 仍按时间倒序、`before` / `limit` 同分页语义,搜索**严格限定在 `{id}` 会话内**。
|
||||
* **`q` 与 `after` 互斥**(同传 `400`)。
|
||||
* @param after **正向增量**(A5,契约 §1 行 6):返回 `created_at > after` 的消息,**按时间
|
||||
* **升序**(与 `before` 的倒序**相反**)——用来补「WS 重连只回放最近 50 条」漏掉的那一段。
|
||||
* 游标是**本端已经看到的最后一条 `createdAt`**;服务端配 `limit` 从最旧的一条开始给,所以
|
||||
* 用「本页最后一条的 `createdAt`」反复推进就能一直追到当前。
|
||||
* **与 `before` / `q` 互斥**(同传 `400`)——三个调用点各自只给一个
|
||||
* (`loadPage` 只给 `before`、`search` 只给 `q`、`loadAfter` 只给 `after`),天然不冲突。
|
||||
* 同毫秒并列超过 `limit` 时纯 `createdAt` 游标会跨页重读,调用方按 `id` 去重兜底。
|
||||
*/
|
||||
@GET("api/v1/sessions/{id}/messages")
|
||||
suspend fun listMessages(
|
||||
@Path("id") sessionId: String,
|
||||
@Query("limit") limit: Int? = null,
|
||||
@Query("before") before: Long? = null,
|
||||
@Query("platform") platform: String? = null,
|
||||
@Query("q") query: String? = null,
|
||||
@Query("after") after: Long? = null,
|
||||
): Response<ApiEnvelope<List<HistoryMessageDto>>>
|
||||
|
||||
/**
|
||||
* 推进已读游标(D4,契约 v0.37 `api-reference §3.6b`)。
|
||||
*
|
||||
* `before` 缺席 = 「现在」;游标**只前进**,所以重复 / 乱序 / 重试都幂等安全。
|
||||
* 错误面:`before` 非法 → `400`、未知会话(含隔离 `sess-xxx`)→ `404`、
|
||||
* 未注入 HistoryStore → `501`。成功后服务端会广播 `session_read`(§3.9)。
|
||||
*/
|
||||
@POST("api/v1/sessions/{id}/read")
|
||||
suspend fun markRead(
|
||||
@Path("id") sessionId: String,
|
||||
@Body body: ReadSessionRequest,
|
||||
): Response<ApiEnvelope<ReadSessionResponse>>
|
||||
|
||||
/**
|
||||
* 编辑单条消息(D3,契约 §1 行 6a):`PATCH /api/v1/messages/{id}`,body `{content}`。
|
||||
*
|
||||
* 响应是 `{data:<消息DTO>}`——`data` **直接就是**更新后的完整消息 DTO,**没有** `message`
|
||||
* 包一层(v0.30 变更记录里那句 `{data:{message}}` 已作废,以契约正文为准)。服务端只改
|
||||
* `content`,其余字段一律原值返回。
|
||||
*
|
||||
* 失败面:`content` 空 / 纯空白 / 超 4000 rune → `400 bad_request`;消息不存在 →
|
||||
* `404 not_found`;未注入 HistoryStore → `501`;权限不足 → `403`。
|
||||
*
|
||||
* ⚠️ 成功后服务端**还会广播** `message_edited`(§3.8)——发起的这条连接自己也会收到它。
|
||||
* 于是「REST 响应」与「广播帧」会把同一件事说两遍,两处必须收敛成一次状态变更
|
||||
* (见 [top.yeij.cyrene.data.repository.applyMessageEdit])。
|
||||
*/
|
||||
@PATCH("api/v1/messages/{id}")
|
||||
suspend fun updateMessage(
|
||||
@Path("id") messageId: String,
|
||||
@Body body: UpdateMessageRequest,
|
||||
): Response<ApiEnvelope<HistoryMessageDto>>
|
||||
|
||||
/**
|
||||
* 删除单条消息(D3,契约 §1 行 6b):`DELETE /api/v1/messages/{id}` →
|
||||
* `{data:{deleted:<消息 id>}}`。
|
||||
*
|
||||
* 失败面:消息不存在 → `404 not_found`;未注入 HistoryStore → `501`;权限不足 → `403`。
|
||||
* 成功后同样会广播 `message_deleted`(§3.8,只带被删消息的 `id`)。
|
||||
*/
|
||||
@DELETE("api/v1/messages/{id}")
|
||||
suspend fun deleteMessage(
|
||||
@Path("id") messageId: String,
|
||||
): Response<ApiEnvelope<DeleteMessageResponse>>
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package top.yeij.cyrene.data.api
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
import okhttp3.ResponseBody
|
||||
import retrofit2.Response
|
||||
import retrofit2.http.Body
|
||||
import retrofit2.http.POST
|
||||
import retrofit2.http.Streaming
|
||||
|
||||
/**
|
||||
* 一段待朗读的文本(`POST /api/v1/audio/speech` 的请求体,api-client.md §1.2 U6 / 行 126)。
|
||||
*
|
||||
* ## 只有两个字段,是**刻意的**
|
||||
* 契约给了五个键 `{text, voice?, format?, instruction?, sessionId?}`,但其中三个在本端**不生效**:
|
||||
* - `voice`:接受但不生效 —— 发音人**不由它决定**;
|
||||
* - `format`:不生效 —— 产物恒为 WAV(响应也明说「别照抄 `Content-Type`」,见 [SpeechApi]);
|
||||
* - `instruction`:只影响表达方式,本端当前的产品口径里没有「怎么说」这一档输入。
|
||||
*
|
||||
* 发一个服务端会忽略的键,只会让排查时多一条**看起来有含义、实际没有**的字段。所以这里只发
|
||||
* 真正起作用的那两个 —— 尤其是 [sessionId]:发音人由它**那条会话的生效人格**解析
|
||||
* (会话绑定人格 → 全局默认人格 → 都没有就取服务端配置的 default),**省略它不等于没有角色**。
|
||||
*
|
||||
* [sessionId] 为 `null` 时字段**整个缺席**(`ApiClientFactory.json` 的 `explicitNulls = false`),
|
||||
* 而不是发一个 `"sessionId": null`。
|
||||
*/
|
||||
@Serializable
|
||||
data class SpeechRequestDto(
|
||||
@SerialName("text") val text: String,
|
||||
@SerialName("sessionId") val sessionId: String? = null,
|
||||
)
|
||||
|
||||
/**
|
||||
* 语音合成(契约 **v0.96 增量 / v0.97 补写**,api-client.md §1.2 **U6** `POST /api/v1/audio/speech`)。
|
||||
*
|
||||
* ## 鉴权:owner-only,双凭据门
|
||||
* `Authorization: Bearer <owner JWT>` **或** `X-API-Key`,两个分支在服务端落到同一个
|
||||
* `authorize(RoleOwner)`。本端只走前者(主人 2026-10-26 拍板:不设 API Key 输入框),令牌由
|
||||
* [ApiClientFactory.create] 挂在 OkHttpClient 上的 `AuthInterceptor` 自动注入,本接口不碰它。
|
||||
* 无 / 无效凭据 → `401`,错误体是 §4.1 那个 `{error:{code,message}}` 信封。
|
||||
*
|
||||
* ## 响应**不是信封**
|
||||
* 成功是 `200` + **原始音频字节**(与 [MediaApi] 同一个形状),所以返回 [ResponseBody] 由上层
|
||||
* 读字节并负责关闭,[Streaming] 让它不一次性读进内存。
|
||||
*
|
||||
* ⚠️ **产物恒为 WAV,按字节嗅探判定,不要照抄 `Content-Type`** —— 那条头的实际值不在契约里
|
||||
* 承诺(§4.1 行 561 的同句旁注就是这个意思)。嗅探在 [top.yeij.cyrene.data.voice.SpeechClient]。
|
||||
*
|
||||
* 失败时的错误码(全部是 `{error:{code}}` 信封):
|
||||
* `400`(空 text)、`500 voice_unavailable`(未接语音)、`500 tts_failed`、
|
||||
* `500 tts_persona_unmapped`(人格没有发音人映射)、`500 tts_no_character`(连生效人格都没有)、
|
||||
* `500 tts_empty`(拿到的是静默空音频,同时响应头带 `X-Voice-Provider: silent`)。
|
||||
*
|
||||
* @see top.yeij.cyrene.data.voice.SpeechClient
|
||||
*/
|
||||
interface SpeechApi {
|
||||
|
||||
/**
|
||||
* 把 [body] 合成为一段 WAV 字节。
|
||||
*
|
||||
* 路径**不带前导 `/`**(与 [ChatApi] 一致):Retrofit 的 `baseUrl` 已含 `/` 结尾,
|
||||
* 带前导 `/` 会把它当成根路径、把部署在子路径下的服务端地址整段丢掉。
|
||||
*/
|
||||
@Streaming
|
||||
@POST("api/v1/audio/speech")
|
||||
suspend fun speech(@Body body: SpeechRequestDto): Response<ResponseBody>
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package top.yeij.cyrene.data.api
|
||||
|
||||
import top.yeij.cyrene.data.api.dto.UploadResponse
|
||||
import okhttp3.MultipartBody
|
||||
import retrofit2.Response
|
||||
import retrofit2.http.Multipart
|
||||
import retrofit2.http.POST
|
||||
import retrofit2.http.Part
|
||||
|
||||
/**
|
||||
* 图片上传端点(`POST /api/v1/upload`,api-client §1 行 13)。权限 `owner|admin`。
|
||||
*
|
||||
* **上传本身不是目的**:服务端把 `chat.image` 字符串**原样落库**,于是每一页历史与每一次
|
||||
* WS `history` 回放都要把整段 base64 再传一遍(协同 G3)。上传一次、消息里只留 URL,
|
||||
* 是这条链路上唯一能把「历史重量」永久压下去的手段。
|
||||
*
|
||||
* 与 [ChatApi] 分开:它的请求体是 multipart(不是 JSON)、错误面也完全不同
|
||||
* (`413 payload_too_large` / `415 unsupported_media_type` 是它的日常回执)。
|
||||
*
|
||||
* 返回原始 [Response] 而不是直接折成 `Result`:与其它端点一样由仓库层统一走
|
||||
* [ApiErrorMapper],状态码在这里**不承载额外语义**(`413`/`415` 的机器可读区分在
|
||||
* `error.code` 里,所以仓库能用 [apiCall])。
|
||||
*/
|
||||
interface UploadApi {
|
||||
|
||||
/**
|
||||
* @param file **部件名必须是 `file`**(服务端 `r.FormFile("file")`):字段名错了会回
|
||||
* `400 {error:{code:"bad_request",message:"file is required"}}`(已对着在线后端实测)。
|
||||
*/
|
||||
@Multipart
|
||||
@POST("api/v1/upload")
|
||||
suspend fun upload(@Part file: MultipartBody.Part): Response<ApiEnvelope<UploadResponse>>
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
package top.yeij.cyrene.data.api.dto
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
|
||||
/**
|
||||
* `GET /api/v1/admin/observability/metrics`(api-client §2「可观测性契约」)。
|
||||
* 后端不统计 token / 成本,因此这里也没有对应字段——不伪造指标。
|
||||
*/
|
||||
@Serializable
|
||||
data class AdminMetricsDto(
|
||||
val totalRequests: Long = 0,
|
||||
val qps: Double = 0.0,
|
||||
val avgLatencyMs: Double = 0.0,
|
||||
val maxLatencyMs: Long = 0,
|
||||
val perMethod: Map<String, Long> = emptyMap(),
|
||||
val perPath: Map<String, Long> = emptyMap(),
|
||||
val statusCounts: Map<String, Long> = emptyMap(),
|
||||
val llmCalls: Long = 0,
|
||||
val subagentEvents: Long = 0,
|
||||
)
|
||||
|
||||
/**
|
||||
* `GET /api/v1/admin/observability/trace?limit=N` 单个 span。
|
||||
* `kind` / `status` 是开放集合,保留原始字符串(不建枚举)。
|
||||
* `startedAt` / `endedAt` 为 RFC3339;`props` / `events` 为任意结构,原样透传。
|
||||
*/
|
||||
@Serializable
|
||||
data class AdminTraceSpanDto(
|
||||
val id: String,
|
||||
val parentId: String? = null,
|
||||
val name: String = "",
|
||||
val kind: String = "",
|
||||
val status: String = "",
|
||||
val startedAt: String = "",
|
||||
val endedAt: String? = null,
|
||||
val durationNs: Long = 0,
|
||||
val props: Map<String, JsonElement>? = null,
|
||||
val events: List<AdminTraceEventDto>? = null,
|
||||
)
|
||||
|
||||
/** `trace.events[]` 一项;`time` 为 RFC3339(纳秒精度)。 */
|
||||
@Serializable
|
||||
data class AdminTraceEventDto(
|
||||
val time: String = "",
|
||||
val type: String = "",
|
||||
val props: Map<String, JsonElement>? = null,
|
||||
)
|
||||
|
||||
/**
|
||||
* 一条后端日志,**两个来源共用这一个形状**(api-client §2「可观测性契约」与 §3.12):
|
||||
* `GET /api/v1/admin/observability/logs` 的 `data[]`,以及 `observability_log` 帧的
|
||||
* `data.entries[]`。后端是同一个生产者(`gateway.AdminLogEntry`,`adminLogOf` 映射),
|
||||
* 契约也明说帧「与 REST 同形」——所以这里只有一份 DTO,不另建一份等价形状。
|
||||
*
|
||||
* - [time] 是 **RFC3339(UTC,秒精度)**——与 `trace.startedAt` / `audit.when` 同源同形状,
|
||||
* 而**不是** backthink 那种 Unix 毫秒([AdminBackthinkItemDto.at]),两者不可共用解析器。
|
||||
* - [level] ∈ `debug|info|warn|error`,是**开放集合**:本层原样保留,未知等级既不丢弃也不归一。
|
||||
* - [source] 是 logger 的来源标签,记录没带来源时是空串(不是 null)。
|
||||
* - 条目**没有 id**:去重只能按这四个字段(见 `ui/admin/AdminOverviewMerge.kt`)。
|
||||
*/
|
||||
@Serializable
|
||||
data class AdminLogEntryDto(
|
||||
val time: String = "",
|
||||
val level: String = "",
|
||||
val source: String = "",
|
||||
val msg: String = "",
|
||||
)
|
||||
|
||||
/**
|
||||
* 人格摘要(用户侧 `GET /api/v1/personas` 与 admin 侧 `GET /api/v1/admin/personas` 同形)。
|
||||
* admin 侧多一个 [thinking];未配置该人格时**该键完全不出现**,故为 nullable。
|
||||
*/
|
||||
@Serializable
|
||||
data class PersonaSummaryDto(
|
||||
val id: String,
|
||||
val name: String = "",
|
||||
val character: String = "",
|
||||
val isDefault: Boolean = false,
|
||||
val mode: String = "",
|
||||
val memoryNamespace: String = "",
|
||||
val avatar: String? = null,
|
||||
val chatBackground: ChatBackgroundDto? = null,
|
||||
val mood: String? = null,
|
||||
val thinking: PersonaThinkingDto? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ChatBackgroundDto(
|
||||
val landscape: String? = null,
|
||||
val portrait: String? = null,
|
||||
)
|
||||
|
||||
/**
|
||||
* 人格 `thinking` 读侧形状(v0.43)。
|
||||
* [showToUser] 是**三态**:`null` = 未配置(保持现行为),true/false = 显式选择;
|
||||
* 不得把 null 拍平成 false。
|
||||
*/
|
||||
@Serializable
|
||||
data class PersonaThinkingDto(
|
||||
val budgetTokens: Int = 0,
|
||||
val effort: String = "",
|
||||
val showToUser: Boolean? = null,
|
||||
val mode: String = "",
|
||||
)
|
||||
|
||||
/** `GET /api/v1/admin/memories` 一条记忆;`scope` 为开放集合(`user|persona|project`)。 */
|
||||
@Serializable
|
||||
data class MemorySummaryDto(
|
||||
val id: String,
|
||||
val scope: String = "",
|
||||
val text: String = "",
|
||||
)
|
||||
|
||||
/**
|
||||
* `GET /api/v1/admin/backthink` 一条后台思考记录。
|
||||
* [at] 是 **Unix 毫秒**(与 trace 的 RFC3339 不同源,切勿共用解析)。
|
||||
*/
|
||||
@Serializable
|
||||
data class AdminBackthinkItemDto(
|
||||
val id: String,
|
||||
val trigger: String = "",
|
||||
val conclusion: String? = null,
|
||||
val shouldNotify: Boolean = false,
|
||||
val pushed: Boolean = false,
|
||||
val at: Long = 0,
|
||||
)
|
||||
@@ -0,0 +1,85 @@
|
||||
package top.yeij.cyrene.data.api.dto
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/**
|
||||
* `POST /api/v1/chat` 请求体(契约 v0.56,api-client §1 行 3)。
|
||||
*
|
||||
* 刻意只带这一个端点要的字段——**不复用整条会话对象**:请求体越小,「WS 帧 4096 字节上限」
|
||||
* 的反面(REST 请求体没有长度上限)就越不会被无关字段吃掉,抓包时也一眼能看出这一轮发了什么。
|
||||
*
|
||||
* 字段语义:
|
||||
* - [text] / [image] **至少一项非空**,两项都空服务端回 `400 bad_request`;
|
||||
* - [image] 是 base64 `data:` URI 或 http(s) URL,**解码后上限 5 MiB**
|
||||
* (超限 `400 image_too_large`,文案指路 `POST /api/v1/upload`);
|
||||
* - [clientMsgId] 是客户端自带的幂等键:同一会话内**同一个非空值只承认第一条**,
|
||||
* 重复到达回 `200 {data:{duplicate:true,messageId,sessionId}}` 且不落库、不起回合;
|
||||
* - [stream] 必须为 `true`(本端**不用** `stream:false`:它是同步的,服务端没有写超时,
|
||||
* 客户端 60s 读超时会先超时、而回合还在跑——重发就是第二次对话)。
|
||||
*
|
||||
* ⚠️ 本项目 `ApiClientFactory.json` 用 kotlinx 默认的 `encodeDefaults = false`:
|
||||
* 取值等于默认值的字段**整键省略**。因此**纯图片消息**(`text == ""`)线上不会带 `text` 键——
|
||||
* 服务端 Go 侧零值同为空串,语义完全等价(已按此实现,见 [ChatDtoRealPayloadTest])。
|
||||
*
|
||||
* [documents] 同理:默认空列表 ⇒ **不带附件的请求线上与加入该字段之前逐字节相同**
|
||||
* (服务端 `Documents []chatDocumentRef \`json:"documents,omitempty"\`` 零值也省略,
|
||||
* 见 `DocumentDtoTest` 里拿字面量 JSON 对比的那条钉子)。
|
||||
*/
|
||||
@Serializable
|
||||
data class ChatRequest(
|
||||
val sessionId: String? = null,
|
||||
val text: String = "",
|
||||
val image: String? = null,
|
||||
val deviceId: String? = null,
|
||||
val mode: String? = null,
|
||||
val stream: Boolean? = null,
|
||||
val clientMsgId: String? = null,
|
||||
/**
|
||||
* 本回合附带的文档引用(契约 v0.59 已发布(api-client.md §1.2「文档存储与摄取契约」),形状见 [DocumentRefDto]):**≤5 条**,
|
||||
* 每条**只给服务端存储名**(`{"name":"<32hex>.<ext>"}`),**不收 URL**。
|
||||
*
|
||||
* 上行只发 `name`:其它字段服务端一律不采信并覆盖(字节嗅探是唯一的类型裁决)。
|
||||
* 空列表 → 整键省略(`encodeDefaults = false`),旧请求逐字节不变。
|
||||
*
|
||||
* ⚠️ **带附件一律走 REST**:文档本体经 `POST /api/v1/documents` 上传,引用随
|
||||
* `POST /api/v1/chat` 发出([top.yeij.cyrene.domain.chat.DocumentDelivery] 的判定);
|
||||
* WS `chat` 帧的 4096 字节读上限仍在,且上行帧里没有承载附件的余地。
|
||||
*/
|
||||
val documents: List<DocumentRefDto> = emptyList(),
|
||||
/**
|
||||
* **请求把本条作为独立一路处理**(忙时并行规范 §2.2,REST 与 WS `chat` **同形**)。
|
||||
*
|
||||
* `null` = **整键省略**(`encodeDefaults = false`),与加入该字段之前**逐字节相同**:
|
||||
* 用户把设置页那栏「她忙时发消息」选成「**并行**」**且会话正忙**时才给 `true`(判据是纯函数
|
||||
* `parallelForSend`;另外两档、以及空闲时都不带这个键)。
|
||||
* 服务端照准与否都不报错——额度满时落回排队并回 `202 {queued:true}`(伴一条
|
||||
* `pending_ack{reason:"parallel_limit"}`),已被在飞路 steer 吃进时回 `200 {stream:true}`。
|
||||
*/
|
||||
val parallel: Boolean? = null,
|
||||
)
|
||||
|
||||
/**
|
||||
* `POST /api/v1/chat` 的**受理回执**(`data` 载荷)。v0.56 起响应面不再是「任何情况都 200」,
|
||||
* 四种判定各有形状,因此这里**全部字段可空**、按组合判读,绝不假设某字段恒在:
|
||||
*
|
||||
* | 判定 | HTTP | 载荷 |
|
||||
* |---|---|---|
|
||||
* | 已起回合 | 200 | `{stream:true}` |
|
||||
* | 正忙已续接排队 | 202 | `{stream:true,queued:true,queueDepth:n}` |
|
||||
* | 幂等键重复 | 200 | `{duplicate:true,messageId,sessionId}` |
|
||||
*
|
||||
* 其余三种(`409 chat_busy` / `500 chat_unavailable` / `503 chat_dispatch_failed`)是
|
||||
* `{error:{code,message}}` 信封,由 [top.yeij.cyrene.data.repository.ChatRepository] 走错误路径分类。
|
||||
*
|
||||
* 不开枚举:服务端还有别的判定会在将来加进来,未知组合一律按「已受理但形状不认识」处理,
|
||||
* 不因为不认识一个布尔而把整轮当失败。
|
||||
*/
|
||||
@Serializable
|
||||
data class ChatAckDto(
|
||||
val stream: Boolean? = null,
|
||||
val queued: Boolean? = null,
|
||||
val queueDepth: Int? = null,
|
||||
val duplicate: Boolean? = null,
|
||||
val messageId: String? = null,
|
||||
val sessionId: String? = null,
|
||||
)
|
||||
@@ -0,0 +1,99 @@
|
||||
package top.yeij.cyrene.data.api.dto
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/**
|
||||
* 文档 DTO —— 「文件进对话」的**唯一形状落点**(契约 **v0.59 已发布(api-client.md §1.2「文档存储与摄取契约」)**)。
|
||||
*
|
||||
* ## 形状从哪来
|
||||
* 写这份代码时 `Cyrene_v2/Docs/api-client.md` 的最新契约版本仍是 **v0.58**(里面**没有**
|
||||
* `documents` 任何形状)。因此形状依据是
|
||||
* `Cyrene-For-Android/devdocs/02-前后端协同事项.md` 的 **回执五**(2026-10-04 深夜,
|
||||
* 标题即「一条契约形状修正」),并用后端仓库已落库的三个切片逐字段核对过:
|
||||
* `407c934`(S1 存储端点)/ `a4789ec`(S3-B 网关入口)/ `5b2b385`(S3.5 文档多值化)。
|
||||
*
|
||||
* **形状要变只改本文件。** 上行(`documents:[{name}]`)与下行/历史(`documents:[{…}]`)
|
||||
* 是**同一个元素类型**([DocumentRefDto]):服务端两条入口共用一处解析,这里也共用一个类。
|
||||
*
|
||||
* ## 回执五 §二「请以此为准」的三条
|
||||
* 1. **单对象 `document` 已作废**,一律**数组** `documents`(≤5)——修的是「一回合 5 个附件,
|
||||
* 历史与后续上下文里只留第一个」这个实测确认的缺陷;
|
||||
* 2. 上行元素**只给服务端存储名 `name`,绝不收 URL**(不收 URL 是为了不重开 `F3` 那种
|
||||
* 「服务端代取 URL」的 SSRF 面);谎报的 `mime`/`url`/`size` 会被服务端覆盖,
|
||||
* **类型裁决只有字节嗅探一条**(客户端声明的 `Content-Type` 与文件名只作参考、可被否决,
|
||||
* 所以一个 `data.json` 可能被回成 `text/plain`);
|
||||
* 3. 下行/历史元素只回**引用与统计,绝不回正文**(吸取 `G3`「base64 原样落库、每页历史重传」
|
||||
* 的教训)。`status:"unreadable"` 才是「读不到这个文件」的可观测信号,**不是** `truncated`
|
||||
* (`truncated` 只说明注入正文被预算截断,正文照样进得了上下文)。
|
||||
* **契约 v0.63(后端 `902b6ee`)在此之上加了可选 [DocumentRefDto.reason]**:读不到时随
|
||||
* `status:"unreadable"` 一起下发的**服务端原话**,客户端原样显示(详见该字段的 KDoc)。
|
||||
*
|
||||
* ## 客户端必须两种都吃的一件事
|
||||
* 「0 个附件」这一形状在后端**尚未定死**(回执五原文:期望「不出现或空数组」,S3.5 回报里定死)。
|
||||
* 因此本包的解析一律按**缺失键 → 空列表**、**空数组 → 空列表**两条都收:
|
||||
* 详见 [HistoryMessageDto.documents] 与 [ChatRequest.documents] 的默认值。
|
||||
*/
|
||||
@Serializable
|
||||
data class DocumentUploadResponse(
|
||||
/** 服务端存储名:32 位小写十六进制 + 白名单扩展名。**上行引用时唯一能用的字段**。 */
|
||||
val name: String = "",
|
||||
/**
|
||||
* **相对**同源路径(`/api/v1/documents/<name>`),与图片上传的**绝对** URL 不同:
|
||||
* 读侧要 Bearer `owner|admin`,所以它不是「贴进 `<img>` 就能取」的能力 URL,绝对化没有意义。
|
||||
* 客户端真正要用的是 [name]。
|
||||
*/
|
||||
val url: String = "",
|
||||
/** 服务端**字节嗅探**所得的基类型(不是客户端声明的),如 `text/plain`。 */
|
||||
val mime: String = "",
|
||||
val size: Long = 0,
|
||||
/** 内容寻址的哈希(64 位小写十六进制):落盘名就是它的前 32 位 + 扩展名。 */
|
||||
val sha256: String = "",
|
||||
/**
|
||||
* `true` = 同一份字节(同扩展名)之前已经上传过:服务端回 `200` + **同一个** `name`,
|
||||
* **必须当成功处理,不是错误**。`false` = 首次上传(`201`)。
|
||||
*/
|
||||
val deduped: Boolean = false,
|
||||
)
|
||||
|
||||
/**
|
||||
* 一条文档引用。**上行只发 [name]**(其余字段服务端不采信、会覆盖),
|
||||
* 下行/历史则带上全部统计字段。
|
||||
*
|
||||
* 字段可出现性(后端 `history.DocumentRef` 的 `omitempty` 口径,逐字段核对过):
|
||||
* `name` / `mime` / `size` **恒在**;`chars` / `truncated` / `status` / `reason` 为 Go 的 `omitempty`
|
||||
* —— 零值(0 / false / "" / nil)时**整键省略**,所以取值一律给默认值,解码不会因缺键失败。
|
||||
*/
|
||||
@Serializable
|
||||
data class DocumentRefDto(
|
||||
/** 服务端存储名(`<32hex>.<ext>`)。客户端**本地**的原始文件名不在这里,也不该在这里。 */
|
||||
val name: String = "",
|
||||
val mime: String = "",
|
||||
val size: Long = 0,
|
||||
/** 本次**注入上下文**的字符数(被预算截断者填截断后的值);`null` = 服务端未报告。 */
|
||||
val chars: Int? = null,
|
||||
/**
|
||||
* 注入正文是否被预算截断:`true` 只说明进上下文的那段被截短了,**不是**「读不到」——
|
||||
* 读不到看 [status](`unreadable`)。两个信号刻意分开,别互相顶替。
|
||||
*/
|
||||
val truncated: Boolean = false,
|
||||
/**
|
||||
* 抽取状态:`extracted` / `listed` / `unreadable`(见
|
||||
* [top.yeij.cyrene.domain.chat.DocumentStatus])。**开放集合,不用 enum**:
|
||||
* 未知值**原样保留**(不丢弃、不报错、不占位符),由客户端决定如何显示。
|
||||
*/
|
||||
val status: String = "",
|
||||
/**
|
||||
* **读不到的原因**(契约 v0.63 新增,后端 `902b6ee`):`status == "unreadable"` 时下发的
|
||||
* 那句**服务端原话**;`status == "extracted"` 时**整键缺席**,因此是可选字段。
|
||||
*
|
||||
* 两句纪律:
|
||||
* 1. **原样显示**。[reason] 与后端**注入模型清单里的那 10 条是同一份字符串**(逐字相同,
|
||||
* 最长的一条 40+ 字),客户端**不替服务端改写、不截断、不归纳**成自己的话——
|
||||
* 本端能编的只有像 [top.yeij.cyrene.domain.chat.DocumentPreviewGate.refusalMessage]
|
||||
* 那样「按类型泛泛说一句」,比这里的原因粗得多;
|
||||
* 2. **绝不做整句相等判断**。分类若真的需要分支,只允许用**稳定子串**(如「需要 OCR」)——
|
||||
* 整句比对会在后端改一个字时静默失效,且失败的样子是「显示成兜底那句话」,
|
||||
* 看起来完全正常。
|
||||
*/
|
||||
val reason: String? = null,
|
||||
)
|
||||
@@ -0,0 +1,59 @@
|
||||
package top.yeij.cyrene.data.api.dto
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class LoginRequest(
|
||||
val username: String,
|
||||
val password: String,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class LoginResponse(
|
||||
val token: String,
|
||||
/** **Unix 秒**(不是毫秒):`time.Now().Add(24h).Unix()`。 */
|
||||
val expiresAt: Long,
|
||||
val role: String,
|
||||
/** B2 的长期凭据;服务端没接线 refresh 存储时整个键缺失。 */
|
||||
val refreshToken: String? = null,
|
||||
)
|
||||
|
||||
/** `POST /api/v1/auth/refresh` 的请求体(公开接口,凭据就在请求体里)。 */
|
||||
@Serializable
|
||||
data class RefreshRequest(
|
||||
val refreshToken: String,
|
||||
)
|
||||
|
||||
/**
|
||||
* `POST /api/v1/auth/refresh` 的成功载荷。
|
||||
*
|
||||
* - [refreshToken] 在默认(一次性轮换)模式下是**新的**凭据,旧的同一笔交易里作废;
|
||||
* `AUTH_REFRESH_ROTATE_DISABLED=true` 时服务端不下发,此时旧凭据继续有效——所以这里必须可空,
|
||||
* 调用方也要在缺失时保留原值。
|
||||
* - [role] 官方文档漏写,但实现一定会返回;给默认值以免文档形状的响应解不动。
|
||||
*/
|
||||
@Serializable
|
||||
data class RefreshResponse(
|
||||
val token: String,
|
||||
/** **Unix 秒**。 */
|
||||
val expiresAt: Long,
|
||||
val role: String = "",
|
||||
val refreshToken: String? = null,
|
||||
)
|
||||
|
||||
/** `POST /api/v1/auth/logout` 的请求体。 */
|
||||
@Serializable
|
||||
data class LogoutRequest(
|
||||
val refreshToken: String,
|
||||
)
|
||||
|
||||
/**
|
||||
* `/auth/logout` 的响应体。文档与实现互相矛盾(`{"ok":true,"revokedCount":n}` / `{"revokedCount":n}`
|
||||
* 对上真实信封里的 `{"revoked":bool}`),而本端**只看 HTTP 2xx**——所以每个字段都给默认值,
|
||||
* 三种形状都能宽容解出,解不动也不影响登出。
|
||||
*/
|
||||
@Serializable
|
||||
data class LogoutResponse(
|
||||
val revoked: Boolean = false,
|
||||
val revokedCount: Int = 0,
|
||||
)
|
||||
@@ -0,0 +1,124 @@
|
||||
package top.yeij.cyrene.data.api.dto
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/**
|
||||
* `GET /api/v1/sessions/{id}/messages` 单条消息(api-reference §3.4)。
|
||||
* 服务端按 `createdAt` 倒序返回,渲染前需 reverse / merge。
|
||||
*
|
||||
* 它同时是 WS 握手 `history` 回放与 `message_persisted` 的载荷消息类型
|
||||
* (服务端三处共用同一个 `historyMessage`),所以 [documents] 的解析一处覆盖三条路。
|
||||
*
|
||||
* [voice] 从契约 v0.97(后端 M44 `fb55769`)起也**不再是例外**:REST 四路、握手 `history` 回放、
|
||||
* `message_persisted` / `channel_persisted` 落库帧**都填它**,第一条帧就带
|
||||
* (前提与理由写在 [voice] 的 KDoc 里)。
|
||||
*/
|
||||
@Serializable
|
||||
data class HistoryMessageDto(
|
||||
val id: String,
|
||||
val sessionId: String,
|
||||
val role: String,
|
||||
val content: String = "",
|
||||
val mode: String? = null,
|
||||
val mood: String? = null,
|
||||
val image: String? = null,
|
||||
val thinking: String? = null,
|
||||
val platform: String? = null,
|
||||
val deviceId: String? = null,
|
||||
val senderName: String? = null,
|
||||
val adapterId: String? = null,
|
||||
/**
|
||||
* 客户端幂等键(契约 v0.56 §3.13,`omitempty`):带 `clientMsgId` 发出的 user 消息会被
|
||||
* 服务端**原样回填**,`GET /messages` / WS `history` / `message_persisted` 都能看到。
|
||||
* 客户端据此把本地乐观气泡对齐到服务端真相——取代「靠文本相等」的脆弱匹配。
|
||||
* 空 = 这条消息没有键(老客户端发的、IM 镜像进来的、v0.56 之前的历史行)。
|
||||
*/
|
||||
val clientMsgId: String? = null,
|
||||
/**
|
||||
* 这条消息携带的文档引用与抽取统计(契约 v0.59 已发布(api-client.md §1.2「文档存储与摄取契约」);形状见 [DocumentRefDto])。
|
||||
*
|
||||
* 默认 [emptyList] 同时吃掉后端**尚未定死**的两种「0 个附件」形状:
|
||||
* **键缺失 → 空列表**(后端 `historyMessage.Documents` 带 `omitempty`,空/缺失即整键省略——
|
||||
* 这一点已对着 `5b2b385` 的实现与 `history_document_test.go` 的断言核对过)、
|
||||
* **空数组 → 空列表**(S3.5 之前写下的行、以及将来若改回恒下发 `[]`)。
|
||||
*
|
||||
* 只回引用与统计、**绝不回正文**;未知 `status` **原样保留**(开放集合,见 [DocumentRefDto])。
|
||||
* WS `message_persisted` 与握手 `history` 回放用的是同一个 DTO,因此这一处解析两条路都覆盖。
|
||||
*/
|
||||
val documents: List<DocumentRefDto> = emptyList(),
|
||||
val createdAt: Long,
|
||||
/**
|
||||
* 产出 / 吃进这条消息的那一路的回合标识(忙时并行规范 §2.7,**纯增量**)。
|
||||
*
|
||||
* 语义按角色分(规范 §2.7 明写):
|
||||
* - **user 行** = 吃进它 / 起它的那一路的 `turnId`(被 steer 吃进的行 = 吃进它的那一轮);
|
||||
* - **assistant 行** = 产出它的那一路的 `turnId`。
|
||||
*
|
||||
* 于是重连后能把「回复 ↔ 提问」精确配对,两条并行回复也天然分属两个 `turnId`(分桶有据),
|
||||
* **不必靠时间戳猜**(历史顺序的真源是 `(created_at, rowid)` = **完成先后**,
|
||||
* 不承诺与提问相邻)。
|
||||
*
|
||||
* **旧行整键省略**(不是空串,规范 §2.7 末条)⇒ [null] 时客户端回落既有的「按顺序渲染」,
|
||||
* 与加入这一列之前逐字相同。四处同源:`message_persisted` / `GET /messages` / 握手 `history`
|
||||
* 回放 / `channel_persisted`(本端不消费最后一条)。
|
||||
*/
|
||||
val turnId: String? = null,
|
||||
/**
|
||||
* 这条消息的语音元数据(契约 v0.67 登记、**v0.79 已实现**,形状见 [VoiceDto]);
|
||||
* **非语音消息整键缺席** —— 所以缺席与 `null` 是同一件事。
|
||||
*
|
||||
* ## 判据是**两个**问题,别再合成一个
|
||||
* - 「这条**来自**一段声音吗」= 这个键在不在(`domain.chat.hasVoice`);
|
||||
* - 「要不要画语音形态」= 键在 **且** `status != "failed"`(`domain.chat.isVoiceMessage`)。
|
||||
* 后端 M57-M59 的出站合成降级行(`{source:"tts", status:"failed", fallbackReason}`)与入站的
|
||||
* 转写失败行都是**键在、但没有可播的语音**:正文照常按文字渲染,另加一行灰色小标
|
||||
* (`domain.chat.voiceFallbackNote`),**不画**语音形态。按旧的单条判据(键在即语音)它们会
|
||||
* 被画成一个点下去必然播不了的气泡。
|
||||
*
|
||||
* ## 覆盖率(契约 v0.97):**全部读面都带**,第一条帧就带
|
||||
* `GET /sessions/{id}/messages`、`GET /admin/channels/messages`、
|
||||
* `GET /admin/channels/{platform}/messages`、`PATCH /messages/{id}` 的响应与
|
||||
* `message_edited` 广播帧,**外加** WS 握手 `history` 回放与 `message_persisted` /
|
||||
* `channel_persisted` 落库帧——后三处由后端 M44(`fb55769`)修好:落库顺序改为
|
||||
* **INSERT → 写 `voice` 列 → notify**,于是语音消息的**第一条**落库帧就带 `voice`
|
||||
* (v0.79 时期「落库那一刻还拿不到、等下一次拉取自动升级」的说法**已作废**)。
|
||||
*
|
||||
* 所以本端**没有任何**「实时帧先按普通文本显示、稍后升级成语音气泡」的兜底:
|
||||
* `voice` 在就按 `status` 分流,不在就不是语音。**不要为它加重试 / 轮询 / 延迟判定**。
|
||||
*
|
||||
* ⚠️ 两条实现前提(服务端口径,未来改动**别破坏它**):① `voice` 列写入必须先于 notify;
|
||||
* ② 帧内 `voice` 是**发出那一刻的即时列读**(每帧多一次查询,亚毫秒)。任一被改回
|
||||
* 「先 notify 再写列」,本端判据立刻退回 v0.79 的行为——而本端**刻意不再为它留兼容分支**。
|
||||
*
|
||||
* ⚠️ 列被外部改坏(非法 JSON / 越出闭集)时服务端**让字段缺席**(记一条不带正文的 WARN),
|
||||
* 于是这种情况在本端与「不是语音」同形——契约的 `voice` 没有「读不出」这一态,缺席是唯一
|
||||
* 诚实的表达(与 [documents] 回一条 `unreadable` 不同)。
|
||||
*/
|
||||
val voice: VoiceDto? = null,
|
||||
)
|
||||
|
||||
/**
|
||||
* `PATCH /api/v1/messages/{id}` 的请求体(D3,契约 §1 行 6a)。
|
||||
*
|
||||
* **只有 `content` 一个键**:服务端按它改 `content`,role / 时间戳 / 平台 / 图片 / 附件一律保持原值,
|
||||
* 所以这里**不能**多带任何字段(多带也不会被采纳,只会让后来的读者以为客户端能改这些)。
|
||||
*
|
||||
* 服务端口径:非空、≤ **4000 rune**;空 / 纯空白 / 超长回 `400 bad_request`。
|
||||
* [content] 要发**用户原样输入的内容**(含首尾空白)——服务端存的就是这个字符串,
|
||||
* 客户端替它 trim 一次等于悄悄改掉用户的消息。
|
||||
*/
|
||||
@Serializable
|
||||
data class UpdateMessageRequest(
|
||||
val content: String,
|
||||
)
|
||||
|
||||
/**
|
||||
* `DELETE /api/v1/messages/{id}` → `200 {"data":{"deleted":"<消息 id>"}}`(契约 §1 行 6b)。
|
||||
*
|
||||
* 与 [DeleteSessionResponse] 逐字同形,但**刻意分开**:这两个 id 的命名空间和「删掉了什么」
|
||||
* 是两件事,共用一个类型会让调用方以为删的是会话。
|
||||
*/
|
||||
@Serializable
|
||||
data class DeleteMessageResponse(
|
||||
val deleted: String,
|
||||
)
|
||||
@@ -0,0 +1,29 @@
|
||||
package top.yeij.cyrene.data.api.dto
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/**
|
||||
* `GET /api/v1/meta/enums` 表内一项(api-client §1.1)。
|
||||
* [id] 是**唯一可比对**的值(契约 id 为小写、大小写敏感);[label] 只是显示默认值,
|
||||
* 自带 i18n 的客户端可以覆盖。
|
||||
*/
|
||||
@Serializable
|
||||
data class EnumItemDto(
|
||||
val id: String = "",
|
||||
val label: String = "",
|
||||
)
|
||||
|
||||
/**
|
||||
* `platform` / `mood` / `mode` 的共享枚举权威表——客户端**不必硬编码**即可拿到稳定映射。
|
||||
*
|
||||
* 字段全部带默认值:只下发部分表(或 `null`)时仍能解码,缺失的那一类按「表内无此项」
|
||||
* 走原样回落,而不是让整次解码失败。
|
||||
* [unknown] 是机器可读的未知值回落约定,当前恒为 `"display-raw"`(原样显示,见 §1.1 红线)。
|
||||
*/
|
||||
@Serializable
|
||||
data class EnumTableDto(
|
||||
val platforms: List<EnumItemDto> = emptyList(),
|
||||
val moods: List<EnumItemDto> = emptyList(),
|
||||
val modes: List<EnumItemDto> = emptyList(),
|
||||
val unknown: String = "",
|
||||
)
|
||||
@@ -0,0 +1,26 @@
|
||||
package top.yeij.cyrene.data.api.dto
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/**
|
||||
* `GET /api/v1/personas`(**用户侧**角色目录,api-client §1 行 9)的一项。
|
||||
*
|
||||
* 与 admin 侧的 [PersonaSummaryDto] 同源同形,但**用户侧不下发 admin 专属的 `thinking`**
|
||||
* ——这正是不能拿 admin 端点当用户侧端点用的原因之一(用户侧不暴露模型调用旋钮)。
|
||||
*
|
||||
* [mode] / [mood] 都是**开放集合**:未知取值原样保留(不丢弃、不报错、不占位符),渲染时才映射。
|
||||
* `mood` 为空串是「该角色暂无被跟踪的情绪」,不是异常,也不伪造默认值。
|
||||
* [chatBackground] 与 admin 侧共用 [ChatBackgroundDto](同一个后端结构)。
|
||||
*/
|
||||
@Serializable
|
||||
data class UserPersonaDto(
|
||||
val id: String,
|
||||
val name: String = "",
|
||||
val character: String = "",
|
||||
val isDefault: Boolean = false,
|
||||
val mode: String = "",
|
||||
val memoryNamespace: String = "",
|
||||
val avatar: String? = null,
|
||||
val chatBackground: ChatBackgroundDto? = null,
|
||||
val mood: String? = null,
|
||||
)
|
||||
@@ -0,0 +1,146 @@
|
||||
package top.yeij.cyrene.data.api.dto
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/**
|
||||
* `GET /api/v1/sessions` 持久化会话摘要(api-reference §3.2 A)。
|
||||
*
|
||||
* 主会话 `id` 恒为 `"web"`、`type = "main"`,但**不恒在首位**:
|
||||
* 服务端排序是 `pinned DESC, type = 'main' DESC, created_at ASC`,
|
||||
* 被置顶的次级会话合法地排在主会话前面。
|
||||
*
|
||||
* [title] 在会话还没有任何用户消息时**整个键缺席**(自动命名只在那时写入)——
|
||||
* 这是正常状态,不是异常;渲染时用客户端兜底标题。
|
||||
*/
|
||||
@Serializable
|
||||
data class SessionSummaryDto(
|
||||
val id: String,
|
||||
val type: String,
|
||||
val title: String? = null,
|
||||
val createdAt: Long,
|
||||
val pinned: Boolean = false,
|
||||
/** 会话还没有消息时**整个键缺席**。 */
|
||||
val lastMessage: SessionLastMessageDto? = null,
|
||||
/**
|
||||
* 未读条数(D4,契约 v0.37 起**恒下发**):`createdAt >` 该会话已读游标的消息条数,
|
||||
* `0` = 无未读。
|
||||
*
|
||||
* 有默认值是为了**旧快照仍能解码**:[top.yeij.cyrene.core.prefs.SessionPreferences]
|
||||
* 把整个列表原样持久化成 JSON,非默认值会让升级后第一次启动的整份快照解码失败,
|
||||
* 临时失效(列表先空、等 REST 回来)——那正是本应用最不该出现的表现。
|
||||
* 默认 `0` 的口径也是安全的:它只会让角标少显示一会儿,`Get /sessions` 一到就被纠正。
|
||||
*/
|
||||
val unreadCount: Int = 0,
|
||||
/**
|
||||
* 该会话绑定的角色 id(v0.42,可选);**空/缺省 = 继承全局默认角色**。
|
||||
*
|
||||
* 只读字段:会话角色**只在创建时**写入,之后不可变(`PATCH` 一旦收到 `personaId` 一律 400),
|
||||
* 所以它不可能被本端改。默认值是为了旧快照仍能解码(同 [unreadCount] 的理由)。
|
||||
*/
|
||||
val personaId: String? = null,
|
||||
) {
|
||||
val isMain: Boolean
|
||||
get() = type == TYPE_MAIN || id == MAIN_SESSION_ID
|
||||
|
||||
companion object {
|
||||
const val MAIN_SESSION_ID = "web"
|
||||
const val TYPE_MAIN = "main"
|
||||
const val TYPE_SUB = "sub"
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* `POST /api/v1/sessions` → `201 {"data":{"sessionId":"web:<uuid>"}}`。
|
||||
*
|
||||
* [personaId] 只在**创建时显式绑了角色**才回显(v0.42);省略时响应形状与 v0.41 完全一致,
|
||||
* 所以它是可空字段,不能拿它的有无去判断别的事。
|
||||
*/
|
||||
@Serializable
|
||||
data class CreateSessionResponse(
|
||||
val sessionId: String,
|
||||
val personaId: String? = null,
|
||||
)
|
||||
|
||||
/**
|
||||
* `POST /api/v1/sessions` 的请求体(v0.42:创建时绑定角色)。
|
||||
*
|
||||
* 服务端把「省略 `personaId`」与「`personaId` 为空」当成同一件事:**不绑**
|
||||
* (`persona_id=''`,继承全局默认角色)。`personaId` 为**未知角色**时回 `400 bad_request`。
|
||||
*
|
||||
* 生产路径上 null 不会走到这里——仓库对 null 走的是**完全不带 body** 的重载
|
||||
* (与本端支持选角之前的线上请求逐字节相同)。这里保留可空字段,是为了让
|
||||
* 「null 序列化后整个键缺席」这件事有一处**可被测试钉住**的表达
|
||||
* (生产 `Json` 的 `explicitNulls = false`)。
|
||||
*/
|
||||
@Serializable
|
||||
data class CreateSessionRequest(
|
||||
val personaId: String? = null,
|
||||
)
|
||||
|
||||
/**
|
||||
* 会话最后一条消息的摘要(服务端 `historyLastMessage`)。
|
||||
*
|
||||
* [content] 已被服务端折叠空白、截断到 60 个字符 + `…`,但**保留原始的 `<p>` / `<action>` 标记**:
|
||||
* 直接拿来显示会露出标签,渲染前必须剥掉。
|
||||
*/
|
||||
@Serializable
|
||||
data class SessionLastMessageDto(
|
||||
val role: String,
|
||||
val content: String = "",
|
||||
val createdAt: Long,
|
||||
)
|
||||
|
||||
/**
|
||||
* `PATCH /api/v1/sessions/{id}` 的请求体。
|
||||
*
|
||||
* 服务端逐字段用指针判断:**字段缺席即保留原值**,没有「省略即清空」的陷阱;
|
||||
* 因此这里也必须可空,并且不能被序列化成显式 `null`——
|
||||
* 生产 `Json` 的 `explicitNulls = false` 正是保证这一点的地方。
|
||||
*
|
||||
* **绝不能加 `personaId`**:服务端对任何值的 `personaId` 都先判 400。
|
||||
*/
|
||||
@Serializable
|
||||
data class UpdateSessionRequest(
|
||||
val title: String? = null,
|
||||
val pinned: Boolean? = null,
|
||||
)
|
||||
|
||||
/**
|
||||
* `PATCH /api/v1/sessions/{id}` 的响应是**局部**形状(`{id, title?}` 和/或 `{id, pinned?}`),
|
||||
* 不是完整的会话行——绝不能拿它当列表行用。
|
||||
*/
|
||||
@Serializable
|
||||
data class UpdateSessionResponse(
|
||||
val id: String = "",
|
||||
val title: String? = null,
|
||||
val pinned: Boolean? = null,
|
||||
)
|
||||
|
||||
/** `DELETE /api/v1/sessions/{id}` → `200 {"data":{"deleted":"<id>"}}`。 */
|
||||
@Serializable
|
||||
data class DeleteSessionResponse(
|
||||
val deleted: String,
|
||||
)
|
||||
|
||||
/**
|
||||
* `POST /api/v1/sessions/{id}/read`(D4)的请求体:可选地把已读游标推进到 `before`。
|
||||
*
|
||||
* - **键缺席 = 「现在」**(「我看到的都算已读」)。生产 `Json` 的 `explicitNulls = false`
|
||||
* 保证 `before = null` 时整个键缺席,序列化出来是 `{}`——服务端明确接受空对象
|
||||
* (`api-reference §3.6b`:**空 body / `{}` = 现在**)。
|
||||
* - 服务端对 `0` / 负数 / 非数字一律 `400`,所以**绝不能**把 `0` 发上去:调用方拿到
|
||||
* 非正的「没有时间戳」时必须折成缺席(null),而不是原样透传。
|
||||
* - 游标**只前进**(SQL `MAX(read_at, before)`),重复 / 乱序 / 重试都幂等安全。
|
||||
*/
|
||||
@Serializable
|
||||
data class ReadSessionRequest(
|
||||
val before: Long? = null,
|
||||
)
|
||||
|
||||
/** `POST /api/v1/sessions/{id}/read` → `200 {"data":{"sessionId":"<id>","readAt":<毫秒>}}`。 */
|
||||
@Serializable
|
||||
data class ReadSessionResponse(
|
||||
val sessionId: String = "",
|
||||
/** 推进**后**的游标(毫秒 Unix 时间戳,与该会话消息的 `createdAt` 同单位)。 */
|
||||
val readAt: Long = 0L,
|
||||
)
|
||||
@@ -0,0 +1,31 @@
|
||||
package top.yeij.cyrene.data.api.dto
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/**
|
||||
* `POST /api/v1/upload` 的成功载荷(api-client §1 行 13,v0.24 起的 B1 端点)。
|
||||
*
|
||||
* 线上形状(`internal/services/gateway/upload.go` 的 `uploadResponse`,字段名逐字如此):
|
||||
* ```
|
||||
* 201 {"data":{"url":"http://192.168.1.10:10942/api/v1/files/<32hex>.png","size":1234,"mime":"image/png","name":"<32hex>.png"}}
|
||||
* ```
|
||||
*
|
||||
* 语义要点:
|
||||
* - [url] 是**绝对同源地址**(`scheme://host/api/v1/files/<name>`),**可直接作 `chat.image`**:
|
||||
* 视觉层只把 `http(s)://` 开头当 URL,裸路径会被当成 base64 —— 所以这个字段必须按 URL 校验,
|
||||
* 不能原样塞回去(见 `UploadRepository`);
|
||||
* - [name] 是服务端生成的 **32 位小写 hex + 白名单扩展名**,客户端原始文件名**被丢弃**(不可信输入);
|
||||
* - [size] 字节数与 [mime] 都是**服务端嗅探/统计的结果**,不是客户端声明的——客户端传的
|
||||
* `Content-Type` 一律不被采信(`sniffImageMIME`)。
|
||||
*
|
||||
* 全部字段带默认值:解码器是生产同一份 `Json`(`ignoreUnknownKeys` / `coerceInputValues`),
|
||||
* 将来服务端加键不会让整次上传变成解析失败。**缺 `url`(或 `url` 不是绝对 http(s))仍要判失败**——
|
||||
* 那是「上传成功但拿不到可用地址」,由仓库层拦,绝不把空串/路径当 URL 发出去。
|
||||
*/
|
||||
@Serializable
|
||||
data class UploadResponse(
|
||||
val url: String = "",
|
||||
val size: Long = 0,
|
||||
val mime: String = "",
|
||||
val name: String = "",
|
||||
)
|
||||
@@ -0,0 +1,86 @@
|
||||
package top.yeij.cyrene.data.api.dto
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/**
|
||||
* 一条消息的**语音元数据**(契约 **v0.67 登记、v0.79 已实现(切片 L45)**,
|
||||
* `Cyrene_v2/Docs/api-client.md` §4.4「`voice` 语义」与 §1.2 U19)。
|
||||
*
|
||||
* ## 🎤 判据 = 本键存在 **且** `status != "failed"`
|
||||
* `voice` 是 [HistoryMessageDto] 上的**指针**:非语音消息**整键缺席**(不是 `{}`,也不是 `null`)。
|
||||
* 客户端**只认这个键**,**绝不**从 `content` 正文里认媒体类型——QQ 语音正文里那个 `[语音]`
|
||||
* 占位标记自 v0.67 起**不再下发**,而正文里**也可能**出现用户自己打出的 `[语音]` 字样。
|
||||
*
|
||||
* 键在只说明「这条来自一段声音」;要不要画语音形态还看 `status`(见下)。
|
||||
* 判据落在一处:`domain.chat.isVoiceMessage`(`domain.chat.hasVoice` 答的是前半个问题)。
|
||||
*
|
||||
* ## 三态 `status`(闭集)
|
||||
* `transcribed`(正文即转写文本;出站方向 = 合成成功)/ `empty`(转写成功但没识别出文字)/
|
||||
* `failed`(**没有可播的语音**——入站没拿到转写文本,出站是合成降级回文字)。客户端**按 `status`
|
||||
* 分支,绝不按正文文案分支**;设计上刻意不下发 `failed` 的机器可读细分(未配置 / 鉴权 / 欠费 /
|
||||
* 限流 / 超时 / 格式不支持 / 无字节一律只进服务端日志),所以本端**不猜**细分。
|
||||
*
|
||||
* ## `fallbackReason` 只在 `failed` 时有
|
||||
* 出站方向(`source = "tts"`)合成降级时,服务端在这个键里给出降级原因;本端**原样**小字展示
|
||||
* (`domain.chat.voiceFallbackNote`),**不猜闭集**——取值集合后端尚未定稿,见那个函数的 KDoc。
|
||||
*
|
||||
* ## `name` / `mime` / `size` / `url` 四者同生共死
|
||||
* 有音频副本时四个键全在,**无副本时全缺席**(服务端保证不会出现 `url` 指着一个不存在的 `name`)。
|
||||
* 反过来说:**四个键没凑齐就不要画播放控件**——不能给一个点下去必然失败的按钮。
|
||||
* 判据落在一处:`domain.chat.voiceAudio`。一个真实的无副本例子是微信(`weixin-ilink`):
|
||||
* 平台自带转写就**不白下载那份音频**,于是 `source` 是 `platform` 而四个键全缺席
|
||||
* (**只有 🎤 标记**,不可回放)。
|
||||
*
|
||||
* ## `durationMs` 只有渠道如实给出时才有
|
||||
* 服务端「读不出来就省略键,**绝不估算**」。本端同理:**缺席就不显示时长**
|
||||
* (不拿 size / 码率反算,也不显示 `0:00` 冒充)。所以它是可空的,且 `<= 0` 一并当缺席
|
||||
* (见 `domain.chat.voiceDurationLabel`)。
|
||||
*
|
||||
* ## `url` 是**服务端相对路径**,客户端绝不自己拼
|
||||
* 取值形如 `/api/v1/media/<name>`,由服务端一处拼出(**唯一派生字段**)。
|
||||
* 客户端要取字节时把它接到**当前服务器地址**后面(`domain.chat.voiceMediaUrl`)——
|
||||
* 与图片那条「保留 path、替换 host」同一取向,但这里服务端给的就是相对路径,不需要换 host。
|
||||
*/
|
||||
@Serializable
|
||||
data class VoiceDto(
|
||||
/**
|
||||
* `"asr"`(服务端语音识别所得,含云端)/ `"platform"`(渠道平台自带转写,微信 iLink 的
|
||||
* `voice_item.text`)/ `"tts"`(我们自己合成的出站语音,后端 M57-M59)。
|
||||
* 见 `domain.chat.VoiceSource`。**开放取值**:认不出的原样保留。
|
||||
*
|
||||
* 接收侧**不按 `source` 分支**:合成成功的那条与入站语音同形(`status = transcribed` +
|
||||
* 四键副本),走同一条渲染路径。
|
||||
*/
|
||||
val source: String = "",
|
||||
/**
|
||||
* `"transcribed"` / `"empty"` / `"failed"`(闭集三态,见 `domain.chat.VoiceStatus`)。
|
||||
*
|
||||
* 默认空串而非某个态,是因为**认不出就不要装认识**:显示层对「不是 `empty`」的所有取值
|
||||
* (含服务端将来新增的态)一视同仁地不额外说话(见 `domain.chat.voiceNote`)。
|
||||
*
|
||||
* ⚠️ 只有 `"failed"` 是**没有可播的语音**的那一态(`domain.chat.isPlayableVoice`);认不出的
|
||||
* 取值一律当可播(fail-open,理由写在那个函数的 KDoc 里)。
|
||||
*/
|
||||
val status: String = "",
|
||||
/** 音频副本的存储名(内容寻址,`<32hex>.<ext>`);**无副本 ⇒ 键缺席**。 */
|
||||
val name: String? = null,
|
||||
/** 副本的 MIME(按存储名扩展名映射)。与 [name] / [size] / [url] 同生共死。 */
|
||||
val mime: String? = null,
|
||||
/** 副本的字节数。与 [name] / [mime] / [url] 同生共死;**不用它反算时长**。 */
|
||||
val size: Long? = null,
|
||||
/** 时长(毫秒)。**只有渠道如实给出时才有**;缺席 = 本端不显示时长。 */
|
||||
val durationMs: Int? = null,
|
||||
/**
|
||||
* 合成降级的原因(后端 M57-M59 预告,`devdocs/02` 已登记;契约 **v0.98 的正式回执尚未到**)。
|
||||
*
|
||||
* 只在 `status == "failed"` 时有意义,其余状态下服务端不会下发(`omitempty`);入站转写失败
|
||||
* 也不带它——入站的失败原因由正文那行诚实提示承载。
|
||||
*
|
||||
* 本端**原样**小字展示(`domain.chat.voiceFallbackNote`),**不猜闭集、不做映射**:取值集合
|
||||
* 后端还没定(`tts_timeout` 之类是我们看到的预告示例,不是契约)。缺席 / 空白时那一行小标
|
||||
* 退化成固定的一句(`VOICE_FALLBACK_NOTE`),不白屏。
|
||||
*/
|
||||
val fallbackReason: String? = null,
|
||||
/** 副本的服务端相对路径(`/api/v1/media/<name>`)。**不要自己拼这条路**,也不要当绝对地址用。 */
|
||||
val url: String? = null,
|
||||
)
|
||||
@@ -0,0 +1,133 @@
|
||||
package top.yeij.cyrene.data.image
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.BitmapFactory
|
||||
import android.net.Uri
|
||||
import android.util.Base64
|
||||
import top.yeij.cyrene.domain.chat.ChatImageBudget
|
||||
import java.io.ByteArrayOutputStream
|
||||
|
||||
/**
|
||||
* 一次重编码的产物。
|
||||
*
|
||||
* [dataUri] 与 [bytes] 是**同一张图**的两种形态:内联(base64 `data:` URI)用前者,
|
||||
* 上传(`POST /api/v1/upload`)用后者。上传时**不能**从 [dataUri] 反解一遍——那既多一次
|
||||
* 解码与分配,也让「上传的字节」和「内联的字节」有漂移的余地。
|
||||
*/
|
||||
class PreparedImage(
|
||||
val dataUri: String,
|
||||
val bytes: ByteArray,
|
||||
val mime: String,
|
||||
)
|
||||
|
||||
/**
|
||||
* 把用户选的本地图片压成可以内联发送的 `data:` URI(**发送前的必经一步**)。
|
||||
*
|
||||
* 为什么要重编码而不是直接读原始字节:服务端把 `image` 字符串**原样落库**,
|
||||
* 每一页历史与每一次 WS `history` 回放都要重传它(协同文档 G3)。所以这里
|
||||
* 无论用户选了什么,出口都保证 ≤ [ChatImageBudget.MAX_DECODED_BYTES]:
|
||||
*
|
||||
* 1. 先按边界解码拿原尺寸 → 等比缩到最长边 [ChatImageBudget.MAX_DIMENSION_PX];
|
||||
* 2. 编码(有 alpha 用 PNG,否则用 JPEG);
|
||||
* 3. 仍超预算 → **尺寸减半重来**(最多 [MAX_ATTEMPTS] 档);
|
||||
* 4. 出口再按服务端的**字符串长度**上限复核一次(4/3 膨胀 + 前缀余量)。
|
||||
*
|
||||
* 任何一步失败(读不到 / 解不出 / 编不出 / 压不到预算内)都返回 `null`——由调用方回滚并报错,
|
||||
* **绝不**退回「原图 base64」:那既会撞服务端 5 MiB 硬顶,也会把历史背成几 MB。
|
||||
*
|
||||
* 产物同时给出 `data:` URI 与**同一份**字节:**内联还是上传由调用方按体积定**
|
||||
* (见 `ChatImageDelivery`)——本对象只管「编到预算内」,通道选择是另一件事,而且它需要
|
||||
* 编完之后的真实字节数才谈得上。
|
||||
*
|
||||
* 预算数学在 [ChatImageBudget](纯函数,可单测);本对象只剩「必须真机才有」的解码与编码。
|
||||
*/
|
||||
object ChatImagePreparer {
|
||||
|
||||
private const val JPEG_QUALITY = 80
|
||||
|
||||
/** 缩不动就减半重来的次数上限(1 MiB 预算下 1280px 起步一般 1–2 次就够)。 */
|
||||
private const val MAX_ATTEMPTS = 4
|
||||
|
||||
/**
|
||||
* @return 可内联发送的 `data:` URI 与可上传的同一份字节;失败返回 `null`。
|
||||
* 必须在后台线程调用(解码 + 缩放 + 编码都是重活)。
|
||||
*/
|
||||
fun prepare(context: Context, uri: Uri): PreparedImage? {
|
||||
val raw = readBytes(context, uri) ?: return null
|
||||
val decoded = decodeScaled(raw) ?: return null
|
||||
return try {
|
||||
encodeWithinBudget(decoded)
|
||||
} finally {
|
||||
decoded.recycle()
|
||||
}
|
||||
}
|
||||
|
||||
private fun readBytes(context: Context, uri: Uri): ByteArray? =
|
||||
runCatching { context.contentResolver.openInputStream(uri)?.use { it.readBytes() } }.getOrNull()
|
||||
|
||||
/** 按**目标尺寸**两步解码:先读边界算 `inSampleSize`(省内存),再精确缩放到目标。 */
|
||||
private fun decodeScaled(raw: ByteArray): Bitmap? {
|
||||
val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
|
||||
BitmapFactory.decodeByteArray(raw, 0, raw.size, bounds)
|
||||
if (bounds.outWidth <= 0 || bounds.outHeight <= 0) return null
|
||||
|
||||
val target = ChatImageBudget.scaledSize(bounds.outWidth, bounds.outHeight)
|
||||
val options = BitmapFactory.Options().apply {
|
||||
inSampleSize = sampleSizeFor(bounds.outWidth, bounds.outHeight, target.width, target.height)
|
||||
}
|
||||
val sampled = runCatching { BitmapFactory.decodeByteArray(raw, 0, raw.size, options) }.getOrNull()
|
||||
?: return null
|
||||
if (sampled.width == target.width && sampled.height == target.height) return sampled
|
||||
|
||||
val scaled = runCatching {
|
||||
Bitmap.createScaledBitmap(sampled, target.width, target.height, true)
|
||||
}.getOrNull() ?: return null
|
||||
if (scaled !== sampled) sampled.recycle()
|
||||
return scaled
|
||||
}
|
||||
|
||||
/** 在不小于目标尺寸的前提下,取最大的 2 的幂作为采样步长。 */
|
||||
private fun sampleSizeFor(width: Int, height: Int, targetWidth: Int, targetHeight: Int): Int {
|
||||
var sample = 1
|
||||
while (width / (sample * 2) >= targetWidth && height / (sample * 2) >= targetHeight) {
|
||||
sample *= 2
|
||||
}
|
||||
return sample
|
||||
}
|
||||
|
||||
/** 逐档降尺寸,直到编码后字节数落进预算;都落不进返回 `null`。 */
|
||||
private fun encodeWithinBudget(source: Bitmap): PreparedImage? {
|
||||
var width = source.width
|
||||
var height = source.height
|
||||
repeat(MAX_ATTEMPTS) { attempt ->
|
||||
val frame = if (attempt == 0) source else scale(source, width, height) ?: return null
|
||||
// 有 alpha 的图走 JPEG 会把透明区压成黑块,所以这类图保持 PNG(无损、体积更大)。
|
||||
val mime = if (frame.hasAlpha()) MIME_PNG else MIME_JPEG
|
||||
val bytes = compress(frame, mime)
|
||||
if (frame !== source) frame.recycle()
|
||||
|
||||
if (ChatImageBudget.withinTarget(bytes.size)) {
|
||||
val dataUri = ChatImageBudget.dataUri(mime, Base64.encodeToString(bytes, Base64.NO_WRAP))
|
||||
if (!ChatImageBudget.withinServerLimit(dataUri.length)) return null
|
||||
return PreparedImage(dataUri = dataUri, bytes = bytes, mime = mime)
|
||||
}
|
||||
width = (width / 2).coerceAtLeast(1)
|
||||
height = (height / 2).coerceAtLeast(1)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
private fun scale(source: Bitmap, width: Int, height: Int): Bitmap? =
|
||||
runCatching { Bitmap.createScaledBitmap(source, width, height, true) }.getOrNull()
|
||||
|
||||
private fun compress(bitmap: Bitmap, mime: String): ByteArray {
|
||||
val format = if (mime == MIME_PNG) Bitmap.CompressFormat.PNG else Bitmap.CompressFormat.JPEG
|
||||
val out = ByteArrayOutputStream()
|
||||
bitmap.compress(format, JPEG_QUALITY, out)
|
||||
return out.toByteArray()
|
||||
}
|
||||
|
||||
private const val MIME_JPEG = "image/jpeg"
|
||||
private const val MIME_PNG = "image/png"
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package top.yeij.cyrene.data.repository
|
||||
|
||||
import top.yeij.cyrene.data.api.AdminApi
|
||||
import top.yeij.cyrene.data.api.apiCall
|
||||
import top.yeij.cyrene.data.api.dto.AdminBackthinkItemDto
|
||||
import top.yeij.cyrene.data.api.dto.AdminLogEntryDto
|
||||
import top.yeij.cyrene.data.api.dto.AdminMetricsDto
|
||||
import top.yeij.cyrene.data.api.dto.AdminTraceSpanDto
|
||||
import top.yeij.cyrene.data.api.dto.MemorySummaryDto
|
||||
import top.yeij.cyrene.data.api.dto.PersonaSummaryDto
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
/**
|
||||
* 管理只读监控取数层(api-client §2)。
|
||||
* 权限判断不在这里:仓库只管取数,403 由调用方按 [top.yeij.cyrene.domain.admin.AdminFailure] 收敛。
|
||||
*/
|
||||
class AdminRepository(
|
||||
private val api: AdminApi,
|
||||
private val dispatcher: CoroutineDispatcher = Dispatchers.IO,
|
||||
) {
|
||||
|
||||
suspend fun metrics(): Result<AdminMetricsDto> = withContext(dispatcher) {
|
||||
apiCall { api.metrics() }
|
||||
}
|
||||
|
||||
/** `GET /observability/trace`;`limit` 与服务端对齐,钳制在 [1, [MAX_TRACE_LIMIT]]。 */
|
||||
suspend fun trace(limit: Int = DEFAULT_TRACE_LIMIT): Result<List<AdminTraceSpanDto>> =
|
||||
withContext(dispatcher) {
|
||||
apiCall { api.trace(limit.coerceIn(1, MAX_TRACE_LIMIT)) }
|
||||
}
|
||||
|
||||
/**
|
||||
* `GET /observability/logs`(后端日志 tail)。`limit` 钳制在 [1, [MAX_LOG_LIMIT]] 后
|
||||
* **显式**发出去(服务端对 ≤0 / 非法值有一条「回落缺省」的兜底,这里不依赖它):
|
||||
* 这样「向服务端要多少条」与客户端的保留窗口(`LOG_WINDOW`)永远同源。
|
||||
*/
|
||||
suspend fun logs(limit: Int = DEFAULT_LOG_LIMIT): Result<List<AdminLogEntryDto>> =
|
||||
withContext(dispatcher) {
|
||||
apiCall { api.logs(limit.coerceIn(1, MAX_LOG_LIMIT)) }
|
||||
}
|
||||
|
||||
suspend fun personas(): Result<List<PersonaSummaryDto>> = withContext(dispatcher) {
|
||||
apiCall { api.personas() }
|
||||
}
|
||||
|
||||
suspend fun memories(): Result<List<MemorySummaryDto>> = withContext(dispatcher) {
|
||||
apiCall { api.memories() }
|
||||
}
|
||||
|
||||
suspend fun backthink(): Result<List<AdminBackthinkItemDto>> = withContext(dispatcher) {
|
||||
apiCall { api.backthink() }
|
||||
}
|
||||
|
||||
companion object {
|
||||
/** trace 拉取条数:够看近期活动,又不会把一屏塞满。 */
|
||||
const val DEFAULT_TRACE_LIMIT = 100
|
||||
|
||||
/** 服务端 `?limit=` 上限。 */
|
||||
const val MAX_TRACE_LIMIT = 500
|
||||
|
||||
/**
|
||||
* 日志拉取条数:契约的缺省值,也就是「最近日志」区块的保留窗口
|
||||
* (`ui/admin/AdminOverviewMerge.kt` 的 `LOG_WINDOW`)与推送帧的单帧上限。
|
||||
*/
|
||||
const val DEFAULT_LOG_LIMIT = 200
|
||||
|
||||
/** 服务端 logs `?limit=` 上限(契约 §2)。 */
|
||||
const val MAX_LOG_LIMIT = 1000
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
package top.yeij.cyrene.data.repository
|
||||
|
||||
import top.yeij.cyrene.core.error.CyreneError
|
||||
import top.yeij.cyrene.data.api.ApiErrorMapper
|
||||
import top.yeij.cyrene.data.api.ChatApi
|
||||
import top.yeij.cyrene.data.api.CyreneException
|
||||
import top.yeij.cyrene.data.api.dto.ChatAckDto
|
||||
import top.yeij.cyrene.data.api.dto.ChatRequest
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
/**
|
||||
* `POST /api/v1/chat` 的受理判定(契约 v0.56)。
|
||||
*
|
||||
* 它是一个**密封结果**而不是 `Result.failure`:v0.56 起「会话正忙」与「排队已满」不再是异常,
|
||||
* 而是编排器的正常回执(修复前 handler 提前 `return 200`,这两种状态**永远不可能出现**)。
|
||||
* 把它们和网络错误混在同一条失败流里,调用方就只能靠字符串猜——这正是本类型要消除的东西。
|
||||
*/
|
||||
sealed interface ChatAck {
|
||||
|
||||
/** `200 {stream:true}`:回合已起,内容帧走**已连接的 WS**。 */
|
||||
data object Accepted : ChatAck
|
||||
|
||||
/** `202 {stream:true,queued:true,queueDepth:n}`:会话正忙,这条已续接排队(**已落库**)。 */
|
||||
data class Queued(val queueDepth: Int) : ChatAck
|
||||
|
||||
/** `200 {duplicate:true,messageId,sessionId}`:幂等键重复,服务端**没有**再落库 / 再起回合。 */
|
||||
data class Duplicate(val messageId: String?, val sessionId: String?) : ChatAck
|
||||
|
||||
/** `409 {error:{code:"chat_busy"}}`:待处理队列已满,这条**没有被受理、没有落库**。 */
|
||||
data object QueueFull : ChatAck
|
||||
|
||||
/**
|
||||
* `500 chat_unavailable` / `503 chat_dispatch_failed`:服务端接不住这一轮。
|
||||
* [code] 保留原始错误码——两者的区别对排查有用,对用户文案无差别。
|
||||
*/
|
||||
data class Unavailable(val code: String, val message: String) : ChatAck
|
||||
|
||||
/** 其余明确的拒绝(`400 bad_request` / `400 image_too_large` / 未知码)。 */
|
||||
data class Rejected(val code: String, val message: String) : ChatAck
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送一条消息(REST 通道)。
|
||||
*
|
||||
* 与别的仓库不同的一点:**非 2xx 里有一半是业务态而不是失败**(见 [ChatAck]),
|
||||
* 所以这里刻意**不使用** [top.yeij.cyrene.data.api.apiCall]——它把 4xx/5xx 一律折进
|
||||
* `Result.failure`、且只回 `data` 丢掉状态码,于是 `202` 与 `409` 的差别在折进去的那一刻
|
||||
* 就没了。信封解析与错误映射仍走 [ApiErrorMapper],口径与其它端点一致。
|
||||
*
|
||||
* `Result.failure` 只留给**真正发不出去**的情况:网络 / 超时 / 解析不出来 / 401。
|
||||
*/
|
||||
class ChatRepository(
|
||||
private val api: ChatApi,
|
||||
private val dispatcher: CoroutineDispatcher = Dispatchers.IO,
|
||||
) {
|
||||
|
||||
suspend fun send(request: ChatRequest): Result<ChatAck> = withContext(dispatcher) {
|
||||
try {
|
||||
val response = api.sendChat(request)
|
||||
val data = response.body()?.data
|
||||
when {
|
||||
response.isSuccessful && data != null -> Result.success(ackOf(data))
|
||||
// 2xx 却没有 data:与 apiCall 同一口径,判解析失败而不是「已受理」。
|
||||
response.isSuccessful -> Result.failure(parseFailure())
|
||||
else -> {
|
||||
val error = ApiErrorMapper.fromHttp(response.code(), response.errorBody()?.string())
|
||||
val ack = serviceAck(error)
|
||||
if (ack != null) Result.success(ack) else Result.failure(CyreneException(error))
|
||||
}
|
||||
}
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
Result.failure(CyreneException(ApiErrorMapper.fromThrowable(e)))
|
||||
}
|
||||
}
|
||||
|
||||
private fun ackOf(data: ChatAckDto): ChatAck = when {
|
||||
data.duplicate == true -> ChatAck.Duplicate(data.messageId, data.sessionId)
|
||||
data.queued == true -> ChatAck.Queued(data.queueDepth ?: 0)
|
||||
// `{stream:true}` 以及将来新增的未知组合:受理了、形状不认识 → 按已受理处理。
|
||||
else -> ChatAck.Accepted
|
||||
}
|
||||
|
||||
/** 非 2xx 里属于**编排器真实回执**的部分;返回 null = 真失败(401 或未知服务端错误)。 */
|
||||
private fun serviceAck(error: CyreneError): ChatAck? {
|
||||
if (error is CyreneError.Unauthorized) return null
|
||||
val code = (error as? CyreneError.Server)?.code.orEmpty()
|
||||
val message = error.userMessage
|
||||
return when (code) {
|
||||
"chat_busy" -> ChatAck.QueueFull
|
||||
"chat_unavailable", "chat_dispatch_failed" -> ChatAck.Unavailable(code, message)
|
||||
else -> ChatAck.Rejected(code, message)
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseFailure() =
|
||||
CyreneException(CyreneError.Local(CyreneError.LocalKind.Parse))
|
||||
}
|
||||
@@ -0,0 +1,380 @@
|
||||
package top.yeij.cyrene.data.repository
|
||||
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import top.yeij.cyrene.core.error.CyreneError
|
||||
import top.yeij.cyrene.data.api.ApiErrorMapper
|
||||
import top.yeij.cyrene.data.api.CyreneException
|
||||
import top.yeij.cyrene.domain.chat.DocumentAttachments
|
||||
import top.yeij.cyrene.domain.chat.DocumentSource
|
||||
import top.yeij.cyrene.domain.chat.ImageDataUri
|
||||
import top.yeij.cyrene.domain.chat.ImageFormats
|
||||
import top.yeij.cyrene.domain.chat.ImageNames
|
||||
import top.yeij.cyrene.domain.chat.ImageSource
|
||||
import top.yeij.cyrene.domain.chat.readBounded
|
||||
import java.io.File
|
||||
import java.io.IOException
|
||||
import java.io.InputStream
|
||||
import java.nio.file.AtomicMoveNotSupportedException
|
||||
import java.nio.file.Files
|
||||
import java.nio.file.StandardCopyOption
|
||||
import java.security.MessageDigest
|
||||
|
||||
/** 待发附件的本地读取失败(授权失效 / 文件已被删 / provider 报错)。 */
|
||||
class LocalDocumentUnavailableException : Exception("local document unreadable")
|
||||
|
||||
/** 待发附件超过本端能处理的体积上限(服务端另有 `413 document_too_large`)。 */
|
||||
class LocalDocumentTooLargeException : Exception("local document too large")
|
||||
|
||||
/** 缓存目录写不进去(连预览都做不了——字节拿到了也没地方交给别的 App)。 */
|
||||
class DocumentCacheUnavailableException : Exception("document cache unavailable")
|
||||
|
||||
/** 服务端返回的图片字节超过本端一次性读取的上限(见 [ImageCacheStore] 那把尺子)。 */
|
||||
class ImageTooLargeException : Exception("image too large")
|
||||
|
||||
/**
|
||||
* `cacheDir/documents/` 的路径推导、命中判定与原子落盘(纯逻辑 + 文件操作,可单测)。
|
||||
*
|
||||
* ## 为什么命中就等于「永远有效」
|
||||
* 服务端落盘名是**内容寻址**的(`sha256(bytes)[:32] + ext`,契约 v0.59 §1.2 ①),且读侧响应带
|
||||
* `Cache-Control: private, max-age=86400, immutable`(§1.2 ②)。名字由内容决定 ⇒ **同一个
|
||||
* `name` 的字节永不改变**。所以这里不做任何过期 / 校验 / 大小比对:文件在且非空就是命中。
|
||||
* 反过来说,服务端就算把源文件删了,本地这份也仍是当初的那一份内容(这正是缓存的意义)。
|
||||
*
|
||||
* ## 为什么必须原子落盘
|
||||
* 下载中途失败(断网 / 进程被杀)会留下一个**截断的文件**。如果它落在正式名下,下一次打开
|
||||
* 就会「命中」并把这个半截文件当成完整内容显示——**静默的错误内容比一次失败糟得多**。
|
||||
* 因此先写 `.part` 临时文件,成功了再改名;任何一步失败都把临时文件删掉。
|
||||
*/
|
||||
object DocumentCache {
|
||||
|
||||
/** FileProvider(见 `AndroidManifest.xml` 与 `res/xml/file_paths.xml`)暴露的那一层目录名。 */
|
||||
const val DIRECTORY_NAME: String = "documents"
|
||||
|
||||
/** 缓存目录:`<cacheDir>/documents`。 */
|
||||
fun directory(cacheDir: File): File = File(cacheDir, DIRECTORY_NAME)
|
||||
|
||||
/**
|
||||
* 某个名字在缓存里的路径;名字**不安全**时回 `null`。
|
||||
*
|
||||
* 缓存目录是唯一被 FileProvider 暴露出去的根,所以这里对名字的容忍度取最小:单段、
|
||||
* 非空、不是 `.` / `..`、不含分隔符与 NUL。服务端形态(`<32hex>.<ext>`)与
|
||||
* [localName] 生成的形态都天然满足;不满足的名字一律拒绝,绝不用它拼出一条能爬出
|
||||
* `documents/` 的路径。
|
||||
*/
|
||||
fun fileFor(cacheDir: File, name: String): File? =
|
||||
if (isSafeName(name)) File(directory(cacheDir), name) else null
|
||||
|
||||
/** 名字是否可以安全地当**单个**文件名用。 */
|
||||
fun isSafeName(name: String): Boolean =
|
||||
name.isNotBlank() &&
|
||||
name != "." &&
|
||||
name != ".." &&
|
||||
name.none { it == '/' || it == '\\' || it == '\u0000' }
|
||||
|
||||
/** 命中判定:**非空文件**才算。0 字节(失败落盘 / 被外部清空的残留)一律当未命中。 */
|
||||
fun isHit(file: File): Boolean = file.isFile && file.length() > 0L
|
||||
|
||||
/**
|
||||
* 原子落盘:写临时文件 → 改名到 [file]。失败时**不留半截文件**在任何正式名下。
|
||||
*
|
||||
* @throws IOException 写不动 / 改名失败(临时文件已清掉)。
|
||||
*/
|
||||
fun write(file: File, bytes: ByteArray) {
|
||||
val directory = file.parentFile ?: throw IOException("cache file has no parent: $file")
|
||||
if (!directory.isDirectory && !directory.mkdirs()) {
|
||||
throw IOException("cannot create cache directory: $directory")
|
||||
}
|
||||
val temporary = File.createTempFile(TEMP_PREFIX, TEMP_SUFFIX, directory)
|
||||
try {
|
||||
temporary.outputStream().use { it.write(bytes) }
|
||||
moveInto(temporary, file)
|
||||
} catch (e: Exception) {
|
||||
temporary.delete()
|
||||
throw e
|
||||
} finally {
|
||||
// 成功时临时文件已被改名(不再存在);失败时这里再兜一次,别把 .part 留在缓存里。
|
||||
if (temporary.exists()) temporary.delete()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 本地待发附件的缓存名:**也按内容寻址**(`sha256(bytes)[:32] + 扩展名`)。
|
||||
*
|
||||
* 复用服务端那条规则有两个好处:同一份字节重复打开只落一份;名字形态与服务端存储名同构,
|
||||
* 「打开 / 保存」两条路不必分支处理。扩展名取自待发附件的显示名(本地这一侧它是可信的,
|
||||
* 用户选的就是它)——拿不到就用空扩展名,**不猜**。
|
||||
*/
|
||||
fun localName(bytes: ByteArray, displayName: String): String =
|
||||
sha256Hex(bytes).take(NAME_HEX_LENGTH) + extensionOf(displayName)
|
||||
|
||||
private fun moveInto(from: File, to: File) {
|
||||
try {
|
||||
Files.move(
|
||||
from.toPath(),
|
||||
to.toPath(),
|
||||
StandardCopyOption.REPLACE_EXISTING,
|
||||
StandardCopyOption.ATOMIC_MOVE,
|
||||
)
|
||||
} catch (_: AtomicMoveNotSupportedException) {
|
||||
Files.move(from.toPath(), to.toPath(), StandardCopyOption.REPLACE_EXISTING)
|
||||
}
|
||||
}
|
||||
|
||||
private fun sha256Hex(bytes: ByteArray): String =
|
||||
MessageDigest.getInstance("SHA-256").digest(bytes).joinToString("") { "%02x".format(it) }
|
||||
|
||||
/** 显示名里的扩展名(带点、小写、≤8 个字母数字);拿不到就回空串。 */
|
||||
private fun extensionOf(displayName: String): String {
|
||||
val extension = displayName.substringAfterLast('.', "").lowercase()
|
||||
val usable = extension.isNotEmpty() &&
|
||||
extension.length <= MAX_EXTENSION_LENGTH &&
|
||||
extension.all { it.isLetterOrDigit() }
|
||||
return if (usable) ".$extension" else ""
|
||||
}
|
||||
|
||||
private const val TEMP_PREFIX = "cache-"
|
||||
private const val TEMP_SUFFIX = ".part"
|
||||
private const val NAME_HEX_LENGTH = 32
|
||||
private const val MAX_EXTENSION_LENGTH = 8
|
||||
}
|
||||
|
||||
/**
|
||||
* 缓存里的一份文档:[file] 是落盘的那一份(交给 FileProvider / 写进下载目录),[bytes] 是它的
|
||||
* 内容(预览切段用)。
|
||||
*
|
||||
* 两者一起返回是刻意的:下载刚拿到的字节与随后写进缓存的是**同一份**,再读一次盘只是白花
|
||||
* 一次 20 MiB 的 IO。
|
||||
*/
|
||||
class CachedDocument(val file: File, val bytes: ByteArray)
|
||||
|
||||
/**
|
||||
* 「按需取字节 + 落盘缓存」的取数层(`GET /api/v1/documents/{name}` 的**第一个调用方**)。
|
||||
*
|
||||
* 服务端([DocumentRepository.download])与本地(待发附件的 `content://`)两条来源在这里汇成
|
||||
* 同一个结果。来源判定的**唯一**落点是 [DocumentSource](见 `DocumentTarget.of`)——本类只按
|
||||
* 它分支,不再自己判断「这是服务端还是本地」。
|
||||
*
|
||||
* [openLocal] 收成一个 `String -> InputStream?`,把 `ContentResolver` 挡在类外:这样本类可以在
|
||||
* 单测里用临时目录 + 假流跑完(Android 的 `Uri` / `ContentResolver` 在 JVM 单测里是 stub,
|
||||
* 一碰就抛)。
|
||||
*/
|
||||
class DocumentCacheStore(
|
||||
private val repository: DocumentRepository,
|
||||
private val cacheDir: File,
|
||||
private val openLocal: (String) -> InputStream?,
|
||||
private val dispatcher: CoroutineDispatcher = Dispatchers.IO,
|
||||
) {
|
||||
|
||||
/**
|
||||
* 取回一份附件的字节并确保它已落进缓存。
|
||||
*
|
||||
* 服务端来源先看缓存([DocumentCache.isHit])——命中就**一个请求都不发**;本地来源没有
|
||||
* 「命中」可言(URI 指向的内容可能变),每次都按当前字节重新算内容寻址名。
|
||||
*/
|
||||
suspend fun load(source: DocumentSource): Result<CachedDocument> = withContext(dispatcher) {
|
||||
when (source) {
|
||||
is DocumentSource.Server -> loadServer(source.name)
|
||||
is DocumentSource.Local -> loadLocal(source)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun loadServer(name: String): Result<CachedDocument> {
|
||||
val file = DocumentCache.fileFor(cacheDir, name)
|
||||
// 名字不安全 = 服务端给了一个本端不敢拼路径的名字;判解析失败,绝不退回本地文件名。
|
||||
?: return Result.failure(CyreneException(CyreneError.Local(CyreneError.LocalKind.Parse)))
|
||||
if (DocumentCache.isHit(file)) {
|
||||
val cached = runCatching { file.readBytes() }.getOrNull()
|
||||
if (cached != null) return Result.success(CachedDocument(file, cached))
|
||||
// 缓存读不动(被外部清理 / 权限异常):删掉重下,绝不当成「空文件」返回。
|
||||
file.delete()
|
||||
}
|
||||
val bytes = repository.download(name).getOrElse { return Result.failure(it) }
|
||||
return cache(file, bytes)
|
||||
}
|
||||
|
||||
private fun loadLocal(source: DocumentSource.Local): Result<CachedDocument> {
|
||||
val bytes = try {
|
||||
val input = openLocal(source.uri)
|
||||
?: return Result.failure(LocalDocumentUnavailableException())
|
||||
input.use { stream ->
|
||||
// 与上传路径同一把尺子:本端能处理的体积上限就是附件上限。
|
||||
readBounded(stream, DocumentAttachments.MAX_FILE_BYTES)
|
||||
} ?: return Result.failure(LocalDocumentTooLargeException())
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (_: Exception) {
|
||||
return Result.failure(LocalDocumentUnavailableException())
|
||||
}
|
||||
val file = DocumentCache.fileFor(cacheDir, DocumentCache.localName(bytes, source.displayName))
|
||||
?: return Result.failure(CyreneException(CyreneError.Local(CyreneError.LocalKind.Parse)))
|
||||
// 内容寻址:同一份字节已经落过盘就不重写(重复打开一份大附件不该反复写 20 MiB)。
|
||||
if (!DocumentCache.isHit(file)) return cache(file, bytes)
|
||||
return Result.success(CachedDocument(file, bytes))
|
||||
}
|
||||
|
||||
private fun cache(file: File, bytes: ByteArray): Result<CachedDocument> = try {
|
||||
DocumentCache.write(file, bytes)
|
||||
Result.success(CachedDocument(file, bytes))
|
||||
} catch (_: Exception) {
|
||||
// 写不进去时把可能的残留清掉,再如实报「缓存不可用」——预览本身也是靠缓存文件那条路
|
||||
// 把完整内容交给别的 App 的,所以这里不回退成「只看得到一半」。
|
||||
file.delete()
|
||||
Result.failure(DocumentCacheUnavailableException())
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 图片字节的取数与落盘(读侧是 `GET /api/v1/files/{name}`)。
|
||||
*
|
||||
* ## 为什么与附件挤在同一个缓存层
|
||||
* 图片与附件落的是**同一个目录**(`cacheDir/documents/`),因此共用同一个 FileProvider 根。
|
||||
* 「缓存目录是唯一被暴露出去的根」这条不变量只允许有一处实现:名字规则、命中判定、原子落盘
|
||||
* 三条全部复用 [DocumentCache]——图片只是多用了「名字推不出来时按内容寻址」这一条。
|
||||
* 不新开 `images/` 目录也不改 `file_paths.xml`:暴露面一个字节都没变。
|
||||
*
|
||||
* ## 三种来源各自的取字节方式
|
||||
* - [ImageSource.Server]:先按 URL 里的名字看缓存(**命中一个请求都不发**,名字由内容决定
|
||||
* ⇒ 命中永不失效),未命中才 GET 那个绝对地址;
|
||||
* - [ImageSource.Inline]:小图内联进历史的那份 `data:` URI,字节就在字符串里,**不联网**;
|
||||
* - [ImageSource.Local]:相册选择器的 `content://` / 分享落地的 `file://`,按 URI 现读。
|
||||
*
|
||||
* ## 为什么用一条**裸的** OkHttp 客户端
|
||||
* 图片读侧是 128-bit 随机名构成的**能力 URL**,服务端刻意不校验 Bearer(`<img>` 也带不了头)。
|
||||
* 所以这里不发 `Authorization`:把主人的 token 发到一个免鉴权端点上没有任何意义,只多一个泄漏面。
|
||||
* ⚠️ 文档读侧(`GET /api/v1/documents/{name}`)**要** Bearer,那条路走 [DocumentCacheStore]。
|
||||
*
|
||||
* ## 为什么不复用 Coil 已经取过的那一份
|
||||
* 气泡缩略图是 Coil 拉的,它的磁盘缓存**不给**「这一份普通文件在哪」这种契约(键与落盘格式都是
|
||||
* 它内部的事)。而两条出口要交给系统别的 App 的恰恰是一份普通文件(FileProvider 只认路径)。
|
||||
* 所以这里自己取一次、按名字落盘:代价是**首次打开一张服务端图会多下一次**(第二次起走本地
|
||||
* 缓存),换来的是「预览看到的、打开给出的、保存写下的」是同一份字节,不会出现三条路各下一个。
|
||||
*
|
||||
* [download] 与 [openLocal] 都收成 lambda,把 OkHttp 与 `ContentResolver` 挡在类外:本类因此
|
||||
* 可以在单测里用临时目录 + 假流跑完(`android.net.Uri` 在 JVM 单测里是 stub,一碰就抛)。
|
||||
*/
|
||||
class ImageCacheStore(
|
||||
private val cacheDir: File,
|
||||
private val openLocal: (String) -> InputStream?,
|
||||
private val download: suspend (String) -> Result<ByteArray> = ::httpGetImage,
|
||||
private val dispatcher: CoroutineDispatcher = Dispatchers.IO,
|
||||
) {
|
||||
|
||||
/**
|
||||
* 取回一张图的字节并确保它已落进缓存。
|
||||
*
|
||||
* 三种来源的产物是同一个形状([CachedDocument]):文件名 + 字节。这样「用其它应用打开 /
|
||||
* 保存到下载目录」两条出口不必按来源分支——交给 FileProvider 的永远是缓存里那一份。
|
||||
*/
|
||||
suspend fun load(source: ImageSource): Result<CachedDocument> = withContext(dispatcher) {
|
||||
when (source) {
|
||||
is ImageSource.Server -> loadServer(source.raw)
|
||||
is ImageSource.Inline -> loadInline(source.raw)
|
||||
is ImageSource.Local -> loadLocal(source.raw)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun loadServer(url: String): Result<CachedDocument> {
|
||||
val name = ImageNames.fromServerUrl(url)
|
||||
if (name != null) cachedHit(name)?.let { return it }
|
||||
val bytes = download(url).getOrElse { return Result.failure(it) }
|
||||
// 名字推不出来 / 拼不出路径(形态陌生、含可疑字符)时按**字节**内容寻址:
|
||||
// 绝不拿一个不可信的名字去拼路径,也绝不因此拒绝打开这张图。
|
||||
return write(name?.let { DocumentCache.fileFor(cacheDir, it) } ?: contentAddressed(bytes), bytes)
|
||||
}
|
||||
|
||||
private fun loadInline(dataUri: String): Result<CachedDocument> {
|
||||
val bytes = ImageDataUri.bytes(dataUri)
|
||||
?: return Result.failure(LocalDocumentUnavailableException())
|
||||
return write(contentAddressed(bytes), bytes)
|
||||
}
|
||||
|
||||
private fun loadLocal(uri: String): Result<CachedDocument> {
|
||||
val bytes = try {
|
||||
val input = openLocal(uri)
|
||||
?: return Result.failure(LocalDocumentUnavailableException())
|
||||
input.use {
|
||||
// 与附件同一把尺子:本端能一次读进内存的上限就是附件上限。
|
||||
readBounded(it, DocumentAttachments.MAX_FILE_BYTES)
|
||||
} ?: return Result.failure(LocalDocumentTooLargeException())
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (_: Exception) {
|
||||
return Result.failure(LocalDocumentUnavailableException())
|
||||
}
|
||||
return write(contentAddressed(bytes), bytes)
|
||||
}
|
||||
|
||||
/** 缓存命中:名字可信、文件在且非空、且真的读得出来。读不动就删掉重来,绝不当成空图返回。 */
|
||||
private fun cachedHit(name: String): Result<CachedDocument>? {
|
||||
val file = DocumentCache.fileFor(cacheDir, name) ?: return null
|
||||
if (!DocumentCache.isHit(file)) return null
|
||||
val bytes = runCatching { file.readBytes() }.getOrNull() ?: run {
|
||||
file.delete()
|
||||
return null
|
||||
}
|
||||
return Result.success(CachedDocument(file, bytes))
|
||||
}
|
||||
|
||||
/** 内容寻址的缓存名(与服务端文档同一规则):`sha256(bytes)[:32] + 嗅探所得的扩展名`。 */
|
||||
private fun contentAddressed(bytes: ByteArray): File? =
|
||||
DocumentCache.fileFor(
|
||||
cacheDir,
|
||||
DocumentCache.localName(bytes, CONTENT_ADDRESSED_HINT + ImageFormats.extensionFor(ImageFormats.sniff(bytes))),
|
||||
)
|
||||
|
||||
private fun write(file: File?, bytes: ByteArray): Result<CachedDocument> {
|
||||
val target = file
|
||||
?: return Result.failure(CyreneException(CyreneError.Local(CyreneError.LocalKind.Parse)))
|
||||
// 内容寻址的名字相同 ⇒ 字节相同:已经落过盘就不重写(同一张图重复打开不该反复写盘)。
|
||||
if (DocumentCache.isHit(target)) return Result.success(CachedDocument(target, bytes))
|
||||
return try {
|
||||
DocumentCache.write(target, bytes)
|
||||
Result.success(CachedDocument(target, bytes))
|
||||
} catch (_: Exception) {
|
||||
target.delete()
|
||||
Result.failure(DocumentCacheUnavailableException())
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
/** 内容寻址名的扩展名载体:`localName` 只看扩展名,名字本身会被 sha256 换掉。 */
|
||||
const val CONTENT_ADDRESSED_HINT = "image"
|
||||
}
|
||||
}
|
||||
|
||||
/** 只服务免鉴权图片读侧的裸客户端;进程内一份,连接池与线程池复用。 */
|
||||
private val imageHttpClient: OkHttpClient by lazy { OkHttpClient() }
|
||||
|
||||
/**
|
||||
* GET 一个图片地址并读回字节(至多 [DocumentAttachments.MAX_FILE_BYTES])。
|
||||
*
|
||||
* 非 2xx 走 [ApiErrorMapper]——与所有 REST 仓库同一口径:服务端有话说就用它那句,没有就说不出
|
||||
* 的时候也不编。2xx 却读不出字节(0 字节 / 超过上限)同样判失败:**绝不**把一份空字节落进缓存,
|
||||
* 那会让下一次打开「命中」一份什么都没有的文件。
|
||||
*/
|
||||
private suspend fun httpGetImage(url: String): Result<ByteArray> = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
imageHttpClient.newCall(Request.Builder().url(url).get().build()).execute().use { response ->
|
||||
if (!response.isSuccessful) {
|
||||
return@withContext Result.failure(
|
||||
CyreneException(ApiErrorMapper.fromHttp(response.code, response.body.string())),
|
||||
)
|
||||
}
|
||||
val bytes = readBounded(response.body.byteStream(), DocumentAttachments.MAX_FILE_BYTES)
|
||||
when {
|
||||
bytes == null -> Result.failure(ImageTooLargeException())
|
||||
bytes.isEmpty() -> Result.failure(CyreneException(CyreneError.Local(CyreneError.LocalKind.Parse)))
|
||||
else -> Result.success(bytes)
|
||||
}
|
||||
}
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
Result.failure(CyreneException(ApiErrorMapper.fromThrowable(e)))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
package top.yeij.cyrene.data.repository
|
||||
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import okhttp3.MediaType.Companion.toMediaTypeOrNull
|
||||
import okhttp3.MultipartBody
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import top.yeij.cyrene.core.error.CyreneError
|
||||
import top.yeij.cyrene.data.api.ApiErrorMapper
|
||||
import top.yeij.cyrene.data.api.CyreneException
|
||||
import top.yeij.cyrene.data.api.DocumentsApi
|
||||
import top.yeij.cyrene.data.api.apiCall
|
||||
|
||||
/**
|
||||
* 一次成功上传的文档。
|
||||
*
|
||||
* **首次上传(`201`)与去重命中(`200`)返回的是同一个结果**——区别只在 [deduped]:
|
||||
* 服务端对「同一份字节 + 同一扩展名」回同一个 [name],调用方**必须当成功处理**,
|
||||
* 唯一的差别是可以据此少显示一次「已上传」的进度。
|
||||
*
|
||||
* [url] 是**相对**同源路径(`/api/v1/documents/<name>`),与图片上传的绝对 URL 不同:
|
||||
* 读侧要 Bearer,客户端真正要用的是 [name]。它留着只为排查与展示,**不要**当 `chat.image`
|
||||
* 那样的可直接取用地址。
|
||||
*/
|
||||
data class UploadedDocument(
|
||||
val name: String,
|
||||
val url: String,
|
||||
val mime: String,
|
||||
val size: Long,
|
||||
val sha256: String,
|
||||
val deduped: Boolean,
|
||||
)
|
||||
|
||||
/**
|
||||
* 文档取数层(`POST /api/v1/documents` 上传、`GET /api/v1/documents/{name}` 取字节)。
|
||||
*
|
||||
* ## 与图片上传([UploadRepository])的三处刻意的不同
|
||||
* 1. **文件名参与裁决**:服务端把**白名单扩展名**与**字节嗅探**合取,两者都过才放行
|
||||
* (图片只看嗅探)。所以 [fileName] 必须原样带上真实扩展名,不能像图片那样自己编一个;
|
||||
* 2. **`200` 是成功**:同一份字节重复上传回 `200` + 同一个 name + `deduped:true`,
|
||||
* **不是错误**。这条由 [apiCall] 天然成立(它只看「2xx 且信封里有 data」);
|
||||
* 3. **`url` 不做绝对化校验**:文档的 url 本来就是相对路径(读侧要 Bearer),
|
||||
* 照搬图片那条「不是 http(s) 就判失败」的规则会把每一次成功上传都判成解析失败。
|
||||
*
|
||||
* ## 错误语义
|
||||
* 用 [apiCall]:状态码**不承载额外语义**(`413` / `415` / `400` / `404` 的机器可读区分
|
||||
* 全在 `error.code` 里),仓库只把它折进 `Result.failure`;调用方用
|
||||
* [top.yeij.cyrene.domain.chat.toDocumentFailure] 收敛成可分支的失败类型。
|
||||
*
|
||||
* ## 客户端不做的事(服务端才是裁决者)
|
||||
* **不做本地扩展名白名单、不做本地 20 MiB 上限**:白名单与上限都是服务端配置(上限是
|
||||
* `CHAT_DOCUMENT_MAX_BYTES`,可部署改),客户端复制一份必然漂移——把「本地觉得不行」当成
|
||||
* 拒绝,就会出现「服务端其实收得下、客户端却拦了」的静默更严。这里宁可让服务端回一个
|
||||
* 带真实上限的 `413` / `415`,让用户看到准确的理由。
|
||||
*/
|
||||
class DocumentRepository(
|
||||
private val api: DocumentsApi,
|
||||
private val dispatcher: CoroutineDispatcher = Dispatchers.IO,
|
||||
) {
|
||||
|
||||
/** 上传一份文档([bytes] 与 [fileName] 必须来自同一个文件)。 */
|
||||
suspend fun upload(bytes: ByteArray, fileName: String): Result<UploadedDocument> =
|
||||
withContext(dispatcher) {
|
||||
apiCall { api.upload(partOf(bytes, fileName)) }.fold(
|
||||
onSuccess = { data ->
|
||||
if (data.name.isBlank()) {
|
||||
// 201/200 却没有可用存储名 = 上传了但引用不上,判解析失败。
|
||||
// **绝不**退回「用本地文件名」——服务端已经把它丢掉了,引用一个不存在的名字
|
||||
// 只会让这一轮在服务端回 404。
|
||||
Result.failure(CyreneException(CyreneError.Local(CyreneError.LocalKind.Parse)))
|
||||
} else {
|
||||
Result.success(
|
||||
UploadedDocument(
|
||||
name = data.name,
|
||||
url = data.url,
|
||||
mime = data.mime,
|
||||
size = data.size,
|
||||
sha256 = data.sha256,
|
||||
deduped = data.deduped,
|
||||
),
|
||||
)
|
||||
}
|
||||
},
|
||||
onFailure = { Result.failure(it) },
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 取回一份文档的原始字节。
|
||||
*
|
||||
* 响应体**不是信封**(就是文件本身),所以这里不用 [apiCall]:成功即读字节,
|
||||
* 失败才走 [ApiErrorMapper](`404 document_not_found` / `401`)。
|
||||
* `@Streaming` 的响应体必须由调用方关闭,[okhttp3.ResponseBody.use] 在这里收口。
|
||||
*/
|
||||
suspend fun download(name: String): Result<ByteArray> = withContext(dispatcher) {
|
||||
try {
|
||||
val response = api.download(name)
|
||||
val body = response.body()
|
||||
when {
|
||||
response.isSuccessful && body != null -> Result.success(body.use { it.bytes() })
|
||||
// 2xx 却没有响应体:与 apiCall 同一口径,判解析失败而不是「拿到了空文件」。
|
||||
response.isSuccessful -> Result.failure(CyreneException(CyreneError.Local(CyreneError.LocalKind.Parse)))
|
||||
else -> Result.failure(
|
||||
CyreneException(
|
||||
ApiErrorMapper.fromHttp(response.code(), response.errorBody()?.string()),
|
||||
),
|
||||
)
|
||||
}
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
Result.failure(CyreneException(ApiErrorMapper.fromThrowable(e)))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* multipart 部件。
|
||||
*
|
||||
* **部件名固定 `file`**(服务端 `r.FormFile("file")`),**文件名必须给且要带扩展名**:
|
||||
* Go 只把带非空 `filename` 参数的部件当**文件**(不带的落进普通表单值,`FormFile` 就找不到它),
|
||||
* 而扩展名要参与服务端的白名单判定。[fileName] 为空时回落到
|
||||
* [FALLBACK_FILE_NAME]——只是为了不让一个「没有名字的部件」变成 `400 需要 multipart 字段 file`;
|
||||
* 真正的原因(用户到底送的是什么)只有调用方知道,**不要**靠这个兜底掩盖调用方的空名字。
|
||||
*
|
||||
* `Content-Type` 固定 `text/plain`:服务端**不采信**客户端声明(字节嗅探是唯一裁决),
|
||||
* 这里给一个诚实的默认值即可——文档白名单里放行的都是文本类。
|
||||
*/
|
||||
private fun partOf(bytes: ByteArray, fileName: String): MultipartBody.Part =
|
||||
MultipartBody.Part.createFormData(
|
||||
name = UPLOAD_FIELD_NAME,
|
||||
filename = fileName.takeIf { it.isNotBlank() } ?: FALLBACK_FILE_NAME,
|
||||
body = bytes.toRequestBody(MIME_TEXT.toMediaTypeOrNull()),
|
||||
)
|
||||
}
|
||||
|
||||
private const val UPLOAD_FIELD_NAME = "file"
|
||||
|
||||
private const val FALLBACK_FILE_NAME = "document.txt"
|
||||
|
||||
private const val MIME_TEXT = "text/plain"
|
||||
@@ -0,0 +1,123 @@
|
||||
package top.yeij.cyrene.data.repository
|
||||
|
||||
import top.yeij.cyrene.data.api.dto.HistoryMessageDto
|
||||
|
||||
/**
|
||||
* 编辑内容的上限(契约 §1 行 6a:非空、≤ **4000 rune**)。
|
||||
*
|
||||
* 「rune」是**码点**而不是 `String.length`:后者数的是 UTF-16 码元,emoji 与绝大多数中文扩展字符
|
||||
* 会被算成两个。按 `length` 卡上限会在边界上**误伤**(客户端以为超了、不发;服务端其实收得下),
|
||||
* 也会**误放**(客户端以为没超、发出去被 400)。
|
||||
*/
|
||||
const val MESSAGE_EDIT_MAX_RUNES = 4000
|
||||
|
||||
/**
|
||||
* 一次编辑提交的准入判定(纯函数的结果,见 [validateMessageEdit])。
|
||||
*
|
||||
* 分成三档是因为**用户要做的动作不同**:空了要接着打字,超长了要删字,只有 [Accepted] 才该发请求。
|
||||
*/
|
||||
sealed interface MessageEditDraft {
|
||||
|
||||
/** 可以提交;[content] 就是要发给服务端的字符串(原样,**不 trim**——见 [validateMessageEdit])。 */
|
||||
data class Accepted(val content: String) : MessageEditDraft
|
||||
|
||||
/** 空 / 纯空白:服务端会回 `400`,本端**不发这个请求**。 */
|
||||
data object Empty : MessageEditDraft
|
||||
|
||||
/** 超过 [max] 个码点:服务端会回 `400`,本端同样不发。 */
|
||||
data class TooLong(val runes: Int, val max: Int = MESSAGE_EDIT_MAX_RUNES) : MessageEditDraft
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验一次编辑的内容(纯函数)。
|
||||
*
|
||||
* **不 trim**:服务端存的就是用户输入的那个字符串(契约只承诺「非空」),客户端替它去掉首尾空白
|
||||
* 等于悄悄改掉用户的消息——对代码块、Markdown 引用这类内容尤其明显。空白只用来判「空」。
|
||||
*
|
||||
* 计数按**码点**([runeCount]),与服务端 Go 的 `utf8.RuneCountInString` 同口径。
|
||||
*/
|
||||
fun validateMessageEdit(raw: String): MessageEditDraft {
|
||||
if (raw.isBlank()) return MessageEditDraft.Empty
|
||||
val runes = runeCount(raw)
|
||||
return if (runes > MESSAGE_EDIT_MAX_RUNES) {
|
||||
MessageEditDraft.TooLong(runes)
|
||||
} else {
|
||||
MessageEditDraft.Accepted(raw)
|
||||
}
|
||||
}
|
||||
|
||||
/** 按 Unicode 码点计数:代理对(emoji、部分汉字)算一个,与服务端 rune 口径一致。 */
|
||||
fun runeCount(text: String): Int = text.codePointCount(0, text.length)
|
||||
|
||||
/**
|
||||
* 按 `id` **原位**改一条消息的 `content`(纯函数;编辑落地的唯一实现)。
|
||||
*
|
||||
* 三条刻意的取舍:
|
||||
* 1. **只改 `content`,不整条替换**。`message_edited` 帧里的 DTO 是完整消息,但它的 `thinking`
|
||||
* 恒为空(契约 v0.59 §4.4:实时帧一律剥离思维链)。整条替换会把本端已渲染的思维链擦掉,
|
||||
* 而契约明说这个操作**只改 content**——照它做,就不会有第二种解读。
|
||||
* 2. **找不到这个 `id` 就原样返回**,绝不插入。契约 §3.8 写得很直白:「找不到该 id 即忽略,
|
||||
* 避免把别的会话的消息注入当前列表」。别人的会话里那条消息不该在这里冒出来。
|
||||
* 3. **空内容是坏帧,不落**。服务端拒空内容(400),合法的编辑结果不可能为空;真收到空白就把
|
||||
* 气泡擦成空白,是拿坏帧毁掉一条好消息。宁可整帧忽略。
|
||||
*
|
||||
* **幂等**:[content] 与现值相同时返回**同一个实例**(Compose 的 key 稳定性靠它),因此
|
||||
* 「REST 响应先到、广播后到」(或反过来)连做两次与做一次完全等价——这就是双到达收敛的根据。
|
||||
* 不修改入参。
|
||||
*/
|
||||
fun applyMessageEdit(
|
||||
messages: List<HistoryMessageDto>,
|
||||
id: String,
|
||||
content: String,
|
||||
): List<HistoryMessageDto> {
|
||||
if (id.isBlank() || content.isBlank()) return messages
|
||||
var changed = false
|
||||
val updated = messages.map { message ->
|
||||
if (message.id != id || message.content == content) {
|
||||
message
|
||||
} else {
|
||||
changed = true
|
||||
message.copy(content = content)
|
||||
}
|
||||
}
|
||||
return if (changed) updated else messages
|
||||
}
|
||||
|
||||
/**
|
||||
* 按 `id` 移除一条消息(纯函数;删除落地的唯一实现)。
|
||||
*
|
||||
* `id` 为空白即原样返回:`message_deleted` 的载荷是 `{id}`,坏帧(缺键)解出空串,
|
||||
* 而「删掉所有 id 为空的消息」不是任何人想要的结果。找不到该 `id` 同样原样返回(幂等)——
|
||||
* 「我们自己的 DELETE 回执 + 广播」两次到达、以及别的端删掉一条本端还没加载的消息,都走这里。
|
||||
*
|
||||
* 删除的**路由**由帧壳的 `sessionId` 负责(见
|
||||
* [top.yeij.cyrene.data.ws.isForSession]);`sessionId` 缺席时契约允许按**全局唯一**的 `id`
|
||||
* 兜底——那正是这个函数天然的行为:别的会话的列表里根本不存在这个 `id`。不修改入参。
|
||||
*/
|
||||
fun removeMessageById(messages: List<HistoryMessageDto>, id: String): List<HistoryMessageDto> {
|
||||
if (id.isBlank()) return messages
|
||||
// 没有这一条就原样返回**同一个实例**(幂等的可观察形式:连做两次与做一次完全等价,
|
||||
// 也不会白分配一个新列表)。
|
||||
if (messages.none { it.id == id }) return messages
|
||||
return messages.filterNot { it.id == id }
|
||||
}
|
||||
|
||||
/**
|
||||
* 这条消息**能不能编辑**(产品口径,UI 与 VM 共用同一份判据)。
|
||||
*
|
||||
* 只放 `user`:assistant 的回复是她说过的话的历史记录,改它等于伪造她说过什么;而要「重发」
|
||||
* 一条说错的回复,正确的动作是删掉它再问一遍(契约也明确没有重发端点)。
|
||||
*
|
||||
* 用 `== "user"` 而不是「非 assistant」:`role` 在后端是**开放集合**,将来多出一个角色时
|
||||
* fail-closed(不改)比「默认都能改」安全。
|
||||
*/
|
||||
fun canEditMessage(message: HistoryMessageDto): Boolean = message.role == "user"
|
||||
|
||||
/**
|
||||
* 这条消息**能不能删除**(产品口径,UI 与 VM 共用同一份判据)。
|
||||
*
|
||||
* user 与 assistant 都放:删掉一句错答是常见需求,而且服务端对这个端点本就不分角色。
|
||||
* 未知 `role` 一律不放(fail-closed)——不认识的东西不主动去删。
|
||||
*/
|
||||
fun canDeleteMessage(message: HistoryMessageDto): Boolean =
|
||||
message.role == "user" || message.role == "assistant"
|
||||
@@ -0,0 +1,300 @@
|
||||
package top.yeij.cyrene.data.repository
|
||||
|
||||
import top.yeij.cyrene.core.error.CyreneError
|
||||
import top.yeij.cyrene.data.api.CyreneException
|
||||
import top.yeij.cyrene.data.api.SessionsApi
|
||||
import top.yeij.cyrene.data.api.apiCall
|
||||
import top.yeij.cyrene.data.api.dto.HistoryMessageDto
|
||||
import top.yeij.cyrene.data.api.dto.UpdateMessageRequest
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
/**
|
||||
* 会话消息层:分页 / 搜索 / **正向增量**读取,以及单条消息的**编辑 / 删除**(D3)。
|
||||
*
|
||||
* 三条读取出口共用同一个端点,只是给的查询参数不同:
|
||||
* - [loadPage] / [loadLatest]:`before` 往回翻(服务端**倒序**返回);
|
||||
* - [search]:`q` 会话内搜索(也是倒序);
|
||||
* - [loadAfter]:`after` 往前追(服务端**升序**返回,契约 §1 行 6);[loadIncrement] 是它在
|
||||
* `after` 方向上的翻页版(一直追到尾巴,追不到就如实说 `complete = false`)。
|
||||
*
|
||||
* 三者都统一转成**升序**的 [PagedMessages] 再交出去。去重 / 合并请用 [mergeMessages]。
|
||||
*/
|
||||
class MessageRepository(
|
||||
private val api: SessionsApi,
|
||||
private val dispatcher: CoroutineDispatcher = Dispatchers.IO,
|
||||
) {
|
||||
|
||||
/**
|
||||
* 拉一页消息。
|
||||
*
|
||||
* @param before 上一页最旧消息的 `createdAt`(用 [PagedMessages.oldestCreatedAt]);
|
||||
* null = 最新一页
|
||||
* @param platform 来源平台过滤;null = 全量(Web 端共享主会话语义)
|
||||
*/
|
||||
suspend fun loadPage(
|
||||
sessionId: String,
|
||||
limit: Int = DEFAULT_PAGE_SIZE,
|
||||
before: Long? = null,
|
||||
platform: String? = null,
|
||||
): Result<PagedMessages> = withContext(dispatcher) {
|
||||
val pageSize = limit.coerceIn(1, MAX_PAGE_SIZE)
|
||||
apiCall { api.listMessages(sessionId, pageSize, before, platform) }
|
||||
.map { raw -> toPagedMessages(raw, pageSize) }
|
||||
}
|
||||
|
||||
/** 首屏 / 刷新:最新一页。 */
|
||||
suspend fun loadLatest(
|
||||
sessionId: String,
|
||||
limit: Int = DEFAULT_PAGE_SIZE,
|
||||
platform: String? = null,
|
||||
): Result<PagedMessages> = loadPage(sessionId, limit, before = null, platform = platform)
|
||||
|
||||
/**
|
||||
* 会话内**内容搜索**(D2,契约 §1 行 6)。
|
||||
*
|
||||
* 与 [loadPage] 走的是**同一个端点、同一套分页语义**,唯一差别是会带上 `q`:
|
||||
* 服务端按大小写不敏感的 ASCII LIKE 在**本会话内**匹配 `content`(`%` / `_` / `\` 已转义为
|
||||
* 字面量),仍按 `createdAt` 倒序返回,`before` 仍是「上一页最旧那条的时间戳」。
|
||||
* 搜索绝不跨会话,所以这里没有 `platform` 参数——那是我方对多端来源的过滤,与搜索正交。
|
||||
*
|
||||
* **空查询不发请求**:契约里「空 `q` = 未传」意味着服务端会退化成普通分页,
|
||||
* 把整段历史当成搜索结果回给调用方——那比不发更像一个谎。所以空/纯空白一律返回一个
|
||||
* 空页(真实的「没有命中」由调用方在**已提交过查询**的前提下渲染)。
|
||||
*/
|
||||
suspend fun search(
|
||||
sessionId: String,
|
||||
query: String,
|
||||
limit: Int = DEFAULT_SEARCH_PAGE_SIZE,
|
||||
before: Long? = null,
|
||||
): Result<PagedMessages> = withContext(dispatcher) {
|
||||
val normalized = normalizeSearchQuery(query)
|
||||
if (normalized == null) return@withContext Result.success(EMPTY_PAGE)
|
||||
val pageSize = limit.coerceIn(1, MAX_PAGE_SIZE)
|
||||
apiCall { api.listMessages(sessionId, pageSize, before, platform = null, query = normalized) }
|
||||
.map { raw -> toPagedMessages(raw, pageSize) }
|
||||
}
|
||||
|
||||
/**
|
||||
* **正向增量**(A5,契约 §1 行 6):`GET /messages?after=<ms>`,返回 `created_at > after` 的
|
||||
* 消息,**升序**(与 [loadPage] 的倒序相反)。
|
||||
*
|
||||
* 它存在的唯一理由是补断线的洞:WS 重连只回放主会话**最近 50 条**,断线期间超过 50 条的
|
||||
* 消息用「再拉一页最新」是补不回来的——最新那一页会直接跳过中间那段。[after] 就是那个游标:
|
||||
* 传本端已看到的最后一条 `createdAt`,服务端从**最旧的一条开始**给。
|
||||
*
|
||||
* 分页推进的规矩(与 [loadPage] 不同,别照抄):下一页的游标是本页**最新**一条的
|
||||
* `createdAt`([PagedMessages.newestCreatedAt]),不是最旧的。
|
||||
*
|
||||
* 同毫秒并列且条数超过 `limit` 时,纯 `createdAt` 游标推不动(契约 §A5 明说的已知边界)——
|
||||
* 落在页边界上的那一毫秒里剩下的行**再也追不回来**,因为服务端只给严格 `>`。调用方按 `id`
|
||||
* 去重,并且**不能把满页之后的一页(空页、不满页都一样)当作追到了尾巴**:要翻页又要把边界
|
||||
* 说清楚,用 [loadIncrement],不要自己拿这一页凑。
|
||||
*/
|
||||
suspend fun loadAfter(
|
||||
sessionId: String,
|
||||
after: Long,
|
||||
limit: Int = DEFAULT_PAGE_SIZE,
|
||||
platform: String? = null,
|
||||
): Result<PagedMessages> = withContext(dispatcher) {
|
||||
val pageSize = limit.coerceIn(1, MAX_PAGE_SIZE)
|
||||
apiCall { api.listMessages(sessionId, pageSize, before = null, platform = platform, after = after) }
|
||||
.map { raw -> toPagedMessages(raw, pageSize) }
|
||||
}
|
||||
|
||||
/**
|
||||
* **增量追尾**(W77):从 [after] 起把某会话的新消息一直翻到尾巴,最多 [maxPages] 页。
|
||||
*
|
||||
* 与裸的 [loadAfter] 的差别只有一件:**它负责把「还没翻完」这件事如实说出来**。
|
||||
*
|
||||
* ## 什么时候只能说 `complete = false`
|
||||
* 游标是本页**最新**一条的 `createdAt`,而服务端只给 `created_at > after`(**严格**大于:
|
||||
* `list_after.go` 的 `ListAfter` 与 `devtools/fake-backend` 两处一致)。于是**落在页边界上被
|
||||
* 劈开的那一毫秒**里的行永远追不回来——它们的 `createdAt` 恰好等于游标,再问一次也问不到
|
||||
* (契约 §A5 的已知边界:纯 `createdAt` 游标没有毫秒内的次序)。
|
||||
*
|
||||
* 要紧的是**切点**:只有当服务端的 `LIMIT` 在某一毫秒中间切过一刀,才会留下追不回来的尾巴。
|
||||
* 客户端看不见那一刀落在哪儿,只知道自己**拿过满页**就等于「发生过一次切点」。所以这里只有一条
|
||||
* 不变式,下面几档都是它的推论:
|
||||
*
|
||||
* > `complete = true` **当且仅当整个追尾从未拿过满页**。
|
||||
*
|
||||
* - **第一页就是空页 ⇒ `true`**:一条新消息都没有,没有可少给的;
|
||||
* - **第一页就不满 ⇒ `true`**:`LIMIT` 没切到任何东西,`> cursor` 的行全在这一页里;
|
||||
* - **拿过整页之后,下一页是空页 ⇒ `false`**:那次空页分不出「干净的尾巴」与「空页底下还压着
|
||||
* 同一毫秒的几条」;
|
||||
* - **拿过整页之后,下一页不满 ⇒ 也是 `false`**:不满只说明「`> cursor` 的行就这么多了」,对
|
||||
* 切点那一毫秒还有没有行**一无所知**。例如积压 `m1@500 m2@500 m3@500 m4@600`、页宽 2:
|
||||
* 第一页给 `m1 m2`(切点正好劈开 500 那一毫秒),第二页 `after=500` 只给 `m4@600`、不满页,
|
||||
* 而 `m3@500` 既不 `> 500`、也就永远拿不到;
|
||||
* - **翻满 [maxPages] ⇒ `false`**。
|
||||
*
|
||||
* 代价:积压一到页宽(默认 50 条)就回落整表。这与「翻满 [maxPages] ⇒ 回落」同类——那时增量
|
||||
* 已经取回 ≥50 行,多问一次整表是边际成本,也正合 [MAX_INCREMENT_PAGES] 那句「积压多到增量不再
|
||||
* 便宜」。主路径(几条新消息、第一页就不满)不受影响。
|
||||
*
|
||||
* 这里**不猜**、也不悄悄少给几条:说不完整,调用方还能去问唯一权威。
|
||||
*
|
||||
* 不传 `platform`:未读面上的口径是服务端 `count(created_at > read_at)`(`unreadCountQuery`,
|
||||
* 不带平台过滤),按平台过滤会让本地数出来的条数**与服务端的 `unreadCount` 不同源**。
|
||||
*
|
||||
* @param after 本端**已见**的最大 `createdAt`(毫秒);服务端返回严格新于它的消息,升序。
|
||||
* @param maxPages 最多翻几页(默认 [MAX_INCREMENT_PAGES])。用尽即 `complete = false`:
|
||||
* 一次静默刷新不该为了补一个角标翻十几页。
|
||||
*/
|
||||
suspend fun loadIncrement(
|
||||
sessionId: String,
|
||||
after: Long,
|
||||
pageSize: Int = DEFAULT_PAGE_SIZE,
|
||||
maxPages: Int = MAX_INCREMENT_PAGES,
|
||||
platform: String? = null,
|
||||
): Result<MessageIncrement> = withContext(dispatcher) {
|
||||
val size = pageSize.coerceIn(1, MAX_PAGE_SIZE)
|
||||
val byId = LinkedHashMap<String, HistoryMessageDto>()
|
||||
var cursor = after
|
||||
// 只要翻到过一整页,就说明服务端的 LIMIT 切过一刀,那一刀可能劈开了一毫秒(见 KDoc)。
|
||||
// 此后无论下一页是空页还是不满页,都说不出「追到了尾巴」。
|
||||
var chasedFullPage = false
|
||||
|
||||
fun done(at: Long, complete: Boolean) = Result.success(
|
||||
MessageIncrement(messages = byId.values.sortedWith(MessageOrder), cursor = at, complete = complete),
|
||||
)
|
||||
|
||||
var page = 0
|
||||
while (page < maxPages.coerceAtLeast(1)) {
|
||||
page++
|
||||
val raw = apiCall { api.listMessages(sessionId, size, before = null, platform = platform, after = cursor) }
|
||||
.getOrElse { return@withContext Result.failure(it) }
|
||||
val paged = toPagedMessages(raw, size)
|
||||
// 空页有两种含义,客户端分不出来:第一页就空 = 真的没有新的;拿过一整页之后空 = 可能还有
|
||||
// 同一毫秒的几条卡在游标上(服务端只给严格大于)。后者只能说「没追完」。
|
||||
if (paged.messages.isEmpty()) {
|
||||
return@withContext done(cursor, complete = !chasedFullPage)
|
||||
}
|
||||
// 去重只是防御:严格 `>` 下相邻两页不可能重叠(下一页只要 > 本页最新那条)。
|
||||
paged.messages.forEach { byId[it.id] = it }
|
||||
val newest = paged.newestCreatedAt ?: return@withContext done(cursor, complete = false)
|
||||
// 不满页只说明「> cursor 的行就这么多了」,对切点那一毫秒一无所知 —— 与空页档同一个洞,
|
||||
// 同样是拿过整页之后就不作数。
|
||||
if (!paged.hasMore) return@withContext done(newest, complete = !chasedFullPage)
|
||||
cursor = newest
|
||||
chasedFullPage = true
|
||||
}
|
||||
done(cursor, complete = false)
|
||||
}
|
||||
|
||||
/**
|
||||
* 编辑单条消息(D3,契约 §1 行 6a):`PATCH /api/v1/messages/{id}`,成功返回**服务端更新后的
|
||||
* 完整消息 DTO**。
|
||||
*
|
||||
* 校验在**发请求之前**做([validateMessageEdit]):空 / 纯空白 / 超 4000 rune 的内容服务端
|
||||
* 一定回 `400`,明知会被拒还发一次只是白等一个来回。被拒时返回的失败**带得出原因**——
|
||||
* 调用方要把「为什么没提交」如实说出来,不能静默什么都不发生。
|
||||
*
|
||||
* 返回的是服务端那一份而不是本地改过的副本:调用方据此更新界面([applyMessageEdit]),
|
||||
* 与随后到达的 `message_edited` 广播收敛成同一次变更。
|
||||
*/
|
||||
suspend fun editMessage(messageId: String, content: String): Result<HistoryMessageDto> {
|
||||
val draft = when (val validation = validateMessageEdit(content)) {
|
||||
MessageEditDraft.Empty -> return Result.failure(
|
||||
CyreneException(CyreneError.Server("bad_request", "消息内容不能为空", 400)),
|
||||
)
|
||||
|
||||
is MessageEditDraft.TooLong -> return Result.failure(
|
||||
CyreneException(
|
||||
CyreneError.Server(
|
||||
"bad_request",
|
||||
"消息内容不能超过 ${validation.max} 个字符(当前 ${validation.runes})",
|
||||
400,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
is MessageEditDraft.Accepted -> validation.content
|
||||
}
|
||||
return withContext(dispatcher) {
|
||||
apiCall { api.updateMessage(messageId, UpdateMessageRequest(content = draft)) }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除单条消息(D3,契约 §1 行 6b):`DELETE /api/v1/messages/{id}`,成功返回被删消息的 `id`。
|
||||
*
|
||||
* 没有本地校验可做(只有一个路径参数),所以失败一律来自服务端:`404 not_found`(这条已经
|
||||
* 不在服务端了,常见于别的端先删掉)、`403`(权限)、`501`(没接 HistoryStore)、网络。
|
||||
* 调用方要把这些如实说出来——**静默失败会让人以为删掉了,而她下一条回复里那条还在**。
|
||||
*/
|
||||
suspend fun deleteMessage(messageId: String): Result<String> = withContext(dispatcher) {
|
||||
apiCall { api.deleteMessage(messageId) }.map { it.deleted }
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val DEFAULT_PAGE_SIZE = 50
|
||||
const val MAX_PAGE_SIZE = 200
|
||||
|
||||
/** 搜索结果一页的条数:命中项要一条条读,比历史分页小一些更好翻。 */
|
||||
const val DEFAULT_SEARCH_PAGE_SIZE = 30
|
||||
|
||||
/**
|
||||
* [loadIncrement] 一次最多翻几页(500 条)。
|
||||
*
|
||||
* 这个上限只为一件事存在:一次**后台**的顺手对齐不该翻十几页。越过它就说明积压已经多到
|
||||
* 「增量」不再便宜,那时整表(`GET /sessions` 一次拿回权威的 `unreadCount` 与预览)
|
||||
* 反而更划算——调用方据此回落,见 `SessionsViewModel.flushPendingIncrements`。
|
||||
*/
|
||||
const val MAX_INCREMENT_PAGES = 10
|
||||
|
||||
private val EMPTY_PAGE = PagedMessages(
|
||||
messages = emptyList(),
|
||||
hasMore = false,
|
||||
oldestCreatedAt = null,
|
||||
newestCreatedAt = null,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 规整搜索关键词:去首尾空白后仍为空 → null(调用方据此**不发请求**,见
|
||||
* [MessageRepository.search])。
|
||||
*
|
||||
* 只做 trim,**不改大小写、不转义 `%` / `_` / `\`**:服务端自己把这三个字符当字面量处理
|
||||
* (契约 §1 行 6),客户端再转一次会把 `\%` 这种输入搜成别的东西——两边都转义必然有一边是错的。
|
||||
* 大小写同理:匹配由服务端做 ASCII 不敏感比较,客户端保留用户原样输入。
|
||||
*/
|
||||
fun normalizeSearchQuery(raw: String): String? = raw.trim().takeIf { it.isNotEmpty() }
|
||||
|
||||
/**
|
||||
* 原始响应 → [PagedMessages]:**无论服务端给的是倒序(`before` / `q`)还是升序(`after`)**,
|
||||
* 一律按 [MessageOrder] 排成升序。
|
||||
*
|
||||
* [hasMore] 两向同义:拿到满页 ⇒ 该方向**可能还有**(`before` 方向是更旧的、`after` 方向是更新的)——
|
||||
* 它是保守判断,具体能不能继续由调用方的游标决定。
|
||||
*/
|
||||
private fun toPagedMessages(
|
||||
raw: List<HistoryMessageDto>,
|
||||
pageSize: Int,
|
||||
): PagedMessages {
|
||||
val ascending = raw.sortedWith(MessageOrder)
|
||||
return PagedMessages(
|
||||
messages = ascending,
|
||||
hasMore = raw.size >= pageSize,
|
||||
oldestCreatedAt = ascending.firstOrNull()?.createdAt,
|
||||
newestCreatedAt = ascending.lastOrNull()?.createdAt,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* [MessageRepository.loadIncrement] 的结论:升序增量消息 + 推进后的游标 + 有没有真的追到尾巴。
|
||||
*
|
||||
* [complete] 为 `false` 时 [messages] 仍是**已拿到的全部**(不丢),但调用方不能把它当成
|
||||
* 「这之后就没有了」——未读面上那种情况要回落到整表,见 `SessionsViewModel.flushPendingIncrements`。
|
||||
*
|
||||
* 放在这里而不是 `PagedMessages.kt`:`PagedMessages` 是纯分页形状(一页的元数据),
|
||||
* 这个类型是 [MessageRepository] 翻页循环的产物,跟着它的生产者走。
|
||||
*/
|
||||
data class MessageIncrement(
|
||||
val messages: List<HistoryMessageDto>,
|
||||
val cursor: Long,
|
||||
val complete: Boolean,
|
||||
)
|
||||
@@ -0,0 +1,22 @@
|
||||
package top.yeij.cyrene.data.repository
|
||||
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import top.yeij.cyrene.data.api.MetaApi
|
||||
import top.yeij.cyrene.data.api.apiCall
|
||||
import top.yeij.cyrene.data.api.dto.EnumTableDto
|
||||
|
||||
/**
|
||||
* 枚举权威表取数层(api-client §1.1)。
|
||||
* 仓库不持有持久化状态:调用方失败时保留上次快照(见 [top.yeij.cyrene.core.prefs.EnumPreferences])。
|
||||
*/
|
||||
class MetaRepository(
|
||||
private val api: MetaApi,
|
||||
private val dispatcher: CoroutineDispatcher = Dispatchers.IO,
|
||||
) {
|
||||
|
||||
suspend fun enums(): Result<EnumTableDto> = withContext(dispatcher) {
|
||||
apiCall { api.enums() }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package top.yeij.cyrene.data.repository
|
||||
|
||||
import top.yeij.cyrene.data.api.dto.HistoryMessageDto
|
||||
|
||||
/**
|
||||
* 一页消息(已由仓库转成 **升序**,可直接追加渲染)。
|
||||
*
|
||||
* @param hasMore 该页是否可能还有更旧的消息(`原始页大小 == 请求页大小` 的保守判断)
|
||||
* @param oldestCreatedAt 本页最旧一条的 `createdAt`,作为下一页的 `before` 游标
|
||||
* @param newestCreatedAt 本页最新一条的 `createdAt`
|
||||
*/
|
||||
data class PagedMessages(
|
||||
val messages: List<HistoryMessageDto>,
|
||||
val hasMore: Boolean,
|
||||
val oldestCreatedAt: Long?,
|
||||
val newestCreatedAt: Long?,
|
||||
)
|
||||
|
||||
/** 消息排序:`createdAt` 升序,同毫秒用 `id` 兜底,保证稳定可复现。 */
|
||||
internal val MessageOrder: Comparator<HistoryMessageDto> = compareBy<HistoryMessageDto>({ it.createdAt }, { it.id })
|
||||
|
||||
/**
|
||||
* 纯函数合并:`existing`(本地已加载,任意序)+ `incoming`(服务端新到的一页,倒序)
|
||||
* → 按 `id` 去重、按 `createdAt` 升序(同毫秒用 `id` 兜底)的结果。
|
||||
*
|
||||
* 冲突时 **incoming 胜出**(§3.2「服务端优先」)。不修改入参,返回新列表。
|
||||
* 时间复杂度 O((n+m) log(n+m)),空间 O(n+m)。
|
||||
*/
|
||||
fun mergeMessages(
|
||||
existing: List<HistoryMessageDto>,
|
||||
incoming: List<HistoryMessageDto>,
|
||||
): List<HistoryMessageDto> {
|
||||
if (incoming.isEmpty()) return existing.sortedWith(MessageOrder)
|
||||
if (existing.isEmpty()) return incoming.sortedWith(MessageOrder)
|
||||
|
||||
val byId = LinkedHashMap<String, HistoryMessageDto>(existing.size + incoming.size)
|
||||
existing.forEach { byId[it.id] = it }
|
||||
incoming.forEach { byId[it.id] = it }
|
||||
return byId.values.sortedWith(MessageOrder)
|
||||
}
|
||||
|
||||
/**
|
||||
* 把一帧 `history` 收敛到**本会话**。
|
||||
*
|
||||
* WS 首帧 `history` 是**主会话**最近 50 条的回放,但连接是全局的、帧会被投给任意
|
||||
* `ChatViewModel`——不过滤就会把主会话的消息混进次级会话。
|
||||
*
|
||||
* 判定以**消息自身的 `sessionId`** 为准,而不是「只有主会话才收 history」:前者更耐将来,
|
||||
* 服务端哪天开始给次级会话回放,这里无需改动。
|
||||
*
|
||||
* `sessionId` 为空的帧视为「无法判定」**放行**——不因缺一个字段就整段丢掉历史(保持既有行为)。
|
||||
*
|
||||
* 纯函数,不修改入参。
|
||||
*/
|
||||
fun historyForSession(
|
||||
messages: List<HistoryMessageDto>,
|
||||
sessionId: String,
|
||||
): List<HistoryMessageDto> = messages.filter { it.sessionId.isBlank() || it.sessionId == sessionId }
|
||||
@@ -0,0 +1,24 @@
|
||||
package top.yeij.cyrene.data.repository
|
||||
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import top.yeij.cyrene.data.api.PersonaApi
|
||||
import top.yeij.cyrene.data.api.apiCall
|
||||
import top.yeij.cyrene.data.api.dto.UserPersonaDto
|
||||
|
||||
/**
|
||||
* 用户侧角色目录取数层(api-client §1 行 9),供「新建会话时选角色」用。
|
||||
*
|
||||
* 仓库不持有持久化状态:调用方失败时保留上一次拿到的列表——角色目录拿不到只会让弹层少几行,
|
||||
* 不该连带挡掉「用默认角色新建会话」这条降级路径。
|
||||
*/
|
||||
class PersonaRepository(
|
||||
private val api: PersonaApi,
|
||||
private val dispatcher: CoroutineDispatcher = Dispatchers.IO,
|
||||
) {
|
||||
|
||||
suspend fun personas(): Result<List<UserPersonaDto>> = withContext(dispatcher) {
|
||||
apiCall { api.personas() }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
package top.yeij.cyrene.data.repository
|
||||
|
||||
import top.yeij.cyrene.data.api.SessionsApi
|
||||
import top.yeij.cyrene.data.api.apiCall
|
||||
import top.yeij.cyrene.data.api.dto.CreateSessionRequest
|
||||
import top.yeij.cyrene.data.api.dto.ReadSessionRequest
|
||||
import top.yeij.cyrene.data.api.dto.ReadSessionResponse
|
||||
import top.yeij.cyrene.data.api.dto.SessionSummaryDto
|
||||
import top.yeij.cyrene.data.api.dto.UpdateSessionRequest
|
||||
import top.yeij.cyrene.data.api.dto.UpdateSessionResponse
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
/**
|
||||
* 会话列表 / 新建 / 重命名置顶 / 删除的数据获取层。
|
||||
*
|
||||
* 列表策略(§3.1):调用方拿到结果后**整体替换**本地快照,不做 merge;
|
||||
* 失败时调用方保留旧快照。仓库本身不持有任何持久化状态。
|
||||
*/
|
||||
class SessionRepository(
|
||||
private val api: SessionsApi,
|
||||
private val dispatcher: CoroutineDispatcher = Dispatchers.IO,
|
||||
) {
|
||||
|
||||
/**
|
||||
* `GET /sessions`,原样返回服务端顺序——**不做客户端排序**:
|
||||
* `pinned DESC, type='main' DESC, created_at ASC`,置顶的次级会话合法地排在主会话前面。
|
||||
*/
|
||||
suspend fun listSessions(): Result<List<SessionSummaryDto>> = withContext(dispatcher) {
|
||||
apiCall { api.listSessions() }
|
||||
}
|
||||
|
||||
/**
|
||||
* `POST /sessions`,成功返回新建的 `sessionId`(形如 `web:<uuid>`)。
|
||||
*
|
||||
* @param personaId null(或全空白)= **不绑角色**:走**不带 body** 的请求,线上与本端支持选角
|
||||
* 之前逐字节相同。空白按 null 处理是有依据的——契约里「省略 `personaId`」与「`personaId`
|
||||
* 为空」是同一件事(不绑,继承全局默认角色),没必要把 `{"personaId":""}` 发上去。
|
||||
* 非空 = 创建时绑定该角色,未知角色服务端回 `400 bad_request`。
|
||||
*
|
||||
* 角色**只在创建时**能定:`PATCH /sessions/{id}` 一旦收到 `personaId` 就回 `400`,要换角色只能新开。
|
||||
*/
|
||||
suspend fun createSession(personaId: String? = null): Result<String> = withContext(dispatcher) {
|
||||
val persona = personaId?.takeIf { it.isNotBlank() }
|
||||
val created = if (persona == null) {
|
||||
apiCall { api.createSession() }
|
||||
} else {
|
||||
apiCall { api.createSession(CreateSessionRequest(personaId = persona)) }
|
||||
}
|
||||
created.map { it.sessionId }
|
||||
}
|
||||
|
||||
/**
|
||||
* `PATCH /sessions/{id}`:只发本次要改的字段,缺席的字段服务端保持原值
|
||||
* (所以 `null` 是「不改」,不是「清空」——服务端也无法把标题清回自动命名)。
|
||||
*/
|
||||
suspend fun updateSession(
|
||||
sessionId: String,
|
||||
title: String? = null,
|
||||
pinned: Boolean? = null,
|
||||
): Result<UpdateSessionResponse> = withContext(dispatcher) {
|
||||
apiCall { api.updateSession(sessionId, UpdateSessionRequest(title = title, pinned = pinned)) }
|
||||
}
|
||||
|
||||
/** `DELETE /sessions/{id}`,成功返回被删除的会话 id;主会话会以 403 失败。 */
|
||||
suspend fun deleteSession(sessionId: String): Result<String> = withContext(dispatcher) {
|
||||
apiCall { api.deleteSession(sessionId) }.map { it.deleted }
|
||||
}
|
||||
|
||||
/**
|
||||
* `POST /sessions/{id}/read`(D4):推进该会话的已读游标,成功返回**推进后**的游标。
|
||||
*
|
||||
* @param before 毫秒时间戳;**null = 现在**(「我看到的都算已读」)。服务端对 `0` / 负数
|
||||
* 一律 `400`,所以调用方必须把「没有时间戳」表达成 null 而不是 0。
|
||||
*
|
||||
* 这是**幂等**的(服务端 `MAX(read_at, before)`,只前进),失败也没有副作用——
|
||||
* 唯一真源仍是服务端游标,漏标一次会在下一次 `GET /sessions` 自然暴露、再被纠正。
|
||||
*/
|
||||
suspend fun markRead(sessionId: String, before: Long? = null): Result<ReadSessionResponse> =
|
||||
withContext(dispatcher) {
|
||||
apiCall { api.markRead(sessionId, ReadSessionRequest(before = before)) }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
package top.yeij.cyrene.data.repository
|
||||
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import okhttp3.MediaType.Companion.toMediaTypeOrNull
|
||||
import okhttp3.MultipartBody
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import top.yeij.cyrene.core.error.CyreneError
|
||||
import top.yeij.cyrene.data.api.CyreneException
|
||||
import top.yeij.cyrene.data.api.UploadApi
|
||||
import top.yeij.cyrene.data.api.apiCall
|
||||
|
||||
/**
|
||||
* 一次成功上传的可用形态。
|
||||
*
|
||||
* 调用方(`ChatViewModel`)只需要 [url],其余三个字段由服务端生成、留着排查用:
|
||||
* [size] 是服务端统计的字节数、[mime] 是**服务端嗅探**出的类型(不是客户端声明的)、
|
||||
* [name] 是服务端存储名(客户端原始文件名已被丢弃)。
|
||||
*/
|
||||
data class UploadedImage(
|
||||
val url: String,
|
||||
val size: Long,
|
||||
val mime: String,
|
||||
val name: String,
|
||||
)
|
||||
|
||||
/**
|
||||
* 图片上传取数层(`POST /api/v1/upload`)。
|
||||
*
|
||||
* ## 为什么这台机器存在
|
||||
* 服务端把 `chat.image` 字符串**原样落库**(api-client §1 行 3 + 协同 G3):内联的 base64
|
||||
* 会在**每一页历史**与**每一次 WS `history` 回放**里重传一遍。上传一次、消息里只留一个
|
||||
* URL,是唯一能让历史不再随图片长大的路。
|
||||
*
|
||||
* ## 错误语义(这里是本仓库与只读仓库的差别)
|
||||
* 用 [apiCall]:它的状态码**不承载额外语义**——`413 payload_too_large` 与
|
||||
* `415 unsupported_media_type` 的机器可读区分都在 `error.code` 里(已对着在线后端实测,
|
||||
* 见 `UploadDtoRealPayloadTest`),不像 `POST /api/v1/chat` 那样必须靠状态码分 `202` / `409`。
|
||||
* 于是网络 / 超时 / `401` / 服务端拒绝一律折进 `Result.failure`,调用方按 `code` 或
|
||||
* [CyreneError] 的类型分支。
|
||||
*
|
||||
* ## 两处**客户端**必须自己把关的地方(服务端帮不了)
|
||||
* 1. **`url` 必须是绝对 http(s) 地址**:`chat.image` 的视觉层只把 `http(s)://` 当 URL
|
||||
* (`vision.ParseImageRef` 的判据就是 `strings.HasPrefix(src, "http://")`),裸路径
|
||||
* (如 `/api/v1/files/x.png`)会被当成 **base64** 载荷。拿到这种 url 还照发,就是把这一轮
|
||||
* 丢进「图片解析失败」的黑洞——所以这里直接判解析失败,宁可当场重试。
|
||||
* 2. **`url` 不能为空**:`201` 却没有可用地址 = 上传了但拿不到东西,同样判失败,**绝不**
|
||||
* 退回内联 base64(那正是本任务要消灭的成本)。
|
||||
*/
|
||||
class UploadRepository(
|
||||
private val api: UploadApi,
|
||||
private val dispatcher: CoroutineDispatcher = Dispatchers.IO,
|
||||
) {
|
||||
|
||||
/**
|
||||
* 上传一张**已重编码**的图片,返回可作 `chat.image` 的绝对 URL。
|
||||
*
|
||||
* [bytes] 与 [mime] 必须来自同一次编码(`ChatImagePreparer` 的产物):
|
||||
* 服务端按**字节嗅探**判类型,声明的 `Content-Type` 只作参考,两者不一致时以字节为准。
|
||||
*/
|
||||
suspend fun uploadImage(bytes: ByteArray, mime: String): Result<UploadedImage> =
|
||||
withContext(dispatcher) {
|
||||
apiCall { api.upload(partOf(bytes, mime)) }.fold(
|
||||
onSuccess = { data ->
|
||||
val url = absoluteHttpUrl(data.url)
|
||||
if (url == null) {
|
||||
Result.failure(CyreneException(CyreneError.Local(CyreneError.LocalKind.Parse)))
|
||||
} else {
|
||||
Result.success(
|
||||
UploadedImage(url = url, size = data.size, mime = data.mime, name = data.name),
|
||||
)
|
||||
}
|
||||
},
|
||||
onFailure = { Result.failure(it) },
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* multipart 部件。
|
||||
*
|
||||
* **部件名固定 `file`**(服务端 `FormFile("file")`)。文件名也**必须给**:Go 的
|
||||
* `mime/multipart` 只把带 `filename` 参数的部件当**文件**,不带的会落进普通表单值、
|
||||
* 于是 `FormFile` 找不到它。扩展名与服务端白名单一致(服务端丢弃原始名,这里只是让
|
||||
* 线上的部件看起来像一张真图,中间件不会因为怪名字做多余判断)。
|
||||
*/
|
||||
private fun partOf(bytes: ByteArray, mime: String): MultipartBody.Part =
|
||||
MultipartBody.Part.createFormData(
|
||||
name = UPLOAD_FIELD_NAME,
|
||||
filename = fileNameFor(mime),
|
||||
body = bytes.toRequestBody(mime.toMediaTypeOrNull()),
|
||||
)
|
||||
|
||||
private fun fileNameFor(mime: String): String = "upload." + when (mime) {
|
||||
MIME_PNG -> "png"
|
||||
MIME_GIF -> "gif"
|
||||
MIME_WEBP -> "webp"
|
||||
MIME_BMP -> "bmp"
|
||||
else -> "jpg"
|
||||
}
|
||||
|
||||
/** 只认绝对 http(s) 地址,且**大小写敏感**(与服务端 `ParseImageRef` 的判据同一口径:放宽只会放行服务端不认的东西)。 */
|
||||
private fun absoluteHttpUrl(raw: String): String? = raw.trim()
|
||||
.takeIf { it.startsWith("http://") || it.startsWith("https://") }
|
||||
}
|
||||
|
||||
private const val UPLOAD_FIELD_NAME = "file"
|
||||
|
||||
private const val MIME_PNG = "image/png"
|
||||
private const val MIME_GIF = "image/gif"
|
||||
private const val MIME_WEBP = "image/webp"
|
||||
private const val MIME_BMP = "image/bmp"
|
||||
@@ -0,0 +1,164 @@
|
||||
package top.yeij.cyrene.data.repository
|
||||
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import top.yeij.cyrene.core.error.CyreneError
|
||||
import top.yeij.cyrene.data.api.ApiErrorMapper
|
||||
import top.yeij.cyrene.data.api.CyreneException
|
||||
import top.yeij.cyrene.data.api.MediaApi
|
||||
import top.yeij.cyrene.domain.chat.DocumentAttachments
|
||||
import top.yeij.cyrene.domain.chat.VoiceAudio
|
||||
import top.yeij.cyrene.domain.chat.readBounded
|
||||
import top.yeij.cyrene.domain.chat.voiceMediaUrl
|
||||
import java.io.File
|
||||
|
||||
/** 本端一次读得下、也放得动的语音副本上限,超过就如实说「放不了」(见 [VoiceTooLargeException])。 */
|
||||
class VoiceTooLargeException : Exception("voice copy too large")
|
||||
|
||||
/** 服务端只给了相对路径,而本端目前拿不到一个能接上去的服务器地址(未登录 / 地址为空)。 */
|
||||
class VoiceUrlUnavailableException : Exception("voice media url unavailable")
|
||||
|
||||
/**
|
||||
* 语音副本的取字节层(`GET /api/v1/media/{name}` 的第一个调用方)。
|
||||
*
|
||||
* 与 [DocumentRepository] 同一个形状:响应不是信封(就是音频本身),所以不用 `apiCall`——
|
||||
* 成功即读字节,失败才走 [ApiErrorMapper](`404 media_not_found` / `401`)。
|
||||
* `@Streaming` 的响应体必须由调用方关闭,[okhttp3.ResponseBody.use] 在这里收口。
|
||||
*
|
||||
* [url] 由调用方拼好(服务端给的**相对路径** + 当前服务器地址,纯函数
|
||||
* [voiceMediaUrl]):本层**不自己拼路径**——契约把那条路径定成服务端唯一的派生字段。
|
||||
* 因此这里也不做形态校验:拼不出可信地址的调用方根本不会走到这里([VoiceStore] 先判)。
|
||||
*/
|
||||
class MediaRepository(
|
||||
private val api: MediaApi,
|
||||
private val dispatcher: CoroutineDispatcher = Dispatchers.IO,
|
||||
) {
|
||||
|
||||
/**
|
||||
* 取回一条语音副本的字节。
|
||||
*
|
||||
* 读到超过 [MAX_VOICE_BYTES] 就判 [VoiceTooLargeException]:语音副本本就不该大,
|
||||
* 而「一次读进内存」这条路上必须有一把尺子(与文档 / 图片同一个做法)。
|
||||
* **0 字节不算成功**——那会让下一次播放「读缓存命中」一份什么都没有的文件。
|
||||
*/
|
||||
suspend fun download(url: String): Result<ByteArray> = withContext(dispatcher) {
|
||||
try {
|
||||
val response = api.download(url)
|
||||
val body = response.body()
|
||||
when {
|
||||
response.isSuccessful && body != null -> body.use { stream ->
|
||||
val bytes = readBounded(stream.byteStream(), MAX_VOICE_BYTES)
|
||||
when {
|
||||
bytes == null -> Result.failure(VoiceTooLargeException())
|
||||
bytes.isEmpty() -> Result.failure(
|
||||
CyreneException(CyreneError.Local(CyreneError.LocalKind.Parse)),
|
||||
)
|
||||
|
||||
else -> Result.success(bytes)
|
||||
}
|
||||
}
|
||||
|
||||
// 2xx 却没有响应体:与 apiCall 同一口径,判解析失败而不是「拿到了空文件」。
|
||||
response.isSuccessful -> Result.failure(
|
||||
CyreneException(CyreneError.Local(CyreneError.LocalKind.Parse)),
|
||||
)
|
||||
|
||||
else -> Result.failure(
|
||||
CyreneException(ApiErrorMapper.fromHttp(response.code(), response.errorBody()?.string())),
|
||||
)
|
||||
}
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
Result.failure(CyreneException(ApiErrorMapper.fromThrowable(e)))
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
/**
|
||||
* 上限取附件那一把尺子(20 MB):语音副本远小于它,而这里要防的只是「一次读进内存」
|
||||
* 这条路上没有上界。**不新造一个数字**——多一个自定义上限就多一处会漂移的口径。
|
||||
*/
|
||||
val MAX_VOICE_BYTES: Long = DocumentAttachments.MAX_FILE_BYTES
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 语音副本的「取字节 + 落盘缓存」层(播放前那一步的**唯一**取数入口)。
|
||||
*
|
||||
* ## 缓存:`<cacheDir>/media/`,复用 [DocumentCache] 的三条规则
|
||||
* - **名字规则 / 命中判定 / 原子落盘**全部复用 [DocumentCache](同一条 `isSafeName`、
|
||||
* `isHit`(非空才算命中)、`write`(先写 `.part` 再改名))——只在缓存根下换一个目录名。
|
||||
* - **特意不落进 `documents/`**:那个目录是 `FileProvider`(`res/xml/file_paths.xml`)**唯一**
|
||||
* 暴露出去的根,「用其它应用打开 / 保存到下载目录」两条出口都从它取文件。语音本片只做
|
||||
* 收 / 放,不提供「交给别的 App」这条出口,所以**不把暴露面扩大一个字节**。
|
||||
* - 目录名是 `media`,与 `voice.url` 里的 `/api/v1/media/` 同源,便于排查时对得上号。
|
||||
*
|
||||
* ## 为什么缓存(而不是每次现下)
|
||||
* 服务端副本是**内容寻址**的(名字由内容决定)+ `Cache-Control: private, max-age=86400,
|
||||
* immutable` ⇒ 同一个名字的字节永不改变,「文件在且非空」就是永远有效的命中。重复播放因此
|
||||
* **一个请求都不发**,离线也能重听刚听过的那条。代价是缓存目录里多占几十 KB~几百 KB 一份,
|
||||
* 而它在 `cacheDir` 下:系统需要空间时可以回收,本端被回收之后下次播放重新下载(**不是**错误)。
|
||||
*
|
||||
* ## 它不做什么
|
||||
* 不拼路径([voiceMediaUrl] 在调用前判,拿不到可信地址就直接失败)、不做形态校验、不猜 MIME、
|
||||
* **不拿 `size` 反算时长**(时长只认服务端给的 `durationMs`)。
|
||||
*/
|
||||
class VoiceStore(
|
||||
/**
|
||||
* 取一条副本的字节(未命中缓存时调一次)。
|
||||
*
|
||||
* 收成一个 lambda 而不是直接收 [MediaRepository]:本类因此可以在单测里用临时目录 + 假下载器
|
||||
* 跑完,不必造一个 HTTP 替身(与 [ImageCacheStore] 的 `download` 参数同一手法)。
|
||||
* 生产路径上它就是 `container.mediaRepository::download`。
|
||||
*/
|
||||
private val download: suspend (String) -> Result<ByteArray>,
|
||||
private val cacheDir: File,
|
||||
private val baseUrlProvider: () -> String,
|
||||
private val dispatcher: CoroutineDispatcher = Dispatchers.IO,
|
||||
) {
|
||||
|
||||
/**
|
||||
* 取这条语音的字节并确保它已落进缓存;返回**可直接交给 `MediaPlayer` 的那份文件**。
|
||||
*
|
||||
* 顺序是刻意的:**先拼地址、再查缓存**。地址拼不出来(未登录 / 地址为空)就如实失败,
|
||||
* 绝不因为「本地碰巧缓存过」而放行——那会让一条本端根本没有服务器归属的语音照旧能放,
|
||||
* 而调用方拿不到任何异常信号。
|
||||
*/
|
||||
suspend fun load(audio: VoiceAudio): Result<File> = withContext(dispatcher) {
|
||||
val url = voiceMediaUrl(audio.url, baseUrlProvider())
|
||||
?: return@withContext Result.failure(VoiceUrlUnavailableException())
|
||||
// ⚠️ 刻意**不用** `DocumentCache.fileFor`:那个函数的缓存根写死成 `documents/`
|
||||
// (FileProvider 那一层),传别的目录进去会拼成 `<cacheDir>/media/documents/<name>`。
|
||||
// 这里只用它的**名字判据**,路径自己拼。
|
||||
if (!DocumentCache.isSafeName(audio.name)) {
|
||||
// 名字不安全 = 服务端给了一个本端不敢拼路径的名字;判解析失败,绝不退回别的名字。
|
||||
return@withContext Result.failure(
|
||||
CyreneException(CyreneError.Local(CyreneError.LocalKind.Parse)),
|
||||
)
|
||||
}
|
||||
val file = File(directory(cacheDir), audio.name)
|
||||
if (DocumentCache.isHit(file)) return@withContext Result.success(file)
|
||||
|
||||
val bytes = download(url).getOrElse { return@withContext Result.failure(it) }
|
||||
try {
|
||||
DocumentCache.write(file, bytes)
|
||||
Result.success(file)
|
||||
} catch (_: Exception) {
|
||||
// 写不进去时把可能的残留清掉,再如实报「缓存不可用」——**绝不**退回「把字节直接喂给
|
||||
// 播放器」那条路:那需要另一条落盘位置,而缓存目录写不进去时多半写得进的地方也没有。
|
||||
file.delete()
|
||||
Result.failure(DocumentCacheUnavailableException())
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
/** 缓存根下的目录名(与 `voice.url` 里的 `/api/v1/media/` 同源,便于对号)。 */
|
||||
const val DIRECTORY_NAME: String = "media"
|
||||
|
||||
/** 语音副本的缓存目录:`<cacheDir>/media`。 */
|
||||
fun directory(cacheDir: File): File = File(cacheDir, DIRECTORY_NAME)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
package top.yeij.cyrene.data.voice
|
||||
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import top.yeij.cyrene.core.auth.NoUnauthorizedPolicy
|
||||
import top.yeij.cyrene.core.auth.UnauthorizedPolicy
|
||||
import top.yeij.cyrene.core.error.CyreneError
|
||||
import top.yeij.cyrene.data.api.ApiClientFactory
|
||||
import top.yeij.cyrene.data.api.ApiErrorMapper
|
||||
import top.yeij.cyrene.data.api.CyreneException
|
||||
import top.yeij.cyrene.data.api.SpeechApi
|
||||
import top.yeij.cyrene.data.api.SpeechRequestDto
|
||||
import top.yeij.cyrene.data.repository.DocumentCache
|
||||
import top.yeij.cyrene.data.repository.DocumentCacheUnavailableException
|
||||
import top.yeij.cyrene.data.repository.VoiceStore
|
||||
import top.yeij.cyrene.domain.chat.DocumentAttachments
|
||||
import top.yeij.cyrene.domain.chat.readBounded
|
||||
import java.io.File
|
||||
import java.security.MessageDigest
|
||||
|
||||
/** 服务端回的字节**不是**一段 WAV(200 却回了 JSON 信封 / HTML 错误页 / 别的东西)。 */
|
||||
class SpeechNotAudioException : Exception("speech response is not wav")
|
||||
|
||||
/**
|
||||
* 一次 TTS 请求的**字节嗅探**:真的是 RIFF/WAVE 吗?
|
||||
*
|
||||
* 契约(api-client.md §4.1 行 561 的旁注、§1.2 U6)只说「产物恒为 WAV」,**没有**承诺
|
||||
* `Content-Type` 是什么,并明写「不要照抄 `Content-Type`」。所以判据只能落在字节上:
|
||||
* 头 4 字节 `RIFF` + 偏移 8 起的 4 字节 `WAVE`(RIFF 容器里夹一段 `fmt ` 那种标准布局)。
|
||||
*
|
||||
* 为什么要判:`MediaPlayer` 拿到非音频字节时抛的是它自己那套 `setDataSource` 异常,
|
||||
* 与本端「取字节失败」的语义完全不同;而 200 回 JSON 信封恰恰是**最可能**的一种
|
||||
* 「网关/反代把错误包了一层」的形态 —— 提前拦在这里,失败就被如实归到「解析失败」那一档,
|
||||
* 而不是在播放器里炸成一个没人认识的东西。
|
||||
*/
|
||||
fun isWavBytes(bytes: ByteArray): Boolean =
|
||||
bytes.size >= WAV_HEADER_BYTES &&
|
||||
bytes[0] == 'R'.code.toByte() &&
|
||||
bytes[1] == 'I'.code.toByte() &&
|
||||
bytes[2] == 'F'.code.toByte() &&
|
||||
bytes[3] == 'F'.code.toByte() &&
|
||||
bytes[8] == 'W'.code.toByte() &&
|
||||
bytes[9] == 'A'.code.toByte() &&
|
||||
bytes[10] == 'V'.code.toByte() &&
|
||||
bytes[11] == 'E'.code.toByte()
|
||||
|
||||
/** RIFF 头里本端要看的那几个字节都在前 12 个之内。 */
|
||||
private const val WAV_HEADER_BYTES = 12
|
||||
|
||||
/** 本端一次读得下、也放得动的合成音频上限(与语音副本同一把尺子:附件上限 20 MB)。 */
|
||||
private val MAX_SPEECH_BYTES: Long = DocumentAttachments.MAX_FILE_BYTES
|
||||
|
||||
/**
|
||||
* 「把一段文字念出来」的取字节层(`POST /api/v1/audio/speech` 的唯一调用方,契约 api-client.md
|
||||
* §1.2 **U6**,v0.96 增量 + v0.97 补写)。
|
||||
*
|
||||
* ## 凭据:只走 Bearer,不设 API Key
|
||||
* 服务端是 owner-only 的**双凭据门**(Bearer owner JWT **或** `X-API-Key`,两个分支落到同一个
|
||||
* `authorize(RoleOwner)`)。本端只走前者:令牌由 [ApiClientFactory.create] 挂在 OkHttpClient 上的
|
||||
* `AuthInterceptor` 注入,并顺带享受「带令牌仍 401 ⇒ 单飞续期并重试一次」。
|
||||
* 主人 2026-10-26 拍板**不加 API Key 输入框** —— 那会多一份要保管、要轮换、要泄漏的凭据,
|
||||
* 而本端本来就已经有 owner 会话。
|
||||
*
|
||||
* ## 发音人从哪来:`sessionId`,不是 `voice`
|
||||
* 契约里 `voice` **接受但不生效**、`format` **不生效**。真正决定发音人的是 `sessionId` 那条会话的
|
||||
* **生效人格**(会话绑定 → 全局默认 → 服务端配置的 default)。所以 [speak] 一定要拿到当前会话 id
|
||||
* ——**省略它不等于没有角色**,只是让服务端退到全局默认。
|
||||
*
|
||||
* ## 缓存:`<cacheDir>/media/tts_<hash>.wav`,同一段文字一个请求都不发第二次
|
||||
* 名字按 **(baseUrl, sessionId, text)** 内容寻址(sha256 前 32 位十六进制),复用 [DocumentCache] 的
|
||||
* 三条规则(`isSafeName` / `isHit` / 原子 `write`),目录复用 [VoiceStore.directory](`media/`,
|
||||
* 与 `voice.url` 的 `/api/v1/media/` 同源,排查时对得上号;且它**不在** FileProvider 暴露的
|
||||
* `documents/` 根下,不扩大暴露面一个字节)。
|
||||
*
|
||||
* `baseUrl` 进哈希是 P2-2 的修法:换一台服务器后,同一个 `(sessionId, text)` 在那边可能是**另一个
|
||||
* 声音**(人格映射 / 发音人 / 合成实现都是那台服务器的),命中上一台合成的那份 WAV 就是念错人。
|
||||
*
|
||||
* ⚠️ 与语音副本的缓存**有一处关键差别**:副本的名字由服务端按内容决定 ⇒ 命中永不失效;而这里的
|
||||
* 名字由**请求参数**决定,服务端换了发音人 / 换了合成实现之后,同一段文字应该能念出不一样的声音。
|
||||
* 因此这里把 `tts_` 前缀留成「本端合成缓存」的标记,命中判定仍用 [DocumentCache.isHit]
|
||||
* (非空才算)——它是 `cacheDir` 下的东西,系统随时可回收,被回收之后下次朗读重新请求,
|
||||
* **不是**错误。
|
||||
*
|
||||
* ## 错误怎么分类
|
||||
* 非 2xx 一律走 [ApiErrorMapper](`401` ⇒ [CyreneError.Unauthorized],其余 ⇒
|
||||
* [CyreneError.Server] 且**保留服务端那句 `code`**——`tts_persona_unmapped` /
|
||||
* `tts_no_character` 这两档的文案完全不同,折成一句话就说不清了)。
|
||||
* 分类成「柔和提示」还是「明确降级」是纯函数的事,在
|
||||
* [top.yeij.cyrene.domain.chat.ttsFailureOf] 里,本类不做产品判断。
|
||||
*
|
||||
* @param baseUrlProvider 每次现读服务器地址(换服务器 ⇒ 客户端重建,与 `AppContainer` 同一纪律)。
|
||||
* @param apiFactory 按 baseUrl 造 [SpeechApi];收成 lambda 是为了单测能塞一个直连本地 HTTP 替身的
|
||||
* 实例,而不必造一整套依赖注入。
|
||||
*/
|
||||
class SpeechClient(
|
||||
private val baseUrlProvider: () -> String,
|
||||
private val tokenProvider: () -> String? = { null },
|
||||
private val unauthorizedPolicy: UnauthorizedPolicy = NoUnauthorizedPolicy,
|
||||
private val cacheDir: File,
|
||||
private val dispatcher: CoroutineDispatcher = Dispatchers.IO,
|
||||
private val apiFactory: (String) -> SpeechApi = { baseUrl ->
|
||||
ApiClientFactory.create(
|
||||
baseUrl,
|
||||
SpeechApi::class.java,
|
||||
tokenProvider,
|
||||
unauthorizedPolicy,
|
||||
)
|
||||
},
|
||||
) {
|
||||
|
||||
/** 按 baseUrl 缓存 Retrofit 实例(换地址才重建),与 `AppContainer.mediaApi` 同一手法。 */
|
||||
private var cachedApi: SpeechApi? = null
|
||||
private var cachedBaseUrl: String? = null
|
||||
|
||||
private fun api(): SpeechApi {
|
||||
val baseUrl = baseUrlProvider()
|
||||
val cached = cachedApi
|
||||
if (cached != null && cachedBaseUrl == baseUrl) return cached
|
||||
return apiFactory(baseUrl).also {
|
||||
cachedApi = it
|
||||
cachedBaseUrl = baseUrl
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 把 [text] 合成成一段 WAV 并落盘,返回**可直接交给 `MediaPlayer`** 的那份文件。
|
||||
*
|
||||
* [sessionId] 是当前会话 id:它决定发音人(见类 KDoc)。传 `null` 表示「本端不知道是哪个会话」,
|
||||
* 服务端会退到全局默认人格 —— 调用方**不要**为了让字段好看而编一个 id 出来。
|
||||
*/
|
||||
suspend fun speak(text: String, sessionId: String?): Result<File> = withContext(dispatcher) {
|
||||
val trimmed = text.trim()
|
||||
if (trimmed.isEmpty()) {
|
||||
// 服务端对空 text 回 `400`;本端连请求都不发(省一次往返,也省一次无意义的失败)。
|
||||
return@withContext Result.failure(
|
||||
CyreneException(CyreneError.Local(CyreneError.LocalKind.Parse)),
|
||||
)
|
||||
}
|
||||
val file = File(VoiceStore.directory(cacheDir), cacheName(trimmed, sessionId, baseUrlProvider()))
|
||||
if (DocumentCache.isHit(file)) return@withContext Result.success(file)
|
||||
|
||||
try {
|
||||
val response = api().speech(SpeechRequestDto(text = trimmed, sessionId = sessionId))
|
||||
val body = response.body()
|
||||
when {
|
||||
response.isSuccessful && body != null -> body.use { stream ->
|
||||
val bytes = readBounded(stream.byteStream(), MAX_SPEECH_BYTES)
|
||||
when {
|
||||
// 超过上限:本端一次读不下(合成音频远小于它,走到这里说明对面不对劲)。
|
||||
bytes == null -> Result.failure(SpeechNotAudioException())
|
||||
// 0 字节:与 MediaRepository 同一口径,判解析失败而不是「拿到一段静音」。
|
||||
bytes.isEmpty() -> Result.failure(
|
||||
CyreneException(CyreneError.Local(CyreneError.LocalKind.Parse)),
|
||||
)
|
||||
// 200 却不是 WAV:**绝不**把 JSON / HTML 喂给 MediaPlayer(见 isWavBytes)。
|
||||
!isWavBytes(bytes) -> Result.failure(SpeechNotAudioException())
|
||||
else -> cache(file, bytes)
|
||||
}
|
||||
}
|
||||
|
||||
// 2xx 却没有响应体:与 apiCall 同一口径。
|
||||
response.isSuccessful -> Result.failure(
|
||||
CyreneException(CyreneError.Local(CyreneError.LocalKind.Parse)),
|
||||
)
|
||||
|
||||
else -> Result.failure(
|
||||
CyreneException(
|
||||
ApiErrorMapper.fromHttp(response.code(), response.errorBody()?.string()),
|
||||
),
|
||||
)
|
||||
}
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
Result.failure(CyreneException(ApiErrorMapper.fromThrowable(e)))
|
||||
}
|
||||
}
|
||||
|
||||
private fun cache(file: File, bytes: ByteArray): Result<File> = try {
|
||||
DocumentCache.write(file, bytes)
|
||||
Result.success(file)
|
||||
} catch (_: Exception) {
|
||||
// 写不进去时把可能的残留清掉,再如实报「缓存不可用」——绝不退回「把字节留在内存里等播放器
|
||||
// 读」那条路:MediaPlayer 只吃路径,而缓存目录写不进去时多半写得进的地方也没有。
|
||||
file.delete()
|
||||
Result.failure(DocumentCacheUnavailableException())
|
||||
}
|
||||
|
||||
private companion object {
|
||||
/** 合成缓存的名字前缀:与语音副本(服务端内容寻址名)在同一目录里区分开。 */
|
||||
const val NAME_PREFIX = "tts_"
|
||||
|
||||
/** sha256 前 32 位十六进制,与服务端存储名 / [DocumentCache.localName] 同一长度。 */
|
||||
const val NAME_HEX_LENGTH = 32
|
||||
|
||||
/**
|
||||
* 缓存名 = `tts_<sha256(baseUrl \n sessionId \n text)[:32]>.wav`。
|
||||
*
|
||||
* 带上 `sessionId` 是**必须的**:发音人由会话的生效人格决定,不同会话的同一段文字
|
||||
* 可能是两个声音。带上 `baseUrl` 同样是必须的:换一台服务器(同一个会话 id、同一段文字)
|
||||
* 命中的会是**上一台服务器**合成的那份 WAV —— 那一台上的人格映射 / 发音人 / 合成实现
|
||||
* 可能完全不同,播出来是别人的声音。缓存是 `cacheDir` 下的东西、系统随时可回收,
|
||||
* 换地址后重新请求一次的代价远小于「念错了声音却看不出为什么」。
|
||||
*
|
||||
* 地址先 `trimEnd('/')` 归一:`http://a/` 与 `http://a` 是同一台服务器,不该分成两份缓存。
|
||||
* `\n` 做分隔符,免得 `(a, "bc")` 与 `(ab, "c")` 撞成同一个名字。
|
||||
*/
|
||||
fun cacheName(text: String, sessionId: String?, baseUrl: String): String {
|
||||
val digest = MessageDigest.getInstance("SHA-256")
|
||||
.digest(
|
||||
"${baseUrl.trimEnd('/')}\n${sessionId.orEmpty()}\n$text".toByteArray(Charsets.UTF_8),
|
||||
)
|
||||
val hex = digest.joinToString("") { "%02x".format(it) }.take(NAME_HEX_LENGTH)
|
||||
return NAME_PREFIX + hex + ".wav"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
package top.yeij.cyrene.data.voice
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.media.AudioFormat
|
||||
import android.media.AudioRecord
|
||||
import android.media.MediaRecorder
|
||||
import java.io.ByteArrayOutputStream
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import top.yeij.cyrene.domain.chat.VoiceClip
|
||||
|
||||
/** 录音机的两个可见态(界面只认这两个)。 */
|
||||
sealed interface VoiceRecorderState {
|
||||
|
||||
/** 没在录。 */
|
||||
data object Idle : VoiceRecorderState
|
||||
|
||||
/**
|
||||
* 正在录。
|
||||
*
|
||||
* [elapsedMs] 由**已收到的字节数**反算([VoiceClip.durationMillisOf]),不是另起一个计时器——
|
||||
* 两个时钟一定会漂,而屏幕上那个数字必须与最终发出去的音频长度一致。
|
||||
*
|
||||
* [reachedLimit] = 已经录满 [VoiceClip.MAX_RECORDING_MILLIS],此后的字节被**丢弃**(不是
|
||||
* 悄悄截断已经收到的部分)。UI 看到它应当立刻收尾发送,见本类注释。
|
||||
*/
|
||||
data class Recording(val elapsedMs: Int, val reachedLimit: Boolean) : VoiceRecorderState
|
||||
}
|
||||
|
||||
/**
|
||||
* 麦克风录音机:`AudioRecord` 收 PCM → 交给 [VoiceClip] 封 WAV(本类不碰封装,见那里的 KDoc)。
|
||||
*
|
||||
* ## 为什么自己持有一个作用域,而不复用调用方的 `rememberCoroutineScope`
|
||||
* 读循环必须活到 [stop] 真的收尾:调用方的组合一旦被丢弃(转屏 / 退到后台那一刻重建),
|
||||
* 挂在它上面的协程会被取消,而 USB/内置麦克风已经交给系统的那一块缓冲就再也取不回来——
|
||||
* 用户按下停止却拿到一个缺尾的片段。所以读循环跑在这个类自己的 `SupervisorJob + IO` 上,
|
||||
* 由 [release] 明确收掉。
|
||||
*
|
||||
* ## 满了不自动发送,只置位
|
||||
* 到 [VoiceClip.MAX_RECORDING_MILLIS] 时把 [VoiceRecorderState.Recording.reachedLimit] 置真、
|
||||
* 继续把多余的字节丢掉。**发送时机由 UI 决定**(它要先把「正在录」那一行换回输入栏,再交给
|
||||
* VM):本类不知道 VM 也不发帧,这条边界让它在单测里不必造一个 VM。
|
||||
*
|
||||
* ## 权限
|
||||
* 调用方必须先拿到 `RECORD_AUDIO`(运行时权限)。本类**不申请**也不检查:`AudioRecord`
|
||||
* 在没权限时会抛 `SecurityException`,[start] 把它连同「设备不支持 / 初始化失败」一起收敛成
|
||||
* `false`,由 UI 按「这台设备现在开不了麦克风」说一句,**绝不静默**。
|
||||
*/
|
||||
class VoiceRecorder(
|
||||
private val dispatcher: CoroutineDispatcher = Dispatchers.IO,
|
||||
) {
|
||||
|
||||
private val scope = CoroutineScope(SupervisorJob() + dispatcher)
|
||||
|
||||
private val _state = MutableStateFlow<VoiceRecorderState>(VoiceRecorderState.Idle)
|
||||
|
||||
/** 当前录音状态(界面据此显示「录音中 0:07」)。 */
|
||||
val state: StateFlow<VoiceRecorderState> = _state.asStateFlow()
|
||||
|
||||
private var record: AudioRecord? = null
|
||||
|
||||
/** 收集到的 PCM;只有 [start] 建、[stop]/[cancel] 取走或丢掉。 */
|
||||
private var collected: ByteArrayOutputStream? = null
|
||||
|
||||
private var reader: Job? = null
|
||||
|
||||
/**
|
||||
* 录音代次:每次 [start] 自增。
|
||||
*
|
||||
* 只服务一件事:读循环的 `finally` 里**只在还属于这一代**时才把状态收回 [VoiceRecorderState.Idle]。
|
||||
* 少了它,「取消 → 立刻重录」的窄窗口里,上一轮的收尾会把新那一轮的「录音中」抹成「没在录」,
|
||||
* 而麦克风其实还在收字节——界面上什么都不显示,用户以为自己没在录。
|
||||
*/
|
||||
private var generation = 0
|
||||
|
||||
@Volatile
|
||||
private var collecting = false
|
||||
|
||||
/**
|
||||
* 开始录音。
|
||||
*
|
||||
* @return `false` = 这台设备现在起不了麦克风(没有权限 / 设备被占用 / 不支持的采样参数)。
|
||||
* 调用方必须**如实告知**(不得假装在录)。
|
||||
*/
|
||||
@SuppressLint("MissingPermission")
|
||||
fun start(): Boolean {
|
||||
if (collecting) return false
|
||||
val minBuffer = AudioRecord.getMinBufferSize(SAMPLE_RATE_HZ, CHANNEL_MASK, ENCODING)
|
||||
if (minBuffer <= 0) return false
|
||||
val recorder = runCatching {
|
||||
AudioRecord.Builder()
|
||||
.setAudioSource(MediaRecorder.AudioSource.MIC)
|
||||
.setAudioFormat(
|
||||
AudioFormat.Builder()
|
||||
.setEncoding(ENCODING)
|
||||
.setSampleRate(SAMPLE_RATE_HZ)
|
||||
.setChannelMask(CHANNEL_MASK)
|
||||
.build(),
|
||||
)
|
||||
// 缓冲给到最小值的两倍:读循环偶尔晚一拍时不至于立刻丢帧。
|
||||
.setBufferSizeInBytes(minBuffer * 2)
|
||||
.build()
|
||||
}.getOrNull() ?: return false
|
||||
if (recorder.state != AudioRecord.STATE_INITIALIZED) {
|
||||
runCatching { recorder.release() }
|
||||
return false
|
||||
}
|
||||
val started = runCatching { recorder.startRecording() }.isSuccess
|
||||
if (!started) {
|
||||
runCatching { recorder.release() }
|
||||
return false
|
||||
}
|
||||
record = recorder
|
||||
val sink = ByteArrayOutputStream(INITIAL_PCM_BYTES)
|
||||
collected = sink
|
||||
collecting = true
|
||||
val current = ++generation
|
||||
_state.value = VoiceRecorderState.Recording(elapsedMs = 0, reachedLimit = false)
|
||||
reader = scope.launch { readLoop(recorder, sink, current) }
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* 停止并交出这一段 PCM;`null` = 没有可发的字节(没在录 / 读失败 / 全被丢弃)。
|
||||
*
|
||||
* `suspend` 是刻意的:必须等读循环真的收尾([reader] 的 `join`)才去读那份缓冲,
|
||||
* 否则最后一块还没写进去就被取走——用户会听到句尾少一截。
|
||||
*/
|
||||
suspend fun stop(): ByteArray? {
|
||||
if (!collecting) return null
|
||||
collecting = false
|
||||
reader?.join()
|
||||
reader = null
|
||||
releaseRecorder()
|
||||
_state.value = VoiceRecorderState.Idle
|
||||
val sink = collected ?: return null
|
||||
collected = null
|
||||
return sink.toByteArray().takeIf { it.isNotEmpty() }
|
||||
}
|
||||
|
||||
/** 取消:丢掉已经收到的字节,一个都不发(用户点了「取消」)。 */
|
||||
fun cancel() {
|
||||
collecting = false
|
||||
reader?.cancel()
|
||||
reader = null
|
||||
releaseRecorder()
|
||||
collected = null
|
||||
_state.value = VoiceRecorderState.Idle
|
||||
}
|
||||
|
||||
/** 组合被丢弃时调(等价于 [cancel]:一段没发出去的录音不该在后台继续占着麦克风)。 */
|
||||
fun release() = cancel()
|
||||
|
||||
/**
|
||||
* 读循环:一块一块收进 [sink],顺手更新已录时长;满了之后**丢字节但不打断录音**
|
||||
* (见类注释「满了不自动发送」)。
|
||||
*
|
||||
* 读出错(`read < 0`)就结束这一次录音:硬件被抢走、设备被拔出都会走到这里,此时
|
||||
* 已经收到的部分仍然有效([stop] 照常交出它们)。
|
||||
*
|
||||
* @param generation 启动时那一代的编号:收尾只在自己还是「当前这一代」时才改状态。
|
||||
*/
|
||||
private suspend fun readLoop(recorder: AudioRecord, sink: ByteArrayOutputStream, generation: Int) {
|
||||
val buffer = ByteArray(READ_BYTES)
|
||||
try {
|
||||
while (collecting) {
|
||||
val read = recorder.read(buffer, 0, buffer.size)
|
||||
if (read < 0) break
|
||||
if (read == 0) continue
|
||||
val used = sink.size()
|
||||
if (used < VoiceClip.MAX_PCM_BYTES) {
|
||||
sink.write(buffer, 0, minOf(read, VoiceClip.MAX_PCM_BYTES - used))
|
||||
}
|
||||
val elapsed = VoiceClip.durationMillisOf(minOf(sink.size(), VoiceClip.MAX_PCM_BYTES))
|
||||
_state.value = VoiceRecorderState.Recording(
|
||||
elapsedMs = elapsed,
|
||||
reachedLimit = elapsed >= VoiceClip.MAX_RECORDING_MILLIS,
|
||||
)
|
||||
}
|
||||
} finally {
|
||||
// 读循环自己结束(出错 / 被取消)时也要把状态收回 Idle,否则界面会永远停在「录音中」。
|
||||
// 只在自己还是当前这一代时才收:否则「取消 → 立刻重录」的窄窗口里,上一轮的收尾
|
||||
// 会把新一轮的「录音中」擦掉。
|
||||
if (generation == this.generation && _state.value is VoiceRecorderState.Recording) {
|
||||
_state.value = VoiceRecorderState.Idle
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 把 `AudioRecord` 拆干净(`stop` 在没起录时抛 `IllegalStateException`,所以都包在 `runCatching` 里)。 */
|
||||
private fun releaseRecorder() {
|
||||
val current = record ?: return
|
||||
record = null
|
||||
runCatching { current.stop() }
|
||||
runCatching { current.release() }
|
||||
}
|
||||
|
||||
private companion object {
|
||||
val SAMPLE_RATE_HZ: Int = VoiceClip.SAMPLE_RATE_HZ
|
||||
const val ENCODING: Int = AudioFormat.ENCODING_PCM_16BIT
|
||||
const val CHANNEL_MASK: Int = AudioFormat.CHANNEL_IN_MONO
|
||||
|
||||
/**
|
||||
* 一次读多少字节:`2048` = 16 kHz 下单声道 16-bit 的 64 ms(1024 帧)。
|
||||
* 比这更小则更新频率过高(每秒几十次状态写),更大则「停止」那一刻的延迟可感。
|
||||
*/
|
||||
const val READ_BYTES: Int = 2048
|
||||
|
||||
/** 初始缓冲按 10 秒预分配(够大多数语音,长的那条会自己扩容)。 */
|
||||
val INITIAL_PCM_BYTES: Int = VoiceClip.pcmBytesFor(10_000)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,405 @@
|
||||
package top.yeij.cyrene.data.ws
|
||||
|
||||
import top.yeij.cyrene.core.auth.AuthRecovery
|
||||
import top.yeij.cyrene.data.api.ApiClientFactory
|
||||
import top.yeij.cyrene.domain.chat.parallelWireValue
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.channels.BufferOverflow
|
||||
import kotlinx.coroutines.cancel
|
||||
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.launch
|
||||
import okhttp3.HttpUrl
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
import okhttp3.WebSocket
|
||||
import okhttp3.WebSocketListener
|
||||
import java.util.concurrent.TimeUnit
|
||||
import kotlin.random.Random
|
||||
|
||||
/**
|
||||
* Cyrene 实时通道客户端(`GET /ws/chat?token=<JWT>`,api-reference §6)。
|
||||
*
|
||||
* ## 用法
|
||||
* 进程内只有**一个**实例,由 `AppContainer.chatSocket` 持有;上层要收帧就取它,**不要自己 new**
|
||||
* (见下面「它不是某个屏幕的私有连接」):
|
||||
* ```
|
||||
* val socket = container.chatSocket
|
||||
* // 收集 socket.events,观察 socket.state;发送用 socket.sendChat / socket.sendInterrupt
|
||||
* ```
|
||||
*
|
||||
* ## 会话归属
|
||||
* 服务端自 v0.36 起在**每个结构化流帧的帧壳**上带 `sessionId`,本类把它原样透传
|
||||
* (见 [WsEvent.sessionId]),不补值。服务端广播**不按会话订阅**:同一条连接会收到**所有**
|
||||
* 会话的帧,因此收集 [events] 的上层**必须**按 [WsEvent.isForSession] 收敛到自己的会话。
|
||||
*
|
||||
* ## 它不是某个屏幕的私有连接:谁都不许关它
|
||||
* 本实例是**进程级共享**的一条连接——所有需要收帧的 ViewModel 订阅的是同一份 [events] / [state]。
|
||||
* 之所以能共享,正是靠上面那条广播语义:「一条连接收到全部帧」与「N 条连接各收全部帧」对上层是
|
||||
* **同一个语义**,每个 VM 收到的帧集合逐帧相同,所以共享改的只是「连接有几条」(连带每个连接
|
||||
* 各带一个 `OkHttpClient` 的成本),不是「帧怎么走」——各 VM 的会话过滤逻辑**一个字都不用动**。
|
||||
*
|
||||
* 由此推出两条硬约束:
|
||||
* 1. **[close] 只归应用级的那条登出 / 会话失效路径**(目前唯一调用点:`SessionsViewModel` 的
|
||||
* 登录态订阅,见 `AppContainer.chatSocket`)。任何 ViewModel 在 `onCleared` 里关它、页面退出时
|
||||
* 关它,都会把**别的屏幕**一起掐断——切会话会掐掉抽屉未读与管理页三帧,关管理页会掐掉主会话的
|
||||
* 流式。连接的生命周期是**进程 + 登录态**,不是屏幕。
|
||||
* 2. **[events] 的 `replay` 恒为 0**(理由见那里的 KDoc):共享之后,新建的 VM 会立刻订阅同一份流,
|
||||
* replay > 0 就等于把**历史**下行帧投给一个新来的订阅者。
|
||||
*
|
||||
* ## 心跳与重连
|
||||
* - OkHttp `pingInterval` 30s(服务端 ping 周期 54s / pong 等待 60s,客户端 30s 足够)。
|
||||
* - 服务端**没有 resume / 补发**:重连成功后由上层用新连接的 `history` 帧 + REST 补差去重。
|
||||
* - 退避 1s/2s/4s/8s/16s/30s(上限 30s,含抖动)。
|
||||
* - 握手返回 **HTTP 401** → 先向 [onAuthRecover] 要一次续期(B2):换到新令牌就**立刻重连一次**;
|
||||
* 明确被拒或重连后仍 401 → [WsState.Offline] 并触发 [onAuthFailed];暂时性失败走普通退避。
|
||||
*
|
||||
* ## 坏帧
|
||||
* 每帧解析都走 [WsEvent.parse],非 JSON / 未知 `type` / 结构不匹配一律丢弃,不会中断连接。
|
||||
*
|
||||
* @param baseUrlProvider 服务器根地址(如 `http://host:8080`),每次重连时重新读取。
|
||||
* @param tokenProvider 当前 JWT,每次重连时重新读取(续期后无需重建实例)。
|
||||
* @param onAuthRecover 握手 401 时的一次性兜底,见 [AuthRecovery]。
|
||||
* @param onAuthFailed 终态回调(令牌被拒且无法续期);在协程内调用,注意切主线程。
|
||||
* @param client 可注入自定义 OkHttpClient(会 `newBuilder` 派生,保留其拦截器);缺省新建。
|
||||
*/
|
||||
class ChatSocket(
|
||||
private val baseUrlProvider: () -> String?,
|
||||
private val tokenProvider: () -> String?,
|
||||
private val onAuthRecover: () -> AuthRecovery = { AuthRecovery.Unrecoverable },
|
||||
private val onAuthFailed: () -> Unit = {},
|
||||
client: OkHttpClient? = null,
|
||||
) {
|
||||
|
||||
private val _state = MutableStateFlow(WsState.Idle)
|
||||
|
||||
/** 连接状态机;见 [WsState]。 */
|
||||
val state: StateFlow<WsState> = _state.asStateFlow()
|
||||
|
||||
/**
|
||||
* 诊断用:此刻底层连接对象还在不在(`webSocket != null`)。
|
||||
*
|
||||
* 只读、不改变任何行为,也**不是**产品逻辑的输入(`send` 判的是 [state] 与这个对象本身)。
|
||||
* 它存在只为一件事:让「这一帧为什么没走 WS」在 logcat 里可读——[state] 与它**可能不一致**
|
||||
* (状态还是 `Connected` 而对象已经被 `finally` 清空、或者反过来),而那个不一致正是
|
||||
* `WsSendOutcome.Failed` 与 `Queued` 的分水岭。tag 见 `ChatViewModel.SEND_ROUTE_LOG_TAG`。
|
||||
*/
|
||||
val hasLiveSocket: Boolean get() = webSocket != null
|
||||
|
||||
private val _events = MutableSharedFlow<WsEvent>(
|
||||
replay = 0,
|
||||
extraBufferCapacity = EVENT_BUFFER,
|
||||
onBufferOverflow = BufferOverflow.DROP_OLDEST,
|
||||
)
|
||||
|
||||
/**
|
||||
* 下行事件流。
|
||||
*
|
||||
* **`replay` 恒为 0,这条不是调优而是必需**:重放旧 delta 会让新订阅者重复渲染,会话状态应由
|
||||
* `history` / REST 重建;而连接**进程内共享**之后,新建的 VM(切到一个没访问过的会话)会**立刻**
|
||||
* 订阅同一份流——replay > 0 等于把历史帧投给它,轻则重复渲染,重则会话串台。
|
||||
*
|
||||
* `extraBufferCapacity = 512 + DROP_OLDEST`:发送方是 OkHttp 读线程,不能阻塞;溢出时丢最旧的
|
||||
* 增量帧(服务端发送缓冲 32 本就会丢帧,`stream_end` / `history` 仍可兜底)。
|
||||
*/
|
||||
val events: SharedFlow<WsEvent> = _events.asSharedFlow()
|
||||
|
||||
private val wsClient: OkHttpClient = (client ?: OkHttpClient()).newBuilder()
|
||||
.pingInterval(PING_INTERVAL_SECONDS, TimeUnit.SECONDS)
|
||||
.readTimeout(0, TimeUnit.MILLISECONDS)
|
||||
.connectTimeout(CONNECT_TIMEOUT_SECONDS, TimeUnit.SECONDS)
|
||||
.build()
|
||||
|
||||
@Volatile
|
||||
private var wantConnection: Boolean = false
|
||||
|
||||
@Volatile
|
||||
private var webSocket: WebSocket? = null
|
||||
|
||||
@Volatile
|
||||
private var attempt: Int = 0
|
||||
|
||||
@Volatile
|
||||
private var sessionJob: Job? = null
|
||||
|
||||
/** 本次 [connect] 是否已经用续期后的令牌重连过一次——避免握手 401 打成死循环。 */
|
||||
@Volatile
|
||||
private var authRetried: Boolean = false
|
||||
|
||||
private var scope: CoroutineScope? = null
|
||||
|
||||
/**
|
||||
* 开始连接。**幂等**:已经在连接 / 重连中([wantConnection] 已置位)时直接返回。
|
||||
*
|
||||
* 因为幂等,任何一个 VM 都可以在需要时调它做兜底;但**决定它该不该在**的只有登录态那条路径
|
||||
* (见类注释的硬约束 1),本方法只负责「接上」。
|
||||
*/
|
||||
fun connect() {
|
||||
if (wantConnection) return
|
||||
wantConnection = true
|
||||
attempt = 0
|
||||
authRetried = false
|
||||
sessionJob = ensureScope().launch { runConnectionLoop() }
|
||||
}
|
||||
|
||||
/**
|
||||
* 主动断开并停掉重连;之后可再次 [connect]。
|
||||
*
|
||||
* ⚠️ 本实例是**进程级共享连接**,不是某个屏幕的私有连接:**只有应用级的登出 / 会话失效路径可以
|
||||
* 调它**(目前唯一调用点:`SessionsViewModel` 的登录态订阅)。ViewModel 的 `onCleared`、
|
||||
* 页面退出、切会话都**不许**调——那会把别的屏幕正在用的连接一起掐断。理由见类注释
|
||||
* 「它不是某个屏幕的私有连接:谁都不许关它」。
|
||||
*/
|
||||
fun close() {
|
||||
wantConnection = false
|
||||
val socket = webSocket
|
||||
webSocket = null
|
||||
socket?.close(CLOSE_CODE_NORMAL, CLOSE_REASON_CLIENT)
|
||||
sessionJob?.cancel()
|
||||
sessionJob = null
|
||||
_state.value = WsState.Idle
|
||||
scope?.cancel()
|
||||
scope = null
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送 `chat`(api-reference §6.3)。
|
||||
* @param clientMsgId 幂等键(契约 v0.56 §3.13);重复到达服务端回 `chat_duplicate`,不落库。
|
||||
* @param parallel 要不要声明 `parallel:true`(忙时并行规范 §2.2)——**默认 `false` = 一个字节
|
||||
* 都不带该键**,线上帧与加入本参数之前**逐字节相同**。调用方只有一处:`ChatViewModel.deliver`,
|
||||
* 它按 App 偏好(`InterruptMode`)与「会话是否正忙」算出来(判据是纯函数 `parallelForSend`)。
|
||||
* @return 未连接或写失败返回 `false`(本层不做离线队列;上层按**这一刻的连接状态**分档处置:
|
||||
* 未就绪 ⇒ 记住这条、连接恢复后按序补发,连接就绪却仍发不出去 ⇒ 回滚 + 诚实文案——
|
||||
* 判据是 `ChatViewModel.deliver` 里的纯函数 `wsSendOutcome`)。
|
||||
*/
|
||||
fun sendChat(
|
||||
sessionId: String?,
|
||||
text: String,
|
||||
deviceId: String?,
|
||||
clientMsgId: String? = null,
|
||||
parallel: Boolean = false,
|
||||
): Boolean = send(
|
||||
WsOutbound.Chat(
|
||||
text = text,
|
||||
sessionId = sessionId,
|
||||
deviceId = deviceId,
|
||||
clientMsgId = clientMsgId,
|
||||
// 直接传 `false` 会序列化成 `"parallel":false`(`WsOutbound.Chat` 只在 `null` 时省略该键),
|
||||
// 那不是「未声明」而是**显式否认**——默认两档必须逐字节不变 ⇒ 只有 true 才带上该键。
|
||||
parallel = parallelWireValue(parallel),
|
||||
),
|
||||
)
|
||||
|
||||
/**
|
||||
* 发送 `interrupt`(api-client §3.1;契约 v0.56/A2 起支持 `scope`)。
|
||||
*
|
||||
* @param scope 打断范围:`null` = **省略该键**(服务端视同 `"all"`:取消在跑回合 + 清空待处理队列,
|
||||
* 与加入 `scope` 之前**逐字节相同**);`"current"` = 只取消在跑回合、保留待处理队列。
|
||||
* 其它取值原样发出,服务端**定向**回 `error{bad_request}` 且不打断任何会话(本层不预判取值域)。
|
||||
*
|
||||
* **调用方有两处,两处都只发 `"current"`**(本端从不发 `all`,见 `devdocs/02` 的 A2 补记):
|
||||
* 1. `ChatViewModel.interruptRunningTurn()`——它在一条消息真的要走上去**之前**按 App 偏好
|
||||
* (`InterruptMode`)决定要不要先打断,且**只在会话正忙时**发(判据是纯函数
|
||||
* `interruptScopeForSend`)。默认档「追加队列」与「并行」这一档都恒不发(后者的 `wireScope`
|
||||
* 就是 `null`:并行与打断互斥),所以本方法只服务「打断」这一档。
|
||||
* 2. `SessionsViewModel.stopGenerating(sessionId)`——抽屉里那一行的「停止」(W52B):它是一个
|
||||
* **独立的动作**(不读偏好),停的是**指定会话**正在跑的那一轮。`WsOutbound.Interrupt`
|
||||
* 本来就带 `sessionId`,所以「停某一个会话」这条既有出口表达得了,不需要新帧。
|
||||
*
|
||||
* 返回 false 只有一种原因(WS 未连接):两处调用方都**静默**,理由见各自的 KDoc。
|
||||
*/
|
||||
fun sendInterrupt(sessionId: String, scope: String? = null): Boolean =
|
||||
send(WsOutbound.Interrupt(sessionId = sessionId, scope = scope))
|
||||
|
||||
/**
|
||||
* 发送 `voice_input`(契约 v0.96 §3.15):一段 base64 音频 + 格式,走与 `chat` 同一条出站通道。
|
||||
*
|
||||
* ## 为什么没有「离线队列」
|
||||
* 文本那条有补发队列(内容只是一小段字符串,留着不贵);语音的 audio 是**几 MB 的 base64**
|
||||
* 内联在帧里,队列化它等于把这段内存钉在待发状态里,而重连之后服务端到底要不要它、要哪一段
|
||||
* 又都是用户该重新决定的事。所以本层与 [sendChat] 一样:**发不出去就回 `false`**,由
|
||||
* `ChatViewModel` 把这段留在内存里、让用户按「重发」(复用同一个 `clientMsgId`)。
|
||||
*
|
||||
* @param audio base64(单行,`Base64.NO_WRAP`)。
|
||||
* @param format 契约白名单 `wav|mp3|ogg` 之一;本端恒 `"wav"`。
|
||||
* @param deviceId 与 `chat` 帧同一份来源标识。
|
||||
* @param clientMsgId 幂等键:同一段语音重发**必须复用**(否则服务端会把同一句话说两遍)。
|
||||
* @return 未连接 / 写失败返回 `false`。
|
||||
*/
|
||||
fun sendVoiceInput(
|
||||
sessionId: String?,
|
||||
audio: String,
|
||||
format: String,
|
||||
deviceId: String?,
|
||||
clientMsgId: String?,
|
||||
): Boolean = send(
|
||||
WsOutbound.VoiceInput(
|
||||
audio = audio,
|
||||
format = format,
|
||||
sessionId = sessionId,
|
||||
deviceId = deviceId,
|
||||
clientMsgId = clientMsgId,
|
||||
),
|
||||
)
|
||||
|
||||
private fun send(outbound: WsOutbound): Boolean {
|
||||
if (_state.value != WsState.Connected) return false
|
||||
val socket = webSocket ?: return false
|
||||
return runCatching { socket.send(outbound.toJson()) }.getOrDefault(false)
|
||||
}
|
||||
|
||||
private suspend fun runConnectionLoop() {
|
||||
try {
|
||||
while (wantConnection) {
|
||||
when (val end = runSession()) {
|
||||
SessionEnd.ConfigMissing -> {
|
||||
wantConnection = false
|
||||
_state.value = WsState.Offline
|
||||
}
|
||||
|
||||
SessionEnd.AuthFailed -> {
|
||||
if (!recoverAuth()) break
|
||||
}
|
||||
|
||||
SessionEnd.Closed -> {
|
||||
if (!wantConnection) break
|
||||
_state.value = WsState.Reconnecting
|
||||
delay(backoffDelayMs(attempt))
|
||||
attempt += 1
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (_: CancellationException) {
|
||||
// close() 主动取消,正常退出。
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 握手被拒(HTTP 401)后的恢复(B2):
|
||||
* - [AuthRecovery.Renewed]:已换到新令牌,**立刻重连一次**(`tokenProvider` 每次重连都会重新读);
|
||||
* - [AuthRecovery.Unrecoverable]:令牌被服务端拒绝且无法续期 → 终态,触发 [onAuthFailed];
|
||||
* - [AuthRecovery.Transient]:暂时续不上(网络 / 5xx),会话保留,走普通退避重连。
|
||||
*
|
||||
* @return 连接循环是否继续。
|
||||
*/
|
||||
private suspend fun recoverAuth(): Boolean {
|
||||
if (authRetried) return endWithAuthFailure()
|
||||
return when (onAuthRecover()) {
|
||||
is AuthRecovery.Renewed -> {
|
||||
authRetried = true
|
||||
attempt = 0
|
||||
_state.value = WsState.Reconnecting
|
||||
true
|
||||
}
|
||||
|
||||
AuthRecovery.Transient -> {
|
||||
_state.value = WsState.Reconnecting
|
||||
delay(backoffDelayMs(attempt))
|
||||
attempt += 1
|
||||
true
|
||||
}
|
||||
|
||||
AuthRecovery.Unrecoverable -> endWithAuthFailure()
|
||||
}
|
||||
}
|
||||
|
||||
private fun endWithAuthFailure(): Boolean {
|
||||
wantConnection = false
|
||||
_state.value = WsState.Offline
|
||||
onAuthFailed()
|
||||
return false
|
||||
}
|
||||
|
||||
/** 建立一次连接并挂起,直到它关闭 / 失败 / 被取消。 */
|
||||
private suspend fun runSession(): SessionEnd {
|
||||
val url = buildUrl() ?: return SessionEnd.ConfigMissing
|
||||
_state.value = if (attempt == 0) WsState.Connecting else WsState.Reconnecting
|
||||
val finished = CompletableDeferred<SessionEnd>()
|
||||
val socket = wsClient.newWebSocket(
|
||||
Request.Builder().url(url).build(),
|
||||
object : WebSocketListener() {
|
||||
override fun onOpen(webSocket: WebSocket, response: Response) {
|
||||
attempt = 0
|
||||
_state.value = WsState.Connected
|
||||
}
|
||||
|
||||
override fun onMessage(webSocket: WebSocket, text: String) {
|
||||
// 未知 type / 坏帧由 parse 返回 null,静默丢弃。
|
||||
WsEvent.parse(text)?.let { _events.tryEmit(it) }
|
||||
}
|
||||
|
||||
override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
|
||||
finished.complete(SessionEnd.Closed)
|
||||
}
|
||||
|
||||
override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
|
||||
val end = if (isAuthFailure(response, t)) SessionEnd.AuthFailed else SessionEnd.Closed
|
||||
finished.complete(end)
|
||||
}
|
||||
},
|
||||
)
|
||||
webSocket = socket
|
||||
return try {
|
||||
finished.await()
|
||||
} finally {
|
||||
if (webSocket === socket) webSocket = null
|
||||
socket.cancel()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 握手鉴权失败是**握手前**的 HTTP 401(不是 WS close),OkHttp 会把它随 [onFailure] 的
|
||||
* `response` 一起回调;个别路径拿不到 response 时再退化为消息文本匹配。
|
||||
*/
|
||||
private fun isAuthFailure(response: Response?, t: Throwable): Boolean {
|
||||
if (response?.code == 401) return true
|
||||
return t.message?.contains("401") == true
|
||||
}
|
||||
|
||||
private fun buildUrl(): HttpUrl? {
|
||||
val token = tokenProvider()?.takeIf { it.isNotBlank() } ?: return null
|
||||
val base = baseUrlProvider()?.takeIf { it.isNotBlank() } ?: return null
|
||||
val httpUrl = ApiClientFactory.normalizeBaseUrl(base).toHttpUrlOrNull() ?: return null
|
||||
return httpUrl.newBuilder()
|
||||
.addPathSegments("ws/chat")
|
||||
.addQueryParameter("token", token)
|
||||
.build()
|
||||
}
|
||||
|
||||
/** 1s/2s/4s/8s/16s/30s,上限 30s;加 0~25% 抖动避免同时重连。 */
|
||||
private fun backoffDelayMs(attempt: Int): Long {
|
||||
val shift = attempt.coerceIn(0, MAX_BACKOFF_SHIFT)
|
||||
val base = (INITIAL_BACKOFF_MS shl shift).coerceAtMost(MAX_BACKOFF_MS)
|
||||
val jitter = Random.nextLong(base / JITTER_DIVISOR + 1)
|
||||
return (base + jitter).coerceAtMost(MAX_BACKOFF_MS)
|
||||
}
|
||||
|
||||
private fun ensureScope(): CoroutineScope =
|
||||
scope ?: CoroutineScope(SupervisorJob() + Dispatchers.IO).also { scope = it }
|
||||
|
||||
private enum class SessionEnd { Closed, AuthFailed, ConfigMissing }
|
||||
|
||||
private companion object {
|
||||
const val PING_INTERVAL_SECONDS = 30L
|
||||
const val CONNECT_TIMEOUT_SECONDS = 10L
|
||||
const val INITIAL_BACKOFF_MS = 1_000L
|
||||
const val MAX_BACKOFF_MS = 30_000L
|
||||
const val MAX_BACKOFF_SHIFT = 5
|
||||
const val JITTER_DIVISOR = 4L
|
||||
const val EVENT_BUFFER = 512
|
||||
const val CLOSE_CODE_NORMAL = 1000
|
||||
const val CLOSE_REASON_CLIENT = "client closed"
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,179 @@
|
||||
package top.yeij.cyrene.data.ws
|
||||
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.put
|
||||
|
||||
/**
|
||||
* 上行事件(客户端 → 服务端)。
|
||||
*
|
||||
* 帧壳:`{type, reqId?, data?}`(api-reference §6.2)。字段名逐字对齐 §6.3:
|
||||
* `chat` / `interrupt` / `voice_input` / `mode_switch` / `subagent_ask` / `task_output_query`。
|
||||
*
|
||||
* 序列化用 [buildJsonObject] 显式拼装,而不是 `@Serializable` 数据类,原因:
|
||||
* 上行帧是「信封 + 变长 data」,这样能保证 `type` 与 `data` 的嵌套结构不随类字段变化,
|
||||
* 且可选字段为 `null` 时整键省略(与后端 `omitempty` 语义一致)。
|
||||
*/
|
||||
sealed interface WsOutbound {
|
||||
|
||||
/** 事件类型,即帧壳的 `type`。 */
|
||||
val type: String
|
||||
|
||||
/** 请求关联 id;服务端当前不回填(api-reference §6.3),预留。 */
|
||||
val reqId: String? get() = null
|
||||
|
||||
/** `data` 负载;返回 `null` 时省略该键。 */
|
||||
fun data(): JsonElement?
|
||||
|
||||
/** 编码成一条文本帧。 */
|
||||
fun toJson(): String = buildJsonObject {
|
||||
put("type", type)
|
||||
reqId?.let { put("reqId", it) }
|
||||
data()?.let { put("data", it) }
|
||||
}.toString()
|
||||
|
||||
/** `chat`:`{sessionId?, text, image?, deviceId?, clientMsgId?, parallel?}`;text+image 至少一项。 */
|
||||
data class Chat(
|
||||
val text: String,
|
||||
val sessionId: String? = null,
|
||||
val deviceId: String? = null,
|
||||
val image: String? = null,
|
||||
/**
|
||||
* 客户端幂等键(契约 v0.56 §3.13):同一会话内同一个非空值只承认第一条,
|
||||
* 重复到达服务端回下行帧 `chat_duplicate`、**不落库、不起回合**。省略 = 老行为逐字不变。
|
||||
*/
|
||||
val clientMsgId: String? = null,
|
||||
/**
|
||||
* **请求把本条作为独立一路处理**(忙时并行规范 §2.2,**纯增量**)。
|
||||
*
|
||||
* `null`(默认)= **整键省略**:行为与加入该字段之前**逐字相同**(忙时落回排队 + 回合内 steer)。
|
||||
* 用户把「她忙时发消息」那栏选成「并行」**且会话正忙**时才给 `true`(判据是纯函数
|
||||
* `parallelForSend`;另外两档与空闲时都不带这个键——线上取值由 `parallelWireValue` 翻)。
|
||||
*
|
||||
* 服务端**照准也不报错**(额度满 ⇒ 落回排队并回 `pending_ack{reason:"parallel_limit"}`;
|
||||
* 已被在飞路 steer 吃进 ⇒ 回 `steer_ack`),所以这个字段永远不该引来一个错误帧。
|
||||
*/
|
||||
val parallel: Boolean? = null,
|
||||
override val reqId: String? = null,
|
||||
) : WsOutbound {
|
||||
override val type: String get() = "chat"
|
||||
|
||||
override fun data(): JsonElement = buildJsonObject {
|
||||
sessionId?.let { put("sessionId", it) }
|
||||
put("text", text)
|
||||
image?.let { put("image", it) }
|
||||
deviceId?.let { put("deviceId", it) }
|
||||
clientMsgId?.let { put("clientMsgId", it) }
|
||||
// 布尔字面量走 JsonPrimitive:`put(key, Boolean)` 的重载在不同 kotlinx 版本里形态不一,
|
||||
// 显式构造不会有歧义,也保证 `null` 时**整键省略**(与后端 `omitempty` 同义)。
|
||||
parallel?.let { put("parallel", JsonPrimitive(it)) }
|
||||
}
|
||||
}
|
||||
|
||||
/** `interrupt`:`{sessionId, scope?, turnId?}`。命中空闲会话为 no-op。 */
|
||||
data class Interrupt(
|
||||
val sessionId: String,
|
||||
/**
|
||||
* 打断范围(契约 v0.56/A2,api-client §3.1):取值域 `{"", "all", "current"}`。
|
||||
* `null` = **整键省略**——服务端视同 `"all"`(取消在跑回合 + 清空待处理队列),
|
||||
* 也就是加入该字段之前那条帧**逐字节相同**;`"current"` = 只取消在跑回合、**保留**待处理队列。
|
||||
* 其它取值服务端定向回 `error{bad_request}`(不打断任何会话)——故本层**原样透传**,
|
||||
* 不做取值校验、不静默回退成默认(服务端的开放取值域由服务端裁决)。
|
||||
*/
|
||||
val scope: String? = null,
|
||||
/**
|
||||
* 只取消**这一路**(忙时并行规范 §2.3,**纯增量**)。
|
||||
*
|
||||
* 含义按 [scope] 分(规范的表):`"current"` + 给了 `turnId` ⇒ **只停该路**,其余路与 pending
|
||||
* 不受影响;`"current"` + 省略 ⇒ 取消**全部在途路**、保留 pending(现状语义的自然推广);
|
||||
* 省略 / `""` / `"all"` ⇒ 忽略本字段(取消全部 + 清 pending)。
|
||||
*
|
||||
* `null` = **整键省略** ⇒ 与加入该字段之前**逐字节相同**。本端今天同样一个字节都不发它:
|
||||
* 唯一的调用点(`ChatViewModel` 的「她正忙时按发送」偏好)走的正是「取消全部在途路」那一档,
|
||||
* 不需要按路精确停(规范未决问题 12 的建议默认:默认目标 = 全部在途路)。
|
||||
*/
|
||||
val turnId: String? = null,
|
||||
override val reqId: String? = null,
|
||||
) : WsOutbound {
|
||||
override val type: String get() = "interrupt"
|
||||
|
||||
override fun data(): JsonElement = buildJsonObject {
|
||||
put("sessionId", sessionId)
|
||||
scope?.let { put("scope", it) }
|
||||
turnId?.let { put("turnId", it) }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* `voice_input`:`{audio?, format?, text?, sessionId?, deviceId?, clientMsgId?}`(契约 v0.96 §3.15)。
|
||||
*
|
||||
* 五个键里 [audio] / [format] 是我们**总要给**的(一段 WAV 的 base64 + `"wav"`,见
|
||||
* `domain.chat.VoiceClip`);[text] 只有本端**自己做过转写**时才给(给了服务端就跳过 STT),
|
||||
* 本端不做本地转写 ⇒ 恒省略。
|
||||
*
|
||||
* [clientMsgId] 与 `chat` 帧**同形同语义**:同一个非空值服务端只承认第一条,重复到达回
|
||||
* `chat_duplicate`、不再落库不再起回合。所以「这一段没发出去、用户点重发」必须**复用**
|
||||
* 同一个键(见 `domain.chat.voiceClientMsgIdFor`),换新键就是让服务端把同一句话再答一遍。
|
||||
*
|
||||
* [deviceId] 的取值与 `chat` 帧同一份(`ChatViewModel.DEVICE_ID`):服务端据此标记来源。
|
||||
*/
|
||||
data class VoiceInput(
|
||||
val audio: String? = null,
|
||||
val format: String? = null,
|
||||
val text: String? = null,
|
||||
val sessionId: String? = null,
|
||||
val deviceId: String? = null,
|
||||
val clientMsgId: String? = null,
|
||||
override val reqId: String? = null,
|
||||
) : WsOutbound {
|
||||
override val type: String get() = "voice_input"
|
||||
|
||||
override fun data(): JsonElement = buildJsonObject {
|
||||
audio?.let { put("audio", it) }
|
||||
format?.let { put("format", it) }
|
||||
text?.let { put("text", it) }
|
||||
sessionId?.let { put("sessionId", it) }
|
||||
deviceId?.let { put("deviceId", it) }
|
||||
clientMsgId?.let { put("clientMsgId", it) }
|
||||
}
|
||||
}
|
||||
|
||||
/** `mode_switch`:服务端原样广播回显,负载形状不限,这里发 `{mode}`。 */
|
||||
data class ModeSwitch(
|
||||
val mode: String,
|
||||
override val reqId: String? = null,
|
||||
) : WsOutbound {
|
||||
override val type: String get() = "mode_switch"
|
||||
|
||||
override fun data(): JsonElement = buildJsonObject { put("mode", mode) }
|
||||
}
|
||||
|
||||
/** `subagent_ask`:`{id, mode?, n?, query?}`。`mode` ∈ `""/full/tail/keyword`。 */
|
||||
data class SubagentAsk(
|
||||
val id: String,
|
||||
val mode: String? = null,
|
||||
val n: Int? = null,
|
||||
val query: String? = null,
|
||||
override val reqId: String? = null,
|
||||
) : WsOutbound {
|
||||
override val type: String get() = "subagent_ask"
|
||||
|
||||
override fun data(): JsonElement = buildJsonObject {
|
||||
put("id", id)
|
||||
mode?.let { put("mode", it) }
|
||||
n?.let { put("n", it) }
|
||||
query?.let { put("query", it) }
|
||||
}
|
||||
}
|
||||
|
||||
/** `task_output_query`:`{taskId}`。 */
|
||||
data class TaskOutputQuery(
|
||||
val taskId: String,
|
||||
override val reqId: String? = null,
|
||||
) : WsOutbound {
|
||||
override val type: String get() = "task_output_query"
|
||||
|
||||
override fun data(): JsonElement = buildJsonObject { put("taskId", taskId) }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package top.yeij.cyrene.data.ws
|
||||
|
||||
/**
|
||||
* 实时通道连接状态。
|
||||
*
|
||||
* 迁移:`Idle → Connecting → Connected → Reconnecting(退避) → Offline`。
|
||||
* `Offline` 只在**不可恢复**的情况下出现(握手 401 / 缺少 token 或服务器地址),
|
||||
* 需要上层重新登录或配置服务器后再调 [ChatSocket.connect]。
|
||||
*/
|
||||
enum class WsState {
|
||||
/** 未启动:初始态,或主动 [ChatSocket.close] 之后。 */
|
||||
Idle,
|
||||
|
||||
/** 首次握手进行中。 */
|
||||
Connecting,
|
||||
|
||||
/** 已连接,可收发帧。 */
|
||||
Connected,
|
||||
|
||||
/** 连接断开,正在按退避策略等待重连。 */
|
||||
Reconnecting,
|
||||
|
||||
/**
|
||||
* 不可恢复(握手 401 / 缺少 token 或地址),不会自动重连。
|
||||
*
|
||||
* ⚠️ **握手拿到「非 101 且非 401」的响应不属于这一档**:那种失败(例如中间设备/代理直接回
|
||||
* `503`、`502`,或握手被打断)在 [ChatSocket.isAuthFailure] 里判不出鉴权失败 ⇒ 走
|
||||
* [Reconnecting] 按退避**继续重连**。判据只认 `response.code == 401`(拿不到 response 时再退化
|
||||
* 成消息文本里含 `401`),所以「代理回 503」时 socket 的真值是 [Reconnecting],不是 [Offline]。
|
||||
* 这一条在发送阶梯上很要命:它决定了「WS 未就绪」时**要不要**去试一次 REST
|
||||
* ([top.yeij.cyrene.ui.chat.sendRouteFor] 只在真 [Offline] 上判进队列、不试;W41 起如此,
|
||||
* W45 起同一条判据仍然成立,只是那一步从「探一次健康端点」换成了「直接试 REST」)。
|
||||
*/
|
||||
Offline,
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
package top.yeij.cyrene.domain.admin
|
||||
|
||||
/**
|
||||
* 管理侧权限矩阵(api-client §2.0)的**客户端镜像**。
|
||||
*
|
||||
* 后端契约明确要求客户端自行降级(`Docs/api-client.md`:「客户端需按本矩阵自行降级」):
|
||||
* 不该读的页**不发请求**,而不是发出去等 403。这里的判断是纯函数,便于单测。
|
||||
*/
|
||||
|
||||
/**
|
||||
* 管理页可见性分档,按权限递增。
|
||||
*
|
||||
* - [Monitor]:api-client §2.0 的①档,`owner`/`admin`/`viewer` 均可读(只读非敏感投影)。
|
||||
* - [OwnerAdmin]:②档,仅 `owner`/`admin`(含他人数据 / 隐私正文),`viewer` 一律 403。
|
||||
*/
|
||||
enum class AdminTier(val rank: Int) {
|
||||
Monitor(0),
|
||||
OwnerAdmin(1),
|
||||
}
|
||||
|
||||
/** 已接线角色,`rank` 与后端 `owner>admin>viewer` 递增一致。 */
|
||||
enum class AdminRole(val rank: Int) {
|
||||
Viewer(0),
|
||||
Admin(1),
|
||||
Owner(2),
|
||||
;
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* 角色名解析。大小写 / 首尾空白不敏感;**未知或空值按 [Viewer] 处理**——
|
||||
* 与服务端一样 fail-closed:宁可少给可见面,也不去撞预期中的 403。
|
||||
*/
|
||||
fun of(raw: String?): AdminRole = when (raw?.trim()?.lowercase()) {
|
||||
"owner" -> Owner
|
||||
"admin" -> Admin
|
||||
else -> Viewer
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 管理页清单:只声明「有哪些页 + 需要哪一档权限」。
|
||||
* 入口页、ViewModel 与各页标题都按本清单驱动,新增管理模块只需在此追加一项。
|
||||
*/
|
||||
enum class AdminPage(
|
||||
val title: String,
|
||||
val summary: String,
|
||||
val required: AdminTier,
|
||||
) {
|
||||
Overview("概览", "请求指标与最近 trace", AdminTier.Monitor),
|
||||
Personas("人格", "角色目录与思考旋钮", AdminTier.Monitor),
|
||||
Memories("记忆", "记忆正文(scope / 内容)", AdminTier.OwnerAdmin),
|
||||
Backthink("后台思考", "后台思考与主动推送记录", AdminTier.OwnerAdmin),
|
||||
}
|
||||
|
||||
/** 当前角色能否使用该页;不能则 UI 禁用入口、ViewModel 直接给「无权限」。 */
|
||||
fun canAccess(role: AdminRole, page: AdminPage): Boolean = role.rank >= page.required.rank
|
||||
|
||||
/**
|
||||
* 能否接收可观测实时推送帧(`observability_metrics` / `observability_trace` /
|
||||
* `observability_log`,契约 §3.12)。
|
||||
*
|
||||
* 这三帧**不在**那 7 个「不含内容的状态机信号」白名单里,服务端按 fail-closed 只发
|
||||
* **owner + admin**。注意这与 [AdminPage.Overview] 的可见性**是两回事**:概览页本身是
|
||||
* [AdminTier.Monitor],`viewer` 也能进、也能读它背后的三个 REST 端点(v0.40 只读监控档),
|
||||
* 但它**一帧推送都收不到**(服务端不推,客户端也就不该开连接去等)。
|
||||
*
|
||||
* 客户端按同一档自行降级:不够档就**不开连接、不合并帧**,而不是等「怎么一直没帧」再猜。
|
||||
* 注意概览页背后的三个 REST 读取(metrics / trace / logs)都落在同一档只读监控里、`viewer` 全读得到:
|
||||
* 也就是 `viewer` 看得到**进页面那一刻的快照**、收不到任何增量。这条不对称对三块都成立,
|
||||
* 日志那块之所以另写成 [canReadObservabilityLogHistory],是因为它还要决定「区块渲不渲染」。
|
||||
*/
|
||||
fun canReceiveObservabilityFrames(role: AdminRole): Boolean =
|
||||
role.rank >= AdminTier.OwnerAdmin.rank
|
||||
|
||||
/**
|
||||
* 能否读取「最近日志」的**历史**(`GET /api/v1/admin/observability/logs`)。
|
||||
*
|
||||
* 该端点在 v0.40 的 12 条 `viewerAuth` 只读监控档里,与 [AdminPage.Overview] 同档 ⇒
|
||||
* `viewer` 也读得到。它与 [canReceiveObservabilityFrames] **刻意不对称**,这是契约的安排、
|
||||
* 不是缺口:
|
||||
*
|
||||
* - `viewer`:读得到进页面那一刻的历史,**收不到**之后的增量;
|
||||
* - `owner` / `admin`:历史与增量都有。
|
||||
*
|
||||
* ⇒ 「最近日志」区块的**内容**只由这次历史读取决定;收不到增量既不是错误也不是空态,
|
||||
* UI 不得据此给出任何失败 / 空提示,也不该为它开一条连接去干等(连接只在
|
||||
* [canReceiveObservabilityFrames] 够档时才建立)。写成纯函数是为了让这条口径只有一个落点,
|
||||
* 而不是散在各处 `if` 里靠人记。
|
||||
*/
|
||||
fun canReadObservabilityLogHistory(role: AdminRole): Boolean = canAccess(role, AdminPage.Overview)
|
||||
@@ -0,0 +1,26 @@
|
||||
package top.yeij.cyrene.domain.admin
|
||||
|
||||
import top.yeij.cyrene.core.error.CyreneError
|
||||
import top.yeij.cyrene.data.api.CyreneException
|
||||
|
||||
/**
|
||||
* 管理页取数失败。**「无权限」必须与其它失败分开**:UI 要能给出「无权限」状态,
|
||||
* 而不是把 403 当成空列表渲染成「暂无数据」。
|
||||
*/
|
||||
sealed interface AdminFailure {
|
||||
/** HTTP 403(角色不够)或 401(会话失效)。 */
|
||||
data object Forbidden : AdminFailure
|
||||
|
||||
data class Other(val message: String) : AdminFailure
|
||||
}
|
||||
|
||||
/** 把仓库层的 `Result` 失败收敛成 [AdminFailure];成功时返回 null。 */
|
||||
fun Throwable.toAdminFailure(): AdminFailure {
|
||||
val error = (this as? CyreneException)?.error
|
||||
return when {
|
||||
error is CyreneError.Unauthorized -> AdminFailure.Forbidden
|
||||
error is CyreneError.Server && error.httpStatus == 403 -> AdminFailure.Forbidden
|
||||
error != null -> AdminFailure.Other(error.userMessage)
|
||||
else -> AdminFailure.Other("操作失败,请稍后重试")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
package top.yeij.cyrene.domain.admin
|
||||
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import top.yeij.cyrene.domain.enums.EnumCatalog
|
||||
import top.yeij.cyrene.domain.parse.ContentPart
|
||||
import top.yeij.cyrene.domain.parse.parse
|
||||
import java.math.BigDecimal
|
||||
import java.math.RoundingMode
|
||||
import java.time.Instant
|
||||
import java.time.OffsetDateTime
|
||||
import java.time.ZoneId
|
||||
import java.time.format.DateTimeFormatter
|
||||
import java.util.Locale
|
||||
|
||||
private val LocalTime = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")
|
||||
|
||||
/**
|
||||
* RFC3339(trace 的 `startedAt` / `endedAt`、trace event 的 `time`)→ 本地时间文本。
|
||||
* 秒精度串与纳秒精度串都要吃得下;解析不了就**原样返回**,绝不静默清空。
|
||||
*/
|
||||
fun formatRfc3339(raw: String?): String? {
|
||||
val text = raw?.takeIf { it.isNotBlank() } ?: return null
|
||||
return runCatching {
|
||||
OffsetDateTime.parse(text).atZoneSameInstant(ZoneId.systemDefault()).format(LocalTime)
|
||||
}.getOrDefault(text)
|
||||
}
|
||||
|
||||
/**
|
||||
* RFC3339 → epoch 秒,供按时间排序用(日志 `time` 只有秒精度,同秒是很常见的并列)。
|
||||
*
|
||||
* 解析不了返回 null——**绝不**在这里退回「当成 Unix 毫秒」:日志 / trace 的 `time` 是 RFC3339,
|
||||
* backthink 的 `at` 才是 Unix 毫秒([formatEpochMillis]),两种形状刻意不共用解析路径
|
||||
* (api-client §1)。调用方对 null(时间判不出来)需自行给一个保守的次序,但**不得丢弃**条目。
|
||||
*/
|
||||
fun rfc3339EpochSecond(raw: String?): Long? = raw
|
||||
?.takeIf { it.isNotBlank() }
|
||||
?.let { runCatching { OffsetDateTime.parse(it).toEpochSecond() }.getOrNull() }
|
||||
|
||||
/**
|
||||
* Unix 毫秒(backthink 的 `at`)→ 本地时间文本。
|
||||
* 与 [formatRfc3339] 是**两套输入**,刻意不共用解析路径(api-client §1 的两种时间形状)。
|
||||
*/
|
||||
fun formatEpochMillis(millis: Long): String =
|
||||
Instant.ofEpochMilli(millis).atZone(ZoneId.systemDefault()).format(LocalTime)
|
||||
|
||||
/** 纳秒时长 → 按量级挑单位。 */
|
||||
fun formatNanos(nanos: Long): String = when {
|
||||
nanos < 1_000 -> "$nanos ns"
|
||||
nanos < 1_000_000 -> String.format(Locale.ROOT, "%.1f µs", nanos / 1_000.0)
|
||||
nanos < 1_000_000_000 -> String.format(Locale.ROOT, "%.1f ms", nanos / 1_000_000.0)
|
||||
else -> String.format(Locale.ROOT, "%.2f s", nanos / 1_000_000_000.0)
|
||||
}
|
||||
|
||||
/**
|
||||
* 计数表(`perMethod` / `perPath` / `statusCounts`)→ 一行文本,按次数倒序。
|
||||
* 键名一律原样保留(开放集合,不做白名单过滤,也不截断条目)。
|
||||
*/
|
||||
fun formatCounts(counts: Map<String, Long>): String =
|
||||
counts.entries
|
||||
.sortedWith(compareByDescending<Map.Entry<String, Long>> { it.value }.thenBy { it.key })
|
||||
.joinToString(" · ") { (key, value) -> "$key $value" }
|
||||
|
||||
/** 后端用 float64 表达计数 / 毫秒,整数时抹掉无意义的小数位。 */
|
||||
fun formatDouble(value: Double, decimals: Int = 2): String =
|
||||
BigDecimal(value).setScale(decimals, RoundingMode.HALF_UP).stripTrailingZeros().toPlainString()
|
||||
|
||||
/** 任意 `props` 对象 → 紧凑 JSON 文本,原样展示(不裁剪、不丢键)。 */
|
||||
fun formatProps(props: Map<String, JsonElement>?): String? =
|
||||
props?.takeIf { it.isNotEmpty() }?.let { JsonObject(it).toString() }
|
||||
|
||||
private val traceKindLabels = mapOf(
|
||||
"llm_call" to "LLM 调用",
|
||||
"tool_call" to "工具调用",
|
||||
"orchestration" to "编排",
|
||||
"state_transition" to "状态迁移",
|
||||
"subagent" to "子代理",
|
||||
"memory" to "记忆",
|
||||
"guardrail" to "护栏",
|
||||
)
|
||||
|
||||
private val traceStatusLabels = mapOf(
|
||||
"ok" to "成功",
|
||||
"error" to "失败",
|
||||
)
|
||||
|
||||
private val memoryScopeLabels = mapOf(
|
||||
"user" to "用户",
|
||||
"persona" to "人格",
|
||||
"project" to "项目",
|
||||
)
|
||||
|
||||
/**
|
||||
* 未知取值回落约定(api-client §1 红线):不在表中的取值**原样返回**——
|
||||
* 不丢弃、不报错、不替换占位符。服务端新增取值属非破坏性变更。
|
||||
*/
|
||||
fun traceKindLabel(raw: String): String = traceKindLabels[raw] ?: raw
|
||||
|
||||
fun traceStatusLabel(raw: String): String = traceStatusLabels[raw] ?: raw
|
||||
|
||||
/**
|
||||
* `mode` / `mood` 的标签来自 `/meta/enums` 的权威表([catalog]),客户端不再硬编码——
|
||||
* 表里没有的取值同样原样返回([EnumCatalog] 负责该回落)。
|
||||
* `trace` / `memory` 那几类**不在**该端点的表内,各自保留本地映射(见上)。
|
||||
*/
|
||||
fun personaModeLabel(raw: String, catalog: EnumCatalog): String = catalog.modeLabel(raw)
|
||||
|
||||
fun memoryScopeLabel(raw: String): String = memoryScopeLabels[raw] ?: raw
|
||||
|
||||
fun moodLabel(raw: String, catalog: EnumCatalog): String = catalog.moodLabel(raw)
|
||||
|
||||
/** 三态展示:null(未配置)与 true/false 是**三种不同**状态,不能合并。 */
|
||||
fun triStateLabel(value: Boolean?): String = when (value) {
|
||||
null -> "未配置"
|
||||
true -> "显示"
|
||||
false -> "不显示"
|
||||
}
|
||||
|
||||
/** 空白字段的展示兜底:只处理「没有值」,不参与未知取值的回落。 */
|
||||
fun orDash(raw: String?): String = raw?.takeIf { it.isNotBlank() } ?: "-"
|
||||
|
||||
/**
|
||||
* 后台思考 `conclusion` 的一段:段内按渲染顺序排列的片段(正文 / 动作 / 旁白)。
|
||||
* 段与段之间的间距由 UI 决定;段内片段顺序即原文顺序。
|
||||
*/
|
||||
data class ConclusionParagraph(val parts: List<ContentPart>)
|
||||
|
||||
/**
|
||||
* `conclusion` → 有序渲染模型,供 UI 逐段渲染。
|
||||
*
|
||||
* 该字段承载的是**与聊天消息同一套 Web 端 markup**(真实样本里有连写的空 `<p></p>`、
|
||||
* `<action>` 等),所以这里**不新增任何 markup 规则**,一律委托 [parse]:
|
||||
* 一个 `<p>` 块 = 一段,旁白/动作块依聊天页的归属规则挂在该段内,畸形标签由 [parse] 兜底
|
||||
* (落单的段落标签剥掉,其余按普通文本回落)。
|
||||
* 空段(`<p></p>` 连写产生的空白)不产出模型项——否则界面上会出现成片空行。
|
||||
* 段内片段顺序取**渲染顺序**:`head` → 正文 → `tail`(与聊天页气泡上/下方旁白的排布一致)。
|
||||
* `null` / 纯空白输入返回空列表。
|
||||
*/
|
||||
fun conclusionParagraphs(raw: String?): List<ConclusionParagraph> {
|
||||
val text = raw?.takeIf { it.isNotBlank() } ?: return emptyList()
|
||||
return parse(text).mapNotNull { bubble ->
|
||||
val parts = buildList {
|
||||
addAll(bubble.head)
|
||||
if (bubble.body.isNotEmpty()) add(ContentPart.Text(bubble.body))
|
||||
addAll(bubble.tail)
|
||||
}
|
||||
parts.takeIf { list -> list.any { it.text.isNotBlank() } }?.let(::ConclusionParagraph)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
package top.yeij.cyrene.domain.admin
|
||||
|
||||
/**
|
||||
* `perPath` 的一项:路径原文 + 命中次数 + 展示文本。
|
||||
*
|
||||
* [path] **不做任何改写**(便于与网关日志 / 后端逐字对齐、复制粘贴),
|
||||
* 手机上要读的那一份在 [display](见 [PathStats.displayPath])。
|
||||
*/
|
||||
data class PathHit(
|
||||
val path: String,
|
||||
val count: Long,
|
||||
val display: String,
|
||||
)
|
||||
|
||||
/**
|
||||
* 同一个前缀下的一组路径,组内已按命中次数倒序(同数按路径升序)。
|
||||
* [hits] / [pathCount] 是组头要显示的两个数:**条数与次数不是一回事**
|
||||
* (真实响应里 `/admin` 一组 = 15 条路径、283 次命中)。
|
||||
*/
|
||||
data class PathGroup(
|
||||
val prefix: String,
|
||||
val paths: List<PathHit>,
|
||||
) {
|
||||
val hits: Long get() = paths.sumOf { it.count }
|
||||
|
||||
val pathCount: Int get() = paths.size
|
||||
|
||||
/**
|
||||
* 默认展开还是收起:条数多的大组默认**收起**,首屏因此只留一行组头,
|
||||
* 展开是用户的一次明确动作(见 [PathStats.COLLAPSE_THRESHOLD])。
|
||||
*/
|
||||
val expandedByDefault: Boolean get() = pathCount <= PathStats.COLLAPSE_THRESHOLD
|
||||
}
|
||||
|
||||
/**
|
||||
* 分组列表里的一行。[Header] 可点、可折叠;[Leaf] 是一条路径。
|
||||
* 只有一条路径的组**不产生组头**(组头与内容完全重复),直接给一条 [Leaf]。
|
||||
*/
|
||||
sealed interface PathRow {
|
||||
data class Header(val group: PathGroup) : PathRow
|
||||
|
||||
/** [nested] = 该行在一个已展开的组里,UI 据此缩进;组外散条不缩进。 */
|
||||
data class Leaf(val hit: PathHit, val nested: Boolean) : PathRow
|
||||
}
|
||||
|
||||
/**
|
||||
* 「按路径」统计的分组与展示模型(纯函数,不含任何 Compose / Android 依赖)。
|
||||
*
|
||||
* ## 为什么要客户端分组
|
||||
* 契约(api-client §2「可观测性契约」)里 `perPath` 是**扁平**的 `路径 → 次数` 映射——
|
||||
* 真实响应 26 条,后端不提供层级信息。之前它被塞进一行右对齐文本([formatCounts]),
|
||||
* 手机上 26 条挤成一团、不可读,所以这里定义一套**可解释的分组规则**:
|
||||
*
|
||||
* **分组键 = 剥掉 [API_BASE_PREFIX] 后的首段**(`/api/v1/admin/llm/catalog` → `/admin`,
|
||||
* `/assets/index-x.js` → `/assets`,`/sw.js` → `/sw.js`,`/` → `/`)。
|
||||
*
|
||||
* - **没有任何路径清单**:规则是纯字符串推导,服务端新增路由、新增路径形状都不需要改客户端
|
||||
* (未知前缀自动成组,见单测里的 `/api/v1/quantum/entangle`)。
|
||||
* - 剥掉 `/api/v1` 是因为它对 21/26 条路径完全相同,占满宽度却零区分度;
|
||||
* 剥离**只影响分组键**,行里显示的仍是完整路径。
|
||||
* - 比「按前两段分组」好:那样 `/admin/automations`、`/admin/personas` … 会切出十几个
|
||||
* 只有一条路径的组,组头与内容重复,比不分组还难读。
|
||||
* - 深层路径(段数 > [MAX_DISPLAY_SEGMENTS])在展示时**折叠中间段、保留尾部**:
|
||||
* 尾部才是把同类路径区分开的部分(`…/admin/observability/metrics`),而整串在手机上
|
||||
* 会被省略号吃掉尾部、丢掉区分度。
|
||||
*
|
||||
* 排序:组按 [PathGroup.hits] 倒序(热路径在前),组内按次数倒序——两处同数都按路径升序,
|
||||
* 保证刷新前后顺序稳定(指标页每次刷新都重排会让读者跟丢)。
|
||||
*/
|
||||
object PathStats {
|
||||
|
||||
/** 所有 REST 端点共有的基址段,只在**分组键**里剥掉。 */
|
||||
const val API_BASE_PREFIX = "/api/v1"
|
||||
|
||||
/** 组内路径条数超过它就默认收起。4 条以内的小组直接展开,省一次点击。 */
|
||||
const val COLLAPSE_THRESHOLD = 4
|
||||
|
||||
/** 段数超过它的路径在展示时折叠中间段。 */
|
||||
const val MAX_DISPLAY_SEGMENTS = 4
|
||||
|
||||
/** 折叠后保留的尾部段数。 */
|
||||
const val RETAINED_TAIL_SEGMENTS = 3
|
||||
|
||||
/** 根路径(`/`)与异常空串的组名。 */
|
||||
private const val ROOT_PREFIX = "/"
|
||||
|
||||
private val API_BASE_SEGMENTS = segments(API_BASE_PREFIX)
|
||||
|
||||
/**
|
||||
* `perPath` → 分组列表:组按命中次数倒序,组内路径同样倒序。
|
||||
* 空输入给空列表(UI 据此渲染「暂无路径统计」,**它是成功后的空**,与失败态无关)。
|
||||
*/
|
||||
fun groupPaths(perPath: Map<String, Long>): List<PathGroup> =
|
||||
perPath.entries
|
||||
.groupBy(
|
||||
keySelector = { groupPrefix(it.key) },
|
||||
valueTransform = { PathHit(path = it.key, count = it.value, display = displayPath(it.key)) },
|
||||
)
|
||||
.map { (prefix, paths) ->
|
||||
PathGroup(
|
||||
prefix = prefix,
|
||||
paths = paths.sortedWith(compareByDescending<PathHit> { it.count }.thenBy { it.path }),
|
||||
)
|
||||
}
|
||||
.sortedWith(compareByDescending<PathGroup> { it.hits }.thenBy { it.prefix })
|
||||
|
||||
/**
|
||||
* 展开状态:**只记用户动过的组**([toggled] = 组前缀 → 是否展开),
|
||||
* 其余按 [PathGroup.expandedByDefault]。这样刷新拿到新数据后,默认收起的大组不会被
|
||||
* 上一轮的点按状态锁死,新出现的分组也自然落到默认档。
|
||||
*/
|
||||
fun expanded(group: PathGroup, toggled: Map<String, Boolean> = emptyMap()): Boolean =
|
||||
toggled[group.prefix] ?: group.expandedByDefault
|
||||
|
||||
/** 分组列表 + 展开状态 → 一维行序列,UI 直接顺序渲染(组头之后紧跟它的子行)。 */
|
||||
fun rows(groups: List<PathGroup>, toggled: Map<String, Boolean> = emptyMap()): List<PathRow> =
|
||||
groups.flatMap { group ->
|
||||
// 两个分支都写明 [PathRow]:否则 `flatMap` 会拿第一个分支的元素类型
|
||||
// (`PathRow.Leaf`)去约束第二个分支,组头就加不进去了。
|
||||
if (group.pathCount == 1) {
|
||||
listOf<PathRow>(PathRow.Leaf(group.paths.single(), nested = false))
|
||||
} else {
|
||||
buildList<PathRow> {
|
||||
add(PathRow.Header(group))
|
||||
if (expanded(group, toggled)) {
|
||||
group.paths.forEach { add(PathRow.Leaf(it, nested = true)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 展示文本:段数不超过 [MAX_DISPLAY_SEGMENTS] 时**原样返回**(不做任何归一化,
|
||||
* 尾斜杠之类都保留);超过则折叠中间段、保留尾部 [RETAINED_TAIL_SEGMENTS] 段,
|
||||
* 前缀 `…/` 明确表示左边还有内容被省掉。
|
||||
*/
|
||||
fun displayPath(path: String): String {
|
||||
val parts = segments(path)
|
||||
if (parts.size <= MAX_DISPLAY_SEGMENTS) return path
|
||||
return "…/" + parts.takeLast(RETAINED_TAIL_SEGMENTS).joinToString("/")
|
||||
}
|
||||
|
||||
/** 分组键:剥掉 [API_BASE_PREFIX] 后的首段,逐段比较(`/api/v1x/a` 不算基址下的路径)。 */
|
||||
private fun groupPrefix(path: String): String {
|
||||
val parts = segments(path)
|
||||
if (parts.isEmpty()) return ROOT_PREFIX
|
||||
val rest = if (parts.take(API_BASE_SEGMENTS.size) == API_BASE_SEGMENTS) {
|
||||
parts.drop(API_BASE_SEGMENTS.size)
|
||||
} else {
|
||||
parts
|
||||
}
|
||||
return rest.firstOrNull()?.let { "/$it" } ?: API_BASE_PREFIX
|
||||
}
|
||||
|
||||
private fun segments(path: String): List<String> = path.split('/').filter { it.isNotEmpty() }
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
package top.yeij.cyrene.domain.chat
|
||||
|
||||
/** 等比缩放后的目标尺寸(px)。 */
|
||||
data class ScaledSize(val width: Int, val height: Int)
|
||||
|
||||
/**
|
||||
* 发送前图片的**体积预算**与尺寸规划(纯函数,编码器与测试共用;不含任何 Android 依赖)。
|
||||
*
|
||||
* ## 为什么必须缩放 / 重编码
|
||||
* 服务端把 `image` 字符串**原样落库**(api-client §1 行 3 + 协同文档 G3),于是
|
||||
* `GET /sessions/{id}/messages` 的**每一页**、WS 握手 `history` 的**每一次回放**
|
||||
* 都要把整段 base64 再传一遍。所以「发一张 8 MB 的原图」= 以后每次翻历史都重传 11 MB。
|
||||
* 客户端预算是这个成本的唯一闸门,**不是优化**。
|
||||
*
|
||||
* 两层上限:
|
||||
* - [MAX_DECODED_BYTES] = 1 MiB:**本端的**目标(缩放 + 重编码后必须 ≤ 它)——
|
||||
* 服务端 5 MiB 只管「收不收」,管不了「历史有多重」;
|
||||
* - [SERVER_MAX_DECODED_BYTES] = 5 MiB:服务端硬顶(解码后),超限 `400 image_too_large`。
|
||||
* 它只作为**最后一道兜底检查**:真要走上传(`POST /api/v1/upload`)时再放宽。
|
||||
*
|
||||
* 校验按**字符串长度**([SERVER_MAX_CHARS])而不是解码字节数——与服务端入口层同一口径
|
||||
* (`chatImageCharsFor`),这样两端不可能漂移。
|
||||
*/
|
||||
object ChatImageBudget {
|
||||
|
||||
/** 服务端 `image` 解码后上限:5 MiB(与 `POST /api/v1/upload` 同口径)。 */
|
||||
const val SERVER_MAX_DECODED_BYTES = 5 * 1024 * 1024
|
||||
|
||||
/** `data:` URI 前缀(`data:image/jpeg;base64,`)的字符余量,与服务端同一取值。 */
|
||||
const val PREFIX_ALLOWANCE_CHARS = 64
|
||||
|
||||
/** [byteCount] 字节 base64 编码后的字符数(每 3 字节 → 4 字符,末组补满)。 */
|
||||
fun base64Chars(byteCount: Int): Int = ((byteCount + 2) / 3) * 4
|
||||
|
||||
/**
|
||||
* 服务端 `image` 字符串长度上限(= [base64Chars] 的 4/3 膨胀 + 前缀余量)。
|
||||
*
|
||||
* 刻意复用 [base64Chars] 而不是把公式再内联写一遍:公式只留一处。代价是它只能是 `val`
|
||||
* ——`const val` 不允许调用函数;取值在对象初始化时算出,与内联写法一致。
|
||||
*/
|
||||
val SERVER_MAX_CHARS: Int = base64Chars(SERVER_MAX_DECODED_BYTES) + PREFIX_ALLOWANCE_CHARS
|
||||
|
||||
/** 本端目标:重编码后的**字节**上限,远低于服务端硬顶(见类注释)。 */
|
||||
const val MAX_DECODED_BYTES = 1 * 1024 * 1024
|
||||
|
||||
/**
|
||||
* 最长边的目标像素。1280 与 provider 自己入模型前的缩放口径(约 1300×1300)同量级,
|
||||
* 所以再放大不会有任何可见收益——省的是库与历史的字节,不是 token。
|
||||
*/
|
||||
const val MAX_DIMENSION_PX = 1280
|
||||
|
||||
/** 字符串长度是否在服务端上限内(入口层同一判据:`<=` 才算通过)。 */
|
||||
fun withinServerLimit(dataUriChars: Int): Boolean = dataUriChars <= SERVER_MAX_CHARS
|
||||
|
||||
/** 编码后字节数是否已落到本端目标内。 */
|
||||
fun withinTarget(byteCount: Int): Boolean = byteCount <= MAX_DECODED_BYTES
|
||||
|
||||
/**
|
||||
* 等比缩放到最长边不超过 [maxDimension];**只缩不放**(小图保持原尺寸),
|
||||
* 边长至少 1 px(极端细长图缩放后可能算成 0)。
|
||||
*/
|
||||
fun scaledSize(width: Int, height: Int, maxDimension: Int = MAX_DIMENSION_PX): ScaledSize {
|
||||
val w = width.coerceAtLeast(1)
|
||||
val h = height.coerceAtLeast(1)
|
||||
val longest = maxOf(w, h)
|
||||
if (longest <= maxDimension) return ScaledSize(w, h)
|
||||
val ratio = maxDimension.toDouble() / longest
|
||||
return ScaledSize(
|
||||
width = (w * ratio).toInt().coerceAtLeast(1),
|
||||
height = (h * ratio).toInt().coerceAtLeast(1),
|
||||
)
|
||||
}
|
||||
|
||||
/** 拼 `data:` URI([mime] 形如 `image/jpeg`,[base64] 为单行 base64)。 */
|
||||
fun dataUri(mime: String, base64: String): String = "data:$mime;base64,$base64"
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package top.yeij.cyrene.domain.chat
|
||||
|
||||
/** 一张**已重编码**的图片该怎么送上去。 */
|
||||
enum class ImageDelivery {
|
||||
/** 直接把 `data:` URI 塞进 `chat.image`:一次请求,但字节永久留在历史里。 */
|
||||
Inline,
|
||||
|
||||
/** 先 `POST /api/v1/upload` 换 URL,再把 URL 当 `chat.image`:多一次往返,历史里只留几十字节。 */
|
||||
Upload,
|
||||
}
|
||||
|
||||
/**
|
||||
* 内联(base64)与上传(URL)的分界(纯函数,VM 与测试共用)。
|
||||
*
|
||||
* ## 这是一笔什么账
|
||||
* 服务端把 `chat.image` 字符串**原样落库**,所以两边的成本不是「发一次」而是「发多少次」:
|
||||
*
|
||||
* | | 发出时 | 之后每一次历史取数 |
|
||||
* |---|---|---|
|
||||
* | 内联 | 请求体里带 base64(4/3 × B 字符) | **再带一遍**(REST 每页、WS `history` 每次回放、每次上拉) |
|
||||
* | 上传 | 多**一次** HTTP 往返;字节同样只传一遍 | 只剩那个 URL(几十字节) |
|
||||
*
|
||||
* 也就是说:上传不是「省一次流量」,是**用一次往返买断此后所有的历史重量**。
|
||||
*
|
||||
* ## 阈值:64 KiB([INLINE_MAX_BYTES],含等于)
|
||||
* 重编码后的字节数 `B`,内联的代价是每次历史取数再多 `4/3 × B`:
|
||||
* - `B = 64 KiB` → 每次取数 +85 KB。一个会话里 20 张这种图,每次冷启动 / 重连回放就要多传
|
||||
* ≥1.7 MB(1 Mbps 下约 14 秒纯图片流量),而那次上传往返在局域网是毫秒、在移动网也就是
|
||||
* 一两百毫秒——**从第二次取数起就稳赚**。
|
||||
* - `B ≈ 32 KiB`(缩略图、小截图)→ 每次取数 +43 KB,省下的量与一次往返本身的量级相当,
|
||||
* 而多出来的一次请求**必然**多一个失败面(上传失败就得让用户重发,内联不会)。
|
||||
* 这种量级下「一次请求发完」更划算,所以留在内联。
|
||||
*
|
||||
* 阈值取整到 64 KiB 而不是更细的数,是因为它本来就是个成本量级的判断,不是服务端限制:
|
||||
* 服务端的两个 5 MiB 上限(`chat.image` 与 `/upload`)离这里都还差两个数量级,
|
||||
* **本决策与「装不装得下」无关**(重编码出口已保证 ≤ [ChatImageBudget.MAX_DECODED_BYTES])。
|
||||
*/
|
||||
object ChatImageDelivery {
|
||||
|
||||
/**
|
||||
* 内联与上传的分界:**重编码后**的字节数(含等于 → 内联)。
|
||||
*
|
||||
* 必须严格小于 [ChatImageBudget.MAX_DECODED_BYTES],否则上传分支永远走不到(死代码);
|
||||
* 这一条由 `ChatImageDeliveryTest` 钉住。
|
||||
*/
|
||||
const val INLINE_MAX_BYTES = 64 * 1024
|
||||
|
||||
/** @param preparedBytes [top.yeij.cyrene.data.image.ChatImagePreparer] 产出的字节数。 */
|
||||
fun forPreparedBytes(preparedBytes: Int): ImageDelivery =
|
||||
if (preparedBytes <= INLINE_MAX_BYTES) ImageDelivery.Inline else ImageDelivery.Upload
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package top.yeij.cyrene.domain.chat
|
||||
|
||||
/** 一条消息该走哪条上行通道。 */
|
||||
enum class SendChannel {
|
||||
/** 短文本走 WS `chat` 帧:直达、绕过有损总线,且今天就能用。 */
|
||||
Ws,
|
||||
|
||||
/** 长文本 / 任何图片走 REST `POST /api/v1/chat`(`stream:true`)。 */
|
||||
Rest,
|
||||
}
|
||||
|
||||
/**
|
||||
* 上行路由规则(纯函数,VM 与测试共用)。
|
||||
*
|
||||
* ## 为什么必须有这条规则
|
||||
* WS 帧的读上限是**服务端** `wsReadLimit = 4096` 字节/帧,且**超限由传输层直接断连**——
|
||||
* 不是回一个错误,而是把这条连接踢掉(api-client §1 行 3 G3 的诚实边界)。所以
|
||||
* 「长文发不出去」在今天不是「服务端拒绝」,是**发送帧把连接打断**。REST 没有这个上限,
|
||||
* 且 `stream:true` 的回合同样在**已连接的 WS** 上流式回来 ⇒ 「REST 发、WS 收」不牺牲流式体验。
|
||||
*
|
||||
* 图片一律走 REST:真实图片的 base64 远超 4096 字节,在 WS 上**必然**断连。
|
||||
* 文档附件同理一律走 REST(上传换引用这件事整个压在同一条通道上,见 [DocumentDelivery])。
|
||||
*
|
||||
* ## 阈值
|
||||
* 判据是**文本的 UTF-8 字节数**(不是字符数——中文一个字 3 字节,按字符数算会在
|
||||
* 333 个汉字时就撞上 4096)。上限取 [WS_TEXT_LIMIT_BYTES] = 4096 − 512 字节预留:
|
||||
* 预留覆盖 `{"type","data":{"sessionId","text","deviceId","clientMsgId","image"}}` 的
|
||||
* JSON 外壳(键名 + 值,UTF-8 最坏情况约 150 字节),并留出余量。留得保守是因为
|
||||
* **超限的代价是断连**,而多走一次 REST 的代价只是几十毫秒——风险不对称,宁可早退。
|
||||
*
|
||||
* 边界:`utf8Length(text) <= WS_TEXT_LIMIT_BYTES` → WS;多 1 字节即 REST。
|
||||
*/
|
||||
object ChatSendRouting {
|
||||
|
||||
/** 服务端 WS 读上限(`wsReadLimit`,契约 v0.56 明确记载)。 */
|
||||
const val WS_FRAME_LIMIT_BYTES = 4096
|
||||
|
||||
/** WS `chat` 帧 JSON 外壳的预留:键名 + `sessionId` / `deviceId` / `clientMsgId` 等值的字节数。 */
|
||||
const val WS_ENVELOPE_RESERVE_BYTES = 512
|
||||
|
||||
/** 允许走 WS 的**文本**字节上限(不含图片:有图片一律走 REST)。 */
|
||||
const val WS_TEXT_LIMIT_BYTES = WS_FRAME_LIMIT_BYTES - WS_ENVELOPE_RESERVE_BYTES
|
||||
|
||||
/** 文本的 UTF-8 字节数;`text` 为空返回 0。 */
|
||||
fun utf8Length(text: String): Int = text.toByteArray(Charsets.UTF_8).size
|
||||
|
||||
/**
|
||||
* 这一条该走哪条通道。
|
||||
* @param hasImage 本次是否带图片(带图**永远**走 REST)
|
||||
* @param hasDocuments 本次是否带**文档附件**(带附件**永远**走 REST):文档本体要先经
|
||||
* `POST /api/v1/documents` 上传,引用只随 `POST /api/v1/chat` 发出——
|
||||
* 「文档一律走 REST」见 [DocumentDelivery] 第 2 条。默认 false ⇒ 既有调用与旧行为逐字不变。
|
||||
*/
|
||||
fun channelFor(
|
||||
text: String,
|
||||
hasImage: Boolean,
|
||||
hasDocuments: Boolean = false,
|
||||
): SendChannel = when {
|
||||
hasImage -> SendChannel.Rest
|
||||
hasDocuments -> SendChannel.Rest
|
||||
utf8Length(text) <= WS_TEXT_LIMIT_BYTES -> SendChannel.Ws
|
||||
else -> SendChannel.Rest
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
package top.yeij.cyrene.domain.chat
|
||||
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.io.InputStream
|
||||
|
||||
/**
|
||||
* 一份**已选好、还没发出去**的待发附件(与 `ChatUiState.draftImages` 平行的概念)。
|
||||
*
|
||||
* 与 [DocumentFile] 的分工:[DocumentFile] 是「发送那一刻的字节」——上传需要它;
|
||||
* 本类型是「还没发送的引用」——输入栏上方的芯片要显示它,而**绝不能**把最多 5×20 MiB
|
||||
* 的字节一直摆在 UI 状态里(`ByteArray` 一进 `StateFlow` 就等于每次重组都摸一遍它)。
|
||||
* 字节只在真的要上传时按 [uri] 现读(见 `ChatViewModel` 的发送路径)。
|
||||
*
|
||||
* 不用 `displayName` 去拼本地路径、也不改它的大小写:它**只用来给上传部件命名**
|
||||
* (服务端要的是扩展名,其余部分会被内容寻址丢掉),以及给用户看。
|
||||
*/
|
||||
data class DraftDocument(
|
||||
/** 本地可读 URI(SAF 的 `content://` 或分享落地后的 `file://`)。 */
|
||||
val uri: String,
|
||||
/** 给用户看的名字,含扩展名;空 = 让仓库层兜 `document.txt`。 */
|
||||
val displayName: String,
|
||||
/** 元数据里报的字节数;[UNKNOWN_SIZE] = 拿不到(此时不做本地体积预检,交给服务端裁决)。 */
|
||||
val sizeBytes: Long = UNKNOWN_SIZE,
|
||||
) {
|
||||
companion object {
|
||||
/** `ContentResolver` 拿不到 `SIZE` 时的取值:**不猜**,也不因此拒绝这个文件。 */
|
||||
const val UNKNOWN_SIZE: Long = -1L
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 一次「选文件」的结果:接受了哪些、又有哪些**没能接受**。
|
||||
*
|
||||
* 三个计数分开记,因为它们该说的话不一样,而且**一个都不许静默消失**——
|
||||
* 「用户以为附件都挂上了、其实少了两个」正是本模块要消灭的东西。
|
||||
*/
|
||||
data class AttachmentPickResult(
|
||||
val accepted: List<DraftDocument> = emptyList(),
|
||||
/** 超过 [DocumentAttachments.MAX_FILE_BYTES] 而未被接受的显示名。 */
|
||||
val tooLarge: List<String> = emptyList(),
|
||||
/** 本回合附件已满([DocumentAttachments.MAX_PER_TURN])而未被接受的份数。 */
|
||||
val overLimit: Int = 0,
|
||||
/** 与已选附件重复、因此没有重复加入的份数。 */
|
||||
val duplicate: Int = 0,
|
||||
) {
|
||||
/** 如实告知的那句话;没有任何东西被挡下时回 `null`(正常选中一个字都不说)。 */
|
||||
val notice: String?
|
||||
get() {
|
||||
val parts = buildList {
|
||||
if (tooLarge.isNotEmpty()) {
|
||||
add(
|
||||
"有 ${tooLarge.size} 个文件超过 " +
|
||||
"${DocumentAttachments.maxFileSizeLabel}," +
|
||||
"没有加入(${tooLarge.joinToString("、")})",
|
||||
)
|
||||
}
|
||||
if (overLimit > 0) {
|
||||
add("一个回合最多 ${DocumentAttachments.MAX_PER_TURN} 个附件,另有 $overLimit 个没有加入")
|
||||
}
|
||||
if (duplicate > 0) add("有 $duplicate 个和已选附件重复,没有重复加入")
|
||||
}
|
||||
return parts.takeIf { it.isNotEmpty() }?.joinToString(";")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 待发附件的准入规则(纯函数,VM 与测试共用)。
|
||||
*
|
||||
* ## 它为什么在客户端也要有一份
|
||||
* 服务端才是裁决者(上限是 `CHAT_DOCUMENT_MAX_BYTES`,可部署配置;类型裁决只有字节嗅探一条),
|
||||
* 所以这里的预检**只做一件服务端做不到的事**:**在把字节传上去之前**就说话。
|
||||
* 一份 30 MiB 的文件照传一遍再吃 `413`,用户等的是上传的那几分钟,而结论本端现在就能给。
|
||||
* 代价是这份上限可能与部署实际不同——因此一旦两边不一致,**服务端的话更权威**:
|
||||
* 本端放过去的,服务端照旧能用 `413` / `415` 拦下(那两条文案会原样显示给用户)。
|
||||
*
|
||||
* ## 为什么不裁剪
|
||||
* [admit] 只**不收**,从不**少收**:多出来的份数与超限的文件都进 [AttachmentPickResult],
|
||||
* 由调用方如实告知。服务端对 >5 个回 `400 too_many_documents`、对非法名字回 `404`,
|
||||
* 两边都不做静默裁剪,客户端也就不该在本地悄悄只发前 5 个。
|
||||
*/
|
||||
object DocumentAttachments {
|
||||
|
||||
/**
|
||||
* 单份附件的本端预检上限。
|
||||
*
|
||||
* 取服务端文档上限的**默认值**(`CHAT_DOCUMENT_MAX_BYTES`,契约 v0.59 §1.2 = 20 MiB)。
|
||||
* 刻意与图片上限(5 MiB,[top.yeij.cyrene.data.repository.UploadRepository] 那条)分开:
|
||||
* 服务端把两者解耦,客户端合成一个数就会先自己把文档卡在图片的量级上。
|
||||
*/
|
||||
const val MAX_FILE_BYTES: Long = 20L * 1024 * 1024
|
||||
|
||||
/** 上限的可读说法(给提示文案用,避免两处各写一个数字)。 */
|
||||
const val maxFileSizeLabel: String = "20 MB"
|
||||
|
||||
/** 一个回合的附件数上限,与判定矩阵同源([DocumentDelivery.MAX_ATTACHMENTS_PER_TURN])。 */
|
||||
const val MAX_PER_TURN: Int = DocumentDelivery.MAX_ATTACHMENTS_PER_TURN
|
||||
|
||||
/**
|
||||
* 把这次选中的文件并进已选列表。
|
||||
*
|
||||
* 顺序:**去重 → 体积 → 份数**。去重放在最前,是因为重复选同一份文件既不该被算成
|
||||
* 「超限」也不该被算成「超体积」——它压根不是一个新附件。
|
||||
*
|
||||
* @param existing 已选附件(输入栏上方那些芯片)。
|
||||
* @param picked 本次选中的文件(顺序即用户看到的顺序)。
|
||||
*/
|
||||
fun admit(existing: List<DraftDocument>, picked: List<DraftDocument>): AttachmentPickResult {
|
||||
val seen = existing.mapTo(mutableSetOf()) { it.uri }
|
||||
val tooLarge = mutableListOf<String>()
|
||||
val fresh = mutableListOf<DraftDocument>()
|
||||
var duplicate = 0
|
||||
|
||||
picked.forEach { document ->
|
||||
if (!seen.add(document.uri)) {
|
||||
duplicate++
|
||||
return@forEach
|
||||
}
|
||||
// 体积拿不到(-1)时不判:本端猜不准的东西,留给服务端的 413 说。
|
||||
if (document.sizeBytes > MAX_FILE_BYTES) {
|
||||
tooLarge += document.displayName.ifBlank { document.uri }
|
||||
return@forEach
|
||||
}
|
||||
fresh += document
|
||||
}
|
||||
|
||||
val room = (MAX_PER_TURN - existing.size).coerceAtLeast(0)
|
||||
val accepted = fresh.take(room)
|
||||
return AttachmentPickResult(
|
||||
accepted = accepted,
|
||||
tooLarge = tooLarge,
|
||||
overLimit = fresh.size - accepted.size,
|
||||
duplicate = duplicate,
|
||||
)
|
||||
}
|
||||
|
||||
/** 从已选列表里去掉第 [index] 个(越界即原样返回,UI 的移除按钮不必自己判界)。 */
|
||||
fun removeAt(existing: List<DraftDocument>, index: Int): List<DraftDocument> =
|
||||
if (index !in existing.indices) existing else existing.filterIndexed { i, _ -> i != index }
|
||||
|
||||
/**
|
||||
* URI 里能看出来的名字(`ContentResolver` 报不出 `DISPLAY_NAME` 时的兜底)。
|
||||
*
|
||||
* 取最后一段、去掉查询串;拿不到就回空串——**不猜**扩展名(扩展名真的参与服务端裁决,
|
||||
* 猜错等于替用户把一个合规文件变成 `415`)。
|
||||
*/
|
||||
fun fallbackDocumentName(uri: String): String =
|
||||
uri.substringBefore('?').substringAfterLast('/').takeIf { it.isNotBlank() }.orEmpty()
|
||||
}
|
||||
|
||||
/**
|
||||
* 读一份附件的字节时会遇到的三种结果。
|
||||
*
|
||||
* 「太大」与「读不到」必须分开:前者的正确动作是换文件,后者是把这份内容**再读一次**
|
||||
* (授权过期、文件被删、provider 抽风)——折成一句「附件失败」会让用户做错事。
|
||||
*/
|
||||
sealed interface DocumentRead {
|
||||
|
||||
data class Ok(val bytes: ByteArray) : DocumentRead
|
||||
|
||||
/** 实际字节超过 [DocumentAttachments.MAX_FILE_BYTES]。 */
|
||||
data object TooLarge : DocumentRead
|
||||
|
||||
/** 打不开 / 读不动(授权失效、文件已不在、provider 报错)。 */
|
||||
data object Unavailable : DocumentRead
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 [input] 读至多 [limit] 字节。
|
||||
*
|
||||
* **返回值语义**:`null` = 这份内容**超过了** [limit];IO 异常**原样抛出**(调用方要能把它
|
||||
* 与「超限」分开说)。这样定是因为调用方必须给出不同的提示,而「读到一半发现超了」不能
|
||||
* 靠 `available()` 猜——`available()` 在 `content://` 流上不可靠。
|
||||
*
|
||||
* 读满 [limit] 就停:一份被换成 500 MiB 的文件(选完之后、发送之前被替换)不会先在
|
||||
* 内存里炸掉进程再报错。
|
||||
*/
|
||||
fun readBounded(input: InputStream, limit: Long): ByteArray? {
|
||||
val buffer = ByteArrayOutputStream()
|
||||
val chunk = ByteArray(DEFAULT_BUFFER_SIZE)
|
||||
var total = 0L
|
||||
while (true) {
|
||||
val read = input.read(chunk)
|
||||
if (read < 0) break
|
||||
total += read
|
||||
if (total > limit) return null
|
||||
buffer.write(chunk, 0, read)
|
||||
}
|
||||
return buffer.toByteArray()
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
package top.yeij.cyrene.domain.chat
|
||||
|
||||
import top.yeij.cyrene.data.api.dto.DocumentRefDto
|
||||
import java.util.Locale
|
||||
|
||||
/**
|
||||
* 一枚附件芯片要显示什么(纯数据,UI 直接照着渲染)。
|
||||
*
|
||||
* 字段刻意是**已格式化好的字符串**而不是原始数值:气泡里没有第二处地方能解释这些数,
|
||||
* 而「12.0 KB」「8,000 字」这种换算一旦写进 Composable 就没法单测了。
|
||||
*/
|
||||
data class DocumentChipModel(
|
||||
/** 服务端存储名(`<32hex>.<ext>`)或待发附件的本地显示名。 */
|
||||
val name: String,
|
||||
/** 类型标签(扩展名大写);判不出类型时是「文件」。 */
|
||||
val typeLabel: String,
|
||||
/** 体积标签;读不出来时是空串。 */
|
||||
val sizeLabel: String,
|
||||
/** 本回合注入的字符数;服务端没报就是 `null`。 */
|
||||
val charsLabel: String?,
|
||||
/** 抽取状态;服务端没报就回 `null`,未知取值**原样显示**。 */
|
||||
val statusLabel: String?,
|
||||
/**
|
||||
* `truncated` 的标签:只表示**注入被预算截断**(正文照样进了上下文),
|
||||
* 与 [unreadable] 是两件事,文案也必须分开写。
|
||||
*/
|
||||
val truncatedLabel: String?,
|
||||
/** `status == "unreadable"`:这是唯一一个「昔涟读不到」的信号,UI 据此给灰态。 */
|
||||
val unreadable: Boolean,
|
||||
/**
|
||||
* 服务端「读不到」的**原话**(`documents[].reason`,契约 v0.63;只在 `unreadable` 时下发)。
|
||||
* 已去掉首尾空白,空白串与缺席一律归一成 `null`。
|
||||
*
|
||||
* ⚠️ **刻意不进 [detail]**:这句话有 40+ 字,塞进芯片那一行只会被 `Ellipsis` 切掉半句,
|
||||
* 比不显示更糟。它要出现的地方是**用户点开附件后的预览弹层**,见
|
||||
* [DocumentTarget.previewPlan]。
|
||||
*/
|
||||
val reason: String? = null,
|
||||
) {
|
||||
/** 芯片副行:`MD · 12 KB · 8,000 字 · 已读入`(缺哪项少哪项,全缺就是空串)。 */
|
||||
val detail: String
|
||||
get() = listOfNotNull(typeLabel, sizeLabel, charsLabel, statusLabel, truncatedLabel)
|
||||
.filter { it.isNotEmpty() }
|
||||
.joinToString(" · ")
|
||||
}
|
||||
|
||||
/** 附件芯片的展示换算(纯函数,与 UI 无关)。 */
|
||||
object DocumentChips {
|
||||
|
||||
/**
|
||||
* 一条**已落库**的文档引用 → 芯片模型。
|
||||
*
|
||||
* 名字只有服务端存储名可用:服务端按内容寻址落盘(`sha256[:32] + ext`),**原始文件名
|
||||
* 永不参与路径**,也就不会回到客户端。所以这里**不编**一个「用户当初叫它什么」的名字——
|
||||
* 那会是本端凭空捏造的信息(历史消息里根本没有它)。长名字由 UI 侧
|
||||
* `TextOverflow.MiddleEllipsis` 处理,保住扩展名。
|
||||
*/
|
||||
fun of(ref: DocumentRefDto): DocumentChipModel = DocumentChipModel(
|
||||
name = ref.name,
|
||||
typeLabel = typeLabel(ref.name),
|
||||
sizeLabel = sizeLabel(ref.size),
|
||||
charsLabel = charsLabel(ref.chars),
|
||||
statusLabel = statusLabel(ref.status),
|
||||
truncatedLabel = if (ref.truncated) TRUNCATED else null,
|
||||
unreadable = DocumentStatus.isUnreadable(ref.status),
|
||||
// 首尾空白去掉;只剩空白(或本就缺席)时归一成 null——「有一句空话」比没有更坏:
|
||||
// 弹层会拿它顶掉那句通用说明,于是用户看到一块什么都没有的正文区。
|
||||
reason = ref.reason?.trim()?.takeIf { it.isNotEmpty() },
|
||||
)
|
||||
|
||||
/** 一份**待发**附件 → 芯片模型:还没有服务端结论,所以状态与字数为空。 */
|
||||
fun of(draft: DraftDocument): DocumentChipModel = DocumentChipModel(
|
||||
name = draft.displayName.ifBlank { draft.uri },
|
||||
typeLabel = typeLabel(draft.displayName),
|
||||
sizeLabel = sizeLabel(draft.sizeBytes),
|
||||
charsLabel = null,
|
||||
statusLabel = null,
|
||||
truncatedLabel = null,
|
||||
unreadable = false,
|
||||
// 待发附件还没有任何服务端结论,也就没有「读不到的原因」可言。
|
||||
reason = null,
|
||||
)
|
||||
|
||||
/** 扩展名大写;没有扩展名或扩展名不像扩展名时回「文件」(不猜类型)。 */
|
||||
fun typeLabel(name: String): String {
|
||||
val extension = name.substringAfterLast('.', "")
|
||||
val usable = extension.length in 1..8 && extension.all { it.isLetterOrDigit() }
|
||||
return if (usable) extension.uppercase(Locale.ROOT) else NO_TYPE
|
||||
}
|
||||
|
||||
/** 体积;[DraftDocument.UNKNOWN_SIZE](拿不到元数据)回空串,**不显示一个假的 0 B**。 */
|
||||
fun sizeLabel(bytes: Long): String = when {
|
||||
bytes < 0 -> ""
|
||||
bytes < KIB -> "$bytes B"
|
||||
bytes < MIB -> oneDecimal(bytes / KIB.toDouble()) + " KB"
|
||||
else -> oneDecimal(bytes / MIB.toDouble()) + " MB"
|
||||
}
|
||||
|
||||
/** 注入字符数;`null`(服务端没报这一项)回 `null`。 */
|
||||
fun charsLabel(chars: Int?): String? =
|
||||
chars?.let { String.format(Locale.ROOT, "%,d", it) + " 字" }
|
||||
|
||||
/**
|
||||
* 抽取状态 → 文案。`""`(服务端未报)回 `null`,**未知取值原样显示**——
|
||||
* 与项目里 `mode` / `platform` / `mood` 的开放集合口径一致,不丢弃、不占位符。
|
||||
*
|
||||
* 三句分开写是刻意的:[LISTED] 只说明「正文没进上下文」,**不是**读不到;
|
||||
* [UNREADABLE] 才是读不到。
|
||||
*/
|
||||
fun statusLabel(status: String): String? = when (status) {
|
||||
"" -> null
|
||||
DocumentStatus.EXTRACTED -> "已读入"
|
||||
DocumentStatus.LISTED -> "只看得到清单"
|
||||
DocumentStatus.UNREADABLE -> "读不到"
|
||||
else -> status
|
||||
}
|
||||
|
||||
private fun oneDecimal(value: Double): String = String.format(Locale.ROOT, "%.1f", value)
|
||||
|
||||
private const val NO_TYPE = "文件"
|
||||
|
||||
private const val TRUNCATED = "注入已截断"
|
||||
|
||||
private const val KIB = 1024L
|
||||
|
||||
private const val MIB = 1024L * 1024L
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
package top.yeij.cyrene.domain.chat
|
||||
|
||||
import top.yeij.cyrene.data.api.dto.DocumentRefDto
|
||||
|
||||
/**
|
||||
* 一份待上传的本地附件。
|
||||
*
|
||||
* 只带「服务端裁决需要的两样」:[fileName](**扩展名真的参与裁决**——服务端把白名单扩展名与
|
||||
* 字节嗅探**合取**,两者都过才放行;其余部分会被丢进内容寻址的存储名里)与 [bytes]。
|
||||
*
|
||||
* 不用 `data class`:`ByteArray` 的 `equals` 是引用比较,写成 `data class` 会给出一个
|
||||
* 「看着像值语义、其实永远不等」的陷阱(测试里第一眼就会撞上)。
|
||||
*/
|
||||
class DocumentFile(
|
||||
val fileName: String,
|
||||
val bytes: ByteArray,
|
||||
) {
|
||||
val sizeBytes: Long get() = bytes.size.toLong()
|
||||
}
|
||||
|
||||
/** 正文该内联发出,还是该先落成文档。 */
|
||||
sealed interface DocumentPlan {
|
||||
|
||||
/** 照既有路由([ChatSendRouting])内联发出:没有附件,正文也没到「文件级」。 */
|
||||
data object Inline : DocumentPlan
|
||||
|
||||
/**
|
||||
* 先 `POST /api/v1/documents` 换成存储名,再用 `chat.documents` 引用发出。
|
||||
* 调用方应把这一路整个交给 REST(见类型 KDoc 第 2 条的策略说明),不要在 WS 上重开一条。
|
||||
*
|
||||
* @param files 本地附件(≤ [DocumentDelivery.MAX_ATTACHMENTS_PER_TURN] 份)。
|
||||
* @param textAsDocument 正文是否也要落成一份 `.txt` 一起当附件发。
|
||||
*/
|
||||
data class AsDocuments(
|
||||
val files: List<DocumentFile> = emptyList(),
|
||||
val textAsDocument: Boolean = false,
|
||||
) : DocumentPlan
|
||||
|
||||
/**
|
||||
* 明确拒绝:调用方**必须**给用户一个说法,**绝不静默丢**。
|
||||
* 「静默丢」正是本模块要消灭的东西——一方以为附件发上去了、另一方一无所知。
|
||||
*/
|
||||
data class Rejected(val reason: DocumentRejection) : DocumentPlan
|
||||
}
|
||||
|
||||
/** 本地就能判定的拒绝理由(服务端的同类拒绝见 [DocumentFailure])。 */
|
||||
enum class DocumentRejection {
|
||||
/** 一个回合的附件数超过上限:不做「只发前 5 个」这种裁剪,多出来的必须让用户看见。 */
|
||||
TooManyAttachments,
|
||||
}
|
||||
|
||||
/**
|
||||
* 「**从别的 App 分享进来**的长正文」该怎么送(纯函数,见 [DocumentDelivery.planSharedText])。
|
||||
*
|
||||
* 只用于分享那条路。用户自己在输入框里打的字**永远**是 [Inline]——「不做长正文自动转文件」是
|
||||
* 已拍板的产品定案(`devdocs/05` 的「三条待拍板项的定案」第 3 条),本枚举不改变它一个字。
|
||||
*/
|
||||
enum class SharedTextPlan {
|
||||
|
||||
/** 正文照正文发(没到 [DocumentDelivery.TEXT_AS_DOCUMENT_RUNE_LIMIT],或附件位不够,见下一档)。 */
|
||||
Inline,
|
||||
|
||||
/**
|
||||
* 正文落成一份 `.txt` 附件:上传 `POST /api/v1/documents` 之后随 `chat.documents` 发,
|
||||
* **正文那一栏留空**。
|
||||
*/
|
||||
AsDocument,
|
||||
|
||||
/**
|
||||
* 正文长到该走文件,但**附件位已经被分享进来的文件占满** ⇒ 只能按正文发。
|
||||
*
|
||||
* 调用方**必须**如实告知(分享那条路从不静默改形态)。取这一档而不是「把 .txt 塞进去、
|
||||
* 挤掉一个用户真的分享进来的文件」:那些文件是用户**明确**要发的东西,正文换形态是**为了
|
||||
* 少占篇幅**的便利——为了便利牺牲用户指名要发的内容,是不划算的交换。
|
||||
*/
|
||||
InlineAttachmentsFull,
|
||||
}
|
||||
|
||||
/**
|
||||
* 「该不该走文件」的判定与 `chat.documents` 载荷构造(纯函数,VM 与测试共用)。
|
||||
*
|
||||
* ## 为什么这条规则值得单独存在
|
||||
* 三条互不相同的约束在客户端汇成一个决定,任何一条被忘掉都会出线上事故:
|
||||
*
|
||||
* 1. **WS 帧 4096 字节硬上限,且超限是直接断连**(不是回错误)⇒ 正文超过它必须走 REST
|
||||
* —— 这一步**已经在** [ChatSendRouting] 里,本对象**不另立阈值**,只用 [textNeedsRest]
|
||||
* 把它显式暴露出来,免得接线时把「超帧限」误当成「该转文件」;
|
||||
* 2. **文档一律走 REST**(回执五 §一 S3-B 的明文规则):文档本体经
|
||||
* `POST /api/v1/documents` 上传、引用随 `POST /api/v1/chat` 发出。实测后端 WS `chat` 帧
|
||||
* **确实**能收 `documents`(`ws_hub_turn.go` 的入站结构与 REST 同形),所以这一条是
|
||||
* **客户端策略**而非后端限制——理由是把「上传 → 引用 → 起回合」压在**一条**通道上,
|
||||
* 不让两条入口在错误面(413 / 415 / 404 / too_many)上分叉。所以
|
||||
* [DocumentPlan.AsDocuments] 的调用方不该再把这条消息交给 WS;
|
||||
* 3. **长正文按文件送,比内联更划算**:内联的正文会进**每一页历史**、每一次 `history` 回放、
|
||||
* 以及这一轮的完整上下文;落成文档后历史里只留一条引用(名 / MIME / 大小 / 字符数 /
|
||||
* 状态),正文按预算注入、还能被 `document-read` 按游标**继续读**。
|
||||
*
|
||||
* ## 阈值与它们各自的分量(**别把三个数混成一个**)
|
||||
* | 界线 | 值 | 谁定的 | 越过的后果 |
|
||||
* |---|---|---|---|
|
||||
* | WS 帧上限 | 4096 字节 | 服务端 `wsReadLimit` | **直接断连**(不是报错)⇒ 走 REST |
|
||||
* | 走 REST 的正文上限 | 3584 字节 | 本端预留 512 字节 JSON 外壳 | 就是「换条通道」,无惩罚 |
|
||||
* | **转文件的正文界线** | 4000 **rune** | **本端自定**(见下) | 换形态:正文变附件 |
|
||||
*
|
||||
* ⚠️ **第三行是客户端的产品判断,不是服务端拒绝线。** 契约里唯一的 4000 rune 上限是
|
||||
* `PATCH /api/v1/messages/{id}` 的**编辑正文**上限(服务端 `maxMessageContentRunes`),
|
||||
* **`POST /api/v1/chat` 根本没有正文长度上限**(已核 `api-client.md` §1 行 3 与后端
|
||||
* `rest.go` / `ws_hub_turn.go` 的请求结构)。这里借它当「正文量级已经不像一条消息」的
|
||||
* 参照——按 Unicode 码点计,与服务端 Go 的 rune 口径一致(代理对算一个)。
|
||||
* 于是 [textNeedsFile] 只是**建议**:[plan] 会据此给出 [DocumentPlan.AsDocuments],
|
||||
* 而**接线时要不要真的自动转换,是产品取舍**(建议先问用户:「这段内容较长,
|
||||
* 作为文件发送?」),纯函数不替产品做决定、也不静默改变既有行为。
|
||||
*
|
||||
* 反过来,**正文只是超过 4096 字节(走 REST 就够)不该被当成文件**:那会平白改变一条
|
||||
* 已经验证过的路径(长文本 → REST 内联)的语义,而 REST 本来装得下。
|
||||
*/
|
||||
object DocumentDelivery {
|
||||
|
||||
/** 一个回合的附件数上限:服务端 `maxChatDocuments = 5`(回执五 §一 S3-B),含等于。 */
|
||||
const val MAX_ATTACHMENTS_PER_TURN: Int = 5
|
||||
|
||||
/**
|
||||
* 正文「已经不像一条消息」的**客户端**界线(rune,含等于 → 仍算一条消息)。
|
||||
*
|
||||
* 取值参照契约里唯一的 rune 计数上限——`PATCH /api/v1/messages/{id}` 的 4000 rune
|
||||
* (见类型 KDoc 的诚实说明:`/chat` 没有这条上限)。
|
||||
*/
|
||||
const val TEXT_AS_DOCUMENT_RUNE_LIMIT: Int = 4000
|
||||
|
||||
/** 长正文落成附件时的扩展名:服务端白名单内的 `.txt`(名字其余部分会被内容寻址丢弃)。 */
|
||||
const val TEXT_DOCUMENT_FILE_NAME: String = "message.txt"
|
||||
|
||||
/** 按 Unicode 码点计数,与服务端 Go 的 rune 口径一致(代理对算一个)。 */
|
||||
fun runeCount(text: String): Int = text.codePointCount(0, text.length)
|
||||
|
||||
/** 正文是否已经超过「一条消息的正文量级」⇒ 建议按文件发送。见类型 KDoc 的界线说明。 */
|
||||
fun textNeedsFile(text: String): Boolean = runeCount(text) > TEXT_AS_DOCUMENT_RUNE_LIMIT
|
||||
|
||||
/**
|
||||
* 正文是否连既有的 WS 通道都放不下 ⇒ 必须走 REST。
|
||||
*
|
||||
* 直接复用 [ChatSendRouting] 的判据(**不另立阈值**):这里只是把
|
||||
* 「超帧限 = 换通道」与「超 rune 界线 = 换形态」两件事在名字上分开。
|
||||
*/
|
||||
fun textNeedsRest(text: String): Boolean =
|
||||
ChatSendRouting.channelFor(text, hasImage = false) == SendChannel.Rest
|
||||
|
||||
/**
|
||||
* 这一轮该怎么发。
|
||||
*
|
||||
* 判定顺序(先拒绝、再看附件、最后看正文):
|
||||
* 1. 附件 > [MAX_ATTACHMENTS_PER_TURN] → [DocumentPlan.Rejected](**不裁剪**);
|
||||
* 2. 有附件 → [DocumentPlan.AsDocuments](附件一律先上传换存储名);
|
||||
* 3. 没附件、正文超 [TEXT_AS_DOCUMENT_RUNE_LIMIT] → [DocumentPlan.AsDocuments](正文落成 `.txt`);
|
||||
* 4. 其余 → [DocumentPlan.Inline]。
|
||||
*
|
||||
* @param text 本轮的正文(调用方已 trim;空串 = 纯附件回合,服务端**支持**纯附件回合)。
|
||||
* @param files 本轮要挂的本地附件。
|
||||
*/
|
||||
fun plan(text: String, files: List<DocumentFile>): DocumentPlan =
|
||||
when (val shape = planForCount(text, files.size)) {
|
||||
is DocumentPlan.AsDocuments -> shape.copy(files = files)
|
||||
else -> shape
|
||||
}
|
||||
|
||||
/**
|
||||
* 同 [plan] 的判定矩阵,但**只看附件份数**。
|
||||
*
|
||||
* 为什么需要它:调用方(`ChatViewModel`)手上还没有字节——最多 5×20 MiB 一次性读进内存
|
||||
* 是不可接受的,而判定矩阵里**唯一**与文件内容有关的就是份数(正文那一项看 [textNeedsFile])。
|
||||
* 有了这一条,调用方就能在读字节之前先答出「要不要拒绝」「要不要走上传这条链」,
|
||||
* 而不是先读一堆字节再发现自己根本不该发。
|
||||
*
|
||||
* @param attachmentCount 本回合要挂的附件份数。
|
||||
*/
|
||||
fun planForCount(text: String, attachmentCount: Int): DocumentPlan = when {
|
||||
attachmentCount > MAX_ATTACHMENTS_PER_TURN -> DocumentPlan.Rejected(DocumentRejection.TooManyAttachments)
|
||||
attachmentCount > 0 -> DocumentPlan.AsDocuments(files = emptyList(), textAsDocument = textNeedsFile(text))
|
||||
textNeedsFile(text) -> DocumentPlan.AsDocuments(textAsDocument = true)
|
||||
else -> DocumentPlan.Inline
|
||||
}
|
||||
|
||||
/**
|
||||
* 把长正文落成一份待上传的 `.txt`(服务端只看扩展名,名字其余部分会被丢弃)。
|
||||
* 字节就是 UTF-8 编码——服务端嗅探得到的基类型因此是 `text/plain`。
|
||||
*/
|
||||
fun textAsFile(text: String, fileName: String = TEXT_DOCUMENT_FILE_NAME): DocumentFile =
|
||||
DocumentFile(fileName = fileName, bytes = text.toByteArray(Charsets.UTF_8))
|
||||
|
||||
/**
|
||||
* **分享路径**专用:这一轮的正文该怎么送([SharedTextPlan])。
|
||||
*
|
||||
* 为什么分享这条路要转、而输入框那条不转(两件事**不是**同一件):
|
||||
* 分享进来的长正文往往是**别的平台把卡片/文章摊平之后的一整段字**(主人原话:「有的时候部分平台
|
||||
* 发送到非官方支持卡片的平台会将卡片内容转化为比较长的字符」)——它不是用户抱着键盘敲出来的
|
||||
* 一句话,而是一份**搬运进来的内容**,以文本消息发出去会把会话拉得很长。用户自己打的字则相反:
|
||||
* 那是他要说的话,悄悄换成附件只会让它在历史里搜不到、气泡变成一枚芯片(`devdocs/05` 的定案)。
|
||||
*
|
||||
* 判据两步,都只有纯值输入:
|
||||
* 1. 正文没到 [textNeedsFile] ⇒ [SharedTextPlan.Inline];
|
||||
* 2. 到了、但附件位已经满([MAX_ATTACHMENTS_PER_TURN])⇒ [SharedTextPlan.InlineAttachmentsFull]
|
||||
* (**如实告知**由调用方负责);否则 ⇒ [SharedTextPlan.AsDocument]。
|
||||
*
|
||||
* @param text 分享进来的正文(调用方已 trim)。
|
||||
* @param attachmentCount 这一轮**已经**要挂的附件份数(分享进来的文件,不含 .txt 本身)。
|
||||
*/
|
||||
fun planSharedText(text: String, attachmentCount: Int): SharedTextPlan = when {
|
||||
!textNeedsFile(text) -> SharedTextPlan.Inline
|
||||
attachmentCount >= MAX_ATTACHMENTS_PER_TURN -> SharedTextPlan.InlineAttachmentsFull
|
||||
else -> SharedTextPlan.AsDocument
|
||||
}
|
||||
|
||||
/**
|
||||
* 由**已上传**的服务端存储名构造 `chat.documents` 载荷。
|
||||
*
|
||||
* 只带 `name`(见 [DocumentRefDto]:服务端不采信其它字段并会覆盖),且**≤5 条**。
|
||||
*
|
||||
* @return `null` = 这份载荷**不合法**(超过 5 条,或有空名字),调用方**必须**给用户提示,
|
||||
* **不得**截断到 5 条、也**不得**丢掉空名字那条——服务端对>5 回 `400 too_many_documents`、
|
||||
* 对非法/不存在的名字回 `404 document_not_found`,两边都不做静默裁剪。
|
||||
*/
|
||||
fun payload(storageNames: List<String>): List<DocumentRefDto>? = when {
|
||||
storageNames.size > MAX_ATTACHMENTS_PER_TURN -> null
|
||||
storageNames.any { it.isBlank() } -> null
|
||||
else -> storageNames.map { DocumentRefDto(name = it) }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package top.yeij.cyrene.domain.chat
|
||||
|
||||
import top.yeij.cyrene.core.error.CyreneError
|
||||
import top.yeij.cyrene.data.api.CyreneException
|
||||
|
||||
/**
|
||||
* 文档操作失败(上传 / 取值)。契约 **v0.59 已发布(api-client.md §1.2「文档存储与摄取契约」)**,错误码依据见
|
||||
* `02-前后端协同事项.md` 回执五 §一 S1 与 §五「v0.59 会登记什么」。
|
||||
*
|
||||
* ## 为什么要把错误码收成类型
|
||||
* 四个码在**用户能做的事**上各不相同,折进一个「操作失败」会让 UI 只能靠字符串猜:
|
||||
*
|
||||
* | 码 | HTTP | 用户能做什么 |
|
||||
* |---|---|---|
|
||||
* | [UnsupportedType] `unsupported_media_type` | 415 | **换文件**:这个格式不在白名单里(deny-by-default 的文本/代码扩展名,含 `.js` / `.py` / `.sh` 一律拒),改名 / 改声明都没有用——**字节嗅探是唯一裁决** |
|
||||
* | [TooLarge] `document_too_large` | 413 | **换文件**:超过单文件上限(默认 20 MiB,可部署配置) |
|
||||
* | [TooMany] `too_many_documents` | 400 | **少选几个**:一个回合最多 5 个附件 |
|
||||
* | [NotFound] `document_not_found` | 404 | 重传:存储名形态非法或文件已不在(非法与不存在**刻意同码**,不给探测面) |
|
||||
* | [Unreadable] `document_unreadable` | 400 | **只有显式索取正文时才会出现**(如 `document-read`),不是「上传失败」 |
|
||||
*
|
||||
* 每个分支都带 [message]:四种拒绝的文案由**服务端产出**(含真实上限),客户端原样展示、
|
||||
* 不自己编数字(与 `image_too_large` 同一口径)。未知码与本地网络错误一律落 [Other],
|
||||
* **原样带上服务端消息**,绝不因为「不认识这个码」就把失败吞掉。
|
||||
*/
|
||||
sealed interface DocumentFailure {
|
||||
|
||||
/** 可直接展示给用户的文案(服务端有话说时就是它说的那句)。 */
|
||||
val message: String
|
||||
|
||||
/** `415 unsupported_media_type`:扩展名白名单外,或字节嗅探判出非文本。 */
|
||||
data class UnsupportedType(override val message: String) : DocumentFailure
|
||||
|
||||
/** `413 document_too_large`:超过单文件字节上限(`CHAT_DOCUMENT_MAX_BYTES`,默认 20 MiB)。 */
|
||||
data class TooLarge(override val message: String) : DocumentFailure
|
||||
|
||||
/** `400 too_many_documents`:一个回合挂的附件数超过 5。 */
|
||||
data class TooMany(override val message: String) : DocumentFailure
|
||||
|
||||
/** `404 document_not_found`:存储名非法或文件不存在(两者同码)。 */
|
||||
data class NotFound(override val message: String) : DocumentFailure
|
||||
|
||||
/** `400 document_unreadable`:正文取不回来(**仅显式索取正文时**)。 */
|
||||
data class Unreadable(override val message: String) : DocumentFailure
|
||||
|
||||
/** 401 / 403:会话失效或角色不够(文档端点要求 `owner|admin`)。 */
|
||||
data object Forbidden : DocumentFailure {
|
||||
override val message: String get() = "登录状态已失效,请重新登录"
|
||||
}
|
||||
|
||||
/** 其它失败:网络 / 超时 / 未知码 / 服务端 5xx——消息照原样带出来。 */
|
||||
data class Other(override val message: String) : DocumentFailure
|
||||
}
|
||||
|
||||
/** 把仓库层的 `Result` 失败收敛成 [DocumentFailure]。 */
|
||||
fun Throwable.toDocumentFailure(): DocumentFailure {
|
||||
val error = (this as? CyreneException)?.error
|
||||
return when {
|
||||
error is CyreneError.Unauthorized -> DocumentFailure.Forbidden
|
||||
error is CyreneError.Server && error.httpStatus == 403 -> DocumentFailure.Forbidden
|
||||
error is CyreneError.Server -> when (error.code) {
|
||||
"unsupported_media_type" -> DocumentFailure.UnsupportedType(error.userMessage)
|
||||
"document_too_large" -> DocumentFailure.TooLarge(error.userMessage)
|
||||
"too_many_documents" -> DocumentFailure.TooMany(error.userMessage)
|
||||
"document_not_found" -> DocumentFailure.NotFound(error.userMessage)
|
||||
"document_unreadable" -> DocumentFailure.Unreadable(error.userMessage)
|
||||
else -> DocumentFailure.Other(error.userMessage)
|
||||
}
|
||||
|
||||
error != null -> DocumentFailure.Other(error.userMessage)
|
||||
else -> DocumentFailure.Other("文档操作失败,请稍后重试")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,486 @@
|
||||
package top.yeij.cyrene.domain.chat
|
||||
|
||||
import top.yeij.cyrene.data.api.dto.DocumentRefDto
|
||||
import top.yeij.cyrene.data.repository.DocumentCacheUnavailableException
|
||||
import top.yeij.cyrene.data.repository.LocalDocumentTooLargeException
|
||||
import top.yeij.cyrene.data.repository.LocalDocumentUnavailableException
|
||||
import java.util.Locale
|
||||
|
||||
/**
|
||||
* 预览 / 打开 / 保存要处理的那一份附件**从哪来**。
|
||||
*
|
||||
* 判定只发生在一处([DocumentTarget.of]),预览取字节、交给别的 App、保存到下载目录三条路
|
||||
* 都从这里取来源——散成两处就会出现「预览读的是服务端、打开读的是本地」这种半截行为。
|
||||
*
|
||||
* 两种来源是**真的事实差异**,不是实现细节:已落库的附件只有服务端存储名(消息 DTO 刻意不带
|
||||
* 正文,见契约 v0.59 §1.2 ④),还没发出去的待发附件**只有**本地 URI(那时还没有存储名)。
|
||||
*/
|
||||
sealed interface DocumentSource {
|
||||
|
||||
/**
|
||||
* 已落库消息里的附件:字节要按服务端存储名现拉(`GET /api/v1/documents/{name}`,要 Bearer)。
|
||||
*
|
||||
* [mime] 是 DTO 里那个**字节嗅探**所得的基类型——「用其它应用打开」就用它,
|
||||
* 不从 [name] 的扩展名猜(见 [DocumentOpenTypes.forServer])。
|
||||
*/
|
||||
data class Server(val name: String, val mime: String) : DocumentSource
|
||||
|
||||
/** 待发附件:字节在本地 URI 上(SAF 的 `content://` 或分享落地后的 `file://`)。 */
|
||||
data class Local(val uri: String, val displayName: String) : DocumentSource
|
||||
}
|
||||
|
||||
/**
|
||||
* 被点开的那一枚附件芯片(纯数据,UI 照着它渲染预览弹层)。
|
||||
*
|
||||
* [chips] 就是气泡里那枚芯片的模型([DocumentChips]),**刻意复用**:弹层再写一套
|
||||
* 「类型 · 体积 · 字数 · 状态」的标签,两边迟早会有一处漏改状态语义。
|
||||
*/
|
||||
data class DocumentTarget(
|
||||
val source: DocumentSource,
|
||||
val chips: DocumentChipModel,
|
||||
) {
|
||||
|
||||
/**
|
||||
* 「用其它应用打开」要声明的类型。
|
||||
*
|
||||
* 服务端来源用 DTO 里嗅探所得的 [DocumentSource.Server.mime];本地来源只能来自平台声明
|
||||
* (`ContentResolver.getType`,由调用方传进来)。**两条路都不从扩展名猜**:服务端的
|
||||
* `data.json` 嗅探回的是 `text/plain`,按扩展名猜成 `application/json` 等于替服务端
|
||||
* 说一个它刻意不承认的类型。
|
||||
*/
|
||||
fun openType(declaredLocalType: String? = null): String = when (val origin = source) {
|
||||
is DocumentSource.Server -> DocumentOpenTypes.forServer(origin.mime)
|
||||
is DocumentSource.Local -> DocumentOpenTypes.forLocal(declaredLocalType)
|
||||
}
|
||||
|
||||
/**
|
||||
* App 内**正文区**该怎么渲染([DocumentPreviewGate] 的裁决,纯函数)。
|
||||
*
|
||||
* 与 [openType] 一起把「哪个字段才是真源」的知识留在这一个类里:服务端来源用 DTO 的
|
||||
* `mime`,本地待发附件**没有**服务端 `mime`(那时还没上传过),只能按显示名兜底。
|
||||
* 本地那条**刻意不问** `ContentResolver.getType`——平台给 `.rs` 这类文件常报
|
||||
* `application/octet-stream` 或空,拿它当判据会把一批纯文本文件挡在预览之外。
|
||||
*
|
||||
* 服务端下发的「读不到的原因」([DocumentChipModel.reason],契约 v0.63)在这里**顺路带进
|
||||
* 裁决**:它是这句话唯一的去处(芯片那一行放不下 40+ 字),详见 [DocumentPreviewGate.plan]。
|
||||
*/
|
||||
fun previewPlan(): DocumentPreviewPlan = when (val origin = source) {
|
||||
is DocumentSource.Server -> DocumentPreviewGate.plan(origin.mime, origin.name, chips.reason)
|
||||
is DocumentSource.Local -> DocumentPreviewGate.plan("", origin.displayName, chips.reason)
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存到下载目录时建议的文件名。
|
||||
*
|
||||
* ⚠️ **服务端来源只能用存储名**:落盘名是 `sha256(bytes)[:32] + ext`(内容寻址),
|
||||
* 原始文件名**永不参与路径**、也不回客户端——DTO 里根本没有它。所以这里如实给
|
||||
* `<32hex>.<ext>`,**绝不**替用户编一个「用户当初叫它什么」的假名字(那会是本端凭空的
|
||||
* 信息)。本地待发附件倒是还有用户当初选的那份显示名。
|
||||
*/
|
||||
fun suggestedFileName(): String = when (val origin = source) {
|
||||
is DocumentSource.Server -> origin.name
|
||||
is DocumentSource.Local -> origin.displayName.ifBlank { FALLBACK_FILE_NAME }
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
/** 已落库消息的附件引用 → 目标(来源恒为服务端)。 */
|
||||
fun of(ref: DocumentRefDto): DocumentTarget =
|
||||
DocumentTarget(DocumentSource.Server(ref.name, ref.mime), DocumentChips.of(ref))
|
||||
|
||||
/** 待发附件 → 目标(来源恒为本地)。 */
|
||||
fun of(draft: DraftDocument): DocumentTarget =
|
||||
DocumentTarget(DocumentSource.Local(draft.uri, draft.displayName), DocumentChips.of(draft))
|
||||
}
|
||||
}
|
||||
|
||||
private const val FALLBACK_FILE_NAME = "document.txt"
|
||||
|
||||
/**
|
||||
* 「用其它应用打开」时 Intent 上要声明的 MIME。
|
||||
*
|
||||
* 与 `ChatScreen` 选文件时那份 [DocumentPickerMimeTypes] 相反:那是「让用户选得到」的宽口径,
|
||||
* 这里是「交给别的 App 时声明它是什么」——**只有服务端嗅探(或平台声明)这一个真源**,
|
||||
* 判不出来宁可用 `application/octet-stream`,也不按扩展名猜。
|
||||
*/
|
||||
object DocumentOpenTypes {
|
||||
|
||||
const val OCTET_STREAM: String = "application/octet-stream"
|
||||
|
||||
/** 服务端文档:DTO 的 `mime` 是嗅探所得基类型(唯一裁决);服务端嗅不出类型时也下发 `octet-stream`。 */
|
||||
fun forServer(mime: String): String = mime.trim().ifBlank { OCTET_STREAM }
|
||||
|
||||
/** 本地待发文件:类型只可能来自平台声明;拿不到就回 `octet-stream`(不猜扩展名)。 */
|
||||
fun forLocal(declared: String?): String = declared?.trim()?.takeIf { it.isNotEmpty() } ?: OCTET_STREAM
|
||||
}
|
||||
|
||||
/** 「用其它应用打开」这一步的裁决结果。 */
|
||||
sealed interface DocumentOpenPlan {
|
||||
|
||||
/** 系统里有 App 能处理这个类型:把 Intent 交出去。 */
|
||||
data object HandOff : DocumentOpenPlan
|
||||
|
||||
/**
|
||||
* 没有任何 App 能打开这个类型:**必须如实告知**,并指路「保存到下载目录」。
|
||||
*
|
||||
* 静默什么也不发生是最坏的结果——用户按了按钮,界面纹丝不动,他既不知道失败在哪,
|
||||
* 也不知道还有别的出路。这条分支因此带一句现成的话,而不是一个光秃秃的布尔。
|
||||
*/
|
||||
data class NoHandler(val message: String) : DocumentOpenPlan
|
||||
}
|
||||
|
||||
/** 「用其它应用打开」的裁决(纯函数:`PackageManager` 只回答 [plan] 的 `hasHandler` 那一问)。 */
|
||||
object DocumentOpen {
|
||||
|
||||
/**
|
||||
* @param type 交给 Intent 的 MIME([DocumentOpenTypes] 的产物)。
|
||||
* @param hasHandler 系统里是否有 App 能处理 `ACTION_VIEW + type`(UI 层查 `PackageManager`)。
|
||||
*/
|
||||
fun plan(type: String, hasHandler: Boolean): DocumentOpenPlan =
|
||||
if (hasHandler) DocumentOpenPlan.HandOff else DocumentOpenPlan.NoHandler(noHandlerMessage(type))
|
||||
|
||||
/**
|
||||
* 没有任何 App 能处理 [type] 时的那句话。
|
||||
*
|
||||
* 单独暴露一个入口是因为「解析得到 handler」与「真的启动起来」之间还有一段窗口(App 被
|
||||
* 卸载 / 被停用)——那条 `ActivityNotFoundException` 分支要说的是**同一句话**,不能在那里
|
||||
* 另写一份文案(两份迟早会分叉)。
|
||||
*/
|
||||
fun noHandlerMessage(type: String): String =
|
||||
"没有应用能打开「$type」格式的文件,可以「保存到下载目录」后再处理"
|
||||
}
|
||||
|
||||
/**
|
||||
* App 内**正文区**能不能按文本渲染。
|
||||
*
|
||||
* 弹层的三条出口与「正文区」是两件事:判成 [NotText] 时弹层**照常打开**(文件名 · 体积 ·
|
||||
* 字数 · 状态与两条出口都在),只是**正文区留空** + 一句人话。
|
||||
*/
|
||||
sealed interface DocumentPreviewPlan {
|
||||
|
||||
/** 文本类:正文按 [DocumentPreview.slice] 的结果渲染。 */
|
||||
data object Text : DocumentPreviewPlan
|
||||
|
||||
/**
|
||||
* 非文本类(PDF 这类二进制内容):**绝不把字节当 UTF-8 渲染**(那会是一整屏乱码),
|
||||
* 也**不假装加载失败**(这不是失败,字节取回来了,只是本端不预览它)。
|
||||
*
|
||||
* [message] 有两种来源,都是**原样显示**:本端按类型写的那句([DocumentPreviewGate.refusalMessage]),
|
||||
* 或服务端下发的「读不到的原因」原话(契约 v0.63 的 `documents[].reason`)。
|
||||
*/
|
||||
data class NotText(val message: String) : DocumentPreviewPlan
|
||||
}
|
||||
|
||||
/**
|
||||
* 「App 内文本预览」这道闸(纯函数,可单测)。
|
||||
*
|
||||
* ## 为什么要有这道闸
|
||||
* W11 的预览是把字节当 UTF-8 文本渲染的——那时白名单里的附件**除了 `.pdf` 之外全是文本类**
|
||||
* (`.pdf` 正是 `v0.61` 才新增的第 23 项),所以「取回字节 → 解码 → 显示」在那些附件上是成立的。
|
||||
* 纳入 `.pdf` 之后,同一段代码会把一份 PDF 渲染成一整屏二进制乱码:看着像文件坏了,
|
||||
* 实际是本端拿错了渲染方式。
|
||||
*
|
||||
* ## 判据(优先服务端,退而扩展名)
|
||||
* 0. `reason` 非空白 ⇒ **直接就是正文区那句说明,原样显示**(契约 v0.63:服务端说读不到时
|
||||
* 下发的原话,比本端按类型编的那句准确)。它压过下面三条,代价见 [plan] 的 KDoc;
|
||||
* 1. `mime` 非空 ⇒ **只看服务端那一次字节嗅探**(契约 §1.2:嗅探是唯一裁决权):
|
||||
* 以 `text/` 开头的判为文本,其余(`application/pdf` / `application/octet-stream` / 图片型 …)
|
||||
* 一律判为**非文本**;
|
||||
* 2. `mime` 缺失 / 空白 ⇒ 按**扩展名**兜底:白名单 23 项里除 `.pdf` 外都是文本类,所以只有
|
||||
* `.pdf` 判为非文本(这条兜底是给**待发附件**用的——它只有本地 URI 与显示名,没有服务端结论);
|
||||
* 3. 未知 `mime`(既不是 `text/` 那一族、也不是认识的类型)⇒ **判为非文本**。两侧的代价不对称:
|
||||
* 误判成非文本只是少一次预览(两条出口照旧完整可用),误判成文本却正是这道闸要消灭的乱码。
|
||||
*
|
||||
* 大小写不敏感,且**剥掉参数**(`text/plain; charset=utf-8` 的基类型是 `text/plain`)——
|
||||
* 带参数的 Content-Type 是常态,不该因为多了一截 `; charset=…` 就整份附件不给预览。
|
||||
*/
|
||||
object DocumentPreviewGate {
|
||||
|
||||
/** `.pdf` 的类型标签:写进那句人话里(用户认得的是「PDF」而不是 `application/pdf`)。 */
|
||||
const val PDF_LABEL: String = "PDF"
|
||||
|
||||
/**
|
||||
* @param mime 服务端 DTO 里的 `mime`(嗅探所得基类型);待发附件传空串。
|
||||
* @param name 服务端存储名或待发附件的显示名——**只在 [mime] 为空时才看它**。
|
||||
* @param reason 服务端下发的「读不到的原因」原话(`documents[].reason`,契约 v0.63;
|
||||
* 已由 [top.yeij.cyrene.domain.chat.DocumentChips] 去掉首尾空白并在空白时归一成 `null`),
|
||||
* 缺席 = 服务端没给,或这条引用本来就不是 `unreadable`。
|
||||
*
|
||||
* [reason] 非空白时**它直接就是正文区那句说明,原样显示**,不再走本端按 [mime] / 扩展名的
|
||||
* 裁决——理由是这句话已经断言了「无法抽取正文」:
|
||||
* - 它比本端那句通用说明([refusalMessage]:只知道类型,不知道到底为什么)**准确**,
|
||||
* 替它改写或归纳就是丢掉服务端唯一一次说清原因的机会;
|
||||
* - 它也不该与正文并存:服务端说正文抽不出来,本端再把字节当 UTF-8 渲染,产出的正是这道
|
||||
* 闸要消灭的「看着像文件坏了」。
|
||||
*
|
||||
* 代价说清楚:这条会让**服务端判为读不到、但字节其实下得动**的附件失去 App 内预览
|
||||
* (例如超限的纯文本)。两条出口(用其它应用打开 / 保存到下载目录)照旧完整可用,
|
||||
* 与下面「未知 mime 判为非文本」是同一笔不对称账。
|
||||
*/
|
||||
fun plan(mime: String, name: String, reason: String? = null): DocumentPreviewPlan {
|
||||
if (!reason.isNullOrBlank()) return DocumentPreviewPlan.NotText(reason)
|
||||
val base = baseType(mime)
|
||||
if (base.isEmpty()) return extensionPlan(name)
|
||||
return if (base.startsWith(TEXT_PREFIX)) DocumentPreviewPlan.Text else refuse(base)
|
||||
}
|
||||
|
||||
/** 非文本那句人话。**说清「不是失败」**(两下都在弹层里摆着)并指路能拿到完整内容的两条出口。 */
|
||||
fun refusalMessage(label: String): String =
|
||||
"这是 $label,App 内不预览这类内容,请用「用其它应用打开」或「保存到下载目录」"
|
||||
|
||||
/** 去掉参数、去空白、转小写:`"text/plain; charset=utf-8"` → `"text/plain"`。 */
|
||||
fun baseType(mime: String): String =
|
||||
mime.substringBefore(';').trim().lowercase(Locale.ROOT)
|
||||
|
||||
/** 扩展名(小写、无点);没有扩展名时回空串。 */
|
||||
private fun extensionOf(name: String): String =
|
||||
name.substringAfterLast('.', "").trim().lowercase(Locale.ROOT)
|
||||
|
||||
private fun extensionPlan(name: String): DocumentPreviewPlan =
|
||||
if (extensionOf(name) == PDF_EXTENSION) refuse(PDF_LABEL) else DocumentPreviewPlan.Text
|
||||
|
||||
private fun refuse(baseType: String): DocumentPreviewPlan {
|
||||
// 已知是 PDF 就报「PDF」(用户认得这个名字),未知类型**原样报 mime**——绝不替服务端
|
||||
// 编一个更好听的类型名。
|
||||
val label = if (baseType == PDF_MIME) PDF_LABEL else baseType
|
||||
return DocumentPreviewPlan.NotText(refusalMessage(label))
|
||||
}
|
||||
|
||||
private const val TEXT_PREFIX = "text/"
|
||||
|
||||
private const val PDF_MIME = "application/pdf"
|
||||
|
||||
private const val PDF_EXTENSION = "pdf"
|
||||
}
|
||||
|
||||
/** 预览的截断阈值(两个上限,**先到者为准**)。 */
|
||||
object DocumentPreviewLimits {
|
||||
|
||||
/**
|
||||
* 单次预览最多取前 256 KB 字节。
|
||||
*
|
||||
* 取值理由:附件上限是 20 MiB(`CHAT_DOCUMENT_MAX_BYTES`),一份 20 MiB 的文本全塞进单个
|
||||
* `Text` 会让测量 / 布局在打开弹层那一刻卡住。256 KB 足够看清开头、翻不到底的都在下一条
|
||||
* 出口(「用其它应用打开」/「保存」)——那两条给的是**完整**内容。
|
||||
*/
|
||||
const val MAX_BYTES: Int = 256 * 1024
|
||||
|
||||
/** 行数上限:与字节上限先到者为准(19 万行的短行文件在 256 KB 内也能有 10 万行)。 */
|
||||
const val MAX_LINES: Int = 5_000
|
||||
|
||||
/**
|
||||
* 正文分块的粒度(行)。
|
||||
*
|
||||
* [MAX_LINES] 5000 行如果是一个 `Text`,一次测量就要算整段排版;切成 200 行一块交给
|
||||
* `LazyColumn`,只测当前可见的几块(最多 25 块)。
|
||||
*/
|
||||
const val LINES_PER_CHUNK: Int = 200
|
||||
}
|
||||
|
||||
/** 预览为什么只显示了一段([None] = 完整显示)。 */
|
||||
enum class PreviewTruncation {
|
||||
|
||||
None,
|
||||
|
||||
/** 撞上 [DocumentPreviewLimits.MAX_BYTES]。 */
|
||||
Bytes,
|
||||
|
||||
/** 撞上 [DocumentPreviewLimits.MAX_LINES]。 */
|
||||
Lines,
|
||||
}
|
||||
|
||||
/**
|
||||
* 预览正文的一段切片。
|
||||
*
|
||||
* [shownBytes] / [shownLines] 是**实际显示了多少**,[totalBytes] / [totalLines] 是全文多少。
|
||||
* 截断说明([notice])里报的是**阈值本身**(256 KB / 5,000 行):实际显示量最多比阈值少 3 字节
|
||||
* (UTF-8 回退那一刀),把它精确写进一句话只会得到「只显示前 262,143 字节」这种没人想看的东西。
|
||||
*/
|
||||
data class PreviewSlice(
|
||||
val text: String,
|
||||
val truncatedBy: PreviewTruncation,
|
||||
val shownBytes: Int,
|
||||
val shownLines: Int,
|
||||
val totalBytes: Int,
|
||||
val totalLines: Int,
|
||||
) {
|
||||
|
||||
val truncated: Boolean get() = truncatedBy != PreviewTruncation.None
|
||||
|
||||
/**
|
||||
* 截断时那句**必须明写**的话;完整显示时回 `null`(一个字都不说)。
|
||||
*
|
||||
* 静默截断是这一块的红线:用户看到一段像是「文件就这么长」的正文,会得出错误结论,
|
||||
* 而真相(后面还有)**只有服务端知道**——所以这里既说「只显示前 N」,也指路两个能给
|
||||
* 完整内容的出口。
|
||||
*/
|
||||
val notice: String?
|
||||
get() {
|
||||
val shown = when (truncatedBy) {
|
||||
PreviewTruncation.None -> return null
|
||||
PreviewTruncation.Bytes -> "${DocumentPreviewLimits.MAX_BYTES / 1024} KB"
|
||||
PreviewTruncation.Lines -> "${grouped(DocumentPreviewLimits.MAX_LINES)} 行"
|
||||
}
|
||||
return "只显示前 $shown,完整内容请用「用其它应用打开」或「保存到下载目录」查看"
|
||||
}
|
||||
}
|
||||
|
||||
/** 文档正文的取段与分块(纯函数,UI 与测试共用)。 */
|
||||
object DocumentPreview {
|
||||
|
||||
/**
|
||||
* 取前 [DocumentPreviewLimits.MAX_BYTES] 字节 / [DocumentPreviewLimits.MAX_LINES] 行,
|
||||
* 先到者为准。
|
||||
*
|
||||
* 顺序是「先字节、后行数」:字节上限决定能解码出多长的文本,行数上限在这一段里再切一次。
|
||||
* 于是两者都超时,[PreviewSlice.truncatedBy] 报 [PreviewTruncation.Bytes]——那才是真正
|
||||
* 先切断它的那一刀。
|
||||
*/
|
||||
fun slice(bytes: ByteArray): PreviewSlice {
|
||||
val totalBytes = bytes.size
|
||||
val totalLines = lineCount(bytes)
|
||||
val byteLimited = totalBytes > DocumentPreviewLimits.MAX_BYTES
|
||||
val prefix = if (byteLimited) {
|
||||
utf8SafePrefixLength(bytes, DocumentPreviewLimits.MAX_BYTES)
|
||||
} else {
|
||||
totalBytes
|
||||
}
|
||||
var text = String(bytes, 0, prefix, Charsets.UTF_8)
|
||||
val lineLimited = lineCount(text) > DocumentPreviewLimits.MAX_LINES
|
||||
if (lineLimited) text = takeLines(text, DocumentPreviewLimits.MAX_LINES)
|
||||
return PreviewSlice(
|
||||
text = text,
|
||||
truncatedBy = when {
|
||||
byteLimited -> PreviewTruncation.Bytes
|
||||
lineLimited -> PreviewTruncation.Lines
|
||||
else -> PreviewTruncation.None
|
||||
},
|
||||
shownBytes = text.toByteArray(Charsets.UTF_8).size,
|
||||
shownLines = lineCount(text),
|
||||
totalBytes = totalBytes,
|
||||
totalLines = totalLines,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 按行数分块([DocumentPreviewLimits.LINES_PER_CHUNK] 行一块)。
|
||||
*
|
||||
* 每块**保留自己的换行符**,因此把各块顺序拼回来等于原文——`LazyColumn` 逐块渲染时行距
|
||||
* 不会因为切点而错位。空文本回空列表(UI 不必为它画一个空块)。
|
||||
*/
|
||||
fun chunks(text: String, linesPerChunk: Int = DocumentPreviewLimits.LINES_PER_CHUNK): List<String> {
|
||||
if (text.isEmpty()) return emptyList()
|
||||
if (linesPerChunk <= 0) return listOf(text)
|
||||
val chunks = mutableListOf<String>()
|
||||
var start = 0
|
||||
var lines = 0
|
||||
for (index in text.indices) {
|
||||
if (text[index] != '\n') continue
|
||||
lines++
|
||||
if (lines == linesPerChunk) {
|
||||
chunks += text.substring(start, index + 1)
|
||||
start = index + 1
|
||||
lines = 0
|
||||
}
|
||||
}
|
||||
if (start < text.length) chunks += text.substring(start)
|
||||
return chunks
|
||||
}
|
||||
|
||||
/** 文本行数:`\n` 计数 + 末尾那行(末尾换行符不算额外一行);空文本是 0 行。 */
|
||||
fun lineCount(text: String): Int = when {
|
||||
text.isEmpty() -> 0
|
||||
text.endsWith('\n') -> text.count { it == '\n' }
|
||||
else -> text.count { it == '\n' } + 1
|
||||
}
|
||||
|
||||
/**
|
||||
* 字节层面的行数——**不解码整份文件**就能算出行数,因此 [slice] 报「共多少行」时
|
||||
* 不必把 20 MiB 全文变成一个 `String`。
|
||||
*
|
||||
* 直接数 `0x0A`:UTF-8 的续字节(`0x80..0xBF`)与各多字节序列里都不可能出现 `0x0A`,
|
||||
* 所以字节计数与字符计数一致。
|
||||
*/
|
||||
fun lineCount(bytes: ByteArray, length: Int = bytes.size): Int {
|
||||
if (length <= 0) return 0
|
||||
var newlines = 0
|
||||
for (index in 0 until length) {
|
||||
if ((bytes[index].toInt() and 0xFF) == NEWLINE) newlines++
|
||||
}
|
||||
return if ((bytes[length - 1].toInt() and 0xFF) == NEWLINE) newlines else newlines + 1
|
||||
}
|
||||
|
||||
/**
|
||||
* 在 [limit] 处取前截断时,把末尾一个**被切成两半的多字节 UTF-8 字符**退回。
|
||||
*
|
||||
* 不退的后果是正文末尾多出一个 `U+FFFD`(`String(bytes, UTF_8)` 的替换字符)——看着像
|
||||
* 文件本身就是坏的,而实际上是本端自己切坏的。退回最多 3 字节即可(UTF-8 最长 4 字节)。
|
||||
*
|
||||
* @return 可以安全解码的字节数(≤ [limit])。输入本身不合法(整段都是续字节)时原样返回
|
||||
* [limit]——不替一个坏文件找补。
|
||||
*/
|
||||
fun utf8SafePrefixLength(bytes: ByteArray, limit: Int): Int {
|
||||
if (limit >= bytes.size) return bytes.size
|
||||
if (limit <= 0) return 0
|
||||
var lead = limit
|
||||
var continuation = 0
|
||||
while (
|
||||
lead > 0 &&
|
||||
continuation < UTF8_MAX_BYTES - 1 &&
|
||||
(bytes[lead - 1].toInt() and 0xC0) == UTF8_CONTINUATION
|
||||
) {
|
||||
lead--
|
||||
continuation++
|
||||
}
|
||||
if (lead == 0) return limit
|
||||
val expected = when (bytes[lead - 1].toInt() and 0xFF) {
|
||||
in 0x00..0x7F -> 1
|
||||
in 0xC0..0xDF -> 2
|
||||
in 0xE0..0xEF -> 3
|
||||
in 0xF0..0xF7 -> 4
|
||||
else -> 1
|
||||
}
|
||||
val available = limit - lead + 1
|
||||
return if (available < expected) lead - 1 else limit
|
||||
}
|
||||
|
||||
/** 取前 [count] 行(含第 [count] 行的换行符);不足 [count] 行时原样返回。 */
|
||||
private fun takeLines(text: String, count: Int): String {
|
||||
if (count <= 0) return ""
|
||||
var seen = 0
|
||||
for (index in text.indices) {
|
||||
if (text[index] != '\n') continue
|
||||
seen++
|
||||
if (seen == count) return text.substring(0, index + 1)
|
||||
}
|
||||
return text
|
||||
}
|
||||
|
||||
private const val NEWLINE = 0x0A
|
||||
private const val UTF8_MAX_BYTES = 4
|
||||
private const val UTF8_CONTINUATION = 0x80
|
||||
}
|
||||
|
||||
/**
|
||||
* 预览取字节失败时的文案(纯函数,可单测)。
|
||||
*
|
||||
* 与 [top.yeij.cyrene.ui.chat.documentUploadFailureMessage] 同一纪律:服务端能说清的就用它
|
||||
* 的原话(含真实原因),本地能分类的就分类说,**绝不把失败渲染成「空文件」**。
|
||||
*
|
||||
* 三条本地失败必须分开,因为它们要用户做的事不一样:读不到 = 重新选一份;太大 = 换一份;
|
||||
* 缓存写不进去 = 这是本端的问题,重试多半还是同样的结果。
|
||||
*/
|
||||
fun documentLoadFailureMessage(e: Throwable): String = when (e) {
|
||||
is LocalDocumentUnavailableException -> "读不到这份文件,请重新选择"
|
||||
is LocalDocumentTooLargeException -> "这份文件超过 ${DocumentAttachments.maxFileSizeLabel},无法预览"
|
||||
is DocumentCacheUnavailableException -> "本地缓存不可用,暂时无法预览这份附件"
|
||||
else -> when (val failure = e.toDocumentFailure()) {
|
||||
// 服务端对「名字非法」与「文件不存在」刻意同码(不泄露差异)——本端只说能确定的那半句。
|
||||
is DocumentFailure.NotFound -> "服务端上已经没有这份文件了"
|
||||
is DocumentFailure.Other -> "附件加载失败," + failure.message
|
||||
else -> failure.message
|
||||
}
|
||||
}
|
||||
|
||||
/** 千分位分组(只用于行数这类计数,纯展示)。 */
|
||||
private fun grouped(value: Int): String = String.format(Locale.ROOT, "%,d", value)
|
||||
@@ -0,0 +1,31 @@
|
||||
package top.yeij.cyrene.domain.chat
|
||||
|
||||
/**
|
||||
* 文档抽取状态(契约 **v0.59 已发布(api-client.md §1.2「文档存储与摄取契约」)**,形状依据见 `02-前后端协同事项.md` 回执五 §一 S3-A/S3-B)。
|
||||
*
|
||||
* **开放集合,不用 enum**:后端的 `status` 是自由字符串,客户端映射只认这三个已知值,
|
||||
* **未知值原样保留**(不丢弃、不报错、不占位符)——与 `mode` / `platform` / `mood` 同一口径。
|
||||
*
|
||||
* 三个值的语义(**别互相顶替**):
|
||||
* - [EXTRACTED]:正文已抽取并进了这一轮的上下文;
|
||||
* - [LISTED]:只给了模型一份附件清单(名 / MIME / 大小 / 结论),**正文没进上下文**;
|
||||
* 它不是错误态——大文件、超预算、或本期不支持的格式都可能停在这里;
|
||||
* - [UNREADABLE]:**读不到这个文件**。这是唯一一个「昔涟看不到内容」的可观测信号,
|
||||
* 与「内容被截断」(`truncated`)是两件事:截断者正文照样进了上下文,只是尾巴被预算切掉。
|
||||
*/
|
||||
object DocumentStatus {
|
||||
|
||||
const val EXTRACTED: String = "extracted"
|
||||
|
||||
const val LISTED: String = "listed"
|
||||
|
||||
const val UNREADABLE: String = "unreadable"
|
||||
|
||||
/**
|
||||
* 这条引用是否报告「读不到」。
|
||||
*
|
||||
* 只比 [UNREADABLE],**不猜**:服务端是本信号的唯一生产者(回合结束后回写该用户消息的
|
||||
* document 列——用户消息是回合前落的、结论是回合中才知道的),客户端不做二次推断。
|
||||
*/
|
||||
fun isUnreadable(status: String): Boolean = status == UNREADABLE
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
package top.yeij.cyrene.domain.chat
|
||||
|
||||
/**
|
||||
* 「保留 path、替换 host」:把历史图片里**绑死在上传那一刻的 host 上**的绝对地址,换到当前 baseUrl。
|
||||
*
|
||||
* ## 它修的是什么(`devdocs/02` 的 `F6①` + 回执十三 §2)
|
||||
* `POST /api/v1/upload` 的 `url` 由服务端按**请求时**的 `r.Host`(+ `X-Forwarded-Proto` / TLS)
|
||||
* 拼成绝对地址,并且**原样落库**(读侧不重写)。于是换一个服务器地址(自部署换机器、`adb reverse`
|
||||
* 换个端口、域名从 IP 换成域名)之后,旧消息里的图片地址指向的还是**当时那个 host**,本端也就取不回
|
||||
* 那些图。
|
||||
*
|
||||
* 后端确认「读取侧三条路」里**只有主模型直收图**(`multimodal=true`)会真的去 GET 那串绝对 URL,
|
||||
* 并明确认可本端自己做这一层:「你可以先在客户端做『保留 path、替换 host』」。本对象的形态就是
|
||||
* 那句原话的逐字落地:**只换 host,不碰 path**。
|
||||
*
|
||||
* ## 保守到什么程度(不认识的一律原样返回)
|
||||
* 只认这一种形态,其余**一个字符都不改**:
|
||||
*
|
||||
* ```
|
||||
* http(s)://<任意 host[:port]>/api/v1/files/<name>
|
||||
* ```
|
||||
*
|
||||
* 具体判据(全部满足才替换,否则原样返回):
|
||||
* - 以 `http://` 或 `https://` 开头(大小写不敏感);**其它一切**都不是这一层的事——`data:` 内联图、
|
||||
* `content://` / `file://` 本地引用、相对路径、裸文件名,全都原样返回,不做任何猜测
|
||||
* (与 [ImageSource.of] 的同一条纪律:形态不认识的,交给它原来那条路);
|
||||
* - 路径**恰好**是 `/api/v1/files/` 再加**一段**名字:名字非空、不含 `/`、不含 `%`、不含 `?` / `#`,
|
||||
* 也不是 `.` / `..`。多一段、带查询串或片段、带百分号编码的,都判为「不是我们认识的那个地址」
|
||||
* ——第三方 CDN 的图与别人签过名的 URL 因此一个都不会被改;
|
||||
* - [baseUrl] 本身能解出 `scheme://authority`(空串、没有 host 的都解不出,于是原样返回)。
|
||||
*
|
||||
* 替换后用的是 **baseUrl 的 scheme + authority**(所以 `http` → `https`、端口变更都跟着走),
|
||||
* **不用** baseUrl 里的路径:服务端自己拼这条地址时只取 `r.Host`,读侧 `GET /api/v1/files/{name}`
|
||||
* 也挂在根路径上,所以部署形态本来就没有路径前缀这一说。
|
||||
*
|
||||
* ## 逆作用:指向不同数据时会 404,那是**诚实的失败**
|
||||
* 如果两个 host **指向的不是同一份存储**(换的是另一台机器、数据没迁过去),替换之后取到的就是
|
||||
* `404`。这仍然比现状好:不替换的话,那个地址**永远**取不回(而且是「连不上/超时」这种更含糊的
|
||||
* 失败),替换之后至少是一个说得清的结果,而图片那条路本来就会把失败如实报出来(见
|
||||
* [imageLoadFailureMessage])。**绝不**因此退回「不替换」——那等于把「换地址后旧图还能看」这件事
|
||||
* 彻底放弃。这条取舍是刻意的:宁可诚实地失败,也不要安静地永远失败。
|
||||
*
|
||||
* ## 不做的事
|
||||
* 不改落库数据、不改上传、不碰 `documents`,也**不**在这一层做任何 IO:它只在渲染前过一遍字符串。
|
||||
* 纯函数,可单测(`ImageHostRebaseTest`)。
|
||||
*/
|
||||
object ImageHostRebase {
|
||||
|
||||
/** 服务端上传产物的读侧路径(`api-client` §1 行 3:`GET /api/v1/files/{name}`)。 */
|
||||
private const val FILES_PREFIX = "/api/v1/files/"
|
||||
|
||||
private const val HTTP_SCHEME = "http://"
|
||||
private const val HTTPS_SCHEME = "https://"
|
||||
|
||||
/**
|
||||
* 把 [raw] 里同源的图片地址换到 [baseUrl] 的 host 上;不认识的形态原样返回(判据见类型 KDoc)。
|
||||
*
|
||||
* 幂等:已经指向当前 host 的地址走完也逐字节不变。
|
||||
*
|
||||
* @param raw 历史消息里那份 `image` 字符串(或任何要交给渲染的图片地址)。
|
||||
* @param baseUrl 当前服务器地址(`AuthManager.currentBaseUrl`);空串 = 不替换。
|
||||
*/
|
||||
fun apply(raw: String, baseUrl: String): String {
|
||||
// 1. raw 必须是 http(s) 绝对地址,且能切出 authority 与 path。
|
||||
val rest = raw.absoluteRest() ?: return raw
|
||||
val slash = rest.indexOf('/')
|
||||
if (slash <= 0) return raw
|
||||
// 2. 路径必须是「恰好一段文件名的 /api/v1/files/…」。
|
||||
if (!isSameOriginFilePath(rest.substring(slash))) return raw
|
||||
// 3. baseUrl 必须能给出 scheme://authority。
|
||||
val origin = originOf(baseUrl) ?: return raw
|
||||
return origin + rest.substring(slash)
|
||||
}
|
||||
|
||||
/**
|
||||
* `http(s)://` 之后的部分(`<authority>/<path>`);不是 http(s) 绝对地址就回 `null`。
|
||||
*
|
||||
* 大小写不敏感,但**原样保留**后面那段:本函数只判断、不重写。
|
||||
*/
|
||||
private fun String.absoluteRest(): String? = when {
|
||||
startsWith(HTTP_SCHEME, ignoreCase = true) -> substring(HTTP_SCHEME.length)
|
||||
startsWith(HTTPS_SCHEME, ignoreCase = true) -> substring(HTTPS_SCHEME.length)
|
||||
else -> null
|
||||
}
|
||||
|
||||
/**
|
||||
* `path` 是不是「恰好 `/api/v1/files/<一段名字>`」。
|
||||
*
|
||||
* 判据取最小容忍度,理由与 [ImageNames.fromServerUrl] 的同一条:这个字符串来自**别人给的**历史
|
||||
* 数据,凡是看不懂的形态就不该被我们改写(改写它等于替用户的图片猜一个新地址)。
|
||||
*/
|
||||
private fun isSameOriginFilePath(path: String): Boolean {
|
||||
if (!path.startsWith(FILES_PREFIX)) return false
|
||||
val name = path.removePrefix(FILES_PREFIX)
|
||||
if (name.isEmpty() || name == "." || name == "..") return false
|
||||
if (name.any { it == '/' || it == '\\' || it == '%' || it == '?' || it == '#' || it == '\u0000' }) {
|
||||
return false
|
||||
}
|
||||
return name.isNotBlank()
|
||||
}
|
||||
|
||||
/** `scheme://authority`;解不出来回 `null`(空串 / 没有 host / 不是 http(s) 都是解不出来)。 */
|
||||
private fun originOf(baseUrl: String): String? {
|
||||
val rest = baseUrl.absoluteRest() ?: return null
|
||||
// authority 在第一个 `/`、`?`、`#` 之前结束(正常 baseUrl 只有尾部那个 `/`;
|
||||
// 多出来的那两样不该被当成 host 的一部分)。
|
||||
val end = rest.indexOfFirst { it == '/' || it == '?' || it == '#' }.let { if (it < 0) rest.length else it }
|
||||
val authority = rest.substring(0, end)
|
||||
if (authority.isBlank()) return null
|
||||
val scheme = baseUrl.take(baseUrl.length - rest.length)
|
||||
return scheme + authority
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
package top.yeij.cyrene.domain.chat
|
||||
|
||||
import top.yeij.cyrene.data.api.CyreneException
|
||||
import top.yeij.cyrene.data.repository.DocumentCache
|
||||
import top.yeij.cyrene.data.repository.DocumentCacheUnavailableException
|
||||
import top.yeij.cyrene.data.repository.ImageTooLargeException
|
||||
import top.yeij.cyrene.data.repository.LocalDocumentTooLargeException
|
||||
import top.yeij.cyrene.data.repository.LocalDocumentUnavailableException
|
||||
import java.util.Base64
|
||||
import java.util.Locale
|
||||
|
||||
/**
|
||||
* 被点开的这张图**从哪来**。
|
||||
*
|
||||
* 判定只发生在一处([of]),渲染、取字节、交给别的 App 三条路都从这里取来源——散成两处就会
|
||||
* 出现「预览看的是这张、打开给的却是另一张」这种半截行为。
|
||||
*
|
||||
* 三种来源是**真的事实差异**,不是实现细节:
|
||||
* - [Server]:上传产物的绝对地址(`POST /api/v1/upload` 的 `url`)。读侧 `GET /api/v1/files/{name}`
|
||||
* 是**免鉴权**的 128-bit 能力 URL,所以这条路的字节可以直接 GET(与文档读侧刻意相反)。
|
||||
* ⚠️ 已知缺口(`02/F6①`):`url` 由服务端按上传时的 `r.Host` 拼成绝对地址并原样落库
|
||||
* ⇒ **换服务器地址后旧图取不回**。本模块不修它,但也不掩盖:取不到就如实报错。
|
||||
* - [Inline]:小图内联进历史的那份 `data:` URI([ChatImageDelivery] 的 Inline 档)。字节就在
|
||||
* 这个字符串里,**不联网**。
|
||||
* - [Local]:本地引用——相册选择器的 `content://`、分享落地的 `file://`。字节要现读。
|
||||
*/
|
||||
sealed interface ImageSource {
|
||||
|
||||
/**
|
||||
* 交给渲染与取字节用的**原始**字符串:气泡里 `message.image` 的那一份,或待发图片的本地 URI。
|
||||
*
|
||||
* 刻意不在这里归一化成「某种标准地址」:渲染交给 Coil(它自己认 `data:` / `content://` /
|
||||
* `http(s)`),取字节由 [ImageCacheStore] 按来源分支。多一层转换只会多一处漂移的余地。
|
||||
*/
|
||||
val raw: String
|
||||
|
||||
/** 上传产物的绝对地址(`http(s)://<host>/api/v1/files/<name>`);读侧免鉴权。 */
|
||||
data class Server(override val raw: String) : ImageSource
|
||||
|
||||
/** 内联进历史的 `data:` URI(`data:image/png;base64,…`)。 */
|
||||
data class Inline(override val raw: String) : ImageSource
|
||||
|
||||
/** 本地引用:`content://`(相册)/ `file://`(分享落地)都走它;读不到会如实报错。 */
|
||||
data class Local(override val raw: String) : ImageSource
|
||||
|
||||
companion object {
|
||||
|
||||
/**
|
||||
* 分类(纯字符串判断,不做任何 IO)。
|
||||
*
|
||||
* 空串回 `null`——那种图本来也不会被渲染。**其余一律落到 [Local]**:一个既不是
|
||||
* `data:` 也不是 `http(s)://` 的字符串只可能是本地引用(`content://` / `file://` /
|
||||
* 裸路径),按本地读、读不到就报「读不到这张图片」。这样做的理由是**不许静默什么都不
|
||||
* 发生**:形态陌生时点一下没反应,比给出一句说得清的失败糟得多。
|
||||
*/
|
||||
fun of(raw: String): ImageSource? = when {
|
||||
raw.isBlank() -> null
|
||||
raw.startsWith("data:", ignoreCase = true) -> Inline(raw)
|
||||
raw.startsWith("http://", ignoreCase = true) -> Server(raw)
|
||||
raw.startsWith("https://", ignoreCase = true) -> Server(raw)
|
||||
else -> Local(raw)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 图片的三种形态互推:**字节 ↔ mime ↔ 扩展名**。
|
||||
*
|
||||
* 放行的集合与服务端 `POST /api/v1/upload` 的白名单**同源**(png / jpeg / gif / webp / bmp):
|
||||
* 那正是服务端会接受、并且会给它起 `<32hex>.<ext>` 名字的一批。刻意**不**把 HEIC / AVIF 列进来
|
||||
* ——它们不在服务端白名单里,本端也拿不到可比对的裁决;但本地相册里的 HEIC 照样能看(渲染交给
|
||||
* Coil,解码是系统的事),只是本端说不出它的精确类型,那时「用其它应用打开」用的是**平台声明**
|
||||
* 的类型(见 [ImageOpenTypes.openType])。
|
||||
*/
|
||||
object ImageFormats {
|
||||
|
||||
const val MIME_PNG: String = "image/png"
|
||||
const val MIME_JPEG: String = "image/jpeg"
|
||||
const val MIME_GIF: String = "image/gif"
|
||||
const val MIME_WEBP: String = "image/webp"
|
||||
const val MIME_BMP: String = "image/bmp"
|
||||
|
||||
/**
|
||||
* 字节嗅探 → mime;认不出回 `null`。
|
||||
*
|
||||
* 只看文件头,不解码整张图:这一层要能在取字节之后立刻给答案,而不必把位图读进内存。
|
||||
* 判据与服务端 upload 的白名单同一套(服务端用 `http.DetectContentType`,本端按同样的签名
|
||||
* 手判)——认不出就说认不出,**不猜**(图片侧猜错的代价是让一个错的类型进 Intent)。
|
||||
*/
|
||||
fun sniff(bytes: ByteArray): String? = when {
|
||||
bytes.startsWith(PNG_MAGIC) -> MIME_PNG
|
||||
bytes.startsWith(JPEG_MAGIC) -> MIME_JPEG
|
||||
bytes.startsWith(GIF_MAGIC) -> MIME_GIF
|
||||
bytes.isWebp() -> MIME_WEBP
|
||||
bytes.isBmp() -> MIME_BMP
|
||||
else -> null
|
||||
}
|
||||
|
||||
/** 名字/地址里的扩展名 → mime;不在图片白名单里回 `null`(不猜)。 */
|
||||
fun mimeForExtension(name: String?): String? =
|
||||
when (name?.substringAfterLast('.', "")?.lowercase(Locale.ROOT).orEmpty()) {
|
||||
"png" -> MIME_PNG
|
||||
"jpg", "jpeg" -> MIME_JPEG
|
||||
"gif" -> MIME_GIF
|
||||
"webp" -> MIME_WEBP
|
||||
"bmp" -> MIME_BMP
|
||||
else -> null
|
||||
}
|
||||
|
||||
/** mime → 带点的扩展名(内容寻址的缓存名要用);认不出回空串。 */
|
||||
fun extensionFor(mime: String?): String = when (mime) {
|
||||
MIME_PNG -> ".png"
|
||||
MIME_JPEG -> ".jpg"
|
||||
MIME_GIF -> ".gif"
|
||||
MIME_WEBP -> ".webp"
|
||||
MIME_BMP -> ".bmp"
|
||||
else -> ""
|
||||
}
|
||||
|
||||
private fun ByteArray.startsWith(magic: ByteArray): Boolean =
|
||||
size >= magic.size && magic.indices.all { this[it] == magic[it] }
|
||||
|
||||
/** WEBP 是 RIFF 容器:前 4 字节 `RIFF`、第 8…11 字节 `WEBP`,只看开头是不够的。 */
|
||||
private fun ByteArray.isWebp(): Boolean =
|
||||
size >= WEBP_MARKER_OFFSET + WEBP_MARKER.size &&
|
||||
startsWith(RIFF_MAGIC) &&
|
||||
WEBP_MARKER.indices.all { this[WEBP_MARKER_OFFSET + it] == WEBP_MARKER[it] }
|
||||
|
||||
/** BMP 是 `BM` 开头的固定 14 字节文件头;只看那两字节太容易撞上以「BM」开头的文本。 */
|
||||
private fun ByteArray.isBmp(): Boolean = size >= BMP_HEADER_BYTES && startsWith(BMP_MAGIC)
|
||||
|
||||
private val PNG_MAGIC = byteArrayOf(0x89.toByte(), 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A)
|
||||
private val JPEG_MAGIC = byteArrayOf(0xFF.toByte(), 0xD8.toByte(), 0xFF.toByte())
|
||||
private val GIF_MAGIC = "GIF8".toByteArray(Charsets.US_ASCII)
|
||||
private val RIFF_MAGIC = "RIFF".toByteArray(Charsets.US_ASCII)
|
||||
private val WEBP_MARKER = "WEBP".toByteArray(Charsets.US_ASCII)
|
||||
private val BMP_MAGIC = "BM".toByteArray(Charsets.US_ASCII)
|
||||
private const val WEBP_MARKER_OFFSET = 8
|
||||
private const val BMP_HEADER_BYTES = 14
|
||||
}
|
||||
|
||||
/**
|
||||
* 服务端图片地址 → 缓存名。
|
||||
*
|
||||
* ## 为什么可以拿 URL 的最后一段当文件名
|
||||
* `POST /api/v1/upload` 的产物名是服务端生成的 `<32hex>.<ext>`,落进 `<dataDir>/uploads/`,
|
||||
* 读侧只认「32 位小写 hex + 白名单扩展名」。同一个名字的字节**永不改变**(上传产物没有覆盖
|
||||
* 写入口),所以**名字就是缓存键、命中即永不失效**——与文档那条内容寻址的结论一致。
|
||||
*
|
||||
* ## 为什么容忍度取最小
|
||||
* 这个名字会变成 `cacheDir/documents/` 下的一个文件名,而那个目录正是 FileProvider 唯一暴露出去
|
||||
* 的根(见 [DocumentCache.isSafeName])。所以只接受「最后一段、单段、非空、不是 `.` / `..`、
|
||||
* 不含分隔符与 NUL、不含 `%`、扩展名在图片白名单里」的名字;任何一条不满足就回 `null`,
|
||||
* 由调用方**回落到按字节内容寻址**——宁可多落一份文件,也绝不用一个不可信的名字拼路径。
|
||||
*
|
||||
* 对 `%` 格外苛刻是有意的:本端**从不**解释 URL 编码,所以一个带 `%` 的段说明它压根不是我们
|
||||
* 认识的那个服务端名字(服务端的读侧也明确拒绝 `%2f` / `%2e%2e` 这些形态)。
|
||||
*/
|
||||
object ImageNames {
|
||||
|
||||
/** 从图片地址里推出缓存名;推不出来 / 不可信时回 `null`。 */
|
||||
fun fromServerUrl(url: String): String? {
|
||||
// 先切掉 fragment 与查询串:缓存名只认路径那一段(`?v=2` 这种不该进文件名)。
|
||||
val path = url.substringBefore('#').substringBefore('?')
|
||||
val name = path.substringAfterLast('/')
|
||||
if (name.isBlank() || name == "." || name == "..") return null
|
||||
if (name.any { it == '\\' || it == '\u0000' || it == '%' }) return null
|
||||
if (!DocumentCache.isSafeName(name)) return null
|
||||
return name.takeIf { ImageFormats.mimeForExtension(it) != null }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 内联图片(`data:image/png;base64,…`)的解析。
|
||||
*
|
||||
* 用 JDK 的 `java.util.Base64` 而**不是** `android.util.Base64`:这一层是纯逻辑,要能在 JVM
|
||||
* 单测里直接跑——`android.util` 在单测里是 stub,一碰就抛。
|
||||
*/
|
||||
object ImageDataUri {
|
||||
|
||||
/** 头部声明的 mime(`data:image/png;base64,…` → `image/png`);没有就回 `null`。 */
|
||||
fun declaredMime(dataUri: String): String? = dataUri
|
||||
.substringAfter("data:", "")
|
||||
.substringBefore(',')
|
||||
.substringBefore(';')
|
||||
.trim()
|
||||
.takeIf { it.isNotEmpty() }
|
||||
|
||||
/**
|
||||
* 解出内联的字节;不是 base64 的 `data:` URI / 解不出来 / 解出来是空的都回 `null`。
|
||||
*
|
||||
* 用 MIME 解码器(忽略行内换行等非字母表字符)而不是严格解码器:`data:` URI 里的 base64
|
||||
* 允许折行,严格解码器会把一整张能看的图判成坏数据。代价是「一整串垃圾」也可能解出一段垃圾
|
||||
* 字节——那一段走到渲染那一步会解不开,由调用方如实报「解不开」,**不会**画成一张空白图。
|
||||
*/
|
||||
fun bytes(dataUri: String): ByteArray? {
|
||||
if (!dataUri.startsWith("data:", ignoreCase = true)) return null
|
||||
val comma = dataUri.indexOf(',')
|
||||
if (comma < 0) return null
|
||||
val head = dataUri.substring("data:".length, comma)
|
||||
if (!head.contains(";base64", ignoreCase = true)) return null
|
||||
return runCatching {
|
||||
Base64.getMimeDecoder().decode(dataUri.substring(comma + 1))
|
||||
}.getOrNull()?.takeIf { it.isNotEmpty() }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 「用其它应用打开」时 Intent 上要声明的 MIME,以及「保存到下载目录」时给 SAF 的类型。
|
||||
*
|
||||
* 与文档那条路同一纪律,但**两条路的类型选择刻意不同**(W11 实机抓到的真 bug):
|
||||
* - **打开**要的正是「能读懂这段内容的类型」⇒ 尽可能精确(嗅探 > 平台声明 > 扩展名 > 图片通配);
|
||||
* - **保存**恒为「通配」类型([ImageOpenTypes.SAVE_MIME])⇒ `CreateDocument` 传具体 mime 时,
|
||||
* SAF 的 Downloads provider 会按它给文件名**补扩展名**(2026-10-05 实测:存的 `…e.md` 变成
|
||||
* `…e.md.txt`)。图片这边同样危险:服务端名字的扩展名与**字节嗅探**所得的基类型不保证一致,
|
||||
* 补一次就成了 `x.jpg.png`。
|
||||
*/
|
||||
object ImageOpenTypes {
|
||||
|
||||
/** 说不清是什么图时的兜底:让图片类应用都能接(不是 `application/octet-stream`)。 */
|
||||
const val ANY_IMAGE: String = "image/*"
|
||||
|
||||
/** 保存到下载目录时给 SAF 的类型:**恒为通配**(为什么不能用具体 mime 见本对象的 KDoc)。 */
|
||||
const val SAVE_MIME: String = "*/*"
|
||||
|
||||
private const val IMAGE_PREFIX = "image/"
|
||||
|
||||
/**
|
||||
* 交给别的 App 时声明的类型,四级回落。
|
||||
*
|
||||
* 顺序是**证据强度**的顺序,不是「谁先拿到」的顺序:
|
||||
* 1. [sniffed] —— 字节嗅探。服务端对图片也是这个口径(`DetectContentType` 是唯一裁决),
|
||||
* 本端拿到的是同一份字节,所以它比任何名字和声明都硬;
|
||||
* 2. [declared] —— 平台声明(本地来源的 `ContentResolver.getType` / 内联 `data:` URI 的头)。
|
||||
* **只认 `image/` 开头的那种**:有些 provider 对图片报 `application/octet-stream`,
|
||||
* 拿它去开图等于告诉对方「这是一坨字节」,不如退到扩展名;
|
||||
* 3. [name] 的扩展名 —— 服务端名字里的扩展名来自它自己那一次嗅探,是可信的第二手证据;
|
||||
* 4. [ANY_IMAGE]。
|
||||
*/
|
||||
fun openType(sniffed: String?, declared: String?, name: String?): String {
|
||||
sniffed?.trim()?.takeIf { it.isNotEmpty() }?.let { return it }
|
||||
declared?.trim()?.takeIf { it.startsWith(IMAGE_PREFIX) }?.let { return it }
|
||||
ImageFormats.mimeForExtension(name)?.let { return it }
|
||||
return ANY_IMAGE
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 取这张图的字节失败时的文案(纯函数,可单测)。
|
||||
*
|
||||
* 与 [documentLoadFailureMessage] 同一纪律:服务端能说清的就用它的原话,本地能分类的就分类说,
|
||||
* **绝不把失败渲染成一张空白图**。三条本地失败分开写,因为它们要用户做的事不一样:读不到 =
|
||||
* 再点一次或重新发一张;太大 = 这个本端处理不了;缓存写不进去 = 本端的问题,重试多半还是这样。
|
||||
*
|
||||
* 三个异常类型与文档共用(它们描述的是**缓存层**的三种处境,不是文档专属);两边的文案各自成句。
|
||||
*/
|
||||
fun imageLoadFailureMessage(e: Throwable): String = when (e) {
|
||||
is LocalDocumentUnavailableException -> "读不到这张图片"
|
||||
is LocalDocumentTooLargeException -> "这张图片超过 ${DocumentAttachments.maxFileSizeLabel},无法预览"
|
||||
is ImageTooLargeException -> "这张图片超过 ${DocumentAttachments.maxFileSizeLabel},无法预览"
|
||||
is DocumentCacheUnavailableException -> "本地缓存不可用,暂时无法预览这张图片"
|
||||
is CyreneException -> when (e.toDocumentFailure()) {
|
||||
// 服务端对「名字非法」与「文件不存在」刻意同码(不泄露差异)——本端只说能确定的那半句。
|
||||
is DocumentFailure.NotFound -> "服务端上已经没有这张图片了"
|
||||
else -> "图片加载失败," + e.error.userMessage
|
||||
}
|
||||
|
||||
else -> "图片加载失败,请稍后重试"
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
package top.yeij.cyrene.domain.chat
|
||||
|
||||
/**
|
||||
* 一个矩形尺寸(像素)。
|
||||
*
|
||||
* 刻意**不用** Compose 的 `Size` / `IntSize`:这一层是纯手势数学,要能在 JVM 单测里直接跑,
|
||||
* 也不想让 domain 依赖 UI 框架。UI 那边把 `IntSize` 换算成它,只有两行。
|
||||
*/
|
||||
data class ImageSize(val width: Float, val height: Float)
|
||||
|
||||
/** 全屏查看的缩放 / 平移:喂给 `graphicsLayer` 的三个数。 */
|
||||
data class ZoomPan(
|
||||
val scale: Float = ImageZoom.MIN_SCALE,
|
||||
val offsetX: Float = 0f,
|
||||
val offsetY: Float = 0f,
|
||||
)
|
||||
|
||||
/**
|
||||
* 全屏查看的缩放 / 平移数学(纯函数,UI 只负责把触摸事件喂进来、把结果画出去)。
|
||||
*
|
||||
* ## 坐标约定
|
||||
* `offsetX` / `offsetY` 是**相对容器中心**的位移;[ImageSize] 给出两个尺寸:
|
||||
* - `container`:手势区的大小;
|
||||
* - `image`:图片的原始像素尺寸(只要宽高比正确即可,缩略图尺寸也行)。
|
||||
*
|
||||
* 于是「scale = 1」就是 **fit-center**:[fittedSize] 算出的内容尺寸(等比、贴住容器的一边)。
|
||||
* 任何偏移都在 [clampOffset] 里被钳到「内容超出容器的那一半」以内——内容比容器小的那一轴恒为 0,
|
||||
* 因此 fit 态下**根本拖不动**,图片永远不可能被拖到看不见的地方。
|
||||
*
|
||||
* ## 为什么下界是 1x 而不是任务书里那句「不低于 0.5x」
|
||||
* 1x 就是「整张图刚好放得下」。再往下缩只会让图比屏幕还小、四周留白,没有任何能看的东西,
|
||||
* 所以这里取一个**更紧**的下界(0.5x 满足「不低于 0.5x」,但它是个不需要的状态)。
|
||||
* 上界 3x:比这更大就只能看几个像素,而双指继续外扩还有系统的手势冲突风险。
|
||||
*/
|
||||
object ImageZoom {
|
||||
|
||||
/** 下界 = fit-center(见 KDoc)。 */
|
||||
const val MIN_SCALE: Float = 1f
|
||||
|
||||
/** 上界。 */
|
||||
const val MAX_SCALE: Float = 3f
|
||||
|
||||
/** 双击放到的倍数(双击是 1x ↔ 2x 的开关,不是「放大一点」)。 */
|
||||
const val DOUBLE_TAP_SCALE: Float = 2f
|
||||
|
||||
/** 判定「还在 fit 态」的容差:`clampScale` 的产物就是字面量 1f,但浮点乘法会留下尾巴。 */
|
||||
private const val EPSILON = 0.01f
|
||||
|
||||
/** 缩放钳制:非有限值(NaN / Infinity,来自 `zoom` 因子的极端输入)一律回 [MIN_SCALE]。 */
|
||||
fun clampScale(scale: Float): Float =
|
||||
if (scale.isFinite()) scale.coerceIn(MIN_SCALE, MAX_SCALE) else MIN_SCALE
|
||||
|
||||
/** 是否处在放大态(双击据此决定「放大到 2x」还是「收回 1x」)。 */
|
||||
fun isZoomed(scale: Float): Boolean = scale > MIN_SCALE + EPSILON
|
||||
|
||||
/** 双击的目标倍数:fit 态 → 2x;已放大 → 回 fit。 */
|
||||
fun doubleTapScale(current: Float): Float =
|
||||
if (isZoomed(current)) MIN_SCALE else DOUBLE_TAP_SCALE
|
||||
|
||||
/**
|
||||
* fit-center 之后、scale = 1 时的内容尺寸(等比缩到贴住容器的一边)。
|
||||
*
|
||||
* 任一侧非正(还没测量出来 / 图片尺寸未知)时回 `(0, 0)`:调用方据此跳过钳制,
|
||||
* 而不是拿 `NaN` 去算位移。
|
||||
*/
|
||||
fun fittedSize(container: ImageSize, image: ImageSize): ImageSize {
|
||||
if (container.width <= 0f || container.height <= 0f) return ImageSize(0f, 0f)
|
||||
if (image.width <= 0f || image.height <= 0f) return ImageSize(0f, 0f)
|
||||
val fit = minOf(container.width / image.width, container.height / image.height)
|
||||
return ImageSize(image.width * fit, image.height * fit)
|
||||
}
|
||||
|
||||
/**
|
||||
* 单轴的位移上限:内容放大后**超出容器的那一半**(内容比容器小时为 0)。
|
||||
*
|
||||
* 取一半是因为偏移以容器中心为原点:往右最多能移「超出量 / 2」,再移就会露出空白。
|
||||
*/
|
||||
fun maxOffset(containerSide: Float, contentSide: Float, scale: Float): Float {
|
||||
if (!containerSide.isFinite() || !contentSide.isFinite() || !scale.isFinite()) return 0f
|
||||
return ((contentSide * scale - containerSide) / 2f).coerceAtLeast(0f)
|
||||
}
|
||||
|
||||
/** 单轴钳制(越界即贴边;fit 态下恒为 0,所以拖不动)。 */
|
||||
fun clampOffset(offset: Float, containerSide: Float, contentSide: Float, scale: Float): Float {
|
||||
if (!offset.isFinite()) return 0f
|
||||
val limit = maxOffset(containerSide, contentSide, scale)
|
||||
return offset.coerceIn(-limit, limit)
|
||||
}
|
||||
|
||||
/** 拖动平移:先加上手指位移,再按两轴各自钳制。 */
|
||||
fun pan(
|
||||
current: ZoomPan,
|
||||
deltaX: Float,
|
||||
deltaY: Float,
|
||||
container: ImageSize,
|
||||
image: ImageSize,
|
||||
): ZoomPan = clamped(
|
||||
scale = current.scale,
|
||||
offsetX = current.offsetX + deltaX,
|
||||
offsetY = current.offsetY + deltaY,
|
||||
container = container,
|
||||
image = image,
|
||||
)
|
||||
|
||||
/**
|
||||
* 以 [focusX] / [focusY](**相对容器中心**的坐标)为不动点缩放到 [targetScale]。
|
||||
*
|
||||
* 推导:屏幕上被手指按住的那个点在内容坐标里是 `c = (focus - offset) / scale`;缩放后要让它
|
||||
* 落在同一处,于是 `offset' = focus - c * scale'`,即 `offset' = focus - (focus - offset) * (scale'/scale)`。
|
||||
* 缩回 fit 时这个式子算出来的位移会被 [clampOffset] 归零(内容不再超出容器),正是要的结果。
|
||||
*/
|
||||
fun zoomAround(
|
||||
current: ZoomPan,
|
||||
targetScale: Float,
|
||||
focusX: Float,
|
||||
focusY: Float,
|
||||
container: ImageSize,
|
||||
image: ImageSize,
|
||||
): ZoomPan {
|
||||
val scale = clampScale(targetScale)
|
||||
val base = if (current.scale.isFinite() && current.scale > 0f) current.scale else MIN_SCALE
|
||||
val ratio = scale / base
|
||||
return clamped(
|
||||
scale = scale,
|
||||
offsetX = focusX - (focusX - current.offsetX) * ratio,
|
||||
offsetY = focusY - (focusY - current.offsetY) * ratio,
|
||||
container = container,
|
||||
image = image,
|
||||
)
|
||||
}
|
||||
|
||||
/** 双击:以双击点为不动点在 fit 与 [DOUBLE_TAP_SCALE] 之间切换(回 fit 时位移自然归零)。 */
|
||||
fun doubleTap(
|
||||
current: ZoomPan,
|
||||
focusX: Float,
|
||||
focusY: Float,
|
||||
container: ImageSize,
|
||||
image: ImageSize,
|
||||
): ZoomPan = zoomAround(
|
||||
current = current,
|
||||
targetScale = doubleTapScale(current.scale),
|
||||
focusX = focusX,
|
||||
focusY = focusY,
|
||||
container = container,
|
||||
image = image,
|
||||
)
|
||||
|
||||
private fun clamped(
|
||||
scale: Float,
|
||||
offsetX: Float,
|
||||
offsetY: Float,
|
||||
container: ImageSize,
|
||||
image: ImageSize,
|
||||
): ZoomPan {
|
||||
val fitted = fittedSize(container, image)
|
||||
return ZoomPan(
|
||||
scale = scale,
|
||||
offsetX = clampOffset(offsetX, container.width, fitted.width, scale),
|
||||
offsetY = clampOffset(offsetY, container.height, fitted.height, scale),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
package top.yeij.cyrene.domain.chat
|
||||
|
||||
import top.yeij.cyrene.core.prefs.InterruptMode
|
||||
|
||||
/**
|
||||
* 「她忙时发消息」这一档偏好在上行的**两个声明**(纯函数,VM 与测试共用):
|
||||
* 协同项 A2 的 `interrupt.scope`([interruptScopeForSend])与忙时并行规范 §2.2 的
|
||||
* `chat.parallel`([parallelForSend] / [parallelWireValue])。
|
||||
*
|
||||
* 文件名沿用了先有的那个(`InterruptScope`):并行那一半接上来时,两者是**同一个判据的两个方向**
|
||||
* (打断 vs 不打断),放在同一处比拆成两个文件更容易看出它们互斥。
|
||||
*/
|
||||
|
||||
/**
|
||||
* 发这条消息**之前**要不要先发一条 `interrupt`:返回要发的作用域,`null` = 一个字节都不发
|
||||
* (纯函数,VM 与测试共用)。
|
||||
*
|
||||
* ## 四条判据(协同 A2 的客户端半边,契约 v0.56 §3.1)
|
||||
*
|
||||
* 1. [InterruptMode.AppendQueue](**默认档**)⇒ 恒为 `null`,线上请求与接入本函数之前**逐字节相同**。
|
||||
* 这是最重要的一条:默认档不许有任何行为变化。
|
||||
* 2. [InterruptMode.Interrupt](`wireScope = "current"`)**且会话正忙** ⇒ `"current"`:
|
||||
* 只取消在跑回合、**保留待处理队列**(队列里的消息按编排器正常续跑,一条都不丢)。
|
||||
* 3. [InterruptMode.Interrupt] 但会话**空闲** ⇒ `null`:命中空闲会话在服务端是 no-op,白发一条帧
|
||||
* 只是多一次往返,还会在 logcat 里制造「这里打断过」的假象。
|
||||
* 4. [InterruptMode.Parallel] ⇒ **恒为 `null`**(它的 `wireScope` 就是 `null`):并行与打断互斥,
|
||||
* 并行档先打断就恰好砍掉它要并行的那一路。这一条不是「这一档没得发」,而是**结构性保证**——
|
||||
* 谓词只读 `wireScope`,并行档不可能发出打断帧。
|
||||
*
|
||||
* 判据里**没有**「队列有多深」这一项,见下。
|
||||
*
|
||||
* ## 「正忙」由谁判
|
||||
* 只认**服务端给的** `session_busy{mode}`(调用方传 `ChatUiState.busyMode != null`)。
|
||||
* **不用 `queueDepth`**:队列深度非零不等于有回合在跑(`pending_ack` / `pending_drain` 这类帧也会动它),
|
||||
* 拿它当判据就会在空闲会话上乱发打断帧。
|
||||
*
|
||||
* @param busy 这条消息要发出去的那一刻,该会话是否有回合在跑。
|
||||
*/
|
||||
fun interruptScopeForSend(mode: InterruptMode, busy: Boolean): String? =
|
||||
if (busy) mode.wireScope else null
|
||||
|
||||
/**
|
||||
* 发这条消息时要不要声明 `parallel:true`(忙时并行规范 §2.2,[interruptScopeForSend] 的兄弟)。
|
||||
*
|
||||
* ## 三条判据
|
||||
*
|
||||
* 1. [InterruptMode.Parallel] **且会话正忙** ⇒ `true`:不打断在跑的那一路,让本条**另起一路**
|
||||
* 同时处理(服务端额度满时不报错,这一条落回排队)。
|
||||
* 2. 另外两档 ⇒ **恒为 `false`**:`parallel` 是纯增量键,省略它线上请求与接入本判据之前**逐字节相同**
|
||||
* ([InterruptMode.AppendQueue] 与 [InterruptMode.Interrupt] 的 `wireParallel` 都是 `false`)。
|
||||
* 3. 并行的同时**绝不打断**:两件事是一对互斥语义(打断 = 先砍掉她正在说的这一句,并行 = 不打断、
|
||||
* 另起一路),所以 [InterruptMode.Parallel] 的 `wireScope` 是 `null` ⇒ 它在
|
||||
* [interruptScopeForSend] 里恒为「不发」。反过来,另外两档的 `wireParallel` 是 `false` ⇒
|
||||
* 它们永远不带 `parallel` 键。「绝不既打断又并行」不是约定,是这两处属性取值本身
|
||||
* (`ParallelForSendTest` 把它钉成穷举断言)。
|
||||
*
|
||||
* ## 为什么空闲时不声明
|
||||
* 规范 §2.2 只说 `parallel:true` 是「**请求**把本条作为独立一路处理」,**没写**空闲时要不要照发。
|
||||
* 空闲时这一条**本来就是它自己的回合**,声明并行与不声明在服务端是同一件事;而多带一个键就多一处
|
||||
* 线上形状差异。于是本端取**与 [interruptScopeForSend] 同一个判据**(忙才声明)——最小惊讶:
|
||||
* 空闲时三档**逐字节**完全相同,切档不会改变一条普通发送的任何字节。
|
||||
*
|
||||
* @param busy 这条消息要发出去的那一刻,该会话是否有回合在跑(与 [interruptScopeForSend] 同一个来源)。
|
||||
*/
|
||||
fun parallelForSend(mode: InterruptMode, busy: Boolean): Boolean = busy && mode.wireParallel
|
||||
|
||||
/**
|
||||
* 把 [parallelForSend] 的结论翻成帧 / DTO 那个可选字段的取值:`false` ⇒ `null`(**整键省略**)。
|
||||
*
|
||||
* 为什么必须翻一道:`WsOutbound.Chat.parallel` 与 `ChatRequest.parallel` 的默认值都是 `null`,
|
||||
* 而「省略」与「显式 `false`」在服务端**不是一回事**(前者 = 未声明,后者 = 明确不要并行);
|
||||
* kotlinx 的 `encodeDefaults = false` 只省略「等于默认值」的字段 ⇒ 把 `false` 直接传下去,会在
|
||||
* **每一条**请求上多出一个 `"parallel":false` 键,默认档就不再逐字节不变了。两个发送层共用这一个
|
||||
* 转换点,免得哪天有人在其中一处「顺手」把布尔原样传下去。
|
||||
*/
|
||||
fun parallelWireValue(declare: Boolean): Boolean? = if (declare) true else null
|
||||
@@ -0,0 +1,106 @@
|
||||
package top.yeij.cyrene.domain.chat
|
||||
|
||||
import top.yeij.cyrene.domain.parse.parse
|
||||
|
||||
/*
|
||||
* MessageEditText —— 「一条消息在编辑框里长什么样」的纯逻辑(无 Android / Compose 依赖)。
|
||||
*
|
||||
* 口径:编辑框只显示**纯文本**,但绝不静默丢东西。两件事必须同时成立:
|
||||
* 1. 原文本来就是纯文本 ⇒ 显示与保存都**逐字节恒等**(`hasMarkup == false`);
|
||||
* 2. 原文真的带标记 ⇒ 显示剥掉标记的可见文本,并由弹层**明说**保存会把那些标记换成用户写的这段字
|
||||
* (`hasMarkup == true`,见 `ui/chat/MessageEditDialog.kt`)。
|
||||
* 所以「剥标记」这条路只对**真有标记**的内容生效,对今天真实的主会话数据(user 消息全是纯文本)
|
||||
* 是一条恒等变换——它是防御,不是修一个正在发生的 bug。这也正是不能顺手把纯文本 trim /
|
||||
* 折叠空行 / 统一换行符的原因:那会把恒等变成「悄悄地改」。
|
||||
*
|
||||
* 剥标记**只用** `domain/parse` 这一个解析器([parse]),不另写正则:两套口径必然分叉,
|
||||
* 屏幕上读到的与她实际改的就会是两段不同的文本。拼纯文本的做法与 `ui/chat/SessionSearchModel.kt`
|
||||
* 的 `plainSearchText` 同源(同一份 `parse` 结果、同一句「按片段顺序取文本」),
|
||||
* 只是彼此的用途不同:搜索片段折成一行、这里保持段落,所以两者的拼接方式各自写在自己的文件里。
|
||||
*
|
||||
* 为什么保存时**不**把标记装回去:任何回填都要先猜「旁白/动作该插回哪一句之间」,
|
||||
* 而这不是本端有权替用户决定的事——猜错就是静默移动她说过的话的位置,且用户看不出发生了什么。
|
||||
* 提交的就是编辑框里那串纯文本本身,标记的归宿由弹层那一行小字**提前说清**。
|
||||
*/
|
||||
|
||||
/**
|
||||
* 一条消息在编辑框里的样子(纯数据)。
|
||||
*
|
||||
* 两个字段是一体的:`text` 是用户看得到、也真的要动手改的那串字,`hasMarkup` 说的是
|
||||
* 「保存会不会因此丢掉别的东西」——弹层靠它决定要不要讲那一句话。
|
||||
*/
|
||||
data class EditableMessage(
|
||||
/** 编辑框显示、也就是用户实际编辑的**纯文本**。 */
|
||||
val text: String,
|
||||
/**
|
||||
* 原文里**确实带标记**(`<p>` / `<action>` / `<narration>`)⇒ 保存会把那些标记换成 `text`。
|
||||
* false = 原文就是纯文本,显示与保存都是**恒等**的。
|
||||
*/
|
||||
val hasMarkup: Boolean,
|
||||
)
|
||||
|
||||
/**
|
||||
* 已知标签的容差,与 [parse] 逐字对齐(只认解析器会**改掉**的那几个标签——行内标签不在其中,
|
||||
* 见下):`<p>`(可带属性)/ `</p>` / `<action>` / `</action>` / `<narration>` / `</narration>`。
|
||||
*
|
||||
* 绝不能「见到一个 `<` 就当标记」:用户打的 `1 < 2`、`<div>`、`<3`、`a<b` 都是普通文本,
|
||||
* 判成标记就会顺手把它们当标签吃掉——那才是真正的静默丢东西。大小写同样照解析器来:
|
||||
* `parse` 只认小写的 `<p>` / `<action>` / `<narration>`,所以 `<P>` 在这里也是普通文本。
|
||||
*
|
||||
* **行内标签(`<b>` / `<code>` …)故意不在**这份容差里:它们是渲染用的标记,但编辑框
|
||||
* 编辑的是**源码本身**,而 `parts`(这一份的来源)是**逐字原文**——所以 `日落 <b>18:35</b>` 在
|
||||
* 编辑框里连 `<b>` 一起显示、也一起保存,这里一个字符都不动它。
|
||||
*
|
||||
* **`<br>` 同理**(契约 v0.89 更正后它是**气泡分隔符**、不是行内标签):只含 `<br>` 的消息在这份
|
||||
* 容差里仍是纯文本 ⇒ 编辑框里逐字恒等(`<br>` 原样显示、原样保存);只有原文**另有**已知标记时
|
||||
* 才走剥标记那条路,那时 `<br>` 与 `<p>` / `</p>` 一样被换成它本来就在表示的**空行分隔**
|
||||
* (保存后渲染结果不变,且弹层已经明说标记会被替换)。
|
||||
*/
|
||||
private val KNOWN_TAG_RE = Regex(
|
||||
"<p(\\s[^>]*)?>|</p>|<\\s*(action|narration)\\s*>|</\\s*(action|narration)\\s*>",
|
||||
)
|
||||
|
||||
/** 气泡之间的分隔:一个空行——屏幕上读到的两段之间就是这么空着的。 */
|
||||
private const val BUBBLE_SEPARATOR = "\n\n"
|
||||
|
||||
/**
|
||||
* 算出这条消息在编辑框里的样子(纯函数,无副作用)。
|
||||
*
|
||||
* 三分支,覆盖「标记只在真的有标记时才动手」这条红线:
|
||||
* - **原文不含任何已知标签** ⇒ 原样返回,且**一个字符都不动**(不 trim、不折叠连续空行、不换换行符);
|
||||
* - **含标签且解析出了气泡** ⇒ `text` = 可见纯文本(见下);
|
||||
* - **含标签但一个气泡都解析不出**(例如整条就是一个没闭合的畸形标签)⇒ `text` 回落成原文:
|
||||
* 宁可原样留着,也不赌一把去删掉屏幕上说不清是什么的东西。
|
||||
*
|
||||
* `hasMarkup` 只在「解析**真的**换掉了什么」时为 true,也就是 `text != content`:
|
||||
* 它承诺的是「保存后这条消息和你现在看到的不一样」,而这个承诺必须句句属实——
|
||||
* 畸形标签、`<p> </p>` 这类解析器按普通文本处理的输入走的是恒等路径,
|
||||
* 那就该如实说「什么都没被替换」,而不是先吓用户一下。
|
||||
*/
|
||||
fun editableMessageOf(content: String): EditableMessage {
|
||||
if (!KNOWN_TAG_RE.containsMatchIn(content)) return EditableMessage(content, hasMarkup = false)
|
||||
val text = visibleTextOf(content)
|
||||
return EditableMessage(text, hasMarkup = text != content)
|
||||
}
|
||||
|
||||
/**
|
||||
* 标记 → 可见纯文本:每个气泡按**原始片段顺序**取文本(正文与旁白/动作一视同仁,都不丢),
|
||||
* 气泡之间一个空行,最后整体 trim。
|
||||
*
|
||||
* 按 `parts` 顺序拼而不是按 head/body/tail 拼:`parts` 就是原文顺序,[parse] 的 head 只是
|
||||
* 「该把这块画在气泡上方」的渲染提示,用它重排会改掉用户读到的次序。
|
||||
*
|
||||
* 每个气泡**自己也 trim**(然后才是气泡之间的空行):气泡在屏幕上本就是独立的一块,
|
||||
* 块内的首尾空白渲染上不显示;不 trim 的话 `</p>\n\n<p>` 这种 Web 形态的块间空行会与
|
||||
* 分隔符叠加成四个换行,读起来就不是屏幕上那两段了。这里去掉的只可能是空白,
|
||||
* 一个可见字符都不会掉。
|
||||
*
|
||||
* 一个气泡都解析不出时**原样返回 `content`**:那是「宁可不剥」的落点,不是错误。
|
||||
*/
|
||||
private fun visibleTextOf(content: String): String {
|
||||
val bubbles = parse(content)
|
||||
if (bubbles.isEmpty()) return content
|
||||
return bubbles
|
||||
.joinToString(BUBBLE_SEPARATOR) { bubble -> bubble.parts.joinToString("") { it.text }.trim() }
|
||||
.trim()
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package top.yeij.cyrene.domain.chat
|
||||
|
||||
/**
|
||||
* 一条待发消息的**内容指纹**:正文 + 图片的本地引用 + 附件的本地引用(纯值,可比较、可单测)。
|
||||
*
|
||||
* ## 它回答的问题
|
||||
* 「用户这次按发送,是不是在**重试**刚才那条没发出去的消息?」——只有答「是」,本端才敢把
|
||||
* 那一次用过的**幂等键**(`clientMsgId`)接着用;答「不是」就必须铸新键,否则服务端会把用户
|
||||
* **改过的那条**当成重复到达(`chat_duplicate` / `{duplicate:true}`)静默丢掉——他的编辑一个字
|
||||
* 都发不出去。
|
||||
*
|
||||
* ## 为什么是「值相等」而不是哈希 / 摘要
|
||||
* 指纹相等 = 本端认定「同一条消息」 = 复用幂等键。摘要一旦碰撞,两条**不同**的消息就会共用
|
||||
* 一个键,后一条被服务端判成重复而丢掉;而摘要在这里买不到任何东西(比较只发生在进程内,
|
||||
* 没有要跨网络传的指纹)。所以直接拿可比较的值本身当指纹,碰撞面为零。
|
||||
*
|
||||
* ## 为什么正文不做任何归一
|
||||
* 一个空白字符的差别也算「改过字」:trim / 折叠空白都可能把用户真实打算发的两条消息并成一条,
|
||||
* 而那正是本类型要防的事。发送路径传进来的已经是发送那一刻的正文(`ChatViewModel.send` 先
|
||||
* `trim`),所以「重试时输入栏里原样躺着的还是同一段字」天然相等。
|
||||
*
|
||||
* ## 附件为什么只取 uri
|
||||
* `DraftDocument` 的 `displayName` / `sizeBytes` 是**元数据**:同一份本地文件在两次
|
||||
* `ContentResolver` 查询之间完全可能一次报得出、一次报不出(`UNKNOWN_SIZE`),那不影响
|
||||
* 「这是同一份文件」。决定内容身份的是**本地引用本身**,所以指纹只取它。
|
||||
* 顺序照旧有意义(附件的先后就是用户在芯片区看到的先后),因此是列表而不是集合。
|
||||
*/
|
||||
data class MessageFingerprint(
|
||||
val text: String,
|
||||
/** 图片的**本地可读** URI;null = 这条不带图(与待发消息的字段一一对应)。 */
|
||||
val imageUri: String? = null,
|
||||
/** 附件的本地 URI,顺序即用户在芯片区看到的顺序。 */
|
||||
val documentUris: List<String> = emptyList(),
|
||||
) {
|
||||
companion object {
|
||||
/** 从发送那一刻的三件套算指纹(正文 / 图片 / 待发附件)。 */
|
||||
fun of(text: String, imageUri: String?, documents: List<DraftDocument>): MessageFingerprint =
|
||||
MessageFingerprint(text = text, imageUri = imageUri, documentUris = documents.map { it.uri })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,331 @@
|
||||
package top.yeij.cyrene.domain.chat
|
||||
|
||||
/**
|
||||
* 忙时并行回合(多路)的客户端模型:**分桶、六态、灰态收口**三件纯逻辑。
|
||||
*
|
||||
* 规格来源:`Cyrene_v2/DevDocs/_specs/忙时并行-规范草案.md`(§1 行为规范 / §2 接口规范 / §4 主人裁决)。
|
||||
* 本文件**只有纯数据与纯函数**:不碰协程、不碰 Android、不碰网络,所以三层逻辑都能直接单测。
|
||||
* 消费方是 `ChatViewModel`(接线)与 `ChatScreen` / `MessageBubble`(渲染)。
|
||||
*
|
||||
* ## 为什么要分桶
|
||||
* 两路并行时,同一 `sessionId` 下**两个不同 `turnId`** 的 `message.delta` / `thinking.delta` /
|
||||
* `tool_call.*` 会**交错到达**。若照老代码那样「一律累加到当前那一个流式气泡」,两条回复会被糊成
|
||||
* 一条(规范 §1.4 明写这一点)。所以落桶键是 `(sessionId, turnId)` 两级(规范 §1.7 第 1 条)。
|
||||
*
|
||||
* ## 字段缺失必须能降级(本文件的头号纪律)
|
||||
* `turnId` 在真后端 v0.36 起就打在**每个结构化流帧**的帧壳上,但本文件**不假设**它一定在:
|
||||
* 帧壳没给(`null` / 空白)时,帧落回**本会话最近见过的那条路**([turnBucketOf] 的 `sessionTurnId`),
|
||||
* 一条都还没见过时落进**匿名桶**(`turnId == ""`)。于是「所有帧都不带 `turnId`」时全部帧落进
|
||||
* **同一个桶**——也就是**今天那一个流式气泡**,行为逐字相同。
|
||||
* 同理 `turn_begin` / `steer_ack` 不来时,六态里只会出现 ①②③⑤,灰态(④)根本不会进入。
|
||||
* `kind` 不来时(老服务端)也只是**不挂并行路角标**——[isParallelLegKind] 认不出来就说不并行,
|
||||
* 绝不拿 `leg` 去替它撑门面。
|
||||
*/
|
||||
|
||||
/** 「路序号未知」:`steer_ack` 只给 `turnId`、不给 `leg`(规范 §2.5 的载荷只有 messageId / atStep)。 */
|
||||
const val UNKNOWN_LEG: Int = 0
|
||||
|
||||
/**
|
||||
* `turn_begin.kind` 里表示「忙时另起的一路」的取值(规范 §2.4)。
|
||||
*
|
||||
* 另一个取值 `"turn"` 表示「新回合 / 续跑」——也就是**普通串行回合**,它不在这里单列常量:
|
||||
* 本端只认得出 `"parallel"` 这一个,其余一切取值(含 `"turn"`)都按「不是并行」处理
|
||||
* (见 [isParallelLegKind] 的 KDoc)。
|
||||
*/
|
||||
private const val PARALLEL_LEG_KIND = "parallel"
|
||||
|
||||
/**
|
||||
* 流式分桶键:`sessionId` → `turnId` 两级(规范 §1.4 / §1.7 第 1 条)。
|
||||
*
|
||||
* [turnId] 为空串 = **匿名桶**:帧壳没给 `turnId`、且本会话还没见过任何 `turnId` 时的落点。
|
||||
* 它是「老的单路行为」在分桶模型里的等价物——所有匿名帧落在这同一个桶里,与今天逐字相同。
|
||||
*/
|
||||
data class TurnBucket(val sessionId: String, val turnId: String) {
|
||||
|
||||
/** 是否匿名桶(帧壳没给 `turnId`)。 */
|
||||
val isAnonymous: Boolean get() = turnId.isEmpty()
|
||||
|
||||
override fun toString(): String = if (isAnonymous) "$sessionId/*" else "$sessionId/$turnId"
|
||||
}
|
||||
|
||||
/**
|
||||
* 帧该落哪个桶(纯函数)。
|
||||
*
|
||||
* 优先级:**帧壳给的 `turnId`** > **本会话最近见过的 `turnId`** > **匿名桶**。
|
||||
*
|
||||
* 第二条是「字段缺失 ⇒ 行为与今天逐字相同」那条硬要求的落地处:帧壳没给 `turnId` 时,帧跟着
|
||||
* 本会话当前那一路走——老代码里它本来就会落进那**唯一**一个流式气泡。因此
|
||||
* `turnBucketOf("web", null, null)` 恒等于匿名桶,全部帧都落在一处,与分桶之前完全一致。
|
||||
*
|
||||
* @param frameTurnId 帧壳上的 `turnId`(可能 `null` / 空白)。
|
||||
* @param sessionTurnId 本会话最近一次见到的**非空** `turnId`(由调用方记住并喂进来)。
|
||||
*/
|
||||
fun turnBucketOf(sessionId: String, frameTurnId: String?, sessionTurnId: String?): TurnBucket =
|
||||
TurnBucket(sessionId, frameTurnId.orNullIfBlank() ?: sessionTurnId.orNullIfBlank() ?: "")
|
||||
|
||||
/**
|
||||
* **收口帧**(`stream_end` / `turn.end` / 落库的助手行)该落哪个桶(纯函数)。
|
||||
*
|
||||
* 与 [turnBucketOf] 的区别只有一条、但很要紧:**收口帧不许新开桶**。
|
||||
*
|
||||
* 理由是实证的:老代码里 `stream_end` 无论带着哪个 `turnId`,都会把**唯一**那个流式气泡收口
|
||||
* (`active = false`)。若照 [turnBucketOf] 给一个认不出来的 `turnId` 开新桶,那个新桶收口、
|
||||
* **真正在跑的那个桶永远收不了口**——光标一直转、`stream_end` 这一帧等于白给。
|
||||
* 所以这里的顺序是:
|
||||
*
|
||||
* 1. 帧壳的 `turnId` 认得出来(在 [existing] 里)⇒ 就是它;
|
||||
* 2. 否则本会话最近见过的 `turnId` 认得出来 ⇒ 用它;
|
||||
* 3. 否则 [existing] 里还有桶 ⇒ 用**最近更新的那一个**(单路场景下它就是正确的那一个);
|
||||
* 4. 一个桶都没有 ⇒ 匿名桶(也不新开,调用方据此可以对「没有在跑的路」整帧跳过)。
|
||||
*
|
||||
* @param existing 当前**已经存在**的桶的 `turnId` 列表,**顺序即更新时间**(最后的最近)。
|
||||
*/
|
||||
fun closingBucketOf(
|
||||
sessionId: String,
|
||||
frameTurnId: String?,
|
||||
sessionTurnId: String?,
|
||||
existing: List<String>,
|
||||
): TurnBucket {
|
||||
val frame = frameTurnId.orNullIfBlank()
|
||||
val session = sessionTurnId.orNullIfBlank()
|
||||
val target = when {
|
||||
frame != null && existing.contains(frame) -> frame
|
||||
session != null && existing.contains(session) -> session
|
||||
existing.isEmpty() -> ""
|
||||
else -> existing.last()
|
||||
}
|
||||
return TurnBucket(sessionId, target)
|
||||
}
|
||||
|
||||
/**
|
||||
* 一条消息在「忙时并行」语义下可能处的**六态**(规范 §1.1)。
|
||||
*
|
||||
* 名字逐字对齐规范表格里的态名,避免两套叫法:
|
||||
* - [Sending] ① 发送中(本地乐观气泡);
|
||||
* - [Sent] ② 已送达(正常气泡,还没进任何一路);
|
||||
* - [Queued] ③ 排队中(气泡角标「排队中」**浅灰**——规范 §1.3 明写:**`queued` 不是灰态**,
|
||||
* `pending_ack` 不许渲染成灰态,`queued` 只靠 `turn_begin` **直接换态**);
|
||||
* - [Answering] ④ 已受理·回答中(**灰态「已读、未答」**);
|
||||
* - [Answered] ⑤ 已答(终态);
|
||||
* - [Rejected] ⑥ 被拒(终态,用户可手动重发)。
|
||||
*/
|
||||
enum class TurnPhase { Sending, Sent, Queued, Answering, Answered, Rejected }
|
||||
|
||||
/**
|
||||
* 六态判定(纯函数,规范 §1.1 的表格逐行对照)。
|
||||
*
|
||||
* 判据只有四个,且**顺序即证据强度**(与 `queueFullOutcome` 同一条纪律):
|
||||
* 1. [rejected] = 收到过明确拒绝(定向 `error{chat_busy}` / 广播 `pending_full`)⇒ ⑥。
|
||||
* 它压过一切:这条消息**根本没进去**,不许再拿「有没有被接手」去描述它;
|
||||
* 2. [binding] 非空且**未收口** ⇒ ④ 灰态(`turn_begin` 的 `messageIds` 含它,或 `steer_ack` 绑上它);
|
||||
* 3. [binding] 非空且**已收口** ⇒ ⑤;
|
||||
* 4. [binding] 为空且是本地乐观气泡 ⇒ [queued] ? ③ : ①;
|
||||
* 5. [binding] 为空且已落库 ⇒ [queued] ? ③ : ②。
|
||||
*
|
||||
* 第 4/5 条里 [queued] 与非 [queued] 分开,是因为服务端的 `pending_ack` **不带消息身份**
|
||||
* (规范 §1.1 ③ 的进入条件明写「现状帧,形状不变」)——客户端只能按「这条我发出去之后收到了入队
|
||||
* 回执」来认,认不出来时为 [Sent] / [Sending](今天的样子),**绝不瞎猜成排队**。
|
||||
*
|
||||
* @param local 这条是不是**本地乐观气泡**(还没落库)。
|
||||
* @param binding 这条消息被哪一路接手(`null` = 没被任何一路接手)。
|
||||
* @param queued 收到过「它进队列」的回执(`pending_ack` / REST `202 queued`)。
|
||||
* @param rejected 收到过明确的拒绝。
|
||||
*/
|
||||
fun turnPhaseOf(
|
||||
local: Boolean,
|
||||
binding: TurnBinding?,
|
||||
queued: Boolean,
|
||||
rejected: Boolean,
|
||||
): TurnPhase = when {
|
||||
rejected -> TurnPhase.Rejected
|
||||
binding != null && !binding.closed -> TurnPhase.Answering
|
||||
binding != null -> TurnPhase.Answered
|
||||
local -> if (queued) TurnPhase.Queued else TurnPhase.Sending
|
||||
queued -> TurnPhase.Queued
|
||||
else -> TurnPhase.Sent
|
||||
}
|
||||
|
||||
/**
|
||||
* 灰态(④)**收口判据**(纯函数,规范 §1.2)。
|
||||
*
|
||||
* 规范给的是**唯一口径**:收口事件 = 该消息所属回合的 `turn.end`,用 `turn.end` **帧壳的 `turnId`**
|
||||
* 与这条消息绑定的 `turnId` 比对,**相等即收口**;此外该 `turnId` 的助手 `message_persisted`
|
||||
* 同样收口(两者顺序不定,任一到即收口)。
|
||||
*
|
||||
* 所以这里就一件事:两个 id **都非空且相等**。任一为空 ⇒ `false`(**不许**用「都为空所以相等」
|
||||
* 收口——那会让「没有 turnId 的老客户端 / 老服务端」上的消息被随便一帧误收口)。
|
||||
*
|
||||
* @param boundTurnId 这条消息绑定的 `turnId`。
|
||||
* @param closingTurnId 收口帧带来的 `turnId`(`turn.end` 帧壳 / 助手落库行的 `turnId`)。
|
||||
*/
|
||||
fun closesAnswering(boundTurnId: String?, closingTurnId: String?): Boolean {
|
||||
val bound = boundTurnId.orNullIfBlank() ?: return false
|
||||
val closing = closingTurnId.orNullIfBlank() ?: return false
|
||||
return bound == closing
|
||||
}
|
||||
|
||||
/**
|
||||
* 消息 ↔ 回合的绑定:**一路**(规范 §2.4 的 `turn_begin` 说的是「编排器每次启动一路发一次」)。
|
||||
*
|
||||
* 它由两件事建立:`turn_begin`(一次给全 `messageIds` / `leg` / `activeTurns` / `kind`)与 `steer_ack`
|
||||
* (只补「某条消息被哪一路吃进」,没有 `leg`)。
|
||||
* 只在 `turnId` **非空**时才会被建立——`turnId` 是这张表的键,也是收口的唯一凭据(见
|
||||
* [closesAnswering]);没有它建出来的绑定永远收不了口,那不是绑定,是一个永远灰着的气泡。
|
||||
*
|
||||
* ## [leg] 与 [parallel] 是两件事(这张表最容易被读错的一处)
|
||||
* [leg] 只是**会话内第几条路**:服务端 `beginLegLocked` 对**每一条新路**都自增 `legSeq`,普通串行
|
||||
* 回合照样占号——会话里第二条普通消息的 `leg` 就是 2。所以 `leg >= 2` **完全不等于**并行,拿它当
|
||||
* 并行的证据会挂出并不存在的「并行路 N」。
|
||||
* 真正说明「这一路是忙时另起的一路」的只有 `turn_begin.kind`,落到 [parallel](见 [isParallelLegKind])。
|
||||
*/
|
||||
data class TurnBinding(
|
||||
/** 这一路的权威回合标识(与该路全部流帧的 `turnId` 同值)。 */
|
||||
val turnId: String,
|
||||
/** 该路的会话内序号([UNKNOWN_LEG] = `steer_ack` 建的表,服务端没告诉序号)。 */
|
||||
val leg: Int = UNKNOWN_LEG,
|
||||
/** 归属这一路的**用户消息 id**(`turn_begin.messageIds` / `steer_ack.messageId`)。 */
|
||||
val messageIds: List<String> = emptyList(),
|
||||
/** 服务端最近一次报的该会话在途路数(`turn_begin.activeTurns`;0 = 没报过)。 */
|
||||
val activeTurns: Int = 0,
|
||||
/** 这一路是不是**真并行路**(服务端 `turn_begin.kind == "parallel"`,见 [isParallelLegKind])。 */
|
||||
val parallel: Boolean = false,
|
||||
/** 该路是否已收口(`turn.end` / 同 `turnId` 的助手落库行)。 */
|
||||
val closed: Boolean = false,
|
||||
)
|
||||
|
||||
/** 这条消息被哪一路接手;`null` = 没被任何一路接手(也是「按顺序渲染」那档的老行为)。 */
|
||||
fun List<TurnBinding>.bindingOfMessage(messageId: String): TurnBinding? =
|
||||
firstOrNull { binding -> messageId.isNotBlank() && binding.messageIds.contains(messageId) }
|
||||
|
||||
/**
|
||||
* `turn_begin.kind` ⇒ 「这一路是不是并行路」(纯函数,规范 §2.4)。
|
||||
*
|
||||
* 只有**逐字等于** `"parallel"` 才算并行:`"turn"`(新回合 / 续跑)不算,缺席 / 空串 / 大小写不同 /
|
||||
* 以后新增的其它取值也一律不算。两侧空白按缺席的写法一并容忍(与 `parallelLimitNoticeFor` 认
|
||||
* `pending_ack.reason` 同一条口径),大小写**不**容忍——认不出来就是认不出来。
|
||||
*
|
||||
* **为什么认不出来就算「不是并行」**:`kind` 是服务端后加的键,本端必须能在**老服务端**(整键不给)
|
||||
* 上跑,而 [TurnBinding.leg] 又**根本不足以**当判据(它是会话内第几条路,串行回合照样占号)。
|
||||
* 两件事叠起来只剩一个如实的答案:认不出来就说「不知道」,而「不知道」在界面上等于**不挂角标**
|
||||
* ——宁可少挂一枚,也不能挂一枚声称存在并不存在的并行的标。
|
||||
*/
|
||||
fun isParallelLegKind(kind: String?): Boolean = kind?.trim() == PARALLEL_LEG_KIND
|
||||
|
||||
/**
|
||||
* 收下一条 `turn_begin`(纯函数,返回新表)。
|
||||
*
|
||||
* 同 [TurnBinding.turnId] 已存在时**合并**而不是新增:`messageIds` 取并集(保持原顺序在前),
|
||||
* `leg` / `activeTurns` 用新报的值,**`closed` 保持原值**——一次重复到达的 `turn_begin` 不该把一条
|
||||
* 已经收口的路「重新打开」(那会让气泡回到灰态并且再也等不到第二次收口)。
|
||||
* 表里没有它时按**追加**处理:两路的顺序就是它们被启动的顺序。
|
||||
*
|
||||
* ## [TurnBinding.parallel] 的合并规则:一个只说真话的**单调**事实
|
||||
* 新建时它 = [kind] 是不是 `"parallel"`(见 [isParallelLegKind],认不出来 ⇒ `false`);
|
||||
* 已存在时取**或**(`old.parallel || 本帧是并行`)——**一旦为 true 就永远是 true**。
|
||||
*
|
||||
* 三条理由:
|
||||
* 1. 「这一路是被并行起出来的」是**既成事实**:服务端同一条路的 `kind` 由创建那一刻的标志决定、
|
||||
* 之后不可变,所以重复帧本不该各说各话;真各说各话时以「说过 parallel」为准——它不会凭一个
|
||||
* **更弱**(认不出 `kind`)的重复帧把已确立的并行抹掉,而并行只会多挂一枚有据可依的角标;
|
||||
* 2. 与 [TurnBinding.closed] 同一条纪律:**单调的既成事实不许被后到的帧翻回去**;
|
||||
* 3. 现实里真有「先弱后强」的顺序:[withSteeredMessage] 可能先按 `steer_ack` 建一条占位表
|
||||
* (那时没有 `kind`,只能是 `false`),随后那一路自己的 `turn_begin` 才是唯一能把它标成并行的地方。
|
||||
*
|
||||
* [kind] 缺席(默认空串)= 老服务端 / 老形状的帧 ⇒ 这一路永远 `false` ⇒ 不挂角标,与今天可解释地
|
||||
* 一致(少一枚角标,但没有多说一句假话)。
|
||||
*/
|
||||
fun List<TurnBinding>.withTurnBegin(
|
||||
turnId: String,
|
||||
leg: Int,
|
||||
messageIds: List<String>,
|
||||
activeTurns: Int,
|
||||
kind: String = "",
|
||||
): List<TurnBinding> {
|
||||
val key = turnId.orNullIfBlank() ?: return this
|
||||
val parallel = isParallelLegKind(kind)
|
||||
val index = indexOfFirst { it.turnId == key }
|
||||
if (index < 0) {
|
||||
return this + TurnBinding(
|
||||
turnId = key,
|
||||
leg = leg,
|
||||
messageIds = messageIds.filter(String::isNotBlank),
|
||||
activeTurns = activeTurns,
|
||||
parallel = parallel,
|
||||
)
|
||||
}
|
||||
val old = this[index]
|
||||
val merged = old.copy(
|
||||
leg = leg.takeIf { it > UNKNOWN_LEG } ?: old.leg,
|
||||
messageIds = (old.messageIds + messageIds.filter(String::isNotBlank)).distinct(),
|
||||
activeTurns = activeTurns.coerceAtLeast(0),
|
||||
parallel = old.parallel || parallel,
|
||||
)
|
||||
return toMutableList().also { it[index] = merged }
|
||||
}
|
||||
|
||||
/**
|
||||
* 收下一条 `steer_ack`(纯函数,返回新表):把 [messageId] 绑到 [turnId] 这一路上——这就是
|
||||
* 「这条消息被在飞的那一轮吃进去了,它进灰态」的凭据(规范 §2.5)。
|
||||
*
|
||||
* 该 `turnId` 还没有表项时**建一个只有这一条消息的表**(`steer_ack` 不给 `leg` / `activeTurns`,
|
||||
* 所以那两项留 [UNKNOWN_LEG] / 0)。`turnId` 或 `messageId` 空白 ⇒ **原样返回**:
|
||||
* 绑不出东西来(见 [TurnBinding] 的 KDoc)。
|
||||
*/
|
||||
fun List<TurnBinding>.withSteeredMessage(turnId: String, messageId: String): List<TurnBinding> {
|
||||
val key = turnId.orNullIfBlank() ?: return this
|
||||
val message = messageId.orNullIfBlank() ?: return this
|
||||
val index = indexOfFirst { it.turnId == key }
|
||||
if (index < 0) return this + TurnBinding(key, UNKNOWN_LEG, listOf(message))
|
||||
val old = this[index]
|
||||
if (old.messageIds.contains(message)) return this
|
||||
return toMutableList().also { it[index] = old.copy(messageIds = old.messageIds + message) }
|
||||
}
|
||||
|
||||
/**
|
||||
* 收口(纯函数,返回新表):把 [closingTurnId] 那一路上所有消息的绑定标记为 `closed`。
|
||||
*
|
||||
* 判据就是 [closesAnswering](两个 id 都非空且相等),因此一个空白 / 认不出来的 `closingTurnId`
|
||||
* **一条都不收口**——这正是老服务端(没有 `turnId`)上的行为:什么都不发生。
|
||||
* 没有命中任何一条时**原样返回同一个实例**(Compose 的稳定性与测试里的 `assertSame` 靠它)。
|
||||
*/
|
||||
fun List<TurnBinding>.closingTurn(closingTurnId: String?): List<TurnBinding> {
|
||||
var changed = false
|
||||
val next = map { binding ->
|
||||
if (!binding.closed && closesAnswering(binding.turnId, closingTurnId)) {
|
||||
changed = true
|
||||
binding.copy(closed = true)
|
||||
} else {
|
||||
binding
|
||||
}
|
||||
}
|
||||
return if (changed) next else this
|
||||
}
|
||||
|
||||
/**
|
||||
* 把**全部**在途路收口(纯函数):`session_idle` 的规范语义是「所有在途路都已收尾且 pending 空」
|
||||
* (规范 §1.5),所以那一帧到达时**不可能**还有没收口的路——本函数就是这条语义的兜底落地。
|
||||
* 它是兜底而不是主路径:主路径仍是各自 `turnId` 的 `turn.end`(规范 §1.2)。
|
||||
*/
|
||||
fun List<TurnBinding>.allClosed(): List<TurnBinding> {
|
||||
if (none { !it.closed }) return this
|
||||
return map { if (it.closed) it else it.copy(closed = true) }
|
||||
}
|
||||
|
||||
/**
|
||||
* 路标文案(规范 §1.4 A 的「小标」):**真的是并行路**且 `leg >= 2` 时才成句,其余返回 `null`。
|
||||
*
|
||||
* 两个条件各挡一类错误,缺一不可:
|
||||
* - [parallel] 为 `false` ⇒ **恒不挂**,哪怕 `leg` 是 2、3、99。`leg` 是**会话内第几条路**
|
||||
* (服务端对每条新路都自增,普通串行回合照样占号),拿它当并行的证据就是**对用户说谎**:
|
||||
* 会话里第二条普通消息会凭空冒出一枚「并行路 2」,而那一刻一条并行都没有。是不是并行路
|
||||
* 只有 `turn_begin.kind`(⇒ [TurnBinding.parallel])说了算;
|
||||
* - [leg] 小于 2(含 [UNKNOWN_LEG])⇒ 不挂:单路是今天的常态(`parallel:true` 是客户端显式声明
|
||||
* 才有的能力,规范 §0 的默认行为一条),给唯一一路也挂个「并行路 1」等于给每条回复都加一个新的
|
||||
* 视觉噪音,却什么都说明不了。所以「有没有小标」本身就精确表达「这里是不是真的有多路」。
|
||||
* `leg` 未知同理不挂——不能把「不知道第几路」说成「第 0 路」。
|
||||
*/
|
||||
fun legBadgeOf(leg: Int, parallel: Boolean): String? =
|
||||
if (parallel && leg >= 2) "并行路 $leg" else null
|
||||
|
||||
/** 空白串等同于缺席(服务端是「整键缺席」,但真给空串时按同一件事处理,与既有各纯函数一致)。 */
|
||||
private fun String?.orNullIfBlank(): String? = this?.takeIf { it.isNotBlank() }
|
||||
@@ -0,0 +1,207 @@
|
||||
package top.yeij.cyrene.domain.chat
|
||||
|
||||
import java.util.Locale
|
||||
|
||||
/**
|
||||
* 预览内容**下方**那一行灰色小字的组装(纯函数,逐档穷举单测在 `PreviewMetadataTest`)。
|
||||
*
|
||||
* ## 这一行是什么,不是什么
|
||||
* 它只放**关于这份文件本身**的格式事实:有多少行、按什么口径解码、多大、多少像素、什么时候拍的。
|
||||
* 它**不是**状态栏、也不是错误提示——「读不到 / 打开失败 / 保存失败」各有自己的去处(芯片行、
|
||||
* 正文区那句人话、弹层下方那行告知),混进来会让这一行读起来像出了什么事。
|
||||
*
|
||||
* ## 纪律:拿不到的项就不出现
|
||||
* 每一项都在**有证据时才产出**,没有证据就整项缺席——**不留占位符、不写「未知」、不写 `--`**。
|
||||
* 一屏小字里堆三个「未知」换不来任何信息,却会让人以为本端查过了。三档证据强度各自成条:
|
||||
* - **字节**(本端真的持有那一份):行数、像素、EXIF 拍摄日期、文件体积;
|
||||
* - **服务端结论**(DTO 里的字段):注入字符数、注入是否被截断、嗅探所得的 mime;
|
||||
* - **本端口径**(本端自己做了什么):`UTF-8` 只是**本端解码口径**,不是文件的真实编码(见下)。
|
||||
*
|
||||
* ## 「编码」为什么敢写 UTF-8(且只敢这么写)
|
||||
* 客户端**拿不到文件的真实编码**:磁盘上那份字节是按什么字符集写的,没有任何字段说过,本端也没有
|
||||
* 可靠的方式判定(猜 GBK / Shift-JIS 就是在编)。能确定的只有一件事:正文区那段文字是
|
||||
* [DocumentPreview.slice] 用 `String(bytes, Charsets.UTF_8)` 解出来的——所以这一项写的是
|
||||
* **「本端解码口径」**,即「你正在读的这段文字是怎么来的」。**表述里必须带这个限定语**:
|
||||
* 裸写一个「UTF-8」会被读成「这份文件是 UTF-8 编码的」,那是本端不知道的事。
|
||||
*
|
||||
* ## 与芯片行重合的两处:一处保留、一处省略
|
||||
* 预览弹层顶部那枚芯片已经写着「类型 · 体积 · 字数 · 状态 · 注入已截断」([DocumentChipModel.detail]),
|
||||
* 因此这一行刻意**不重复**:
|
||||
* - **省略**「体积」与「服务端状态」:[DocumentChips.sizeLabel] / [DocumentChips.statusLabel] 那两个
|
||||
* 数字/词与芯片行**逐字相同**、且没有口径可补,再写一遍只是噪音(图片那一路没有芯片行,所以
|
||||
* 体积在 [imageItems] 里是要显示的);
|
||||
* - **保留**「字符数」并补上口径:芯片只写「8,000 字」,读者无法知道那是**服务端注入上下文**的
|
||||
* 字数还是这份文件的字数——这正是一句会把人带偏的话,补上「(服务端注入口径)」才算说清。
|
||||
*
|
||||
* ## 本端的截断为什么不在这里再说一遍
|
||||
* [PreviewSlice.truncated](本端只预览了前 256 KB / 5,000 行)已经让 [PreviewSlice.notice] 在**正文
|
||||
* 上方**明说了,本行不再重复同一件事;而**服务端**的「注入被预算截断」是另一件事([DocumentRefDto.truncated],
|
||||
* 正文照样进了上下文),它没有别的去处,所以在 [textItems] 里单列一条。
|
||||
*/
|
||||
object PreviewMetadata {
|
||||
|
||||
/** 这一行各项之间的分隔符。 */
|
||||
const val ITEM_SEPARATOR: String = " · "
|
||||
|
||||
/** 把各项拼成能直接交给 `Text` 的那一行;空列表拼成空串(调用方据此不画这一行)。 */
|
||||
fun join(items: List<String>): String = items.joinToString(ITEM_SEPARATOR)
|
||||
|
||||
/**
|
||||
* 文本类预览的那几项:行数 · 字符数(带口径)· 解码口径 · 服务端的截断标记。
|
||||
*
|
||||
* @param slice 正文区的切段结果([DocumentPreview.slice] 的产物)。
|
||||
* @param serverCharsLabel 服务端下发的注入字符数**已格式化**的那一份
|
||||
* (`documents[].chars` → [DocumentChips.charsLabel],如 `"8,000 字"`);`null` = 服务端没报
|
||||
* (`chars` 整键缺席,契约 v0.59 §1.2),此时改报本端预览内容的字数。
|
||||
* @param contextTruncated 服务端是否报告了「注入被预算截断」(`documents[].truncated`)。
|
||||
* ⚠️ 与 [PreviewSlice.truncated](**本端**预览截断)是两件事,见本对象 KDoc。
|
||||
*/
|
||||
fun textItems(
|
||||
slice: PreviewSlice,
|
||||
serverCharsLabel: String?,
|
||||
contextTruncated: Boolean,
|
||||
): List<String> = buildList {
|
||||
add(linesItem(slice))
|
||||
add(charsItem(slice.text, serverCharsLabel))
|
||||
// 本端解码口径:正文就是 String(bytes, UTF_8) 的产物,说得出、也只在说这一件事。
|
||||
add(DECODED_AS_UTF8)
|
||||
if (contextTruncated) add(TRUNCATED_FOR_CONTEXT)
|
||||
}
|
||||
|
||||
/**
|
||||
* 非文本类(PDF 这类二进制)的那几项:只有服务端**字节嗅探**所得的 mime。
|
||||
*
|
||||
* 体积与状态刻意不在这里(芯片行已经写着,见本对象 KDoc);服务端说「读不到」时下发的
|
||||
* **原因原话**也不在这里——它已经被 [DocumentPreviewGate.plan] 送进正文区那句人话里**原样显示**,
|
||||
* 这里是同一屏的第二个位置,重复一遍只会让那句话看着像两条不同的消息。
|
||||
*
|
||||
* @param serverMime DTO 的 `mime`(服务端嗅探所得基类型);本地待发附件**没有**这个结论
|
||||
* (还没上传过),传 `null` —— 这一项因此缺席,**不由本端按扩展名补一个**。
|
||||
*/
|
||||
fun fileItems(serverMime: String?): List<String> =
|
||||
listOfNotNull(serverMime?.trim()?.takeIf { it.isNotEmpty() })
|
||||
|
||||
/**
|
||||
* 图片的那几项:格式(mime)· 分辨率 · 体积 · 拍摄日期。
|
||||
*
|
||||
* 图片这一路没有附件芯片行,所以体积在这里是要显示的。每一项都可能在拿不到时缺席:
|
||||
* 格式(嗅探与平台声明都判不出来时)、分辨率(本端解不出边界时)、拍摄日期(EXIF 里没有
|
||||
* `DateTimeOriginal`,或那个值不是能认的日期时)——见各自的参数说明。
|
||||
*
|
||||
* @param mime 交给别的应用的类型([ImageOpenTypes.openType] 的产物)。⚠️ 退化到「图片通配」
|
||||
* 时**必须传 `null`**:`image/` 通配([ImageOpenTypes.ANY_IMAGE])不是格式,写出来等于给了一个什么都没说的答案。
|
||||
* @param width / @param height 本端按 `inJustDecodeBounds` 读到的像素;任一侧 ≤ 0 / 缺席即整项不出现。
|
||||
* @param sizeBytes 本端实际持有的字节数;[UNKNOWN_BYTES] 即整项不出现。
|
||||
* @param capturedAt EXIF `DateTimeOriginal` 的**原始字符串**(`yyyy:MM:dd HH:mm:ss`,
|
||||
* 没有时区、按本地墙钟读,见 [formatExifDateTime])。
|
||||
*/
|
||||
fun imageItems(
|
||||
mime: String?,
|
||||
width: Int? = null,
|
||||
height: Int? = null,
|
||||
sizeBytes: Long = UNKNOWN_BYTES,
|
||||
capturedAt: String? = null,
|
||||
): List<String> = buildList {
|
||||
mime?.trim()?.takeIf { it.isNotEmpty() }?.let { add(it) }
|
||||
if (width != null && height != null && width > 0 && height > 0) add("$width × $height")
|
||||
DocumentChips.sizeLabel(sizeBytes).takeIf { it.isNotEmpty() }?.let { add(it) }
|
||||
formatExifDateTime(capturedAt)?.let { add(CAPTURED_AT_PREFIX + it) }
|
||||
}
|
||||
|
||||
/**
|
||||
* EXIF 的拍摄时间 → 人能读的样子(`"2019:07:04 20:31:05"` → `"拍摄于 2019年7月4日 20:31"`);
|
||||
* 认不出就回 `null`(宁可不显示,也不显示一个错的日期)。
|
||||
*
|
||||
* ## 两个必须说清的取舍
|
||||
* 1. **没有时区**。EXIF 的 `DateTimeOriginal` 是纯墙钟字符串(`yyyy:MM:dd HH:mm:ss`),规范里
|
||||
* 根本没有偏移量字段。所以这里**不做任何时区换算**,按字面数字当日历时间读——把它当 UTC
|
||||
* 再转本地,只会让一个原本正确的日期凭空错几个小时。
|
||||
* 2. **严格到不近人情**。只有逐位对上 `yyyy:MM:dd HH:mm:ss`(19 个字符、分隔符固定)且各段落在
|
||||
* 合法范围内才认。相机的「值未写入」占位符 `"0000:00:00 00:00:00"` 因此落在月份 0 上被判非法
|
||||
* ——这是对的,它本来就表示「不知道」。宽松解析(补零、猜分隔符)换来的是**看起来正常的错日期**,
|
||||
* 比不显示糟得多。
|
||||
*
|
||||
* 秒刻意丢掉:这一行是给人看的摘要,分钟已经足够定位(同秒的两张图对用户没有区别)。
|
||||
*/
|
||||
fun formatExifDateTime(raw: String?): String? {
|
||||
val value = raw?.trim().orEmpty()
|
||||
val groups = EXIF_DATE_TIME.matchEntire(value) ?: return null
|
||||
// 正则已保证每段都是纯数字,`toInt()` 在这里不会抛。
|
||||
val (year, month, day, hour, minute, second) = groups.destructured
|
||||
val y = year.toInt()
|
||||
val mo = month.toInt()
|
||||
val d = day.toInt()
|
||||
val h = hour.toInt()
|
||||
val mi = minute.toInt()
|
||||
val s = second.toInt()
|
||||
if (y < MIN_YEAR) return null
|
||||
if (mo !in 1..12) return null
|
||||
if (d !in 1..31) return null
|
||||
if (h !in 0..23) return null
|
||||
if (mi !in 0..59) return null
|
||||
// 60 是闰秒,相机会写出来,不该判成坏日期。
|
||||
if (s !in 0..60) return null
|
||||
return String.format(Locale.ROOT, "%d年%d月%d日 %02d:%02d", y, mo, d, h, mi)
|
||||
}
|
||||
|
||||
/**
|
||||
* 行数那一项。
|
||||
*
|
||||
* 显示量按 [DocumentPreview.lineCount] **从正文本身重算**(不是取 [PreviewSlice.shownLines]):
|
||||
* 这一行的标题就是「你看到的这段有多少行」,重算让它与正文永远自洽。被本端截断时补上全文行数
|
||||
* ([PreviewSlice.totalLines] 是按整份字节数出来的,本端确实持有那份字节)——只说「显示 N 行」
|
||||
* 会让人以为文件就这么长。
|
||||
*/
|
||||
private fun linesItem(slice: PreviewSlice): String {
|
||||
val shown = grouped(DocumentPreview.lineCount(slice.text))
|
||||
return if (slice.truncated) {
|
||||
"显示 $shown 行 / 共 ${grouped(slice.totalLines)} 行"
|
||||
} else {
|
||||
"$shown 行"
|
||||
}
|
||||
}
|
||||
|
||||
/** 字符数一项:服务端报了就报它那句(补口径),没报才用本端的计数(另一句口径)。 */
|
||||
private fun charsItem(text: String, serverCharsLabel: String?): String =
|
||||
if (serverCharsLabel != null) {
|
||||
serverCharsLabel + SERVER_CHARS_SCOPE
|
||||
} else {
|
||||
grouped(codePointCount(text)) + " 字" + LOCAL_CHARS_SCOPE
|
||||
}
|
||||
|
||||
/**
|
||||
* 「字」数按 **Unicode 码点**算(`codePointCount`):一个 emoji / 一个汉字都算一个字。
|
||||
* 直接取 `String.length` 会把 BMP 之外的字符算成两个(UTF-16 代理对),与「字数」这个说法不符。
|
||||
*/
|
||||
private fun codePointCount(text: String): Int =
|
||||
text.codePointCount(0, text.length)
|
||||
|
||||
/** 千分位分组(只用于计数,纯展示)。 */
|
||||
private fun grouped(value: Int): String = String.format(Locale.ROOT, "%,d", value)
|
||||
|
||||
// —— 每一项的措辞都收在这里,测试锚的也是这几句 ——
|
||||
|
||||
/** 本端解码口径。**限定语不许省**:省了就成了对文件真实编码的断言(见本对象 KDoc)。 */
|
||||
const val DECODED_AS_UTF8: String = "UTF-8(本端解码口径)"
|
||||
|
||||
/** 服务端 `chars` 的口径:那是**注入上下文**的字数,不是这份文件的字数。 */
|
||||
const val SERVER_CHARS_SCOPE: String = "(服务端注入口径)"
|
||||
|
||||
/** 本端自己数出来的字数口径:只数了预览内容(截断时就不是全文)。 */
|
||||
const val LOCAL_CHARS_SCOPE: String = "(本端预览内容)"
|
||||
|
||||
/** 服务端的 `truncated`:注入被预算截断,**正文照样进了上下文**(与「读不到」是两件事)。 */
|
||||
const val TRUNCATED_FOR_CONTEXT: String = "正文被截断(进上下文的部分)"
|
||||
|
||||
/** 拍摄日期前缀:一个光秃秃的日期在这一行里会被当成「文件修改时间」之类的东西。 */
|
||||
const val CAPTURED_AT_PREFIX: String = "拍摄于 "
|
||||
|
||||
/** 字节数拿不到时的占位:交给 [DocumentChips.sizeLabel],它会回空串(于是整项不出现)。 */
|
||||
const val UNKNOWN_BYTES: Long = -1L
|
||||
|
||||
private const val MIN_YEAR = 1
|
||||
|
||||
/** EXIF 规范里的日期时间形状;只认这一种,见 [formatExifDateTime]。 */
|
||||
private val EXIF_DATE_TIME =
|
||||
Regex("""(\d{4}):(\d{2}):(\d{2}) (\d{2}):(\d{2}):(\d{2})""")
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
package top.yeij.cyrene.domain.chat
|
||||
|
||||
/**
|
||||
* 一次「没发出去」留下的记录:**那一条**的内容指纹 + 当时铸的幂等键(`localId`)+ 记下它的时刻。
|
||||
*
|
||||
* [atMillis] 只服务于 [RetryKeyMemory.SLOT_TTL_MILLIS] 那道时间闸,与键本身的语义无关:没有它,一条
|
||||
* 几小时前的失败记录会一直活着,用户**新**打的一句同内容的话就会被当成重试(见 [RetryKeyMemory])。
|
||||
*/
|
||||
data class RetryKeySlot(
|
||||
val fingerprint: MessageFingerprint,
|
||||
val localId: String,
|
||||
val atMillis: Long,
|
||||
)
|
||||
|
||||
/**
|
||||
* [RetryKeyMemory.keyFor] 的判定结果:这一次发送该用哪个幂等键,以及记忆变成什么样。
|
||||
*
|
||||
* 两半都是判定的一部分,所以一起回:**命中的那一条**会被消费掉(见 [RetryKeyMemory.keyFor]),
|
||||
* 把它放进返回值,调用方就没有「用完了忘了清」的机会。
|
||||
*/
|
||||
data class RetryKeyDecision(val localId: String, val memory: RetryKeyMemory)
|
||||
|
||||
/**
|
||||
* 「没发出去的那几条」的记忆——**多槽**、**有时间上界**、纯值、可单测。
|
||||
*
|
||||
* ## 它存在的理由
|
||||
* 幂等键要想真的挡住重复,就必须在**同一条消息**的两次发送之间保持不变。而「某次失败」并不
|
||||
* 等于「服务端没收下」:队列满(REST `409` / 定向 `chat_busy`)是**真的没进去**,但超时、
|
||||
* 回执丢失、连接被踢完全可能是「服务端已经落库了,只是客户端没看见」。用户手动重试时若铸一个
|
||||
* 新键,服务端就会把它当成**第二条**消息落库 —— 用户看到重复。这里记下失败那次的指纹与键,
|
||||
* 让「一模一样的内容再发一次」落回同一个键,由服务端用 `chat_duplicate` /
|
||||
* `{duplicate:true,messageId}` 替本端裁决。
|
||||
*
|
||||
* ## 时间上界:10 分钟,为什么多槽必须配一个时钟
|
||||
* 记录是**跨很多次发送**存活的(这正是多槽的意义,见下面「为什么是多槽」),于是有了一个单槽时代
|
||||
* 不存在的窗口:用户**几小时前**失败过一句「你好」,之后**新**打一遍「你好」—— 他心里当的是一条
|
||||
* **新**消息,指纹却与那条旧记录一字不差 ⇒ [keyFor] 命中 ⇒ 复用旧键 ⇒ 服务端回 `chat_duplicate`
|
||||
* ⇒ 这条新消息**根本不发出去**,屏幕上看着像「按了没反应」。给每个槽加一道时间闸
|
||||
* [SLOT_TTL_MILLIS],这个窗口就被钉死在 10 分钟内;而真正需要幂等的那一幕 ——「刚失败完,用户马上
|
||||
* 又按了一次发送」—— 一秒都没被削掉。
|
||||
*
|
||||
* 时钟由调用方喂([remember] 的 `atMillis`、[keyFor] 的 `nowMillis`),纯值类型里**不藏**
|
||||
* `System.currentTimeMillis()`:判据才可单测,也才不用为了「等 10 分钟」写 sleep。
|
||||
*
|
||||
* ## 边界口径:正好等于 TTL **不算**过期
|
||||
* 判据是 `nowMillis - atMillis > SLOT_TTL_MILLIS`(严格大于),所以 `nowMillis == atMillis + TTL`
|
||||
* 那一条**仍然命中**,真正过期要再晚一毫秒。选这一边的理由:常量自身就能当「仍在窗口内」的边界值
|
||||
* 直接断言(测试不必为「还活着」那一侧发明魔法数),过期那一侧才需要显式的 `+1`;而真实时钟几乎
|
||||
* 不可能精确落在这 1 毫秒上,口径选哪边都没有实害,可测的那边更有价值。测试
|
||||
* `a slot exactly at the TTL boundary still counts as fresh` 把这条钉住。
|
||||
*
|
||||
* ## 三条纪律
|
||||
* 1. **只在回滚时写**([remember]:发送失败 / 队列满被拒 / 附件与图片上传失败 / 图片处理失败…):
|
||||
* 被受理的那条没有任何要记的东西。
|
||||
* 2. **在用户下一次发送时按指纹取用**([keyFor]):指纹相等 ⇒ 复用那个键、**且只消费那一条**;
|
||||
* 不等 ⇒ 铸新键、**一条槽也不清**(多槽下「不等也清」是错的,见 [keyFor] 的注释)。
|
||||
* 3. **按 `localId` 清**([forget]),不是无条件清空:几条同时在飞时,一条被受理(它的键与别的槽
|
||||
* 里记的那条对不上)不该把别条刚记下的重试键抹掉。
|
||||
*
|
||||
* ## 为什么是「多槽」:单槽的边界
|
||||
* 单槽只记得**最近一次**失败那条,而用户的重试顺序不必和失败顺序一致:他重试 A(又失败),再回头
|
||||
* 去重试更早失败的 B —— B 的槽早被 A 顶掉了,那次重发只能铸新键([keyFor] 未命中),于是
|
||||
* 「服务端其实已经收下」的那条 B 落成**第二条**消息,正是本机制要消灭的重复。多槽让每条失败各留
|
||||
* 一个槽,用户重试哪一条就落回哪一条的键,与他的操作顺序无关。
|
||||
*
|
||||
* ## 上限与淘汰:条数上限 8 + 时间上界 10 分钟,两条规则同时生效
|
||||
* 一条记录只在「用户可能还要重试」的窗口里有意义:失败后正文 / 图片 / 附件都还回了输入栏,他要么
|
||||
* 马上原样重发、要么早就改了内容(那就成了另一条消息,记录永远命中不了)。窗口外留下的只是垃圾,
|
||||
* 所以有两把尺子:条数上限 [MAX_SLOTS](满了丢**最旧**的,[remember] 里 FIFO)与时间上界
|
||||
* [SLOT_TTL_MILLIS](比上限还旧的那条,无论排第几都作废)。
|
||||
*
|
||||
* **顺序是死的**:[remember] / [keyFor] **先按时间淘汰**过期槽,再做自己那件事(去重追加 / 查命中),
|
||||
* 写入时**最后**才看条数上限。先按时间清的好处是过期槽不占那份本该给新鲜记录的额度 —— 否则 8 个
|
||||
* 早已过期的槽会把刚写进来的那条挤出去(或者更糟:让 [keyFor] 在一个条数已满、内容却全过期的记忆里
|
||||
* 报「满了」)。
|
||||
*
|
||||
* 刻意**不**做 LRU:命中并消费是**轮询**而不是访问 —— [keyFor] 把命中的那条整个摘走,与它有多旧
|
||||
* 无关(只要求没过期),谈「最近使用」没有意义;也正因如此,剩下的槽天然还是写入顺序。
|
||||
*
|
||||
* ## 与「绝不自动重试」不矛盾
|
||||
* 这里一个字节都不自动重发:复用键只发生在**用户自己又按了一次发送**、且内容一字不差的时候。
|
||||
* 它做的唯一一件事是把那次重发变成幂等的。
|
||||
*/
|
||||
data class RetryKeyMemory(val slots: List<RetryKeySlot> = emptyList()) {
|
||||
|
||||
/**
|
||||
* 记下「这一条没发出去」(回滚时调用)。
|
||||
*
|
||||
* 三件事按固定顺序做,别调换(理由见类注释「上限与淘汰」):
|
||||
* 1. **先按 [atMillis] 淘汰过期槽**——[atMillis] 既是这条记录的出生时刻,也是「现在」;
|
||||
* 2. 同一个指纹**先摘掉旧的、再把新的追加到末尾**:同一条又失败一次是**更新**它的键与时刻,
|
||||
* 而不是并列两条 —— 两条同指纹时 [keyFor] 只会命中更早那条,用户的重试反而用不上刚铸的键,
|
||||
* 而且那条更早的还会**先**过期(明明刚失败过,记录却已经超时,重试退回铸新键);
|
||||
* 3. 超过 [MAX_SLOTS] 就从头部丢最旧的:本函数最多让**末尾**那条变新,淘汰因此绝不会伤到刚写的
|
||||
* 这条。
|
||||
*
|
||||
* @param atMillis 这次失败发生的时刻,由调用方喂(`System.currentTimeMillis()`)。
|
||||
*/
|
||||
fun remember(fingerprint: MessageFingerprint, localId: String, atMillis: Long): RetryKeyMemory {
|
||||
val fresh = purge(atMillis)
|
||||
val kept = fresh.slots.filterNot { it.fingerprint == fingerprint } +
|
||||
RetryKeySlot(fingerprint, localId, atMillis)
|
||||
return RetryKeyMemory(if (kept.size > MAX_SLOTS) kept.takeLast(MAX_SLOTS) else kept)
|
||||
}
|
||||
|
||||
/**
|
||||
* 忘掉 [localId] 那条——受理成功 / 撞上 `duplicate` / 气泡被收编时调用。
|
||||
*
|
||||
* 一条都对不上就**原样返回同一个实例**(一条也不清):槽里躺的多半是别的刚回滚的消息,顺手清掉
|
||||
* 它们会让那几条的重试退回「铸新键」。同一个 `localId` 万一有多条([remember] 保证不会有),
|
||||
* 一并清掉:留着一条键已被服务端受理过的记录,只会让将来的某次重发被当成重复吞掉。
|
||||
*
|
||||
* **与时间无关**:受理回执说的是「这条键服务端认了」,无论它记于几分钟前。此处也不顺手扫过期槽
|
||||
* —— 那要一个调用方没给的「现在」,而且过期槽本来就命中不了([keyFor] 入口会清掉它们),留着
|
||||
* 最多是几个死条目的内存占用。
|
||||
*/
|
||||
fun forget(localId: String): RetryKeyMemory {
|
||||
val kept = slots.filterNot { it.localId == localId }
|
||||
return if (kept.size == slots.size) this else RetryKeyMemory(kept)
|
||||
}
|
||||
|
||||
/**
|
||||
* 这一次发送该用哪个幂等键:内容与某条**未过期**的记录一致 ⇒ **复用**它的键,并**只消费那一条**
|
||||
* (其余槽原样保留 —— 它们对应的是别的失败消息,正等着被各自的重试捡起来);一条都对不上 ⇒
|
||||
* 铸一个新的([mint] 由调用方给,测试才能喂确定性的键,也才能断言「该不该铸」),**且一条槽也不清**。
|
||||
*
|
||||
* 入口**先**按 [nowMillis] 淘汰过期槽,过期的那条**视同不存在**:找不到就铸新键,与「从来没记过」
|
||||
* 这条完全一样。这正是时间上界要挡住的那一幕(见类注释「时间上界」)—— 几小时前失败过的同内容
|
||||
* 消息,今天再打一遍就是一条**新**消息,绝不能因为撞上旧键而被服务端当成重复吞掉。
|
||||
*
|
||||
* 最后这半句是与单槽时代最本质的差别,**别照着旧注释改回去**:那时记忆只有一条,「未命中就清空」
|
||||
* 与「保留」是同一件事;多槽下清空会把用户**别的**失败记录一并抹掉(他重试 A 时铸了新键,接着去
|
||||
* 重试 B,B 的槽已经没了 ⇒ 那次重发退化成非幂等)。未命中只说明「这次的正文没有任何失败记录对得
|
||||
* 上」,不是「记忆都作废了」。(过期槽被摘掉不算例外:它们本来就再也命中不了。)
|
||||
*
|
||||
* @param nowMillis 这次发送的时刻,由调用方喂(`System.currentTimeMillis()`)。
|
||||
*/
|
||||
fun keyFor(
|
||||
fingerprint: MessageFingerprint,
|
||||
nowMillis: Long,
|
||||
mint: () -> String,
|
||||
): RetryKeyDecision {
|
||||
val fresh = purge(nowMillis)
|
||||
val hitIndex = fresh.slots.indexOfFirst { it.fingerprint == fingerprint }
|
||||
if (hitIndex < 0) return RetryKeyDecision(mint(), fresh)
|
||||
|
||||
val consumed = RetryKeyMemory(fresh.slots.filterIndexed { index, _ -> index != hitIndex })
|
||||
return RetryKeyDecision(fresh.slots[hitIndex].localId, consumed)
|
||||
}
|
||||
|
||||
/**
|
||||
* 摘掉在 [nowMillis] 这一刻已经过期的槽;一条都没过期就**原样返回同一个实例**(不必新建对象)。
|
||||
*
|
||||
* 过期判据见类注释「边界口径」——正好等于 [SLOT_TTL_MILLIS] 的那条**留着**。
|
||||
*/
|
||||
private fun purge(nowMillis: Long): RetryKeyMemory {
|
||||
val kept = slots.filterNot { it.isExpiredAt(nowMillis) }
|
||||
return if (kept.size == slots.size) this else RetryKeyMemory(kept)
|
||||
}
|
||||
|
||||
companion object {
|
||||
/** 同时保留的失败记录条数上限;淘汰策略见类注释「上限与淘汰」。 */
|
||||
const val MAX_SLOTS = 8
|
||||
|
||||
/**
|
||||
* 一条失败记录最多活多久(10 分钟):超过它,[keyFor] 就再也认不出这条记录。
|
||||
*
|
||||
* 公开是因为**测试拿它做断言**(别在测试里重写一遍 `10 * 60 * 1000L`,那样改口径时测试不会
|
||||
* 跟着变)。为什么需要这道闸、边界怎么算,见类注释「时间上界」与「边界口径」。
|
||||
*/
|
||||
const val SLOT_TTL_MILLIS = 10 * 60 * 1000L
|
||||
|
||||
/** 这条记录在 [nowMillis] 这一刻是否已过期;`>` 而不是 `>=`,见类注释「边界口径」。 */
|
||||
private fun RetryKeySlot.isExpiredAt(nowMillis: Long): Boolean =
|
||||
nowMillis - atMillis > SLOT_TTL_MILLIS
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,238 @@
|
||||
package top.yeij.cyrene.domain.chat
|
||||
|
||||
/**
|
||||
* 一段**要发出去的语音**:录音参数、WAV 封装、体积闸门与幂等键(纯函数,可穷举单测)。
|
||||
*
|
||||
* ## 为什么自己封 WAV,而不是用 `MediaRecorder`
|
||||
* 契约 v0.96 给 `voice_input.format` 的白名单是 **`wav|mp3|ogg`**,而 Android `MediaRecorder`
|
||||
* 的默认产物是 **`m4a/aac`**——**不在白名单里**。要用它就得另接一个编码器把 aac 转成 mp3/ogg,
|
||||
* 那是新依赖(项目纪律:加依赖先问)。而 `AudioRecord` 给的 PCM + 一个 44 字节的 RIFF 头
|
||||
* 就是 WAV:**零依赖、零转码、逐字节可预期**,也能在单测里把头的每个字段钉死。
|
||||
*
|
||||
* ## 采样参数:16 kHz / 单声道 / 16-bit
|
||||
* - **采样率 16 kHz**:语音识别的行业口径就是 16 kHz(电话带宽 8 kHz 会把擦音削掉,44.1 kHz
|
||||
* 对 STT 没有任何增益)。同一句话的字节数因此只有 44.1 kHz 的 **36%**——这一条在本端格外重要,
|
||||
* 因为 `voice_input.audio` 是**内联 base64**(4/3 膨胀)走一条 WS 文本帧。
|
||||
* - **单声道**:麦克风阵列的立体声对 STT 只有副作用(两路几乎相同,白背一倍体积)。
|
||||
* - **16-bit**:`AudioRecord` 的原生 PCM 编码(`ENCODING_PCM_16BIT`),不做压缩——压缩要依赖。
|
||||
*
|
||||
* 由此得出一个**只有一条**的换算:`1 ms = 32 字节`,`1 s = 32 000 字节`。
|
||||
*
|
||||
* ## 体积闸门(为什么是 5 MiB)
|
||||
* 契约 v0.96 的 `voice_input` **没写** `audio` 的体积上限(`chat.image` 写了:解码后 5 MiB /
|
||||
* 字符串 6990572 字符)。本端按**已知最严的那一档**设计,也就是 [MAX_AUDIO_BYTES] =
|
||||
* `ChatImageBudget.SERVER_MAX_DECODED_BYTES`(5 MiB,解码后)——同一个部署对「内联 base64 字段」
|
||||
* 已经明确接受的量级,voice 没有理由比它更宽。超限**明确拒绝**([VOICE_TOO_LARGE_MESSAGE]),
|
||||
* 绝不静默截断:截断后的音频在服务端听来就是「话说到一半断了」。
|
||||
*
|
||||
* 60 秒的录满长度([MAX_RECORDING_MILLIS])只用到这条闸门的 **36%**(1 920 044 字节),所以
|
||||
* 正常路径永远撞不到它——它是**第二道**闸(硬件偶尔会多给几块缓冲,谁也说不准),不是主判据。
|
||||
*
|
||||
* ## 时长下限
|
||||
* 按下就松手的误触会得到几十毫秒的音频:那不是「说话」,送给服务端只会换来一次空转写。
|
||||
* 不足 [MIN_RECORDING_MILLIS] 的录音在**本端**就被挡下并说明([VOICE_TOO_SHORT_MESSAGE])。
|
||||
*/
|
||||
object VoiceClip {
|
||||
|
||||
/** 采样率(Hz)。16 kHz = STT 的行业口径,见类注释。 */
|
||||
const val SAMPLE_RATE_HZ: Int = 16_000
|
||||
|
||||
/** 声道数:单声道(立体声对 STT 无益,只翻倍体积)。 */
|
||||
const val CHANNEL_COUNT: Int = 1
|
||||
|
||||
/** 位深:`AudioRecord` 的原生 16-bit PCM。 */
|
||||
const val BITS_PER_SAMPLE: Int = 16
|
||||
|
||||
/** 每采样的字节数。 */
|
||||
const val BYTES_PER_SAMPLE: Int = BITS_PER_SAMPLE / 8
|
||||
|
||||
/** RIFF/WAVE 的 PCM 头长度:`RIFF`(4) + size(4) + `WAVE`(4) + `fmt `(16+8) + `data`(8)。 */
|
||||
const val HEADER_BYTES: Int = 44
|
||||
|
||||
/** 上行 `format` 的取值。契约白名单 `wav|mp3|ogg` 里我们**只产出**这一个。 */
|
||||
const val FORMAT: String = "wav"
|
||||
|
||||
/** 每秒的 PCM 字节数(= [SAMPLE_RATE_HZ] × [CHANNEL_COUNT] × [BYTES_PER_SAMPLE])。 */
|
||||
val BYTES_PER_SECOND: Int = SAMPLE_RATE_HZ * CHANNEL_COUNT * BYTES_PER_SAMPLE
|
||||
|
||||
/**
|
||||
* 一段语音(**含头**)的字节上限:与 `chat.image` 解码后同一条线,见类注释。
|
||||
* 刻意**引用**那个常量而不是再写一遍 `5 * 1024 * 1024`:上限只留一处,改的时候不会漂。
|
||||
*/
|
||||
const val MAX_AUDIO_BYTES: Int = ChatImageBudget.SERVER_MAX_DECODED_BYTES
|
||||
|
||||
/**
|
||||
* 一次录音的**最长**时长(毫秒)。60 秒取自 IM 的通行口径(微信 / QQ 都是这个量级),
|
||||
* 也对得上服务端 STT 的现实:再长的语音转写质量与等待都不划算。
|
||||
*
|
||||
* 到点由录音机自己置「满了」标记(见 `VoiceRecorder`),UI 随即收尾发送——
|
||||
* 不是悄悄截断,用户看到的是「到 60 秒了,这段发出去」。
|
||||
*/
|
||||
const val MAX_RECORDING_MILLIS: Int = 60_000
|
||||
|
||||
/** 短于这个时长的录音**不送**(按下就松手的误触),见类注释。 */
|
||||
const val MIN_RECORDING_MILLIS: Int = 1_000
|
||||
|
||||
/** 60 秒对应的 PCM 字节数(录音机据此封顶,不在读循环里另算一遍公式)。 */
|
||||
val MAX_PCM_BYTES: Int = pcmBytesFor(MAX_RECORDING_MILLIS)
|
||||
|
||||
/** 这段 PCM 加上 WAV 头之后的**总**字节数。 */
|
||||
fun totalBytesFor(pcmBytes: Int): Int = if (pcmBytes <= 0) 0 else HEADER_BYTES + pcmBytes
|
||||
|
||||
/** 时长 → PCM 字节数(向下取整;`<= 0` 的时长回 0)。 */
|
||||
fun pcmBytesFor(durationMillis: Int): Int =
|
||||
if (durationMillis <= 0) 0 else (durationMillis.toLong() * BYTES_PER_SECOND / 1000L).toInt()
|
||||
|
||||
/** PCM 字节数 → 时长(毫秒,向下取整)。 */
|
||||
fun durationMillisOf(pcmBytes: Int): Int =
|
||||
if (pcmBytes <= 0) 0 else (pcmBytes.toLong() * 1000L / BYTES_PER_SECOND).toInt()
|
||||
|
||||
/** 总字节数是否在 [MAX_AUDIO_BYTES] 以内(**等于**算通过:`<=`,与 `withinServerLimit` 同口径)。 */
|
||||
fun withinLimit(totalBytes: Int): Boolean = totalBytes <= MAX_AUDIO_BYTES
|
||||
|
||||
/**
|
||||
* WAV 头(44 字节,PCM / 单块 / 无扩展)。
|
||||
*
|
||||
* 逐字段按 RIFF 规范写:`RIFF` + 文件长 - 8 + `WAVE` + `fmt `(16 字节,PCM=1) + 声道 /
|
||||
* 采样率 / 字节率 / 块对齐 / 位深 + `data` + 数据长。全部**小端**。
|
||||
*/
|
||||
fun wavHeader(pcmBytes: Int): ByteArray {
|
||||
val data = ByteArray(HEADER_BYTES)
|
||||
putAscii(data, 0, "RIFF")
|
||||
putIntLe(data, 4, HEADER_BYTES - 8 + pcmBytes)
|
||||
putAscii(data, 8, "WAVE")
|
||||
putAscii(data, 12, "fmt ")
|
||||
putIntLe(data, 16, 16)
|
||||
putShortLe(data, 20, AUDIO_FORMAT_PCM)
|
||||
putShortLe(data, 22, CHANNEL_COUNT)
|
||||
putIntLe(data, 24, SAMPLE_RATE_HZ)
|
||||
putIntLe(data, 28, BYTES_PER_SECOND)
|
||||
putShortLe(data, 32, CHANNEL_COUNT * BYTES_PER_SAMPLE)
|
||||
putShortLe(data, 34, BITS_PER_SAMPLE)
|
||||
putAscii(data, 36, "data")
|
||||
putIntLe(data, 40, pcmBytes)
|
||||
return data
|
||||
}
|
||||
|
||||
/** 头 + PCM = 一个可直接交给任何 `WAV` 解码器的字节数组。 */
|
||||
fun toWav(pcm: ByteArray): ByteArray {
|
||||
val header = wavHeader(pcm.size)
|
||||
val out = ByteArray(HEADER_BYTES + pcm.size)
|
||||
header.copyInto(out, 0)
|
||||
pcm.copyInto(out, HEADER_BYTES)
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* 这段 PCM 该不该发、发出去长什么样(**唯一**的准入判据,录音机与 VM 共用)。
|
||||
*
|
||||
* 判序是刻意的:**空 → 超限 → 太短 → 可发**。
|
||||
* - 空:一个字节都没有(设备没给 / 权限刚被撤),连「太短」都谈不上;
|
||||
* - 超限:长度或字节数已经越线,绝不能发(哪怕时长看起来正常——硬件也可能给出异常长的一块);
|
||||
* - 太短:见 [MIN_RECORDING_MILLIS]。
|
||||
*/
|
||||
fun plan(pcm: ByteArray): VoiceClipPlan {
|
||||
if (pcm.isEmpty()) return VoiceClipPlan.Empty
|
||||
val total = totalBytesFor(pcm.size)
|
||||
if (!withinLimit(total)) return VoiceClipPlan.TooLarge(total)
|
||||
val duration = durationMillisOf(pcm.size)
|
||||
if (duration < MIN_RECORDING_MILLIS) return VoiceClipPlan.TooShort(duration)
|
||||
return VoiceClipPlan.Ready(toWav(pcm), duration)
|
||||
}
|
||||
|
||||
/** RIFF 的 `audioFormat`:1 = PCM(整数)。 */
|
||||
private const val AUDIO_FORMAT_PCM: Int = 1
|
||||
|
||||
private fun putAscii(target: ByteArray, offset: Int, text: String) {
|
||||
for (index in text.indices) target[offset + index] = text[index].code.toByte()
|
||||
}
|
||||
|
||||
private fun putIntLe(target: ByteArray, offset: Int, value: Int) {
|
||||
target[offset] = (value and 0xFF).toByte()
|
||||
target[offset + 1] = ((value ushr 8) and 0xFF).toByte()
|
||||
target[offset + 2] = ((value ushr 16) and 0xFF).toByte()
|
||||
target[offset + 3] = ((value ushr 24) and 0xFF).toByte()
|
||||
}
|
||||
|
||||
private fun putShortLe(target: ByteArray, offset: Int, value: Int) {
|
||||
target[offset] = (value and 0xFF).toByte()
|
||||
target[offset + 1] = ((value ushr 8) and 0xFF).toByte()
|
||||
}
|
||||
}
|
||||
|
||||
/** [VoiceClip.plan] 的四档结论。 */
|
||||
sealed interface VoiceClipPlan {
|
||||
|
||||
/** 一段**可以发**的语音:已经封好 WAV,[durationMs] 是它的实际时长。 */
|
||||
class Ready(val wav: ByteArray, val durationMs: Int) : VoiceClipPlan
|
||||
|
||||
/** 一个字节都没录到。 */
|
||||
data object Empty : VoiceClipPlan
|
||||
|
||||
/** 短于 [VoiceClip.MIN_RECORDING_MILLIS](按下就松手那种误触)。 */
|
||||
data class TooShort(val durationMs: Int) : VoiceClipPlan
|
||||
|
||||
/** 总字节数超过 [VoiceClip.MAX_AUDIO_BYTES]。 */
|
||||
data class TooLarge(val totalBytes: Int) : VoiceClipPlan
|
||||
}
|
||||
|
||||
/** 空录音的如实告知(**不是错误**:什么都没发生,用户重来一次即可)。 */
|
||||
const val VOICE_EMPTY_MESSAGE: String = "没有录到声音,再按一次试试"
|
||||
|
||||
/** 太短的如实告知。 */
|
||||
const val VOICE_TOO_SHORT_MESSAGE: String = "录得太短了,说满一秒再发"
|
||||
|
||||
/**
|
||||
* 超限的如实告知。刻意说「没发出去」:那句话必须与事实上一致(这条一个字节都没上行),
|
||||
* 也不能把责任推给用户(他确实就说了那么久,是本端的闸门挡下的)。
|
||||
*/
|
||||
const val VOICE_TOO_LARGE_MESSAGE: String = "这段语音太长,本机没有发出去"
|
||||
|
||||
/** 这一条被本端挡下时该说的那句话;`null` = 可以发(纯函数)。 */
|
||||
fun voiceClipRejection(plan: VoiceClipPlan): String? = when (plan) {
|
||||
is VoiceClipPlan.Ready -> null
|
||||
VoiceClipPlan.Empty -> VOICE_EMPTY_MESSAGE
|
||||
is VoiceClipPlan.TooShort -> VOICE_TOO_SHORT_MESSAGE
|
||||
is VoiceClipPlan.TooLarge -> VOICE_TOO_LARGE_MESSAGE
|
||||
}
|
||||
|
||||
/**
|
||||
* 录音中 / 待重发那一段的时长文案(纯函数):`m:ss`,**从 0:00 起就有值**。
|
||||
*
|
||||
* 与接收侧的 [voiceDurationLabel] 差一条:那个对「不足一秒」回 `null`(服务端没给准数时宁可
|
||||
* 不显示),而这里显示的是**本端自己正在数的时间**——0:00 是事实,不是冒充。
|
||||
* 负数按 0 处理(时钟回拨 / 调用方算错时也不出现 `-1:-1`)。
|
||||
*/
|
||||
fun voiceElapsedLabel(elapsedMs: Int): String {
|
||||
val totalSeconds = elapsedMs.coerceAtLeast(0) / 1000
|
||||
return "${totalSeconds / 60}:${(totalSeconds % 60).toString().padStart(2, '0')}"
|
||||
}
|
||||
|
||||
/** 语音消息的幂等键前缀:与文本那条(`local-`)/ 分享那条(`share-`)同一手法,见 [voiceClientMsgIdFor]。 */
|
||||
const val VOICE_CLIENT_MSG_ID_PREFIX: String = "voice-"
|
||||
|
||||
/**
|
||||
* 一条**录好、还没发出去**的语音在本端的记账(只有小字段,audio 不在这里)。
|
||||
*
|
||||
* `clientMsgId` 是契约 v0.96 §3.15 的幂等键:同一个非空值服务端**只承认第一条**,重复到达回
|
||||
* `chat_duplicate`、不落库不起回合。所以「重发这一段」必须**复用**它([voiceClientMsgIdFor]),
|
||||
* 换了新键就是让服务端把同一句话再答一遍。
|
||||
*
|
||||
* `audio`(base64)刻意**不进** [top.yeij.cyrene.ui.chat.ChatUiState]:那是一格会被反复 `copy`
|
||||
* 并逐字段比较的状态(流式期间每秒几十次),几 MB 的字符串放进去等于每次比较都扫一遍它。
|
||||
* 字节留在 VM 自己那个字段上(生命周期与 VM 相同,转屏不丢)。
|
||||
*/
|
||||
data class VoiceDraftInfo(
|
||||
val clientMsgId: String,
|
||||
val durationMs: Int,
|
||||
val totalBytes: Int,
|
||||
)
|
||||
|
||||
/**
|
||||
* 这一段该用哪个幂等键(纯函数):
|
||||
* - [draft] 非空 = **重发刚才没发出去的那一段** ⇒ 原样复用它当时的键(契约要求);
|
||||
* - [draft] 为空 = 一段**新**录音 ⇒ 现铸一个:前缀 + [mint] 给的随机串(随机而不是自增,
|
||||
* 理由与文本那条相同:服务端的判重窗口是整个会话历史)。
|
||||
*/
|
||||
fun voiceClientMsgIdFor(draft: VoiceDraftInfo?, mint: () -> String): String =
|
||||
draft?.clientMsgId ?: "$VOICE_CLIENT_MSG_ID_PREFIX${mint()}"
|
||||
@@ -0,0 +1,311 @@
|
||||
package top.yeij.cyrene.domain.chat
|
||||
|
||||
import top.yeij.cyrene.core.error.CyreneError
|
||||
import top.yeij.cyrene.data.api.CyreneException
|
||||
import top.yeij.cyrene.data.api.dto.HistoryMessageDto
|
||||
import top.yeij.cyrene.data.api.dto.VoiceDto
|
||||
import top.yeij.cyrene.data.repository.DocumentCacheUnavailableException
|
||||
import top.yeij.cyrene.data.repository.VoiceTooLargeException
|
||||
import top.yeij.cyrene.data.repository.VoiceUrlUnavailableException
|
||||
|
||||
/**
|
||||
* `voice.source` 的闭集(契约 §4.4):`asr` = 服务端(含云端)语音识别所得;
|
||||
* `platform` = 渠道平台自带转写(微信 iLink 的 `voice_item.text`);
|
||||
* `tts` = **我们自己合成的出站语音**(她的 `speak` 工具产出的那一段,后端 M57-M59 新增)。
|
||||
*
|
||||
* 三个取值在**接收侧**没有分叉:合成成功的那条与入站语音同形(`status = transcribed` + 四键
|
||||
* 副本齐全),照同一条路渲染;合成降级的那条是 `status = failed`(见 [VoiceStatus.FAILED])。
|
||||
*/
|
||||
object VoiceSource {
|
||||
|
||||
const val ASR: String = "asr"
|
||||
|
||||
const val PLATFORM: String = "platform"
|
||||
|
||||
const val TTS: String = "tts"
|
||||
}
|
||||
|
||||
/**
|
||||
* `voice.status` 的闭集(契约 §4.4,**三态**)。
|
||||
*
|
||||
* 与 [DocumentStatus] 同一条纪律:**只认闭集里的取值,认不出的不猜**——但两者收尾的方式不同。
|
||||
* 文档的未知 `status` 要**原样显示**(那是给用户看的结论),语音的未知 `status` 只是
|
||||
* 「本端不多说一句话」(正文由服务端给,见 [voiceNote])。
|
||||
*/
|
||||
object VoiceStatus {
|
||||
|
||||
/** 正文**就是**转写文本(出站方向 = 合成成功、有副本可播)。 */
|
||||
const val TRANSCRIBED: String = "transcribed"
|
||||
|
||||
/** 转写成功但**没识别出文字** ⇒ 正文多半是空的,本端补一句([VOICE_EMPTY_NOTE])。 */
|
||||
const val EMPTY: String = "empty"
|
||||
|
||||
/**
|
||||
* 这一条**没有可播的语音**,正文是一句话(见 [isPlayableVoice])。
|
||||
*
|
||||
* 两个方向同形、语义统一(后端 M57-M59 预告,契约 v0.98 待正式回执):
|
||||
* - **入站**:没拿到转写文本 ⇒ 正文是服务端给的那行中文诚实提示;
|
||||
* - **出站**:她本想用语音、合成降级回文字 ⇒ 正文是普通文字,另带 `fallbackReason`
|
||||
* (见 [voiceFallbackNote])。
|
||||
*
|
||||
* 入站方向设计上**刻意不下发**失败细分(未配置 / 鉴权 / 欠费 / 限流 / 超时 / 格式不支持 /
|
||||
* 无字节一律只进服务端日志),所以本端**一个细分都不许猜**,也不替服务端改写那句话。
|
||||
*/
|
||||
const val FAILED: String = "failed"
|
||||
}
|
||||
|
||||
/**
|
||||
* 「转写成功但没识别出文字」时本端补的那一句。
|
||||
*
|
||||
* 为什么这句是**本端的**而不是服务端的原话:这一态的语义由契约定义(§4.4 的三态之一),而
|
||||
* `empty` 的正文按定义就是空的——不补一句的话,一条「微信语音、没有音频副本、也没转出文字」
|
||||
* 的消息在屏幕上**只剩一个「语音」标签**,看的人无从知道发生了什么。这不是替服务端改文案
|
||||
* (那句话说的是**这一态的语义**,不是失败原因),也**不覆盖**任何正文。
|
||||
*/
|
||||
const val VOICE_EMPTY_NOTE: String = "没有识别出文字"
|
||||
|
||||
/**
|
||||
* `status == "failed"` 的那一行**灰色小标**的固定前半句(后面可能跟服务端给的 `fallbackReason`)。
|
||||
*
|
||||
* 为什么要有这一行:这一态的消息 `voice` 键**在**、但**没有可播的语音**(见 [isPlayableVoice]),
|
||||
* 所以屏幕上不会有语音形态。少了这一行,一条「她本想发语音、合成降级成文字」的消息与一条普通文字
|
||||
* 消息**逐像素相同**——看的人无从知道这条本来是一段声音。它只说本端能确定的那件事(这条没有可播的
|
||||
* 声音),**不解释原因**(原因归服务端,见 [voiceFallbackNote] 里那段原话)。
|
||||
*
|
||||
* ⚠️ 措辞要同时站得住两个方向(入站转写失败 / 出站合成降级),所以**不写**「转写失败」也**不写**
|
||||
* 「合成失败」:本端分不清,也不该替服务端选一个说法。
|
||||
*/
|
||||
const val VOICE_FALLBACK_NOTE: String = "这条语音没有可播放的声音"
|
||||
|
||||
/**
|
||||
* 解码 / 播放阶段失败(`MediaPlayer` 报错或起播抛异常)时的文案。
|
||||
*
|
||||
* 与取字节失败([voicePlaybackFailureMessage])分开:两者要用户做的事不一样——字节没拿到是
|
||||
* 「网络 / 过期 / 服务端没有这份副本」,可以再点一次;这一段是「字节在手上,本机解不开」
|
||||
* (格式不支持、文件损坏),再点一次多半还是同样的结果。
|
||||
*/
|
||||
const val VOICE_UNPLAYABLE_MESSAGE: String = "这段语音本机播不了"
|
||||
|
||||
/**
|
||||
* 一条语音的**可播放副本**:四个键(`name` / `mime` / `size` / `url`)齐全时才构造得出来。
|
||||
*
|
||||
* 存在的理由是把「能不能播」这件事**收成一个类型**,而不是让 UI / 播放器各自去测四个 `!= null`:
|
||||
* 拿到 [VoiceAudio] 就等于「有副本、可以画播放控件、可以发请求」;拿不到就**一个播放控件都不画**
|
||||
* (不能给一个点下去必然失败的按钮)。
|
||||
*
|
||||
* @property durationMs 从 [VoiceDto] 原样带过来;`null` = 服务端没给,**不显示时长**。
|
||||
*/
|
||||
data class VoiceAudio(
|
||||
val name: String,
|
||||
val mime: String,
|
||||
val size: Long,
|
||||
val url: String,
|
||||
val durationMs: Int? = null,
|
||||
)
|
||||
|
||||
/**
|
||||
* `voice` 键在不在(纯函数)—— 「这条消息**来自**一条语音」。
|
||||
*
|
||||
* 这是契约 §4.4 给的**唯一**来源判据:非语音消息整键缺席(不是 `{}` 也不是 `null`),
|
||||
* `content` 里有没有 `[语音]` 这类字样与本判据无关(QQ 的占位标记自 v0.67 起不再下发,
|
||||
* 别人也可以自己打出一模一样的字)。
|
||||
*
|
||||
* ⚠️ 它与「要不要画语音气泡」**不是同一件事**了:合成降级 / 转写失败的那些行 `voice` 键照样在,
|
||||
* 但**没有可播的语音**(见 [isPlayableVoice])。判「是不是语音消息」用本函数,判「画不画语音
|
||||
* 形态」用 [isVoiceMessage]——两个问题分开问,混用会让降级行被画成一个必然播不了的气泡。
|
||||
*/
|
||||
fun hasVoice(message: HistoryMessageDto): Boolean = message.voice != null
|
||||
|
||||
/**
|
||||
* 这条语音**有没有可播的语音形态**(纯函数):除了 `voice` 键要在,`status` 还不能是 `failed`。
|
||||
*
|
||||
* ## 为什么判据是「不是 `failed`」而不是「是 `transcribed` / `empty`」
|
||||
* 后端 M57-M59 预告(契约 v0.98 待正式回执)只点名了 `failed` 这一态**明确没有可播的语音**:
|
||||
* 入站是没拿到转写文本、出站是合成降级回文字,两个方向的正文都是一句话而不是一段语音。
|
||||
*
|
||||
* 反过来,认不出的 `status` **一律当可播**(fail-open)。理由是这个方向错得便宜、另一个方向错得
|
||||
* 贵:后端将来若新增一个成功态(比如 `transcribed` 之外的 `truncated`),fail-open 只是多画一枚
|
||||
* 语音形态(最坏是点了播不出来,有既有的失败文案兜着);fail-closed 则会把一条**真的有副本**的
|
||||
* 语音静默吞成纯文字,用户再也点不到那段声音——而且没有任何界面提示说明为什么。所以这里只把
|
||||
* 契约明确说过「没有语音」的那一态排除掉,其余交给「四键齐全才画播放控件」([voiceAudio])。
|
||||
*/
|
||||
fun isPlayableVoice(voice: VoiceDto): Boolean = voice.status != VoiceStatus.FAILED
|
||||
|
||||
/**
|
||||
* 🎤 **要不要画语音形态的判据** = `voice` 键存在 **且** `status != "failed"`(契约 §4.4 +
|
||||
* 后端 M57-M59 预告)。
|
||||
*
|
||||
* 只有这两个条件,而且**永远只有这两个**:
|
||||
* - `voice` 键缺席 ⇒ 不是语音消息([hasVoice]);
|
||||
* - `status == "failed"` ⇒ 这条**没有可播的语音**(入站转写失败 / 出站合成降级),正文照常按文字
|
||||
* 渲染,另加一行灰色小标([voiceFallbackNote])——**不**画语音形态,因为画出来就是一个必然
|
||||
* 播不了的控件。
|
||||
*
|
||||
* 入站语音(`asr` / `platform`)与合成成功的出站语音(`tts`)走的是**同一条**路:判据只看
|
||||
* `status`,不看 `source`。
|
||||
*/
|
||||
fun isVoiceMessage(message: HistoryMessageDto): Boolean {
|
||||
val voice = message.voice ?: return false
|
||||
return isPlayableVoice(voice)
|
||||
}
|
||||
|
||||
/**
|
||||
* 这条语音的**可播放副本**;四个键没凑齐(或名字 / MIME / URL 是空白)就回 `null`。
|
||||
*
|
||||
* 服务端保证「四者同生共死」,本端仍然逐项检查:**这条判据的下游是「给不给播放控件」**,
|
||||
* 而契约里那份保证一旦被某个部署打破,唯一的后果就是用户点到一个必然失败的按钮
|
||||
* ——宁可少画一个控件,也不要画一个假的。
|
||||
*
|
||||
* `size` 只要求在(**不要求 > 0**):契约说它恒在,本端也不拿它推导别的任何东西
|
||||
* (尤其**不反算时长**,见 [voiceDurationLabel])。
|
||||
*/
|
||||
fun voiceAudio(voice: VoiceDto?): VoiceAudio? {
|
||||
if (voice == null) return null
|
||||
val name = voice.name?.takeIf { it.isNotBlank() } ?: return null
|
||||
val mime = voice.mime?.takeIf { it.isNotBlank() } ?: return null
|
||||
val url = voice.url?.takeIf { it.isNotBlank() } ?: return null
|
||||
val size = voice.size ?: return null
|
||||
return VoiceAudio(name = name, mime = mime, size = size, url = url, durationMs = voice.durationMs)
|
||||
}
|
||||
|
||||
/**
|
||||
* 时长文案(纯函数):`m:ss`(如 `0:03` / `1:05`)。
|
||||
*
|
||||
* **缺席就不显示**是契约的硬要求(服务端「读不出来就省略键,绝不估算」,本端同理):
|
||||
* - `null` ⇒ 回 `null`(调用方因此**一个字符都不画**,更不会出现 `0:00` 那种冒充);
|
||||
* - `<= 0` ⇒ 也回 `null`:`0` 毫秒的语音不存在,把服务端的一个异常取值显示成 `0:00`
|
||||
* 就是拿一个编出来的数字冒充事实;
|
||||
* - **不足一秒**(`1..999`)⇒ 也回 `null`:`m:ss` 只能把它显示成 `0:00`,而 `0:00` 与
|
||||
* 「没有时长」在屏幕上是同一个样子——与其显示一个看起来像没读到的时间,不如什么都不显示
|
||||
* (渠道也不会真的发这种语音);
|
||||
* - 不设上限:语音消息不会长到需要小时制,超过 99 分钟就照实显示 `124:03`(**不折算**成
|
||||
* 「2:04:03」——那是另一种自己发明的表达)。
|
||||
*/
|
||||
fun voiceDurationLabel(durationMs: Int?): String? {
|
||||
if (durationMs == null || durationMs < 1_000) return null
|
||||
val totalSeconds = durationMs / 1000
|
||||
val minutes = totalSeconds / 60
|
||||
val seconds = totalSeconds % 60
|
||||
return "$minutes:${seconds.toString().padStart(2, '0')}"
|
||||
}
|
||||
|
||||
/**
|
||||
* 语音形态**之外**还要多说的一句话;不需要多说就回 `null`(纯函数)。
|
||||
*
|
||||
* 只对 `status == "empty"` 且**正文为空**时成立:
|
||||
* - `transcribed`:正文**就是**转写文本,多说一句都是噪声(所以什么都不补);
|
||||
* - `empty` 而正文非空:正文本身就是要说的话,此时再说「没有识别出文字」会**自相矛盾**
|
||||
* (这一形状按契约不该出现,但真出现了也不该让屏幕上出现两句互相顶嘴的话);
|
||||
* - 未知 `status`(含空串):**不猜**,只显示正文。
|
||||
*
|
||||
* ⚠️ `failed` 那一态**不走这里**(它连语音形态都不画):正文由服务端给,本端另加一行小标
|
||||
* ([voiceFallbackNote])。本函数仍对 `failed` 回 `null`,因为「语音形态旁边多说一句」这个位置
|
||||
* 对那一态根本不存在。
|
||||
*/
|
||||
fun voiceNote(status: String, content: String): String? =
|
||||
if (status == VoiceStatus.EMPTY && content.isBlank()) VOICE_EMPTY_NOTE else null
|
||||
|
||||
/**
|
||||
* `status == "failed"` 时那行灰色小标该写什么;别的状态回 `null`(纯函数)。
|
||||
*
|
||||
* 两段拼成一句话:**固定前半句**([VOICE_FALLBACK_NOTE],说的是本端能确定的事实)+ 服务端给的
|
||||
* `fallbackReason`(**一个字符都不改**,原样放进括号里)。
|
||||
*
|
||||
* ## 为什么原样放、不做映射
|
||||
* `fallbackReason` 的取值集合**后端还没定**(`devdocs/02` 里我们已经问了「是闭集还是自由文本」,
|
||||
* 尚未答复;契约 v0.98 的正式回执也还没到)。在有答案之前,本端**不猜**它是不是闭集、更不替它
|
||||
* 编一套友好文案:认不出的取值被映射成一句话,比原样显示更难排查——用户报「显示成『语音超时』」
|
||||
* 时,我们连服务端到底给了什么都不知道。原样显示,最坏是屏幕上出现一个 `tts_timeout` 这样的词,
|
||||
* 而它**正是**我们和后端对账时要的那个字符串。
|
||||
*
|
||||
* ## `fallbackReason` 缺席时
|
||||
* 只有固定前半句。这一档同时是**空正文 + `failed`** 的兜底:这一态的消息正文按契约应当非空
|
||||
* (服务端那行诚实提示 / 降级后的原文本),但本端**不靠**这条保证——真收到一条空正文的 `failed`
|
||||
* 行时,屏幕上至少还有这行小标,而不是一个**什么都没有**的空白(那正是空气泡那种最糟的呈现)。
|
||||
*/
|
||||
fun voiceFallbackNote(voice: VoiceDto?): String? {
|
||||
if (voice == null || voice.status != VoiceStatus.FAILED) return null
|
||||
val reason = voice.fallbackReason?.takeIf { it.isNotBlank() } ?: return VOICE_FALLBACK_NOTE
|
||||
return "$VOICE_FALLBACK_NOTE($reason)"
|
||||
}
|
||||
|
||||
/**
|
||||
* 把服务端给的**相对路径** [url] 接到当前服务器地址 [baseUrl] 上(纯函数)。
|
||||
*
|
||||
* 契约明写 `url` 是唯一的派生字段(`/api/v1/media/<name>`),**客户端绝不自己拼路径**——所以
|
||||
* 本函数只做「保留 path、换成当前 host」这一件事,与 [ImageHostRebase] 同一取向
|
||||
* (图片那条要换的是**别人给过的绝对地址**里的 host,这里要接的是**服务端给的相对路径**)。
|
||||
*
|
||||
* 判据取最小容忍度,任何一条不满足就回 `null`(调用方据此如实报「暂时播不了」,**绝不**
|
||||
* 退回「自己拼一条 `/api/v1/media/<name>`」——那正是契约禁止的那件事):
|
||||
* - [url] 必须以 `/api/v1/media/` 开头,且后面**恰好**是一段名字:非空、不是 `.` / `..`、
|
||||
* 不含 `/` `\` `%` `?` `#` 与 NUL;
|
||||
* - [baseUrl] 必须能解出 `scheme://authority`(空串 / 没有 host 都解不出)。
|
||||
*
|
||||
* 用的是 [baseUrl] 的 scheme + authority(所以 `http → https`、端口变更都跟着走),**不用**
|
||||
* baseUrl 里的路径:这条地址挂在根路径上,部署形态本来就没有路径前缀这一说。
|
||||
*/
|
||||
fun voiceMediaUrl(url: String, baseUrl: String): String? {
|
||||
val name = url.removePrefix(MEDIA_PREFIX)
|
||||
if (!url.startsWith(MEDIA_PREFIX) || !isSafeMediaName(name)) return null
|
||||
val origin = originOf(baseUrl) ?: return null
|
||||
return origin + url
|
||||
}
|
||||
|
||||
/**
|
||||
* 取字节失败时的文案(纯函数,可单测)。
|
||||
*
|
||||
* 与 `documentLoadFailureMessage` / `imageLoadFailureMessage` 同一纪律:服务端能说清的用它的
|
||||
* 原话,本端能分类的分开说,**绝不把失败渲染成「静音」**——一条点了没反应的语音消息在用户眼里
|
||||
* 与「坏了」没有区别。
|
||||
*
|
||||
* 三档本地失败分开写,因为它们要用户做的事不一样:地址拼不出来 = 本端环境不对(重新登录 / 换
|
||||
* 服务器);太大 = 这条本端处理不了;缓存写不进去 = 本端的问题,重试多半还是这样。
|
||||
*/
|
||||
fun voicePlaybackFailureMessage(e: Throwable): String = when (e) {
|
||||
is VoiceUrlUnavailableException -> "当前服务器地址不可用,暂时播不了这条语音"
|
||||
is VoiceTooLargeException -> "这条语音太大,本机放不了"
|
||||
is DocumentCacheUnavailableException -> "本地缓存不可用,暂时播不了这条语音"
|
||||
is CyreneException -> when {
|
||||
// ⚠️ 刻意**不**走 `toDocumentFailure()`:那个映射只认文档那四个码(`document_not_found`),
|
||||
// `media_not_found` 在它眼里是「未知码」⇒ 会把「服务端上已经没有这条语音了」说成
|
||||
// 「语音加载失败,media not found」。这一档本端说得准,就说得准一点。
|
||||
e.error.isMediaNotFound() -> "服务端上已经没有这条语音了"
|
||||
else -> "语音加载失败," + e.error.userMessage
|
||||
}
|
||||
|
||||
else -> "语音加载失败,请稍后重试"
|
||||
}
|
||||
|
||||
/** 服务端对「名字非法 / 穿越 / 不存在」统一回这一个码(U19:**不区分**,不给探测面)。 */
|
||||
private const val MEDIA_NOT_FOUND_CODE = "media_not_found"
|
||||
|
||||
private fun CyreneError.isMediaNotFound(): Boolean =
|
||||
this is CyreneError.Server && code == MEDIA_NOT_FOUND_CODE
|
||||
|
||||
/** 服务端相对路径的前缀(契约 §1.2 U19)。 */
|
||||
private const val MEDIA_PREFIX = "/api/v1/media/"
|
||||
|
||||
/**
|
||||
* 媒体名是不是「恰好一段」:不许有分隔符、百分号编码、查询串,也不许是 `.` / `..`。
|
||||
*
|
||||
* 与 `ImageHostRebase` 的同名判据同一条理由:这个字符串来自**别人给的**数据,
|
||||
* 看不懂的形态就不该被我们拼进一条请求地址。
|
||||
*/
|
||||
private fun isSafeMediaName(name: String): Boolean {
|
||||
if (name.isEmpty() || name == "." || name == "..") return false
|
||||
return name.none { it == '/' || it == '\\' || it == '%' || it == '?' || it == '#' || it == '\u0000' }
|
||||
}
|
||||
|
||||
/** `scheme://authority`;解不出来回 `null`(空串 / 没有 host / 不是 http(s) 都是解不出来)。 */
|
||||
private fun originOf(baseUrl: String): String? {
|
||||
val rest = when {
|
||||
baseUrl.startsWith("http://", ignoreCase = true) -> baseUrl.substring("http://".length)
|
||||
baseUrl.startsWith("https://", ignoreCase = true) -> baseUrl.substring("https://".length)
|
||||
else -> return null
|
||||
}
|
||||
val end = rest.indexOfFirst { it == '/' || it == '?' || it == '#' }.let { if (it < 0) rest.length else it }
|
||||
val authority = rest.substring(0, end)
|
||||
if (authority.isBlank()) return null
|
||||
return baseUrl.take(baseUrl.length - rest.length) + authority
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
package top.yeij.cyrene.domain.chat
|
||||
|
||||
/**
|
||||
* `voice_output` 的**判据**(纯函数,可穷举单测):契约 v0.96 §3.15 下行帧。
|
||||
*
|
||||
* 帧形状(后端 `回执三十六`):
|
||||
* ```json
|
||||
* { "type": "voice_output",
|
||||
* "data": { "audio": "<base64>", "format": "wav", "text": "...", "reply": "她的话",
|
||||
* "sessionId": "web", "deviceId?": "...", "provider?": "silent|emotiontts|..." } }
|
||||
* ```
|
||||
*
|
||||
* ## `text` 与 `reply` 是两个东西(本端怎么用)
|
||||
* - **[text]**:这一轮**转写出来的话**——也就是我们那段音频被识别成什么。它与
|
||||
* `voice_input.text`(「已自行转写则跳过 STT」)同一个键名、同一个语义,只是方向相反。
|
||||
* - **[reply]**:**她说的话**(音频念的就是它),也就是用户真正要听/要看的那一句。
|
||||
*
|
||||
* 于是「屏幕上该显示哪一句」的答案是 [voiceReplyText]:**`reply` 优先**,它缺席时才退到 `text`
|
||||
* (宁可显示转写也不显示空白),两者都缺席就什么都不显示(绝不编一句)。
|
||||
*
|
||||
* ## `provider` 是**降级信号**,不是装饰
|
||||
* `provider == "silent"` = 这一轮**没有真音频**(`audio` 空,但 `text` / `reply` 有)。
|
||||
* 契约明写「UI 别把它画成播放器」——画了就是一个点下去必然失败的按钮(与接收侧
|
||||
* `voiceAudio` 的「四个键不齐就不画播放控件」同一条纪律)。
|
||||
*
|
||||
* 除此之外 `audio` 空白也按「没有音频」处理([voiceOutputHasAudio]):契约说 silent 时
|
||||
* `audio` 为空,反过来「空 audio + 没给 provider」也绝不能画出一个播放器。
|
||||
*
|
||||
* ## 它与 `voice` 键那条(接收侧放别人发来的语音)**互不相干**
|
||||
* 见 [isVoiceMessage]/[voiceAudio]:那是消息 DTO 的历史副本,这里是**本端刚发出去的那一段**的
|
||||
* 实时应答——两者不共用判据,共用只会把两套字段口径搅在一起。
|
||||
*/
|
||||
|
||||
/** `provider` 里那个**降级档**的取值(契约 v0.96)。 */
|
||||
object VoiceProvider {
|
||||
|
||||
/** 这一轮**没有真音频**(`audio` 空、`text`/`reply` 有)。 */
|
||||
const val SILENT: String = "silent"
|
||||
}
|
||||
|
||||
/**
|
||||
* 这一帧是不是「没有真音频」那一档(纯函数)。
|
||||
*
|
||||
* 只认字面 `silent`(忽略首尾空白与大小写:它是机器可读码,不是文案)。未知取值一律**不当**
|
||||
* silent——那是另一个 provider,`audio` 在不在由 [voiceOutputHasAudio] 按字节判,不靠猜。
|
||||
*/
|
||||
fun isSilentVoiceProvider(provider: String?): Boolean =
|
||||
provider?.trim().equals(VoiceProvider.SILENT, ignoreCase = true)
|
||||
|
||||
/**
|
||||
* 这一轮**有没有真音频**(纯函数):`provider != silent` **且** `audio` 非空白。
|
||||
*
|
||||
* 两个条件都不许省:silent 档的 `audio` 契约上就是空的(万一真给了也不算数,因为服务端已经
|
||||
* 明说这一轮没发音),而非 silent 档的 `audio` 也可能因为编码失败缺席。
|
||||
*/
|
||||
fun voiceOutputHasAudio(audio: String?, provider: String?): Boolean =
|
||||
!isSilentVoiceProvider(provider) && !audio.isNullOrBlank()
|
||||
|
||||
/**
|
||||
* 屏幕上该显示的那一句(纯函数,见文件头「`text` 与 `reply` 是两个东西」)。
|
||||
* `reply` 非空白 ⇒ 它;否则 `text` 非空白 ⇒ 它;都没有 ⇒ `null`(一个字符都不显示)。
|
||||
*/
|
||||
fun voiceReplyText(reply: String?, text: String?): String? =
|
||||
reply?.takeIf { it.isNotBlank() } ?: text?.takeIf { it.isNotBlank() }
|
||||
|
||||
/**
|
||||
* 这张卡片下面要补的那一句(纯函数:不需要补时回 `null`)。
|
||||
*
|
||||
* 两档**都要解释,但说的不是同一件事**:
|
||||
* - [silent]:**服务端**这一轮没有真音频(`provider == "silent"`)⇒「她只回了文字」。不解释的话,
|
||||
* 用户按着麦克风说话、回来的却是一段文字,分不清「她选择只回文字」与「播放器坏了」。
|
||||
* - 有音频却没落地([hasAudioFile] 为假):那是**本端**解码 / 写缓存失败 ⇒ 说「这段语音本机没
|
||||
* 准备好」,绝不把本机的故障说成「她只回了文字」(那是替服务端下了一个它没下的结论)。
|
||||
*/
|
||||
fun voiceReplyNote(silent: Boolean, hasAudioFile: Boolean): String? = when {
|
||||
silent -> VOICE_SILENT_NOTE
|
||||
!hasAudioFile -> VOICE_REPLY_LOCAL_UNAVAILABLE_NOTE
|
||||
else -> null
|
||||
}
|
||||
|
||||
/**
|
||||
* 「这一轮没有音频」的说明。刻意说清是**服务端**这一轮没给音频(`provider` 的降级信号),
|
||||
* 而不是「本机播不了」——后者会把人引向「换个播放器」这种无用的动作。
|
||||
*/
|
||||
const val VOICE_SILENT_NOTE: String = "这一轮她只回了文字,没有语音"
|
||||
|
||||
/**
|
||||
* 「音频有、但本机没准备好」的说明(解码 / 写缓存失败)。
|
||||
*
|
||||
* 与 [VOICE_SILENT_NOTE] 分开是必须的:这一档的真相是**本端**的问题,而文字照样在
|
||||
* ([voiceReplyText] 已经把它取出来了),所以用户至少还能读到她说的话。
|
||||
*/
|
||||
const val VOICE_REPLY_LOCAL_UNAVAILABLE_NOTE: String = "这段语音本机没能准备好,先看文字"
|
||||
Loaded 100 of 333 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user