4 Commits
Author SHA1 Message Date
AskaEth 5dc6408bb9 merge: W49 —— 非激活会话落新消息时节流静默重拉(3 秒窗口 + 结构性 trailing),让抽屉红点在不开抽屉时也能出现 2026-10-05 22:28:39 +08:00
AskaEth 93cf3e359a fix(sessions): 非激活会话落新消息时自愈式节流重拉,抽屉红点不再等用户开一次抽屉(S6)
现象:App 停在主会话时,次级会话落了新消息 ⇒ 抽屉红点不出现,必须打开一次抽屉
(那次静默 refresh)才补上。

根因:message_persisted 只在 sessionId == activeSessionId 时才推进 / 更新,非激活
会话那一帧直接 return;而会话列表更新的触发点此前只有抽屉打开 / 新建 / 删除 / init
四处,于是「别的会话攒了新消息」没有任何人会去发现。

为什么是客户端自愈而不是等后端:这一帧不带 unreadCount(契约面缺一个增量),而本端
没有逐条时间线 —— 自己累加出来的数会与编辑 / 删除 / 别端操作漂移,那是造数不是修复。
帧壳本身已经说清「某个会话有新消息落库」这件事,所以本端只是据此去问一次服务端,
走的是与抽屉打开时那次完全相同的路径(refresh(silent = true),含已读游标遮罩),
只是不需要用户先开一次抽屉。后端把增量补上之后这条路可以退成不必走,但在此之前它
是唯一能把红点点亮的路。

节流窗口 3 秒,理由:
- 够快:3 秒落在「看起来就是刚发生的」这个感知里;5 秒往上就开始像卡了一下才补上;
- 够粗:一次静默重拉是两个请求(refresh 里还跟着一次角色目录),而播出方是按每条
  消息推帧的 —— 3 秒能把人手连打 5 条(约 2~4 秒)或一轮回复的连续落库收进同一个
  窗口,收到 1 秒就会漏成两三次请求;
- 不另立尺子:3 秒正是本项目已有的「等够久才值得为一次等待开口」那个量级
  (ChatScreen 的 SHARE_WAIT_NOTICE_MILLIS)。

trailing 怎么保证:窗口内的第二条起不拉,但排一次「窗口到点补拉」(等待时长 =
窗口 - 已过时间),到点直接拉一次并把窗口重开;同一时刻最多一个补拉任务(已排上时
后续成因只更新账本、不重排),所以最后一条之后必定还会再拉一次,红点不会停在旧值。
另外「正好等于窗口」算窗口已过(>= 而不是 >),补拉到点那一刻不会被自己刚开的窗口
挡回去。

哪几种情况不拉:当前打开的那个会话落新消息(那条路本来就有「看到即已读」,多拉一次
列表就是白花两个请求);同一窗口内的后续消息;窗口内且补拉已经排上。另外切会话那条
路刻意不接进来 —— select 紧接着就发推进已读游标的请求,此时重拉会与还没落地的游标
抢跑、把刚清掉的角标闪回来(抽屉打开时那一次紧跟在游标之后,本来就会补上这一拉)。

判据抽成纯函数(新增 InactiveSessionPull.kt):persistedRoute 决定这一帧走哪条路,
silentRefreshStep 决定这一刻该不该拉、拉完账本怎么走;时钟由调用方喂,不用 sleep
也就能穷举边界。

顺带把身份变化(重登成另一个账号 / 换了服务器地址)也接进这条路径,用 ContextChanged
绕过节流窗口 —— 那份列表换了主人,等满一个为「别的会话又攒了几条」而设的窗口没有
道理;登出时把账本与补拉任务一起作废(否则补拉会带着一个已经没有令牌的身份发请求)。
抽屉打开时那次 refresh、activeSessionId 的推进、已读游标的既有行为都没动。

验证:gradlew.bat assembleDebug testDebugUnitTest —— BUILD SUCCESSFUL;
1171 条单测 failures 0 / errors 0 / skipped 0,其中新增 InactiveSessionPullTest 13 条。
2026-10-05 22:28:19 +08:00
AskaEth e589ab303d merge: W48 —— 聊天页 VM key 带上账号指纹(修换环境后仍显示上个环境的会话)+ 注释纠错 + 把 WS 拦截代理挪进 devtools 2026-10-05 22:26:44 +08:00
AskaEth 4f860aa499 fix(chat): W48 聊天页 VM key 带上账号指纹,修换环境后仍显示上个环境的会话
缺陷:换账号或换服务器后,聊天页仍然摆着上一个环境的消息。成因是 ChatScreen 里那个
ChatViewModel 的 owner 是 Activity 级的 ViewModelStore,而登出只是把 ChatHomeScreen
移出组合、并不清理那个 store(ChatViewModel.onCleared 是空的,全仓也没有 ViewModelStore.clear())。
key 里只有 sessionId 时,换环境再进来 viewModel() 会把上一个环境那个还活着的实例原样取回来,
它继续收着那个环境的帧,屏幕上就是别的服务器的消息。主会话的 id 恒为 web,两次登录必然撞上
同一个 key,所以这不是理论风险。

为什么 VM key 要带账号指纹:指纹回答的是「这份本地状态属于哪个环境」。把它抽成纯函数
accountFingerprint(baseUrl, username),与 SessionPreferences 判断快照作废、
SessionsViewModel 落盘快照用的是同一份口径(SessionsViewModel 原本就地拼的那串改成调用
同一个函数,免得两套判据分叉——分叉的那一侧就是漏出去的那一侧)。同一个环境里切会话,
key 未变,各会话的状态照旧各留一份(这正是「切回来状态还在」依赖的);环境一变(换了服务器
或换了账号),key 就变,取到的一定是新实例。

会话内搜索那个小 VM 是同一类残留:它持有的命中就是消息正文,换环境后在主会话上重开搜索,
面板里就是别的服务器的消息。它的 key 同样带上指纹。

为什么本轮不回收旧 VM:要真正回收掉上一个环境那个实例,得自定义 ViewModelStoreOwner 或直接
清 store,而「切回来状态还在」这条体验正绑在 store 不清这件事上,属于要主人拍板的设计取舍。
本轮只保证换环境后取到的是新 VM,回收这件事不动。

顺带的零成本纠错(只改注释的成因描述,行为一字未动):ChatHomeScreen / ChatScreen /
ChatViewModel 里几处「onSessionGone 一被调用,那个会话的 VM 连同它的状态立刻被销毁」是错的——
变的是 key,被销毁的只是承载横幅的那段组合;VM 实例仍在 Activity 级的 store 里活着并继续收帧。
「横幅必须挂在上一层」那条结论本身是对的,原样保留;改的只是它前面的理由。
同时把 devtools 里那个窄代理的路径写对(此前注释指向一个并不存在的路径)。

开发工具挪出会被 clean 扫掉的位置:app/build/e2e-shots/ws_block_proxy.py 躺在 gitignored 的
app/build/ 里,一次 gradlew clean 就没了。挪进 devtools/ws-block-proxy/(与 fake-backend
同级同风格)并配 README,写清用途、用法(python ws_block_proxy.py 18098 10942,配
adb reverse tcp:10942 tcp:18098)与它最有价值的判据:调试时 Web 前端直连后端、不经过
adb reverse,所以代理日志里的每一行都只可能来自 App。脚本行为未改,也没有硬编码任何凭据。

新增单测 ChatViewModelKeyTest 钉住拼法:同账号同地址 => 同 key;换地址 / 换账号 / 换会话
=> 不同 key;username 缺失时退化成 baseUrl 一段,换地址仍然分得开。

验证:gradlew.bat assembleDebug 与 gradlew.bat testDebugUnitTest,
BUILD SUCCESSFUL;单测 1164 条,failures 0、errors 0、skipped 0。
2026-10-05 22:25:57 +08:00
13 changed files with 864 additions and 36 deletions

No files matched your search

