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:
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()) {
|
||||
|
||||
Reference in new issue
Block a user