AskaEth
|
3fe55e8d0c
|
docs(05): 锚点补记四十二 —— 行内 HTML 标签渲染(W67:b/strong、i/em、code、br;只认小写、未配对保持字面、code 内不解析、整段仅标记回落字面、流式扣留未闭合行内标签;不碰星号语义)+ 真机五组样本逐条验收 + 八条故意不做 + 给后端登记的一问
|
2026-10-07 17:17:26 +08:00 |
|
AskaEth
|
3d4a9f288a
|
merge: W67 —— 行内 HTML 标签基本渲染(b/strong、i/em、code、br;只认小写、未配对保持字面、code 内不解析;不碰星号语义)
|
2026-10-07 17:15:48 +08:00 |
|
AskaEth
|
d7d93b4cb0
|
feat(W67): 正文行内标签渲染 —— <b>/<strong>、<i>/<em>、<code>、<br> 四类(成对才认、只认小写、未配对/嵌套错位一律字面)
改了什么:
- 新增 `domain/parse/InlineMarkup.kt`:纯函数 `parseInlineMarkup(text) -> List<InlineRun>`
(`InlineRun(text, styles: Set<InlineStyle>)`,样式只有 Bold / Italic / Code;`<br>` 不是样式、
它是字符 `\n`)。相邻同样式的片段合并,两端空白去掉。整段只有标记没有字时(`<b></b>` / `<br>`)
返回空列表 = 回落字面原文。
- `ParsedBubble` 加一个**有默认值**的字段 `runs: List<InlineRun> = emptyList()`(既有构造点无需改动);
`toBubble` 在**段落定稿之后**对最终正文调一次 `parseInlineMarkup`:`body` 因此是正文的渲染文本
(标签已消费、`<br>` 已换成换行),而 `parts` 仍是逐字原文——编辑框 / 搜索片段 / 通知文案读的是它。
- `MessageBubble`:`BubbleBody` 多收 `runs` + `trailingCursor`;`runs` 非空时用一个 `AnnotatedString`
承载粗体 / 斜体 / 等宽底色 / 换行(`<code>` 底色:她侧 `surfaceContainerHigh`、主人侧 `onPrimary`
压 18% 浅底),光标在片段**之外**补上(不继承她最后一段的样式)。`runs` 为空走原来的 `Text(text)`,
逐像素不变(绝大多数消息都走这一档)。
- `ChatContentParser.stablePrefixEnd` 的扣留词表补上行内开标签(`TAG_NAMES` = 段落/旁白 + `INLINE_TAG_NAMES`,
两边由同一份名单生成的正则判):未闭合的行内开标签同样入栈扣留;`<br>` 自闭合、**不入栈**
(入了就永远弹不出来),只在残缺(`<br`)时按词表扣留。
- 新增 `InlineMarkupTest`:36 条用例(四类标签各自的片段形状、6 条边界逐条、星号语义反证、
流式扣留与「只增不改口」、以及编辑/搜索/分享三个调用点的护栏、复杂度回归)。
为什么:
- 只认小写:`parse` 只认小写 `<p>`(`<P>` 字面显示是既有测试钉死的),且流式扣留侧的标签名扫描只认
`a..z`——行内标签若认大小写,扣留与消费两边就分叉,中间态会把 `<B>` 当正文吐上屏、收口时再吃掉
(屏幕上显示过的字会改口)。实测命中的 2 条也都小写。
- **成对 + 正确嵌套才认,其余一律字面**:与既有「未闭合/畸形标签回落普通文本」同一口径,也保证绝不丢字
(`1 < 2`、`a<b`、`<b`、`<p2>`、`<B>` 全部原样)。正文里的 `a<b>c` 是既有测试钉着的字面文本,
行内标签没有「段落结构已被完整块定死」那种语境,所以不套用 W55 的落单标签剥离容错。
- `<code>` 里不再解析:HTML 语义上它就是「原样文本」容器,也正是「想显示尖括号怎么办」的答案。
- 不碰 Markdown 的 `*` / `_`:星号已被 `ROLE_RE` 占成旁白/动作语义(既有测试钉着),再给同两个星号
一个「斜体」的说法就是改既有语义。`#`/列表/表格/围栏/`[链接](url)`/`<a href>`/HTML 实体一律不做,
逐条理由写在 `InlineMarkup.kt` 的文件头 KDoc(0 命中、或需单独拍板)。
- 行内解析刻意**不**放进 `tokenize`:分段、旁白归属、落单标签剥离与 W63 那两处线性优化一个字不动,
`<br>` 因此只换行、不分气泡。
- 旁白/动作行不做行内解析:它读的是 `parts` 原文,与分享预览那行同源,而预览侧(`ShareConfirmPanel`)
本次是红线——只改这边就等于让「预览 == 气泡」在旁白上重新裂开。
验收:`assembleDebug` 通过(`app/build/outputs/apk/debug/app-debug.apk`,47,308,566 B);
`testDebugUnitTest` 全量 **126 suites / 1545 tests / 0 failures / 0 errors**(基线 125/1509,
本次新增 1 个测试类 + 36 条用例)。
|
2026-10-07 17:14:44 +08:00 |
|
AskaEth
|
6d4a37e484
|
docs(05): 锚点补记四十一 —— 通知横幅(DEFAULT→HIGH,因渠道重要级不可变故换 id 到 proactive_v2)+ 免打扰开关(两条渠道切 id,默认都弹,真机双档验收)+ W63 去二次方(两处 O(N²),20000 个落单标签 26405ms→17ms)+ W64 分享回落链 + W65 预览与气泡一致 + 国产 ROM 上横幅还需什么
|
2026-10-07 11:57:46 +08:00 |
|
AskaEth
|
3b9936e212
|
merge: W64 —— 分享回落链(EXTRA_TEXT → SUBJECT → TITLE)+ 三者都空时明确告知而不静默(来源提示走柔和横幅、绝不进正文)
|
2026-10-07 11:54:44 +08:00 |
|
AskaEth
|
dc5972213e
|
merge: W65 —— 分享预览改走同一套解析(所见即所发:多段成块、旁白灰斜体;超窗判据改为内容自然高 vs 窗口高)
|
2026-10-07 11:54:34 +08:00 |
|
AskaEth
|
c07a713e86
|
merge: W66 —— 免打扰开关(两条渠道:HIGH 弹横幅 / DEFAULT 只在通知栏;设置页新开关,默认两者都弹)
|
2026-10-07 11:53:58 +08:00 |
|
AskaEth
|
bfa79946d4
|
merge: W63 —— 解析器去二次方(两处 O(N²):松标签重复扫描 + P_BLOCK_RE 回溯;20000 个落单标签 26405ms → 17ms,语义一字不变)
|
2026-10-07 11:53:57 +08:00 |
|
AskaEth
|
0e2df1193d
|
feat(notify): 设置页加「免打扰」开关 —— 她主动说的话拆成两条渠道按开关发(一并重做 proactive_v2/HIGH 那份改动)
改了什么:
- 新增第二条渠道 `proactive_quiet`(`IMPORTANCE_DEFAULT`,渠道名「她主动说的话(免打扰:只在通知栏)」),
与 `proactive_v2`(`IMPORTANCE_HIGH`,弹横幅)并存;两条的定义收在一个纯函数
`proactiveChannelSpec(quiet)` 里(id / 渠道名 / 重要级 / 描述成对给全),`proactiveChannelId(quiet)` 是它的 id。
- 设置页新增「通知」段 + 一行「免打扰」(`SwitchPreference` 外包 `PreferenceCard`,照「后台保持连接」那一行;
副文案走纯函数 `proactiveQuietSummary`,写明「这不是静音」)。偏好落进既有的 `AppSettings`
(键 `proactive_quiet`,**默认 `false` = 都弹**),不新造存储。
- 发通知时按开关选渠道:`postProactiveNotification(..., quiet)`,7.x 及更早的 `setPriority` 跟着一起降回
`PRIORITY_DEFAULT`;`AppContainer` 的 `post` lambda 每次**现读** `AppSettings.current`(`StateFlow` 热副本),
所以开关一改、下一条通知就走新渠道,不必重启 App。
- 重做主仓那份未提交的渠道改动:`PROACTIVE_CHANNEL_ID` 由 `proactive` 改为 `proactive_v2`、
`PROACTIVE_IMPORTANCE` 由 DEFAULT 提到 HIGH、新增 `LEGACY_PROACTIVE_CHANNEL_ID = "proactive"` 并在建渠道时
`deleteNotificationChannel` 掉它、`setPriority` 改 `PRIORITY_HIGH`,对应测试断言同步更新。
为什么:
- 横幅由**渠道重要级**决定,而重要级**一旦创建只有用户改得动**(App 改不动)⇒「开关横幅」不可能靠运行时改
设置实现,只能两条渠道切着用。
- 刻意**不用** `setSilent(true)`:那是完全静音(连提示音都没),而用户要的是「只在通知栏提示,别弹横幅」。
- 惰性创建只保证**这一次要用的那一条**(不存在的渠道会让 `notify` 被系统静默丢弃,所以必须在发之前建):
一个从没收到过她消息的用户,系统设置里不会平白多出渠道;另一条不删(用户可能对它调过设置),
只删那条再也发不到的 legacy id。
- 去重、通知 id 派生、「是否该弹」的判据一个字节都没动。
验收:`assembleDebug` 通过;`testDebugUnitTest` 全量 **123 suites / 1479 tests / 0 failures / 0 errors**
(基线 121/1471,本次新增 2 个测试类 + 8 条用例)。
|
2026-10-07 11:53:04 +08:00 |
|
AskaEth
|
0e834fc854
|
perf(parse): 落单 <p> 的「后面还有没有正文」判据改记忆化,parse 从 O(N²) 回到 O(N)
W61 审查点出的问题:`stripLooseTags` 对**每一个**落单 `<p>` 都要调一次 `hasContentAfter`,
而那函数每次从该位置一路现扫到串尾 ⇒ 正文里有 N 个落单标签整体 O(N²)。`parse` 跑在主线程
(`ui/chat/MessageBubble.kt` 的 `remember`),用户粘一段 HTML 残骸就能把界面卡死十几秒。
改了什么
1. `hasContentAfter(text, from)` → `ContentAfterQuery`(同文件私有类):判定用的还是原来那段
扫描本身(`next` 逐分支对应原循环,判据仍是同一组 OPEN_P_TAG_RE / CLOSE_P_TAG_RE,没有另立
一套「什么算 `<p>` 标签」的定义),只是把走链途经位置的答案落表,之后 O(1) 命中。每个位置
至多被算一次 ⇒ 整体线性。惰性建表:没遇到落单 `<p>` 开标签的常规消息一个字节都不多花。
2. 顺着同一个输入查下去,发现同一形状下**还有另一处 O(N²)**:`tokenize` 的
`P_BLOCK_RE.findAll(raw)`——正文里没有 `</p>` 时,每个 `<p` 候选点都会带着懒惰的
`([\s\S]*?)</p>` 回溯到串尾。完整块必然以 `</p>` 收尾,所以把最后一个 `</p>` 之后切掉再扫:
每个匹配都整体落在被切下的前缀里、正则的尝试顺序也没变 ⇒ 匹配结果逐字不变,而这一路回溯没了。
实测(本机 JVM 单测内 `System.nanoTime` 计时,修复前后跑的是同一份测速脚手架、同一台机器)
`parse("<p>".repeat(N))` 修复前 修复后
N = 2000 854ms 5ms
N = 8000 4254ms 12ms
N = 20000 26405ms 17ms
另一轮完整曲线 N=1000/2000/4000/8000/16000 = 148/238/881/3700/15973ms(每翻一倍涨约 4 倍)
新增的复杂度回归测试在修复前报:`60000 字符(20000 个落单标签)用了 31226ms`
两处各自的份额(N=20000,`"<p>".repeat(N)`):落单标签判据约 23s、完整块搜索约 8.2s。
`parse(" \n<p> ".repeat(20000))`(标签间夹空白):修复前也是 20s 量级,修复后 6ms。
已知残留(**非本次引入**,改前改后一致,在测试里如实登记):`"<p ".repeat(N) + "</p>"`
这种「`<p` + 空白一路没有 `>`、最后只有一个 `</p>`」的病态输入仍走完整块搜索的最坏路径
(N=2000/8000/20000 = 105/1067/7314ms → 108/1242/7201ms)。要治它得把完整块搜索换成手写的
线性匹配(牵动气泡切分的核心语义),本切片没做。
语义
`parse` 的对外行为一字未改:气泡切分、旁白依附、空块过滤、畸形回落全部照旧。既有 121 个测试
套件 1471 条全部通过、一条期望值都没动,另外新增 2 条测试:1 条复杂度回归
(`hugeRunOfLooseTagsIsDroppedWithinALinearBudget`,20000 个落单标签 + 宽松阈值,注释里写明
它测的是复杂度不是精确耗时)+ 1 条钉住「后面有正文才留字面」的语义
(`aLooseTagIsKeptWheneverThereIsStillContentAfterIt`)。
验收:`gradlew.bat testDebugUnitTest assembleDebug --rerun-tasks` → BUILD SUCCESSFUL
(121 suites / 1473 tests / 0 failures / 0 errors;APK 已产出)。无新依赖。
|
2026-10-07 11:50:53 +08:00 |
|
AskaEth
|
4fe5509c24
|
feat(share): 正文回落链 EXTRA_TEXT → EXTRA_SUBJECT → EXTRA_TITLE(修「只有标题的分享整条静默」)
W61 分享路径审查登记的那个缺口:有些发送方不填 `EXTRA_TEXT`,内容只在
`EXTRA_SUBJECT`(少数在 `EXTRA_TITLE`)里,而本端只读 `EXTRA_TEXT` ⇒ 那次分享
**整条静默**:用户点了分享,界面毫无反应、后端零请求,连 `contentUnreadable`
那条「读不进来」的告知也够不着(它的判据里没有这两个 extra)。
改动(按主人拍板的方向):
1. **回落链**:`EXTRA_TEXT` → `EXTRA_SUBJECT` → `EXTRA_TITLE`,取第一个非空白的
当正文(归一化口径与原来取 text 时完全一致)。`EXTRA_TEXT` 非空时它一定赢 ⇒
既有情形逐字不变(`ShareTextFallbackTest` 有专门的用例钉住这一点)。
2. **来源如实登记**:`SharedContent.textSource`(`ShareTextSource.Text/Subject/Title`)
记下正文是从哪个 extra 来的;渲染层据此说一句「这段文字来自对方的分享标题
(对方没有提供正文)」——回落来的正文与真正文长得一模一样,用户只能靠这一句分辨。
⚠️ 那句话只出现在界面上,**绝不进正文**(面板预览与发出去的消息逐字相同,有用例钉住)。
面板那边的接线需改 `ShareConfirmPanel.kt`(本批的**红线**:另一支正在改它),
所以这一句先挂在**认领那一刻**的柔和告知通道上(`ChatViewModel.claimSharedContent`
→ `showShareNotice`):它必须在用户点「发送」之前说。
3. **三个 extra 都空 / 都读不出来 ⇒ 走「读不进来」告知,绝不静默**:判据加了
`textPresent`(「三个里至少一个躺着原始值」由读了原始值的那一层报进来,与既有的
`streamPresent` 同一个形态)。「连值都没有」(误触分享)照旧静默 —— 边界没放宽。
4. **读取防御式**:四个 extra 统一读**原始值**再按形态分派(`Intent.rawExtra`,带
`runCatching`)。核对过 AOSP `android/os/BaseBundle.java`:`getCharSequence` 自己
catch `ClassCastException` 回 null(**类型不符不抛**),会抛的是 `unparcel()` 的
`BadParcelableException`(畸形 bundle)——那一路由 `runCatching` 挡住,不让异常从
`accept` 冒出去。不引入新依赖,UI 侧零新增控件。
测试:新增 `ShareTextFallbackTest`(11)、`ShareTextExtrasTest`(8)、`ShareNoticeTest` +4;
全量 testDebugUnitTest 123 suites / 1494 tests / 0 failures / 0 errors
(基线 121/1471/0/0);`assembleDebug` 通过(--no-build-cache 真跑)。
|
2026-10-07 11:41:55 +08:00 |
|
AskaEth
|
cdd58fa9c4
|
W65: 分享确认面板的预览改走与气泡同一套解析(所见即所发)
W61 审查发现的那处不一致:预览把正文 trim 之后**原样显示**、不过解析器,而发送后的气泡走
`domain/parse` 的 `parse`——分享 `"<p><p>正文</p>"` 进来,预览里是一串标签、气泡里只剩
「正文」。用户按发送之前看到的不是他将要发出的东西。
- 预览正文区改为渲染 `sharePreviewBlocks(text)`:就是 `MessageBubble` 用的那一个 `parse`
的结果(多段 ⇒ 多个块;`<action>` / `<narration>` 与星号那几种写法 ⇒ 灰斜体小字的旁白行)。
面板不再自己写一套「剥标签」;`parse` 一行未改。解析结果为空时按「整段原样显示」兜底,
与 `MessageBubble` 的 `parsed.ifEmpty { plainBubble(content) }` 同一条——绝不显示空白。
- 「超窗」判据从「数一个 Text 的行数」换成**比高度**:内容自然高度(`onSizeChanged` 挂在
`verticalScroll` **里面**,量到的是不受上限影响的自然高)vs 窗口高度(量到的行高 ×
SHARE_PREVIEW_TEXT_LINES)。比较在组合期算完、由 `SideEffect` 报上去(排版回调里读到的两个量
都是上一帧的,在那里比会永远差一帧)。窗口高也不再取「第三行的底边」:多块之后没有任何一个块的
第三行等于这一窗的第三行。
- 两处 `verticalScroll` 的有限约束一如既往:外层预览区仍是 `heightIn(sharePreviewMaxHeight)`;
正文窗仍是 `heightIn(shareConfirmWindowCap(cap))`(唯一出口,返回值恒有限),展开态把上限与滚动
一起去掉。W58 那两次崩溃的形状一个都没碰。
- 测试:`ShareTextPipelineTest` 里那条钉着「预览原样、气泡解析」分工的用例按新口径**推翻重写**
(预览与气泡的正文文本一致、标签不进可见文本),另加三条(多段块数 / 旁白段 / 空解析兜底);
`ShareConfirmTest` 的超窗判据用例换成新纯函数的穷举,并新增 `sharePreviewWindowHeight` 的用例。
验证:`assembleDebug` 通过;`testDebugUnitTest` 121 suites / 1476 tests / 0 failures / 0 errors。
|
2026-10-07 11:38:07 +08:00 |
|
AskaEth
|
541a70f126
|
docs(05): 锚点补记四十 —— 后端回执二十八四条答案 + 畸形回归通过(部署后无新畸形)+ W60 后端重启自愈(根因两半、拉不到新消息的真相)+ W61 分享审查(当前 HEAD 无残留崩溃点,主人手机是旧包;三处如实登记的缺口)+ W62 语音接收侧真机验收通过(五档夹具 + 播放出声)+ 一条流程教训(合并≠设备上是新包)
|
2026-10-07 11:19:21 +08:00 |
|
AskaEth
|
3d8bc61912
|
merge: W61 —— 分享路径审查(补 12 条跨层串测 ShareTextPipelineTest;shareConfirmWindowCap 加有限性守卫,把「上限恒有限」从调用方好习惯变成唯一出口保证)
|
2026-10-07 11:15:28 +08:00 |
|
AskaEth
|
9ced7444bd
|
merge: W62 —— 语音消息接收侧(voice DTO + 语音气泡 + 播放;无副本不画播放键、时长缺席不估算、实时帧不等 voice)
|
2026-10-07 11:14:54 +08:00 |
|
AskaEth
|
2fbf24a675
|
feat(voice): 接收侧语音消息 —— 显示 + 播放(W62 / 契约 v0.79 §4.4 与 §1.2 U19)
别的客户端(QQ / 微信)发来的语音,现在能正确显示并播放。只做接收侧,不做本端录音/发送。
契约口径(逐条落地,不自己发明键名):
- **判据 = `voice` 键存在**:绝不从 `content` 正文里认媒体类型(`[语音]` 占位标记自 v0.67 起不再
下发,正文里也可能有用户自己打的同样字样);
- **按 `status` 分支**(`transcribed` / `empty` / `failed` 三态),正文一律照常显示、一个字不改;
- **`name` / `mime` / `size` / `url` 四者同生共死** ⇒ 凑不齐就**不画播放控件**(不给必然失败的按钮);
- **`durationMs` 缺席就不显示时长**(不反算、不显示 `0:00` 冒充);
- **不在 WS 实时帧上等 `voice`**(覆盖率:只有四个 REST 读取面填充)—— 实时到达的先按普通文本显示,
由既有的「按 id 合并、新到的胜出」在下一次拉取时自动升级成语音气泡(新增单测钉住这条依赖)。
改了什么:
- `VoiceDto`(7 个键逐字照抄)+ `HistoryMessageDto.voice: VoiceDto? = null`(缺席与 null 同一件事);
- `domain/chat/VoiceMessage.kt`:判据与展示换算的**唯一落点**(`isVoiceMessage` / `voiceAudio` /
`voiceDurationLabel` / `voiceNote` / `voiceMediaUrl` / `voicePlaybackFailureMessage`);
- `domain/chat/VoicePlayback.kt`:播放通道的纯状态机(Idle / Loading / Playing / Failed),
每次转换都按 `messageId` 比对 ⇒ 迟到回调不许改状态;
- `ui/chat/VoicePlayer.kt`:`android.media.MediaPlayer`(平台 API,**不引任何新依赖**)驱动那条通道,
三处收干净:换目标先停旧、换会话换实例、组合销毁 `onDispose` 释放;
- `ui/chat/VoiceBubble.kt`:`[播放/暂停] 语音 · 时长` + `empty` 那句补充说明 + 失败提示(一行,error 色);
图标用 AAR 里真实存在的 `MiuixIcons.Play` / `Pause`(extended 包,basic 那 7 枚里没有);
- `data/api/MediaApi.kt` + `data/repository/VoiceStore.kt`:`GET {当前地址}{voice.url}`(自带的
Bearer + 401 续期走 `ApiClientFactory`),字节落 `cacheDir/media/<存储名>`(复用 `DocumentCache`
的名字判据 / 命中判定 / 原子落盘;**不落 `documents/`**,那是 FileProvider 唯一暴露的根);
- `MessageBubble` 渲染语音形态;`isContentlessUserMessage` 加一条「语音消息不算什么都没有」
(否则空转写的语音会被扣上「内容没有被传过来」那顶**假**帽子);
- `devtools/fake-backend/`:新增语音夹具(`__control/messages?voice=&duration=` + 真 2 秒 WAV 的
`/api/v1/media/{name}`)—— 生产库里一条带 `voice` 的真消息都没有,设备验收只能靠造。
为什么这么改:`MediaPlayer` 不 `release()` 会一直占着解码器与音频通路,而语音是唯一「会出声」的
消息形态,生命周期必须有一处说清楚(写在 `VoicePlayer` 的 KDoc);播放失败必须可见(按钮回未播 +
一行短提示),一条点了没反应的语音在用户眼里就是坏的。
验收:`assembleDebug` 通过;`testDebugUnitTest` 120 suites / 1458 tests / 0 failures / 0 errors
(基线 115 / 1405,新增 5 个套件 53 条)。
|
2026-10-07 11:14:28 +08:00 |
|
AskaEth
|
22d655b73b
|
fix(chat): 分享面板的高度上限出口补上「非有限值」守卫(W61 分享全链路复核)
主人报「网易云分享文本到 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 + 多行」逐字节到气泡正文;用户补的描述同样);
②**正文里的标记按既有语义走且绝不抛异常**(`<p><p>` / `<action>` / 全角半角星号 / `1 < 2` /
半个代理对 / `\u0000` / 200 个落单 `<p>` / 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` 静默
(正确);`<p><p>` / 星号 / `<` 一律不抛(新用例钉住)。③ 面板渲染: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 例,其余一条没碰坏)。
|
2026-10-07 11:00:45 +08:00 |
|
AskaEth
|
55612a3922
|
merge: W60 —— 后端重启后自愈(连接重建即收口未结束的回合/流式卡/工具卡,修「永久卡在正在思考」)
|
2026-10-07 10:54:37 +08:00 |
|
AskaEth
|
83a2a88b3c
|
fix(chat): 连接重建(后端重启)时把「进行中」全部收口,不再永久卡在思考/工具调用
改了什么
- 新增纯判据 `isReconnect(previous, current, everConnected)`(ui/chat/ReconnectReset.kt):
只有「**曾经**到过 Connected 之后又回到 Connected」才算重建,第一次接上一个字节都不动。
主判据是 VM 自己那位只置不翻的 `everConnected`(状态流会合流,`Connected → Reconnecting →
Connected` 中间那一跳可能被吞掉,只置不翻的位不受影响);第二证据是上一跳为 `Reconnecting`
(只在「本 VM 是离线期间建起来的」时成立,且那一刻它手里什么都没有,收口是恒等变换)。
- 新增 `ChatUiState.settledAfterReconnect()`(纯函数):把服务端那一轮在本端留下的五个面一次收口 ——
`turns.allClosed()`(未收口绑定全标 closed,气泡 ④ 灰态 → ⑤)、`streams.allFinished()`
(每一路停止生长:思维链不再「思考中…」、工具卡不再「执行 X…」)、`busyMode = null`、
`activeTurns = 0`、`queueDepth = 0`。无变化时返回**同一个实例**。
- `TurnStreams.kt` 新增 `allFinished()`:正文与思维链一个字都不删;**唯一**的删除是「本来就什么都
没有」的空路(`active` 而一个像素都不画的那种,留着就是一个占着列表末项的幽灵)。
- 工具卡收口 `List<ToolCallState>.settledAfterReconnect()`:`Running` ⇒ `phase = null` + `done = true`
(渲染成「工具调用 · X」——不再有省略号、也不替服务端下「失败」的结论);`Done`/`Error` 一个字节
都不动。`ToolPhase` 枚举**没加第四档**(它逐字对齐后端流程图 status,是契约面)。
- 接线在 `ChatViewModel.onConnectionChanged`:收口刻意放在既有的「同值早退」**之前**(见上),
补拉那一半(`reconnectPull` / `gapCursor` / `firstPageState`)**一个字都没改**。
为什么
后端卡在回合里被重启 ⇒ 那一轮在服务端已经不存在 ⇒ 本端**永远**等不到 `turn.end` / `stream_end` /
助手落库行,而本端「进行中」的每一条闸都只认帧(灰态只认 `TurnBinding.closed`、卡片只认
`thinkingActive`/`active`/`phase`、底栏只认 `busyMode`)⇒ 三条路都不会自己收口,界面永久停在那一格。
今天唯一的兜底是 `session_idle` / `session_interrupted` 的那两个 `allClosed()` 调用点,而说那两句话的
进程已经没了。连带的第二半:那条永远 `active` 的流式路永远是 `items` 的最后一项(`reverseLayout` 下就是
屏幕最下方),而 `ChatScreen` 的「跟随底部」判的正是 `items.lastOrNull()?.key` ⇒ 末项不再变化 ⇒
此后新消息不再把列表滚到底(用户视角就是「拉不到新消息」)。
触发时机只做「连接重建」(a),**不做静默超时** (b):理由、代价与风险((b) 要多一个只能拍的时间常量、
会把正常但慢的回合误杀;但它能兜住「连接没断而编排器死锁」这一档,今天没有现场证据)都写在
`ReconnectReset.kt` 顶上,留主人拍板。
如实登记的两处代价
1. 收口**不删**正文/思维链/工具卡内容,所以那张卡本身要等一个后续事件才消失(它自己那一路的落库行,
或下一轮 `message.start` 的 `beginLeg` 把它当「已结束的路」丢掉)。若主人更想要「末项立刻让开」,
代价是那半段正文从屏幕上消失——我没选它(硬要求:不许动消息内容)。
2. `busyMode = null` 连带改变下一次发送的声明(`emitAndDeliver` 用 `busyMode != null` 算
`interruptScopeForSend` / `parallelForSend`):重建后的**第一次**发送不再先发 `interrupt`。如实说:
重建之后本端确实不知道那一轮还在不在;服务端下一次报 `session_busy` 就恢复。
「闪动」口径:`closed` 单调(迟到的帧不会把它翻回灰态),流式卡片会被后续真帧重新拉回「在跑」
(那是如实的、且不丢字),`busyMode` 由服务端下一帧恢复。
验证
- `./gradlew.bat assembleDebug` BUILD SUCCESSFUL(app-debug.apk 已产出)
- `./gradlew.bat testDebugUnitTest` 全量:**115 suites / 1405 tests / 0 failures / 0 errors**
(基线 114 / 1388 ⇒ +1 suite、+17 tests,全部来自新增的 `ReconnectResetTest`)
- 新增 `ReconnectResetTest`(17 例):连接重建 ⇒ 未收口绑定全 closed(且 `phaseOf` 真的变成 Answered);
第一次连接(`Idle`/`Connecting → Connected`)不收口;判据 5×5×2 = 50 格全表穷举;`settledAfterReconnect`
幂等且无变化时 `assertSame`;已 closed 的不会被 `withTurnBegin` 翻回;流式卡片全停且正文保留、空路被丢;
工具卡 `Running → null+done`、`Done`/`Error` 原样、后续真帧能放回 `Running`;
消息/待发/草稿/队列/error/绑定 messageIds **一律原样**。
|
2026-10-07 10:50:23 +08:00 |
|
AskaEth
|
53c3c5c8ee
|
docs(05): 锚点补记三十九 —— 后端回执二十五/二十六要点(.end 加 resultSummary 已批准、proactive 在 data 里、帧 id 同源、channel_persisted 可能带 proactive、markup 畸形确认是 bug 且已修但未部署)+ 我们去重口径复核没踩坑 + W59 工具卡结果区落地 + 真机验收边界(这批只有单测证据)
|
2026-10-07 10:15:13 +08:00 |
|
AskaEth
|
e0bdaa3537
|
merge: W59 —— 工具卡结果区(tool_execution.end 的可选 resultSummary;缺席/空串一律不画,与老服务端逐字节一致)
|
2026-10-07 10:14:30 +08:00 |
|
AskaEth
|
316cd27049
|
feat(chat): 工具卡接上 resultSummary 结果区(缺席时与今天逐字节一致)
后端新契约切片给 `tool_execution.end` 的 `data` 补了一个**可选** `resultSummary`
(与回看面 `toolCalls[].resultSummary` 同一份预算:已脱敏、已截断 ≤500 字符)。
它的语义是「拿到就带、没拿到就缺席」——失败 / 取消 / 超时 / 未落图时**整个键缺席**,
且契约明说**别把缺席读成「没有输出」**。在那之前工具卡只能显示工具名与状态。
改了什么:
- wire:`WsEvent.ToolExecutionEnd` 新增 `resultSummary: String? = null`;共用的
`ToolExecutionData` 跟着加这个字段(只由 `.end` 读,`.start` / `.update` 的领域事件
仍然没有它)。缺键 ⇒ `null`,空串 ⇒ `""`,都在解码层照原样保留、不编结论。
- 领域:`ToolCallState` 新增 `resultSummary`;`applyToolExecution` 多一个同参数
(非空白时顶替、缺席 / 空白时保留原值——三帧里只有 `.end` 带它,一次落不到不该抹掉旧的);
VM 的 `onToolExecution` 与 `ToolExecutionEnd` 分支透传。
- 判据:新增纯函数 `toolResultTextOf(tool)`:摘要非空白 ⇒ 用它;否则回落 `output`
(今天唯一的来源,口径不变);两者都没有 ⇒ `null`。
- UI:`ToolCard` 折叠区里「结果」那一块(参数之后)改由 `toolResultTextOf` 决定要不要画。
`null` 时**连标签与它上方那道 Spacer 都不画** ⇒ 摘要缺席时这一屏与后端补这个键之前
逐字节相同。空串同理(服务端承诺不发空串,但真发了也绝不渲染出一个空的结果区块)。
摘要只展示:不再截断第二次、不解析、不当 markdown;正文沿用与参数同一个 `FoldCardBody`
(`SelectionContainer` + 不设 `maxLines`),长文本整段可见,没有新增任何滚动 / 限高 modifier。
为什么这么做:一句话的判据、纯函数、单测可钉;渲染层只做「画 / 不画」这一个决定,
于是「老服务端(键恒缺席)」与「新服务端(键在场)」共用同一套 UI,不用等部署。
测试:新增 14 条(`WsEventToolExecutionTest` +5、`ToolExecutionCorrelationTest` +9),
覆盖 ①键在场 ⇒ 解出该值(含 500 字符原样透传)②键缺席 ⇒ `null` 且不报错
③空串 ⇒ 解出 `""` 且判据按「不画」处理 ④`toolResultTextOf` 在缺席 / 空串 / 有值三档下的输出
(含摘要优先于 `output`、结果落卡、缺席不抹旧值、落不到卡时不影响任何卡)。
`assembleDebug` 通过;全量单测 114 suites / 1388 tests / 0 failures / 0 errors(基线 1374)。
|
2026-10-07 10:14:10 +08:00 |
|
AskaEth
|
c40a752bcf
|
docs(05): 锚点补记三十八 —— 分享面板 P0 崩溃(两次:Dp.Infinity 当哨兵值 / 展开态只去上限没去 verticalScroll)已修并真机验收(收起 153px↔展开 255px,同一 pid 无崩溃);三条教训(单测抓不到渲染层崩溃、子代理论证再详细也不能替代真机点按、verticalScroll 的约束来源要看谁在外面);顺带推翻 W56『首帧回流看不见』的结论
|
2026-10-07 09:51:59 +08:00 |
|
AskaEth
|
df7b504fa2
|
fix(chat): 分享面板展开态也会崩 —— 只去掉上限是不够的,必须连 verticalScroll 一起去掉
补上一个修复的漏:上一笔只把展开态的 heightIn 上限去掉,没去掉 verticalScroll。而外层
ShareConfirmPreviews 自己就是一个 verticalScroll,它给内容的是**无界高度** ⇒ 这一层的 scroll
在无界高度下被测量 ⇒ 抛同一个 IllegalStateException。真机上点「展开」的那一下崩了第二次
(pid 2077,01:48:53,reason=4 APP CRASH)。
改:展开态把这一层**整个**去掉(上限 + 滚动一起),只留外层预览区那一层滚 —— 这也正是 W56
原本的设计意图('展开态只留外层那一层')。shareConfirmWindowCap 去掉了 expanded 维度(展开态
不再走它),测试同步改写并补了一条「两种状态在链上都有有限约束」。
真机验收(emulator-5554 + 真后端):收起 153px(3 行)+『展开』→ 点开展开 255px +『收起』→
点回收起 153px,**三次操作同一个 pid 4063,没有任何新崩溃记录**。
|
2026-10-07 09:51:44 +08:00 |
|
AskaEth
|
6d4f2cdbae
|
fix(chat): 分享确认面板一打开就崩 —— 正文那一层绝不能再拿到 Dp.Infinity
真机崩溃栈:heightIn(max = Dp.Infinity) 等于不施加约束,verticalScroll 在无界高度下被测量时
checkScrollableContainerConstraints 直接抛 IllegalStateException(ScrollNode.measure ←
SizeNode.measure ← DialogLayout.internalOnMeasure),分享进来、面板刚打开就闪退。
改法:这一层的高度上限只经纯函数 shareConfirmWindowCap() 一个出口 —— 首帧用
SHARE_PREVIEW_MAX_HEIGHT(150dp) 兜底(有限值),展开态这一层不设上限(外层 240dp 仍是有限约束),
并且不再用 Dp.Infinity 当「还没量到」的哨兵值(改用 null)。新增 4 条单测钉住
「任何输入组合都不会产出非有限上限」。
|
2026-10-07 09:45:34 +08:00 |
|
AskaEth
|
af9c63d267
|
docs(05): 锚点三十七补第 5 节 —— 收尾:假后端任务到点、tcp:80 切回真后端、App 已重连并拉到真实会话;并如实记录「换后端目标但地址字符串不变 ⇒ 本地消息残留」这个观察(已核实真后端没有那条测试消息,且正常换地址必经登出会清)
|
2026-10-07 03:57:03 +08:00 |
|
AskaEth
|
c8f19f9c84
|
docs(05): 锚点补记三十七 —— 长跑第3次定案(30min 有效通过;2h 失败是模拟器 system_server 崩溃连带杀进程,五条证据)+ Android 14 划掉会终止 dataSync 前台服务(验收造场景改用返回键 finish Activity)+ W57 端到端验收通过(含未做改前对照的如实说明)
|
2026-10-07 00:18:51 +08:00 |
|
AskaEth
|
f9555fba85
|
docs(05): 锚点补记三十六 —— W57 通知消费点提到进程级(根因链:连接是进程级/订阅挂 Activity 级 VM/replay=0 丢帧)+ 长跑第3次基线(开关确认是开的,推翻上节那条猜想)+ W57 设备验收步骤与副作用
|
2026-10-06 22:23:09 +08:00 |
|
AskaEth
|
900d191b8d
|
merge: W57 —— 把「她主动说的话」的通知消费点提到进程级(新增 ProactiveFrameIntake;修 Activity 被划掉后收不到帧/不弹通知)
|
2026-10-06 22:22:26 +08:00 |
|
AskaEth
|
0e03eba6a4
|
feat(notify): W57 —— 「她主动说的话 ⇒ 弹通知」的帧消费点提到进程级
改了什么
- 新增 app/ProactiveFrameIntake:订 ChatSocket.events,把两类帧(proactive_message、
message_persisted{proactive:true})折成四个入参交给 ProactiveNotifier。纯函数
proactiveFrameOf 管「哪些帧算她主动说的话、每个入参取自帧上的哪个键」。构造即订阅,
刻意不提供 start/stop。
- AppContainer:新增 proactiveFrameIntake(与 chatSocket 同一层,进程级)。
- SessionsViewModel:删掉 onEvent 里那两处转交 proactiveNotifier 的调用,本类不再判断
通知的任何一件事(未读、「谁在跑」原样不动)。
- AppPresence / ProactiveNotifier 的 KDoc 跟着改:谁在读「在不在前台」、谁在喂帧。
为什么
改前这份订阅挂在 SessionsViewModel 的 viewModelScope 上。连接本身是进程级的
(ChatSocket 自带 scope,只有 close() 取消它,而 SessionsViewModel.onCleared 刻意不关),
所以从最近任务把 Activity 划掉之后连接照旧收帧——但 events 是 replay=0 的 SharedFlow,
没有订阅者的 tryEmit 当场丢掉:她的话进了连接、然后没了,通知永远不弹。这是 W53N 登记
的那一格缺口。
边界
- 判据与去重一个字节没动(仍在 ProactiveNotifier 的纯逻辑里)。
- 连接所有权没动:仍然只在登录态那一处 connect/close。
- 不是「新起一条连接」,只是把帧的消费点从 Activity 级提到进程级;VM 里那两处转交是
删而不是留(留两处等于靠去重去遮一次重复消费,且「谁在喂通知」会有两个答案)。
- deliver 里接住落地异常:这条订阅不会再被重建,抛出去就是进程崩溃 + 通知整个哑掉。
验收
assembleDebug 通过;testDebugUnitTest 全量 113 suites / 1370 tests / 0 failures /
0 errors(基线 112/1361,新增的 9 条全在 ProactiveFrameIntakeTest)。
|
2026-10-06 22:21:26 +08:00 |
|
AskaEth
|
6de2f7656a
|
docs(05): 锚点三十五补第 7 节 —— 保活长跑第2次复查结果作废(装包必然杀进程 / tcp:80 已指向真后端使注入 delivered=0 / 复查时进程本就不在),并写明重做需要的前提与代价
|
2026-10-06 22:14:13 +08:00 |
|
AskaEth
|
5f7489d70e
|
docs(05): 锚点补记三十五 —— 模拟器已切真后端(含全角冒号坑与「未知错误」根因);主人报的标签解析 bug 定位到后端畸形 markup <p><p>正文</p> 并已容错修复 + 真机验收;W54A/C/D + W55 + W56 五支合并推送(112/1361 绿);模拟器卡的原因诊断
|
2026-10-06 21:44:07 +08:00 |
|
AskaEth
|
a60e3abc12
|
merge: W56 —— 分享面板预览加「展开/收起」(长正文可读全文;展开态上限 240dp;短正文不显示控件)
|
2026-10-06 21:43:30 +08:00 |
|
AskaEth
|
14955ef6c0
|
feat(chat): 分享确认面板的正文预览可以展开 / 收起
问题:W54D 把正文那一窗改成「三行 + 内部滚动」之后「读不到整段正文」是解决了,但一次只看得见
三行。主人拍板「可以滚动 可以展开」——两个都要,所以这一轮补上后半。
改法(只动 ShareConfirmPanel.kt 与它的测试,ChatScreen / ChatViewModel 与接线一字未碰):
- 正文卡片里加一枚「展开 / 收起」(`Text` + `clickable`,不用 `TextButton`:按钮自带高度会把
这一行撑高一截)。它挂在「分享的文字」那一行的右端,不挂在正文末尾——展开后正文可能好几屏,
末尾那枚「收起」要滚到底才找得到。
- 收起态一个字没变:三行窗(SHARE_PREVIEW_TEXT_LINES = 3)+ 内部滚动。
- 展开态:正文那一窗撤掉自己的上限(`heightIn(max = Dp.Infinity)`,正是首帧那个状态),整段交给
外层预览区滚;外层上限由 150dp 抬到 240dp(SHARE_PREVIEW_EXPANDED_MAX_HEIGHT)。展开态**只留
这一层**滚动是刻意的:两层都设上限时手势先被内层吃掉、内层滚到底才轮到外层,两层上界还互相
牵制,最容易出的错就是「明明展开了还剩半截读不到」。
- 控件**只在正文排下来超过三行时出现**。判据是行数 > SHARE_PREVIEW_TEXT_LINES,不是
`hasVisualOverflow`:这一窗的高度不受限(`verticalScroll` 给子项的就是无上限约束),`Text` 自己
永远报「没有溢出」,拿它当判据会在最长的那种正文上漏掉控件;行数则与上限无关,首帧就可用。
- 展开态为什么是 240dp:这个上限管的是**整个预览区**,下面还压着描述输入框(最多 4 行)与两枚
按钮,面板贴着屏幕底、键盘一弹可用高度只剩半屏上下。150 → 240 在默认字号档上把正文窗从三行抬到
十行上下;再往上(例如整屏的六成)先被顶掉的会是按钮。⚠️ 这个数**没在真机上量过**,是按这笔账
取的保守值,观感由主人验收,要调只改这一个常量。
- 两条判据都做成纯函数(`sharePreviewTextOverflowsWindow` / `sharePreviewMaxHeight`),调用点只读
结论,单测钉的就是它们。
上一批留下的「首帧回流会不会看得出来」疑点,复核结论:**留着,且确认它看不见**(理由已写进
`ShareConfirmBody` 的 KDoc):① 首帧那一下撑不动任何别的东西——正文块是预览区里的最后一个元素,
可视像素只由外层那层独立的上限裁剪决定,上面图片/文件的位置一分不动;② 展开态本来就没有这一层
上限,不存在「量完再回落」;而展开 / 收起那一帧的尺寸变化是用户动作的**目的**,不是回流。真正多
出来的是控件本身(第二帧才出现,因为行数是排完才知道的),它与标签同用 `footnote2`、行高不变,
也顶不动任何东西。组合期 `rememberTextMeasurer` 那条路要外包 `BoxWithConstraints` 拿宽度、还要
自己复现字号与字体缩放,等于把排版做两遍——为一个看不见的跳动换掉结构不划算,不做。
已知取舍(如实记下,没修):转屏(Activity 重建)后展开态回到收起。它现在活在
`remember(preview.text)` 里,跨转屏要活下来只有两条路——进 `ChatUiState`(W54C 给「已取走的分享」
与描述走的就是那条路,但那是 ChatScreen / ChatViewModel 的地盘,本批红线不许碰),或
`rememberSaveable`(本仓一次都没用过的新写法,且弹层窗口带不带 saveable registry 没在真机上验过)。
两条都没走,留一条 ⚠️ 在代码里 + 留给主人定。
测试:新增 3 条(纯函数判据)——「什么时候该出现展开控件」(0/1/3 行不给、4 行起给,并接一份
40 行的真分享走一遍)、「展开态给多高」(150 / 240 两个字面量 + 展开严格更高 + 不超过收起态的
两倍)、「两句标签不同且非空」。展开与滚动本身单测够不着(本模块没有 Compose UI 测试的依赖),
如实说明;既有断言一条未放松。
验证:`./gradlew.bat assembleDebug testDebugUnitTest --tests "*ShareConfirm*"` → BUILD SUCCESSFUL
(ShareConfirmTest 15 条 = 既有 12 + 新增 3、ShareConfirmDraftTest 3 条,0 失败 / 0 错误);
全量 `./gradlew.bat testDebugUnitTest` → BUILD SUCCESSFUL,112 suite / 1361 tests / 0 失败。
|
2026-10-06 21:43:12 +08:00 |
|
AskaEth
|
9ff2de3a1d
|
merge: W55 —— 解析器剥掉落单标签(修后端畸形 <p><p>正文</p> 导致标签字面显示在气泡里)
|
2026-10-06 21:26:34 +08:00 |
|
AskaEth
|
f3c1121887
|
merge: W54D —— 分享确认面板正文预览改成三行窗口 + 内部滚动(发送前能读到整段正文)
|
2026-10-06 21:26:33 +08:00 |
|
AskaEth
|
a861881ec4
|
merge: W54C —— 转屏不再静默丢掉已取走的分享(面板状态与描述移进 ChatUiState,take 仍只发生一次)
|
2026-10-06 21:26:29 +08:00 |
|
AskaEth
|
b3ded77c23
|
merge: W54A —— 通知一事一条(通知 id / PendingIntent requestCode 按消息稳定派生,修她连说几句时后到覆盖先到)
|
2026-10-06 21:26:26 +08:00 |
|
AskaEth
|
464e91621e
|
fix(parse): 容错落单的段落标签,绝不把 <p> 当正文渲染
真机实测:后端产出的某条消息第三段是 `<p><p>那现在…?</p>`(两个开标签、一个闭标签)。
P_BLOCK_RE 是非贪婪的,先把外层 `<p>` 配到那个 `</p>`,内层那个裸 `<p>` 就成了「块内容」;
而 appendAnnotations 只认 `<action>` / `<narration>` / 星号,不认裸 `<p>`,于是它作为普通文本
落进气泡正文,屏幕上直接显示 `<p>那现在…`。
改了什么:新增 stripLooseTags,在切出 `<p>` 块之后按区剥掉「落单标签」——
- 消息里已有完整 `<p>…</p>` 块时,块内块外多出来的每个 `<p>` / `</p>` 一律剥掉,块外没被
ROLE_RE 认领的 `<action>` / `</action>` 同样剥掉;
- 一个完整块都没有时,维持既有「未闭合 `<p>` 整段回落字面文本」的契约(编辑框靠它保证不
静默改字),只再剥两类连「被截断的结构」都算不上的东西:落单的 `</p>` 与后面什么都没有的空开标签。
判据只认完整匹配已知标签词表的形状,`1 < 2` / `<3` / `a<b` / `<p2>` / `<P>` / `<p`(残缺) 一律原样保留。
为什么选择「剥」而不是「原样显示」:这些是服务端产出的标记、不是她说的话;渲染成正文等于往
气泡里塞一串用户既看不懂、也改不掉的字符,而剥掉只是少显示几个本就不该出现的字符,正文一个字
都不动。这只是客户端的容错,不代表服务端这样发是对的。
流式:stablePrefixEnd 不动。扣留点取「第一个没闭合的开标签」,而前缀里的每个开标签必定在同一个
前缀内配到闭标签,所以「是不是落单」在它被产出的那一刻就已定死,中间态剥掉、收口也不用改口;
放宽扣留(把重复的 `<p><p>` 当外层冗余)反而会让 `<p>甲<p>乙` 在中间态把 `<p>甲` 当字面文本吐上屏,
等 `</p>` 到了再跳成 `甲乙`。新增测试钉住这条:逐字符喂实测畸形串时,任何一帧都不许漏出标签,
且已显示的正文只许增不许改口。
测试:assembleDebug 通过;ChatContentParserTest 55 项全绿(新增 17 项);全量 110 suites /
1349 tests / 0 failures / 0 errors(主仓同 HEAD 基线 110 suites / 1332 tests,本次 +17 项)。
|
2026-10-06 21:26:07 +08:00 |
|
AskaEth
|
ec89d828d1
|
fix(chat): 分享确认面板的正文预览改成三行窗口 + 内部滚动
问题:正文那一窗此前是 maxLines = 3 + 省略号,超过三行的分享正文在按「发送」之前根本读不到。
面板里再没有第二处放着这段正文(输入框只剩「用户附加的描述」,初值恒为空),截断就等于
「要发出去的那段正文看不见」——而这正是用户点「发送」时唯一要确认的东西。
改法:正文那一窗高度恒为三行、内部可滚,外层预览区那 150dp 的上限原样不动,所以面板不会被
长正文撑高(图片 / 文件芯片 / 按钮的位置都不变)。滚到底后余量交给外层 scroll,嵌套滚动本身
就有,不用额外接线。
上限为什么不是写死的 dp:Miuix 的 footnote1 只给了 13sp 的字号,lineHeight 是 Unspecified,
行高由系统字体的 ascent/descent 决定、随「大字体」设置一起缩放。写死 dp 在默认字号档上勉强
对得上,到大字号档就会矮一截、把末行的字切一半。所以改为量:从 TextLayoutResult 上取第三行的
底边当上限,量到之前先按无上限排一次(heightIn(max = Dp.Infinity) 在确认过 Compose 的
Density.roundToPx 对 Infinity 的处理后是安全的,即「没有上限」)。
SHARE_PREVIEW_TEXT_LINES 由 private 改 internal,只为让单测能把「这一窗是三行」钉成字面量
(本模块没有 Compose UI 测试的依赖,滚动行为单测够不着)。
测试:新增两条——正文远超三行时纯函数层仍原样交付**完整**正文(一个字不少、不加省略号),
以及三行上限的字面量;既有断言一条未放松。
|
2026-10-06 21:11:30 +08:00 |
|
AskaEth
|
1142bf5ed1
|
fix(share): 转屏不再静默丢掉「已取走、还没确认」的那份分享 —— 面板状态移进 ChatViewModel
分享进来的内容被 `ShareIntake.take()` 取走之后没有归还路径,而它此前只挂在一格
`remember(sessionId)` 上:Activity 因转屏 / 深浅色切换重建时那次组合整份丢掉,那份分享
**既不在待发队列里、也不在界面上**(用户什么都没看到,也没有任何提示);用户已经输入到
一半的那句描述同样丢。
改法:把「已取走、正等确认的那份分享 + 用户补的描述」移进 `ChatUiState.shareConfirm`
(`ShareConfirmDraft`),那一格由 VM 持有,而 VM 活在 `ChatSessionSlots` 的 `ViewModelStore`
里、由 AppContainer 撑着 —— 比组合与 Activity 都活得久,所以重建之后面板照旧长出来,
描述也跟着回来(描述与正文绑在同一个值里,两者同生共死)。
`take()` 仍然只发生一次:认领那一步收进 `ChatViewModel.claimSharedContent`,闸门是纯函数
`shareConfirmClaim`,且**在 `take()` 之前** —— 手上已经有那份内容时一个字节都不动队列
(既不取新的一条,也不会把用户正看着的那份顶掉)。重建后的新一帧照样会走到判据这里,
而那时手上已经有东西(它活在 VM 里)⇒ 不会再取第二次。判据、出队、落地之间没有挂起点,
所以也不存在「取走了却没记住」的窗口。
顺带把这一格计入 `holdsUnsentWork`:这份内容已经没有退路,那一格被回收就等于把它删掉。
`ShareLanding` / `ShareIntake.take()` 里「取走之后直接发」的旧说法一并改成「交给确认面板」。
新增 ShareConfirmDraftTest:钉住「重建后的第二帧不再调 take、队列里那条也不许被取走」、
「内容与描述原样还在」、「队列为空时不造一个空面板」。
|
2026-10-06 21:09:15 +08:00 |
|
AskaEth
|
53339b369a
|
feat(notify): 她主动说的话改成「一事一条」——通知号按消息派生,后到的不再顶掉先到的
改前所有她主动说的话共用一个固定的通知 id,而同 id 的 `notify` 是**替换**:她连说几句时后来
的那条把先来的顶掉,用户拿起手机只看到最后一句,前面几句一声没响就没了(这正是 W50 判据下游
的落地形状)。
改成按消息派生:
- `ProactiveNotify.kt` 新增纯函数 `proactiveNotificationId(key)`:把**去重键**(`proactiveNotifyKey`
的输出)折成通知号。刻意与去重键同源——去重说「这是同一条」、派生说「这是同一条 ⇒ 同一个号」,
两处口径一旦分成两套,就会出现「认得出是同一条、却排出两条通知」的形状漂移(实时帧与落库帧
两条路都会到,这条尤其要命)。
- 两个坑都真的堵上:① 号必须 > 0,而 `hashCode()` 可以是负的、甚至正好是 `Int.MIN_VALUE`
(`abs(Int.MIN_VALUE)` 在 int 域里仍是它自己)⇒ **先展宽到 `Long` 再取绝对值**,不引任何新依赖;
② 不撞既有 id:全仓只有保活那条常驻通知(id = 1)与它 `PendingIntent` 的 request code = 0 是
占用者,派生的号从 1000 起步、宽度 2×10⁹,落点与它们**结构性**不相交。
- `ProactiveNotifier.post` 的签名带上这个号(`post: (text: String, notificationId: Int) -> Unit`),
`AppContainer` 的装配 lambda 同步改;`ProactiveNotification.kt` 删掉恒定的
`PROACTIVE_NOTIFICATION_ID` / `PROACTIVE_OPEN_REQUEST_CODE`,通知 id 与 `PendingIntent` 的
request code **一起**随消息走(只换 id 不换 request code,点后到的那条通知会串到先到那条的
`PendingIntent` 上)。
- 撞号仍会发生(`String.hashCode()` 只有 32 位):后果与改前一样是「后来者替换先来者」,已在
`proactiveNotificationId` 的 KDoc 里如实写清概率,以及为什么接受这一侧。**没有**放松任何既有断言。
测试:新增 `ProactiveNotificationIdTest`(同一条派生两次同号 / 两条不同消息不同号 / 任何输入恒 > 0,
最后一条用 hashCode 正好是 `Int.MIN_VALUE` 的字符串 `polygenelubricants` 钉住前提);另在
`ProactiveNotifierTest` 加一条端到端的号断言(两条路都到的第一条只发一次且号不变、第二条是另一个
号)。`ProactiveNotificationShapeTest` 里那条「不与保活那条相撞」改成扫一遍真实形状的键。
验证:`./gradlew.bat assembleDebug` → BUILD SUCCESSFUL;`./gradlew.bat testDebugUnitTest` →
BUILD SUCCESSFUL,111 suite / 1336 tests / 0 失败(基线 110 suite / 1332 tests,本批净增 4 条)。
|
2026-10-06 21:08:23 +08:00 |
|
AskaEth
|
e5e61e131d
|
docs(05): 锚点补记三十四 —— 压缩后续接(W54 三支并行在飞:通知一事一条/转屏丢分享/预览可滚动;第二批进程级订阅待开)+ 主人三条拍板(预览要滚动+展开、提示词已贴等回执、推送待研究)+ 环境核对
|
2026-10-06 21:02:43 +08:00 |
|
AskaEth
|
33c320219a
|
docs(05): 锚点补记三十三 —— 续接锚点(仓库现状/本轮八项已完成及其真机证据/保活长跑在跑/待主人拍板四条/环境与两个会踩空的地方/两条教训)
|
2026-10-06 20:59:32 +08:00 |
|
AskaEth
|
827f0e61a4
|
docs(05): 锚点三十二补第 6 节 —— 分享面板语义改对并上机验过(预览留正文 / 输入框空+占位符 / 正文+空行+描述 拼接端到端验过)+ 保活长跑因装包重起(新基线 20:13:35 / pid 25584)
另记两条:① 预览正文仍只有 3 行 ⇒ 发送前读不到全文,属观感取舍待主人定;② adb am start 的 --es 值会被空格拆开(我那次 URL 就是这么丢的,不是 App 吞的)。
|
2026-10-06 20:13:52 +08:00 |
|
AskaEth
|
30c53451dc
|
merge: W53X3 —— 分享面板的输入框改成「用户附加的描述」(默认空 + 占位符),正文留在预览区;发送时把分享正文与描述拼起来(空行分隔)
|
2026-10-06 20:10:03 +08:00 |
|
AskaEth
|
514901c12d
|
fix(share): 确认面板的输入框改成「用户附加的描述」——不再预填分享正文,发送时由正文+描述拼成
W53X 的第一版把那个输入框当成了分享正文的可编辑副本(`shareConfirmDraft(share)` 直接返回分享
正文、由 ChatScreen 预填进去),在真机上就渲染成「同一句话排两遍」:预览的「分享的文字」块一遍、
输入框里又一遍,而输入框里那句还看着像一段只读展示。主人澄清语义:那个框是**用户要附加的描述**
——「这次要发什么」归预览,「你想补一句什么」才归这个框,两件事被合成一件才是根因。
改成:
- 输入框初值**恒为空**(`shareCaption` 在取走一份分享时显式归零),占位符改成「加一句描述…」
(与输入栏那句「和昔涟说点什么…」同一个 `useLabelAsPlaceholder` 写法);预览区一个字没动
(图片缩略图 / 文件 chip / 分享正文都留着,那才是这次要发出去的东西)。
- 新增纯判据 `shareConfirmBody(share, caption)` = 分享正文 + 用户描述:两段都在时用**一个空行**
分隔,只有一段就只用那一段,都空就是空串(两侧空白按 `ShareRules.extract` / `sendShared`
的口径归一,中间一个字不动,也不加任何来源标记——那是项目红线)。
- 删掉 `shareConfirmDraft`(名字说「草稿/正文」、行为是「把正文当输入框初值」,留着就是骗人);
`shareConfirmCanSend(share, caption)` 改成**直接问 `shareConfirmBody`** 非空、或另有图片/文件,
于是「发送」可点与否与 `sendShared` 开头那条早退同源;描述为空时不再误把「纯文本分享」判成不可发。
- ChatScreen 的发送口由 `share.copy(text = shareDraft)` 改成
`share.copy(text = shareConfirmBody(share, shareCaption))`;接线语义(`take()` 只发生一次、
面板渲染位置、WindowDialog 选型)一字未动。
测试:`ShareConfirmTest` 跟着改到新语义,拼接规则**穷举**钉住(都有 / 只有正文 / 只有描述 / 都空,
分隔符写成字面量 `"\n\n"`),并钉住「都空且无文件 ⇒ 不能发」与「可点 ≙ 拼出来的正文非空或另有
附件」(同源性)。10 条测试全绿。
验证:`./gradlew.bat --console=plain assembleDebug testDebugUnitTest` → BUILD SUCCESSFUL,
110 suite / 1332 tests / 0 失败(基线 110 / 1330,本批净增 2 条)。
|
2026-10-06 20:09:35 +08:00 |
|
AskaEth
|
59012f711b
|
docs(05): 更正分享面板的语义 —— 输入框是「用户附加的描述」而不是分享正文的复刻
主人澄清:那个输入框(我这边先误判成「预览多余」)应当是用户附加描述的输入框。正确形态:
预览留着分享正文(它才是要发的内容)、输入框默认留空 + 占位符、发送时「分享正文 + 用户描述」拼接
(两段都在时空行分隔)、都空且无文件时按钮不可点。我先前那条「删预览正文」的指令方向反了,已更正,
W53X2 回来即按更正执行。
|
2026-10-06 20:06:00 +08:00 |
|
AskaEth
|
863ac93b25
|
docs(05): 锚点补记三十二 —— 续接锚点(主人两条新指令:复验分享面板「正文两次」、开跑保活长时间后台)+ 环境状态 + 两条各自照抄即可的跑法
|
2026-10-06 20:03:56 +08:00 |
|
AskaEth
|
d8e44c6692
|
docs(05): 锚点补记三十一 —— W53 四项落地(后台通知/退出确认框/保活文案与入口/分享确认面板)+ 真机验收全过
- 三支各自的机制与判据要点,含两条「子代理纠正任务书」的记录(Miuix 0.9.3 没有 extra.* 包;
proactive 判据后端放在 data 里而不是帧壳顶层——我读后端源码核过)+ 我自己补的那处
(AppPresence 补同源 StateFlow 当 key,修「去系统设置授权后回来服务没起」)。
- 真机验收逐条证据(后台通知的形状与点它回主会话、退出确认框、保活文案与「去系统设置」、
分享面板确实没被直接发出、INTERNET 来源是源码)。
- 仍未验/已知缺口如实列出(两条路去重只有单测、通知 id 覆盖、Activity 销毁后收不到帧、
转屏丢已取走的分享、S4 对照组没跑干净)。
|
2026-10-06 19:00:03 +08:00 |
|