比较提交
4 次代码提交
e991c06bff
...
b34b88e29e
| 作者 | SHA1 | 提交日期 | |
|---|---|---|---|
|
|
b34b88e29e | ||
|
|
8610ea5745 | ||
|
|
8204ee7c38 | ||
|
|
d05b85b024 |
2
Jenkinsfile
vendored
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,
|
||||||
|
|||||||
37
README.md
37
README.md
@ -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/ # IM:WebSocket 实时通信
|
├── sdk-im/ # IM:WebSocket 实时通信
|
||||||
├── 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() → 通知所有子模块(反射 / 接口回调)
|
||||||
├── ApiClient(OkHttp 4.12 + Retrofit 3)
|
├── ApiClient(OkHttp 4.12 + Retrofit 3)
|
||||||
├── TokenStore(Android Keystore + AES-256-GCM)
|
├── TokenStore(Android 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+ 使用 MediaStore;Android 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/5G;SDK 不会为了更新检查
|
||||||
|
* 申请电话或定位权限,无法可靠识别时返回 [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")
|
||||||
|
|||||||
正在加载...
在新工单中引用
屏蔽一个用户