feat(share): 接入系统分享入口 —— 分享文本 / 图片进会话(未登录先暂存,登录后落地)

主人 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)。
This commit is contained in:
AskaEth committed 2026-10-04 18:05:27 +08:00
1 parent 664fd8dcef
commit ae4e4e53f6
9 files changed
+821 -16

No files matched your search

+27
View File
@@ -11,14 +11,41 @@
android:supportsRtl="true"
android:theme="@style/Theme.Cyrene">
<!--
launchMode="singleTop":App 已经开着时从别的 App 分享过来,系统不再新建一个实例,
改成回调 onNewIntent(见 MainActivity)。不写它就会起第二个 Activity 实例、
两个实例各持一套 ViewModel / socket。
-->
<activity
android:name=".app.MainActivity"
android:exported="true"
android:label="@string/app_name"
android:launchMode="singleTop"
android:windowSoftInputMode="adjustResize">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<!--
分享入口:只声明本端真能落地的两类 MIME(纯文本 / 图片)。
刻意不声明 */*——对话路径上今天没有「文件」这个表示(协同文档 02/F1 的三缺),
声明了就等于给出一个点进去必然失败的入口。标签沿用应用名(活动不设自己的
label / icon 时,分享菜单取的就是 Cyrene 的名字与启动图标)。
-->
<intent-filter>
<action android:name="android.intent.action.SEND" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="text/plain" />
<data android:mimeType="image/*" />
</intent-filter>
<!-- 多图分享只收图片:多条纯文本的 EXTRA_TEXT(SEND_MULTIPLE + text/plain)本端不接。 -->
<intent-filter>
<action android:name="android.intent.action.SEND_MULTIPLE" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="image/*" />
</intent-filter>
</activity>
</application>
@@ -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)
}
}
@@ -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<SharedContent>()
/** 待落地的分享内容;消费侧(ChatScreen)订阅它。 */
val pending: StateFlow<List<SharedContent>> = 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<String> =
getParcelableArrayListExtra(Intent.EXTRA_STREAM, Uri::class.java).orEmpty().map { it.toString() }
/** `ClipData` 里的 URI(部分 App 只填它);纯文本条目的 URI 为空,天然被过滤掉。 */
private fun Intent.clipUris(): List<String> {
val clip = clipData ?: return emptyList()
return (0 until clip.itemCount).mapNotNull { clip.getItemAt(it).uri?.toString() }
}
}
@@ -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<T> {
private val state = MutableStateFlow<List<T>>(emptyList())
/** 还没落地的内容(按收到顺序);空列表 = 没有待办。 */
val pending: StateFlow<List<T>> = state.asStateFlow()
/** 追加一条(后到的排在后面:先分享的先落地)。 */
fun put(value: T) = state.update { it + value }
/**
* 取走队首并出队;队列为空时回 `null`。
*
* `getAndUpdate` 的 CAS 循环保证「取到的队首」就是**本次转换**的旧值:两个线程同时调用,
* 一个拿到第一条、另一个拿到第二条,不会双双拿到同一条,也不会漏掉一条。
*/
fun take(): T? = state.getAndUpdate { it.drop(1) }.firstOrNull()
}
@@ -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<String> = 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<String>? = null,
clipUris: List<String> = 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"
}
}
@@ -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(";")
}
@@ -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<PendingMessage> = 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
@@ -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<String>()
assertNull(holder.take())
assertEquals(emptyList<String>(), holder.pending.value)
}
@Test
fun `each put is taken exactly once in arrival order`() {
val holder = PendingShareHolder<String>()
holder.put("第一条")
holder.put("第二条")
assertEquals("第一条", holder.take())
assertEquals("第二条", holder.take())
assertNull(holder.take())
assertEquals(emptyList<String>(), holder.pending.value)
}
@Test
fun `a later put is still visible after the earlier ones were taken`() {
val holder = PendingShareHolder<String>()
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<String>()
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<Int>()
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())
}
}
@@ -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"))
}
}