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

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