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"))
+ }
+}