diff --git a/app/src/main/kotlin/top/yeij/cyrene/app/ShareIntake.kt b/app/src/main/kotlin/top/yeij/cyrene/app/ShareIntake.kt index 77705e1..e311d06 100644 --- a/app/src/main/kotlin/top/yeij/cyrene/app/ShareIntake.kt +++ b/app/src/main/kotlin/top/yeij/cyrene/app/ShareIntake.kt @@ -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 = - getParcelableArrayListExtra(Intent.EXTRA_STREAM, Uri::class.java).orEmpty().map { it.toString() } - - /** `ClipData` 里的 URI(部分 App 只填它);纯文本条目的 URI 为空,天然被过滤掉。 */ - private fun Intent.clipUris(): List { + /** + * `ClipData` 各条目的 URI(部分 App 只填它);纯文本条目的 URI 是 `null`。 + * + * 这里刻意**保留**没有 URI 的条目(用 `null` 占位)而不是就地滤掉:条目本身就是「这次分享带着 + * 内容」的证据——只有纯文本条目时本端一个字节都读不到(本端不读 `ClipData` 里的文本),那种 + * 情形必须能说出口,见 [SharedContent.contentUnreadable]。 + */ + private fun Intent.clipUris(): List { 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`)、`String[]`、 + * `ArrayList`。后两种(历史遗留 / 跨平台壳应用常见)此前**完全认不出**:`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, + val stringForms: List, +) + +/** 见 [ShareStreamExtras]:把 `EXTRA_STREAM` 的原始值按形态摊平成两组地址(纯逻辑,可单测)。 */ +internal fun shareStreamExtrasOf(raw: Any?): ShareStreamExtras { + val uriForms = mutableListOf() + val stringForms = mutableListOf() + + 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) +} diff --git a/app/src/main/kotlin/top/yeij/cyrene/domain/share/SharePayload.kt b/app/src/main/kotlin/top/yeij/cyrene/domain/share/SharePayload.kt index 216512d..b8bbfeb 100644 --- a/app/src/main/kotlin/top/yeij/cyrene/domain/share/SharePayload.kt +++ b/app/src/main/kotlin/top/yeij/cyrene/domain/share/SharePayload.kt @@ -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`)、`String[]`、 + * `ArrayList`。后两种本模块此前**完全认不出**:`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` 摊平后的值,形态分派在 `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? = null, - clipUris: List = emptyList(), + streamStrings: List = emptyList(), + streamPresent: Boolean = false, + clipUris: List = 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() val documents = mutableListOf() @@ -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 **对象**写进去的,而 `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`——真正的类型由字节嗅探裁决,不靠扩展名)。 diff --git a/app/src/main/kotlin/top/yeij/cyrene/ui/chat/ChatScreen.kt b/app/src/main/kotlin/top/yeij/cyrene/ui/chat/ChatScreen.kt index 88968e4..c9d50bf 100644 --- a/app/src/main/kotlin/top/yeij/cyrene/ui/chat/ChatScreen.kt +++ b/app/src/main/kotlin/top/yeij/cyrene/ui/chat/ChatScreen.kt @@ -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) == diff --git a/app/src/test/kotlin/top/yeij/cyrene/app/ShareStreamExtrasTest.kt b/app/src/test/kotlin/top/yeij/cyrene/app/ShareStreamExtrasTest.kt new file mode 100644 index 0000000..17eac1b --- /dev/null +++ b/app/src/test/kotlin/top/yeij/cyrene/app/ShareStreamExtrasTest.kt @@ -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`)、`String[]`、 + * `ArrayList`。后两种此前**完全认不出**——`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 ,` 打出来的就是这个形态。 + 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("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) + } +} diff --git a/app/src/test/kotlin/top/yeij/cyrene/domain/share/ShareExtractionTest.kt b/app/src/test/kotlin/top/yeij/cyrene/domain/share/ShareExtractionTest.kt index c331c55..464e5e6 100644 --- a/app/src/test/kotlin/top/yeij/cyrene/domain/share/ShareExtractionTest.kt +++ b/app/src/test/kotlin/top/yeij/cyrene/domain/share/ShareExtractionTest.kt @@ -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` 怎么变成下面这些字符串)在 + // `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) + } } diff --git a/app/src/test/kotlin/top/yeij/cyrene/ui/chat/ShareNoticeTest.kt b/app/src/test/kotlin/top/yeij/cyrene/ui/chat/ShareNoticeTest.kt new file mode 100644 index 0000000..1c50d69 --- /dev/null +++ b/app/src/test/kotlin/top/yeij/cyrene/ui/chat/ShareNoticeTest.kt @@ -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("分享的内容是空的,没有可发送的内容")) + } +}