fix(share): 认下字符串形态的 EXTRA_STREAM,并在一条内容都没读进来时如实告知

为什么字符串形态必须认
真实世界里 EXTRA_STREAM 有三种形态:规范推荐的 Uri(多选时 ArrayList<Uri>)、String[]、
ArrayList<String>。Android 上确实有一部分发送方(历史遗留 / 跨平台壳应用)在用后两种,而本端
此前完全认不出:getParcelableExtra 遇到 String[] 只往 logcat 打一句 ClassCastException 再回
null,getParcelableArrayListExtra 同理。实测 am start ... --esa android.intent.extra.STREAM
uri1,uri2 投两张图进来就是这个样子。

为什么静默是 bug
上面那次分享的结局是:后端零请求、库里零新增、界面连一句软告知都没有。用户点了分享,没有任何
东西告诉他"没读进来"还是"在路上"——这是最糟的那种失败形态,所以本次一并消灭:带着内容迹象却
一条可用内容都没提取出来时,走既有的柔和通道说一句"这次分享的内容没能读进来,没有发送任何内容"。
EXTRA_TEXT 与 EXTRA_STREAM 都为空(用户确实没分享任何东西)时仍然保持静默,那条边界有测试钉住。

做了什么
- ShareIntake 改成读 EXTRA_STREAM 的原始值再按形态分派(Bundle.get 不做类型检查),Uri / Uri
  数组 / String / CharSequence / 数组 / 列表一并摊平;顺带不再往 logcat 丢那两行 ClassCastException。
- 字符串形态只认 content://:那是本端新开的一条读入口,送进来的只是一段文本,没有任何框架级担保。
  file:// 等于让发送方替本端指定一个本地路径、由本端拿自己的权限去读它;http(s):// 是网络地址,
  收下它等于让第三方的一行文本指定一个 URL 由本应用代取。被拒的条目逐条跳过,不让整次分享失败。
- 优先顺序与去重不变:EXTRA_STREAM 的规范形态 -> 字符串兼容形态 -> ClipData(仅前者全为空时兜底),
  trim / 去空 / 去重,先来的先算;重复项既不算截断,也不把真图挤到上限之外。
- 「带着内容迹象」的判据取 EXTRA_STREAM 的原始值(由调用方读了原始值判出来,含形态本端认不出的那种),
  不是「认下来的地址」——否则「有值但一条都认不出」恰恰会被判成「什么都没分享」而继续静默。
- SharedContent 新增 contentUnreadable 与 worthDelivering:后者是唯一判据,ShareIntake 用它决定
  要不要往下接,shareNoticeFor 用它守卫"什么都没有就一个字都不说"那条边界,两处不会走岔。
- 分类(图片 vs 文档)、落 cache、截断与拷贝失败的既有语义一概不动。

测试
- ShareExtractionTest:字符串形态的顺序与逐条跳过、两种形态并存去重、scheme 白名单表、
  读不进来 / 形态认不出 / 静默三条边界。
- ShareStreamExtrasTest(新):按真实对象验形态分派(String[] / ArrayList<String> / String /
  CharSequence / 认不出的形态不猜但照样算带着内容),并接到提取层验一次端到端。
- ShareNoticeTest(新):"没能读进来"成句且不说成发送失败、正常落地一个字不说、空白分享仍然静默。

assembleDebug 与 testDebugUnitTest 全绿(1084 tests / 0 failures / 0 errors)。
This commit is contained in:
AskaEth committed 2026-10-05 13:05:20 +08:00
1 parent ef257c3167
commit 6cdf111419
6 files changed
+663 -29

No files matched your search

@@ -0,0 +1,75 @@
package top.yeij.cyrene.ui.chat
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.SharedContent
import top.yeij.cyrene.domain.share.SharedDocument
/**
* 分享落地那条**柔和的告知**([shareNoticeFor],画面上走 `Banner` 的 `onSurfaceVariantSummary`,
* 不是错误横幅)。
*
* 背景:主人实机分享两张图进来「什么都没发生」——没有消息发出去,界面也没有任何告知。第一处来处是
* 提取层(`EXTRA_STREAM` 的字符串形态认不出,见 `ShareExtractionTest` / `ShareStreamExtrasTest`);
* 第二处就在这里:一条内容谁都没读进来的分享,此前既不落盘、也不说一句话。本测试钉住三件事:
*
* 1. 「内容没能读进来」要成句,且**不说成发送失败**——失败的是读取,不是发送;
* 2. 正常落地(一条文本 / 一张图 / 一个文件)一个字都不说;
* 3. 边界:整条分享里什么都没有时**仍然静默**(那种情况没什么可说的,说了才是噪声)。
*/
class ShareNoticeTest {
@Test
fun `a share nothing could be read from says so`() {
assertEquals(SHARE_UNREADABLE_NOTICE, shareNoticeFor(SharedContent(contentUnreadable = true)))
}
@Test
fun `the unreadable notice never says the send failed`() {
assertTrue(SHARE_UNREADABLE_NOTICE.contains("没能读进来"))
assertFalse(SHARE_UNREADABLE_NOTICE.contains("失败"))
}
@Test
fun `an unreadable share never also claims to be an empty share`() {
val said = shareNoticeFor(SharedContent(contentUnreadable = true))!!
assertFalse("两句混在一起会让人以为东西还在路上", said.contains("分享的内容是空的"))
}
@Test
fun `a normal share says nothing`() {
assertNull(shareNoticeFor(SharedContent(text = "看这个")))
assertNull(shareNoticeFor(SharedContent(images = listOf("file:///cache/share-1.png"))))
assertNull(
shareNoticeFor(SharedContent(documents = listOf(SharedDocument(uri = "file:///cache/share-0.txt")))),
)
}
@Test
fun `a share that carries nothing at all stays silent`() {
// 边界:既没有文本、`EXTRA_STREAM` 也没有值、`ClipData` 也没有条目。
assertNull(shareNoticeFor(SharedContent()))
assertNull(shareNoticeFor(ShareRules.extract(action = ShareRules.ACTION_SEND)))
}
@Test
fun `the existing wording for a truncated share is unchanged`() {
assertEquals(
"分享的图片超过 ${ShareRules.MAX_IMAGES} 张,只发送前 1 张",
shareNoticeFor(SharedContent(images = listOf("file:///cache/share-1.png"), truncatedImages = 3)),
)
}
@Test
fun `dropped content next to an empty body says both things`() {
val said = shareNoticeFor(SharedContent(failedImages = 2))!!
assertTrue(said.contains("有 2 张图片读不到,未发送"))
assertTrue(said.contains("分享的内容是空的,没有可发送的内容"))
}
}