比较提交

...

4 次代码提交

作者 SHA1 备注 提交日期
XuqmGroup
b34b88e29e refactor(file): consolidate file capabilities into core 2026-07-28 20:52:34 +08:00
XuqmGroup
8610ea5745 feat(bugcollect): honor server backpressure 2026-07-28 20:41:05 +08:00
XuqmGroup
8204ee7c38 feat(update): expose store bridge entry 2026-07-28 20:22:40 +08:00
XuqmGroup
d05b85b024 refactor(update): make native update flow authoritative 2026-07-28 20:14:50 +08:00
共有 55 个文件被更改,包括 1003 次插入1016 次删除

2
Jenkinsfile vendored
查看文件

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

查看文件

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

查看文件

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

查看文件

@ -1,29 +1,74 @@
# Android SDK 重构实施接管文档 # Android SDK 重构实施接管文档
> 更新时间2026-07-26 > 更新时间2026-07-28
> 分支:`main` > 分支:`main`
> 发布约束Maven 制品只允许通过 `https://jenkins.xuqinmin.com/` 发布。 > 发布约束Maven 制品只允许通过 `https://jenkins.xuqinmin.com/` 发布。
## 2026-07-28 Update 原生权威契约与客户端秘密清理
- 新签发配置不再包含 `signingKey/appSecret`;Android 客户端删除可提取长期秘密及
HMAC 请求头生成。配置真实性仍由 Ed25519 签名保证,登录请求只使用宿主同步的短期令牌。
- `NetworkMonitor` 成为唯一网络状态来源,可独立于 SDK 初始化使用,公开
`NONE/WIFI/CELLULAR/CELLULAR_4G/CELLULAR_5G/ETHERNET/OTHER` 与是否计费网络。
宿主自行决定是否允许蜂窝网络下载,租户平台不保存网络建议。
- `checkAppUpdate` 返回 `UpdateCheckResult`,明确区分
`UPDATE_AVAILABLE/NO_UPDATE/LOGIN_REQUIRED/SERVICE_DISABLED/FAILED`,并携带网络快照;
更新服务失败、未登录或未开通不再用 `null` 混淆,也不阻断宿主业务。
- 原生 Update 同时提供检查、只下载、只安装、下载并安装和应用商店跳转。下载任务不跨
进程恢复;取消和失败清理 `.part`。安装前校验 SHA-256、包名与签名证书。
- 已校验 APK 可按宿主选择额外导出到系统 Downloads。Android 10+ 使用 MediaStore;
Android 9 及以下遵循系统运行时存储权限,不申请所有文件访问权限。
- 删除未接入正式构建链且会读取明文发布参数、在本机直接发布的
`sdk-update/scripts/xuqm_release.gradle.kts`
- `sdk-webview` 已删除所有文件访问权限门禁与设置页跳转,下载统一复用 core FileSDK。
本批已验证:
```bash
./gradlew :sdk-core:testDebugUnitTest \
:sdk-update:testDebugUnitTest \
:sdk-update:assembleRelease \
:sdk-webview:testDebugUnitTest \
:sdk-webview:lintRelease \
verifySdkManifestOwnership
```
上述命令在增加包名/签名校验与 Java/RN 回调后最终复验通过233 个 Gradle task,
68 executed、165 up-to-date
Sample 全量编译被真实配置门禁按预期拦截:仓库未包含租户平台签发的
`sample-app/src/main/assets/config/config.xuqmconfig`。未伪造配置,真机、Jenkins 和
Maven 发布尚未执行。
## 2026-07-28 BugCollect 摄取防雪崩
- 原生上传器识别 HTTP 429 与 `BUGCOLLECT_RATE_LIMITED`,按标准 `Retry-After`
暂停上传,缺失或非法值默认 60 秒,最长一小时。
- 冷却期间定时刷新直接跳过;fatal/error 加密队列和 Crash 文件保持不变,不向宿主
业务线程抛异常,也不把临时限流误判为服务永久关闭。
- `:sdk-bugcollect:testDebugUnitTest :sdk-bugcollect:lintRelease
: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-core` 是配置、会话、网络、文件、时间等公共能力的唯一实现。
- `sdk-update` 只提供更新领域 API;宿主与 RN 桥接不复制下载、校验和安装代码。 - `sdk-update` 只提供更新领域 API;宿主与 RN 桥接不复制下载、校验和安装代码。
- APK 更新必须支持 SHA-256、原子临时文件、进度、取消、有限重试、FileProvider、未知来源安装权限及确定错误码。 - 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 已完成 ## 2026-07-17 已完成
- `FileSDK` 新增详细下载进度、SHA-256 与哈希匹配公共 API。 - `FileSDK` 新增详细下载进度、SHA-256 与哈希匹配公共 API。
- 下载改为 `.part` 临时文件完成后替换目标文件;HTTP 非 2xx 失败,协程取消会中断连接并清理临时文件。 - 下载改为 `.part` 临时文件完成后替换目标文件;HTTP 非 2xx 失败,协程取消会中断连接并清理临时文件。
- 当时的 core FileProvider 归属已被后续终态替代:当前仅 `sdk-file` 声明,core 零 Manifest。 - 当时的 FileProvider 归属已被后续终态替代:当前由 core 唯一声明。
- `sdk-update` 删除无版本 APK 兼容路径和弃用下载入口。 - `sdk-update` 删除无版本 APK 兼容路径和弃用下载入口。
- `sdk-update` 2.0.0-SNAPSHOT 强制要求合法 SHA-256,并提供下载重试、取消、缓存文件复验和确定错误码。 - `sdk-update` 2.0.0-SNAPSHOT 强制要求合法 SHA-256,并提供下载重试、取消、缓存文件复验和确定错误码。
- 增加 Java/RN 可直接使用的 `startDownloadAndInstall`、`UpdateInstallTask`、进度/结果回调和安装权限设置 Intent。 - 增加 Java/RN 可直接使用的 `startDownloadAndInstall`、`UpdateInstallTask`、进度/结果回调和安装权限设置 Intent。
@ -93,15 +138,15 @@
`POST /api/sdk/build/config/validate`,提交原始签名配置和最终 applicationId。服务不可达、 `POST /api/sdk/build/config/validate`,提交原始签名配置和最终 applicationId。服务不可达、
配置撤销、过期或包名不匹配都会阻止打包;应急关闭 BugCollect 也不能绕过配置校验。 配置撤销、过期或包名不匹配都会阻止打包;应急关闭 BugCollect 也不能绕过配置校验。
- 删除不再参与 Manifest 的 `BugCollectInitProvider`,避免扩展模块平行初始化。 - 删除不再参与 Manifest 的 `BugCollectInitProvider`,避免扩展模块平行初始化。
- 删除公开手动初始化与旧 Provider 兼容类。`sdk-core` 单独集成不注册 Provider - 删除公开手动初始化与旧 Provider 兼容类。`sdk-core` 单独集成不注册初始化 Provider
FileProvider 或通知权限;需要平台能力的扩展统一合并同一个 `XuqmMergedProvider` 或通知权限;需要平台能力的扩展统一合并同一个 `XuqmMergedProvider`文件 Provider
FileProvider 归唯一 `sdk-file`,通知权限归 `sdk-push` 归唯一 core,通知权限归 `sdk-push`
- Sample 删除硬编码 appKey,只接受被 Git 忽略的租户平台签发配置;缺少文件时 Debug/ - Sample 删除硬编码 appKey,只接受被 Git 忽略的租户平台签发配置;缺少文件时 Debug/
Release 构建门禁给出明确错误。Release 签名只从用户级 Gradle 属性读取。 Release 构建门禁给出明确错误。Release 签名只从用户级 Gradle 属性读取。
- 敏感存储收敛为唯一 `SecureStore`Android Keystore 不可导出 AES-256-GCM 密钥, - 敏感存储收敛为唯一 `SecureStore`Android Keystore 不可导出 AES-256-GCM 密钥,
namespace/key 作为 AAD;删除废弃 security-crypto 依赖。 namespace/key 作为 AAD;删除废弃 security-crypto 依赖。
- `sdk-webview/XWebViewView.kt` 的既有未提交修改始终作为用户基线保留;本轮只在该基线 - `sdk-webview/XWebViewView.kt` 的既有未提交修改始终作为用户基线保留;本轮只在该基线
上将打开文件能力切换到唯一 `PlatformFileSDK`,未覆盖其它改动。 上将打开文件能力切换到唯一 `FileSDK`,未覆盖其它改动。
本轮最终已验证: 本轮最终已验证:
@ -158,16 +203,16 @@ Sample 源码编译时只为静态验证跳过真实签发文件的存在性门
mapping 上传;mapping 仍保留为本地产物。Debug 只关闭自动采集,可调试宿主在构建未 mapping 上传;mapping 仍保留为本地产物。Debug 只关闭自动采集,可调试宿主在构建未
禁用、隐私同意、平台启用时可通过隐藏开发入口显式发送一次测试事件。 禁用、隐私同意、平台启用时可通过隐藏开发入口显式发送一次测试事件。
- common 插件按变体生成唯一 Manifest 标记、buildId 与 BugCollect 构建开关;扩展 AAR - common 插件按变体生成唯一 Manifest 标记、buildId 与 BugCollect 构建开关;扩展 AAR
的合并 Provider 未检测到该标记时只退出,不执行初始化,core-only 仍保持零 Manifest 的合并 Provider 未检测到该标记时只退出,不执行初始化,core-only 不触发初始化
- Update 先服从平台 `features.update`,相同用户重复同步不会再次补检;下载与安装授权 - Update 先服从平台 `features.update`,相同用户重复同步不会再次补检;下载与安装授权
绑定当前 generation 和 userId,切换或登出会取消活动下载、清除授权并阻止旧灰度结果安装。 绑定当前 generation 和 userId,切换或登出会取消活动下载、清除授权并阻止旧灰度结果安装。
- BugCollect 队列 flush 使用 Mutex 单飞,定时与满批触发不会重复上传同一批数据。 - BugCollect 队列 flush 使用 Mutex 单飞,定时与满批触发不会重复上传同一批数据。
- 内部 BugCollect 状态桥接改为私有反射 SPI,不进入 Java/Kotlin 公开 API。 - 内部 BugCollect 状态桥接改为私有反射 SPI,不进入 Java/Kotlin 公开 API。
- `sdk-core` 保持零 Manifest,只保留无需初始化的纯本地文件能力。新增唯一 `sdk-file` - `sdk-core` 统一承载本地文件、租户平台上传与 FileProvider 打开;IM、Update、WebView
扩展承载租户平台上传与 FileProvider 打开;IM、Update、WebView 复用该扩展 直接复用 core,不存在第二个文件模块
- `sdk-file`Uri、File、ByteArray 三个上传入口统一先等待平台初始化,再创建请求体 - Uri、File、ByteArray 三个上传入口统一先等待平台初始化,再创建请求体
和读取服务端点;平台失败统一抛出保留 `code/status/message` 且不暴露响应体的 和读取服务端点;平台失败统一抛出保留 `code/status/message` 且不暴露响应体的
`PlatformFileException`。`openFile` 仍为不依赖初始化的纯本地能力。 `FileUploadException`。`openFile` 仍为不依赖初始化的纯本地能力。
- 全仓日志、标准输出和异常文案不再输出 token、userId、groupId、消息目标、H5 - 全仓日志、标准输出和异常文案不再输出 token、userId、groupId、消息目标、H5
payload、请求/响应正文、文件 Uri/绝对路径等实际值。根任务 payload、请求/响应正文、文件 Uri/绝对路径等实际值。根任务
`verifyNoSensitiveLogging` 在各模块 `testDebugUnitTest`、`lintRelease` 和 `check` `verifyNoSensitiveLogging` 在各模块 `testDebugUnitTest`、`lintRelease` 和 `check`

查看文件

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

查看文件

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

查看文件

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

查看文件

