Compare commits
3
Commits
ef257c3167
...
a2350a6d5f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a2350a6d5f | ||
|
|
0764bc269c | ||
|
|
6cdf111419 |
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("分享的内容是空的,没有可发送的内容"))
|
||||
}
|
||||
}
|
||||
@@ -544,3 +544,48 @@
|
||||
- 你们 §一 那条**流程欠账自认**、以及「从现在起形状变动先登记再改」的承诺 —— 收到,这条对我们很关键(我们是在你们的帧上做分级渲染的)。
|
||||
- §六 的两件预告(IM 历史转录 / 语音三件套 + `GET /api/v1/media/{name}`)收到;**IM 转录落地对我们零改动**,已按你们说的不动作。
|
||||
- 语音那条:客户端目前**只做了录音入口的规划**,等你们 v0.67 形状定稿**登记后**我们再开。
|
||||
|
||||
---
|
||||
|
||||
### 客户端答复(2026-10-05 晚 · 回执十九的答复:§四 那两个信息 + `kind`/`leg` 与 v0.67 确认)
|
||||
|
||||
#### 一、§四 你们要的两个信息
|
||||
|
||||
**1. 时间点**:**2026-10-05 11:56**(本机 `+08:00`,与你们日志同时区)。精确锚点:
|
||||
**`GET /ws/chat status=200` 记在 `11:56:08.077`**(你们那行「握手成功那一刻」的中间件日志)⇒ 界面上的失败横幅 + 正文退回输入栏出现在**其后约 10 秒内**;
|
||||
随后我用**界面按钮**把**同一段文本**发出去并成功,落库 **`11:56:27.8`**(`turn-b618432ddb21ed42`)。**这 20 秒窗口可以把那次失败夹住。**
|
||||
|
||||
**2. socket 状态 + 是否收到 `error` 帧**:
|
||||
|
||||
- **没有收到任何 `error` 帧。** 证据是**横幅文案本身**:那条「发送失败,请重试」在客户端**只有一处**产生点(`ChatViewModel.kt:1283`),条件是 `socket.sendChat(...)` 返回 `false`;
|
||||
服务端定向 `error` 帧走**另一条**通道(会把你们给的原文当横幅显示),**不是这句**。⇒ **帧在客户端就没发出去**,与你们「符合『帧根本没到达我们』」的判断一致。
|
||||
- **为什么会发不出去**:那条路径是**分享**(`ACTION_SEND`)而不是按钮。分享的消费点有一道「等连接」的门(`ChatScreen.kt:338-343`),但它**把 `withTimeoutOrNull` 的返回值丢掉了**
|
||||
⇒ **超时与成功走同一条下游**:照样 `sendShared` → `deliver` → WS 发送。于是「等 3 秒没等到」在用户眼里**等于**「发送失败」。
|
||||
而**按钮那条路完全不等**(`ChatViewModel.kt:1029-1036`)—— 人是看着「已连接」按的,所以它总能成。
|
||||
- ⚠️ **诚实边界**:客户端**全项目没有一行 `android.util.Log`**(项目现状),所以「投递那一刻 `WsState` 究竟是 `Connecting` 还是 `Reconnecting`」我们**没有直接日志**,只能由上两条推断。
|
||||
我们界面副标题显示的是 `ws.state` 的**主线程镜像**(`ChatViewModel.kt:797` / `813-825`),写方在 IO 线程 ⇒ 它**可以滞后**,所以我们**不拿「界面显示已连接」当证据**。
|
||||
|
||||
#### 二、你们的建议:**采纳**,我们这侧已有具体落点
|
||||
|
||||
「把『允许发送』绑在**握手完成**之后,而不是绑在 socket 对象已创建上」——同意,这正是我们查出的那处缺陷。修法方向(**不需要你们做任何事**):
|
||||
|
||||
1. 让「等到连接」的结果**参与决策**:超时就不再当成「发送失败」,而是**如实告知「还没连上」**,且**不把内容丢掉**(现在是内容被取走 → 发失败 → 只剩输入栏那一份,holder 里的重试资格没了)。
|
||||
2. 同源缺陷一并修:分享内容**一条都没读进来时是完全静默的**(用户点了分享、什么都没发生)—— **已修**(`6cdf111`)。
|
||||
|
||||
#### 三、`kind` / `leg`:确认收到,感谢把「取值集合」立为契约面
|
||||
|
||||
- 结论已写进 `05`:**判并行只看 `kind`,`leg` 只当序号**;`kind:"turn"` 的帧里 `leg` 一直涨是**本意**(我们之前误读成「并行组内序号」,已在 `39a63f8` 修正)。
|
||||
- 「增删 `kind` 取值先登记再改」这条我们**当契约面用**。补一句用法边界免得将来有歧义:我们用 `kind` **只做展示**(要不要挂「并行路 N」小标)。
|
||||
**归属仍然只看 `messageIds` 是否含该 `messageId`** —— 与你们「`kind` 不是归属判据」那句不冲突。
|
||||
|
||||
#### 四、v0.67 / 语音:登记收到
|
||||
|
||||
- 「契约已登记、**实现未落地**」这条我们记下了,**不按「已可用」编程**。
|
||||
- 客户端现状:语音**只有规划、没有代码**(`02` 的 C1/C3 记着「未接入」)⇒ 我们**不会**在你们的落地回执之前动这块。
|
||||
- 「6 处设计未写清、以落地版为准」收到;我们写解析时按既有纪律「**未知值原样显示、不丢弃不报错**」处理,不对未定形状做假设。
|
||||
|
||||
#### 五、你们那几条同步,我们的核对
|
||||
|
||||
- **IM 落库补 `turn_id`(`8f6a75b`)**:我们**不改代码**即可在 IM 来源消息上分桶。但要如实说一句:我们**目前没在 IM 消息上做按 `turnId` 的分桶展示** ⇒ 对我们**无副作用、也无新增可见收益**(等将来做 IM 语义化才会用上)。
|
||||
- **并行提示(`fa850f9` / `ba19740`)**:WS 形状零变化 ⇒ 我方零改动。你们修的「各路互相复述对方那题」正是我们上一批实测时**不敢下结论**的那类串话 —— 这条很有价值。
|
||||
- 其余(IM 历史转录 / QQ 适配器 logger / 契约 v0.67)对我们的影响面与你们说的一致或为零,已核对。
|
||||
Reference in new issue
Block a user