From 22d655b73b5dac6d27d8995b44bab3d1afba0cb0 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Wed, 7 Oct 2026 11:00:45 +0800 Subject: [PATCH] =?UTF-8?q?fix(chat):=20=E5=88=86=E4=BA=AB=E9=9D=A2?= =?UTF-8?q?=E6=9D=BF=E7=9A=84=E9=AB=98=E5=BA=A6=E4=B8=8A=E9=99=90=E5=87=BA?= =?UTF-8?q?=E5=8F=A3=E8=A1=A5=E4=B8=8A=E3=80=8C=E9=9D=9E=E6=9C=89=E9=99=90?= =?UTF-8?q?=E5=80=BC=E3=80=8D=E5=AE=88=E5=8D=AB=EF=BC=88W61=20=E5=88=86?= =?UTF-8?q?=E4=BA=AB=E5=85=A8=E9=93=BE=E8=B7=AF=E5=A4=8D=E6=A0=B8=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 主人报「网易云分享文本到 App 会闪退」。已修的那个 P0(df7b504 / 6d4f2cd:面板一打开就崩 / 点展开又崩)是 Dp.Infinity 当哨兵值与展开态没去掉 verticalScroll,那是**最可能**的成因。 这一批把这**整条链**(Manifest 的 intent-filter → ShareIntake → 落地面板 → sendConfirm → 气泡渲染)从「假设主人的包就是当前 HEAD」重审了一遍,找残留崩溃点。 结论:**当前 HEAD 这条路径我没有找到残留崩溃点**。逐项复核见下面「查了什么」。审查中发现 一处**不变量只写在 KDoc 里、实现并不保证**的地方,本批把它补实(不改变任何现状行为)。 改了什么(最小的一处 + 一处 KDoc 更正): 1. `shareConfirmWindowCap` 从纯透传改成**守卫式**:`measured` 非有限(`Dp.Infinity` / `Dp.Unspecified` 即 NaN)时与「还没量到」同等对待,回落 150dp。它上面那段 KDoc 一直写着「返回值永远不是 Dp.Infinity」,而实现是 `measured ?: 150.dp` —— 今天唯一的调用方传的是 null 或 `getLineBottom(...).toDp()`(恒有限),所以**现状没有可触发的崩溃**;但那条不变量此前只由 「调用方的好习惯」保证,而它正是 W58 崩过两次的那一条。补守卫之后可以给出一句可证明的话: 这一层给出的上限**恒有限**,于是面板里那两个 `verticalScroll`(外层预览区 / 收起态正文窗) 在任何输入与状态下都不可能落在无界高度上。 2. `ShareConfirmBody` 那段 KDoc 里一句不准确的话改准:旧文说该函数的出口「要么返回 null(不设 上限)」——它恒返回 Dp,「不设上限」发生在展开态那一支(连 verticalScroll 一起去掉),与它是两回事。 新增测试: - `ShareTextPipelineTest`(12 例,新文件):把「分享一段文本进来」这条链从 Intent 字段一路串到 气泡正文(`ShareRules.extract` → `shareConfirmPreview` / `shareConfirmBody` / `shareConfirmCanSend` → `parse`),此前**跨层串测完全空缺**(每一站各自有单测,没有一处把它们接起来)。钉三件事: ①**内容一字不丢**(「分享单曲 + 链接 + emoji + 多行」逐字节到气泡正文;用户补的描述同样); ②**正文里的标记按既有语义走且绝不抛异常**(`

` / `` / 全角半角星号 / `1 < 2` / 半个代理对 / `\u0000` / 200 个落单 `

` / 10 万字符都过一遍); ③**面板预览不过解析器、气泡过解析器**(同一段带标记的字,预览里是原样、气泡里被剥离—— 这是如实记下的一处不一致,不是崩溃)。 - `ShareConfirmTest`:把 `Dp.Infinity` 与 `Dp.Unspecified` 放进「任何输入都给不出非有限上限」的 输入表——原先那张表**恰好漏掉了唯一能推翻该断言的两个值**。反证过:把实现改回纯透传时, 这一条用例确实 FAILED(`no input can produce a non finite cap`),改回守卫后通过。 查了什么(逐项,都是读代码 + 纯函数求证,不是猜): ① Intent 形态:`getCharSequenceExtra` 对非 CharSequence(`String[]` / `ArrayList`)是**catch 住 ClassCastException 回 null**(SDK 源码 `BaseOs/BaseBundle.getCharSequence` 已核),不抛; EXTRA_STREAM 走原始值按形态分派;字符串形态只认 `content://`;ClipData 纯文本条目只当 「带着内容迹象」用来出告知。② 空/边界输入:空串 / 纯空白 → `worthDelivering=false` 静默 (正确);`

` / 星号 / `<` 一律不抛(新用例钉住)。③ 面板渲染:Miuix 的 `WindowDialog` / `TextField` 里**没有**任何 scroll 节点(反编译 `miuix-ui-0.9.3` 的 class 查过),面板里的两个 `verticalScroll` 各自都先过一层有限的 `heightIn`。④ 出队闸门:闸门在 `take()` 之前、认领落在 VM 状态里,重建不会再取;`claimSharedContent` 只有 Compose 主线程一个调用点。⑤ 发送后渲染: `parse` 的落单标签口径(W55)与既有契约一致,新用例把分享正文含标记时的落点一并钉住。 ⑥ 通知/保活:分享落地与 `ProactiveFrameIntake` / `KeepAliveService` 之间没有任何联动路径。 没覆盖 / 只能上真机的:渲染层本身(本模块没有 Compose UI 测试依赖,`checkScrollableContainerConstraints` 那一类崩溃单测抓不到——W58 的教训),以及任何需要设备的观感;本批**没有**装包上机(emulator-5554 上有别的会话在跑,主仓在我复核期间刚构建过 APK,我刻意没有动它)。 真跑:`./gradlew.bat assembleDebug` 通过;`./gradlew.bat testDebugUnitTest` 全量 **115 suites / 1400 tests / 0 skipped / 0 failures / 0 errors**(基线 114 / 1388,差的正是本批 新增的 1 个 suite 12 例,其余一条没碰坏)。 --- .../yeij/cyrene/ui/chat/ShareConfirmPanel.kt | 16 +- .../yeij/cyrene/ui/chat/ShareConfirmTest.kt | 7 +- .../cyrene/ui/chat/ShareTextPipelineTest.kt | 250 ++++++++++++++++++ 3 files changed, 268 insertions(+), 5 deletions(-) create mode 100644 app/src/test/kotlin/top/yeij/cyrene/ui/chat/ShareTextPipelineTest.kt diff --git a/app/src/main/kotlin/top/yeij/cyrene/ui/chat/ShareConfirmPanel.kt b/app/src/main/kotlin/top/yeij/cyrene/ui/chat/ShareConfirmPanel.kt index 331118d..9df509e 100644 --- a/app/src/main/kotlin/top/yeij/cyrene/ui/chat/ShareConfirmPanel.kt +++ b/app/src/main/kotlin/top/yeij/cyrene/ui/chat/ShareConfirmPanel.kt @@ -401,8 +401,9 @@ private fun ShareConfirmExpandToggle(expanded: Boolean, onToggle: () -> Unit) { * Compose 的 `checkScrollableContainerConstraints` 会**直接抛 `IllegalStateException` 把 App 打崩** * (真机复现:分享进来、面板刚打开就闪退,栈是 `ScrollNode.measure` ← `SizeNode.measure` ← `DialogLayout`)。 * - * 所以这一层的高度上限**只经 [shareConfirmWindowCap] 一个出口**,它保证返回值要么是 `null` - * (这一层不设上限)、要么是**有限值**,永远不是 `Dp.Infinity`: + * 所以这一层的高度上限**只经 [shareConfirmWindowCap] 一个出口**,它给出的**恒是一个有限值**; + * 而「这一层干脆不设上限」只发生在展开态——那时它连 `verticalScroll` 一起去掉(见下一节), + * 于是「无界高度 + 滚动」这个组合在两种状态下都不成立: * - **首帧**(还没量到)用 [SHARE_PREVIEW_MAX_HEIGHT] 兜底 —— 有限,且正好是收起态外层预览区的上限; * - **展开态**整层不设上限,但**外层**预览区此时换成 [SHARE_PREVIEW_EXPANDED_MAX_HEIGHT],那仍是一层 * 有限的约束在管事(不是「没有约束」)。 @@ -491,11 +492,18 @@ private fun ShareConfirmBody( * 展开态**不走这里**:那一态连 `verticalScroll` 一起去掉(只留外层预览区那一层滚),没有「上限」 * 这个维度可给 —— 见 [ShareConfirmBody] 的 KDoc。 * - * ⚠️ 返回值**永远不是 [Dp.Infinity]** —— `heightIn(max = Dp.Infinity)` 等于不施加约束,而 + * ⚠️ 返回值**永远是一个有限值** —— `heightIn(max = Dp.Infinity)` 等于不施加约束,而 * `verticalScroll` 在无界高度下被测量时 Compose 会直接抛 `IllegalStateException` 把 App 打崩 * (W58 真机崩过两次:一次是初值用了 `Dp.Infinity`,一次是展开态只去掉了上限)。这条不变量有单测钉着。 + * + * 所以非有限的那几个取值([Dp.Infinity] 与 [Dp.Unspecified],后者是 `NaN`)**与「还没量到」同等对待**, + * 一律回落 [SHARE_PREVIEW_MAX_HEIGHT]:这条 KDoc 写的是一条**判据**,而不该只是对调用方的请求——本函数 + * 是这一层唯一的出口,它自己要能保证「给出去的上限总是有限」。今天唯一的调用方传的是 `null` 或 + * `getLineBottom(...).toDp()`(后者恒有限),所以这一道守卫不改变任何现状;它挡的是**下一次**有人 + * 顺手把 `Dp.Infinity` 当「不设上限」传进来(那正是 W58 第一次崩溃的形状)。 */ -internal fun shareConfirmWindowCap(measured: Dp?): Dp = measured ?: SHARE_PREVIEW_MAX_HEIGHT +internal fun shareConfirmWindowCap(measured: Dp?): Dp = + measured?.takeIf { it.value.isFinite() } ?: SHARE_PREVIEW_MAX_HEIGHT /** 预览区在**收起态**的高度上限(见 [ShareConfirmPreviews]:超出的部分靠内部滚动看)。 */ internal val SHARE_PREVIEW_MAX_HEIGHT = 150.dp diff --git a/app/src/test/kotlin/top/yeij/cyrene/ui/chat/ShareConfirmTest.kt b/app/src/test/kotlin/top/yeij/cyrene/ui/chat/ShareConfirmTest.kt index 8464b03..3560a7c 100644 --- a/app/src/test/kotlin/top/yeij/cyrene/ui/chat/ShareConfirmTest.kt +++ b/app/src/test/kotlin/top/yeij/cyrene/ui/chat/ShareConfirmTest.kt @@ -1,5 +1,6 @@ package top.yeij.cyrene.ui.chat +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse @@ -351,9 +352,13 @@ class ShareConfirmWindowCapTest { @Test fun `no input can produce a non finite cap`() { - for (m in listOf(null, 0.dp, 1.dp, 150.dp, 10_000.dp)) { + // 非有限的那两个取值都在表里:`Dp.Infinity` 是 W58 第一次崩溃的形状(当时它被当「还没量到」 + // 的哨兵值),`Dp.Unspecified`(NaN)是「我忘了给它一个数」最常见的写法。两个都必须回落兜底—— + // 这一层的出口要自己保证「给出去的上限总是有限」,而不是指望调用方永远不传错。 + for (m in listOf(null, 0.dp, 1.dp, 150.dp, 10_000.dp, Dp.Infinity, Dp.Unspecified)) { val cap = shareConfirmWindowCap(m) assertTrue("measured=$m 给出了非有限的上限:$cap", cap.value.isFinite()) + assertNotEquals("measured=$m 仍把无界约束放过去了", Dp.Infinity, cap) } } diff --git a/app/src/test/kotlin/top/yeij/cyrene/ui/chat/ShareTextPipelineTest.kt b/app/src/test/kotlin/top/yeij/cyrene/ui/chat/ShareTextPipelineTest.kt new file mode 100644 index 0000000..aed4366 --- /dev/null +++ b/app/src/test/kotlin/top/yeij/cyrene/ui/chat/ShareTextPipelineTest.kt @@ -0,0 +1,250 @@ +package top.yeij.cyrene.ui.chat + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import top.yeij.cyrene.domain.chat.DocumentDelivery +import top.yeij.cyrene.domain.chat.SharedTextPlan +import top.yeij.cyrene.domain.parse.parse +import top.yeij.cyrene.domain.share.ShareRules +import top.yeij.cyrene.domain.share.SharedContent + +/** + * 「从别的 App 分享一段**文本**进来」这条路的**端到端纯函数串**(W61 审查补的回归网)。 + * + * ## 为什么要一个跨层的串测 + * 这条路上的每一站都各自被单测钉过(`ShareExtractionTest` 管 Intent 字段怎么读、 + * `ShareConfirmTest` 管面板的预览与拼正文、`ShareNoticeTest` 管告知、`ChatContentParserTest` 管标记), + * 但**没有任何一个用例把这几站接起来跑一遍**——而真机上的那次事故(网易云分享文本 → 闪退) + * 恰恰发生在接缝上:正文在 `ShareRules.extract` 里被 `trim`、在 `shareConfirmPreview` 里再 `trim` + * 一次、在 `shareConfirmBody` 里与用户描述用空行拼接、最后整段落进 `parse` 按标记切气泡。 + * 四站各自都对,合起来也未必是用户看到的那句话。 + * + * ## 它钉住的三件事(都是**判据**,不是实现) + * 1. **内容一字不丢**:一次「分享单曲 + 链接 + emoji + 多行」的分享,从 Intent 字段一路到气泡正文 + * 逐字节相同;用户补的那句描述同样逐字节相同。 + * 2. **正文里的标记按既有语义走,且绝不抛异常**:分享进来的那段字里带 `

` / `` / 星号时, + * 渲染结果由 `domain/parse` 的既有口径决定(W55 的落单标签剥离、旁白/动作标记、Markdown 强调不吞), + * 这里只钉「不崩、且除标记本身之外一个字不少」。 + * 3. **面板预览与气泡渲染是两条路**:预览走的是**原样文本**(不过解析器),气泡走解析器—— + * 于是同一段带标记的文本,预览与发出后的气泡长得不一样。这不是崩溃,是本文件要**如实记下**的 + * 一处不一致(见 `the preview shows the raw text while the bubble goes through the parser`)。 + * + * ⚠️ 本文件**不**覆盖渲染层本身(Compose):本模块没有 Compose UI 测试依赖,`Dp.Infinity` 那一类 + * 崩溃单测抓不到(那是 W58 的教训)。这里管的是**进渲染层之前**那份数据对不对、会不会抛异常。 + */ +class ShareTextPipelineTest { + + /** 网易云那类分享的典型形态:多行、书名号、链接、emoji、全角标点。 */ + private val neteaseLike = + "分享单曲《夜曲》 - 周杰伦 🎵\n" + + "https://music.163.com/song?id=185809\n" + + "只想说一句:谢谢昔涟 💛" + + /** 一次分享的正文从 Intent 字段走到「该发出去的那段字」(面板那一站的纯函数)。 */ + private fun bodyOf(raw: String, caption: String = ""): String = + shareConfirmBody(ShareRules.extract(action = ShareRules.ACTION_SEND, text = raw), caption) + + @Test + fun `a text share reaches the bubble byte for byte`() { + val share = ShareRules.extract(action = ShareRules.ACTION_SEND, text = neteaseLike) + + assertEquals(neteaseLike, share.text) + assertTrue(share.worthDelivering) + assertFalse(share.isEmpty) + assertFalse(share.contentUnreadable) + // 一次正常的文本分享没有任何要对用户说的事(有内容、没丢件、没有读不进来)。 + assertNull(shareNoticeFor(share)) + + val preview = shareConfirmPreview(share) + assertEquals(neteaseLike, preview.text) + assertTrue(preview.hasText) + assertFalse(preview.isEmpty) + assertTrue(shareConfirmCanSend(share, "")) + + val bubbles = parse(shareConfirmBody(share, "")) + assertEquals(1, bubbles.size) + assertEquals(neteaseLike, bubbles.single().body) + assertTrue(bubbles.single().head.isEmpty()) + assertTrue(bubbles.single().tail.isEmpty()) + } + + @Test + fun `the caption is appended unchanged and both halves stay readable`() { + val caption = "这条你听过吗?" + val body = bodyOf(neteaseLike, caption) + + assertEquals(neteaseLike + SHARE_CONFIRM_BODY_SEPARATOR + caption, body) + // 空行是「这是两段」的表达,而既有渲染规则把空行当**气泡边界**(`blankLineFallbackSplitsBubbles`): + // 于是这条消息在界面上是两个气泡,两段各自逐字节不变。这不是分享引入的规则——用户手打两段 + // 同样如此;这里把它钉住,是为了让「分享 + 描述」的观感有一处能说清的话。 + assertEquals( + listOf(neteaseLike, caption), + parse(body).map { it.body }, + ) + } + + @Test + fun `the caption alone is sent when the share carried no text`() { + val share = ShareRules.extract(action = ShareRules.ACTION_SEND, text = " ") + + assertFalse(share.worthDelivering) + assertTrue(share.isEmpty) + // 正文全空白 ⇒ 面板里没得预览;这时能发的只剩用户补的描述(按钮判据与发送路径同源)。 + assertFalse(shareConfirmPreview(share).hasText) + assertFalse(shareConfirmCanSend(share, "")) + assertTrue(shareConfirmCanSend(share, "只看图")) + assertEquals("只看图", shareConfirmBody(share, "只看图")) + } + + @Test + fun `a very long shared text is kept whole and goes out as a file`() { + val long = "这是一段被摊平的卡片内容。".repeat(5_000) + + val share = ShareRules.extract(action = ShareRules.ACTION_SEND, text = long) + + assertEquals(long, share.text) + assertEquals(1, parse(shareConfirmBody(share, "")).size) + // 「分享进来的长正文落成 .txt」是既有定案(DocumentDelivery.planSharedText);本端一个字都不改它, + // 而这条判据决定了正文那一栏会不会被清空——两个入口共用同一份判据是刻意的。 + assertEquals(SharedTextPlan.AsDocument, DocumentDelivery.planSharedText(share.text, 0)) + } + + @Test + fun `markup in the shared text is handled by the parser and never crashes`() { + // 分享进来的字里带**真标记**时的落点:剥离/解析口径是 `domain/parse` 的既有契约(W55 起), + // 这里只断言「不抛异常 + 结果与那条契约一致」,不发明新口径。 + val malformed = parse("

正文

").map { it.body } + assertEquals(listOf("正文"), malformed) + + val loose = parse("x") + assertEquals(1, loose.size) + assertEquals("", loose.single().body) + assertEquals(1, loose.single().head.size) + } + + @Test + fun `the preview shows the raw text while the bubble goes through the parser`() { + // ⚠️ 两条路的分工(本文件如实记下的一处不一致): + // - 面板预览 = `shareConfirmPreview`,**不过解析器**,用户看到的是原样的那几个字符; + // - 发出后的气泡 = `MessageBubble` 里的 `parse`,标记按既有语义被剥离/降级成旁白。 + // 于是 `"

正文

"` 这样的分享:预览里是一串标签,气泡里只剩「正文」。 + // 不崩、也不丢「正文」,但预览那句「她将收到的是什么」在这里是打了折的。 + listOf("

正文

", "看着窗外", "*动作*").forEach { raw -> + val share = ShareRules.extract(action = ShareRules.ACTION_SEND, text = raw) + assertEquals(raw, shareConfirmPreview(share).text) + } + } + + @Test + fun `stars in the shared text follow the existing markup rule`() { + // 全角短句星号 ⇒ 动作(既有语义);半角带关键词 ⇒ 动作;半角无关键词 ⇒ 保持 Markdown 强调文本。 + val action = parse("*今天也要加油*") + assertEquals("", action.single().body) + assertEquals("今天也要加油", action.single().head.single().text) + + val keyed = parse("*动作: 挥手*") + assertEquals("", keyed.single().body) + assertEquals("挥手", keyed.single().head.single().text) + + assertEquals("*随便*", parse("*随便*").single().body) + } + + @Test + fun `plain angle brackets in the shared text are never eaten`() { + listOf("1 < 2 和 a不是标签", "a < b > c").forEach { raw -> + assertEquals(raw, bodyOf(raw)) + assertEquals(raw, parse(bodyOf(raw)).single().body) + } + } + + @Test + fun `empty whitespace and hostile inputs never throw anywhere on the way`() { + val inputs = listOf( + "", + " ", + "\n", + "\t\n ", + "<", + ">", + "", + "

", + "

", + "

正文

", + "", + "", + "*", + "*", + "\u0000", + "\u0000

", + "🎵", + "\uD83C", // 半个代理对:畸形但可能出现在任何一段别人给的文本里 + "

".repeat(200), + " + val outcome = runCatching { + val share = ShareRules.extract(action = ShareRules.ACTION_SEND, text = raw) + shareConfirmPreview(share) + shareConfirmCanSend(share, "描述") + shareNoticeFor(share) + parse(shareConfirmBody(share, "描述")) + } + assertTrue( + "输入「${raw.take(24)}…」抛了:${outcome.exceptionOrNull()}", + outcome.isSuccess, + ) + } + } + + @Test + fun `a share that carries nothing at all stays silent`() { + // 三个字段都没有值 ⇒ 什么都不该发生、也不该说话(否则每次误触分享都弹一句)。 + val nothing = ShareRules.extract(action = ShareRules.ACTION_SEND) + assertFalse(nothing.worthDelivering) + assertNull(shareNoticeFor(nothing)) + assertFalse(shareConfirmCanSend(nothing, "")) + } + + @Test + fun `a stream we cannot read still speaks instead of vanishing`() { + // 同一个 Intent 里带着内容迹象却一条都读不进来(形态认不出 / scheme 不支持): + // 内容照旧为「空」,但**必须**有话可说——这是 `contentUnreadable` 存在的全部理由。 + val unreadable = ShareRules.extract( + action = ShareRules.ACTION_SEND, + streamStrings = listOf("file:///etc/hosts", "https://example.com/a.png"), + ) + + assertTrue(unreadable.isEmpty) + assertTrue(unreadable.worthDelivering) + assertEquals(SHARE_UNREADABLE_NOTICE, shareNoticeFor(unreadable)) + assertFalse(shareConfirmCanSend(unreadable, "")) + } + + @Test + fun `a shared document reaches the panel and keeps the send button usable`() { + val share = SharedContent( + text = "看这份", + documents = listOf( + top.yeij.cyrene.domain.share.SharedDocument( + uri = "file:///cache/share-intake/share-1-0.md", + displayName = "笔记.md", + sizeBytes = 2048, + ), + ), + ) + + val preview = shareConfirmPreview(share) + // 预览里带的就是这份文件本身(显示名由芯片那一栏换算,算术在 DocumentChipTest 里各测各的)。 + assertEquals(listOf("笔记.md"), preview.documents.map { it.displayName }) + assertTrue(preview.hasText) + assertTrue(shareConfirmCanSend(share, "")) + // 只有附件、没有正文时按钮照旧可点(服务端支持纯附件回合),且告知只说丢件、不抱怨没正文。 + assertNull(shareNoticeFor(share)) + } +}