diff --git a/app/src/main/kotlin/top/yeij/cyrene/ui/sessions/InactiveSessionPull.kt b/app/src/main/kotlin/top/yeij/cyrene/ui/sessions/InactiveSessionPull.kt new file mode 100644 index 0000000..343021d --- /dev/null +++ b/app/src/main/kotlin/top/yeij/cyrene/ui/sessions/InactiveSessionPull.kt @@ -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) diff --git a/app/src/main/kotlin/top/yeij/cyrene/ui/sessions/SessionsViewModel.kt b/app/src/main/kotlin/top/yeij/cyrene/ui/sessions/SessionsViewModel.kt index 96d4726..2e4fbbd 100644 --- a/app/src/main/kotlin/top/yeij/cyrene/ui/sessions/SessionsViewModel.kt +++ b/app/src/main/kotlin/top/yeij/cyrene/ui/sessions/SessionsViewModel.kt @@ -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) + } + } } /** diff --git a/app/src/test/kotlin/top/yeij/cyrene/ui/sessions/InactiveSessionPullTest.kt b/app/src/test/kotlin/top/yeij/cyrene/ui/sessions/InactiveSessionPullTest.kt new file mode 100644 index 0000000..c1abd4a --- /dev/null +++ b/app/src/test/kotlin/top/yeij/cyrene/ui/sessions/InactiveSessionPullTest.kt @@ -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) + } +}