8 Commits
Author SHA1 Message Date
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
19 changed files with 1651 additions and 149 deletions

No files matched your search

@@ -72,7 +72,17 @@ class AppContainer(context: Context) {
*/
val proactiveNotifier: ProactiveNotifier = ProactiveNotifier(
presence = appPresence,
post = { text, notificationId -> postProactiveNotification(appContext, text, notificationId) },
post = { text, notificationId ->
// 「免打扰」**每次现读**(`AppSettings.current` 是那份热副本):开关一改,下一条通知就
// 换一条渠道,不必重启 App。为什么不能在这里挂起读一次、以及那个毫秒级窗口是怎么回事,
// 都写在 `AppSettings.current` 的 KDoc 里。
postProactiveNotification(
context = appContext,
text = text,
notificationId = notificationId,
quiet = appSettings.current.value.proactiveQuiet,
)
},
)
/**
@@ -48,6 +48,17 @@ import java.util.UUID
* 猜错等于替用户把一个合规文件变成一次失败;代价是「图片但 provider 没报 MIME」会落到文档路、
* 被服务端按 `415` 拒(有如实文案)。画廊 / 相机 / MediaStore 都会报图片那一类,风险面很小。
*
* ## 四个 extra 为什么都读「原始值」
* `EXTRA_TEXT` / `EXTRA_SUBJECT` / `EXTRA_TITLE` / `EXTRA_STREAM` 一律用 [Intent.rawExtra] 取原始值,
* 再由本文件按**形态**分派([shareStreamExtrasOf] / [shareTextExtrasOf])。两个理由:逐类型的
* `getXxxExtra` 遇到别的形态只会往 logcat 打一句 `ClassCastException` 再把值扔掉(那正是「分享点下去
* 什么都没发生」的来处),而本端还要知道「这个 extra 里**到底有没有躺着值**」——那件事纯函数看不到,
* 只能由这里如实报进去(两个 `present` 参数)。
*
* ⚠️ **正文的回落链起点也在这里**:`EXTRA_TEXT` 读不出东西时,`EXTRA_SUBJECT` → `EXTRA_TITLE` 依次
* 顶上([ShareRules.extract]),来源记进 `SharedContent.textSource`。三个都空 / 都读不出来时,这一次
* 分享照样要往下走(走「读不进来」那条如实告知),**绝不静默**。
*
* ## 清理
* 缓存副本只对**产生它的那个进程**有意义(持有者在内存里,进程没了就没人认领),所以
* 进程启动时整目录删一次([reset])。运行期间不做细粒度回收:那些文件在发送失败被回滚到
@@ -90,14 +101,24 @@ object ShareIntake {
*/
fun accept(context: Context, intent: Intent) {
val appContext = context.applicationContext
// EXTRA_STREAM 一次读原始值、按**形态**分派(规范形态 + 字符串兼容形态),见 [shareStreamExtrasOf]:
// 挨个 getParcelableExtra / getParcelableArrayListExtra 去问的话,遇到字符串形态只会拿到两行
// `ClassCastException` 加一个 `null`——那正是「分享进来什么都没发生」的来处。
val rawStream = intent.streamExtraValue()
// 四个 extra 一律读**原始值**、按**形态**分派(见 [shareStreamExtrasOf] / [shareTextExtrasOf]):
// 挨个 getXxxExtra 去问的话,遇到别的形态只会拿到一行 `ClassCastException` 加一个 `null`
// ——那正是「分享进来什么都没发生」的来处。读不出形态时本端既不猜、也不闭嘴(见下面两个
// `present`):那照样是一次「带着内容迹象却什么都没读进来」。
val rawStream = intent.rawExtra(Intent.EXTRA_STREAM)
val streamExtras = shareStreamExtrasOf(rawStream)
val textExtras = shareTextExtrasOf(
text = intent.rawExtra(Intent.EXTRA_TEXT),
subject = intent.rawExtra(Intent.EXTRA_SUBJECT),
title = intent.rawExtra(Intent.EXTRA_TITLE),
)
val extracted = ShareRules.extract(
action = intent.action,
text = intent.getCharSequenceExtra(Intent.EXTRA_TEXT),
// 正文走回落链:EXTRA_TEXT → EXTRA_SUBJECT → EXTRA_TITLE(有些发送方不填 EXTRA_TEXT,
// 内容只在后两个里;只读第一个会让那次分享整条静默)。来源记在 `textSource` 里。
text = textExtras.text,
subject = textExtras.subject,
title = textExtras.title,
// 单值 / 列表两个字段都喂进去(本端不再按 action 猜该读哪个,见 [ShareRules.extract])。
stream = streamExtras.uriForms.firstOrNull(),
streams = streamExtras.uriForms,
@@ -105,6 +126,7 @@ object ShareIntake {
// 「里面躺着值」这件事只有这里知道(纯函数看不到原始值):形态认不出的那种也算带着内容,
// 否则「分享点下去什么都没发生」会从那条缝里回来。
streamPresent = rawStream != null,
textPresent = textExtras.present,
clipUris = intent.clipUris(),
// 分流就发生在这一处:读不到类型(null)也按文档路走,见类 KDoc。
mimeOf = { uri ->
@@ -280,14 +302,27 @@ object ShareIntake {
}
/**
* `EXTRA_STREAM` 的原始值(**故意读原始值**,理由见 [ShareStreamExtras])。
* 一个 extra 的**原始值**(四个 extra 共用),取不到回 `null`。
*
* ## 为什么读原始值而不是 `getXxxExtra`
* `Bundle.get` 在 API 33 起被打上废弃:它绕过类型安全。这里要的正是「绕过类型安全」——要判的
* 就是「这个值到底是什么形态」。逐类型的 `getXxxExtra` 遇到别的形态只会往 logcat 打一句
* `ClassCastException` 再把值扔掉,那正是本次要修的那个静默。
* `ClassCastException` 再把值扔掉(`String[]` 形态的 `EXTRA_STREAM` 就是这么静默丢掉的),而那
* 正是「分享点下去什么都没发生」的来处。形态分派见 [shareStreamExtrasOf] / [shareTextExtrasOf]。
*
* ## 为什么要 `runCatching`
* `Bundle.get` 第一件事是 `unparcel()`,它会解析别的进程给的字节:里面有个解析不出来的
* `Parcelable` 时它抛 `BadParcelableException`(核对过 AOSP 的 `android/os/BaseBundle.java`:
* defuse 关着时**直接抛出**,开着时也还要看 `Flags.deprecateBundleDefuse()` 那个 flag)。那是
* `RuntimeException`,从 `accept` 冒出去就是 `onCreate` 里的一次崩溃。
*
* ⚠️ 与它相对的是「类型不符」那种:`Bundle.getCharSequence` 自己 `catch` 了 `ClassCastException`
* 并回 `null`(同一个 AOSP 文件),所以那一档**不会**抛异常——但也**读不到值**,于是它只能靠
* 本函数给出的原始值被记成「里面躺着值」(见 [ShareTextExtras.present]),不会被静默当成
* 「什么都没分享」。两件事一起挡住:畸形 bundle 不当崩,读不出来的值不当没发生。
*/
@Suppress("DEPRECATION")
private fun Intent.streamExtraValue(): Any? = extras?.get(Intent.EXTRA_STREAM)
private fun Intent.rawExtra(key: String): Any? = runCatching { extras?.get(key) }.getOrNull()
/**
* `ClipData` 各条目的 URI(部分 App 只填它);纯文本条目的 URI 是 `null`。
@@ -348,3 +383,54 @@ internal fun shareStreamExtrasOf(raw: Any?): ShareStreamExtras {
return ShareStreamExtras(uriForms = uriForms, stringForms = stringForms)
}
/**
* `EXTRA_TEXT` / `EXTRA_SUBJECT` / `EXTRA_TITLE` 三个**原始值** ⇒ 能当正文用的那几段。
*
* ## 为什么也读原始值(而不是 `getCharSequenceExtra` 挨个问)
* 两个理由,第一个是「读得回来」,第二个是「读不回来也要知道有东西」:
*
* 1. `getCharSequenceExtra` 只回「读到 / 没读到」,而本端要按**形态**分派:值可能是 `String`、
* `Spanned`(富文本分享的常态)、`SpannedString`……它们都是 `CharSequence`,但按 `String` 读就
* 直接是 `null`(那正是「分享进来什么都没发生」的来处之一)。这里用 `as?` 一次判完:是
* `CharSequence` 就收下,不是就当没有——**不猜、也不抛**。
* 2. 「三个里**有没有躺着值**」这件事纯函数看不到(它只收到几个 `CharSequence?`),而
* 「有值、只是本端一段都读不出来」与「用户什么都没分享」是**两件事**:前者必须能说出口,
* 否则那三种 extra 一起落空就是一次静默。所以 [present] 由这里如实报进去
* (见 [ShareRules.extract] 的 `textPresent`)。
*
* ## 它抛不抛异常(核对过 AOSP 源码,`sources/android-36.1/android/os/BaseBundle.java`)
* - **类型不符不抛**:`Bundle.getCharSequence` 自己 `catch` 了 `ClassCastException`(`unparcel()` 之后
* 直接 `(CharSequence) o`,失败就 `typeWarning` 记一句 log 再回 `null`)⇒ 所以
* `intent.getCharSequenceExtra(...)` 不会因为「值不是 CharSequence」把 App 打崩,它只是**读不到**。
* 那一档在这里落成 [present] 为真、[text] 为 `null`:既不崩、也不再静默。
* - **畸形 bundle 会抛**:它内部的 `unparcel()` 在别的进程给的 bundle 解析不出来时抛
* `BadParcelableException`(`RuntimeException`)。那一路由 [Intent.rawExtra] 的 `runCatching` 挡住
* ——读不到就当没有,**绝不让异常从 `accept` 冒出去**(那会是 `onCreate` 里的一次崩溃)。
*/
internal data class ShareTextExtras(
/** `EXTRA_TEXT` 读出来的那一段(读不回来是 `null`;空白**原样留着**,由纯函数判它算不算正文)。 */
val text: String?,
/** `EXTRA_SUBJECT` 读出来的那一段。 */
val subject: String?,
/** `EXTRA_TITLE` 读出来的那一段。 */
val title: String?,
/**
* 三个 extra 里**至少一个躺着原始值**——含**读不出类型**的那一种,也含**只是一段空白**的那一种。
*
* 空白也算,是刻意的:它说的是「对方**给了**这个字段」,与「这个字段里有没有可用的正文」是两件
* 事。而 `EXTRA_STREAM` 那边早就是这个口径(原始值只要非 null 就算 `streamPresent`),两个 extra
* 用同一条判据,才不会出现「流的空值会说话、文本的空值却闭嘴」这种要靠运气才发现的差别。
*/
val present: Boolean,
)
/** 见 [ShareTextExtras]:把三个文本 extra 的原始值读成能当正文用的那几段(纯逻辑,可单测)。 */
internal fun shareTextExtrasOf(text: Any?, subject: Any?, title: Any?): ShareTextExtras = ShareTextExtras(
text = (text as? CharSequence)?.toString(),
subject = (subject as? CharSequence)?.toString(),
title = (title as? CharSequence)?.toString(),
// 读不出类型(`42`、别种 `Parcelable`……)也算「里面躺着值」:本端不猜它是什么意思,但不该因此
// 把整次分享当成「什么都没发生」。`null`(连值都没有)才是真的没有。
present = text != null || subject != null || title != null,
)
@@ -11,25 +11,70 @@ import top.yeij.cyrene.app.MainActivity
import top.yeij.cyrene.core.keepalive.notificationsAllowed
/**
* 「她主动说的话」那条通知的渠道 id。恒定不变:改了它等于换一条渠道,用户对旧渠道做过的静音
* 设置会失效(与 `keep_alive` 同一条纪律)。
* 「她主动说的话」那条通知的渠道 id —— **弹横幅**的那一条([PROACTIVE_IMPORTANCE])。
*
* ⚠️ **这里是 `proactive_v2`,不是最初的 `proactive`**:渠道的**重要级一旦创建就改不了**
* (Android 的设计 —— 只有用户能改,App 改不动)。2026-10-07 主人明确要求「要横幅」,而把重要级
* 从 DEFAULT 提到 HIGH **对已经建过旧渠道的设备不生效** ⇒ 只能换一个新 id,让系统按新重要级
* 重新建一条。旧渠道由 [ensureProactiveChannel] 顺手删掉(它的重要级改不动,留着只会让系统设置里
* 多一条永远不再出现的条目)。
*
* **刻意与保活那条分成两个渠道**:保活那条是 LOW(不响不震、用户没要求被叫住),这一条是
* [PROACTIVE_IMPORTANCE](**要提醒人**)。两者混在一条渠道里,就只能在「保活开始响铃」与
* 「她的话不响铃」之间二选一——那正是「一个渠道只能是其中一件事」的由来。
* [PROACTIVE_IMPORTANCE]。两者混在一条渠道里,就只能在「保活开始响铃」与「她的话不响铃」之间
* 二选一——那正是「一个渠道只能是其中一件事」的由来。
*
* 与 [QUIET_PROACTIVE_CHANNEL_ID] **也是两条**,理由与上面那条一模一样:横幅由渠道重要级决定,
* 而重要级建完就改不了 ⇒ 设置页那个「免打扰」开关**不可能**靠运行时改设置实现,只能按开关选一条
* 渠道发(见 [proactiveChannelId])。
*/
internal const val PROACTIVE_CHANNEL_ID = "proactive"
internal const val PROACTIVE_CHANNEL_ID = "proactive_v2"
/**
* 渠道重要级:`DEFAULT`(有提示音、不弹全屏横幅)。
* 「她主动说的话」在**免打扰**下走的那条渠道 id:重要级是 [QUIET_PROACTIVE_IMPORTANCE]
* (有提示音、通知栏有条目、**不弹横幅**)。
*
* 这一条**是**要来提醒人的(她主动找我说话),所以不用 LOW;也不升到 HIGH——HIGH 会在屏幕顶部
* 弹横幅盖住用户正在做的事,而这句话并没有急到那个程度。
* 为「免打扰」单独建**第二条**渠道,是因为**重要级建完就改不了**(理由见 [PROACTIVE_CHANNEL_ID])
* ——想「只关横幅」就只有换渠道这一条路。
*
* 与保活那条同理:重要级在**建渠道**时定死,应用之后改不了(只有用户能改),所以这里必须是
* 最终值。`ProactiveNotificationTest` 把这两个值钉住。
* ⚠️ 刻意**不用** `setSilent(true)`:那是**完全静音**(连提示音都没),而用户要的语义是
* 「只在通知栏提示,别在屏幕顶部弹横幅」——两者之间差着一个提示音,不是同一件事。
*/
internal const val PROACTIVE_IMPORTANCE = NotificationManager.IMPORTANCE_DEFAULT
internal const val QUIET_PROACTIVE_CHANNEL_ID = "proactive_quiet"
/**
* 上一个渠道 id([PROACTIVE_CHANNEL_ID] 的前身)。**只在建渠道时用于删除**:它的重要级是
* DEFAULT 且改不动,留着毫无用处。
*/
internal const val LEGACY_PROACTIVE_CHANNEL_ID = "proactive"
/**
* 弹横幅那条渠道的重要级:**`HIGH`**(屏幕顶部弹横幅 + 提示音)。
*
* 这一条**是**来提醒人的 —— 她主动找我说话的那一刻,我显然不在跟她聊天(App 不在前台),
* 正是最需要在屏幕顶部弹一下的场景。DEFAULT 只会响一声、安静躺在通知栏里,很容易错过
* (主人 2026-10-07 的原话:「我在其他 APP 时没看到横幅」)。
*
* 想安静的用户可以在系统设置里单独把这条渠道调低、或关掉它的横幅 —— 那是用户的权利,
* 不是我们对他的默认;应用内的那条捷径是设置页的「免打扰」,它把通知改发到
* [QUIET_PROACTIVE_CHANNEL_ID]。
*
* ⚠️ 与保活那条同理:重要级在**建渠道**时定死,应用之后改不了(只有用户能改)⇒ **改这个常量
* 对已建过渠道的设备不生效**,必须同时换 [PROACTIVE_CHANNEL_ID]。`ProactiveNotificationShapeTest`
* 把这两个值钉住。
*/
internal const val PROACTIVE_IMPORTANCE = NotificationManager.IMPORTANCE_HIGH
/**
* 免打扰那条渠道的重要级:**`DEFAULT`**(提示音 + 通知栏条目,**没有横幅**)。
*
* `HIGH`(4) 才是屏幕顶部弹横幅的门槛,所以 DEFAULT 正是「她的话照旧说、只是不盖住我正在做的事」
* 那一档。
*
* ⚠️ 与上面那条同理:**改这个常量必须同时换 [QUIET_PROACTIVE_CHANNEL_ID]**,否则对已经建过它的
* 设备一个字节都不生效。这个方向(往安静改)出错时用户的感受只是「开关按了没反应」——不会有任何
* 报错,所以这条纪律只能靠常量与测试钉住。
*/
internal const val QUIET_PROACTIVE_IMPORTANCE = NotificationManager.IMPORTANCE_DEFAULT
/** 通知标题:**是谁在说话**。正文是她的原话(见 [ProactiveNotifier])。 */
internal const val PROACTIVE_NOTIFICATION_TITLE = "昔涟"
@@ -40,6 +85,67 @@ internal const val PROACTIVE_NOTIFICATION_TITLE = "昔涟"
*/
internal const val PROACTIVE_CHANNEL_NAME = "她主动说的话"
/**
* 免打扰那条渠道的渠道名。**必须一眼看出与 [PROACTIVE_CHANNEL_NAME] 的区别**:系统设置的渠道列表
* 里这两行会并排出现,用户就是靠这一行判断「哪条会弹横幅」的——同名的话两条渠道就白分了
* (他只能点进去、一条条试着看)。
*/
internal const val QUIET_PROACTIVE_CHANNEL_NAME = "她主动说的话(免打扰:只在通知栏)"
/** 弹横幅那条渠道的描述(点进渠道详情才看得到)。 */
internal const val PROACTIVE_CHANNEL_DESCRIPTION = "她主动找你说话时的通知"
/**
* 免打扰那条渠道的描述。把「不弹横幅」与「还有提示音」都说清:用户点进来想知道的是**它到底静到
* 什么程度**,而「免打扰」四个字很容易被读成「完全没有声音」——那是另一件事(也正是用户会去系统
* 设置里翻、翻完还是不确定的那一件事)。
*/
internal const val QUIET_PROACTIVE_CHANNEL_DESCRIPTION =
"她主动找你说话时的通知(只在通知栏提示、不弹横幅;仍有提示音)"
/**
* 一条 proactive 渠道的完整定义:**id / 渠道名 / 重要级 / 描述一次给全**。
*
* 为什么要打包成一个值:这四样必须**成对地**跟着「免打扰」这个布尔走(换了 id 却没换重要级 =
* 开关没生效;换了重要级却没换 id = 对老设备没生效)。散成四个各自 `if (quiet)` 就是四份口径,
* 而它们分叉时的症状全都是「用户按了开关,什么都没变」——最难被发现的那种坏法。
*/
internal data class ProactiveChannelSpec(
val id: String,
val name: String,
val importance: Int,
val description: String,
)
/**
* 「免打扰」→ 这一次该用哪条渠道 —— **纯函数**(不含任何 Android 调用,两档可穷举)。
*
* @param quiet 设置页那个「免打扰」开关**当下的值**(`false` = 都弹,也是默认)。
*/
internal fun proactiveChannelSpec(quiet: Boolean): ProactiveChannelSpec = if (quiet) {
ProactiveChannelSpec(
id = QUIET_PROACTIVE_CHANNEL_ID,
name = QUIET_PROACTIVE_CHANNEL_NAME,
importance = QUIET_PROACTIVE_IMPORTANCE,
description = QUIET_PROACTIVE_CHANNEL_DESCRIPTION,
)
} else {
ProactiveChannelSpec(
id = PROACTIVE_CHANNEL_ID,
name = PROACTIVE_CHANNEL_NAME,
importance = PROACTIVE_IMPORTANCE,
description = PROACTIVE_CHANNEL_DESCRIPTION,
)
}
/**
* 发通知那一刻用哪条渠道 id —— **纯函数**,就是「同一个开关、两条渠道」这件事的全部落地形状。
*
* 单独再露一层是因为调用方要的正好只是这个 id(`NotificationCompat.Builder(context, ???)`),
* 而测试要问的也只是这一个问题:「开关两档各发去哪条」。
*/
internal fun proactiveChannelId(quiet: Boolean): String = proactiveChannelSpec(quiet).id
/**
* 点通知进来时那个「切到主会话」的 Intent extra。
*
@@ -52,15 +158,20 @@ internal const val EXTRA_PROACTIVE_OPEN_MAIN_SESSION = "top.yeij.cyrene.extra.PR
/**
* 弹一条「她主动说的话」的通知。
*
* 只做三件事:建渠道(惰性,第一次真要用时才建)、拼通知、`notify`。**判据、去重与 id 的派生
* 都不在这里**(那是 [ProactiveNotifier] 与 [proactiveNotificationId] 的事)——本函数被调用就
* 意味着「这一次确实该弹,且这条消息的号已经定好了」,它只负责把号用在通知上。
* 只做四件事:按 [quiet] 选渠道、建渠道(惰性,第一次真要用时才建)、拼通知、`notify`。**判据、
* 去重与 id 的派生都不在这里**(那是 [ProactiveNotifier] 与 [proactiveNotificationId] 的事)
* ——本函数被调用就意味着「这一次确实该弹,且这条消息的号已经定好了」,它只负责把号用在通知上。
*
* ## 为什么 id 是入参而不是这里恒定一个
* 同 id 的 `notify` 是**替换**:恒用一个号就等于「后来的覆盖先来的」——她连说几句,用户只看到
* 最后一句。入参那个号由 [proactiveNotificationId] 按消息派生(输入就是去重键),于是同一句
* 无论到几次都落在同一条通知上,而不同的句子各占一条。
*
* ## 免打扰只换渠道,别的一个字节都不动
* [quiet] 为真时整条通知除了「发去哪条渠道 / 7.x 的优先级」以外**逐字节不变**:同一个标题、同一
* 段正文、同一个通知号、同一次点击行为。用户要的是「别在我正做事的时候盖住屏幕」,不是「别让我
* 知道」——所以什么都不该少,只是她的话安静地躺在通知栏里。
*
* ## 没授通知权限时什么都不做
* `minSdk 33` ⇒ `POST_NOTIFICATIONS` 恒是运行时权限,而系统在未授权时**静默丢弃**通知。与其发一条
* 必然无声无息的东西,不如在这里就收手(判据复用保活那处 [notificationsAllowed],全 App 只有
@@ -74,11 +185,24 @@ internal const val EXTRA_PROACTIVE_OPEN_MAIN_SESSION = "top.yeij.cyrene.extra.PR
*
* @param notificationId 这条消息的通知 id,同时也是它点击用的 request code(两者必须一起动:
* 只换 id 不换 request code,点后到的那条通知会串到先到那条的 `PendingIntent` 上)。
* @param quiet 「免打扰」开关**当下的值**:`true` ⇒ 发到 [QUIET_PROACTIVE_CHANNEL_ID](有提示音、
* 不弹横幅);`false` ⇒ 发到 [PROACTIVE_CHANNEL_ID](弹横幅)。**刻意不给默认值**:调用方忘写
* 这一位时症状是「开关按了没反应」,那种坏法没有任何报错——让编译器拦住它。
* 值由调用方每次**现读**(见 `AppContainer` 里 `post` 那个 lambda),所以开关一改不必重启 App,
* 下一条通知就走新渠道。
*/
internal fun postProactiveNotification(context: Context, text: String, notificationId: Int) {
internal fun postProactiveNotification(
context: Context,
text: String,
notificationId: Int,
quiet: Boolean,
) {
if (!context.notificationsAllowed()) return
ensureProactiveChannel(context)
// 选渠道与建渠道用的是**同一个** spec:两处各自 `if (quiet)` 就会有一条渠道永远建不出来,
// 而它的症状是「开了免打扰之后她再也不通知我了」(Android 8+ 对不存在的渠道是静默丢弃)。
val channel = proactiveChannelSpec(quiet)
ensureProactiveChannel(context, channel)
// request code 随消息走(`proactiveNotificationId` 的下界远高于保活那条用的 `0`,所以不会
// 撞上它):两个 Intent 都是「启动 MainActivity」、没有 action / data / categories ⇒
@@ -91,7 +215,7 @@ internal fun postProactiveNotification(context: Context, text: String, notificat
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
)
val notification = NotificationCompat.Builder(context, PROACTIVE_CHANNEL_ID)
val notification = NotificationCompat.Builder(context, channel.id)
.setSmallIcon(android.R.drawable.stat_notify_chat)
.setContentTitle(PROACTIVE_NOTIFICATION_TITLE)
.setContentText(text)
@@ -102,7 +226,10 @@ internal fun postProactiveNotification(context: Context, text: String, notificat
// 点掉即消失:这条通知说的是一次刚发生的事,用户点开就等于看到了。
.setAutoCancel(true)
.setCategory(NotificationCompat.CATEGORY_MESSAGE)
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
// 横幅:Android 8+ 由**渠道重要级**决定(上面那条 spec 选出来的那条渠道),这一句管的是
// 7.x 及更早(那边还没有渠道,优先级就是全部)。两处必须同向——免打扰那一档在这里也得
// 降回 DEFAULT,否则老设备上「开着免打扰却照样弹横幅」。
.setPriority(if (quiet) NotificationCompat.PRIORITY_DEFAULT else NotificationCompat.PRIORITY_HIGH)
.build()
// 一事一条:号随消息走。撞号会发生,代价与改前一样是「后来的把先来的替换掉」,
@@ -111,20 +238,40 @@ internal fun postProactiveNotification(context: Context, text: String, notificat
}
/**
* 建(或更新)渠道。惰性创建:一个从没收到过她主动消息的用户,系统设置的渠道列表里不会平白多出
* 一条「她的话」。
* 建(或更新)**这一次要用的那一条**渠道。惰性创建:一个从没收到过她主动消息的用户,系统设置的
* 渠道列表里不会平白多出一条「她的话」。
*
* ## 为什么只建「这一次要用的那一条」,而不是两条都建
* 「免打扰」决定的是**下一条**通知发去哪条渠道,而渠道只需要在 `notify` 之前存在。两条都建的话,
* 一个从没开过免打扰、将来也不打算开的用户,系统设置里会平白多出一条他从没见过的「她主动说的话
* (免打扰:只在通知栏)」——那是我们替他做的选择,也算脏数据。所以这里只 ensure 被选中的那一条。
*
* ⚠️ 但**必须在 `notify` 之前** ensure:Android 8+ 上目标渠道不存在时,那条通知是被系统**静默
* 丢弃**的(不报错、不打日志、只是什么都没有)。这正是「只建一条」这个省法的代价 —— 它靠
* [postProactiveNotification] 里「选渠道」与「建渠道」用的是**同一个** [ProactiveChannelSpec]
* 兜住(两处各自 `if (quiet)` 就会有一条渠道永远建不出来,而症状是「开了免打扰之后她再也不
* 通知我了」——用户只会以为是她不说话了)。
*
* ## 另一条渠道**不删**
* 它是「这一次没选」,不是「以后不再用」:用户来回切开关时,他在系统设置里对另一条调过的横幅 /
* 静音设置得留着(删掉 = 把他的选择一起删掉,还得重新建一条空白的)。唯一会删的是
* [LEGACY_PROACTIVE_CHANNEL_ID] —— 那条 id 本端**再也不会发**,而它的重要级又改不动,留着只会
* 在系统设置里多一条永远不再出现的条目(用户可能在那儿调半天、却发现怎么调都没变化)。
*
* 重复调用是幂等的(同 id ⇒ 更新那条渠道,不会建出第二条)。**注意重要级只在第一次创建时生效**
* ——它已经在 [PROACTIVE_IMPORTANCE] 上定死。
* ——它已经在 [PROACTIVE_IMPORTANCE] / [QUIET_PROACTIVE_IMPORTANCE] 上定死(渠道名与描述此后仍
* 可以改,重要级改不了)。
*
* @param spec 这一次要用的那条渠道(由 [proactiveChannelSpec] 按「免打扰」开关选出来)。
*/
private fun ensureProactiveChannel(context: Context) {
val channel = NotificationChannel(
PROACTIVE_CHANNEL_ID,
PROACTIVE_CHANNEL_NAME,
PROACTIVE_IMPORTANCE,
).apply {
description = "她主动找你说话时的通知"
private fun ensureProactiveChannel(context: Context, spec: ProactiveChannelSpec) {
val channel = NotificationChannel(spec.id, spec.name, spec.importance).apply {
description = spec.description
setShowBadge(true)
}
context.getSystemService(NotificationManager::class.java).createNotificationChannel(channel)
val manager = context.getSystemService(NotificationManager::class.java)
manager.createNotificationChannel(channel)
// 旧的 DEFAULT 渠道删掉:重要级改不动,留着只会在系统设置里多一条永远不再出现的条目
// (用户可能在那儿调半天、却发现怎么调都没变化)。删一条不存在的渠道是幂等的。
manager.deleteNotificationChannel(LEGACY_PROACTIVE_CHANNEL_ID)
}
@@ -8,8 +8,14 @@ import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
private val Context.appDataStore: DataStore<Preferences> by preferencesDataStore("cyrene_app")
@@ -72,6 +78,23 @@ fun String?.toInterruptMode(): InterruptMode = when (this) {
else -> InterruptMode.AppendQueue
}
/**
* 「免打扰」([AppPrefs.proactiveQuiet])没有落盘值时的取值 —— **`false` = 都弹**(横幅 + 提示音
* + 通知栏条目)。老安装、或从没碰过这个开关的用户走的都是它。
*
* 两条路必须落在**同一个**值上:[AppPrefs] 那个数据类的默认参数(进程刚起来、还没读到盘时用),
* 与 [toProactiveQuiet](盘上没有这个键时用)。分叉的症状是「开关显示关着、行为却是安静的」
* ——一个默认就安静的 App 会让人以为通知坏了。
*/
internal const val PROACTIVE_QUIET_DEFAULT = false
/**
* 落盘布尔 → 免打扰开关(`AppSettings` 的 `proactive_quiet` 键读取口;抽成顶层函数是为了能单测
* ——`AppSettings` 本体要 Android + DataStore,单测里起不来)。缺省 / 盘上没有这个键
* ⇒ [PROACTIVE_QUIET_DEFAULT]。
*/
internal fun Boolean?.toProactiveQuiet(): Boolean = this ?: PROACTIVE_QUIET_DEFAULT
/**
* App 级偏好(非机密)。
*
@@ -81,6 +104,10 @@ fun String?.toInterruptMode(): InterruptMode = when (this) {
* @param interruptMode 她正忙时按发送的行为(默认追加队列)
* @param keepAliveEnabled 后台保持连接(常驻一条前台服务,**默认关**;判据见
* `core.keepalive.shouldKeepAliveRun`)
* @param proactiveQuiet 免打扰:她主动找你说话**只在通知栏提示,不在屏幕顶部弹横幅**(**默认
* `false` = 都弹**)。⚠️ 它的语义**不是静音**:提示音与通知栏条目照旧,只是不盖住用户正在做的
* 事。落地形状是**换一条渠道**(见 `core.notify.proactiveChannelId`)——横幅由渠道重要级决定,
* 而重要级建完就改不了,所以只能两条渠道切着用
*/
data class AppPrefs(
val themeMode: ThemeMode = ThemeMode.System,
@@ -88,6 +115,7 @@ data class AppPrefs(
val thinkingExpanded: Boolean = true,
val interruptMode: InterruptMode = InterruptMode.AppendQueue,
val keepAliveEnabled: Boolean = false,
val proactiveQuiet: Boolean = false,
)
/** 全局注入 App 偏好,供聊天渲染等深层组件读取。 */
@@ -97,6 +125,13 @@ class AppSettings(context: Context) {
private val store: DataStore<Preferences> = context.applicationContext.appDataStore
/**
* 与 `AppSettings` 同寿的收集 scope —— 也就是**与进程同寿**(`AppContainer` 在
* `Application.onCreate` 里建它)。刻意**不取消**:[current] 的下一个读者可以是任何时刻的
* 任何一次通知落地,没有比进程结束更晚的时刻需要它。
*/
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
val data: Flow<AppPrefs> = store.data.map { prefs ->
AppPrefs(
themeMode = prefs[KEY_THEME_MODE].toThemeMode(),
@@ -104,9 +139,31 @@ class AppSettings(context: Context) {
thinkingExpanded = prefs[KEY_THINKING_EXPANDED] ?: true,
interruptMode = prefs[KEY_INTERRUPT_MODE].toInterruptMode(),
keepAliveEnabled = prefs[KEY_KEEP_ALIVE] ?: false,
proactiveQuiet = prefs[KEY_PROACTIVE_QUIET].toProactiveQuiet(),
)
}
/**
* [data] 的**热副本**:进程里任何地方都读得到「当下是什么偏好」。
*
* ## 为什么需要它
* 发通知那一刻**没有挂起上下文**:帧的落地是一次同步回调(`ProactiveNotifier.post` 不是
* `suspend`,也不该是——它是连接那条已建立的流水线上的最后一跳),而 DataStore 的读取是挂起
* 的。所以只能由这里常驻订阅、把最近一次读数摆在一个同步读得到的地方(`StateFlow.value`)。
*
* ## 为什么是 `Eagerly`
* 它的读者(发通知那条路)随时可能来,而「等有人订阅了再开始读」意味着第一次落地时读到的是
* 初始值。`Eagerly` 让这一次读盘在设置对象构造时就发生 —— 与 UI 那边(`CyreneRoot` /
* `SettingsScreen` 各自 `collectAsState`)读的是同一份上游数据,没有多出一次磁盘 IO 的量级。
*
* ⚠️ 它有一个**毫秒级**的不准确窗口:进程刚起来、第一次读盘还没回来时,[StateFlow.value] 是
* [AppPrefs] 的默认值(免打扰 = false ⇒ 弹横幅)。要撞上它,得在「本对象构造完 → DataStore
* 第一次读完」之间就有一条她的主动消息走完建连、认证、收帧(`ProactiveFrameIntake`)——
* 实际上到不了那么快。**代价封顶是「开了免打扰的用户在冷启动瞬间多看到一次横幅」**,
* 而不是丢通知。
*/
val current: StateFlow<AppPrefs> = data.stateIn(scope, SharingStarted.Eagerly, AppPrefs())
suspend fun setThemeMode(mode: ThemeMode) {
store.edit { it[KEY_THEME_MODE] = mode.name }
}
@@ -127,6 +184,15 @@ class AppSettings(context: Context) {
store.edit { it[KEY_KEEP_ALIVE] = enabled }
}
/**
* 写「免打扰」。**写这一处就够了**:发通知那条路每次现读 [current](见它的 KDoc),所以这里
* 一落盘、DataStore 一发出新值,**下一条**通知就走新渠道——不必重启 App,也不必在这里再去
* 通知谁。
*/
suspend fun setProactiveQuiet(enabled: Boolean) {
store.edit { it[KEY_PROACTIVE_QUIET] = enabled }
}
private fun String?.toThemeMode(): ThemeMode = when (this) {
ThemeMode.Light.name -> ThemeMode.Light
ThemeMode.Dark.name -> ThemeMode.Dark
@@ -139,5 +205,6 @@ class AppSettings(context: Context) {
val KEY_THINKING_EXPANDED = booleanPreferencesKey("thinking_expanded")
val KEY_INTERRUPT_MODE = stringPreferencesKey("interrupt_mode")
val KEY_KEEP_ALIVE = booleanPreferencesKey("keep_alive_enabled")
val KEY_PROACTIVE_QUIET = booleanPreferencesKey("proactive_quiet")
}
}
@@ -172,7 +172,18 @@ private sealed interface Tok {
/** 先切 `<p>` 块(决定气泡边界),再按区剥落单标签,最后在每个文本区做旁白/星号解析。 */
private fun tokenize(raw: String): List<Tok> {
val pMatches = P_BLOCK_RE.findAll(raw).toList()
// 完整块必然以 `</p>` 收尾,所以最后一个 `</p>` 之后不可能再有块:切掉尾巴再让正则去扫。不切的话,
// 每个 `<p` 候选点都会带着懒惰的 `([\s\S]*?)</p>` 一路回溯到串尾——正文里 N 个落单标签时
// 这里就是 O(N²):实测 20000 个要 8.2 秒(同一输入下 `stripLooseTags` 那边还要 23 秒,见
// [ContentAfterQuery])。切掉后同一输入 1 毫秒内出结果,且匹配结果逐字不变(每个匹配都整体落在
// 被切下的前缀里,正则的尝试顺序也没变)。
val lastClose = raw.lastIndexOf("</p>")
val pMatches =
if (lastClose < 0) {
emptyList()
} else {
P_BLOCK_RE.findAll(raw.substring(0, lastClose + "</p>".length)).toList()
}
val out = ArrayList<Tok>()
if (pMatches.isEmpty()) {
// 一条完整块都没有:`<p>` 及其后内容按既有契约整段回落字面文本,`<action>` 这类旁白标签
@@ -224,6 +235,9 @@ private fun tokenize(raw: String): List<Tok> {
private fun stripLooseTags(text: String, markupMode: Boolean, stripStrayRoleTags: Boolean): String {
if (text.indexOf('<') < 0) return text
val roleRanges = if (stripStrayRoleTags) ROLE_RE.findAll(text).map { it.range }.toList() else emptyList()
// 「这个落单 `<p>` 后面还有没有正文」按位置记表:正文里 N 个落单标签,现扫是 O(N²)(见
// [ContentAfterQuery] 的 KDoc)。常规消息(没有落单 `<p>` 开标签)一次都不建表。
val contentAfter = ContentAfterQuery(text)
val out = StringBuilder(text.length)
var last = 0
var i = 0
@@ -234,7 +248,7 @@ private fun stripLooseTags(text: String, markupMode: Boolean, stripStrayRoleTags
if (gt < 0) break
val tag = text.substring(lt, gt + 1)
val drop = when {
OPEN_P_TAG_RE.matches(tag) -> markupMode || !hasContentAfter(text, gt + 1)
OPEN_P_TAG_RE.matches(tag) -> markupMode || !contentAfter.hasContentAfter(gt + 1)
CLOSE_P_TAG_RE.matches(tag) -> true
!stripStrayRoleTags -> false
roleRanges.any { lt in it } -> false
@@ -253,27 +267,78 @@ private fun stripLooseTags(text: String, markupMode: Boolean, stripStrayRoleTags
return out.toString()
}
/** [from] 之后是否还有正文:跳过空白与后续的 `<p>` 族标签后仍非空即为有。 */
private fun hasContentAfter(text: String, from: Int): Boolean {
var i = from
while (i < text.length) {
if (text[i].isWhitespace()) {
i++
continue
/**
* [stripLooseTags] 判落单 `<p>` 用的「[from] 之后还有没有正文」查询:空白跳过,`<p>` / `</p>`
* 整段跳过,其余字符(别的标签、落单的 `<`)一律算有正文——跳过空白与后续 `<p>` 族标签后仍非空
* 即为有。判据与原实现逐字一致,只多了记忆化。
*
* **为什么要记忆化**:这条判据原本每次查询都一路现扫到串尾,而 [stripLooseTags] 对**每一个**
* 落单 `<p>` 都要问一次,正文里 N 个落单标签就是 O(N²)——20000 个落单 `<p>` 的输入实测这条判据
* 要 23 秒(同一次 `parse` 合计 31 秒),而 `parse` 跑在主线程上(`ui/chat/MessageBubble.kt` 的
* `remember`),用户粘一段 HTML 残骸就能把界面卡死十几秒。
*
* **为什么可以只算一次**:答案只取决于**位置**——同一个位置再问一次,走的一定是同一条链、到的
* 一定是同一个终点。所以走链时把沿途每个位置的答案落表,之后 O(1) 命中;每个位置至多被算一次,
* N 个落单标签整体就是线性的(同一输入实测这条判据从 23 秒降到毫秒级)。
*
* **为什么不改变语义**:判定用的就是原来那段扫描本身([next] 逐分支对应原循环),没有另立一套
* 「什么算 `<p>` 标签」的判据——仍是同一组 [OPEN_P_TAG_RE] / [CLOSE_P_TAG_RE]。落表的答案是同一条
* 扫描推出来的,因此与逐次现扫逐字相同;连「起点落在标签内部」这种查询也一致(原扫描看到的是
* 标签里的字符、判有正文,[next] 在这里同样返回 null)。
*
* 惰性:没遇到落单 `<p>` 开标签的常规消息一次也不建表,一个字节都不多花。
*/
private class ContentAfterQuery(private val text: String) {
/** 每个位置的答案:`0` = 还没算、[NOISE_ONLY] = 后面只剩空白与 `<p>` 族标签、[HAS_CONTENT] = 有正文。 */
private val answers: ByteArray by lazy(LazyThreadSafetyMode.NONE) {
ByteArray(text.length + 1).also { it[text.length] = NOISE_ONLY }
}
/** 本次走链途经的位置,用来把 [answers] 一次落表。 */
private val path: IntArray by lazy(LazyThreadSafetyMode.NONE) { IntArray(text.length) }
/** 与原先「每次现扫到串尾」的判定逐字等价。 */
fun hasContentAfter(from: Int): Boolean {
var walked = 0
var i = from
while (true) {
val known = answers[i]
if (known != UNKNOWN) return finish(walked, known)
path[walked++] = i
val step = next(i)
if (step == null) return finish(walked, HAS_CONTENT)
i = step
}
if (text[i] == '<') {
}
/** 把这次走链途经的位置一次落表:它们与起点走的是同一条链,答案必然相同。 */
private fun finish(walked: Int, answer: Byte): Boolean {
for (k in 0 until walked) answers[path[k]] = answer
return answer == HAS_CONTENT
}
/**
* 原扫描的一步:空白 → 下一位;`<p>` 族标签 → 标签之后;其余(别的标签、落单的 `<`)→ `null`,
* 即「有正文」,扫描到此为止。走到 `text.length` 时由 [answers] 里预置的答案收口。
*/
private fun next(i: Int): Int? {
val c = text[i]
if (c.isWhitespace()) return i + 1
if (c == '<') {
val gt = text.indexOf('>', i)
if (gt >= 0) {
val tag = text.substring(i, gt + 1)
if (OPEN_P_TAG_RE.matches(tag) || CLOSE_P_TAG_RE.matches(tag)) {
i = gt + 1
continue
}
if (OPEN_P_TAG_RE.matches(tag) || CLOSE_P_TAG_RE.matches(tag)) return gt + 1
}
}
return true
return null
}
private companion object {
const val UNKNOWN: Byte = 0
const val NOISE_ONLY: Byte = 1
const val HAS_CONTENT: Byte = 2
}
return false
}
/** 在文本区内按左到右顺序切出 Text / Action / Narration。 */
@@ -12,9 +12,27 @@ package top.yeij.cyrene.domain.share
*
* 还有第三件要说的:[contentUnreadable]——对方给了东西、本端**连地址都没认下来**。它连丢件计数
* 都留不下(本端一条都没接收),所以单独一格;否则「分享点下去什么都不发生」正是它造成的。
*
* 正文那一格也多带一件要看的东西:[textSource]——这一段是从 `EXTRA_TEXT` 来的,还是从
* `EXTRA_SUBJECT` / `EXTRA_TITLE` **回落**来的。有些发送方根本不填 `EXTRA_TEXT`,本端为此把取正文
* 改成一条回落链([ShareRules.extract]);而回落来的正文与真正文长得一模一样,用户只能靠这一格分辨。
*/
data class SharedContent(
/**
* 这次分享的正文(两侧空白已去掉;全空白算没有正文,见 [ShareRules.extract])。
*
* 它可能是从 `EXTRA_SUBJECT` / `EXTRA_TITLE` **回落**来的(有些发送方根本不填 `EXTRA_TEXT`),
* 来源记在 [textSource] 里:回落来的那一段与真正文长得一模一样,用户只能靠那一格分辨。
*/
val text: String = "",
/**
* 正文是从哪个 extra 拿到的(默认 [ShareTextSource.Text] = 正文本来就是正文)。
*
* ⚠️ 它**不参与发送的内容**:这条消息发出去的正文只有 [text] 本身。它的唯一去处是**本端渲染层**
* 的那一句如实小字(见 `shareTextOriginNotice`)——回落来的正文必须能让用户察觉,否则「预览里只有
* 一句话」会被读成「对方发来的就是这句正文」。
*/
val textSource: ShareTextSource = ShareTextSource.Text,
val images: List<String> = emptyList(),
/**
* 非图片的分享文件。MIME 以 `image/` 开头者走 [images],其余一律走这一条
@@ -31,13 +49,14 @@ data class SharedContent(
/** 已接收但**拷贝失败**的文件条数。 */
val failedDocuments: Int = 0,
/**
* 这次分享**带着内容迹象**(`EXTRA_STREAM` 有值、或 `ClipData` 有条目),却一条可用内容都没
* 提取出来。
* 这次分享**带着内容迹象**(三个文本 extra 里躺着值、或 `EXTRA_STREAM` 有值、或 `ClipData`
* 有条目),却一条可用内容都没提取出来。
*
* 与 [failedImages] / [failedDocuments] **不是**同一件事:那两个说的是「本端已经认下来的地址
* 读不到」(对方给的东西本端拿不动),这里说的是「本端连地址都没认下来」(多为发送方把
* `EXTRA_STREAM` 塞成了本端认不出的形态,或用的是本端不读的 scheme)。它是**唯一**一种既没有
* 内容、也没有丢件计数的失败形态,所以要单独记一格,否则结局就是「用户点了分享、界面毫无反应」。
* 读不到」(对方给的东西本端拿不动),这里说的是「本端连可用的内容都没认下来」(多为发送方把
* `EXTRA_STREAM` 塞成了本端认不出的形态、用本端不读的 scheme,或把正文放在本端认不出的地方)。
* 它是**唯一**一种既没有内容、也没有丢件计数的失败形态,所以要单独记一格,否则结局就是
* 「用户点了分享、界面毫无反应」。
*/
val contentUnreadable: Boolean = false,
) {
@@ -55,13 +74,43 @@ data class SharedContent(
*
* 判据只此一处:`ShareIntake.accept` 用它决定要不要往下接,`shareNoticeFor` 用它守卫「什么都没有
* 的分享一个字都不说」那条边界——两处必须同一条,否则会出现「接了却不说」或「说了却没接」。
* 整条分享里什么都没有(`EXTRA_TEXT` 没有值、`EXTRA_STREAM` 也没有值、`ClipData` 也没有条目)
* 时它是 `false`:那次分享确实无话可说,静默才是对的。
* 整条分享里什么都没有(三个文本 extra 都没有值、`EXTRA_STREAM` 也没有值、`ClipData` 也没有条目)
* 时它是 `false`:那次分享确实无话可说,静默才是对的。而「有值、只是本端一段都读不出来」(含
* 空白与类型不符)**不**属于「什么都没有」:它由调用方以 `textPresent` 如实报进来,正是
* [contentUnreadable] 那一格要接的。
*/
val worthDelivering: Boolean
get() = !isEmpty || droppedImages > 0 || droppedDocuments > 0 || contentUnreadable
}
/**
* 这次分享的正文**是从哪个 extra 拿到的**。
*
* ## 为什么要把来源留下来
* 真实世界里有些发送方不填 `EXTRA_TEXT`:内容在 `EXTRA_SUBJECT`(少数在 `EXTRA_TITLE`)里。本端
* 此前只读 `EXTRA_TEXT`,那条分享因此**整条静默**——用户点了分享,界面毫无反应、也没有一句解释
* (正是本项目最忌讳的那种失败)。所以取正文改成一条**回落链**([ShareRules.extract])。
*
* 但「这段字其实只是对方给的标题」必须能让用户分辨:预览里只有一句话时,他会以为对方发来的就是这句
* 正文。于是一并记下**来源**,由渲染层如实标一句(`shareTextOriginNotice`)——那句话只出现在界面上,
* **绝不**进正文(来源标记不许拼进用户正文是项目红线,见 [ShareRules] 的「为什么正文一个字都不加」)。
*
* ## 为什么分成三档而不是一个布尔
* 三档是**如实登记**对方把内容放在哪个字段里:排查时「这条是从 SUBJECT 还是 TITLE 来的」是要看得见
* 的事实,而这两种形态在真实发送方里的出现频率差得很远(SUBJECT 常见、TITLE 罕见)。但两者对用户
* 说的是同一句话——标题与主题在用户眼里本来就是同一件事。
*/
enum class ShareTextSource {
/** `EXTRA_TEXT`:规范路径,也是唯一一条「正文本来就是正文」的路。 */
Text,
/** 从 `EXTRA_SUBJECT` 回落的(对方没给 `EXTRA_TEXT`,或给的是一段空白)。 */
Subject,
/** 从 `EXTRA_TITLE` 回落的。 */
Title,
}
/**
* 一份分享进来的**非图片**文件。
*
@@ -120,10 +169,19 @@ fun classifySharedStream(mime: String?): SharedStreamKind {
* ⚠️ **字符串形态只认 `content://`**(判据见 [stringStreamUri]):那是本端**新开**的一条读入口,
* 松不得。被拒的条目**逐条跳过**——一次分享里有几条坏的,不该把整次分享废掉。
*
* ## 正文的回落链(本模块补上的另一条兼容读取)
* 真实世界里有发送方**不填** `EXTRA_TEXT`:内容只在 `EXTRA_SUBJECT`(少数在 `EXTRA_TITLE`)里。
* 本端此前只读 `EXTRA_TEXT`,那样一次分享因此**整条静默**——用户点了分享,界面毫无反应、也没有一句
* 解释。所以取正文改成 `EXTRA_TEXT` → `EXTRA_SUBJECT` → `EXTRA_TITLE` 的**回落链**([extract]),
* 来源记在 [SharedContent.textSource] 里,由渲染层如实标一句(见 [ShareTextSource])。
*
* ## 为什么正文一个字都不加
* **绝不**往用户正文里拼「[用户分享]」之类的来源标记:来源标识走后端的独立 `RoleSystem`
* 来源消息 + 结构化 `SenderName`(`02/F4` / `design/11 §10`),人类可见的标记只能放在本端
* 渲染层。把标记拼进正文 = 让用户正文冒充系统指令,是项目红线。分享进来的正文**原样**发出。
*
* 回落来的正文(从 `EXTRA_SUBJECT` / `EXTRA_TITLE` 取到的那一段)同样**原样**发出:来源提示是界面
* 上的一句小字,**不是**正文的一部分——所以面板里那段预览与真正发出去的那条消息逐字相同。
*/
object ShareRules {
@@ -173,6 +231,21 @@ object ShareRules {
/**
* 从分享 Intent 的原始字段提取内容。
*
* ## 正文的回落链:`EXTRA_TEXT` → `EXTRA_SUBJECT` → `EXTRA_TITLE`
* 取**第一个非空白**的那一段当正文(口径与原先只读 `text` 时**完全一致**:两侧去空白、全空白算
* 没有);来源记在 [SharedContent.textSource] 里,好让渲染层如实说一句(见 [ShareTextSource])。
*
* 为什么要有这条链:真实世界里有些发送方**不填** `EXTRA_TEXT`,内容只在 `EXTRA_SUBJECT`
* (少数在 `EXTRA_TITLE`)里。此前只读 `EXTRA_TEXT` ⇒ 那样一次分享**整条静默**——用户点了分享,
* 界面毫无反应,后端零请求,也没有一句解释。前后两种口径的对外差别只有一个:**`EXTRA_TEXT`
* 非空时回落链一定不生效**(它是第一站),所以既有情形的结果一个字都没变。
*
* ## 三个文本 extra 都没换来正文时**不许闭嘴**
* 只要三个里**任意一个躺着原始值**(哪怕是一段空白、哪怕类型读不出来),这一次就不是「用户什么都
* 没分享」,而是「本端什么都没读出来」——[SharedContent.contentUnreadable] 如实告知,绝不静默。
* 「里面躺着值」这件事只有调用方知道(纯函数看不到原始值),所以由 [textPresent] 报进来,与
* [streamPresent] 同一个形态。
*
* ## 优先顺序与去重(一次分享里多种来源并存时)
* 顺序 = `EXTRA_STREAM` 的规范形态([stream] 在前、[streams] 在后)→ 字符串兼容形态(保持原顺序)
* → `ClipData`(只在前两者一条都没认下时兜底)。同一个地址串被两处各给一次时**只留先到的那一条**:
@@ -184,7 +257,10 @@ object ShareRules {
*
* @param action `Intent.action`;只认 [ACTION_SEND] / [ACTION_SEND_MULTIPLE]
* @param text `EXTRA_TEXT`——按 `CharSequence` 收,因为它常常是 `Spanned`(带样式的
* HTML 富文本),读成 `String` 会直接拿到 `null`
* HTML 富文本),读成 `String` 会直接拿到 `null`。**回落链的第一站**
* @param subject `EXTRA_SUBJECT`——第二站。发送方「对象 / 标题」那个字段,正是有些 App 拿来放
* 正文的地方(`EXTRA_TEXT` 一个值都没有)
* @param title `EXTRA_TITLE`——第三站(少见,但真实发送方里确实有)
* @param stream `EXTRA_STREAM` 的单值形态([ACTION_SEND] 那个只放一条的字段)
* @param streams `EXTRA_STREAM` 的列表形态([ACTION_SEND_MULTIPLE] 的字段)
* @param streamStrings `EXTRA_STREAM` 的**字符串兼容形态**(`String` / `String[]` /
@@ -195,6 +271,10 @@ object ShareRules {
* @param streamPresent `EXTRA_STREAM` 里确实躺着**原始值**——由调用方读了原始值判出来(本函数
* 看不到那个值),含本端**认不出形态**的那种。它只有一个用处:形态认不出时本端既不猜、也不该
* 因此闭嘴,那照样是一次「带着内容迹象却什么都没读进来」。
* @param textPresent 三个文本 extra([text] / [subject] / [title])里**至少一个躺着原始值**——
* 同样由读了原始值的调用方判出来,含**读不出类型**与**只是一段空白**的那两种(`textPresent`
* 与 [streamPresent] 一样是「原料里有」「认下来的内容里没有」,用后者判会把「有值但读不出来」
* 误当成「什么都没分享」)。正文一旦读出来(哪怕是从标题回落的)它就不起作用了。
* @param clipUris `ClipData` 各条目的 URI,纯文本条目是 `null`(本端**不读** `ClipData` 里的
* 文本,所以「只有纯文本条目」等同于「读不进来」,用来判 [SharedContent.contentUnreadable])
* @param mimeOf 取一条 URI 的 MIME(Android 侧就是 `ContentResolver.getType`)。本函数是
@@ -204,17 +284,30 @@ object ShareRules {
fun extract(
action: String?,
text: CharSequence? = null,
subject: CharSequence? = null,
title: CharSequence? = null,
stream: String? = null,
streams: List<String>? = null,
streamStrings: List<String> = emptyList(),
streamPresent: Boolean = false,
textPresent: Boolean = false,
clipUris: List<String?> = emptyList(),
mimeOf: (String) -> String? = { null },
): SharedContent {
val recognized = action == ACTION_SEND || action == ACTION_SEND_MULTIPLE
if (!recognized) return SharedContent()
val body = text?.toString().orEmpty().trim()
// 回落链(见 KDoc):三段一起按同一个口径归一(`toString` → 两侧 trim → 非空才算有),
// 取先到的那个**非空白**值。`EXTRA_TEXT` 非空时它一定赢 ⇒ 既有情形逐字不变。
val picked = listOf(
ShareTextSource.Text to text,
ShareTextSource.Subject to subject,
ShareTextSource.Title to title,
).firstNotNullOfOrNull { (source, value) ->
value?.toString()?.trim()?.takeIf(String::isNotEmpty)?.let { source to it }
}
val body = picked?.second.orEmpty()
val textSource = picked?.first ?: ShareTextSource.Text
// 两个字段一并收:单值 / 列表是历史形态(当年靠 getParcelableExtra 与
// getParcelableArrayListExtra 两个读法分开取,才不得不按 action 二选一),
@@ -239,9 +332,9 @@ object ShareRules {
}
}
// 「带着内容迹象」的判据取的是**原料**(EXTRA_STREAM 里躺着的原始值 / ClipData 的条目数),
// 不是「认下来的地址」——「有值但一条都认不出」正是要说的那种情形,用后者判反倒会把它当成
// 「什么都没分享」而闭嘴。
// 「带着内容迹象」的判据取的是**原料**(三个文本 extra 里躺着的原始值 / EXTRA_STREAM 里躺着的
// 原始值 / ClipData 的条目数),不是「认下来的内容」——「有值但一条都认不出」正是要说的那种
// 情形,用后者判反倒会把它当成「什么都没分享」而闭嘴。
val hadStreamValue = streamPresent ||
!stream.isNullOrBlank() ||
streams.orEmpty().any(String::isNotBlank) ||
@@ -249,6 +342,7 @@ object ShareRules {
return SharedContent(
text = body,
textSource = textSource,
images = images.take(MAX_IMAGES),
// 提取阶段还不知道显示名与大小(要 ContentResolver),先放原样 URI;
// ShareIntake 拷进 cache 后再换成 file:// + 显示名 + 真实字节数。
@@ -256,7 +350,7 @@ object ShareRules {
truncatedImages = (images.size - MAX_IMAGES).coerceAtLeast(0),
truncatedDocuments = (documents.size - MAX_DOCUMENTS).coerceAtLeast(0),
contentUnreadable = unique.isEmpty() && body.isEmpty() &&
(hadStreamValue || clipUris.isNotEmpty()),
(hadStreamValue || textPresent || clipUris.isNotEmpty()),
)
}
@@ -1594,12 +1594,19 @@ class ChatViewModel(
*
* 新认领的那一份描述从空开始([ShareConfirmDraft.caption] 的初值):它是用户要附加的话、**不是**
* 这次分享进来的正文——预填一次就会在面板里把同一句话排两遍(W53X 的第一版正是这么错的)。
*
* ## 认领那一刻还要如实说一句「正文是从标题回落来的」
* 回落来的正文与真正文长得一模一样(见 [shareTextOriginNotice]):面板里那段字看着就是对方发来的
* 正文本体,而它其实只是对方给的标题。这件事必须在用户点「发送」**之前**说——所以它挂在**认领**
* 这一步上(面板正要长出来的那一刻),而不是挂在发送路径的告知里。通道是 [showShareNotice]
* 那条柔和告知(不是错误横幅):没有出错,只是这一次拿到的东西与用户以为的不一样。
*/
fun claimSharedContent() {
val holding = _state.value.shareConfirm
val claimed = shareConfirmClaim(holding) { ShareIntake.take() }
if (claimed != null && claimed !== holding) {
_state.update { it.copy(shareConfirm = claimed) }
shareTextOriginNotice(claimed.share)?.let(::showShareNotice)
}
}
@@ -16,6 +16,7 @@ import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.runtime.Composable
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
@@ -23,12 +24,18 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.TextLayoutResult
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import coil3.compose.AsyncImage
import top.yeij.cyrene.domain.chat.DocumentChips
import top.yeij.cyrene.domain.chat.DraftDocument
import top.yeij.cyrene.domain.parse.ContentPart
import top.yeij.cyrene.domain.parse.ParsedBubble
import top.yeij.cyrene.domain.parse.parse
import top.yeij.cyrene.domain.share.SharedContent
import top.yeij.cyrene.domain.share.SharedDocument
import top.yukonga.miuix.kmp.basic.Text
@@ -253,6 +260,17 @@ const val SHARE_CONFIRM_CAPTION_PLACEHOLDER = "加一句描述…"
* 输入框与按钮原地不动。上限按状态取([sharePreviewMaxHeight]):收起态
* [SHARE_PREVIEW_MAX_HEIGHT]、用户展开后 [SHARE_PREVIEW_EXPANDED_MAX_HEIGHT]。
*
* ## 正文走**与气泡同一套解析**(W65),所以这一窗里排的是「气泡块」而不是一段原样的字
* 正文不是原样铺进去的:它先过 [top.yeij.cyrene.domain.parse.parse]——与 `MessageBubble` 里那条
* 完全同一个函数、同一个口径([sharePreviewBlocks] 只是给结果加了一道兜底)。于是在这里看到的就是
* 发出去之后气泡里的样子:每个 `<p>…</p>`(无标签时按空行分段)是**一个独立的块**,
* `<action>` / `<narration>`(以及星号那几种写法)不当正文、而是**灰斜体小字**的旁白块,标签本身
* 一个字符都不显示。
*
* 这一层修掉的是 W61 审查发现的一处「所见非所发」:预览此前把正文 `trim` 之后原样显示、**不过解析
* 器**,而发送后的气泡过解析器——分享一段 `"<p><p>正文</p>"` 进来,**预览里是一串标签、气泡里只剩
* 「正文」**。用户按发送之前看到的不是他将要发出的东西。
*
* ## 正文给三行,但三行之外是「滚」不是「省略号」
* 预览只是「分享进来的是什么」的一眼,而用户要的是按一下发送:把一段几万字的正文整个铺开,这个面板
* 就成了一个阅读器。所以正文那一窗**恒为三行高**([SHARE_PREVIEW_TEXT_LINES]),面板不随正文长短
@@ -265,10 +283,10 @@ const val SHARE_CONFIRM_CAPTION_PLACEHOLDER = "加一句描述…"
*
* ## 三行读不下时,再给一次「展开」
* 三行窗 + 内部滚动管的是「读得到」,但一次只看得见三行;主人要的是**也能一次多看几行**
* (W56 拍板:滚动与展开**两个都要**)。所以正文排下来超过三行时(判据是
* [sharePreviewTextOverflowsWindow],正好三行不给)卡片右上一枚「展开」把这一窗放到
* [SHARE_PREVIEW_EXPANDED_MAX_HEIGHT],再点「收起」回到三行。没超过三行时这一枚**不出现**——
* 短正文上摆一枚点了没反应的「展开」比没有更糟。
* (W56 拍板:滚动与展开**两个都要**)。所以这一窗**装不下**里面那几块时(判据是
* [sharePreviewTextOverflowsWindow]:量出来的内容自然高度比窗口高,正好放满不给)卡片右上一枚
* 「展开」把这一窗放到 [SHARE_PREVIEW_EXPANDED_MAX_HEIGHT],再点「收起」回到三行。装得下时这一枚
* **不出现**——短正文上摆一枚点了没反应的「展开」比没有更糟。
*
* 「读不到尾」这条红线在两种状态下都守:收起态窗内滚,展开态由外层这一层滚(正文那一层不再设上限,
* 见 [ShareConfirmBody] 的「展开态为什么干脆不留这一层上限」)。
@@ -316,8 +334,8 @@ private fun ShareConfirmPreviews(preview: ShareConfirmPreview) {
DocumentChipRow(chips = preview.documents.map { DocumentChips.of(it.toDraftDocument()) })
}
if (preview.hasText) {
// 「超没超过三行」只有排完才知道(折行位置由字号与系统的大字体缩放决定),所以它由正文
// 本体量出来之后回调上来,标签这一行凭它决定要不要给出展开控件。
// 「装不装得下」只有排完才知道(折行位置由字号与系统的大字体缩放决定,块数与旁白行数更是
// 解析出来的),所以它由正文那一窗量出来之后回调上来,标签这一行凭它决定要不要给出控件。
var bodyOverflows by remember(preview.text) { mutableStateOf(false) }
Column(
modifier = Modifier
@@ -385,16 +403,54 @@ private fun ShareConfirmExpandToggle(expanded: Boolean, onToggle: () -> Unit) {
)
}
/** 相邻两个气泡块之间的间距(见 [ShareConfirmBody])。块与块靠这一层间距分开,不靠气泡底色。 */
private val SHARE_PREVIEW_BLOCK_GAP = 6.dp
/** 一个块里旁白行与正文行之间的间距(气泡里那两行也挨得很近,与 `AnnotationLine` 的 2dp 同源)。 */
private val SHARE_PREVIEW_LINE_GAP = 2.dp
/**
* 正文那一窗:**高度恒为三行,再长就在这一窗里滚**(W54D 主人拍板,见 [ShareConfirmPreviews]);
* 用户点了「展开」之后这一窗撤掉自己的上限,整段正文交给外层预览区那一层滚(W56)。
* 正文那一窗:**高度恒为三行、再长就在这一窗里滚**(W54D 主人拍板,见 [ShareConfirmPreviews]);
* 用户点了「展开」之后这一窗撤掉自己的上限,全部内容交给外层预览区那一层滚(W56)。
*
* 里面排的**不是一段原样的字**,而是 [sharePreviewBlocks] 给出的那几个「气泡块」(W65 起走与
* `MessageBubble` 同一个 [parse])。每个块自己排旁白行与正文行,块与块之间留
* [SHARE_PREVIEW_BLOCK_GAP]。
*
* ## 为什么量行高,而不是写一个 dp 常量
* 这个上限必须**正好三行**:矮了会在字身上切一刀(滚动停在一个被切开的字形上),高了就等于把「三行」
* 这个上限悄悄放宽。而三行有多高没法提前写出来——Miuix 的 `footnote1` 只给了 13sp 的字号,
* `lineHeight` 是 `Unspecified`,行高由系统字体的 ascent/descent 决定、随「大字体」设置一起缩放。
* 一个 dp 常量在默认字号档上勉强对得上,到了大字号档就会矮一大截(行数不足三行、末行被切)。
* 所以这里改为**量**:从 `TextLayoutResult` 上取第三行的底边当上限。
* 所以这里改为**量**:任一排版结果都能给出「一行多高」(`getLineBottom(0) - getLineTop(0)`),
* 窗口高度 = 它 × [SHARE_PREVIEW_TEXT_LINES]([sharePreviewWindowHeight])。
*
* **为什么不再取「第三行的底边」**(那是 W65 之前的量法):那时这一窗里只有一个 `Text`,
* `getLineBottom(2)` 就是那一个文本的三行底。现在里面是**多个块**,每块还有自己的旁白行与间距——
* 在任何一个块上取「第三行」都不再是这个窗口的第三行。行高则是唯一一个跨块也成立、且与块数无关的
* 量:窗口恒为「三行字高」,块与旁白占掉多少就从这三行里吃掉多少(这正是它的语义)。
*
* ## 「装不下」是**量**出来的,不是**数**出来的(W65 换掉的判据)
* W65 之前这里数的是**一个 `Text` 的行数**(`lineCount > 3`)。多块之后行数不再是一个数得出来的量:
* 旁白行与块间距都占高度,却不属于任何一块的「行数」;字号缩放与折行位置也全落在行数之外。按行数算,
* 两块各三行加起来是 6、窗口只装得下 3 行——这条能对,但「一块三行 + 一条旁白 + 一层间距」这种更常见
* 的形状就数不准了,而这恰是解析之后最常见的形状。
*
* 所以现在比的是**高度**:内容排下来的自然高度 vs 这一窗的高度([sharePreviewTextOverflowsWindow])。
* 两个量都是排完才知道的:
* - 窗口高由上面的行高乘三给出;
* - 自然高由 [androidx.compose.ui.layout.onSizeChanged] 读到——它挂在 `verticalScroll` **里面**,
* 而 `verticalScroll` 给子项的是无界高度,所以读到的是内容的**自然高度**(不受这一窗上限影响的那
* 一个,收起态与展开态读到的是同一个数)。
*
* 「自然高度 > 窗口高度」就是「窗里还有东西没露出来」,正是那枚「展开」承诺的事;旁白、块间距、折行、
* 系统大字体缩放全被算在同一把尺子上——它可自证的地方在于:两边都是这一帧真实排出来的像素,没有一处
* 是我们估的行数或猜的 dp。
*
* ⚠️ 这个比较在**组合期**算完、由 [SideEffect] 报上去,**不**在排版回调里算:两个量都来自排版回调,
* 而在回调里读到的是**上一帧**组合里的值(`contentHeight` 与 `cap` 都不在本帧的组合里),在那里直接比
* 会永远差一帧——首帧报一次「不超窗」之后就再也不会报,长正文上那枚「展开」永远不出现。组合期读到的
* 一定是最新的一帧,代价只是一次幂等的重复上报([SideEffect] 每次组合都跑,值没变就是空写)。
*
* ## ⚠️ 这一层绝不能拿到 [Dp.Infinity](W58 修的崩溃)
* `heightIn(max = Dp.Infinity)` 等于**不施加约束**,而 `verticalScroll` 一旦在**无界高度**下被测量,
@@ -408,9 +464,9 @@ private fun ShareConfirmExpandToggle(expanded: Boolean, onToggle: () -> Unit) {
* - **展开态**整层不设上限,但**外层**预览区此时换成 [SHARE_PREVIEW_EXPANDED_MAX_HEIGHT],那仍是一层
* 有限的约束在管事(不是「没有约束」)。
*
* 上限只写一次(`cap` 还是 `null` 时才改),所以不会来回跳:拿到上限后重新排版,正文的宽度
* 与样式都没变,行位置与首帧一致,量出来的值也一样。展开 / 收起切换**也不会改写它**——`cap` 记的
* 恒是收起态那一窗的高度(这一窗的折行只由宽度决定,两种状态下排出来的行位置一样)。
* 上限只写一次(`cap` 还是 `null` 时才改),所以不会来回跳:拿到上限后重新排版,块的内容、宽度
* 与样式都没变,行高与首帧一致,量出来的值也一样。展开 / 收起切换**也不会改写它**——`cap` 记的
* 恒是收起态那一窗的高度(这一窗的折行只由宽度决定,两种状态下排出来的行高一样)。
*
* ## 展开态为什么干脆不留这一层上限
* 展开之后正文可能几屏长。若在这一窗上再设一个上限,就成了「窗外滚 + 窗内滚」两层都要滚的手势
@@ -421,13 +477,17 @@ private fun ShareConfirmExpandToggle(expanded: Boolean, onToggle: () -> Unit) {
*
* ## 首帧的那次回流:留着,且确认它看不见
* 上限是**首帧之后**才量到的,所以长正文会先按 fallback([SHARE_PREVIEW_MAX_HEIGHT])排一次,
* 下一帧才收到三行。真机上**看不见**跳动,两条理由:
* 下一帧才收到三行。真机上**看不见**跳动,三条理由:
* 1. 首帧那一下撑不动任何别的东西——正文块是预览区里的**最后一个**元素(图片、文件都在它上面),
* 而预览区自己还有一层独立的上限在管可视高度:下面没有东西被它顶下去,上面那两样的位置一分
* 不动,可视的像素与量完之后**一样**(那些像素由 `heightIn` 的裁剪决定,裁剪高度不变)。
* 2. 展开态本来就没有这一层上限,不存在「量完再回落」;而展开 / 收起是用户主动的一次布局变化,
* 那一帧的尺寸变化是它的**目的**,不是回流。真正多出来的是展开控件本身——它要到第二帧才出现
* (行数是排完才知道的),但这一枚也顶不动东西:标签行里那两句话用的是同一个 `footnote2`,行高不变。
* (自然高度是排完才知道的),但这一枚也顶不动东西:标签行里那两句话用的是同一个 `footnote2`,
* 行高不变。
* 3. 首帧那一次比出来的是「自然高度 vs 150dp 兜底」而不是「vs 三行」:那一帧的结论**可能是错的**
* (一段四五行的正文在 150dp 下看着「装得下」),但它在下一帧就被纠正,而这一枚控件本来就要到
* 第二帧才可能摆上——所以这条错判永远看不见。
*
* 另一条路是在组合期用 `rememberTextMeasurer` 把上限量出来,好让控件从首帧就摆上:那要先知道宽度
* (得外包一层 `BoxWithConstraints`),还要自己复现字号、字体缩放与字体回退,等于把排版做两遍——
@@ -451,8 +511,11 @@ private fun ShareConfirmBody(
onOverflowChange: (Boolean) -> Unit,
) {
val density = LocalDensity.current
val blocks = remember(text) { sharePreviewBlocks(text) }
// `null` = 还没量到。**不要**拿 [Dp.Infinity] 当这个哨兵值(见 KDoc 里那次崩溃)。
var cap by remember(text) { mutableStateOf<Dp?>(null) }
// 内容排下来的**自然高度**(见 KDoc:量在 `verticalScroll` 里面,拿到的是不受上限影响的那一个)。
var contentHeight by remember(text) { mutableStateOf(0) }
// 无条件 `remember`,避免在分支里建状态;滚动位置由这一窗自己的生命周期管。
val scroll = rememberScrollState()
@@ -465,22 +528,83 @@ private fun ShareConfirmBody(
.verticalScroll(scroll)
}
Column(modifier = Modifier.fillMaxWidth().then(window)) {
Text(
text = text,
style = MiuixTheme.textStyles.footnote1,
onTextLayout = { layout ->
if (cap == null && layout.lineCount > 0) {
// 正文不到三行时上限就取全文的高度(`heightIn` 是个上限,短了本来也不生效)。
val lastVisible = layout.lineCount.coerceAtMost(SHARE_PREVIEW_TEXT_LINES) - 1
cap = with(density) { layout.getLineBottom(lastVisible).toDp() }
}
// 行数与上限无关(这一窗没有 `maxLines`、收起态的高度不受限,排一次行数就定了),所以
// 这一句同时管着「收不起时要不要给出展开控件」与「展开态下要不要留着它」。
onOverflowChange(sharePreviewTextOverflowsWindow(layout.lineCount))
},
)
// 一行有多高:正文行与旁白行同为 `footnote1`,所以第一个排完的文本就能给(不限是哪一块的哪一行)。
// 量到一次就定死——`cap` 只在这一个分支里被写、且只从 `null` 变成一个值,不会来回跳。
val measureLine: (TextLayoutResult) -> Unit = { layout ->
if (cap == null && layout.lineCount > 0) {
val line = layout.getLineBottom(0) - layout.getLineTop(0)
// 0 或负数(还没排稳)不记:150dp 的兜底比一扇零高的窗好(同 [shareConfirmWindowCap] 的取向)。
if (line > 0) cap = sharePreviewWindowHeight(with(density) { line.toDp() })
}
}
Column(
modifier = Modifier
.fillMaxWidth()
.then(window)
.onSizeChanged { contentHeight = it.height },
verticalArrangement = Arrangement.spacedBy(SHARE_PREVIEW_BLOCK_GAP),
) {
blocks.forEach { block -> ShareConfirmBlock(block, measureLine) }
}
// 见 KDoc:在组合期算完、由 SideEffect 报上去(两个量在排版回调里读到的是上一帧的值)。
val overflows = sharePreviewTextOverflowsWindow(
contentHeightPx = contentHeight,
windowHeightPx = with(density) { shareConfirmWindowCap(cap).roundToPx() },
)
SideEffect { onOverflowChange(overflows) }
}
/**
* 预览里的**一个气泡块**:上依附的旁白 + 正文 + 下依附的旁白。
*
* 这个顺序就是 `MessageBubble` 里那个 `Bubble` 的顺序(`head` → 正文 → `tail`),用的也是同一个
* [parse] 给出的同一批块——「预览里长这样」与「发出去之后长那样」因此是**同一套判据**的两次渲染。
*
* 块与块之间只留一层间距(外层 [ShareConfirmBody] 的 `spacedBy`),**不画气泡底色**:这一栏是面板里
* 的一张卡片(`surfaceContainer` 底),而气泡底色在聊天里表示「谁说的」——用户那条是 `primary` 色、
* 她那侧是灰。在确认面板里复刻一层假气泡底色会让人以为这里已经是聊天记录;而且那层内边距要从三行窗
* 里再吃掉近一行,正是 W54D 之后最不该动的那点可见正文。
*/
@Composable
private fun ShareConfirmBlock(block: ParsedBubble, onTextLayout: (TextLayoutResult) -> Unit) {
Column(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(SHARE_PREVIEW_LINE_GAP),
) {
ShareConfirmAnnotation(block.head, onTextLayout)
if (block.body.isNotEmpty()) {
Text(
text = block.body,
style = MiuixTheme.textStyles.footnote1,
onTextLayout = onTextLayout,
)
}
ShareConfirmAnnotation(block.tail, onTextLayout)
}
}
/**
* 预览里的旁白行:**灰斜体小字**——与气泡里那行(`MessageBubble` 的 `AnnotationLine`)同一个形态、
* 同一种拼法(多个片段用一个空格连起来),字全是 [parse] 给的那几个 `ContentPart` 的原文。
*
* ⚠️ 这里**不**读 `LocalAppPrefs.showNarration`(气泡那行会读)。那个偏好管的是**聊天记录里**要不要
* 显示旁白,而这块面板回答的是「她将收到的是什么」:按那个偏好把旁白藏起来,就等于让用户在按下发送
* 之前看不到这次要发出去的一部分内容——而 W61 审查发现的那处「预览里是标签、气泡里只剩正文」,错的
* 正是同一件事(看到的不是要发的)。要改成与气泡一样随时可以,但那是主人拍板的事,不在这里顺手做。
*/
@Composable
private fun ShareConfirmAnnotation(parts: List<ContentPart>, onTextLayout: (TextLayoutResult) -> Unit) {
val text = parts.joinToString(" ") { it.text }.trim()
if (text.isEmpty()) return
Text(
text = text,
style = MiuixTheme.textStyles.footnote1,
fontStyle = FontStyle.Italic,
color = MiuixTheme.colorScheme.onSurfaceVariantSummary,
onTextLayout = onTextLayout,
)
}
/**
@@ -499,8 +623,8 @@ private fun ShareConfirmBody(
* 所以非有限的那几个取值([Dp.Infinity] 与 [Dp.Unspecified],后者是 `NaN`)**与「还没量到」同等对待**,
* 一律回落 [SHARE_PREVIEW_MAX_HEIGHT]:这条 KDoc 写的是一条**判据**,而不该只是对调用方的请求——本函数
* 是这一层唯一的出口,它自己要能保证「给出去的上限总是有限」。今天唯一的调用方传的是 `null` 或
* `getLineBottom(...).toDp()`(后者恒有限),所以这一道守卫不改变任何现状;它挡的是**下一次**有人
* 顺手把 `Dp.Infinity` 当「不设上限」传进来(那正是 W58 第一次崩溃的形状)。
* [sharePreviewWindowHeight] 的结果(正数 × 三,恒有限),所以这一道守卫不改变任何现状;它挡的是
* **下一次**有人顺手把 `Dp.Infinity` 当「不设上限」传进来(那正是 W58 第一次崩溃的形状)。
*/
internal fun shareConfirmWindowCap(measured: Dp?): Dp =
measured?.takeIf { it.value.isFinite() } ?: SHARE_PREVIEW_MAX_HEIGHT
@@ -529,8 +653,7 @@ internal val SHARE_PREVIEW_EXPANDED_MAX_HEIGHT = 240.dp
/**
* 预览里正文那一窗给几行(W54D 起**不再截断**,超出的部分在窗内滚动,见 [ShareConfirmBody])。
*
* 它同时是「要不要给出展开控件」的那条线([sharePreviewTextOverflowsWindow]):正好这么多行就没有
* 东西可展开。
* 它同时是窗口高度那笔账里唯一的数:窗口高 = **量到的行高** × 这个数([sharePreviewWindowHeight])。
*
* `internal` 是为了让 `ShareConfirmTest` 能把这个数钉成字面量:面板的滚动行为单测够不着(本模块没有
* Compose UI 测试的依赖),能钉住的只有「这一窗是三行」这条判据本身。
@@ -547,18 +670,67 @@ internal const val SHARE_PREVIEW_EXPAND_LABEL = "展开"
internal const val SHARE_PREVIEW_COLLAPSE_LABEL = "收起"
/**
* 正文**排下来**超过三行窗口了吗(**纯函数**,可单测)——也就是「要不要给出展开控件」。
* 三行窗口有多高(**纯函数**,可单测):量到的一行高度 × [SHARE_PREVIEW_TEXT_LINES]。
*
* 判据是**行数**,不是 `hasVisualOverflow`:正文这一窗的裁剪落在它外面那层 `Column` 的
* `heightIn(max = cap)` 上,而这一窗自己的排版宽度是满宽、高度不受限(`verticalScroll` 给子项的
* 就是无上限约束),所以 `Text` 自己**永远**报「没有溢出」——拿它当判据会在最长的那种正文上漏掉
* 控件。行数则与上限无关:只要排下来比窗口多一行,第四行就一定在窗口之外,展开就有东西可看。
*
* 边界是**严格大于**:正好三行时整段都在窗里(`getLineBottom(2)` 就是这一窗的底),此时给一枚
* 「展开」是点了没有反应的假控件。
* 行高只能是**量**出来的(见 [ShareConfirmBody] 的「为什么量行高,而不是写一个 dp 常量」),所以这里
* 只做乘法、不掺任何 dp 常量:大字体档下量到的行高更大,窗口就跟着更高,「三行」始终是三行。把这个数
* 留在唯一一处,改它只改 [SHARE_PREVIEW_TEXT_LINES]。
*/
internal fun sharePreviewTextOverflowsWindow(lineCount: Int): Boolean =
lineCount > SHARE_PREVIEW_TEXT_LINES
internal fun sharePreviewWindowHeight(lineHeight: Dp): Dp =
lineHeight * SHARE_PREVIEW_TEXT_LINES
/**
* 预览正文区该渲染成哪几块(**纯函数**,可单测)——**走的就是气泡那一套解析**(W65 起)。
*
* 用的是 `MessageBubble` 里那同**一个** [parse],连兜底都是同一条:解析结果为空(或全是被丢掉的空块)
* 时回落到「整段原样显示」的那一块,与那边 `parsed.ifEmpty { listOf(plainBubble(content)) }` 逐字同义。
* 所以「预览里看不到那一串标签」不是本函数自己剥出来的,而是 [parse] 的既有语义(落单标签一律剥掉、
* 完整块切气泡、旁白另成一类)——这里一个字符都不改写、也不另写一套「去标签」。
*
* **兜底不是可选项**:`parse` 对某些输入会回空列表(纯空白是既有的一档,落单标签被剥光是另一档,
* 见 `ShareTextPipelineTest` 里的 `<p><p>` 那几条),而面板的 [ShareConfirmPreview.hasText] 只管
* 「正文非空白」,管不到解析结果。少了这条兜底,用户就会在一块**空白**前面按发送——而正文这一栏是他按
* 发送之前唯一能核对的东西。
*
* 正文在这里**不再 trim**:两侧空白早在 [shareConfirmPreview] 那一站去过一次(`share.text.trim()`,
* 与 `sendShared` 开头同一个口径),这里再来一次只会多一个说法不一的出处。
*/
internal fun sharePreviewBlocks(text: String): List<ParsedBubble> =
parse(text).filterNot { it.isEmpty }.ifEmpty {
listOf(ParsedBubble(body = text, parts = emptyList(), head = emptyList(), tail = emptyList()))
}
/**
* 这一窗**装不下**里面的内容吗(**纯函数**,可单测)——也就是「要不要给出展开控件」。
*
* ## 判据是**高度**,不是行数(W65 换掉的)
* W65 之前这里数的是**一个 `Text` 的行数**(`lineCount > SHARE_PREVIEW_TEXT_LINES`)。正文改走解析
* 之后这一窗里是**多个块**,每块还各自带旁白行与块间距,于是行数不再是一个覆盖得住窗口的量:
*
* - 旁白行与块间距都占高度,却谁也不属于哪一块的「行数」;
* - 折行位置、字号与系统大字体缩放都落在行数之外:「两个块各占两行、中间一层间距」这种最常见的形状,
* 按行数只能说「4 > 3」,说不出它到底差多少,也说不出一块内部的折行吃掉了第二块的位置;
* - 反过来也漏:「一块两行 + 一条两行的旁白」数出来是 4 行,但它真正超出的高度取决于那两层间距与
* 旁白自己的行高——行数这个量从头到尾没有把它们算进去。
*
* 所以比的是**两边真实排出来的像素**:内容自然高度(由挂在 `verticalScroll` 里面的
* [androidx.compose.ui.layout.onSizeChanged] 读到,见 [ShareConfirmBody])vs 这一窗的高度
* ([sharePreviewWindowHeight] 那个口径:量到的行高 × 三)。「自然高度 > 窗口高度」**就是**「窗里还有
* 东西没露出来」,正是那枚「展开」承诺的事。它可自证的地方在于:两个量都是这一帧真实排出来的像素,
* 没有任何一处是我们估的行数或猜的 dp。
*
* 也不是 `hasVisualOverflow`:裁剪落在这一窗外面那层 `Column` 的 `heightIn(max = cap)` 上,而每个
* `Text` 自己的排版宽度是满宽、高度不受限(`verticalScroll` 给子项的就是无上限约束),所以它们永远报
* 「没有溢出」——拿它当判据会把控件在最长的那种正文上漏掉。
*
* ## 边界与首帧
* 严格大于:**正好一样高**就是整段都在窗里(窗口底下一条不多),此时给一枚「展开」是点了没有反应的
* 假控件。
*
* 任何一边还没量到(`0`)都回 `false`:首帧摆上一枚点了没有反应的「展开」比晚一帧出现更糟。
*/
internal fun sharePreviewTextOverflowsWindow(contentHeightPx: Int, windowHeightPx: Int): Boolean =
contentHeightPx > 0 && windowHeightPx > 0 && contentHeightPx > windowHeightPx
/**
* 预览区当前该给多高(**纯函数**,可单测):收起态 [SHARE_PREVIEW_MAX_HEIGHT]、展开态
@@ -0,0 +1,43 @@
package top.yeij.cyrene.ui.chat
import top.yeij.cyrene.domain.share.ShareTextSource
import top.yeij.cyrene.domain.share.SharedContent
/**
* 「这段正文是从对方给的**分享标题**回落来的」——面板上那句如实的小字(**纯函数**,可单测)。
*
* ## 它补的是哪一处
* 取正文的回落链([top.yeij.cyrene.domain.share.ShareRules.extract]:`EXTRA_TEXT` →
* `EXTRA_SUBJECT` → `EXTRA_TITLE`)让「对方只给了标题」的那次分享**不再静默**——用户可以确认、可以
* 取消。但回落来的那一段与真正文**长得一模一样**:预览里只有一句话时,用户会以为对方发来的就是这句
* 正文,其实本端只拿到了标题。所以这里给出一句**只出现在界面上**的说明。
*
* ## 红线:它绝不进正文
* 这句话是**本端渲染层**的信息,一个字都不许拼进将要发送的那段正文(`shareConfirmBody` 拼的是分享
* 正文 + 用户描述,与这里无关):把来源标记拼进正文 = 让用户正文冒充系统指令,是项目红线
* (见 `ShareRules` 的「为什么正文一个字都不加」)。所以这条消息的内容**永远只是正文本身**,
* 预览里那段字与发出去的那条消息逐字相同。
*
* ## 为什么 `Subject` 与 `Title` 共用一句
* 两档在**数据**上分得很清([ShareTextSource],排查时要看得见对方把内容塞进了哪个字段),但对用户说
* 的是同一件事:标题与主题在他眼里没有区别。各写一句只会多出一处迟早会走样的措辞。
*
* ## 不说「正文」的那一档为什么回 `null`
* [ShareTextSource.Text] 是规范路径(正文本来就是正文):一切照旧,**一个字都不说**——正常落地时
* 说话才是噪声(与 `shareNoticeFor` 的静默边界同一条纪律)。
*
* @return 该说的那句话;没有要说的(正文来自 `EXTRA_TEXT`)回 `null`。
*/
fun shareTextOriginNotice(share: SharedContent): String? = when (share.textSource) {
ShareTextSource.Text -> null
ShareTextSource.Subject, ShareTextSource.Title -> SHARE_TEXT_FROM_TITLE_NOTICE
}
/**
* 那句话说出口的样子。
*
* 两件事要在同一句里说清:**它是什么**(对方给的分享标题)与**缺了什么**(对方没有提供正文)——
* 只说前一半,用户仍会以为这句话就是对方发来的正文;只说后一半,他不知道预览里那行字是从哪冒出来的。
* 刻意**不说**「失败」「错误」:这一次分享的内容一个字节都没丢,只是它本来就只有标题。
*/
const val SHARE_TEXT_FROM_TITLE_NOTICE = "这段文字来自对方的分享标题(对方没有提供正文)"
@@ -114,6 +114,27 @@ fun SettingsScreen(
)
}
/*
* 「免打扰」单独占一段,不塞进上面那个「聊天」里:它管的是**通知**(屏幕上会不会被
* 盖住),与「聊天页怎么渲染」是两件事。下一段「连接」也是同一个道理(那边问的是连接
* 会不会断)。
*
* 写法与「后台保持连接」那一行逐项对齐(`KeepAliveRow.kt`):`SwitchPreference` 外面
* 必须包一层 `PreferenceCard`(Miuix 的 Preference 系列自己没有背景与圆角),副文案
* 走一个纯函数(好单测,见 `proactiveQuietSummary`),改开关只写偏好、**不在这里做
* 任何通知相关的事**——发通知时自己会现读(见 `AppSettings.current`)。
*/
SectionTitle("通知")
PreferenceCard {
SwitchPreference(
title = "免打扰",
summary = proactiveQuietSummary(prefs.proactiveQuiet),
checked = prefs.proactiveQuiet,
onCheckedChange = { scope.launch { settings.setProactiveQuiet(it) } },
)
}
Spacer(Modifier.height(8.dp))
SectionTitle("她忙时发消息")
InterruptMode.entries.forEach { mode ->
PreferenceCard {
@@ -261,3 +282,19 @@ private fun InterruptMode.summary(): String = when (this) {
InterruptMode.Parallel ->
"她正忙时不打断当前这一句,另起一路同时处理这条;两路各算一遍,会多花一份算力与 token"
}
/**
* 「免打扰」开关下面那行字(**纯函数**,测试钉住)。它要说清的只有一件事:
* **这不是静音** —— 关掉的是横幅,不是消息、也不是提示音。
*
* 为什么非说不可:「免打扰」四个字在系统里到处都是,用户对它的第一印象常是「什么都不响」。而这个
* 开关实际做的是「她的话照旧响一声、躺在通知栏里,只是不再盖住我正在做的事」——两句话指向的期待
* 差得很远,一句不说就等于让用户自己猜(猜错的代价是他以为通知坏了,或以为自己买到了完全静音)。
*
* 两档都说出**当下会怎么弹**:用户据以决定的就是「现在这样够不够吵」,而只写「开着 = 安静」
* 等于让他自己去比较两种安静。
*/
internal fun proactiveQuietSummary(enabled: Boolean): String = when {
enabled -> "已开:她主动找你说话只在通知栏提示(有提示音),不在屏幕顶部弹横幅"
else -> "关(默认):她主动找你说话会在屏幕顶部弹横幅,同时有提示音与通知栏条目"
}
@@ -0,0 +1,143 @@
package top.yeij.cyrene.app
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.share.ShareRules
import top.yeij.cyrene.domain.share.ShareTextSource
/**
* 三个文本 extra(`EXTRA_TEXT` / `EXTRA_SUBJECT` / `EXTRA_TITLE`)**原始值**的读取
* ([shareTextExtrasOf])。
*
* ## 为什么值得单测
* 这一层只有两件事要做,而两件都容易错:
*
* 1. **按形态读**(`as? CharSequence`):真实分享里这几段常是 `Spanned`(富文本),按 `String` 读会
* 直接拿到 `null` —— 那正是「分享进来什么都没发生」的来处之一。而「读不出类型」(值是个数字、
* 别种 `Parcelable`……)时本端**不猜**:那一站读不到就是读不到,交给下一站。
* 2. **「里面躺着值」要如实报上去**([ShareTextExtras.present]):值与正文是两件事。空白值、读不出
* 类型的值都算「对方给了这个字段」,于是三个 extra 一起落空时本端会走「读不进来」那条如实告知,
* 而不是静默(判据见 `ShareRules.extract` 的 `textPresent`)。
*
* ⚠️ 这里**不**覆盖 `Bundle.getCharSequence` 到底抛不抛异常:那要真安卓运行时(JVM 单测里 android.jar
* 的桩方法一调就抛 `Stub!`)。结论与出处写在 `ShareIntake.kt` 的 `rawExtra` / [ShareTextExtras] 的
* KDoc 里(核的是 AOSP 的 `android/os/BaseBundle.java`:类型不符它自己 catch 掉回 `null`,会抛的是
* `unparcel()` 的 `BadParcelableException`,那一路由 `runCatching` 挡住)。
*/
class ShareTextExtrasTest {
@Test
fun `a plain string text is read through`() {
val extras = shareTextExtrasOf(text = "正文", subject = null, title = null)
assertEquals("正文", extras.text)
assertNull(extras.subject)
assertNull(extras.title)
assertTrue(extras.present)
}
@Test
fun `a spanned like char sequence is read through`() {
// 分享链路里的 `EXTRA_TEXT` 常常是 `Spanned`(带样式的 HTML 富文本):它**不是** String,
// 按 String 读会直接拿到 null。(JVM 单测造不出 `android.text.Spanned`,用另一个
// `CharSequence` 实现把这条读法钉住。)
val raw: Any = StringBuilder("来自网页的一段富文本")
val extras = shareTextExtrasOf(text = raw, subject = null, title = null)
assertEquals("来自网页的一段富文本", extras.text)
assertTrue(extras.present)
}
@Test
fun `a value we cannot read as a char sequence yields no body but is still a value`() {
// 形态读不出来(这里是数字,真实世界是别种 Parcelable):本端不猜它是什么意思 —— 但也不许
// 因此把整次分享当成「什么都没发生」,所以它照样算「里面躺着值」。
val extras = shareTextExtrasOf(text = 42, subject = null, title = null)
assertNull(extras.text)
assertTrue(extras.present)
}
@Test
fun `a blank value is still a value`() {
// 空白也算「对方给了这个字段」:`EXTRA_STREAM` 那边早就是这个口径(原始值非 null 即
// `streamPresent`),两个 extra 用同一条判据才不会出现「流的空值说话、文本的空值闭嘴」。
val extras = shareTextExtrasOf(text = " ", subject = "", title = null)
assertEquals(" ", extras.text)
assertEquals("", extras.subject)
assertTrue(extras.present)
}
@Test
fun `an absent trio yields nothing and is not a value`() {
// 「连值都没有」才是真的没有:这条边界撑着「用户确实什么都没分享 ⇒ 一个字都不说」。
val extras = shareTextExtrasOf(text = null, subject = null, title = null)
assertNull(extras.text)
assertNull(extras.subject)
assertNull(extras.title)
assertFalse(extras.present)
}
@Test
fun `a subject only pair of raw values lands as the body`() {
// 端到端那一步:读出来的三段交给纯函数之后,只有 SUBJECT 的那次分享真的变成了正文,
// 并且来源如实记着。
val extras = shareTextExtrasOf(text = null, subject = "对方只给了标题", title = null)
val share = ShareRules.extract(
action = ShareRules.ACTION_SEND,
text = extras.text,
subject = extras.subject,
title = extras.title,
textPresent = extras.present,
)
assertEquals("对方只给了标题", share.text)
assertEquals(ShareTextSource.Subject, share.textSource)
assertTrue(share.worthDelivering)
assertFalse(share.contentUnreadable)
}
@Test
fun `raw values that are all unusable speak instead of vanishing`() {
// 三个 extra 里躺着值、而一段正文都没读出来:内容照旧为「空」,但**必须**有话可说 ——
// 这是本次要修的另一种静默(此前只有 `EXTRA_TEXT` 那一路,后两个 extra 落空时全静默)。
val extras = shareTextExtrasOf(text = 42, subject = " ", title = null)
val share = ShareRules.extract(
action = ShareRules.ACTION_SEND,
text = extras.text,
subject = extras.subject,
title = extras.title,
textPresent = extras.present,
)
assertTrue(share.isEmpty)
assertTrue(share.contentUnreadable)
assertTrue(share.worthDelivering)
}
@Test
fun `nothing at all in the bundle is not a value`() {
// 与上一条成对:`ShareIntake` 拿到的原始值全是 null(误触分享、Intent 里什么都没有)时,
// 本端一个字都不说 —— 边界不许因为这批改动而放宽。
val extras = shareTextExtrasOf(text = null, subject = null, title = null)
val share = ShareRules.extract(
action = ShareRules.ACTION_SEND,
text = extras.text,
subject = extras.subject,
title = extras.title,
textPresent = extras.present,
)
assertFalse(share.worthDelivering)
assertFalse(share.contentUnreadable)
}
}
@@ -136,8 +136,78 @@ class ProactiveNotificationShapeTest {
"两条渠道各自表达一件事,混在一条里就只能二选一",
PROACTIVE_CHANNEL_ID != top.yeij.cyrene.core.keepalive.KEEP_ALIVE_CHANNEL_ID,
)
// 恒定不变:改 id = 换渠道 = 用户对旧渠道做过的静音设置全部作废。
assertEquals("proactive", PROACTIVE_CHANNEL_ID)
// 🔴 这里是 `proactive_v2` 而**不是** `proactive`:渠道重要级一旦创建就改不了(只有用户能改),
// 2026-10-07 把它从 DEFAULT 提到 HIGH 对**已建过旧渠道**的设备不生效 ⇒ 只能换一个新 id
// 让系统按新重要级重新建。这条断言就是「以后谁要改重要级,必须同时换 id」的机器可读版本。
assertEquals("proactive_v2", PROACTIVE_CHANNEL_ID)
assertEquals("proactive", LEGACY_PROACTIVE_CHANNEL_ID)
assertTrue(
"新 id 必须不同于旧 id —— 相同就说明有人改了重要级却没换 id(对老设备无效)",
PROACTIVE_CHANNEL_ID != LEGACY_PROACTIVE_CHANNEL_ID,
)
}
/**
* 「免打扰」那条渠道:它必须是**另一条**,而且是**安静的那一条**。
*
* 这几件事各有各的坏法,且都很难在真机上发现:
* - 与 `PROACTIVE_CHANNEL_ID` 同 id ⇒ 开关两档发到同一条渠道,「开着免打扰照样弹横幅」;
* - 重要级不低于 HIGH ⇒ 它照样弹横幅,用户按了开关却什么都没变(而**不会有任何报错**);
* - 撞上旧 id ⇒ 建新渠道时会被顺手删掉(那条通知随后就被系统静默丢弃)。
*/
@Test
fun `the do-not-disturb channel is a second, quieter channel`() {
assertEquals("proactive_quiet", QUIET_PROACTIVE_CHANNEL_ID)
assertEquals(
"免打扰那一档只许是 DEFAULT(有提示音、不弹横幅):HIGH 会照样弹横幅",
android.app.NotificationManager.IMPORTANCE_DEFAULT,
QUIET_PROACTIVE_IMPORTANCE,
)
assertTrue(
"两条渠道必须真的不同 —— 同 id 就等于「开关两档没有区别」",
QUIET_PROACTIVE_CHANNEL_ID != PROACTIVE_CHANNEL_ID,
)
assertTrue(
"它也不许撞上旧 id(否则建新渠道那一步会把自己删掉)",
QUIET_PROACTIVE_CHANNEL_ID != LEGACY_PROACTIVE_CHANNEL_ID,
)
assertTrue(
"更不许撞上保活那条(否则用户对保活的静音设置会被她的话一起改掉)",
QUIET_PROACTIVE_CHANNEL_ID != top.yeij.cyrene.core.keepalive.KEEP_ALIVE_CHANNEL_ID,
)
}
/**
* 开关 → 渠道 id 的**纯函数**(两档)。这是「免打扰」唯一的落地形状:横幅由渠道重要级决定,
* 而重要级建完就改不了 ⇒ 只能按开关选一条渠道发。
*
* 同时钉住 `ProactiveChannelSpec` 的**成对性**:id / 渠道名 / 重要级必须一起跟着开关走。四处
* 各写一份 `if (quiet)` 时的坏法是「换了 id 却没换重要级」,症状是「按了开关,什么都没变」。
*/
@Test
fun `the switch picks the channel and the whole spec moves with it`() {
assertEquals(PROACTIVE_CHANNEL_ID, proactiveChannelId(quiet = false))
assertEquals(QUIET_PROACTIVE_CHANNEL_ID, proactiveChannelId(quiet = true))
val loud = proactiveChannelSpec(quiet = false)
val quiet = proactiveChannelSpec(quiet = true)
assertEquals(PROACTIVE_CHANNEL_ID, loud.id)
assertEquals(PROACTIVE_CHANNEL_NAME, loud.name)
assertEquals(PROACTIVE_IMPORTANCE, loud.importance)
assertEquals(QUIET_PROACTIVE_CHANNEL_ID, quiet.id)
assertEquals(QUIET_PROACTIVE_CHANNEL_NAME, quiet.name)
assertEquals(QUIET_PROACTIVE_IMPORTANCE, quiet.importance)
// 系统设置的渠道列表里这两行会并排出现,用户就是靠它判断「哪条会弹横幅」——同名等于白分两条。
assertNotEquals(
"两条渠道名必须一眼看得出区别",
PROACTIVE_CHANNEL_NAME,
QUIET_PROACTIVE_CHANNEL_NAME,
)
assertTrue(
"免打扰那条的渠道名要点明它是什么:$QUIET_PROACTIVE_CHANNEL_NAME",
QUIET_PROACTIVE_CHANNEL_NAME.contains("免打扰"),
)
}
@Test
@@ -147,7 +217,13 @@ class ProactiveNotificationShapeTest {
"她要提醒人,重要级必须高于保活那条",
PROACTIVE_IMPORTANCE > top.yeij.cyrene.core.keepalive.KEEP_ALIVE_CHANNEL_IMPORTANCE,
)
assertEquals(android.app.NotificationManager.IMPORTANCE_DEFAULT, PROACTIVE_IMPORTANCE)
// `HIGH`(4) 是**屏幕顶部弹横幅**的门槛:DEFAULT 只会响一声躺在通知栏里。
// 主人 2026-10-07 的原话「我在其他 APP 时没看到横幅」就是 DEFAULT 这一档造成的。
assertEquals(android.app.NotificationManager.IMPORTANCE_HIGH, PROACTIVE_IMPORTANCE)
assertTrue(
"免打扰那一档必须比它低 —— 否则「只关横幅」这件事就没有档位可用了",
QUIET_PROACTIVE_IMPORTANCE < PROACTIVE_IMPORTANCE,
)
}
}
@@ -0,0 +1,49 @@
package top.yeij.cyrene.core.prefs
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Test
/**
* 「免打扰」这个设置项的形状(默认值 + 读写往返)。
*
* `AppSettings` 本体要 Android + DataStore、单测里起不来(与 `InterruptModeTest` 同一条理由),
* 所以钉的是它两侧的**纯**部分。三件事各自对应一种真会发生的坏法:
*
* 1. **默认必须是 `false`(都弹)**:主人 2026-10-07 的话是「关闭时两者都弹,**默认都弹**」——
* 一个默认就安静的 App 会让人以为通知坏了(而这一条恰恰没有任何报错);
* 2. **数据类的默认参数与落盘读取口的缺省值必须是同一个值**:它们表达的是同一个「缺省」,
* 分叉的症状是「开关显示关着、行为却安静」(或反过来),同样没有任何报错;
* 3. **读写往返**:写进去什么就读出什么,两个布尔都走一遍。这一档没有 `Enum` 那种映射层,
* 唯一会坏的方式就是某个分支被写反,而写反的后果正好是「开关与行为相反」。
*/
class AppPrefsQuietTest {
@Test
fun `do not disturb is off by default so both keep alerting`() {
assertFalse(
"默认必须是「都弹」:默认就安静只会让人以为通知坏了",
AppPrefs().proactiveQuiet,
)
assertFalse("缺省常量本身就是默认值,它不能是 true", PROACTIVE_QUIET_DEFAULT)
}
@Test
fun `the data-class default and the read seam agree on one default`() {
// 两条路读的是同一个「缺省」:进程刚起来(数据类默认参数)与盘上没有这个键(读取口)。
assertEquals(
"数据类的默认参数与落盘读取口的缺省值必须同源",
AppPrefs().proactiveQuiet,
(null as Boolean?).toProactiveQuiet(),
)
}
@Test
fun `the switch round-trips from the data class through the read seam`() {
listOf(false, true).forEach { wanted ->
val written = AppPrefs(proactiveQuiet = wanted).proactiveQuiet
assertEquals("写进去什么就得读出什么", wanted, (written as Boolean?).toProactiveQuiet())
}
}
}
@@ -325,6 +325,14 @@ class ChatContentParserTest {
assertEquals(listOf("甲", "乙"), parse("<p>甲</p><p>乙").map { it.body })
}
/** 剥不剥只看它**后面**还有没有内容:后面有正文的留字面,后面什么都没有的才剥。 */
@Test
fun aLooseTagIsKeptWheneverThereIsStillContentAfterIt() {
assertEquals(listOf("<p>甲<p>乙"), parse("<p>甲<p>乙").map { it.body })
assertEquals(listOf("<p>甲"), parse("<p>甲<p>").map { it.body })
assertEquals(listOf("<p>甲"), parse("<p>甲<p><p><p>").map { it.body })
}
@Test
fun strayRoleTagOutsideParagraphIsDropped() {
assertEquals(listOf("甲"), parse("<p>甲</p></action>").map { it.body })
@@ -509,6 +517,42 @@ class ChatContentParserTest {
assertEquals(listOf("新"), parser.onDelta("<p>新</p>").map { it.body })
}
// ------------------------------------------------------------------
// 复杂度回归:一大段落单标签不许把 parse 拖成 O(N²)
//
// ⚠️ 这条测的是**复杂度**,不是精确耗时:阈值宽到 20000 个落单标签给 2 秒,只用来区分
// 「线性」与「平方」。修复前实测:2000 个 854ms、8000 个 4254ms、20000 个 26405ms(每翻
// 一倍涨约 4 倍,典型平方);修复后 20000 个是十几毫秒。所以阈值别卡紧(CI 机器可能更慢),
// 这里量到的数也别当性能指标读——要盯的是「有没有退回 O(N²)」。
//
// 这个形状同时压住两处(修复前各占一部分,都在 `ChatContentParser` 里):
// - 落单标签的「后面还有没有正文」判据(`ContentAfterQuery`,修复前约 23 秒);
// - 完整块搜索 `P_BLOCK_RE.findAll`(没有 `</p>` 时每个 `<p` 候选点都回溯到串尾,约 8.2 秒)。
// ------------------------------------------------------------------
/** 复杂度回归的宽松上限(毫秒):只区分线性与平方,不是耗时指标。 */
private val looseTagBudgetMs = 2_000L
@Test
fun hugeRunOfLooseTagsIsDroppedWithinALinearBudget() {
val count = 20_000
val inputs = listOf(
"<p>".repeat(count), // 一串纯落单开标签
" \n<p> ".repeat(count), // 标签之间夹空白:跳过空白后仍是「后面什么都没有」
)
for (input in inputs) {
val startedAt = System.nanoTime()
val bubbles = parse(input)
val elapsedMs = (System.nanoTime() - startedAt) / 1_000_000
assertTrue("落单标签应当被剥净,实际留下:${bubbles.map { it.body }}", bubbles.isEmpty())
assertTrue(
"${input.length} 字符($count 个落单标签)用了 ${elapsedMs}ms," +
"超出线性预算 ${looseTagBudgetMs}ms——复杂度退回 O(N²) 了?",
elapsedMs < looseTagBudgetMs,
)
}
}
/** 产出的任何片段里都不许出现段落标签本身——这是落单标签容错的底线。 */
private fun assertNoLooseTagInRenderedText(bubbles: List<ParsedBubble>) {
for (bubble in bubbles) {
@@ -0,0 +1,170 @@
package top.yeij.cyrene.domain.share
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* 正文的**回落链**:`EXTRA_TEXT` → `EXTRA_SUBJECT` → `EXTRA_TITLE`([ShareRules.extract])。
*
* ## 这条链修的是哪种静默
* 有些发送方**不填** `EXTRA_TEXT`,内容只在 `EXTRA_SUBJECT`(少数在 `EXTRA_TITLE`)里。本端此前只读
* `EXTRA_TEXT` ⇒ 那样一次分享**整条静默**:用户点了分享,界面毫无反应、后端零请求、也没有一句解释
* (`SharePayload.kt` 里那条 `contentUnreadable` 判据也不含这两个 extra)。修法就是这条链。
*
* ## 本文件钉住的三件事
* 1. **回落链本身**:只有 `SUBJECT` / 只有 `TITLE` ⇒ 用它当正文,且来源如实记进
* `SharedContent.textSource`(渲染层靠它说一句「来自分享标题」);
* 2. **既有情形逐字不变**:`EXTRA_TEXT` 非空时它**一定**赢(它是第一站),结果与今天的实现完全相同
* ——回落链不许「取到更多东西」,它补的是原先那条缝;
* 3. **都不成正文时不许闭嘴**:三个文本 extra 里躺着值(含空白、含读不出类型)而没换来正文 ⇒
* `contentUnreadable`,走既有的「读不进来」告知;而**三个都没有值**(用户确实什么都没分享)照旧
* 静默。两者的分野就是 `textPresent` 这个由调用方(读了原始值的那一层)报进来的事实。
*
* 真实 `Intent` 的读取在 `app/ShareIntake.kt`(`rawExtra` + [top.yeij.cyrene.app.shareTextExtrasOf],
* 那个「值在不在」的判断与 `runCatching` 的守卫各有一份用例,见 `ShareTextExtrasTest`)。
*/
class ShareTextFallbackTest {
/** 一次最普通的 `ACTION_SEND`,只把三个文本 extra 摆出来(其余字段都空)。 */
private fun extract(
text: CharSequence? = null,
subject: CharSequence? = null,
title: CharSequence? = null,
textPresent: Boolean = false,
): SharedContent = ShareRules.extract(
action = ShareRules.ACTION_SEND,
text = text,
subject = subject,
title = title,
textPresent = textPresent,
)
@Test
fun `a subject only share becomes the body and says where it came from`() {
// 这次分享的现场:`EXTRA_TEXT` 一个值都没有,内容全在 `EXTRA_SUBJECT` 里。
val share = extract(subject = "分享自某某App:一篇文章的标题")
assertEquals("分享自某某App:一篇文章的标题", share.text)
assertEquals(ShareTextSource.Subject, share.textSource)
// 拿到的是一段真内容 ⇒ 既不是「空分享」、也不是「读不进来」,与一次普通的文本分享没有区别。
assertFalse(share.isEmpty)
assertFalse(share.contentUnreadable)
assertTrue(share.worthDelivering)
}
@Test
fun `a title only share becomes the body`() {
val share = extract(title = "只有标题的一次分享")
assertEquals("只有标题的一次分享", share.text)
// 来源分档只为「如实登记」,用户那边 Subject 与 Title 说的是同一句话(见 `ShareTextOrigin.kt`)。
assertEquals(ShareTextSource.Title, share.textSource)
assertTrue(share.worthDelivering)
}
@Test
fun `a non blank text always wins the chain`() {
// 既有情形逐字不变:三个都有值时,正文与来源都是今天那一份(回落链对它**完全**不生效)。
val share = extract(text = "对方写的正文", subject = "对方给的标题", title = "对方给的另一个标题")
assertEquals("对方写的正文", share.text)
assertEquals(ShareTextSource.Text, share.textSource)
}
@Test
fun `a blank text falls back to the subject`() {
// 空白串(不是 null)同样是「没有正文」:口径与取 text 时那一次 trim 完判空完全一致。
val share = extract(text = " \n\t ", subject = "标题顶上")
assertEquals("标题顶上", share.text)
assertEquals(ShareTextSource.Subject, share.textSource)
}
@Test
fun `a blank text and a blank subject fall back to the title`() {
val share = extract(text = " ", subject = "\n", title = "第三站顶上")
assertEquals("第三站顶上", share.text)
assertEquals(ShareTextSource.Title, share.textSource)
}
@Test
fun `the body is trimmed the same way whichever station it came from`() {
// 三站的归一化口径必须一模一样(`toString` → 两侧 trim):不然同一段字从 SUBJECT 来就会多带
// 一个换行,而正文那一段是要**逐字**发出去的。
listOf(
ShareTextSource.Text to extract(text = " 正文 "),
ShareTextSource.Subject to extract(subject = " 正文 "),
ShareTextSource.Title to extract(title = " 正文 "),
).forEach { (source, share) ->
assertEquals(source.name, "正文", share.text)
assertEquals(source.name, source, share.textSource)
}
}
@Test
fun `a fallen back body is a normal share in every other respect`() {
// 回落来的正文走的是同一条既有的路:`isEmpty` / `worthDelivering` / 附件上限这些判据一个字都
// 没为它改过 —— 除了「来源」那一格。
val text = extract(text = "正文")
val subject = extract(subject = "正文")
assertTrue(subject.text == text.text)
assertEquals(text.isEmpty, subject.isEmpty)
assertEquals(text.contentUnreadable, subject.contentUnreadable)
assertEquals(text.images, subject.images)
assertEquals(text.documents, subject.documents)
assertEquals(ShareTextSource.Text, text.textSource)
}
@Test
fun `when none of the three yields a body we speak instead of staying silent`() {
// 三者都空(甚至读不出类型)**不是**「什么都没分享」:用户点了分享,本端一个字节都没读出来。
// 那件事必须能说出口(`textPresent` 由读了原始值的调用方如实报进来),否则又是一次静默。
val share = extract(text = null, subject = " ", title = null, textPresent = true)
assertTrue(share.isEmpty)
assertTrue(share.contentUnreadable)
assertTrue(share.worthDelivering)
}
@Test
fun `a share whose three extras hold nothing at all still stays silent`() {
// 边界(既有口径,不许放宽):三个 extra **连值都没有** ⇒ 用户确实什么都没分享,说一句才是噪声。
// 分野就在 `textPresent` 这一个事实上,不在「正文是不是空的」上。
val share = extract()
assertTrue(share.isEmpty)
assertFalse(share.contentUnreadable)
assertFalse(share.worthDelivering)
}
@Test
fun `a fallen back body next to unusable streams is not a whole read failure`() {
// 「读不进来」那条判据只在**一条可用内容都没有**时成立:从标题回落的正文照样是一次拿到了东西
// 的分享,不能因为对方给的那几个地址用不了就说成「这次分享的内容没能读进来」。
val share = ShareRules.extract(
action = ShareRules.ACTION_SEND,
subject = "从标题来的正文",
streamStrings = listOf("file:///etc/hosts"),
textPresent = true,
)
assertEquals("从标题来的正文", share.text)
assertFalse(share.contentUnreadable)
}
@Test
fun `an unrelated intent with a subject in it still extracts to nothing`() {
// 不是分享 action 就不是分享:哪怕三个 extra 里照样躺着值,也不该走这条路(更不该出一条告知)。
val share = ShareRules.extract(
action = "android.intent.action.VIEW",
subject = "标题",
textPresent = true,
)
assertEquals(SharedContent(), share)
}
}
@@ -44,12 +44,26 @@ import top.yeij.cyrene.domain.share.SharedDocument
* `the preview window stays three lines tall`。
*
* ## W56 又加上了什么(主人拍板:「可以滚动 可以展开」,两个都要)
* 三行窗 + 内部滚动管的是「读得到」,但一次只看得见三行;所以正文排下来超过三行时([sharePreviewTextOverflowsWindow])
* 卡片右上一枚「展开」把这一窗放到 [SHARE_PREVIEW_EXPANDED_MAX_HEIGHT],再点「收起」回到三行。
* 三行窗 + 内部滚动管的是「读得到」,但一次只看得见三行;所以这一窗**装不下**里面的内容时
* ([sharePreviewTextOverflowsWindow])卡片右上一枚「展开」把这一窗放到
* [SHARE_PREVIEW_EXPANDED_MAX_HEIGHT],再点「收起」回到三行。
* **展开与滚动本身仍然是渲染层的事、单测够不着**,所以这里钉的同样是它们的两个前提:**什么时候该
* 出现那枚控件**、**展开态给多高**——两条判据都做成了纯函数([sharePreviewTextOverflowsWindow] /
* [sharePreviewMaxHeight]),见 `the expand control shows up only when the body does not fit the
* window` 与 `expanding gives the preview more room, still within a bound`。
* 出现那枚控件**、**展开态给多高**——判据都做成了纯函数([sharePreviewTextOverflowsWindow] /
* [sharePreviewWindowHeight] / [sharePreviewMaxHeight]),见
* `the expand control shows up only when the content does not fit the window`、
* `the window is as tall as three measured lines, whatever the font scale says` 与
* `expanding gives the preview more room, still within a bound`。
*
* ## W65 把「超没超窗」的判据整个换掉了(读这一节才知道为什么是高度)
* W65 之前预览的正文是**一个 `Text`**(原样那一段字),所以「装不装得下」数得出来:`lineCount > 3`。
* W65 起预览也走 `domain/parse` 的解析([sharePreviewBlocks],与气泡同一个 `parse`),这一窗里成了
* **多个块**,每块还各自带旁白行与块间距——行数再也不覆盖窗口的量。判据因此从「数行数」换成
* **比高度**:内容自然高度 vs 窗口高度([sharePreviewTextOverflowsWindow] 的两个参数);窗口高本身也
* 不再取「第三行的底边」,而是「量到的一行 × [SHARE_PREVIEW_TEXT_LINES]」
* ([sharePreviewWindowHeight])——多块之后没有任何一个块的第三行等于这一窗的第三行。
*
* ⚠️ 解析口径本身的变化(预览与气泡由此同源)不在本文件里钉,在 `ShareTextPipelineTest` 里
* (那一份测的正是「从分享进来一路到气泡」这条跨层串)。
*/
class ShareConfirmTest {
@@ -260,29 +274,56 @@ class ShareConfirmTest {
}
// ——— W56:「展开 / 收起」(主人拍板:滚动与展开**两个都要**)———
// 展开与滚动本身在单测里断言不了(本模块没有 Compose UI 测试的依赖),能钉住的是它们的两个前提:
// 那枚控件什么时候出现、展开态给多高。两条判据都在 ShareConfirmPanel.kt 里做成了纯函数。
// 展开与滚动本身在单测里断言不了(本模块没有 Compose UI 测试的依赖),能钉住的是它们的前提:
// 那枚控件什么时候出现、这一窗有多高、展开态给多高。三条判据都在 ShareConfirmPanel.kt 里做成了
// 纯函数(W65 把「什么时候出现」从数行数换成了比高度,理由见那边的 KDoc)。
@Test
fun `the expand control shows up only when the body does not fit the window`() {
// 正好三行 = 整段都在窗里(这一窗的底就是第三行的底边):此时给一枚「展开」就是主人说的
// 「点了没反应的展开」。多一行就是第四行落在窗外,展开确有东西可看。
assertFalse(sharePreviewTextOverflowsWindow(0))
assertFalse(sharePreviewTextOverflowsWindow(1))
assertFalse(sharePreviewTextOverflowsWindow(SHARE_PREVIEW_TEXT_LINES))
assertTrue(sharePreviewTextOverflowsWindow(SHARE_PREVIEW_TEXT_LINES + 1))
assertTrue(sharePreviewTextOverflowsWindow(40))
fun `the expand control shows up only when the content does not fit the window`() {
// 判据的两边都是**排出来的**(W65 起):内容自然高(挂在 `verticalScroll` **里面**量到的那个,
// 不受这一窗上限影响)与窗口高(量到的行高 × 三)。这里把它的全部形状穷举完。
//
// 还没量到的那一帧(0):不给控件——首帧摆上一枚点了没反应的「展开」,比它晚一帧出现更糟。
assertFalse(sharePreviewTextOverflowsWindow(contentHeightPx = 0, windowHeightPx = 0))
assertFalse(sharePreviewTextOverflowsWindow(contentHeightPx = 0, windowHeightPx = 100))
assertFalse(sharePreviewTextOverflowsWindow(contentHeightPx = 100, windowHeightPx = 0))
// 判据跟着那一窗的行数走(上面用的是常量),所以再用主人拍的那个数字面量钉一遍「这条线是
// 3」——两条一起才既钉住行为、又钉住那个数(同 `the preview window stays three lines tall`
// 的口径)。
assertFalse(sharePreviewTextOverflowsWindow(3))
assertTrue(sharePreviewTextOverflowsWindow(4))
// 边界是**严格大于**:一样高就是整段都在窗里(窗底一条不多),此时给一枚「展开」是主人说的
// 「点了没反应的展开」;多一个像素就是窗里还有东西没露出来,展开确有东西可看。
assertFalse(sharePreviewTextOverflowsWindow(contentHeightPx = 100, windowHeightPx = 100))
assertTrue(sharePreviewTextOverflowsWindow(contentHeightPx = 101, windowHeightPx = 100))
assertTrue(sharePreviewTextOverflowsWindow(contentHeightPx = 400, windowHeightPx = 60))
assertFalse(sharePreviewTextOverflowsWindow(contentHeightPx = 59, windowHeightPx = 60))
// 接一份真分享走一遍:正文里每有一个换行,排下来就至少多一行,所以 40 行的那段一定超过窗口
// (排版后的行数只会比 `lines().size` 更多——单行太长还会折行)。
// 单调:内容只增、窗口只减,都只会把它推向「超窗」——两个方向各钉一条,防止将来有人把比较写反。
assertTrue(sharePreviewTextOverflowsWindow(contentHeightPx = 1000, windowHeightPx = 60))
assertFalse(sharePreviewTextOverflowsWindow(contentHeightPx = 59, windowHeightPx = 600))
// 接一份真分享走一遍:40 行的正文在解析后仍然是一块(无标签的正文按既有语义整段成块),而这一块
// 排下来必然高过三行窗。⚠️ 「高多少」是**渲染层排出来的像素**,单测这里拿不到(本模块没有 Compose
// UI 测试依赖),所以这一条只钉到「内容就是那 40 行」为止——旧口径按行数比得出 40 > 3,新口径下
// 那个比较整个属于渲染层,这一份测试能守的就是它的输入。
val preview = shareConfirmPreview(SharedContent(text = (1..40).joinToString("\n") { "第 $it 行" }))
assertTrue(sharePreviewTextOverflowsWindow(preview.text.lines().size))
assertEquals(40, sharePreviewBlocks(preview.text).single().body.lines().size)
}
@Test
fun `the window is as tall as three measured lines, whatever the font scale says`() {
// 窗口高**没有**自己的 dp 常量:它是「量到的一行高度」× SHARE_PREVIEW_TEXT_LINES(理由见
// `ShareConfirmPanel` 里 `sharePreviewWindowHeight` 的 KDoc)。3 这个数只有一处。
assertEquals(3, SHARE_PREVIEW_TEXT_LINES)
assertEquals(60.dp, sharePreviewWindowHeight(20.dp))
assertEquals(20.dp * SHARE_PREVIEW_TEXT_LINES, sharePreviewWindowHeight(20.dp))
// 大字体档:量到的行高更大 ⇒ 窗口跟着更高。这正是「不许写死一个 dp 常量」的算术面——写死的那个
// 数在大字号档上会让这一窗矮到不足三行、末行被切。
assertEquals(120.dp, sharePreviewWindowHeight(40.dp))
assertTrue(sharePreviewWindowHeight(40.dp) > sharePreviewWindowHeight(20.dp))
// 它下一站就是 `heightIn`,所以给出去的东西必须恒有限(那一条的整体不变量见
// `ShareConfirmWindowCapTest`)。
assertTrue(sharePreviewWindowHeight(1.dp).value.isFinite())
assertTrue(shareConfirmWindowCap(sharePreviewWindowHeight(20.dp)).value.isFinite())
}
@Test
@@ -22,6 +22,11 @@ import top.yeij.cyrene.domain.share.SharedDocument
* 3. 边界:整条分享里什么都没有时**仍然静默**(那种情况没什么可说的,说了才是噪声);
* 4. **「读不到」与「内容是空的」不许叠说**:2026-10-05 实机读到的「有 2 个文件读不到,未发送;
* 分享的内容是空的,没有可发送的内容」会让用户以为出了两个问题,而前者本身就蕴含后者。
*
* 末尾还有**另一句告知**的用例:[shareTextOriginNotice](正文是从 `EXTRA_SUBJECT` / `EXTRA_TITLE`
* 回落来的那一句)。它与 [shareNoticeFor] 是同族的「如实告知」,但**时机不同**:那条走的是认领那一刻
* 的柔和通道(面板正要长出来时),因为「这段字只是对方给的标题」必须在用户点「发送」**之前**说;
* 而它同样有一条不许破的边界——**绝不进正文**。
*/
class ShareNoticeTest {
@@ -121,4 +126,60 @@ class ShareNoticeTest {
shareNoticeFor(SharedContent(failedImages = 2, contentUnreadable = true)),
)
}
// —— 正文是从标题回落来的那一句(分享认领那一刻的如实小字,见 `ShareTextOrigin.kt`)——
@Test
fun `a body that came from the share title says so`() {
// 有些发送方把内容放在 `EXTRA_SUBJECT` / `EXTRA_TITLE` 里(`EXTRA_TEXT` 一个值都没有)。
// 回落来的那一段与真正文长得一模一样:不标一句,用户会以为对方发来的就是这句正文。
listOf(
ShareRules.extract(action = ShareRules.ACTION_SEND, subject = "对方只给了标题"),
ShareRules.extract(action = ShareRules.ACTION_SEND, title = "对方只给了标题"),
).forEach { share ->
assertEquals("对方只给了标题", share.text)
assertEquals(SHARE_TEXT_FROM_TITLE_NOTICE, shareTextOriginNotice(share))
}
}
@Test
fun `the origin hint says both what it is and what is missing`() {
// 只说「它是什么」,用户仍会以为这句话就是对方发来的正文;只说「缺了什么」,他不知道预览里那行
// 字是从哪冒出来的。也刻意不说「失败」「错误」:这次分享一个字节都没丢,只是本来就只有标题。
assertTrue(SHARE_TEXT_FROM_TITLE_NOTICE.contains("分享标题"))
assertTrue(SHARE_TEXT_FROM_TITLE_NOTICE.contains("没有提供正文"))
assertFalse(SHARE_TEXT_FROM_TITLE_NOTICE.contains("失败"))
assertFalse(SHARE_TEXT_FROM_TITLE_NOTICE.contains("错误"))
}
@Test
fun `a body that came from the text says nothing`() {
// 规范路径一切照旧:正文本来就是正文 ⇒ 一个字都不说(与 `shareNoticeFor` 的静默边界同一条纪律)。
assertNull(
shareTextOriginNotice(
ShareRules.extract(action = ShareRules.ACTION_SEND, text = "对方写的正文", subject = "标题"),
),
)
assertNull(shareTextOriginNotice(SharedContent(text = "看这个")))
// 只有图片 / 文件的那一次分享也没有「来源」这件事可说。
assertNull(shareTextOriginNotice(SharedContent(images = listOf("file:///cache/share-1.png"))))
assertNull(
shareTextOriginNotice(
SharedContent(documents = listOf(SharedDocument(uri = "file:///cache/share-0.txt"))),
),
)
}
@Test
fun `the origin hint never leaks into the message we send`() {
// 红线:那句说明只出现在**界面**上,一个字都不许拼进将要发送的正文(把来源标记拼进正文 =
// 让用户正文冒充系统指令)。所以预览里那段字与发出去的那条消息逐字相同。
val share = ShareRules.extract(action = ShareRules.ACTION_SEND, subject = "对方只给了标题")
assertEquals("对方只给了标题", shareConfirmPreview(share).text)
assertEquals("对方只给了标题", shareConfirmBody(share, ""))
assertEquals("对方只给了标题${SHARE_CONFIRM_BODY_SEPARATOR}补一句", shareConfirmBody(share, "补一句"))
assertFalse(shareConfirmBody(share, "补一句").contains("分享标题"))
assertFalse(shareConfirmBody(share, "补一句").contains(SHARE_TEXT_FROM_TITLE_NOTICE))
}
}
@@ -7,6 +7,8 @@ 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.ContentPart
import top.yeij.cyrene.domain.parse.ParsedBubble
import top.yeij.cyrene.domain.parse.parse
import top.yeij.cyrene.domain.share.ShareRules
import top.yeij.cyrene.domain.share.SharedContent
@@ -28,9 +30,19 @@ import top.yeij.cyrene.domain.share.SharedContent
* 2. **正文里的标记按既有语义走,且绝不抛异常**:分享进来的那段字里带 `<p>` / `<action>` / 星号时,
* 渲染结果由 `domain/parse` 的既有口径决定(W55 的落单标签剥离、旁白/动作标记、Markdown 强调不吞),
* 这里只钉「不崩、且除标记本身之外一个字不少」。
* 3. **面板预览与气泡渲染是两条路**:预览走的是**原样文本**(不过解析器),气泡走解析器——
* 于是同一段带标记的文本,预览与发出后的气泡长得不一样。这不是崩溃,是本文件要**如实记下**的
* 一处不一致(见 `the preview shows the raw text while the bubble goes through the parser`)。
* 3. **面板预览与气泡走的是同一套解析**(W65 起):预览的正文区渲染的就是 `MessageBubble` 用的那
* 同一个 `parse` 的结果(`sharePreviewBlocks` 只给它加了一条兜底),所以「预览里是一串标签、
* 气泡里只剩正文」这类不一致不会再出现——见下面那几条 W65 的用例。
*
* ## W65 改掉了什么(口径变更,读这份测试时要先知道)
* W61 审查发现:预览把正文 `trim` 之后**原样显示**、不过解析器,而发送后的气泡过解析器。分享一段
* `"<p><p>正文</p>"` 进来,**预览里是一串标签、气泡里只剩「正文」**——用户按发送之前看到的不是他
* 将要发出的东西(这不是崩溃,是当时如实记下的一处不一致)。
*
* W65 把这条口径改掉了:预览按**与气泡同一套解析**渲染(多段 → 多个块,`<action>` / `<narration>`
* → 灰斜体小字,星号规则同源)。所以本文件原本如实记下的那处不一致(旧用例
* `the preview shows the raw text while the bubble goes through the parser`)**已被推翻**,替换成
* 新口径下的断言:**预览与气泡的正文文本一致**,且标签一个字都不显示。
*
* ⚠️ 本文件**不**覆盖渲染层本身(Compose):本模块没有 Compose UI 测试依赖,`Dp.Infinity` 那一类
* 崩溃单测抓不到(那是 W58 的教训)。这里管的是**进渲染层之前**那份数据对不对、会不会抛异常。
@@ -125,17 +137,150 @@ class ShareTextPipelineTest {
assertEquals(1, loose.single().head.size)
}
@Test
fun `the preview shows the raw text while the bubble goes through the parser`() {
// ⚠️ 两条路的分工(本文件如实记下的一处不一致):
// - 面板预览 = `shareConfirmPreview`,**不过解析器**,用户看到的是原样的那几个字符;
// - 发出后的气泡 = `MessageBubble` 里的 `parse`,标记按既有语义被剥离/降级成旁白。
// 于是 `"<p><p>正文</p>"` 这样的分享:预览里是一串标签,气泡里只剩「正文」。
// 不崩、也不丢「正文」,但预览那句「她将收到的是什么」在这里是打了折的。
listOf("<p><p>正文</p>", "<action>看着窗外</action>", "*动作*").forEach { raw ->
val share = ShareRules.extract(action = ShareRules.ACTION_SEND, text = raw)
assertEquals(raw, shareConfirmPreview(share).text)
/**
* 预览里**看得见的字**:按渲染顺序(每块:上依附的旁白 → 正文 → 下依附的旁白),空白的不算。
*
* 这个顺序就是 `ShareConfirmPanel` 里 `ShareConfirmBlock` 的排法——用它当「用户看到的是什么」,
* 好让下面几条断言能直接钉住**可见文本**,而不只是内部结构。
*/
private fun visibleLines(blocks: List<ParsedBubble>): List<String> = buildList {
blocks.forEach { block ->
addAll(block.head.map { it.text })
add(block.body)
addAll(block.tail.map { it.text })
}
}.filter { it.isNotBlank() }
@Test
fun `the preview and the bubble show the very same text`() {
// W65 口径变更(先读类上的「W65 改掉了什么」):预览的正文区**走的就是气泡那一个 `parse`**,
// 所以两边看得见的正文与旁白必须一字不差。
//
// ⚠️ 但 `preview.text` 本身仍然是**原样那一段字**(只 `trim`)——解析发生在**渲染**这一层,没有
// 搬进 `shareConfirmPreview`。这一条也一起钉住:数据层不许替用户改字。
listOf(
"<p><p>正文</p>",
"<p>第一段</p><p>第二段</p><p>第三段</p>",
"<action>看着窗外</action>",
"<p>说话</p><action>顿了顿</action>",
"第一段\n\n第二段",
"1 < 2 和 a<b",
neteaseLike,
).forEach { raw ->
val share = ShareRules.extract(action = ShareRules.ACTION_SEND, text = raw)
val preview = shareConfirmPreview(share)
val sent = shareConfirmBody(share, "")
assertEquals("预览的原始正文被改字了:$raw", raw, preview.text)
// 气泡那一侧:`MessageBubble` 用的同一个 `parse`(非空结果上那一条 `filterNot { isEmpty }`
// 不删任何东西,这里照抄它当基准——预览那一侧多出来的只是同一条兜底)。
val bubbles = parse(sent).filterNot { it.isEmpty }
.ifEmpty { listOf(ParsedBubble(sent, emptyList(), emptyList(), emptyList())) }
val rendered = sharePreviewBlocks(preview.text)
assertEquals("预览与气泡的正文不一致:$raw", bubbles.map { it.body }, rendered.map { it.body })
assertEquals(
"预览与气泡的旁白不一致:$raw",
bubbles.map { b -> (b.head + b.tail).map { it.text } },
rendered.map { b -> (b.head + b.tail).map { it.text } },
)
// 标记一个字符都不许出现在**看得见的地方**:预览此前把 `<p>` / `</p>` 原样摆给用户看,
// 那正是这一批要修的东西。
visibleLines(rendered).forEach { line ->
assertFalse("标记漏进了可见文本:$line", line.contains("<p") || line.contains("</p"))
assertFalse("标记漏进了可见文本:$line", line.contains("<action") || line.contains("<narration"))
}
}
}
@Test
fun `a share full of stray paragraph tags shows the same text in the preview as in the bubble`() {
// W61 审查发现的那处不一致的**原样输入**:多一个 `<p>`、只有一个 `</p>`(服务端确实会这样产出)。
// 旧口径:预览是 `"<p><p>正文</p>"` 这一串字符;气泡里只剩「正文」。
// 新口径(W65):预览也走 `parse`,两边都是「正文」,一个标签字符都不显示。
val raw = "<p><p>正文</p>"
val share = ShareRules.extract(action = ShareRules.ACTION_SEND, text = raw)
val rendered = sharePreviewBlocks(shareConfirmPreview(share).text)
assertEquals(listOf("正文"), visibleLines(rendered))
assertEquals("正文", rendered.single().body)
// 气泡那一侧(同一条消息发出去之后渲染的那一份)逐字相同。
assertEquals(parse(shareConfirmBody(share, "")).single().body, rendered.single().body)
}
@Test
fun `each paragraph of the shared text becomes its own block in the preview`() {
// 多段 ⇒ 多个块:旧口径下 `<p>甲</p><p>乙</p>` 在预览里是**一行还带着标签的原样文本**,现在与
// 气泡里一样是两块。切段不是面板新写的规则,是 `parse` 的既有语义。
val three = ShareRules.extract(
action = ShareRules.ACTION_SEND,
text = "<p>第一段</p><p>第二段</p><p>第三段</p>",
)
assertEquals(listOf("第一段", "第二段", "第三段"), sharePreviewBlocks(three.text).map { it.body })
// 无标签的正文按**空行**分段(同上):两段就是两块,而不是一段里的一行。
val plain = ShareRules.extract(action = ShareRules.ACTION_SEND, text = "第一段\n\n第二段")
assertEquals(listOf("第一段", "第二段"), sharePreviewBlocks(plain.text).map { it.body })
// 段数不是从正文里数出来的:一行里写三个完整块也是三块(按行数只会数出 1)。
assertEquals(3, sharePreviewBlocks("<p>甲</p><p>乙</p><p>丙</p>").size)
assertEquals(2, sharePreviewBlocks("甲\n\n乙").size)
// 单个换行是**句内换行**,绝不切分(既有语义):一块里两行。
assertEquals(1, sharePreviewBlocks("甲\n乙").size)
}
@Test
fun `narration in the shared text is an annotation block, not body text`() {
// `<action>` / `<narration>` 在预览里与气泡里一样**不进正文**:那一块没有正文,这一段落在 head
// 上,由 `ShareConfirmAnnotation` 渲染成灰斜体小字。标签本身一个字符都不显示。
val action = sharePreviewBlocks("<action>看着窗外</action>").single()
assertEquals("", action.body)
assertEquals(listOf("看着窗外"), action.head.map { it.text })
assertTrue(action.head.single() is ContentPart.Action)
assertTrue(action.tail.isEmpty())
val narration = sharePreviewBlocks("<narration>旁白一句</narration>").single()
assertEquals("", narration.body)
assertTrue(narration.head.single() is ContentPart.Narration)
// 星号规则同源(`parse` 的既有规则):全角短句 ⇒ 旁白那一行;半角无关键词 ⇒ 保持 Markdown
// 强调文本,仍然留在**正文**里。
val star = sharePreviewBlocks("*今天也要加油*").single()
assertEquals("", star.body)
assertEquals(listOf("今天也要加油"), star.head.map { it.text })
assertEquals("*随便*", sharePreviewBlocks("*随便*").single().body)
// 「看得见的那几行」也钉住:旁白排在正文**之外**,按渲染顺序在正文之前(与气泡里 `head` 的位置
// 一样)。这一条是「形如气泡」这件事在纯函数层唯一能自证的地方。
assertEquals(
listOf("顿了顿", "说话"),
visibleLines(sharePreviewBlocks("<action>顿了顿</action><p>说话</p>")),
)
}
@Test
fun `a shared text the parser drops entirely is still shown verbatim`() {
// 解析结果为空时**必须有兜底**(原样显示,不是一块空白):正文那一栏是用户在按发送之前唯一能
// 核对的东西。兜底与气泡那一侧同一条(`MessageBubble`:`parsed.ifEmpty { plainBubble(content) }`)
// ——都显示**原样那一段字**,哪怕它本来就是几个没配对的标签。
val raw = "<p><p>"
assertTrue("这一档必须真的解析为空,否则下面几条断言什么也证明不了", parse(raw).isEmpty())
val block = sharePreviewBlocks(raw).single()
assertEquals(raw, block.body)
assertTrue(block.head.isEmpty())
assertTrue(block.tail.isEmpty())
assertEquals(listOf(raw), visibleLines(sharePreviewBlocks(raw)))
// 走一遍真分享的路:`preview.text` 就是 `trim` 过的原串,于是解析为空、由这一条兜底把它显示出来。
val share = ShareRules.extract(action = ShareRules.ACTION_SEND, text = raw)
val preview = shareConfirmPreview(share)
assertEquals(raw, preview.text)
assertEquals(listOf(raw), visibleLines(sharePreviewBlocks(preview.text)))
// 兜底不是给空正准备的:这一档的正文本身非空白(否则面板根本不显示正文那一栏)。
assertTrue(preview.hasText)
}
@Test
@@ -0,0 +1,45 @@
package top.yeij.cyrene.ui.settings
import org.junit.Assert.assertNotEquals
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* 「免打扰」开关下面那行字(纯函数)。
*
* 这一行的全部价值在一件事上:**说清它不是静音**。「免打扰」四个字在系统里到处都是,用户对它的
* 第一印象常是「什么都不响」,而这个开关实际做的是「她的话照旧响一声、躺在通知栏里,只是不再盖住
* 我正在做的事」——两句话指向的期待差得很远,一句不说就是让用户自己猜(猜错的代价是他以为通知
* 坏了,或者以为自己买到了完全静音)。
*
* 两档都必须说出**当下会怎么弹**:用户据以决定的就是「现在这样够不够吵」,只写「开着 = 安静」
* 等于让他自己去比较两种安静。
*/
class ProactiveQuietSummaryTest {
@Test
fun `the on state says it is not silence`() {
val text = proactiveQuietSummary(enabled = true)
assertTrue("要说清它还在通知栏里:$text", text.contains("通知栏"))
assertTrue("要说清关掉的只是横幅:$text", text.contains("横幅"))
assertTrue("必须明说提示音还在(否则等于说自己在静音):$text", text.contains("提示音"))
}
@Test
fun `the off state names the banner as what gets silenced`() {
val text = proactiveQuietSummary(enabled = false)
assertTrue("要说清默认这一档是弹横幅的:$text", text.contains("默认"))
assertTrue("要说清默认会弹横幅:$text", text.contains("横幅"))
}
@Test
fun `the two states are two different sentences`() {
assertNotEquals(
"两档必须各说各的,任何一次改文案都不该把某一档悄悄吞掉",
proactiveQuietSummary(enabled = false),
proactiveQuietSummary(enabled = true),
)
}
}