Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3b9936e212 | ||
|
|
dc5972213e | ||
|
|
c07a713e86 | ||
|
|
bfa79946d4 | ||
|
|
0e2df1193d | ||
|
|
0e834fc854 | ||
|
|
4fe5509c24 | ||
|
|
cdd58fa9c4 |
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),
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user