merge: W38 —— 分享落地必须等到连接就绪:超时不再等于发送失败,内容不再从队列丢失,连接恢复后自动补发

This commit is contained in:
AskaEth committed 2026-10-05 13:21:26 +08:00
commit d8228fb315
4 files changed
+277 -20

No files matched your search

@@ -54,10 +54,8 @@ import coil3.compose.AsyncImage
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeoutOrNull
import top.yeij.cyrene.app.AppContainer import top.yeij.cyrene.app.AppContainer
import top.yeij.cyrene.app.ShareIntake import top.yeij.cyrene.app.ShareIntake
import top.yeij.cyrene.data.repository.DocumentCacheStore import top.yeij.cyrene.data.repository.DocumentCacheStore
@@ -95,8 +93,14 @@ import top.yukonga.miuix.kmp.theme.MiuixTheme
/** 判定「贴着底部」的容差(px)。 */ /** 判定「贴着底部」的容差(px)。 */
private const val BOTTOM_TOLERANCE = 64 private const val BOTTOM_TOLERANCE = 64
/** 分享落地前等 WS 连接就绪的上限(毫秒):超时也照样发,让失败落在看得见的地方。 */ /**
private const val SHARE_CONNECT_TIMEOUT_MILLIS = 3_000L * 分享落地前「等到该说一句」的上限(毫秒)。
*
* **不是发送的时限**:到点之后内容照旧留在待发队列里等连接,只是界面上多一句如实告知
* ([SHARE_NOT_CONNECTED_NOTICE])。此前这个数被当成「超时就照发」的口径,那正是
* 「超时 = 发送失败」那条假象的来处——见 [shareLanding] 的 KDoc。
*/
private const val SHARE_WAIT_NOTICE_MILLIS = 3_000L
/** 「已定位 / 这条不在窗口里」那句告知挂多久(毫秒)。 */ /** 「已定位 / 这条不在窗口里」那句告知挂多久(毫秒)。 */
private const val SEARCH_NOTICE_MILLIS = 8_000L private const val SEARCH_NOTICE_MILLIS = 8_000L
@@ -326,21 +330,62 @@ fun ChatScreen(
) { uris -> viewModel.onDocumentsPicked(uris.map { it.toString() }) } ) { uris -> viewModel.onDocumentsPicked(uris.map { it.toString() }) }
// 「分享到 App」的落地时机。待落地的内容活在 Compose 之外(`ShareIntake`),这里只做三件事: // 「分享到 App」的落地时机。待落地的内容活在 Compose 之外(`ShareIntake`),这里只做三件事:
// 挑时机、取走一条、交给既有发送路径。 // 挑时机、取走一条、交给既有发送路径。判据本身是纯函数 [shareLanding](可单测)。
// - **先等连接、但只等一小会儿**:短文本走 WS,而冷启动时握手还没完成(分享冷启动必然撞上), // - **取走必须晚于「连接就绪」**:短文本走 WS,而冷启动时握手还没完成(分享冷启动必然撞上),
// 抢在那之前发就只换来一次「发送失败,请重试」。等待有上限——真的没网时宁可退化成 // 抢在那之前发只换来一次 `sendChat` 回 false 的「发送失败,请重试」;而 [ShareIntake.take] 是
// 「内容回到输入框、用户自己重试」,也不要让它停在界面上看不见的地方。 // 原子出队、没有归还路径 ⇒ 那一次「其实一次都没发出去」的失败会把内容从待发队列里抹掉,
// 连重试资格一起丢。**超时绝不等于发送失败**:没连上时一个字节都不发,内容原地等着,
// 连接恢复后由下面这个 effect 自动补发。
// - **等太久要说一句,但只说一次**:「还没连上、内容会等连上后自动发出」走 [ChatViewModel.showShareNotice]
// 这条柔和通道(不是带 X 的 error 横幅),文案见 [SHARE_NOT_CONNECTED_NOTICE]。
// - **只有当前会话的 ChatScreen 在组合**:已访问过的会话的 VM 都还活着(Activity 级 // - **只有当前会话的 ChatScreen 在组合**:已访问过的会话的 VM 都还活着(Activity 级
// ViewModelStore),由 UI 层认领才不会让旧会话把内容抢走。 // ViewModelStore),由 UI 层认领才不会让旧会话把内容抢走。
// - [ShareIntake.take] 是原子出队:同一条内容不会被发两次(重组 / 切会话 / 换连接都会重跑 // - [ShareIntake.take] 是原子出队:同一条内容不会被发两次。重组 / 切会话 / 连接变化都会重跑这个
// 这个 effect)。 // effect,哪怕其中一次拿着**已经过期的**快照进来,`take()` 给出的也只是队列里的下一条或 null。
val pendingShares by ShareIntake.pending.collectAsState() val pendingShares by ShareIntake.pending.collectAsState()
LaunchedEffect(sessionId, pendingShares) { val shareConnection = state.connection
if (pendingShares.isEmpty()) return@LaunchedEffect // 「这一轮等待已经等够久了」。计时只跟**「队列里有东西」与「连上了没有」这两个事实**走:
withTimeoutOrNull(SHARE_CONNECT_TIMEOUT_MILLIS) { // 连接在 Connecting / Reconnecting 之间抖动不重置它——否则一直连不上时,该说的那句话反而
viewModel.state.first { it.connection == WsState.Connected } // 永远等不到(每次抖动都从头数秒)。
var shareWaitedLong by remember(sessionId) { mutableStateOf(false) }
// 「这一轮等待已经说过了」。它与上面那个标志的生命周期刻意不同:连接抖动(effect 因键变化重跑)
// 不把它归零,否则同一句话会跟着抖动反复弹——刷屏。内容被取走、或队列清空,才算了结这一轮。
var shareToldWaiting by remember(sessionId) { mutableStateOf(false) }
LaunchedEffect(sessionId, pendingShares.isEmpty(), shareConnection == WsState.Connected) {
if (pendingShares.isEmpty() || shareConnection == WsState.Connected) {
shareWaitedLong = false
return@LaunchedEffect
}
delay(SHARE_WAIT_NOTICE_MILLIS)
shareWaitedLong = true
}
// 这个 effect 的键里有连接状态 ⇒ 连接后来就绪时会重跑,内容因此**一次都不会漏发**(也不需要
// 任何轮询:状态一变就重来一遍判据)。
LaunchedEffect(sessionId, pendingShares, shareConnection, shareWaitedLong) {
when (
shareLanding(
hasPending = pendingShares.isNotEmpty(),
connection = shareConnection,
waitedLong = shareWaitedLong,
alreadyTold = shareToldWaiting,
)
) {
// 取走之后这一轮等待就结束了:下一批内容(或下一次断线)重新计时、重新有资格说那句话。
ShareLanding.Send -> {
shareToldWaiting = false
ShareIntake.take()?.let { share -> viewModel.sendShared(share, shareNoticeFor(share)) }
}
ShareLanding.TellWaiting -> {
shareToldWaiting = true
viewModel.showShareNotice(SHARE_NOT_CONNECTED_NOTICE)
}
// 未就绪:**绝不 take()**(内容原封不动留在 holder 里)、也不说任何话。
ShareLanding.KeepWaiting -> Unit
ShareLanding.Nothing -> shareToldWaiting = false
} }
ShareIntake.take()?.let { share -> viewModel.sendShared(share, shareNoticeFor(share)) }
} }
// 是否跟随底部(微信/QQ 式):只有用户主动滚到别处时才停止跟随。 // 是否跟随底部(微信/QQ 式):只有用户主动滚到别处时才停止跟随。
@@ -868,6 +913,17 @@ private fun busyLabel(state: ChatUiState): String {
).joinToString(" ").ifBlank { "" } ).joinToString(" ").ifBlank { "" }
} }
/**
* 「分享进来的内容还在等连接」的如实告知(判据见 [shareLanding],落点是它的 [ShareLanding.TellWaiting])。
*
* 说清两件事:**还没连上**(所以一个字节都还没发出去——别让人以为东西已经在路上),
* 以及**内容留着、连上后自动发出**(用户不需要做任何事,也不用重来一遍)。
* 措辞刻意不写「失败」,更不写「请重试」:失败的是连接,而内容一条没少、就在待发队列里等着;
* 说成「发送失败」既不是事实(没发生过任何发送),也会把人推向「重新分享/重新输入」这种多余动作。
* 它走 `ChatUiState.shareNotice` 这条柔和通道,**不是**带 X 的 error 横幅(error 的语义是「出错了」)。
*/
const val SHARE_NOT_CONNECTED_NOTICE = "还没连上,分享的内容会留着,连上后自动发出"
/** /**
* 「这次分享的内容没能读进来」的如实告知(判据见 [shareNoticeFor])。 * 「这次分享的内容没能读进来」的如实告知(判据见 [shareNoticeFor])。
* *
@@ -552,10 +552,13 @@ data class ChatUiState(
*/ */
val errorIsQueueFullNotice: Boolean = false, val errorIsQueueFullNotice: Boolean = false,
/** /**
* 「分享到 App」落地的如实告知(例如有几张图没能发送);null = 没有要说的。 * 「分享到 App」相关的如实告知;null = 没有要说的。
* *
* 与 [error] 分开:分享通常**成功**,只是被本端的接收上限截掉了一部分,那不是错误。 * 两个来源共用这一块牌子:分享**落地之后**的那条(例如有几张图没能发送,经 [sendShared]),
* 它到期自动消失([SHARE_NOTICE_MILLIS]),因为它描述的是刚刚发生的一次动作。 * 以及**落地之前**的那条——内容还在待发队列里等 WS 连接([top.yeij.cyrene.ui.chat.ShareLanding.TellWaiting],
* 由渲染层经 [showShareNotice] 记入)。两者都**不是错误**,所以都与 [error] 分开:
* 前者是分享通常成功、只是被本端的接收上限截掉了一部分;后者是发送**还没发生**(连接一到就自动发)。
* 它到期自动消失([SHARE_NOTICE_MILLIS]),因为它描述的是刚刚发生(或刚刚等着)的一次动作。
*/ */
val shareNotice: String? = null, val shareNotice: String? = null,
/** /**
@@ -1135,8 +1138,16 @@ class ChatViewModel(
} }
} }
/** 记一条分享提示,到期自动收走(同一条未过期就不重复刷)。 */ /**
private fun showShareNotice(text: String) { * 记一条分享提示,到期自动收走(同一条未过期就不重复刷)。
*
* 公开是给渲染层用的:有一条分享相关的告知**不产生于本 VM 的发送路径**——
* 「内容还在待发队列里等连接」([top.yeij.cyrene.ui.chat.ShareLanding.TellWaiting])。
* 判它的是 UI 层(`ChatScreen` 的那个 effect,认领待发内容的也只有它),因为「分享进来的内容」
* 按约定只由**当前会话在组合中**的界面认领;但牌子该怎么挂、挂多久属于本 VM 的状态,
* 所以判据留在 UI、通道留在 VM,而不是在 UI 里自己造一块牌子。
*/
fun showShareNotice(text: String) {
_state.update { it.copy(shareNotice = text) } _state.update { it.copy(shareNotice = text) }
viewModelScope.launch { viewModelScope.launch {
kotlinx.coroutines.delay(SHARE_NOTICE_MILLIS) kotlinx.coroutines.delay(SHARE_NOTICE_MILLIS)
@@ -0,0 +1,70 @@
package top.yeij.cyrene.ui.chat
import top.yeij.cyrene.data.ws.WsState
/** 「分享进来的内容」这一刻该做什么([shareLanding] 的取值)。 */
enum class ShareLanding {
/** 没有待发内容:什么都不做(任何连接状态下都一样)。 */
Nothing,
/** 还没连上,继续等:**既不取走也不发送**,内容原封不动留在待发队列里。 */
KeepWaiting,
/** 等到该说一句的时候了:说一次「还没连上、连上后自动发出」(柔和通道,不是错误横幅)。 */
TellWaiting,
/** 连接就绪:取走一条并交给既有发送路径。 */
Send,
}
/**
* 分享落地时机的判据(纯函数,UI 与测试共用)。
*
* ## 为什么取走必须晚于「连接就绪」
* 短文本走 WS `chat` 帧,而**分享冷启动必然赶上握手还没完成**:`ChatSocket` 未就绪时
* `sendChat` 回 `false`,`ChatViewModel.deliver` 随即 `rollback(localId, "发送失败,请重试")`。
* 2026-10-05 11:56 实机取证:客户端在 `GET /ws/chat status=200`(握手成功)前后的窗口里投递分享,
* 得到「发送失败,请重试」+ 正文退回输入栏,而**后端零请求**(`POST /api/v1/chat` 一条都没有,
* 也没有任何 `error` 帧)——那条横幅把「一次都没尝试发出去」说成了「发送失败」。
*
* 更糟的是它会**吃掉重试资格**:[top.yeij.cyrene.domain.share.PendingShareHolder.take] 是原子出队、
* **没有归还路径**。失败之后内容既不在待发队列里、也不在服务端,只剩回滚进输入栏的那一份
* (多图分享里除了第一张,其余几条连输入栏都回不去)。所以在连接就绪之前**一个字节都别动**:
* 不 `take()`、不 `sendShared`、不产生任何 error 横幅——**超时绝不等于发送失败**。
*
* ## 判据与优先级
* 1. **没有待发内容** ⇒ [ShareLanding.Nothing]:任何连接状态下都不做事、也不说话。
* 2. **[WsState.Connected]** ⇒ [ShareLanding.Send]:取走一条并发送。它**压过**后两条——连接一到就该发,
* 哪怕此前已经等够久、那句「等连上会自动发」也说过了。返回值是**单值动作**,所以「发送」与
* 「告知」在类型上就不可能同时发生(不会既发了又报怨还没连上)。
* 3. 未就绪且**还没等够久**(`waitedLong = false`)⇒ [ShareLanding.KeepWaiting]:继续等,什么都不说
* (刚分享进来就弹一句「还没连上」是噪声,握手正常也就几百毫秒)。
* 4. 未就绪、已等够久、且**还没说过** ⇒ [ShareLanding.TellWaiting]:说一次。
* 5. 未就绪、已等够久、且**已经说过** ⇒ [ShareLanding.KeepWaiting]:同一轮等待只说一次。
* 这一步不是可有可无的:连接在 `Connecting` 与 `Reconnecting` 之间抖动会让调用方反复问到
* 同一个问题,没有这一条就是反复弹同一句话。
*
* ## 「等够久」与「说过了」为什么不在这里判
* 判据本身**看不出时间与历史**——那样它才是一个能穷举、能在 JVM 上单测的函数(见 `ShareLandingTest`),
* 而不是又一份会随重组漂移的 Compose 状态。两个事实由调用方各自记账(`ChatScreen` 里那两个
* `remember`),且**它们的生命周期也不一样**:「说过了」在连接抖动时不归零(不刷屏),
* 而「等够久」的计时只看「队列里有没有东西」与「连上了没有」这两个事实。
*
* @param hasPending 待发队列里有没有内容。只看一眼的判据是 `ShareIntake.pending`(StateFlow),
* **绝不是 `take()`**——那是出队。
* @param connection 这一刻的 WS 连接状态。
* @param waitedLong 这一轮等待是否已经等够久(够久才值得说那句话)。
* @param alreadyTold 这一轮等待是否已经告知过(同一轮只说一次)。
*/
fun shareLanding(
hasPending: Boolean,
connection: WsState,
waitedLong: Boolean,
alreadyTold: Boolean,
): ShareLanding = when {
!hasPending -> ShareLanding.Nothing
connection == WsState.Connected -> ShareLanding.Send
!waitedLong -> ShareLanding.KeepWaiting
alreadyTold -> ShareLanding.KeepWaiting
else -> ShareLanding.TellWaiting
}
@@ -0,0 +1,120 @@
package top.yeij.cyrene.ui.chat
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
import top.yeij.cyrene.data.ws.WsState
/**
* 分享落地时机的判据([shareLanding])。
*
* 背景:主人实机复现——WS 还没连上时投递分享,得到「发送失败,请重试」+ 正文退回输入栏,
* 而后端**零请求**(`POST /api/v1/chat` 一条都没有)。原来是「等连接」那个 `withTimeoutOrNull`
* 的返回值被丢掉了 ⇒ **超时与成功走同一条下游**:照样取走内容去发。而 `ShareIntake.take()` 是
* 原子出队、没有归还路径,那一次「其实一次都没发出去」的失败把内容从待发队列里抹掉了。
*
* 本测试钉住三件事:
*
* 1. **取走内容的前提是连接就绪**:未就绪时恒不 [ShareLanding.Send](不取、不发)。
* 2. **等够久只说一次**「还没连上、连上后会自动发出」([ShareLanding.TellWaiting]),
* 同一轮等待里连接反复抖动不再说第二遍。
* 3. 反面边界:**没有待发内容**时任何连接状态下都不做事、不告知;**已经连上**时不告知
* (发送压过告知,返回值是单值动作 ⇒ 两者在类型上就不可能同时发生)。
*/
class ShareLandingTest {
@Test
fun `content is taken only once the connection is ready`() {
val ready = shareLanding(
hasPending = true,
connection = WsState.Connected,
waitedLong = false,
alreadyTold = false,
)
assertEquals(ShareLanding.Send, ready)
// 其余任何连接状态都不是「可以取走」:Idle / Connecting / Reconnecting / Offline 一律不动它。
WsState.entries.filter { it != WsState.Connected }.forEach { connection ->
listOf(false, true).forEach { waitedLong ->
listOf(false, true).forEach { alreadyTold ->
val landed = shareLanding(true, connection, waitedLong, alreadyTold)
assertTrue(
"$connection / waitedLong=$waitedLong / alreadyTold=$alreadyTold 不该取走内容",
landed != ShareLanding.Send,
)
}
}
}
}
@Test
fun `before the connection is ready nothing is taken and nothing is said`() {
assertEquals(
ShareLanding.KeepWaiting,
shareLanding(true, WsState.Connecting, waitedLong = false, alreadyTold = false),
)
assertEquals(
ShareLanding.KeepWaiting,
shareLanding(true, WsState.Reconnecting, waitedLong = false, alreadyTold = true),
)
}
@Test
fun `waiting too long with no connection says it, once`() {
assertEquals(
ShareLanding.TellWaiting,
shareLanding(true, WsState.Connecting, waitedLong = true, alreadyTold = false),
)
}
@Test
fun `an already told wait stays quiet however the connection flaps`() {
// 这一条是「不刷屏」:连接在 Connecting 与 Reconnecting 之间抖动会让界面反复问到同一个问题。
WsState.entries.filter { it != WsState.Connected }.forEach { connection ->
assertEquals(
"$connection 上不该把同一句话弹第二遍",
ShareLanding.KeepWaiting,
shareLanding(true, connection, waitedLong = true, alreadyTold = true),
)
}
}
@Test
fun `sending wins over telling and they never happen together`() {
// 连接一到就该发:哪怕此前已经等够久、那句「等连上会自动发」也说过了,此刻的动作也只有「发送」。
listOf(false, true).forEach { waitedLong ->
listOf(false, true).forEach { alreadyTold ->
assertEquals(
"waitedLong=$waitedLong / alreadyTold=$alreadyTold 时该发就发",
ShareLanding.Send,
shareLanding(true, WsState.Connected, waitedLong, alreadyTold),
)
}
}
}
@Test
fun `nothing pending does nothing in any connection state`() {
WsState.entries.forEach { connection ->
listOf(false, true).forEach { waitedLong ->
listOf(false, true).forEach { alreadyTold ->
assertEquals(
"$connection 上没有待发内容就不该有动作",
ShareLanding.Nothing,
shareLanding(false, connection, waitedLong, alreadyTold),
)
}
}
}
}
@Test
fun `the waiting notice says it is not connected and that it will go out by itself`() {
assertTrue(SHARE_NOT_CONNECTED_NOTICE.contains("还没连上"))
assertTrue(SHARE_NOT_CONNECTED_NOTICE.contains("自动发出"))
// 「失败」「重试」这一族措辞会把用户推向「重新分享一遍」——内容明明一条没少、就在队列里等着。
assertFalse(SHARE_NOT_CONNECTED_NOTICE.contains("失败"))
assertFalse(SHARE_NOT_CONNECTED_NOTICE.contains("重试"))
}
}