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。
This commit is contained in:
1 parent
df8df67742
commit
4f860aa499
11 files changed
+308
-21
No files matched your search
@@ -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]。
|
||||
*
|
||||
|
||||
Reference in new issue
Block a user