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:
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"))
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user