@ -193,13 +193,13 @@ class SdkIntegrationTest {
*/ */
@Test @Test
fun tc07_updateCheck() = runBlocking { fun tc07_updateCheck() = runBlocking {
val updateInfo = UpdateSDK.checkAppUpdate(appCtx) val result = UpdateSDK.checkAppUpdate(appCtx)
// updateInfo 为 null 表示当前已是最新版,非 null 则包含版本详情 val updateInfo = result.update
if (updateInfo != null) { if (result.hasUpdate && updateInfo != null) {
assertTrue("versionCode 应大于 0如有更新", updateInfo.versionCode > 0) assertTrue("versionCode 应大于 0如有更新", updateInfo.versionCode > 0)
assertTrue("versionName 应非空", updateInfo.versionName.isNotBlank()) assertTrue("versionName 应非空", updateInfo.versionName.isNotBlank())
} }
// 关键断言: 接口调用不抛异常,UpdateInfo 结构正确 // 关键断言:检查始终返回明确状态,不再以 null 混淆无需更新、登录门禁与错误。
} }
/** /**

查看文件

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

查看文件

@ -36,6 +36,7 @@ import com.xuqm.sdk.im.ImSDK
import com.xuqm.sdk.update.UpdateSDK import com.xuqm.sdk.update.UpdateSDK
import com.xuqm.sdk.update.model.ApkInstallRequest import com.xuqm.sdk.update.model.ApkInstallRequest
import com.xuqm.sdk.update.model.UpdateInfo import com.xuqm.sdk.update.model.UpdateInfo
import com.xuqm.sdk.update.model.UpdateCheckStatus
import com.xuqm.sdk.sample.ui.contact.ContactScreen import com.xuqm.sdk.sample.ui.contact.ContactScreen
import com.xuqm.sdk.sample.ui.conversation.ConversationScreen import com.xuqm.sdk.sample.ui.conversation.ConversationScreen
import com.xuqm.sdk.sample.ui.group.GroupListScreen import com.xuqm.sdk.sample.ui.group.GroupListScreen
@ -76,7 +77,10 @@ fun MainScreen(
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
if (!updateChecked) { if (!updateChecked) {
updateChecked = true updateChecked = true
pendingUpdate = UpdateSDK.checkAppUpdate(context)?.takeIf { it.needsUpdate } val result = UpdateSDK.checkAppUpdate(context)
pendingUpdate = result.update?.takeIf {
result.status == UpdateCheckStatus.UPDATE_AVAILABLE
}
} }
} }

查看文件

@ -26,6 +26,7 @@ import androidx.lifecycle.viewModelScope
import com.xuqm.sdk.update.UpdateSDK import com.xuqm.sdk.update.UpdateSDK
import com.xuqm.sdk.update.model.ApkInstallRequest import com.xuqm.sdk.update.model.ApkInstallRequest
import com.xuqm.sdk.update.model.UpdateInfo import com.xuqm.sdk.update.model.UpdateInfo
import com.xuqm.sdk.update.model.UpdateCheckStatus
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@ -45,11 +46,17 @@ class UpdateViewModel : ViewModel() {
fun checkAppUpdate(context: Context) { fun checkAppUpdate(context: Context) {
viewModelScope.launch { viewModelScope.launch {
_state.value = _state.value.copy(isChecking = true, message = null) _state.value = _state.value.copy(isChecking = true, message = null)
val info = UpdateSDK.checkAppUpdate(context) val result = UpdateSDK.checkAppUpdate(context)
_state.value = _state.value.copy( _state.value = _state.value.copy(
isChecking = false, isChecking = false,
appUpdate = info, appUpdate = result.update,
message = if (info?.needsUpdate == true) null else "已是最新版本", message = when (result.status) {
UpdateCheckStatus.UPDATE_AVAILABLE -> null
UpdateCheckStatus.NO_UPDATE -> "已是最新版本"
UpdateCheckStatus.LOGIN_REQUIRED -> "登录后可检查灰度更新"
UpdateCheckStatus.SERVICE_DISABLED -> "当前未启用版本更新"
UpdateCheckStatus.FAILED -> result.message ?: "检查更新失败"
},
) )
} }
} }

查看文件

@ -2,6 +2,9 @@
XuqmGroup Android SDK Bug 采集模块。提供错误采集、Crash 捕获、漏斗分析能力。 XuqmGroup Android SDK Bug 采集模块。提供错误采集、Crash 捕获、漏斗分析能力。
服务端返回 HTTP 429 或 `BUGCOLLECT_RATE_LIMITED` 时,SDK 按 `Retry-After` 暂停后台
上传(最长一小时),保留本地队列且不向宿主业务线程抛异常。
## 依赖 ## 依赖
```kotlin ```kotlin

查看文件

@ -49,6 +49,7 @@ internal class LogQueue(
private val lock = Any() private val lock = Any()
private val flushMutex = Mutex() private val flushMutex = Mutex()
@Volatile private var stopped = false @Volatile private var stopped = false
@Volatile private var uploadCooldownUntilMillis = 0L
private var flushJob: Job? = null private var flushJob: Job? = null
init { init {
@ -91,6 +92,7 @@ internal class LogQueue(
disableAndClear() disableAndClear()
return@launch return@launch
} }
is UploadResult.RateLimited -> return@launch
is UploadResult.Failed -> return@launch is UploadResult.Failed -> return@launch
} }
} }
@ -132,6 +134,7 @@ internal class LogQueue(
/** 单飞执行;定时器、满批触发和手动触发不能重复上传同一批事件。 */ /** 单飞执行;定时器、满批触发和手动触发不能重复上传同一批事件。 */
private suspend fun flushOnce() { private suspend fun flushOnce() {
if (stopped || !XuqmSDK.bugCollectEnabled || !XuqmSDK.privacyConsentGranted) return if (stopped || !XuqmSDK.bugCollectEnabled || !XuqmSDK.privacyConsentGranted) return
if (System.currentTimeMillis() < uploadCooldownUntilMillis) return
val issues: List<IssueEvent> val issues: List<IssueEvent>
val events: List<LogEvent> val events: List<LogEvent>
synchronized(lock) { synchronized(lock) {
@ -149,6 +152,7 @@ internal class LogQueue(
disableAndClear() disableAndClear()
return return
} }
is UploadResult.RateLimited -> return
is UploadResult.Failed -> Unit is UploadResult.Failed -> Unit
} }
} }
@ -156,6 +160,7 @@ internal class LogQueue(
when (uploadWithRetry { LogUploader.uploadEvents(requireUrl(), events) }) { when (uploadWithRetry { LogUploader.uploadEvents(requireUrl(), events) }) {
UploadResult.Success -> synchronized(lock) { pendingEvents.removeAll(events.toSet()) } UploadResult.Success -> synchronized(lock) { pendingEvents.removeAll(events.toSet()) }
UploadResult.Disabled -> disableAndClear() UploadResult.Disabled -> disableAndClear()
is UploadResult.RateLimited -> Unit
is UploadResult.Failed -> Unit is UploadResult.Failed -> Unit
} }
} }
@ -166,6 +171,10 @@ internal class LogQueue(
repeat(3) { attempt -> repeat(3) { attempt ->
if (stopped) return UploadResult.Failed() if (stopped) return UploadResult.Failed()
val result = runCatching(block).getOrElse { UploadResult.Failed(it) } val result = runCatching(block).getOrElse { UploadResult.Failed(it) }
if (result is UploadResult.RateLimited) {
uploadCooldownUntilMillis = System.currentTimeMillis() + result.retryAfterMillis
return result
}
if (result !is UploadResult.Failed) return result if (result !is UploadResult.Failed) return result
last = result last = result
if (attempt < 2) { if (attempt < 2) {

查看文件

@ -17,6 +17,7 @@ import java.util.concurrent.TimeUnit
internal sealed interface UploadResult { internal sealed interface UploadResult {
data object Success : UploadResult data object Success : UploadResult
data object Disabled : UploadResult data object Disabled : UploadResult
data class RateLimited(val retryAfterMillis: Long) : UploadResult
data class Failed(val cause: Throwable? = null) : UploadResult data class Failed(val cause: Throwable? = null) : UploadResult
} }
@ -56,6 +57,10 @@ internal object LogUploader {
val responseBody = response.body?.string().orEmpty() val responseBody = response.body?.string().orEmpty()
if (isDisabled(responseBody)) { if (isDisabled(responseBody)) {
UploadResult.Disabled UploadResult.Disabled
} else if (response.code == 429 || isRateLimited(responseBody)) {
UploadResult.RateLimited(
RetryAfter.parseMillis(response.header("Retry-After")),
)
} else if (response.isSuccessful) { } else if (response.isSuccessful) {
UploadResult.Success UploadResult.Success
} else { } else {
@ -72,6 +77,14 @@ internal object LogUploader {
.any { it == DISABLED_CODE } .any { it == DISABLED_CODE }
} }
private fun isRateLimited(body: String): Boolean {
if (body.isBlank()) return false
val json = runCatching { JSONObject(body) }.getOrNull() ?: return false
return sequenceOf("code", "errorCode", "message")
.mapNotNull { key -> json.opt(key)?.toString() }
.any { it == "BUGCOLLECT_RATE_LIMITED" }
}
private fun resolveUrl(path: String): String { private fun resolveUrl(path: String): String {
val trimmed = path.trimEnd('/') val trimmed = path.trimEnd('/')
if (trimmed.startsWith("http://") || trimmed.startsWith("https://")) return trimmed if (trimmed.startsWith("http://") || trimmed.startsWith("https://")) return trimmed
@ -91,3 +104,23 @@ internal object LogUploader {
private fun eventToJson(event: LogEvent): JSONObject = private fun eventToJson(event: LogEvent): JSONObject =
JSONObject(com.google.gson.Gson().toJson(event)) JSONObject(com.google.gson.Gson().toJson(event))
} }
internal object RetryAfter {
private const val DEFAULT_MILLIS = 60_000L
private const val MAX_MILLIS = 60L * 60_000L
fun parseMillis(value: String?, nowMillis: Long = System.currentTimeMillis()): Long {
if (value.isNullOrBlank()) return DEFAULT_MILLIS
val millis = value.trim().toLongOrNull()?.times(1_000L)
?: runCatching {
SimpleDateFormat("EEE, dd MMM yyyy HH:mm:ss zzz", Locale.US).apply {
timeZone = TimeZone.getTimeZone("GMT")
isLenient = false
}.parse(value)?.time?.minus(nowMillis)
}.getOrNull()
return millis
?.takeIf { it > 0L }
?.coerceIn(1_000L, MAX_MILLIS)
?: DEFAULT_MILLIS
}
}

查看文件

@ -0,0 +1,24 @@
package com.xuqm.sdk.bugcollect.internal
import org.junit.Assert.assertEquals
import org.junit.Test
class RetryAfterTest {
@Test
fun parsesSecondsAndHttpDate() {
val now = 1_775_000_000_000L
assertEquals(27_000L, RetryAfter.parseMillis("27", now))
val formatter = java.text.SimpleDateFormat("EEE, dd MMM yyyy HH:mm:ss zzz", java.util.Locale.US)
formatter.timeZone = java.util.TimeZone.getTimeZone("GMT")
val date = formatter.format(java.util.Date(now + 45_000L))
assertEquals(45_000L, RetryAfter.parseMillis(date, now))
}
@Test
fun invalidAndExtremeValuesStayBounded() {
assertEquals(60_000L, RetryAfter.parseMillis(null))
assertEquals(60_000L, RetryAfter.parseMillis("invalid"))
assertEquals(60L * 60_000L, RetryAfter.parseMillis("999999"))
}
}

查看文件

@ -21,10 +21,11 @@ app/src/main/assets/config/config.xuqmconfig
宿主不需要编写初始化代码,也不得修改、复制或自行生成该文件。SDK 先验证平台 宿主不需要编写初始化代码,也不得修改、复制或自行生成该文件。SDK 先验证平台
Ed25519 签名,再解密引导配置。V1、未知 keyId、签名错误和包名不匹配均拒绝使用。 Ed25519 签名,再解密引导配置。V1、未知 keyId、签名错误和包名不匹配均拒绝使用。
`serverUrl` 是签发配置中的唯一平台地址且必填,运行时没有默认地址或跨平台回退。 `serverUrl` 是签发配置中的唯一平台地址且必填,运行时没有默认地址或跨平台回退。
配置只包含公开应用身份与平台地址,不包含 `appSecret` 或其它客户端长期认证秘密。
只依赖 `sdk-core` 时不会注册初始化 Provider、申请通知权限或合并 FileProvider, 只依赖 `sdk-core` 时不会注册初始化 Provider 或申请通知权限,只合并安全打开/分享
基础 HTTP、下载、摘要、MediaStore、时间和安全存储能力无需初始化或登录。引入扩展 SDK 后才由扩展 本地文件所需的专用 FileProvider。基础 HTTP、下载、摘要、MediaStore、时间和安全存储
Manifest 合并唯一初始化 Provider。 能力无需初始化或登录;引入扩展 SDK 后才由扩展 Manifest 合并唯一初始化 Provider。
## API ## API
@ -45,6 +46,8 @@ Manifest 合并唯一初始化 Provider。
| `XuqmSDK.bugCollectApiUrl` | Bug 收集服务地址(从平台配置获取) | | `XuqmSDK.bugCollectApiUrl` | Bug 收集服务地址(从平台配置获取) |
| `XuqmSDK.bugCollectEnabled` | 是否启用 Bug 收集上报 | | `XuqmSDK.bugCollectEnabled` | 是否启用 Bug 收集上报 |
| `XuqmSDK.tokenStore` | Token 存储Android Keystore + AES-256-GCM | | `XuqmSDK.tokenStore` | Token 存储Android Keystore + AES-256-GCM |
| `NetworkMonitor.observe(context)` | 独立启动网络观察并返回当前状态 |
| `NetworkMonitor.status` | 网络状态 `StateFlow` |
### XuqmUserInfo ### XuqmUserInfo
@ -71,13 +74,18 @@ XuqmSDK.tokenStore.clear()
### FileSDK ### FileSDK
`FileSDK` 只保留无需初始化、零 Manifest 的本地下载、摘要与 MediaStore 能力。 `FileSDK` 提供无需初始化的本地下载、摘要与 MediaStore 能力;
依赖租户平台的上传和依赖 FileProvider 的打开文件能力属于独立 `sdk-file` 同一个 `FileSDK` 还提供上传和 FileProvider 安全打开能力。只有上传会等待平台配置
统一通过 `PlatformFileSDK` 调用 打开本地文件不要求初始化
```kotlin ```kotlin
// 下载 // 下载
val file = FileSDK.download(context, url, fileName, destination, notificationTitle, onProgress) val file = FileSDK.download(context, url, fileName, destination, notificationTitle, onProgress)
``` ```
平台配置成功后以加密方式保存七天 LKG。平台暂不可用且 LKG 有效时进入 保存到公共 Downloads 时,Android 10+ 使用 MediaStore;Android 9 及以下由宿主按系统
`DEGRADED`;没有可用缓存时扩展能力返回结构化错误,宿主核心业务不受影响。 规范申请 `WRITE_EXTERNAL_STORAGE`。SDK 不申请“所有文件访问权限”,也不执行 shell 命令。
`NetworkMonitor` 不要求 SDK 初始化。它只申请 `ACCESS_NETWORK_STATE`,不会为了细分
蜂窝网络代际申请电话或定位权限;系统不允许读取时稳定返回 `CELLULAR`
平台配置成功后只保存严格、版本化且不含秘密的最后成功配置。平台暂不可用且缓存结构
有效时进入 `DEGRADED`;没有可用缓存时扩展能力返回结构化错误,宿主核心业务不受影响。

查看文件

@ -11,6 +11,11 @@
# ── File SDK ────────────────────────────────────────────────────────────────── # ── File SDK ──────────────────────────────────────────────────────────────────
-keep class com.xuqm.sdk.file.FileSDK { *; } -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 ────────────────────── # ── Gson: preserve field names on all SDK model classes ──────────────────────
-keepclassmembers class com.xuqm.sdk.** { -keepclassmembers class com.xuqm.sdk.** {

查看文件

@ -1 +1,19 @@
<manifest /> <manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- NetworkMonitor 只读取当前网络,不申请电话、定位或后台权限。 -->
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<!-- Android 9 及以下写入公共 Downloads 的平台限制;Android 10+ 统一使用 MediaStore。 -->
<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>

查看文件

@ -15,6 +15,7 @@ import com.xuqm.sdk.internal.ConfigCache
import com.xuqm.sdk.internal.PlatformConfigCache import com.xuqm.sdk.internal.PlatformConfigCache
import com.xuqm.sdk.internal.BugCollectRuntimePolicy import com.xuqm.sdk.internal.BugCollectRuntimePolicy
import com.xuqm.sdk.network.ApiClient import com.xuqm.sdk.network.ApiClient
import com.xuqm.sdk.network.NetworkMonitor
import com.xuqm.sdk.network.SdkPlatformConfig import com.xuqm.sdk.network.SdkPlatformConfig
import com.xuqm.sdk.network.SdkPlatformConfigApi import com.xuqm.sdk.network.SdkPlatformConfigApi
import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CompletableDeferred
@ -84,10 +85,6 @@ object XuqmSDK {
@Volatile var privacyConsentGranted: Boolean = false @Volatile var privacyConsentGranted: Boolean = false
private set private set
/** 请求签名密钥,从配置文件读取。 */
@Volatile var signingKey: String? = null
private set
private val pendingInitCallbacks = mutableListOf<() -> Unit>() private val pendingInitCallbacks = mutableListOf<() -> Unit>()
private val pendingReadyCallbacks = mutableListOf<() -> Unit>() private val pendingReadyCallbacks = mutableListOf<() -> Unit>()
@ -143,9 +140,7 @@ object XuqmSDK {
) )
} }
signingKey = configFile.signingKey Log.i(TAG, "Verified config loaded")
val signingConfigured = !configFile.signingKey.isNullOrBlank()
Log.i(TAG, "Config loaded: signingConfigured=$signingConfigured")
// 2. 初始化 sdk-core // 2. 初始化 sdk-core
initializeVerifiedConfig(ctx, configFile.appKey, configFile.serverUrl, logLevel) initializeVerifiedConfig(ctx, configFile.appKey, configFile.serverUrl, logLevel)
@ -208,6 +203,7 @@ object XuqmSDK {
appContext = applicationContext appContext = applicationContext
tokenStore = TokenStore(applicationContext) tokenStore = TokenStore(applicationContext)
deviceId = resolveDeviceId(applicationContext) deviceId = resolveDeviceId(applicationContext)
NetworkMonitor.observe(applicationContext)
ApiClient.init(config, tokenStore) ApiClient.init(config, tokenStore)
initializedAppKey = appKey initializedAppKey = appKey
initialized = true initialized = true

查看文件

@ -3,7 +3,6 @@ package com.xuqm.sdk.core
data class SDKConfig( data class SDKConfig(
val appKey: String, val appKey: String,
val logLevel: LogLevel = LogLevel.WARN, val logLevel: LogLevel = LogLevel.WARN,
val signingKey: String? = null,
) )
enum class LogLevel { DEBUG, INFO, WARN, ERROR, NONE } enum class LogLevel { DEBUG, INFO, WARN, ERROR, NONE }

查看文件

@ -17,8 +17,17 @@ import android.webkit.MimeTypeMap
import androidx.core.app.NotificationCompat import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat import androidx.core.app.NotificationManagerCompat
import androidx.core.content.ContextCompat 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.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import okhttp3.MultipartBody
import java.io.File import java.io.File
import java.security.MessageDigest import java.security.MessageDigest
@ -39,6 +48,97 @@ data class FileDownloadProgress(
} }
object FileSDK { 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( suspend fun downloadToFile(
downloadUrl: String, downloadUrl: String,
@ -284,7 +384,7 @@ object FileSDK {
* 直接 FileOutputStream Android 10+ 无权限EACCES必须走 MediaStore * 直接 FileOutputStream Android 10+ 无权限EACCES必须走 MediaStore
*/ */
@androidx.annotation.RequiresApi(Build.VERSION_CODES.Q) @androidx.annotation.RequiresApi(Build.VERSION_CODES.Q)
private fun saveFileToPublicDownloads(context: Context, file: File, fileName: String) { private fun saveFileToPublicDownloads(context: Context, file: File, fileName: String): Uri {
val mimeType = MimeTypeMap.getSingleton() val mimeType = MimeTypeMap.getSingleton()
.getMimeTypeFromExtension(fileName.substringAfterLast('.', "").lowercase()) .getMimeTypeFromExtension(fileName.substringAfterLast('.', "").lowercase())
?: "application/octet-stream" ?: "application/octet-stream"
@ -308,12 +408,49 @@ object FileSDK {
values.clear() values.clear()
values.put(MediaStore.Downloads.IS_PENDING, 0) values.put(MediaStore.Downloads.IS_PENDING, 0)
resolver.update(uri, values, null, null) resolver.update(uri, values, null, null)
return uri
}
/**
* 将已校验的文件额外导出到系统 Downloads
*
* Android 10+ 使用 MediaStoreAndroid 9 及以下需要宿主先取得
* WRITE_EXTERNAL_STORAGE 运行时权限返回系统可访问的 Uri
*/
fun exportToPublicDownloads(
context: Context,
file: File,
displayName: String = file.name,
): Uri {
require(file.isFile) { "Source file does not exist" }
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
saveFileToPublicDownloads(context, file, displayName)
} else {
if (
ContextCompat.checkSelfPermission(
context,
Manifest.permission.WRITE_EXTERNAL_STORAGE,
) != PackageManager.PERMISSION_GRANTED
) {
throw SecurityException(
"Android 9 及以下导出到 Downloads 需要 WRITE_EXTERNAL_STORAGE 权限",
)
}
@Suppress("DEPRECATION")
val downloads = Environment.getExternalStoragePublicDirectory(
Environment.DIRECTORY_DOWNLOADS,
)
check(downloads.exists() || downloads.mkdirs()) {
"Unable to create public Downloads directory"
}
Uri.fromFile(file.copyTo(uniqueFile(downloads, displayName)))
}
} }
/** /**
* 通过 MediaStore 将文件写入公共 Downloads 目录 * 通过 MediaStore 将文件写入公共 Downloads 目录
* 在华为等设备上直接文件 I/O 会失败MediaStore 通过 ContentResolver 操作兼容性更好 * 在华为等设备上直接文件 I/O 会失败MediaStore 通过 ContentResolver 操作兼容性更好
* 返回缓存目录中的副本便于调用方继续展示分享或交给可选 sdk-file 打开 * 返回缓存目录中的副本便于调用方继续展示分享或通过本对象打开
*/ */
@androidx.annotation.RequiresApi(Build.VERSION_CODES.Q) @androidx.annotation.RequiresApi(Build.VERSION_CODES.Q)
private fun saveBlobViaMediaStore(context: Context, bytes: ByteArray, fileName: String): File { private fun saveBlobViaMediaStore(context: Context, bytes: ByteArray, fileName: String): File {
@ -358,53 +495,16 @@ object FileSDK {
), ),
appName, appName,
) )
// mkdirs() 在部分华为/小米设备上静默失败,多重尝试确保目录存在 check(dir.exists() || dir.mkdirs()) {
if (!dir.exists()) { "Unable to create public Downloads directory"
dir.mkdirs()
} }
if (!dir.exists()) {
runCatching { Runtime.getRuntime().exec(arrayOf("mkdir", "-p", dir.absolutePath)).waitFor() }
}
if (!dir.exists()) {
android.util.Log.w("XWV", "resolveBaseDir: falling back to Sandbox")
context.getExternalFilesDir(null) ?: context.filesDir
} else {
dir dir
} }
}
FileDownloadDestination.Sandbox -> { FileDownloadDestination.Sandbox -> {
context.getExternalFilesDir(null) ?: context.filesDir context.getExternalFilesDir(null) ?: context.filesDir
} }
} }
/**
* 检查是否拥有"所有文件访问权限"MANAGE_EXTERNAL_STORAGE
* Android 11+ 需要此权限才能写入公共 Downloads 目录
* Android 10 及以下始终返回 true使用 WRITE_EXTERNAL_STORAGE
*/
fun isManageStorageGranted(context: Context): Boolean {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
Environment.isExternalStorageManager()
} else {
true
}
}
/**
* 返回跳转到"所有文件访问权限"设置页的 Intent
* 调用方应使用 startActivityForResult registerForActivityResult 处理返回
*/
fun requestManageStorageIntent(context: Context): Intent? {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) return null
return try {
Intent(android.provider.Settings.ACTION_MANAGE_APP_ALL_FILES_ACCESS_PERMISSION).apply {
data = Uri.parse("package:${context.packageName}")
}
} catch (_: Exception) {
Intent(android.provider.Settings.ACTION_MANAGE_ALL_FILES_ACCESS_PERMISSION)
}
}
/** /**
* Saves an image file to the system photo gallery (MediaStore). * Saves an image file to the system photo gallery (MediaStore).
* Returns true if the file is an image and was saved successfully. * Returns true if the file is an image and was saved successfully.

查看文件

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

查看文件

@ -17,7 +17,6 @@ internal data class ConfigFile(
@SerializedName(value = "iosBundleId", alternate = ["ios_bundle_id"]) val iosBundleId: String? = null, @SerializedName(value = "iosBundleId", alternate = ["ios_bundle_id"]) val iosBundleId: String? = null,
@SerializedName(value = "harmonyBundleName", alternate = ["harmony_bundle_name"]) val harmonyBundleName: String? = null, @SerializedName(value = "harmonyBundleName", alternate = ["harmony_bundle_name"]) val harmonyBundleName: String? = null,
@SerializedName(value = "serverUrl", alternate = ["server_url"]) val serverUrl: String, @SerializedName(value = "serverUrl", alternate = ["server_url"]) val serverUrl: String,
@SerializedName(value = "signingKey", alternate = ["signing_key"]) val signingKey: String? = null,
@SerializedName(value = "issuedAt", alternate = ["issued_at"]) val issuedAt: String, @SerializedName(value = "issuedAt", alternate = ["issued_at"]) val issuedAt: String,
@SerializedName(value = "expiresAt", alternate = ["expires_at"]) val expiresAt: String? = null, @SerializedName(value = "expiresAt", alternate = ["expires_at"]) val expiresAt: String? = null,
) )

查看文件

@ -1,7 +1,6 @@
package com.xuqm.sdk.network package com.xuqm.sdk.network
import android.util.Log import android.util.Log
import com.xuqm.sdk.XuqmSDK
import com.xuqm.sdk.auth.TokenStore import com.xuqm.sdk.auth.TokenStore
import com.xuqm.sdk.core.ServiceEndpointRegistry import com.xuqm.sdk.core.ServiceEndpointRegistry
import com.xuqm.sdk.core.LogLevel import com.xuqm.sdk.core.LogLevel
@ -22,10 +21,7 @@ import java.time.Instant
import java.time.LocalDateTime import java.time.LocalDateTime
import java.time.ZoneOffset import java.time.ZoneOffset
import java.time.format.DateTimeFormatter import java.time.format.DateTimeFormatter
import java.util.UUID
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
import javax.crypto.Mac
import javax.crypto.spec.SecretKeySpec
object ApiClient { object ApiClient {
@ -84,32 +80,6 @@ object ApiClient {
} else chain.request() } else chain.request()
chain.proceed(req) chain.proceed(req)
} }
.addInterceptor { chain ->
val request = chain.request()
val signingKey = XuqmSDK.signingKey
if (signingKey.isNullOrBlank()) {
chain.proceed(request)
} else {
val appKey = XuqmSDK.config.appKey
val timestamp = Instant.now().epochSecond.toString()
val nonce = UUID.randomUUID().toString()
val userId = XuqmSDK.getUserId() ?: ""
val payload = "$appKey\n$userId\n$timestamp\n$nonce"
val signature = hmacSha256(signingKey, payload)
val signedRequest = request.newBuilder()
.header("X-App-Key", appKey)
.header("X-Timestamp", timestamp)
.header("X-Nonce", nonce)
.header("X-Signature", signature)
.apply {
if (userId.isNotBlank()) {
header("X-User-Id", userId)
}
}
.build()
chain.proceed(signedRequest)
}
}
.build() .build()
synchronized(this) { synchronized(this) {
@ -117,13 +87,6 @@ object ApiClient {
} }
} }
private fun hmacSha256(secret: String, data: String): String {
val mac = Mac.getInstance("HmacSHA256")
mac.init(SecretKeySpec(secret.toByteArray(Charsets.UTF_8), "HmacSHA256"))
val digest = mac.doFinal(data.toByteArray(Charsets.UTF_8))
return android.util.Base64.encodeToString(digest, android.util.Base64.URL_SAFE or android.util.Base64.NO_PADDING or android.util.Base64.NO_WRAP)
}
fun <T : Any> create(service: Class<T>, baseUrl: String = ServiceEndpointRegistry.controlBaseUrl): T { fun <T : Any> create(service: Class<T>, baseUrl: String = ServiceEndpointRegistry.controlBaseUrl): T {
val retrofit = synchronized(this) { val retrofit = synchronized(this) {
retrofitCache[baseUrl] ?: Retrofit.Builder() retrofitCache[baseUrl] ?: Retrofit.Builder()

查看文件

@ -0,0 +1,102 @@
package com.xuqm.sdk.network
import android.Manifest
import android.annotation.SuppressLint
import android.content.Context
import android.content.pm.PackageManager
import android.net.ConnectivityManager
import android.net.Network
import android.net.NetworkCapabilities
import android.telephony.TelephonyManager
import androidx.core.content.ContextCompat
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
/**
* Xuqm SDK 唯一网络状态来源
*
* [observe] 可以在不初始化 XuqmSDK 的情况下独立使用重复调用不会重复注册系统回调
* 宿主只根据返回状态决定是否提示或允许下载SDK 不替宿主制定 Wi-Fi/流量策略
*/
object NetworkMonitor {
private val mutableStatus = MutableStateFlow(NetworkStatus.Disconnected)
private val lock = Any()
@Volatile
private var connectivityManager: ConnectivityManager? = null
@Volatile
private var applicationContext: Context? = null
private val callback = object : ConnectivityManager.NetworkCallback() {
override fun onAvailable(network: Network) = refresh()
override fun onLost(network: Network) = refresh()
override fun onCapabilitiesChanged(network: Network, capabilities: NetworkCapabilities) = refresh()
}
val status: StateFlow<NetworkStatus> = mutableStatus.asStateFlow()
/** 开始观察并返回当前快照;无需 XuqmSDK 初始化。 */
@JvmStatic
fun observe(context: Context): NetworkStatus {
val manager = synchronized(lock) {
applicationContext = context.applicationContext
connectivityManager ?: (
context.applicationContext.getSystemService(Context.CONNECTIVITY_SERVICE)
as ConnectivityManager
).also { manager ->
manager.registerDefaultNetworkCallback(callback)
connectivityManager = manager
}
}
return resolve(context.applicationContext, manager).also { mutableStatus.value = it }
}
/** 返回最后一次快照;首次使用建议调用 [observe]。 */
@JvmStatic
fun current(): NetworkStatus = mutableStatus.value
private fun refresh() {
val manager = connectivityManager ?: return
// 系统回调只需要及时更新连接类型;蜂窝代际识别失败时安全回退为 CELLULAR。
mutableStatus.value = resolve(applicationContext, manager)
}
private fun resolve(context: Context?, manager: ConnectivityManager): NetworkStatus {
val network = manager.activeNetwork ?: return NetworkStatus.Disconnected
val capabilities = manager.getNetworkCapabilities(network) ?: return NetworkStatus.Disconnected
val connected = capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) &&
capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED)
if (!connected) return NetworkStatus.Disconnected
val type = when {
capabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) -> NetworkType.WIFI
capabilities.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET) -> NetworkType.ETHERNET
capabilities.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) ->
resolveCellularType(context)
else -> NetworkType.OTHER
}
return NetworkStatus(type, isConnected = true, isMetered = manager.isActiveNetworkMetered)
}
@SuppressLint("MissingPermission")
private fun resolveCellularType(context: Context?): NetworkType {
if (context == null ||
ContextCompat.checkSelfPermission(context, Manifest.permission.READ_PHONE_STATE) !=
PackageManager.PERMISSION_GRANTED
) {
return NetworkType.CELLULAR
}
val manager = context.getSystemService(Context.TELEPHONY_SERVICE) as? TelephonyManager
?: return NetworkType.CELLULAR
val networkType = runCatching {
@Suppress("DEPRECATION")
manager.dataNetworkType
}.getOrNull() ?: return NetworkType.CELLULAR
return when (networkType) {
TelephonyManager.NETWORK_TYPE_LTE -> NetworkType.CELLULAR_4G
TelephonyManager.NETWORK_TYPE_NR -> NetworkType.CELLULAR_5G
else -> NetworkType.CELLULAR
}
}
}

