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:supportsRtl="true"
android:theme="@style/Theme.Cyrene"> android:theme="@style/Theme.Cyrene">
<!--
launchMode="singleTop":App 已经开着时从别的 App 分享过来,系统不再新建一个实例,
改成回调 onNewIntent(见 MainActivity)。不写它就会起第二个 Activity 实例、
两个实例各持一套 ViewModel / socket。
-->
<activity <activity
android:name=".app.MainActivity" android:name=".app.MainActivity"
android:exported="true" android:exported="true"
android:label="@string/app_name"
android:launchMode="singleTop"
android:windowSoftInputMode="adjustResize"> android:windowSoftInputMode="adjustResize">
<intent-filter> <intent-filter>
<action android:name="android.intent.action.MAIN" /> <action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" /> <category android:name="android.intent.category.LAUNCHER" />
</intent-filter> </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> </activity>
</application> </application>
@@ -1,16 +1,38 @@
package top.yeij.cyrene.app package top.yeij.cyrene.app
import android.content.Intent
import android.os.Bundle import android.os.Bundle
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge import androidx.activity.enableEdgeToEdge
class MainActivity : ComponentActivity() { class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
enableEdgeToEdge() enableEdgeToEdge()
super.onCreate(savedInstanceState) 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 val container = (application as CyreneApplication).container
setContent { setContent {
CyreneRoot(container) 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.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
@@ -41,8 +42,13 @@ import androidx.lifecycle.viewmodel.initializer
import androidx.lifecycle.viewmodel.viewModelFactory import androidx.lifecycle.viewmodel.viewModelFactory
import coil3.compose.AsyncImage import coil3.compose.AsyncImage
import kotlinx.coroutines.flow.distinctUntilChanged 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.AppContainer
import top.yeij.cyrene.app.ShareIntake
import top.yeij.cyrene.data.ws.WsState import top.yeij.cyrene.data.ws.WsState
import top.yeij.cyrene.domain.share.ShareRules
import top.yeij.cyrene.domain.share.SharedContent
import top.yukonga.miuix.kmp.basic.Button import top.yukonga.miuix.kmp.basic.Button
import top.yukonga.miuix.kmp.basic.Icon import top.yukonga.miuix.kmp.basic.Icon
import top.yukonga.miuix.kmp.basic.IconButton import top.yukonga.miuix.kmp.basic.IconButton
@@ -59,6 +65,9 @@ import top.yukonga.miuix.kmp.theme.MiuixTheme
/** 判定「贴着底部」的容差(px)。 */ /** 判定「贴着底部」的容差(px)。 */
private const val BOTTOM_TOLERANCE = 64 private const val BOTTOM_TOLERANCE = 64
/** 分享落地前等 WS 连接就绪的上限(毫秒):超时也照样发,让失败落在看得见的地方。 */
private const val SHARE_CONNECT_TIMEOUT_MILLIS = 3_000L
/** 输入栏下方预留的高度:避开屏幕圆角,同时承载忙碌提示。 */ /** 输入栏下方预留的高度:避开屏幕圆角,同时承载忙碌提示。 */
private val InputBottomSpacer = 22.dp private val InputBottomSpacer = 22.dp
@@ -82,6 +91,24 @@ fun ChatScreen(
ActivityResultContracts.PickVisualMedia(), ActivityResultContracts.PickVisualMedia(),
) { uri -> uri?.let { viewModel.onImagePicked(it.toString()) } } ) { 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 式):只有用户主动滚到别处时才停止跟随。 // 是否跟随底部(微信/QQ 式):只有用户主动滚到别处时才停止跟随。
// 只在「滚动结束」时更新,避免新消息插入导致的锚点位移把状态带偏。 // 只在「滚动结束」时更新,避免新消息插入导致的锚点位移把状态带偏。
var followBottom by remember { mutableStateOf(true) } var followBottom by remember { mutableStateOf(true) }
@@ -184,22 +211,36 @@ fun ChatScreen(
} }
} }
state.error?.let { message -> Column(
Text( modifier = Modifier
text = message, .align(Alignment.TopCenter)
style = MiuixTheme.textStyles.footnote1, .fillMaxWidth(),
color = MiuixTheme.colorScheme.error, ) {
modifier = Modifier state.error?.let { message ->
.align(Alignment.TopCenter) Banner(text = message, color = MiuixTheme.colorScheme.error)
.fillMaxWidth() }
.background(MiuixTheme.colorScheme.surfaceContainer) state.shareNotice?.let { message ->
.padding(12.dp), 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 @Composable
private fun InputBar( private fun InputBar(
value: String, value: String,
@@ -330,3 +371,22 @@ private fun busyLabel(state: ChatUiState): String = when {
state.queueDepth > 0 -> "已排队 ${state.queueDepth} 条" state.queueDepth > 0 -> "已排队 ${state.queueDepth} 条"
else -> "" 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.chat.SendChannel
import top.yeij.cyrene.domain.parse.ParsedBubble import top.yeij.cyrene.domain.parse.ParsedBubble
import top.yeij.cyrene.domain.parse.StreamingContentParser import top.yeij.cyrene.domain.parse.StreamingContentParser
import top.yeij.cyrene.domain.share.SharedContent
import java.util.UUID import java.util.UUID
const val MAIN_SESSION_ID = "web" const val MAIN_SESSION_ID = "web"
@@ -50,7 +51,7 @@ private const val DEVICE_ID = "voiceassist:cyrene:v1"
data class PendingMessage( data class PendingMessage(
val localId: String, val localId: String,
val text: String, val text: String,
/** 本次带的图片的**本地**预览(`content://` URI);null = 纯文本消息。 */ /** 本次带的图片的**本地可读** URI(`content://` 或 `file://`);null = 纯文本消息。 */
val imageUri: String? = null, val imageUri: String? = null,
) )
@@ -188,12 +189,19 @@ data class ChatUiState(
val pending: List<PendingMessage> = emptyList(), val pending: List<PendingMessage> = emptyList(),
val streaming: StreamingState? = null, val streaming: StreamingState? = null,
val draft: String = "", val draft: String = "",
/** 已选好、还没发出去的图片(本地 `content://` URI 字符串);null = 没有待发图片。 */ /** 已选好、还没发出去的图片(本地 URI 字符串);null = 没有待发图片。 */
val draftImage: String? = null, val draftImage: String? = null,
val loading: Boolean = true, val loading: Boolean = true,
val loadingOlder: Boolean = false, val loadingOlder: Boolean = false,
val hasMore: Boolean = true, val hasMore: Boolean = true,
val error: String? = null, val error: String? = null,
/**
* 「分享到 App」落地的如实告知(例如有几张图没能发送);null = 没有要说的。
*
* 与 [error] 分开:分享通常**成功**,只是被本端的接收上限截掉了一部分,那不是错误。
* 它到期自动消失([SHARE_NOTICE_MILLIS]),因为它描述的是刚刚发生的一次动作。
*/
val shareNotice: String? = null,
val connection: WsState = WsState.Idle, val connection: WsState = WsState.Idle,
/** 编排忙碌态:`idle`/`thinking`/`executing`/`subagent`;null = 空闲。 */ /** 编排忙碌态:`idle`/`thinking`/`executing`/`subagent`;null = 空闲。 */
val busyMode: String? = null, val busyMode: String? = null,
@@ -227,6 +235,9 @@ data class ChatUiState(
* 换 URL 再发(见 [deliverImage])——服务端把 `image` **原样落库**,内联的字节会在每一页 * 换 URL 再发(见 [deliverImage])——服务端把 `image` **原样落库**,内联的字节会在每一页
* 历史与每一次 `history` 回放里重传,上传是唯一能把这个成本买断的手段。 * 历史与每一次 `history` 回放里重传,上传是唯一能把这个成本买断的手段。
* *
* 上行的两个入口共用这一套实现:输入框的 [send],以及「分享到 App」的 [sendShared]
* (`ACTION_SEND` 进来的内容由 `app/ShareIntake` 拿着,等本会话在登录态下组合出来再落地)。
*
* 本 VM 只收**属于 [sessionId] 的帧**(见 [onEvent] 的会话闸)。服务端广播不按会话订阅, * 本 VM 只收**属于 [sessionId] 的帧**(见 [onEvent] 的会话闸)。服务端广播不按会话订阅,
* 且每个会话各有一个 VM(各自的 socket 都还活着),没有这道闸就会串台。 * 且每个会话各有一个 VM(各自的 socket 都还活着),没有这道闸就会串台。
*/ */
@@ -303,18 +314,62 @@ class ChatViewModel(
val text = snapshot.draft.trim() val text = snapshot.draft.trim()
val imageUri = snapshot.draftImage val imageUri = snapshot.draftImage
if (text.isEmpty() && imageUri == null) return 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 即幂等键:重试复用同一个值,服务端不会把它当成第二条消息。 // localId 即幂等键:重试复用同一个值,服务端不会把它当成第二条消息。
val localId = "local-${UUID.randomUUID()}" val localId = "local-${UUID.randomUUID()}"
_state.update { _state.update {
it.copy( it.copy(
pending = it.pending + PendingMessage(localId, text, imageUri), pending = it.pending + PendingMessage(localId, text, imageUri),
draft = "", draft = if (clearDraft) "" else it.draft,
draftImage = null, draftImage = if (clearDraft) null else it.draftImage,
error = null, error = null,
) )
} }
viewModelScope.launch { deliver(localId, text, imageUri) } deliver(localId, text, imageUri)
} }
private suspend fun deliver(localId: String, text: String, imageUri: String?) { 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 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"))
}
}