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

@@ -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