@@ -20,7 +20,7 @@ private val Context.sessionDataStore: DataStore<Preferences> by preferencesDataS
*
* @param snapshot 上次成功拉取的会话列表;服务端不可用时用它先渲染
* @param snapshotAt 快照时间(用于「同步于 N 分钟前」)
* @param fingerprint `baseUrl + username`;换服务器/账号后快照作废
* @param fingerprint [accountFingerprint]:换服务器/账号后快照作废
* @param activeSessionId 上次停留的会话
*/
data class SessionPrefs(
@@ -35,6 +35,20 @@ data class SessionPrefs(
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
@@ -27,8 +27,11 @@ private const val SESSION_GONE_NOTICE_MILLIS = 8_000L
* 会话在别处被删掉时那句如实告知。
*
* 它必须由**这一层**持有,不能挂在 `ChatViewModel` 上:`onSessionGone` 一被调用,
* `activeSessionId` 就会被收敛回主会话,那个会话的 VM 连同它的状态**立刻被销毁**,
* 挂在上面的横幅一个字都来不及显示(真机实测过)。这一层的 `remember` 活得比它久。
* `activeSessionId` 就会被收敛回主会话,ChatScreen 的 VM key(含会话 id)随之变化 ——
* 变的是 key,于是**承载横幅的那段组合被销毁重建**;那个 VM 实例本身住在 Activity 级的
* ViewModelStore 里,**仍然活着、继续收帧**(连登出都不回收它,见 `chatViewModelKey` 的 KDoc)。
* 横幅挂在 VM 状态上的话活不过这一帧、一个字都来不及显示(真机实测过)。
* 这一层的 `remember` 活得比它久。
*/
private const val SESSION_GONE_MESSAGE = "这个会话在服务端已经不存在了(可能已在别处删除),已切回主会话"
@@ -52,7 +55,7 @@ fun ChatHomeScreen(
var showAbout by remember { mutableStateOf(false) }
// 当前会话在别处被删掉时那句告知(见 [SESSION_GONE_MESSAGE])。放在这一层是因为承载它的
// 会话 VM 会在切回主会话的瞬间被销毁。
// 那段组合(ChatScreen)会在切回主会话的瞬间被销毁重建,而这里的 remember 活得比它久。
var sessionNotice by remember { mutableStateOf<String?>(null) }
LaunchedEffect(sessionNotice) {
@@ -104,8 +107,8 @@ fun ChatHomeScreen(
// `refresh` 里面会把「指向已删会话」的 activeSessionId 收敛回主会话
// (`resolveActiveSession`),抽屉里那条也跟着消失。所以这里不需要自己判「该切到哪儿」。
//
// 顺序很重要:告知必须**先**立起来再刷新——刷新会让本层的 ChatScreen 换 key、
// 把那个会话的 VM 销毁掉,晚一步就什么都看不到了。
// 顺序很重要:告知必须**先**立起来再刷新——刷新会让本层的 ChatScreen 换 key,
// 那段组合连同挂在 VM 状态上的东西一起被丢弃重建,晚一步就什么都看不到了。
onSessionGone = {
sessionNotice = SESSION_GONE_MESSAGE
sessionsViewModel.refresh(silent = true)
@@ -58,6 +58,7 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import top.yeij.cyrene.app.AppContainer
import top.yeij.cyrene.app.ShareIntake
import top.yeij.cyrene.core.prefs.accountFingerprint
import top.yeij.cyrene.data.repository.DocumentCacheStore
import top.yeij.cyrene.data.repository.ImageCacheStore
import top.yeij.cyrene.data.ws.WsState
@@ -108,6 +109,21 @@ private const val SEARCH_NOTICE_MILLIS = 8_000L
/** 输入栏下方预留的高度:避开屏幕圆角,同时承载忙碌提示。 */
private val InputBottomSpacer = 22.dp
/**
* 聊天页那个 [ChatViewModel] 的 key(纯函数):**账号指纹 + 会话 id**。
*
* `viewModel(key = ...)` 的 owner 是 **Activity 级的 ViewModelStore**,而登出只把本组合移出、
* **不清理那个 store**([ChatViewModel.onCleared] 是空的,全仓也没有 `ViewModelStore.clear()`)。
* key 里只放 sessionId 的话,换账号/换服务器再进来时,`viewModel()` 会把**上一个环境**
* 那个还活着的实例原样取回来——它会继续收那个环境的帧,屏幕上摆的就是别的服务器的消息。
* 指纹进 key 之后,环境一变就取到新实例;同一个环境里切会话仍然各留一份状态(key 未变)。
*
* [accountFingerprint] 与 [SessionPreferences] / `SessionsViewModel` 是**同一份**口径:
* 别在这里另拼一套字符串——两套判据早晚会分叉,而分叉的那一侧就是漏出去的那一侧。
*/
internal fun chatViewModelKey(accountFingerprint: String, sessionId: String): String =
"$accountFingerprint|$sessionId"
/**
* 选文件时交给系统选择器的 MIME 列表。
*
@@ -147,8 +163,16 @@ fun ChatScreen(
onSessionGone: () -> Unit = {},
sessionNotice: String? = null,
) {
// 账号指纹:**必须进下面那两个 VM 的 key**。登出只是把本组合移出(见 CyreneApp 的 when),
// 而这两个 VM 住在 Activity 级的 ViewModelStore 里、**不会随登出回收**——key 里不带指纹的话,
// 换账号/换服务器再进来会按同一个 key 取回上一个环境那个还活着的实例,屏幕上摆的就是
// 别的服务器的消息。`remember` 每次进入本组合重算一次(换环境必经登出 ⇒ 本组合被销毁重建),
// 所以不会拿着上一份指纹;不在每次重组时读加密存储是因为流式期间重组非常频繁。
val account = remember {
accountFingerprint(container.authManager.currentBaseUrl, container.sessionStore.load().username)
}
val viewModel: ChatViewModel = viewModel(
key = sessionId,
key = chatViewModelKey(account, sessionId),
factory = viewModelFactory { initializer { ChatViewModel(container, sessionId) } },
)
val state by viewModel.state.collectAsState()
@@ -159,9 +183,10 @@ fun ChatScreen(
// 所以这里不会出现「已经主会话还往上跳一次」的回环。
//
// ⚠️ **那句解释不能挂在这个 VM 上**:`onSessionGone` 一被调用,上层就会把 activeSessionId 收敛回
// 主会话,`key = sessionId` 随之变化 ⇒ **这个 VM 连同它的状态一起被销毁**,挂在上面的横幅
// 一个字都来不及显示(真机实测过:切回主会话干净利落,用户完全不知道为什么)。
// 所以文案由 [sessionNotice] 从上层注入(那个状态活得比本 VM 久)。
// 主会话,VM key 里的会话 id 随之变化 ⇒ **承载它的那段组合被销毁重建**。(变的是 key,
// 不是 VM:那个实例仍在 Activity 级的 ViewModelStore 里活着、继续收帧——见上面 key 的 KDoc。)
// 挂在 VM 状态上的横幅活不过这一帧,一个字都来不及显示(真机实测过:切回主会话干净利落,
// 用户完全不知道为什么)。所以文案由 [sessionNotice] 从上层注入(那个状态活得比本 VM 久)。
LaunchedEffect(state.sessionGone) {
if (state.sessionGone) onSessionGone()
}
@@ -175,7 +200,9 @@ fun ChatScreen(
// 待执行的定位请求。命中在窗口里时先关面板再滚,交给这个 effect 在组合完成之后做。
var pendingScroll by remember { mutableStateOf<Int?>(null) }
val searchViewModel: SessionSearchViewModel = viewModel(
key = "session-search:$sessionId",
// 与上面那个 VM 同样的理由:搜索命中也是**上一个环境的聊天内容**,key 里不带账号指纹的话,
// 换环境后在主会话(id 恒为 web)上重开搜索,面板里就是别的服务器的消息。
key = "session-search:${chatViewModelKey(account, sessionId)}",
factory = viewModelFactory {
initializer { SessionSearchViewModel(container, sessionId) }
},
@@ -959,8 +959,8 @@ class ChatViewModel(
// 连接是**进程内共享**的那一条(`AppContainer.chatSocket`),本 VM 只订阅它。开与关归
// 「登录态」那条路径(唯一主人是 `SessionsViewModel`,见它的类注释):`connect()` 虽然幂等,
// 但主人只有一个——本 VM 会随「切到别的会话」被销毁,让它插手就等于让一个屏幕的生死
// 去掐全 App 的连接。
// 但主人只有一个——本 VM 的 key 一换(切会话 / 换账号)就只是另起一个实例,旧实例仍留在
// Activity 级的 `ViewModelStore` 里,让它插手就等于让一个屏幕的生死去掐全 App 的连接。
val ws = container.chatSocket
socket = ws
viewModelScope.launch { ws.state.collect(::onConnectionChanged) }
@@ -2905,9 +2905,11 @@ class ChatViewModel(
fun dismissError() = _state.update { it.copy(error = null) }
/**
* **刻意不关连接**:`socket` 是**全 App 共享**的那一条(`AppContainer.chatSocket`),而本 VM 随
* 「切到别的会话」就会被销毁。在 `onCleared` 里 close 会连抽屉的未读、管理页的三帧、别的会话的
* 流式一起掐断——连接的开与关只由**登录态**表达(主人是 `SessionsViewModel`,见它的类注释)。
* **刻意不关连接**:`socket` 是**全 App 共享**的那一条(`AppContainer.chatSocket`),而本 VM
* 并不随切会话 / 登出消失——它的 key 一换只是另起一个实例,旧实例仍留在 Activity 级的
* `ViewModelStore` 里(见 `chatViewModelKey` 的 KDoc),`onCleared` 要等整个 Activity 结束才来。
* 在 `onCleared` 里 close 会连抽屉的未读、管理页的三帧、别的会话的流式一起掐断——连接的
* 开与关只由**登录态**表达(主人是 `SessionsViewModel`,见它的类注释)。
*/
override fun onCleared() {
super.onCleared()
@@ -122,7 +122,7 @@ enum class RestFailureLanding {
*
* ## 为什么是「直接试 REST」(W45 主人的定案)
* W41 的阶梯是「WS 未就绪 ⇒ **先探 `GET /api/v1/health`** ⇒ 通才回落 REST」。真机回归
* (`devtools/ws_block_proxy.py` 只掐 `/ws/chat`、HTTP 全放行)发现消息**进了队列而不是回落
* (`devtools/ws-block-proxy/ws_block_proxy.py` 只掐 `/ws/chat`、HTTP 全放行)发现消息**进了队列而不是回落
* REST**;而 `ProbeClientTest` 证明那条链在 JVM 上是通的,`Unreachable` 生在**连接之前**,三种
* 成因(建 client 抛 / 连上但一个字没写 / ConnectException)**在外同形** ⇒ 那一轮无法定论。
*
@@ -48,8 +48,10 @@ data class SessionSearchUiState(
* 会话内搜索的小 ViewModel:**只属于聊天页的搜索态**,与 [ChatViewModel] 完全不相干——
* 搜索不改变消息窗、不碰 WS、不发消息,所以它不该住进那个正在被另一个模块改动的 VM 里。
*
* 作用域由调用方决定(`viewModel(key = "session-search:<id>")`),因此每个会话各有一份,
* 切回来时上一次的查询与结果还在。取数一律走既有的 `MessageRepository`。
* 作用域由调用方决定(`viewModel(key = "session-search:<账号指纹>|<id>")`),因此每个会话各有一份、
* 切回来时上一次的查询与结果还在;而**换账号/换服务器后指纹变了 ⇒ 取到的是新实例**,
* 面板里不会残留上一个环境的命中(那些命中就是别的服务器的消息正文)。
* 取数一律走既有的 `MessageRepository`。
*
* 并发:`generation` 是「谁的结果才算数」的代际号。搜索是**用户连打两次回车**就会并发的地方,
* 加上翻页与首批可以同时存在,没有代际号就会出现「先发的慢请求后到,把新关键词的结果顶掉」。
@@ -0,0 +1,192 @@
package top.yeij.cyrene.ui.sessions
/**
* 一帧 `message_persisted` 属于哪条路([persistedRoute] 的取值)。
*
* 这一帧按下行的**每一个**会话广播(服务端不按会话订阅,见 `ChatSocket`),而收帧的
* `SessionsViewModel` 手里是**整个列表**,所以每一帧都要先回答这一个问题。
*/
internal enum class PersistedRoute {
/**
* 就是**当前打开的那个会话**:消息已经显示在屏幕上了,就地推进已读游标即可
* (契约 §3.9 的客户端处理建议:看不到它攒出角标)。
*
* 这条路**不发任何请求去拉列表** —— 「自己正在看的会话」从来不是未读的来源,
* 多拉一次列表只会白花两个请求(而抽屉里的未读本来也不会因为这一帧变多)。
*/
ActiveSession,
/**
* **别的**会话落了新消息:它攒未读是正确行为(红点该亮),而本端**数不出这个数**
* (`message_persisted` 帧不带 `unreadCount`)⇒ 节流地静默重拉一次列表,问服务端要那个数。
*
* 这正是 W49 修的那一幕:**App 停在主会话时,次级会话来了新消息,抽屉红点不出现**。
* 此前帧在这一档直接 `return`,而列表更新的触发点只有抽屉打开 / 新建 / 删除 / init 四处,
* 于是红点必须靠用户开一次抽屉(那次静默重拉)才补上。
*/
InactiveSession,
}
/**
* 这一帧该走哪条路(**纯函数**,VM 与测试共用)。
*
* 判据只有一句等值——它单独成一个函数,是因为**「激活会话那一档一定不拉列表」是要一直成立的边界**:
* 写成一个函数才断言得了(先例:`ui.chat.hasSendableContent`,同样是把「入口与投递口必须用同一条
* 判据」这件事拎出来)。`sessionId` 由调用方保证非空白(帧壳里没有会话 id 的坏帧在更外层就整帧跳过了:
* 那种帧连「是谁」都不知道,没有一条路可走)。
*/
internal fun persistedRoute(sessionId: String, activeSessionId: String): PersistedRoute =
if (sessionId == activeSessionId) PersistedRoute.ActiveSession else PersistedRoute.InactiveSession
/**
* 静默重拉的节流窗口(毫秒):**3 秒**。
*
* 为什么是这个数(三个约束互相顶出来的折中):
* - **要够快**,否则等于没修:那一幕是「App 停在主会话、次级会话来了新消息」,红点必须在用户还盯着
* 聊天页的时候就自己出现(今天那一眼只能看见标题栏,看不见抽屉)。3 秒落在「看起来就是刚发生的」
* 这个感知里;5 秒往上就开始像「卡了一下才补上」。
* - **要够粗**,否则节流没意义:一次静默重拉是**两个**请求([SessionsViewModel.refresh] 里还跟着
* 一次角色目录),而播出方是按**每条消息**推 `message_persisted` 的 —— 有人连发 5 条、或一轮回复
* 连续落库几条,都必须在同一个窗口里合并。3 秒能把「一次连发」稳稳收进来(人手连打 5 条约 2~4 秒,
* 同一轮落库通常在 1 秒内);窗口收到 1 秒就会漏成两三次请求。
* - **别另立尺子**:3 秒正是本项目已有的「等够久才值得为一次等待开口」的那个量级
* (`ChatScreen.SHARE_WAIT_NOTICE_MILLIS`),沿用同一套体感标尺。
*
* 它给的是**上限**保证(窗口内最多一次请求),**下限**由补拉那一档给
* ([SilentRefreshAction.ScheduleTrailing]:最后一条之后必定还会再拉一次)。两个一起才成立:
* 只有上限会把红点停在旧值上,只有下限等于没节流。
*/
internal const val SILENT_REFRESH_WINDOW_MILLIS = 3_000L
/**
* 一次静默重拉的成因([silentRefreshStep] 的入参之一)。
*
* 只分两类,是因为判据**只关心一件事**:这一次该不该被节流窗口挡住。窗口存在的唯一理由是
* 「把同一场连发合并成一次请求」,所以要能分辨「这场连发」与「跟连发无关、本来就必须马上对齐」的那类。
*/
internal enum class SilentRefreshCause {
/**
* 非激活会话落了新消息([PersistedRoute.InactiveSession]):**必须走窗口** ——
* 播出方按每条消息推帧,不节流就是连发几条打几个请求。
*/
InactiveSessionMessage,
/**
* 手里这份列表**换了主人**(账号 / 服务器地址变了,在登录态上报时判出):**不该被窗口挡住**。
*
* 窗口等的是「别的会话又攒了多少条」,与「这份列表还是不是当前这个账号的」毫无关系 ——
* 被它挡住的话,新身份会拿着一份属于上个身份(或已被清空)的列表过完整个窗口,而这时**根本没有
* 任何一场连发值得合并**。
*
* 会话切换(`SessionsViewModel.select`)在判据上属于同一类,但那条路**刻意不接进来** ——
* 理由写在那里:它紧接着就发了一次推进已读游标的请求,重拉会与还没落地的游标抢跑、把刚清掉的
* 角标闪回来。
*/
ContextChanged,
}
/**
* 静默重拉的节流账本(**纯值**:[silentRefreshStep] 的入参,也是它的出参)。
*
* 时钟由调用方喂([silentRefreshStep] 的 `nowMillis`),这里**不藏** `System.currentTimeMillis()`:
* 判据才可单测,也才不用为了「等 3 秒」写 sleep(先例:`domain.chat.RetryKeyMemory`)。
*
* @param lastPullAt 上一次**真的发出**那次静默重拉的时刻(毫秒);`null` = 本进程还没拉过。
* 只由**这条路径**记账:抽屉打开 / 新建 / 删除 / init 那几处的拉取不写它。刻意不写 —— 那几处是
* 用户动作或一次性事件,把它们的时刻算进来只会让本路径**少拉**一次,而少拉正是「红点停在旧值」
* 的成因本身。
* @param trailingPending 已经排上了一次「窗口到点补拉」。已排上时再来的成因只更新账本、不重复排
* ([SilentRefreshAction.Ignore])—— 「同一时刻最多一个补拉任务」是补拉不会滚成一串的结构性保证,
* 而不是某一次调用的运气。
*/
internal data class SilentRefreshThrottle(
val lastPullAt: Long? = null,
val trailingPending: Boolean = false,
)
/** 这一刻该做什么([silentRefreshStep] 的取值)。 */
internal enum class SilentRefreshAction {
/** 现在就拉,窗口从这一刻重新开始。 */
PullNow,
/** 窗口内:**现在不拉**,排一次窗口到点的补拉(trailing),见 [silentRefreshStep] 第 3 条。 */
ScheduleTrailing,
/** 什么都不做:窗口内,且补拉已经排上了 —— 那一次本来就是把窗口里所有成因合起来做掉。 */
Ignore,
}
/**
* [silentRefreshStep] 的结论:**该做什么** + **做完之后账本走成什么样**。
*
* 两半一起回是刻意的(先例:`domain.chat.RetryKeyDecision`):账本的另一半(窗口起点、有没有补拉)
* 由本次判定决定,拆开返回就给了调用方「判了却忘了推进窗口」的机会 —— 那正是节流失效最常见的写法。
*/
internal data class SilentRefreshStep(
val action: SilentRefreshAction,
/** 补拉还要等多久(毫秒);只有 [SilentRefreshAction.ScheduleTrailing] 有意义,其余恒 `0`。 */
val delayMillis: Long,
val throttle: SilentRefreshThrottle,
)
/**
* 静默重拉的时机判据(**纯函数**,VM 与测试共用)。
*
* ## 为什么需要这一层
* [PersistedRoute.InactiveSession] 那一档要自己拉一次列表,但**裸着重拉不行**:播出方按每条消息推帧,
* 连发 5 条就是 5 个请求。所以有这里。
*
* ## 判据与优先级(自上而下,先命中先返回)
* 1. [SilentRefreshCause.ContextChanged] ⇒ [SilentRefreshAction.PullNow]。**压过窗口**(理由见那个
* 枚举值)。这一拉已经把窗口里那条消息要的刷新一并做掉了,所以窗口从这一刻**重开**、不再补一次
* —— 落到第 3 条的话会 3 秒后再白拉一次同样的东西。
* 2. 窗口外(`lastPullAt` 为 `null`,或已过去 [SILENT_REFRESH_WINDOW_MILLIS])⇒
* [SilentRefreshAction.PullNow],窗口从这一刻重开。
* 3. 窗口内、**还没有**排上补拉 ⇒ [SilentRefreshAction.ScheduleTrailing],等「窗口减去已过去的时间」。
* 这一档就是 trailing 语义:**最后一条之后必定还会再拉一次**,红点不会停在旧值上。
* 4. 窗口内、补拉**已经排上** ⇒ [SilentRefreshAction.Ignore]:那一次补拉本来就是窗口里所有成因的合并。
*
* 判据里除了「成因」与「账本」什么都不看(不知道是哪个会话、也读不到 VM 状态),才能穷举、才能在 JVM
* 上单测(先例:`ui.chat.shareLanding`)。
*
* ## 边界口径:正好等于窗口**算窗口已过**(`>=` 而不是 `>`)
* 取这一边的理由很具体:补拉的等待时长就是按同一个常量算出来的(`窗口 - 已过去的时间`),它醒来
* 那一刻读到的钟通常**正好**是 `lastPullAt + 窗口` —— 判据这边若是 `>`,那一刻会被判成「还在窗口内」,
* 补拉就得再排一次(虽然最终仍会拉,但白绕一圈)。另一面也更好断言:测试直接拿
* [SILENT_REFRESH_WINDOW_MILLIS] 当「窗口刚过」的边界值,不必发明魔法数。(`RetryKeyMemory` 的边界
* 口径选的是**相反**的一侧,理由写在那边;两处不对称是各自的用途不同,不是笔误。)
*
* @param nowMillis 调用方喂的「现在」(`System.currentTimeMillis()`)。
* @param throttle 这份列表的节流账本,见 [SilentRefreshThrottle]。
*/
internal fun silentRefreshStep(
cause: SilentRefreshCause,
nowMillis: Long,
throttle: SilentRefreshThrottle,
): SilentRefreshStep {
if (cause == SilentRefreshCause.ContextChanged) {
return SilentRefreshStep(SilentRefreshAction.PullNow, 0L, throttleAfterPull(nowMillis))
}
val lastPullAt = throttle.lastPullAt
if (lastPullAt == null || nowMillis - lastPullAt >= SILENT_REFRESH_WINDOW_MILLIS) {
return SilentRefreshStep(SilentRefreshAction.PullNow, 0L, throttleAfterPull(nowMillis))
}
if (!throttle.trailingPending) {
return SilentRefreshStep(
action = SilentRefreshAction.ScheduleTrailing,
delayMillis = SILENT_REFRESH_WINDOW_MILLIS - (nowMillis - lastPullAt),
throttle = throttle.copy(trailingPending = true),
)
}
return SilentRefreshStep(SilentRefreshAction.Ignore, 0L, throttle)
}
/**
* 真的拉过一次之后的账本(**纯函数**):窗口从 [nowMillis] 重开,且不再有补拉 ——
* 这一拉已经把窗口里排队的成因一并覆盖,补拉再跑一次就是同一个请求白做一遍。
*
* 单独成一个函数,是因为**补拉任务到点时也要走这一步**(它照样是「真的拉过一次」,只是发起人不同);
* 两处各写一遍 `SilentRefreshThrottle(lastPullAt = …)` 就多了一个会分叉的真相源。
*/
internal fun throttleAfterPull(nowMillis: Long): SilentRefreshThrottle =
SilentRefreshThrottle(lastPullAt = nowMillis)
@@ -2,6 +2,8 @@ package top.yeij.cyrene.ui.sessions
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
@@ -10,6 +12,7 @@ import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import top.yeij.cyrene.app.AppContainer
import top.yeij.cyrene.core.auth.SessionState
import top.yeij.cyrene.core.prefs.accountFingerprint
import top.yeij.cyrene.data.api.CyreneException
import top.yeij.cyrene.data.api.dto.SessionSummaryDto
import top.yeij.cyrene.data.ws.ChatSocket
@@ -69,6 +72,12 @@ data class SessionsUiState(
* 同时调 `POST /sessions/{id}/read` 把游标推上去(策略详见 [pushReadCursor])。
* 反向同步(别的端读了)靠下行 `session_read` 帧。
*
* **非激活会话攒未读**(别人发来、别的设备发来)本端**不自己数**——`message_persisted` 帧不带
* `unreadCount`,逐条累加会与编辑 / 删除 / 别端操作漂移。本端做的是**自己去问一次服务端**:
* 收到非激活会话的落库广播就**节流地静默重拉**([requestSilentRefresh],窗口
* [SILENT_REFRESH_WINDOW_MILLIS]),于是抽屉的红点**不需要用户先开一次抽屉**就会出现
* (S6,登记见 `devdocs/05-开发进度与交接.md`)。
*
* ## WS 连接:全 App 共享一条,而**本类是它唯一的主人**
* `session_read` / `message_persisted` 都在 WS 上,这个连接就是**进程内共享**的那一条
* (`AppContainer.chatSocket`;为什么可以共享见那里的 KDoc——服务端广播 ⇒ 各 VM 各收全部帧
@@ -106,7 +115,29 @@ class SessionsViewModel(private val container: AppContainer) : ViewModel() {
/** 全 App 共享的那条连接;本类只订阅它,并且**是它唯一的主人**(登录时连、登出时关,见类注释)。 */
private val socket: ChatSocket = container.chatSocket
/**
* 静默重拉的节流账本(判据见 [silentRefreshStep])。只有两条路会写它:非激活会话的落库广播
* ([onMessagePersisted])与身份变化(登录态上报时的 [fingerprint] 比对)。
*/
private var silentRefreshThrottle = SilentRefreshThrottle()
/**
* 排上的那次「窗口到点补拉」([SilentRefreshAction.ScheduleTrailing])。
* 它保证**最后一条新消息之后还会再拉一次**,红点因此不会停在旧值上;登出时取消
* (否则它会带着一个已经没有令牌的身份去发请求)。
*/
private var trailingRefreshJob: Job? = null
/**
* 手里这份列表是**为哪个身份**拉回来的([fingerprint]);`null` = 这份列表没有主人
* (登出清空之后、换账号还没重拉)。
*/
private var pulledForIdentity: String? = null
init {
// 构造这一刻读到的身份就是 init 里那次 refresh 的身份(指纹只读本地已存的账号与地址,
// 是同步的)。先记下它,登录态第一次上报时才不会把「本来就是它拉的」误判成身份变了。
pulledForIdentity = fingerprint()
viewModelScope.launch {
val fingerprint = fingerprint()
val cached = prefs.data.first()
@@ -139,13 +170,33 @@ class SessionsViewModel(private val container: AppContainer) : ViewModel() {
socket.close()
readCursors.clear()
prefs.clear()
// 手里这份列表与它的节流账本都属于上一个身份,一起作废:留着账本会让
// 重登后那一次必要的重拉被上一轮的窗口挡住;留着补拉任务会让它带着一个
// 已经没有令牌的身份去发请求(必然 401,还会在抽屉里挂一行错误)。
trailingRefreshJob?.cancel()
trailingRefreshJob = null
silentRefreshThrottle = SilentRefreshThrottle()
pulledForIdentity = null
_state.value = SessionsUiState()
}
// 连接只在登录后建立:baseUrl / token 都是每次重连现读的(换账号、续期都不需要
// 重建实例),connect() 幂等(已连接时直接返回)。这里订阅的是一份 StateFlow,
// 订阅即收到当前值,所以「重登接上」不需要额外的事件。
is SessionState.LoggedIn -> socket.connect()
is SessionState.LoggedIn -> {
socket.connect()
// 身份换了(重登成另一个账号、或换了服务器地址):手里这份列表属于上一个
// 身份,必须自己对齐一次。它**不该**被给「别的会话新消息」用的那个窗口挡住
// (见 [SilentRefreshCause.ContextChanged]:那场连发与「列表是谁的」无关)。
//
// 同一个身份重复上报时什么都不做:这里订阅的是一份 StateFlow(订阅即收当前
// 值,续期 / 重连都会再上报一次),每次都拉就成了凭空多出来的请求。
val identity = fingerprint()
if (identity != pulledForIdentity) {
pulledForIdentity = identity
requestSilentRefresh(SilentRefreshCause.ContextChanged)
}
}
SessionState.Unknown -> Unit
}
@@ -226,6 +277,65 @@ class SessionsViewModel(private val container: AppContainer) : ViewModel() {
}
}
/**
* 节流地静默重拉一次列表 —— W49 修的「停在主会话时,次级会话来了新消息,抽屉红点不出现,
* 必须开一次抽屉才补上」(S6)。
*
* 列表此前只在抽屉打开 / 新建 / 删除 / init 四处更新,而 `message_persisted` 只处理
* 「正在看的那个会话」—— 别的会话攒未读**没有任何人会去发现**。
*
* ## 为什么是「重拉」而不是「本地把未读数加一」
* `message_persisted` 帧**不带 `unreadCount`**:本地没有逐条时间线,自己数出来的数会与编辑 /
* 删除 / 别端操作漂移(这正是 [onMessagePersisted] 里那条纪律的由来)。所以这里**一个数都不造**,
* 只是把「服务端才是唯一真源」这件事提前做一次 —— 走的是**与抽屉打开时那次完全相同**的路径
* ([refresh],含已读游标遮罩),只是不需要用户先开一次抽屉。
*
* ## 三条纪律
* 1. **节流**(判据 [silentRefreshStep],窗口 [SILENT_REFRESH_WINDOW_MILLIS]):一次连发只发一次
* 请求;同时保证**最后一条之后还会再拉一次**(trailing),红点不会停在旧值上。
* 2. **静默**:`silent = true`,不闪「同步中…」——用户根本没开抽屉,不该有任何东西在动。
* 失败时做什么**完全沿用既有行为**(挂 [SessionsUiState.error] 那一行,只有打开抽屉才看得见;
* 以及「网络异常,显示 x 分钟前的结果」那一句),本方法不另加提示:一次后台的顺手对齐失败,
* 不该比用户自己打开抽屉时的那次刷新更吵。
* 3. **不抢跑**:不改 `activeSessionId`、不推进已读游标、不碰 [select]。[refresh] 内部唯一会动
* `activeSessionId` 的地方是 [resolveActiveSession](当前会话被别处删掉时收敛回主会话),
* 那是既有的、正确的收敛,不是重置。
*
* @param cause 这次为什么要拉;决定它走不走窗口(见 [SilentRefreshCause])。
*/
private fun requestSilentRefresh(cause: SilentRefreshCause) {
val step = silentRefreshStep(cause, System.currentTimeMillis(), silentRefreshThrottle)
silentRefreshThrottle = step.throttle
when (step.action) {
// 该拉了。先把**等在半路**的那次补拉撤掉:它要做的就是同一件事,留着会多拉一次。
// (只有 ContextChanged 压过窗口时才可能真的取消到一个活着的补拉任务——窗口外那一档
// 走到这里时 trailingPending 必为 false,补拉早跑完了。)
SilentRefreshAction.PullNow -> {
trailingRefreshJob?.cancel()
trailingRefreshJob = null
refresh(silent = true)
}
// 窗口内:排一次补拉。它是「最后一条之后还会再拉一次」的**唯一**保证——没有它,一串
// 广播的尾巴会被窗口吃掉,红点就停在旧值上(这正是节流最容易做错的那一半)。
//
// 到点时**直接拉**,不再回头问一次判据:那一刻读到的钟可能早一两毫秒,再问就会被自己
// 刚开的窗口挡回去、只能再排一次;而「窗口已经过去」是补拉自己等出来的事实,直接拉、
// 直接开新窗口更诚实,也少一条能自锁的路径。账本仍走纯函数那一份([throttleAfterPull]),
// 免得两处各写一遍、各自漂移。
SilentRefreshAction.ScheduleTrailing -> {
trailingRefreshJob = viewModelScope.launch {
delay(step.delayMillis)
silentRefreshThrottle = throttleAfterPull(System.currentTimeMillis())
refresh(silent = true)
}
}
// 窗口内、补拉已经排上:那一次本来就是把窗口里所有成因合起来做掉,这里不必再排。
SilentRefreshAction.Ignore -> Unit
}
}
/**
* 切换 / 进入会话。
*
@@ -236,6 +346,13 @@ class SessionsViewModel(private val container: AppContainer) : ViewModel() {
*
* `createSession` / `deleteSession` 也走这里,语义同样成立:那两个动作之后,
* 屏幕上(即将)显示的就是这个会话。
*
* **这里刻意不顺手静默重拉**(虽然「切会话」在判据上属于 [SilentRefreshCause.ContextChanged]
* 那一类):本函数紧接着就发了一次推进已读游标([pushReadCursor]),而**它还没落地**——
* 这一刻的 `GET /sessions` 很可能仍按旧游标算出未读,就地清零会被它覆盖成「又有未读」
* (本端此时也没有可以遮罩的游标:[readCursors] 只在响应 / 帧到达后才记),用户就会看到
* 刚点开的那个会话角标**闪回来**。列表本来也不缺这一拉:抽屉打开时那一次(`ChatHomeScreen`)
* 紧接着就到,而它发生在游标之后。
*/
fun select(sessionId: String) {
_state.update {
@@ -336,8 +453,9 @@ class SessionsViewModel(private val container: AppContainer) : ViewModel() {
when (event) {
// 别处(Web 面板 / 别的设备 / 本端另一次请求)读了:清掉那一行的红点。
is WsEvent.SessionRead -> onSessionRead(event)
// 正在看的会话落了新消息:消息已经显示在屏幕上了,立刻推进游标,
// 否则「自己正看着的会话」会攒出一个未读角标(契约 §3.9 的客户端处理建议)。
// 落了新消息。哪一个会话都有事做:正在看的那个推进游标(消息已经显示在屏幕上了,
// 否则「自己正看着的会话」会攒出一个未读角标,契约 §3.9 的客户端处理建议),
// 别的会话则去问一次服务端(红点不该等用户开抽屉才出现)——两半都在 onMessagePersisted。
is WsEvent.MessagePersisted -> onMessagePersisted(event)
else -> Unit
}
@@ -350,22 +468,44 @@ class SessionsViewModel(private val container: AppContainer) : ViewModel() {
}
/**
* `message_persisted`:只有**当前打开的那个会话**才推进游标。
* `message_persisted` 的两条出路(路由判据 [persistedRoute]):**当前打开的那个会话**推进游标;
* **别的会话**去问一次服务端。
*
* 别的会话攒未读是正确行为(红点本来就该亮),所以这里不碰它们的计数——
* 本端只做「看到即已读」,不做「替服务端数未读」(增量计数会与编辑 / 删除 / 别端操作漂移,
* 而抽屉每次打开都会静默重拉,以服务端为准)。
* 两半共用同一条纪律:**本端哪里都不替服务端数未读**(逐条累加会与编辑 / 删除 / 别端操作漂移)。
* 区别只在「那它靠什么显示」:
* - [PersistedRoute.ActiveSession] ⇒ 就地清零 + 推进游标(这段一个字没改);
* - [PersistedRoute.InactiveSession] ⇒ **节流地静默重拉**([requestSilentRefresh]),
* 用服务端下发的 `unreadCount` 说话。这正是 W49 修的那一幕:此前这一档直接 `return`,于是
* 「App 停在主会话、次级会话来了新消息」没有任何人会去发现,红点要等用户打开一次抽屉(那次
* 静默重拉)才补上。
*
* 两档的**载荷要求不同**,是刻意的:推游标要用消息自己的 `createdAt`(缺了就只能什么都不做),
* 而「重拉一次列表」不需要载荷里的任何一个字段 —— **帧壳本身就是全部证据**(服务端只为「某条
* 消息已在某会话落库」才推这一帧)。所以非激活那一档**不检查 `event.message`**:真遇上载荷解不出的
* 坏帧(契约漂移的典型症状),宁可多问一次服务端,也不要让红点又停在旧值上。
*/
private fun onMessagePersisted(event: WsEvent.MessagePersisted) {
val sessionId = event.sessionId?.takeIf { it.isNotBlank() } ?: return
if (sessionId != _state.value.activeSessionId) return
val message = event.message ?: return
// `createdAt` 是服务端时间:用它当 before 比「现在」更精确——只把**这条**及其之前标成
// 已读,同时落库的其它消息(时间更晚)仍算未读。缺时间戳时退化成「现在」,语义见 select()。
val before = message.createdAt.takeIf { it > 0 }
before?.let { recordReadCursor(sessionId, it) }
_state.update { it.copy(sessions = it.sessions.markRead(sessionId)) }
pushReadCursor(sessionId, before)
when (persistedRoute(sessionId, _state.value.activeSessionId)) {
// 别的会话:本端数不出它的未读(帧不带 `unreadCount`)⇒ 节流地静默重拉一次列表,
// 红点于是在用户**没开抽屉**的时候也会出现。这一帧到此为止。
PersistedRoute.InactiveSession ->
requestSilentRefresh(SilentRefreshCause.InactiveSessionMessage)
// 屏幕上就是这个会话:就地清零 + 推进游标,**不发任何请求去拉列表**。
// 这里用 `when` 穷举而不是「不满足上面那条就走这段」:路由将来多一档时,
// 必须有人在这里做决定(先例:`ui.chat.queuedSendNoticeFor`)。
PersistedRoute.ActiveSession -> {
val message = event.message ?: return
// `createdAt` 是服务端时间:用它当 before 比「现在」更精确——只把**这条**及其之前
// 标成已读,同时落库的其它消息(时间更晚)仍算未读。缺时间戳时退化成「现在」,
// 语义见 select()。
val before = message.createdAt.takeIf { it > 0 }
before?.let { recordReadCursor(sessionId, it) }
_state.update { it.copy(sessions = it.sessions.markRead(sessionId)) }
pushReadCursor(sessionId, before)
}
}
}
/**
@@ -394,8 +534,9 @@ class SessionsViewModel(private val container: AppContainer) : ViewModel() {
readCursors[sessionId] = maxOf(readCursors[sessionId] ?: 0L, readAt)
}
/** 与聊天页的 VM key 用**同一份**口径([accountFingerprint]),不要就地另拼字符串。 */
private fun fingerprint(): String =
"${container.authManager.currentBaseUrl}|${container.sessionStore.load().username.orEmpty()}"
accountFingerprint(container.authManager.currentBaseUrl, container.sessionStore.load().username)
}
internal fun SessionSummaryDto.toItem(): SessionItem = SessionItem(
@@ -0,0 +1,75 @@
package top.yeij.cyrene.ui.chat
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotEquals
import org.junit.Test
import top.yeij.cyrene.core.prefs.accountFingerprint
/**
* 聊天页那两个 VM 的 key 里**必须带账号指纹**。
*
* 要钉住的事:这个 VM 的 owner 是 Activity 级的 `ViewModelStore`,登出只是把 ChatScreen
* 移出组合、**不回收 store 里的实例**(全仓没有 `ViewModelStore.clear()`)。所以只要 key 不变,
* `viewModel()` 就会把**上一个环境**那个还活着的实例原样取回来——它继续收着上个服务器的帧,
* 屏幕上摆的就是别的环境的消息。判据因此很直接:**环境一变,key 必须变**;环境没变,
* key 必须不变(否则同一个环境里切一下会话 / 重组几次就把状态丢了)。
*
* 纯粹的 seam 测试:`viewModel()` 与 Android 的 store 拖不进 JVM 测试,这里只钉拼法。
*/
class ChatViewModelKeyTest {
private val hostA = "http://host-a:10942/"
private val hostB = "http://host-b:10942/"
@Test
fun `same account on the same server yields the same key`() {
val first = chatViewModelKey(accountFingerprint(hostA, "testuser"), "web")
val again = chatViewModelKey(accountFingerprint(hostA, "testuser"), "web")
assertEquals(first, again)
}
@Test
fun `another server yields another key`() {
val here = chatViewModelKey(accountFingerprint(hostA, "testuser"), "web")
assertNotEquals(here, chatViewModelKey(accountFingerprint(hostB, "testuser"), "web"))
}
@Test
fun `another account on the same server yields another key`() {
val mine = chatViewModelKey(accountFingerprint(hostA, "testuser"), "web")
assertNotEquals(mine, chatViewModelKey(accountFingerprint(hostA, "someone-else"), "web"))
}
@Test
fun `another session inside the same account yields another key`() {
val main = chatViewModelKey(accountFingerprint(hostA, "testuser"), "web")
// 同一个环境里每个会话各留一份状态:key 必须分得开,否则切会话会把上一个会话的消息留下来。
assertNotEquals(main, chatViewModelKey(accountFingerprint(hostA, "testuser"), "web:sub-1"))
}
@Test
fun `a missing username still cannot collide with a blank one on another server`() {
// 登录状态里 username 缺失(老安装 / 异常路径)时指纹退化成 baseUrl 一段:
// 地址不同仍然分得开,这才是这条缺陷最要紧的一半。
assertNotEquals(
chatViewModelKey(accountFingerprint(hostA, null), "web"),
chatViewModelKey(accountFingerprint(hostB, null), "web"),
)
// 缺失与空串是同一个意思,不该拼出两种 key(否则同一次登录的两个时刻会各是各的)。
assertEquals(
accountFingerprint(hostA, null),
accountFingerprint(hostA, ""),
)
}
@Test
fun `the account fingerprint is baseUrl plus username, the existing caliber`() {
// 口径与 SessionPreferences / SessionsViewModel 同一份:就地改这里会同时改掉两处闸门。
assertEquals("http://host-a:10942/|testuser", accountFingerprint(hostA, "testuser"))
assertEquals("http://host-a:10942/|", accountFingerprint(hostA, null))
}
}
@@ -19,7 +19,7 @@ import top.yeij.cyrene.domain.chat.SendChannel
*
* ## 背景:W45 把探测换成了直接尝试(主人的定案)
* W41 的阶梯是「WS 未就绪 ⇒ 先探 `GET /api/v1/health` ⇒ 通才回落 REST」。真机回归
* (`devtools/ws_block_proxy.py` 只掐 `/ws/chat`、HTTP 全放行)发现消息进了队列而不是回落 REST,
* (`devtools/ws-block-proxy/ws_block_proxy.py` 只掐 `/ws/chat`、HTTP 全放行)发现消息进了队列而不是回落 REST,
* 而 `ProbeClientTest` 证明那条链在 JVM 上是通的、`Unreachable` 生在**连接之前**,三种成因在外
* 同形 ⇒ 无法定论。判据与论证见 [sendRouteFor]。
*
@@ -0,0 +1,210 @@
package top.yeij.cyrene.ui.sessions
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* 非激活会话的新消息 ⇒ 节流地静默重拉(W49 修的 S6:「停在主会话时次级会话来了新消息,
* 抽屉红点不出现,必须开一次抽屉才补上」)。
*
* 两块纯判据([persistedRoute] 定这一帧走哪条路,[silentRefreshStep] 定这一刻该不该拉、拉完窗口怎么走),
* 钉住四条要一直成立的边界:
* 1. 非激活会话的新消息 ⇒ **该拉**;
* 2. 同一节流窗口内的后续消息 ⇒ **不拉**,但窗口到点**必定**还会再拉一次(trailing,红点不会停在旧值);
* 3. 当前打开的那个会话的新消息 ⇒ **不拉**(那条路本来就有「看到即已读」,多拉一次列表就是白花两个
* 请求);
* 4. 换账号 / 换地址 ⇒ **不被窗口挡住**(该拉)。切会话与它同类,但那条路刻意没接进来 ——
* 理由见 [silentRefreshStep] 与 SessionsViewModel.select。
*
* 时钟一律由测试喂(判据自己不读 `System.currentTimeMillis()`),所以这里不用 sleep、也没有 flaky。
*/
class InactiveSessionPullTest {
// —— 路由:哪一帧有资格触发拉列表 ——
@Test
fun `a message in another session takes the pull route`() {
assertEquals(
PersistedRoute.InactiveSession,
persistedRoute(sessionId = "web:other", activeSessionId = "web"),
)
}
@Test
fun `a message in the active session never pulls the list`() {
// 「别加请求」:当前会话的未读由「看到即已读」处理掉了,抽屉里的未读不会因这一帧变多。
assertEquals(
PersistedRoute.ActiveSession,
persistedRoute(sessionId = "web", activeSessionId = "web"),
)
}
@Test
fun `ids are compared verbatim, not by prefix`() {
// 主会话 `web` 与次级会话 `web:xxx` 是**两个**会话(前缀相同):判据必须是等值,不是前缀。
assertEquals(
PersistedRoute.InactiveSession,
persistedRoute(sessionId = "web:1", activeSessionId = "web"),
)
assertEquals(
PersistedRoute.InactiveSession,
persistedRoute(sessionId = "web", activeSessionId = "web:1"),
)
}
// —— 节流:该拉的那一刻拉,窗口内不重复拉 ——
@Test
fun `the first inactive message pulls right away`() {
val step = silentRefreshStep(
cause = SilentRefreshCause.InactiveSessionMessage,
nowMillis = 1_000L,
throttle = SilentRefreshThrottle(),
)
assertEquals(SilentRefreshAction.PullNow, step.action)
// 账本:窗口从这一拉开始,且没有悬着的补拉。
assertEquals(1_000L, step.throttle.lastPullAt)
assertFalse(step.throttle.trailingPending)
}
@Test
fun `a second message inside the window does not pull, but arms the trailing pull`() {
val armed = silentRefreshStep(
cause = SilentRefreshCause.InactiveSessionMessage,
nowMillis = 1_000L,
throttle = SilentRefreshThrottle(lastPullAt = 0L),
)
assertEquals(SilentRefreshAction.ScheduleTrailing, armed.action)
// 等到窗口走完(0 + 窗口),不是「从这一刻再等一个窗口」。
assertEquals(SILENT_REFRESH_WINDOW_MILLIS - 1_000L, armed.delayMillis)
assertTrue(armed.throttle.trailingPending)
}
@Test
fun `more messages inside the same window do not arm a second trailing pull`() {
val armed = SilentRefreshThrottle(lastPullAt = 0L, trailingPending = true)
val step = silentRefreshStep(
cause = SilentRefreshCause.InactiveSessionMessage,
nowMillis = 2_000L,
throttle = armed,
)
// 已经排上的那次补拉就是「窗口里所有成因的合并」,再排一次只会多一个请求。
assertEquals(SilentRefreshAction.Ignore, step.action)
assertEquals(armed, step.throttle)
}
@Test
fun `a message exactly one window later pulls again`() {
// 边界口径:正好等于窗口**算窗口已过**(见 silentRefreshStep 的 KDoc)。
val step = silentRefreshStep(
cause = SilentRefreshCause.InactiveSessionMessage,
nowMillis = SILENT_REFRESH_WINDOW_MILLIS,
throttle = SilentRefreshThrottle(lastPullAt = 0L),
)
assertEquals(SilentRefreshAction.PullNow, step.action)
assertEquals(SILENT_REFRESH_WINDOW_MILLIS, step.throttle.lastPullAt)
}
@Test
fun `one millisecond before the window is over still does not pull`() {
val step = silentRefreshStep(
cause = SilentRefreshCause.InactiveSessionMessage,
nowMillis = SILENT_REFRESH_WINDOW_MILLIS - 1L,
throttle = SilentRefreshThrottle(lastPullAt = 0L),
)
assertEquals(SilentRefreshAction.ScheduleTrailing, step.action)
assertEquals(1L, step.delayMillis)
}
// —— trailing:连发之后一定还会再拉一次 ——
@Test
fun `a burst of five messages ends as two pulls`() {
// 别人连发 5 条(第 1 条时就拉过了):总共**两次**请求 —— 第 1 条那次,以及窗口到点的补拉。
// 只有上限没有下限的话,第 2..5 条会被窗口吃掉,红点停在「第 1 条」的旧值上。
var ledger = throttleAfterPull(0L)
// 第 2 条:窗口内 ⇒ 不拉,但排上补拉。
val second = silentRefreshStep(SilentRefreshCause.InactiveSessionMessage, 500L, ledger)
assertEquals(SilentRefreshAction.ScheduleTrailing, second.action)
ledger = second.throttle
// 第 3、4、5 条:同一窗口内 ⇒ 安静(补拉已经排上了)。
for (at in listOf(900L, 1_500L, 2_800L)) {
val step = silentRefreshStep(SilentRefreshCause.InactiveSessionMessage, at, ledger)
assertEquals(SilentRefreshAction.Ignore, step.action)
ledger = step.throttle
}
// 补拉到点:把它拉掉(VM 到点后走的就是这一步 —— 账本按「真的拉过一次」推进)。
val trailingAt = 500L + second.delayMillis
assertEquals(SILENT_REFRESH_WINDOW_MILLIS, trailingAt)
ledger = throttleAfterPull(trailingAt)
assertEquals(SILENT_REFRESH_WINDOW_MILLIS, ledger.lastPullAt)
assertFalse(ledger.trailingPending)
// 补拉**不是终点**:下一个窗口里的新消息照样能拉(不是「只补一次就哑了」)。
val later = silentRefreshStep(
SilentRefreshCause.InactiveSessionMessage,
SILENT_REFRESH_WINDOW_MILLIS * 2,
ledger,
)
assertEquals(SilentRefreshAction.PullNow, later.action)
}
@Test
fun `the trailing delay lands exactly on the window boundary`() {
// 补拉要等的时间与判据用的是同一个常量:从「排上的那一刻」往后等,正好落在窗口结束那一点
// (VM 到点就直接拉、不再回头问判据,所以这里钉的是那个时长本身没算错)。
val armed = silentRefreshStep(
cause = SilentRefreshCause.InactiveSessionMessage,
nowMillis = 700L,
throttle = SilentRefreshThrottle(lastPullAt = 0L),
)
assertEquals(0L + SILENT_REFRESH_WINDOW_MILLIS, 700L + armed.delayMillis)
}
// —— 身份变化不被窗口挡住 ——
@Test
fun `a context change pulls even inside the window`() {
val insideWindow = SilentRefreshThrottle(lastPullAt = 1_000L, trailingPending = true)
val step = silentRefreshStep(
cause = SilentRefreshCause.ContextChanged,
nowMillis = 1_500L,
throttle = insideWindow,
)
// 换账号 / 换地址那一拉与「别的会话又攒了几条」无关,不该被窗口挡住。
assertEquals(SilentRefreshAction.PullNow, step.action)
// 这一拉已经把窗口里那条消息要的刷新一并做掉 ⇒ 窗口重开,不再补一次。
assertEquals(1_500L, step.throttle.lastPullAt)
assertFalse(step.throttle.trailingPending)
}
// —— 账本:只记真的拉过的那一次 ——
@Test
fun `an empty ledger has no pull time`() {
assertNull(SilentRefreshThrottle().lastPullAt)
}
@Test
fun `after a pull the window restarts and nothing is left pending`() {
val ledger = throttleAfterPull(42L)
assertEquals(42L, ledger.lastPullAt)
assertFalse(ledger.trailingPending)
}
}
+72
View File
@@ -0,0 +1,72 @@
# WS 断、HTTP 通的窄代理(`devtools/ws-block-proxy/`)
一个**只掐 `/ws/chat`、其余 HTTP 一律放行**的窄代理:用来把「WebSocket 断了,但 REST 还通」
这个场景变成可复现、可取证的状态。纯 Python 3 标准库,零第三方依赖。
它**不是**产品的一部分,也**不是**后端行为的权威来源。它只做一件事:把发往 `/ws/chat`
的请求就地回 `503`(`ws blocked by test proxy`),别的请求原样转发给真后端,并把
**每一行的去向**打到 stdout。
它**不做什么**:不鉴权、不改写请求体、不落盘、不重试、不实现任何业务端点。
转发时逐行丢掉 hop-by-hop 头(`Connection` / `Upgrade` / `Transfer-Encoding` 等),
请求体按 `Content-Length` 读全再转发——**只要真后端还听得懂,它就照转**。
---
## 1. 用途
W41 的「三级阶梯」(WS → 健康探测 → REST 回落)只在**WS 连不上**时才会走。真实的断线
不可控也不好复现,用这个代理把 WS 单独掐掉即可:客户端应该表现为「重连中…」(而不是「离线」),
然后按阶梯回落 `POST /api/v1/chat` 把消息发出去。
排查时的用处同样直接:**想知道 App 到底发了什么、发没发出去**,看代理日志就行。
## 2. 用法
```bash
python ws_block_proxy.py <listen_port> <target_port>
```
- `<listen_port>`:代理自己监听的端口(**App 要连的那个口**,见下面的 `adb reverse`)
- `<target_port>`:真后端在本机监听的端口
典型用法(真后端在 10942,代理听 18098):
```bash
# 终端 A:代理听 18098,转发给 127.0.0.1:10942
cd /d/Project/Code/Uni/Cyrene-For-Android/devtools/ws-block-proxy
python ws_block_proxy.py 18098 10942
# 终端 B:把设备上的 10942 引到宿主机的 18098(即代理)
adb reverse tcp:10942 tcp:18098
```
App 侧不用改任何配置:它照旧连 `127.0.0.1:10942`,被 `adb reverse` 送到代理。
日志(stdout)长这样:
```
proxy 18098 -> 127.0.0.1:10942, blocking /ws/chat # 启动
BLOCKED GET /ws/chat?token=... # WS 被掐(App 会退避重试)
PASS GET /api/v1/sessions/web/messages?limit=50 -> 200
PASS POST /api/v1/chat -> 200
ERR GET /api/v1/health -> ConnectionRefusedError(...) # 真后端没起来时
```
收尾:撤掉端口映射(`adb reverse --remove tcp:10942`)、Ctrl-C 停代理。
**用完记得撤**——留着的话别的会话连真后端也会经过这个「掐 WS」的口。
## 3. 关键性质(它最有价值的一条)
**调试期间 Web 前端是直连后端(`127.0.0.1:10942`)的,不经过 `adb reverse`。**
所以设备侧的口只有 App 会用,**代理日志里的每一行都只可能来自 App** ——
不存在「这条请求到底是谁发的」这个问题。这是分辨「App 到底发了什么」最干净的判据:
要确认某次发送走的是 WS 还是 REST 回落,看日志里有没有对应的 `PASS POST /api/v1/chat` 就够了,
不需要在客户端加任何日志。
## 4. 注意
- **日志里会带查询串**(例如 `/ws/chat?token=...`),贴给别人之前先抹掉凭据。
脚本本身**不硬编码任何凭据**,也不写任何文件。
- 它**不是**后端行为的事实来源:要判断真后端怎么回,去看真后端本身。
- 端口随便挑,但**别让代理和真后端抢同一个口**;`10942` 是真后端的常规端口。
+90
View File
@@ -0,0 +1,90 @@
"""W41 回归用的窄代理:HTTP 全放行,只把 /ws/chat 掐掉。
用途:模拟「WS 断了但 HTTP 通」——W41 三级阶梯应该在这种情况下
探测 /api/v1/health 成功、回落 POST /api/v1/chat 把消息发出去。
仅本机开发用;这个文件**在 git 里**(`devtools/ws-block-proxy/`),不再放 `app/build/`——
那里被 gitignore,一次 `gradlew clean` 就把它扫没了。用法见同目录的 README.md。
用法:python ws_block_proxy.py <listen_port> <target_port>
"""
import http.client
import socketserver
import sys
LISTEN = int(sys.argv[1])
TARGET = int(sys.argv[2])
BLOCK = "/ws/chat"
HOP_BY_HOP = {
"connection", "keep-alive", "proxy-authenticate", "proxy-authorization",
"te", "trailers", "transfer-encoding", "upgrade",
}
class Handler(socketserver.BaseRequestHandler):
def handle(self):
self.request.settimeout(30)
buf = b""
while b"\r\n\r\n" not in buf:
chunk = self.request.recv(4096)
if not chunk:
return
buf += chunk
head, _, rest = buf.partition(b"\r\n\r\n")
lines = head.decode("latin-1").split("\r\n")
method, path, _ = lines[0].split(" ", 2)
if path.split("?")[0] == BLOCK:
body = b"ws blocked by test proxy"
self.request.sendall(
b"HTTP/1.1 503 Service Unavailable\r\n"
b"Content-Type: text/plain\r\n"
b"Content-Length: " + str(len(body)).encode() + b"\r\n\r\n" + body
)
print("BLOCKED %s %s" % (method, path), flush=True)
return
headers = {}
for line in lines[1:]:
k, _, v = line.partition(":")
if k.lower() in HOP_BY_HOP:
continue
headers[k.strip()] = v.strip()
length = int(headers.get("Content-Length") or 0)
body = rest
while len(body) < length:
more = self.request.recv(length - len(body))
if not more:
break
body += more
conn = http.client.HTTPConnection("127.0.0.1", TARGET, timeout=60)
try:
conn.request(method, path, body=body if length else None, headers=headers)
resp = conn.getresponse()
payload = resp.read()
out = ["HTTP/1.1 %d %s" % (resp.status, resp.reason)]
for k, v in resp.getheaders():
if k.lower() in HOP_BY_HOP or k.lower() == "content-length":
continue
out.append("%s: %s" % (k, v))
out.append("Content-Length: %d" % len(payload))
self.request.sendall(("\r\n".join(out) + "\r\n\r\n").encode("latin-1") + payload)
print("PASS %s %s -> %d" % (method, path, resp.status), flush=True)
except Exception as exc: # noqa: BLE001
self.request.sendall(b"HTTP/1.1 502 Bad Gateway\r\nContent-Length: 0\r\n\r\n")
print("ERR %s %s -> %r" % (method, path, exc), flush=True)
finally:
conn.close()
class Server(socketserver.ThreadingTCPServer):
allow_reuse_address = True
daemon_threads = True
if __name__ == "__main__":
print("proxy %d -> 127.0.0.1:%d, blocking %s" % (LISTEN, TARGET, BLOCK), flush=True)
Server(("127.0.0.1", LISTEN), Handler).serve_forever()