feat(chat): 文档附件接到 UI 与发送链路(选文件 / 待发芯片 / chat.documents / 分享分流)

把 W6 做好的「文档进对话」数据层接上 UI 与发送路径,产品决定按 05 的定案执行:
**不做长正文自动转文件**、**附件与正文各自独立存在**、上行**只给服务端存储名 `name`、≤5 条**、
带附件一律走 REST。

改了什么(按清单):
- AppContainer:照 cachedUploadApi 的写法加 documentsApi + documentRepository,**与 chatApi 同
  baseUrl / 同 token·401 策略**(上传拿到的 name 要交给同一个后端的 /chat,不同源必然 404)。
- domain/chat/DocumentAttachment.kt(新):DraftDocument(待发附件,只带本地引用,**不带字节**)、
  admit()(去重 → 20 MiB 单份预检 → 5 份上限,被挡下的**都计数**、绝不静默裁)、
  readBounded()(超限回 null、IO 异常原样抛——「太大」与「读不到」必须分得开)。
- domain/chat/DocumentChip.kt(新):附件芯片的展示换算(类型 / 体积 / 注入字数 / 状态),
  unreadable(读不到)与 truncated(注入被预算截断)**两句文案分开**,未知 status 原样显示。
- domain/chat/DocumentDelivery.kt:加 planForCount(text, 份数)——同一套判定矩阵,但只看份数,
  因为 VM 手上还没有字节(最多 5×20 MiB 一次性读进内存不可接受)。plan() 委托给它,行为不变。
- ChatViewModel:ChatUiState 加 draftDocuments / attachmentNotice;send() → deliver() 走
  DocumentDelivery 判定 → 逐份上传(保留扩展名、200+deduped 当成功)→ payload(names) 为 null
  时**提示而不截断** → sendViaRest 带 documents;失败与正文、图片一起回滚(附件还给输入栏)。
- ChatScreen:SAF OpenMultipleDocuments 入口(MiuixIcons.File + contentDescription「添加文件附件」),
  MIME 列表贴近后端文本白名单的语义(含 octet-stream 以免「后端收得下、用户选不到」),
  输入栏上方一排可移除芯片;发送按钮在只有附件时也可用(纯附件回合服务端支持)。
- MessageBubble:documents 渲染成芯片(名字中略保住扩展名 / 类型·体积·字数·状态),
  unreadable 整枚转灰并明写「读不到」;空列表不占位。PendingBubble 显示本地待发件。
- ShareIntake + ShareRules:EXTRA_STREAM 按 ContentResolver.getType 分流——image/ 前缀走既有
  图片路,其余(含 null)走**文档路**(拷进 cache、带显示名与真实字节数)。此前一律当图片拷,
  一份 .md 只能得到「图片处理失败」。**Manifest 一行未改**:后端明令拒绝 html/SVG/zip/脚本,
  声明全通配会造出「点进去必然失败」的入口,text/plain 已能接住 .txt/.md/.log。

测试:485 → 536(+51,0 失败 0 错误)。
This commit is contained in:
AskaEth committed 2026-10-04 18:44:23 +08:00
1 parent a6b4bae2a0
commit 30180ab1e5
14 files changed
+1668 -65

No files matched your search

