feat(share): 正文回落链 EXTRA_TEXT → EXTRA_SUBJECT → EXTRA_TITLE(修「只有标题的分享整条静默」)
W61 分享路径审查登记的那个缺口:有些发送方不填 `EXTRA_TEXT`,内容只在
`EXTRA_SUBJECT`(少数在 `EXTRA_TITLE`)里,而本端只读 `EXTRA_TEXT` ⇒ 那次分享
**整条静默**:用户点了分享,界面毫无反应、后端零请求,连 `contentUnreadable`
那条「读不进来」的告知也够不着(它的判据里没有这两个 extra)。
改动(按主人拍板的方向):
1. **回落链**:`EXTRA_TEXT` → `EXTRA_SUBJECT` → `EXTRA_TITLE`,取第一个非空白的
当正文(归一化口径与原来取 text 时完全一致)。`EXTRA_TEXT` 非空时它一定赢 ⇒
既有情形逐字不变(`ShareTextFallbackTest` 有专门的用例钉住这一点)。
2. **来源如实登记**:`SharedContent.textSource`(`ShareTextSource.Text/Subject/Title`)
记下正文是从哪个 extra 来的;渲染层据此说一句「这段文字来自对方的分享标题
(对方没有提供正文)」——回落来的正文与真正文长得一模一样,用户只能靠这一句分辨。
⚠️ 那句话只出现在界面上,**绝不进正文**(面板预览与发出去的消息逐字相同,有用例钉住)。
面板那边的接线需改 `ShareConfirmPanel.kt`(本批的**红线**:另一支正在改它),
所以这一句先挂在**认领那一刻**的柔和告知通道上(`ChatViewModel.claimSharedContent`
→ `showShareNotice`):它必须在用户点「发送」之前说。
3. **三个 extra 都空 / 都读不出来 ⇒ 走「读不进来」告知,绝不静默**:判据加了
`textPresent`(「三个里至少一个躺着原始值」由读了原始值的那一层报进来,与既有的
`streamPresent` 同一个形态)。「连值都没有」(误触分享)照旧静默 —— 边界没放宽。
4. **读取防御式**:四个 extra 统一读**原始值**再按形态分派(`Intent.rawExtra`,带
`runCatching`)。核对过 AOSP `android/os/BaseBundle.java`:`getCharSequence` 自己
catch `ClassCastException` 回 null(**类型不符不抛**),会抛的是 `unparcel()` 的
`BadParcelableException`(畸形 bundle)——那一路由 `runCatching` 挡住,不让异常从
`accept` 冒出去。不引入新依赖,UI 侧零新增控件。
测试:新增 `ShareTextFallbackTest`(11)、`ShareTextExtrasTest`(8)、`ShareNoticeTest` +4;
全量 testDebugUnitTest 123 suites / 1494 tests / 0 failures / 0 errors
(基线 121/1471/0/0);`assembleDebug` 通过(--no-build-cache 真跑)。
This commit is contained in:
1 parent
541a70f126
commit
4fe5509c24
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))
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user