merge: W9 — 文档附件接到 UI 与发送链路(选文件 / 待发芯片 / chat.documents / 分享分流)
This commit is contained in:
commit
ab76798bff
14 files changed
+1668
-65
No files matched your search
@@ -9,12 +9,14 @@ import top.yeij.cyrene.core.prefs.SessionPreferences
|
|||||||
import top.yeij.cyrene.data.api.AdminApi
|
import top.yeij.cyrene.data.api.AdminApi
|
||||||
import top.yeij.cyrene.data.api.ApiClientFactory
|
import top.yeij.cyrene.data.api.ApiClientFactory
|
||||||
import top.yeij.cyrene.data.api.ChatApi
|
import top.yeij.cyrene.data.api.ChatApi
|
||||||
|
import top.yeij.cyrene.data.api.DocumentsApi
|
||||||
import top.yeij.cyrene.data.api.MetaApi
|
import top.yeij.cyrene.data.api.MetaApi
|
||||||
import top.yeij.cyrene.data.api.PersonaApi
|
import top.yeij.cyrene.data.api.PersonaApi
|
||||||
import top.yeij.cyrene.data.api.SessionsApi
|
import top.yeij.cyrene.data.api.SessionsApi
|
||||||
import top.yeij.cyrene.data.api.UploadApi
|
import top.yeij.cyrene.data.api.UploadApi
|
||||||
import top.yeij.cyrene.data.repository.AdminRepository
|
import top.yeij.cyrene.data.repository.AdminRepository
|
||||||
import top.yeij.cyrene.data.repository.ChatRepository
|
import top.yeij.cyrene.data.repository.ChatRepository
|
||||||
|
import top.yeij.cyrene.data.repository.DocumentRepository
|
||||||
import top.yeij.cyrene.data.repository.MessageRepository
|
import top.yeij.cyrene.data.repository.MessageRepository
|
||||||
import top.yeij.cyrene.data.repository.MetaRepository
|
import top.yeij.cyrene.data.repository.MetaRepository
|
||||||
import top.yeij.cyrene.data.repository.PersonaRepository
|
import top.yeij.cyrene.data.repository.PersonaRepository
|
||||||
@@ -105,6 +107,35 @@ class AppContainer(context: Context) {
|
|||||||
|
|
||||||
val uploadRepository: UploadRepository get() = UploadRepository(uploadApi)
|
val uploadRepository: UploadRepository get() = UploadRepository(uploadApi)
|
||||||
|
|
||||||
|
private var cachedDocumentsApi: DocumentsApi? = null
|
||||||
|
private var cachedDocumentsBaseUrl: String? = null
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 文档存储端点(`POST /api/v1/documents` 上传、`GET /api/v1/documents/{name}` 取字节);
|
||||||
|
* 与 [chatApi] **必须同源**(同一 baseUrl、同一 token/401 处理)——上传拿到的存储名就是要交给
|
||||||
|
* 同一个后端的 `POST /api/v1/chat` 的 `documents[].name`,两条链路不在同一台机器上就必然 404。
|
||||||
|
*
|
||||||
|
* Bearer 与 401 续期不用在这里做额外事:[ApiClientFactory.create] 按 **OkHttpClient**
|
||||||
|
* 挂 `AuthInterceptor`,文档读侧那点「与图片读侧刻意相反」的鉴权要求因此天然满足。
|
||||||
|
*/
|
||||||
|
val documentsApi: DocumentsApi
|
||||||
|
get() {
|
||||||
|
val baseUrl = authManager.currentBaseUrl
|
||||||
|
val cached = cachedDocumentsApi
|
||||||
|
if (cached != null && cachedDocumentsBaseUrl == baseUrl) return cached
|
||||||
|
val created = ApiClientFactory.create(
|
||||||
|
baseUrl = baseUrl,
|
||||||
|
service = DocumentsApi::class.java,
|
||||||
|
tokenProvider = authManager::currentToken,
|
||||||
|
unauthorizedPolicy = authManager.unauthorizedPolicy,
|
||||||
|
)
|
||||||
|
cachedDocumentsBaseUrl = baseUrl
|
||||||
|
cachedDocumentsApi = created
|
||||||
|
return created
|
||||||
|
}
|
||||||
|
|
||||||
|
val documentRepository: DocumentRepository get() = DocumentRepository(documentsApi)
|
||||||
|
|
||||||
private var cachedAdminApi: AdminApi? = null
|
private var cachedAdminApi: AdminApi? = null
|
||||||
private var cachedAdminBaseUrl: String? = null
|
private var cachedAdminBaseUrl: String? = null
|
||||||
|
|
||||||
|
|||||||
@@ -3,14 +3,17 @@ package top.yeij.cyrene.app
|
|||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
|
import android.provider.OpenableColumns
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.SupervisorJob
|
import kotlinx.coroutines.SupervisorJob
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import top.yeij.cyrene.domain.chat.DocumentAttachments
|
||||||
import top.yeij.cyrene.domain.share.PendingShareHolder
|
import top.yeij.cyrene.domain.share.PendingShareHolder
|
||||||
import top.yeij.cyrene.domain.share.ShareRules
|
import top.yeij.cyrene.domain.share.ShareRules
|
||||||
import top.yeij.cyrene.domain.share.SharedContent
|
import top.yeij.cyrene.domain.share.SharedContent
|
||||||
|
import top.yeij.cyrene.domain.share.SharedDocument
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
|
|
||||||
@@ -26,13 +29,28 @@ import java.util.UUID
|
|||||||
* ## 图片为什么在收到的那一刻就落盘
|
* ## 图片为什么在收到的那一刻就落盘
|
||||||
* `content://` 的读授权只给到**接收它的那个 Activity / 任务**,登录(可能十几秒、还可能切页)
|
* `content://` 的读授权只给到**接收它的那个 Activity / 任务**,登录(可能十几秒、还可能切页)
|
||||||
* 之后再去读,拿到的多半是 `SecurityException` / 打不开。所以字节在这里立刻拷进 `cacheDir`,
|
* 之后再去读,拿到的多半是 `SecurityException` / 打不开。所以字节在这里立刻拷进 `cacheDir`,
|
||||||
* 之后一律用本端自己的 `file://` 路径——**外部 Uri 只在这个方法里出现一次**。
|
* 之后一律用本端自己的 `file://` 路径——**外部 Uri 只在这个方法里出现一次**。文件(非图片)同理。
|
||||||
|
*
|
||||||
|
* ## 分流:图片还是文件
|
||||||
|
* 逐条 URI 看 `ContentResolver.getType`(判定见 [top.yeij.cyrene.domain.share.classifySharedStream]):
|
||||||
|
* MIME 以 `image/` 开头走既有图片路,**其余一律走文档路**。此前一律当图片拷、当图片解码,
|
||||||
|
* 一份 `.md` 因此只有两种结局——「图片处理失败」或者被当成一张读不出来的图。
|
||||||
|
*
|
||||||
|
* ⚠️ **Manifest 刻意不声明全通配或文本通配 MIME**:后端**明令拒绝** `text/html` / SVG / XML /
|
||||||
|
* 压缩包 / 脚本,放宽声明会造出一堆「点进去必然失败」的入口。所以这里能接到的**文件**只有
|
||||||
|
* 系统按 `text/plain` 投过来那些(`.txt` / `.md` / `.log` 这类文件管理器确实会报 `text/plain`);
|
||||||
|
* 报成 `text/markdown` 之类的分享**根本不会**出现在 Cyrene 的分享菜单里。
|
||||||
|
*
|
||||||
|
* ⚠️ **provider 不报 MIME(`getType` 回 null)时按文档路走**,这是刻意的:本端不猜类型,
|
||||||
|
* 猜错等于替用户把一个合规文件变成一次失败;代价是「图片但 provider 没报 MIME」会落到文档路、
|
||||||
|
* 被服务端按 `415` 拒(有如实文案)。画廊 / 相机 / MediaStore 都会报图片那一类,风险面很小。
|
||||||
*
|
*
|
||||||
* ## 清理
|
* ## 清理
|
||||||
* 缓存副本只对**产生它的那个进程**有意义(持有者在内存里,进程没了就没人认领),所以
|
* 缓存副本只对**产生它的那个进程**有意义(持有者在内存里,进程没了就没人认领),所以
|
||||||
* 进程启动时整目录删一次([reset])。运行期间不做细粒度回收:那些文件在发送失败被回滚到
|
* 进程启动时整目录删一次([reset])。运行期间不做细粒度回收:那些文件在发送失败被回滚到
|
||||||
* 输入框之后**仍然要用**(用户可能过一会儿才点发送),提前删掉就会让预览变空图。
|
* 输入框之后**仍然要用**(用户可能过一会儿才点发送),提前删掉就会让预览变空图。
|
||||||
* 目录因此以「一次进程生命周期内的分享次数 × 4」为界,另有系统的 cache 回收兜底。
|
* 目录因此以「一次进程生命周期内的分享份数」为界(图片 4 + 文件 5,见 [ShareRules]),
|
||||||
|
* 另有系统的 cache 回收兜底。
|
||||||
*/
|
*/
|
||||||
object ShareIntake {
|
object ShareIntake {
|
||||||
|
|
||||||
@@ -64,30 +82,41 @@ object ShareIntake {
|
|||||||
/**
|
/**
|
||||||
* 接收一个分享 Intent(冷启动在 `onCreate`、热启动在 `onNewIntent`)。
|
* 接收一个分享 Intent(冷启动在 `onCreate`、热启动在 `onNewIntent`)。
|
||||||
*
|
*
|
||||||
* 提取是纯函数([ShareRules.extract]),拷图是重活,所以在 IO 线程做:`content://` 的读
|
* 提取是纯函数([ShareRules.extract]),拷字节是重活,所以在 IO 线程做:`content://` 的读
|
||||||
* 授权绑的是 Activity 而不是某一次调用栈,异步读一样有效。
|
* 授权绑的是 Activity 而不是某一次调用栈,异步读一样有效。
|
||||||
*/
|
*/
|
||||||
fun accept(context: Context, intent: Intent) {
|
fun accept(context: Context, intent: Intent) {
|
||||||
|
val appContext = context.applicationContext
|
||||||
val extracted = ShareRules.extract(
|
val extracted = ShareRules.extract(
|
||||||
action = intent.action,
|
action = intent.action,
|
||||||
text = intent.getCharSequenceExtra(Intent.EXTRA_TEXT),
|
text = intent.getCharSequenceExtra(Intent.EXTRA_TEXT),
|
||||||
stream = intent.streamUri(),
|
stream = intent.streamUri(),
|
||||||
streams = intent.streamUris(),
|
streams = intent.streamUris(),
|
||||||
clipUris = intent.clipUris(),
|
clipUris = intent.clipUris(),
|
||||||
|
// 分流就发生在这一处:读不到类型(null)也按文档路走,见类 KDoc。
|
||||||
|
mimeOf = { uri ->
|
||||||
|
runCatching { context.contentResolver.getType(Uri.parse(uri)) }.getOrNull()
|
||||||
|
},
|
||||||
)
|
)
|
||||||
if (extracted.isEmpty && extracted.droppedImages == 0) return
|
// 没有任何文本、也没有任何待落地的 URI ⇒ 这条分享里什么都没有(丢件计数在提取期恒为 0;
|
||||||
|
// 保留原来那条防御性判断,只是把「文件」那一档一并算上)。
|
||||||
|
if (extracted.isEmpty && extracted.droppedImages == 0 && extracted.droppedDocuments == 0) return
|
||||||
|
|
||||||
val appContext = context.applicationContext
|
|
||||||
scope.launch {
|
scope.launch {
|
||||||
val dir = shareDir(appContext).apply { mkdirs() }
|
val dir = shareDir(appContext).apply { mkdirs() }
|
||||||
val copied = extracted.images.mapIndexedNotNull { index, uri ->
|
val copied = extracted.images.mapIndexedNotNull { index, uri ->
|
||||||
copyToCache(appContext, dir, uri, index)
|
copyToCache(appContext, dir, uri, index)
|
||||||
}
|
}
|
||||||
|
val documents = extracted.documents.mapIndexedNotNull { index, document ->
|
||||||
|
copyDocumentToCache(appContext, dir, document, index)
|
||||||
|
}
|
||||||
holder.put(
|
holder.put(
|
||||||
extracted.copy(
|
extracted.copy(
|
||||||
images = copied,
|
images = copied,
|
||||||
|
documents = documents,
|
||||||
// 拷不动的那些如实计入「没发送」:宁可界面上说一句,也不要静默少一张。
|
// 拷不动的那些如实计入「没发送」:宁可界面上说一句,也不要静默少一张。
|
||||||
failedImages = extracted.failedImages + (extracted.images.size - copied.size),
|
failedImages = extracted.failedImages + (extracted.images.size - copied.size),
|
||||||
|
failedDocuments = extracted.failedDocuments + (extracted.documents.size - documents.size),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -103,6 +132,55 @@ object ShareIntake {
|
|||||||
|
|
||||||
private fun shareDir(context: Context): File = File(context.cacheDir, DIR_NAME)
|
private fun shareDir(context: Context): File = File(context.cacheDir, DIR_NAME)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 把一份非图片分享文件的字节拷进 cache,返回**本端自己的** `file://` 地址 + 显示名 + 真实字节数;
|
||||||
|
* 失败回 `null`。
|
||||||
|
*
|
||||||
|
* 显示名必须先取(拷完之后外部 URI 就只在这个调用栈里了),取不到就回落 URI 最后一段——
|
||||||
|
* 它是**上传部件的名字**,服务端只从中取扩展名(判定白名单),空名字则由仓库层兜
|
||||||
|
* `document.txt`。
|
||||||
|
*/
|
||||||
|
private fun copyDocumentToCache(
|
||||||
|
context: Context,
|
||||||
|
dir: File,
|
||||||
|
document: SharedDocument,
|
||||||
|
index: Int,
|
||||||
|
): SharedDocument? {
|
||||||
|
val parsed = runCatching { Uri.parse(document.uri) }.getOrNull() ?: return null
|
||||||
|
if (parsed.scheme.isNullOrBlank()) return null
|
||||||
|
|
||||||
|
val name = displayNameOf(context, parsed)
|
||||||
|
val target = File(dir, ShareRules.documentCacheFileName(UUID.randomUUID().toString(), index, name))
|
||||||
|
return try {
|
||||||
|
val source = context.contentResolver.openInputStream(parsed) ?: return null
|
||||||
|
source.use { input -> target.outputStream().use { output -> input.copyTo(output) } }
|
||||||
|
val size = target.length()
|
||||||
|
if (size > 0L) {
|
||||||
|
document.copy(uri = Uri.fromFile(target).toString(), displayName = name, sizeBytes = size)
|
||||||
|
} else {
|
||||||
|
target.delete()
|
||||||
|
null
|
||||||
|
}
|
||||||
|
} catch (_: Exception) {
|
||||||
|
// 半截文件不能留下:下一次发送会把它当一份真文件去读,报出的错会指向错的地方。
|
||||||
|
target.delete()
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** provider 报的显示名优先;拿不到就回落到 URI 最后一段(都不给就留空,不猜扩展名)。 */
|
||||||
|
private fun displayNameOf(context: Context, uri: Uri): String {
|
||||||
|
val fromProvider = runCatching {
|
||||||
|
context.contentResolver.query(uri, null, null, null, null)?.use { cursor ->
|
||||||
|
if (!cursor.moveToFirst()) return@use null
|
||||||
|
val index = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME)
|
||||||
|
if (index < 0 || cursor.isNull(index)) null else cursor.getString(index)
|
||||||
|
}
|
||||||
|
}.getOrNull()
|
||||||
|
return fromProvider?.takeIf { it.isNotBlank() }
|
||||||
|
?: DocumentAttachments.fallbackDocumentName(uri.toString())
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 把一个外部 URI 的字节拷进 cache,返回本端自己的 `file://` URI;失败回 `null`。
|
* 把一个外部 URI 的字节拷进 cache,返回本端自己的 `file://` URI;失败回 `null`。
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -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 text 本轮的正文(调用方已 trim;空串 = 纯附件回合,服务端**支持**纯附件回合)。
|
||||||
* @param files 本轮要挂的本地附件。
|
* @param files 本轮要挂的本地附件。
|
||||||
*/
|
*/
|
||||||
fun plan(text: String, files: List<DocumentFile>): DocumentPlan = when {
|
fun plan(text: String, files: List<DocumentFile>): DocumentPlan =
|
||||||
files.size > MAX_ATTACHMENTS_PER_TURN -> DocumentPlan.Rejected(DocumentRejection.TooManyAttachments)
|
when (val shape = planForCount(text, files.size)) {
|
||||||
files.isNotEmpty() -> DocumentPlan.AsDocuments(files = files, textAsDocument = textNeedsFile(text))
|
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)
|
textNeedsFile(text) -> DocumentPlan.AsDocuments(textAsDocument = true)
|
||||||
else -> DocumentPlan.Inline
|
else -> DocumentPlan.Inline
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,36 +3,87 @@ package top.yeij.cyrene.domain.share
|
|||||||
/**
|
/**
|
||||||
* 从别的 App 分享进来、已经可以落地的内容。
|
* 从别的 App 分享进来、已经可以落地的内容。
|
||||||
*
|
*
|
||||||
* [images] 是**本端自己落盘后**的 `file://` URI:外部 `content://` 的读授权只在这个 intent
|
* [images] / [documents] 里的 URI 在**提取阶段**还是对方给的原始 URI;由 `app/ShareIntake.kt`
|
||||||
* 的接收方(Activity / 任务)存活期间有效,跨不过「未登录 → 登录」,所以图片必须在收到的那一刻
|
* 拷进缓存后换成**本端自己**的 `file://`:外部 `content://` 的读授权只在这个 intent 的接收方
|
||||||
* 就拷进 app 的 cache 目录(见 `app/ShareIntake.kt`)。
|
* (Activity / 任务)存活期间有效,跨不过「未登录 → 登录」,所以必须在收到的那一刻就落盘。
|
||||||
*
|
*
|
||||||
* 两个丢件计数分开记,是因为它们该说的话不一样:截断是「本端的规则」,读不到是「对方给的
|
* 丢件计数按**两条路分开记**(图片 / 文档),因为「本端的接收上限」与「对方给的东西读不到」
|
||||||
* 东西用不了」。分享进来的内容**不许静默少发**——少发的部分要在界面上如实讲清楚。
|
* 是两句不同的话,而图片与文档的上限也不是同一个数。分享进来的内容**不许静默少发**。
|
||||||
*/
|
*/
|
||||||
data class SharedContent(
|
data class SharedContent(
|
||||||
val text: String = "",
|
val text: String = "",
|
||||||
val images: List<String> = emptyList(),
|
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,
|
val truncatedImages: Int = 0,
|
||||||
/** 已接收但**拷贝失败**(读不到 / 写不进 cache)的条数。 */
|
/** 已接收但**拷贝失败**(读不到 / 写不进 cache)的图片条数。 */
|
||||||
val failedImages: Int = 0,
|
val failedImages: Int = 0,
|
||||||
|
/** 超过 [ShareRules.MAX_DOCUMENTS] 而未被接收的文件条数。 */
|
||||||
|
val truncatedDocuments: Int = 0,
|
||||||
|
/** 已接收但**拷贝失败**的文件条数。 */
|
||||||
|
val failedDocuments: Int = 0,
|
||||||
) {
|
) {
|
||||||
/** 没能落地的总条数。 */
|
/** 没能落地的总条数。 */
|
||||||
val droppedImages: Int get() = truncatedImages + failedImages
|
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`)。
|
* 分享入口的规则(纯函数,与 Android 无关,可单测;Intent 的读取在 `app/ShareIntake.kt`)。
|
||||||
*
|
*
|
||||||
* ## 为什么只认这两个 action、两类 MIME
|
* ## 为什么只认这两个 action、Manifest 里只声明两类 MIME
|
||||||
* Manifest 只声明 `text/plain` 与 `image/*`:声明的每一类都必须是**本端真能落地**的东西。
|
* Manifest 只声明 `text/plain` 与图片那一类(`image/` 前缀):声明的每一类都必须是**本端真能
|
||||||
* 声明 `*/*` 会让「分享 PDF / 视频 / 压缩包给昔涟」出现在系统分享菜单里,而对话路径上今天
|
* 落地**的东西。后端**明令拒绝** `text/html` / SVG / XML / 压缩包 / 脚本,声明全通配 MIME
|
||||||
* 根本没有「文件」这个表示(协同文档 `02/F1`:表示 / 存储 / 摄取三缺)——那会造出一个
|
* 或文本通配 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`
|
* **绝不**往用户正文里拼「[用户分享]」之类的来源标记:来源标识走后端的独立 `RoleSystem`
|
||||||
@@ -41,10 +92,10 @@ data class SharedContent(
|
|||||||
*/
|
*/
|
||||||
object ShareRules {
|
object ShareRules {
|
||||||
|
|
||||||
/** `Intent.ACTION_SEND`:一条文本,或一张图片(可能还带一段说明文字)。 */
|
/** `Intent.ACTION_SEND`:一条文本,或一张图片 / 一个文件(可能还带一段说明文字)。 */
|
||||||
const val ACTION_SEND = "android.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"
|
const val ACTION_SEND_MULTIPLE = "android.intent.action.SEND_MULTIPLE"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -56,10 +107,19 @@ object ShareRules {
|
|||||||
*/
|
*/
|
||||||
const val MAX_IMAGES = 4
|
const val MAX_IMAGES = 4
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 一次分享最多接收几个非图片文件。
|
||||||
|
*
|
||||||
|
* 取服务端的一个回合上限(契约 v0.59 §1.2:`documents` **≤5 条**)——附件是**挂在同一条
|
||||||
|
* 消息上**的,所以这里不能像图片那样「多出来的另起几条消息」:超过就是服务端
|
||||||
|
* `400 too_many_documents`。超出的部分同样如实告知,绝不静默裁。
|
||||||
|
*/
|
||||||
|
const val MAX_DOCUMENTS = 5
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 从分享 Intent 的原始字段提取内容。
|
* 从分享 Intent 的原始字段提取内容。
|
||||||
*
|
*
|
||||||
* 图片的取值顺序是 `EXTRA_STREAM` → `ClipData`:前者是契约路径,后者是兜底——确实有
|
* URI 的取值顺序是 `EXTRA_STREAM` → `ClipData`:前者是契约路径,后者是兜底——确实有
|
||||||
* App(尤其旧版本)只填 `ClipData` 而不填 `EXTRA_STREAM`,只认前者会让分享静默落空。
|
* App(尤其旧版本)只填 `ClipData` 而不填 `EXTRA_STREAM`,只认前者会让分享静默落空。
|
||||||
* 两者都有时**只取前者**,不会把同一张图数两遍。
|
* 两者都有时**只取前者**,不会把同一张图数两遍。
|
||||||
*
|
*
|
||||||
@@ -69,6 +129,9 @@ object ShareRules {
|
|||||||
* @param stream `EXTRA_STREAM`([ACTION_SEND] 的单条 URI 字符串)
|
* @param stream `EXTRA_STREAM`([ACTION_SEND] 的单条 URI 字符串)
|
||||||
* @param streams `EXTRA_STREAM`([ACTION_SEND_MULTIPLE] 的 URI 列表)
|
* @param streams `EXTRA_STREAM`([ACTION_SEND_MULTIPLE] 的 URI 列表)
|
||||||
* @param clipUris `ClipData` 各条目的 URI;文本类条目没有 URI,会被自动忽略
|
* @param clipUris `ClipData` 各条目的 URI;文本类条目没有 URI,会被自动忽略
|
||||||
|
* @param mimeOf 取一条 URI 的 MIME(Android 侧就是 `ContentResolver.getType`)。本函数是
|
||||||
|
* **纯函数**,所以类型判定由调用方注入;默认「拿不到类型」⇒ 按文档路走
|
||||||
|
* (见 [classifySharedStream],默认值就是「不猜」)。
|
||||||
*/
|
*/
|
||||||
fun extract(
|
fun extract(
|
||||||
action: String?,
|
action: String?,
|
||||||
@@ -76,6 +139,7 @@ object ShareRules {
|
|||||||
stream: String? = null,
|
stream: String? = null,
|
||||||
streams: List<String>? = null,
|
streams: List<String>? = null,
|
||||||
clipUris: List<String> = emptyList(),
|
clipUris: List<String> = emptyList(),
|
||||||
|
mimeOf: (String) -> String? = { null },
|
||||||
): SharedContent {
|
): SharedContent {
|
||||||
val recognized = action == ACTION_SEND || action == ACTION_SEND_MULTIPLE
|
val recognized = action == ACTION_SEND || action == ACTION_SEND_MULTIPLE
|
||||||
if (!recognized) return SharedContent()
|
if (!recognized) return SharedContent()
|
||||||
@@ -93,10 +157,23 @@ object ShareRules {
|
|||||||
.filter(String::isNotEmpty)
|
.filter(String::isNotEmpty)
|
||||||
.distinct()
|
.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(
|
return SharedContent(
|
||||||
text = body,
|
text = body,
|
||||||
images = unique.take(MAX_IMAGES),
|
images = images.take(MAX_IMAGES),
|
||||||
truncatedImages = (unique.size - MAX_IMAGES).coerceAtLeast(0),
|
// 提取阶段还不知道显示名与大小(要 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] 都不信——非法字符一律折成
|
* 口子接到自己的 cache 目录上。这个函数是纯的,所以它连 [token] 都不信——非法字符一律折成
|
||||||
* `_`,`. .` 因此拼不出 `..`,`/` 也活不下来。
|
* `_`,`. .` 因此拼不出 `..`,`/` 也活不下来。
|
||||||
*/
|
*/
|
||||||
fun cacheFileName(token: String, index: Int, mime: String?): String {
|
fun cacheFileName(token: String, index: Int, mime: String?): String =
|
||||||
val safe = token.map { c ->
|
"share-${safeToken(token)}-$index.${extensionFor(mime)}"
|
||||||
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)}"
|
* 非图片分享文件的缓存名。
|
||||||
|
*
|
||||||
|
* 扩展名**照抄对方给的名字**(服务端按扩展名判白名单,缓存件本身只是本端的临时载体,
|
||||||
|
* 但名字对得上排查时少一次误解),并且同样只由本端 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`。 */
|
/** MIME → 扩展名;未知 / 缺失回落 `img`。 */
|
||||||
private fun extensionFor(mime: String?): String =
|
private fun extensionFor(mime: String?): String =
|
||||||
when (mime?.substringBefore(';')?.trim()?.lowercase()) {
|
when (mime?.substringBefore(';')?.trim()?.lowercase()) {
|
||||||
|
|||||||
@@ -47,6 +47,8 @@ import kotlinx.coroutines.withTimeoutOrNull
|
|||||||
import top.yeij.cyrene.app.AppContainer
|
import top.yeij.cyrene.app.AppContainer
|
||||||
import top.yeij.cyrene.app.ShareIntake
|
import top.yeij.cyrene.app.ShareIntake
|
||||||
import top.yeij.cyrene.data.ws.WsState
|
import top.yeij.cyrene.data.ws.WsState
|
||||||
|
import top.yeij.cyrene.domain.chat.DocumentChips
|
||||||
|
import top.yeij.cyrene.domain.chat.DraftDocument
|
||||||
import top.yeij.cyrene.domain.share.ShareRules
|
import top.yeij.cyrene.domain.share.ShareRules
|
||||||
import top.yeij.cyrene.domain.share.SharedContent
|
import top.yeij.cyrene.domain.share.SharedContent
|
||||||
import top.yukonga.miuix.kmp.basic.Button
|
import top.yukonga.miuix.kmp.basic.Button
|
||||||
@@ -59,6 +61,7 @@ import top.yukonga.miuix.kmp.basic.TextField
|
|||||||
import top.yukonga.miuix.kmp.icon.MiuixIcons
|
import top.yukonga.miuix.kmp.icon.MiuixIcons
|
||||||
import top.yukonga.miuix.kmp.icon.basic.Close
|
import top.yukonga.miuix.kmp.icon.basic.Close
|
||||||
import top.yukonga.miuix.kmp.icon.basic.Sidebar
|
import top.yukonga.miuix.kmp.icon.basic.Sidebar
|
||||||
|
import top.yukonga.miuix.kmp.icon.extended.File
|
||||||
import top.yukonga.miuix.kmp.icon.extended.Image
|
import top.yukonga.miuix.kmp.icon.extended.Image
|
||||||
import top.yukonga.miuix.kmp.theme.MiuixTheme
|
import top.yukonga.miuix.kmp.theme.MiuixTheme
|
||||||
|
|
||||||
@@ -71,6 +74,31 @@ private const val SHARE_CONNECT_TIMEOUT_MILLIS = 3_000L
|
|||||||
/** 输入栏下方预留的高度:避开屏幕圆角,同时承载忙碌提示。 */
|
/** 输入栏下方预留的高度:避开屏幕圆角,同时承载忙碌提示。 */
|
||||||
private val InputBottomSpacer = 22.dp
|
private val InputBottomSpacer = 22.dp
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 选文件时交给系统选择器的 MIME 列表。
|
||||||
|
*
|
||||||
|
* 判据不是「后端白名单的 22 个扩展名」本身——那 22 个扩展名里有 `.go` / `.rs` / `.sql` / `.log`,
|
||||||
|
* 各家文件管理器给它们报的类型五花八门(`text/x-go`、`application/octet-stream`……)。列窄了
|
||||||
|
* 会出现「后端收得下、用户却选不到」的假失败,所以这里取**语义上贴近文本/代码**的那一族,
|
||||||
|
* 并含 `application/octet-stream`(未知扩展名的常见报法)。
|
||||||
|
*
|
||||||
|
* **刻意不列**已经确定会被后端拒的那几类(`application/pdf`、`application/zip`、
|
||||||
|
* `image/svg+xml`、`text/html` 等):让它们在选择器里就是灰的,比让用户传完再看一句
|
||||||
|
* 「服务端不接受这个格式」更省事。最终裁决仍是**服务端的字节嗅探**——这里放行的,服务端
|
||||||
|
* 照旧可以用 `415 unsupported_media_type` 拒掉,那条文案会原样显示给用户,不假装成功。
|
||||||
|
*/
|
||||||
|
private val DocumentPickerMimeTypes = arrayOf(
|
||||||
|
"text/*",
|
||||||
|
"application/json",
|
||||||
|
"application/x-json",
|
||||||
|
"application/x-yaml",
|
||||||
|
"application/yaml",
|
||||||
|
"text/yaml",
|
||||||
|
"application/toml",
|
||||||
|
"application/x-toml",
|
||||||
|
"application/octet-stream",
|
||||||
|
)
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun ChatScreen(
|
fun ChatScreen(
|
||||||
container: AppContainer,
|
container: AppContainer,
|
||||||
@@ -91,6 +119,14 @@ fun ChatScreen(
|
|||||||
ActivityResultContracts.PickVisualMedia(),
|
ActivityResultContracts.PickVisualMedia(),
|
||||||
) { uri -> uri?.let { viewModel.onImagePicked(it.toString()) } }
|
) { uri -> uri?.let { viewModel.onImagePicked(it.toString()) } }
|
||||||
|
|
||||||
|
// 文件选择器(SAF `OpenMultipleDocuments`):同样不需要任何存储权限——授权是**逐次**给的,
|
||||||
|
// 因此这条 URI 的读授权只活到本 Activity 任务结束;而发送可能发生在登录之后(分享落地)
|
||||||
|
// 或很久之后,那时授权还在(同一个任务内)。跨任务的情况由 `ShareIntake` 自己拷进 cache 兜住。
|
||||||
|
// 一次可选多份(上限由 VM 的准入规则管,超了会如实告知)。
|
||||||
|
val documentPicker = rememberLauncherForActivityResult(
|
||||||
|
ActivityResultContracts.OpenMultipleDocuments(),
|
||||||
|
) { uris -> viewModel.onDocumentsPicked(uris.map { it.toString() }) }
|
||||||
|
|
||||||
// 「分享到 App」的落地时机。待落地的内容活在 Compose 之外(`ShareIntake`),这里只做三件事:
|
// 「分享到 App」的落地时机。待落地的内容活在 Compose 之外(`ShareIntake`),这里只做三件事:
|
||||||
// 挑时机、取走一条、交给既有发送路径。
|
// 挑时机、取走一条、交给既有发送路径。
|
||||||
// - **先等连接、但只等一小会儿**:短文本走 WS,而冷启动时握手还没完成(分享冷启动必然撞上),
|
// - **先等连接、但只等一小会儿**:短文本走 WS,而冷启动时握手还没完成(分享冷启动必然撞上),
|
||||||
@@ -158,6 +194,7 @@ fun ChatScreen(
|
|||||||
InputBar(
|
InputBar(
|
||||||
value = state.draft,
|
value = state.draft,
|
||||||
imageUri = state.draftImage,
|
imageUri = state.draftImage,
|
||||||
|
documents = state.draftDocuments,
|
||||||
busyText = busyLabel(state),
|
busyText = busyLabel(state),
|
||||||
onValueChange = viewModel::onDraftChange,
|
onValueChange = viewModel::onDraftChange,
|
||||||
onSend = viewModel::send,
|
onSend = viewModel::send,
|
||||||
@@ -167,6 +204,8 @@ fun ChatScreen(
|
|||||||
)
|
)
|
||||||
},
|
},
|
||||||
onClearImage = viewModel::clearDraftImage,
|
onClearImage = viewModel::clearDraftImage,
|
||||||
|
onPickDocument = { documentPicker.launch(DocumentPickerMimeTypes) },
|
||||||
|
onRemoveDocument = viewModel::removeDraftDocument,
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
) { padding ->
|
) { padding ->
|
||||||
@@ -222,6 +261,9 @@ fun ChatScreen(
|
|||||||
state.shareNotice?.let { message ->
|
state.shareNotice?.let { message ->
|
||||||
Banner(text = message, color = MiuixTheme.colorScheme.onSurfaceVariantSummary)
|
Banner(text = message, color = MiuixTheme.colorScheme.onSurfaceVariantSummary)
|
||||||
}
|
}
|
||||||
|
state.attachmentNotice?.let { message ->
|
||||||
|
Banner(text = message, color = MiuixTheme.colorScheme.onSurfaceVariantSummary)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -245,11 +287,14 @@ private fun Banner(text: String, color: Color) {
|
|||||||
private fun InputBar(
|
private fun InputBar(
|
||||||
value: String,
|
value: String,
|
||||||
imageUri: String?,
|
imageUri: String?,
|
||||||
|
documents: List<DraftDocument>,
|
||||||
busyText: String,
|
busyText: String,
|
||||||
onValueChange: (String) -> Unit,
|
onValueChange: (String) -> Unit,
|
||||||
onSend: () -> Unit,
|
onSend: () -> Unit,
|
||||||
onPickImage: () -> Unit,
|
onPickImage: () -> Unit,
|
||||||
onClearImage: () -> Unit,
|
onClearImage: () -> Unit,
|
||||||
|
onPickDocument: () -> Unit,
|
||||||
|
onRemoveDocument: (Int) -> Unit,
|
||||||
) {
|
) {
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
@@ -258,6 +303,8 @@ private fun InputBar(
|
|||||||
.padding(start = 12.dp, end = 12.dp, top = 8.dp, bottom = 4.dp),
|
.padding(start = 12.dp, end = 12.dp, top = 8.dp, bottom = 4.dp),
|
||||||
) {
|
) {
|
||||||
imageUri?.let { DraftImagePreview(uri = it, onClear = onClearImage) }
|
imageUri?.let { DraftImagePreview(uri = it, onClear = onClearImage) }
|
||||||
|
// 待发附件:输入栏上方的一排可移除芯片(顺序即发送顺序,服务端也按这个顺序存)。
|
||||||
|
DraftDocumentChips(documents = documents, onRemove = onRemoveDocument)
|
||||||
Row(verticalAlignment = Alignment.Bottom) {
|
Row(verticalAlignment = Alignment.Bottom) {
|
||||||
// 图片入口只在没有待发图片时出现:已经选好一张就没必要再添一张(一次一条消息一张图)。
|
// 图片入口只在没有待发图片时出现:已经选好一张就没必要再添一张(一次一条消息一张图)。
|
||||||
if (imageUri == null) {
|
if (imageUri == null) {
|
||||||
@@ -270,6 +317,15 @@ private fun InputBar(
|
|||||||
}
|
}
|
||||||
Spacer(Modifier.width(4.dp))
|
Spacer(Modifier.width(4.dp))
|
||||||
}
|
}
|
||||||
|
// 附件入口一直在:一回合最多 5 份,到顶时由 VM 给出「已达上限」的提示(而不是把入口藏起来)。
|
||||||
|
IconButton(onClick = onPickDocument) {
|
||||||
|
Icon(
|
||||||
|
imageVector = MiuixIcons.File,
|
||||||
|
contentDescription = "添加文件附件",
|
||||||
|
tint = MiuixTheme.colorScheme.onSurfaceVariantSummary,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Spacer(Modifier.width(4.dp))
|
||||||
TextField(
|
TextField(
|
||||||
value = value,
|
value = value,
|
||||||
onValueChange = onValueChange,
|
onValueChange = onValueChange,
|
||||||
@@ -283,8 +339,9 @@ private fun InputBar(
|
|||||||
Spacer(Modifier.width(8.dp))
|
Spacer(Modifier.width(8.dp))
|
||||||
Button(
|
Button(
|
||||||
onClick = onSend,
|
onClick = onSend,
|
||||||
// 图片可以单独发出(契约:text / image 至少一项非空)。
|
// 附件可以单独发出(契约:`text` / `image` / `documents` 任一项非空即可,
|
||||||
enabled = value.isNotBlank() || imageUri != null,
|
// 纯附件回合服务端**支持**)。
|
||||||
|
enabled = value.isNotBlank() || imageUri != null || documents.isNotEmpty(),
|
||||||
) {
|
) {
|
||||||
Text("发送")
|
Text("发送")
|
||||||
}
|
}
|
||||||
@@ -306,6 +363,16 @@ private fun InputBar(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 待发附件的芯片排:横向可滚(5 枚芯片不挤成两行,也不会把输入框顶上去)。 */
|
||||||
|
@Composable
|
||||||
|
private fun DraftDocumentChips(documents: List<DraftDocument>, onRemove: (Int) -> Unit) {
|
||||||
|
DocumentChipRow(
|
||||||
|
chips = documents.map(DocumentChips::of),
|
||||||
|
onRemove = onRemove,
|
||||||
|
modifier = Modifier.padding(bottom = 6.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
/** 待发图片的本地预览(`content://` URI,直接由 Coil 读本地文件,不联网)。 */
|
/** 待发图片的本地预览(`content://` URI,直接由 Coil 读本地文件,不联网)。 */
|
||||||
@Composable
|
@Composable
|
||||||
private fun DraftImagePreview(uri: String, onClear: () -> Unit) {
|
private fun DraftImagePreview(uri: String, onClear: () -> Unit) {
|
||||||
@@ -373,19 +440,23 @@ private fun busyLabel(state: ChatUiState): String = when {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 分享落地的如实告知:只在「有东西没能按原样发出」时才成句,正常落地(一条文本 / 一张图)
|
* 分享落地的如实告知:只在「有东西没能按原样发出」时才成句,正常落地(一条文本 / 一张图 /
|
||||||
* 一个字都不说。
|
* 一个文件)一个字都不说。
|
||||||
*
|
*
|
||||||
* 这是**本端渲染层**的信息,绝不写进正文:来源 / 系统提示的边界见 `design/11 §10`
|
* 这是**本端渲染层**的信息,绝不写进正文:来源 / 系统提示的边界见 `design/11 §10`
|
||||||
* (`02/F4` 的红线)。截断与读不到要说的话不一样——前者是本端的接收上限,后者是对方给的东西
|
* (`02/F4` 的红线)。截断与读不到要说的话不一样——前者是本端的接收上限,后者是对方给的东西
|
||||||
* 用不了,混成一句会让人以为图还在路上。
|
* 用不了,混成一句会让人以为图还在路上。图片与文件也分开说:两者的上限不是一个数。
|
||||||
*/
|
*/
|
||||||
private fun shareNoticeFor(share: SharedContent): String? {
|
private fun shareNoticeFor(share: SharedContent): String? {
|
||||||
val parts = buildList {
|
val parts = buildList {
|
||||||
if (share.truncatedImages > 0) {
|
if (share.truncatedImages > 0) {
|
||||||
add("分享的图片超过 ${ShareRules.MAX_IMAGES} 张,只发送前 ${share.images.size} 张")
|
add("分享的图片超过 ${ShareRules.MAX_IMAGES} 张,只发送前 ${share.images.size} 张")
|
||||||
}
|
}
|
||||||
|
if (share.truncatedDocuments > 0) {
|
||||||
|
add("分享的文件超过 ${ShareRules.MAX_DOCUMENTS} 个,只发送前 ${share.documents.size} 个")
|
||||||
|
}
|
||||||
if (share.failedImages > 0) add("有 ${share.failedImages} 张图片读不到,未发送")
|
if (share.failedImages > 0) add("有 ${share.failedImages} 张图片读不到,未发送")
|
||||||
|
if (share.failedDocuments > 0) add("有 ${share.failedDocuments} 个文件读不到,未发送")
|
||||||
if (share.isEmpty) add("分享的内容是空的,没有可发送的内容")
|
if (share.isEmpty) add("分享的内容是空的,没有可发送的内容")
|
||||||
}
|
}
|
||||||
return parts.takeIf { it.isNotEmpty() }?.joinToString(";")
|
return parts.takeIf { it.isNotEmpty() }?.joinToString(";")
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
package top.yeij.cyrene.ui.chat
|
package top.yeij.cyrene.ui.chat
|
||||||
|
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
|
import android.provider.OpenableColumns
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import kotlinx.coroutines.CancellationException
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
@@ -14,6 +16,7 @@ import top.yeij.cyrene.app.AppContainer
|
|||||||
import top.yeij.cyrene.core.error.CyreneError
|
import top.yeij.cyrene.core.error.CyreneError
|
||||||
import top.yeij.cyrene.data.api.CyreneException
|
import top.yeij.cyrene.data.api.CyreneException
|
||||||
import top.yeij.cyrene.data.api.dto.ChatRequest
|
import top.yeij.cyrene.data.api.dto.ChatRequest
|
||||||
|
import top.yeij.cyrene.data.api.dto.DocumentRefDto
|
||||||
import top.yeij.cyrene.data.api.dto.HistoryMessageDto
|
import top.yeij.cyrene.data.api.dto.HistoryMessageDto
|
||||||
import top.yeij.cyrene.data.image.ChatImagePreparer
|
import top.yeij.cyrene.data.image.ChatImagePreparer
|
||||||
import top.yeij.cyrene.data.image.PreparedImage
|
import top.yeij.cyrene.data.image.PreparedImage
|
||||||
@@ -27,8 +30,16 @@ import top.yeij.cyrene.data.ws.WsState
|
|||||||
import top.yeij.cyrene.data.ws.isForSession
|
import top.yeij.cyrene.data.ws.isForSession
|
||||||
import top.yeij.cyrene.domain.chat.ChatImageDelivery
|
import top.yeij.cyrene.domain.chat.ChatImageDelivery
|
||||||
import top.yeij.cyrene.domain.chat.ChatSendRouting
|
import top.yeij.cyrene.domain.chat.ChatSendRouting
|
||||||
|
import top.yeij.cyrene.domain.chat.DocumentAttachments
|
||||||
|
import top.yeij.cyrene.domain.chat.DocumentDelivery
|
||||||
|
import top.yeij.cyrene.domain.chat.DocumentFailure
|
||||||
|
import top.yeij.cyrene.domain.chat.DocumentPlan
|
||||||
|
import top.yeij.cyrene.domain.chat.DocumentRead
|
||||||
|
import top.yeij.cyrene.domain.chat.DraftDocument
|
||||||
import top.yeij.cyrene.domain.chat.ImageDelivery
|
import top.yeij.cyrene.domain.chat.ImageDelivery
|
||||||
import top.yeij.cyrene.domain.chat.SendChannel
|
import top.yeij.cyrene.domain.chat.SendChannel
|
||||||
|
import top.yeij.cyrene.domain.chat.readBounded
|
||||||
|
import top.yeij.cyrene.domain.chat.toDocumentFailure
|
||||||
import top.yeij.cyrene.domain.parse.ParsedBubble
|
import top.yeij.cyrene.domain.parse.ParsedBubble
|
||||||
import top.yeij.cyrene.domain.parse.StreamingContentParser
|
import top.yeij.cyrene.domain.parse.StreamingContentParser
|
||||||
import top.yeij.cyrene.domain.share.SharedContent
|
import top.yeij.cyrene.domain.share.SharedContent
|
||||||
@@ -53,6 +64,11 @@ data class PendingMessage(
|
|||||||
val text: String,
|
val text: String,
|
||||||
/** 本次带的图片的**本地可读** URI(`content://` 或 `file://`);null = 纯文本消息。 */
|
/** 本次带的图片的**本地可读** URI(`content://` 或 `file://`);null = 纯文本消息。 */
|
||||||
val imageUri: String? = null,
|
val imageUri: String? = null,
|
||||||
|
/**
|
||||||
|
* 本次带的文档附件(本地引用,还没上传)。它们与 [text]、[imageUri] 各自独立存在:
|
||||||
|
* 用户打的字照发、附件随 `chat.documents` 一起发(见 `05` 的定案)。
|
||||||
|
*/
|
||||||
|
val documents: List<DraftDocument> = emptyList(),
|
||||||
)
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -191,6 +207,12 @@ data class ChatUiState(
|
|||||||
val draft: String = "",
|
val draft: String = "",
|
||||||
/** 已选好、还没发出去的图片(本地 URI 字符串);null = 没有待发图片。 */
|
/** 已选好、还没发出去的图片(本地 URI 字符串);null = 没有待发图片。 */
|
||||||
val draftImage: String? = null,
|
val draftImage: String? = null,
|
||||||
|
/**
|
||||||
|
* 已选好、还没发出去的文档附件(与 [draftImage] 平行的概念)。发送失败时与正文、图片一起
|
||||||
|
* 回到这里(见 [ChatViewModel.rollback]),所以它们必须一路带着**本地引用**——
|
||||||
|
* 重新上传的代价是几 MB 的流量与一次白等。
|
||||||
|
*/
|
||||||
|
val draftDocuments: List<DraftDocument> = emptyList(),
|
||||||
val loading: Boolean = true,
|
val loading: Boolean = true,
|
||||||
val loadingOlder: Boolean = false,
|
val loadingOlder: Boolean = false,
|
||||||
val hasMore: Boolean = true,
|
val hasMore: Boolean = true,
|
||||||
@@ -202,6 +224,13 @@ data class ChatUiState(
|
|||||||
* 它到期自动消失([SHARE_NOTICE_MILLIS]),因为它描述的是刚刚发生的一次动作。
|
* 它到期自动消失([SHARE_NOTICE_MILLIS]),因为它描述的是刚刚发生的一次动作。
|
||||||
*/
|
*/
|
||||||
val shareNotice: String? = null,
|
val shareNotice: String? = null,
|
||||||
|
/**
|
||||||
|
* 附件动作的如实告知(例如「有 2 个文件超过 20 MB,没有加入」);null = 没有要说的。
|
||||||
|
*
|
||||||
|
* 与 [error] 分开、也与 [shareNotice] 分开:被挡下的文件**不是发送失败**,用户要做的动作
|
||||||
|
* 也不一样(换文件 / 先发一批)。同样到期自动消失([ATTACHMENT_NOTICE_MILLIS])。
|
||||||
|
*/
|
||||||
|
val attachmentNotice: String? = null,
|
||||||
val connection: WsState = WsState.Idle,
|
val connection: WsState = WsState.Idle,
|
||||||
/** 编排忙碌态:`idle`/`thinking`/`executing`/`subagent`;null = 空闲。 */
|
/** 编排忙碌态:`idle`/`thinking`/`executing`/`subagent`;null = 空闲。 */
|
||||||
val busyMode: String? = null,
|
val busyMode: String? = null,
|
||||||
@@ -298,31 +327,64 @@ class ChatViewModel(
|
|||||||
fun clearDraftImage() = _state.update { it.copy(draftImage = null) }
|
fun clearDraftImage() = _state.update { it.copy(draftImage = null) }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 发送:先乐观上屏,再按路由决定走哪条上行通道;失败回滚并把文本 / 图片还给输入框。
|
* 选好待发附件(SAF `OpenDocument` / `OpenMultipleDocuments` 回来的 URI)。
|
||||||
*
|
*
|
||||||
* 路由 [ChatSendRouting]:**短文本且无图 → WS**(直达、绕过有损总线,今天就能用);
|
* 元数据(显示名 / 字节数)要问 `ContentResolver`,所以放到 IO 线程;准入判定本身是纯函数
|
||||||
* **长文本或任何图片 → REST `stream:true`**。REST 起的回合**同样在已连接的 WS 上流式
|
* ([DocumentAttachments.admit]),被挡下的份数如实告知、**不静默裁**。
|
||||||
* 回来**(同一个编排器 + 广播路径),所以「REST 发、WS 收」不牺牲流式体验。
|
*/
|
||||||
|
fun onDocumentsPicked(uris: List<String>) {
|
||||||
|
val picked = uris.map(String::trim).filter(String::isNotEmpty)
|
||||||
|
if (picked.isEmpty()) return
|
||||||
|
viewModelScope.launch {
|
||||||
|
val documents = withContext(Dispatchers.IO) { picked.map(::draftDocumentFor) }
|
||||||
|
var notice: String? = null
|
||||||
|
_state.update { state ->
|
||||||
|
val result = DocumentAttachments.admit(state.draftDocuments, documents)
|
||||||
|
notice = result.notice
|
||||||
|
state.copy(draftDocuments = state.draftDocuments + result.accepted)
|
||||||
|
}
|
||||||
|
notice?.let(::showAttachmentNotice)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 移除第 [index] 个待发附件(越界原样不动,UI 的移除按钮不必自己判界)。 */
|
||||||
|
fun removeDraftDocument(index: Int) =
|
||||||
|
_state.update { it.copy(draftDocuments = DocumentAttachments.removeAt(it.draftDocuments, index)) }
|
||||||
|
|
||||||
|
fun clearDraftDocuments() = _state.update { it.copy(draftDocuments = emptyList()) }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 发送:先乐观上屏,再按路由决定走哪条上行通道;失败回滚并把文本 / 图片 / 附件还给输入框。
|
||||||
|
*
|
||||||
|
* 路由 [ChatSendRouting]:**短文本且无图无附件 → WS**(直达、绕过有损总线,今天就能用);
|
||||||
|
* **长文本、任何图片、任何附件 → REST `stream:true`**。REST 起的回合**同样在已连接的 WS 上
|
||||||
|
* 流式回来**(同一个编排器 + 广播路径),所以「REST 发、WS 收」不牺牲流式体验。
|
||||||
*
|
*
|
||||||
* 图片先在本端解码 / 缩放 / 重编码([ChatImagePreparer]),这一步是重活,放在 IO 线程上;
|
* 图片先在本端解码 / 缩放 / 重编码([ChatImagePreparer]),这一步是重活,放在 IO 线程上;
|
||||||
* 气泡同步上屏,所以等待期间用户已经能看到它。重编码之后再按体积决定**内联还是上传**
|
* 气泡同步上屏,所以等待期间用户已经能看到它。重编码之后再按体积决定**内联还是上传**
|
||||||
* (见 [deliverImage] 与 [ChatImageDelivery])——大图宁可多走一次 `POST /api/v1/upload`,
|
* (见 [deliverImage] 与 [ChatImageDelivery])——大图宁可多走一次 `POST /api/v1/upload`,
|
||||||
* 也不把几 MB base64 永久留在每一页历史里。
|
* 也不把几 MB base64 永久留在每一页历史里。
|
||||||
|
*
|
||||||
|
* 附件**一律先上传**换服务端存储名(`POST /api/v1/documents`),再随 `chat.documents` 引用
|
||||||
|
* 发出,上行只带 `name`(见 [DocumentDelivery.payload] 与 [uploadDocuments])。
|
||||||
*/
|
*/
|
||||||
fun send() {
|
fun send() {
|
||||||
val snapshot = _state.value
|
val snapshot = _state.value
|
||||||
val text = snapshot.draft.trim()
|
val text = snapshot.draft.trim()
|
||||||
val imageUri = snapshot.draftImage
|
val imageUri = snapshot.draftImage
|
||||||
if (text.isEmpty() && imageUri == null) return
|
val documents = snapshot.draftDocuments
|
||||||
viewModelScope.launch { emitAndDeliver(text, imageUri, clearDraft = true) }
|
if (text.isEmpty() && imageUri == null && documents.isEmpty()) return
|
||||||
|
viewModelScope.launch { emitAndDeliver(text, imageUri, documents, clearDraft = true) }
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 「分享到 App」的落地路径:把从别的 App 分享进来的内容按**现有发送路径**发出
|
* 「分享到 App」的落地路径:把从别的 App 分享进来的内容按**现有发送路径**发出
|
||||||
* (短文本 → WS,长文本 / 图片 → REST,图片先 [ChatImagePreparer] 重编码、大图先 `/upload`),
|
* (短文本 → WS,长文本 / 图片 / 附件 → REST,图片先 [ChatImagePreparer] 重编码、
|
||||||
* **不另起一套发送实现**,也不动用户正文里的一个字。
|
* 大图先 `/upload`、附件先 `/documents`),**不另起一套发送实现**,也不动用户正文里的一个字。
|
||||||
*
|
*
|
||||||
* 一条消息只能带一张图,所以 N 张图 = N 条消息:第一条带上分享的正文,其余每条一张图。
|
* 一条消息只能带一张图,所以 N 张图 = N 条消息:第一条带上分享的正文,其余每条一张图。
|
||||||
|
* 非图片文件([SharedContent.documents])没有这个限制——它们与正文、图片挂在**同一条**
|
||||||
|
* 消息上(`chat.documents` 本就是数组,≤5 条),因此只随第一条走。
|
||||||
* 顺序由这里串起来(同一个协程里依次投递)——各投各的会让它们互相赛跑,落库顺序反过来。
|
* 顺序由这里串起来(同一个协程里依次投递)——各投各的会让它们互相赛跑,落库顺序反过来。
|
||||||
*
|
*
|
||||||
* 两处刻意的取舍:
|
* 两处刻意的取舍:
|
||||||
@@ -334,10 +396,13 @@ class ChatViewModel(
|
|||||||
fun sendShared(share: SharedContent, notice: String? = null) {
|
fun sendShared(share: SharedContent, notice: String? = null) {
|
||||||
notice?.let(::showShareNotice)
|
notice?.let(::showShareNotice)
|
||||||
val body = share.text.trim()
|
val body = share.text.trim()
|
||||||
if (body.isEmpty() && share.images.isEmpty()) return
|
val documents = share.documents.map {
|
||||||
|
DraftDocument(uri = it.uri, displayName = it.displayName, sizeBytes = it.sizeBytes)
|
||||||
|
}
|
||||||
|
if (body.isEmpty() && share.images.isEmpty() && documents.isEmpty()) return
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
emitAndDeliver(body, share.images.firstOrNull(), clearDraft = false)
|
emitAndDeliver(body, share.images.firstOrNull(), documents, clearDraft = false)
|
||||||
share.images.drop(1).forEach { emitAndDeliver("", it, clearDraft = false) }
|
share.images.drop(1).forEach { emitAndDeliver("", it, emptyList(), clearDraft = false) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -350,29 +415,60 @@ class ChatViewModel(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 记一条附件提示,到期自动收走(与 [showShareNotice] 同一形态,只是另一块牌子)。 */
|
||||||
|
private fun showAttachmentNotice(text: String) {
|
||||||
|
_state.update { it.copy(attachmentNotice = text) }
|
||||||
|
viewModelScope.launch {
|
||||||
|
kotlinx.coroutines.delay(ATTACHMENT_NOTICE_MILLIS)
|
||||||
|
_state.update { if (it.attachmentNotice == text) it.copy(attachmentNotice = null) else it }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 把一条消息乐观上屏并投递出去([send] 与 [sendShared] 共用)。
|
* 把一条消息乐观上屏并投递出去([send] 与 [sendShared] 共用)。
|
||||||
*
|
*
|
||||||
* @param clearDraft 只有「用户按了发送键」才清草稿——那时草稿正是刚发出去的这一条;
|
* @param clearDraft 只有「用户按了发送键」才清草稿——那时草稿正是刚发出去的这一条;
|
||||||
* 分享落地是替用户发一条他自己录进来的内容,草稿不是它。
|
* 分享落地是替用户发一条他自己录进来的内容,草稿不是它。
|
||||||
*/
|
*/
|
||||||
private suspend fun emitAndDeliver(text: String, imageUri: String?, clearDraft: Boolean) {
|
private suspend fun emitAndDeliver(
|
||||||
if (text.isEmpty() && imageUri == null) return
|
text: String,
|
||||||
|
imageUri: String?,
|
||||||
|
documents: List<DraftDocument>,
|
||||||
|
clearDraft: Boolean,
|
||||||
|
) {
|
||||||
|
if (text.isEmpty() && imageUri == null && documents.isEmpty()) return
|
||||||
|
|
||||||
// localId 即幂等键:重试复用同一个值,服务端不会把它当成第二条消息。
|
// localId 即幂等键:重试复用同一个值,服务端不会把它当成第二条消息。
|
||||||
val localId = "local-${UUID.randomUUID()}"
|
val localId = "local-${UUID.randomUUID()}"
|
||||||
_state.update {
|
_state.update {
|
||||||
it.copy(
|
it.copy(
|
||||||
pending = it.pending + PendingMessage(localId, text, imageUri),
|
pending = it.pending + PendingMessage(localId, text, imageUri, documents),
|
||||||
draft = if (clearDraft) "" else it.draft,
|
draft = if (clearDraft) "" else it.draft,
|
||||||
draftImage = if (clearDraft) null else it.draftImage,
|
draftImage = if (clearDraft) null else it.draftImage,
|
||||||
|
draftDocuments = if (clearDraft) emptyList() else it.draftDocuments,
|
||||||
error = null,
|
error = null,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
deliver(localId, text, imageUri)
|
deliver(localId, text, imageUri, documents)
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun deliver(localId: String, text: String, imageUri: String?) {
|
private suspend fun deliver(
|
||||||
|
localId: String,
|
||||||
|
text: String,
|
||||||
|
imageUri: String?,
|
||||||
|
documents: List<DraftDocument>,
|
||||||
|
) {
|
||||||
|
// 判定矩阵([DocumentDelivery]):附件 >5 **直接拒绝,绝不裁剪**。这里用只需份数的
|
||||||
|
// [DocumentDelivery.planForCount]——字节还没读(最多 5×20 MiB 一次性读进内存不可接受),
|
||||||
|
// 而矩阵里唯一与文件内容有关的就是份数。
|
||||||
|
if (DocumentDelivery.planForCount(text, documents.size) is DocumentPlan.Rejected) {
|
||||||
|
rollback(localId, "一个回合最多 ${DocumentDelivery.MAX_ATTACHMENTS_PER_TURN} 个附件,没有发送")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// 产品定案(`05` 末节):**不做「长正文自动转文件」**。plan 的 `textAsDocument` 因此
|
||||||
|
// 一律不落地——用户打的字照发,长正文本来就已经由 [ChatSendRouting] 走 REST;
|
||||||
|
// 把它悄悄换成附件只会让它在历史里搜不到、气泡变成一枚芯片。
|
||||||
|
|
||||||
val image = imageUri?.let { uri ->
|
val image = imageUri?.let { uri ->
|
||||||
withContext(Dispatchers.IO) { ChatImagePreparer.prepare(container.appContext, Uri.parse(uri)) }
|
withContext(Dispatchers.IO) { ChatImagePreparer.prepare(container.appContext, Uri.parse(uri)) }
|
||||||
}
|
}
|
||||||
@@ -382,20 +478,122 @@ class ChatViewModel(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
when (ChatSendRouting.channelFor(text, hasImage = image != null)) {
|
// 附件先换存储名(失败已在里面回滚,这一轮到此为止)。
|
||||||
|
val payload = if (documents.isEmpty()) emptyList() else {
|
||||||
|
val names = uploadDocuments(localId, documents) ?: return
|
||||||
|
// 载荷不合法(>5 或有空名字)**必须提示**:服务端对这两种情况分别回 400 / 404,
|
||||||
|
// 客户端这边绝不能截断到 5 条、也不能把空名字那条悄悄丢掉。
|
||||||
|
DocumentDelivery.payload(names) ?: run {
|
||||||
|
rollback(localId, "附件引用不合法,没有发送")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
when (ChatSendRouting.channelFor(text, hasImage = image != null, hasDocuments = payload.isNotEmpty())) {
|
||||||
SendChannel.Ws -> {
|
SendChannel.Ws -> {
|
||||||
val sent = socket?.sendChat(sessionId, text, DEVICE_ID, localId) ?: false
|
val sent = socket?.sendChat(sessionId, text, DEVICE_ID, localId) ?: false
|
||||||
if (!sent) rollback(localId, "发送失败,请重试")
|
if (!sent) rollback(localId, "发送失败,请重试")
|
||||||
}
|
}
|
||||||
|
|
||||||
SendChannel.Rest -> if (image == null) {
|
SendChannel.Rest -> if (image == null) {
|
||||||
sendViaRest(localId, text, image = null)
|
sendViaRest(localId, text, image = null, documents = payload)
|
||||||
} else {
|
} else {
|
||||||
deliverImage(localId, text, image)
|
deliverImage(localId, text, image, payload)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 逐份把待发附件换成服务端存储名(`POST /api/v1/documents`,部件名 `file`、**保留扩展名**)。
|
||||||
|
*
|
||||||
|
* 三处刻意的取舍:
|
||||||
|
* 1. **一份一份传**(不是并发):上传失败要**立刻停**并把这一轮整个回滚,并发只会让后面
|
||||||
|
* 几份白传一遍,而服务端那边**没有删除端点**(`02/F7`)⇒ 多传一份就是多一份孤儿文件;
|
||||||
|
* 2. **字节现读**:`DraftDocument` 只带本地引用(见其 KDoc),最多 5×20 MiB 的字节不会
|
||||||
|
* 一直摆在 UI 状态里;读的时候用 [readBounded] 卡住上限,选完之后被换成超大文件也不会
|
||||||
|
* 先在内存里炸掉;
|
||||||
|
* 3. **`200 + deduped:true` 当成功**:[DocumentRepository] 已按 `apiCall` 的口径处理,
|
||||||
|
* 这里不做区分(同一份字节重复上传回的就是同一个 `name`)。
|
||||||
|
*
|
||||||
|
* @return 服务端存储名(顺序即附件顺序);任一份失败回 `null`(此时已经回滚并给过提示)。
|
||||||
|
*/
|
||||||
|
private suspend fun uploadDocuments(localId: String, documents: List<DraftDocument>): List<String>? {
|
||||||
|
val names = mutableListOf<String>()
|
||||||
|
documents.forEach { document ->
|
||||||
|
when (val read = readDocument(document)) {
|
||||||
|
is DocumentRead.TooLarge -> {
|
||||||
|
rollback(
|
||||||
|
localId,
|
||||||
|
"「${documentLabel(document)}」超过 ${DocumentAttachments.maxFileSizeLabel},没有发送",
|
||||||
|
)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
is DocumentRead.Unavailable -> {
|
||||||
|
rollback(localId, "读不到「${documentLabel(document)}」,请重新选择后再发")
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
is DocumentRead.Ok -> {
|
||||||
|
val uploaded = container.documentRepository
|
||||||
|
.upload(read.bytes, document.displayName)
|
||||||
|
.getOrElse { e ->
|
||||||
|
rollback(localId, documentUploadFailureMessage(e))
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
names += uploaded.name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return names
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 读一份待发附件的字节。元数据里已经报了体积的就先按体积挡(省一次几 MB 的读),
|
||||||
|
* 其余边读边卡上限——**超限与读不到必须分开**,两者要用户做的事不一样。
|
||||||
|
*/
|
||||||
|
private suspend fun readDocument(document: DraftDocument): DocumentRead {
|
||||||
|
if (document.sizeBytes > DocumentAttachments.MAX_FILE_BYTES) return DocumentRead.TooLarge
|
||||||
|
return withContext(Dispatchers.IO) {
|
||||||
|
try {
|
||||||
|
val input = container.appContext.contentResolver.openInputStream(Uri.parse(document.uri))
|
||||||
|
?: return@withContext DocumentRead.Unavailable
|
||||||
|
input.use { stream ->
|
||||||
|
val bytes = readBounded(stream, DocumentAttachments.MAX_FILE_BYTES)
|
||||||
|
if (bytes == null) DocumentRead.TooLarge else DocumentRead.Ok(bytes)
|
||||||
|
}
|
||||||
|
} catch (e: CancellationException) {
|
||||||
|
throw e
|
||||||
|
} catch (_: Exception) {
|
||||||
|
DocumentRead.Unavailable
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 一条待发附件的元数据(SAF 回来的 URI → 显示名 + 字节数)。拿不到就留空 / 留 -1,不猜。 */
|
||||||
|
private fun draftDocumentFor(uri: String): DraftDocument {
|
||||||
|
val parsed = runCatching { Uri.parse(uri) }.getOrNull()
|
||||||
|
var name = ""
|
||||||
|
var size = DraftDocument.UNKNOWN_SIZE
|
||||||
|
if (parsed != null) {
|
||||||
|
runCatching {
|
||||||
|
container.appContext.contentResolver.query(parsed, null, null, null, null)?.use { cursor ->
|
||||||
|
if (cursor.moveToFirst()) {
|
||||||
|
val nameIndex = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME)
|
||||||
|
if (nameIndex >= 0 && !cursor.isNull(nameIndex)) name = cursor.getString(nameIndex).orEmpty()
|
||||||
|
val sizeIndex = cursor.getColumnIndex(OpenableColumns.SIZE)
|
||||||
|
if (sizeIndex >= 0 && !cursor.isNull(sizeIndex)) size = cursor.getLong(sizeIndex)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return DraftDocument(
|
||||||
|
uri = uri,
|
||||||
|
displayName = name.ifBlank { DocumentAttachments.fallbackDocumentName(uri) },
|
||||||
|
sizeBytes = size,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 一张图怎么送上去:小图内联成 `data:` URI,大图先上传、再把返回的 URL 当 `image`
|
* 一张图怎么送上去:小图内联成 `data:` URI,大图先上传、再把返回的 URL 当 `image`
|
||||||
* (阈值与这笔成本账见 [ChatImageDelivery])。两者都走 REST `stream:true`,收都在 WS 上。
|
* (阈值与这笔成本账见 [ChatImageDelivery])。两者都走 REST `stream:true`,收都在 WS 上。
|
||||||
@@ -404,18 +602,32 @@ class ChatViewModel(
|
|||||||
* 落库的却是几 MB base64,而且是在他看不见的情况下换了一条语义不同的路。这里与
|
* 落库的却是几 MB base64,而且是在他看不见的情况下换了一条语义不同的路。这里与
|
||||||
* [ChatImagePreparer] 同一纪律:宁可回滚、把图和文本还给输入框让用户重试。
|
* [ChatImagePreparer] 同一纪律:宁可回滚、把图和文本还给输入框让用户重试。
|
||||||
*/
|
*/
|
||||||
private suspend fun deliverImage(localId: String, text: String, image: PreparedImage) {
|
private suspend fun deliverImage(
|
||||||
|
localId: String,
|
||||||
|
text: String,
|
||||||
|
image: PreparedImage,
|
||||||
|
documents: List<DocumentRefDto>,
|
||||||
|
) {
|
||||||
when (ChatImageDelivery.forPreparedBytes(image.bytes.size)) {
|
when (ChatImageDelivery.forPreparedBytes(image.bytes.size)) {
|
||||||
ImageDelivery.Inline -> sendViaRest(localId, text, image.dataUri)
|
ImageDelivery.Inline -> sendViaRest(localId, text, image.dataUri, documents)
|
||||||
|
|
||||||
ImageDelivery.Upload -> container.uploadRepository
|
ImageDelivery.Upload -> container.uploadRepository
|
||||||
.uploadImage(image.bytes, image.mime)
|
.uploadImage(image.bytes, image.mime)
|
||||||
.onSuccess { uploaded -> sendViaRest(localId, text, uploaded.url) }
|
.onSuccess { uploaded -> sendViaRest(localId, text, uploaded.url, documents) }
|
||||||
.onFailure { e -> rollback(localId, uploadFailureMessage(e)) }
|
.onFailure { e -> rollback(localId, uploadFailureMessage(e)) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun sendViaRest(localId: String, text: String, image: String?) {
|
/**
|
||||||
|
* 走 REST 发出这一轮(`stream:true`)。[documents] 是**已上传**的附件引用(空 = 没附件):
|
||||||
|
* 空列表在线上整键省略(`encodeDefaults = false`),所以不带附件的请求与加入该字段之前逐字节相同。
|
||||||
|
*/
|
||||||
|
private suspend fun sendViaRest(
|
||||||
|
localId: String,
|
||||||
|
text: String,
|
||||||
|
image: String?,
|
||||||
|
documents: List<DocumentRefDto> = emptyList(),
|
||||||
|
) {
|
||||||
val request = ChatRequest(
|
val request = ChatRequest(
|
||||||
sessionId = sessionId,
|
sessionId = sessionId,
|
||||||
text = text,
|
text = text,
|
||||||
@@ -425,6 +637,7 @@ class ChatViewModel(
|
|||||||
// 慢回合会在客户端先超时、服务端还在跑,重发就是第二次对话(契约明确禁用)。
|
// 慢回合会在客户端先超时、服务端还在跑,重发就是第二次对话(契约明确禁用)。
|
||||||
stream = true,
|
stream = true,
|
||||||
clientMsgId = localId,
|
clientMsgId = localId,
|
||||||
|
documents = documents,
|
||||||
)
|
)
|
||||||
container.chatRepository
|
container.chatRepository
|
||||||
.send(request)
|
.send(request)
|
||||||
@@ -507,8 +720,9 @@ class ChatViewModel(
|
|||||||
.fold(pending) { acc, message -> consumePendingFor(acc, message) }
|
.fold(pending) { acc, message -> consumePendingFor(acc, message) }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 回滚乐观气泡:把文本 / 图片还给输入框并给出原因(挂掉的气泡本身就是权威副本)。
|
* 回滚乐观气泡:把文本 / 图片 / 附件还给输入框并给出原因(挂掉的气泡本身就是权威副本)。
|
||||||
* 空文本(纯图片)不覆盖用户当前正在输入的内容;图片照原样还给待发位,不必让用户重挑。
|
* 空文本(纯图片或纯附件)不覆盖用户当前正在输入的内容;图片与附件照原样还给待发位,
|
||||||
|
* 不必让用户重挑(附件已经上传过的那几份在服务端是孤儿,但本地引用仍然有效、可直接重发)。
|
||||||
*/
|
*/
|
||||||
private fun rollback(localId: String, reason: String) {
|
private fun rollback(localId: String, reason: String) {
|
||||||
_state.update { state ->
|
_state.update { state ->
|
||||||
@@ -517,6 +731,7 @@ class ChatViewModel(
|
|||||||
pending = state.pending.filterNot { it.localId == localId },
|
pending = state.pending.filterNot { it.localId == localId },
|
||||||
draft = failed?.text?.ifBlank { state.draft } ?: state.draft,
|
draft = failed?.text?.ifBlank { state.draft } ?: state.draft,
|
||||||
draftImage = failed?.imageUri ?: state.draftImage,
|
draftImage = failed?.imageUri ?: state.draftImage,
|
||||||
|
draftDocuments = failed?.documents?.takeIf { it.isNotEmpty() } ?: state.draftDocuments,
|
||||||
error = reason,
|
error = reason,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -778,3 +993,35 @@ private const val THINKING_LINGER_MILLIS = 10 * 60 * 1000L
|
|||||||
|
|
||||||
/** 分享提示在界面上停留的时长(它描述的是一次刚发生的动作,不是持续状态)。 */
|
/** 分享提示在界面上停留的时长(它描述的是一次刚发生的动作,不是持续状态)。 */
|
||||||
private const val SHARE_NOTICE_MILLIS = 8 * 1000L
|
private const val SHARE_NOTICE_MILLIS = 8 * 1000L
|
||||||
|
|
||||||
|
/** 附件提示在界面上停留的时长(同上:描述动作,不描述持续状态)。 */
|
||||||
|
private const val ATTACHMENT_NOTICE_MILLIS = 8 * 1000L
|
||||||
|
|
||||||
|
/** 附件的可读名字:显示名拿不到时用 URI,再拿不到就只说「附件」(不编造一个名字)。 */
|
||||||
|
private fun documentLabel(document: DraftDocument): String =
|
||||||
|
document.displayName.ifBlank { document.uri }.ifBlank { "附件" }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 附件上传失败的文案(纯函数,可单测)。
|
||||||
|
*
|
||||||
|
* 四个服务端机器可读拒绝码各有**不同的正确动作**,所以那句话由服务端说(含真实上限与原因),
|
||||||
|
* 客户端原样展示、**绝不自己编一个数字**:`415 unsupported_media_type` / `413 document_too_large`
|
||||||
|
* 是「换文件」,`400 too_many_documents` 是「少选几个」,`404 document_not_found` 是「重传」
|
||||||
|
* (见 [DocumentFailure])。401/403 也原样透出——那种情况下正确的动作是重新登录,
|
||||||
|
* 而不是「换一份文件」。
|
||||||
|
*
|
||||||
|
* 其余(网络 / 超时 / 未知码 / 5xx)用既有错误口径,并**点明是上传这一步**失败:否则用户看到
|
||||||
|
* 「无法连接到服务器」会以为整条消息没发出去,而实际上文字与附件是跟着一起回滚的。
|
||||||
|
*/
|
||||||
|
fun documentUploadFailureMessage(e: Throwable): String {
|
||||||
|
val error = (e as? CyreneException)?.error
|
||||||
|
return when (val failure = e.toDocumentFailure()) {
|
||||||
|
is DocumentFailure.UnsupportedType -> failure.message
|
||||||
|
is DocumentFailure.TooLarge -> failure.message
|
||||||
|
is DocumentFailure.TooMany -> failure.message
|
||||||
|
is DocumentFailure.NotFound -> failure.message
|
||||||
|
is DocumentFailure.Unreadable -> failure.message
|
||||||
|
is DocumentFailure.Forbidden -> failure.message
|
||||||
|
is DocumentFailure.Other -> "附件上传失败," + (error?.userMessage ?: "请重试")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,13 +1,16 @@
|
|||||||
package top.yeij.cyrene.ui.chat
|
package top.yeij.cyrene.ui.chat
|
||||||
|
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.horizontalScroll
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.ColumnScope
|
import androidx.compose.foundation.layout.ColumnScope
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.widthIn
|
import androidx.compose.foundation.layout.widthIn
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.foundation.text.selection.SelectionContainer
|
import androidx.compose.foundation.text.selection.SelectionContainer
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
@@ -16,26 +19,38 @@ import androidx.compose.ui.Alignment
|
|||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.text.font.FontStyle
|
import androidx.compose.ui.text.font.FontStyle
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import coil3.compose.AsyncImage
|
import coil3.compose.AsyncImage
|
||||||
import top.yeij.cyrene.core.prefs.LocalAppPrefs
|
import top.yeij.cyrene.core.prefs.LocalAppPrefs
|
||||||
|
import top.yeij.cyrene.data.api.dto.DocumentRefDto
|
||||||
import top.yeij.cyrene.data.api.dto.HistoryMessageDto
|
import top.yeij.cyrene.data.api.dto.HistoryMessageDto
|
||||||
|
import top.yeij.cyrene.domain.chat.DocumentChipModel
|
||||||
|
import top.yeij.cyrene.domain.chat.DocumentChips
|
||||||
import top.yeij.cyrene.domain.enums.LocalEnumCatalog
|
import top.yeij.cyrene.domain.enums.LocalEnumCatalog
|
||||||
import top.yeij.cyrene.domain.parse.ContentPart
|
import top.yeij.cyrene.domain.parse.ContentPart
|
||||||
import top.yeij.cyrene.domain.parse.ParsedBubble
|
import top.yeij.cyrene.domain.parse.ParsedBubble
|
||||||
import top.yeij.cyrene.domain.parse.parse
|
import top.yeij.cyrene.domain.parse.parse
|
||||||
|
import top.yukonga.miuix.kmp.basic.Icon
|
||||||
|
import top.yukonga.miuix.kmp.basic.IconButton
|
||||||
import top.yukonga.miuix.kmp.basic.Text
|
import top.yukonga.miuix.kmp.basic.Text
|
||||||
|
import top.yukonga.miuix.kmp.icon.MiuixIcons
|
||||||
|
import top.yukonga.miuix.kmp.icon.basic.Close
|
||||||
import top.yukonga.miuix.kmp.theme.MiuixTheme
|
import top.yukonga.miuix.kmp.theme.MiuixTheme
|
||||||
|
|
||||||
/** 流式进行中的光标。 */
|
/** 流式进行中的光标。 */
|
||||||
private const val CURSOR = "▍"
|
private const val CURSOR = "▍"
|
||||||
|
|
||||||
|
/** 附件芯片排的最大宽度:与气泡同级,避免 5 枚芯片把整行铺满屏幕。 */
|
||||||
|
private val CHIP_ROW_MAX_WIDTH = 320.dp
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 一条落库消息渲染成一个或多个气泡:
|
* 一条落库消息渲染成一个或多个气泡:
|
||||||
* - `mode` 为 `chat`(或缺失——后端当前不落库 mode,一律回落 chat)时按 `<p>` 切分,
|
* - `mode` 为 `chat`(或缺失——后端当前不落库 mode,一律回落 chat)时按 `<p>` 切分,
|
||||||
* `<action>` / `<narration>` 解析成灰斜体小字,依附到所属气泡的上方/下方;
|
* `<action>` / `<narration>` 解析成灰斜体小字,依附到所属气泡的上方/下方;
|
||||||
* - 其它 mode 原样整条渲染,不解析标签;
|
* - 其它 mode 原样整条渲染,不解析标签;
|
||||||
* - `thinking` 非空时(来自 REST `/messages`,owner 可见)在气泡上方显示思维链卡片;
|
* - `thinking` 非空时(来自 REST `/messages`,owner 可见)在气泡上方显示思维链卡片;
|
||||||
|
* - `documents` 非空时在气泡上方显示附件芯片(绝大多数消息是空列表,此时一个像素都不画);
|
||||||
* - `image` 非空时在气泡上方显示图片。
|
* - `image` 非空时在气泡上方显示图片。
|
||||||
*
|
*
|
||||||
* 文本可自由选择:每个气泡/旁白各自包一层 [SelectionContainer],
|
* 文本可自由选择:每个气泡/旁白各自包一层 [SelectionContainer],
|
||||||
@@ -55,6 +70,7 @@ fun MessageBubble(message: HistoryMessageDto) {
|
|||||||
|
|
||||||
MessageGroup(isUser = isUser, footer = messageSourceLabel(message, LocalEnumCatalog.current)) {
|
MessageGroup(isUser = isUser, footer = messageSourceLabel(message, LocalEnumCatalog.current)) {
|
||||||
message.thinking?.takeIf { it.isNotBlank() }?.let { ThinkingCard(it) }
|
message.thinking?.takeIf { it.isNotBlank() }?.let { ThinkingCard(it) }
|
||||||
|
DocumentChipRow(chips = message.documents.map(DocumentChips::of))
|
||||||
message.image?.takeIf { it.isNotBlank() }?.let { ChatImage(it) }
|
message.image?.takeIf { it.isNotBlank() }?.let { ChatImage(it) }
|
||||||
bubbles.forEachIndexed { index, bubble ->
|
bubbles.forEachIndexed { index, bubble ->
|
||||||
Bubble(bubble, isUser, isLast = index == bubbles.lastIndex)
|
Bubble(bubble, isUser, isLast = index == bubbles.lastIndex)
|
||||||
@@ -67,15 +83,102 @@ fun MessageBubble(message: HistoryMessageDto) {
|
|||||||
*
|
*
|
||||||
* 图片显示的是**本地** `content://` URI(不是上行的那段 base64):预览不需要联网、
|
* 图片显示的是**本地** `content://` URI(不是上行的那段 base64):预览不需要联网、
|
||||||
* 也不必把几 MB 的字符串塞进 UI 状态。落库后由 `message.image` 接管。
|
* 也不必把几 MB 的字符串塞进 UI 状态。落库后由 `message.image` 接管。
|
||||||
|
* 附件同理显示**本地显示名**(那时还没有服务端存储名,也还没有任何服务端结论)。
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun PendingBubble(item: PendingMessage) {
|
fun PendingBubble(item: PendingMessage) {
|
||||||
MessageGroup(isUser = true, footer = "发送中…") {
|
MessageGroup(isUser = true, footer = "发送中…") {
|
||||||
|
DocumentChipRow(chips = item.documents.map(DocumentChips::of))
|
||||||
item.imageUri?.takeIf { it.isNotBlank() }?.let { ChatImage(it) }
|
item.imageUri?.takeIf { it.isNotBlank() }?.let { ChatImage(it) }
|
||||||
if (item.text.isNotEmpty()) Bubble(plainBubble(item.text), isUser = true, isLast = true)
|
if (item.text.isNotEmpty()) Bubble(plainBubble(item.text), isUser = true, isLast = true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 附件芯片排:横向可滚(最多 5 枚也不挤成两行),一枚一枚展示
|
||||||
|
* 「名字(中略,保住扩展名)/ 类型 · 体积 · 字数 · 状态」。
|
||||||
|
*
|
||||||
|
* [onRemove] 非空即带移除按钮——待发位(输入栏上方)要能反悔,历史气泡里则不给。
|
||||||
|
*
|
||||||
|
* 附件可能是空列表(绝大多数消息都是),此时**不占位、不留空白**。
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun DocumentChipRow(
|
||||||
|
chips: List<DocumentChipModel>,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
onRemove: ((Int) -> Unit)? = null,
|
||||||
|
) {
|
||||||
|
if (chips.isEmpty()) return
|
||||||
|
Row(
|
||||||
|
modifier = modifier
|
||||||
|
.widthIn(max = CHIP_ROW_MAX_WIDTH)
|
||||||
|
.horizontalScroll(rememberScrollState()),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||||
|
) {
|
||||||
|
chips.forEachIndexed { index, chip ->
|
||||||
|
DocumentChip(model = chip, onRemove = onRemove?.let { remove -> { remove(index) } })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 一枚附件芯片。
|
||||||
|
*
|
||||||
|
* 状态分两处说,**不许互相顶替**(契约 v0.59 §1.2):
|
||||||
|
* - `status:"unreadable"` = **昔涟读不到这个文件** → 整枚转灰 + 明写「读不到」;
|
||||||
|
* - `truncated:true` = **注入被预算截断**(正文照样进了上下文)→ 只加一句「注入已截断」。
|
||||||
|
*
|
||||||
|
* 未知 `status` 原样显示(开放集合,见 [DocumentChips.statusLabel])。
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun DocumentChip(model: DocumentChipModel, onRemove: (() -> Unit)?) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.clip(RoundedCornerShape(12.dp))
|
||||||
|
.background(
|
||||||
|
if (model.unreadable) {
|
||||||
|
MiuixTheme.colorScheme.surfaceContainer
|
||||||
|
} else {
|
||||||
|
MiuixTheme.colorScheme.surfaceContainerHigh
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.padding(start = 10.dp, end = 4.dp, top = 6.dp, bottom = 6.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Column {
|
||||||
|
Text(
|
||||||
|
text = model.name,
|
||||||
|
style = MiuixTheme.textStyles.footnote1,
|
||||||
|
color = if (model.unreadable) {
|
||||||
|
MiuixTheme.colorScheme.onSurfaceVariantSummary
|
||||||
|
} else {
|
||||||
|
MiuixTheme.colorScheme.onSurfaceContainerHigh
|
||||||
|
},
|
||||||
|
maxLines = 1,
|
||||||
|
// 存储名是 32 位十六进制 + 扩展名,长名字必须中略——从尾部截会把扩展名切掉,
|
||||||
|
// 而扩展名正是这里唯一有语义的部分。
|
||||||
|
overflow = TextOverflow.MiddleEllipsis,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = model.detail,
|
||||||
|
style = MiuixTheme.textStyles.footnote2,
|
||||||
|
color = MiuixTheme.colorScheme.onSurfaceVariantSummary,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
onRemove?.let { remove ->
|
||||||
|
IconButton(onClick = remove) {
|
||||||
|
Icon(
|
||||||
|
imageVector = MiuixIcons.Basic.Close,
|
||||||
|
contentDescription = "移除附件 ${model.name}",
|
||||||
|
tint = MiuixTheme.colorScheme.onSurfaceVariantSummary,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** 正在流式的助手回复:思维链 → 工具卡 → 气泡(末尾带光标)。 */
|
/** 正在流式的助手回复:思维链 → 工具卡 → 气泡(末尾带光标)。 */
|
||||||
@Composable
|
@Composable
|
||||||
fun StreamingBubble(streaming: StreamingState) {
|
fun StreamingBubble(streaming: StreamingState) {
|
||||||
|
|||||||
@@ -0,0 +1,206 @@
|
|||||||
|
package top.yeij.cyrene.domain.chat
|
||||||
|
|
||||||
|
import org.junit.Assert.assertArrayEquals
|
||||||
|
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 java.io.ByteArrayInputStream
|
||||||
|
import java.io.IOException
|
||||||
|
import java.io.InputStream
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 待发附件的准入规则与读取边界(纯函数)。
|
||||||
|
*
|
||||||
|
* 这里锚的是**「不许静默」**这条行为承诺:被挡下的东西必须有计数、有名字、有一句能被界面讲出
|
||||||
|
* 来的话;**绝不**「只发前 5 个」或者悄悄少挂一份——服务端对 >5 回 `400 too_many_documents`、
|
||||||
|
* 对超限回 `413 document_too_large`,两边都不做静默裁剪,客户端也就不该在本地悄悄裁。
|
||||||
|
*/
|
||||||
|
class DocumentAttachmentsTest {
|
||||||
|
|
||||||
|
private fun draft(uri: String, name: String = "note.txt", size: Long = 16L) =
|
||||||
|
DraftDocument(uri = uri, displayName = name, sizeBytes = size)
|
||||||
|
|
||||||
|
private fun drafts(count: Int, prefix: String = "content://doc/") =
|
||||||
|
List(count) { draft(uri = "$prefix$it", name = "note-$it.txt") }
|
||||||
|
|
||||||
|
// —— 份数上限 ——
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `exactly the maximum number of attachments is accepted`() {
|
||||||
|
val result = DocumentAttachments.admit(emptyList(), drafts(DocumentAttachments.MAX_PER_TURN))
|
||||||
|
|
||||||
|
assertEquals(DocumentAttachments.MAX_PER_TURN, result.accepted.size)
|
||||||
|
assertEquals(DocumentAttachments.MAX_PER_TURN, DocumentDelivery.MAX_ATTACHMENTS_PER_TURN)
|
||||||
|
assertEquals(0, result.overLimit)
|
||||||
|
assertNull(result.notice)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `one attachment over the maximum is reported, never trimmed silently`() {
|
||||||
|
val result = DocumentAttachments.admit(emptyList(), drafts(6))
|
||||||
|
|
||||||
|
assertEquals(5, result.accepted.size)
|
||||||
|
assertEquals(1, result.overLimit)
|
||||||
|
assertTrue(result.notice!!, result.notice!!.contains("最多 5 个"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `the room left is computed against what is already attached`() {
|
||||||
|
val existing = drafts(3, prefix = "content://old/")
|
||||||
|
|
||||||
|
val result = DocumentAttachments.admit(existing, drafts(4, prefix = "content://new/"))
|
||||||
|
|
||||||
|
assertEquals(2, result.accepted.size)
|
||||||
|
assertEquals(2, result.overLimit)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a full attachment list accepts nothing and says so`() {
|
||||||
|
val existing = drafts(DocumentAttachments.MAX_PER_TURN, prefix = "content://old/")
|
||||||
|
|
||||||
|
val result = DocumentAttachments.admit(existing, drafts(1, prefix = "content://new/"))
|
||||||
|
|
||||||
|
assertTrue(result.accepted.isEmpty())
|
||||||
|
assertEquals(1, result.overLimit)
|
||||||
|
}
|
||||||
|
|
||||||
|
// —— 单份体积 ——
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a file exactly at the size limit is accepted`() {
|
||||||
|
val atLimit = draft("content://doc/big", name = "big.txt", size = DocumentAttachments.MAX_FILE_BYTES)
|
||||||
|
|
||||||
|
val result = DocumentAttachments.admit(emptyList(), listOf(atLimit))
|
||||||
|
|
||||||
|
assertEquals(1, result.accepted.size)
|
||||||
|
assertNull(result.notice)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `one byte over the size limit is refused before anything is uploaded`() {
|
||||||
|
// 本地预检的意义就在这儿:30 MiB 的文件照传一遍再吃 413,用户白等的是上传那几分钟。
|
||||||
|
val overLimit = draft("content://doc/big", name = "big.txt", size = DocumentAttachments.MAX_FILE_BYTES + 1)
|
||||||
|
|
||||||
|
val result = DocumentAttachments.admit(emptyList(), listOf(overLimit))
|
||||||
|
|
||||||
|
assertTrue(result.accepted.isEmpty())
|
||||||
|
assertEquals(listOf("big.txt"), result.tooLarge)
|
||||||
|
assertTrue(result.notice!!, result.notice!!.contains("big.txt"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `an unknown size is not judged locally`() {
|
||||||
|
// 拿不到 SIZE 时本端不猜:宁可让服务端用它自己的上限回 413(带上真实数字),
|
||||||
|
// 也不要在这边凭空拒掉一个可能合规的文件。
|
||||||
|
val unknown = draft("content://doc/x", name = "x.txt", size = DraftDocument.UNKNOWN_SIZE)
|
||||||
|
|
||||||
|
val result = DocumentAttachments.admit(emptyList(), listOf(unknown))
|
||||||
|
|
||||||
|
assertEquals(1, result.accepted.size)
|
||||||
|
assertNull(result.notice)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `an oversized file does not eat the room of the ones after it`() {
|
||||||
|
val files = listOf(
|
||||||
|
draft("content://doc/huge", name = "huge.txt", size = DocumentAttachments.MAX_FILE_BYTES + 1),
|
||||||
|
draft("content://doc/ok", name = "ok.txt"),
|
||||||
|
)
|
||||||
|
|
||||||
|
val result = DocumentAttachments.admit(emptyList(), files)
|
||||||
|
|
||||||
|
assertEquals(listOf("ok.txt"), result.accepted.map { it.displayName })
|
||||||
|
assertEquals(1, result.tooLarge.size)
|
||||||
|
}
|
||||||
|
|
||||||
|
// —— 去重 ——
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `picking the same file twice does not attach it twice`() {
|
||||||
|
val once = draft("content://doc/a")
|
||||||
|
|
||||||
|
val result = DocumentAttachments.admit(listOf(once), listOf(once))
|
||||||
|
|
||||||
|
assertTrue(result.accepted.isEmpty())
|
||||||
|
assertEquals(1, result.duplicate)
|
||||||
|
// 重复既不算「超限」也不算「超体积」——它压根不是一个新附件。
|
||||||
|
assertEquals(0, result.overLimit)
|
||||||
|
assertTrue(result.tooLarge.isEmpty())
|
||||||
|
assertTrue(result.notice!!, result.notice!!.contains("重复"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `duplicates inside one pick are collapsed too`() {
|
||||||
|
val result = DocumentAttachments.admit(emptyList(), listOf(draft("content://doc/a"), draft("content://doc/a")))
|
||||||
|
|
||||||
|
assertEquals(1, result.accepted.size)
|
||||||
|
assertEquals(1, result.duplicate)
|
||||||
|
}
|
||||||
|
|
||||||
|
// —— 移除 ——
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `removeAt drops exactly one chip and survives an out of range index`() {
|
||||||
|
val files = drafts(3)
|
||||||
|
|
||||||
|
assertEquals(listOf("note-0.txt", "note-2.txt"), DocumentAttachments.removeAt(files, 1).map { it.displayName })
|
||||||
|
assertEquals(files, DocumentAttachments.removeAt(files, -1))
|
||||||
|
assertEquals(files, DocumentAttachments.removeAt(files, 3))
|
||||||
|
}
|
||||||
|
|
||||||
|
// —— URI 兜底名 ——
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `the fallback name is the last path segment and never guesses an extension`() {
|
||||||
|
assertEquals("report.md", DocumentAttachments.fallbackDocumentName("content://doc/a/b/report.md"))
|
||||||
|
assertEquals("report.md", DocumentAttachments.fallbackDocumentName("file:///cache/x/report.md?token=1"))
|
||||||
|
// 拿不到名字就留空串:让仓库层去兜 document.txt,比本端编一个扩展名安全。
|
||||||
|
assertEquals("", DocumentAttachments.fallbackDocumentName("content://doc/"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// —— 有界读取 ——
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `readBounded returns the whole content when it fits`() {
|
||||||
|
val bytes = ByteArray(8) { it.toByte() }
|
||||||
|
|
||||||
|
assertArrayEquals(bytes, readBounded(ByteArrayInputStream(bytes), limit = 8))
|
||||||
|
assertArrayEquals(bytes, readBounded(ByteArrayInputStream(bytes), limit = 1024))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `readBounded returns null as soon as the content is over the limit`() {
|
||||||
|
// 选完之后、发送之前文件被换成一个超大文件:绝不能先在内存里把它整个读进来再报错。
|
||||||
|
val bytes = ByteArray(9)
|
||||||
|
|
||||||
|
assertNull(readBounded(ByteArrayInputStream(bytes), limit = 8))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `readBounded lets IO failures through so they can be told apart from too large`() {
|
||||||
|
// 「太大」与「读不到」要用户做的事不一样(换文件 / 重新选),所以这里**不吞**异常:
|
||||||
|
// 调用方靠「null = 超限、抛出 = 读不到」把两者分开。
|
||||||
|
val failing = object : InputStream() {
|
||||||
|
override fun read(): Int = throw IOException("boom")
|
||||||
|
}
|
||||||
|
|
||||||
|
var thrown: IOException? = null
|
||||||
|
try {
|
||||||
|
readBounded(failing, limit = 8)
|
||||||
|
} catch (e: IOException) {
|
||||||
|
thrown = e
|
||||||
|
}
|
||||||
|
|
||||||
|
assertEquals("boom", thrown?.message)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a zero byte stream reads as empty rather than failing`() {
|
||||||
|
val empty = readBounded(ByteArrayInputStream(ByteArray(0)), limit = 8)
|
||||||
|
|
||||||
|
assertFalse(empty == null)
|
||||||
|
assertEquals(0, empty!!.size)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
package top.yeij.cyrene.domain.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.data.api.dto.DocumentRefDto
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 附件芯片的展示换算(纯函数)。
|
||||||
|
*
|
||||||
|
* 这里锚三件事:
|
||||||
|
* 1. **状态与截断不许互相顶替**——`unreadable` 才是「读不到」,`truncated` 只说明注入被预算
|
||||||
|
* 截断(正文照样进了上下文),两句文案必须分开;
|
||||||
|
* 2. **未知 `status` 原样显示**(开放集合:不丢弃、不报错、不占位符);
|
||||||
|
* 3. 服务端**没报**的字段(`chars` 缺席、体积为 0/未知)不要编出一个数字来。
|
||||||
|
*/
|
||||||
|
class DocumentChipTest {
|
||||||
|
|
||||||
|
private fun ref(
|
||||||
|
name: String = "0123456789abcdef0123456789abcdef.md",
|
||||||
|
mime: String = "text/plain",
|
||||||
|
size: Long = 12_345,
|
||||||
|
chars: Int? = 8_000,
|
||||||
|
truncated: Boolean = false,
|
||||||
|
status: String = DocumentStatus.EXTRACTED,
|
||||||
|
) = DocumentRefDto(
|
||||||
|
name = name,
|
||||||
|
mime = mime,
|
||||||
|
size = size,
|
||||||
|
chars = chars,
|
||||||
|
truncated = truncated,
|
||||||
|
status = status,
|
||||||
|
)
|
||||||
|
|
||||||
|
// —— 类型 ——
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `the type label comes from the extension, not from the sniffed mime`() {
|
||||||
|
// 服务端的 mime 是**嗅探基类型**(`data.json` 变 `text/plain`),按它显示类型会全变成
|
||||||
|
// 「TEXT/PLAIN」;有语义的只有存储名里的扩展名。
|
||||||
|
val chip = DocumentChips.of(ref(name = "0123456789abcdef0123456789abcdef.json"))
|
||||||
|
|
||||||
|
assertEquals("JSON", chip.typeLabel)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a name without a usable extension is shown as a plain file`() {
|
||||||
|
assertEquals("文件", DocumentChips.typeLabel("0123456789abcdef0123456789abcdef"))
|
||||||
|
assertEquals("文件", DocumentChips.typeLabel(""))
|
||||||
|
assertEquals("文件", DocumentChips.typeLabel("a.this-is-not-an-extension"))
|
||||||
|
assertEquals("MD", DocumentChips.typeLabel("a.md"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// —— 体积 / 字数 ——
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `sizes are rendered in the unit a human reads`() {
|
||||||
|
assertEquals("0 B", DocumentChips.sizeLabel(0))
|
||||||
|
assertEquals("512 B", DocumentChips.sizeLabel(512))
|
||||||
|
assertEquals("1.0 KB", DocumentChips.sizeLabel(1024))
|
||||||
|
assertEquals("12.1 KB", DocumentChips.sizeLabel(12_345))
|
||||||
|
assertEquals("20.0 MB", DocumentChips.sizeLabel(DocumentAttachments.MAX_FILE_BYTES))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `an unknown size renders as nothing rather than a fake zero`() {
|
||||||
|
assertEquals("", DocumentChips.sizeLabel(DraftDocument.UNKNOWN_SIZE))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `the char count is grouped and a missing one stays missing`() {
|
||||||
|
assertEquals("8,000 字", DocumentChips.charsLabel(8_000))
|
||||||
|
assertEquals("0 字", DocumentChips.charsLabel(0))
|
||||||
|
assertNull(DocumentChips.charsLabel(null))
|
||||||
|
}
|
||||||
|
|
||||||
|
// —— 状态 ——
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `the three known statuses each get their own sentence`() {
|
||||||
|
assertEquals("已读入", DocumentChips.statusLabel(DocumentStatus.EXTRACTED))
|
||||||
|
assertEquals("只看得到清单", DocumentChips.statusLabel(DocumentStatus.LISTED))
|
||||||
|
assertEquals("读不到", DocumentChips.statusLabel(DocumentStatus.UNREADABLE))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `an unknown status is shown verbatim`() {
|
||||||
|
assertEquals("extracting", DocumentChips.statusLabel("extracting"))
|
||||||
|
assertNull(DocumentChips.statusLabel(""))
|
||||||
|
}
|
||||||
|
|
||||||
|
// —— 不可读与截断是两件事 ——
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `unreadable marks the chip grey and says so`() {
|
||||||
|
val chip = DocumentChips.of(ref(status = DocumentStatus.UNREADABLE, chars = 0))
|
||||||
|
|
||||||
|
assertTrue(chip.unreadable)
|
||||||
|
assertEquals("读不到", chip.statusLabel)
|
||||||
|
assertNull("不可读不该被说成截断", chip.truncatedLabel)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `truncated only means the injection was cut and never means unreadable`() {
|
||||||
|
val chip = DocumentChips.of(ref(truncated = true, status = DocumentStatus.EXTRACTED))
|
||||||
|
|
||||||
|
assertFalse(chip.unreadable)
|
||||||
|
assertEquals("已读入", chip.statusLabel)
|
||||||
|
assertEquals("注入已截断", chip.truncatedLabel)
|
||||||
|
assertTrue(chip.detail, chip.detail.contains("已读入"))
|
||||||
|
assertTrue(chip.detail, chip.detail.contains("注入已截断"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a plain reference has no status sentence at all`() {
|
||||||
|
val chip = DocumentChips.of(ref(chars = null, status = ""))
|
||||||
|
|
||||||
|
assertNull(chip.statusLabel)
|
||||||
|
assertNull(chip.truncatedLabel)
|
||||||
|
assertEquals("MD · 12.1 KB", chip.detail)
|
||||||
|
}
|
||||||
|
|
||||||
|
// —— 副行拼接 ——
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `the detail line joins what is actually known`() {
|
||||||
|
val chip = DocumentChips.of(ref())
|
||||||
|
|
||||||
|
assertEquals("MD · 12.1 KB · 8,000 字 · 已读入", chip.detail)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `the detail line skips the parts the server did not report`() {
|
||||||
|
val chip = DocumentChips.of(ref(size = 0, chars = null, status = DocumentStatus.LISTED))
|
||||||
|
|
||||||
|
assertEquals("MD · 0 B · 只看得到清单", chip.detail)
|
||||||
|
}
|
||||||
|
|
||||||
|
// —— 待发附件(还没有任何服务端结论)——
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a draft attachment shows its local name and size with no status`() {
|
||||||
|
val chip = DocumentChips.of(
|
||||||
|
DraftDocument(uri = "content://doc/1", displayName = "报告.md", sizeBytes = 2048),
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals("报告.md", chip.name)
|
||||||
|
assertEquals("MD", chip.typeLabel)
|
||||||
|
assertEquals("2.0 KB", chip.sizeLabel)
|
||||||
|
assertNull(chip.statusLabel)
|
||||||
|
assertFalse(chip.unreadable)
|
||||||
|
assertEquals("MD · 2.0 KB", chip.detail)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a draft with no metadata falls back to the uri and says nothing about size`() {
|
||||||
|
val chip = DocumentChips.of(
|
||||||
|
DraftDocument(
|
||||||
|
uri = "content://doc/1",
|
||||||
|
displayName = "",
|
||||||
|
sizeBytes = DraftDocument.UNKNOWN_SIZE,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals("content://doc/1", chip.name)
|
||||||
|
assertEquals("", chip.sizeLabel)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -236,4 +236,41 @@ class DocumentDeliveryTest {
|
|||||||
fun `the txt file name can be overridden for the share intake`() {
|
fun `the txt file name can be overridden for the share intake`() {
|
||||||
assertEquals("报告.md", DocumentDelivery.textAsFile("正文", fileName = "报告.md").fileName)
|
assertEquals("报告.md", DocumentDelivery.textAsFile("正文", fileName = "报告.md").fileName)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// —— 只看份数的那条入口(调用方还没读字节时用)——
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `planForCount answers the same shape as plan for every branch`() {
|
||||||
|
// 只比「形状」(拒绝 / 该不该走上传 / 正文要不要转文件):`plan` 还会把字节带上,
|
||||||
|
// `planForCount` 带不了——那是它存在的理由(见下一个用例)。
|
||||||
|
assertEquals(DocumentPlan.Inline, DocumentDelivery.planForCount("嗨", 0))
|
||||||
|
assertEquals(
|
||||||
|
DocumentPlan.AsDocuments(files = emptyList(), textAsDocument = false),
|
||||||
|
DocumentDelivery.planForCount("嗨", 2),
|
||||||
|
)
|
||||||
|
assertEquals(
|
||||||
|
DocumentPlan.Rejected(DocumentRejection.TooManyAttachments),
|
||||||
|
DocumentDelivery.planForCount("", 6),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `planForCount only differs from plan by not carrying the files`() {
|
||||||
|
// 调用方(ChatViewModel)用它**在读字节之前**决定「要不要拒绝 / 要不要走上传这条链」——
|
||||||
|
// 字节是发送时才现读的,最多 5×20 MiB 一次性读进内存不可接受。
|
||||||
|
val two = files(2)
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
DocumentPlan.AsDocuments(files = emptyList(), textAsDocument = false),
|
||||||
|
DocumentDelivery.planForCount("看看这个", two.size),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `planForCount stops at the same attachment limit`() {
|
||||||
|
assertEquals(
|
||||||
|
DocumentPlan.Rejected(DocumentRejection.TooManyAttachments),
|
||||||
|
DocumentDelivery.planForCount("", DocumentDelivery.MAX_ATTACHMENTS_PER_TURN + 1),
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -15,6 +15,15 @@ import top.yeij.cyrene.domain.chat.SendChannel
|
|||||||
*/
|
*/
|
||||||
class ShareExtractionTest {
|
class ShareExtractionTest {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 分享进来的一张图:`ContentResolver.getType` 照例回图片那一类。分流就发生在提取这一步,
|
||||||
|
* 所以每个与图片有关的用例都必须把它交进来——**默认值是「拿不到类型」**,那按文档路走。
|
||||||
|
*/
|
||||||
|
private val imageMime: (String) -> String? = { "image/png" }
|
||||||
|
|
||||||
|
/** 分享进来的一个文件:文件管理器对 `.txt` / `.md` / `.log` 的常见报法。 */
|
||||||
|
private val textMime: (String) -> String? = { "text/plain" }
|
||||||
|
|
||||||
// —— 文本 ——
|
// —— 文本 ——
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -26,6 +35,7 @@ class ShareExtractionTest {
|
|||||||
|
|
||||||
assertEquals("帮我看一下这段话", share.text)
|
assertEquals("帮我看一下这段话", share.text)
|
||||||
assertTrue(share.images.isEmpty())
|
assertTrue(share.images.isEmpty())
|
||||||
|
assertTrue(share.documents.isEmpty())
|
||||||
assertEquals(0, share.droppedImages)
|
assertEquals(0, share.droppedImages)
|
||||||
assertFalse(share.isEmpty)
|
assertFalse(share.isEmpty)
|
||||||
}
|
}
|
||||||
@@ -77,9 +87,11 @@ class ShareExtractionTest {
|
|||||||
val share = ShareRules.extract(
|
val share = ShareRules.extract(
|
||||||
action = ShareRules.ACTION_SEND,
|
action = ShareRules.ACTION_SEND,
|
||||||
stream = "content://media/external/images/media/7",
|
stream = "content://media/external/images/media/7",
|
||||||
|
mimeOf = imageMime,
|
||||||
)
|
)
|
||||||
|
|
||||||
assertEquals(listOf("content://media/external/images/media/7"), share.images)
|
assertEquals(listOf("content://media/external/images/media/7"), share.images)
|
||||||
|
assertTrue(share.documents.isEmpty())
|
||||||
assertFalse(share.isEmpty)
|
assertFalse(share.isEmpty)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -89,6 +101,7 @@ class ShareExtractionTest {
|
|||||||
action = ShareRules.ACTION_SEND,
|
action = ShareRules.ACTION_SEND,
|
||||||
text = "看这个",
|
text = "看这个",
|
||||||
stream = "content://media/external/images/media/7",
|
stream = "content://media/external/images/media/7",
|
||||||
|
mimeOf = imageMime,
|
||||||
)
|
)
|
||||||
|
|
||||||
assertEquals("看这个", share.text)
|
assertEquals("看这个", share.text)
|
||||||
@@ -99,7 +112,11 @@ class ShareExtractionTest {
|
|||||||
fun `a multiple image share keeps them in order`() {
|
fun `a multiple image share keeps them in order`() {
|
||||||
val uris = (1..3).map { "content://media/external/images/media/$it" }
|
val uris = (1..3).map { "content://media/external/images/media/$it" }
|
||||||
|
|
||||||
val share = ShareRules.extract(action = ShareRules.ACTION_SEND_MULTIPLE, streams = uris)
|
val share = ShareRules.extract(
|
||||||
|
action = ShareRules.ACTION_SEND_MULTIPLE,
|
||||||
|
streams = uris,
|
||||||
|
mimeOf = imageMime,
|
||||||
|
)
|
||||||
|
|
||||||
assertEquals(uris, share.images)
|
assertEquals(uris, share.images)
|
||||||
assertEquals(0, share.truncatedImages)
|
assertEquals(0, share.truncatedImages)
|
||||||
@@ -109,7 +126,11 @@ class ShareExtractionTest {
|
|||||||
fun `images beyond the cap are reported instead of silently dropped`() {
|
fun `images beyond the cap are reported instead of silently dropped`() {
|
||||||
val uris = (1..7).map { "content://media/external/images/media/$it" }
|
val uris = (1..7).map { "content://media/external/images/media/$it" }
|
||||||
|
|
||||||
val share = ShareRules.extract(action = ShareRules.ACTION_SEND_MULTIPLE, streams = uris)
|
val share = ShareRules.extract(
|
||||||
|
action = ShareRules.ACTION_SEND_MULTIPLE,
|
||||||
|
streams = uris,
|
||||||
|
mimeOf = imageMime,
|
||||||
|
)
|
||||||
|
|
||||||
assertEquals(ShareRules.MAX_IMAGES, share.images.size)
|
assertEquals(ShareRules.MAX_IMAGES, share.images.size)
|
||||||
assertEquals(uris.take(ShareRules.MAX_IMAGES), share.images)
|
assertEquals(uris.take(ShareRules.MAX_IMAGES), share.images)
|
||||||
@@ -128,6 +149,7 @@ class ShareExtractionTest {
|
|||||||
"content://media/external/images/media/1",
|
"content://media/external/images/media/1",
|
||||||
"content://media/external/images/media/2",
|
"content://media/external/images/media/2",
|
||||||
),
|
),
|
||||||
|
mimeOf = imageMime,
|
||||||
)
|
)
|
||||||
|
|
||||||
assertEquals(
|
assertEquals(
|
||||||
@@ -143,7 +165,11 @@ class ShareExtractionTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `a single image share ignores a stream that only holds blank space`() {
|
fun `a single image share ignores a stream that only holds blank space`() {
|
||||||
val share = ShareRules.extract(action = ShareRules.ACTION_SEND, stream = " ")
|
val share = ShareRules.extract(
|
||||||
|
action = ShareRules.ACTION_SEND,
|
||||||
|
stream = " ",
|
||||||
|
mimeOf = imageMime,
|
||||||
|
)
|
||||||
|
|
||||||
assertTrue(share.images.isEmpty())
|
assertTrue(share.images.isEmpty())
|
||||||
assertTrue(share.isEmpty)
|
assertTrue(share.isEmpty)
|
||||||
@@ -159,6 +185,7 @@ class ShareExtractionTest {
|
|||||||
"content://media/external/images/media/9",
|
"content://media/external/images/media/9",
|
||||||
"content://media/external/images/media/8",
|
"content://media/external/images/media/8",
|
||||||
),
|
),
|
||||||
|
mimeOf = imageMime,
|
||||||
)
|
)
|
||||||
|
|
||||||
assertEquals(
|
assertEquals(
|
||||||
@@ -179,11 +206,102 @@ class ShareExtractionTest {
|
|||||||
"content://media/external/images/media/1",
|
"content://media/external/images/media/1",
|
||||||
"content://media/external/images/media/2",
|
"content://media/external/images/media/2",
|
||||||
),
|
),
|
||||||
|
mimeOf = imageMime,
|
||||||
)
|
)
|
||||||
|
|
||||||
assertEquals(listOf("content://media/external/images/media/1"), share.images)
|
assertEquals(listOf("content://media/external/images/media/1"), share.images)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// —— 文件(非图片)分流 ——
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a non image stream goes to the document path instead of the image path`() {
|
||||||
|
// 这是本模块修掉的那个真事故:一份 `.md` 此前被当图片拷、当图片解码,
|
||||||
|
// 结局只有「图片处理失败」或者被当成一张读不出来的图。
|
||||||
|
val share = ShareRules.extract(
|
||||||
|
action = ShareRules.ACTION_SEND,
|
||||||
|
stream = "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 },
|
||||||
|
)
|
||||||
|
assertFalse(share.isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a stream whose type is unknown goes to the document path, never guessed`() {
|
||||||
|
// provider 不报类型(getType 回 null)时按文档路走:本端不猜类型,猜错就是替用户
|
||||||
|
// 把一个合规文件变成一次失败。默认 mimeOf 就是「拿不到类型」。
|
||||||
|
val share = ShareRules.extract(
|
||||||
|
action = ShareRules.ACTION_SEND,
|
||||||
|
stream = "content://com.example/files/notes",
|
||||||
|
)
|
||||||
|
|
||||||
|
assertTrue(share.images.isEmpty())
|
||||||
|
assertEquals(1, share.documents.size)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `text and a file are kept side by side`() {
|
||||||
|
val share = ShareRules.extract(
|
||||||
|
action = ShareRules.ACTION_SEND,
|
||||||
|
text = "看看这份",
|
||||||
|
stream = "content://com.example/files/report.md",
|
||||||
|
mimeOf = textMime,
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals("看看这份", share.text)
|
||||||
|
assertEquals(1, share.documents.size)
|
||||||
|
assertEquals(0, share.truncatedDocuments)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `images and files are split before either cap is applied`() {
|
||||||
|
// 混着分享时两边各自的份数必须独立计算:图片的上限是 4、文件的上限是 5,
|
||||||
|
// 先按一个上限裁完再分流,会把另一边的份额挤掉。
|
||||||
|
val uris = (1..6).map { "content://media/external/images/media/$it" } +
|
||||||
|
(1..7).map { "content://com.example/files/doc$it" }
|
||||||
|
val mimeOf: (String) -> String? = { uri ->
|
||||||
|
if (uri.contains("images")) "image/png" else "text/plain"
|
||||||
|
}
|
||||||
|
|
||||||
|
val share = ShareRules.extract(
|
||||||
|
action = ShareRules.ACTION_SEND_MULTIPLE,
|
||||||
|
streams = uris,
|
||||||
|
mimeOf = mimeOf,
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals(ShareRules.MAX_IMAGES, share.images.size)
|
||||||
|
assertEquals(6 - ShareRules.MAX_IMAGES, share.truncatedImages)
|
||||||
|
assertEquals(ShareRules.MAX_DOCUMENTS, share.documents.size)
|
||||||
|
assertEquals(7 - ShareRules.MAX_DOCUMENTS, share.truncatedDocuments)
|
||||||
|
assertEquals(2, share.droppedDocuments)
|
||||||
|
}
|
||||||
|
|
||||||
|
// —— MIME 分流本身(纯函数)——
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `the image prefix decides the path, case and parameters included`() {
|
||||||
|
assertEquals(SharedStreamKind.Image, classifySharedStream("image/png"))
|
||||||
|
assertEquals(SharedStreamKind.Image, classifySharedStream("image/jpeg; charset=binary"))
|
||||||
|
assertEquals(SharedStreamKind.Image, classifySharedStream("IMAGE/PNG"))
|
||||||
|
assertEquals(SharedStreamKind.Image, classifySharedStream(" image/webp "))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `anything that is not an image goes to the document path`() {
|
||||||
|
assertEquals(SharedStreamKind.Document, classifySharedStream(null))
|
||||||
|
assertEquals(SharedStreamKind.Document, classifySharedStream(""))
|
||||||
|
assertEquals(SharedStreamKind.Document, classifySharedStream("text/plain"))
|
||||||
|
assertEquals(SharedStreamKind.Document, classifySharedStream("application/octet-stream"))
|
||||||
|
// 已知会被服务端拒的也走文档路:由服务端按 415 说,而不是在这儿假装它是图片。
|
||||||
|
assertEquals(SharedStreamKind.Document, classifySharedStream("application/pdf"))
|
||||||
|
}
|
||||||
|
|
||||||
// —— 超大文本 ——
|
// —— 超大文本 ——
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -229,4 +347,30 @@ class ShareExtractionTest {
|
|||||||
fun `an empty token still yields a usable file name`() {
|
fun `an empty token still yields a usable file name`() {
|
||||||
assertEquals("share-x-0.png", ShareRules.cacheFileName("", 0, "image/png"))
|
assertEquals("share-x-0.png", ShareRules.cacheFileName("", 0, "image/png"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// —— 文件的缓存文件名 ——
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a document cache name keeps the original extension so the server whitelist can judge it`() {
|
||||||
|
assertEquals("share-3f2a-0.md", ShareRules.documentCacheFileName("3f2a", 0, "报告.md"))
|
||||||
|
assertEquals("share-3f2a-2.toml", ShareRules.documentCacheFileName("3f2a", 2, "Cargo.toml"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a document cache name falls back to txt when there is no usable extension`() {
|
||||||
|
assertEquals("share-3f2a-0.txt", ShareRules.documentCacheFileName("3f2a", 0, ""))
|
||||||
|
assertEquals("share-3f2a-0.txt", ShareRules.documentCacheFileName("3f2a", 0, "notes"))
|
||||||
|
// 超长 / 带分隔符的「扩展名」不是扩展名:一律回落 txt,绝不把它接进路径。
|
||||||
|
assertEquals("share-3f2a-0.txt", ShareRules.documentCacheFileName("3f2a", 0, "a.this-is-way-too-long"))
|
||||||
|
assertEquals("share-3f2a-0.txt", ShareRules.documentCacheFileName("3f2a", 0, "a.md/../../etc/passwd"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a hostile document name cannot escape the cache directory either`() {
|
||||||
|
val name = ShareRules.documentCacheFileName("../../etc", 0, "../../etc/passwd")
|
||||||
|
|
||||||
|
assertFalse(name, name.contains('/'))
|
||||||
|
assertFalse(name, name.contains(".."))
|
||||||
|
assertTrue(name, name.startsWith("share-"))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
package top.yeij.cyrene.ui.chat
|
||||||
|
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertFalse
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
import top.yeij.cyrene.core.error.CyreneError
|
||||||
|
import top.yeij.cyrene.data.api.CyreneException
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 附件上传失败的文案(纯函数)。
|
||||||
|
*
|
||||||
|
* 这里锚的是**每个拒绝码对应的正确动作**:这四个码在用户能做的事上各不相同
|
||||||
|
* (415/413 = 换文件、400 = 少选几个、404 = 重传、401/403 = 重新登录),折成一句
|
||||||
|
* 「附件失败」等于让用户去猜。文案由**服务端**给(含真实上限),客户端**不自己编数字**。
|
||||||
|
*
|
||||||
|
* 另一个锚点是「这是上传这一步失败」:网络类失败必须点明,否则用户看到「无法连接到服务器」
|
||||||
|
* 会以为整条消息没发出去,而实际上文字与附件一起回到了输入框。
|
||||||
|
*/
|
||||||
|
class DocumentUploadFailureTest {
|
||||||
|
|
||||||
|
private fun server(code: String, message: String, status: Int = 400) =
|
||||||
|
CyreneException(CyreneError.Server(code, message, status))
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `unsupported media type keeps the server wording instead of inventing one`() {
|
||||||
|
val message = documentUploadFailureMessage(
|
||||||
|
server("unsupported_media_type", "extension not allowed or content is not plain text", 415),
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals("extension not allowed or content is not plain text", message)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `too large keeps the server limit so the number is the real one`() {
|
||||||
|
val message = documentUploadFailureMessage(
|
||||||
|
server("document_too_large", "file exceeds the 20971520 byte limit", 413),
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals("file exceeds the 20971520 byte limit", message)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `too many documents keeps the server wording as well`() {
|
||||||
|
val message = documentUploadFailureMessage(server("too_many_documents", "at most 5 documents", 400))
|
||||||
|
|
||||||
|
assertEquals("at most 5 documents", message)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a missing document says to send it again`() {
|
||||||
|
val message = documentUploadFailureMessage(server("document_not_found", "document not found", 404))
|
||||||
|
|
||||||
|
assertEquals("document not found", message)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `an expired session asks for a re-login, not for another file`() {
|
||||||
|
val message = documentUploadFailureMessage(
|
||||||
|
CyreneException(CyreneError.Unauthorized("unauthorized", "auth: unauthorized")),
|
||||||
|
)
|
||||||
|
|
||||||
|
assertTrue(message, message.contains("重新登录"))
|
||||||
|
assertFalse("鉴权失败不该劝用户换文件", message.contains("换"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a network failure names the step that failed and keeps the reason`() {
|
||||||
|
val message = documentUploadFailureMessage(
|
||||||
|
CyreneException(CyreneError.Local(CyreneError.LocalKind.NoNetwork)),
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals("附件上传失败,网络不可用,请检查连接", message)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `an unknown server code still carries the server message`() {
|
||||||
|
val message = documentUploadFailureMessage(server("weird_new_code", "something went wrong", 500))
|
||||||
|
|
||||||
|
assertEquals("附件上传失败,something went wrong", message)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a non Cyrene throwable still produces an actionable message`() {
|
||||||
|
assertEquals("附件上传失败,请重试", documentUploadFailureMessage(IllegalStateException("boom")))
|
||||||
|
assertEquals("附件上传失败,请重试", documentUploadFailureMessage(RuntimeException()))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `no failure path ever claims the attachment was sent`() {
|
||||||
|
val messages = listOf(
|
||||||
|
documentUploadFailureMessage(server("unsupported_media_type", "x", 415)),
|
||||||
|
documentUploadFailureMessage(server("document_too_large", "x", 413)),
|
||||||
|
documentUploadFailureMessage(server("too_many_documents", "x", 400)),
|
||||||
|
documentUploadFailureMessage(server("document_not_found", "x", 404)),
|
||||||
|
documentUploadFailureMessage(CyreneException(CyreneError.Local(CyreneError.LocalKind.Timeout))),
|
||||||
|
documentUploadFailureMessage(IllegalStateException()),
|
||||||
|
)
|
||||||
|
|
||||||
|
// 文案里绝不能出现「已发送 / 已上传」这类暗示:失败即整轮回滚,附件与正文一起回到输入框。
|
||||||
|
assertTrue(messages.toString(), messages.none { it.contains("已发送") || it.contains("已上传") })
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user