From 4fe5509c24c46159e0ec74a6ef63b0b347807f42 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Wed, 7 Oct 2026 11:41:55 +0800 Subject: [PATCH] =?UTF-8?q?feat(share):=20=E6=AD=A3=E6=96=87=E5=9B=9E?= =?UTF-8?q?=E8=90=BD=E9=93=BE=20EXTRA=5FTEXT=20=E2=86=92=20EXTRA=5FSUBJECT?= =?UTF-8?q?=20=E2=86=92=20EXTRA=5FTITLE=EF=BC=88=E4=BF=AE=E3=80=8C?= =?UTF-8?q?=E5=8F=AA=E6=9C=89=E6=A0=87=E9=A2=98=E7=9A=84=E5=88=86=E4=BA=AB?= =?UTF-8?q?=E6=95=B4=E6=9D=A1=E9=9D=99=E9=BB=98=E3=80=8D=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit W61 分享路径审查登记的那个缺口:有些发送方不填 `EXTRA_TEXT`,内容只在 `EXTRA_SUBJECT`(少数在 `EXTRA_TITLE`)里,而本端只读 `EXTRA_TEXT` ⇒ 那次分享 **整条静默**:用户点了分享,界面毫无反应、后端零请求,连 `contentUnreadable` 那条「读不进来」的告知也够不着(它的判据里没有这两个 extra)。 改动(按主人拍板的方向): 1. **回落链**:`EXTRA_TEXT` → `EXTRA_SUBJECT` → `EXTRA_TITLE`,取第一个非空白的 当正文(归一化口径与原来取 text 时完全一致)。`EXTRA_TEXT` 非空时它一定赢 ⇒ 既有情形逐字不变(`ShareTextFallbackTest` 有专门的用例钉住这一点)。 2. **来源如实登记**:`SharedContent.textSource`(`ShareTextSource.Text/Subject/Title`) 记下正文是从哪个 extra 来的;渲染层据此说一句「这段文字来自对方的分享标题 (对方没有提供正文)」——回落来的正文与真正文长得一模一样,用户只能靠这一句分辨。 ⚠️ 那句话只出现在界面上,**绝不进正文**(面板预览与发出去的消息逐字相同,有用例钉住)。 面板那边的接线需改 `ShareConfirmPanel.kt`(本批的**红线**:另一支正在改它), 所以这一句先挂在**认领那一刻**的柔和告知通道上(`ChatViewModel.claimSharedContent` → `showShareNotice`):它必须在用户点「发送」之前说。 3. **三个 extra 都空 / 都读不出来 ⇒ 走「读不进来」告知,绝不静默**:判据加了 `textPresent`(「三个里至少一个躺着原始值」由读了原始值的那一层报进来,与既有的 `streamPresent` 同一个形态)。「连值都没有」(误触分享)照旧静默 —— 边界没放宽。 4. **读取防御式**:四个 extra 统一读**原始值**再按形态分派(`Intent.rawExtra`,带 `runCatching`)。核对过 AOSP `android/os/BaseBundle.java`:`getCharSequence` 自己 catch `ClassCastException` 回 null(**类型不符不抛**),会抛的是 `unparcel()` 的 `BadParcelableException`(畸形 bundle)——那一路由 `runCatching` 挡住,不让异常从 `accept` 冒出去。不引入新依赖,UI 侧零新增控件。 测试:新增 `ShareTextFallbackTest`(11)、`ShareTextExtrasTest`(8)、`ShareNoticeTest` +4; 全量 testDebugUnitTest 123 suites / 1494 tests / 0 failures / 0 errors (基线 121/1471/0/0);`assembleDebug` 通过(--no-build-cache 真跑)。 --- .../kotlin/top/yeij/cyrene/app/ShareIntake.kt | 102 ++++++++++- .../yeij/cyrene/domain/share/SharePayload.kt | 120 +++++++++++-- .../top/yeij/cyrene/ui/chat/ChatViewModel.kt | 7 + .../yeij/cyrene/ui/chat/ShareTextOrigin.kt | 43 +++++ .../yeij/cyrene/app/ShareTextExtrasTest.kt | 143 +++++++++++++++ .../domain/share/ShareTextFallbackTest.kt | 170 ++++++++++++++++++ .../yeij/cyrene/ui/chat/ShareNoticeTest.kt | 61 +++++++ 7 files changed, 625 insertions(+), 21 deletions(-) create mode 100644 app/src/main/kotlin/top/yeij/cyrene/ui/chat/ShareTextOrigin.kt create mode 100644 app/src/test/kotlin/top/yeij/cyrene/app/ShareTextExtrasTest.kt create mode 100644 app/src/test/kotlin/top/yeij/cyrene/domain/share/ShareTextFallbackTest.kt diff --git a/app/src/main/kotlin/top/yeij/cyrene/app/ShareIntake.kt b/app/src/main/kotlin/top/yeij/cyrene/app/ShareIntake.kt index 9585623..67fef88 100644 --- a/app/src/main/kotlin/top/yeij/cyrene/app/ShareIntake.kt +++ b/app/src/main/kotlin/top/yeij/cyrene/app/ShareIntake.kt @@ -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, +) diff --git a/app/src/main/kotlin/top/yeij/cyrene/domain/share/SharePayload.kt b/app/src/main/kotlin/top/yeij/cyrene/domain/share/SharePayload.kt index 7371743..3d42d8b 100644 --- a/app/src/main/kotlin/top/yeij/cyrene/domain/share/SharePayload.kt +++ b/app/src/main/kotlin/top/yeij/cyrene/domain/share/SharePayload.kt @@ -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 = 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? = null, streamStrings: List = emptyList(), streamPresent: Boolean = false, + textPresent: Boolean = false, clipUris: List = 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()), ) } diff --git a/app/src/main/kotlin/top/yeij/cyrene/ui/chat/ChatViewModel.kt b/app/src/main/kotlin/top/yeij/cyrene/ui/chat/ChatViewModel.kt index 17ae64f..3b4e659 100644 --- a/app/src/main/kotlin/top/yeij/cyrene/ui/chat/ChatViewModel.kt +++ b/app/src/main/kotlin/top/yeij/cyrene/ui/chat/ChatViewModel.kt @@ -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) } } diff --git a/app/src/main/kotlin/top/yeij/cyrene/ui/chat/ShareTextOrigin.kt b/app/src/main/kotlin/top/yeij/cyrene/ui/chat/ShareTextOrigin.kt new file mode 100644 index 0000000..e2e5134 --- /dev/null +++ b/app/src/main/kotlin/top/yeij/cyrene/ui/chat/ShareTextOrigin.kt @@ -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 = "这段文字来自对方的分享标题(对方没有提供正文)" diff --git a/app/src/test/kotlin/top/yeij/cyrene/app/ShareTextExtrasTest.kt b/app/src/test/kotlin/top/yeij/cyrene/app/ShareTextExtrasTest.kt new file mode 100644 index 0000000..3c15fc3 --- /dev/null +++ b/app/src/test/kotlin/top/yeij/cyrene/app/ShareTextExtrasTest.kt @@ -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) + } +} diff --git a/app/src/test/kotlin/top/yeij/cyrene/domain/share/ShareTextFallbackTest.kt b/app/src/test/kotlin/top/yeij/cyrene/domain/share/ShareTextFallbackTest.kt new file mode 100644 index 0000000..4512507 --- /dev/null +++ b/app/src/test/kotlin/top/yeij/cyrene/domain/share/ShareTextFallbackTest.kt @@ -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) + } +} diff --git a/app/src/test/kotlin/top/yeij/cyrene/ui/chat/ShareNoticeTest.kt b/app/src/test/kotlin/top/yeij/cyrene/ui/chat/ShareNoticeTest.kt index 3b7c355..2f7cd1a 100644 --- a/app/src/test/kotlin/top/yeij/cyrene/ui/chat/ShareNoticeTest.kt +++ b/app/src/test/kotlin/top/yeij/cyrene/ui/chat/ShareNoticeTest.kt @@ -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)) + } }