Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5dc6408bb9 | ||
|
|
93cf3e359a | ||
|
|
e589ab303d | ||
|
|
4f860aa499 |
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)
|
||||
}
|
||||
}
|
||||
@@ -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` 是真后端的常规端口。
|
||||
@@ -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()
|
||||
Reference in new issue
Block a user