From ae4e4e53f6437bb4084113d3c27236011e4782b3 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Sun, 4 Oct 2026 18:05:27 +0800 Subject: [PATCH] =?UTF-8?q?feat(share):=20=E6=8E=A5=E5=85=A5=E7=B3=BB?= =?UTF-8?q?=E7=BB=9F=E5=88=86=E4=BA=AB=E5=85=A5=E5=8F=A3=20=E2=80=94?= =?UTF-8?q?=E2=80=94=20=E5=88=86=E4=BA=AB=E6=96=87=E6=9C=AC=20/=20?= =?UTF-8?q?=E5=9B=BE=E7=89=87=E8=BF=9B=E4=BC=9A=E8=AF=9D=EF=BC=88=E6=9C=AA?= =?UTF-8?q?=E7=99=BB=E5=BD=95=E5=85=88=E6=9A=82=E5=AD=98=EF=BC=8C=E7=99=BB?= =?UTF-8?q?=E5=BD=95=E5=90=8E=E8=90=BD=E5=9C=B0=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 主人 2026-10-04 提出的「分享到 App」的客户端半边。触发面就是系统分享菜单的 ACTION_SEND / ACTION_SEND_MULTIPLE;发送侧**完全复用既有路径**(短文本 WS、 长文本与图片 REST、大图先 /upload),不另起一套。 - Manifest:MainActivity 加 SEND / SEND_MULTIPLE 的 intent-filter,MIME 只声明 text/plain 与 image/*。刻意不声明 */*:对话路径上今天没有「文件」这个表示 (协同 02/F1 三缺),声明 */* 等于给出一个点进去必然失败的入口;收窄之后非图片 文件干脆不进分享菜单。另加 launchMode=singleTop 与 label(分享菜单里显示 Cyrene, 图标沿用应用启动图标,不另设一份会漂移的来源)。 - MainActivity:冷启动(onCreate)与热启动(onNewIntent + setIntent)两条路都接。 冷启动只在 savedInstanceState==null 时接 intent —— Activity 因旋转 / 深色切换重建 时系统会把同一个 intent 再送一遍,照单全收就会把同一条分享发第二次。 - 新增 domain/share(纯逻辑,可单测): - ShareRules.extract:文本 / 单图 / 多图 / 空内容 / 只有 MIME 没有内容 / EXTRA_STREAM 缺席时回落到 ClipData;去重与空白条目在**截断之前**处理 (重复项既不该挤掉真图,也不该被算成「截掉了」)。一次最多接 4 张,超出的 条数如实计数,不静默丢。正文一个字都不加(来源标记走独立 RoleSystem 消息 + SenderName,拼进正文即违反红线,见 02/F4)。 - ShareRules.cacheFileName:文件名只由本端 token + 序号 + MIME 白名单扩展名构成, 外部 URI 的路径 / 显示名绝不参与(纯函数里连本端 token 都不信,非法字符折成 _)。 - PendingShareHolder:待落地内容的队列,take() 是原子出队 —— 一条内容只能被消费 一次(消费它的地方都会被重建:重组、切会话、Activity 重建),同时不把先后两条 挤在同一窗口的那次分享丢掉。 - 新增 app/ShareIntake:进程级单例(不塞 AppContainer,避免碰别的并行改动)。 图片在收到的那一刻就拷进 cacheDir 并换成**本端 file://**(content:// 的读授权活不过 登录 / 切页,拷不动的那几张如实计入「未发送」);进程启动清一次缓存目录 (那份副本只对产生它的进程有意义),运行期间不回收 —— 发送失败回滚到输入框之后 仍要读它。 - ChatViewModel:sendShared 与输入框的 send 共用 emitAndDeliver(同一套乐观气泡 / 幂等键 / 回滚),差别只有「不动用户草稿」(分享不是用户按的发送键)。一条消息只能 带一张图 ⇒ N 张图 = N 条消息,第一条带分享的正文;顺序在同一个协程里串起来。 长文本不截断,由既有 ChatSendRouting 判据改走 REST(WS 帧 4096 字节是断连不是报错)。 - ChatScreen:仅当**当前会话**的组合存在时认领(已访问过的会话 VM 都还活着,UI 层 认证才不会让旧会话抢走),落地前先等 WS 连接、上限 3 秒(冷启动必然撞上握手;超时 也照发,让「没网」退化成内容回到输入框这种看得见的结果,而不是停在看不见的地方)。 截断 / 读不到 / 空内容用 shareNotice 如实告知(8 秒后自动收走),与 error 分开。 - 测试 +23(ShareExtractionTest 18 / PendingShareHolderTest 5):文本、单图、多图、 空、只有 MIME、ClipData 兜底与去重、截断计数、超大文本不截断且落 REST、 文件名不可穿越、原子出队(并发 64 取 64 条不重不漏)。assembleDebug 绿, testDebugUnitTest 353 → 376 全绿(failures/errors 皆 0)。 --- app/src/main/AndroidManifest.xml | 27 ++ .../top/yeij/cyrene/app/MainActivity.kt | 22 ++ .../kotlin/top/yeij/cyrene/app/ShareIntake.kt | 151 ++++++++++++ .../cyrene/domain/share/PendingShareHolder.kt | 45 ++++ .../yeij/cyrene/domain/share/SharePayload.kt | 129 ++++++++++ .../top/yeij/cyrene/ui/chat/ChatScreen.kt | 82 ++++++- .../top/yeij/cyrene/ui/chat/ChatViewModel.kt | 68 ++++- .../domain/share/PendingShareHolderTest.kt | 81 ++++++ .../domain/share/ShareExtractionTest.kt | 232 ++++++++++++++++++ 9 files changed, 821 insertions(+), 16 deletions(-) create mode 100644 app/src/main/kotlin/top/yeij/cyrene/app/ShareIntake.kt create mode 100644 app/src/main/kotlin/top/yeij/cyrene/domain/share/PendingShareHolder.kt create mode 100644 app/src/main/kotlin/top/yeij/cyrene/domain/share/SharePayload.kt create mode 100644 app/src/test/kotlin/top/yeij/cyrene/domain/share/PendingShareHolderTest.kt create mode 100644 app/src/test/kotlin/top/yeij/cyrene/domain/share/ShareExtractionTest.kt diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 4592c25..7c77b59 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -11,14 +11,41 @@ android:supportsRtl="true" android:theme="@style/Theme.Cyrene"> + + + + + + + + + + + + + + + + diff --git a/app/src/main/kotlin/top/yeij/cyrene/app/MainActivity.kt b/app/src/main/kotlin/top/yeij/cyrene/app/MainActivity.kt index 8ff9f56..732def6 100644 --- a/app/src/main/kotlin/top/yeij/cyrene/app/MainActivity.kt +++ b/app/src/main/kotlin/top/yeij/cyrene/app/MainActivity.kt @@ -1,16 +1,38 @@ package top.yeij.cyrene.app +import android.content.Intent import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge + class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { enableEdgeToEdge() super.onCreate(savedInstanceState) + // 冷启动的两步:先清掉上一进程遗留的分享缓存(它已经没有主了),再接这次带来的分享。 + // 顺序不能反——反过来会把刚拷进来的图一起删掉。 + ShareIntake.reset(this) + // 只在**真正的冷启动**接 intent:Activity 因旋转 / 深色切换而重建时,系统会把原来那个 + // intent 再送一遍,照单全收就会把同一条分享发第二次。重建带 savedInstanceState, + // 冷启动不带——这就是判据。 + if (savedInstanceState == null) { + intent?.let { ShareIntake.accept(this, it) } + } val container = (application as CyreneApplication).container setContent { CyreneRoot(container) } } + + /** + * 热启动(`launchMode="singleTop"`):App 已经开着时系统不再新建实例,只回调这里。 + * 不接这一路的话,「从浏览器分享 → 回到已经开着的 Cyrene」会一点反应都没有。 + */ + override fun onNewIntent(intent: Intent) { + super.onNewIntent(intent) + // 让 getIntent() 与新到的 intent 一致(系统不会自己更新它),否则后续读到的还是旧的那个。 + setIntent(intent) + ShareIntake.accept(this, intent) + } } diff --git a/app/src/main/kotlin/top/yeij/cyrene/app/ShareIntake.kt b/app/src/main/kotlin/top/yeij/cyrene/app/ShareIntake.kt new file mode 100644 index 0000000..526da05 --- /dev/null +++ b/app/src/main/kotlin/top/yeij/cyrene/app/ShareIntake.kt @@ -0,0 +1,151 @@ +package top.yeij.cyrene.app + +import android.content.Context +import android.content.Intent +import android.net.Uri +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.launch +import kotlinx.coroutines.flow.StateFlow +import top.yeij.cyrene.domain.share.PendingShareHolder +import top.yeij.cyrene.domain.share.ShareRules +import top.yeij.cyrene.domain.share.SharedContent +import java.io.File +import java.util.UUID + +/** + * 「分享到 App」的接收入口(`ACTION_SEND` / `ACTION_SEND_MULTIPLE`)。 + * + * ## 它为什么是一个进程级单例 + * 分享进来时可能还没登录、ChatScreen 甚至还没被组合出来,而登录之后消费它的又是**另一个** + * Compose 实例。内容因此必须待在一个不属于任何 UI / ViewModel 的地方——见 + * [PendingShareHolder]。不塞进 `AppContainer` 是因为那个文件同时被别的并行改动占用, + * 这里没有任何依赖需要它:本对象只需要一个 `Context`(读 URI、写 cache)。 + * + * ## 图片为什么在收到的那一刻就落盘 + * `content://` 的读授权只给到**接收它的那个 Activity / 任务**,登录(可能十几秒、还可能切页) + * 之后再去读,拿到的多半是 `SecurityException` / 打不开。所以字节在这里立刻拷进 `cacheDir`, + * 之后一律用本端自己的 `file://` 路径——**外部 Uri 只在这个方法里出现一次**。 + * + * ## 清理 + * 缓存副本只对**产生它的那个进程**有意义(持有者在内存里,进程没了就没人认领),所以 + * 进程启动时整目录删一次([reset])。运行期间不做细粒度回收:那些文件在发送失败被回滚到 + * 输入框之后**仍然要用**(用户可能过一会儿才点发送),提前删掉就会让预览变空图。 + * 目录因此以「一次进程生命周期内的分享次数 × 4」为界,另有系统的 cache 回收兜底。 + */ +object ShareIntake { + + private const val DIR_NAME = "share-intake" + + private val holder = PendingShareHolder() + + /** 待落地的分享内容;消费侧(ChatScreen)订阅它。 */ + val pending: StateFlow> = holder.pending + + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + + /** 进程启动时调用一次:把上一进程遗留的分享缓存清干净。 */ + fun reset(context: Context) { + if (cleanedThisProcess) return + cleanedThisProcess = true + runCatching { shareDir(context).deleteRecursively() } + } + + /** + * 「本进程已经清过一次」的标记。 + * + * Activity 会因为旋转 / 分屏 / 深色切换而**重建**,重建就会再走一次 `onCreate`—— + * 那时清缓存会把用户刚分享进来、还摆在输入框里的那张图删掉(预览变空图、发送也没有字节)。 + * 缓存副本只对「产生它的进程」有意义,所以判据是**进程**而不是 Activity 实例。 + */ + private var cleanedThisProcess = false + + /** + * 接收一个分享 Intent(冷启动在 `onCreate`、热启动在 `onNewIntent`)。 + * + * 提取是纯函数([ShareRules.extract]),拷图是重活,所以在 IO 线程做:`content://` 的读 + * 授权绑的是 Activity 而不是某一次调用栈,异步读一样有效。 + */ + fun accept(context: Context, intent: Intent) { + val extracted = ShareRules.extract( + action = intent.action, + text = intent.getCharSequenceExtra(Intent.EXTRA_TEXT), + stream = intent.streamUri(), + streams = intent.streamUris(), + clipUris = intent.clipUris(), + ) + if (extracted.isEmpty && extracted.droppedImages == 0) return + + val appContext = context.applicationContext + scope.launch { + val dir = shareDir(appContext).apply { mkdirs() } + val copied = extracted.images.mapIndexedNotNull { index, uri -> + copyToCache(appContext, dir, uri, index) + } + holder.put( + extracted.copy( + images = copied, + // 拷不动的那些如实计入「没发送」:宁可界面上说一句,也不要静默少一张。 + failedImages = extracted.failedImages + (extracted.images.size - copied.size), + ), + ) + } + } + + /** + * 取走一条待落地内容(原子出队,只有取到它的那个调用方会去发);队列为空回 `null`。 + * + * 出队是**唯一的认领方式**:消费它的地方会被重建(重组 / 切会话 / Activity 重建), + * 所以「已经发过了」这件事不能记在消费方身上(见 [PendingShareHolder])。 + */ + fun take(): SharedContent? = holder.take() + + private fun shareDir(context: Context): File = File(context.cacheDir, DIR_NAME) + + /** + * 把一个外部 URI 的字节拷进 cache,返回本端自己的 `file://` URI;失败回 `null`。 + * + * 必须是 `file://` 而不是裸路径:`ContentResolver.openInputStream` 认 `file` scheme, + * 但裸路径没有 scheme,会走到「找 content provider」那条路并抛 `FileNotFoundException`。 + */ + private fun copyToCache(context: Context, dir: File, uri: String, index: Int): String? { + val parsed = runCatching { Uri.parse(uri) }.getOrNull() ?: return null + if (parsed.scheme.isNullOrBlank()) return null + + val target = File( + dir, + ShareRules.cacheFileName( + token = UUID.randomUUID().toString(), + index = index, + mime = runCatching { context.contentResolver.getType(parsed) }.getOrNull(), + ), + ) + return try { + val source = context.contentResolver.openInputStream(parsed) ?: return null + source.use { input -> target.outputStream().use { output -> input.copyTo(output) } } + if (target.length() > 0L) { + Uri.fromFile(target).toString() + } else { + target.delete() + null + } + } catch (_: Exception) { + // 半截文件不能留下:下一次发送会把它当一张真图去解码,报出的错会指向错的地方。 + target.delete() + null + } + } + + private fun Intent.streamUri(): String? = + getParcelableExtra(Intent.EXTRA_STREAM, Uri::class.java)?.toString() + + private fun Intent.streamUris(): List = + getParcelableArrayListExtra(Intent.EXTRA_STREAM, Uri::class.java).orEmpty().map { it.toString() } + + /** `ClipData` 里的 URI(部分 App 只填它);纯文本条目的 URI 为空,天然被过滤掉。 */ + private fun Intent.clipUris(): List { + val clip = clipData ?: return emptyList() + return (0 until clip.itemCount).mapNotNull { clip.getItemAt(it).uri?.toString() } + } +} diff --git a/app/src/main/kotlin/top/yeij/cyrene/domain/share/PendingShareHolder.kt b/app/src/main/kotlin/top/yeij/cyrene/domain/share/PendingShareHolder.kt new file mode 100644 index 0000000..aa6622b --- /dev/null +++ b/app/src/main/kotlin/top/yeij/cyrene/domain/share/PendingShareHolder.kt @@ -0,0 +1,45 @@ +package top.yeij.cyrene.domain.share + +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.getAndUpdate +import kotlinx.coroutines.flow.update + +/** + * 待落地内容的持有者(与 Android 无关,可单测)。 + * + * ## 为什么它必须活在 Compose 与 ViewModel 之外 + * 内容要跨过「未登录 → 登录」:分享进来时可能还在登录页,甚至 ChatScreen 都还没被组合出来。 + * 而消费方(Compose 的 `LaunchedEffect`、Activity、各会话自己的 ChatViewModel)都会随重组 / + * 重建 / 切会话而消失——把待落地内容放在任何一个消费方里,都会在它消失时一起丢掉。 + * + * ## 唯一的不变量:一条内容只能被消费一次 + * [take] 是**原子出队**:两个消费者同时来(切会话、连接恢复各触发一次),各自只会拿到不同的一条。 + * 判据不能是「消费方自己记得已经发过」——消费方本身就会被重建,一重建就忘。 + * + * ## 队列而不是单槽 + * 单槽会在两条分享挤在同一窗口时**静默丢掉前一条**(比如未登录时先后从相册和浏览器各分享一次)。 + * 队列让它们都留在里面,按收到的顺序逐条落地。 + * + * 内容只在**进程内存**里:它跨得过登录,跨不过进程被杀。为那一步准备的落盘副本由 + * `app/ShareIntake.kt` 在下次启动时清理。 + */ +class PendingShareHolder { + + private val state = MutableStateFlow>(emptyList()) + + /** 还没落地的内容(按收到顺序);空列表 = 没有待办。 */ + val pending: StateFlow> = state.asStateFlow() + + /** 追加一条(后到的排在后面:先分享的先落地)。 */ + fun put(value: T) = state.update { it + value } + + /** + * 取走队首并出队;队列为空时回 `null`。 + * + * `getAndUpdate` 的 CAS 循环保证「取到的队首」就是**本次转换**的旧值:两个线程同时调用, + * 一个拿到第一条、另一个拿到第二条,不会双双拿到同一条,也不会漏掉一条。 + */ + fun take(): T? = state.getAndUpdate { it.drop(1) }.firstOrNull() +} diff --git a/app/src/main/kotlin/top/yeij/cyrene/domain/share/SharePayload.kt b/app/src/main/kotlin/top/yeij/cyrene/domain/share/SharePayload.kt new file mode 100644 index 0000000..6c9ca9a --- /dev/null +++ b/app/src/main/kotlin/top/yeij/cyrene/domain/share/SharePayload.kt @@ -0,0 +1,129 @@ +package top.yeij.cyrene.domain.share + +/** + * 从别的 App 分享进来、已经可以落地的内容。 + * + * [images] 是**本端自己落盘后**的 `file://` URI:外部 `content://` 的读授权只在这个 intent + * 的接收方(Activity / 任务)存活期间有效,跨不过「未登录 → 登录」,所以图片必须在收到的那一刻 + * 就拷进 app 的 cache 目录(见 `app/ShareIntake.kt`)。 + * + * 两个丢件计数分开记,是因为它们该说的话不一样:截断是「本端的规则」,读不到是「对方给的 + * 东西用不了」。分享进来的内容**不许静默少发**——少发的部分要在界面上如实讲清楚。 + */ +data class SharedContent( + val text: String = "", + val images: List = emptyList(), + /** 超过 [ShareRules.MAX_IMAGES] 而未被接收的条数。 */ + val truncatedImages: Int = 0, + /** 已接收但**拷贝失败**(读不到 / 写不进 cache)的条数。 */ + val failedImages: Int = 0, +) { + /** 没能落地的总条数。 */ + val droppedImages: Int get() = truncatedImages + failedImages + + /** 没有任何可发送的内容(此时只剩下 [droppedImages] 需要告知)。 */ + val isEmpty: Boolean get() = text.isBlank() && images.isEmpty() +} + +/** + * 分享入口的规则(纯函数,与 Android 无关,可单测;Intent 的读取在 `app/ShareIntake.kt`)。 + * + * ## 为什么只认这两个 action、两类 MIME + * Manifest 只声明 `text/plain` 与 `image/*`:声明的每一类都必须是**本端真能落地**的东西。 + * 声明 `*/*` 会让「分享 PDF / 视频 / 压缩包给昔涟」出现在系统分享菜单里,而对话路径上今天 + * 根本没有「文件」这个表示(协同文档 `02/F1`:表示 / 存储 / 摄取三缺)——那会造出一个 + * **点进去必然失败**的入口。收窄之后非图片文件干脆不进分享菜单,用户看不到一个假的选项。 + * + * ## 为什么正文一个字都不加 + * **绝不**往用户正文里拼「[用户分享]」之类的来源标记:来源标识走后端的独立 `RoleSystem` + * 来源消息 + 结构化 `SenderName`(`02/F4` / `design/11 §10`),人类可见的标记只能放在本端 + * 渲染层。把标记拼进正文 = 让用户正文冒充系统指令,是项目红线。分享进来的正文**原样**发出。 + */ +object ShareRules { + + /** `Intent.ACTION_SEND`:一条文本,或一张图片(可能还带一段说明文字)。 */ + const val ACTION_SEND = "android.intent.action.SEND" + + /** `Intent.ACTION_SEND_MULTIPLE`:多条;本端只接图片。 */ + const val ACTION_SEND_MULTIPLE = "android.intent.action.SEND_MULTIPLE" + + /** + * 一次分享最多接收几张图。 + * + * 现有发送路径**一条消息只能带一张图**,所以 N 张图 = N 条消息(第一条带上分享的正文)。 + * 上限压在 4 是为了不让一次分享在会话里炸出一长串回合;超出的部分不静默丢, + * 由调用方如实告知(见 [SharedContent.truncatedImages])。 + */ + const val MAX_IMAGES = 4 + + /** + * 从分享 Intent 的原始字段提取内容。 + * + * 图片的取值顺序是 `EXTRA_STREAM` → `ClipData`:前者是契约路径,后者是兜底——确实有 + * App(尤其旧版本)只填 `ClipData` 而不填 `EXTRA_STREAM`,只认前者会让分享静默落空。 + * 两者都有时**只取前者**,不会把同一张图数两遍。 + * + * @param action `Intent.action`;只认 [ACTION_SEND] / [ACTION_SEND_MULTIPLE] + * @param text `EXTRA_TEXT`——按 `CharSequence` 收,因为它常常是 `Spanned`(带样式的 + * HTML 富文本),读成 `String` 会直接拿到 `null` + * @param stream `EXTRA_STREAM`([ACTION_SEND] 的单条 URI 字符串) + * @param streams `EXTRA_STREAM`([ACTION_SEND_MULTIPLE] 的 URI 列表) + * @param clipUris `ClipData` 各条目的 URI;文本类条目没有 URI,会被自动忽略 + */ + fun extract( + action: String?, + text: CharSequence? = null, + stream: String? = null, + streams: List? = null, + clipUris: List = emptyList(), + ): SharedContent { + val recognized = action == ACTION_SEND || action == ACTION_SEND_MULTIPLE + if (!recognized) return SharedContent() + + val body = text?.toString().orEmpty().trim() + + val primary = when (action) { + ACTION_SEND -> listOfNotNull(stream) + else -> streams.orEmpty() + } + // 空串条目(有的 App 会塞占位)与重复条目都要去掉:不然「4 张图」里可能只剩 2 张真图, + // 而截断计数还会把占位算成一张。 + val unique = primary.ifEmpty { clipUris } + .map(String::trim) + .filter(String::isNotEmpty) + .distinct() + + return SharedContent( + text = body, + images = unique.take(MAX_IMAGES), + truncatedImages = (unique.size - MAX_IMAGES).coerceAtLeast(0), + ) + } + + /** + * 落盘文件名:**只由本端生成**的 [token] 与序号 [index] 拼成,扩展名取自 MIME 白名单 + * (不在表里就回落 `.img`——真正的类型由字节嗅探裁决,不靠扩展名)。 + * + * **绝不**沿用外部 URI 里的路径 / 显示名:那是别人给的字符串,直接当文件名等于把路径穿越的 + * 口子接到自己的 cache 目录上。这个函数是纯的,所以它连 [token] 都不信——非法字符一律折成 + * `_`,`. .` 因此拼不出 `..`,`/` 也活不下来。 + */ + fun cacheFileName(token: String, index: Int, mime: String?): String { + val safe = token.map { c -> + if (c in 'a'..'z' || c in 'A'..'Z' || c in '0'..'9' || c == '-' || c == '_') c else '_' + }.joinToString("") + return "share-${safe.ifEmpty { "x" }}-$index.${extensionFor(mime)}" + } + + /** MIME → 扩展名;未知 / 缺失回落 `img`。 */ + private fun extensionFor(mime: String?): String = + when (mime?.substringBefore(';')?.trim()?.lowercase()) { + "image/png" -> "png" + "image/jpeg", "image/jpg" -> "jpg" + "image/webp" -> "webp" + "image/gif" -> "gif" + "image/bmp", "image/x-ms-bmp" -> "bmp" + "image/heic", "image/heif" -> "heic" + else -> "img" + } +} diff --git a/app/src/main/kotlin/top/yeij/cyrene/ui/chat/ChatScreen.kt b/app/src/main/kotlin/top/yeij/cyrene/ui/chat/ChatScreen.kt index 98b907f..740f2c5 100644 --- a/app/src/main/kotlin/top/yeij/cyrene/ui/chat/ChatScreen.kt +++ b/app/src/main/kotlin/top/yeij/cyrene/ui/chat/ChatScreen.kt @@ -34,6 +34,7 @@ import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel @@ -41,8 +42,13 @@ import androidx.lifecycle.viewmodel.initializer import androidx.lifecycle.viewmodel.viewModelFactory import coil3.compose.AsyncImage import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.withTimeoutOrNull import top.yeij.cyrene.app.AppContainer +import top.yeij.cyrene.app.ShareIntake import top.yeij.cyrene.data.ws.WsState +import top.yeij.cyrene.domain.share.ShareRules +import top.yeij.cyrene.domain.share.SharedContent import top.yukonga.miuix.kmp.basic.Button import top.yukonga.miuix.kmp.basic.Icon import top.yukonga.miuix.kmp.basic.IconButton @@ -59,6 +65,9 @@ import top.yukonga.miuix.kmp.theme.MiuixTheme /** 判定「贴着底部」的容差(px)。 */ private const val BOTTOM_TOLERANCE = 64 +/** 分享落地前等 WS 连接就绪的上限(毫秒):超时也照样发,让失败落在看得见的地方。 */ +private const val SHARE_CONNECT_TIMEOUT_MILLIS = 3_000L + /** 输入栏下方预留的高度:避开屏幕圆角,同时承载忙碌提示。 */ private val InputBottomSpacer = 22.dp @@ -82,6 +91,24 @@ fun ChatScreen( ActivityResultContracts.PickVisualMedia(), ) { uri -> uri?.let { viewModel.onImagePicked(it.toString()) } } + // 「分享到 App」的落地时机。待落地的内容活在 Compose 之外(`ShareIntake`),这里只做三件事: + // 挑时机、取走一条、交给既有发送路径。 + // - **先等连接、但只等一小会儿**:短文本走 WS,而冷启动时握手还没完成(分享冷启动必然撞上), + // 抢在那之前发就只换来一次「发送失败,请重试」。等待有上限——真的没网时宁可退化成 + // 「内容回到输入框、用户自己重试」,也不要让它停在界面上看不见的地方。 + // - **只有当前会话的 ChatScreen 在组合**:已访问过的会话的 VM 都还活着(Activity 级 + // ViewModelStore),由 UI 层认领才不会让旧会话把内容抢走。 + // - [ShareIntake.take] 是原子出队:同一条内容不会被发两次(重组 / 切会话 / 换连接都会重跑 + // 这个 effect)。 + val pendingShares by ShareIntake.pending.collectAsState() + LaunchedEffect(sessionId, pendingShares) { + if (pendingShares.isEmpty()) return@LaunchedEffect + withTimeoutOrNull(SHARE_CONNECT_TIMEOUT_MILLIS) { + viewModel.state.first { it.connection == WsState.Connected } + } + ShareIntake.take()?.let { share -> viewModel.sendShared(share, shareNoticeFor(share)) } + } + // 是否跟随底部(微信/QQ 式):只有用户主动滚到别处时才停止跟随。 // 只在「滚动结束」时更新,避免新消息插入导致的锚点位移把状态带偏。 var followBottom by remember { mutableStateOf(true) } @@ -184,22 +211,36 @@ fun ChatScreen( } } - state.error?.let { message -> - Text( - text = message, - style = MiuixTheme.textStyles.footnote1, - color = MiuixTheme.colorScheme.error, - modifier = Modifier - .align(Alignment.TopCenter) - .fillMaxWidth() - .background(MiuixTheme.colorScheme.surfaceContainer) - .padding(12.dp), - ) + Column( + modifier = Modifier + .align(Alignment.TopCenter) + .fillMaxWidth(), + ) { + state.error?.let { message -> + Banner(text = message, color = MiuixTheme.colorScheme.error) + } + state.shareNotice?.let { message -> + Banner(text = message, color = MiuixTheme.colorScheme.onSurfaceVariantSummary) + } } } } } +/** 消息区上方的一行提示:错误与分享告知共用同一形态(铺满宽度、卡片底色)。 */ +@Composable +private fun Banner(text: String, color: Color) { + Text( + text = text, + style = MiuixTheme.textStyles.footnote1, + color = color, + modifier = Modifier + .fillMaxWidth() + .background(MiuixTheme.colorScheme.surfaceContainer) + .padding(12.dp), + ) +} + @Composable private fun InputBar( value: String, @@ -330,3 +371,22 @@ private fun busyLabel(state: ChatUiState): String = when { state.queueDepth > 0 -> "已排队 ${state.queueDepth} 条" else -> "" } + +/** + * 分享落地的如实告知:只在「有东西没能按原样发出」时才成句,正常落地(一条文本 / 一张图) + * 一个字都不说。 + * + * 这是**本端渲染层**的信息,绝不写进正文:来源 / 系统提示的边界见 `design/11 §10` + * (`02/F4` 的红线)。截断与读不到要说的话不一样——前者是本端的接收上限,后者是对方给的东西 + * 用不了,混成一句会让人以为图还在路上。 + */ +private fun shareNoticeFor(share: SharedContent): String? { + val parts = buildList { + if (share.truncatedImages > 0) { + add("分享的图片超过 ${ShareRules.MAX_IMAGES} 张,只发送前 ${share.images.size} 张") + } + if (share.failedImages > 0) add("有 ${share.failedImages} 张图片读不到,未发送") + if (share.isEmpty) add("分享的内容是空的,没有可发送的内容") + } + return parts.takeIf { it.isNotEmpty() }?.joinToString(";") +} diff --git a/app/src/main/kotlin/top/yeij/cyrene/ui/chat/ChatViewModel.kt b/app/src/main/kotlin/top/yeij/cyrene/ui/chat/ChatViewModel.kt index fc435a4..88b40e0 100644 --- a/app/src/main/kotlin/top/yeij/cyrene/ui/chat/ChatViewModel.kt +++ b/app/src/main/kotlin/top/yeij/cyrene/ui/chat/ChatViewModel.kt @@ -31,6 +31,7 @@ import top.yeij.cyrene.domain.chat.ImageDelivery import top.yeij.cyrene.domain.chat.SendChannel import top.yeij.cyrene.domain.parse.ParsedBubble import top.yeij.cyrene.domain.parse.StreamingContentParser +import top.yeij.cyrene.domain.share.SharedContent import java.util.UUID const val MAIN_SESSION_ID = "web" @@ -50,7 +51,7 @@ private const val DEVICE_ID = "voiceassist:cyrene:v1" data class PendingMessage( val localId: String, val text: String, - /** 本次带的图片的**本地**预览(`content://` URI);null = 纯文本消息。 */ + /** 本次带的图片的**本地可读** URI(`content://` 或 `file://`);null = 纯文本消息。 */ val imageUri: String? = null, ) @@ -188,12 +189,19 @@ data class ChatUiState( val pending: List = emptyList(), val streaming: StreamingState? = null, val draft: String = "", - /** 已选好、还没发出去的图片(本地 `content://` URI 字符串);null = 没有待发图片。 */ + /** 已选好、还没发出去的图片(本地 URI 字符串);null = 没有待发图片。 */ val draftImage: String? = null, val loading: Boolean = true, val loadingOlder: Boolean = false, val hasMore: Boolean = true, val error: String? = null, + /** + * 「分享到 App」落地的如实告知(例如有几张图没能发送);null = 没有要说的。 + * + * 与 [error] 分开:分享通常**成功**,只是被本端的接收上限截掉了一部分,那不是错误。 + * 它到期自动消失([SHARE_NOTICE_MILLIS]),因为它描述的是刚刚发生的一次动作。 + */ + val shareNotice: String? = null, val connection: WsState = WsState.Idle, /** 编排忙碌态:`idle`/`thinking`/`executing`/`subagent`;null = 空闲。 */ val busyMode: String? = null, @@ -227,6 +235,9 @@ data class ChatUiState( * 换 URL 再发(见 [deliverImage])——服务端把 `image` **原样落库**,内联的字节会在每一页 * 历史与每一次 `history` 回放里重传,上传是唯一能把这个成本买断的手段。 * + * 上行的两个入口共用这一套实现:输入框的 [send],以及「分享到 App」的 [sendShared] + * (`ACTION_SEND` 进来的内容由 `app/ShareIntake` 拿着,等本会话在登录态下组合出来再落地)。 + * * 本 VM 只收**属于 [sessionId] 的帧**(见 [onEvent] 的会话闸)。服务端广播不按会话订阅, * 且每个会话各有一个 VM(各自的 socket 都还活着),没有这道闸就会串台。 */ @@ -303,18 +314,62 @@ class ChatViewModel( val text = snapshot.draft.trim() val imageUri = snapshot.draftImage if (text.isEmpty() && imageUri == null) return + viewModelScope.launch { emitAndDeliver(text, imageUri, clearDraft = true) } + } + + /** + * 「分享到 App」的落地路径:把从别的 App 分享进来的内容按**现有发送路径**发出 + * (短文本 → WS,长文本 / 图片 → REST,图片先 [ChatImagePreparer] 重编码、大图先 `/upload`), + * **不另起一套发送实现**,也不动用户正文里的一个字。 + * + * 一条消息只能带一张图,所以 N 张图 = N 条消息:第一条带上分享的正文,其余每条一张图。 + * 顺序由这里串起来(同一个协程里依次投递)——各投各的会让它们互相赛跑,落库顺序反过来。 + * + * 两处刻意的取舍: + * - **不动草稿**:分享不是用户按的发送键,不能把他正在输入的内容顶掉; + * - **空内容也照常处理**:[notice] 该说就说,静默什么都不发生比报一句更糟。 + * + * @param notice 如实告知(截断 / 读不到 / 空内容),由渲染层组织文案;null = 没什么要说的 + */ + fun sendShared(share: SharedContent, notice: String? = null) { + notice?.let(::showShareNotice) + val body = share.text.trim() + if (body.isEmpty() && share.images.isEmpty()) return + viewModelScope.launch { + emitAndDeliver(body, share.images.firstOrNull(), clearDraft = false) + share.images.drop(1).forEach { emitAndDeliver("", it, clearDraft = false) } + } + } + + /** 记一条分享提示,到期自动收走(同一条未过期就不重复刷)。 */ + private fun showShareNotice(text: String) { + _state.update { it.copy(shareNotice = text) } + viewModelScope.launch { + kotlinx.coroutines.delay(SHARE_NOTICE_MILLIS) + _state.update { if (it.shareNotice == text) it.copy(shareNotice = null) else it } + } + } + + /** + * 把一条消息乐观上屏并投递出去([send] 与 [sendShared] 共用)。 + * + * @param clearDraft 只有「用户按了发送键」才清草稿——那时草稿正是刚发出去的这一条; + * 分享落地是替用户发一条他自己录进来的内容,草稿不是它。 + */ + private suspend fun emitAndDeliver(text: String, imageUri: String?, clearDraft: Boolean) { + if (text.isEmpty() && imageUri == null) return // localId 即幂等键:重试复用同一个值,服务端不会把它当成第二条消息。 val localId = "local-${UUID.randomUUID()}" _state.update { it.copy( pending = it.pending + PendingMessage(localId, text, imageUri), - draft = "", - draftImage = null, + draft = if (clearDraft) "" else it.draft, + draftImage = if (clearDraft) null else it.draftImage, error = null, ) } - viewModelScope.launch { deliver(localId, text, imageUri) } + deliver(localId, text, imageUri) } private suspend fun deliver(localId: String, text: String, imageUri: String?) { @@ -720,3 +775,6 @@ fun uploadFailureMessage(e: Throwable): String { /** 思维链在流式结束后继续保留的时长。 */ private const val THINKING_LINGER_MILLIS = 10 * 60 * 1000L + +/** 分享提示在界面上停留的时长(它描述的是一次刚发生的动作,不是持续状态)。 */ +private const val SHARE_NOTICE_MILLIS = 8 * 1000L diff --git a/app/src/test/kotlin/top/yeij/cyrene/domain/share/PendingShareHolderTest.kt b/app/src/test/kotlin/top/yeij/cyrene/domain/share/PendingShareHolderTest.kt new file mode 100644 index 0000000..74ee12d --- /dev/null +++ b/app/src/test/kotlin/top/yeij/cyrene/domain/share/PendingShareHolderTest.kt @@ -0,0 +1,81 @@ +package top.yeij.cyrene.domain.share + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +/** + * 待落地内容的持有者:**一条内容只能被消费一次**。 + * + * 这条不变量是「分享不会发两遍、也不会丢一条」的全部依据:消费它的地方有多个,而且都是会 + * 被重建的(Compose 的 `LaunchedEffect` 随重组 / 切会话重跑,Activity 重建, + * 每个会话各有一个 ChatViewModel 且都还活着)。 + */ +class PendingShareHolderTest { + + @Test + fun `an empty holder hands out nothing`() { + val holder = PendingShareHolder() + + assertNull(holder.take()) + assertEquals(emptyList(), holder.pending.value) + } + + @Test + fun `each put is taken exactly once in arrival order`() { + val holder = PendingShareHolder() + holder.put("第一条") + holder.put("第二条") + + assertEquals("第一条", holder.take()) + assertEquals("第二条", holder.take()) + assertNull(holder.take()) + assertEquals(emptyList(), holder.pending.value) + } + + @Test + fun `a later put is still visible after the earlier ones were taken`() { + val holder = PendingShareHolder() + holder.put("旧") + assertEquals("旧", holder.take()) + + holder.put("新") + + assertEquals(listOf("新"), holder.pending.value) + assertEquals("新", holder.take()) + } + + @Test + fun `two identical shares are not conflated`() { + // MutableStateFlow 按 equals 合并值:持有者若把「一条内容」直接当状态,两次一模一样的 + // 分享只会触发一次消费。队列形态天然免疫——出队后长度变了。 + val holder = PendingShareHolder() + holder.put("一样的") + holder.put("一样的") + + assertEquals(2, holder.pending.value.size) + assertEquals("一样的", holder.take()) + assertEquals("一样的", holder.take()) + assertNull(holder.take()) + } + + @Test + fun `concurrent takers never get the same item`() = runBlocking { + val holder = PendingShareHolder() + val items = (1..64).toList() + items.forEach(holder::put) + + val taken = items.indices + .map { async(Dispatchers.Default) { holder.take() } } + .awaitAll() + + // 恰好一次:没有重复(两条线程拿到同一条),也没有漏(某条没人拿到)。 + assertEquals(items.toSet(), taken.filterNotNull().toSet()) + assertEquals(items.size, taken.count { it != null }) + assertNull(holder.take()) + } +} diff --git a/app/src/test/kotlin/top/yeij/cyrene/domain/share/ShareExtractionTest.kt b/app/src/test/kotlin/top/yeij/cyrene/domain/share/ShareExtractionTest.kt new file mode 100644 index 0000000..8bfbb96 --- /dev/null +++ b/app/src/test/kotlin/top/yeij/cyrene/domain/share/ShareExtractionTest.kt @@ -0,0 +1,232 @@ +package top.yeij.cyrene.domain.share + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import top.yeij.cyrene.domain.chat.ChatSendRouting +import top.yeij.cyrene.domain.chat.SendChannel + +/** + * 分享入口的提取规则(纯函数)。 + * + * 这里钉住的三件事,每一件都对应一种「分享进来却什么都没发生」的真实失败形态: + * 富文本读成 `null`、多图只留一张、外部的路径字符串被当成文件名。 + */ +class ShareExtractionTest { + + // —— 文本 —— + + @Test + fun `a text share comes through verbatim`() { + val share = ShareRules.extract( + action = ShareRules.ACTION_SEND, + text = "帮我看一下这段话", + ) + + assertEquals("帮我看一下这段话", share.text) + assertTrue(share.images.isEmpty()) + assertEquals(0, share.droppedImages) + assertFalse(share.isEmpty) + } + + @Test + fun `a non string char sequence text share is read through`() { + // 分享链路里的 EXTRA_TEXT 常常是 Spanned(带样式的 HTML 富文本)——它本身不是 String, + // 按 String 读会直接拿到 null,整条分享就静默变成「空的」。 + val rich: CharSequence = StringBuilder("来自网页的标题").apply { insert(0, "") } + + val share = ShareRules.extract(action = ShareRules.ACTION_SEND, text = rich) + + assertEquals("来自网页的标题", share.text) + } + + @Test + fun `a whitespace only text counts as no text`() { + val share = ShareRules.extract(action = ShareRules.ACTION_SEND, text = " \n\t ") + + assertTrue(share.isEmpty) + } + + // —— 只有 MIME、没有内容 —— + + @Test + fun `a share with nothing in it extracts to nothing`() { + val share = ShareRules.extract(action = ShareRules.ACTION_SEND) + + assertTrue(share.isEmpty) + assertEquals(0, share.droppedImages) + } + + @Test + fun `an unrelated intent extracts to nothing`() { + val share = ShareRules.extract( + action = "android.intent.action.VIEW", + text = "不该被当成分享", + stream = "content://media/external/images/media/1", + ) + + assertTrue(share.isEmpty) + assertTrue(share.images.isEmpty()) + } + + // —— 图片 —— + + @Test + fun `a single image share keeps one uri`() { + val share = ShareRules.extract( + action = ShareRules.ACTION_SEND, + stream = "content://media/external/images/media/7", + ) + + assertEquals(listOf("content://media/external/images/media/7"), share.images) + assertFalse(share.isEmpty) + } + + @Test + fun `text plus image keeps both`() { + val share = ShareRules.extract( + action = ShareRules.ACTION_SEND, + text = "看这个", + stream = "content://media/external/images/media/7", + ) + + assertEquals("看这个", share.text) + assertEquals(listOf("content://media/external/images/media/7"), share.images) + } + + @Test + fun `a multiple image share keeps them in order`() { + val uris = (1..3).map { "content://media/external/images/media/$it" } + + val share = ShareRules.extract(action = ShareRules.ACTION_SEND_MULTIPLE, streams = uris) + + assertEquals(uris, share.images) + assertEquals(0, share.truncatedImages) + } + + @Test + fun `images beyond the cap are reported instead of silently dropped`() { + val uris = (1..7).map { "content://media/external/images/media/$it" } + + val share = ShareRules.extract(action = ShareRules.ACTION_SEND_MULTIPLE, streams = uris) + + assertEquals(ShareRules.MAX_IMAGES, share.images.size) + assertEquals(uris.take(ShareRules.MAX_IMAGES), share.images) + // 少发的 3 张必须能被界面讲出来 —— 这就是「不许静默丢」的可观测形态。 + assertEquals(3, share.truncatedImages) + assertEquals(3, share.droppedImages) + } + + @Test + fun `duplicate and blank uris are collapsed before the cap is applied`() { + val share = ShareRules.extract( + action = ShareRules.ACTION_SEND_MULTIPLE, + streams = listOf( + "content://media/external/images/media/1", + " ", + "content://media/external/images/media/1", + "content://media/external/images/media/2", + ), + ) + + assertEquals( + listOf( + "content://media/external/images/media/1", + "content://media/external/images/media/2", + ), + share.images, + ) + // 去重发生在截断之前:重复项不该把真图挤掉,也不该被算成「截掉了 2 张」。 + assertEquals(0, share.truncatedImages) + } + + @Test + fun `a single image share ignores a stream that only holds blank space`() { + val share = ShareRules.extract(action = ShareRules.ACTION_SEND, stream = " ") + + assertTrue(share.images.isEmpty()) + assertTrue(share.isEmpty) + } + + // —— ClipData 兜底 —— + + @Test + fun `clip data is used when extra stream is absent`() { + val share = ShareRules.extract( + action = ShareRules.ACTION_SEND_MULTIPLE, + clipUris = listOf( + "content://media/external/images/media/9", + "content://media/external/images/media/8", + ), + ) + + assertEquals( + listOf( + "content://media/external/images/media/9", + "content://media/external/images/media/8", + ), + share.images, + ) + } + + @Test + fun `extra stream wins over clip data so nothing is counted twice`() { + val share = ShareRules.extract( + action = ShareRules.ACTION_SEND, + stream = "content://media/external/images/media/1", + clipUris = listOf( + "content://media/external/images/media/1", + "content://media/external/images/media/2", + ), + ) + + assertEquals(listOf("content://media/external/images/media/1"), share.images) + } + + // —— 超大文本 —— + + @Test + fun `a long shared text is kept whole and left to the existing routing`() { + // 分享一段长文是常态(网页 / 文档)。本端**不截断**:WS 收不下的那部分由既有路由 + // 改走 REST,而不是把用户的正文剪掉。 + val long = "昔".repeat(ChatSendRouting.WS_TEXT_LIMIT_BYTES) + + val share = ShareRules.extract(action = ShareRules.ACTION_SEND, text = long) + + assertEquals(long.length, share.text.length) + assertEquals(SendChannel.Rest, ChatSendRouting.channelFor(share.text, hasImage = false)) + } + + // —— 落盘文件名 —— + + @Test + fun `the cache file name comes from our own token and a whitelisted extension`() { + val name = ShareRules.cacheFileName("3f2a", 1, "image/jpeg") + + assertEquals("share-3f2a-1.jpg", name) + } + + @Test + fun `an unknown mime falls back to img instead of guessing an extension`() { + assertEquals("share-3f2a-0.img", ShareRules.cacheFileName("3f2a", 0, null)) + assertEquals("share-3f2a-0.img", ShareRules.cacheFileName("3f2a", 0, "application/pdf")) + assertEquals("share-3f2a-0.png", ShareRules.cacheFileName("3f2a", 0, "image/png; charset=x")) + } + + @Test + fun `a hostile token cannot escape the cache directory`() { + // 外部 URI 的显示名 / 路径绝不参与文件名(这里连本端 token 都不信):'/' 与 '.' 都活不下来。 + val name = ShareRules.cacheFileName("../../etc/passwd", 0, "image/png") + + assertFalse(name.contains('/')) + assertFalse(name.contains("..")) + assertTrue(name.startsWith("share-")) + assertTrue(name.endsWith(".png")) + } + + @Test + fun `an empty token still yields a usable file name`() { + assertEquals("share-x-0.png", ShareRules.cacheFileName("", 0, "image/png")) + } +}