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 条。
This commit is contained in:
AskaEth committed 2026-10-05 22:28:19 +08:00
1 parent df8df67742
commit 93cf3e359a
3 files changed
+556 -15

No files matched your search

@@ -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
@@ -69,6 +71,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 +114,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 +169,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 +276,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 +345,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 +452,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 +467,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)
}
}
}
/**
@@ -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)
}
}