点一枚附件芯片此前完全没有反应。现在点它会开一个 App 内预览弹层,弹层里再给两条出口。
- FileProvider(authority = ${applicationId}.fileprovider,res/xml/file_paths.xml 只暴露
cacheDir/documents/):把 file:// 交给别的 App 在 API 24+ 会抛 FileUriExposedException,
必须走 content://;因此 cacheDir 下真正要交出去的那一层才被开出来。
- 新增 data/repository/DocumentCache.kt:缓存路径推导 / 命中判定 / 原子落盘是可单测的纯逻辑;
DocumentCacheStore 把「服务端按存储名下载」与「本地待发附件」两条来源汇成同一份文件 + 字节。
服务端存储名是内容寻址(sha256[:32]+ext)且读侧响应 immutable,所以命中即永不失效、命中时
一个请求都不发;先写 .part 再改名、失败删干净——半截文件被下一次「命中」当成完整内容,
是最坏的一种静默错误。
- 新增 domain/chat/DocumentPreview.kt(纯逻辑):截断(首 256 KB 或 5000 行,先到者为准;
字节切点不切坏多字节 UTF-8)、正文分块(200 行一块)、来源判定(服务端 vs 本地,只有一处)、
打开用的 MIME(只用 DTO 里字节嗅探所得的 mime,绝不按扩展名猜)、「没有 App 能开」的裁决与
文案、取字节失败的分类文案。
- 新增 ui/chat/DocumentPreviewDialog.kt:Compose Dialog + Miuix 内容。正文用 LazyColumn 分块渲染
(5000 行不塞进单个 Text),每块各自 SelectionContainer;加载态与失败态分明(失败给重试,
绝不渲染成空文件);截断明写「只显示前 N,完整内容请用…」,不静默截断;复用 DocumentChips
显示类型/体积/字数/状态,不另写一套标签;status 为 unreadable 不挡预览(那是模型读不到,
文件本体一直在)。
- ui/chat/MessageBubble.kt:芯片整枚可点(点击区是那枚芯片本身,不是里面的两行文字),
并带 contentDescription「查看附件 <name>」。
- ui/chat/ChatScreen.kt:预览状态 + 取字节 + SAF 保存 + ACTION_VIEW 打开。没有 App 能开时
如实告知并指路保存,绝不静默失败。
契约缺口(需登记 02):服务端按内容寻址落盘,DTO 里没有原始文件名,保存到下载目录只能用
<32hex>.<ext>——弹层里已把这一点写在界面上,没有替用户编一个假名字。
测试 567 → 610(+29 DocumentPreviewTest / +14 DocumentCacheTest),0 失败 0 错误。
71 lines
3.4 KiB
XML
71 lines
3.4 KiB
XML
<?xml version="1.0" encoding="utf-8"?>
|
||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||
|
||
<application
|
||
android:name=".app.CyreneApplication"
|
||
android:allowBackup="false"
|
||
android:icon="@mipmap/ic_launcher"
|
||
android:label="@string/app_name"
|
||
android:networkSecurityConfig="@xml/network_security_config"
|
||
android:roundIcon="@mipmap/ic_launcher_round"
|
||
android:supportsRtl="true"
|
||
android:theme="@style/Theme.Cyrene">
|
||
|
||
<!--
|
||
launchMode="singleTop":App 已经开着时从别的 App 分享过来,系统不再新建一个实例,
|
||
改成回调 onNewIntent(见 MainActivity)。不写它就会起第二个 Activity 实例、
|
||
两个实例各持一套 ViewModel / socket。
|
||
-->
|
||
<activity
|
||
android:name=".app.MainActivity"
|
||
android:exported="true"
|
||
android:label="@string/app_name"
|
||
android:launchMode="singleTop"
|
||
android:windowSoftInputMode="adjustResize">
|
||
<intent-filter>
|
||
<action android:name="android.intent.action.MAIN" />
|
||
<category android:name="android.intent.category.LAUNCHER" />
|
||
</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>
|
||
|
||
<!--
|
||
附件预览的「用其它应用打开 / 保存」出口:`cacheDir/documents/` 下的文件要交给
|
||
系统里别的 App 读,就必须是 content:// 而不是 file://——API 24 起把 file:// 交出去
|
||
会直接抛 FileUriExposedException(且 StrictMode 下同样致命)。
|
||
路径范围见 res/xml/file_paths.xml:只暴露 cacheDir/documents/ 这一层,
|
||
exported="false" + 按次授权(grantUriPermissions)保证这份缓存不对外常开。
|
||
-->
|
||
<provider
|
||
android:name="androidx.core.content.FileProvider"
|
||
android:authorities="${applicationId}.fileprovider"
|
||
android:exported="false"
|
||
android:grantUriPermissions="true">
|
||
<meta-data
|
||
android:name="android.support.FILE_PROVIDER_PATHS"
|
||
android:resource="@xml/file_paths" />
|
||
</provider>
|
||
|
||
</application>
|
||
|
||
</manifest>
|