Compare commits
8
Commits
e5e61e131d
...
9ff2de3a1d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9ff2de3a1d | ||
|
|
f3c1121887 | ||
|
|
a861881ec4 | ||
|
|
b3ded77c23 | ||
|
|
464e91621e | ||
|
|
ec89d828d1 | ||
|
|
1142bf5ed1 | ||
|
|
53339b369a |
No files matched your search
@@ -70,7 +70,7 @@ class AppContainer(context: Context) {
|
||||
*/
|
||||
val proactiveNotifier: ProactiveNotifier = ProactiveNotifier(
|
||||
presence = appPresence,
|
||||
post = { text -> postProactiveNotification(appContext, text) },
|
||||
post = { text, notificationId -> postProactiveNotification(appContext, text, notificationId) },
|
||||
)
|
||||
|
||||
/**
|
||||
|
||||
@@ -139,8 +139,12 @@ object ShareIntake {
|
||||
/**
|
||||
* 取走一条待落地内容(原子出队,只有取到它的那个调用方会去发);队列为空回 `null`。
|
||||
*
|
||||
* 出队是**唯一的认领方式**:消费它的地方会被重建(重组 / 切会话 / Activity 重建),
|
||||
* 所以「已经发过了」这件事不能记在消费方身上(见 [PendingShareHolder])。
|
||||
* 出队是**唯一的认领方式**,而它在这里是原子的:两个消费者同时来也只会各拿各的一条
|
||||
* (判据见 [PendingShareHolder.take])。而「**已经认领过了**」这件事此后记在认领它的那一格上
|
||||
* ——`ChatViewModel` 的 [top.yeij.cyrene.ui.chat.ChatUiState.shareConfirm]。那一格活在
|
||||
* `AppContainer` 那一层(`core.session.ChatSessionSlots` 的 `ViewModelStore`),比组合与
|
||||
* Activity 都活得久,所以转屏 / 深浅色切换重建之后**不会再取第二遍**,也不会把队列里下一条顶掉:
|
||||
* 认领的闸门在 `ChatViewModel.claimSharedContent` 里,且在调用本函数**之前**。
|
||||
*/
|
||||
fun take(): SharedContent? = holder.take()
|
||||
|
||||
|
||||
@@ -31,12 +31,6 @@ internal const val PROACTIVE_CHANNEL_ID = "proactive"
|
||||
*/
|
||||
internal const val PROACTIVE_IMPORTANCE = NotificationManager.IMPORTANCE_DEFAULT
|
||||
|
||||
/**
|
||||
* 通知 id。**必须**与保活那条(`KEEP_ALIVE_NOTIFICATION_ID` = 1)分开:同 id 的 `notify` 是
|
||||
* **替换**,撞上就是把「正在后台保持连接」那条常驻通知顶掉(前台服务还在跑,而它的通知没了)。
|
||||
*/
|
||||
internal const val PROACTIVE_NOTIFICATION_ID = 2
|
||||
|
||||
/** 通知标题:**是谁在说话**。正文是她的原话(见 [ProactiveNotifier])。 */
|
||||
internal const val PROACTIVE_NOTIFICATION_TITLE = "昔涟"
|
||||
|
||||
@@ -55,21 +49,17 @@ internal const val PROACTIVE_CHANNEL_NAME = "她主动说的话"
|
||||
*/
|
||||
internal const val EXTRA_PROACTIVE_OPEN_MAIN_SESSION = "top.yeij.cyrene.extra.PROACTIVE_OPEN_MAIN_SESSION"
|
||||
|
||||
/**
|
||||
* 那个 `PendingIntent` 的 request code。
|
||||
*
|
||||
* ⚠️ **绝不能和保活那条用同一个**(那边是 `0`):两个 Intent 都是「启动 MainActivity」、没有
|
||||
* action / data / categories ⇒ `Intent.filterEquals` 判它们**相等**,而 `PendingIntent` 的相等
|
||||
* 判据里**不含 extras**。同 request code 时系统直接复用**先建的那一个**,后建这枚的 extra 会被
|
||||
* 悄悄丢掉——症状是「点通知进来了,但停在别的会话上,什么都没发生」。
|
||||
*/
|
||||
private const val PROACTIVE_OPEN_REQUEST_CODE = 2
|
||||
|
||||
/**
|
||||
* 弹一条「她主动说的话」的通知。
|
||||
*
|
||||
* 只做三件事:建渠道(惰性,第一次真要用时才建)、拼通知、`notify`。**判据与去重都不在这里**
|
||||
* (那是 [ProactiveNotifier] 的事)——本函数被调用就意味着「这一次确实该弹」。
|
||||
* 只做三件事:建渠道(惰性,第一次真要用时才建)、拼通知、`notify`。**判据、去重与 id 的派生
|
||||
* 都不在这里**(那是 [ProactiveNotifier] 与 [proactiveNotificationId] 的事)——本函数被调用就
|
||||
* 意味着「这一次确实该弹,且这条消息的号已经定好了」,它只负责把号用在通知上。
|
||||
*
|
||||
* ## 为什么 id 是入参而不是这里恒定一个
|
||||
* 同 id 的 `notify` 是**替换**:恒用一个号就等于「后来的覆盖先来的」——她连说几句,用户只看到
|
||||
* 最后一句。入参那个号由 [proactiveNotificationId] 按消息派生(输入就是去重键),于是同一句
|
||||
* 无论到几次都落在同一条通知上,而不同的句子各占一条。
|
||||
*
|
||||
* ## 没授通知权限时什么都不做
|
||||
* `minSdk 33` ⇒ `POST_NOTIFICATIONS` 恒是运行时权限,而系统在未授权时**静默丢弃**通知。与其发一条
|
||||
@@ -81,15 +71,22 @@ private const val PROACTIVE_OPEN_REQUEST_CODE = 2
|
||||
* 工程里没有为通知准备单色小图标(`@mipmap/ic_launcher` 是彩色的,拿它当通知小图标在通知栏上
|
||||
* 会显示成一块白斑)。所以用平台自带的 `stat_notify_chat`——与保活那条用 `stat_notify_sync` 是
|
||||
* 同一个做法。要换成本应用的图标时,只需换下面这一行。
|
||||
*
|
||||
* @param notificationId 这条消息的通知 id,同时也是它点击用的 request code(两者必须一起动:
|
||||
* 只换 id 不换 request code,点后到的那条通知会串到先到那条的 `PendingIntent` 上)。
|
||||
*/
|
||||
internal fun postProactiveNotification(context: Context, text: String) {
|
||||
internal fun postProactiveNotification(context: Context, text: String, notificationId: Int) {
|
||||
if (!context.notificationsAllowed()) return
|
||||
|
||||
ensureProactiveChannel(context)
|
||||
|
||||
// request code 随消息走(`proactiveNotificationId` 的下界远高于保活那条用的 `0`,所以不会
|
||||
// 撞上它):两个 Intent 都是「启动 MainActivity」、没有 action / data / categories ⇒
|
||||
// `Intent.filterEquals` 判它们**相等**,而 `PendingIntent` 的相等判据里**不含 extras**。
|
||||
// 同 request code 时系统直接复用**先建的那一个**,后建这枚的 extra 会被悄悄丢掉。
|
||||
val openApp = PendingIntent.getActivity(
|
||||
context,
|
||||
PROACTIVE_OPEN_REQUEST_CODE,
|
||||
notificationId,
|
||||
Intent(context, MainActivity::class.java).putExtra(EXTRA_PROACTIVE_OPEN_MAIN_SESSION, true),
|
||||
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
|
||||
)
|
||||
@@ -108,10 +105,9 @@ internal fun postProactiveNotification(context: Context, text: String) {
|
||||
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
|
||||
.build()
|
||||
|
||||
// 恒用同一个 id:她连说几句时**后来的覆盖前面的**,通知栏里只留最新一句。
|
||||
// 这是刻意的取舍——通知只是「她找你了」这一下提醒,全部内容都在聊天页里;
|
||||
// 换成一事一条会把通知栏刷满,而她的话本来就都在主会话里躺着。
|
||||
NotificationManagerCompat.from(context).notify(PROACTIVE_NOTIFICATION_ID, notification)
|
||||
// 一事一条:号随消息走。撞号会发生,代价与改前一样是「后来的把先来的替换掉」,
|
||||
// 理由与概率写在 `proactiveNotificationId` 的 KDoc 里。
|
||||
NotificationManagerCompat.from(context).notify(notificationId, notification)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -51,12 +51,14 @@ enum class ProactiveNotifyOutcome {
|
||||
* 4. [ProactiveNotifyOutcome.SeenOnScreen] / [ProactiveNotifyOutcome.Posted]:最后才问时机。
|
||||
*
|
||||
* @param presence 「App 在不在前台 / 聊天页显示着谁」,由 `MainActivity` 与 `ChatScreen` 报告。
|
||||
* @param post 真正的落地动作(Android 那边是建渠道 + `notify`)。测试传一个记录用的假实现即可。
|
||||
* @param post 真正的落地动作(Android 那边是建渠道 + `notify`)。第二个入参是**这条消息派生出来的
|
||||
* 通知 id**(见 [proactiveNotificationId]):落地侧必须用它,别再恒用一个号——恒用一个号时
|
||||
* `notify` 是替换,她连说几句就只剩最后一句。测试传一个记录用的假实现即可。
|
||||
* @param mainSessionId 主会话 id。默认取 [SessionSummaryDto.MAIN_SESSION_ID],测试可换。
|
||||
*/
|
||||
class ProactiveNotifier(
|
||||
private val presence: AppPresence,
|
||||
private val post: (String) -> Unit,
|
||||
private val post: (text: String, notificationId: Int) -> Unit,
|
||||
private val mainSessionId: String = SessionSummaryDto.MAIN_SESSION_ID,
|
||||
private val recent: RecentProactiveNotices = RecentProactiveNotices(),
|
||||
) {
|
||||
@@ -84,7 +86,8 @@ class ProactiveNotifier(
|
||||
|
||||
val text = proactiveNotificationText(content) ?: return ProactiveNotifyOutcome.NothingToSay
|
||||
|
||||
val firstArrival = recent.markIfNew(proactiveNotifyKey(id, messageId, timestamp, text))
|
||||
val key = proactiveNotifyKey(id, messageId, timestamp, text)
|
||||
val firstArrival = recent.markIfNew(key)
|
||||
if (!firstArrival) return ProactiveNotifyOutcome.Duplicate
|
||||
|
||||
// 判据只回答「弹不弹」,「用户会不会正好看到」是它的反面——别在接线层另写一遍这个取反
|
||||
@@ -95,7 +98,8 @@ class ProactiveNotifier(
|
||||
)
|
||||
if (seenOnScreen) return ProactiveNotifyOutcome.SeenOnScreen
|
||||
|
||||
post(text)
|
||||
// id 与去重键同源,正是为了让「同一条 ⇒ 同一个号」与「同一条 ⇒ 只弹一次」永远不会分叉。
|
||||
post(text, proactiveNotificationId(key))
|
||||
return ProactiveNotifyOutcome.Posted
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
package top.yeij.cyrene.core.notify
|
||||
|
||||
import kotlin.math.absoluteValue
|
||||
import top.yeij.cyrene.domain.parse.parse
|
||||
|
||||
/**
|
||||
@@ -110,6 +111,54 @@ fun proactiveNotifyKey(
|
||||
.firstOrNull { !it.isNullOrBlank() }
|
||||
?: fallback
|
||||
|
||||
/**
|
||||
* 派生 id 的取值**下界**(见 [proactiveNotificationId])。
|
||||
*
|
||||
* 通知 id 与 `PendingIntent` 的 request code 都是**全 App 共用**的命名空间,而它们现有的占用者
|
||||
* (保活那条常驻通知 `KEEP_ALIVE_NOTIFICATION_ID` = 1、它点击用的 request code = 0)全在低位。
|
||||
* 从远高于它们的地方起步,「派生 id 不撞既有 id」就是**结构性**成立的,不是靠「碰巧没撞上」。
|
||||
*/
|
||||
private const val PROACTIVE_NOTIFICATION_ID_FLOOR = 1_000
|
||||
|
||||
/**
|
||||
* 派生 id 的取值**宽度**(见 [proactiveNotificationId])。
|
||||
*
|
||||
* 取这么宽是为了让 id 空间尽量大(撞号的概率随宽度下降),同时**贴着 `Int.MAX_VALUE` 留出下界的
|
||||
* 位置**:「下界 + 宽度」一旦越过 `Int.MAX_VALUE`,末尾的 `.toInt()` 就会折回负数。
|
||||
*/
|
||||
private const val PROACTIVE_NOTIFICATION_ID_SPAN = 2_000_000_000
|
||||
|
||||
/**
|
||||
* 一条 proactive 消息对应的**通知 id(同时也是它点击用的 request code)**——纯函数,由去重键
|
||||
* [proactiveNotifyKey] 的输出派生。这就是「一事一条」的全部落地形状(W54A)。
|
||||
*
|
||||
* ## 为什么必须与去重键同源
|
||||
* 改前所有她的话共用一个固定 id,而 `notify` 遇同 id 是**替换**:她连说几句,用户只看到最后
|
||||
* 一句,前面那几句在他拿起手机之前就已经没了。按消息派生要回答的第一个问题是「凭什么是同一条」
|
||||
* ——那正是 [proactiveNotifyKey] 已经回答过的(实时帧与落库帧共用它)。**两处口径必须永远同源**:
|
||||
* 去重说「这是同一条」、派生说「这是同一条 ⇒ 同一个号」;分成两套,就会出现「认得出是同一条、
|
||||
* 却排出两条 id」的形状漂移。
|
||||
*
|
||||
* ## 它会撞号,后果如实说
|
||||
* `String.hashCode()` 只有 32 位,再折进 [PROACTIVE_NOTIFICATION_ID_SPAN] 这一段 ⇒ **不同消息
|
||||
* 派生出同一个 id 是会发生的**(约 2×10⁹ 的空间,按生日问题算,通知栏里装几十条时概率可忽略)。
|
||||
* 撞上的后果与改前**一模一样**:后来那条替换先来那条,用户少看见一条提醒。这是刻意接受的那一侧
|
||||
* ——通知只是「她找你了」这一下,她的话本身在主会话里一条不少;反过来要绝对不撞,就得在服务端
|
||||
* 唯一号之外再维护一套映射并持久化它,代价比「极少数情况下少一条提醒」大得多。
|
||||
*
|
||||
* ## 两个坑都在这里堵上
|
||||
* 1. **号必须为正**:通知 id 是一个有符号 `int`,而这个命名空间里正数是纪律(本仓保活那条常驻通知
|
||||
* 是 1;平台对前台服务的 id 更直接写明「must not be 0」)。而 `hashCode()` 可以是负的、甚至
|
||||
* 正好是 `Int.MIN_VALUE`——后者连 `abs` 都兜不住(在 int 域里 `abs(Int.MIN_VALUE)` 仍是它自己)。
|
||||
* 所以这里**先展宽到 `Long` 再取绝对值**:`Int.MIN_VALUE.toLong()` = 2147483648,取绝对值是安全
|
||||
* 的,再折进 [PROACTIVE_NOTIFICATION_ID_SPAN] 后落在正区间。
|
||||
* 2. **不撞既有 id**:见 [PROACTIVE_NOTIFICATION_ID_FLOOR]。
|
||||
*/
|
||||
internal fun proactiveNotificationId(key: String): Int {
|
||||
val magnitude = key.hashCode().toLong().absoluteValue
|
||||
return (PROACTIVE_NOTIFICATION_ID_FLOOR + magnitude % PROACTIVE_NOTIFICATION_ID_SPAN).toInt()
|
||||
}
|
||||
|
||||
/**
|
||||
* 通知正文:她的原话,**剥掉标记、折成一行、过长截断**;没有可显示的字时回 `null`。
|
||||
*
|
||||
|
||||
@@ -130,7 +130,8 @@ data class ConclusionParagraph(val parts: List<ContentPart>)
|
||||
*
|
||||
* 该字段承载的是**与聊天消息同一套 Web 端 markup**(真实样本里有连写的空 `<p></p>`、
|
||||
* `<action>` 等),所以这里**不新增任何 markup 规则**,一律委托 [parse]:
|
||||
* 一个 `<p>` 块 = 一段,旁白/动作块依聊天页的归属规则挂在该段内,畸形标签按普通文本回落。
|
||||
* 一个 `<p>` 块 = 一段,旁白/动作块依聊天页的归属规则挂在该段内,畸形标签由 [parse] 兜底
|
||||
* (落单的段落标签剥掉,其余按普通文本回落)。
|
||||
* 空段(`<p></p>` 连写产生的空白)不产出模型项——否则界面上会出现成片空行。
|
||||
* 段内片段顺序取**渲染顺序**:`head` → 正文 → `tail`(与聊天页气泡上/下方旁白的排布一致)。
|
||||
* `null` / 纯空白输入返回空列表。
|
||||
|
||||
@@ -19,7 +19,20 @@ import kotlin.math.min
|
||||
* - 漂移兼容:全角 `*…*`(无关键词时要求去空白后 ≤40 字符且不含换行 → action);
|
||||
* 半角 `*…*` 仅当以 动作/旁白/内心/神态/轻声 开头才算旁白,否则保持普通文本(不吞 Markdown 强调)。
|
||||
* 关键词 旁白/内心 → narration,其余 → action。
|
||||
* - 未闭合/畸形的标签一律回落为普通文本,绝不抛异常。
|
||||
* - 落单标签容错:服务端确实会产出不配对的段落标签(实测形态 `<p><p>正文</p>`:多一个 `<p>`、
|
||||
* 只有一个 `</p>`),块外也可能出现孤立的旁白标签。这类标签一律**剥掉**,绝不落进正文——
|
||||
* 它们本就是服务端产出的标记、不是她说的话;渲染成正文等于往气泡里塞一串用户既看不懂、
|
||||
* 也改不掉的字符,而剥掉只是少显示几个本就不该出现的字符,正文一个字都不动。
|
||||
* 这只是客户端的容错,不代表服务端这样发是对的。
|
||||
* 判据([stripLooseTags])分两种情形:消息里**已有完整块**时,段落结构已被完整块定死,块内
|
||||
* 块外多出来的每个 `<p>` / `</p>`、以及块外孤立的旁白标签,都是冗余噪声,一律剥掉(块内的
|
||||
* 落单旁白标签例外,见最后一条);消息里**一个完整块都没有**时,按上一条整段回落字面文本,
|
||||
* 只再剥两类连「被截断的结构」都算不上的东西:落单的 `</p>`,与后面什么都没有的空开标签。
|
||||
* 只认「确定是标签」的形状:`<p>` / `<p 属性>` / `</p>` / `<action>` 等。`1 < 2`、`<3`、
|
||||
* `a<b`、`<p2>`、`<P>`(词表只有小写,与 `MessageEditText` 同一口径)、`<p`(残缺,没有 `>`)
|
||||
* 都原样保留——见到 `<` 就吃,才是真正会丢字的那种错。
|
||||
* - 未闭合/畸形的其余标签一律回落为普通文本,绝不抛异常。块内没配对的 `<action>` / `</action>`
|
||||
* 也保持字面文本,这是既有契约(`unclosedActionFallsBackToText` / `strayClosingTagStaysText`)。
|
||||
*
|
||||
* `ParsedBubble` 结构(每条气泡渲染一个):
|
||||
* - `body` :气泡正文(仅 Text 部分按原顺序拼接后 trim),已剔除全部旁白/动作块;
|
||||
@@ -37,6 +50,15 @@ import kotlin.math.min
|
||||
* 2) 尾部残缺标签(如 `<act`、`</act`、`<p`),仅当它可能继续补全为合法标签时才扣留;
|
||||
* 3) 最近一个完整标签之后(自由文本区)的孤立全角 `*`;
|
||||
* 4) 自由文本区中可能开启关键词半角星号(如 `*动`)的孤立 `*`。
|
||||
* - 落单标签的剥离**不需要**额外的扣留规则,这是「剥标签与扣留共用同一组标签正则」的直接结果:
|
||||
* 扣留点取「第一个没闭合的开标签」,而落在前缀里的每个 `<p>` / `<action>` 开标签必定在
|
||||
* **同一个前缀内**配到闭合标签(否则它就是那个没闭合的,扣留点会落在它前面)。所以
|
||||
* 「这个标签是不是落单的」在它被产出的那一刻就已经定死,中间态剥掉、收口时也不用再改口。
|
||||
* - 反过来放宽扣留是不行的:把重复的 `<p><p>` 当「外层冗余」让前缀继续推进,`<p>甲<p>乙`
|
||||
* 就会在中间态把 `<p>甲` 当字面文本吐上屏,等 `</p>` 到了又变成 `甲乙`——字会跳。
|
||||
* 剥标签与扣留的标签词表因此必须逐字一致(共用 OPEN_P_TAG_RE / CLOSE_P_TAG_RE /
|
||||
* OPEN_ROLE_TAG_RE / CLOSE_ROLE_TAG_RE,无大小写、无词表扩展),两边一旦分叉,
|
||||
* 就会出现「中间态当正文、收口时又剥掉」的跳动。
|
||||
* - 标签残缺判断只影响「何时产出」,不影响最终结果;扣留期间不会抛异常。
|
||||
* - 本类无副作用、非线程安全,调用方保证单线程喂入即可。
|
||||
*/
|
||||
@@ -91,6 +113,10 @@ private val BLANK_LINE_RE = Regex("\\n\\s*\\n")
|
||||
/** 无关键词的全角星块超过此长度即视为普通文本(对齐 Web `STAR_SHORT_RUNES`)。 */
|
||||
private const val FULL_STAR_MAX_LEN = 40
|
||||
|
||||
/**
|
||||
* 已知标签词表(只有小写,与 `MessageEditText` 同一口径):流式边界判定 [isPotentialTagPrefix]
|
||||
* 补全残缺标签时用;[stripLooseTags] 判「是不是标签」用的是下面这组同词表的正则,两边必须同源。
|
||||
*/
|
||||
private val TAG_NAMES = listOf("action", "narration", "p")
|
||||
private val OPEN_ROLE_TAG_RE = Regex("^<\\s*(action|narration)\\s*>$")
|
||||
private val CLOSE_ROLE_TAG_RE = Regex("^</\\s*(action|narration)\\s*>$")
|
||||
@@ -144,29 +170,112 @@ private sealed interface Tok {
|
||||
object Break : Tok
|
||||
}
|
||||
|
||||
/** 先切 `<p>` 块(决定气泡边界),再在每个文本区做旁白/星号解析。 */
|
||||
/** 先切 `<p>` 块(决定气泡边界),再按区剥落单标签,最后在每个文本区做旁白/星号解析。 */
|
||||
private fun tokenize(raw: String): List<Tok> {
|
||||
val pMatches = P_BLOCK_RE.findAll(raw).toList()
|
||||
val out = ArrayList<Tok>()
|
||||
if (pMatches.isEmpty()) {
|
||||
appendAnnotations(raw, out, splitBlankLines = true)
|
||||
// 一条完整块都没有:`<p>` 及其后内容按既有契约整段回落字面文本,`<action>` 这类旁白标签
|
||||
// 落单时同理(`MessageEditTextTest.没闭合的 action 同样原样留着`)。此处只剥掉连「被截断的
|
||||
// 结构」都算不上的东西:落单的 `</p>`,与后面什么都没有的空开标签。判据见 [stripLooseTags]。
|
||||
appendAnnotations(
|
||||
stripLooseTags(raw, markupMode = false, stripStrayRoleTags = false),
|
||||
out,
|
||||
splitBlankLines = true,
|
||||
)
|
||||
return out
|
||||
}
|
||||
var last = 0
|
||||
for (m in pMatches) {
|
||||
val lead = raw.substring(last, m.range.first)
|
||||
if (lead.isNotEmpty()) appendAnnotations(lead, out, splitBlankLines = false)
|
||||
if (lead.isNotEmpty()) {
|
||||
appendAnnotations(stripLooseTags(lead, true, stripStrayRoleTags = true), out, splitBlankLines = false)
|
||||
}
|
||||
out.add(Tok.Break)
|
||||
val inner = m.groupValues[2]
|
||||
if (inner.isNotEmpty()) appendAnnotations(inner, out, splitBlankLines = false)
|
||||
if (inner.isNotEmpty()) {
|
||||
// 块内只剥 `<p>` 族:`<action>` / `</action>` 落单时保留字面文本,是既有契约
|
||||
// (unclosedActionFallsBackToText / strayClosingTagStaysText 钉住的行为)。
|
||||
appendAnnotations(stripLooseTags(inner, true, stripStrayRoleTags = false), out, splitBlankLines = false)
|
||||
}
|
||||
out.add(Tok.Break)
|
||||
last = m.range.last + 1
|
||||
}
|
||||
val trail = raw.substring(last)
|
||||
if (trail.isNotEmpty()) appendAnnotations(trail, out, splitBlankLines = false)
|
||||
if (trail.isNotEmpty()) {
|
||||
appendAnnotations(stripLooseTags(trail, true, stripStrayRoleTags = true), out, splitBlankLines = false)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* 剥掉「落单标签」,其余字符逐字保留(返回原对象:没剥到东西时不做新分配)。
|
||||
*
|
||||
* [markupMode] = 这条消息里**已有完整 `<p>…</p>` 块**,段落结构已被完整块定死,于是块内/块外
|
||||
* 多出来的每一个 `<p>` / `</p>` 都是冗余噪声,一律剥掉。
|
||||
* 没有完整块时(`parse` 会整段回落字面文本,见 `unclosedParagraphStaysLiteralText`;编辑框也靠
|
||||
* 这条保证不静默改字,见 `MessageEditTextTest.没闭合的 p 不抛异常`)只再剥两类不可能是正文的东西:
|
||||
* 落单的 `</p>`,以及后面什么都没有的**空**开标签——它们连「被截断的段落」都算不上。
|
||||
*
|
||||
* [stripStrayRoleTags] 只对**块外**文本区为 true:那里的 `<action>` / `</action>` 若没被 [ROLE_RE]
|
||||
* 认领(即没配成对),就是落单标签,剥掉。已配对的必须留给 [ROLE_RE] 解析成 Action/Narration,
|
||||
* 提前剥掉等于把旁白降级为正文(`textOutsideParagraphsIsPreserved` 钉住的行为)。
|
||||
*/
|
||||
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()
|
||||
val out = StringBuilder(text.length)
|
||||
var last = 0
|
||||
var i = 0
|
||||
while (i < text.length) {
|
||||
val lt = text.indexOf('<', i)
|
||||
if (lt < 0) break
|
||||
val gt = text.indexOf('>', lt)
|
||||
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)
|
||||
CLOSE_P_TAG_RE.matches(tag) -> true
|
||||
!stripStrayRoleTags -> false
|
||||
roleRanges.any { lt in it } -> false
|
||||
else -> OPEN_ROLE_TAG_RE.matches(tag) || CLOSE_ROLE_TAG_RE.matches(tag)
|
||||
}
|
||||
if (drop) {
|
||||
out.append(text, last, lt)
|
||||
last = gt + 1
|
||||
i = last
|
||||
} else {
|
||||
i = lt + 1
|
||||
}
|
||||
}
|
||||
if (last == 0) return text
|
||||
out.append(text, last, text.length)
|
||||
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
|
||||
}
|
||||
if (text[i] == '<') {
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/** 在文本区内按左到右顺序切出 Text / Action / Narration。 */
|
||||
private fun appendAnnotations(text: String, out: MutableList<Tok>, splitBlankLines: Boolean) {
|
||||
var last = 0
|
||||
|
||||
@@ -405,7 +405,7 @@ fun ChatScreen(
|
||||
) { uris -> viewModel.onDocumentsPicked(uris.map { it.toString() }) }
|
||||
|
||||
// 「分享到 App」的落地时机。待落地的内容活在 Compose 之外(`ShareIntake`),这里只做三件事:
|
||||
// 挑时机、取走一条、交给**确认面板**(`ShareConfirmPanel`:内容一进来就发的那条老路已废,
|
||||
// 挑时机、请 VM 认领一条、交给**确认面板**(`ShareConfirmPanel`:内容一进来就发的那条老路已废,
|
||||
// 2026-10-06 主人要求像其他 IM 一样先确认——只有点「发送」才真的发)。
|
||||
// 判据本身是纯函数 [shareLanding](可单测)。
|
||||
// - **取走必须晚于「连接就绪」**:短文本走 WS,而冷启动时握手还没完成(分享冷启动必然撞上),
|
||||
@@ -422,11 +422,12 @@ fun ChatScreen(
|
||||
// 见 `ChatSessionSlots`),由 UI 层认领才不会让旧会话把内容抢走。
|
||||
// - [ShareIntake.take] 是原子出队:同一条内容不会被发两次。重组 / 切会话 / 连接变化都会重跑这个
|
||||
// effect,哪怕其中一次拿着**已经过期的**快照进来,`take()` 给出的也只是队列里的下一条或 null。
|
||||
// - **取走只发生一次,而且只发生在面板没开的时候**:取到的那一份存进下面那格 Compose 状态,
|
||||
// 面板(`ShareConfirmPanel`)靠它渲染。effect 的键里有连接状态 ⇒ 它会反复重跑,而重跑
|
||||
// **绝不会**再取一份、也绝不会重复发送:`take()` 只在 `shareConfirm == null` 时被调用一次,
|
||||
// 取走之后队列已空(判据看到的是 `Nothing`),而面板开着时新来的分享**原地在队列里等**——
|
||||
// 键里的 `shareConfirm == null` 保证面板一关(发送 / 取消)就会再跑一次判据,把它接上。
|
||||
// - **取走只发生一次,而且只发生在「手上什么都没有」的那一帧**:取到的那一份落进**VM 的状态**
|
||||
// ([ChatUiState.shareConfirm],不是这里的一格 `remember`),面板靠它渲染。effect 的键里有连接
|
||||
// 状态 ⇒ 它会反复重跑,而重跑**绝不会**再取一份、也绝不会重复发送:闸门在 `take()` **之前**,
|
||||
// 而闸门的依据就是 VM 里那一格(`ChatViewModel.claimSharedContent` 的 KDoc 写着它为什么在
|
||||
// `take()` 之前)。面板开着时新来的分享**原地在队列里等**——键里的 `shareConfirm == null`
|
||||
// 保证面板一关(发送 / 取消)就会再跑一次判据,把它接上。
|
||||
val pendingShares by ShareIntake.pending.collectAsState()
|
||||
val shareConnection = state.connection
|
||||
// 「这一轮等待已经等够久了」。计时只跟**「队列里有东西」与「连上了没有」这两个事实**走:
|
||||
@@ -436,17 +437,13 @@ fun ChatScreen(
|
||||
// 「这一轮等待已经说过了」。它与上面那个标志的生命周期刻意不同:连接抖动(effect 因键变化重跑)
|
||||
// 不把它归零,否则同一句话会跟着抖动反复弹——刷屏。内容被取走、或队列清空,才算了结这一轮。
|
||||
var shareToldWaiting by remember(sessionId) { mutableStateOf(false) }
|
||||
// 「这一份分享已经取走、正在等用户确认」。它此前**没有地方待**——取到就直接送进发送链了;
|
||||
// 现在取到的那一份停在这格 Compose 状态里,确认面板靠它渲染(`ShareConfirmPanel`)。
|
||||
//
|
||||
// 键(sessionId)与旁边两个等待标志同一个口径:换会话等于换一格。别的会话的 VM 也还活着,
|
||||
// 这份内容绝不能被另一个会话的面板认领去发——那会把分享发进错的会话。
|
||||
var shareConfirm by remember(sessionId) { mutableStateOf<SharedContent?>(null) }
|
||||
// 面板输入框里的文字 = **用户附加的描述**(W53X3 更正:它不再是分享正文的复刻,初值恒为空)。
|
||||
// 它与上面那份内容**分开存**是刻意的:预览那一份说的是「分享进来的是什么」,这格说的是
|
||||
// 「用户想补一句什么」,两者本来就是两件事,合成一个字段会让预览跟着输入框一起变。
|
||||
// 真正发出去的正文由纯函数 [shareConfirmBody] 在按下「发送」那一刻拼(分享正文 + 这段描述)。
|
||||
var shareCaption by remember(sessionId) { mutableStateOf("") }
|
||||
// 「一份分享已经取走、正在等用户确认」+ 用户补的那句描述。**它们刻意不在 Compose 里**:那一份
|
||||
// 已经出队、没有归还路径,而这一格 `remember` 会随转屏 / 深浅色切换整份丢掉——丢掉的后果是
|
||||
// 那份分享既不在队列里、也不在界面上,**静默消失**(用户什么都没看到,也没有任何提示)。
|
||||
// 所以它住在 VM 的状态里(面板输入框那句描述与正文绑在同一个值里,见 [ShareConfirmDraft]):
|
||||
// `ChatSessionSlots` 的那一格 `ViewModelStore` 由 `AppContainer` 持有,比 Activity 活得久。
|
||||
// ⚠️ 别把它挪回这里——「比 Activity 活得久」正是它存在的全部理由。
|
||||
val shareConfirm = state.shareConfirm
|
||||
LaunchedEffect(sessionId, pendingShares.isEmpty(), shareConnection == WsState.Connected) {
|
||||
if (pendingShares.isEmpty() || shareConnection == WsState.Connected) {
|
||||
shareWaitedLong = false
|
||||
@@ -469,19 +466,14 @@ fun ChatScreen(
|
||||
when (landing) {
|
||||
// 取走之后这一轮等待就结束了:下一批内容(或下一次断线)重新计时、重新有资格说那句话。
|
||||
//
|
||||
// **取走只在这里、且只在面板没开的时候**:`shareConfirm == null` 既是这条闸门,也是本
|
||||
// effect 的键之一。面板开着时来的新分享一个字节都不动(原地留在待发队列里,等这一份
|
||||
// 确认完再接)——那样才不会出现「用户正看着 A,面板里的内容被换成了 B」。
|
||||
// **取走只在这一处发生**,而且闸门在 `take()` **之前**(判据是纯函数 [shareConfirmClaim],
|
||||
// 落在 `ChatViewModel.claimSharedContent` 里):手上已经有了那份未确认的分享时,这一帧
|
||||
// 一个字节都不动队列——面板开着时来的新分享原地留在待发队列里,等这一份确认完再接,
|
||||
// 那样才不会出现「用户正看着 A,面板里的内容被换成了 B」。转屏重建之后新的一帧照样走到
|
||||
// 这里,而那时**手上已经有东西**(它活在 VM 的状态里),所以也不会再取一次。
|
||||
ShareLanding.Send -> {
|
||||
shareToldWaiting = false
|
||||
if (shareConfirm == null) {
|
||||
ShareIntake.take()?.let { share ->
|
||||
shareConfirm = share
|
||||
// 描述从空开始:它是用户要附加的话,**不是**这次分享进来的正文——
|
||||
// 预填一次就会在面板里把同一句话排两遍(W53X 的第一版正是这么错的)。
|
||||
shareCaption = ""
|
||||
}
|
||||
}
|
||||
viewModel.claimSharedContent()
|
||||
}
|
||||
|
||||
// 两个告知档只差措辞(时机一致):说哪句由纯函数 [shareWaitingNotice] 定,
|
||||
@@ -824,34 +816,19 @@ fun ChatScreen(
|
||||
|
||||
// 分享进来的内容的**确认面板**(W53X:「一到就发」那条老路已废)。面板开着时**一个字节都还
|
||||
// 没发出去**:点「发送」才走既有发送路径,点「取消」这一份就作废。这一处只做接线——内容与
|
||||
// 描述都在上面那格 Compose 状态里,面板本身与它的判据都在 `ShareConfirmPanel.kt`。
|
||||
// 描述都在 **VM 的状态**里(`state.shareConfirm`,理由见那里与 `ChatViewModel.claimSharedContent`
|
||||
// 的 KDoc),面板本身与它的判据都在 `ShareConfirmPanel.kt`。转屏重建之后面板照旧在这里长出来,
|
||||
// 因为 VM 比 Activity 活得久;**它绝不能被挪回这个组合里**。
|
||||
//
|
||||
// ⚠️ 取走是不可逆的(`ShareIntake.take()` 没有归还路径),所以「取消」**不把内容塞回队列**:
|
||||
// 塞回去会让「用户刚作废的那一份」与「又来了新分享」变成同一件事,两种状态就再也分不开了。
|
||||
shareConfirm?.let { share ->
|
||||
shareConfirm?.let { draft ->
|
||||
ShareConfirmPanel(
|
||||
share = share,
|
||||
caption = shareCaption,
|
||||
onCaptionChange = { shareCaption = it },
|
||||
onSend = {
|
||||
// 先收面板再发:这一次发送与面板本身互不相干,而它紧接着的那次重组里已经不该在了。
|
||||
//
|
||||
// 正文 = 分享自带的那一段 + 用户补的那一句,由纯函数 [shareConfirmBody] 拼
|
||||
// (两段都在时**空行**分隔):W53X3 起输入框只代表「用户附加的描述」,所以这里
|
||||
// **不能**再直接拿输入框的内容当正文——那正是「同一句话排两遍」那一版的错法。
|
||||
val sent = share.copy(text = shareConfirmBody(share, shareCaption))
|
||||
shareConfirm = null
|
||||
shareCaption = ""
|
||||
// 告知按**真正发出去的那一份**算(`sent`,不是原始的那一份):正文里可能刚多了用户
|
||||
// 补的那一句,拿改之前那份去算会说出「正文较长、本想作为文件发送」这种与事实不符
|
||||
// 的话。(`shareNoticeFor` 本身一个字没改,`sendShared` 里「超长正文 → 文件」那条裁决
|
||||
// 也一字未动——现在参与那条裁决的是**拼好之后**的整段正文,那才是真发出去的东西。)
|
||||
viewModel.sendShared(sent, shareNoticeFor(sent))
|
||||
},
|
||||
onCancel = {
|
||||
shareConfirm = null
|
||||
shareCaption = ""
|
||||
},
|
||||
share = draft.share,
|
||||
caption = draft.caption,
|
||||
onCaptionChange = viewModel::onShareCaptionChange,
|
||||
onSend = viewModel::sendShareConfirm,
|
||||
onCancel = viewModel::cancelShareConfirm,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -633,6 +633,15 @@ data class ChatUiState(
|
||||
* 它到期自动消失([SHARE_NOTICE_MILLIS]),因为它描述的是刚刚发生(或刚刚等着)的一次动作。
|
||||
*/
|
||||
val shareNotice: String? = null,
|
||||
/**
|
||||
* 面板手上正拿着的那份分享(**已经出队**)+ 用户补的那句描述;null = 没有面板。
|
||||
*
|
||||
* 它**必须**在这里(而不是一格 `remember`):内容出队之后没有归还路径,而 Activity 转屏 /
|
||||
* 深浅色切换会让组合整份重建——挂在组合上的那一格一丢,那份分享就从世界上消失了(队列里没有、
|
||||
* 界面上也没有,用户什么都没看到)。理由与「正文 + 描述为什么绑在一个值里」见
|
||||
* [ShareConfirmDraft] 的 KDoc;认领那一步见 [ChatViewModel.claimSharedContent]。
|
||||
*/
|
||||
val shareConfirm: ShareConfirmDraft? = null,
|
||||
/**
|
||||
* 附件动作的如实告知(例如「有 2 个文件超过 20 MB,没有加入」);null = 没有要说的。
|
||||
*
|
||||
@@ -816,6 +825,11 @@ class ChatViewModel(
|
||||
* - [ChatUiState.queuedForConnection]:一次都还没上过路、在等连接的那几条(内容挂在 `pending`
|
||||
* 那条气泡上,两者同生共死;这里两样都写,是为了将来只改其中一处时仍然拦得住)。
|
||||
*
|
||||
* 第四个来源 [ChatUiState.shareConfirm] 是**同上**的一格,只是更没有退路:那份分享已经从待发
|
||||
* 队列里出队(没有归还路径),把这一格交出去等于把它删掉——界面上那个确认面板会跟着这一格一起
|
||||
* 消失,而它连输入栏都回不去。它同样只在「本端唯一一份」这个意义上算数(用户已经输入到面板里的
|
||||
* 描述也算,两者本来就绑在同一个值里)。
|
||||
*
|
||||
* 流式中的回复([ChatUiState.streams])**刻意不算**:那是服务端正在生成的那一份的本地投影,
|
||||
* 本端这一格没了那一轮照旧跑完、照旧落库,重进会话会重新拉到;它也不具备「本端唯一」这条性质
|
||||
* (而它正是「回收能省下多少」的大头之一,把它算进来等于让回收在这最该省的时候不动手)。
|
||||
@@ -826,7 +840,8 @@ class ChatViewModel(
|
||||
it.draftImages.isNotEmpty() ||
|
||||
it.draftDocuments.isNotEmpty() ||
|
||||
it.pending.isNotEmpty() ||
|
||||
it.queuedForConnection.isNotEmpty()
|
||||
it.queuedForConnection.isNotEmpty() ||
|
||||
it.shareConfirm != null
|
||||
}
|
||||
|
||||
private var socket: ChatSocket? = null
|
||||
@@ -1458,6 +1473,74 @@ class ChatViewModel(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 认出「这一帧该落一份分享」([ShareLanding.Send]):手上没有待确认的那份时,去待发队列认领一条。
|
||||
*
|
||||
* ## 为什么认领与保管在同一处、而且都在 VM 里
|
||||
* `ShareIntake.take()` **不可逆**(原子出队、没有归还路径),而触发认领的那一帧随时会消失:
|
||||
* Activity 因转屏 / 深浅色切换重建会让组合整份丢掉。此前那份内容只挂在一格 `remember(sessionId)`
|
||||
* 上 ⇒ 重建之后它既不在队列里(已经出队)、也不在界面上(那格状态没了),**静默丢失**。
|
||||
* 现在认领与保管是同一件事:一取走就落进 [ChatUiState.shareConfirm],而那一格活在
|
||||
* `core.session.ChatSessionSlots` 的 `ViewModelStore` 里(由 `AppContainer` 持有,比 Activity 久)。
|
||||
*
|
||||
* ## 闸门在 `take()` **之前**
|
||||
* 重建之后新的一帧照样会走到这里,而它看见手上已经有东西 ⇒ **一个字节都不动队列**:既不取新的
|
||||
* 一条,也不会把用户正看着的那份顶掉(判据是纯函数 [shareConfirmClaim],可单测)。于是「这份分享
|
||||
* 是在哪一步、被谁、取走了几次」有一句能说清的话:**只由当前会话这个 VM**、在**手上为空的那一帧**
|
||||
* 取走**一次**——判据、出队、落地三步之间没有挂起点,所以不存在「取走了却没记住」的窗口。
|
||||
*
|
||||
* 新认领的那一份描述从空开始([ShareConfirmDraft.caption] 的初值):它是用户要附加的话、**不是**
|
||||
* 这次分享进来的正文——预填一次就会在面板里把同一句话排两遍(W53X 的第一版正是这么错的)。
|
||||
*/
|
||||
fun claimSharedContent() {
|
||||
val holding = _state.value.shareConfirm
|
||||
val claimed = shareConfirmClaim(holding) { ShareIntake.take() }
|
||||
if (claimed != null && claimed !== holding) {
|
||||
_state.update { it.copy(shareConfirm = claimed) }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 面板输入框里的那句描述变了。
|
||||
*
|
||||
* 它与正文**同生共死**(两者都在 [ChatUiState.shareConfirm] 里),所以它活得过转屏重建——
|
||||
* 用户打到一半的描述不会因为屏幕转了一下就没了。手上没有那一份时什么都不做:没有面板的地方
|
||||
* 不该攒下一句没主的描述。
|
||||
*/
|
||||
fun onShareCaptionChange(caption: String) {
|
||||
_state.update { state ->
|
||||
state.shareConfirm?.let { state.copy(shareConfirm = it.copy(caption = caption)) } ?: state
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 用户把面板撤了:这一份作废。
|
||||
*
|
||||
* 刻意**不把内容塞回待发队列**([top.yeij.cyrene.domain.share.PendingShareHolder] 的 KDoc 里
|
||||
* 写着理由):塞回去会让「用户刚作废的那一份」与「又来了新分享」变成同一件事,两种状态就再也
|
||||
* 分不开了。撤回的是**这一格**,队列里别的内容一个字节都不动。
|
||||
*/
|
||||
fun cancelShareConfirm() {
|
||||
_state.update { it.copy(shareConfirm = null) }
|
||||
}
|
||||
|
||||
/**
|
||||
* 用户点了面板上的「发送」:把**拼好的那一段正文**交给既有发送路径([sendShared])。
|
||||
*
|
||||
* 正文由纯函数 [shareConfirmBody] 拼(分享自带的正文 + 用户补的描述,两段都在时**空行**分隔),
|
||||
* 而告知按**真正发出去的那一份**算(`sent`,不是拼之前那份):正文里可能刚多了用户补的那一句,
|
||||
* 拿改之前那份去算会说出「正文较长、本想作为文件发送」这种与事实不符的话。
|
||||
*
|
||||
* 先把面板那一格清空再发是刻意的:这一次发送与面板本身互不相干,而它紧接着的那次重组里
|
||||
* 已经不该在了。
|
||||
*/
|
||||
fun sendShareConfirm() {
|
||||
val draft = _state.value.shareConfirm ?: return
|
||||
val sent = draft.share.copy(text = shareConfirmBody(draft.share, draft.caption))
|
||||
_state.update { it.copy(shareConfirm = null) }
|
||||
sendShared(sent, shareNoticeFor(sent))
|
||||
}
|
||||
|
||||
/**
|
||||
* 记一条分享提示,到期自动收走(同一条未过期就不重复刷)。
|
||||
*
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
package top.yeij.cyrene.ui.chat
|
||||
|
||||
import top.yeij.cyrene.domain.share.SharedContent
|
||||
|
||||
/**
|
||||
* 「一份**已经取走**、正等着用户确认的分享」,外加用户此刻补的那句描述。
|
||||
*
|
||||
* ## 为什么它必须活在 VM 里,而不是一格 `remember`
|
||||
* 这份内容**已经**从待发队列里出队了([top.yeij.cyrene.app.ShareIntake.take] 是原子出队、
|
||||
* **没有归还路径**),而它此前只挂在一格 `remember(sessionId)` 上——那格状态属于**那一次组合**。
|
||||
* Activity 因转屏 / 深浅色切换重建时组合整份丢掉:那份分享既不在队列里(已经出队)、也不在界面上
|
||||
* (那格状态没了),**静默消失**——用户什么都没看到,也没有任何提示。所以它待在
|
||||
* [ChatUiState.shareConfirm] 里:VM 由 `AppContainer` 那一层持有(`core.session.ChatSessionSlots`
|
||||
* 的 `ViewModelStore`),比组合与 Activity 都活得久。
|
||||
*
|
||||
* 正文([share])与描述([caption])**绑在同一个值里**是刻意的:描述说的是「你要补的那一句话」,
|
||||
* 它只对这一次确认有意义——分成两格迟早会出现「描述换了一份、正文还是上一份」。
|
||||
*
|
||||
* [caption] 初值**恒为空**:面板上半是这次分享进来的东西,下半才是用户要附加的话,预填一次就会在
|
||||
* 面板里把同一句话排两遍(W53X 的第一版正是这么错的,见 `ShareConfirmPanel` 的 KDoc)。
|
||||
*/
|
||||
data class ShareConfirmDraft(
|
||||
val share: SharedContent,
|
||||
val caption: String = "",
|
||||
)
|
||||
|
||||
/**
|
||||
* 「手上这一份 + 去待发队列认领一次」⇒ 面板这一刻该拿着哪一份(**纯函数**,可单测)。
|
||||
*
|
||||
* ## 它回答的是**不可逆那一步该不该走**
|
||||
* [take] 就是 `ShareIntake.take()`(**不可逆**),所以「什么时候允许调它」是这里唯一的正事:
|
||||
* **手上已经有东西时一个字节都不动**——连队列里新来的那条也不碰,它原地等着,面板上此刻摆的
|
||||
* 仍是用户正在看的那一份,不会被后来的一条顶掉。这就是「同一份分享只被取走一次」的落点:
|
||||
* Activity 重建之后新的一帧会再问一次同样的问题,而那时 [holding] 里已经装着那份内容
|
||||
* (它活在 VM 的状态里,活过了重建)⇒ 不会再 [take] 第二次。
|
||||
*
|
||||
* ## 为什么不是「取完之后再拿结果比一比」
|
||||
* 不能靠「第二次 [take] 拿到 null 就当没发生过」兜底:第二次跑起来拿到的要么是 null(白跑一趟),
|
||||
* 要么是**队列里的下一条**——后者会把用户正看着的那份顶掉,那是一次静默丢失。所以闸门必须在
|
||||
* [take] **之前**,而不是在它之后回滚。
|
||||
*
|
||||
* @param holding 这一刻面板手上那一份([ChatUiState.shareConfirm]);null = 手上没东西。
|
||||
* @param take 去待发队列认领一条(`ShareIntake.take()`):**只在 [holding] 为 null 时被调用**,
|
||||
* 且最多一次。返回 null(队列为空)时本函数也回 null——不造一个「什么都没有的面板」。
|
||||
* @return 面板该拿着的那一份:手上原有的**原样**返回(连实例都不换——`ShareConfirmPanel` 用它做
|
||||
* `remember` 的键,换一份等值的新对象会让预览白重建一次);手上没有时就是刚认领的那一条。
|
||||
*/
|
||||
internal fun shareConfirmClaim(
|
||||
holding: ShareConfirmDraft?,
|
||||
take: () -> SharedContent?,
|
||||
): ShareConfirmDraft? = holding ?: take()?.let { ShareConfirmDraft(share = it) }
|
||||
@@ -15,11 +15,15 @@ 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.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
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.text.style.TextOverflow
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import coil3.compose.AsyncImage
|
||||
import top.yeij.cyrene.domain.chat.DocumentChips
|
||||
@@ -247,15 +251,15 @@ const val SHARE_CONFIRM_CAPTION_PLACEHOLDER = "加一句描述…"
|
||||
* 所以预览区自己带一个高度上限 + 内部滚动:内容少时它按内容高(上限不生效),内容多时它滚动、
|
||||
* 输入框与按钮原地不动。
|
||||
*
|
||||
* ## 正文为什么只给三行
|
||||
* ## 正文给三行,但三行之外是「滚」不是「省略号」
|
||||
* 预览只是「分享进来的是什么」的一眼,而用户要的是按一下发送:把一段几万字的正文整个铺开,这个面板
|
||||
* 就成了一个阅读器。三行之外用省略号收口,不假装那是全文。
|
||||
* 就成了一个阅读器。所以正文那一窗**恒为三行高**([SHARE_PREVIEW_TEXT_LINES]),面板不随正文长短
|
||||
* 变化。
|
||||
*
|
||||
* ⚠️ W53X3 之后**面板里再没有第二个地方放着这段正文**:输入框已经改成「用户附加的描述」(初值恒为
|
||||
* 空),不再是正文的可编辑副本。也就是说超过三行的分享正文在面板上只看得到开头——**这不影响发出去的
|
||||
* 内容**([shareConfirmBody] 用的是完整的那一段,一字不丢),但它确实少了一处「发送前读全文」的地方。
|
||||
* 这是 W53X 那一版的老行为,本批只把它如实写下来、**没有顺手改**:正文块要不要能展开属于观感取舍,
|
||||
* 得由主人拍板。
|
||||
* 但三行之外**不再收成省略号**(W54D 主人拍板改掉了 W53X 的老行为):W53X3 之后**面板里再没有第二
|
||||
* 个地方放着这段正文**——输入框是「用户附加的描述」(初值恒为空),不再是正文的可编辑副本。于是截断
|
||||
* 成省略号就等于「按发送之前读不到要发的那段正文」,而用户点这颗「发送」要确认的恰恰是它。所以这一窗
|
||||
* 内部可滚([ShareConfirmBody]):**面板的高度一分不涨,正文滚着读完**。
|
||||
*/
|
||||
@Composable
|
||||
private fun ShareConfirmPreviews(preview: ShareConfirmPreview) {
|
||||
@@ -303,22 +307,70 @@ private fun ShareConfirmPreviews(preview: ShareConfirmPreview) {
|
||||
style = MiuixTheme.textStyles.footnote2,
|
||||
color = MiuixTheme.colorScheme.onSurfaceVariantSummary,
|
||||
)
|
||||
Text(
|
||||
text = preview.text,
|
||||
style = MiuixTheme.textStyles.footnote1,
|
||||
maxLines = SHARE_PREVIEW_TEXT_LINES,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
ShareConfirmBody(preview.text)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 正文那一窗:**高度恒为三行,再长就在这一窗里滚**(W54D 主人拍板,见 [ShareConfirmPreviews])。
|
||||
*
|
||||
* ## 为什么量行高,而不是写一个 dp 常量
|
||||
* 这个上限必须**正好三行**:矮了会在字身上切一刀(滚动停在一个被切开的字形上),高了就等于把「三行」
|
||||
* 这个上限悄悄放宽。而三行有多高没法提前写出来——Miuix 的 `footnote1` 只给了 13sp 的字号,
|
||||
* `lineHeight` 是 `Unspecified`,行高由系统字体的 ascent/descent 决定、随「大字体」设置一起缩放。
|
||||
* 一个 dp 常量在默认字号档上勉强对得上,到了大字号档就会矮一大截(行数不足三行、末行被切)。
|
||||
* 所以这里改为**量**:先按没有上限排一次,从 `TextLayoutResult` 上取第三行的底边当上限。
|
||||
*
|
||||
* 上限只写一次(`cap` 还是 [Dp.Infinity] 时才改),所以不会来回跳:拿到上限后重新排版,正文的宽度
|
||||
* 与样式都没变,行位置与首帧一致,量出来的值也一样。
|
||||
*
|
||||
* ⚠️ 代价是**首帧先按完整正文排一次**、下一帧才收到三行:正文很长时,这一窗理论上会有一帧高出一截、
|
||||
* 然后回落。另一条路——写死一个 dp 上限——没有这一帧,但在非默认字号档上必然切字,两害相权取了
|
||||
* 「量」。这一帧在实际渲染里看不看得见(弹层这时正在进场,可能被动画盖住)**没有在真机上验过**,
|
||||
* 留给主人真机验收。
|
||||
*
|
||||
* ## 两层滚动怎么分工
|
||||
* 这一窗自己滚(正文读完),外层 [ShareConfirmPreviews] 也滚(图片 + 文件芯片 + 这一块合起来的高度
|
||||
* 上限)。手指停在正文上时先滚正文、滚到底再把余量交给外层——`verticalScroll` 本来就走嵌套滚动,
|
||||
* 不用额外接线。
|
||||
*/
|
||||
@Composable
|
||||
private fun ShareConfirmBody(text: String) {
|
||||
val density = LocalDensity.current
|
||||
var cap by remember(text) { mutableStateOf(Dp.Infinity) }
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(max = cap)
|
||||
.verticalScroll(rememberScrollState()),
|
||||
) {
|
||||
Text(
|
||||
text = text,
|
||||
style = MiuixTheme.textStyles.footnote1,
|
||||
onTextLayout = { layout ->
|
||||
if (cap == Dp.Infinity && layout.lineCount > 0) {
|
||||
// 正文不到三行时上限就取全文的高度(`heightIn` 是个上限,短了本来也不生效)。
|
||||
val lastVisible = layout.lineCount.coerceAtMost(SHARE_PREVIEW_TEXT_LINES) - 1
|
||||
cap = with(density) { layout.getLineBottom(lastVisible).toDp() }
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** 预览区的高度上限(见 [ShareConfirmPreviews]:超出的部分靠内部滚动看)。 */
|
||||
private val SHARE_PREVIEW_MAX_HEIGHT = 150.dp
|
||||
|
||||
/** 预览里正文给几行(超出的部分收成省略号,见 [ShareConfirmPreviews] 的 KDoc)。 */
|
||||
private const val SHARE_PREVIEW_TEXT_LINES = 3
|
||||
/**
|
||||
* 预览里正文那一窗给几行(W54D 起**不再截断**,超出的部分在窗内滚动,见 [ShareConfirmBody])。
|
||||
*
|
||||
* `internal` 是为了让 `ShareConfirmTest` 能把这个数钉成字面量:面板的滚动行为单测够不着(本模块没有
|
||||
* Compose UI 测试的依赖),能钉住的只有「这一窗是三行」这条判据本身。
|
||||
*/
|
||||
internal const val SHARE_PREVIEW_TEXT_LINES = 3
|
||||
|
||||
/**
|
||||
* 分享进来的一份文件 ⇒ 待发附件。
|
||||
|
||||
@@ -20,7 +20,7 @@ enum class ShareLanding {
|
||||
*/
|
||||
TellOffline,
|
||||
|
||||
/** 连接就绪:取走一条并交给既有发送路径。 */
|
||||
/** 连接就绪:取走一条并交给确认面板(点「发送」才真的发出去)。 */
|
||||
Send,
|
||||
}
|
||||
|
||||
@@ -41,9 +41,9 @@ enum class ShareLanding {
|
||||
*
|
||||
* ## 判据与优先级
|
||||
* 1. **没有待发内容** ⇒ [ShareLanding.Nothing]:任何连接状态下都不做事、也不说话。
|
||||
* 2. **[WsState.Connected]** ⇒ [ShareLanding.Send]:取走一条并发送。它**压过**后面每一条——连接一到就该发,
|
||||
* 哪怕此前已经等够久、那句「等连上会自动发」也说过了。返回值是**单值动作**,所以「发送」与
|
||||
* 「告知」在类型上就不可能同时发生(不会既发了又报怨还没连上)。
|
||||
* 2. **[WsState.Connected]** ⇒ [ShareLanding.Send]:取走一条并交给确认面板(点「发送」才真的发)。
|
||||
* 它**压过**后面每一条——连接一到就该认领,哪怕此前已经等够久、那句「等连上会自动发」也说过了。
|
||||
* 返回值是**单值动作**,所以「认领」与「告知」在类型上就不可能同时发生(不会既取了又报怨还没连上)。
|
||||
* 3. 未就绪且**还没等够久**(`waitedLong = false`)⇒ [ShareLanding.KeepWaiting]:继续等,什么都不说
|
||||
* (刚分享进来就弹一句「还没连上」是噪声,握手正常也就几百毫秒)。
|
||||
* 4. 未就绪、已等够久、且**还没说过** ⇒ 说一次。说哪一句由连接状态分**两档**:可恢复的那三档
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package top.yeij.cyrene.core.notify
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNotEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import top.yeij.cyrene.data.api.dto.SessionSummaryDto
|
||||
@@ -18,7 +19,7 @@ class ProactiveNotifierTest {
|
||||
|
||||
private val presence = AppPresence()
|
||||
private val posted = mutableListOf<String>()
|
||||
private val notifier = ProactiveNotifier(presence = presence, post = { posted += it })
|
||||
private val notifier = ProactiveNotifier(presence = presence, post = { text, _ -> posted += text })
|
||||
|
||||
/** 一个「Activity 实例」(身份即语义,见 [AppPresence])。 */
|
||||
private val screen = Any()
|
||||
@@ -61,6 +62,30 @@ class ProactiveNotifierTest {
|
||||
assertEquals(listOf("记得喝水哦"), posted)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `each message gets its own notification id while a replay does not`() {
|
||||
// 「一事一条」在这一层就定好了:号由**去重键**派生,所以「认得出是同一条」与「落在同一条
|
||||
// 通知上」是同一个判断的两面。第二条消息必须是另一个号——同一个号时 `notify` 是替换,
|
||||
// 她连说两句用户就只看到最后一句(这就是改前的症状)。
|
||||
background()
|
||||
val ids = mutableListOf<Int>()
|
||||
val recorder = ProactiveNotifier(presence = presence, post = { _, id -> ids += id })
|
||||
|
||||
recorder.onProactiveFrame(null, id = "1371", messageId = "im-7", timestamp = null, content = "记得喝水哦")
|
||||
// 同一条的落库帧(另一条路):认得出是同一条 ⇒ 不再发一条,也就不该多出第二个号。
|
||||
recorder.onProactiveFrame(
|
||||
SessionSummaryDto.MAIN_SESSION_ID,
|
||||
id = "1371",
|
||||
messageId = null,
|
||||
timestamp = null,
|
||||
content = "记得喝水哦",
|
||||
)
|
||||
recorder.onProactiveFrame(null, id = "1372", messageId = null, timestamp = null, content = "在忙吗")
|
||||
|
||||
assertEquals(2, ids.size)
|
||||
assertNotEquals("两条不同的消息必须占两条通知", ids[0], ids[1])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a frame without a row id is deduplicated by the im message id`() {
|
||||
// 落库失败那一档:`id` / `createdAt` 一起缺席,但 IM 侧消息号还在——服务端重推同一帧
|
||||
|
||||
@@ -2,6 +2,7 @@ package top.yeij.cyrene.core.notify
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNotEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
@@ -106,10 +107,27 @@ class ProactiveNotificationShapeTest {
|
||||
|
||||
@Test
|
||||
fun `the proactive notification never replaces the keep-alive one`() {
|
||||
assertTrue(
|
||||
"同 id 的 notify 是替换:撞上就把「正在后台保持连接」那条常驻通知顶掉",
|
||||
PROACTIVE_NOTIFICATION_ID != top.yeij.cyrene.core.keepalive.KEEP_ALIVE_NOTIFICATION_ID,
|
||||
// 同 id 的 notify 是替换:撞上就把「正在后台保持连接」那条常驻通知顶掉。
|
||||
// 号现在按消息派生(见 `proactiveNotificationId`),所以这里钉的是「派生的号落在保活那条
|
||||
// 之外」——扫一遍真实形状的键(服务端行 id、IM 消息号、时间字面量、正文兜底、空键)。
|
||||
val keys = listOf(
|
||||
"0",
|
||||
"1",
|
||||
"2",
|
||||
"1371",
|
||||
"im-7",
|
||||
"4294967295",
|
||||
"2026-10-06T01:20:05.123+08:00",
|
||||
"记得喝水哦",
|
||||
"",
|
||||
)
|
||||
|
||||
keys.forEach { key ->
|
||||
assertTrue(
|
||||
"键「$key」派生出的号撞上了保活那条常驻通知",
|
||||
proactiveNotificationId(key) != top.yeij.cyrene.core.keepalive.KEEP_ALIVE_NOTIFICATION_ID,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -132,3 +150,50 @@ class ProactiveNotificationShapeTest {
|
||||
assertEquals(android.app.NotificationManager.IMPORTANCE_DEFAULT, PROACTIVE_IMPORTANCE)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 通知 id 的派生(W54A)——「一事一条」靠的就是它。
|
||||
*
|
||||
* 三条各自钉住一件**错了就肉眼可见**的事:
|
||||
* 1. **同一条消息派生两次必须同号**:重放、或实时帧与落库帧两条路都到,落的是同一条通知;
|
||||
* 2. **两条不同的消息必须不同号**:同号时 `notify` 是替换,她连说两句就只剩最后一句(改前的症状);
|
||||
* 3. **任何输入都得落在合法区间**:Android 的通知 id 是 int,而 `hashCode()` 可以为负、甚至正好是
|
||||
* `Int.MIN_VALUE`——那一个连 `abs` 都兜不住(`abs(Int.MIN_VALUE)` 在 int 域里仍是它自己)。
|
||||
*/
|
||||
class ProactiveNotificationIdTest {
|
||||
|
||||
@Test
|
||||
fun `the same message derives the same id every time`() {
|
||||
// 幂等:进程重启、两条路先后到达都不该改变「这条消息是哪一条通知」。
|
||||
val key = proactiveNotifyKey("1371", "im-7", null, "记得喝水哦")
|
||||
|
||||
assertEquals(proactiveNotificationId(key), proactiveNotificationId(key))
|
||||
// 键里那三个 id 的**优先级**也是同一件事的一半:同一条消息两条路给的是不同的入参形状,
|
||||
// 落到同一个键上才算真的稳。
|
||||
assertEquals(
|
||||
key,
|
||||
proactiveNotifyKey("1371", null, null, "记得喝水哦"),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `two different messages derive two different ids`() {
|
||||
val first = proactiveNotificationId(proactiveNotifyKey("1371", "im-7", null, "记得喝水哦"))
|
||||
val second = proactiveNotificationId(proactiveNotifyKey("1372", "im-8", null, "在忙吗"))
|
||||
|
||||
assertNotEquals("不同号是「先来的不被后来的顶掉」的全部前提", first, second)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `every derived id stays positive even when the hash is Int MIN_VALUE`() {
|
||||
// 先钉住这条用例的前提:这个字符串的 hashCode **正好是** Int.MIN_VALUE(`abs` 兜不住的那个
|
||||
// 值)。前提没了就等于这条用例什么都没验。
|
||||
val extreme = "polygenelubricants"
|
||||
assertEquals(Int.MIN_VALUE, extreme.hashCode())
|
||||
|
||||
val keys = listOf("", "0", "1371", "web", "她主动说的话", extreme)
|
||||
keys.forEach { key ->
|
||||
assertTrue("键「$key」派生出了非正号:${proactiveNotificationId(key)}", proactiveNotificationId(key) > 0)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -245,6 +245,109 @@ class ChatContentParserTest {
|
||||
assertTrue(bubbles[0].parts.any { it is ContentPart.Action })
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// 落单标签容错:宁可剥掉标签,也绝不把标签当正文
|
||||
//
|
||||
// 畸形 markup 来自服务端(客户端只做容错,不代表服务端这样发是对的)。
|
||||
// 这些断言是上面「畸形 / 未闭合」那组的另一面,两边必须同时成立。
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
/** 线上实测的一条畸形消息:第三段多一个 `<p>`,只有一个 `</p>`。 */
|
||||
private val malformedRealMessage =
|
||||
"<p><action>满意地点点头</action></p>\n" +
|
||||
"<p>真乖♪ 这可是今天最让人家放心的一句。</p>\n" +
|
||||
"<p><p>那现在——想让人家陪你看会儿晚霞,还是安静坐着发会儿呆?</p>"
|
||||
|
||||
@Test
|
||||
fun realMalformedMessageRendersCleanBubbles() {
|
||||
val bubbles = parse(malformedRealMessage)
|
||||
assertEquals(
|
||||
listOf(
|
||||
"真乖♪ 这可是今天最让人家放心的一句。",
|
||||
"那现在——想让人家陪你看会儿晚霞,还是安静坐着发会儿呆?",
|
||||
),
|
||||
bubbles.map { it.body },
|
||||
)
|
||||
// 纯旁白块照既有规则依附到下一个有正文的气泡上(head)。
|
||||
assertEquals(listOf(ContentPart.Action("满意地点点头")), bubbles[0].head)
|
||||
assertNoLooseTagInRenderedText(bubbles)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun duplicatedOpeningTagIsDropped() {
|
||||
assertEquals(listOf("正文"), parse("<p><p>正文</p>").map { it.body })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun strayClosingTagIsDropped() {
|
||||
assertEquals(listOf("正文"), parse("<p>正文</p></p>").map { it.body })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun duplicatedOpeningTagInLaterBlockKeepsBubbleSplit() {
|
||||
assertEquals(listOf("甲", "乙"), parse("<p>甲</p><p><p>乙</p>").map { it.body })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun duplicatedOpeningTagKeepsTheNoteAttachedToTheNextParagraph() {
|
||||
val bubble = parse("<p><action>点头</action></p><p><p>乙</p>").single()
|
||||
assertEquals("乙", bubble.body)
|
||||
assertEquals(listOf(ContentPart.Action("点头")), bubble.head)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun duplicatedOpeningTagWithAttributesIsDropped() {
|
||||
assertEquals(listOf("正文"), parse("<p><p class=\"line\">正文</p>").map { it.body })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun looseTagsSurroundedByWhitespaceAreDropped() {
|
||||
assertEquals(listOf("正文"), parse(" <p>\n<p>正文</p>\n</p> ").map { it.body })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun leadingAndTrailingLooseTagsLeaveNoEmptyBubble() {
|
||||
assertEquals(listOf("甲"), parse("</p><p>甲</p></p>").map { it.body })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun loneParagraphTagsProduceNoBubble() {
|
||||
assertTrue(parse("<p>").isEmpty())
|
||||
assertTrue(parse("</p>").isEmpty())
|
||||
assertTrue(parse(" <p> ").isEmpty())
|
||||
assertTrue(parse("<p></p></p>").isEmpty())
|
||||
assertTrue(parse("<p><p>").isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun unclosedTagAfterACompleteBlockIsDroppedNotShown() {
|
||||
// 段落结构已被前一个完整块定死,尾部这个没闭合的 `<p>` 只可能是标记。
|
||||
assertEquals(listOf("甲", "乙"), parse("<p>甲</p><p>乙").map { it.body })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun strayRoleTagOutsideParagraphIsDropped() {
|
||||
assertEquals(listOf("甲"), parse("<p>甲</p></action>").map { it.body })
|
||||
assertEquals(listOf("甲"), parse("</action><p>甲</p>").map { it.body })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun plainAngleBracketsAreNeverEaten() {
|
||||
assertEquals(listOf("1 < 2 且 3 > 2"), parse("1 < 2 且 3 > 2").map { it.body })
|
||||
assertEquals(listOf("<3 是想你的意思"), parse("<3 是想你的意思").map { it.body })
|
||||
assertEquals(listOf("a<b>c"), parse("a<b>c").map { it.body })
|
||||
assertEquals(listOf("<div>这是一段 HTML</div>"), parse("<div>这是一段 HTML</div>").map { it.body })
|
||||
assertEquals(listOf("<p2>这不关 p 标签的事</p2>"), parse("<p2>这不关 p 标签的事</p2>").map { it.body })
|
||||
assertEquals(listOf("<P>大小写不认</P>"), parse("<P>大小写不认</P>").map { it.body })
|
||||
assertEquals(listOf("你好<p"), parse("你好<p").map { it.body })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun plainAngleBracketsInsideAParagraphAreNeverEaten() {
|
||||
assertEquals(listOf("甲 1 < 2", "乙"), parse("<p>甲 1 < 2</p><p>乙</p>").map { it.body })
|
||||
assertEquals(listOf("甲<p2>乙"), parse("<p>甲<p2>乙</p>").map { it.body })
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// 流式增量:最终结果必须与一次性解析一致
|
||||
// ------------------------------------------------------------------
|
||||
@@ -257,6 +360,7 @@ class ChatContentParserTest {
|
||||
<p>你好*弯起眼睛*</p>
|
||||
<p>这是*强调*文字</p>
|
||||
<p>*旁白:其实偷偷开心了一下*</p>
|
||||
<p><p>那现在——想让人家陪你看会儿晚霞,还是安静坐着发会儿呆?</p>
|
||||
<p>晚安。</p>
|
||||
""".trimIndent()
|
||||
|
||||
@@ -315,6 +419,63 @@ class ChatContentParserTest {
|
||||
assertEquals(listOf("第一", "第二"), second.map { it.body })
|
||||
}
|
||||
|
||||
/** 逐字符喂实测畸形消息:中间态任何一帧都不许把标签当正文吐上屏。 */
|
||||
@Test
|
||||
fun streamingRealMalformedMessageNeverLeaksTheTag() {
|
||||
val parser = StreamingContentParser()
|
||||
for (ch in malformedRealMessage) {
|
||||
assertNoLooseTagInRenderedText(parser.onDelta(ch.toString()))
|
||||
}
|
||||
assertEquals(parse(malformedRealMessage), parser.finish())
|
||||
}
|
||||
|
||||
/** 中间态只许「加」,不许改口:已经显示过的正文不能因为后续 delta 变样(那才是跳动)。 */
|
||||
@Test
|
||||
fun streamingRealMalformedMessageOnlyGrowsWhatItAlreadyShowed() {
|
||||
val parser = StreamingContentParser()
|
||||
var shown = emptyList<String>()
|
||||
for (ch in malformedRealMessage) {
|
||||
val bodies = parser.onDelta(ch.toString()).map { it.body }
|
||||
assertTrue("气泡数回退了:$shown → $bodies", bodies.size >= shown.size)
|
||||
shown.forEachIndexed { i, body ->
|
||||
assertTrue("已显示的正文改口了:$body → ${bodies[i]}", bodies[i].startsWith(body))
|
||||
}
|
||||
shown = bodies
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun streamingRandomChunksOfMalformedMessageMatchOneShot() {
|
||||
val expected = parse(malformedRealMessage)
|
||||
val random = Random(20261006)
|
||||
repeat(25) {
|
||||
val parser = StreamingContentParser()
|
||||
var index = 0
|
||||
while (index < malformedRealMessage.length) {
|
||||
val size = 1 + random.nextInt(7)
|
||||
val end = minOf(index + size, malformedRealMessage.length)
|
||||
parser.onDelta(malformedRealMessage.substring(index, end))
|
||||
index = end
|
||||
}
|
||||
assertEquals(expected, parser.finish())
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 重复开标签必须连同它后面的正文一起被扣留到收口。
|
||||
*
|
||||
* 放宽扣留(把重复的 `<p>` 当「外层冗余」、让前缀继续推进)会把 `甲` 先吐上屏,
|
||||
* 等 `</p>` 到了再变成 `甲乙`——用户看到的字会跳,所以这里钉住「中间态一帧都不吐」。
|
||||
*/
|
||||
@Test
|
||||
fun streamingWithholdsDuplicateOpeningTagUntilItsPartnerArrives() {
|
||||
val parser = StreamingContentParser()
|
||||
for (chunk in listOf("<p>甲", "<p>乙", "</p>")) {
|
||||
assertNoLooseTagInRenderedText(parser.onDelta(chunk))
|
||||
}
|
||||
assertEquals(listOf("甲乙"), parser.finish().map { it.body })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun streamingUnclosedActionFallsBackOnFinish() {
|
||||
val parser = StreamingContentParser()
|
||||
@@ -347,4 +508,13 @@ class ChatContentParserTest {
|
||||
assertTrue(parser.onDelta("").isEmpty())
|
||||
assertEquals(listOf("新"), parser.onDelta("<p>新</p>").map { it.body })
|
||||
}
|
||||
|
||||
/** 产出的任何片段里都不许出现段落标签本身——这是落单标签容错的底线。 */
|
||||
private fun assertNoLooseTagInRenderedText(bubbles: List<ParsedBubble>) {
|
||||
for (bubble in bubbles) {
|
||||
for (part in bubble.parts) {
|
||||
assertFalse("标签漏进了正文:${part.text}", part.text.contains("<p") || part.text.contains("</p"))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
package top.yeij.cyrene.ui.chat
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertSame
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import top.yeij.cyrene.domain.share.SharedContent
|
||||
|
||||
/**
|
||||
* 「一份已经取走的分享怎么活过重建」([shareConfirmClaim])。
|
||||
*
|
||||
* 背景:分享进来的内容先弹确认面板(主人 2026-10-06 拍板:像其他 IM 一样先确认再发),而它被
|
||||
* `ShareIntake.take()` **取走**之后就没有归还路径了;此前那份内容与用户正在输入的那句描述只挂在
|
||||
* `ChatScreen` 的一格 `remember(sessionId)` 上——Activity 因转屏 / 深浅色切换重建时组合整份丢掉,
|
||||
* 那份分享既不在待发队列里、也不在界面上,**静默消失**(用户什么都没看到,也没有任何提示)。
|
||||
*
|
||||
* 本测试钉住的是**判据**(它此后活在 `ChatUiState.shareConfirm` 里,由 VM 经
|
||||
* `ChatViewModel.claimSharedContent` 调):
|
||||
*
|
||||
* 1. **手上已经有东西时,队列一个字节都不动**——重建之后新的一帧会再问一次同样的问题,而那时
|
||||
* 手上已经有那份内容 ⇒ [shareConfirmClaim] **不会**调 `take()`。这条是「同一份分享只被取走一次」
|
||||
* 的落点;少了它,第二次取走拿到的要么是 null(白跑),要么是**队列里的下一条**——后者会把用户
|
||||
* 正看着的那份顶掉,那是一次静默丢失。
|
||||
* 2. **内容与描述都原样还在**(描述与正文绑在同一个值里,两者同生共死)。
|
||||
* 3. **手上没有时才认领,且只认领一次**;队列为空时什么都不发生(不造一个「什么都没有的面板」,
|
||||
* 也不会把「空手」记成「已经取到了」——下一帧队列里有东西时照旧认领得到)。
|
||||
*
|
||||
* ## 这里怎么模拟「重建」
|
||||
* 单测里没有 Activity,也没有组合。这里的做法是把 [ShareConfirmDraft] 当成**那一格不会随组合消失
|
||||
* 的值**(它在真机上就是 `ChatUiState.shareConfirm`),然后拿 `take()` 的调用次数当探针:转屏前后
|
||||
* 是**同一个值**、而队列是**同一个队列**(第二次取走一定会取出「后来又分享的那一条」)。真机上
|
||||
* 「面板转屏后照旧长出来」这一步由主人验收——那是 Compose 的观感,JVM 单测碰不到。
|
||||
*/
|
||||
class ShareConfirmDraftTest {
|
||||
|
||||
@Test
|
||||
fun `a rebuilt screen still holds the same share and caption, and never takes twice`() {
|
||||
val first = SharedContent(text = "https://example.com/某个页面?id=1")
|
||||
val later = SharedContent(text = "后来又分享的一条")
|
||||
val queue = ArrayDeque(listOf(first, later))
|
||||
var takeCalls = 0
|
||||
fun take(): SharedContent? {
|
||||
takeCalls++
|
||||
return queue.removeFirstOrNull()
|
||||
}
|
||||
|
||||
// 第一帧:手上空空 ⇒ 认领队首那一条,描述从空开始(预填一次会把同一句话在面板里排两遍)。
|
||||
val claimed = shareConfirmClaim(holding = null, take = ::take)
|
||||
assertEquals(1, takeCalls)
|
||||
assertSame("认领的必须是队列里那条本身", first, claimed?.share)
|
||||
assertEquals("描述初值恒为空", "", claimed?.caption)
|
||||
|
||||
// 用户补了一句描述(它跟着同一格走)。
|
||||
var holding = requireNotNull(claimed).copy(caption = "这句话不错")
|
||||
|
||||
// 转屏:旧的那次组合整份丢掉,新的一帧拿着**同一格**再走到判据这里——走两遍,因为
|
||||
// effect 的键里有连接状态,重建之后它会不止重跑一次。
|
||||
repeat(2) {
|
||||
val next = shareConfirmClaim(holding = holding, take = ::take)
|
||||
assertSame("重建之后内容必须还是那一份", holding.share, next?.share)
|
||||
assertEquals("重建之后用户输入到一半的描述也不能丢", "这句话不错", next?.caption)
|
||||
holding = requireNotNull(next)
|
||||
}
|
||||
|
||||
// 关键的一条:`take()` 只在第一帧跑过那一次。第二次跑起来会取出「后来又分享的一条」——
|
||||
// 那正是「用户正看着 A、面板里的内容却被换成了 B」的来处。
|
||||
assertEquals("手上已经有东西时绝不再取一次", 1, takeCalls)
|
||||
assertEquals("队列里那一条也不许被白白取走", listOf(later), queue.toList())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an empty queue claims nothing, and the next frame still can`() {
|
||||
val queue = ArrayDeque<SharedContent>()
|
||||
var takeCalls = 0
|
||||
fun take(): SharedContent? {
|
||||
takeCalls += 1
|
||||
return queue.removeFirstOrNull()
|
||||
}
|
||||
|
||||
// 队列为空:什么都不发生——不造一个空的「面板」,也不把「空手」记成「已经取到了」。
|
||||
assertNull(shareConfirmClaim(holding = null, take = ::take))
|
||||
assertEquals(1, takeCalls)
|
||||
|
||||
// 下一帧队列里有了东西:照旧认领得到(这一档正是「面板开着时新来的分享原地在队列里等,
|
||||
// 等这一份确认完再接上」)。
|
||||
queue += SharedContent(text = "等面板关掉才轮到我")
|
||||
val claimed = shareConfirmClaim(holding = null, take = ::take)
|
||||
assertEquals("等面板关掉才轮到我", claimed?.share?.text)
|
||||
assertEquals(2, takeCalls)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a share that could not be read is handed over as it is`() {
|
||||
// 认领只做出队这一件事:内容什么样都**原样**交给面板。`contentUnreadable` 那条分享(对方给了
|
||||
// 东西、本端连地址都没认下来)照样要弹面板——它的预览是空的,但「要不要说一句、说什么」由
|
||||
// `shareNoticeFor` 与面板自己负责,判据不该在这里替它筛一遍(筛掉就成了一次静默丢弃)。
|
||||
val unreadable = SharedContent(contentUnreadable = true)
|
||||
val claimed = shareConfirmClaim(holding = null) { unreadable }
|
||||
|
||||
assertSame(unreadable, claimed?.share)
|
||||
assertTrue(
|
||||
"原样交出去,丢件 / 读不到这些账一个字段都不动",
|
||||
requireNotNull(claimed).share.contentUnreadable,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -32,6 +32,13 @@ import top.yeij.cyrene.domain.share.SharedDocument
|
||||
* ⚠️ 「输入框初值为空」本身是 Compose 状态(`ChatScreen` 里那格 `remember`),单测碰不到——它由
|
||||
* 主人真机验收(输入框空着、预览里有正文)。这份测试能钉住的是它的**后果**:描述为空时,拼出来的
|
||||
* 正文就是分享正文本身、而且只出现**一次**(见 `the caption is not a copy of the shared text`)。
|
||||
*
|
||||
* ## W54D 又改掉了什么
|
||||
* 正文那一窗从「三行 + 省略号」改成「三行窗口 + 内部滚动」(主人拍板):面板里再没有第二处放着这段
|
||||
* 正文,收成省略号就等于「按发送之前读不到要发的那段正文」。滚动是渲染层的事、单测够不着(本模块
|
||||
* 没有 Compose UI 测试的依赖),所以这里钉的是它的两个前提——**正文完整地进到预览**(一个字都不少)
|
||||
* 与**这一窗仍然是三行**(面板不随正文变高),见 `the whole body reaches the preview …` 与
|
||||
* `the preview window stays three lines tall`。
|
||||
*/
|
||||
class ShareConfirmTest {
|
||||
|
||||
@@ -54,6 +61,33 @@ class ShareConfirmTest {
|
||||
assertFalse(preview.isEmpty)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the whole body reaches the preview even when it is longer than the window`() {
|
||||
val long = (1..40).joinToString("\n") { "第 $it 行" }
|
||||
|
||||
// W54D 主人拍板:预览的正文那一窗恒为三行高,但**里面能滚**——三行之外不再收成省略号。这条钉
|
||||
// 的是那一窗的输入:纯函数层把**完整**的正文交给渲染层(这一段一个字都不许在这里少),少了就
|
||||
// 一定读不到——面板里再没有第二处放着这段正文(输入框只剩「用户附加的描述」)。
|
||||
val preview = shareConfirmPreview(SharedContent(text = long))
|
||||
assertEquals(long, preview.text)
|
||||
assertEquals(40, preview.text.lines().size)
|
||||
assertTrue(
|
||||
"这一档必须真的超过三行,否则上面那条断言什么也证明不了",
|
||||
preview.text.lines().size > SHARE_PREVIEW_TEXT_LINES,
|
||||
)
|
||||
|
||||
// 发出去的那一段同样一个字不少(两边同源,见 [shareConfirmBody])。
|
||||
assertEquals(long, shareConfirmBody(SharedContent(text = long), ""))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the preview window stays three lines tall`() {
|
||||
// 3 这个字面量就是这条测试的全部内容:滚动是渲染层的事、单测够不着(本模块没有 Compose UI
|
||||
// 测试的依赖),能钉住的只有「这一窗是三行」这条判据本身。改大 ⇒ 面板会随长正文变高(主人
|
||||
// 拍板的高度上限没了);改小 ⇒ 正文的可见窗口白缩一行。
|
||||
assertEquals(3, SHARE_PREVIEW_TEXT_LINES)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `blank pieces count as nothing to show`() {
|
||||
val preview = shareConfirmPreview(
|
||||
|
||||
Reference in new issue
Block a user