refactor(file): consolidate file capabilities into core

这个提交包含在:
XuqmGroup 2026-07-28 20:52:34 +08:00
父节点 8610ea5745
当前提交 b34b88e29e
共有 33 个文件被更改,包括 236 次插入369 次删除

2
Jenkinsfile vendored
查看文件

@ -12,7 +12,6 @@ pipeline {
// 要构建并发布的模块(勾选即发布)
booleanParam(name: 'MOD_CORE', defaultValue: false, description: '发布 sdk-core')
booleanParam(name: 'MOD_FILE', defaultValue: false, description: '发布 sdk-file')
booleanParam(name: 'MOD_IM', defaultValue: false, description: '发布 sdk-im')
booleanParam(name: 'MOD_PUSH', defaultValue: false, description: '发布 sdk-push')
booleanParam(name: 'MOD_UPDATE', defaultValue: false, description: '发布 sdk-update')
@ -37,7 +36,6 @@ pipeline {
def moduleChecks = [
'sdk-common-plugin': params.MOD_CORE,
'sdk-core': params.MOD_CORE,
'sdk-file': params.MOD_FILE,
'sdk-im': params.MOD_IM,
'sdk-push': params.MOD_PUSH,
'sdk-update': params.MOD_UPDATE,

查看文件

@ -7,7 +7,6 @@
```
XuqmGroup-AndroidSDK/
├── sdk-core/ # 核心:初始化、基础 HTTP、本地文件、时间、安全存储
├── sdk-file/ # 平台文件上传与 FileProvider 打开能力
├── sdk-bugcollect/ # 日志监控错误上报、Crash 捕获、漏斗分析(含 Gradle Plugin
├── sdk-im/ # IMWebSocket 实时通信
├── sdk-push/ # 推送:设备 Token 注册
@ -61,7 +60,6 @@ NEXUS_PASSWORD=your_password
```kotlin
dependencies {
implementation("com.xuqm:sdk-core:VERSION")
implementation("com.xuqm:sdk-file:VERSION") // 平台文件能力可选
implementation("com.xuqm:sdk-bugcollect:VERSION") // 可选
implementation("com.xuqm:sdk-im:VERSION") // 可选
implementation("com.xuqm:sdk-push:VERSION") // 可选
@ -79,8 +77,9 @@ dependencies {
编写初始化代码,也不得修改、复制、自行生成或在 flavor/buildType 下放置第二份文件。
配置中的 `serverUrl` 是唯一平台地址且必填,运行时没有默认地址或跨平台回退。
仅依赖 `sdk-core` 时不会合并初始化 Provider、通知权限或 FileProvider,也不要求配置
文件、初始化或登录。引入任一需要平台能力的扩展后,各 AAR 中同名声明会合并为唯一
仅依赖 `sdk-core` 时不会合并初始化 Provider或通知权限,也不要求配置文件、初始化或
登录;core 只注册安全打开/分享本地文件所需的专用 FileProvider。引入任一需要平台
能力的扩展后,各 AAR 中同名声明会合并为唯一
`XuqmMergedProvider`;Provider 只有检测到 common 构建插件生成的标记后才会自动读取
上述配置,避免未应用插件的宿主发生隐式初始化。
@ -170,26 +169,26 @@ XuqmSDK.tokenStore.clear()
### 网络边界
`sdk-core``CommonHttpClient` 与下载/摘要能力无需 SDK 初始化;依赖租户平台认证、
服务地址和 FileProvider 的上传、打开能力统一由 `sdk-file``PlatformFileSDK` 提供
`sdk-core``CommonHttpClient`、下载、摘要、打开和分享能力无需 SDK 初始化;
`FileSDK` 的上传入口位于同一 core,但只有上传会等待平台配置和登录态
### FileSDK
纯本地能力位于 `com.xuqm.sdk.file.FileSDK`;平台上传与 FileProvider 打开位于
`com.xuqm.sdk.file.platform.PlatformFileSDK`。
下载、摘要、平台上传与 FileProvider 安全打开统一由
`com.xuqm.sdk.file.FileSDK` 提供
#### 上传
```kotlin
// 从 Uri文件选择器返回值上传,自动解析文件名和 MIME 类型
val result: FileUploadResult = PlatformFileSDK.upload(
val result: FileUploadResult = FileSDK.upload(
context = context,
uri = uri,
onProgress = { progress -> /* 0–100 */ },
)
// 直接上传字节数组(如相机拍照后的 ByteArray
val result = PlatformFileSDK.uploadBytes(
val result = FileSDK.uploadBytes(
fileName = "photo.jpg",
mimeType = "image/jpeg",
bytes = byteArray,
@ -197,12 +196,12 @@ val result = PlatformFileSDK.uploadBytes(
)
// 上传 File 对象
val result = PlatformFileSDK.upload(file = file)
val result = FileSDK.upload(file = file)
```
`FileUploadResult` 字段:`url`、`thumbnailUrl`、`hash`、`size`、`originalName`、`mimeType`、`ext`
。上传入口会自动等待平台初始化;服务端业务失败或响应结构错误统一抛出保留
`code/status/message``PlatformFileException`,不会暴露原始响应体。
`code/status/message``FileUploadException`,不会暴露原始响应体。
#### 下载
@ -230,14 +229,14 @@ val file: File = FileSDK.download(
```kotlin
// 用系统应用打开本地文件(通过 FileProvider + ACTION_VIEW
PlatformFileSDK.openFile(context, file)
FileSDK.openFile(context, file)
```
`openFile` 是纯本地操作,不要求 SDK 初始化。
`sdk-file` 是该 FileProvider 的唯一所有者;`sdk-update`、`sdk-im` 和 `sdk-webview`
均复用它。只集成 `sdk-core` 不会向宿主注入 Provider,也不存在 core 内的上传或打开
转发 API
`sdk-core` 是该 FileProvider、文件下载、上传、打开和分享能力的唯一所有者;
`sdk-update`、`sdk-im` 和 `sdk-webview` 均直接复用 core,不再发布独立 `sdk-file`
制品
---
@ -411,7 +410,7 @@ WebView 内 `<input type="file">` 和 `<input type="file" capture>` 均已内置
### 下载拦截
注入的 JS 自动拦截以下两种场景,下载完成后调用 `PlatformFileSDK.openFile()` 打开文件:
注入的 JS 自动拦截以下两种场景,下载完成后调用 `FileSDK.openFile()` 打开文件:
- 带 `download` 属性的 `<a>` 标签,或链接以可下载扩展名(`.pdf`、`.zip`、`.docx` 等)结尾
- Blob URL自动转 base64 后传给 native 处理)

查看文件

@ -122,10 +122,9 @@ subprojects {
val verifySdkManifestOwnership by tasks.registering {
group = "verification"
description = "验证 common 无宿主 Manifest 副作用,扩展能力各自声明所需组件。"
description = "验证 common 的文件共享 Provider 与扩展初始化组件归属。"
dependsOn(
":sdk-core:processDebugManifest",
":sdk-file:processDebugManifest",
":sdk-update:processDebugManifest",
":sdk-push:processDebugManifest",
":sdk-im:processDebugManifest",
@ -140,17 +139,13 @@ val verifySdkManifestOwnership by tasks.registering {
val core = mergedManifest("sdk-core")
check("XuqmMergedProvider" !in core)
check("POST_NOTIFICATIONS" !in core)
check("com.xuqm.sdk.file.XuqmFileProvider" in core)
check("androidx.core.content.FileProvider" !in core)
val update = mergedManifest("sdk-update")
check("XuqmMergedProvider" in update)
check("androidx.core.content.FileProvider" !in update)
val file = mergedManifest("sdk-file")
check("XuqmMergedProvider" in file)
check("com.xuqm.sdk.file.platform.XuqmFileProvider" in file)
check("androidx.core.content.FileProvider" !in file)
val push = mergedManifest("sdk-push")
check("XuqmMergedProvider" in push)
check("POST_NOTIFICATIONS" in push)
@ -172,7 +167,7 @@ val verifySampleManifestMerge by tasks.registering {
).readText()
check(Regex("com\\.xuqm\\.sdk\\.internal\\.XuqmMergedProvider")
.findAll(manifest).count() == 1)
check(Regex("com\\.xuqm\\.sdk\\.file\\.platform\\.XuqmFileProvider")
check(Regex("com\\.xuqm\\.sdk\\.file\\.XuqmFileProvider")
.findAll(manifest).count() == 1)
check(Regex("com\\.xuqm\\.common_plugin_applied")
.findAll(manifest).count() == 1)

查看文件

@ -49,26 +49,26 @@ Maven 发布尚未执行。
:sdk-bugcollect:assembleRelease` 已通过。服务端默认每个 appKey 每分钟最多接收
6000 个事件,实际部署可通过环境变量调整。
## 2026-07-28 文件能力归并终态
- 删除独立 `sdk-file` 模块、版本参数、Jenkins 发布选项和文档入口,不保留空目录或
兼容制品。
- `sdk-core` 成为下载、摘要、MediaStore、平台上传、FileProvider 安全打开/分享的
唯一实现;本地能力和打开文件不要求初始化,只有平台上传等待统一初始化。
- `sdk-update`、`sdk-im`、`sdk-webview` 直接依赖 core。专用
`XuqmFileProvider` 由 core 唯一声明,仍与宿主自己的 FileProvider 隔离。
## 当前目标
- `sdk-core` 是配置、会话、网络、文件、时间等公共能力的唯一实现。
- `sdk-update` 只提供更新领域 API;宿主与 RN 桥接不复制下载、校验和安装代码。
- APK 更新必须支持 SHA-256、原子临时文件、进度、取消、有限重试、FileProvider、未知来源安装权限及确定错误码。
## 2026-07-27 sdk-file 与宿主 FileProvider 隔离
- `sdk-file` 不再直接把 `androidx.core.content.FileProvider` 注册进宿主 Manifest,
改为注册唯一的 `XuqmFileProvider` 子类。
- Android Manifest 可能按 Provider 类名合并节点;宿主已有 `FileProvider` 时,
不同 authority 会在合并阶段冲突。
- authority 和公开文件 API 保持不变,宿主不需要 `tools:replace` 或新增 Manifest
配置。
## 2026-07-17 已完成
- `FileSDK` 新增详细下载进度、SHA-256 与哈希匹配公共 API。
- 下载改为 `.part` 临时文件完成后替换目标文件;HTTP 非 2xx 失败,协程取消会中断连接并清理临时文件。
- 当时的 core FileProvider 归属已被后续终态替代:当前`sdk-file` 声明,core 零 Manifest
- 当时的 FileProvider 归属已被后续终态替代:当前由 core 唯一声明。
- `sdk-update` 删除无版本 APK 兼容路径和弃用下载入口。
- `sdk-update` 2.0.0-SNAPSHOT 强制要求合法 SHA-256,并提供下载重试、取消、缓存文件复验和确定错误码。
- 增加 Java/RN 可直接使用的 `startDownloadAndInstall`、`UpdateInstallTask`、进度/结果回调和安装权限设置 Intent。
@ -138,15 +138,15 @@ Maven 发布尚未执行。
`POST /api/sdk/build/config/validate`,提交原始签名配置和最终 applicationId。服务不可达、
配置撤销、过期或包名不匹配都会阻止打包;应急关闭 BugCollect 也不能绕过配置校验。
- 删除不再参与 Manifest 的 `BugCollectInitProvider`,避免扩展模块平行初始化。
- 删除公开手动初始化与旧 Provider 兼容类。`sdk-core` 单独集成不注册 Provider
FileProvider 或通知权限;需要平台能力的扩展统一合并同一个 `XuqmMergedProvider`
FileProvider 归唯一 `sdk-file`,通知权限归 `sdk-push`
- 删除公开手动初始化与旧 Provider 兼容类。`sdk-core` 单独集成不注册初始化 Provider
或通知权限;需要平台能力的扩展统一合并同一个 `XuqmMergedProvider`文件 Provider
归唯一 core,通知权限归 `sdk-push`
- Sample 删除硬编码 appKey,只接受被 Git 忽略的租户平台签发配置;缺少文件时 Debug/
Release 构建门禁给出明确错误。Release 签名只从用户级 Gradle 属性读取。
- 敏感存储收敛为唯一 `SecureStore`Android Keystore 不可导出 AES-256-GCM 密钥,
namespace/key 作为 AAD;删除废弃 security-crypto 依赖。
- `sdk-webview/XWebViewView.kt` 的既有未提交修改始终作为用户基线保留;本轮只在该基线
上将打开文件能力切换到唯一 `PlatformFileSDK`,未覆盖其它改动。
上将打开文件能力切换到唯一 `FileSDK`,未覆盖其它改动。
本轮最终已验证:
@ -203,16 +203,16 @@ Sample 源码编译时只为静态验证跳过真实签发文件的存在性门
mapping 上传;mapping 仍保留为本地产物。Debug 只关闭自动采集,可调试宿主在构建未
禁用、隐私同意、平台启用时可通过隐藏开发入口显式发送一次测试事件。
- common 插件按变体生成唯一 Manifest 标记、buildId 与 BugCollect 构建开关;扩展 AAR
的合并 Provider 未检测到该标记时只退出,不执行初始化,core-only 仍保持零 Manifest
的合并 Provider 未检测到该标记时只退出,不执行初始化,core-only 不触发初始化
- Update 先服从平台 `features.update`,相同用户重复同步不会再次补检;下载与安装授权
绑定当前 generation 和 userId,切换或登出会取消活动下载、清除授权并阻止旧灰度结果安装。
- BugCollect 队列 flush 使用 Mutex 单飞,定时与满批触发不会重复上传同一批数据。
- 内部 BugCollect 状态桥接改为私有反射 SPI,不进入 Java/Kotlin 公开 API。
- `sdk-core` 保持零 Manifest,只保留无需初始化的纯本地文件能力。新增唯一 `sdk-file`
扩展承载租户平台上传与 FileProvider 打开;IM、Update、WebView 复用该扩展
- `sdk-file`Uri、File、ByteArray 三个上传入口统一先等待平台初始化,再创建请求体
- `sdk-core` 统一承载本地文件、租户平台上传与 FileProvider 打开;IM、Update、WebView
直接复用 core,不存在第二个文件模块
- Uri、File、ByteArray 三个上传入口统一先等待平台初始化,再创建请求体
和读取服务端点;平台失败统一抛出保留 `code/status/message` 且不暴露响应体的
`PlatformFileException`。`openFile` 仍为不依赖初始化的纯本地能力。
`FileUploadException`。`openFile` 仍为不依赖初始化的纯本地能力。
- 全仓日志、标准输出和异常文案不再输出 token、userId、groupId、消息目标、H5
payload、请求/响应正文、文件 Uri/绝对路径等实际值。根任务
`verifyNoSensitiveLogging` 在各模块 `testDebugUnitTest`、`lintRelease` 和 `check`

查看文件

@ -6,10 +6,10 @@
宿主 App
└── 放置租户平台签发的 assets/config/config.xuqmconfig
sdk-core核心层;单独集成时零初始化、零权限、零 Provider
sdk-core核心层;单独集成时零初始化,文件 Provider 仅用于安全打开/分享
├── XuqmMergedProvider 实现(仅由扩展 Manifest 注册)
├── 初始化状态IDLE / INITIALIZING / READY / DEGRADED / FAILED
├── 七天加密 LKG仅平台暂不可用时降级使用
├── 严格非敏感 LKG仅平台暂不可用时降级使用
├── XuqmSDK.setUserInfo() → 通知所有子模块(反射 / 接口回调)
├── ApiClientOkHttp 4.12 + Retrofit 3
├── TokenStoreAndroid Keystore + AES-256-GCM

查看文件

@ -4,7 +4,6 @@ kotlin.code.style=official
android.nonTransitiveRClass=true
PUBLISH_VERSION=1.0.5
SDK_CORE_VERSION=1.1.6-SNAPSHOT
SDK_FILE_VERSION=1.0.0-SNAPSHOT
SDK_IM_VERSION=1.1.3
SDK_PUSH_VERSION=1.1.4-SNAPSHOT
SDK_UPDATE_VERSION=2.0.0-SNAPSHOT

查看文件

@ -89,7 +89,6 @@ tasks.matching { it.name == "preReleaseBuild" }.configureEach {
dependencies {
coreLibraryDesugaring(libs.desugar.jdk.libs)
implementation(project(":sdk-core"))
implementation(project(":sdk-file"))
implementation(project(":sdk-bugcollect"))
implementation(project(":sdk-im"))
implementation(project(":sdk-push"))

查看文件

@ -68,7 +68,6 @@ import com.xuqm.sdk.ui.InitialAvatar
import com.xuqm.sdk.ui.SearchBarField
import coil3.compose.AsyncImage
import androidx.compose.ui.platform.LocalContext
import androidx.core.content.FileProvider
import com.xuqm.sdk.file.FileSDK
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.launch
@ -539,11 +538,7 @@ private fun createCaptureUri(
): Uri {
val dir = context.getExternalFilesDir(directoryType) ?: context.filesDir
val file = File(dir, "$prefix$suffix")
return FileProvider.getUriForFile(
context,
"${context.packageName}.xuqm.fileprovider",
file,
)
return FileSDK.uriForFile(context, file)
}
@Composable

查看文件

@ -23,9 +23,9 @@ Ed25519 签名,再解密引导配置。V1、未知 keyId、签名错误和包
`serverUrl` 是签发配置中的唯一平台地址且必填,运行时没有默认地址或跨平台回退。
配置只包含公开应用身份与平台地址,不包含 `appSecret` 或其它客户端长期认证秘密。
只依赖 `sdk-core` 时不会注册初始化 Provider、申请通知权限或合并 FileProvider,
基础 HTTP、下载、摘要、MediaStore、时间和安全存储能力无需初始化或登录。引入扩展 SDK 后才由扩展
Manifest 合并唯一初始化 Provider。
只依赖 `sdk-core` 时不会注册初始化 Provider 或申请通知权限,只合并安全打开/分享
本地文件所需的专用 FileProvider。基础 HTTP、下载、摘要、MediaStore、时间和安全存储
能力无需初始化或登录;引入扩展 SDK 后才由扩展 Manifest 合并唯一初始化 Provider。
## API
@ -74,9 +74,9 @@ XuqmSDK.tokenStore.clear()
### FileSDK
`FileSDK` 只保留无需初始化的本地下载、摘要与 MediaStore 能力。
依赖租户平台的上传和依赖 FileProvider 的打开文件能力属于独立 `sdk-file`
统一通过 `PlatformFileSDK` 调用
`FileSDK` 提供无需初始化的本地下载、摘要与 MediaStore 能力;
同一个 `FileSDK` 还提供上传和 FileProvider 安全打开能力。只有上传会等待平台配置
打开本地文件不要求初始化
```kotlin
// 下载
@ -87,5 +87,5 @@ val file = FileSDK.download(context, url, fileName, destination, notificationTit
`NetworkMonitor` 不要求 SDK 初始化。它只申请 `ACCESS_NETWORK_STATE`,不会为了细分
蜂窝网络代际申请电话或定位权限;系统不允许读取时稳定返回 `CELLULAR`
平台配置成功后以加密方式保存七天 LKG。平台暂不可用且 LKG 有效时进入
`DEGRADED`;没有可用缓存时扩展能力返回结构化错误,宿主核心业务不受影响。
平台配置成功后只保存严格、版本化且不含秘密的最后成功配置。平台暂不可用且缓存结构
有效时进入 `DEGRADED`;没有可用缓存时扩展能力返回结构化错误,宿主核心业务不受影响。

查看文件

@ -11,6 +11,11 @@
# ── File SDK ──────────────────────────────────────────────────────────────────
-keep class com.xuqm.sdk.file.FileSDK { *; }
-keep class com.xuqm.sdk.file.FileUploadResult { *; }
-keep class com.xuqm.sdk.file.FileUploadException { *; }
-keepclassmembers class com.xuqm.sdk.file.internal.FileApiResponse {
<fields>;
}
# ── Gson: preserve field names on all SDK model classes ──────────────────────
-keepclassmembers class com.xuqm.sdk.** {

查看文件

@ -5,4 +5,15 @@
<uses-permission
android:name="android.permission.WRITE_EXTERNAL_STORAGE"
android:maxSdkVersion="28" />
<application>
<provider
android:name="com.xuqm.sdk.file.XuqmFileProvider"
android:authorities="${applicationId}.xuqm.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/xuqm_file_paths" />
</provider>
</application>
</manifest>

查看文件

@ -17,8 +17,17 @@ import android.webkit.MimeTypeMap
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.core.content.ContextCompat
import androidx.core.content.FileProvider
import com.xuqm.sdk.XuqmSDK
import com.xuqm.sdk.core.ServiceEndpointRegistry
import com.xuqm.sdk.file.internal.FileApi
import com.xuqm.sdk.file.internal.FileUploadGateway
import com.xuqm.sdk.file.internal.FileUploadParts
import com.xuqm.sdk.file.internal.UploadRequestBodies
import com.xuqm.sdk.network.ApiClient
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.MultipartBody
import java.io.File
import java.security.MessageDigest
@ -39,6 +48,97 @@ data class FileDownloadProgress(
}
object FileSDK {
private val uploadGateway = FileUploadGateway(
awaitInitialization = XuqmSDK::awaitInitialization,
apiProvider = {
ApiClient.create(FileApi::class.java, ServiceEndpointRegistry.fileBaseUrl)
},
)
/** 从系统 Uri 流式上传;只有上传能力会等待平台统一初始化。 */
suspend fun upload(
context: Context,
uri: Uri,
displayName: String? = null,
mimeType: String? = null,
thumbnailBytes: ByteArray? = null,
onProgress: (Int) -> Unit = {},
): FileUploadResult = uploadGateway.upload {
val name = displayName?.takeIf(String::isNotBlank)
?: UploadRequestBodies.resolveDisplayName(context, uri)
val type = mimeType?.takeIf(String::isNotBlank)
?: context.contentResolver.getType(uri)
FileUploadParts(
fileName = name,
file = MultipartBody.Part.createFormData(
"file",
name,
UploadRequestBodies.createUri(context, uri, type, onProgress),
),
thumbnailBytes = thumbnailBytes,
)
}
/** 从文件流式上传,不把文件整体读入内存。 */
suspend fun upload(
file: File,
thumbnailBytes: ByteArray? = null,
onProgress: (Int) -> Unit = {},
): FileUploadResult = uploadGateway.upload {
FileUploadParts(
fileName = file.name,
file = MultipartBody.Part.createFormData(
"file",
file.name,
UploadRequestBodies.createFile(
file,
java.net.URLConnection.guessContentTypeFromName(file.name),
onProgress,
),
),
thumbnailBytes = thumbnailBytes,
)
}
suspend fun uploadBytes(
fileName: String,
mimeType: String?,
bytes: ByteArray,
thumbnailBytes: ByteArray? = null,
onProgress: (Int) -> Unit = {},
): FileUploadResult = uploadGateway.upload {
FileUploadParts(
fileName = fileName,
file = MultipartBody.Part.createFormData(
"file",
fileName,
UploadRequestBodies.createBytes(mimeType, bytes, onProgress),
),
thumbnailBytes = thumbnailBytes,
)
}
/** 为相机、分享和安装等系统 Intent 生成统一的只读内容 Uri。 */
fun uriForFile(context: Context, file: File): Uri = FileProvider.getUriForFile(
context,
"${context.packageName}.xuqm.fileprovider",
file,
)
/** 使用 core 唯一 FileProvider 安全打开本地文件;不要求 SDK 初始化。 */
fun openFile(context: Context, file: File) {
val mimeType = MimeTypeMap.getSingleton()
.getMimeTypeFromExtension(file.extension.lowercase())
?: "application/octet-stream"
val uri = uriForFile(context, file)
val view = Intent(Intent.ACTION_VIEW).apply {
setDataAndType(uri, mimeType)
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
context.startActivity(Intent.createChooser(view, null).apply {
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
})
}
suspend fun downloadToFile(
downloadUrl: String,
@ -350,7 +450,7 @@ object FileSDK {
/**
* 通过 MediaStore 将文件写入公共 Downloads 目录
* 在华为等设备上直接文件 I/O 会失败MediaStore 通过 ContentResolver 操作兼容性更好
* 返回缓存目录中的副本便于调用方继续展示分享或交给可选 sdk-file 打开
* 返回缓存目录中的副本便于调用方继续展示分享或通过本对象打开
*/
@androidx.annotation.RequiresApi(Build.VERSION_CODES.Q)
private fun saveBlobViaMediaStore(context: Context, bytes: ByteArray, fileName: String): File {

查看文件

@ -1,4 +1,4 @@
package com.xuqm.sdk.file.platform
package com.xuqm.sdk.file
/**
* 平台文件服务返回的结构化业务错误
@ -6,7 +6,7 @@ package com.xuqm.sdk.file.platform
* [code][status] [message] 均来自服务端响应HTTP 层无法解析出业务响应时
* code 使用 HTTP 状态码status 使用 `HTTP_<状态码>`异常不会携带或输出原始响应体
*/
class PlatformFileException(
class FileUploadException(
val code: Int,
val status: String,
message: String,

查看文件

@ -0,0 +1,11 @@
package com.xuqm.sdk.file
data class FileUploadResult(
val url: String,
val thumbnailUrl: String? = null,
val hash: String,
val size: Long,
val originalName: String? = null,
val mimeType: String? = null,
val ext: String? = null,
)

查看文件

@ -1,4 +1,4 @@
package com.xuqm.sdk.file.platform
package com.xuqm.sdk.file
import androidx.core.content.FileProvider

查看文件

@ -1,5 +1,7 @@
package com.xuqm.sdk.file.platform
package com.xuqm.sdk.file.internal
import com.xuqm.sdk.file.FileUploadException
import com.xuqm.sdk.file.FileUploadResult
import com.google.gson.JsonParser
import okhttp3.MediaType.Companion.toMediaTypeOrNull
import okhttp3.MultipartBody
@ -9,23 +11,23 @@ import retrofit2.http.Multipart
import retrofit2.http.POST
import retrofit2.http.Part
internal data class PlatformFileApiResponse<T>(
internal data class FileApiResponse<T>(
val code: Int? = null,
val status: String? = null,
val data: T? = null,
val message: String? = null,
)
internal interface PlatformFileApi {
internal interface FileApi {
@Multipart
@POST("api/file/upload")
suspend fun upload(
@Part file: MultipartBody.Part,
@Part thumbnail: MultipartBody.Part? = null,
): PlatformFileApiResponse<FileUploadResult>
): FileApiResponse<FileUploadResult>
}
internal data class PlatformFileUploadParts(
internal data class FileUploadParts(
val fileName: String,
val file: MultipartBody.Part,
val thumbnailBytes: ByteArray?,
@ -37,11 +39,11 @@ internal data class PlatformFileUploadParts(
* 这样 Uri/File/ByteArray 三个公开入口不会在 Provider 异步初始化期间抢先读取端点
* 也不会在初始化失败时提前访问 Uri 或文件
*/
internal class PlatformFileUploadGateway(
internal class FileUploadGateway(
private val awaitInitialization: suspend () -> Unit,
private val apiProvider: () -> PlatformFileApi,
private val apiProvider: () -> FileApi,
) {
suspend fun upload(createParts: () -> PlatformFileUploadParts): FileUploadResult {
suspend fun upload(createParts: () -> FileUploadParts): FileUploadResult {
awaitInitialization()
val parts = createParts()
val thumbnail = parts.thumbnailBytes?.takeIf(ByteArray::isNotEmpty)?.let {
@ -54,24 +56,24 @@ internal class PlatformFileUploadGateway(
val response = try {
apiProvider().upload(parts.file, thumbnail)
} catch (error: HttpException) {
throw error.toPlatformFileException()
throw error.toFileUploadException()
}
return response.requireUploadResult()
}
}
internal fun PlatformFileApiResponse<FileUploadResult>.requireUploadResult(): FileUploadResult {
internal fun FileApiResponse<FileUploadResult>.requireUploadResult(): FileUploadResult {
val responseCode = code ?: UNKNOWN_CODE
val responseStatus = status.orEmpty()
val responseMessage = message?.takeIf(String::isNotBlank)
?: "平台文件服务返回无效响应"
if (responseCode != SUCCESS_CODE || responseStatus != SUCCESS_STATUS) {
throw PlatformFileException(responseCode, responseStatus, responseMessage)
throw FileUploadException(responseCode, responseStatus, responseMessage)
}
return data ?: throw PlatformFileException(responseCode, responseStatus, responseMessage)
return data ?: throw FileUploadException(responseCode, responseStatus, responseMessage)
}
private fun HttpException.toPlatformFileException(): PlatformFileException {
private fun HttpException.toFileUploadException(): FileUploadException {
val httpCode = code()
val parsed = runCatching {
val body = response()?.errorBody()?.string().orEmpty()
@ -88,7 +90,7 @@ private fun HttpException.toPlatformFileException(): PlatformFileException {
?.takeIf(String::isNotBlank)
Triple(code, status, message)
}.getOrNull()
return PlatformFileException(
return FileUploadException(
code = parsed?.first ?: httpCode,
status = parsed?.second ?: "HTTP_$httpCode",
message = parsed?.third ?: "平台文件服务请求失败HTTP $httpCode",

查看文件

@ -1,4 +1,4 @@
package com.xuqm.sdk.file.platform.internal
package com.xuqm.sdk.file.internal
import android.content.Context
import android.net.Uri

查看文件

@ -1,5 +1,7 @@
package com.xuqm.sdk.file.platform
package com.xuqm.sdk.file.internal
import com.xuqm.sdk.file.FileUploadException
import com.xuqm.sdk.file.FileUploadResult
import com.xuqm.sdk.XuqmErrorCode
import com.xuqm.sdk.XuqmSdkException
import kotlinx.coroutines.runBlocking
@ -14,12 +16,12 @@ import org.junit.Test
import retrofit2.HttpException
import retrofit2.Response
class PlatformFileUploadGatewayTest {
class FileUploadGatewayTest {
@Test
fun `successful envelope returns upload data`() {
val expected = uploadResult()
val actual = PlatformFileApiResponse(
val actual = FileApiResponse(
code = 200,
status = "0",
data = expected,
@ -31,8 +33,8 @@ class PlatformFileUploadGatewayTest {
@Test
fun `successful envelope without data remains structured error`() {
val error = expectPlatformFileException {
PlatformFileApiResponse<FileUploadResult>(
val error = expectFileUploadException {
FileApiResponse<FileUploadResult>(
code = 200,
status = "0",
data = null,
@ -47,8 +49,8 @@ class PlatformFileUploadGatewayTest {
@Test
fun `business failure preserves server code status and message`() {
val error = expectPlatformFileException {
PlatformFileApiResponse<FileUploadResult>(
val error = expectFileUploadException {
FileApiResponse<FileUploadResult>(
code = 403,
status = "1",
data = null,
@ -66,11 +68,11 @@ class PlatformFileUploadGatewayTest {
val expected = XuqmSdkException(XuqmErrorCode.XUQM_NOT_READY, "SDK 尚未初始化")
var partsCreated = false
var apiAccessed = false
val gateway = PlatformFileUploadGateway(
val gateway = FileUploadGateway(
awaitInitialization = { throw expected },
apiProvider = {
apiAccessed = true
fakeApi(PlatformFileApiResponse(200, "0", uploadResult(), "success"))
fakeApi(FileApiResponse(200, "0", uploadResult(), "success"))
},
)
@ -96,14 +98,14 @@ class PlatformFileUploadGatewayTest {
"debugSecret": "do-not-leak"
}
""".trimIndent().toResponseBody("application/json".toMediaType())
val gateway = PlatformFileUploadGateway(
val gateway = FileUploadGateway(
awaitInitialization = {},
apiProvider = {
object : PlatformFileApi {
object : FileApi {
override suspend fun upload(
file: MultipartBody.Part,
thumbnail: MultipartBody.Part?,
): PlatformFileApiResponse<FileUploadResult> {
): FileApiResponse<FileUploadResult> {
throw HttpException(
Response.error<FileUploadResult>(429, responseBody),
)
@ -113,8 +115,8 @@ class PlatformFileUploadGatewayTest {
)
val error = runCatching { gateway.upload(::uploadParts) }
.exceptionOrNull() as? PlatformFileException
?: error("Expected PlatformFileException")
.exceptionOrNull() as? FileUploadException
?: error("Expected FileUploadException")
assertEquals(42901, error.code)
assertEquals("1", error.status)
@ -122,20 +124,20 @@ class PlatformFileUploadGatewayTest {
assertFalse(error.message.orEmpty().contains("do-not-leak"))
}
private fun expectPlatformFileException(block: () -> Unit): PlatformFileException =
runCatching(block).exceptionOrNull() as? PlatformFileException
?: error("Expected PlatformFileException")
private fun expectFileUploadException(block: () -> Unit): FileUploadException =
runCatching(block).exceptionOrNull() as? FileUploadException
?: error("Expected FileUploadException")
private fun fakeApi(
response: PlatformFileApiResponse<FileUploadResult>,
): PlatformFileApi = object : PlatformFileApi {
response: FileApiResponse<FileUploadResult>,
): FileApi = object : FileApi {
override suspend fun upload(
file: MultipartBody.Part,
thumbnail: MultipartBody.Part?,
): PlatformFileApiResponse<FileUploadResult> = response
): FileApiResponse<FileUploadResult> = response
}
private fun uploadParts() = PlatformFileUploadParts(
private fun uploadParts() = FileUploadParts(
fileName = "report.pdf",
file = MultipartBody.Part.createFormData(
"file",

查看文件

@ -1,4 +1,4 @@
package com.xuqm.sdk.file.platform.internal
package com.xuqm.sdk.file.internal
import okio.Buffer
import org.junit.Assert.assertArrayEquals

查看文件

@ -1,63 +0,0 @@
# sdk-file
XuqmGroup Android SDK 的平台文件扩展。它是依赖 `sdk-core` 的可选模块,统一承载:
- 使用 XuqmSDK 当前登录态和租户平台文件服务上传文件;
- 通过模块唯一声明的 `FileProvider` 安全打开本地文件。
纯下载、SHA-256、MediaStore 保存等无需初始化的能力仍由
`com.xuqm.sdk.file.FileSDK` 提供,`sdk-core` 不包含上传转发 API 或 FileProvider。
## 集成
```kotlin
dependencies {
implementation("com.xuqm:sdk-file:VERSION")
}
```
宿主同时应用 `com.xuqm.common` 构建插件,并将租户平台签发的
`config.xuqmconfig` 放在 `app/src/main/assets/config/config.xuqmconfig`。模块的
`XuqmMergedProvider` 只有检测到构建插件生成的标记后才会自动初始化 SDK。
## 上传
```kotlin
val fromUri = PlatformFileSDK.upload(
context = context,
uri = uri,
onProgress = { progress -> /* 0..100 */ },
)
val fromFile = PlatformFileSDK.upload(
file = file,
onProgress = { progress -> /* 0..100 */ },
)
val fromBytes = PlatformFileSDK.uploadBytes(
fileName = "photo.jpg",
mimeType = "image/jpeg",
bytes = bytes,
)
```
`Uri``File` 使用流式请求体,不会把完整文件读入内存。返回
`FileUploadResult`,字段包括 `url`、`thumbnailUrl`、`hash`、`size`、
`originalName`、`mimeType` 和 `ext`
三个上传入口都会先等待签名配置和平台服务配置初始化完成,宿主无需自行增加等待
逻辑。平台返回失败、状态不一致或成功响应缺少 data 时抛出
`PlatformFileException`;其 `code`、`status`、`message` 保留服务端结构化错误,
但不会暴露原始响应体。
## 打开文件
```kotlin
PlatformFileSDK.openFile(context, file)
```
打开本地文件不依赖平台初始化或登录。
模块通过独立的 `XuqmFileProvider` 声明 `${applicationId}.xuqm.fileprovider`
它不会与宿主或其他 SDK 自己的 `FileProvider` 合并冲突,宿主无需添加
`tools:replace`。其他模块不得重复声明同一 Xuqm authority。

查看文件

@ -1,30 +0,0 @@
plugins {
alias(libs.plugins.android.library)
}
apply(from = rootProject.file("gradle/publish.gradle"))
version = providers.gradleProperty("SDK_FILE_VERSION")
.orElse(providers.gradleProperty("PUBLISH_VERSION"))
.getOrElse("0.1.0-SNAPSHOT")
android {
namespace = "com.xuqm.sdk.file.platform"
compileSdk = libs.versions.compileSdk.get().toInt()
defaultConfig {
minSdk = libs.versions.minSdk.get().toInt()
consumerProguardFiles("consumer-rules.pro")
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_11
targetCompatibility = JavaVersion.VERSION_11
}
publishing {
singleVariant("release") { withSourcesJar() }
}
}
dependencies {
api(project(":sdk-core"))
testImplementation(libs.junit4)
}

查看文件

@ -1,6 +0,0 @@
-keep class com.xuqm.sdk.file.platform.PlatformFileSDK { *; }
-keep class com.xuqm.sdk.file.platform.FileUploadResult { *; }
-keep class com.xuqm.sdk.file.platform.PlatformFileException { *; }
-keepclassmembers class com.xuqm.sdk.file.platform.PlatformFileApiResponse {
<fields>;
}

查看文件

@ -1,18 +0,0 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application>
<provider
android:name="com.xuqm.sdk.file.platform.XuqmFileProvider"
android:authorities="${applicationId}.xuqm.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/xuqm_file_paths" />
</provider>
<provider
android:name="com.xuqm.sdk.internal.XuqmMergedProvider"
android:authorities="${applicationId}.xuqm-init"
android:exported="false"
android:initOrder="50" />
</application>
</manifest>

查看文件

@ -1,116 +0,0 @@
package com.xuqm.sdk.file.platform
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.webkit.MimeTypeMap
import androidx.core.content.FileProvider
import com.xuqm.sdk.XuqmSDK
import com.xuqm.sdk.core.ServiceEndpointRegistry
import com.xuqm.sdk.file.platform.internal.UploadRequestBodies
import com.xuqm.sdk.network.ApiClient
import okhttp3.MultipartBody
import java.io.File
data class FileUploadResult(
val url: String,
val thumbnailUrl: String? = null,
val hash: String,
val size: Long,
val originalName: String? = null,
val mimeType: String? = null,
val ext: String? = null,
)
/**
* 依赖租户平台和 FileProvider 的文件扩展
*
* 纯下载摘要MediaStore 保存仍属于无需初始化且零 Manifest core FileSDK
*/
object PlatformFileSDK {
private val uploadGateway = PlatformFileUploadGateway(
awaitInitialization = XuqmSDK::awaitInitialization,
apiProvider = {
ApiClient.create(PlatformFileApi::class.java, ServiceEndpointRegistry.fileBaseUrl)
},
)
suspend fun upload(
context: Context,
uri: Uri,
displayName: String? = null,
mimeType: String? = null,
thumbnailBytes: ByteArray? = null,
onProgress: (Int) -> Unit = {},
): FileUploadResult = uploadGateway.upload {
val name = displayName?.takeIf(String::isNotBlank)
?: UploadRequestBodies.resolveDisplayName(context, uri)
val type = mimeType?.takeIf(String::isNotBlank)
?: context.contentResolver.getType(uri)
PlatformFileUploadParts(
fileName = name,
file = MultipartBody.Part.createFormData(
"file",
name,
UploadRequestBodies.createUri(context, uri, type, onProgress),
),
thumbnailBytes = thumbnailBytes,
)
}
suspend fun upload(
file: File,
thumbnailBytes: ByteArray? = null,
onProgress: (Int) -> Unit = {},
): FileUploadResult = uploadGateway.upload {
PlatformFileUploadParts(
fileName = file.name,
file = MultipartBody.Part.createFormData(
"file",
file.name,
UploadRequestBodies.createFile(
file,
java.net.URLConnection.guessContentTypeFromName(file.name),
onProgress,
),
),
thumbnailBytes = thumbnailBytes,
)
}
suspend fun uploadBytes(
fileName: String,
mimeType: String?,
bytes: ByteArray,
thumbnailBytes: ByteArray? = null,
onProgress: (Int) -> Unit = {},
): FileUploadResult = uploadGateway.upload {
PlatformFileUploadParts(
fileName = fileName,
file = MultipartBody.Part.createFormData(
"file",
fileName,
UploadRequestBodies.createBytes(mimeType, bytes, onProgress),
),
thumbnailBytes = thumbnailBytes,
)
}
fun openFile(context: Context, file: File) {
val mimeType = MimeTypeMap.getSingleton()
.getMimeTypeFromExtension(file.extension.lowercase())
?: "application/octet-stream"
val uri = FileProvider.getUriForFile(
context,
"${context.packageName}.xuqm.fileprovider",
file,
)
val view = Intent(Intent.ACTION_VIEW).apply {
setDataAndType(uri, mimeType)
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
context.startActivity(Intent.createChooser(view, null).apply {
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
})
}
}

查看文件

@ -31,7 +31,7 @@ android {
}
dependencies {
api(project(":sdk-file"))
api(project(":sdk-core"))
implementation(libs.kotlinx.coroutines.android)
testImplementation(libs.junit4)
}

查看文件

@ -30,8 +30,8 @@ import com.xuqm.sdk.im.model.PageResult
import com.xuqm.sdk.im.model.UserProfile
import com.xuqm.sdk.im.model.BlacklistEntry
import com.xuqm.sdk.im.model.FriendRequest
import com.xuqm.sdk.file.platform.PlatformFileSDK
import com.xuqm.sdk.file.platform.FileUploadResult
import com.xuqm.sdk.file.FileSDK
import com.xuqm.sdk.file.FileUploadResult
import com.xuqm.sdk.network.ApiClient
import android.util.Log
import kotlinx.coroutines.CoroutineScope
@ -164,7 +164,7 @@ object ImSDK {
width: Int? = null,
height: Int? = null,
): ImMessage = withContext(Dispatchers.IO) {
val result = PlatformFileSDK.upload(file)
val result = FileSDK.upload(file)
sendImageMessageWithUploadResult(toId, chatType, result, width, height)
}
@ -200,7 +200,7 @@ object ImSDK {
height: Int? = null,
durationMs: Long? = null,
): ImMessage = withContext(Dispatchers.IO) {
val result = PlatformFileSDK.upload(file)
val result = FileSDK.upload(file)
sendVideoMessageWithUploadResult(toId, chatType, result, width, height, durationMs)
}
@ -235,7 +235,7 @@ object ImSDK {
chatType: String,
file: File,
): ImMessage = withContext(Dispatchers.IO) {
val result = PlatformFileSDK.upload(file)
val result = FileSDK.upload(file)
sendFileMessageWithUploadResult(toId, chatType, result)
}
@ -264,7 +264,7 @@ object ImSDK {
file: File,
durationMs: Long? = null,
): ImMessage = withContext(Dispatchers.IO) {
val result = PlatformFileSDK.upload(file)
val result = FileSDK.upload(file)
sendAudioMessageWithUploadResult(toId, chatType, result, durationMs)
}

查看文件

@ -49,7 +49,7 @@ data class UpdateCheckResult(
## 工作原理
- `downloadAndInstall` 将 APK 下载到 `getExternalFilesDir(null)`,通过 `FileProvider` 触发系统安装
- 安装所需 FileProvider 由依赖的 `sdk-file` 唯一声明,Update 不复制 Manifest 或 paths 资源
- 安装所需 FileProvider 由依赖的 `sdk-core` 唯一声明,Update 不复制 Manifest 或 paths 资源
- 安装前依次校验 SHA-256、宿主 packageName 和 APK 签名证书;只允许安装本次检查授权的版本
- 下载任务取消或进程退出后删除 `.part`,下次启动不会自动继续
- `PUBLIC_DOWNLOADS` 仅额外导出已校验 APK,不改变 SDK 私有安装源

查看文件

@ -31,7 +31,7 @@ android {
}
dependencies {
api(project(":sdk-file"))
api(project(":sdk-core"))
implementation(libs.kotlinx.coroutines.android)
implementation(libs.gson)
testImplementation(libs.junit4)

查看文件

@ -8,7 +8,6 @@ import android.os.Handler
import android.os.Looper
import android.provider.Settings
import android.util.Log
import androidx.core.content.FileProvider
import com.xuqm.sdk.XuqmSDK
import com.xuqm.sdk.XuqmInitializationState
import com.xuqm.sdk.XuqmLoginSession
@ -730,11 +729,7 @@ object UpdateSDK {
val intent = try {
Intent(Intent.ACTION_VIEW).apply {
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_GRANT_READ_URI_PERMISSION
val uri = FileProvider.getUriForFile(
context,
"${context.packageName}.xuqm.fileprovider",
apkFile,
)
val uri = FileSDK.uriForFile(context, apkFile)
setDataAndType(uri, "application/vnd.android.package-archive")
}
} catch (error: Exception) {

查看文件

@ -36,7 +36,7 @@ android {
}
dependencies {
api(project(":sdk-file"))
api(project(":sdk-core"))
api(platform(libs.androidx.compose.bom))
api(libs.androidx.ui)
api(libs.androidx.ui.graphics)

查看文件

@ -42,11 +42,9 @@ import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.ui.viewinterop.AndroidView
import androidx.core.content.ContextCompat
import androidx.core.content.FileProvider
import android.provider.MediaStore
import com.xuqm.sdk.file.FileDownloadDestination
import com.xuqm.sdk.file.FileSDK
import com.xuqm.sdk.file.platform.PlatformFileSDK
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
@ -375,7 +373,7 @@ fun XWebViewView(
}.onSuccess { file ->
withContext(Dispatchers.Main) {
dispatchDownloadEvent("__xwvDownloadDone", url, ",success:true")
PlatformFileSDK.openFile(context, file)
FileSDK.openFile(context, file)
}
}.onFailure { e ->
withContext(Dispatchers.Main) {
@ -408,7 +406,7 @@ fun XWebViewView(
withContext(Dispatchers.Main) {
dispatchDownloadEvent("__xwvDownloadDone", url, ",success:true")
// Only open with file viewer when not saved to gallery (e.g. zip, docx)
if (!savedToGallery) PlatformFileSDK.openFile(context, file)
if (!savedToGallery) FileSDK.openFile(context, file)
}
}.onFailure { e ->
android.util.Log.e("XWV", "blobdownload FAILED", e)
@ -533,11 +531,7 @@ fun XWebViewView(
if (granted && launch) {
runCatching {
val imageFile = File.createTempFile("cam_", ".jpg", context.cacheDir)
val uri = FileProvider.getUriForFile(
context,
"${context.packageName}.xuqm.fileprovider",
imageFile,
)
val uri = FileSDK.uriForFile(context, imageFile)
pendingCameraUri.value = uri
takePictureLauncher.launch(uri)
}.onFailure {
@ -580,11 +574,7 @@ fun XWebViewView(
if (ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED) {
runCatching {
val imageFile = File.createTempFile("cam_", ".jpg", context.cacheDir)
val uri = FileProvider.getUriForFile(
context,
"${context.packageName}.xuqm.fileprovider",
imageFile,
)
val uri = FileSDK.uriForFile(context, imageFile)
pendingCameraUri.value = uri
takePictureLauncher.launch(uri)
}.onFailure {

查看文件

@ -26,7 +26,6 @@ dependencyResolutionManagement {
rootProject.name = "XuqmGroupAndroidSDK"
include(":sdk-core")
include(":sdk-file")
include(":sdk-im")
include(":sdk-push")
include(":sdk-update")