merge: W64 —— 分享回落链(EXTRA_TEXT → SUBJECT → TITLE)+ 三者都空时明确告知而不静默(来源提示走柔和横幅、绝不进正文)

This commit is contained in:
AskaEth committed 2026-10-07 11:54:44 +08:00
commit 3b9936e212
7 files changed
+625 -21

No files matched your search

@@ -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,
)
@@ -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)
}
}
@@ -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 = "这段文字来自对方的分享标题(对方没有提供正文)"
@@ -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)
}
}
@@ -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)
}
}
@@ -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))
}
}