查看文件

@ -0,0 +1,31 @@
package com.xuqm.sdk.network
/**
* 当前默认网络的稳定公共模型
*
* 蜂窝网络代际只有在系统允许无额外授权读取时才细分为 4G/5GSDK 不会为了更新检查
* 申请电话或定位权限无法可靠识别时返回 [CELLULAR]
*/
enum class NetworkType {
NONE,
WIFI,
CELLULAR,
CELLULAR_4G,
CELLULAR_5G,
ETHERNET,
OTHER,
}
data class NetworkStatus(
val type: NetworkType,
val isConnected: Boolean,
val isMetered: Boolean,
) {
companion object {
val Disconnected = NetworkStatus(
type = NetworkType.NONE,
isConnected = false,
isMetered = false,
)
}
}

查看文件

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

查看文件

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

查看文件

@ -3,6 +3,7 @@ package com.xuqm.sdk.internal
import org.bouncycastle.jce.provider.BouncyCastleProvider import org.bouncycastle.jce.provider.BouncyCastleProvider
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
import org.junit.Assert.assertThrows import org.junit.Assert.assertThrows
import org.junit.Assert.assertFalse
import org.junit.Test import org.junit.Test
import java.security.KeyPairGenerator import java.security.KeyPairGenerator
import java.security.Signature import java.security.Signature
@ -17,7 +18,7 @@ class ConfigFileCryptoTest {
private val provider = BouncyCastleProvider() private val provider = BouncyCastleProvider()
@Test @Test
fun serverGeneratedV2VectorIsInteroperable() { fun legacyServerVectorRemainsCryptographicallyReadable() {
assertEquals(SERVER_VECTOR_JSON, ConfigFileCrypto.decrypt(SERVER_VECTOR_TOKEN)) assertEquals(SERVER_VECTOR_JSON, ConfigFileCrypto.decrypt(SERVER_VECTOR_TOKEN))
} }
@ -28,6 +29,13 @@ class ConfigFileCryptoTest {
assertEquals(fixture.json, ConfigFileCrypto.decrypt(fixture.content, mapOf("test" to fixture.publicKey))) assertEquals(fixture.json, ConfigFileCrypto.decrypt(fixture.content, mapOf("test" to fixture.publicKey)))
} }
@Test
fun configDomainModelDoesNotExposeClientSecret() {
val fields = ConfigFile::class.java.declaredFields.map { it.name }.toSet()
assertFalse("signingKey" in fields)
assertFalse("appSecret" in fields)
}
@Test @Test
fun tamperedCiphertextIsRejectedBySignature() { fun tamperedCiphertextIsRejectedBySignature() {
val fixture = fixture() val fixture = fixture()
@ -53,7 +61,7 @@ class ConfigFileCryptoTest {
private fun fixture(): Fixture { private fun fixture(): Fixture {
val json = val json =
"""{"appKey":"ak_test","appName":"Test","configId":"123e4567-e89b-12d3-a456-426614174000","issuedAt":"2026-07-26T00:00:00Z","revision":1,"schemaVersion":2,"serverUrl":"https://dev.example.com/","signingKey":"fixture"}""" """{"appKey":"ak_test","appName":"Test","configId":"123e4567-e89b-12d3-a456-426614174000","issuedAt":"2026-07-26T00:00:00Z","revision":1,"schemaVersion":2,"serverUrl":"https://dev.example.com/"}"""
val salt = ByteArray(16) { (it + 1).toByte() } val salt = ByteArray(16) { (it + 1).toByte() }
val iv = ByteArray(12) { (it + 17).toByte() } val iv = ByteArray(12) { (it + 17).toByte() }
val keySpec = PBEKeySpec( val keySpec = PBEKeySpec(
@ -95,8 +103,8 @@ class ConfigFileCryptoTest {
companion object { companion object {
/** /**
* 由服务端 ConfigFileCrypto.encryptAndSign 使用开发测试密钥真实生成 * 历史跨端向量只验证 V2 密码协议明文中的旧 signingKey 已不进入 ConfigFile
* 测试向量仅包含公开配置公钥可验证签名和密文不包含私钥 * 领域模型新签发配置由服务端测试明确断言不含客户端秘密
*/ */
private const val SERVER_VECTOR_JSON = private const val SERVER_VECTOR_JSON =
"""{"appKey":"ak_vector","appName":"Vector App","configId":"00000000-0000-4000-8000-000000000001","expiresAt":"2030-01-01T00:00:00Z","issuedAt":"2026-07-26T00:00:00Z","packageName":"com.xuqm.vector","revision":1,"schemaVersion":2,"serverUrl":"https://dev.xuqinmin.com/","signingKey":"test-signing-key"}""" """{"appKey":"ak_vector","appName":"Vector App","configId":"00000000-0000-4000-8000-000000000001","expiresAt":"2030-01-01T00:00:00Z","issuedAt":"2026-07-26T00:00:00Z","packageName":"com.xuqm.vector","revision":1,"schemaVersion":2,"serverUrl":"https://dev.xuqinmin.com/","signingKey":"test-signing-key"}"""

查看文件

@ -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 { dependencies {
api(project(":sdk-file")) api(project(":sdk-core"))
implementation(libs.kotlinx.coroutines.android) implementation(libs.kotlinx.coroutines.android)
testImplementation(libs.junit4) 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.UserProfile
import com.xuqm.sdk.im.model.BlacklistEntry import com.xuqm.sdk.im.model.BlacklistEntry
import com.xuqm.sdk.im.model.FriendRequest import com.xuqm.sdk.im.model.FriendRequest
import com.xuqm.sdk.file.platform.PlatformFileSDK import com.xuqm.sdk.file.FileSDK
import com.xuqm.sdk.file.platform.FileUploadResult import com.xuqm.sdk.file.FileUploadResult
import com.xuqm.sdk.network.ApiClient import com.xuqm.sdk.network.ApiClient
import android.util.Log import android.util.Log
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
@ -164,7 +164,7 @@ object ImSDK {
width: Int? = null, width: Int? = null,
height: Int? = null, height: Int? = null,
): ImMessage = withContext(Dispatchers.IO) { ): ImMessage = withContext(Dispatchers.IO) {
val result = PlatformFileSDK.upload(file) val result = FileSDK.upload(file)
sendImageMessageWithUploadResult(toId, chatType, result, width, height) sendImageMessageWithUploadResult(toId, chatType, result, width, height)
} }
@ -200,7 +200,7 @@ object ImSDK {
height: Int? = null, height: Int? = null,
durationMs: Long? = null, durationMs: Long? = null,
): ImMessage = withContext(Dispatchers.IO) { ): ImMessage = withContext(Dispatchers.IO) {
val result = PlatformFileSDK.upload(file) val result = FileSDK.upload(file)
sendVideoMessageWithUploadResult(toId, chatType, result, width, height, durationMs) sendVideoMessageWithUploadResult(toId, chatType, result, width, height, durationMs)
} }
@ -235,7 +235,7 @@ object ImSDK {
chatType: String, chatType: String,
file: File, file: File,
): ImMessage = withContext(Dispatchers.IO) { ): ImMessage = withContext(Dispatchers.IO) {
val result = PlatformFileSDK.upload(file) val result = FileSDK.upload(file)
sendFileMessageWithUploadResult(toId, chatType, result) sendFileMessageWithUploadResult(toId, chatType, result)
} }
@ -264,7 +264,7 @@ object ImSDK {
file: File, file: File,
durationMs: Long? = null, durationMs: Long? = null,
): ImMessage = withContext(Dispatchers.IO) { ): ImMessage = withContext(Dispatchers.IO) {
val result = PlatformFileSDK.upload(file) val result = FileSDK.upload(file)
sendAudioMessageWithUploadResult(toId, chatType, result, durationMs) sendAudioMessageWithUploadResult(toId, chatType, result, durationMs)
} }

查看文件

@ -14,10 +14,11 @@ implementation("com.xuqm:sdk-core:VERSION") // 必须
```kotlin ```kotlin
val result = UpdateSDK.checkAppUpdate(context) val result = UpdateSDK.checkAppUpdate(context)
if (result.needsUpdate) { if (result.status == UpdateCheckStatus.UPDATE_AVAILABLE) {
val update = requireNotNull(result.update)
UpdateSDK.downloadAndInstallApk( UpdateSDK.downloadAndInstallApk(
context, context,
ApkInstallRequest(result.downloadUrl, result.versionCode, requireNotNull(result.apkHash)), ApkInstallRequest(update.downloadUrl, update.versionCode, requireNotNull(update.apkHash)),
) )
} }
``` ```
@ -27,26 +28,32 @@ if (result.needsUpdate) {
| API | 说明 | | API | 说明 |
|-----|------| |-----|------|
| `UpdateSDK.checkAppUpdate(context, bypassIgnore)` | 检查 App 更新 | | `UpdateSDK.checkAppUpdate(context, bypassIgnore)` | 检查 App 更新 |
| `UpdateSDK.downloadApk(context, request, onProgress)` | 只下载并校验,可选额外保存到系统 Downloads |
| `UpdateSDK.installDownloadedApk(context, versionCode, sha256)` | 安装当前检查已授权且校验通过的 APK |
| `UpdateSDK.downloadAndInstallApk(context, request, onProgress)` | 下载、校验并调起系统安装器 | | `UpdateSDK.downloadAndInstallApk(context, request, onProgress)` | 下载、校验并调起系统安装器 |
| `UpdateSDK.openStore(context, updateInfo)` | 优先打开设备商店,失败时使用平台通用下载页 |
| `UpdateSDK.setLoginUpdateListener(listener)` | 接收登录后自动补检结果 | | `UpdateSDK.setLoginUpdateListener(listener)` | 接收登录后自动补检结果 |
### UpdateResult ### UpdateCheckResult
```kotlin ```kotlin
data class UpdateResult( data class UpdateCheckResult(
val needsUpdate: Boolean, val status: UpdateCheckStatus,
val versionName: String?, val network: NetworkStatus,
val versionCode: Int?, val update: UpdateInfo?,
val downloadUrl: String?, val errorCode: String?,
val changeLog: String?, val message: String?,
val forceUpdate: Boolean?,
) )
``` ```
## 工作原理 ## 工作原理
- `downloadAndInstall` 将 APK 下载到 `getExternalFilesDir(null)`,通过 `FileProvider` 触发系统安装 - `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 私有安装源
- 支持 WebSocket 实时推送更新通知 - 支持 WebSocket 实时推送更新通知
- 是否要求登录由租户平台配置决定;要求登录时,未登录检查会静默跳过 - 是否要求登录由租户平台配置决定;要求登录时返回 `LOGIN_REQUIRED`,不抛错、不阻断宿主
- `network` 只提供当前网络事实,是否允许蜂窝网络下载完全由宿主决定
- 账号切换会取消旧会话检查并清理灰度判断缓存,新会话只自动补检一次 - 账号切换会取消旧会话检查并清理灰度判断缓存,新会话只自动补检一次

查看文件

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

查看文件

@ -1,389 +0,0 @@
/**
* XuqmGroup Update Service — Android Gradle Release Task
*
* Copy this file to your app module directory and apply it:
* apply(from = "xuqm_release.gradle.kts")
*
* Then run:
* ./gradlew xuqmRelease
*
* Config: xuqm.properties in the project root (or module root)
* ---
* xuqm.serverUrl=https://update.dev.xuqinmin.com
* xuqm.appKey=your-app-key
* xuqm.apiToken=your-api-token
* xuqm.storeTargets=HUAWEI,MI,OPPO # optional
* xuqm.autoPublishAfterReview=false
* xuqm.publishImmediately=false # optional: publish update-service record immediately
* xuqm.scheduledPublishAt= # optional ISO datetime
* xuqm.webhookUrl= # optional webhook for review status changes
* ---
*
* The task:
* 1. Reads versionName / versionCode from the android extension
* 2. Checks the latest version on the update server
* 3. Aborts if the local versionCode is not greater than the server's
* 4. Assembles the release APK (assembleRelease)
* 5. Uploads the APK + metadata to the update service as a DRAFT
* 6. Optionally triggers server-side store submission
*/
import org.gradle.api.DefaultTask
import org.gradle.api.GradleException
import org.gradle.api.tasks.TaskAction
import java.io.File
import java.net.URI
import java.net.http.HttpClient
import java.net.http.HttpRequest
import java.net.http.HttpResponse
import java.util.Properties
import java.util.UUID
// ── Config loading ────────────────────────────────────────────────────────
fun loadXuqmConfig(projectDir: File): Properties {
val props = Properties()
listOf(
File(projectDir, "xuqm.properties"),
File(projectDir.parentFile, "xuqm.properties"),
).firstOrNull { it.exists() }?.inputStream()?.use(props::load)
?: throw GradleException("xuqm.properties not found in module or project root")
return props
}
// ── HTTP helper ───────────────────────────────────────────────────────────
fun httpGet(url: String, token: String): String {
val client = HttpClient.newHttpClient()
val builder = HttpRequest.newBuilder(URI.create(url))
if (token.isNotBlank()) {
builder.header("Authorization", "Bearer $token")
}
val req = builder.GET().build()
return client.send(req, HttpResponse.BodyHandlers.ofString()).body()
}
/**
* Multipart POST — minimal implementation without external dependencies.
* Returns response body string.
*/
fun httpMultipartPost(url: String, token: String, parts: Map<String, Any>): String {
val boundary = UUID.randomUUID().toString()
val baos = java.io.ByteArrayOutputStream()
fun writeln(s: String) = baos.write(("$s\r\n").toByteArray())
for ((name, value) in parts) {
writeln("--$boundary")
when (value) {
is File -> {
writeln("Content-Disposition: form-data; name=\"$name\"; filename=\"${value.name}\"")
writeln("Content-Type: application/octet-stream")
writeln("")
baos.write(value.readBytes())
writeln("")
}
else -> {
writeln("Content-Disposition: form-data; name=\"$name\"")
writeln("")
writeln(value.toString())
}
}
}
writeln("--$boundary--")
val body = baos.toByteArray()
val client = HttpClient.newHttpClient()
val builder = HttpRequest.newBuilder(URI.create(url))
.header("Content-Type", "multipart/form-data; boundary=$boundary")
if (token.isNotBlank()) {
builder.header("Authorization", "Bearer $token")
}
val req = builder.POST(HttpRequest.BodyPublishers.ofByteArray(body)).build()
return client.send(req, HttpResponse.BodyHandlers.ofString()).body()
}
fun parseJson(json: String, key: String): String? =
Regex("\"$key\"\\s*:\\s*\"([^\"]+)\"").find(json)?.groupValues?.get(1)
fun parseJsonInt(json: String, key: String): Int? =
Regex("\"$key\"\\s*:\\s*(\\d+)").find(json)?.groupValues?.get(1)?.toIntOrNull()
fun parseJsonBool(json: String, key: String, default: Boolean = false): Boolean =
Regex("\"$key\"\\s*:\\s*(true|false)").find(json)?.groupValues?.get(1)?.toBooleanStrictOrNull() ?: default
fun parseCsv(value: String?): List<String> =
value?.split(",")?.map { it.trim() }?.filter { it.isNotBlank() } ?: emptyList()
fun promptLine(message: String): String? {
val console = System.console() ?: return null
print(message)
return console.readLine()
}
fun promptYesNo(message: String, default: Boolean = false): Boolean {
val suffix = if (default) " [Y/n]: " else " [y/N]: "
val answer = promptLine(message + suffix)?.trim().orEmpty()
return when {
answer.isBlank() -> default
answer.equals("y", ignoreCase = true) || answer.equals("yes", ignoreCase = true) -> true
answer.equals("n", ignoreCase = true) || answer.equals("no", ignoreCase = true) -> false
else -> default
}
}
fun promptReleaseMode(): String {
val answer = promptLine(
"Publish mode? [1=manual, 2=publish now, 3=scheduled, 4=auto after review, 5=dry-run]: "
)?.trim().orEmpty()
return when (answer) {
"2" -> "NOW"
"3" -> "SCHEDULED"
"4" -> "AUTO_REVIEW"
"5" -> "DRY_RUN"
else -> "MANUAL"
}
}
data class ReleaseDefaults(
val enabled: Boolean = true,
val defaultStoreTargets: List<String> = emptyList(),
val defaultPublishMode: String = "MANUAL",
val defaultPublishImmediately: Boolean = false,
val defaultScheduledPublishAt: String = "",
val defaultAutoPublishAfterReview: Boolean = false,
val defaultWebhookUrl: String = "",
val defaultForceUpdate: Boolean = false,
val defaultGrayEnabled: Boolean = false,
val defaultGrayPercent: Int = 0,
val defaultPackageName: String = "",
val defaultAppStoreUrl: String = "",
val defaultMarketUrl: String = "",
)
fun fetchReleaseDefaults(tenantUrl: String, appKey: String): ReleaseDefaults? {
if (tenantUrl.isBlank()) return null
val url = "$tenantUrl/api/sdk/config?appKey=$appKey&platform=ANDROID"
val json = runCatching { httpGet(url, "") }.getOrNull() ?: return null
if (!parseJsonBool(json, "updateEnabled", false)) return ReleaseDefaults(enabled = false)
return ReleaseDefaults(
enabled = true,
defaultStoreTargets = parseCsv(parseJson(json, "updateDefaultStoreTargets")),
defaultPublishMode = parseJson(json, "updateDefaultPublishMode") ?: "MANUAL",
defaultPublishImmediately = parseJsonBool(json, "updateDefaultPublishImmediately"),
defaultScheduledPublishAt = parseJson(json, "updateDefaultScheduledPublishAt").orEmpty(),
defaultAutoPublishAfterReview = parseJsonBool(json, "updateDefaultAutoPublishAfterReview"),
defaultWebhookUrl = parseJson(json, "updateDefaultWebhookUrl").orEmpty(),
defaultForceUpdate = parseJsonBool(json, "updateDefaultForceUpdate"),
defaultGrayEnabled = parseJsonBool(json, "updateDefaultGrayEnabled"),
defaultGrayPercent = parseJsonInt(json, "updateDefaultGrayPercent") ?: 0,
defaultPackageName = parseJson(json, "updateDefaultPackageName").orEmpty(),
defaultAppStoreUrl = parseJson(json, "updateDefaultAppStoreUrl").orEmpty(),
defaultMarketUrl = parseJson(json, "updateDefaultMarketUrl").orEmpty(),
)
}
// ── Task registration ─────────────────────────────────────────────────────
tasks.register("xuqmRelease") {
group = "xuqm"
description = "Build release APK and upload to XuqmGroup Update Service"
// Ensure assembleRelease runs first
dependsOn("assembleRelease")
doLast {
val cfg = loadXuqmConfig(projectDir)
val serverUrl = cfg.getProperty("xuqm.serverUrl") ?: throw GradleException("xuqm.serverUrl missing")
val appKey = cfg.getProperty("xuqm.appKey") ?: throw GradleException("xuqm.appKey missing")
val apiToken = cfg.getProperty("xuqm.apiToken") ?: throw GradleException("xuqm.apiToken missing")
val tenantUrl = cfg.getProperty("xuqm.tenantUrl", "").trim()
val dryRunMode = cfg.getProperty("xuqm.dryRun", "false").toBoolean()
val tenantDefaults = fetchReleaseDefaults(tenantUrl, appKey)
val updateEnabled = tenantDefaults?.enabled ?: true
val defaultStoreTargets = tenantDefaults?.defaultStoreTargets?.joinToString(",").orEmpty()
val defaultPublishMode = tenantDefaults?.defaultPublishMode ?: "MANUAL"
val defaultPublishImmediately = tenantDefaults?.defaultPublishImmediately ?: false
val defaultScheduledAt = tenantDefaults?.defaultScheduledPublishAt.orEmpty()
val defaultAutoPublishAfterReview = tenantDefaults?.defaultAutoPublishAfterReview ?: false
val defaultWebhookUrl = tenantDefaults?.defaultWebhookUrl.orEmpty()
val defaultForceUpdate = tenantDefaults?.defaultForceUpdate ?: false
val defaultGrayEnabled = tenantDefaults?.defaultGrayEnabled ?: false
val defaultGrayPercent = tenantDefaults?.defaultGrayPercent ?: 0
var storeTargets = cfg.getProperty("xuqm.storeTargets", defaultStoreTargets)
var autoPublish = cfg.getProperty("xuqm.autoPublishAfterReview", defaultAutoPublishAfterReview.toString()).toBoolean()
var publishImmediately = cfg.getProperty("xuqm.publishImmediately", defaultPublishImmediately.toString()).toBoolean()
var scheduledAt = cfg.getProperty("xuqm.scheduledPublishAt", defaultScheduledAt)
var webhookUrl = cfg.getProperty("xuqm.webhookUrl", defaultWebhookUrl)
var publishMode = cfg.getProperty("xuqm.publishMode", defaultPublishMode).trim().uppercase()
val forceUpdate = cfg.getProperty("xuqm.forceUpdate", defaultForceUpdate.toString()).toBoolean()
val grayEnabled = cfg.getProperty("xuqm.grayEnabled", defaultGrayEnabled.toString()).toBoolean()
val grayPercent = cfg.getProperty("xuqm.grayPercent", defaultGrayPercent.toString()).toIntOrNull()?.coerceIn(1, 100) ?: defaultGrayPercent
val allowVersionMismatch = cfg.getProperty("xuqm.allowVersionMismatch", "false").toBoolean()
var dryRun = dryRunMode || publishMode == "DRY_RUN" || !updateEnabled
if (publishMode.isBlank() && !publishImmediately && scheduledAt.isBlank() && !autoPublish && System.console() != null) {
publishMode = promptReleaseMode()
if (publishMode == "SCHEDULED" && scheduledAt.isBlank()) {
scheduledAt = promptLine("Scheduled publish time (ISO datetime): ")?.trim().orEmpty()
}
}
if (publishMode == "DRY_RUN") {
dryRun = true
println("[xuqm] Dry-run mode enabled: release will stop after validation and packaging.")
}
if (scheduledAt.isNotBlank()) {
publishImmediately = false
autoPublish = false
}
if (publishMode == "NOW") {
publishImmediately = true
autoPublish = false
} else if (publishMode == "AUTO_REVIEW") {
autoPublish = true
publishImmediately = false
}
// ── 1. Read local version ──────────────────────────────────────────
val android = project.extensions.findByName("android")
?: throw GradleException("This task must run in an Android module")
val defaultConfig = android::class.java.getMethod("getDefaultConfig").invoke(android)
val versionName = defaultConfig::class.java.getMethod("getVersionName").invoke(defaultConfig) as? String
?: throw GradleException("versionName not set")
val versionCode = (defaultConfig::class.java.getMethod("getVersionCode").invoke(defaultConfig) as? Int)
?: throw GradleException("versionCode not set")
val applicationId = defaultConfig::class.java.getMethod("getApplicationId").invoke(defaultConfig) as? String ?: ""
println("[xuqm] Local version: $versionName ($versionCode), packageName: $applicationId")
// ── 2. Check server latest ─────────────────────────────────────────
val listResp = httpGet("$serverUrl/api/v1/updates/app/list?appKey=$appKey&platform=ANDROID", apiToken)
// Find highest published versionCode
val serverVersionCode = Regex("\"versionCode\"\\s*:\\s*(\\d+)").findAll(listResp)
.mapNotNull { it.groupValues[1].toIntOrNull() }
.maxOrNull() ?: 0
println("[xuqm] Server latest versionCode: $serverVersionCode")
var releaseVersionName = versionName
var releaseVersionCode = versionCode
if (releaseVersionCode <= serverVersionCode) {
if (System.console() != null && !allowVersionMismatch) {
println("[xuqm] Local version is not greater than server latest. Please enter corrected release version info.")
val inputVersionName = promptLine("Release versionName [$versionName]: ")?.trim().orEmpty()
val inputVersionCode = promptLine("Release versionCode [$versionCode]: ")?.trim().orEmpty()
if (inputVersionName.isNotBlank()) releaseVersionName = inputVersionName
if (inputVersionCode.isNotBlank()) releaseVersionCode = inputVersionCode.toInt()
}
if (releaseVersionCode <= serverVersionCode) {
throw GradleException(
"[xuqm] Release versionCode ($releaseVersionCode) must be greater than server ($serverVersionCode). " +
"Bump versionCode in build.gradle.kts or pass xuqm.allowVersionMismatch=true if you deliberately override."
)
}
}
if (releaseVersionName != versionName || releaseVersionCode != versionCode) {
println("[xuqm] Using release override: $releaseVersionName ($releaseVersionCode)")
}
if (storeTargets.isBlank() && System.console() != null && !dryRun) {
val inputTargets = promptLine("Store targets (comma separated, blank for none): ")?.trim().orEmpty()
if (inputTargets.isNotBlank()) {
storeTargets = inputTargets
}
}
val storeTargetList = parseCsv(storeTargets)
// ── 3. Locate APK ─────────────────────────────────────────────────
val apkDir = File(buildDir, "outputs/apk/release")
val apkFile = apkDir.listFiles { f -> f.extension == "apk" }?.firstOrNull()
?: throw GradleException("No APK found in ${apkDir.absolutePath}. Did assembleRelease succeed?")
println("[xuqm] APK prepared: ${apkFile.name}")
if (dryRun) {
println("[xuqm] Dry-run summary:")
println(" updateEnabled=$updateEnabled")
println(" releaseVersion=$releaseVersionName ($releaseVersionCode)")
println(" publishMode=$publishMode")
println(" publishImmediately=$publishImmediately")
println(" autoPublishAfterReview=$autoPublish")
println(" scheduledPublishAt=${scheduledAt.ifBlank { "-" }}")
println(" webhookConfigured=${webhookUrl.isNotBlank()}")
println(" forceUpdate=$forceUpdate")
println(" grayEnabled=$grayEnabled, grayPercent=$grayPercent")
println(" storeTargets=${storeTargetList.joinToString(",").ifBlank { "-" }}")
println(" packageName=${applicationId.ifBlank { "-" }}")
println("[xuqm] Dry-run completed. No upload performed.")
return@doLast
}
// ── 4. Upload to update service ───────────────────────────────────
val parts = mutableMapOf<String, Any>(
"appKey" to appKey,
"platform" to "ANDROID",
"versionName" to releaseVersionName,
"versionCode" to releaseVersionCode,
"forceUpdate" to forceUpdate.toString(),
"autoPublishAfterReview" to autoPublish.toString(),
"apkFile" to apkFile,
)
if (applicationId.isNotBlank()) parts["packageName"] = applicationId
if (storeTargetList.isNotEmpty()) parts["storeSubmitTargets"] = "[\"${storeTargetList.joinToString("\",\"")}\"]"
if (scheduledAt.isNotBlank()) parts["scheduledPublishAt"] = scheduledAt
if (webhookUrl.isNotBlank()) parts["webhookUrl"] = webhookUrl
if (publishMode == "NOW") parts["publishImmediately"] = "true"
if (publishMode == "AUTO_REVIEW") parts["autoPublishAfterReview"] = "true"
println("[xuqm] Uploading APK...")
val uploadResp = httpMultipartPost("$serverUrl/api/v1/updates/app/upload", apiToken, parts)
val versionId = parseJson(uploadResp, "id")
?: throw GradleException("[xuqm] Upload failed:\n$uploadResp")
println("[xuqm] Uploaded, version ID: $versionId")
// ── 5. Trigger server-side store submission ────────────────────────
if (storeTargetList.isNotEmpty()) {
println("[xuqm] Triggering store submission: ${storeTargetList.joinToString(",")} ...")
val storeBody = """{"storeTypes":[${storeTargetList.joinToString(",") { "\"$it\"" }}]}"""
val client = HttpClient.newHttpClient()
val req = HttpRequest.newBuilder(URI.create("$serverUrl/api/v1/updates/store/app/$versionId/execute-submit"))
.header("Authorization", "Bearer $apiToken")
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(storeBody))
.build()
val storeResp = client.send(req, HttpResponse.BodyHandlers.ofString())
println("[xuqm] Store submission triggered (HTTP ${storeResp.statusCode()})")
}
if (grayEnabled && (publishImmediately || publishMode == "NOW")) {
val client = HttpClient.newHttpClient()
val grayBody = """{"enabled":true,"percent":$grayPercent}"""
val req = HttpRequest.newBuilder(URI.create("$serverUrl/api/v1/updates/app/$versionId/gray"))
.header("Authorization", "Bearer $apiToken")
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(grayBody))
.build()
val grayResp = client.send(req, HttpResponse.BodyHandlers.ofString())
println("[xuqm] Gray release configured (HTTP ${grayResp.statusCode()})")
}
if (publishImmediately || publishMode == "NOW") {
println("[xuqm] Published immediately in update service.")
} else if (publishMode == "SCHEDULED" || scheduledAt.isNotBlank()) {
println("[xuqm] Will auto-publish at: ${scheduledAt.ifBlank { "(use update service scheduled publish config)" }}")
} else if (autoPublish) {
println("[xuqm] Will auto-publish after all store reviews pass.")
} else if (System.console() != null && promptYesNo("[xuqm] Publish now?", false)) {
val client = HttpClient.newHttpClient()
val req = HttpRequest.newBuilder(URI.create("$serverUrl/api/v1/updates/app/$versionId/publish"))
.header("Authorization", "Bearer $apiToken")
.POST(HttpRequest.BodyPublishers.noBody())
.build()
val publishResp = client.send(req, HttpResponse.BodyHandlers.ofString())
println("[xuqm] Publish HTTP ${publishResp.statusCode()}")
}
println("[xuqm] Done. Version is in DRAFT state.")
if (!publishImmediately && publishMode != "NOW" && scheduledAt.isBlank() && !autoPublish) {
println("[xuqm] Publish manually: POST $serverUrl/api/v1/updates/app/$versionId/publish")
}
}
}

查看文件

@ -8,17 +8,23 @@ import android.os.Handler
import android.os.Looper import android.os.Looper
import android.provider.Settings import android.provider.Settings
import android.util.Log import android.util.Log
import androidx.core.content.FileProvider
import com.xuqm.sdk.XuqmSDK import com.xuqm.sdk.XuqmSDK
import com.xuqm.sdk.XuqmInitializationState import com.xuqm.sdk.XuqmInitializationState
import com.xuqm.sdk.XuqmLoginSession import com.xuqm.sdk.XuqmLoginSession
import com.xuqm.sdk.file.FileSDK import com.xuqm.sdk.file.FileSDK
import com.xuqm.sdk.core.ServiceEndpointRegistry import com.xuqm.sdk.core.ServiceEndpointRegistry
import com.xuqm.sdk.network.ApiClient import com.xuqm.sdk.network.ApiClient
import com.xuqm.sdk.network.NetworkMonitor
import com.xuqm.sdk.update.api.UpdateApi import com.xuqm.sdk.update.api.UpdateApi
import com.xuqm.sdk.update.model.ApkInstallRequest import com.xuqm.sdk.update.model.ApkInstallRequest
import com.xuqm.sdk.update.model.UpdateDownloadProgress import com.xuqm.sdk.update.model.UpdateDownloadProgress
import com.xuqm.sdk.update.model.DownloadedApk
import com.xuqm.sdk.update.model.UpdateDownloadDestination
import com.xuqm.sdk.update.model.UpdateCheckCallback
import com.xuqm.sdk.update.model.UpdateDownloadCallback
import com.xuqm.sdk.update.model.UpdateInfo import com.xuqm.sdk.update.model.UpdateInfo
import com.xuqm.sdk.update.model.UpdateCheckResult
import com.xuqm.sdk.update.model.UpdateCheckStatus
import com.xuqm.sdk.update.model.UpdateInstallCallback import com.xuqm.sdk.update.model.UpdateInstallCallback
import com.xuqm.sdk.update.model.UpdateInstallErrorCode import com.xuqm.sdk.update.model.UpdateInstallErrorCode
import com.xuqm.sdk.update.model.UpdateInstallException import com.xuqm.sdk.update.model.UpdateInstallException
@ -112,8 +118,8 @@ object UpdateSDK {
val listener = loginUpdateListener ?: return@launch val listener = loginUpdateListener ?: return@launch
Handler(Looper.getMainLooper()).post { Handler(Looper.getMainLooper()).post {
runCatching { runCatching {
if (result?.needsUpdate == true) { if (result.status == UpdateCheckStatus.UPDATE_AVAILABLE && result.update != null) {
listener.onUpdateAvailable(result) listener.onUpdateAvailable(result.update)
} else { } else {
listener.onNoUpdate() listener.onNoUpdate()
} }
@ -235,12 +241,82 @@ object UpdateSDK {
* 返回的 [UpdateInfo.alreadyDownloaded] true 可直接调用 [installDownloadedApk] 安装 * 返回的 [UpdateInfo.alreadyDownloaded] true 可直接调用 [installDownloadedApk] 安装
* 无需再次调用 [downloadAndInstall] * 无需再次调用 [downloadAndInstall]
*/ */
suspend fun checkAppUpdate(context: Context, bypassIgnore: Boolean = false): UpdateInfo? = withContext(Dispatchers.IO) { suspend fun checkAppUpdate(
awaitInitialization() context: Context,
if (!UpdateLoginPolicy.shouldCheck(XuqmSDK.platformConfig, resolveUserId())) { bypassIgnore: Boolean = false,
// 平台明确要求登录时,未登录属于“跳过”,不是错误。 ): UpdateCheckResult = withContext(Dispatchers.IO) {
return@withContext null val network = NetworkMonitor.observe(context)
if (!network.isConnected) {
return@withContext UpdateCheckResult(
status = UpdateCheckStatus.FAILED,
network = network,
errorCode = "NETWORK_UNAVAILABLE",
message = "当前网络不可用",
)
} }
try {
awaitInitialization()
} catch (error: Throwable) {
return@withContext UpdateCheckResult(
status = UpdateCheckStatus.FAILED,
network = network,
errorCode = "SDK_NOT_READY",
message = error.message ?: "SDK 初始化未完成",
)
}
if (!UpdateLoginPolicy.isEnabled(XuqmSDK.platformConfig)) {
return@withContext UpdateCheckResult(
status = UpdateCheckStatus.SERVICE_DISABLED,
network = network,
message = "更新服务未启用",
)
}
if (!UpdateLoginPolicy.shouldCheck(XuqmSDK.platformConfig, resolveUserId())) {
return@withContext UpdateCheckResult(
status = UpdateCheckStatus.LOGIN_REQUIRED,
network = network,
message = "当前发布策略要求登录后检查更新",
)
}
val info = try {
checkAppUpdateInfo(context, bypassIgnore)
} catch (error: UpdateCheckException) {
return@withContext UpdateCheckResult(
status = if (error.code == "SERVICE_DISABLED") {
UpdateCheckStatus.SERVICE_DISABLED
} else {
UpdateCheckStatus.FAILED
},
network = network,
errorCode = error.code,
message = error.message,
)
} catch (error: Throwable) {
return@withContext UpdateCheckResult(
status = UpdateCheckStatus.FAILED,
network = network,
errorCode = "UPDATE_CHECK_FAILED",
message = error.message ?: "更新检查失败",
)
}
return@withContext when {
info.needsUpdate -> UpdateCheckResult(
status = UpdateCheckStatus.UPDATE_AVAILABLE,
network = network,
update = info,
)
else -> UpdateCheckResult(
status = UpdateCheckStatus.NO_UPDATE,
network = network,
update = info,
)
}
}
private suspend fun checkAppUpdateInfo(
context: Context,
bypassIgnore: Boolean,
): UpdateInfo {
val packageInfo = context.packageManager.getPackageInfo(context.packageName, 0) val packageInfo = context.packageManager.getPackageInfo(context.packageName, 0)
val versionCode = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { val versionCode = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
packageInfo.longVersionCode.toInt() packageInfo.longVersionCode.toInt()
@ -263,17 +339,17 @@ object UpdateSDK {
cached cached
} }
if (afterIgnore.needsUpdate && afterIgnore.versionCode > 0) { if (afterIgnore.needsUpdate && afterIgnore.versionCode > 0) {
return@withContext authorizeInstall( return authorizeInstall(
afterIgnore.copy( afterIgnore.copy(
alreadyDownloaded = isVerifiedApkDownloaded(context, afterIgnore), alreadyDownloaded = isVerifiedApkDownloaded(context, afterIgnore),
), ),
) )
} }
return@withContext authorizeInstall(afterIgnore) return authorizeInstall(afterIgnore)
} }
} }
runCatching { return try {
val deviceInfo = com.xuqm.sdk.utils.DeviceUtils.getDeviceInfo() val deviceInfo = com.xuqm.sdk.utils.DeviceUtils.getDeviceInfo()
val versionName = try { val versionName = try {
context.packageManager.getPackageInfo(context.packageName, 0).versionName ?: "" context.packageManager.getPackageInfo(context.packageName, 0).versionName ?: ""
@ -290,7 +366,7 @@ object UpdateSDK {
currentVersionName = versionName, currentVersionName = versionName,
) )
if (response.code == 40404) { if (response.code == 40404) {
throw IllegalStateException("更新服务未开通 (code=40404)") throw UpdateCheckException("SERVICE_DISABLED", response.message)
} }
response.data?.toUpdateInfo()?.let { info -> response.data?.toUpdateInfo()?.let { info ->
val normalized = info.copy(downloadUrl = normalizeDownloadUrl(info.downloadUrl) ?: info.downloadUrl) val normalized = info.copy(downloadUrl = normalizeDownloadUrl(info.downloadUrl) ?: info.downloadUrl)
@ -311,13 +387,17 @@ object UpdateSDK {
// Cache the result (30 min TTL) // Cache the result (30 min TTL)
putCachedUpdateInfo(prefs, cacheKey, result) putCachedUpdateInfo(prefs, cacheKey, result)
authorizeInstall(result) authorizeInstall(result)
} } ?: throw UpdateCheckException(
}.onFailure { e -> code = "INVALID_RESPONSE",
message = response.message.ifBlank { "更新服务返回空数据" },
)
} catch (error: Throwable) {
Log.e( Log.e(
"UpdateSDK", "UpdateSDK",
"Update check failed: versionCode=$versionCode errorType=${e.javaClass.simpleName}", "Update check failed: versionCode=$versionCode errorType=${error.javaClass.simpleName}",
) )
}.getOrNull() throw error
}
} }
private suspend fun awaitInitialization() { private suspend fun awaitInitialization() {
@ -341,6 +421,17 @@ object UpdateSDK {
Log.d("UpdateSDK", "XuqmSDK initialized, proceeding with update check") Log.d("UpdateSDK", "XuqmSDK initialized, proceeding with update check")
} }
/** Java/RN 检查入口;结果始终回到主线程且不会以异常代替业务状态。 */
@JvmStatic
fun startCheckAppUpdate(
context: Context,
bypassIgnore: Boolean,
callback: UpdateCheckCallback,
): Job = backgroundScope.launch {
val result = checkAppUpdate(context.applicationContext, bypassIgnore)
Handler(Looper.getMainLooper()).post { callback.onResult(result) }
}
private fun invalidateSessionChecks(context: Context) { private fun invalidateSessionChecks(context: Context) {
val prefs = prefs(context) val prefs = prefs(context)
val editor = prefs.edit() val editor = prefs.edit()
@ -354,12 +445,12 @@ object UpdateSDK {
// 下载与安装 // 下载与安装
// ───────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────
/** Downloads, verifies, and opens the system package installer. */ /** 只下载并校验 APK,不弹安装器,也不会在进程重启后自动续传。 */
suspend fun downloadAndInstallApk( suspend fun downloadApk(
context: Context, context: Context,
request: ApkInstallRequest, request: ApkInstallRequest,
onProgress: ((UpdateDownloadProgress) -> Unit)? = null, onProgress: ((UpdateDownloadProgress) -> Unit)? = null,
) = withContext(Dispatchers.IO) { ): DownloadedApk = withContext(Dispatchers.IO) {
if (request.downloadUrl.isBlank() || request.versionCode <= 0) { if (request.downloadUrl.isBlank() || request.versionCode <= 0) {
throw UpdateInstallException( throw UpdateInstallException(
UpdateInstallErrorCode.DOWNLOAD_FAILED, UpdateInstallErrorCode.DOWNLOAD_FAILED,
@ -378,9 +469,8 @@ object UpdateSDK {
try { try {
if (isApkDownloaded(context, request.versionCode, request.sha256)) { if (isApkDownloaded(context, request.versionCode, request.sha256)) {
assertAuthorizationCurrent(authorization) assertAuthorizationCurrent(authorization)
val apkFile = requireNotNull(resolveDownloadedApk(context, request.versionCode)) val cached = requireNotNull(resolveDownloadedApk(context, request.versionCode))
withContext(Dispatchers.Main) { installApk(context, apkFile) } return@withContext downloadedResult(context, cached, request)
return@withContext
} }
val apkFile = versionedApkFile(context, request.versionCode) val apkFile = versionedApkFile(context, request.versionCode)
@ -404,8 +494,7 @@ object UpdateSDK {
) )
} }
assertAuthorizationCurrent(authorization) assertAuthorizationCurrent(authorization)
withContext(Dispatchers.Main) { installApk(context, apkFile) } return@withContext downloadedResult(context, apkFile, request)
return@withContext
} catch (error: CancellationException) { } catch (error: CancellationException) {
throw UpdateInstallException( throw UpdateInstallException(
UpdateInstallErrorCode.DOWNLOAD_CANCELLED, UpdateInstallErrorCode.DOWNLOAD_CANCELLED,
@ -429,6 +518,35 @@ object UpdateSDK {
} }
} }
/** 下载、校验后打开系统安装器。 */
suspend fun downloadAndInstallApk(
context: Context,
request: ApkInstallRequest,
onProgress: ((UpdateDownloadProgress) -> Unit)? = null,
) {
val apkFile = downloadApk(context, request, onProgress).localFile
withContext(Dispatchers.Main) {
installApk(context, apkFile)
}
}
private fun downloadedResult(
context: Context,
apkFile: File,
request: ApkInstallRequest,
): DownloadedApk {
val publicUri = if (request.destination == UpdateDownloadDestination.PUBLIC_DOWNLOADS) {
FileSDK.exportToPublicDownloads(
context = context,
file = apkFile,
displayName = "${context.packageName}-${request.versionCode}.apk",
)
} else {
null
}
return DownloadedApk(apkFile, publicUri)
}
/** Java/RN entry point backed by the same suspending implementation. */ /** Java/RN entry point backed by the same suspending implementation. */
@JvmStatic @JvmStatic
fun startDownloadAndInstall( fun startDownloadAndInstall(
@ -463,6 +581,64 @@ object UpdateSDK {
return UpdateInstallTask(job) return UpdateInstallTask(job)
} }
/** Java/RN 只下载入口;与下载并安装共用同一校验和授权链。 */
@JvmStatic
fun startDownload(
context: Context,
request: ApkInstallRequest,
progressListener: UpdateProgressListener?,
callback: UpdateDownloadCallback,
): UpdateInstallTask {
val mainHandler = Handler(Looper.getMainLooper())
val job = CoroutineScope(Dispatchers.Main.immediate).launch {
try {
val result = downloadApk(context.applicationContext, request) { progress ->
progressListener?.let { listener ->
mainHandler.post { listener.onProgress(progress) }
}
}
callback.onSuccess(result.localFile.absolutePath, result.publicUri?.toString())
} catch (error: UpdateInstallException) {
callback.onError(error.errorCode.wireValue, error.message.orEmpty())
} catch (error: CancellationException) {
callback.onError(
UpdateInstallErrorCode.DOWNLOAD_CANCELLED.wireValue,
"APK download was cancelled",
)
} catch (error: Throwable) {
callback.onError(
UpdateInstallErrorCode.DOWNLOAD_FAILED.wireValue,
error.message ?: "APK download failed",
)
}
}
return UpdateInstallTask(job)
}
/** Java/RN 安装已校验下载入口。 */
@JvmStatic
fun startInstallDownloaded(
context: Context,
versionCode: Int,
expectedSha256: String,
callback: UpdateInstallCallback,
): UpdateInstallTask {
val job = CoroutineScope(Dispatchers.Main.immediate).launch {
try {
installDownloadedApk(context.applicationContext, versionCode, expectedSha256)
callback.onSuccess()
} catch (error: UpdateInstallException) {
callback.onError(error.errorCode.wireValue, error.message.orEmpty())
} catch (error: Throwable) {
callback.onError(
UpdateInstallErrorCode.INSTALL_FAILED.wireValue,
error.message ?: "APK installation failed",
)
}
}
return UpdateInstallTask(job)
}
// ───────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────
// WebSocket 实时通知 // WebSocket 实时通知
// ───────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────
@ -507,7 +683,43 @@ object UpdateSDK {
fun canRequestPackageInstalls(context: Context): Boolean = fun canRequestPackageInstalls(context: Context): Boolean =
Build.VERSION.SDK_INT < Build.VERSION_CODES.O || context.packageManager.canRequestPackageInstalls() Build.VERSION.SDK_INT < Build.VERSION_CODES.O || context.packageManager.canRequestPackageInstalls()
/**
* 打开租户平台返回的设备商店地址无法处理时回退通用下载页
*
* SDK 不展示 UI返回 false 时由宿主决定是否提示无法提供下载服务
*/
@JvmStatic
fun openStore(context: Context, updateInfo: UpdateInfo): Boolean =
openStore(context, updateInfo.marketUrl, updateInfo.appStoreUrl)
/** Java/RN 使用的同一商店跳转入口。 */
@JvmStatic
fun openStore(
context: Context,
marketUrl: String?,
appStoreUrl: String?,
): Boolean {
val candidates = listOf(marketUrl.orEmpty(), appStoreUrl.orEmpty())
.map(String::trim)
.filter(String::isNotBlank)
.distinct()
return candidates.any { url ->
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(url)).apply {
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
runCatching {
if (intent.resolveActivity(context.packageManager) == null) {
false
} else {
context.startActivity(intent)
true
}
}.getOrDefault(false)
}
}
private fun installApk(context: Context, apkFile: File) { private fun installApk(context: Context, apkFile: File) {
verifyApkIdentity(context, apkFile)
if (!canRequestPackageInstalls(context)) { if (!canRequestPackageInstalls(context)) {
throw UpdateInstallException( throw UpdateInstallException(
UpdateInstallErrorCode.INSTALL_PERMISSION_REQUIRED, UpdateInstallErrorCode.INSTALL_PERMISSION_REQUIRED,
@ -517,11 +729,7 @@ object UpdateSDK {
val intent = try { val intent = try {
Intent(Intent.ACTION_VIEW).apply { Intent(Intent.ACTION_VIEW).apply {
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_GRANT_READ_URI_PERMISSION flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_GRANT_READ_URI_PERMISSION
val uri = FileProvider.getUriForFile( val uri = FileSDK.uriForFile(context, apkFile)
context,
"${context.packageName}.xuqm.fileprovider",
apkFile,
)
setDataAndType(uri, "application/vnd.android.package-archive") setDataAndType(uri, "application/vnd.android.package-archive")
} }
} catch (error: Exception) { } catch (error: Exception) {
@ -548,6 +756,55 @@ object UpdateSDK {
} }
} }
private fun verifyApkIdentity(context: Context, apkFile: File) {
val manager = context.packageManager
val flags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
android.content.pm.PackageManager.GET_SIGNING_CERTIFICATES
} else {
@Suppress("DEPRECATION")
android.content.pm.PackageManager.GET_SIGNATURES
}
val archive = manager.getPackageArchiveInfo(apkFile.absolutePath, flags)
?: throw UpdateInstallException(
UpdateInstallErrorCode.PACKAGE_MISMATCH,
"无法读取更新包信息",
)
if (archive.packageName != context.packageName) {
throw UpdateInstallException(
UpdateInstallErrorCode.PACKAGE_MISMATCH,
"更新包与当前应用不匹配",
)
}
val installed = manager.getPackageInfo(context.packageName, flags)
val trusted = packageSignatures(installed).map(::signatureDigest).toSet()
val candidate = packageSignatures(archive)
if (candidate.isEmpty() || candidate.none { signatureDigest(it) in trusted }) {
throw UpdateInstallException(
UpdateInstallErrorCode.SIGNATURE_MISMATCH,
"更新包签名与当前应用不一致",
)
}
}
private fun packageSignatures(info: android.content.pm.PackageInfo): Array<android.content.pm.Signature> {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
val signingInfo = info.signingInfo ?: return emptyArray()
if (signingInfo.hasMultipleSigners()) {
signingInfo.apkContentsSigners
} else {
signingInfo.signingCertificateHistory
}
} else {
@Suppress("DEPRECATION")
info.signatures ?: emptyArray()
}
}
private fun signatureDigest(signature: android.content.pm.Signature): String =
java.security.MessageDigest.getInstance("SHA-256")
.digest(signature.toByteArray())
.joinToString(separator = "") { byte -> "%02x".format(byte) }
private fun authorizationKey(versionCode: Int, sha256: String): String = private fun authorizationKey(versionCode: Int, sha256: String): String =
"$versionCode:${sha256.lowercase()}" "$versionCode:${sha256.lowercase()}"
@ -595,6 +852,11 @@ object UpdateSDK {
} }
} }
private class UpdateCheckException(
val code: String,
message: String,
) : IllegalStateException(message)
/** /**
* Update 登录门禁的唯一规则旧租户配置缺少字段时必须安全地要求登录 * Update 登录门禁的唯一规则旧租户配置缺少字段时必须安全地要求登录
*/ */

查看文件

@ -4,6 +4,7 @@ import android.content.Context
import android.util.Log import android.util.Log
import com.google.gson.Gson import com.google.gson.Gson
import com.xuqm.sdk.core.ServiceEndpointRegistry import com.xuqm.sdk.core.ServiceEndpointRegistry
import com.xuqm.sdk.update.model.UpdateCheckStatus
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import okhttp3.Request import okhttp3.Request
import okhttp3.Response import okhttp3.Response
@ -155,13 +156,18 @@ class UpdateWebSocket internal constructor(
private fun checkUpdateAndNotify() { private fun checkUpdateAndNotify() {
Thread { Thread {
try { try {
val updateInfo = kotlinx.coroutines.runBlocking { val result = kotlinx.coroutines.runBlocking {
UpdateSDK.checkAppUpdate(context, bypassIgnore = false) UpdateSDK.checkAppUpdate(context, bypassIgnore = false)
} }
android.os.Handler(android.os.Looper.getMainLooper()).post { android.os.Handler(android.os.Looper.getMainLooper()).post {
if (updateInfo != null && updateInfo.needsUpdate) { val updateInfo = result.update
if (result.status == UpdateCheckStatus.UPDATE_AVAILABLE && updateInfo != null) {
Log.d(TAG, "Update available: ${updateInfo.versionName} (${updateInfo.versionCode})") Log.d(TAG, "Update available: ${updateInfo.versionName} (${updateInfo.versionCode})")
listener.onUpdateAvailable(updateInfo) listener.onUpdateAvailable(updateInfo)
} else if (result.status == UpdateCheckStatus.FAILED) {
listener.onError(
IllegalStateException(result.message ?: "Update check failed"),
)
} else { } else {
Log.d(TAG, "No update available") Log.d(TAG, "No update available")
listener.onNoUpdate() listener.onNoUpdate()

查看文件

@ -1,11 +1,15 @@
package com.xuqm.sdk.update.model package com.xuqm.sdk.update.model
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import android.net.Uri
import java.io.File
enum class UpdateInstallErrorCode(val wireValue: String) { enum class UpdateInstallErrorCode(val wireValue: String) {
DOWNLOAD_FAILED("DOWNLOAD_FAILED"), DOWNLOAD_FAILED("DOWNLOAD_FAILED"),
DOWNLOAD_CANCELLED("DOWNLOAD_CANCELLED"), DOWNLOAD_CANCELLED("DOWNLOAD_CANCELLED"),
HASH_MISMATCH("HASH_MISMATCH"), HASH_MISMATCH("HASH_MISMATCH"),
PACKAGE_MISMATCH("PACKAGE_MISMATCH"),
SIGNATURE_MISMATCH("SIGNATURE_MISMATCH"),
INSTALL_PERMISSION_REQUIRED("INSTALL_PERMISSION_REQUIRED"), INSTALL_PERMISSION_REQUIRED("INSTALL_PERMISSION_REQUIRED"),
INSTALLER_UNAVAILABLE("INSTALLER_UNAVAILABLE"), INSTALLER_UNAVAILABLE("INSTALLER_UNAVAILABLE"),
INSTALL_FAILED("INSTALL_FAILED"), INSTALL_FAILED("INSTALL_FAILED"),
@ -33,6 +37,18 @@ data class ApkInstallRequest(
val sha256: String, val sha256: String,
/** Number of retries after the initial download attempt. */ /** Number of retries after the initial download attempt. */
val retryCount: Int = 1, val retryCount: Int = 1,
/** 是否在校验通过后额外保存一份到系统 Downloads。 */
val destination: UpdateDownloadDestination = UpdateDownloadDestination.SDK_PRIVATE,
)
enum class UpdateDownloadDestination {
SDK_PRIVATE,
PUBLIC_DOWNLOADS,
}
data class DownloadedApk(
val localFile: File,
val publicUri: Uri? = null,
) )
fun interface UpdateProgressListener { fun interface UpdateProgressListener {
@ -44,6 +60,15 @@ interface UpdateInstallCallback {
fun onError(code: String, message: String) fun onError(code: String, message: String)
} }
fun interface UpdateCheckCallback {
fun onResult(result: UpdateCheckResult)
}
interface UpdateDownloadCallback {
fun onSuccess(localPath: String, publicUri: String?)
fun onError(code: String, message: String)
}
class UpdateInstallTask internal constructor(private val job: Job) { class UpdateInstallTask internal constructor(private val job: Job) {
fun cancel() { fun cancel() {
job.cancel() job.cancel()

查看文件

@ -1,5 +1,7 @@
package com.xuqm.sdk.update.model package com.xuqm.sdk.update.model
import com.xuqm.sdk.network.NetworkStatus
data class UpdateInfo( data class UpdateInfo(
val needsUpdate: Boolean, val needsUpdate: Boolean,
val versionName: String = "", val versionName: String = "",
@ -16,3 +18,23 @@ data class UpdateInfo(
/** APK 文件的 SHA-256 哈希值,用于校验本地文件完整性;服务端未返回时为 null */ /** APK 文件的 SHA-256 哈希值,用于校验本地文件完整性;服务端未返回时为 null */
val apkHash: String? = null, val apkHash: String? = null,
) )
/** 更新检查的完整状态;宿主无需再用 null 或异常猜测业务含义。 */
enum class UpdateCheckStatus {
UPDATE_AVAILABLE,
NO_UPDATE,
LOGIN_REQUIRED,
SERVICE_DISABLED,
FAILED,
}
data class UpdateCheckResult(
val status: UpdateCheckStatus,
val network: NetworkStatus,
val update: UpdateInfo? = null,
val errorCode: String? = null,
val message: String? = null,
) {
val hasUpdate: Boolean
get() = status == UpdateCheckStatus.UPDATE_AVAILABLE && update != null
}

查看文件

@ -19,6 +19,8 @@ class ApkInstallTest {
"DOWNLOAD_FAILED", "DOWNLOAD_FAILED",
"DOWNLOAD_CANCELLED", "DOWNLOAD_CANCELLED",
"HASH_MISMATCH", "HASH_MISMATCH",
"PACKAGE_MISMATCH",
"SIGNATURE_MISMATCH",
"INSTALL_PERMISSION_REQUIRED", "INSTALL_PERMISSION_REQUIRED",
"INSTALLER_UNAVAILABLE", "INSTALLER_UNAVAILABLE",
"INSTALL_FAILED", "INSTALL_FAILED",

查看文件

@ -67,3 +67,12 @@ window.addEventListener('__xwvDownloadDone', (e) => { ... })
- `<input type="file">` + `accept="image/*"` + `capture` → 系统相机 - `<input type="file">` + `accept="image/*"` + `capture` → 系统相机
- `<input type="file">` + `.docx/.xlsx` → 文件选择器 - `<input type="file">` + `.docx/.xlsx` → 文件选择器
- `getUserMedia()` WebRTC → 自动请求 CAMERA 权限 - `getUserMedia()` WebRTC → 自动请求 CAMERA 权限
## 公共 Downloads
`downloadDestination = FileDownloadDestination.PublicDownloads` 时,WebView 直接复用
common 文件能力Android 10 及以上通过 MediaStore 写入系统 Downloads。该流程不会
申请 `MANAGE_EXTERNAL_STORAGE`,不会跳转“所有文件访问权限”设置页,也不会因未授予
该敏感权限而中断下载。Android 9 及以下仍遵循系统的
`WRITE_EXTERNAL_STORAGE` 运行时权限规则。只有启用下载通知且运行于 Android 13
及以上时,宿主才可能需要处理通知权限。

查看文件

@ -36,7 +36,7 @@ android {
} }
dependencies { dependencies {
api(project(":sdk-file")) api(project(":sdk-core"))
api(platform(libs.androidx.compose.bom)) api(platform(libs.androidx.compose.bom))
api(libs.androidx.ui) api(libs.androidx.ui)
api(libs.androidx.ui.graphics) api(libs.androidx.ui.graphics)
@ -46,4 +46,9 @@ dependencies {
api(libs.androidx.webkit) api(libs.androidx.webkit)
implementation(libs.androidx.activity.compose) implementation(libs.androidx.activity.compose)
implementation(libs.androidx.lifecycle.runtime.compose) implementation(libs.androidx.lifecycle.runtime.compose)
testImplementation(libs.junit4)
}
android.testOptions.unitTests.all {
it.systemProperty("xuqm.sdkWebViewProjectDir", project.projectDir.absolutePath)
} }

查看文件

@ -9,7 +9,6 @@
<activity <activity
android:name=".XWebViewActivity" android:name=".XWebViewActivity"
android:exported="false" android:exported="false"
android:screenOrientation="unspecified"
android:windowSoftInputMode="adjustResize" /> android:windowSoftInputMode="adjustResize" />
</application> </application>

查看文件

@ -41,15 +41,10 @@ import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.ui.viewinterop.AndroidView import androidx.compose.ui.viewinterop.AndroidView
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.core.content.FileProvider
import android.provider.MediaStore import android.provider.MediaStore
import com.xuqm.sdk.file.FileDownloadDestination import com.xuqm.sdk.file.FileDownloadDestination
import com.xuqm.sdk.file.FileSDK import com.xuqm.sdk.file.FileSDK
import com.xuqm.sdk.file.platform.PlatformFileSDK
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
@ -330,10 +325,6 @@ fun XWebViewView(
var currentUrl by remember { mutableStateOf<String?>(config.url.ifBlank { null }) } var currentUrl by remember { mutableStateOf<String?>(config.url.ifBlank { null }) }
val coroutineScope = rememberCoroutineScope() val coroutineScope = rememberCoroutineScope()
var showImageSourceDialog by remember { mutableStateOf(false) } var showImageSourceDialog by remember { mutableStateOf(false) }
var showStoragePermissionDialog by remember { mutableStateOf(false) }
val pendingUrlDownload = remember { mutableStateOf<Pair<String, String?>?>(null) }
// blobdownload pending: (url, filename, base64Data)
val pendingBlobDownload = remember { mutableStateOf<Triple<String, String, String>?>(null) }
val notificationPermissionLauncher = rememberLauncherForActivityResult( val notificationPermissionLauncher = rememberLauncherForActivityResult(
ActivityResultContracts.RequestPermission() ActivityResultContracts.RequestPermission()
@ -358,17 +349,8 @@ fun XWebViewView(
"download" -> { "download" -> {
val url = payload.optString("url").takeIf { it.isNotBlank() } ?: return@handler val url = payload.optString("url").takeIf { it.isNotBlank() } ?: return@handler
val filename = payload.optString("filename").takeIf { it.isNotBlank() } val filename = payload.optString("filename").takeIf { it.isNotBlank() }
// On API 30+, writing to public Downloads requires MANAGE_EXTERNAL_STORAGE. // PublicDownloads 由 common 文件能力按系统版本选择 MediaStore/公共目录;
// Show a rationale dialog; after the user grants permission and returns, the // WebView 不申请“所有文件访问权限”,也不维护返回设置页后的续传状态。
// lifecycle observer will resume the download automatically.
if (config.downloadDestination == FileDownloadDestination.PublicDownloads &&
Build.VERSION.SDK_INT >= Build.VERSION_CODES.R &&
!FileSDK.isManageStorageGranted(context)
) {
pendingUrlDownload.value = url to filename
showStoragePermissionDialog = true
return@handler
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU &&
config.downloadNotificationTitle != null && config.downloadNotificationTitle != null &&
ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED
@ -391,7 +373,7 @@ fun XWebViewView(
}.onSuccess { file -> }.onSuccess { file ->
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
dispatchDownloadEvent("__xwvDownloadDone", url, ",success:true") dispatchDownloadEvent("__xwvDownloadDone", url, ",success:true")
PlatformFileSDK.openFile(context, file) FileSDK.openFile(context, file)
} }
}.onFailure { e -> }.onFailure { e ->
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
@ -407,15 +389,7 @@ fun XWebViewView(
android.util.Log.e("XWV", "blobdownload: empty data") android.util.Log.e("XWV", "blobdownload: empty data")
return@handler return@handler
} }
// On API 30+, writing to public Downloads requires MANAGE_EXTERNAL_STORAGE. // Blob 与普通 URL 下载共享同一存储策略,不能形成第二套权限门禁。
if (config.downloadDestination == FileDownloadDestination.PublicDownloads &&
Build.VERSION.SDK_INT >= Build.VERSION_CODES.R &&
!FileSDK.isManageStorageGranted(context)
) {
pendingBlobDownload.value = Triple(url, filename, b64)
showStoragePermissionDialog = true
return@handler
}
android.util.Log.d( android.util.Log.d(
"XWV", "XWV",
"Blob download requested: bytesBase64Length=${b64.length} " + "Blob download requested: bytesBase64Length=${b64.length} " +
@ -432,7 +406,7 @@ fun XWebViewView(
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
dispatchDownloadEvent("__xwvDownloadDone", url, ",success:true") dispatchDownloadEvent("__xwvDownloadDone", url, ",success:true")
// Only open with file viewer when not saved to gallery (e.g. zip, docx) // 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 -> }.onFailure { e ->
android.util.Log.e("XWV", "blobdownload FAILED", e) android.util.Log.e("XWV", "blobdownload FAILED", e)
@ -557,11 +531,7 @@ fun XWebViewView(
if (granted && launch) { if (granted && launch) {
runCatching { runCatching {
val imageFile = File.createTempFile("cam_", ".jpg", context.cacheDir) val imageFile = File.createTempFile("cam_", ".jpg", context.cacheDir)
val uri = FileProvider.getUriForFile( val uri = FileSDK.uriForFile(context, imageFile)
context,
"${context.packageName}.xuqm.fileprovider",
imageFile,
)
pendingCameraUri.value = uri pendingCameraUri.value = uri
takePictureLauncher.launch(uri) takePictureLauncher.launch(uri)
}.onFailure { }.onFailure {
@ -584,74 +554,6 @@ fun XWebViewView(
} }
} }
// When returning from the MANAGE_EXTERNAL_STORAGE settings page, resume pending download.
val lifecycleOwner = LocalLifecycleOwner.current
DisposableEffect(lifecycleOwner) {
val observer = LifecycleEventObserver { _, event ->
if (event == Lifecycle.Event.ON_RESUME) {
val pd = pendingUrlDownload.value
val pbd = pendingBlobDownload.value
if (pd != null && FileSDK.isManageStorageGranted(context)) {
val (url, filename) = pd
pendingUrlDownload.value = null
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU &&
config.downloadNotificationTitle != null &&
ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED
) {
notificationPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS)
}
coroutineScope.launch(Dispatchers.IO) {
runCatching {
FileSDK.download(
context = context,
downloadUrl = url,
fileName = filename,
destination = config.downloadDestination,
notificationTitle = config.downloadNotificationTitle,
) { progress ->
mainHandler.post {
dispatchDownloadEvent("__xwvDownloadProgress", url, ",progress:$progress")
}
}
}.onSuccess { file ->
withContext(Dispatchers.Main) {
dispatchDownloadEvent("__xwvDownloadDone", url, ",success:true")
PlatformFileSDK.openFile(context, file)
}
}.onFailure { e ->
withContext(Dispatchers.Main) {
dispatchDownloadEvent("__xwvDownloadDone", url, ",success:false,error:'${e.message?.escapeJs()}'")
}
}
}
}
if (pbd != null && FileSDK.isManageStorageGranted(context)) {
val (url, filename, b64) = pbd
pendingBlobDownload.value = null
coroutineScope.launch(Dispatchers.IO) {
runCatching {
FileSDK.saveBlobDownload(context, b64, filename, config.downloadDestination)
}.onSuccess { file ->
android.util.Log.d("XWV", "Blob download saved: size=${file.length()}")
val savedToGallery = runCatching { FileSDK.saveImageToGallery(context, file) }.getOrDefault(false)
withContext(Dispatchers.Main) {
dispatchDownloadEvent("__xwvDownloadDone", url, ",success:true")
if (!savedToGallery) PlatformFileSDK.openFile(context, file)
}
}.onFailure { e ->
android.util.Log.e("XWV", "blobdownload FAILED", e)
withContext(Dispatchers.Main) {
dispatchDownloadEvent("__xwvDownloadDone", url, ",success:false,error:'${e.message?.escapeJs()}'")
}
}
}
}
}
}
lifecycleOwner.lifecycle.addObserver(observer)
onDispose { lifecycleOwner.lifecycle.removeObserver(observer) }
}
// Checks storage permissions before launching the file picker. // Checks storage permissions before launching the file picker.
// Requests missing permissions on first use; launches the picker after the result either way. // Requests missing permissions on first use; launches the picker after the result either way.
val launchPickerWithPermission: (Array<String>) -> Unit = { mimes -> val launchPickerWithPermission: (Array<String>) -> Unit = { mimes ->
@ -672,11 +574,7 @@ fun XWebViewView(
if (ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED) { if (ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED) {
runCatching { runCatching {
val imageFile = File.createTempFile("cam_", ".jpg", context.cacheDir) val imageFile = File.createTempFile("cam_", ".jpg", context.cacheDir)
val uri = FileProvider.getUriForFile( val uri = FileSDK.uriForFile(context, imageFile)
context,
"${context.packageName}.xuqm.fileprovider",
imageFile,
)
pendingCameraUri.value = uri pendingCameraUri.value = uri
takePictureLauncher.launch(uri) takePictureLauncher.launch(uri)
}.onFailure { }.onFailure {
@ -756,6 +654,7 @@ fun XWebViewView(
} }
} }
@androidx.annotation.RequiresApi(Build.VERSION_CODES.O)
override fun onRenderProcessGone(view: WebView, detail: RenderProcessGoneDetail): Boolean { override fun onRenderProcessGone(view: WebView, detail: RenderProcessGoneDetail): Boolean {
// WebView renderer (sandboxed process) crashed or was OOM-killed. // WebView renderer (sandboxed process) crashed or was OOM-killed.
// Return true to keep the main app alive; the crashed WebView instance // Return true to keep the main app alive; the crashed WebView instance
@ -852,31 +751,6 @@ fun XWebViewView(
}, },
) )
if (showStoragePermissionDialog) {
AlertDialog(
onDismissRequest = {
showStoragePermissionDialog = false
pendingUrlDownload.value = null
pendingBlobDownload.value = null
},
title = { Text("需要存储权限") },
text = { Text("下载文件需要「所有文件访问权限」,请在设置中授权后将自动继续下载。") },
confirmButton = {
TextButton(onClick = {
showStoragePermissionDialog = false
FileSDK.requestManageStorageIntent(context)?.let { context.startActivity(it) }
}) { Text("前往设置") }
},
dismissButton = {
TextButton(onClick = {
showStoragePermissionDialog = false
pendingUrlDownload.value = null
pendingBlobDownload.value = null
}) { Text("取消") }
},
)
}
if (showImageSourceDialog) { if (showImageSourceDialog) {
AlertDialog( AlertDialog(
onDismissRequest = { onDismissRequest = {

查看文件

@ -0,0 +1,41 @@
package com.xuqm.sdk.webview
import java.io.File
import org.junit.Assert.assertFalse
import org.junit.Test
/**
* 公共 Downloads Android 10+ common 文件能力通过 MediaStore 写入
*
* WebView 不得重新引入所有文件访问权限门禁否则会把普通下载升级为敏感权限
* 并在 Android 11+ 中断本应直接执行的下载流程
*/
class PublicDownloadsPermissionContractTest {
private val projectDir = File(
requireNotNull(System.getProperty("xuqm.sdkWebViewProjectDir")) {
"sdk-webview project directory is not configured"
},
)
@Test
fun `webview download does not request broad storage access`() {
val source = projectDir
.resolve("src/main/java/com/xuqm/sdk/webview/XWebViewView.kt")
.readText(Charsets.UTF_8)
assertFalse(source.contains("MANAGE_EXTERNAL_STORAGE"))
assertFalse(source.contains("isManageStorageGranted"))
assertFalse(source.contains("requestManageStorageIntent"))
assertFalse(source.contains("ACTION_MANAGE_ALL_FILES_ACCESS_PERMISSION"))
assertFalse(source.contains("ACTION_MANAGE_APP_ALL_FILES_ACCESS_PERMISSION"))
}
@Test
fun `sdk manifest does not declare broad storage permission`() {
val manifest = projectDir.resolve("src/main/AndroidManifest.xml")
.readText(Charsets.UTF_8)
assertFalse(manifest.contains("android.permission.MANAGE_EXTERNAL_STORAGE"))
}
}

查看文件

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