@@ -0,0 +1,189 @@
package top.yeij.cyrene.domain.chat
import java.io.ByteArrayOutputStream
import java.io.InputStream
/**
* 一份**已选好、还没发出去**的待发附件(与 `ChatUiState.draftImage` 平行的概念)。
*
* 与 [DocumentFile] 的分工:[DocumentFile] 是「发送那一刻的字节」——上传需要它;
* 本类型是「还没发送的引用」——输入栏上方的芯片要显示它,而**绝不能**把最多 5×20 MiB
* 的字节一直摆在 UI 状态里(`ByteArray` 一进 `StateFlow` 就等于每次重组都摸一遍它)。
* 字节只在真的要上传时按 [uri] 现读(见 `ChatViewModel` 的发送路径)。
*
* 不用 `displayName` 去拼本地路径、也不改它的大小写:它**只用来给上传部件命名**
* (服务端要的是扩展名,其余部分会被内容寻址丢掉),以及给用户看。
*/
data class DraftDocument(
/** 本地可读 URI(SAF 的 `content://` 或分享落地后的 `file://`)。 */
val uri: String,
/** 给用户看的名字,含扩展名;空 = 让仓库层兜 `document.txt`。 */
val displayName: String,
/** 元数据里报的字节数;[UNKNOWN_SIZE] = 拿不到(此时不做本地体积预检,交给服务端裁决)。 */
val sizeBytes: Long = UNKNOWN_SIZE,
) {
companion object {
/** `ContentResolver` 拿不到 `SIZE` 时的取值:**不猜**,也不因此拒绝这个文件。 */
const val UNKNOWN_SIZE: Long = -1L
}
}
/**
* 一次「选文件」的结果:接受了哪些、又有哪些**没能接受**。
*
* 三个计数分开记,因为它们该说的话不一样,而且**一个都不许静默消失**——
* 「用户以为附件都挂上了、其实少了两个」正是本模块要消灭的东西。
*/
data class AttachmentPickResult(
val accepted: List<DraftDocument> = emptyList(),
/** 超过 [DocumentAttachments.MAX_FILE_BYTES] 而未被接受的显示名。 */
val tooLarge: List<String> = emptyList(),
/** 本回合附件已满([DocumentAttachments.MAX_PER_TURN])而未被接受的份数。 */
val overLimit: Int = 0,
/** 与已选附件重复、因此没有重复加入的份数。 */
val duplicate: Int = 0,
) {
/** 如实告知的那句话;没有任何东西被挡下时回 `null`(正常选中一个字都不说)。 */
val notice: String?
get() {
val parts = buildList {
if (tooLarge.isNotEmpty()) {
add(
"有 ${tooLarge.size} 个文件超过 " +
"${DocumentAttachments.maxFileSizeLabel}," +
"没有加入(${tooLarge.joinToString("、")})",
)
}
if (overLimit > 0) {
add("一个回合最多 ${DocumentAttachments.MAX_PER_TURN} 个附件,另有 $overLimit 个没有加入")
}
if (duplicate > 0) add("有 $duplicate 个和已选附件重复,没有重复加入")
}
return parts.takeIf { it.isNotEmpty() }?.joinToString(";")
}
}
/**
* 待发附件的准入规则(纯函数,VM 与测试共用)。
*
* ## 它为什么在客户端也要有一份
* 服务端才是裁决者(上限是 `CHAT_DOCUMENT_MAX_BYTES`,可部署配置;类型裁决只有字节嗅探一条),
* 所以这里的预检**只做一件服务端做不到的事**:**在把字节传上去之前**就说话。
* 一份 30 MiB 的文件照传一遍再吃 `413`,用户等的是上传的那几分钟,而结论本端现在就能给。
* 代价是这份上限可能与部署实际不同——因此一旦两边不一致,**服务端的话更权威**:
* 本端放过去的,服务端照旧能用 `413` / `415` 拦下(那两条文案会原样显示给用户)。
*
* ## 为什么不裁剪
* [admit] 只**不收**,从不**少收**:多出来的份数与超限的文件都进 [AttachmentPickResult],
* 由调用方如实告知。服务端对 >5 个回 `400 too_many_documents`、对非法名字回 `404`,
* 两边都不做静默裁剪,客户端也就不该在本地悄悄只发前 5 个。
*/
object DocumentAttachments {
/**
* 单份附件的本端预检上限。
*
* 取服务端文档上限的**默认值**(`CHAT_DOCUMENT_MAX_BYTES`,契约 v0.59 §1.2 = 20 MiB)。
* 刻意与图片上限(5 MiB,[top.yeij.cyrene.data.repository.UploadRepository] 那条)分开:
* 服务端把两者解耦,客户端合成一个数就会先自己把文档卡在图片的量级上。
*/
const val MAX_FILE_BYTES: Long = 20L * 1024 * 1024
/** 上限的可读说法(给提示文案用,避免两处各写一个数字)。 */
const val maxFileSizeLabel: String = "20 MB"
/** 一个回合的附件数上限,与判定矩阵同源([DocumentDelivery.MAX_ATTACHMENTS_PER_TURN])。 */
const val MAX_PER_TURN: Int = DocumentDelivery.MAX_ATTACHMENTS_PER_TURN
/**
* 把这次选中的文件并进已选列表。
*
* 顺序:**去重 → 体积 → 份数**。去重放在最前,是因为重复选同一份文件既不该被算成
* 「超限」也不该被算成「超体积」——它压根不是一个新附件。
*
* @param existing 已选附件(输入栏上方那些芯片)。
* @param picked 本次选中的文件(顺序即用户看到的顺序)。
*/
fun admit(existing: List<DraftDocument>, picked: List<DraftDocument>): AttachmentPickResult {
val seen = existing.mapTo(mutableSetOf()) { it.uri }
val tooLarge = mutableListOf<String>()
val fresh = mutableListOf<DraftDocument>()
var duplicate = 0
picked.forEach { document ->
if (!seen.add(document.uri)) {
duplicate++
return@forEach
}
// 体积拿不到(-1)时不判:本端猜不准的东西,留给服务端的 413 说。
if (document.sizeBytes > MAX_FILE_BYTES) {
tooLarge += document.displayName.ifBlank { document.uri }
return@forEach
}
fresh += document
}
val room = (MAX_PER_TURN - existing.size).coerceAtLeast(0)
val accepted = fresh.take(room)
return AttachmentPickResult(
accepted = accepted,
tooLarge = tooLarge,
overLimit = fresh.size - accepted.size,
duplicate = duplicate,
)
}
/** 从已选列表里去掉第 [index] 个(越界即原样返回,UI 的移除按钮不必自己判界)。 */
fun removeAt(existing: List<DraftDocument>, index: Int): List<DraftDocument> =
if (index !in existing.indices) existing else existing.filterIndexed { i, _ -> i != index }
/**
* URI 里能看出来的名字(`ContentResolver` 报不出 `DISPLAY_NAME` 时的兜底)。
*
* 取最后一段、去掉查询串;拿不到就回空串——**不猜**扩展名(扩展名真的参与服务端裁决,
* 猜错等于替用户把一个合规文件变成 `415`)。
*/
fun fallbackDocumentName(uri: String): String =
uri.substringBefore('?').substringAfterLast('/').takeIf { it.isNotBlank() }.orEmpty()
}
/**
* 读一份附件的字节时会遇到的三种结果。
*
* 「太大」与「读不到」必须分开:前者的正确动作是换文件,后者是把这份内容**再读一次**
* (授权过期、文件被删、provider 抽风)——折成一句「附件失败」会让用户做错事。
*/
sealed interface DocumentRead {
data class Ok(val bytes: ByteArray) : DocumentRead
/** 实际字节超过 [DocumentAttachments.MAX_FILE_BYTES]。 */
data object TooLarge : DocumentRead
/** 打不开 / 读不动(授权失效、文件已不在、provider 报错)。 */
data object Unavailable : DocumentRead
}
/**
* 从 [input] 读至多 [limit] 字节。
*
* **返回值语义**:`null` = 这份内容**超过了** [limit];IO 异常**原样抛出**(调用方要能把它
* 与「超限」分开说)。这样定是因为调用方必须给出不同的提示,而「读到一半发现超了」不能
* 靠 `available()` 猜——`available()` 在 `content://` 流上不可靠。
*
* 读满 [limit] 就停:一份被换成 500 MiB 的文件(选完之后、发送之前被替换)不会先在
* 内存里炸掉进程再报错。
*/
fun readBounded(input: InputStream, limit: Long): ByteArray? {
val buffer = ByteArrayOutputStream()
val chunk = ByteArray(DEFAULT_BUFFER_SIZE)
var total = 0L
while (true) {
val read = input.read(chunk)
if (read < 0) break
total += read
if (total > limit) return null
buffer.write(chunk, 0, read)
}
return buffer.toByteArray()
}
@@ -0,0 +1,113 @@
package top.yeij.cyrene.domain.chat
import top.yeij.cyrene.data.api.dto.DocumentRefDto
import java.util.Locale
/**
* 一枚附件芯片要显示什么(纯数据,UI 直接照着渲染)。
*
* 字段刻意是**已格式化好的字符串**而不是原始数值:气泡里没有第二处地方能解释这些数,
* 而「12.0 KB」「8,000 字」这种换算一旦写进 Composable 就没法单测了。
*/
data class DocumentChipModel(
/** 服务端存储名(`<32hex>.<ext>`)或待发附件的本地显示名。 */
val name: String,
/** 类型标签(扩展名大写);判不出类型时是「文件」。 */
val typeLabel: String,
/** 体积标签;读不出来时是空串。 */
val sizeLabel: String,
/** 本回合注入的字符数;服务端没报就是 `null`。 */
val charsLabel: String?,
/** 抽取状态;服务端没报就回 `null`,未知取值**原样显示**。 */
val statusLabel: String?,
/**
* `truncated` 的标签:只表示**注入被预算截断**(正文照样进了上下文),
* 与 [unreadable] 是两件事,文案也必须分开写。
*/
val truncatedLabel: String?,
/** `status == "unreadable"`:这是唯一一个「昔涟读不到」的信号,UI 据此给灰态。 */
val unreadable: Boolean,
) {
/** 芯片副行:`MD · 12 KB · 8,000 字 · 已读入`(缺哪项少哪项,全缺就是空串)。 */
val detail: String
get() = listOfNotNull(typeLabel, sizeLabel, charsLabel, statusLabel, truncatedLabel)
.filter { it.isNotEmpty() }
.joinToString(" · ")
}
/** 附件芯片的展示换算(纯函数,与 UI 无关)。 */
object DocumentChips {
/**
* 一条**已落库**的文档引用 → 芯片模型。
*
* 名字只有服务端存储名可用:服务端按内容寻址落盘(`sha256[:32] + ext`),**原始文件名
* 永不参与路径**,也就不会回到客户端。所以这里**不编**一个「用户当初叫它什么」的名字——
* 那会是本端凭空捏造的信息(历史消息里根本没有它)。长名字由 UI 侧
* `TextOverflow.MiddleEllipsis` 处理,保住扩展名。
*/
fun of(ref: DocumentRefDto): DocumentChipModel = DocumentChipModel(
name = ref.name,
typeLabel = typeLabel(ref.name),
sizeLabel = sizeLabel(ref.size),
charsLabel = charsLabel(ref.chars),
statusLabel = statusLabel(ref.status),
truncatedLabel = if (ref.truncated) TRUNCATED else null,
unreadable = DocumentStatus.isUnreadable(ref.status),
)
/** 一份**待发**附件 → 芯片模型:还没有服务端结论,所以状态与字数为空。 */
fun of(draft: DraftDocument): DocumentChipModel = DocumentChipModel(
name = draft.displayName.ifBlank { draft.uri },
typeLabel = typeLabel(draft.displayName),
sizeLabel = sizeLabel(draft.sizeBytes),
charsLabel = null,
statusLabel = null,
truncatedLabel = null,
unreadable = false,
)
/** 扩展名大写;没有扩展名或扩展名不像扩展名时回「文件」(不猜类型)。 */
fun typeLabel(name: String): String {
val extension = name.substringAfterLast('.', "")
val usable = extension.length in 1..8 && extension.all { it.isLetterOrDigit() }
return if (usable) extension.uppercase(Locale.ROOT) else NO_TYPE
}
/** 体积;[DraftDocument.UNKNOWN_SIZE](拿不到元数据)回空串,**不显示一个假的 0 B**。 */
fun sizeLabel(bytes: Long): String = when {
bytes < 0 -> ""
bytes < KIB -> "$bytes B"
bytes < MIB -> oneDecimal(bytes / KIB.toDouble()) + " KB"
else -> oneDecimal(bytes / MIB.toDouble()) + " MB"
}
/** 注入字符数;`null`(服务端没报这一项)回 `null`。 */
fun charsLabel(chars: Int?): String? =
chars?.let { String.format(Locale.ROOT, "%,d", it) + " 字" }
/**
* 抽取状态 → 文案。`""`(服务端未报)回 `null`,**未知取值原样显示**——
* 与项目里 `mode` / `platform` / `mood` 的开放集合口径一致,不丢弃、不占位符。
*
* 三句分开写是刻意的:[LISTED] 只说明「正文没进上下文」,**不是**读不到;
* [UNREADABLE] 才是读不到。
*/
fun statusLabel(status: String): String? = when (status) {
"" -> null
DocumentStatus.EXTRACTED -> "已读入"
DocumentStatus.LISTED -> "只看得到清单"
DocumentStatus.UNREADABLE -> "读不到"
else -> status
}
private fun oneDecimal(value: Double): String = String.format(Locale.ROOT, "%.1f", value)
private const val NO_TYPE = "文件"
private const val TRUNCATED = "注入已截断"
private const val KIB = 1024L
private const val MIB = 1024L * 1024L
}
@@ -130,9 +130,25 @@ object DocumentDelivery {
* @param text 本轮的正文(调用方已 trim;空串 = 纯附件回合,服务端**支持**纯附件回合)。
* @param files 本轮要挂的本地附件。
*/
fun plan(text: String, files: List<DocumentFile>): DocumentPlan = when {
files.size > MAX_ATTACHMENTS_PER_TURN -> DocumentPlan.Rejected(DocumentRejection.TooManyAttachments)
files.isNotEmpty() -> DocumentPlan.AsDocuments(files = files, textAsDocument = textNeedsFile(text))
fun plan(text: String, files: List<DocumentFile>): DocumentPlan =
when (val shape = planForCount(text, files.size)) {
is DocumentPlan.AsDocuments -> shape.copy(files = files)
else -> shape
}
/**
* 同 [plan] 的判定矩阵,但**只看附件份数**。
*
* 为什么需要它:调用方(`ChatViewModel`)手上还没有字节——最多 5×20 MiB 一次性读进内存
* 是不可接受的,而判定矩阵里**唯一**与文件内容有关的就是份数(正文那一项看 [textNeedsFile])。
* 有了这一条,调用方就能在读字节之前先答出「要不要拒绝」「要不要走上传这条链」,
* 而不是先读一堆字节再发现自己根本不该发。
*
* @param attachmentCount 本回合要挂的附件份数。
*/
fun planForCount(text: String, attachmentCount: Int): DocumentPlan = when {
attachmentCount > MAX_ATTACHMENTS_PER_TURN -> DocumentPlan.Rejected(DocumentRejection.TooManyAttachments)
attachmentCount > 0 -> DocumentPlan.AsDocuments(files = emptyList(), textAsDocument = textNeedsFile(text))
textNeedsFile(text) -> DocumentPlan.AsDocuments(textAsDocument = true)
else -> DocumentPlan.Inline
}
@@ -3,36 +3,87 @@ package top.yeij.cyrene.domain.share
/**
* 从别的 App 分享进来、已经可以落地的内容。
*
* [images] 是**本端自己落盘后**的 `file://` URI:外部 `content://` 的读授权只在这个 intent
* 的接收方(Activity / 任务)存活期间有效,跨不过「未登录 → 登录」,所以图片必须在收到的那一刻
* 就拷进 app 的 cache 目录(见 `app/ShareIntake.kt`)。
* [images] / [documents] 里的 URI 在**提取阶段**还是对方给的原始 URI;由 `app/ShareIntake.kt`
* 拷进缓存后换成**本端自己**的 `file://`:外部 `content://` 的读授权只在这个 intent 的接收方
* (Activity / 任务)存活期间有效,跨不过「未登录 → 登录」,所以必须在收到的那一刻就落盘。
*
* 两个丢件计数分开记,是因为它们该说的话不一样:截断是「本端的规则」,读不到是「对方给的
* 东西用不了」。分享进来的内容**不许静默少发**——少发的部分要在界面上如实讲清楚。
* 丢件计数按**两条路分开记**(图片 / 文档),因为「本端的接收上限」与「对方给的东西读不到」
* 是两句不同的话,而图片与文档的上限也不是同一个数。分享进来的内容**不许静默少发**。
*/
data class SharedContent(
val text: String = "",
val images: List<String> = emptyList(),
/** 超过 [ShareRules.MAX_IMAGES] 而未被接收的条数。 */
/**
* 非图片的分享文件。MIME 以 `image/` 开头者走 [images],其余一律走这一条
* (判定见 [classifySharedStream])——这正是修复「把一份 `.md` 当图片去解码、
* 最后报一句『图片读不到』」的地方。
*/
val documents: List<SharedDocument> = emptyList(),
/** 超过 [ShareRules.MAX_IMAGES] 而未被接收的图片条数。 */
val truncatedImages: Int = 0,
/** 已接收但**拷贝失败**(读不到 / 写不进 cache)的条数。 */
/** 已接收但**拷贝失败**(读不到 / 写不进 cache)的图片条数。 */
val failedImages: Int = 0,
/** 超过 [ShareRules.MAX_DOCUMENTS] 而未被接收的文件条数。 */
val truncatedDocuments: Int = 0,
/** 已接收但**拷贝失败**的文件条数。 */
val failedDocuments: Int = 0,
) {
/** 没能落地的总条数。 */
val droppedImages: Int get() = truncatedImages + failedImages
/** 没有任何可发送的内容(此时只剩下 [droppedImages] 需要告知)。 */
val isEmpty: Boolean get() = text.isBlank() && images.isEmpty()
/** 没能落地的文件总条数。 */
val droppedDocuments: Int get() = truncatedDocuments + failedDocuments
/** 没有任何可发送的内容(此时只剩下丢件计数需要告知)。 */
val isEmpty: Boolean get() = text.isBlank() && images.isEmpty() && documents.isEmpty()
}
/**
* 一份分享进来的**非图片**文件。
*
* [uri] 是拷进 cache 后的本端 `file://` 地址;[displayName] 只用来给上传部件命名(服务端只看
* 扩展名)与给用户看,[sizeBytes] 用于发送前的本地体积预检(拿不到时是 -1,不猜)。
*/
data class SharedDocument(
val uri: String,
val displayName: String = "",
val sizeBytes: Long = -1L,
)
/** 一条分享进来的 URI 该走哪条路。 */
enum class SharedStreamKind { Image, Document }
/**
* 按 MIME 分流(纯函数,可单测)。
*
* **以 `image/` 开头才当图片**,其余(含 `null`、`text/plain`、`application/pdf`……)一律走文档路:
* 此前把所有 `EXTRA_STREAM` 都当图片拷、当图片解码,于是一份 `.md` 只有两种结局——
* 「图片处理失败」或者被悄悄当成一张读不出来的图。文档路至少会把字节原样交给服务端,
* 由**字节嗅探**裁决(白名单内的放行,其余用 `415` 的文案如实说)。
*
* `null`(provider 没报类型)也走文档路:本端**不猜**类型,猜错等于替用户把一个合规文件
* 变成一次失败。代价是「图片但 provider 没报 MIME」会落到文档路并被服务端拒——这是刻意的
* 取舍,见 `app/ShareIntake.kt` 的 KDoc。
*/
fun classifySharedStream(mime: String?): SharedStreamKind {
val base = mime?.substringBefore(';')?.trim()?.lowercase()
return if (base != null && base.startsWith("image/")) SharedStreamKind.Image else SharedStreamKind.Document
}
/**
* 分享入口的规则(纯函数,与 Android 无关,可单测;Intent 的读取在 `app/ShareIntake.kt`)。
*
* ## 为什么只认这两个 action、两类 MIME
* Manifest 只声明 `text/plain` 与 `image/*`:声明的每一类都必须是**本端真能落地**的东西。
* 声明 `*/*` 会让「分享 PDF / 视频 / 压缩包给昔涟」出现在系统分享菜单里,而对话路径上今天
* 根本没有「文件」这个表示(协同文档 `02/F1`:表示 / 存储 / 摄取三缺)——那会造出一个
* **点进去必然失败**的入口。收窄之后非图片文件干脆不进分享菜单,用户看不到一个假的选项。
* ## 为什么只认这两个 action、Manifest 里只声明两类 MIME
* Manifest 只声明 `text/plain` 与图片那一类(`image/` 前缀):声明的每一类都必须是**本端真能
* 落地**的东西。后端**明令拒绝** `text/html` / SVG / XML / 压缩包 / 脚本,声明全通配 MIME
* 或文本通配 MIME 会造出一堆「点进去必然失败」的入口(`02/F1` 那条的三缺早已补上,但类型
* 白名单本身就是 deny-by-default 的 22 个扩展名)。`text/plain` 已经能接住 `.txt` / `.md` /
* `.log` 这类文件管理器会报 `text/plain` 的文件;`.md` 若被报成 `text/markdown`,那它**不进**
* 分享菜单——少一个入口,好过一个必然失败的入口。(**Manifest 不动**是硬约束。)
*
* ## 两个 action 与两条路
* [ACTION_SEND] 收文本 + 一个 URI(图片或文件);[ACTION_SEND_MULTIPLE] 本端只声明了图片那一类,
* 所以多选一条实际只可能是图片。URI 走哪条路由 [classifySharedStream] 按 MIME 判。
*
* ## 为什么正文一个字都不加
* **绝不**往用户正文里拼「[用户分享]」之类的来源标记:来源标识走后端的独立 `RoleSystem`
@@ -41,10 +92,10 @@ data class SharedContent(
*/
object ShareRules {
/** `Intent.ACTION_SEND`:一条文本,或一张图片(可能还带一段说明文字)。 */
/** `Intent.ACTION_SEND`:一条文本,或一张图片 / 一个文件(可能还带一段说明文字)。 */
const val ACTION_SEND = "android.intent.action.SEND"
/** `Intent.ACTION_SEND_MULTIPLE`:多条;本端只接图片。 */
/** `Intent.ACTION_SEND_MULTIPLE`:多条;本端 Manifest 只声明了图片那一类。 */
const val ACTION_SEND_MULTIPLE = "android.intent.action.SEND_MULTIPLE"
/**
@@ -56,10 +107,19 @@ object ShareRules {
*/
const val MAX_IMAGES = 4
/**
* 一次分享最多接收几个非图片文件。
*
* 取服务端的一个回合上限(契约 v0.59 §1.2:`documents` **≤5 条**)——附件是**挂在同一条
* 消息上**的,所以这里不能像图片那样「多出来的另起几条消息」:超过就是服务端
* `400 too_many_documents`。超出的部分同样如实告知,绝不静默裁。
*/
const val MAX_DOCUMENTS = 5
/**
* 从分享 Intent 的原始字段提取内容。
*
* 图片的取值顺序是 `EXTRA_STREAM` → `ClipData`:前者是契约路径,后者是兜底——确实有
* URI 的取值顺序是 `EXTRA_STREAM` → `ClipData`:前者是契约路径,后者是兜底——确实有
* App(尤其旧版本)只填 `ClipData` 而不填 `EXTRA_STREAM`,只认前者会让分享静默落空。
* 两者都有时**只取前者**,不会把同一张图数两遍。
*
@@ -69,6 +129,9 @@ object ShareRules {
* @param stream `EXTRA_STREAM`([ACTION_SEND] 的单条 URI 字符串)
* @param streams `EXTRA_STREAM`([ACTION_SEND_MULTIPLE] 的 URI 列表)
* @param clipUris `ClipData` 各条目的 URI;文本类条目没有 URI,会被自动忽略
* @param mimeOf 取一条 URI 的 MIME(Android 侧就是 `ContentResolver.getType`)。本函数是
* **纯函数**,所以类型判定由调用方注入;默认「拿不到类型」⇒ 按文档路走
* (见 [classifySharedStream],默认值就是「不猜」)。
*/
fun extract(
action: String?,
@@ -76,6 +139,7 @@ object ShareRules {
stream: String? = null,
streams: List<String>? = null,
clipUris: List<String> = emptyList(),
mimeOf: (String) -> String? = { null },
): SharedContent {
val recognized = action == ACTION_SEND || action == ACTION_SEND_MULTIPLE
if (!recognized) return SharedContent()
@@ -93,10 +157,23 @@ object ShareRules {
.filter(String::isNotEmpty)
.distinct()
val images = mutableListOf<String>()
val documents = mutableListOf<String>()
unique.forEach { uri ->
when (classifySharedStream(mimeOf(uri))) {
SharedStreamKind.Image -> images += uri
SharedStreamKind.Document -> documents += uri
}
}
return SharedContent(
text = body,
images = unique.take(MAX_IMAGES),
truncatedImages = (unique.size - MAX_IMAGES).coerceAtLeast(0),
images = images.take(MAX_IMAGES),
// 提取阶段还不知道显示名与大小(要 ContentResolver),先放原样 URI;
// ShareIntake 拷进 cache 后再换成 file:// + 显示名 + 真实字节数。
documents = documents.take(MAX_DOCUMENTS).map { SharedDocument(uri = it) },
truncatedImages = (images.size - MAX_IMAGES).coerceAtLeast(0),
truncatedDocuments = (documents.size - MAX_DOCUMENTS).coerceAtLeast(0),
)
}
@@ -108,13 +185,31 @@ object ShareRules {
* 口子接到自己的 cache 目录上。这个函数是纯的,所以它连 [token] 都不信——非法字符一律折成
* `_`,`. .` 因此拼不出 `..`,`/` 也活不下来。
*/
fun cacheFileName(token: String, index: Int, mime: String?): String {
val safe = token.map { c ->
if (c in 'a'..'z' || c in 'A'..'Z' || c in '0'..'9' || c == '-' || c == '_') c else '_'
}.joinToString("")
return "share-${safe.ifEmpty { "x" }}-$index.${extensionFor(mime)}"
fun cacheFileName(token: String, index: Int, mime: String?): String =
"share-${safeToken(token)}-$index.${extensionFor(mime)}"
/**
* 非图片分享文件的缓存名。
*
* 扩展名**照抄对方给的名字**(服务端按扩展名判白名单,缓存件本身只是本端的临时载体,
* 但名字对得上排查时少一次误解),并且同样只由本端 token 与序号构成路径——
* 对方名字里的 `/` 与 `..` 活不下来(见 [safeExtension])。
*/
fun documentCacheFileName(token: String, index: Int, originalName: String): String =
"share-${safeToken(token)}-$index.${safeExtension(originalName)}"
/** 非白名单 / 空 / 含非法字符的扩展名一律回落 `txt`(**不猜**类型)。 */
private fun safeExtension(originalName: String): String {
val extension = originalName.substringAfterLast('.', "").lowercase()
val usable = extension.length in 1..8 && extension.all { it.isLetterOrDigit() }
return if (usable) extension else "txt"
}
private fun safeToken(token: String): String =
token.map { c ->
if (c in 'a'..'z' || c in 'A'..'Z' || c in '0'..'9' || c == '-' || c == '_') c else '_'
}.joinToString("").ifEmpty { "x" }
/** MIME → 扩展名;未知 / 缺失回落 `img`。 */
private fun extensionFor(mime: String?): String =
when (mime?.substringBefore(';')?.trim()?.lowercase()) {