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