merge: W37 —— 分享认下字符串形态的 EXTRA_STREAM(String[] / ArrayList<String>),并在一条内容都没读进来时如实告知,不再静默
This commit is contained in:
commit
a2350a6d5f
6 files changed
+663
-29
No files matched your search
@@ -90,20 +90,31 @@ object ShareIntake {
|
||||
*/
|
||||
fun accept(context: Context, intent: Intent) {
|
||||
val appContext = context.applicationContext
|
||||
// EXTRA_STREAM 一次读原始值、按**形态**分派(规范形态 + 字符串兼容形态),见 [shareStreamExtrasOf]:
|
||||
// 挨个 getParcelableExtra / getParcelableArrayListExtra 去问的话,遇到字符串形态只会拿到两行
|
||||
// `ClassCastException` 加一个 `null`——那正是「分享进来什么都没发生」的来处。
|
||||
val rawStream = intent.streamExtraValue()
|
||||
val streamExtras = shareStreamExtrasOf(rawStream)
|
||||
val extracted = ShareRules.extract(
|
||||
action = intent.action,
|
||||
text = intent.getCharSequenceExtra(Intent.EXTRA_TEXT),
|
||||
stream = intent.streamUri(),
|
||||
streams = intent.streamUris(),
|
||||
// 单值 / 列表两个字段都喂进去(本端不再按 action 猜该读哪个,见 [ShareRules.extract])。
|
||||
stream = streamExtras.uriForms.firstOrNull(),
|
||||
streams = streamExtras.uriForms,
|
||||
streamStrings = streamExtras.stringForms,
|
||||
// 「里面躺着值」这件事只有这里知道(纯函数看不到原始值):形态认不出的那种也算带着内容,
|
||||
// 否则「分享点下去什么都没发生」会从那条缝里回来。
|
||||
streamPresent = rawStream != null,
|
||||
clipUris = intent.clipUris(),
|
||||
// 分流就发生在这一处:读不到类型(null)也按文档路走,见类 KDoc。
|
||||
mimeOf = { uri ->
|
||||
runCatching { context.contentResolver.getType(Uri.parse(uri)) }.getOrNull()
|
||||
},
|
||||
)
|
||||
// 没有任何文本、也没有任何待落地的 URI ⇒ 这条分享里什么都没有(丢件计数在提取期恒为 0;
|
||||
// 保留原来那条防御性判断,只是把「文件」那一档一并算上)。
|
||||
if (extracted.isEmpty && extracted.droppedImages == 0 && extracted.droppedDocuments == 0) return
|
||||
// 没什么值得往下走的就到此为止(判据是 [SharedContent.worthDelivering]:有可落地的内容,
|
||||
// 或者有丢件 / 读不进来要对用户说清的事)。整条分享里什么都没有时静默是对的;而
|
||||
// 「带着内容迹象却一条都没读进来」必须继续往下走——那条告知在下游 `shareNoticeFor` 里等它。
|
||||
if (!extracted.worthDelivering) return
|
||||
|
||||
scope.launch {
|
||||
val dir = shareDir(appContext).apply { mkdirs() }
|
||||
@@ -264,15 +275,72 @@ object ShareIntake {
|
||||
}
|
||||
}
|
||||
|
||||
private fun Intent.streamUri(): String? =
|
||||
getParcelableExtra(Intent.EXTRA_STREAM, Uri::class.java)?.toString()
|
||||
/**
|
||||
* `EXTRA_STREAM` 的原始值(**故意读原始值**,理由见 [ShareStreamExtras])。
|
||||
*
|
||||
* `Bundle.get` 在 API 33 起被打上废弃:它绕过类型安全。这里要的正是「绕过类型安全」——要判的
|
||||
* 就是「这个值到底是什么形态」。逐类型的 `getXxxExtra` 遇到别的形态只会往 logcat 打一句
|
||||
* `ClassCastException` 再把值扔掉,那正是本次要修的那个静默。
|
||||
*/
|
||||
@Suppress("DEPRECATION")
|
||||
private fun Intent.streamExtraValue(): Any? = extras?.get(Intent.EXTRA_STREAM)
|
||||
|
||||
private fun Intent.streamUris(): List<String> =
|
||||
getParcelableArrayListExtra(Intent.EXTRA_STREAM, Uri::class.java).orEmpty().map { it.toString() }
|
||||
|
||||
/** `ClipData` 里的 URI(部分 App 只填它);纯文本条目的 URI 为空,天然被过滤掉。 */
|
||||
private fun Intent.clipUris(): List<String> {
|
||||
/**
|
||||
* `ClipData` 各条目的 URI(部分 App 只填它);纯文本条目的 URI 是 `null`。
|
||||
*
|
||||
* 这里刻意**保留**没有 URI 的条目(用 `null` 占位)而不是就地滤掉:条目本身就是「这次分享带着
|
||||
* 内容」的证据——只有纯文本条目时本端一个字节都读不到(本端不读 `ClipData` 里的文本),那种
|
||||
* 情形必须能说出口,见 [SharedContent.contentUnreadable]。
|
||||
*/
|
||||
private fun Intent.clipUris(): List<String?> {
|
||||
val clip = clipData ?: return emptyList()
|
||||
return (0 until clip.itemCount).mapNotNull { clip.getItemAt(it).uri?.toString() }
|
||||
return (0 until clip.itemCount).map { clip.getItemAt(it).uri?.toString() }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* `EXTRA_STREAM` 一条**原始值** ⇒ 里面能读出来的地址,按形态分成两组。
|
||||
*
|
||||
* ## 为什么读原始值而不是挨个 `getXxxExtra` 去问
|
||||
* `EXTRA_STREAM` 在真实世界里有三种形态:规范推荐的 `Uri`(多选时 `ArrayList<Uri>`)、`String[]`、
|
||||
* `ArrayList<String>`。后两种(历史遗留 / 跨平台壳应用常见)此前**完全认不出**:`getParcelableExtra`
|
||||
* 对 `String[]` 只会往 logcat 打一句
|
||||
* `ClassCastException: Cannot cast [Ljava.lang.String; to android.net.Uri` 然后回 `null`,
|
||||
* `getParcelableArrayListExtra` 同理——于是整次分享「什么都没发生」,连一句告知都没有。读原始值再按
|
||||
* 类型分派是一次判完三种形态,顺带也不再往 logcat 里丢那两行噪声(`Bundle.get` 不做类型检查,
|
||||
* 正是这里需要的;`getXxxExtra` 那一套是查完再 cast,查不中就把值扔了)。
|
||||
*
|
||||
* ## 形态决定信任级别
|
||||
* - [uriForms]:`Uri`,以及数组 / 列表里的 `Uri`。对方是把 URI **对象**写进去的,这是规范路径;
|
||||
* 本端维持既有口径不动(不看 scheme)。
|
||||
* - [stringForms]:`String` / `CharSequence` 及其数组 / 列表。这是本端新开的一条读入口,所以开得窄
|
||||
* ——能不能用由 [ShareRules.stringStreamUri] 逐条判(只认 `content://`)。
|
||||
*
|
||||
* 认不出形态的原始值(别的 `Parcelable` / 数字 / 映射……)**不猜**:两边都不收。
|
||||
*/
|
||||
internal data class ShareStreamExtras(
|
||||
val uriForms: List<String>,
|
||||
val stringForms: List<String>,
|
||||
)
|
||||
|
||||
/** 见 [ShareStreamExtras]:把 `EXTRA_STREAM` 的原始值按形态摊平成两组地址(纯逻辑,可单测)。 */
|
||||
internal fun shareStreamExtrasOf(raw: Any?): ShareStreamExtras {
|
||||
val uriForms = mutableListOf<String>()
|
||||
val stringForms = mutableListOf<String>()
|
||||
|
||||
fun collect(value: Any?) {
|
||||
when (value) {
|
||||
null -> Unit
|
||||
is Uri -> uriForms += value.toString()
|
||||
// 含 String。`Spanned` 之类也走这一条:它照样要过 scheme 白名单,不是「因为它是
|
||||
// CharSequence 就信」。
|
||||
is CharSequence -> stringForms += value.toString()
|
||||
is Array<*> -> value.forEach { collect(it) }
|
||||
is Iterable<*> -> value.forEach { collect(it) }
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
collect(raw)
|
||||
|
||||
return ShareStreamExtras(uriForms = uriForms, stringForms = stringForms)
|
||||
}
|
||||
@@ -9,6 +9,9 @@ package top.yeij.cyrene.domain.share
|
||||
*
|
||||
* 丢件计数按**两条路分开记**(图片 / 文档),因为「本端的接收上限」与「对方给的东西读不到」
|
||||
* 是两句不同的话,而图片与文档的上限也不是同一个数。分享进来的内容**不许静默少发**。
|
||||
*
|
||||
* 还有第三件要说的:[contentUnreadable]——对方给了东西、本端**连地址都没认下来**。它连丢件计数
|
||||
* 都留不下(本端一条都没接收),所以单独一格;否则「分享点下去什么都不发生」正是它造成的。
|
||||
*/
|
||||
data class SharedContent(
|
||||
val text: String = "",
|
||||
@@ -27,6 +30,16 @@ data class SharedContent(
|
||||
val truncatedDocuments: Int = 0,
|
||||
/** 已接收但**拷贝失败**的文件条数。 */
|
||||
val failedDocuments: Int = 0,
|
||||
/**
|
||||
* 这次分享**带着内容迹象**(`EXTRA_STREAM` 有值、或 `ClipData` 有条目),却一条可用内容都没
|
||||
* 提取出来。
|
||||
*
|
||||
* 与 [failedImages] / [failedDocuments] **不是**同一件事:那两个说的是「本端已经认下来的地址
|
||||
* 读不到」(对方给的东西本端拿不动),这里说的是「本端连地址都没认下来」(多为发送方把
|
||||
* `EXTRA_STREAM` 塞成了本端认不出的形态,或用的是本端不读的 scheme)。它是**唯一**一种既没有
|
||||
* 内容、也没有丢件计数的失败形态,所以要单独记一格,否则结局就是「用户点了分享、界面毫无反应」。
|
||||
*/
|
||||
val contentUnreadable: Boolean = false,
|
||||
) {
|
||||
/** 没能落地的总条数。 */
|
||||
val droppedImages: Int get() = truncatedImages + failedImages
|
||||
@@ -36,6 +49,17 @@ data class SharedContent(
|
||||
|
||||
/** 没有任何可发送的内容(此时只剩下丢件计数需要告知)。 */
|
||||
val isEmpty: Boolean get() = text.isBlank() && images.isEmpty() && documents.isEmpty()
|
||||
|
||||
/**
|
||||
* 这条分享值不值得继续走:有可落地的内容,或有要对用户说清的事(丢件计数 / 读不进来)。
|
||||
*
|
||||
* 判据只此一处:`ShareIntake.accept` 用它决定要不要往下接,`shareNoticeFor` 用它守卫「什么都没有
|
||||
* 的分享一个字都不说」那条边界——两处必须同一条,否则会出现「接了却不说」或「说了却没接」。
|
||||
* 整条分享里什么都没有(`EXTRA_TEXT` 没有值、`EXTRA_STREAM` 也没有值、`ClipData` 也没有条目)
|
||||
* 时它是 `false`:那次分享确实无话可说,静默才是对的。
|
||||
*/
|
||||
val worthDelivering: Boolean
|
||||
get() = !isEmpty || droppedImages > 0 || droppedDocuments > 0 || contentUnreadable
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -85,6 +109,17 @@ fun classifySharedStream(mime: String?): SharedStreamKind {
|
||||
* [ACTION_SEND] 收文本 + 一个 URI(图片或文件);[ACTION_SEND_MULTIPLE] 本端只声明了图片那一类,
|
||||
* 所以多选一条实际只可能是图片。URI 走哪条路由 [classifySharedStream] 按 MIME 判。
|
||||
*
|
||||
* ## 字符串形态的 `EXTRA_STREAM`(本模块补上的兼容读取)
|
||||
* 真实世界里 `EXTRA_STREAM` 有三种形态:规范推荐的 `Uri`(多选时 `ArrayList<Uri>`)、`String[]`、
|
||||
* `ArrayList<String>`。后两种本模块此前**完全认不出**:`getParcelableExtra` 遇到 `String[]` 只往
|
||||
* logcat 打一句 `ClassCastException`、然后回 `null`,`getParcelableArrayListExtra` 同理,于是整次
|
||||
* 分享**静默落空**——用户点了分享,后端零请求、库里零新增、界面连一句话都没有。历史遗留的发送方与
|
||||
* 跨平台壳应用确实在用这两种形态,所以一并收下。形态分派在 `app/ShareIntake.kt` 的
|
||||
* `shareStreamExtrasOf`(那一步要读 `Intent` 的原始值),这里只管「拿到一串字符串之后怎么用」。
|
||||
*
|
||||
* ⚠️ **字符串形态只认 `content://`**(判据见 [stringStreamUri]):那是本端**新开**的一条读入口,
|
||||
* 松不得。被拒的条目**逐条跳过**——一次分享里有几条坏的,不该把整次分享废掉。
|
||||
*
|
||||
* ## 为什么正文一个字都不加
|
||||
* **绝不**往用户正文里拼「[用户分享]」之类的来源标记:来源标识走后端的独立 `RoleSystem`
|
||||
* 来源消息 + 结构化 `SenderName`(`02/F4` / `design/11 §10`),人类可见的标记只能放在本端
|
||||
@@ -119,16 +154,30 @@ object ShareRules {
|
||||
/**
|
||||
* 从分享 Intent 的原始字段提取内容。
|
||||
*
|
||||
* URI 的取值顺序是 `EXTRA_STREAM` → `ClipData`:前者是契约路径,后者是兜底——确实有
|
||||
* App(尤其旧版本)只填 `ClipData` 而不填 `EXTRA_STREAM`,只认前者会让分享静默落空。
|
||||
* 两者都有时**只取前者**,不会把同一张图数两遍。
|
||||
* ## 优先顺序与去重(一次分享里多种来源并存时)
|
||||
* 顺序 = `EXTRA_STREAM` 的规范形态([stream] 在前、[streams] 在后)→ 字符串兼容形态(保持原顺序)
|
||||
* → `ClipData`(只在前两者一条都没认下时兜底)。同一个地址串被两处各给一次时**只留先到的那一条**:
|
||||
* 先 trim、去空、去重,再按 MIME 分流、再各自套上限——重复项既不该把真图挤到上限之外,也不该
|
||||
* 被算成「截掉了 N 条」。**先来的先算**这条在字符串形态上尤其要紧:发送方给的顺序就是它给的顺序。
|
||||
*
|
||||
* `ClipData` 只在 `EXTRA_STREAM` 一条都没认下时才用(既有口径):两者都有时只取前者,不会把同一张
|
||||
* 图数两遍。
|
||||
*
|
||||
* @param action `Intent.action`;只认 [ACTION_SEND] / [ACTION_SEND_MULTIPLE]
|
||||
* @param text `EXTRA_TEXT`——按 `CharSequence` 收,因为它常常是 `Spanned`(带样式的
|
||||
* HTML 富文本),读成 `String` 会直接拿到 `null`
|
||||
* @param stream `EXTRA_STREAM`([ACTION_SEND] 的单条 URI 字符串)
|
||||
* @param streams `EXTRA_STREAM`([ACTION_SEND_MULTIPLE] 的 URI 列表)
|
||||
* @param clipUris `ClipData` 各条目的 URI;文本类条目没有 URI,会被自动忽略
|
||||
* @param stream `EXTRA_STREAM` 的单值形态([ACTION_SEND] 那个只放一条的字段)
|
||||
* @param streams `EXTRA_STREAM` 的列表形态([ACTION_SEND_MULTIPLE] 的字段)
|
||||
* @param streamStrings `EXTRA_STREAM` 的**字符串兼容形态**(`String` / `String[]` /
|
||||
* `ArrayList<String>` 摊平后的值,形态分派在 `app/ShareIntake.kt`)。这一档每条都要过
|
||||
* [stringStreamUri] 的白名单,被拒的**逐条跳过**;调用方给的是原样值(还没过滤),
|
||||
* 所以「有值但一条都不能用」这件事这里看得见——它就是 [SharedContent.contentUnreadable]
|
||||
* 要说的那种情形。
|
||||
* @param streamPresent `EXTRA_STREAM` 里确实躺着**原始值**——由调用方读了原始值判出来(本函数
|
||||
* 看不到那个值),含本端**认不出形态**的那种。它只有一个用处:形态认不出时本端既不猜、也不该
|
||||
* 因此闭嘴,那照样是一次「带着内容迹象却什么都没读进来」。
|
||||
* @param clipUris `ClipData` 各条目的 URI,纯文本条目是 `null`(本端**不读** `ClipData` 里的
|
||||
* 文本,所以「只有纯文本条目」等同于「读不进来」,用来判 [SharedContent.contentUnreadable])
|
||||
* @param mimeOf 取一条 URI 的 MIME(Android 侧就是 `ContentResolver.getType`)。本函数是
|
||||
* **纯函数**,所以类型判定由调用方注入;默认「拿不到类型」⇒ 按文档路走
|
||||
* (见 [classifySharedStream],默认值就是「不猜」)。
|
||||
@@ -138,7 +187,9 @@ object ShareRules {
|
||||
text: CharSequence? = null,
|
||||
stream: String? = null,
|
||||
streams: List<String>? = null,
|
||||
clipUris: List<String> = emptyList(),
|
||||
streamStrings: List<String> = emptyList(),
|
||||
streamPresent: Boolean = false,
|
||||
clipUris: List<String?> = emptyList(),
|
||||
mimeOf: (String) -> String? = { null },
|
||||
): SharedContent {
|
||||
val recognized = action == ACTION_SEND || action == ACTION_SEND_MULTIPLE
|
||||
@@ -146,16 +197,19 @@ object ShareRules {
|
||||
|
||||
val body = text?.toString().orEmpty().trim()
|
||||
|
||||
val primary = when (action) {
|
||||
ACTION_SEND -> listOfNotNull(stream)
|
||||
else -> streams.orEmpty()
|
||||
}
|
||||
// 空串条目(有的 App 会塞占位)与重复条目都要去掉:不然「4 张图」里可能只剩 2 张真图,
|
||||
// 而截断计数还会把占位算成一张。
|
||||
val unique = primary.ifEmpty { clipUris }
|
||||
// 两个字段一并收:单值 / 列表是历史形态(当年靠 getParcelableExtra 与
|
||||
// getParcelableArrayListExtra 两个读法分开取,才不得不按 action 二选一),
|
||||
// 本端现在只关心「有哪些地址」,重复的由下面的 distinct 收掉。
|
||||
val uriForms = listOfNotNull(stream) + streams.orEmpty()
|
||||
val primary = (uriForms + streamStrings.mapNotNull(::stringStreamUri))
|
||||
.map(String::trim)
|
||||
.filter(String::isNotEmpty)
|
||||
.distinct()
|
||||
// 空串条目(有的 App 会塞占位)与重复条目都要去掉:不然「4 张图」里可能只剩 2 张真图,
|
||||
// 而截断计数还会把占位算成一张。
|
||||
val unique = primary.ifEmpty {
|
||||
clipUris.filterNotNull().map(String::trim).filter(String::isNotEmpty).distinct()
|
||||
}
|
||||
|
||||
val images = mutableListOf<String>()
|
||||
val documents = mutableListOf<String>()
|
||||
@@ -166,6 +220,14 @@ object ShareRules {
|
||||
}
|
||||
}
|
||||
|
||||
// 「带着内容迹象」的判据取的是**原料**(EXTRA_STREAM 里躺着的原始值 / ClipData 的条目数),
|
||||
// 不是「认下来的地址」——「有值但一条都认不出」正是要说的那种情形,用后者判反倒会把它当成
|
||||
// 「什么都没分享」而闭嘴。
|
||||
val hadStreamValue = streamPresent ||
|
||||
!stream.isNullOrBlank() ||
|
||||
streams.orEmpty().any(String::isNotBlank) ||
|
||||
streamStrings.any(String::isNotBlank)
|
||||
|
||||
return SharedContent(
|
||||
text = body,
|
||||
images = images.take(MAX_IMAGES),
|
||||
@@ -174,9 +236,55 @@ object ShareRules {
|
||||
documents = documents.take(MAX_DOCUMENTS).map { SharedDocument(uri = it) },
|
||||
truncatedImages = (images.size - MAX_IMAGES).coerceAtLeast(0),
|
||||
truncatedDocuments = (documents.size - MAX_DOCUMENTS).coerceAtLeast(0),
|
||||
contentUnreadable = unique.isEmpty() && body.isEmpty() &&
|
||||
(hadStreamValue || clipUris.isNotEmpty()),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 字符串形态的一条候选地址 ⇒ 可用的地址(trim 之后),不可用回 `null`。
|
||||
*
|
||||
* ## 为什么字符串形态只认 `content://`(安全纪律)
|
||||
* 字符串形态是本端**新开**的一条读入口:送进来的只是一段文本,没有任何框架级的担保(`Uri` /
|
||||
* `ArrayList<Uri>` 那两个形态至少是对方用 URI **对象**写进去的,而 `EXTRA_STREAM` 里的字符串
|
||||
* 往往是从别处转发来的——网页给的、壳应用拼的)。所以这条入口开得窄:
|
||||
*
|
||||
* - `content://` ⇒ 收下。这是 AOSP 推荐的形态,读权限随这次分享由框架授予。
|
||||
* - `file://` ⇒ **拒**。那等于让发送方替本端指定一个本地路径、由本端拿**自己的**权限去读它
|
||||
* (本应用私有目录也在其中),而分享的语义是「用户选中的东西」,不是「别人给的任意路径」。
|
||||
* 何况 Android 13 起跨应用读外部存储的 `file://` 基本已不通,收下来多半也只是变成一次失败。
|
||||
* - `http(s)://` ⇒ **拒**。那是网络地址不是本地字节源:收下它等于让第三方的一行文本指定一个 URL、
|
||||
* 由本应用代它去取,而那条取法在本端没有任何既有的授权 / 体积 / 超时约束。
|
||||
* - 其余(`javascript:` / `intent:` / 自定义 scheme / 没有 scheme 的裸路径 / 空串)⇒ **拒**。
|
||||
*
|
||||
* 被拒的条目由调用方逐条跳过(见 [extract]):一次分享里有几条坏的,不该把整次分享废掉;
|
||||
* 一条可用地址都没剩下时由 [SharedContent.contentUnreadable] 如实告知。
|
||||
*
|
||||
* 判 scheme 用的是 RFC 3986 的形态(首字符字母,其后字母 / 数字 / `+` `-` `.`),与 `Uri.parse`
|
||||
* 认 scheme 的口径一致;本函数是纯的(`domain/share` 不碰 Android 类型)。
|
||||
*/
|
||||
fun stringStreamUri(raw: String): String? {
|
||||
val trimmed = raw.trim()
|
||||
val scheme = schemeOf(trimmed) ?: return null
|
||||
return trimmed.takeIf { scheme.equals(CONTENT_SCHEME, ignoreCase = true) }
|
||||
}
|
||||
|
||||
/** 本端唯一收下的 scheme(理由见 [stringStreamUri])。 */
|
||||
private const val CONTENT_SCHEME = "content"
|
||||
|
||||
/** 一条地址的 scheme(按 RFC 3986 的形态判,大小写不敏感地在调用方比);没有 scheme 回 `null`。 */
|
||||
private fun schemeOf(raw: String): String? {
|
||||
val colon = raw.indexOf(':')
|
||||
if (colon <= 0) return null
|
||||
val head = raw.substring(0, colon)
|
||||
val first = head[0]
|
||||
if (first !in 'a'..'z' && first !in 'A'..'Z') return null
|
||||
val usable = head.drop(1).all {
|
||||
it in 'a'..'z' || it in 'A'..'Z' || it in '0'..'9' || it == '+' || it == '-' || it == '.'
|
||||
}
|
||||
return head.takeIf { usable }
|
||||
}
|
||||
|
||||
/**
|
||||
* 落盘文件名:**只由本端生成**的 [token] 与序号 [index] 拼成,扩展名取自 MIME 白名单
|
||||
* (不在表里就回落 `.img`——真正的类型由字节嗅探裁决,不靠扩展名)。
|
||||
|
||||
@@ -868,6 +868,16 @@ private fun busyLabel(state: ChatUiState): String {
|
||||
).joinToString(" ").ifBlank { "" }
|
||||
}
|
||||
|
||||
/**
|
||||
* 「这次分享的内容没能读进来」的如实告知(判据见 [shareNoticeFor])。
|
||||
*
|
||||
* 措辞里是三件事:说清**发生了什么**(内容没能读进来)、说清**没有发生什么**(没有发送任何内容,
|
||||
* 别让人去历史里翻)、给一个**能走通的替代动作**(本应用里的选择器)。刻意不写「发送失败」——
|
||||
* 失败的是读取,不是发送:说「发送失败」会让人以为东西已经出去了、只是没成功。
|
||||
*/
|
||||
const val SHARE_UNREADABLE_NOTICE =
|
||||
"这次分享的内容没能读进来,没有发送任何内容;可以改从本应用里选图片或文件再试一次"
|
||||
|
||||
/**
|
||||
* 分享落地的如实告知:只在「有东西没能按原样发出」时才成句,正常落地(一条文本 / 一张图 /
|
||||
* 一个文件)一个字都不说。
|
||||
@@ -875,8 +885,16 @@ private fun busyLabel(state: ChatUiState): String {
|
||||
* 这是**本端渲染层**的信息,绝不写进正文:来源 / 系统提示的边界见 `design/11 §10`
|
||||
* (`02/F4` 的红线)。截断与读不到要说的话不一样——前者是本端的接收上限,后者是对方给的东西
|
||||
* 用不了,混成一句会让人以为图还在路上。图片与文件也分开说:两者的上限不是一个数。
|
||||
* 第三档是 [SHARE_UNREADABLE_NOTICE]([SharedContent.contentUnreadable]):连丢件计数都留不下
|
||||
* (本端一条地址都没认下来),但它**必须**说——用户点了分享、界面毫无反应是今天最糟的那种失败。
|
||||
*
|
||||
* 开头那道判断是**静默那条边界**:整条分享里什么都没有(既没文本、`EXTRA_STREAM` 也没有值、
|
||||
* `ClipData` 也没有条目)时一个字都不说——用户确实没分享任何东西,没什么可说的。判据用
|
||||
* [SharedContent.worthDelivering],与 `ShareIntake.accept` 决定「要不要往下接」**同一条**:
|
||||
* 换成两个判据就会出现「接了却不说」或者「说了却没接」。
|
||||
*/
|
||||
private fun shareNoticeFor(share: SharedContent): String? {
|
||||
fun shareNoticeFor(share: SharedContent): String? {
|
||||
if (!share.worthDelivering) return null
|
||||
val parts = buildList {
|
||||
if (share.truncatedImages > 0) {
|
||||
add("分享的图片超过 ${ShareRules.MAX_IMAGES} 张,只发送前 ${share.images.size} 张")
|
||||
@@ -886,7 +904,12 @@ private fun shareNoticeFor(share: SharedContent): String? {
|
||||
}
|
||||
if (share.failedImages > 0) add("有 ${share.failedImages} 张图片读不到,未发送")
|
||||
if (share.failedDocuments > 0) add("有 ${share.failedDocuments} 个文件读不到,未发送")
|
||||
if (share.isEmpty) add("分享的内容是空的,没有可发送的内容")
|
||||
when {
|
||||
// 「读不进来」压过「内容是空的」:两者不可能同时成立(一条都没认下来的那次分享里根本没有
|
||||
// 丢件计数),而混着说会让人以为东西还在路上。
|
||||
share.contentUnreadable -> add(SHARE_UNREADABLE_NOTICE)
|
||||
share.isEmpty -> add("分享的内容是空的,没有可发送的内容")
|
||||
}
|
||||
// 超长正文本该落成 .txt 附件,但附件位已经被分享进来的文件占满 ⇒ 只能按正文发。
|
||||
// 这是**形态变了**(不是内容少了),所以单独成句且说清为什么(见 DocumentDelivery.planSharedText)。
|
||||
if (DocumentDelivery.planSharedText(share.text, share.documents.size) ==
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
package top.yeij.cyrene.app
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import top.yeij.cyrene.domain.share.ShareRules
|
||||
|
||||
/**
|
||||
* `EXTRA_STREAM` 原始值的**形态分派**([shareStreamExtrasOf])。
|
||||
*
|
||||
* 真实世界里这个 extra 有三种形态:规范推荐的 `Uri`(多选时 `ArrayList<Uri>`)、`String[]`、
|
||||
* `ArrayList<String>`。后两种此前**完全认不出**——`getParcelableExtra` 遇到 `String[]` 只往 logcat
|
||||
* 打一行 `ClassCastException` 再回 `null`,于是整次分享静默落空。这里按**真实对象**把新认下的形态、
|
||||
* 「认不出的形态不猜」以及「接到 [ShareRules.extract] 之后真的能落地」一起钉住。
|
||||
*
|
||||
* `Uri` 形态在这里分不出单独的用例:JVM 单测造不出 `android.net.Uri`(android.jar 的桩方法一调就抛
|
||||
* `Stub!`)。那一路由 `ShareRules.extract` 的既有用例与真机验证兜住;本文件管的是新开的两条路。
|
||||
*/
|
||||
class ShareStreamExtrasTest {
|
||||
|
||||
private val imageMime: (String) -> String? = { "image/png" }
|
||||
|
||||
@Test
|
||||
fun `a string array stream yields every entry in order`() {
|
||||
// `am start … --esa android.intent.extra.STREAM <uri1>,<uri2>` 打出来的就是这个形态。
|
||||
val extras = shareStreamExtrasOf(
|
||||
arrayOf("content://media/external/images/media/1", "content://media/external/images/media/2"),
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
listOf("content://media/external/images/media/1", "content://media/external/images/media/2"),
|
||||
extras.stringForms,
|
||||
)
|
||||
assertTrue(extras.uriForms.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a string list stream yields every entry in order`() {
|
||||
val extras = shareStreamExtrasOf(
|
||||
arrayListOf("content://media/external/images/media/1", "content://media/external/images/media/2"),
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
listOf("content://media/external/images/media/1", "content://media/external/images/media/2"),
|
||||
extras.stringForms,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a bare string stream is a string form`() {
|
||||
assertEquals(listOf("content://m/1"), shareStreamExtrasOf("content://m/1").stringForms)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a char sequence stream is read through`() {
|
||||
// `Spanned` 之类也是 `CharSequence`:它照样要过 scheme 白名单,不是「因为它是 CharSequence
|
||||
// 就信」。按 String 读会直接拿到 null。
|
||||
val raw: Any = StringBuilder("content://m/1")
|
||||
|
||||
assertEquals(listOf("content://m/1"), shareStreamExtrasOf(raw).stringForms)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an absent stream yields nothing`() {
|
||||
val extras = shareStreamExtrasOf(null)
|
||||
|
||||
assertTrue(extras.uriForms.isEmpty())
|
||||
assertTrue(extras.stringForms.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a shape we do not know is not guessed`() {
|
||||
val extras = shareStreamExtrasOf(42)
|
||||
|
||||
assertTrue(extras.uriForms.isEmpty())
|
||||
assertTrue(extras.stringForms.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an entry of a shape we do not know is skipped and the rest survives`() {
|
||||
val raw: Any = arrayListOf<Any?>("content://m/1", 42, null, "content://m/2")
|
||||
|
||||
assertEquals(listOf("content://m/1", "content://m/2"), shareStreamExtrasOf(raw).stringForms)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a string array stream ends up as two images`() {
|
||||
// 端到端那一步:事故现场那两张图现在真的进到待发送内容里了,顺序不变。
|
||||
val extras = shareStreamExtrasOf(
|
||||
arrayOf("content://media/external/images/media/1", "content://media/external/images/media/2"),
|
||||
)
|
||||
|
||||
val share = ShareRules.extract(
|
||||
action = ShareRules.ACTION_SEND_MULTIPLE,
|
||||
stream = extras.uriForms.firstOrNull(),
|
||||
streams = extras.uriForms,
|
||||
streamStrings = extras.stringForms,
|
||||
mimeOf = imageMime,
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
listOf("content://media/external/images/media/1", "content://media/external/images/media/2"),
|
||||
share.images,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a string array stream of unusable uris ends up as a read failure we can talk about`() {
|
||||
val extras = shareStreamExtrasOf(arrayOf("javascript:alert(1)", "这只是一段纯文本"))
|
||||
|
||||
val share = ShareRules.extract(
|
||||
action = ShareRules.ACTION_SEND_MULTIPLE,
|
||||
streamStrings = extras.stringForms,
|
||||
mimeOf = imageMime,
|
||||
)
|
||||
|
||||
assertTrue(share.isEmpty)
|
||||
assertTrue(share.contentUnreadable)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a shape we cannot decode still counts as a share that carried something`() {
|
||||
// 形态认不出时本端既不猜、也不该因此闭嘴:`streamPresent` 由读了原始值的调用方判(见
|
||||
// `ShareIntake.accept`),否则「分享点下去什么都没发生」会从那条缝里回来。
|
||||
val raw: Any? = 42
|
||||
|
||||
val share = ShareRules.extract(
|
||||
action = ShareRules.ACTION_SEND,
|
||||
streamPresent = raw != null,
|
||||
mimeOf = imageMime,
|
||||
)
|
||||
|
||||
assertTrue(share.isEmpty)
|
||||
assertTrue(share.contentUnreadable)
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package top.yeij.cyrene.domain.share
|
||||
|
||||
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.chat.ChatSendRouting
|
||||
@@ -212,6 +213,137 @@ class ShareExtractionTest {
|
||||
assertEquals(listOf("content://media/external/images/media/1"), share.images)
|
||||
}
|
||||
|
||||
// —— 字符串形态的 EXTRA_STREAM(兼容读取)——
|
||||
|
||||
// 形态分派(`String` / `String[]` / `ArrayList<String>` 怎么变成下面这些字符串)在
|
||||
// `app/ShareStreamExtrasTest`——那一步要按**真实对象**判形态,放不进这个纯函数。
|
||||
// 这里管的是「拿到这些字符串之后怎么用」。
|
||||
|
||||
@Test
|
||||
fun `string form content uris are delivered in the order the sender gave them`() {
|
||||
// 实测事故:`am start … --esa android.intent.extra.STREAM uri1,uri2` 投两张图,
|
||||
// EXTRA_STREAM 是 `String[]`,getParcelableExtra / getParcelableArrayListExtra 双双回 null,
|
||||
// 于是整次分享什么都没发生(后端零请求、库里零新增、界面一句话都没有)。
|
||||
val share = ShareRules.extract(
|
||||
action = ShareRules.ACTION_SEND_MULTIPLE,
|
||||
streamStrings = listOf(
|
||||
"content://media/external/images/media/1",
|
||||
"content://media/external/images/media/2",
|
||||
),
|
||||
mimeOf = imageMime,
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
listOf(
|
||||
"content://media/external/images/media/1",
|
||||
"content://media/external/images/media/2",
|
||||
),
|
||||
share.images,
|
||||
)
|
||||
assertEquals(0, share.truncatedImages)
|
||||
assertFalse(share.contentUnreadable)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `string form items with an unusable scheme are skipped one by one`() {
|
||||
val share = ShareRules.extract(
|
||||
action = ShareRules.ACTION_SEND_MULTIPLE,
|
||||
streamStrings = listOf(
|
||||
"content://media/external/images/media/1",
|
||||
"",
|
||||
" ",
|
||||
"javascript:alert(1)",
|
||||
"intent://scan/#Intent;scheme=zxing;end",
|
||||
"这只是一段纯文本",
|
||||
"file:///sdcard/DCIM/Camera/IMG_0001.jpg",
|
||||
"https://example.com/a.png",
|
||||
"/sdcard/DCIM/Camera/IMG_0001.jpg",
|
||||
"content://media/external/images/media/2",
|
||||
),
|
||||
mimeOf = imageMime,
|
||||
)
|
||||
|
||||
// 一条坏的不能把整次分享废掉。被拒的也不计进丢件计数:那说的是「对方给的东西读不到」,
|
||||
// 与「本端不收这个 scheme」不是一回事(后者连读都没试过)。
|
||||
assertEquals(
|
||||
listOf(
|
||||
"content://media/external/images/media/1",
|
||||
"content://media/external/images/media/2",
|
||||
),
|
||||
share.images,
|
||||
)
|
||||
assertEquals(0, share.truncatedImages)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `string form files go to the document path`() {
|
||||
val share = ShareRules.extract(
|
||||
action = ShareRules.ACTION_SEND,
|
||||
streamStrings = listOf("content://com.android.providers.downloads.documents/document/42"),
|
||||
mimeOf = textMime,
|
||||
)
|
||||
|
||||
assertTrue(share.images.isEmpty())
|
||||
assertEquals(
|
||||
listOf("content://com.android.providers.downloads.documents/document/42"),
|
||||
share.documents.map { it.uri },
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a uri given in both forms is counted once and the uri form comes first`() {
|
||||
val share = ShareRules.extract(
|
||||
action = ShareRules.ACTION_SEND,
|
||||
stream = "content://media/external/images/media/1",
|
||||
streamStrings = listOf(
|
||||
"content://media/external/images/media/1",
|
||||
"content://media/external/images/media/2",
|
||||
),
|
||||
mimeOf = imageMime,
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
listOf(
|
||||
"content://media/external/images/media/1",
|
||||
"content://media/external/images/media/2",
|
||||
),
|
||||
share.images,
|
||||
)
|
||||
// 去重发生在截断之前:重复项不该把真图挤掉,也不该被算成「截掉了 1 张」。
|
||||
assertEquals(0, share.truncatedImages)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the string form only accepts content uris`() {
|
||||
assertEquals(
|
||||
"content://media/external/images/media/1",
|
||||
ShareRules.stringStreamUri("content://media/external/images/media/1"),
|
||||
)
|
||||
// 两侧空白是传输噪声,trim 掉再用(认它比因为一个空格而静默安全)。
|
||||
assertEquals("content://m/1", ShareRules.stringStreamUri(" content://m/1 "))
|
||||
// scheme 大小写不敏感(`Uri.parse` 也会把它归一成小写)。
|
||||
assertEquals("CONTENT://m/1", ShareRules.stringStreamUri("CONTENT://m/1"))
|
||||
// 这里只判 scheme 形态,不解析路径:这条确实读不到,会由落盘那一步如实计入丢件。
|
||||
assertEquals("content:not//a/uri", ShareRules.stringStreamUri("content:not//a/uri"))
|
||||
|
||||
// 下面这些**不能**当地址读。`file://` 是别人指定的本地路径(本端会拿自己的权限去读它,
|
||||
// 本应用私有目录也在里面);`http(s)://` 是网络地址(收下等于让第三方指定一个 URL、
|
||||
// 由本应用代它去取);其余是任意 scheme / 没有 scheme 的裸路径 / 纯文本。
|
||||
assertNull(ShareRules.stringStreamUri("file:///sdcard/DCIM/Camera/IMG_0001.jpg"))
|
||||
assertNull(ShareRules.stringStreamUri("http://example.com/a.png"))
|
||||
assertNull(ShareRules.stringStreamUri("https://example.com/a.png"))
|
||||
assertNull(ShareRules.stringStreamUri("javascript:alert(1)"))
|
||||
assertNull(ShareRules.stringStreamUri("intent://x/#Intent;end"))
|
||||
assertNull(ShareRules.stringStreamUri("android.resource://com.example/drawable/icon"))
|
||||
assertNull(ShareRules.stringStreamUri("/sdcard/DCIM/Camera/IMG_0001.jpg"))
|
||||
assertNull(ShareRules.stringStreamUri("这只是一段纯文本"))
|
||||
// scheme 的形态按 RFC 3986:首字符必须是字母,其后不能出现 `/`。
|
||||
assertNull(ShareRules.stringStreamUri("1content://m/1"))
|
||||
assertNull(ShareRules.stringStreamUri("a/b:c"))
|
||||
assertNull(ShareRules.stringStreamUri(""))
|
||||
assertNull(ShareRules.stringStreamUri(" "))
|
||||
}
|
||||
|
||||
// —— 文件(非图片)分流 ——
|
||||
|
||||
@Test
|
||||
@@ -373,4 +505,96 @@ class ShareExtractionTest {
|
||||
assertFalse(name, name.contains(".."))
|
||||
assertTrue(name, name.startsWith("share-"))
|
||||
}
|
||||
|
||||
// —— 读不进来 / 静默那条边界 ——
|
||||
|
||||
@Test
|
||||
fun `stream values that are all unusable are reported as unreadable instead of vanishing`() {
|
||||
val share = ShareRules.extract(
|
||||
action = ShareRules.ACTION_SEND_MULTIPLE,
|
||||
streamStrings = listOf("javascript:alert(1)", "这只是一段纯文本"),
|
||||
mimeOf = imageMime,
|
||||
)
|
||||
|
||||
assertTrue(share.isEmpty)
|
||||
assertTrue("有内容迹象却一条都没读进来,必须能说出口", share.contentUnreadable)
|
||||
assertTrue(share.worthDelivering)
|
||||
// 它连丢件计数都留不下(本端一条都没接收),所以这件事不能靠 droppedImages 来说。
|
||||
assertEquals(0, share.droppedImages)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `clip items without a uri are an indication too`() {
|
||||
// ClipData 里有条目、但只有纯文本条目(uri == null):本端**不读** ClipData 里的文本,
|
||||
// 所以这也是一次「读不进来」,而不是「什么都没分享」。
|
||||
val share = ShareRules.extract(
|
||||
action = ShareRules.ACTION_SEND,
|
||||
clipUris = listOf(null),
|
||||
)
|
||||
|
||||
assertTrue(share.isEmpty)
|
||||
assertTrue(share.contentUnreadable)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a share that carries nothing at all stays silent`() {
|
||||
// 边界:EXTRA_TEXT 没有值、EXTRA_STREAM 也没有值、ClipData 也没有条目——用户确实没分享
|
||||
// 任何东西,这时候说一句才是噪声。(`ShareIntake` 靠 worthDelivering 判要不要往下走,
|
||||
// `shareNoticeFor` 靠同一条判要不要说。)
|
||||
val share = ShareRules.extract(action = ShareRules.ACTION_SEND)
|
||||
|
||||
assertTrue(share.isEmpty)
|
||||
assertFalse(share.contentUnreadable)
|
||||
assertFalse(share.worthDelivering)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a blank text with blank stream entries is still nothing`() {
|
||||
val share = ShareRules.extract(
|
||||
action = ShareRules.ACTION_SEND,
|
||||
text = " \n\t ",
|
||||
streamStrings = listOf(" ", ""),
|
||||
)
|
||||
|
||||
assertTrue(share.isEmpty)
|
||||
assertFalse("占位空白不是内容迹象", share.contentUnreadable)
|
||||
assertFalse(share.worthDelivering)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `unusable streams next to a usable text are not a whole read failure`() {
|
||||
val share = ShareRules.extract(
|
||||
action = ShareRules.ACTION_SEND,
|
||||
text = "看这个",
|
||||
streamStrings = listOf("javascript:alert(1)"),
|
||||
)
|
||||
|
||||
assertEquals("看这个", share.text)
|
||||
// 正文照发:这次分享不是「什么都没读进来」,就不能说成那一句。
|
||||
assertFalse(share.contentUnreadable)
|
||||
assertTrue(share.worthDelivering)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a stream value whose shape we cannot decode is still an indication`() {
|
||||
// 调用方读了 EXTRA_STREAM 的原始值:里面确实躺着东西,只是形态本端认不出(本端**不猜**)。
|
||||
// 这也不能闭嘴——它是「分享点下去什么都没发生」的另一条缝。默认 false 保住上面那条静默边界。
|
||||
val share = ShareRules.extract(action = ShareRules.ACTION_SEND, streamPresent = true)
|
||||
|
||||
assertTrue(share.isEmpty)
|
||||
assertTrue(share.contentUnreadable)
|
||||
assertTrue(share.worthDelivering)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an unrelated intent with a stream in it still extracts to nothing`() {
|
||||
// 不是分享 action 就不是分享:哪怕里面照样躺着地址,也不该走这条路(更不该出一条告知)。
|
||||
val share = ShareRules.extract(
|
||||
action = "android.intent.action.VIEW",
|
||||
streamStrings = listOf("content://media/external/images/media/1"),
|
||||
)
|
||||
|
||||
assertFalse(share.contentUnreadable)
|
||||
assertFalse(share.worthDelivering)
|
||||
}
|
||||
}
|
||||
@@ -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("分享的内容是空的,没有可发送的内容"))
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user