refactor(sdk): unify configuration and extension lifecycle
这个提交包含在:
父节点
f784f3354c
当前提交
fcdc94b309
8
.gitignore
vendored
8
.gitignore
vendored
@ -5,6 +5,7 @@ dist/
|
|||||||
target/
|
target/
|
||||||
build/
|
build/
|
||||||
.gradle/
|
.gradle/
|
||||||
|
.kotlin/
|
||||||
*.iml
|
*.iml
|
||||||
.idea/
|
.idea/
|
||||||
*.log
|
*.log
|
||||||
@ -13,3 +14,10 @@ build/
|
|||||||
sentry.properties
|
sentry.properties
|
||||||
/.gradle-home/
|
/.gradle-home/
|
||||||
local.properties
|
local.properties
|
||||||
|
|
||||||
|
# 本地签名材料只允许通过 ~/.gradle/gradle.properties 引用,禁止进入仓库。
|
||||||
|
*.jks
|
||||||
|
*.keystore
|
||||||
|
*.p12
|
||||||
|
*.pfx
|
||||||
|
*.xuqmconfig
|
||||||
|
|||||||
29
CLAUDE.md
29
CLAUDE.md
@ -25,23 +25,14 @@ XuqmGroup Android SDK,Gradle multi-module 项目。为集成宿主 App(如 Y
|
|||||||
|
|
||||||
## 核心 API(sdk-core)
|
## 核心 API(sdk-core)
|
||||||
|
|
||||||
### 初始化(两种方式,均不得修改签名)
|
### 初始化(唯一入口)
|
||||||
|
|
||||||
**方式 A — ContentProvider 自动初始化(推荐)**
|
将租户平台签发的 `config.xuqmconfig` 原样放入
|
||||||
将 `config.xuqm` 放入 `src/main/assets/xuqm/`,App 无需调用任何初始化代码。
|
`src/main/assets/config/config.xuqmconfig`。集成任一需要初始化的扩展 SDK 后,
|
||||||
`XuqmInitializerProvider` 在 App 启动时自动触发。
|
合并 Provider 在 App 启动时自动触发;宿主不得手动传 appKey 初始化。
|
||||||
|
|
||||||
**方式 B — 手动初始化**
|
`serverUrl` 是配置签发时确定的唯一平台地址。运行时不存在默认平台地址,也不允许
|
||||||
```kotlin
|
宿主覆盖、跨平台降级或维护第二套初始化配置。
|
||||||
// Application.onCreate() 中:
|
|
||||||
XuqmSDK.initialize(context, appKey = "xxx") // 公有平台
|
|
||||||
XuqmSDK.initialize(context, appKey = "xxx", platformUrl = "https://xxx") // 私有化平台
|
|
||||||
|
|
||||||
// 等待平台配置(在 coroutine 中):
|
|
||||||
XuqmSDK.awaitInitialization()
|
|
||||||
```
|
|
||||||
|
|
||||||
**两种平台互相独立,不允许自动降级到默认公有平台(`DEFAULT_PLATFORM_URL`)。**
|
|
||||||
|
|
||||||
### 用户信息
|
### 用户信息
|
||||||
|
|
||||||
@ -68,7 +59,8 @@ XuqmSDK.setUserInfo(null) // 登出,触发所有子 SDK 登出
|
|||||||
- 平台托管用户(有 userSig):完整功能,包含 IM
|
- 平台托管用户(有 userSig):完整功能,包含 IM
|
||||||
- **禁止在 push / update / bugcollect 等 SDK 内部校验 userSig 是否存在**
|
- **禁止在 push / update / bugcollect 等 SDK 内部校验 userSig 是否存在**
|
||||||
|
|
||||||
**注意:** push 设备注册依赖请求签名(`signingKey` in config.xuqm),与 userSig 无关。`signingKey` 未配置时,设备注册请求会被服务端拒绝(HTTP 401)。需在平台后台为该 appKey 生成 signingKey 并重新下载 config.xuqm。
|
**注意:** push 设备注册依赖 `config.xuqmconfig` 中的平台请求签名信息,与 userSig
|
||||||
|
无关。未配置时设备注册请求会被服务端拒绝,需在平台后台重新生成并下载配置文件。
|
||||||
|
|
||||||
### 平台配置读取(init 完成后)
|
### 平台配置读取(init 完成后)
|
||||||
```kotlin
|
```kotlin
|
||||||
@ -80,7 +72,7 @@ XuqmSDK.appKey // 当前 appKey
|
|||||||
## Bug 采集 SDK(sdk-bugcollect)
|
## Bug 采集 SDK(sdk-bugcollect)
|
||||||
|
|
||||||
```kotlin
|
```kotlin
|
||||||
// Application.onCreate() 中(XuqmSDK.initialize 之后):
|
// 平台配置自动初始化完成且宿主已取得隐私授权后:
|
||||||
BugCollect.setLogLevel(LogLevel.INFO)
|
BugCollect.setLogLevel(LogLevel.INFO)
|
||||||
BugCollect.setEnvironment("production")
|
BugCollect.setEnvironment("production")
|
||||||
BugCollect.startCrashCapture() // 开启 UncaughtExceptionHandler
|
BugCollect.startCrashCapture() // 开启 UncaughtExceptionHandler
|
||||||
@ -97,9 +89,8 @@ BugCollect.defineFunnel("checkout", listOf("cart_view", "checkout_start", "payme
|
|||||||
|
|
||||||
`bugCollectApiUrl` 由 SDK 在 init 后从平台配置自动获取,无需 App 传入。
|
`bugCollectApiUrl` 由 SDK 在 init 后从平台配置自动获取,无需 App 传入。
|
||||||
|
|
||||||
## 向下兼容约束
|
## API 约束
|
||||||
|
|
||||||
- `XuqmSDK.initialize()` 现有参数不得删除或修改类型
|
|
||||||
- `XuqmSDK.setUserInfo()` 现有 `XuqmUserInfo` 字段不得删除
|
- `XuqmSDK.setUserInfo()` 现有 `XuqmUserInfo` 字段不得删除
|
||||||
- `SdkPlatformConfig` 新增字段一律为可选(`val xxx: Type? = null`)
|
- `SdkPlatformConfig` 新增字段一律为可选(`val xxx: Type? = null`)
|
||||||
- 旧服务端不返回新字段时,客户端使用合理默认值
|
- 旧服务端不返回新字段时,客户端使用合理默认值
|
||||||
|
|||||||
18
Jenkinsfile
vendored
18
Jenkinsfile
vendored
@ -12,6 +12,7 @@ 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')
|
||||||
@ -34,7 +35,9 @@ pipeline {
|
|||||||
])
|
])
|
||||||
script {
|
script {
|
||||||
def moduleChecks = [
|
def moduleChecks = [
|
||||||
|
'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,
|
||||||
@ -86,7 +89,8 @@ pipeline {
|
|||||||
def modules = env.PUBLISH_MODULES.split(',').toList()
|
def modules = env.PUBLISH_MODULES.split(',').toList()
|
||||||
// sdk-bugcollect-plugin 随 sdk-bugcollect 自动联动,共享版本号
|
// sdk-bugcollect-plugin 随 sdk-bugcollect 自动联动,共享版本号
|
||||||
def propNameByModule = modules.collectEntries { mod ->
|
def propNameByModule = modules.collectEntries { mod ->
|
||||||
def propName = mod == 'sdk-bugcollect-plugin' ? 'SDK_BUGCOLLECT_VERSION' : "SDK_${mod.replace('sdk-', '').toUpperCase()}_VERSION"
|
def propName = mod == 'sdk-bugcollect-plugin' ? 'SDK_BUGCOLLECT_VERSION' :
|
||||||
|
(mod == 'sdk-common-plugin' ? 'SDK_CORE_VERSION' : "SDK_${mod.replace('sdk-', '').toUpperCase()}_VERSION")
|
||||||
[mod, propName]
|
[mod, propName]
|
||||||
}
|
}
|
||||||
def uniquePropNames = propNameByModule.values().toList().unique()
|
def uniquePropNames = propNameByModule.values().toList().unique()
|
||||||
@ -134,7 +138,9 @@ pipeline {
|
|||||||
script {
|
script {
|
||||||
def modules = env.PUBLISH_MODULES.split(',').toList()
|
def modules = env.PUBLISH_MODULES.split(',').toList()
|
||||||
for (mod in modules) {
|
for (mod in modules) {
|
||||||
if (mod == 'sdk-bugcollect-plugin') {
|
if (mod == 'sdk-common-plugin') {
|
||||||
|
bat "gradlew.bat -p sdk-common-plugin check ${env.VERSION_ARGS} --no-daemon"
|
||||||
|
} else if (mod == 'sdk-bugcollect-plugin') {
|
||||||
bat "gradlew.bat :${mod}:check ${env.VERSION_ARGS} --no-daemon"
|
bat "gradlew.bat :${mod}:check ${env.VERSION_ARGS} --no-daemon"
|
||||||
} else {
|
} else {
|
||||||
bat "gradlew.bat :${mod}:lintRelease :${mod}:testDebugUnitTest ${env.VERSION_ARGS} --no-daemon"
|
bat "gradlew.bat :${mod}:lintRelease :${mod}:testDebugUnitTest ${env.VERSION_ARGS} --no-daemon"
|
||||||
@ -149,7 +155,9 @@ pipeline {
|
|||||||
script {
|
script {
|
||||||
def modules = env.PUBLISH_MODULES.split(',').toList()
|
def modules = env.PUBLISH_MODULES.split(',').toList()
|
||||||
// sdk-bugcollect-plugin 是 JVM Gradle 插件,没有 assembleRelease 任务
|
// sdk-bugcollect-plugin 是 JVM Gradle 插件,没有 assembleRelease 任务
|
||||||
def aarModules = modules.findAll { it != 'sdk-bugcollect-plugin' }
|
def aarModules = modules.findAll {
|
||||||
|
it != 'sdk-bugcollect-plugin' && it != 'sdk-common-plugin'
|
||||||
|
}
|
||||||
if (aarModules.isEmpty()) {
|
if (aarModules.isEmpty()) {
|
||||||
bat 'chcp 65001 >nul && echo 没有AAR模块需要构建'
|
bat 'chcp 65001 >nul && echo 没有AAR模块需要构建'
|
||||||
} else {
|
} else {
|
||||||
@ -167,11 +175,15 @@ pipeline {
|
|||||||
def credArgs = "-PNEXUS_USER=%NEXUS_CREDS_USR% -PNEXUS_PASSWORD=%NEXUS_CREDS_PSW%"
|
def credArgs = "-PNEXUS_USER=%NEXUS_CREDS_USR% -PNEXUS_PASSWORD=%NEXUS_CREDS_PSW%"
|
||||||
// 按依赖顺序逐个发布,避免扩展 POM 先于 sdk-core 可见。
|
// 按依赖顺序逐个发布,避免扩展 POM 先于 sdk-core 可见。
|
||||||
for (mod in modules) {
|
for (mod in modules) {
|
||||||
|
if (mod == 'sdk-common-plugin') {
|
||||||
|
bat "gradlew.bat -p sdk-common-plugin publish ${env.VERSION_ARGS} ${credArgs} --no-daemon"
|
||||||
|
} else {
|
||||||
bat "gradlew.bat :${mod}:publish ${env.VERSION_ARGS} ${credArgs} --no-daemon"
|
bat "gradlew.bat :${mod}:publish ${env.VERSION_ARGS} ${credArgs} --no-daemon"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
stage('Commit Version Bump') {
|
stage('Commit Version Bump') {
|
||||||
when { expression { !params.SNAPSHOT } }
|
when { expression { !params.SNAPSHOT } }
|
||||||
|
|||||||
143
README.md
143
README.md
@ -6,7 +6,8 @@
|
|||||||
|
|
||||||
```
|
```
|
||||||
XuqmGroup-AndroidSDK/
|
XuqmGroup-AndroidSDK/
|
||||||
├── sdk-core/ # 核心:初始化、HTTP、Token 存储、文件上传、通用工具/组件
|
├── 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 注册
|
||||||
@ -34,6 +35,22 @@ dependencyResolutionManagement {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
所有宿主变体必须应用 common 构建插件。它会扫描宿主 `src/**`,只接受唯一固定路径
|
||||||
|
`src/main/assets/config/config.xuqmconfig`;任何改名、嵌套或其它 sourceSet 中的
|
||||||
|
`.xuqmconfig` 文件都会直接失败。
|
||||||
|
Debug 执行本地 V2 验签,Release 额外执行租户平台在线有效性校验,并负责不可变 buildId 和
|
||||||
|
BugCollect 构建门禁,并生成扩展自动初始化所需的唯一 Manifest 标记;BugCollect 插件
|
||||||
|
仅负责 R8 mapping 上传。Debug 自动采集固定关闭,但非应急禁用的可调试宿主可通过内部
|
||||||
|
开发入口显式发送一次测试事件。
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
plugins {
|
||||||
|
id("com.xuqm.common") version "VERSION"
|
||||||
|
// 使用 BugCollect 且 Release 开启 R8 时再添加:
|
||||||
|
id("com.xuqm.bugcollect") version "VERSION"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
在 `gradle.properties` 或环境变量中配置:
|
在 `gradle.properties` 或环境变量中配置:
|
||||||
```properties
|
```properties
|
||||||
NEXUS_USER=your_username
|
NEXUS_USER=your_username
|
||||||
@ -43,12 +60,13 @@ NEXUS_PASSWORD=your_password
|
|||||||
引入依赖:
|
引入依赖:
|
||||||
```kotlin
|
```kotlin
|
||||||
dependencies {
|
dependencies {
|
||||||
implementation("com.xuqm:sdk-core:0.4.2")
|
implementation("com.xuqm:sdk-core:VERSION")
|
||||||
implementation("com.xuqm:sdk-bugcollect:0.4.2") // 日志监控(推荐)
|
implementation("com.xuqm:sdk-file:VERSION") // 平台文件能力可选
|
||||||
implementation("com.xuqm:sdk-im:0.4.2") // 可选
|
implementation("com.xuqm:sdk-bugcollect:VERSION") // 可选
|
||||||
implementation("com.xuqm:sdk-push:0.4.2") // 可选
|
implementation("com.xuqm:sdk-im:VERSION") // 可选
|
||||||
implementation("com.xuqm:sdk-update:0.4.2") // 可选
|
implementation("com.xuqm:sdk-push:VERSION") // 可选
|
||||||
implementation("com.xuqm:sdk-webview:0.4.2") // 可选
|
implementation("com.xuqm:sdk-update:VERSION") // 可选
|
||||||
|
implementation("com.xuqm:sdk-webview:VERSION") // 可选
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@ -56,27 +74,22 @@ dependencies {
|
|||||||
|
|
||||||
### 1. 初始化
|
### 1. 初始化
|
||||||
|
|
||||||
**方式 A(推荐):** 将加密配置文件放置到 `src/main/assets/xuqm/config.xuqm`,ContentProvider 自动完成初始化,无需代码。
|
从租户平台下载签名的 `config.xuqmconfig`,原样放入
|
||||||
|
`app/src/main/assets/config/config.xuqmconfig`。ContentProvider 会自动初始化,宿主无需
|
||||||
|
编写初始化代码,也不得修改、复制、自行生成或在 flavor/buildType 下放置第二份文件。
|
||||||
|
配置中的 `serverUrl` 是唯一平台地址且必填,运行时没有默认地址或跨平台回退。
|
||||||
|
|
||||||
**方式 B(手动):** 在 `Application.onCreate()` 中调用:
|
仅依赖 `sdk-core` 时不会合并初始化 Provider、通知权限或 FileProvider,也不要求配置
|
||||||
|
文件、初始化或登录。引入任一需要平台能力的扩展后,各 AAR 中同名声明会合并为唯一
|
||||||
```kotlin
|
`XuqmMergedProvider`;Provider 只有检测到 common 构建插件生成的标记后才会自动读取
|
||||||
XuqmSDK.initialize(
|
上述配置,避免未应用插件的宿主发生隐式初始化。
|
||||||
context = this,
|
|
||||||
appKey = "ak_your_app_key",
|
|
||||||
logLevel = if (BuildConfig.DEBUG) LogLevel.DEBUG else LogLevel.WARN
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. 用户登录后初始化 IM
|
### 2. 用户登录后初始化 IM
|
||||||
|
|
||||||
```kotlin
|
```kotlin
|
||||||
// 调用业务登录接口,拿到 userSig 后只需要登录一次 SDK
|
// 调用业务登录接口,拿到 userSig 后只需要登录一次 SDK
|
||||||
val userSig = api.getUserSig(userId)
|
val userSig = api.getUserSig(userId)
|
||||||
XuqmSDK.login(
|
XuqmSDK.setUserInfo(XuqmUserInfo(userId = userId, userSig = userSig))
|
||||||
userId = userId,
|
|
||||||
userSig = userSig,
|
|
||||||
)
|
|
||||||
|
|
||||||
// 如果工程里集成了 sdk-im / sdk-push / sdk-update,
|
// 如果工程里集成了 sdk-im / sdk-push / sdk-update,
|
||||||
// SDK 会自动完成对应模块的登录与初始化
|
// SDK 会自动完成对应模块的登录与初始化
|
||||||
@ -110,27 +123,6 @@ openXWebView(
|
|||||||
// navigate("xwebview") 后使用 XWebViewScreen()
|
// navigate("xwebview") 后使用 XWebViewScreen()
|
||||||
```
|
```
|
||||||
|
|
||||||
### 3. 切换联调环境
|
|
||||||
|
|
||||||
默认使用外网域名。若要本地联调,可在 `Application.onCreate()` 里切换:
|
|
||||||
|
|
||||||
```kotlin
|
|
||||||
XuqmSDK.useLocalServiceEndpoints("192.168.113.37")
|
|
||||||
// 物理设备可改成你的电脑局域网 IP
|
|
||||||
```
|
|
||||||
|
|
||||||
如果是 sample-app,也可以直接调用:
|
|
||||||
|
|
||||||
```kotlin
|
|
||||||
SampleEnvironmentConfig.useLocalhost("192.168.113.37")
|
|
||||||
```
|
|
||||||
|
|
||||||
切换后,HTTP API 会立即走新端点;如果 IM 已登录,SDK 会自动重新连接。
|
|
||||||
|
|
||||||
sample-app 里也提供了一个“环境设置”页面,可以直接在外网和本地联调之间切换。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## sdk-core
|
## sdk-core
|
||||||
|
|
||||||
### SDKConfig
|
### SDKConfig
|
||||||
@ -142,7 +134,28 @@ sample-app 里也提供了一个“环境设置”页面,可以直接在外网
|
|||||||
|
|
||||||
### TokenStore
|
### TokenStore
|
||||||
|
|
||||||
基于 `EncryptedSharedPreferences` 持久化存储。
|
基于统一的 `SecureStore` 持久化:密钥由 Android Keystore 生成且不可导出,
|
||||||
|
值使用 AES-256-GCM 加密并通过 namespace/key 关联数据认证。
|
||||||
|
|
||||||
|
## Sample Release 签名
|
||||||
|
|
||||||
|
示例应用的 Release 签名只从用户级 `~/.gradle/gradle.properties` 读取,仓库不保存
|
||||||
|
密钥库路径或凭据。需要配置的属性名为:
|
||||||
|
|
||||||
|
- `YIWANGXIN_STORE_FILE`
|
||||||
|
- `YIWANGXIN_STORE_PASSWORD`
|
||||||
|
- `YIWANGXIN_ALIAS`
|
||||||
|
- `YIWANGXIN_KEY_PASSWORD`
|
||||||
|
|
||||||
|
缺少属性或密钥库文件不可读时,Release 构建会在签名前失败;Debug 使用 Android
|
||||||
|
默认调试签名,不读取生产签名材料。
|
||||||
|
|
||||||
|
Sample 还要求把租户平台签发且包名匹配的文件放到
|
||||||
|
`sample-app/src/main/assets/config/config.xuqmconfig`。该文件被 Git 忽略;Debug 与
|
||||||
|
Release 构建都会在文件缺失时给出明确错误,禁止提交、伪造或手工修改配置。
|
||||||
|
|
||||||
|
运行 Sample 仪器集成测试时,测试密码通过
|
||||||
|
`-e XUQM_TEST_PASSWORD <value>` 注入,禁止写入源码或 Gradle 配置。
|
||||||
|
|
||||||
```kotlin
|
```kotlin
|
||||||
// 存储
|
// 存储
|
||||||
@ -155,31 +168,28 @@ val token = XuqmSDK.tokenStore.getToken()
|
|||||||
XuqmSDK.tokenStore.clear()
|
XuqmSDK.tokenStore.clear()
|
||||||
```
|
```
|
||||||
|
|
||||||
### ApiClient
|
### 网络边界
|
||||||
|
|
||||||
基于 OkHttp 5 + Retrofit 3,自动附加 Bearer Token,统一解析 `ApiResponse<T>`。
|
`sdk-core` 的 `CommonHttpClient` 与下载/摘要能力无需 SDK 初始化;依赖租户平台认证、
|
||||||
|
服务地址和 FileProvider 的上传、打开能力统一由 `sdk-file` 的 `PlatformFileSDK` 提供。
|
||||||
```kotlin
|
|
||||||
// 通过 RetrofitFactory 获取接口实例
|
|
||||||
val service = RetrofitFactory.create(MyApiService::class.java)
|
|
||||||
```
|
|
||||||
|
|
||||||
### FileSDK
|
### FileSDK
|
||||||
|
|
||||||
文件上传、下载、打开的统一入口,位于 `com.xuqm.sdk.file`。
|
纯本地能力位于 `com.xuqm.sdk.file.FileSDK`;平台上传与 FileProvider 打开位于
|
||||||
|
`com.xuqm.sdk.file.platform.PlatformFileSDK`。
|
||||||
|
|
||||||
#### 上传
|
#### 上传
|
||||||
|
|
||||||
```kotlin
|
```kotlin
|
||||||
// 从 Uri(文件选择器返回值)上传,自动解析文件名和 MIME 类型
|
// 从 Uri(文件选择器返回值)上传,自动解析文件名和 MIME 类型
|
||||||
val result: FileUploadResult = FileSDK.upload(
|
val result: FileUploadResult = PlatformFileSDK.upload(
|
||||||
context = context,
|
context = context,
|
||||||
uri = uri,
|
uri = uri,
|
||||||
onProgress = { progress -> /* 0–100 */ },
|
onProgress = { progress -> /* 0–100 */ },
|
||||||
)
|
)
|
||||||
|
|
||||||
// 直接上传字节数组(如相机拍照后的 ByteArray)
|
// 直接上传字节数组(如相机拍照后的 ByteArray)
|
||||||
val result = FileSDK.uploadBytes(
|
val result = PlatformFileSDK.uploadBytes(
|
||||||
fileName = "photo.jpg",
|
fileName = "photo.jpg",
|
||||||
mimeType = "image/jpeg",
|
mimeType = "image/jpeg",
|
||||||
bytes = byteArray,
|
bytes = byteArray,
|
||||||
@ -187,10 +197,12 @@ val result = FileSDK.uploadBytes(
|
|||||||
)
|
)
|
||||||
|
|
||||||
// 上传 File 对象
|
// 上传 File 对象
|
||||||
val result = FileSDK.upload(file = file)
|
val result = PlatformFileSDK.upload(file = file)
|
||||||
```
|
```
|
||||||
|
|
||||||
`FileUploadResult` 字段:`url`、`thumbnailUrl`、`hash`、`size`、`originalName`、`mimeType`、`ext`
|
`FileUploadResult` 字段:`url`、`thumbnailUrl`、`hash`、`size`、`originalName`、`mimeType`、`ext`
|
||||||
|
。上传入口会自动等待平台初始化;服务端业务失败或响应结构错误统一抛出保留
|
||||||
|
`code/status/message` 的 `PlatformFileException`,不会暴露原始响应体。
|
||||||
|
|
||||||
#### 下载
|
#### 下载
|
||||||
|
|
||||||
@ -218,22 +230,14 @@ val file: File = FileSDK.download(
|
|||||||
|
|
||||||
```kotlin
|
```kotlin
|
||||||
// 用系统应用打开本地文件(通过 FileProvider + ACTION_VIEW)
|
// 用系统应用打开本地文件(通过 FileProvider + ACTION_VIEW)
|
||||||
FileSDK.openFile(context, file)
|
PlatformFileSDK.openFile(context, file)
|
||||||
```
|
```
|
||||||
|
|
||||||
需在 `AndroidManifest.xml` 中配置 `FileProvider`:
|
`openFile` 是纯本地操作,不要求 SDK 初始化。
|
||||||
|
|
||||||
```xml
|
`sdk-file` 是该 FileProvider 的唯一所有者;`sdk-update`、`sdk-im` 和 `sdk-webview`
|
||||||
<provider
|
均复用它。只集成 `sdk-core` 不会向宿主注入 Provider,也不存在 core 内的上传或打开
|
||||||
android:name="androidx.core.content.FileProvider"
|
转发 API。
|
||||||
android:authorities="${applicationId}.fileprovider"
|
|
||||||
android:exported="false"
|
|
||||||
android:grantUriPermissions="true">
|
|
||||||
<meta-data
|
|
||||||
android:name="android.support.FILE_PROVIDER_PATHS"
|
|
||||||
android:resource="@xml/file_paths" />
|
|
||||||
</provider>
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -337,7 +341,8 @@ data class ImMessage(
|
|||||||
|
|
||||||
### 推送接入
|
### 推送接入
|
||||||
|
|
||||||
在 `XuqmSDK.login()` 成功后,SDK 会自动完成当前系统推送 token 的注册与上传,不再要求业务侧手工传入 token。`logout()` 时会自动注销当前设备绑定。
|
宿主调用 `XuqmSDK.setUserInfo(XuqmUserInfo(...))` 后,SDK 会自动完成当前系统推送
|
||||||
|
token 的注册与上传,不再要求业务侧手工传入 token;传入 `null` 时自动注销当前设备绑定。
|
||||||
|
|
||||||
还可以按用户设置接收开关:
|
还可以按用户设置接收开关:
|
||||||
|
|
||||||
@ -406,7 +411,7 @@ WebView 内 `<input type="file">` 和 `<input type="file" capture>` 均已内置
|
|||||||
|
|
||||||
### 下载拦截
|
### 下载拦截
|
||||||
|
|
||||||
注入的 JS 自动拦截以下两种场景,下载完成后调用 `FileSDK.openFile()` 打开文件:
|
注入的 JS 自动拦截以下两种场景,下载完成后调用 `PlatformFileSDK.openFile()` 打开文件:
|
||||||
|
|
||||||
- 带 `download` 属性的 `<a>` 标签,或链接以可下载扩展名(`.pdf`、`.zip`、`.docx` 等)结尾
|
- 带 `download` 属性的 `<a>` 标签,或链接以可下载扩展名(`.pdf`、`.zip`、`.docx` 等)结尾
|
||||||
- Blob URL(自动转 base64 后传给 native 处理)
|
- Blob URL(自动转 base64 后传给 native 处理)
|
||||||
|
|||||||
165
build.gradle.kts
165
build.gradle.kts
@ -14,3 +14,168 @@ version = providers.gradleProperty("PUBLISH_VERSION").getOrElse("0.1.0-SNAPSHOT"
|
|||||||
ext["nexusUrl"] = "https://nexus.xuqinmin.com/repository/android-hosted/"
|
ext["nexusUrl"] = "https://nexus.xuqinmin.com/repository/android-hosted/"
|
||||||
ext["nexusUser"] = providers.gradleProperty("NEXUS_USER").getOrElse("")
|
ext["nexusUser"] = providers.gradleProperty("NEXUS_USER").getOrElse("")
|
||||||
ext["nexusPassword"] = providers.gradleProperty("NEXUS_PASSWORD").getOrElse("")
|
ext["nexusPassword"] = providers.gradleProperty("NEXUS_PASSWORD").getOrElse("")
|
||||||
|
|
||||||
|
val verifyNoSensitiveLogging by tasks.registering {
|
||||||
|
group = "verification"
|
||||||
|
description = "阻止日志、标准输出和异常文案包含凭据或用户标识实际值。"
|
||||||
|
doLast {
|
||||||
|
val sensitiveIdentifiers = Regex(
|
||||||
|
"""(?i)\b(accessToken|token|userSig|pushToken|password|apiKey|signingKey|""" +
|
||||||
|
"""userId|groupId|targetId|messageId|requestId|toId|fromId|phone|email|regId)\b""",
|
||||||
|
)
|
||||||
|
val sensitiveInterpolation = Regex(
|
||||||
|
"""(?i)\$(?:\{[^}]*\b(accessToken|token|userSig|pushToken|password|apiKey|""" +
|
||||||
|
"""signingKey|userId|groupId|targetId|messageId|requestId|toId|fromId|""" +
|
||||||
|
"""phone|email|regId)\b[^}]*}|(accessToken|token|userSig|pushToken|password|""" +
|
||||||
|
"""apiKey|signingKey|userId|groupId|targetId|messageId|requestId|toId|""" +
|
||||||
|
"""fromId|phone|email|regId)\b)""",
|
||||||
|
)
|
||||||
|
val sinkStart = Regex(
|
||||||
|
"""(?:(?:android\.util\.)?Log\.[vdiew]|println|System\.out\.print(?:ln)?|""" +
|
||||||
|
"""[A-Za-z_][A-Za-z0-9_]*Exception|error)""" +
|
||||||
|
"""\s*\(""",
|
||||||
|
)
|
||||||
|
|
||||||
|
fun invocationAt(source: String, start: Int): String {
|
||||||
|
val open = source.indexOf('(', start)
|
||||||
|
if (open < 0) return source.substring(start)
|
||||||
|
var depth = 0
|
||||||
|
var quote: Char? = null
|
||||||
|
var escaped = false
|
||||||
|
for (index in open until source.length) {
|
||||||
|
val char = source[index]
|
||||||
|
if (quote != null) {
|
||||||
|
if (escaped) {
|
||||||
|
escaped = false
|
||||||
|
} else if (char == '\\') {
|
||||||
|
escaped = true
|
||||||
|
} else if (char == quote) {
|
||||||
|
quote = null
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (char == '"' || char == '\'') {
|
||||||
|
quote = char
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (char == '(') depth++
|
||||||
|
if (char == ')' && --depth == 0) return source.substring(start, index + 1)
|
||||||
|
}
|
||||||
|
return source.substring(start)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun withoutStringLiterals(value: String): String {
|
||||||
|
val result = StringBuilder(value.length)
|
||||||
|
var quote: Char? = null
|
||||||
|
var escaped = false
|
||||||
|
value.forEach { char ->
|
||||||
|
if (quote != null) {
|
||||||
|
if (escaped) {
|
||||||
|
escaped = false
|
||||||
|
} else if (char == '\\') {
|
||||||
|
escaped = true
|
||||||
|
} else if (char == quote) {
|
||||||
|
quote = null
|
||||||
|
}
|
||||||
|
result.append(' ')
|
||||||
|
} else if (char == '"' || char == '\'') {
|
||||||
|
quote = char
|
||||||
|
result.append(' ')
|
||||||
|
} else {
|
||||||
|
result.append(char)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result.toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
val violations = mutableListOf<String>()
|
||||||
|
fileTree(rootDir) {
|
||||||
|
include("**/*.kt", "**/*.java", "**/*.kts", "**/*.gradle")
|
||||||
|
exclude("**/build/**", "**/.gradle/**")
|
||||||
|
}.files.sortedBy { it.path }.forEach { sourceFile ->
|
||||||
|
val source = sourceFile.readText(Charsets.UTF_8)
|
||||||
|
sinkStart.findAll(source).forEach { match ->
|
||||||
|
val invocation = invocationAt(source, match.range.first)
|
||||||
|
if (
|
||||||
|
sensitiveInterpolation.containsMatchIn(invocation) ||
|
||||||
|
sensitiveIdentifiers.containsMatchIn(withoutStringLiterals(invocation))
|
||||||
|
) {
|
||||||
|
val line = source.take(match.range.first).count { it == '\n' } + 1
|
||||||
|
violations += "${sourceFile.relativeTo(rootDir)}:$line"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
check(violations.isEmpty()) {
|
||||||
|
"Sensitive value may reach a log, stdout, or exception message:\n" +
|
||||||
|
violations.joinToString("\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
subprojects {
|
||||||
|
tasks.matching {
|
||||||
|
it.name == "testDebugUnitTest" || it.name == "lintRelease" || it.name == "check"
|
||||||
|
}.configureEach {
|
||||||
|
dependsOn(rootProject.tasks.named("verifyNoSensitiveLogging"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val verifySdkManifestOwnership by tasks.registering {
|
||||||
|
group = "verification"
|
||||||
|
description = "验证 common 无宿主 Manifest 副作用,扩展能力各自声明所需组件。"
|
||||||
|
dependsOn(
|
||||||
|
":sdk-core:processDebugManifest",
|
||||||
|
":sdk-file:processDebugManifest",
|
||||||
|
":sdk-update:processDebugManifest",
|
||||||
|
":sdk-push:processDebugManifest",
|
||||||
|
":sdk-im:processDebugManifest",
|
||||||
|
":sdk-webview:processDebugManifest",
|
||||||
|
":sdk-bugcollect:processDebugManifest",
|
||||||
|
)
|
||||||
|
doLast {
|
||||||
|
fun mergedManifest(module: String): String =
|
||||||
|
file("$module/build/intermediates/merged_manifest/debug/processDebugManifest/AndroidManifest.xml")
|
||||||
|
.readText()
|
||||||
|
|
||||||
|
val core = mergedManifest("sdk-core")
|
||||||
|
check("XuqmMergedProvider" !in core)
|
||||||
|
check("POST_NOTIFICATIONS" !in core)
|
||||||
|
check("androidx.core.content.FileProvider" !in core)
|
||||||
|
|
||||||
|
val update = mergedManifest("sdk-update")
|
||||||
|
check("XuqmMergedProvider" in update)
|
||||||
|
check("androidx.core.content.FileProvider" !in update)
|
||||||
|
|
||||||
|
val file = mergedManifest("sdk-file")
|
||||||
|
check("XuqmMergedProvider" in file)
|
||||||
|
check("androidx.core.content.FileProvider" in file)
|
||||||
|
|
||||||
|
val push = mergedManifest("sdk-push")
|
||||||
|
check("XuqmMergedProvider" in push)
|
||||||
|
check("POST_NOTIFICATIONS" in push)
|
||||||
|
|
||||||
|
listOf("sdk-im", "sdk-webview", "sdk-bugcollect").forEach { module ->
|
||||||
|
check("XuqmMergedProvider" in mergedManifest(module))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val verifySampleManifestMerge by tasks.registering {
|
||||||
|
group = "verification"
|
||||||
|
description = "验证多个扩展合并后只存在一个初始化 Provider。"
|
||||||
|
dependsOn(":sample-app:processDebugMainManifest")
|
||||||
|
doLast {
|
||||||
|
val manifest = file(
|
||||||
|
"sample-app/build/intermediates/merged_manifest/debug/" +
|
||||||
|
"processDebugMainManifest/AndroidManifest.xml",
|
||||||
|
).readText()
|
||||||
|
check(Regex("com\\.xuqm\\.sdk\\.internal\\.XuqmMergedProvider")
|
||||||
|
.findAll(manifest).count() == 1)
|
||||||
|
check(Regex("androidx\\.core\\.content\\.FileProvider")
|
||||||
|
.findAll(manifest).count() == 1)
|
||||||
|
check(Regex("com\\.xuqm\\.common_plugin_applied")
|
||||||
|
.findAll(manifest).count() == 1)
|
||||||
|
check("com.xuqm.bugcollect_build_enabled" in manifest)
|
||||||
|
check("com.xuqm.bugcollect_automatic_enabled" in manifest)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
# Android SDK 重构实施接管文档
|
# Android SDK 重构实施接管文档
|
||||||
|
|
||||||
> 更新时间:2026-07-18
|
> 更新时间:2026-07-26
|
||||||
> 分支:`main`
|
> 分支:`main`
|
||||||
> 发布约束:Maven 制品只允许通过 `https://jenkins.xuqinmin.com/` 发布。
|
> 发布约束:Maven 制品只允许通过 `https://jenkins.xuqinmin.com/` 发布。
|
||||||
|
|
||||||
@ -9,19 +9,18 @@
|
|||||||
- `sdk-core` 是配置、会话、网络、文件、时间等公共能力的唯一实现。
|
- `sdk-core` 是配置、会话、网络、文件、时间等公共能力的唯一实现。
|
||||||
- `sdk-update` 只提供更新领域 API;宿主与 RN 桥接不复制下载、校验和安装代码。
|
- `sdk-update` 只提供更新领域 API;宿主与 RN 桥接不复制下载、校验和安装代码。
|
||||||
- APK 更新必须支持 SHA-256、原子临时文件、进度、取消、有限重试、FileProvider、未知来源安装权限及确定错误码。
|
- APK 更新必须支持 SHA-256、原子临时文件、进度、取消、有限重试、FileProvider、未知来源安装权限及确定错误码。
|
||||||
- 废弃的离线激活 `sdk-license` 已彻底删除,不得恢复反射初始化或兼容入口。
|
|
||||||
|
|
||||||
## 2026-07-17 已完成
|
## 2026-07-17 已完成
|
||||||
|
|
||||||
- `FileSDK` 新增详细下载进度、SHA-256 与哈希匹配公共 API。
|
- `FileSDK` 新增详细下载进度、SHA-256 与哈希匹配公共 API。
|
||||||
- 下载改为 `.part` 临时文件完成后替换目标文件;HTTP 非 2xx 失败,协程取消会中断连接并清理临时文件。
|
- 下载改为 `.part` 临时文件完成后替换目标文件;HTTP 非 2xx 失败,协程取消会中断连接并清理临时文件。
|
||||||
- `sdk-core` 自动合并 FileProvider,扩展 SDK 不再要求宿主重复声明 provider。
|
- 当时的 core FileProvider 归属已被后续终态替代:当前仅 `sdk-file` 声明,core 零 Manifest。
|
||||||
- `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。
|
||||||
- `sdk-core`、`sdk-update`、`sample-app` 编译通过。
|
- `sdk-core`、`sdk-update`、`sample-app` 编译通过。
|
||||||
- `sdk-license` 模块、源码、初始化反射、Gradle/Jenkins 参数和架构文档引用已全部删除。
|
- 收敛 update/push/im/sample 的空 package/resource 目录;源码树禁止保留空目录或
|
||||||
- 本轮同时删除本地遗留的 `sdk-license` 构建目录及其空源码包,并收敛 update/push/im/sample 的空 package/resource 目录;源码树禁止保留空目录或 `.gitkeep` 占位。
|
`.gitkeep` 占位。
|
||||||
- 清理后 `:sdk-core:testDebugUnitTest :sdk-update:testDebugUnitTest` 重新执行并通过。
|
- 清理后 `:sdk-core:testDebugUnitTest :sdk-update:testDebugUnitTest` 重新执行并通过。
|
||||||
- `sdk-core` 1 个单元测试、`sdk-update` 2 个单元测试通过。
|
- `sdk-core` 1 个单元测试、`sdk-update` 2 个单元测试通过。
|
||||||
- 修复通知权限检查、MediaStore API 等级和 `java.time` minSdk 24 反向移植,core/update Release Lint 均通过。
|
- 修复通知权限检查、MediaStore API 等级和 `java.time` minSdk 24 反向移植,core/update Release Lint 均通过。
|
||||||
@ -37,12 +36,12 @@
|
|||||||
`com.squareup.okhttp3` 组件版本一致。
|
`com.squareup.okhttp3` 组件版本一致。
|
||||||
- `sdk-update` 的 Gson 改为复用版本目录定义,删除模块内的重复版本事实来源。
|
- `sdk-update` 的 Gson 改为复用版本目录定义,删除模块内的重复版本事实来源。
|
||||||
|
|
||||||
## 下一步
|
## 后续外部验收
|
||||||
|
|
||||||
1. 补充 Android 仪器测试。
|
1. 从租户平台下载与 Sample 包名匹配的真实 `config.xuqmconfig`,再执行 Sample
|
||||||
2. 审计 Java 8 对外制品与内部 Java 21 集成边界,完成对应构建门禁。
|
Debug/Release 整包和仪器测试。
|
||||||
3. 通过 Jenkins 先发布 `sdk-core` Snapshot,再发布 `sdk-update:2.0.0-SNAPSHOT`。
|
2. 在目标宿主中验证下载取消、错误码、权限跳转和安装器唤起。
|
||||||
4. 在 RN SDK Android 宿主中编译并验证下载取消、错误码、权限跳转和安装器唤起。
|
3. 发布只允许通过 Jenkins 执行;当前开发机未发布任何 Maven 制品。
|
||||||
|
|
||||||
## 已验证命令
|
## 已验证命令
|
||||||
|
|
||||||
@ -53,3 +52,127 @@
|
|||||||
```
|
```
|
||||||
|
|
||||||
不得在开发机执行 Maven 发布。
|
不得在开发机执行 Maven 发布。
|
||||||
|
|
||||||
|
## 2026-07-26 初始化、Update 与 BugCollect 收敛
|
||||||
|
|
||||||
|
- `sdk-core` 新增唯一初始化状态机:`IDLE / INITIALIZING / READY / DEGRADED / FAILED`,
|
||||||
|
对外失败使用 `XuqmSdkException + XuqmErrorCode`。
|
||||||
|
- 平台配置以 `appKey + packageName + platformUrl` 隔离,使用加密 LKG 保存七天;
|
||||||
|
远端配置最多执行三次指数退避重试,有有效 LKG 时降级运行,否则只停用扩展能力。
|
||||||
|
- 初始化文件唯一位置为 `assets/config/config.xuqmconfig`。运行时只接受
|
||||||
|
`XUQM-CONFIG-V2`:Ed25519 签名覆盖前五段 ASCII,先验签、后 AES 解密,
|
||||||
|
canonical JSON、schema、UUID、revision 和 RFC3339 时间均强校验;V1 与未知 keyId 拒绝。
|
||||||
|
- 已内置开发签发公钥 `xuqm-config-dev-2026-07`;私钥不属于 SDK 仓库。
|
||||||
|
- `BugCollect` 必须同时满足平台启用、隐私已同意、上传地址有效。关闭时恢复宿主原
|
||||||
|
UncaughtExceptionHandler 并删除队列;服务端 `BUGCOLLECT_DISABLED` 会形成持久停用锁,
|
||||||
|
仅新的成功平台配置可解除。
|
||||||
|
- error/fatal 加密持久化,最多 500 条且保留七天;普通事件仅内存。后台上传最多
|
||||||
|
重试三次并加入抖动,不向宿主线程抛错;统一脱敏入口在入队前清理敏感键、手机号、
|
||||||
|
身份证号和 Bearer Token。
|
||||||
|
- Update 使用平台动态 `updateRequiresLogin`:未登录正常跳过;登录后同一会话自动补检
|
||||||
|
一次;退出或切换用户会取消旧任务并清除灰度检查缓存,回调统一切回主线程。
|
||||||
|
- BugCollect Gradle 插件拒绝 V1,Release 读取同一 V2 文件;支持
|
||||||
|
`-Pxuqm.bugcollect=disabled` 应急构建,此时保留本地 mapping 产物但不访问上传服务。
|
||||||
|
- Android R8 `mapping.txt` 通过 `POST /bugcollect/v1/artifacts/upload` 以
|
||||||
|
`artifactType=R8_MAPPING` 上传,并携带 appKey、platform、appVersion、buildId 与文件
|
||||||
|
SHA-256。它不是 RN Source Map,不改名为 `.map`,也不伪造 module/bundle 身份。
|
||||||
|
上传使用构建机 `XUQM_API_TOKEN` Bearer 认证;令牌不进入配置、任务输入快照或日志。
|
||||||
|
平台启用 BugCollect 但缺少令牌时 Release 明确失败;应急 disabled 构建不要求令牌。
|
||||||
|
- 所有 Release(无论是否开启混淆)都会在打包前调用
|
||||||
|
`POST /api/sdk/build/config/validate`,提交原始签名配置和最终 applicationId。服务不可达、
|
||||||
|
配置撤销、过期或包名不匹配都会阻止打包;应急关闭 BugCollect 也不能绕过配置校验。
|
||||||
|
- 删除不再参与 Manifest 的 `BugCollectInitProvider`,避免扩展模块平行初始化。
|
||||||
|
- 删除公开手动初始化与旧 Provider 兼容类。`sdk-core` 单独集成不注册 Provider、
|
||||||
|
FileProvider 或通知权限;需要平台能力的扩展统一合并同一个 `XuqmMergedProvider`。
|
||||||
|
FileProvider 归唯一 `sdk-file`,通知权限归 `sdk-push`。
|
||||||
|
- Sample 删除硬编码 appKey,只接受被 Git 忽略的租户平台签发配置;缺少文件时 Debug/
|
||||||
|
Release 构建门禁给出明确错误。Release 签名只从用户级 Gradle 属性读取。
|
||||||
|
- 敏感存储收敛为唯一 `SecureStore`:Android Keystore 不可导出 AES-256-GCM 密钥,
|
||||||
|
namespace/key 作为 AAD;删除废弃 security-crypto 依赖。
|
||||||
|
- `sdk-webview/XWebViewView.kt` 的既有未提交修改始终作为用户基线保留;本轮只在该基线
|
||||||
|
上将打开文件能力切换到唯一 `PlatformFileSDK`,未覆盖其它改动。
|
||||||
|
|
||||||
|
本轮最终已验证:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./gradlew --no-daemon -Dkotlin.compiler.execution.strategy=in-process \
|
||||||
|
:sdk-core:testDebugUnitTest \
|
||||||
|
:sdk-bugcollect:testDebugUnitTest \
|
||||||
|
:sdk-update:testDebugUnitTest \
|
||||||
|
:sdk-bugcollect-plugin:test \
|
||||||
|
:sdk-core:lintRelease \
|
||||||
|
:sdk-bugcollect:lintRelease \
|
||||||
|
:sdk-update:lintRelease
|
||||||
|
|
||||||
|
./gradlew --no-daemon \
|
||||||
|
:sdk-core:assembleRelease \
|
||||||
|
:sdk-bugcollect:assembleRelease \
|
||||||
|
:sdk-update:assembleRelease \
|
||||||
|
:sdk-bugcollect-plugin:assemble \
|
||||||
|
verifySdkManifestOwnership
|
||||||
|
|
||||||
|
./gradlew :sample-app:compileDebugKotlin \
|
||||||
|
-x :sample-app:validateSampleSdkConfig
|
||||||
|
|
||||||
|
./gradlew :sample-app:compileDebugAndroidTestKotlin \
|
||||||
|
-x :sample-app:validateSampleSdkConfig
|
||||||
|
```
|
||||||
|
|
||||||
|
Sample 源码编译时只为静态验证跳过真实签发文件的存在性门禁;正常执行
|
||||||
|
`:sample-app:validateSampleSdkConfig` 已确认会以明确错误阻止缺配置构建。合并 Manifest
|
||||||
|
静态结果确认多个扩展最终只有一个初始化 Provider。
|
||||||
|
|
||||||
|
尚未执行 Jenkins、Maven 发布、真机仪器测试或带真实租户配置的 Sample 整包。在线校验的不可达、HTTP 失败、
|
||||||
|
`CONFIG_REVOKED`、`CONFIG_EXPIRED`、`PACKAGE_MISMATCH` 和成功响应已有单元测试覆盖;
|
||||||
|
服务端真实生成的 V2 固定向量已在 Android 端完成验签、解密和 canonical JSON 精确
|
||||||
|
比对。R8 multipart 上传字段、Bearer Header、SHA-256、`.txt` 文件名与令牌不进入请求
|
||||||
|
正文均有本地 HTTP 契约测试。
|
||||||
|
|
||||||
|
## 2026-07-26 审计后终态修复
|
||||||
|
|
||||||
|
- 新增唯一 `com.xuqm.common` 构建插件:扫描宿主 `src/**`,所有变体只接受
|
||||||
|
`src/main/assets/config/config.xuqmconfig`;改名、嵌套或其它 sourceSet 中出现任何
|
||||||
|
`.xuqmconfig` 都会失败。Debug
|
||||||
|
执行本地 V2 验签,Release 额外在线校验。buildId 与运行时
|
||||||
|
BugCollect 构建门禁均归此插件;可选 BugCollect 插件只上传 R8 mapping。
|
||||||
|
- 解密后的 `serverUrl` 是唯一且必填的平台地址;已删除旧业务默认地址和运行时回退,
|
||||||
|
配置缺失或地址为空时初始化明确失败。
|
||||||
|
- 删除 `configureServiceEndpoints`、`useExternalServiceEndpoints`、
|
||||||
|
`useLocalServiceEndpoints` 及 Sample 的环境设置页面、路由和持久化。服务端点仅可由
|
||||||
|
签发配置的 `serverUrl` 与该平台远程配置产生;远程字段缺失时只回退到同一
|
||||||
|
`serverUrl`,不会切换平台。
|
||||||
|
- Release buildId 优先读取 Jenkins/CLI 的 `XUQM_BUILD_ID`;本地缺失时使用 UUID。
|
||||||
|
为防止 Gradle configuration cache 复用随机身份,启用配置缓存的 Release 强制要求显式 buildId。
|
||||||
|
- `-Pxuqm.bugcollect=disabled` 是不可覆盖的构建禁用,同时关闭运行时采集/上传与
|
||||||
|
mapping 上传;mapping 仍保留为本地产物。Debug 只关闭自动采集,可调试宿主在构建未
|
||||||
|
禁用、隐私同意、平台启用时可通过隐藏开发入口显式发送一次测试事件。
|
||||||
|
- common 插件按变体生成唯一 Manifest 标记、buildId 与 BugCollect 构建开关;扩展 AAR
|
||||||
|
的合并 Provider 未检测到该标记时只退出,不执行初始化,core-only 仍保持零 Manifest。
|
||||||
|
- Update 先服从平台 `features.update`,相同用户重复同步不会再次补检;下载与安装授权
|
||||||
|
绑定当前 generation 和 userId,切换或登出会取消活动下载、清除授权并阻止旧灰度结果安装。
|
||||||
|
- BugCollect 队列 flush 使用 Mutex 单飞,定时与满批触发不会重复上传同一批数据。
|
||||||
|
- 内部 BugCollect 状态桥接改为私有反射 SPI,不进入 Java/Kotlin 公开 API。
|
||||||
|
- `sdk-core` 保持零 Manifest,只保留无需初始化的纯本地文件能力。新增唯一 `sdk-file`
|
||||||
|
扩展承载租户平台上传与 FileProvider 打开;IM、Update、WebView 复用该扩展。
|
||||||
|
- `sdk-file` 的 Uri、File、ByteArray 三个上传入口统一先等待平台初始化,再创建请求体
|
||||||
|
和读取服务端点;平台失败统一抛出保留 `code/status/message` 且不暴露响应体的
|
||||||
|
`PlatformFileException`。`openFile` 仍为不依赖初始化的纯本地能力。
|
||||||
|
- 全仓日志、标准输出和异常文案不再输出 token、userId、groupId、消息目标、H5
|
||||||
|
payload、请求/响应正文、文件 Uri/绝对路径等实际值。根任务
|
||||||
|
`verifyNoSensitiveLogging` 在各模块 `testDebugUnitTest`、`lintRelease` 和 `check`
|
||||||
|
前扫描 Kotlin/Java/Gradle 源码,阻止凭据或用户标识重新进入输出边界。
|
||||||
|
- 删除文档中不存在的 `RetrofitFactory` 示例;公开文档同步 common/file 的真实边界。
|
||||||
|
|
||||||
|
### 本批最终验证
|
||||||
|
|
||||||
|
- core/file/bugcollect/update/im/push 与 BugCollect Gradle 插件单元测试通过;
|
||||||
|
`sdk-common-plugin` 独立构建测试通过,包含严格 `data.valid` 防伪响应和单配置路径测试。
|
||||||
|
- core/file/bugcollect/update/im/push/webview 的 `lintRelease` 与 `assembleRelease` 全部通过。
|
||||||
|
- Sample 的 Debug Kotlin、AndroidTest 编译以及合并 Manifest 校验通过;因仓库不提交被忽略的
|
||||||
|
`config.xuqmconfig`,验证编译时仅显式跳过 `xuqmValidateConfigDebug`。
|
||||||
|
- 单独执行 `xuqmValidateConfigDebug` 已按预期失败,并明确提示唯一缺失路径
|
||||||
|
`src/main/assets/config/config.xuqmconfig`;未伪造配置、未绕过在线 Release 校验。
|
||||||
|
- 普通 Release 生成结果为 `buildEnabled=true/automaticEnabled=true`;应急参数生成结果为
|
||||||
|
`false/false`;Debug 为 `true/false`,显式开发测试仍受隐私和平台开关约束。
|
||||||
|
- Release 未提供 `XUQM_BUILD_ID` 且启用 configuration cache 时按预期失败;提供显式
|
||||||
|
buildId 后配置缓存可保存、复用,生成 Manifest 中的值保持不变。
|
||||||
|
|||||||
@ -13,12 +13,14 @@
|
|||||||
- `checkAppUpdate(context, bypassIgnore: Boolean = false)`:
|
- `checkAppUpdate(context, bypassIgnore: Boolean = false)`:
|
||||||
- `bypassIgnore = false`(默认,静默检查):用户已忽略的版本不再弹窗,适合启动时后台检查。
|
- `bypassIgnore = false`(默认,静默检查):用户已忽略的版本不再弹窗,适合启动时后台检查。
|
||||||
- `bypassIgnore = true`(主动检查):绕过忽略记录,始终返回真实更新状态;无更新时由调用方显示提示。
|
- `bypassIgnore = true`(主动检查):绕过忽略记录,始终返回真实更新状态;无更新时由调用方显示提示。
|
||||||
- 移除了旧版 `userIdOverride` 参数,userId 统一通过 `XuqmSDK.login()` 建立 session 后自动传递。
|
- 移除了旧版 `userIdOverride` 参数,userId 统一通过 `XuqmSDK.setUserInfo()` 同步后自动传递。
|
||||||
|
|
||||||
### 兼容性
|
### 兼容性
|
||||||
|
|
||||||
- `updateInfo.requiresLogin` 默认值为 `false`,服务端未升级时行为不变。
|
- `updateInfo.requiresLogin` 默认值为 `false`,服务端未升级时行为不变。
|
||||||
- **破坏性变更**:移除 `userIdOverride` 参数。调用方应先 `XuqmSDK.login(userId, userSig)` 建立 session,再调用 `checkAppUpdate(context)`,SDK 自动从 session 读取 userId。
|
- **破坏性变更**:移除 `userIdOverride` 参数。调用方应先
|
||||||
|
`XuqmSDK.setUserInfo(XuqmUserInfo(...))` 同步用户,再调用 `checkAppUpdate(context)`;
|
||||||
|
SDK 自动从统一用户状态读取 userId。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
40
docs/架构总览.md
40
docs/架构总览.md
@ -3,14 +3,16 @@
|
|||||||
## 整体架构
|
## 整体架构
|
||||||
|
|
||||||
```
|
```
|
||||||
宿主 App(YwxMobileApp 等)
|
宿主 App
|
||||||
└── 初始化(方式A: ContentProvider 自动 / 方式B: Application.onCreate 手动)
|
└── 放置租户平台签发的 assets/config/config.xuqmconfig
|
||||||
↓
|
↓
|
||||||
sdk-core(核心层)
|
sdk-core(核心层;单独集成时零初始化、零权限、零 Provider)
|
||||||
├── XuqmSDK.initialize() → 拉取 /api/sdk/config
|
├── XuqmMergedProvider 实现(仅由扩展 Manifest 注册)
|
||||||
|
├── 初始化状态:IDLE / INITIALIZING / READY / DEGRADED / FAILED
|
||||||
|
├── 七天加密 LKG(仅平台暂不可用时降级使用)
|
||||||
├── XuqmSDK.setUserInfo() → 通知所有子模块(反射 / 接口回调)
|
├── XuqmSDK.setUserInfo() → 通知所有子模块(反射 / 接口回调)
|
||||||
├── ApiClient(OkHttp 5 + Retrofit 3)
|
├── ApiClient(OkHttp 4.12 + Retrofit 3)
|
||||||
├── TokenStore(EncryptedSharedPreferences)
|
├── TokenStore(Android Keystore + AES-256-GCM)
|
||||||
├── FileSDK(上传 / 下载 / 打开文件)
|
├── FileSDK(上传 / 下载 / 打开文件)
|
||||||
├── ServiceEndpointRegistry(各服务地址注册表)
|
├── ServiceEndpointRegistry(各服务地址注册表)
|
||||||
└── bugCollectApiUrl / bugCollectEnabled(从平台配置获取)
|
└── bugCollectApiUrl / bugCollectEnabled(从平台配置获取)
|
||||||
@ -51,30 +53,18 @@ XuqmSDK.setUserInfo(null)
|
|||||||
|
|
||||||
## 初始化流程
|
## 初始化流程
|
||||||
|
|
||||||
### 方式 A(ContentProvider 自动)
|
|
||||||
|
|
||||||
```
|
```
|
||||||
App 启动
|
App 启动
|
||||||
↓
|
↓
|
||||||
XuqmInitializerProvider.onCreate()
|
任一扩展合并的唯一 XuqmMergedProvider.onCreate()
|
||||||
↓ 读取 assets/xuqm/config.xuqm
|
↓ 主线程仅读取 assets/config/config.xuqmconfig
|
||||||
ConfigFileReader.read(context)
|
IO 线程验证 Ed25519 签名 → 解密 canonical JSON → 校验包身份
|
||||||
↓ 解析 appKey + serverUrl
|
|
||||||
XuqmSDK.initialize(context, appKey, serverUrl)
|
|
||||||
↓ HTTP GET
|
↓ HTTP GET
|
||||||
/api/sdk/config → 返回 { apiUrl, imWsUrl, fileServiceUrl, ... }
|
/api/sdk/config → 返回 { apiUrl, imWsUrl, fileServiceUrl, ... }
|
||||||
```
|
|
||||||
|
|
||||||
### 方式 B(手动)
|
|
||||||
|
|
||||||
```
|
|
||||||
Application.onCreate()
|
|
||||||
↓
|
↓
|
||||||
XuqmSDK.initialize(context, appKey = "xxx")
|
成功:READY 并原子保存 LKG
|
||||||
↓ HTTP GET
|
失败且七天 LKG 有效:DEGRADED
|
||||||
/api/sdk/config → 解析平台配置
|
失败且无有效 LKG:FAILED;扩展停用,宿主核心业务继续
|
||||||
↓
|
|
||||||
各子模块就绪(等待 setUserInfo 触发具体功能)
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## 技术栈
|
## 技术栈
|
||||||
@ -86,7 +76,7 @@ XuqmSDK.initialize(context, appKey = "xxx")
|
|||||||
| minSdk | 24 |
|
| minSdk | 24 |
|
||||||
| compileSdk | 36 |
|
| compileSdk | 36 |
|
||||||
| Java | 21 |
|
| Java | 21 |
|
||||||
| OkHttp | 5.x |
|
| OkHttp | 4.12.x(与 React Native 宿主二进制兼容) |
|
||||||
| Retrofit | 3.x |
|
| Retrofit | 3.x |
|
||||||
| Coroutines | 1.x |
|
| Coroutines | 1.x |
|
||||||
|
|
||||||
|
|||||||
@ -4,6 +4,7 @@ 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
|
||||||
|
|||||||
@ -26,11 +26,11 @@ espresso = "3.7.0"
|
|||||||
hilt = "2.56.2"
|
hilt = "2.56.2"
|
||||||
ksp = "2.3.7"
|
ksp = "2.3.7"
|
||||||
navigationCompose = "2.9.0"
|
navigationCompose = "2.9.0"
|
||||||
securityCrypto = "1.0.0"
|
|
||||||
hiltNavigationCompose = "1.2.0"
|
hiltNavigationCompose = "1.2.0"
|
||||||
viewmodelCompose = "2.10.0"
|
viewmodelCompose = "2.10.0"
|
||||||
material = "1.13.0"
|
material = "1.13.0"
|
||||||
firebaseBom = "34.7.0"
|
firebaseBom = "34.7.0"
|
||||||
|
bouncyCastle = "1.84"
|
||||||
|
|
||||||
[libraries]
|
[libraries]
|
||||||
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
|
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
|
||||||
@ -52,7 +52,6 @@ androidx-material-icons-extended = { group = "androidx.compose.material", name =
|
|||||||
androidx-datastore-preferences = { group = "androidx.datastore", name = "datastore-preferences", version.ref = "datastore" }
|
androidx-datastore-preferences = { group = "androidx.datastore", name = "datastore-preferences", version.ref = "datastore" }
|
||||||
androidx-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigationCompose" }
|
androidx-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigationCompose" }
|
||||||
androidx-hilt-navigation-compose = { group = "androidx.hilt", name = "hilt-navigation-compose", version.ref = "hiltNavigationCompose" }
|
androidx-hilt-navigation-compose = { group = "androidx.hilt", name = "hilt-navigation-compose", version.ref = "hiltNavigationCompose" }
|
||||||
androidx-security-crypto = { group = "androidx.security", name = "security-crypto", version.ref = "securityCrypto" }
|
|
||||||
kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "jserialization" }
|
kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "jserialization" }
|
||||||
androidx-webkit = { group = "androidx.webkit", name = "webkit", version.ref = "webkit" }
|
androidx-webkit = { group = "androidx.webkit", name = "webkit", version.ref = "webkit" }
|
||||||
coil-compose = { group = "io.coil-kt.coil3", name = "coil-compose", version.ref = "coil" }
|
coil-compose = { group = "io.coil-kt.coil3", name = "coil-compose", version.ref = "coil" }
|
||||||
@ -70,6 +69,7 @@ hilt-android = { group = "com.google.dagger", name = "hilt-android", version.ref
|
|||||||
hilt-compiler = { group = "com.google.dagger", name = "hilt-compiler", version.ref = "hilt" }
|
hilt-compiler = { group = "com.google.dagger", name = "hilt-compiler", version.ref = "hilt" }
|
||||||
junit4 = { group = "junit", name = "junit", version.ref = "junit4" }
|
junit4 = { group = "junit", name = "junit", version.ref = "junit4" }
|
||||||
desugar-jdk-libs = { group = "com.android.tools", name = "desugar_jdk_libs", version.ref = "desugarJdkLibs" }
|
desugar-jdk-libs = { group = "com.android.tools", name = "desugar_jdk_libs", version.ref = "desugarJdkLibs" }
|
||||||
|
bouncycastle = { group = "org.bouncycastle", name = "bcprov-jdk18on", version.ref = "bouncyCastle" }
|
||||||
androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "androidxJunit" }
|
androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "androidxJunit" }
|
||||||
androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espresso" }
|
androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espresso" }
|
||||||
|
|
||||||
|
|||||||
@ -5,17 +5,17 @@ afterEvaluate {
|
|||||||
publications {
|
publications {
|
||||||
release(MavenPublication) {
|
release(MavenPublication) {
|
||||||
from components.release
|
from components.release
|
||||||
groupId rootProject.group
|
groupId = rootProject.group
|
||||||
artifactId project.name
|
artifactId = project.name
|
||||||
version project.version
|
version = project.version
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
repositories {
|
repositories {
|
||||||
maven {
|
maven {
|
||||||
url rootProject.ext['nexusUrl']
|
url = rootProject.ext['nexusUrl']
|
||||||
credentials {
|
credentials {
|
||||||
username rootProject.ext['nexusUser']
|
username = rootProject.ext['nexusUser']
|
||||||
password rootProject.ext['nexusPassword']
|
password = rootProject.ext['nexusPassword']
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,6 +1,17 @@
|
|||||||
plugins {
|
plugins {
|
||||||
alias(libs.plugins.android.application)
|
alias(libs.plugins.android.application)
|
||||||
alias(libs.plugins.kotlin.compose)
|
alias(libs.plugins.kotlin.compose)
|
||||||
|
id("com.xuqm.common")
|
||||||
|
}
|
||||||
|
|
||||||
|
val releaseSigningProperties = mapOf(
|
||||||
|
"storeFile" to providers.gradleProperty("YIWANGXIN_STORE_FILE"),
|
||||||
|
"storePassword" to providers.gradleProperty("YIWANGXIN_STORE_PASSWORD"),
|
||||||
|
"keyAlias" to providers.gradleProperty("YIWANGXIN_ALIAS"),
|
||||||
|
"keyPassword" to providers.gradleProperty("YIWANGXIN_KEY_PASSWORD"),
|
||||||
|
)
|
||||||
|
val hasReleaseSigning = releaseSigningProperties.values.all {
|
||||||
|
it.orNull?.isNotBlank() == true
|
||||||
}
|
}
|
||||||
|
|
||||||
android {
|
android {
|
||||||
@ -17,26 +28,26 @@ android {
|
|||||||
}
|
}
|
||||||
|
|
||||||
signingConfigs {
|
signingConfigs {
|
||||||
create("ywq") {
|
if (hasReleaseSigning) {
|
||||||
storeFile = file("/Users/xuqinmin/Projects/TrustProjects/YiwangxinApp/android/keystores/wsecx.keystore")
|
create("releaseFromProperties") {
|
||||||
storePassword = "bjca123456"
|
storeFile = file(releaseSigningProperties.getValue("storeFile").get())
|
||||||
keyAlias = "bjcawsecx"
|
storePassword = releaseSigningProperties.getValue("storePassword").get()
|
||||||
keyPassword = "bjca123456"
|
keyAlias = releaseSigningProperties.getValue("keyAlias").get()
|
||||||
|
keyPassword = releaseSigningProperties.getValue("keyPassword").get()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
buildTypes {
|
buildTypes {
|
||||||
release {
|
release {
|
||||||
isMinifyEnabled = false
|
isMinifyEnabled = false
|
||||||
signingConfig = signingConfigs.getByName("ywq")
|
signingConfig = signingConfigs.findByName("releaseFromProperties")
|
||||||
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
|
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
|
||||||
}
|
}
|
||||||
debug {
|
|
||||||
signingConfig = signingConfigs.getByName("ywq")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
compileOptions {
|
compileOptions {
|
||||||
|
isCoreLibraryDesugaringEnabled = true
|
||||||
sourceCompatibility = JavaVersion.VERSION_11
|
sourceCompatibility = JavaVersion.VERSION_11
|
||||||
targetCompatibility = JavaVersion.VERSION_11
|
targetCompatibility = JavaVersion.VERSION_11
|
||||||
}
|
}
|
||||||
@ -47,8 +58,39 @@ android {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val validateSampleReleaseSigning by tasks.registering {
|
||||||
|
group = "verification"
|
||||||
|
description = "校验 Sample Release 签名属性存在且密钥库文件可读。"
|
||||||
|
doLast {
|
||||||
|
val missing = releaseSigningProperties
|
||||||
|
.filterValues { it.orNull.isNullOrBlank() }
|
||||||
|
.keys
|
||||||
|
check(missing.isEmpty()) {
|
||||||
|
"Sample Release signing properties are missing: " +
|
||||||
|
missing.joinToString { key ->
|
||||||
|
when (key) {
|
||||||
|
"storeFile" -> "YIWANGXIN_STORE_FILE"
|
||||||
|
"storePassword" -> "YIWANGXIN_STORE_PASSWORD"
|
||||||
|
"keyAlias" -> "YIWANGXIN_ALIAS"
|
||||||
|
else -> "YIWANGXIN_KEY_PASSWORD"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
check(file(releaseSigningProperties.getValue("storeFile").get()).isFile) {
|
||||||
|
"YIWANGXIN_STORE_FILE does not reference a readable file"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tasks.matching { it.name == "preReleaseBuild" }.configureEach {
|
||||||
|
dependsOn(validateSampleReleaseSigning)
|
||||||
|
}
|
||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
|
coreLibraryDesugaring(libs.desugar.jdk.libs)
|
||||||
implementation(project(":sdk-core"))
|
implementation(project(":sdk-core"))
|
||||||
|
implementation(project(":sdk-file"))
|
||||||
|
implementation(project(":sdk-bugcollect"))
|
||||||
implementation(project(":sdk-im"))
|
implementation(project(":sdk-im"))
|
||||||
implementation(project(":sdk-push"))
|
implementation(project(":sdk-push"))
|
||||||
implementation(project(":sdk-update"))
|
implementation(project(":sdk-update"))
|
||||||
|
|||||||
@ -7,7 +7,6 @@ import com.xuqm.sdk.XuqmSDK
|
|||||||
import com.xuqm.sdk.XuqmUserInfo
|
import com.xuqm.sdk.XuqmUserInfo
|
||||||
import com.xuqm.sdk.im.ImSDK
|
import com.xuqm.sdk.im.ImSDK
|
||||||
import com.xuqm.sdk.im.model.ImConnectionState
|
import com.xuqm.sdk.im.model.ImConnectionState
|
||||||
import com.xuqm.sdk.sample.config.SampleEnvironmentConfig
|
|
||||||
import com.xuqm.sdk.sample.data.api.DEMO_APP_ID
|
import com.xuqm.sdk.sample.data.api.DEMO_APP_ID
|
||||||
import com.xuqm.sdk.sample.data.api.DemoApiFactory
|
import com.xuqm.sdk.sample.data.api.DemoApiFactory
|
||||||
import com.xuqm.sdk.sample.data.api.LoginRequest
|
import com.xuqm.sdk.sample.data.api.LoginRequest
|
||||||
@ -45,18 +44,18 @@ private const val CONNECT_TIMEOUT_MS = 20_000L
|
|||||||
private const val POLL_INTERVAL_MS = 2_000L
|
private const val POLL_INTERVAL_MS = 2_000L
|
||||||
private const val POLL_TOTAL_MS = 40_000L
|
private const val POLL_TOTAL_MS = 40_000L
|
||||||
|
|
||||||
private fun initSdkOnce(ctx: Context) {
|
private fun awaitSdkInitialization() {
|
||||||
XuqmSDK.initialize(ctx, DEMO_APP_ID)
|
runBlocking { XuqmSDK.awaitInitialization() }
|
||||||
XuqmSDK.setUserInfo(null)
|
XuqmSDK.setUserInfo(null)
|
||||||
Thread.sleep(1_500)
|
Thread.sleep(1_500)
|
||||||
XuqmSDK.setUserInfo(null)
|
XuqmSDK.setUserInfo(null)
|
||||||
SampleEnvironmentConfig.useExternal()
|
|
||||||
Thread.sleep(500)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun loginAndConnect(userId: String, ctx: Context): String {
|
private suspend fun loginAndConnect(userId: String, ctx: Context): String {
|
||||||
val api = DemoApiFactory.create(BASE_URL) { null }
|
val api = DemoApiFactory.create(BASE_URL) { null }
|
||||||
val res = api.login(LoginRequest(DEMO_APP_ID, userId, "123456"))
|
val res = api.login(
|
||||||
|
LoginRequest(DEMO_APP_ID, userId, IntegrationTestCredentials.password()),
|
||||||
|
)
|
||||||
val token = requireNotNull(res.data?.imToken) { "Login failed for $userId: ${res.message}" }
|
val token = requireNotNull(res.data?.imToken) { "Login failed for $userId: ${res.message}" }
|
||||||
XuqmSDK.setUserInfo(XuqmUserInfo(userId = userId, userSig = token))
|
XuqmSDK.setUserInfo(XuqmUserInfo(userId = userId, userSig = token))
|
||||||
withTimeoutOrNull(CONNECT_TIMEOUT_MS) {
|
withTimeoutOrNull(CONNECT_TIMEOUT_MS) {
|
||||||
@ -77,7 +76,7 @@ class CrossDeviceSenderTest {
|
|||||||
@BeforeClass @JvmStatic
|
@BeforeClass @JvmStatic
|
||||||
fun init() {
|
fun init() {
|
||||||
appCtx = InstrumentationRegistry.getInstrumentation().targetContext
|
appCtx = InstrumentationRegistry.getInstrumentation().targetContext
|
||||||
initSdkOnce(appCtx)
|
awaitSdkInitialization()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -141,7 +140,7 @@ class CrossDeviceReceiverTest {
|
|||||||
@BeforeClass @JvmStatic
|
@BeforeClass @JvmStatic
|
||||||
fun init() {
|
fun init() {
|
||||||
appCtx = InstrumentationRegistry.getInstrumentation().targetContext
|
appCtx = InstrumentationRegistry.getInstrumentation().targetContext
|
||||||
initSdkOnce(appCtx)
|
awaitSdkInitialization()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -0,0 +1,12 @@
|
|||||||
|
package com.xuqm.sdk.sample
|
||||||
|
|
||||||
|
import androidx.test.platform.app.InstrumentationRegistry
|
||||||
|
|
||||||
|
/** 集成测试凭据只允许由测试运行命令注入,禁止进入源码与测试报告。 */
|
||||||
|
internal object IntegrationTestCredentials {
|
||||||
|
fun password(): String =
|
||||||
|
InstrumentationRegistry.getArguments()
|
||||||
|
.getString("XUQM_TEST_PASSWORD")
|
||||||
|
?.takeIf { it.isNotBlank() }
|
||||||
|
?: error("Missing instrumentation argument XUQM_TEST_PASSWORD")
|
||||||
|
}
|
||||||
@ -7,7 +7,6 @@ import com.xuqm.sdk.XuqmSDK
|
|||||||
import com.xuqm.sdk.XuqmUserInfo
|
import com.xuqm.sdk.XuqmUserInfo
|
||||||
import com.xuqm.sdk.im.ImSDK
|
import com.xuqm.sdk.im.ImSDK
|
||||||
import com.xuqm.sdk.im.model.ImConnectionState
|
import com.xuqm.sdk.im.model.ImConnectionState
|
||||||
import com.xuqm.sdk.sample.config.SampleEnvironmentConfig
|
|
||||||
import com.xuqm.sdk.sample.data.api.DEMO_APP_ID
|
import com.xuqm.sdk.sample.data.api.DEMO_APP_ID
|
||||||
import com.xuqm.sdk.sample.data.api.DemoApiFactory
|
import com.xuqm.sdk.sample.data.api.DemoApiFactory
|
||||||
import com.xuqm.sdk.sample.data.api.LoginRequest
|
import com.xuqm.sdk.sample.data.api.LoginRequest
|
||||||
@ -38,7 +37,6 @@ class NetworkResilienceTest {
|
|||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
private const val USER_A = "user_a"
|
private const val USER_A = "user_a"
|
||||||
private const val PASSWORD = "123456"
|
|
||||||
private const val BASE_URL = "https://dev.xuqinmin.com/"
|
private const val BASE_URL = "https://dev.xuqinmin.com/"
|
||||||
private const val CONNECT_TIMEOUT_MS = 20_000L
|
private const val CONNECT_TIMEOUT_MS = 20_000L
|
||||||
private const val RECONNECT_TIMEOUT_MS = 15_000L // 首次退避 1s,留 15s 余量
|
private const val RECONNECT_TIMEOUT_MS = 15_000L // 首次退避 1s,留 15s 余量
|
||||||
@ -48,12 +46,10 @@ class NetworkResilienceTest {
|
|||||||
@BeforeClass @JvmStatic
|
@BeforeClass @JvmStatic
|
||||||
fun initSdk() {
|
fun initSdk() {
|
||||||
appCtx = InstrumentationRegistry.getInstrumentation().targetContext
|
appCtx = InstrumentationRegistry.getInstrumentation().targetContext
|
||||||
XuqmSDK.initialize(appCtx, DEMO_APP_ID)
|
runBlocking { XuqmSDK.awaitInitialization() }
|
||||||
XuqmSDK.setUserInfo(null)
|
XuqmSDK.setUserInfo(null)
|
||||||
Thread.sleep(1_500)
|
Thread.sleep(1_500)
|
||||||
XuqmSDK.setUserInfo(null)
|
XuqmSDK.setUserInfo(null)
|
||||||
SampleEnvironmentConfig.useExternal()
|
|
||||||
Thread.sleep(500)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -61,7 +57,9 @@ class NetworkResilienceTest {
|
|||||||
fun setUp() {
|
fun setUp() {
|
||||||
runBlocking {
|
runBlocking {
|
||||||
val res = DemoApiFactory.create(BASE_URL) { null }
|
val res = DemoApiFactory.create(BASE_URL) { null }
|
||||||
.login(LoginRequest(DEMO_APP_ID, USER_A, PASSWORD))
|
.login(
|
||||||
|
LoginRequest(DEMO_APP_ID, USER_A, IntegrationTestCredentials.password()),
|
||||||
|
)
|
||||||
val token = requireNotNull(res.data?.imToken) { "Login failed: ${res.message}" }
|
val token = requireNotNull(res.data?.imToken) { "Login failed: ${res.message}" }
|
||||||
XuqmSDK.setUserInfo(XuqmUserInfo(userId = USER_A, userSig = token))
|
XuqmSDK.setUserInfo(XuqmUserInfo(userId = USER_A, userSig = token))
|
||||||
withTimeoutOrNull(CONNECT_TIMEOUT_MS) {
|
withTimeoutOrNull(CONNECT_TIMEOUT_MS) {
|
||||||
|
|||||||
@ -8,7 +8,6 @@ import com.xuqm.sdk.XuqmSDK
|
|||||||
import com.xuqm.sdk.XuqmUserInfo
|
import com.xuqm.sdk.XuqmUserInfo
|
||||||
import com.xuqm.sdk.push.PushSDK
|
import com.xuqm.sdk.push.PushSDK
|
||||||
import com.xuqm.sdk.push.model.PushVendor
|
import com.xuqm.sdk.push.model.PushVendor
|
||||||
import com.xuqm.sdk.sample.config.SampleEnvironmentConfig
|
|
||||||
import com.xuqm.sdk.sample.data.api.DEMO_APP_ID
|
import com.xuqm.sdk.sample.data.api.DEMO_APP_ID
|
||||||
import com.xuqm.sdk.sample.data.api.DemoApiFactory
|
import com.xuqm.sdk.sample.data.api.DemoApiFactory
|
||||||
import com.xuqm.sdk.sample.data.api.LoginRequest
|
import com.xuqm.sdk.sample.data.api.LoginRequest
|
||||||
@ -33,7 +32,6 @@ class PushSdkTest {
|
|||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
private const val USER_A = "user_a"
|
private const val USER_A = "user_a"
|
||||||
private const val PASSWORD = "123456"
|
|
||||||
private const val BASE_URL = "https://dev.xuqinmin.com/"
|
private const val BASE_URL = "https://dev.xuqinmin.com/"
|
||||||
|
|
||||||
lateinit var appCtx: Context
|
lateinit var appCtx: Context
|
||||||
@ -42,19 +40,19 @@ class PushSdkTest {
|
|||||||
@JvmStatic
|
@JvmStatic
|
||||||
fun initSdk() {
|
fun initSdk() {
|
||||||
appCtx = InstrumentationRegistry.getInstrumentation().targetContext
|
appCtx = InstrumentationRegistry.getInstrumentation().targetContext
|
||||||
XuqmSDK.initialize(appCtx, DEMO_APP_ID)
|
runBlocking { XuqmSDK.awaitInitialization() }
|
||||||
XuqmSDK.setUserInfo(null)
|
XuqmSDK.setUserInfo(null)
|
||||||
Thread.sleep(1_500)
|
Thread.sleep(1_500)
|
||||||
XuqmSDK.setUserInfo(null)
|
XuqmSDK.setUserInfo(null)
|
||||||
SampleEnvironmentConfig.useExternal()
|
|
||||||
Thread.sleep(500)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Before
|
@Before
|
||||||
fun setUp() {
|
fun setUp() {
|
||||||
runBlocking {
|
runBlocking {
|
||||||
val res = DemoApiFactory.create(BASE_URL) { null }.login(LoginRequest(DEMO_APP_ID, USER_A, PASSWORD))
|
val res = DemoApiFactory.create(BASE_URL) { null }.login(
|
||||||
|
LoginRequest(DEMO_APP_ID, USER_A, IntegrationTestCredentials.password()),
|
||||||
|
)
|
||||||
val token = requireNotNull(res.data?.imToken) { "Login failed: ${res.message}" }
|
val token = requireNotNull(res.data?.imToken) { "Login failed: ${res.message}" }
|
||||||
XuqmSDK.setUserInfo(XuqmUserInfo(userId = USER_A, userSig = token))
|
XuqmSDK.setUserInfo(XuqmUserInfo(userId = USER_A, userSig = token))
|
||||||
}
|
}
|
||||||
|
|||||||
@ -5,10 +5,8 @@ import androidx.test.ext.junit.runners.AndroidJUnit4
|
|||||||
import androidx.test.platform.app.InstrumentationRegistry
|
import androidx.test.platform.app.InstrumentationRegistry
|
||||||
import com.xuqm.sdk.XuqmSDK
|
import com.xuqm.sdk.XuqmSDK
|
||||||
import com.xuqm.sdk.XuqmUserInfo
|
import com.xuqm.sdk.XuqmUserInfo
|
||||||
import com.xuqm.sdk.core.LogLevel
|
|
||||||
import com.xuqm.sdk.im.ImSDK
|
import com.xuqm.sdk.im.ImSDK
|
||||||
import com.xuqm.sdk.im.model.ImConnectionState
|
import com.xuqm.sdk.im.model.ImConnectionState
|
||||||
import com.xuqm.sdk.sample.config.SampleEnvironmentConfig
|
|
||||||
import com.xuqm.sdk.sample.data.api.DEMO_APP_ID
|
import com.xuqm.sdk.sample.data.api.DEMO_APP_ID
|
||||||
import com.xuqm.sdk.sample.data.api.DemoApiFactory
|
import com.xuqm.sdk.sample.data.api.DemoApiFactory
|
||||||
import com.xuqm.sdk.sample.data.api.LoginRequest
|
import com.xuqm.sdk.sample.data.api.LoginRequest
|
||||||
@ -35,7 +33,7 @@ import java.io.File
|
|||||||
/**
|
/**
|
||||||
* TC-05 / TC-07 / TC-08 / TC-11~15 集成测试
|
* TC-05 / TC-07 / TC-08 / TC-11~15 集成测试
|
||||||
* 运行环境: external (https://dev.xuqinmin.com)
|
* 运行环境: external (https://dev.xuqinmin.com)
|
||||||
* 测试账号: user_a / user_b (password: 123456, appKey: ak_demo_chat)
|
* 测试账号与密码由运行环境注入,源码不保存凭据。
|
||||||
*
|
*
|
||||||
* 设计原则:
|
* 设计原则:
|
||||||
* - tc05/tc11a/tc11b/tc14/tc99 使用 @BeforeClass 中创建的新鲜 GROUP(testGroupId),
|
* - tc05/tc11a/tc11b/tc14/tc99 使用 @BeforeClass 中创建的新鲜 GROUP(testGroupId),
|
||||||
@ -51,7 +49,6 @@ class SdkIntegrationTest {
|
|||||||
companion object {
|
companion object {
|
||||||
private const val USER_A = "user_a"
|
private const val USER_A = "user_a"
|
||||||
private const val USER_B = "user_b"
|
private const val USER_B = "user_b"
|
||||||
private const val PASSWORD = "123456"
|
|
||||||
private const val BASE_URL = "https://dev.xuqinmin.com/"
|
private const val BASE_URL = "https://dev.xuqinmin.com/"
|
||||||
private const val CONNECT_TIMEOUT_MS = 20_000L
|
private const val CONNECT_TIMEOUT_MS = 20_000L
|
||||||
|
|
||||||
@ -64,21 +61,19 @@ class SdkIntegrationTest {
|
|||||||
@JvmStatic
|
@JvmStatic
|
||||||
fun initSdk() {
|
fun initSdk() {
|
||||||
appCtx = InstrumentationRegistry.getInstrumentation().targetContext
|
appCtx = InstrumentationRegistry.getInstrumentation().targetContext
|
||||||
XuqmSDK.initialize(appCtx, DEMO_APP_ID, LogLevel.DEBUG)
|
runBlocking { XuqmSDK.awaitInitialization() }
|
||||||
// logout BEFORE useExternal: configureServiceEndpoints re-triggers onSdkLogin
|
|
||||||
// with sample app's cached testuser1 session if loginSession is not null.
|
|
||||||
XuqmSDK.setUserInfo(null)
|
XuqmSDK.setUserInfo(null)
|
||||||
Thread.sleep(1_500) // let any in-flight 1s reconnect timers fire and be blocked
|
Thread.sleep(1_500) // let any in-flight 1s reconnect timers fire and be blocked
|
||||||
XuqmSDK.setUserInfo(null) // second safety net
|
XuqmSDK.setUserInfo(null) // second safety net
|
||||||
SampleEnvironmentConfig.useExternal()
|
|
||||||
Thread.sleep(500)
|
|
||||||
|
|
||||||
// Create a fresh GROUP for this test run. A new group each run avoids any
|
// Create a fresh GROUP for this test run. A new group each run avoids any
|
||||||
// stale conversation state from previous runs (e.g., a prior deleteConversation
|
// stale conversation state from previous runs (e.g., a prior deleteConversation
|
||||||
// on a SINGLE conversation permanently removing it from the server).
|
// on a SINGLE conversation permanently removing it from the server).
|
||||||
runBlocking {
|
runBlocking {
|
||||||
val api = DemoApiFactory.create(BASE_URL) { null }
|
val api = DemoApiFactory.create(BASE_URL) { null }
|
||||||
val resA = api.login(LoginRequest(DEMO_APP_ID, USER_A, PASSWORD))
|
val resA = api.login(
|
||||||
|
LoginRequest(DEMO_APP_ID, USER_A, IntegrationTestCredentials.password()),
|
||||||
|
)
|
||||||
resA.data?.imToken?.let { tokenA ->
|
resA.data?.imToken?.let { tokenA ->
|
||||||
XuqmSDK.setUserInfo(XuqmUserInfo(userId = USER_A, userSig = tokenA))
|
XuqmSDK.setUserInfo(XuqmUserInfo(userId = USER_A, userSig = tokenA))
|
||||||
withTimeoutOrNull(CONNECT_TIMEOUT_MS) {
|
withTimeoutOrNull(CONNECT_TIMEOUT_MS) {
|
||||||
@ -100,7 +95,9 @@ class SdkIntegrationTest {
|
|||||||
private fun buildDemoApi() = DemoApiFactory.create(BASE_URL) { null }
|
private fun buildDemoApi() = DemoApiFactory.create(BASE_URL) { null }
|
||||||
|
|
||||||
private suspend fun loginAs(userId: String): String {
|
private suspend fun loginAs(userId: String): String {
|
||||||
val res = buildDemoApi().login(LoginRequest(DEMO_APP_ID, userId, PASSWORD))
|
val res = buildDemoApi().login(
|
||||||
|
LoginRequest(DEMO_APP_ID, userId, IntegrationTestCredentials.password()),
|
||||||
|
)
|
||||||
val data = requireNotNull(res.data) { "Demo login failed for $userId: ${res.message}" }
|
val data = requireNotNull(res.data) { "Demo login failed for $userId: ${res.message}" }
|
||||||
XuqmSDK.setUserInfo(XuqmUserInfo(userId = userId, userSig = data.imToken))
|
XuqmSDK.setUserInfo(XuqmUserInfo(userId = userId, userSig = data.imToken))
|
||||||
return data.imToken
|
return data.imToken
|
||||||
@ -215,7 +212,9 @@ class SdkIntegrationTest {
|
|||||||
assertTrue("@Before 后应处于 Connected 状态", stateBefore is ImConnectionState.Connected)
|
assertTrue("@Before 后应处于 Connected 状态", stateBefore is ImConnectionState.Connected)
|
||||||
|
|
||||||
// 不登出,直接重新获取 token 并调用 login()
|
// 不登出,直接重新获取 token 并调用 login()
|
||||||
val newToken = buildDemoApi().login(LoginRequest(DEMO_APP_ID, USER_A, PASSWORD)).data?.imToken
|
val newToken = buildDemoApi().login(
|
||||||
|
LoginRequest(DEMO_APP_ID, USER_A, IntegrationTestCredentials.password()),
|
||||||
|
).data?.imToken
|
||||||
requireNotNull(newToken) { "重登录 API 失败" }
|
requireNotNull(newToken) { "重登录 API 失败" }
|
||||||
XuqmSDK.setUserInfo(XuqmUserInfo(userId = USER_A, userSig = newToken))
|
XuqmSDK.setUserInfo(XuqmUserInfo(userId = USER_A, userSig = newToken))
|
||||||
|
|
||||||
|
|||||||
@ -2,22 +2,22 @@ package com.xuqm.sdk.sample
|
|||||||
|
|
||||||
import android.app.Application
|
import android.app.Application
|
||||||
import com.xuqm.sdk.XuqmSDK
|
import com.xuqm.sdk.XuqmSDK
|
||||||
import com.xuqm.sdk.core.LogLevel
|
|
||||||
import com.xuqm.sdk.sample.di.AppDependencies
|
import com.xuqm.sdk.sample.di.AppDependencies
|
||||||
import kotlinx.coroutines.runBlocking
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
class XuqmSampleApp : Application() {
|
class XuqmSampleApp : Application() {
|
||||||
|
private val applicationScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||||
|
|
||||||
override fun onCreate() {
|
override fun onCreate() {
|
||||||
super.onCreate()
|
super.onCreate()
|
||||||
AppDependencies.init(this)
|
AppDependencies.init(this)
|
||||||
XuqmSDK.initialize(
|
XuqmSDK.afterInit {
|
||||||
context = this,
|
applicationScope.launch {
|
||||||
appKey = "ak_c6fce237cae94ef5ab71fda6",
|
|
||||||
logLevel = if (BuildConfig.DEBUG) LogLevel.DEBUG else LogLevel.WARN,
|
|
||||||
)
|
|
||||||
runBlocking {
|
|
||||||
AppDependencies.authRepository.restoreSdkSession()
|
AppDependencies.authRepository.restoreSdkSession()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,34 +0,0 @@
|
|||||||
package com.xuqm.sdk.sample.config
|
|
||||||
|
|
||||||
import com.xuqm.sdk.XuqmSDK
|
|
||||||
data class SampleEnvironment(
|
|
||||||
val demoBaseUrl: String,
|
|
||||||
val serviceHost: String? = null,
|
|
||||||
)
|
|
||||||
|
|
||||||
object SampleEnvironmentConfig {
|
|
||||||
|
|
||||||
@Volatile
|
|
||||||
var current: SampleEnvironment = external()
|
|
||||||
private set
|
|
||||||
|
|
||||||
fun external(): SampleEnvironment = SampleEnvironment(
|
|
||||||
demoBaseUrl = "https://dev.xuqinmin.com/",
|
|
||||||
serviceHost = null,
|
|
||||||
)
|
|
||||||
|
|
||||||
fun localhost(host: String): SampleEnvironment = SampleEnvironment(
|
|
||||||
demoBaseUrl = "http://$host:8085/",
|
|
||||||
serviceHost = host,
|
|
||||||
)
|
|
||||||
|
|
||||||
fun useExternal() {
|
|
||||||
current = external()
|
|
||||||
XuqmSDK.useExternalServiceEndpoints()
|
|
||||||
}
|
|
||||||
|
|
||||||
fun useLocalhost(host: String) {
|
|
||||||
current = localhost(host)
|
|
||||||
XuqmSDK.useLocalServiceEndpoints(host)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,8 +1,6 @@
|
|||||||
package com.xuqm.sdk.sample.data.repo
|
package com.xuqm.sdk.sample.data.repo
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import androidx.security.crypto.EncryptedSharedPreferences
|
|
||||||
import androidx.security.crypto.MasterKeys
|
|
||||||
import com.xuqm.sdk.XuqmSDK
|
import com.xuqm.sdk.XuqmSDK
|
||||||
import com.xuqm.sdk.XuqmUserInfo
|
import com.xuqm.sdk.XuqmUserInfo
|
||||||
import com.xuqm.sdk.sample.data.api.AuthResult
|
import com.xuqm.sdk.sample.data.api.AuthResult
|
||||||
@ -15,7 +13,7 @@ import com.xuqm.sdk.sample.data.api.RegisterRequest
|
|||||||
import com.xuqm.sdk.sample.data.api.ResetPasswordRequest
|
import com.xuqm.sdk.sample.data.api.ResetPasswordRequest
|
||||||
import com.xuqm.sdk.sample.data.api.UpdateProfileRequest
|
import com.xuqm.sdk.sample.data.api.UpdateProfileRequest
|
||||||
import com.xuqm.sdk.sample.data.api.UserData
|
import com.xuqm.sdk.sample.data.api.UserData
|
||||||
import com.xuqm.sdk.sample.config.SampleEnvironmentConfig
|
import com.xuqm.sdk.storage.SecureStore
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.SupervisorJob
|
import kotlinx.coroutines.SupervisorJob
|
||||||
@ -30,25 +28,19 @@ import java.util.concurrent.atomic.AtomicBoolean
|
|||||||
class AuthRepository(context: Context) {
|
class AuthRepository(context: Context) {
|
||||||
|
|
||||||
private val appScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
private val appScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||||
private val prefs = EncryptedSharedPreferences.create(
|
private val store = SecureStore.open(context, "sample.auth")
|
||||||
"xuqm_demo_auth",
|
|
||||||
MasterKeys.getOrCreate(MasterKeys.AES256_GCM_SPEC),
|
|
||||||
context,
|
|
||||||
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
|
|
||||||
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
|
|
||||||
)
|
|
||||||
|
|
||||||
private val api: DemoApi
|
private val api: DemoApi
|
||||||
get() = DemoApiFactory.create(
|
get() = DemoApiFactory.create(
|
||||||
baseUrl = SampleEnvironmentConfig.current.demoBaseUrl,
|
baseUrl = XuqmSDK.platformUrl,
|
||||||
tokenProvider = ::getDemoToken,
|
tokenProvider = ::getDemoToken,
|
||||||
)
|
)
|
||||||
|
|
||||||
fun getDemoToken(): String? = prefs.getString("demo_token", null)
|
fun getDemoToken(): String? = store.getString("demo_token")
|
||||||
fun getCurrentUserId(): String? = prefs.getString("user_id", null)
|
fun getCurrentUserId(): String? = store.getString("user_id")
|
||||||
fun getCurrentNickname(): String? = prefs.getString("nickname", null)
|
fun getCurrentNickname(): String? = store.getString("nickname")
|
||||||
fun getCurrentAvatar(): String? = prefs.getString("avatar", null)
|
fun getCurrentAvatar(): String? = store.getString("avatar")
|
||||||
fun getCurrentUserSig(): String? = prefs.getString("user_sig", null)
|
fun getCurrentUserSig(): String? = store.getString("user_sig")
|
||||||
fun isLoggedIn(): Boolean = hasUsableSession()
|
fun isLoggedIn(): Boolean = hasUsableSession()
|
||||||
|
|
||||||
private fun hasUsableSession(): Boolean {
|
private fun hasUsableSession(): Boolean {
|
||||||
@ -62,13 +54,11 @@ class AuthRepository(context: Context) {
|
|||||||
|
|
||||||
private fun saveSession(result: AuthResult) {
|
private fun saveSession(result: AuthResult) {
|
||||||
val profile = result.profile
|
val profile = result.profile
|
||||||
prefs.edit()
|
store.putString("demo_token", result.demoToken)
|
||||||
.putString("demo_token", result.demoToken)
|
store.putString("user_id", profile.userId)
|
||||||
.putString("user_id", profile.userId)
|
store.putString("nickname", profile.nickname)
|
||||||
.putString("nickname", profile.nickname)
|
store.putString("avatar", profile.avatar)
|
||||||
.putString("avatar", profile.avatar)
|
store.putString("user_sig", result.imToken)
|
||||||
.putString("user_sig", result.imToken)
|
|
||||||
.apply()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun login(userId: String, password: String): Result<UserData> =
|
suspend fun login(userId: String, password: String): Result<UserData> =
|
||||||
@ -123,7 +113,8 @@ class AuthRepository(context: Context) {
|
|||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
runCatching {
|
runCatching {
|
||||||
val data = requireNotNull(api.updateProfile(UpdateProfileRequest(nickname, avatar)).data)
|
val data = requireNotNull(api.updateProfile(UpdateProfileRequest(nickname, avatar)).data)
|
||||||
prefs.edit().putString("nickname", data.nickname).putString("avatar", data.avatar).apply()
|
store.putString("nickname", data.nickname)
|
||||||
|
store.putString("avatar", data.avatar)
|
||||||
data
|
data
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -162,7 +153,7 @@ class AuthRepository(context: Context) {
|
|||||||
|
|
||||||
fun logout() {
|
fun logout() {
|
||||||
XuqmSDK.setUserInfo(null)
|
XuqmSDK.setUserInfo(null)
|
||||||
prefs.edit().clear().apply()
|
store.clear()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun <T> Result<T>.mapFailureMessage(transform: (Throwable) -> String): Result<T> =
|
private fun <T> Result<T>.mapFailureMessage(transform: (Throwable) -> String): Result<T> =
|
||||||
|
|||||||
@ -1,59 +0,0 @@
|
|||||||
package com.xuqm.sdk.sample.data.repo
|
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import com.xuqm.sdk.sample.config.SampleEnvironmentConfig
|
|
||||||
|
|
||||||
enum class EnvironmentMode {
|
|
||||||
EXTERNAL,
|
|
||||||
LOCALHOST,
|
|
||||||
}
|
|
||||||
|
|
||||||
data class EnvironmentState(
|
|
||||||
val mode: EnvironmentMode = EnvironmentMode.EXTERNAL,
|
|
||||||
val host: String = "192.168.113.37",
|
|
||||||
)
|
|
||||||
|
|
||||||
class EnvironmentRepository(context: Context) {
|
|
||||||
|
|
||||||
private val prefs = context.getSharedPreferences("xuqm_demo_env", Context.MODE_PRIVATE)
|
|
||||||
|
|
||||||
fun current(): EnvironmentState = load().also { apply(it) }
|
|
||||||
|
|
||||||
fun setExternal() {
|
|
||||||
save(EnvironmentState(mode = EnvironmentMode.EXTERNAL, host = load().host))
|
|
||||||
SampleEnvironmentConfig.useExternal()
|
|
||||||
}
|
|
||||||
|
|
||||||
fun setLocalhost(host: String) {
|
|
||||||
val normalizedHost = host.trim().ifBlank { "192.168.113.37" }
|
|
||||||
save(EnvironmentState(mode = EnvironmentMode.LOCALHOST, host = normalizedHost))
|
|
||||||
SampleEnvironmentConfig.useLocalhost(normalizedHost)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun load(): EnvironmentState {
|
|
||||||
val mode = runCatching {
|
|
||||||
EnvironmentMode.valueOf(prefs.getString(KEY_MODE, EnvironmentMode.EXTERNAL.name)!!)
|
|
||||||
}.getOrDefault(EnvironmentMode.EXTERNAL)
|
|
||||||
val host = prefs.getString(KEY_HOST, "192.168.113.37").orEmpty().ifBlank { "192.168.113.37" }
|
|
||||||
return EnvironmentState(mode = mode, host = host)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun save(state: EnvironmentState) {
|
|
||||||
prefs.edit()
|
|
||||||
.putString(KEY_MODE, state.mode.name)
|
|
||||||
.putString(KEY_HOST, state.host)
|
|
||||||
.apply()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun apply(state: EnvironmentState) {
|
|
||||||
when (state.mode) {
|
|
||||||
EnvironmentMode.EXTERNAL -> SampleEnvironmentConfig.useExternal()
|
|
||||||
EnvironmentMode.LOCALHOST -> SampleEnvironmentConfig.useLocalhost(state.host)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private companion object {
|
|
||||||
const val KEY_MODE = "mode"
|
|
||||||
const val KEY_HOST = "host"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -5,7 +5,6 @@ import android.util.Log
|
|||||||
import com.xuqm.sdk.sample.data.repo.AuthRepository
|
import com.xuqm.sdk.sample.data.repo.AuthRepository
|
||||||
import com.xuqm.sdk.sample.data.repo.AttachmentRepository
|
import com.xuqm.sdk.sample.data.repo.AttachmentRepository
|
||||||
import com.xuqm.sdk.sample.data.local.LocalContactCache
|
import com.xuqm.sdk.sample.data.local.LocalContactCache
|
||||||
import com.xuqm.sdk.sample.data.repo.EnvironmentRepository
|
|
||||||
import com.xuqm.sdk.sample.data.local.LocalImCache
|
import com.xuqm.sdk.sample.data.local.LocalImCache
|
||||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||||
import kotlinx.coroutines.flow.asSharedFlow
|
import kotlinx.coroutines.flow.asSharedFlow
|
||||||
@ -16,8 +15,6 @@ object AppDependencies {
|
|||||||
|
|
||||||
lateinit var authRepository: AuthRepository
|
lateinit var authRepository: AuthRepository
|
||||||
private set
|
private set
|
||||||
lateinit var environmentRepository: EnvironmentRepository
|
|
||||||
private set
|
|
||||||
lateinit var localImCache: LocalImCache
|
lateinit var localImCache: LocalImCache
|
||||||
private set
|
private set
|
||||||
lateinit var localContactCache: LocalContactCache
|
lateinit var localContactCache: LocalContactCache
|
||||||
@ -29,8 +26,6 @@ object AppDependencies {
|
|||||||
val conversationRefreshRequests = _conversationRefreshRequests.asSharedFlow()
|
val conversationRefreshRequests = _conversationRefreshRequests.asSharedFlow()
|
||||||
|
|
||||||
fun init(context: Context) {
|
fun init(context: Context) {
|
||||||
environmentRepository = EnvironmentRepository(context)
|
|
||||||
environmentRepository.current()
|
|
||||||
localImCache = LocalImCache(context)
|
localImCache = LocalImCache(context)
|
||||||
localContactCache = LocalContactCache(context)
|
localContactCache = LocalContactCache(context)
|
||||||
attachmentRepository = AttachmentRepository(context.applicationContext)
|
attachmentRepository = AttachmentRepository(context.applicationContext)
|
||||||
|
|||||||
@ -10,7 +10,6 @@ import com.xuqm.sdk.sample.data.repo.AuthRepository
|
|||||||
import com.xuqm.sdk.sample.ui.auth.LoginScreen
|
import com.xuqm.sdk.sample.ui.auth.LoginScreen
|
||||||
import com.xuqm.sdk.sample.ui.auth.RegisterScreen
|
import com.xuqm.sdk.sample.ui.auth.RegisterScreen
|
||||||
import com.xuqm.sdk.sample.ui.chat.ChatScreen
|
import com.xuqm.sdk.sample.ui.chat.ChatScreen
|
||||||
import com.xuqm.sdk.sample.ui.environment.EnvironmentScreen
|
|
||||||
import com.xuqm.sdk.sample.ui.group.GroupSettingsScreen
|
import com.xuqm.sdk.sample.ui.group.GroupSettingsScreen
|
||||||
import com.xuqm.sdk.sample.ui.main.MainScreen
|
import com.xuqm.sdk.sample.ui.main.MainScreen
|
||||||
import com.xuqm.sdk.webview.XWebViewScreen
|
import com.xuqm.sdk.webview.XWebViewScreen
|
||||||
@ -35,7 +34,6 @@ fun AppNavGraph(
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
onNavigateToRegister = { navController.navigate("register") },
|
onNavigateToRegister = { navController.navigate("register") },
|
||||||
onOpenEnvironment = { navController.navigate("environment") },
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
composable("register") {
|
composable("register") {
|
||||||
@ -62,7 +60,6 @@ fun AppNavGraph(
|
|||||||
onOpenWebView = {
|
onOpenWebView = {
|
||||||
navController.navigate("xwebview")
|
navController.navigate("xwebview")
|
||||||
},
|
},
|
||||||
onOpenEnvironment = { navController.navigate("environment") },
|
|
||||||
onLogout = {
|
onLogout = {
|
||||||
navController.navigate("auth") {
|
navController.navigate("auth") {
|
||||||
popUpTo("main") { inclusive = true }
|
popUpTo("main") { inclusive = true }
|
||||||
@ -102,11 +99,5 @@ fun AppNavGraph(
|
|||||||
onNavigateBack = { navController.popBackStack() },
|
onNavigateBack = { navController.popBackStack() },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
composable("environment") {
|
|
||||||
EnvironmentScreen(
|
|
||||||
onNavigateBack = { navController.popBackStack() },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -39,7 +39,6 @@ import android.widget.Toast
|
|||||||
fun LoginScreen(
|
fun LoginScreen(
|
||||||
onLoginSuccess: () -> Unit,
|
onLoginSuccess: () -> Unit,
|
||||||
onNavigateToRegister: () -> Unit,
|
onNavigateToRegister: () -> Unit,
|
||||||
onOpenEnvironment: () -> Unit,
|
|
||||||
viewModel: LoginViewModel = viewModel(
|
viewModel: LoginViewModel = viewModel(
|
||||||
factory = viewModelFactory {
|
factory = viewModelFactory {
|
||||||
initializer { LoginViewModel(AppDependencies.authRepository) }
|
initializer { LoginViewModel(AppDependencies.authRepository) }
|
||||||
@ -120,10 +119,6 @@ fun LoginScreen(
|
|||||||
Text("没有账号?注册")
|
Text("没有账号?注册")
|
||||||
}
|
}
|
||||||
|
|
||||||
TextButton(onClick = onOpenEnvironment) {
|
|
||||||
Text("环境设置")
|
|
||||||
}
|
|
||||||
|
|
||||||
if (BuildConfig.DEBUG) {
|
if (BuildConfig.DEBUG) {
|
||||||
Spacer(Modifier.height(8.dp))
|
Spacer(Modifier.height(8.dp))
|
||||||
TextButton(onClick = { viewModel.login("testuser1", "test123") }) {
|
TextButton(onClick = { viewModel.login("testuser1", "test123") }) {
|
||||||
|
|||||||
@ -541,7 +541,7 @@ private fun createCaptureUri(
|
|||||||
val file = File(dir, "$prefix$suffix")
|
val file = File(dir, "$prefix$suffix")
|
||||||
return FileProvider.getUriForFile(
|
return FileProvider.getUriForFile(
|
||||||
context,
|
context,
|
||||||
"${context.packageName}.fileprovider",
|
"${context.packageName}.xuqm.fileprovider",
|
||||||
file,
|
file,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -182,11 +182,11 @@ class ChatViewModel : ViewModel() {
|
|||||||
private suspend fun fetchHistory(page: Int): List<ImMessage> {
|
private suspend fun fetchHistory(page: Int): List<ImMessage> {
|
||||||
val remote = if (chatType == "GROUP") {
|
val remote = if (chatType == "GROUP") {
|
||||||
runCatching { ImSDK.fetchGroupHistory(targetId, page = page, size = HISTORY_PAGE_SIZE) }
|
runCatching { ImSDK.fetchGroupHistory(targetId, page = page, size = HISTORY_PAGE_SIZE) }
|
||||||
.onFailure { Log.w(TAG, "fetchGroupHistory failed targetId=$targetId page=$page", it) }
|
.onFailure { Log.w(TAG, "fetchGroupHistory failed: page=$page") }
|
||||||
.getOrNull()
|
.getOrNull()
|
||||||
} else {
|
} else {
|
||||||
runCatching { ImSDK.fetchHistory(targetId, page = page, size = HISTORY_PAGE_SIZE) }
|
runCatching { ImSDK.fetchHistory(targetId, page = page, size = HISTORY_PAGE_SIZE) }
|
||||||
.onFailure { Log.w(TAG, "fetchHistory failed targetId=$targetId page=$page", it) }
|
.onFailure { Log.w(TAG, "fetchHistory failed: page=$page") }
|
||||||
.getOrNull()
|
.getOrNull()
|
||||||
}
|
}
|
||||||
if (remote != null) {
|
if (remote != null) {
|
||||||
@ -216,7 +216,7 @@ class ChatViewModel : ViewModel() {
|
|||||||
}
|
}
|
||||||
.onFailure {
|
.onFailure {
|
||||||
_events.tryEmit("消息编辑失败,请检查网络后重试")
|
_events.tryEmit("消息编辑失败,请检查网络后重试")
|
||||||
Log.w(TAG, "editMessage failed messageId=${editingTarget.id}", it)
|
Log.w(TAG, "editMessage failed")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
@ -238,7 +238,7 @@ class ChatViewModel : ViewModel() {
|
|||||||
appendOutgoingMessage(sent)
|
appendOutgoingMessage(sent)
|
||||||
if (sent.status.uppercase() == "FAILED") {
|
if (sent.status.uppercase() == "FAILED") {
|
||||||
_events.tryEmit("消息发送失败,请检查网络后重试")
|
_events.tryEmit("消息发送失败,请检查网络后重试")
|
||||||
Log.w(TAG, "sendText failed status=${sent.status} targetId=$targetId chatType=$chatType")
|
Log.w(TAG, "sendText failed: status=${sent.status} chatType=$chatType")
|
||||||
} else {
|
} else {
|
||||||
trackPendingDelivery(sent.id)
|
trackPendingDelivery(sent.id)
|
||||||
_draftText.value = ""
|
_draftText.value = ""
|
||||||
@ -248,7 +248,7 @@ class ChatViewModel : ViewModel() {
|
|||||||
}
|
}
|
||||||
} catch (t: Throwable) {
|
} catch (t: Throwable) {
|
||||||
_events.tryEmit("消息发送失败,请检查网络后重试")
|
_events.tryEmit("消息发送失败,请检查网络后重试")
|
||||||
Log.w(TAG, "sendText threw targetId=$targetId chatType=$chatType", t)
|
Log.w(TAG, "sendText failed: chatType=$chatType errorType=${t.javaClass.simpleName}")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -338,7 +338,7 @@ class ChatViewModel : ViewModel() {
|
|||||||
.onSuccess { revoked -> handleIncomingMessage(revoked) }
|
.onSuccess { revoked -> handleIncomingMessage(revoked) }
|
||||||
.onFailure {
|
.onFailure {
|
||||||
_events.tryEmit("消息撤回失败,请检查网络后重试")
|
_events.tryEmit("消息撤回失败,请检查网络后重试")
|
||||||
Log.w(TAG, "revokeMessage failed messageId=$messageId", it)
|
Log.w(TAG, "revokeMessage failed")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -373,13 +373,13 @@ class ChatViewModel : ViewModel() {
|
|||||||
appendOutgoingMessage(sent.message)
|
appendOutgoingMessage(sent.message)
|
||||||
if (sent.message.status.uppercase() == "FAILED") {
|
if (sent.message.status.uppercase() == "FAILED") {
|
||||||
_events.tryEmit("语音发送失败,请检查网络后重试")
|
_events.tryEmit("语音发送失败,请检查网络后重试")
|
||||||
Log.w(TAG, "sendAudioRecording failed status=${sent.message.status} targetId=$targetId chatType=$chatType")
|
Log.w(TAG, "sendAudioRecording failed: status=${sent.message.status} chatType=$chatType")
|
||||||
} else {
|
} else {
|
||||||
trackPendingDelivery(sent.message.id)
|
trackPendingDelivery(sent.message.id)
|
||||||
}
|
}
|
||||||
}.onFailure {
|
}.onFailure {
|
||||||
_events.tryEmit("语音发送失败,请检查网络后重试")
|
_events.tryEmit("语音发送失败,请检查网络后重试")
|
||||||
Log.w(TAG, "sendAudioRecording threw targetId=$targetId chatType=$chatType fileName=${recording.fileName}", it)
|
Log.w(TAG, "sendAudioRecording failed: chatType=$chatType")
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
_isSendingAttachment.value = false
|
_isSendingAttachment.value = false
|
||||||
@ -429,7 +429,7 @@ class ChatViewModel : ViewModel() {
|
|||||||
.filter { it.nickname.isNotBlank() }
|
.filter { it.nickname.isNotBlank() }
|
||||||
.sortedBy { it.nickname.ifBlank { it.userId } }
|
.sortedBy { it.nickname.ifBlank { it.userId } }
|
||||||
}.onSuccess { _mentionableUsers.value = it }
|
}.onSuccess { _mentionableUsers.value = it }
|
||||||
.onFailure { Log.w(TAG, "loadMentionableUsers failed targetId=$targetId", it) }
|
.onFailure { Log.w(TAG, "loadMentionableUsers failed") }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -479,13 +479,13 @@ class ChatViewModel : ViewModel() {
|
|||||||
appendOutgoingMessage(sent.message)
|
appendOutgoingMessage(sent.message)
|
||||||
if (sent.message.status.uppercase() == "FAILED") {
|
if (sent.message.status.uppercase() == "FAILED") {
|
||||||
_events.tryEmit("${kind.previewLabel()}发送失败,请检查网络后重试")
|
_events.tryEmit("${kind.previewLabel()}发送失败,请检查网络后重试")
|
||||||
Log.w(TAG, "sendAttachment failed status=${sent.message.status} targetId=$targetId chatType=$chatType kind=$kind")
|
Log.w(TAG, "sendAttachment failed: status=${sent.message.status} chatType=$chatType kind=$kind")
|
||||||
} else {
|
} else {
|
||||||
trackPendingDelivery(sent.message.id)
|
trackPendingDelivery(sent.message.id)
|
||||||
}
|
}
|
||||||
}.onFailure {
|
}.onFailure {
|
||||||
_events.tryEmit("${kind.previewLabel()}发送失败,请检查网络后重试")
|
_events.tryEmit("${kind.previewLabel()}发送失败,请检查网络后重试")
|
||||||
Log.w(TAG, "sendAttachment threw targetId=$targetId chatType=$chatType kind=$kind uri=$uri", it)
|
Log.w(TAG, "sendAttachment failed: chatType=$chatType kind=$kind")
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
_isSendingAttachment.value = false
|
_isSendingAttachment.value = false
|
||||||
@ -524,13 +524,13 @@ class ChatViewModel : ViewModel() {
|
|||||||
appendOutgoingMessage(sent.message)
|
appendOutgoingMessage(sent.message)
|
||||||
if (sent.message.status.uppercase() == "FAILED") {
|
if (sent.message.status.uppercase() == "FAILED") {
|
||||||
_events.tryEmit("${AttachmentKind.IMAGE.previewLabel()}发送失败,请检查网络后重试")
|
_events.tryEmit("${AttachmentKind.IMAGE.previewLabel()}发送失败,请检查网络后重试")
|
||||||
Log.w(TAG, "sendAttachmentBytes failed status=${sent.message.status} targetId=$targetId chatType=$chatType fileName=$fileName kind=$kind")
|
Log.w(TAG, "sendAttachmentBytes failed: status=${sent.message.status} chatType=$chatType kind=$kind")
|
||||||
} else {
|
} else {
|
||||||
trackPendingDelivery(sent.message.id)
|
trackPendingDelivery(sent.message.id)
|
||||||
}
|
}
|
||||||
}.onFailure {
|
}.onFailure {
|
||||||
_events.tryEmit("${AttachmentKind.IMAGE.previewLabel()}发送失败,请检查网络后重试")
|
_events.tryEmit("${AttachmentKind.IMAGE.previewLabel()}发送失败,请检查网络后重试")
|
||||||
Log.w(TAG, "sendAttachmentBytes threw targetId=$targetId chatType=$chatType fileName=$fileName kind=$kind", it)
|
Log.w(TAG, "sendAttachmentBytes failed: chatType=$chatType kind=$kind")
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
_isSendingAttachment.value = false
|
_isSendingAttachment.value = false
|
||||||
@ -556,7 +556,8 @@ class ChatViewModel : ViewModel() {
|
|||||||
}
|
}
|
||||||
Log.d(
|
Log.d(
|
||||||
TAG,
|
TAG,
|
||||||
"isRelevant=$relevant messageId=${message.id} messageChatType=${message.chatType} messageFrom=${message.fromId} messageTo=${message.toId} targetId=$targetId currentChatType=$chatType",
|
"Message relevance checked: relevant=$relevant " +
|
||||||
|
"messageChatType=${message.chatType} currentChatType=$chatType",
|
||||||
)
|
)
|
||||||
return relevant
|
return relevant
|
||||||
}
|
}
|
||||||
@ -574,13 +575,14 @@ class ChatViewModel : ViewModel() {
|
|||||||
private fun handleIncomingMessage(message: ImMessage) {
|
private fun handleIncomingMessage(message: ImMessage) {
|
||||||
Log.d(
|
Log.d(
|
||||||
TAG,
|
TAG,
|
||||||
"incoming message id=${message.id} chatType=${message.chatType} msgType=${message.msgType} from=${message.fromId} to=${message.toId} status=${message.status} targetId=$targetId currentChatType=$chatType",
|
"Incoming message: chatType=${message.chatType} msgType=${message.msgType} " +
|
||||||
|
"status=${message.status} currentChatType=$chatType",
|
||||||
)
|
)
|
||||||
if (!isRelevant(message)) return
|
if (!isRelevant(message)) return
|
||||||
if (message.fromId == currentUserId) {
|
if (message.fromId == currentUserId) {
|
||||||
pendingDeliveryTimeouts.remove(message.id)?.cancel()
|
pendingDeliveryTimeouts.remove(message.id)?.cancel()
|
||||||
}
|
}
|
||||||
Log.d(TAG, "incoming message matched targetId=$targetId currentChatType=$chatType")
|
Log.d(TAG, "Incoming message matched current chat: chatType=$chatType")
|
||||||
_messages.value = mergeMessages(_messages.value, listOf(message))
|
_messages.value = mergeMessages(_messages.value, listOf(message))
|
||||||
cache.mergeHistory(targetId, chatType, _messages.value)
|
cache.mergeHistory(targetId, chatType, _messages.value)
|
||||||
cache.upsertConversation(
|
cache.upsertConversation(
|
||||||
@ -684,7 +686,7 @@ class ChatViewModel : ViewModel() {
|
|||||||
if (stillPending != null) {
|
if (stillPending != null) {
|
||||||
updateMessageStatus(messageId, "FAILED")
|
updateMessageStatus(messageId, "FAILED")
|
||||||
_events.tryEmit("消息发送失败,请检查网络后重试")
|
_events.tryEmit("消息发送失败,请检查网络后重试")
|
||||||
Log.w(TAG, "delivery timeout messageId=$messageId")
|
Log.w(TAG, "Message delivery timed out")
|
||||||
}
|
}
|
||||||
pendingDeliveryTimeouts.remove(messageId)
|
pendingDeliveryTimeouts.remove(messageId)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -160,7 +160,7 @@ class ContactViewModel(
|
|||||||
}
|
}
|
||||||
.onFailure {
|
.onFailure {
|
||||||
_events.tryEmit("解除好友失败:${it.message ?: "未知错误"}")
|
_events.tryEmit("解除好友失败:${it.message ?: "未知错误"}")
|
||||||
android.util.Log.w(TAG, "removeFriend failed userId=$userId", it)
|
android.util.Log.w(TAG, "removeFriend failed")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -177,7 +177,7 @@ class ContactViewModel(
|
|||||||
.onSuccess { refresh() }
|
.onSuccess { refresh() }
|
||||||
.onFailure {
|
.onFailure {
|
||||||
_events.tryEmit("接受好友申请失败:${it.message ?: "未知错误"}")
|
_events.tryEmit("接受好友申请失败:${it.message ?: "未知错误"}")
|
||||||
android.util.Log.w(TAG, "acceptFriendRequest failed requestId=$requestId", it)
|
android.util.Log.w(TAG, "acceptFriendRequest failed")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -188,7 +188,7 @@ class ContactViewModel(
|
|||||||
.onSuccess { refresh() }
|
.onSuccess { refresh() }
|
||||||
.onFailure {
|
.onFailure {
|
||||||
_events.tryEmit("拒绝好友申请失败:${it.message ?: "未知错误"}")
|
_events.tryEmit("拒绝好友申请失败:${it.message ?: "未知错误"}")
|
||||||
android.util.Log.w(TAG, "rejectFriendRequest failed requestId=$requestId", it)
|
android.util.Log.w(TAG, "rejectFriendRequest failed")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -248,7 +248,7 @@ class ContactViewModel(
|
|||||||
cache.saveProfiles(list)
|
cache.saveProfiles(list)
|
||||||
rebuildFriendList()
|
rebuildFriendList()
|
||||||
}.onFailure {
|
}.onFailure {
|
||||||
android.util.Log.w(TAG, "loadMembersInternal failed", it)
|
android.util.Log.w(TAG, "loadMembersInternal failed")
|
||||||
val cached = cache.loadProfiles()
|
val cached = cache.loadProfiles()
|
||||||
.filter { it.userId != currentUserId }
|
.filter { it.userId != currentUserId }
|
||||||
.sortedBy { it.userId }
|
.sortedBy { it.userId }
|
||||||
@ -267,7 +267,7 @@ class ContactViewModel(
|
|||||||
cache.saveFriendIds(list.toList())
|
cache.saveFriendIds(list.toList())
|
||||||
rebuildFriendList()
|
rebuildFriendList()
|
||||||
}.onFailure {
|
}.onFailure {
|
||||||
android.util.Log.w(TAG, "loadFriendsInternal failed", it)
|
android.util.Log.w(TAG, "loadFriendsInternal failed")
|
||||||
friendIds = cache.loadFriendIds().toSet()
|
friendIds = cache.loadFriendIds().toSet()
|
||||||
rebuildFriendList()
|
rebuildFriendList()
|
||||||
}
|
}
|
||||||
@ -280,7 +280,7 @@ class ContactViewModel(
|
|||||||
request.status.equals("PENDING", ignoreCase = true)
|
request.status.equals("PENDING", ignoreCase = true)
|
||||||
}
|
}
|
||||||
}.onFailure {
|
}.onFailure {
|
||||||
android.util.Log.w(TAG, "loadFriendRequestsInternal failed", it)
|
android.util.Log.w(TAG, "loadFriendRequestsInternal failed")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -288,7 +288,7 @@ class ContactViewModel(
|
|||||||
runCatching { ImSDK.listBlacklist() }
|
runCatching { ImSDK.listBlacklist() }
|
||||||
.onSuccess { _blacklist.value = it }
|
.onSuccess { _blacklist.value = it }
|
||||||
.onFailure {
|
.onFailure {
|
||||||
android.util.Log.w(TAG, "loadBlacklistInternal failed", it)
|
android.util.Log.w(TAG, "loadBlacklistInternal failed")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -37,34 +37,22 @@ class ConversationViewModel : ViewModel() {
|
|||||||
|
|
||||||
private val listener = object : ImEventListener {
|
private val listener = object : ImEventListener {
|
||||||
override fun onMessage(message: ImMessage) {
|
override fun onMessage(message: ImMessage) {
|
||||||
Log.d(
|
Log.d(TAG, "Incoming single message: msgType=${message.msgType}")
|
||||||
TAG,
|
|
||||||
"incoming single message refresh request id=${message.id} from=${message.fromId} to=${message.toId} msgType=${message.msgType}",
|
|
||||||
)
|
|
||||||
refresh()
|
refresh()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onGroupMessage(message: ImMessage) {
|
override fun onGroupMessage(message: ImMessage) {
|
||||||
Log.d(
|
Log.d(TAG, "Incoming group message: msgType=${message.msgType}")
|
||||||
TAG,
|
|
||||||
"incoming group message refresh request id=${message.id} from=${message.fromId} to=${message.toId} msgType=${message.msgType}",
|
|
||||||
)
|
|
||||||
refresh()
|
refresh()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onRead(message: ImMessage) {
|
override fun onRead(message: ImMessage) {
|
||||||
Log.d(
|
Log.d(TAG, "Incoming read event: msgType=${message.msgType}")
|
||||||
TAG,
|
|
||||||
"incoming read refresh request id=${message.id} from=${message.fromId} to=${message.toId} msgType=${message.msgType}",
|
|
||||||
)
|
|
||||||
refresh()
|
refresh()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onRevoke(message: ImMessage) {
|
override fun onRevoke(message: ImMessage) {
|
||||||
Log.d(
|
Log.d(TAG, "Incoming revoke event: msgType=${message.msgType}")
|
||||||
TAG,
|
|
||||||
"incoming revoke refresh request id=${message.id} from=${message.fromId} to=${message.toId} msgType=${message.msgType}",
|
|
||||||
)
|
|
||||||
refresh()
|
refresh()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -122,7 +110,7 @@ class ConversationViewModel : ViewModel() {
|
|||||||
fun deleteConversation(targetId: String, chatType: String) {
|
fun deleteConversation(targetId: String, chatType: String) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
runCatching { ImSDK.deleteConversation(targetId, chatType) }
|
runCatching { ImSDK.deleteConversation(targetId, chatType) }
|
||||||
.onFailure { Log.w(TAG, "deleteConversation failed targetId=$targetId chatType=$chatType", it) }
|
.onFailure { Log.w(TAG, "deleteConversation failed: chatType=$chatType") }
|
||||||
cache.deleteConversation(targetId, chatType)
|
cache.deleteConversation(targetId, chatType)
|
||||||
_conversations.value = cache.loadConversations().sortedByDescending { it.lastMsgTime }
|
_conversations.value = cache.loadConversations().sortedByDescending { it.lastMsgTime }
|
||||||
_totalUnreadCount.value = _conversations.value.sumOf { it.unreadCount }
|
_totalUnreadCount.value = _conversations.value.sumOf { it.unreadCount }
|
||||||
@ -131,13 +119,13 @@ class ConversationViewModel : ViewModel() {
|
|||||||
|
|
||||||
suspend fun setPinned(targetId: String, chatType: String, pinned: Boolean) {
|
suspend fun setPinned(targetId: String, chatType: String, pinned: Boolean) {
|
||||||
runCatching { ImSDK.setConversationPinned(targetId, chatType, pinned) }
|
runCatching { ImSDK.setConversationPinned(targetId, chatType, pinned) }
|
||||||
.onFailure { Log.w(TAG, "setPinned failed targetId=$targetId chatType=$chatType pinned=$pinned", it) }
|
.onFailure { Log.w(TAG, "setPinned failed: chatType=$chatType pinned=$pinned") }
|
||||||
refresh()
|
refresh()
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun setMuted(targetId: String, chatType: String, muted: Boolean) {
|
suspend fun setMuted(targetId: String, chatType: String, muted: Boolean) {
|
||||||
runCatching { ImSDK.setConversationMuted(targetId, chatType, muted) }
|
runCatching { ImSDK.setConversationMuted(targetId, chatType, muted) }
|
||||||
.onFailure { Log.w(TAG, "setMuted failed targetId=$targetId chatType=$chatType muted=$muted", it) }
|
.onFailure { Log.w(TAG, "setMuted failed: chatType=$chatType muted=$muted") }
|
||||||
refresh()
|
refresh()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,316 +0,0 @@
|
|||||||
package com.xuqm.sdk.sample.ui.environment
|
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.Row
|
|
||||||
import androidx.compose.foundation.layout.Spacer
|
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
|
||||||
import androidx.compose.foundation.layout.height
|
|
||||||
import androidx.compose.foundation.layout.imePadding
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.foundation.rememberScrollState
|
|
||||||
import androidx.compose.foundation.verticalScroll
|
|
||||||
import androidx.compose.material.icons.Icons
|
|
||||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
|
||||||
import androidx.compose.material.icons.filled.Settings
|
|
||||||
import androidx.compose.material3.Button
|
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
|
||||||
import androidx.compose.material3.Icon
|
|
||||||
import androidx.compose.material3.IconButton
|
|
||||||
import androidx.compose.material3.HorizontalDivider
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.OutlinedTextField
|
|
||||||
import androidx.compose.material3.RadioButton
|
|
||||||
import androidx.compose.material3.Scaffold
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.material3.Switch
|
|
||||||
import androidx.compose.material3.TopAppBar
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.runtime.mutableStateOf
|
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
import androidx.compose.runtime.setValue
|
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import com.xuqm.sdk.XuqmSDK
|
|
||||||
import com.xuqm.sdk.push.PushSDK
|
|
||||||
import com.xuqm.sdk.push.model.PushRegistrationSnapshot
|
|
||||||
import androidx.lifecycle.ViewModel
|
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
|
||||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
|
||||||
import androidx.lifecycle.viewmodel.initializer
|
|
||||||
import androidx.lifecycle.viewmodel.viewModelFactory
|
|
||||||
import com.xuqm.sdk.sample.data.repo.EnvironmentMode
|
|
||||||
import com.xuqm.sdk.sample.data.repo.EnvironmentRepository
|
|
||||||
import com.xuqm.sdk.sample.data.repo.EnvironmentState
|
|
||||||
import com.xuqm.sdk.sample.di.AppDependencies
|
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
|
||||||
|
|
||||||
data class EnvironmentUiState(
|
|
||||||
val mode: EnvironmentMode = EnvironmentMode.EXTERNAL,
|
|
||||||
val host: String = "192.168.113.37",
|
|
||||||
val message: String? = null,
|
|
||||||
)
|
|
||||||
|
|
||||||
class EnvironmentViewModel(
|
|
||||||
private val environmentRepository: EnvironmentRepository,
|
|
||||||
) : ViewModel() {
|
|
||||||
|
|
||||||
private val initial = environmentRepository.current()
|
|
||||||
private val _state = MutableStateFlow(initial.toUiState())
|
|
||||||
val state: StateFlow<EnvironmentUiState> = _state
|
|
||||||
|
|
||||||
fun selectExternal() {
|
|
||||||
_state.value = _state.value.copy(mode = EnvironmentMode.EXTERNAL, message = null)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun selectLocalhost() {
|
|
||||||
_state.value = _state.value.copy(mode = EnvironmentMode.LOCALHOST, message = null)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun updateHost(host: String) {
|
|
||||||
_state.value = _state.value.copy(host = host, message = null)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun apply() {
|
|
||||||
val current = _state.value
|
|
||||||
when (current.mode) {
|
|
||||||
EnvironmentMode.EXTERNAL -> environmentRepository.setExternal()
|
|
||||||
EnvironmentMode.LOCALHOST -> environmentRepository.setLocalhost(current.host)
|
|
||||||
}
|
|
||||||
_state.value = current.copy(message = "已切换并保存")
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun EnvironmentState.toUiState(): EnvironmentUiState =
|
|
||||||
EnvironmentUiState(mode = mode, host = host)
|
|
||||||
}
|
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
|
||||||
@Composable
|
|
||||||
fun EnvironmentScreen(
|
|
||||||
onNavigateBack: () -> Unit,
|
|
||||||
viewModel: EnvironmentViewModel = viewModel(
|
|
||||||
factory = viewModelFactory {
|
|
||||||
initializer { EnvironmentViewModel(AppDependencies.environmentRepository) }
|
|
||||||
}
|
|
||||||
),
|
|
||||||
) {
|
|
||||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
|
||||||
var hostInput by remember(state.host) { mutableStateOf(state.host) }
|
|
||||||
|
|
||||||
LaunchedEffect(state.host) {
|
|
||||||
hostInput = state.host
|
|
||||||
}
|
|
||||||
|
|
||||||
Scaffold(
|
|
||||||
topBar = {
|
|
||||||
TopAppBar(
|
|
||||||
title = { Text("环境设置") },
|
|
||||||
navigationIcon = {
|
|
||||||
IconButton(onClick = onNavigateBack) {
|
|
||||||
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
},
|
|
||||||
) { padding ->
|
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxSize()
|
|
||||||
.padding(padding)
|
|
||||||
.padding(24.dp)
|
|
||||||
.imePadding()
|
|
||||||
.verticalScroll(rememberScrollState()),
|
|
||||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
imageVector = Icons.Default.Settings,
|
|
||||||
contentDescription = null,
|
|
||||||
tint = MaterialTheme.colorScheme.primary,
|
|
||||||
)
|
|
||||||
|
|
||||||
Text(
|
|
||||||
text = "切换 demo 与 SDK 的联调端点",
|
|
||||||
style = MaterialTheme.typography.titleMedium,
|
|
||||||
fontWeight = FontWeight.SemiBold,
|
|
||||||
)
|
|
||||||
|
|
||||||
Text(
|
|
||||||
text = if (state.mode == EnvironmentMode.EXTERNAL) {
|
|
||||||
"当前使用开发服务:https://dev.xuqinmin.com"
|
|
||||||
} else {
|
|
||||||
"当前使用本地服务:http://${state.host}:8085"
|
|
||||||
},
|
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
|
||||||
)
|
|
||||||
|
|
||||||
EnvironmentModeRow(
|
|
||||||
title = "开发服务",
|
|
||||||
selected = state.mode == EnvironmentMode.EXTERNAL,
|
|
||||||
description = "走线上 / 开发服务器,适合正常联调。",
|
|
||||||
onClick = viewModel::selectExternal,
|
|
||||||
)
|
|
||||||
|
|
||||||
EnvironmentModeRow(
|
|
||||||
title = "本地联调",
|
|
||||||
selected = state.mode == EnvironmentMode.LOCALHOST,
|
|
||||||
description = "走本机或局域网服务,适合本地开发。",
|
|
||||||
onClick = viewModel::selectLocalhost,
|
|
||||||
)
|
|
||||||
|
|
||||||
OutlinedTextField(
|
|
||||||
value = hostInput,
|
|
||||||
onValueChange = {
|
|
||||||
hostInput = it
|
|
||||||
viewModel.updateHost(it)
|
|
||||||
},
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
label = { Text("本地 Host") },
|
|
||||||
placeholder = { Text("192.168.113.37 或你的电脑局域网 IP") },
|
|
||||||
singleLine = true,
|
|
||||||
enabled = state.mode == EnvironmentMode.LOCALHOST,
|
|
||||||
)
|
|
||||||
|
|
||||||
Text(
|
|
||||||
text = "切换后会立即重建 HTTP 客户端;如果 IM 已登录,会自动重连。",
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = MaterialTheme.colorScheme.outline,
|
|
||||||
)
|
|
||||||
|
|
||||||
Button(
|
|
||||||
onClick = viewModel::apply,
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
) {
|
|
||||||
Text("保存并应用")
|
|
||||||
}
|
|
||||||
|
|
||||||
state.message?.let {
|
|
||||||
Text(
|
|
||||||
text = it,
|
|
||||||
color = MaterialTheme.colorScheme.primary,
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
HorizontalDivider()
|
|
||||||
|
|
||||||
PushRegistrationSection()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun PushRegistrationSection() {
|
|
||||||
val context = androidx.compose.ui.platform.LocalContext.current
|
|
||||||
val currentUserId = XuqmSDK.getUserId() ?: AppDependencies.authRepository.getCurrentUserId()
|
|
||||||
var statusMessage by remember { mutableStateOf<String?>(null) }
|
|
||||||
var snapshot by remember { mutableStateOf<PushRegistrationSnapshot?>(null) }
|
|
||||||
|
|
||||||
fun refresh(): PushRegistrationSnapshot? {
|
|
||||||
val current = runCatching { PushSDK.currentRegistration(context) }.getOrNull()
|
|
||||||
snapshot = current
|
|
||||||
return current
|
|
||||||
}
|
|
||||||
|
|
||||||
LaunchedEffect(Unit) {
|
|
||||||
refresh()
|
|
||||||
}
|
|
||||||
|
|
||||||
Column(
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = "推送注册",
|
|
||||||
style = MaterialTheme.typography.titleMedium,
|
|
||||||
fontWeight = FontWeight.SemiBold,
|
|
||||||
)
|
|
||||||
|
|
||||||
Text(
|
|
||||||
text = if (currentUserId.isNullOrBlank()) {
|
|
||||||
"当前未登录,登录后会自动请求系统推送 token 并完成绑定。"
|
|
||||||
} else {
|
|
||||||
"当前登录用户:$currentUserId"
|
|
||||||
},
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = MaterialTheme.colorScheme.outline,
|
|
||||||
)
|
|
||||||
|
|
||||||
Text(
|
|
||||||
text = if (snapshot?.hasPushToken == true) {
|
|
||||||
"系统 token:已就绪"
|
|
||||||
} else {
|
|
||||||
"系统 token:等待 Firebase 回调"
|
|
||||||
},
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
)
|
|
||||||
|
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
|
||||||
Switch(
|
|
||||||
checked = snapshot?.receivePush ?: true,
|
|
||||||
onCheckedChange = { enabled ->
|
|
||||||
PushSDK.setOfflinePushEnabled(enabled)
|
|
||||||
snapshot = snapshot?.copy(receivePush = enabled) ?: snapshot
|
|
||||||
statusMessage = if (enabled) "已允许接收推送" else "已关闭接收推送"
|
|
||||||
},
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.height(0.dp))
|
|
||||||
Text(
|
|
||||||
text = "接收推送",
|
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
Text(
|
|
||||||
text = "系统 token 由 `FirebaseMessagingService.onNewToken()` 自动上报;登录后会自动完成绑定。",
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = MaterialTheme.colorScheme.outline,
|
|
||||||
)
|
|
||||||
|
|
||||||
statusMessage?.let {
|
|
||||||
Text(
|
|
||||||
text = it,
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = MaterialTheme.colorScheme.primary,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
|
||||||
@Composable
|
|
||||||
private fun EnvironmentModeRow(
|
|
||||||
title: String,
|
|
||||||
selected: Boolean,
|
|
||||||
description: String,
|
|
||||||
onClick: () -> Unit,
|
|
||||||
) {
|
|
||||||
androidx.compose.material3.Surface(
|
|
||||||
onClick = onClick,
|
|
||||||
tonalElevation = if (selected) 2.dp else 0.dp,
|
|
||||||
shape = MaterialTheme.shapes.medium,
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
) {
|
|
||||||
Column(
|
|
||||||
modifier = Modifier.padding(16.dp),
|
|
||||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
|
||||||
) {
|
|
||||||
androidx.compose.foundation.layout.Row(
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
) {
|
|
||||||
RadioButton(selected = selected, onClick = onClick)
|
|
||||||
Text(title, style = MaterialTheme.typography.titleSmall)
|
|
||||||
}
|
|
||||||
Text(
|
|
||||||
text = description,
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = MaterialTheme.colorScheme.outline,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -72,7 +72,7 @@ class GroupViewModel : ViewModel() {
|
|||||||
loadGroupMembersInternal(groupId)
|
loadGroupMembersInternal(groupId)
|
||||||
}
|
}
|
||||||
.onFailure {
|
.onFailure {
|
||||||
Log.w(TAG, "loadGroupInfo failed groupId=$groupId", it)
|
Log.w(TAG, "loadGroupInfo failed")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -102,7 +102,7 @@ class GroupViewModel : ViewModel() {
|
|||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
runCatching { ImSDK.createGroup(name, memberIds, groupType) }
|
runCatching { ImSDK.createGroup(name, memberIds, groupType) }
|
||||||
.onSuccess { group -> group?.let { onSuccess(it); loadGroups() } }
|
.onSuccess { group -> group?.let { onSuccess(it); loadGroups() } }
|
||||||
.onFailure { Log.w(TAG, "createGroup failed name=$name groupType=$groupType", it) }
|
.onFailure { Log.w(TAG, "createGroup failed: groupType=$groupType") }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -110,7 +110,7 @@ class GroupViewModel : ViewModel() {
|
|||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
runCatching { ImSDK.updateGroupInfo(groupId, name, announcement) }
|
runCatching { ImSDK.updateGroupInfo(groupId, name, announcement) }
|
||||||
.onSuccess { loadGroupInfo(groupId) }
|
.onSuccess { loadGroupInfo(groupId) }
|
||||||
.onFailure { Log.w(TAG, "updateGroup failed groupId=$groupId", it) }
|
.onFailure { Log.w(TAG, "updateGroup failed") }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -118,7 +118,7 @@ class GroupViewModel : ViewModel() {
|
|||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
runCatching { ImSDK.setGroupRole(groupId, userId, role) }
|
runCatching { ImSDK.setGroupRole(groupId, userId, role) }
|
||||||
.onSuccess { loadGroupInfo(groupId) }
|
.onSuccess { loadGroupInfo(groupId) }
|
||||||
.onFailure { Log.w(TAG, "setRole failed groupId=$groupId userId=$userId role=$role", it) }
|
.onFailure { Log.w(TAG, "setRole failed: role=$role") }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -126,7 +126,7 @@ class GroupViewModel : ViewModel() {
|
|||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
runCatching { ImSDK.addGroupMember(groupId, userId) }
|
runCatching { ImSDK.addGroupMember(groupId, userId) }
|
||||||
.onSuccess { loadGroupInfo(groupId) }
|
.onSuccess { loadGroupInfo(groupId) }
|
||||||
.onFailure { Log.w(TAG, "addMember failed groupId=$groupId userId=$userId", it) }
|
.onFailure { Log.w(TAG, "addMember failed") }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -134,7 +134,7 @@ class GroupViewModel : ViewModel() {
|
|||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
runCatching { ImSDK.muteGroupMember(groupId, userId, minutes) }
|
runCatching { ImSDK.muteGroupMember(groupId, userId, minutes) }
|
||||||
.onSuccess { loadGroupInfo(groupId) }
|
.onSuccess { loadGroupInfo(groupId) }
|
||||||
.onFailure { Log.w(TAG, "muteMember failed groupId=$groupId userId=$userId minutes=$minutes", it) }
|
.onFailure { Log.w(TAG, "muteMember failed: minutes=$minutes") }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -142,7 +142,7 @@ class GroupViewModel : ViewModel() {
|
|||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
runCatching { ImSDK.dismissGroup(groupId) }
|
runCatching { ImSDK.dismissGroup(groupId) }
|
||||||
.onSuccess { loadGroups(); onSuccess() }
|
.onSuccess { loadGroups(); onSuccess() }
|
||||||
.onFailure { Log.w(TAG, "dismissGroup failed groupId=$groupId", it) }
|
.onFailure { Log.w(TAG, "dismissGroup failed") }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -150,7 +150,7 @@ class GroupViewModel : ViewModel() {
|
|||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
runCatching { ImSDK.removeGroupMember(groupId, userId) }
|
runCatching { ImSDK.removeGroupMember(groupId, userId) }
|
||||||
.onSuccess { loadGroupInfo(groupId) }
|
.onSuccess { loadGroupInfo(groupId) }
|
||||||
.onFailure { Log.w(TAG, "removeMember failed groupId=$groupId userId=$userId", it) }
|
.onFailure { Log.w(TAG, "removeMember failed") }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -158,14 +158,14 @@ class GroupViewModel : ViewModel() {
|
|||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
runCatching { ImSDK.leaveGroup(groupId) }
|
runCatching { ImSDK.leaveGroup(groupId) }
|
||||||
.onSuccess { loadGroups(); onSuccess() }
|
.onSuccess { loadGroups(); onSuccess() }
|
||||||
.onFailure { Log.w(TAG, "leaveGroup failed groupId=$groupId", it) }
|
.onFailure { Log.w(TAG, "leaveGroup failed") }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun requestJoinGroup(groupId: String, remark: String? = null) {
|
fun requestJoinGroup(groupId: String, remark: String? = null) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
runCatching { ImSDK.sendGroupJoinRequest(groupId, remark) }
|
runCatching { ImSDK.sendGroupJoinRequest(groupId, remark) }
|
||||||
.onFailure { Log.w(TAG, "requestJoinGroup failed groupId=$groupId", it) }
|
.onFailure { Log.w(TAG, "requestJoinGroup failed") }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -173,7 +173,7 @@ class GroupViewModel : ViewModel() {
|
|||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
runCatching { ImSDK.listGroupJoinRequests(groupId) }
|
runCatching { ImSDK.listGroupJoinRequests(groupId) }
|
||||||
.onSuccess { _joinRequests.value = it }
|
.onSuccess { _joinRequests.value = it }
|
||||||
.onFailure { Log.w(TAG, "loadJoinRequests failed groupId=$groupId", it) }
|
.onFailure { Log.w(TAG, "loadJoinRequests failed") }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -185,7 +185,7 @@ class GroupViewModel : ViewModel() {
|
|||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
runCatching { ImSDK.acceptGroupJoinRequest(groupId, requestId) }
|
runCatching { ImSDK.acceptGroupJoinRequest(groupId, requestId) }
|
||||||
.onSuccess { loadJoinRequests(groupId); loadGroupInfo(groupId) }
|
.onSuccess { loadJoinRequests(groupId); loadGroupInfo(groupId) }
|
||||||
.onFailure { Log.w(TAG, "acceptJoinRequest failed groupId=$groupId requestId=$requestId", it) }
|
.onFailure { Log.w(TAG, "acceptJoinRequest failed") }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -193,7 +193,7 @@ class GroupViewModel : ViewModel() {
|
|||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
runCatching { ImSDK.rejectGroupJoinRequest(groupId, requestId) }
|
runCatching { ImSDK.rejectGroupJoinRequest(groupId, requestId) }
|
||||||
.onSuccess { loadJoinRequests(groupId); loadGroupInfo(groupId) }
|
.onSuccess { loadJoinRequests(groupId); loadGroupInfo(groupId) }
|
||||||
.onFailure { Log.w(TAG, "rejectJoinRequest failed groupId=$groupId requestId=$requestId", it) }
|
.onFailure { Log.w(TAG, "rejectJoinRequest failed") }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -203,7 +203,7 @@ class GroupViewModel : ViewModel() {
|
|||||||
}.onSuccess {
|
}.onSuccess {
|
||||||
_members.value = it
|
_members.value = it
|
||||||
}.onFailure {
|
}.onFailure {
|
||||||
Log.w(TAG, "loadMembersInternal failed", it)
|
Log.w(TAG, "loadMembersInternal failed")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -213,20 +213,20 @@ class GroupViewModel : ViewModel() {
|
|||||||
_groupMembers.value = profiles.map { it.toUserData() }
|
_groupMembers.value = profiles.map { it.toUserData() }
|
||||||
}
|
}
|
||||||
.onFailure {
|
.onFailure {
|
||||||
Log.w(TAG, "loadGroupMembersInternal failed groupId=$groupId", it)
|
Log.w(TAG, "loadGroupMembersInternal failed")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun loadGroupsInternal() {
|
private suspend fun loadGroupsInternal() {
|
||||||
runCatching { ImSDK.listGroups() }
|
runCatching { ImSDK.listGroups() }
|
||||||
.onSuccess { _groups.value = it }
|
.onSuccess { _groups.value = it }
|
||||||
.onFailure { Log.w(TAG, "loadGroupsInternal failed", it) }
|
.onFailure { Log.w(TAG, "loadGroupsInternal failed") }
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun loadPublicGroupsInternal(keyword: String) {
|
private suspend fun loadPublicGroupsInternal(keyword: String) {
|
||||||
runCatching { ImSDK.listPublicGroups(keyword.ifBlank { null }) }
|
runCatching { ImSDK.listPublicGroups(keyword.ifBlank { null }) }
|
||||||
.onSuccess { _publicGroups.value = it }
|
.onSuccess { _publicGroups.value = it }
|
||||||
.onFailure { Log.w(TAG, "loadPublicGroupsInternal failed keyword=$keyword", it) }
|
.onFailure { Log.w(TAG, "loadPublicGroupsInternal failed") }
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun UserProfile.toUserData(): UserData {
|
private fun UserProfile.toUserData(): UserData {
|
||||||
|
|||||||
@ -9,7 +9,6 @@ import androidx.compose.material.icons.filled.Group
|
|||||||
import androidx.compose.material.icons.filled.Language
|
import androidx.compose.material.icons.filled.Language
|
||||||
import androidx.compose.material.icons.filled.People
|
import androidx.compose.material.icons.filled.People
|
||||||
import androidx.compose.material.icons.filled.Person
|
import androidx.compose.material.icons.filled.Person
|
||||||
import androidx.compose.material.icons.filled.Settings
|
|
||||||
import androidx.compose.material.icons.filled.SystemUpdate
|
import androidx.compose.material.icons.filled.SystemUpdate
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.AlertDialog
|
import androidx.compose.material3.AlertDialog
|
||||||
@ -18,7 +17,6 @@ import androidx.compose.material3.CircularProgressIndicator
|
|||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.NavigationBar
|
import androidx.compose.material3.NavigationBar
|
||||||
import androidx.compose.material3.NavigationBarItem
|
import androidx.compose.material3.NavigationBarItem
|
||||||
import androidx.compose.material3.IconButton
|
|
||||||
import androidx.compose.material3.Scaffold
|
import androidx.compose.material3.Scaffold
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TopAppBar
|
import androidx.compose.material3.TopAppBar
|
||||||
@ -64,7 +62,6 @@ fun MainScreen(
|
|||||||
onOpenChat: (targetId: String, chatType: String, targetName: String) -> Unit,
|
onOpenChat: (targetId: String, chatType: String, targetName: String) -> Unit,
|
||||||
onGroupSettings: (groupId: String) -> Unit,
|
onGroupSettings: (groupId: String) -> Unit,
|
||||||
onOpenWebView: () -> Unit,
|
onOpenWebView: () -> Unit,
|
||||||
onOpenEnvironment: () -> Unit,
|
|
||||||
onLogout: () -> Unit,
|
onLogout: () -> Unit,
|
||||||
) {
|
) {
|
||||||
var selectedTab by remember { mutableIntStateOf(0) }
|
var selectedTab by remember { mutableIntStateOf(0) }
|
||||||
@ -88,11 +85,6 @@ fun MainScreen(
|
|||||||
androidx.compose.foundation.layout.Column {
|
androidx.compose.foundation.layout.Column {
|
||||||
TopAppBar(
|
TopAppBar(
|
||||||
title = { Text(tabs[selectedTab].label) },
|
title = { Text(tabs[selectedTab].label) },
|
||||||
actions = {
|
|
||||||
IconButton(onClick = onOpenEnvironment) {
|
|
||||||
Icon(Icons.Default.Settings, contentDescription = "环境设置")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
ConnectionStatusBanner(connectionState)
|
ConnectionStatusBanner(connectionState)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -38,6 +38,9 @@ import kotlinx.coroutines.flow.StateFlow
|
|||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import androidx.lifecycle.viewmodel.initializer
|
import androidx.lifecycle.viewmodel.initializer
|
||||||
import androidx.lifecycle.viewmodel.viewModelFactory
|
import androidx.lifecycle.viewmodel.viewModelFactory
|
||||||
|
import com.xuqm.sdk.XuqmSDK
|
||||||
|
import com.xuqm.sdk.bugcollect.BugCollect
|
||||||
|
import com.xuqm.sdk.sample.BuildConfig
|
||||||
|
|
||||||
data class ProfileUiState(
|
data class ProfileUiState(
|
||||||
val userId: String = "",
|
val userId: String = "",
|
||||||
@ -89,6 +92,7 @@ fun ProfileScreen(
|
|||||||
),
|
),
|
||||||
) {
|
) {
|
||||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||||
|
var developerTestResult by remember { mutableStateOf<String?>(null) }
|
||||||
|
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
@ -144,6 +148,26 @@ fun ProfileScreen(
|
|||||||
|
|
||||||
Spacer(Modifier.height(8.dp))
|
Spacer(Modifier.height(8.dp))
|
||||||
|
|
||||||
|
if (BuildConfig.DEBUG) {
|
||||||
|
OutlinedButton(
|
||||||
|
onClick = {
|
||||||
|
// Sample 中的显式点击代表同意本次诊断测试;正式宿主应接入自己的隐私授权状态。
|
||||||
|
XuqmSDK.setPrivacyConsent(true)
|
||||||
|
developerTestResult = if (BugCollect.sendDeveloperTestOnce()) {
|
||||||
|
"已调度一次开发测试事件"
|
||||||
|
} else {
|
||||||
|
"未发送:请确认初始化、平台开关或本进程是否已测试"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
) {
|
||||||
|
Text("发送一次 BugCollect 开发测试")
|
||||||
|
}
|
||||||
|
developerTestResult?.let {
|
||||||
|
Text(it, style = MaterialTheme.typography.bodySmall)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
OutlinedButton(
|
OutlinedButton(
|
||||||
onClick = { viewModel.logout(onLogout) },
|
onClick = { viewModel.logout(onLogout) },
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
package com.xuqm.sdk.sample.ui.webview
|
package com.xuqm.sdk.sample.ui.webview
|
||||||
|
|
||||||
|
import android.content.ClipData
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
@ -18,16 +19,18 @@ import androidx.compose.runtime.getValue
|
|||||||
import androidx.compose.runtime.mutableIntStateOf
|
import androidx.compose.runtime.mutableIntStateOf
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.runtime.saveable.rememberSaveable
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.platform.LocalClipboardManager
|
import androidx.compose.ui.platform.ClipEntry
|
||||||
|
import androidx.compose.ui.platform.LocalClipboard
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.text.AnnotatedString
|
|
||||||
import com.xuqm.sdk.webview.XWebViewConfig
|
import com.xuqm.sdk.webview.XWebViewConfig
|
||||||
import com.xuqm.sdk.webview.XWebViewControl
|
import com.xuqm.sdk.webview.XWebViewControl
|
||||||
import com.xuqm.sdk.webview.XWebViewView
|
import com.xuqm.sdk.webview.XWebViewView
|
||||||
import com.xuqm.sdk.webview.openXWebView
|
import com.xuqm.sdk.webview.openXWebView
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
private enum class WebViewMode { Embedded, Page }
|
private enum class WebViewMode { Embedded, Page }
|
||||||
|
|
||||||
@ -41,7 +44,8 @@ fun WebViewEntryScreen(
|
|||||||
var statusVersion by rememberSaveable { mutableIntStateOf(0) }
|
var statusVersion by rememberSaveable { mutableIntStateOf(0) }
|
||||||
val selectedMode = WebViewMode.entries[mode]
|
val selectedMode = WebViewMode.entries[mode]
|
||||||
val config = XWebViewConfig(url = url.trim(), title = title.trim())
|
val config = XWebViewConfig(url = url.trim(), title = title.trim())
|
||||||
val clipboard = LocalClipboardManager.current
|
val clipboard = LocalClipboard.current
|
||||||
|
val coroutineScope = rememberCoroutineScope()
|
||||||
val currentUrl = remember(statusVersion, config.url) {
|
val currentUrl = remember(statusVersion, config.url) {
|
||||||
XWebViewControl.currentUrl() ?: config.url
|
XWebViewControl.currentUrl() ?: config.url
|
||||||
}
|
}
|
||||||
@ -125,7 +129,16 @@ fun WebViewEntryScreen(
|
|||||||
|
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||||
Button(onClick = {
|
Button(onClick = {
|
||||||
clipboard.setText(AnnotatedString(currentUrl.ifBlank { config.url }))
|
coroutineScope.launch {
|
||||||
|
clipboard.setClipEntry(
|
||||||
|
ClipEntry(
|
||||||
|
ClipData.newPlainText(
|
||||||
|
"URL",
|
||||||
|
currentUrl.ifBlank { config.url },
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
statusVersion++
|
statusVersion++
|
||||||
}) { Text("复制 URL") }
|
}) { Text("复制 URL") }
|
||||||
|
|
||||||
|
|||||||
@ -36,6 +36,7 @@ gradlePlugin {
|
|||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
compileOnly("com.android.tools.build:gradle:9.1.0")
|
compileOnly("com.android.tools.build:gradle:9.1.0")
|
||||||
|
testImplementation("junit:junit:4.13.2")
|
||||||
}
|
}
|
||||||
|
|
||||||
publishing {
|
publishing {
|
||||||
|
|||||||
@ -5,130 +5,64 @@ import com.android.build.api.dsl.ApplicationExtension
|
|||||||
import com.android.build.api.variant.ApplicationAndroidComponentsExtension
|
import com.android.build.api.variant.ApplicationAndroidComponentsExtension
|
||||||
import org.gradle.api.Plugin
|
import org.gradle.api.Plugin
|
||||||
import org.gradle.api.Project
|
import org.gradle.api.Project
|
||||||
import java.io.File
|
|
||||||
import java.text.SimpleDateFormat
|
|
||||||
import java.util.Base64
|
|
||||||
import java.util.Date
|
|
||||||
import java.util.Locale
|
|
||||||
import javax.crypto.Cipher
|
|
||||||
import javax.crypto.SecretKeyFactory
|
|
||||||
import javax.crypto.spec.GCMParameterSpec
|
|
||||||
import javax.crypto.spec.PBEKeySpec
|
|
||||||
import javax.crypto.spec.SecretKeySpec
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* BugCollect 构建插件只负责 R8 mapping 上传。
|
||||||
|
*
|
||||||
|
* Release 配置验证、唯一配置路径、buildId 与运行时构建门禁统一归
|
||||||
|
* [XuqmCommonPlugin],本插件不得维护第二套定义。
|
||||||
|
*/
|
||||||
class XuqmBugCollectPlugin : Plugin<Project> {
|
class XuqmBugCollectPlugin : Plugin<Project> {
|
||||||
|
|
||||||
private fun base64UrlDecode(s: String): ByteArray {
|
|
||||||
val padded = s.replace('-', '+').replace('_', '/')
|
|
||||||
.let { it + "=".repeat((4 - it.length % 4) % 4) }
|
|
||||||
return Base64.getDecoder().decode(padded)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Read appKey/platformUrl from a SDK Mode A encrypted config file.
|
|
||||||
* Format: XUQM-CONFIG-V1.{salt_b64url}.{iv_b64url}.{ciphertext_b64url}
|
|
||||||
*/
|
|
||||||
private fun readEncryptedConfigFile(file: File): Pair<String?, String?> {
|
|
||||||
val content = runCatching { file.readText().trim() }.getOrElse { return null to null }
|
|
||||||
val parts = content.split(".")
|
|
||||||
if (parts.size != 4 || parts[0] != "XUQM-CONFIG-V1") return null to null
|
|
||||||
|
|
||||||
return runCatching {
|
|
||||||
val salt = base64UrlDecode(parts[1])
|
|
||||||
val iv = base64UrlDecode(parts[2])
|
|
||||||
val ciphertext = base64UrlDecode(parts[3])
|
|
||||||
val passphrase = "xuqm-config-file-v1.2026.internal"
|
|
||||||
|
|
||||||
val keySpec = PBEKeySpec(passphrase.toCharArray(), salt, 120_000, 256)
|
|
||||||
val keyBytes = SecretKeyFactory.getInstance("PBKDF2WithHmacSHA256").generateSecret(keySpec).encoded
|
|
||||||
val aesKey = SecretKeySpec(keyBytes, "AES")
|
|
||||||
|
|
||||||
val cipher = Cipher.getInstance("AES/GCM/NoPadding")
|
|
||||||
cipher.init(Cipher.DECRYPT_MODE, aesKey, GCMParameterSpec(128, iv))
|
|
||||||
val json = String(cipher.doFinal(ciphertext))
|
|
||||||
|
|
||||||
val appKey = Regex(""""appKey"\s*:\s*"([^"]+)"""").find(json)?.groupValues?.get(1)
|
|
||||||
val platformUrl = Regex(""""baseUrl"\s*:\s*"([^"]+)"""").find(json)?.groupValues?.get(1)
|
|
||||||
?: Regex(""""serverUrl"\s*:\s*"([^"]+)"""").find(json)?.groupValues?.get(1)
|
|
||||||
appKey to platformUrl
|
|
||||||
}.getOrElse { null to null }
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Read appKey/platformUrl from xuqm.config.json at project root (optional). */
|
|
||||||
private fun readConfigFile(rootDir: File): Pair<String?, String?> {
|
|
||||||
val f = File(rootDir, "xuqm.config.json").takeIf { it.exists() } ?: return null to null
|
|
||||||
val text = runCatching { f.readText() }.getOrElse { return null to null }
|
|
||||||
val appKey = Regex(""""appKey"\s*:\s*"([^"]+)"""").find(text)?.groupValues?.get(1)
|
|
||||||
val platformUrl = Regex(""""platformUrl"\s*:\s*"([^"]+)"""").find(text)?.groupValues?.get(1)
|
|
||||||
return appKey to platformUrl
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Search flavor-specific and main asset source sets for a xuqm config file.
|
|
||||||
* Accepts config.xuqm or any *.xuqmconfig file under assets/xuqm/.
|
|
||||||
*/
|
|
||||||
private fun findVariantConfigFile(projectDir: File, flavorName: String?, buildType: String?): File? {
|
|
||||||
val candidates = buildList {
|
|
||||||
flavorName?.takeIf { it.isNotEmpty() }?.let { add(File(projectDir, "src/$it/assets")) }
|
|
||||||
buildType?.takeIf { it.isNotEmpty() }?.let { add(File(projectDir, "src/$it/assets")) }
|
|
||||||
add(File(projectDir, "src/main/assets"))
|
|
||||||
}
|
|
||||||
for (assetsDir in candidates) {
|
|
||||||
val xuqmDir = File(assetsDir, "xuqm")
|
|
||||||
if (!xuqmDir.isDirectory) continue
|
|
||||||
val configFile = File(xuqmDir, "config.xuqm").takeIf { it.exists() }
|
|
||||||
?: xuqmDir.listFiles { f -> f.extension == "xuqmconfig" }?.firstOrNull()
|
|
||||||
if (configFile != null) return configFile
|
|
||||||
}
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun apply(target: Project) {
|
override fun apply(target: Project) {
|
||||||
val androidComponents = target.extensions.findByType(ApplicationAndroidComponentsExtension::class.java)
|
check(target.pluginManager.hasPlugin("com.xuqm.common")) {
|
||||||
?: return
|
"Apply com.xuqm.common before com.xuqm.bugcollect"
|
||||||
val androidDsl = target.extensions.findByType(ApplicationExtension::class.java)
|
}
|
||||||
?: return
|
target.pluginManager.withPlugin("com.android.application") {
|
||||||
|
configureAndroidApplication(target)
|
||||||
androidComponents.onVariants { variant ->
|
}
|
||||||
// Always inject buildId — the AAR manifest placeholder must be resolved for every variant.
|
|
||||||
val buildId = SimpleDateFormat("yyyyMMddHHmmss", Locale.US).format(Date())
|
|
||||||
variant.manifestPlaceholders.put("xuqmBuildId", buildId)
|
|
||||||
|
|
||||||
// Mapping upload only makes sense for minified (release) builds.
|
|
||||||
val buildType = androidDsl.buildTypes.findByName(variant.buildType ?: "") ?: return@onVariants
|
|
||||||
if (!buildType.isMinifyEnabled) return@onVariants
|
|
||||||
|
|
||||||
val configFile = findVariantConfigFile(target.projectDir, variant.flavorName, variant.buildType)
|
|
||||||
val (fileAppKey, filePlatformUrl) = when {
|
|
||||||
configFile != null -> readEncryptedConfigFile(configFile)
|
|
||||||
else -> readConfigFile(target.rootDir)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
val productFlavor = androidDsl.productFlavors.findByName(variant.flavorName ?: "")
|
private fun configureAndroidApplication(target: Project) {
|
||||||
val versionName = productFlavor?.versionName ?: androidDsl.defaultConfig.versionName ?: ""
|
val components = target.extensions.getByType(ApplicationAndroidComponentsExtension::class.java)
|
||||||
|
val android = target.extensions.getByType(ApplicationExtension::class.java)
|
||||||
val taskName = "xuqmUploadMapping${variant.name.replaceFirstChar { it.uppercase() }}"
|
components.onVariants { variant ->
|
||||||
val uploadTask = target.tasks.register(taskName, XuqmUploadMappingTask::class.java) { task ->
|
if (!variant.buildType.equals("release", ignoreCase = true)) return@onVariants
|
||||||
|
val buildType = android.buildTypes.findByName(variant.buildType ?: "") ?: return@onVariants
|
||||||
|
if (!buildType.isMinifyEnabled) return@onVariants
|
||||||
|
val extras = target.extensions.extraProperties
|
||||||
|
val buildId = extras.get("xuqm.buildId.${variant.name}") as String
|
||||||
|
val appKey = extras.get("xuqm.appKey.${variant.name}") as? String
|
||||||
|
?: error("Missing or invalid assets/config/config.xuqmconfig")
|
||||||
|
val platformUrl = extras.get("xuqm.platformUrl.${variant.name}") as? String
|
||||||
|
?: error("Missing or invalid assets/config/config.xuqmconfig")
|
||||||
|
val flavor = android.productFlavors.findByName(variant.flavorName ?: "")
|
||||||
|
val versionName = flavor?.versionName ?: android.defaultConfig.versionName.orEmpty()
|
||||||
|
val suffix = variant.name.replaceFirstChar { it.uppercase() }
|
||||||
|
val uploadTask = target.tasks.register(
|
||||||
|
"xuqmUploadMapping$suffix",
|
||||||
|
XuqmUploadMappingTask::class.java,
|
||||||
|
) { task ->
|
||||||
task.group = "xuqm"
|
task.group = "xuqm"
|
||||||
task.description = "Upload ProGuard mapping to BugCollect service (${variant.name})"
|
task.description = "Upload R8 mapping to BugCollect (${variant.name})"
|
||||||
task.appKey.set(
|
task.appKey.set(appKey)
|
||||||
fileAppKey
|
task.platformUrl.set(platformUrl)
|
||||||
?: target.findProperty("XUQM_APP_KEY")?.toString()
|
|
||||||
?: target.findProperty("xuqm.appKey")?.toString()
|
|
||||||
)
|
|
||||||
task.platformUrl.set(
|
|
||||||
filePlatformUrl
|
|
||||||
?: target.findProperty("XUQM_PLATFORM_URL")?.toString()
|
|
||||||
?: "https://dev.xuqinmin.com"
|
|
||||||
)
|
|
||||||
task.appVersion.set(versionName)
|
task.appVersion.set(versionName)
|
||||||
task.buildId.set(buildId)
|
task.buildId.set(buildId)
|
||||||
task.platform.set("android")
|
task.platform.set("android")
|
||||||
|
task.apiToken.set(
|
||||||
|
target.providers.gradleProperty("XUQM_API_TOKEN")
|
||||||
|
.orElse(target.providers.environmentVariable("XUQM_API_TOKEN")),
|
||||||
|
)
|
||||||
task.mappingFile.set(variant.artifacts.get(SingleArtifact.OBFUSCATION_MAPPING_FILE))
|
task.mappingFile.set(variant.artifacts.get(SingleArtifact.OBFUSCATION_MAPPING_FILE))
|
||||||
|
task.emergencyDisabled.set(
|
||||||
|
target.providers.gradleProperty("xuqm.bugcollect")
|
||||||
|
.map { it.equals("disabled", ignoreCase = true) }
|
||||||
|
.orElse(false),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
target.tasks.matching {
|
||||||
val assembleTaskName = "assemble${variant.name.replaceFirstChar { it.uppercase() }}"
|
it.name == "assemble$suffix" || it.name == "bundle$suffix"
|
||||||
target.tasks.matching { it.name == assembleTaskName }.configureEach { it.finalizedBy(uploadTask) }
|
}.configureEach { it.finalizedBy(uploadTask) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,15 +1,18 @@
|
|||||||
package com.xuqm.sdk.bugcollect.gradle
|
package com.xuqm.sdk.bugcollect.gradle
|
||||||
|
|
||||||
import org.gradle.api.DefaultTask
|
import org.gradle.api.DefaultTask
|
||||||
|
import org.gradle.api.GradleException
|
||||||
import org.gradle.api.file.RegularFileProperty
|
import org.gradle.api.file.RegularFileProperty
|
||||||
import org.gradle.api.provider.Property
|
import org.gradle.api.provider.Property
|
||||||
import org.gradle.api.tasks.Input
|
import org.gradle.api.tasks.Input
|
||||||
import org.gradle.api.tasks.InputFile
|
import org.gradle.api.tasks.InputFile
|
||||||
|
import org.gradle.api.tasks.Internal
|
||||||
import org.gradle.api.tasks.Optional
|
import org.gradle.api.tasks.Optional
|
||||||
import org.gradle.api.tasks.TaskAction
|
import org.gradle.api.tasks.TaskAction
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.net.HttpURLConnection
|
import java.net.HttpURLConnection
|
||||||
import java.net.URL
|
import java.net.URI
|
||||||
|
import java.security.MessageDigest
|
||||||
|
|
||||||
abstract class XuqmUploadMappingTask : DefaultTask() {
|
abstract class XuqmUploadMappingTask : DefaultTask() {
|
||||||
@get:Input abstract val appKey: Property<String>
|
@get:Input abstract val appKey: Property<String>
|
||||||
@ -17,34 +20,57 @@ abstract class XuqmUploadMappingTask : DefaultTask() {
|
|||||||
@get:Input abstract val appVersion: Property<String>
|
@get:Input abstract val appVersion: Property<String>
|
||||||
@get:Input @get:Optional abstract val buildId: Property<String>
|
@get:Input @get:Optional abstract val buildId: Property<String>
|
||||||
@get:Input abstract val platform: Property<String>
|
@get:Input abstract val platform: Property<String>
|
||||||
|
@get:Input abstract val emergencyDisabled: Property<Boolean>
|
||||||
|
/** 认证令牌属于构建机秘密,不参与 Gradle 输入快照,也不得写入日志。 */
|
||||||
|
@get:Internal abstract val apiToken: Property<String>
|
||||||
@get:InputFile @get:Optional abstract val mappingFile: RegularFileProperty
|
@get:InputFile @get:Optional abstract val mappingFile: RegularFileProperty
|
||||||
|
|
||||||
@TaskAction
|
@TaskAction
|
||||||
fun upload() {
|
fun upload() {
|
||||||
val key = appKey.orNull ?: run { logger.warn("[XuqmLog] XUQM_APP_KEY not set, skip upload"); return }
|
if (emergencyDisabled.getOrElse(false)) {
|
||||||
|
logger.lifecycle("[XuqmLog] BugCollect disabled for this build; mapping kept as local build artifact")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val key = appKey.get()
|
||||||
val file = mappingFile.orNull?.asFile ?: run { logger.warn("[XuqmLog] No mapping file found"); return }
|
val file = mappingFile.orNull?.asFile ?: run { logger.warn("[XuqmLog] No mapping file found"); return }
|
||||||
if (!file.exists()) { logger.warn("[XuqmLog] Mapping file not found: ${file.path}"); return }
|
if (!file.exists()) { logger.warn("[XuqmLog] Mapping file not found: ${file.path}"); return }
|
||||||
|
|
||||||
val configUrl = "${platformUrl.get().trimEnd('/')}/api/sdk/config?appKey=$key"
|
val configUrl = "${platformUrl.get().trimEnd('/')}/api/sdk/config?appKey=$key"
|
||||||
val config = fetchConfig(configUrl) ?: run { logger.warn("[XuqmLog] Skip sourcemap upload: SDK config unavailable"); return }
|
val config = fetchConfig(configUrl)
|
||||||
|
?: error("BugCollect platform config unavailable; production mapping upload cannot be verified")
|
||||||
|
|
||||||
if (config.bugCollectEnabled != true) {
|
if (config.bugCollectEnabled != true) {
|
||||||
logger.lifecycle("[XuqmLog] BugCollect not enabled for appKey=$key, skip sourcemap upload")
|
logger.lifecycle("[XuqmLog] BugCollect not enabled for appKey=$key, skip R8 mapping upload")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
val bugCollectApiUrl = config.bugCollectApiUrl ?: run { logger.warn("[XuqmLog] bugCollectApiUrl missing in config, skip upload"); return }
|
val bugCollectApiUrl = config.bugCollectApiUrl ?: run { logger.warn("[XuqmLog] bugCollectApiUrl missing in config, skip upload"); return }
|
||||||
val uploadUrl = "${bugCollectApiUrl.trimEnd('/')}/bugcollect/v1/sourcemaps/upload"
|
val token = apiToken.orNull?.takeIf { it.isNotBlank() }
|
||||||
val bid = buildId.orNull
|
?: error("XUQM_API_TOKEN is required when BugCollect mapping upload is enabled")
|
||||||
uploadFile(uploadUrl, key, file, appVersion.get(), platform.get(), bid)
|
val bid = buildId.orNull?.takeIf { it.isNotBlank() }
|
||||||
logger.lifecycle("[XuqmLog] Mapping uploaded: ${file.name} (android v${appVersion.get()} buildId=$bid)")
|
?: error("buildId is required for R8 mapping upload")
|
||||||
|
val artifactHash = sha256(file)
|
||||||
|
NativeArtifactUploader.uploadR8Mapping(
|
||||||
|
baseUrl = bugCollectApiUrl,
|
||||||
|
appKey = key,
|
||||||
|
file = file,
|
||||||
|
appVersion = appVersion.get(),
|
||||||
|
platform = platform.get(),
|
||||||
|
buildId = bid,
|
||||||
|
artifactHash = artifactHash,
|
||||||
|
apiToken = token,
|
||||||
|
)
|
||||||
|
logger.lifecycle(
|
||||||
|
"[XuqmLog] R8 mapping uploaded: ${file.name} " +
|
||||||
|
"(android v${appVersion.get()} buildId=$bid sha256=$artifactHash)",
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private data class SdkConfig(val bugCollectEnabled: Boolean?, val bugCollectApiUrl: String?)
|
private data class SdkConfig(val bugCollectEnabled: Boolean?, val bugCollectApiUrl: String?)
|
||||||
|
|
||||||
private fun fetchConfig(url: String): SdkConfig? {
|
private fun fetchConfig(url: String): SdkConfig? {
|
||||||
return runCatching {
|
return runCatching {
|
||||||
val conn = URL(url).openConnection() as HttpURLConnection
|
val conn = URI.create(url).toURL().openConnection() as HttpURLConnection
|
||||||
conn.requestMethod = "GET"
|
conn.requestMethod = "GET"
|
||||||
val code = conn.responseCode
|
val code = conn.responseCode
|
||||||
if (code !in 200..299) {
|
if (code !in 200..299) {
|
||||||
@ -62,22 +88,83 @@ abstract class XuqmUploadMappingTask : DefaultTask() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun uploadFile(url: String, appKey: String, file: File, version: String, platform: String, buildId: String?) {
|
private fun sha256(file: File): String {
|
||||||
|
val digest = MessageDigest.getInstance("SHA-256")
|
||||||
|
file.inputStream().buffered().use { input ->
|
||||||
|
val buffer = ByteArray(DEFAULT_BUFFER_SIZE)
|
||||||
|
while (true) {
|
||||||
|
val read = input.read(buffer)
|
||||||
|
if (read < 0) break
|
||||||
|
digest.update(buffer, 0, read)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return digest.digest().joinToString("") { "%02x".format(it) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 原生 R8 mapping 的独立上传契约。
|
||||||
|
*
|
||||||
|
* R8 文本不是 RN Source Map,禁止改名为 `.map` 或复用 module/bundle 身份字段。
|
||||||
|
*/
|
||||||
|
internal object NativeArtifactUploader {
|
||||||
|
private const val ARTIFACT_TYPE = "R8_MAPPING"
|
||||||
|
|
||||||
|
fun uploadR8Mapping(
|
||||||
|
baseUrl: String,
|
||||||
|
appKey: String,
|
||||||
|
file: File,
|
||||||
|
appVersion: String,
|
||||||
|
platform: String,
|
||||||
|
buildId: String,
|
||||||
|
artifactHash: String,
|
||||||
|
apiToken: String,
|
||||||
|
) {
|
||||||
|
require(file.name == "mapping.txt") {
|
||||||
|
"R8 artifact must keep the canonical mapping.txt filename"
|
||||||
|
}
|
||||||
|
require(artifactHash.matches(Regex("^[0-9a-f]{64}$"))) {
|
||||||
|
"artifactHash must be a lowercase SHA-256 digest"
|
||||||
|
}
|
||||||
|
require(apiToken.isNotBlank()) { "XUQM_API_TOKEN is required" }
|
||||||
|
val url = "${baseUrl.trimEnd('/')}/bugcollect/v1/artifacts/upload"
|
||||||
val boundary = "XuqmBoundary${System.currentTimeMillis()}"
|
val boundary = "XuqmBoundary${System.currentTimeMillis()}"
|
||||||
val conn = URL(url).openConnection() as HttpURLConnection
|
val conn = URI.create(url).toURL().openConnection() as HttpURLConnection
|
||||||
conn.requestMethod = "POST"
|
conn.requestMethod = "POST"
|
||||||
|
conn.connectTimeout = 10_000
|
||||||
|
conn.readTimeout = 30_000
|
||||||
conn.doOutput = true
|
conn.doOutput = true
|
||||||
|
conn.setRequestProperty("Authorization", "Bearer $apiToken")
|
||||||
conn.setRequestProperty("Content-Type", "multipart/form-data; boundary=$boundary")
|
conn.setRequestProperty("Content-Type", "multipart/form-data; boundary=$boundary")
|
||||||
conn.outputStream.use { out ->
|
conn.outputStream.use { out ->
|
||||||
fun field(name: String, value: String) =
|
fun field(name: String, value: String) =
|
||||||
out.write("--$boundary\r\nContent-Disposition: form-data; name=\"$name\"\r\n\r\n$value\r\n".toByteArray())
|
out.write("--$boundary\r\nContent-Disposition: form-data; name=\"$name\"\r\n\r\n$value\r\n".toByteArray())
|
||||||
field("appKey", appKey); field("platform", platform); field("appVersion", version)
|
field("artifactType", ARTIFACT_TYPE)
|
||||||
if (!buildId.isNullOrEmpty()) field("buildId", buildId)
|
field("appKey", appKey)
|
||||||
val uploadName = if (file.name.endsWith(".map")) file.name else "${file.nameWithoutExtension}.map"
|
field("platform", platform)
|
||||||
out.write("--$boundary\r\nContent-Disposition: form-data; name=\"file\"; filename=\"$uploadName\"\r\nContent-Type: text/plain\r\n\r\n".toByteArray())
|
field("appVersion", appVersion)
|
||||||
|
field("buildId", buildId)
|
||||||
|
field("artifactHash", artifactHash)
|
||||||
|
out.write(
|
||||||
|
(
|
||||||
|
"--$boundary\r\nContent-Disposition: form-data; name=\"file\"; " +
|
||||||
|
"filename=\"mapping.txt\"\r\nContent-Type: text/plain\r\n\r\n"
|
||||||
|
).toByteArray(),
|
||||||
|
)
|
||||||
out.write(file.readBytes())
|
out.write(file.readBytes())
|
||||||
out.write("\r\n--$boundary--\r\n".toByteArray())
|
out.write("\r\n--$boundary--\r\n".toByteArray())
|
||||||
}
|
}
|
||||||
check(conn.responseCode in 200..299) { "Upload failed: HTTP ${conn.responseCode}" }
|
try {
|
||||||
|
val code = conn.responseCode
|
||||||
|
if (code !in 200..299) {
|
||||||
|
val response = conn.errorStream?.bufferedReader()?.use { it.readText() }.orEmpty()
|
||||||
|
throw GradleException(
|
||||||
|
"R8 mapping upload failed: HTTP $code" +
|
||||||
|
response.takeIf { it.isNotBlank() }?.let { " — $it" }.orEmpty(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
conn.disconnect()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,93 @@
|
|||||||
|
package com.xuqm.sdk.bugcollect.gradle
|
||||||
|
|
||||||
|
import com.sun.net.httpserver.HttpServer
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertFalse
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Assert.assertThrows
|
||||||
|
import org.junit.Test
|
||||||
|
import java.net.InetSocketAddress
|
||||||
|
import java.nio.file.Files
|
||||||
|
import java.security.MessageDigest
|
||||||
|
import java.util.concurrent.atomic.AtomicReference
|
||||||
|
|
||||||
|
class NativeArtifactUploaderTest {
|
||||||
|
@Test
|
||||||
|
fun `R8 mapping uses native artifact identity and keeps txt filename`() {
|
||||||
|
val requestPath = AtomicReference<String>()
|
||||||
|
val requestBody = AtomicReference<String>()
|
||||||
|
val authorization = AtomicReference<String>()
|
||||||
|
val server = HttpServer.create(InetSocketAddress("127.0.0.1", 0), 0).apply {
|
||||||
|
createContext("/bugcollect/v1/artifacts/upload") { exchange ->
|
||||||
|
requestPath.set(exchange.requestURI.path)
|
||||||
|
authorization.set(exchange.requestHeaders.getFirst("Authorization"))
|
||||||
|
requestBody.set(exchange.requestBody.readBytes().toString(Charsets.UTF_8))
|
||||||
|
exchange.sendResponseHeaders(200, 0)
|
||||||
|
exchange.responseBody.use { it.write("""{"code":0}""".toByteArray()) }
|
||||||
|
}
|
||||||
|
start()
|
||||||
|
}
|
||||||
|
val tempDirectory = Files.createTempDirectory("xuqm-r8-").toFile()
|
||||||
|
val mapping = tempDirectory.resolve("mapping.txt").apply {
|
||||||
|
writeText("com.example.Real -> a:")
|
||||||
|
}
|
||||||
|
val hash = MessageDigest.getInstance("SHA-256")
|
||||||
|
.digest(mapping.readBytes())
|
||||||
|
.joinToString("") { "%02x".format(it) }
|
||||||
|
|
||||||
|
try {
|
||||||
|
NativeArtifactUploader.uploadR8Mapping(
|
||||||
|
baseUrl = "http://127.0.0.1:${server.address.port}",
|
||||||
|
appKey = "ak_test",
|
||||||
|
file = mapping,
|
||||||
|
appVersion = "8.0.0",
|
||||||
|
platform = "android",
|
||||||
|
buildId = "20260726123456",
|
||||||
|
artifactHash = hash,
|
||||||
|
apiToken = "test-api-token",
|
||||||
|
)
|
||||||
|
} finally {
|
||||||
|
server.stop(0)
|
||||||
|
mapping.delete()
|
||||||
|
tempDirectory.delete()
|
||||||
|
}
|
||||||
|
|
||||||
|
val body = requestBody.get()
|
||||||
|
assertEquals("/bugcollect/v1/artifacts/upload", requestPath.get())
|
||||||
|
assertEquals("Bearer test-api-token", authorization.get())
|
||||||
|
assertTrue(body.contains("name=\"artifactType\"\r\n\r\nR8_MAPPING"))
|
||||||
|
assertTrue(body.contains("name=\"appKey\"\r\n\r\nak_test"))
|
||||||
|
assertTrue(body.contains("name=\"platform\"\r\n\r\nandroid"))
|
||||||
|
assertTrue(body.contains("name=\"appVersion\"\r\n\r\n8.0.0"))
|
||||||
|
assertTrue(body.contains("name=\"buildId\"\r\n\r\n20260726123456"))
|
||||||
|
assertTrue(body.contains("name=\"artifactHash\"\r\n\r\n$hash"))
|
||||||
|
assertTrue(body.contains("filename=\"mapping.txt\""))
|
||||||
|
assertFalse(body.contains("filename=\"mapping.map\""))
|
||||||
|
assertFalse(body.contains("name=\"moduleId\""))
|
||||||
|
assertFalse(body.contains("name=\"bundleHash\""))
|
||||||
|
assertFalse(body.contains("test-api-token"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `missing token is rejected before network access`() {
|
||||||
|
val tempDirectory = Files.createTempDirectory("xuqm-r8-token-").toFile()
|
||||||
|
val mapping = tempDirectory.resolve("mapping.txt").apply { writeText("mapping") }
|
||||||
|
try {
|
||||||
|
assertThrows(IllegalArgumentException::class.java) {
|
||||||
|
NativeArtifactUploader.uploadR8Mapping(
|
||||||
|
baseUrl = "http://127.0.0.1:1",
|
||||||
|
appKey = "ak_test",
|
||||||
|
file = mapping,
|
||||||
|
appVersion = "8.0.0",
|
||||||
|
platform = "android",
|
||||||
|
buildId = "build",
|
||||||
|
artifactHash = "0".repeat(64),
|
||||||
|
apiToken = "",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
mapping.delete()
|
||||||
|
tempDirectory.delete()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -9,13 +9,18 @@ implementation("com.xuqm:sdk-bugcollect:VERSION")
|
|||||||
implementation("com.xuqm:sdk-core:VERSION") // 必须
|
implementation("com.xuqm:sdk-core:VERSION") // 必须
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Release 构建先应用 `com.xuqm.common`,开启 R8 mapping 上传时再应用
|
||||||
|
`com.xuqm.bugcollect`。配置验证与 buildId 只由 common 插件负责,BugCollect 插件
|
||||||
|
不会维护第二套配置路径或初始化定义。Debug 固定关闭自动采集与上传;隐藏开发测试
|
||||||
|
入口仅允许在可调试、非应急禁用、隐私已同意且平台已开通时显式发送一次,不改变自动状态。
|
||||||
|
|
||||||
## 快速开始
|
## 快速开始
|
||||||
|
|
||||||
```kotlin
|
```kotlin
|
||||||
// Application.onCreate() 中(XuqmSDK.initialize 之后):
|
// 宿主取得隐私同意后同步一次;撤回时传 false。
|
||||||
|
XuqmSDK.setPrivacyConsent(true)
|
||||||
BugCollect.setLogLevel(LogLevel.INFO)
|
BugCollect.setLogLevel(LogLevel.INFO)
|
||||||
BugCollect.setEnvironment("production")
|
BugCollect.setEnvironment("production")
|
||||||
BugCollect.startCrashCapture()
|
|
||||||
|
|
||||||
// 业务代码中
|
// 业务代码中
|
||||||
BugCollect.event("page_view", mapOf("page" to "home"))
|
BugCollect.event("page_view", mapOf("page" to "home"))
|
||||||
@ -30,7 +35,6 @@ BugCollect.captureError(exception)
|
|||||||
|-----|------|
|
|-----|------|
|
||||||
| `BugCollect.setLogLevel(level)` | 设置日志级别(`DEBUG` / `INFO` / `WARN` / `ERROR`) |
|
| `BugCollect.setLogLevel(level)` | 设置日志级别(`DEBUG` / `INFO` / `WARN` / `ERROR`) |
|
||||||
| `BugCollect.setEnvironment(env)` | 设置环境标签 |
|
| `BugCollect.setEnvironment(env)` | 设置环境标签 |
|
||||||
| `BugCollect.startCrashCapture()` | 开启全局 UncaughtExceptionHandler |
|
|
||||||
| `BugCollect.event(name, properties?)` | 记录自定义事件 |
|
| `BugCollect.event(name, properties?)` | 记录自定义事件 |
|
||||||
| `BugCollect.captureError(error, metadata?)` | 上报异常 |
|
| `BugCollect.captureError(error, metadata?)` | 上报异常 |
|
||||||
| `BugCollect.warn(message, metadata?)` | 记录警告 |
|
| `BugCollect.warn(message, metadata?)` | 记录警告 |
|
||||||
@ -45,11 +49,18 @@ enum class LogLevel { DEBUG, INFO, WARN, ERROR, NONE }
|
|||||||
|
|
||||||
### 工作原理
|
### 工作原理
|
||||||
|
|
||||||
- **LogQueue**:事件进入内存队列,按批次异步上传到 `bugCollectApiUrl`
|
- **运行门禁**:仅在平台启用且宿主已同意隐私时注册采集器
|
||||||
- **CrashCapture**:注册 `Thread.UncaughtExceptionHandler`,捕获 Native Crash 并写入文件,下次启动时上传
|
- **LogQueue**:error/fatal 加密持久化,普通事件仅驻留内存;最多 500 条、保留 7 天
|
||||||
|
- **CrashCapture**:捕获未处理 Java/Kotlin 异常,加密保存后在下次启动上传
|
||||||
- **Fingerprint**:为错误生成指纹(基于 message + stack),服务端去重聚合
|
- **Fingerprint**:为错误生成指纹(基于 message + stack),服务端去重聚合
|
||||||
- **FunnelTracker**:客户端维护漏斗进度,服务端跨 session 聚合
|
- **FunnelTracker**:客户端维护漏斗进度,服务端跨 session 聚合
|
||||||
|
|
||||||
### 配置
|
### 配置
|
||||||
|
|
||||||
`bugCollectApiUrl` 和 `bugCollectEnabled` 由 `sdk-core` 在 init 后从平台配置自动获取,无需 App 传入。
|
`bugCollectApiUrl` 和 `bugCollectEnabled` 由 `sdk-core` 在 init 后从平台配置自动获取,无需 App 传入。
|
||||||
|
后台上传失败采用带随机抖动的指数退避,最多尝试 3 次,不会向宿主主线程抛错。
|
||||||
|
服务端返回固定业务码 `BUGCOLLECT_DISABLED` 时立即停止采集、清除队列,直到平台配置明确重新启用。
|
||||||
|
|
||||||
|
Release 的 R8 mapping 上传由 Gradle 插件从 `XUQM_API_TOKEN` Gradle 属性或同名环境变量
|
||||||
|
取得 Bearer 令牌。令牌不得写入 `config.xuqmconfig`、源码或日志。平台启用 BugCollect
|
||||||
|
但构建机未提供令牌时 Release 失败;`-Pxuqm.bugcollect=disabled` 应急构建不上传且不要求令牌。
|
||||||
|
|||||||
@ -52,6 +52,8 @@ afterEvaluate {
|
|||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
implementation(project(":sdk-core"))
|
implementation(project(":sdk-core"))
|
||||||
implementation("com.squareup.okhttp3:okhttp:4.12.0")
|
implementation(platform(libs.okhttp.bom))
|
||||||
implementation("com.google.code.gson:gson:2.10.1")
|
implementation(libs.okhttp)
|
||||||
|
implementation(libs.gson)
|
||||||
|
testImplementation(libs.junit4)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,6 +3,10 @@
|
|||||||
public *;
|
public *;
|
||||||
static <fields>;
|
static <fields>;
|
||||||
}
|
}
|
||||||
|
-keepclassmembers class com.xuqm.sdk.bugcollect.BugCollect {
|
||||||
|
private void onSdkConfigurationChanged();
|
||||||
|
private void onSdkLogout();
|
||||||
|
}
|
||||||
-keep public class com.xuqm.sdk.bugcollect.LogLevel { public *; }
|
-keep public class com.xuqm.sdk.bugcollect.LogLevel { public *; }
|
||||||
-keep public class com.xuqm.sdk.bugcollect.Breadcrumb { public *; }
|
-keep public class com.xuqm.sdk.bugcollect.Breadcrumb { public *; }
|
||||||
-keep public class com.xuqm.sdk.bugcollect.FunnelTracker { public *; }
|
-keep public class com.xuqm.sdk.bugcollect.FunnelTracker { public *; }
|
||||||
@ -10,7 +14,6 @@
|
|||||||
-keep public class com.xuqm.sdk.bugcollect.LogEvent { *; }
|
-keep public class com.xuqm.sdk.bugcollect.LogEvent { *; }
|
||||||
|
|
||||||
# ContentProvider: class name referenced in AndroidManifest — must not be renamed
|
# ContentProvider: class name referenced in AndroidManifest — must not be renamed
|
||||||
-keep class com.xuqm.sdk.bugcollect.internal.BugCollectInitProvider
|
|
||||||
|
|
||||||
-keepattributes *Annotation*, Signature, InnerClasses, EnclosingMethod
|
-keepattributes *Annotation*, Signature, InnerClasses, EnclosingMethod
|
||||||
|
|
||||||
|
|||||||
@ -1,13 +1,10 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
<application>
|
<application>
|
||||||
<!--
|
<provider
|
||||||
Build ID injected by the XuqmGroup BugCollect Gradle plugin at compile time.
|
android:name="com.xuqm.sdk.internal.XuqmMergedProvider"
|
||||||
The plugin fills ${xuqmBuildId} via variant.manifestPlaceholders.
|
android:authorities="${applicationId}.xuqm-init"
|
||||||
SDK reads it via PackageManager.getApplicationInfo(GET_META_DATA).
|
android:exported="false"
|
||||||
-->
|
android:initOrder="50" />
|
||||||
<meta-data
|
|
||||||
android:name="com.xuqm.build_id"
|
|
||||||
android:value="${xuqmBuildId}" />
|
|
||||||
</application>
|
</application>
|
||||||
</manifest>
|
</manifest>
|
||||||
|
|||||||
@ -5,13 +5,16 @@ import android.content.Context
|
|||||||
import android.content.pm.ApplicationInfo
|
import android.content.pm.ApplicationInfo
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import com.xuqm.sdk.XuqmSDK
|
import com.xuqm.sdk.XuqmSDK
|
||||||
|
import com.xuqm.sdk.bugcollect.internal.DataSanitizer
|
||||||
|
import com.xuqm.sdk.bugcollect.internal.DeveloperBugCollectPolicy
|
||||||
|
import com.xuqm.sdk.bugcollect.internal.LogUploader
|
||||||
|
import com.xuqm.sdk.bugcollect.internal.UploadResult
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.GlobalScope
|
|
||||||
import kotlinx.coroutines.SupervisorJob
|
import kotlinx.coroutines.SupervisorJob
|
||||||
import kotlinx.coroutines.delay
|
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import java.util.LinkedList
|
import java.util.LinkedList
|
||||||
|
import java.util.concurrent.atomic.AtomicBoolean
|
||||||
|
|
||||||
object BugCollect {
|
object BugCollect {
|
||||||
|
|
||||||
@ -19,12 +22,11 @@ object BugCollect {
|
|||||||
private var environment: String = "production"
|
private var environment: String = "production"
|
||||||
@Volatile private var queue: LogQueue? = null
|
@Volatile private var queue: LogQueue? = null
|
||||||
@Volatile private var crashCaptureStarted = false
|
@Volatile private var crashCaptureStarted = false
|
||||||
|
private val developerScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||||
|
private val developerTestSent = AtomicBoolean(false)
|
||||||
|
|
||||||
private val breadcrumbBuffer: LinkedList<Breadcrumb> = LinkedList()
|
private val breadcrumbBuffer: LinkedList<Breadcrumb> = LinkedList()
|
||||||
private val breadcrumbLock = Any()
|
private val breadcrumbLock = Any()
|
||||||
// SDK 未就绪时暂存的错误事件,就绪后自动 flush
|
|
||||||
private val pendingErrors = LinkedList<IssueEvent>()
|
|
||||||
private val pendingErrorsLock = Any()
|
|
||||||
private const val MAX_BREADCRUMBS = 50
|
private const val MAX_BREADCRUMBS = 50
|
||||||
private const val SDK_NAME = "bugcollect.android"
|
private const val SDK_NAME = "bugcollect.android"
|
||||||
private const val SDK_VERSION = "1.1.0"
|
private const val SDK_VERSION = "1.1.0"
|
||||||
@ -38,22 +40,23 @@ object BugCollect {
|
|||||||
level: String = "info",
|
level: String = "info",
|
||||||
data: Map<String, Any?> = emptyMap(),
|
data: Map<String, Any?> = emptyMap(),
|
||||||
) {
|
) {
|
||||||
|
if (!isReady()) return
|
||||||
synchronized(breadcrumbLock) {
|
synchronized(breadcrumbLock) {
|
||||||
if (breadcrumbBuffer.size >= MAX_BREADCRUMBS) breadcrumbBuffer.removeFirst()
|
if (breadcrumbBuffer.size >= MAX_BREADCRUMBS) breadcrumbBuffer.removeFirst()
|
||||||
breadcrumbBuffer.addLast(Breadcrumb(
|
breadcrumbBuffer.addLast(Breadcrumb(
|
||||||
timestamp = System.currentTimeMillis(),
|
timestamp = System.currentTimeMillis(),
|
||||||
category = category,
|
category = category,
|
||||||
message = message,
|
message = DataSanitizer.sanitizeText(message),
|
||||||
level = level,
|
level = level,
|
||||||
data = data,
|
data = DataSanitizer.sanitizeMap(data),
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun event(name: String, properties: Map<String, Any?> = emptyMap()) {
|
fun event(name: String, properties: Map<String, Any?> = emptyMap()) {
|
||||||
if (!isReady()) return
|
if (!isReady()) return
|
||||||
queue().push(LogEvent(
|
enqueue(LogEvent(
|
||||||
name = name, properties = properties,
|
name = name, properties = DataSanitizer.sanitizeMap(properties),
|
||||||
appKey = XuqmSDK.appKey, userId = XuqmSDK.getUserId() ?: XuqmSDK.deviceId,
|
appKey = XuqmSDK.appKey, userId = XuqmSDK.getUserId() ?: XuqmSDK.deviceId,
|
||||||
platform = "android", release = appVersion(), environment = environment,
|
platform = "android", release = appVersion(), environment = environment,
|
||||||
sdk = IssueEvent.SdkInfo(SDK_NAME, SDK_VERSION),
|
sdk = IssueEvent.SdkInfo(SDK_NAME, SDK_VERSION),
|
||||||
@ -62,40 +65,14 @@ object BugCollect {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun captureError(error: Throwable, tags: Map<String, Any?> = emptyMap(), fingerprint: String? = null) {
|
fun captureError(error: Throwable, tags: Map<String, Any?> = emptyMap(), fingerprint: String? = null) {
|
||||||
// SDK 未初始化时,将错误加入待处理队列
|
// 隐私授权、平台开关和初始化状态任一未满足时均不得采集或暂存。
|
||||||
if (!XuqmSDK.isInitialized()) {
|
if (!isReady()) return
|
||||||
val event = IssueEvent(
|
|
||||||
level = "error",
|
|
||||||
platform = "android",
|
|
||||||
fingerprint = fingerprint ?: Fingerprint.compute(
|
|
||||||
error.javaClass.simpleName,
|
|
||||||
error.message ?: error.javaClass.name,
|
|
||||||
error.stackTraceToString(),
|
|
||||||
),
|
|
||||||
appKey = "pending",
|
|
||||||
exception = IssueEvent.ExceptionInfo(
|
|
||||||
type = error.javaClass.simpleName,
|
|
||||||
value = error.message ?: error.javaClass.name,
|
|
||||||
stacktrace = error.stackTraceToString(),
|
|
||||||
),
|
|
||||||
breadcrumbs = emptyList(),
|
|
||||||
release = appVersion(),
|
|
||||||
buildId = buildId(),
|
|
||||||
environment = environment,
|
|
||||||
userId = "unknown",
|
|
||||||
user = IssueEvent.UserInfo(id = "unknown"),
|
|
||||||
device = buildDeviceInfo(),
|
|
||||||
tags = tags,
|
|
||||||
)
|
|
||||||
synchronized(pendingErrorsLock) { pendingErrors.add(event) }
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
val crumbs = synchronized(breadcrumbLock) { breadcrumbBuffer.toList() }
|
val crumbs = synchronized(breadcrumbLock) { breadcrumbBuffer.toList() }
|
||||||
val fp = fingerprint ?: Fingerprint.compute(
|
val fp = fingerprint ?: Fingerprint.compute(
|
||||||
error.javaClass.simpleName,
|
error.javaClass.simpleName,
|
||||||
error.message ?: error.javaClass.name,
|
DataSanitizer.sanitizeText(error.message ?: error.javaClass.name),
|
||||||
error.stackTraceToString(),
|
DataSanitizer.sanitizeText(error.stackTraceToString()),
|
||||||
)
|
)
|
||||||
val event = IssueEvent(
|
val event = IssueEvent(
|
||||||
level = "error",
|
level = "error",
|
||||||
@ -104,8 +81,8 @@ object BugCollect {
|
|||||||
appKey = XuqmSDK.appKey,
|
appKey = XuqmSDK.appKey,
|
||||||
exception = IssueEvent.ExceptionInfo(
|
exception = IssueEvent.ExceptionInfo(
|
||||||
type = error.javaClass.simpleName,
|
type = error.javaClass.simpleName,
|
||||||
value = error.message ?: error.javaClass.name,
|
value = DataSanitizer.sanitizeText(error.message ?: error.javaClass.name),
|
||||||
stacktrace = error.stackTraceToString(),
|
stacktrace = DataSanitizer.sanitizeText(error.stackTraceToString()),
|
||||||
),
|
),
|
||||||
breadcrumbs = crumbs,
|
breadcrumbs = crumbs,
|
||||||
release = appVersion(),
|
release = appVersion(),
|
||||||
@ -114,33 +91,79 @@ object BugCollect {
|
|||||||
userId = XuqmSDK.getUserId() ?: XuqmSDK.deviceId,
|
userId = XuqmSDK.getUserId() ?: XuqmSDK.deviceId,
|
||||||
user = IssueEvent.UserInfo(id = XuqmSDK.getUserId() ?: XuqmSDK.deviceId),
|
user = IssueEvent.UserInfo(id = XuqmSDK.getUserId() ?: XuqmSDK.deviceId),
|
||||||
device = buildDeviceInfo(),
|
device = buildDeviceInfo(),
|
||||||
tags = tags,
|
tags = DataSanitizer.sanitizeMap(tags),
|
||||||
)
|
)
|
||||||
if (!isReady()) {
|
enqueue(event)
|
||||||
synchronized(pendingErrorsLock) { pendingErrors.add(event) }
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
flushPendingErrors()
|
|
||||||
queue().push(event)
|
/**
|
||||||
|
* Debug 宿主显式发送一次开发验证事件。
|
||||||
|
*
|
||||||
|
* 该入口不会开启自动采集,仅在可调试宿主、非应急禁用构建、隐私已同意且平台已开通
|
||||||
|
* BugCollect 时调度一次发送。它通过 JvmSynthetic 隐藏于 Java 公共 API,也不进入
|
||||||
|
* 对外集成文档。
|
||||||
|
*/
|
||||||
|
@JvmSynthetic
|
||||||
|
fun sendDeveloperTestOnce(message: String = "BugCollect developer test"): Boolean {
|
||||||
|
if (!XuqmSDK.isInitialized()) return false
|
||||||
|
val context = runCatching { XuqmSDK.appContext }.getOrNull() ?: return false
|
||||||
|
val applicationInfo = runCatching {
|
||||||
|
context.packageManager.getApplicationInfo(
|
||||||
|
context.packageName,
|
||||||
|
android.content.pm.PackageManager.GET_META_DATA,
|
||||||
|
)
|
||||||
|
}.getOrNull() ?: return false
|
||||||
|
val uploadEndpoint = XuqmSDK.bugCollectApiUrl?.takeIf(String::isNotBlank)
|
||||||
|
val allowed = DeveloperBugCollectPolicy.isAllowed(
|
||||||
|
debuggable = applicationInfo.flags and ApplicationInfo.FLAG_DEBUGGABLE != 0,
|
||||||
|
buildEnabled = applicationInfo.metaData
|
||||||
|
?.getBoolean("com.xuqm.bugcollect_build_enabled", false) == true,
|
||||||
|
privacyGranted = XuqmSDK.privacyConsentGranted,
|
||||||
|
platformEnabled = XuqmSDK.platformConfig?.features?.bugCollect == true,
|
||||||
|
hasUploadEndpoint = uploadEndpoint != null,
|
||||||
|
)
|
||||||
|
if (!allowed || !developerTestSent.compareAndSet(false, true)) return false
|
||||||
|
|
||||||
|
val safeMessage = DataSanitizer.sanitizeText(message)
|
||||||
|
val event = IssueEvent(
|
||||||
|
level = "info",
|
||||||
|
platform = "android",
|
||||||
|
fingerprint = Fingerprint.compute("DeveloperTest", safeMessage, ""),
|
||||||
|
appKey = XuqmSDK.appKey,
|
||||||
|
exception = IssueEvent.ExceptionInfo("DeveloperTest", safeMessage),
|
||||||
|
release = appVersion(),
|
||||||
|
buildId = buildId(),
|
||||||
|
environment = environment,
|
||||||
|
userId = XuqmSDK.getUserId() ?: XuqmSDK.deviceId,
|
||||||
|
user = IssueEvent.UserInfo(XuqmSDK.getUserId() ?: XuqmSDK.deviceId),
|
||||||
|
device = buildDeviceInfo(),
|
||||||
|
tags = mapOf("developerTest" to true),
|
||||||
|
)
|
||||||
|
developerScope.launch {
|
||||||
|
if (LogUploader.uploadIssue(requireNotNull(uploadEndpoint), event) == UploadResult.Disabled) {
|
||||||
|
disableFromServer()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
fun captureCrash(error: Throwable, tags: Map<String, Any?> = emptyMap()) {
|
fun captureCrash(error: Throwable, tags: Map<String, Any?> = emptyMap()) {
|
||||||
// SDK 未初始化时直接返回,崩溃事件无法缓存
|
// SDK 未初始化时直接返回,崩溃事件无法缓存
|
||||||
if (!XuqmSDK.isInitialized() || !isReady()) return
|
if (!XuqmSDK.isInitialized() || !isReady()) return
|
||||||
val crumbs = synchronized(breadcrumbLock) { breadcrumbBuffer.toList() }
|
val crumbs = synchronized(breadcrumbLock) { breadcrumbBuffer.toList() }
|
||||||
queue().push(IssueEvent(
|
enqueue(IssueEvent(
|
||||||
level = "fatal",
|
level = "fatal",
|
||||||
platform = "android",
|
platform = "android",
|
||||||
fingerprint = Fingerprint.compute(
|
fingerprint = Fingerprint.compute(
|
||||||
error.javaClass.simpleName,
|
error.javaClass.simpleName,
|
||||||
error.message ?: error.javaClass.name,
|
DataSanitizer.sanitizeText(error.message ?: error.javaClass.name),
|
||||||
error.stackTraceToString(),
|
DataSanitizer.sanitizeText(error.stackTraceToString()),
|
||||||
),
|
),
|
||||||
appKey = XuqmSDK.appKey,
|
appKey = XuqmSDK.appKey,
|
||||||
exception = IssueEvent.ExceptionInfo(
|
exception = IssueEvent.ExceptionInfo(
|
||||||
type = error.javaClass.simpleName,
|
type = error.javaClass.simpleName,
|
||||||
value = error.message ?: error.javaClass.name,
|
value = DataSanitizer.sanitizeText(error.message ?: error.javaClass.name),
|
||||||
stacktrace = error.stackTraceToString(),
|
stacktrace = DataSanitizer.sanitizeText(error.stackTraceToString()),
|
||||||
),
|
),
|
||||||
breadcrumbs = crumbs,
|
breadcrumbs = crumbs,
|
||||||
release = appVersion(),
|
release = appVersion(),
|
||||||
@ -149,25 +172,28 @@ object BugCollect {
|
|||||||
userId = XuqmSDK.getUserId() ?: XuqmSDK.deviceId,
|
userId = XuqmSDK.getUserId() ?: XuqmSDK.deviceId,
|
||||||
user = IssueEvent.UserInfo(id = XuqmSDK.getUserId() ?: XuqmSDK.deviceId),
|
user = IssueEvent.UserInfo(id = XuqmSDK.getUserId() ?: XuqmSDK.deviceId),
|
||||||
device = buildDeviceInfo(),
|
device = buildDeviceInfo(),
|
||||||
tags = tags,
|
tags = DataSanitizer.sanitizeMap(tags),
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
fun warn(message: String, tags: Map<String, Any?> = emptyMap()) {
|
fun warn(message: String, tags: Map<String, Any?> = emptyMap()) {
|
||||||
if (logLevel.ordinal > LogLevel.WARN.ordinal) return
|
if (logLevel.ordinal > LogLevel.WARN.ordinal) return
|
||||||
if (!isReady()) return
|
if (!isReady()) return
|
||||||
queue().push(IssueEvent(
|
enqueue(IssueEvent(
|
||||||
level = "warning",
|
level = "warning",
|
||||||
platform = "android",
|
platform = "android",
|
||||||
fingerprint = Fingerprint.compute("Warning", message, ""),
|
fingerprint = Fingerprint.compute("Warning", DataSanitizer.sanitizeText(message), ""),
|
||||||
appKey = XuqmSDK.appKey,
|
appKey = XuqmSDK.appKey,
|
||||||
exception = IssueEvent.ExceptionInfo(type = "Warning", value = message),
|
exception = IssueEvent.ExceptionInfo(
|
||||||
|
type = "Warning",
|
||||||
|
value = DataSanitizer.sanitizeText(message),
|
||||||
|
),
|
||||||
release = appVersion(),
|
release = appVersion(),
|
||||||
buildId = buildId(),
|
buildId = buildId(),
|
||||||
environment = environment,
|
environment = environment,
|
||||||
userId = XuqmSDK.getUserId() ?: XuqmSDK.deviceId,
|
userId = XuqmSDK.getUserId() ?: XuqmSDK.deviceId,
|
||||||
user = IssueEvent.UserInfo(id = XuqmSDK.getUserId() ?: XuqmSDK.deviceId),
|
user = IssueEvent.UserInfo(id = XuqmSDK.getUserId() ?: XuqmSDK.deviceId),
|
||||||
tags = tags,
|
tags = DataSanitizer.sanitizeMap(tags),
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -176,73 +202,59 @@ object BugCollect {
|
|||||||
event("__log_info", tags + ("message" to message))
|
event("__log_info", tags + ("message" to message))
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** 注册全局崩溃拦截器(幂等);未满足运行条件时不会启动。 */
|
||||||
* 注册全局崩溃拦截器(幂等)。
|
|
||||||
* 通常由 BugCollectInitProvider 在 app 启动时自动调用,无需 app 代码主动触发。
|
|
||||||
* 如需在特定时机手动注册,调用此方法也是安全的。
|
|
||||||
*/
|
|
||||||
fun startCrashCapture() {
|
fun startCrashCapture() {
|
||||||
if (crashCaptureStarted) return
|
if (crashCaptureStarted) return
|
||||||
if (!XuqmSDK.isInitialized()) return
|
if (!isReady()) return
|
||||||
crashCaptureStarted = true
|
runCatching {
|
||||||
CrashCapture.start(
|
CrashCapture.start(
|
||||||
appContext = XuqmSDK.appContext,
|
appContext = XuqmSDK.appContext,
|
||||||
appKey = XuqmSDK.appKey,
|
appKey = XuqmSDK.appKey,
|
||||||
getUserId = { XuqmSDK.getUserId() ?: XuqmSDK.deviceId },
|
getUserId = { XuqmSDK.getUserId() ?: XuqmSDK.deviceId },
|
||||||
)
|
)
|
||||||
flushPendingErrors()
|
crashCaptureStarted = true
|
||||||
queue().uploadPendingCrashes()
|
queue().uploadPendingCrashes()
|
||||||
// 平台配置可能在初始化之后才下发,延迟重试 flush
|
}.onFailure {
|
||||||
GlobalScope.launch(Dispatchers.IO) {
|
CrashCapture.stop()
|
||||||
for (i in 1..10) {
|
crashCaptureStarted = false
|
||||||
delay(2000)
|
|
||||||
if (!XuqmSDK.bugCollectApiUrl.isNullOrBlank()) {
|
|
||||||
flushPendingErrors()
|
|
||||||
queue().uploadPendingCrashes()
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 将 SDK 未就绪时暂存的错误事件 flush 到队列。
|
/**
|
||||||
*
|
* sdk-core 在平台配置或隐私授权变化后调用的唯一状态同步入口。
|
||||||
* 早于 SDK 初始化完成前调用 captureError 时,事件以 appKey="pending"、userId="unknown"
|
* 停用时恢复宿主原有崩溃处理器,并清除所有未上传数据。
|
||||||
* 占位存入 pendingErrors。flush 时用真实值替换,避免服务端因找不到应用而丢弃事件。
|
|
||||||
*/
|
*/
|
||||||
private fun flushPendingErrors() {
|
private fun onSdkConfigurationChanged() {
|
||||||
val events = synchronized(pendingErrorsLock) {
|
if (isReady()) {
|
||||||
if (pendingErrors.isEmpty()) return
|
startCrashCapture()
|
||||||
val list = pendingErrors.toList()
|
return
|
||||||
pendingErrors.clear()
|
|
||||||
list
|
|
||||||
}
|
|
||||||
val realAppKey = if (XuqmSDK.isInitialized()) XuqmSDK.appKey else return
|
|
||||||
val realUserId = XuqmSDK.getUserId() ?: XuqmSDK.deviceId
|
|
||||||
events.forEach { event ->
|
|
||||||
val fixed = if (event.appKey == "pending") {
|
|
||||||
event.copy(
|
|
||||||
appKey = realAppKey,
|
|
||||||
userId = realUserId,
|
|
||||||
user = IssueEvent.UserInfo(id = realUserId),
|
|
||||||
release = appVersion(),
|
|
||||||
buildId = buildId(),
|
|
||||||
)
|
|
||||||
} else event
|
|
||||||
queue().push(fixed)
|
|
||||||
}
|
}
|
||||||
|
CrashCapture.stop()
|
||||||
|
crashCaptureStarted = false
|
||||||
|
synchronized(breadcrumbLock) { breadcrumbBuffer.clear() }
|
||||||
|
queue?.stopAndClear()
|
||||||
|
queue = null
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 上传上次崩溃落盘的文件(远端配置就绪后由 BugCollectInitProvider 自动调用)。 */
|
/** 退出登录后保留匿名诊断,但移除队列中与账号相关的身份字段。 */
|
||||||
|
private fun onSdkLogout() {
|
||||||
|
queue?.clearUserContext()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 上传上次崩溃的加密持久化记录。 */
|
||||||
fun uploadPendingCrashes() {
|
fun uploadPendingCrashes() {
|
||||||
if (isReady()) queue().uploadPendingCrashes()
|
if (isReady()) runCatching { queue().uploadPendingCrashes() }
|
||||||
}
|
}
|
||||||
|
|
||||||
fun defineFunnel(id: String, steps: List<String>) {
|
fun defineFunnel(id: String, steps: List<String>) {
|
||||||
FunnelTracker.define(id, steps)
|
FunnelTracker.define(id, steps)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun isReady() = XuqmSDK.isInitialized() && XuqmSDK.bugCollectEnabled
|
private fun isReady() =
|
||||||
|
XuqmSDK.isInitialized() &&
|
||||||
|
XuqmSDK.bugCollectEnabled &&
|
||||||
|
XuqmSDK.privacyConsentGranted &&
|
||||||
|
!XuqmSDK.bugCollectApiUrl.isNullOrBlank()
|
||||||
|
|
||||||
private fun queue(): LogQueue {
|
private fun queue(): LogQueue {
|
||||||
return queue ?: synchronized(this) {
|
return queue ?: synchronized(this) {
|
||||||
@ -253,6 +265,11 @@ object BugCollect {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 采集能力自身的存储异常不得传播给宿主业务调用栈。 */
|
||||||
|
private fun enqueue(event: Any) {
|
||||||
|
runCatching { queue().push(event) }
|
||||||
|
}
|
||||||
|
|
||||||
private fun appVersion(): String = runCatching {
|
private fun appVersion(): String = runCatching {
|
||||||
if (!XuqmSDK.isInitialized()) return "unknown"
|
if (!XuqmSDK.isInitialized()) return "unknown"
|
||||||
val ctx = XuqmSDK.appContext
|
val ctx = XuqmSDK.appContext
|
||||||
@ -304,4 +321,12 @@ object BugCollect {
|
|||||||
buildType = if (isDebug) "debug" else "release",
|
buildType = if (isDebug) "debug" else "release",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun disableFromServer() {
|
||||||
|
runCatching {
|
||||||
|
XuqmSDK::class.java.getDeclaredMethod("disableBugCollectFromExtension")
|
||||||
|
.apply { isAccessible = true }
|
||||||
|
.invoke(XuqmSDK)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -6,15 +6,29 @@ import com.xuqm.sdk.bugcollect.internal.LogStorage
|
|||||||
internal object CrashCapture {
|
internal object CrashCapture {
|
||||||
|
|
||||||
@Volatile private var started = false
|
@Volatile private var started = false
|
||||||
|
private var installedHandler: Thread.UncaughtExceptionHandler? = null
|
||||||
|
private var previousHandler: Thread.UncaughtExceptionHandler? = null
|
||||||
|
|
||||||
/** 注册全局崩溃拦截器,只需 appContext + appKey,不依赖上传 URL。 */
|
/** 注册全局崩溃拦截器,只需 appContext + appKey,不依赖上传 URL。 */
|
||||||
fun start(appContext: Context, appKey: String, getUserId: () -> String?) {
|
fun start(appContext: Context, appKey: String, getUserId: () -> String?) {
|
||||||
if (started) return
|
if (started) return
|
||||||
started = true
|
started = true
|
||||||
val prev = Thread.getDefaultUncaughtExceptionHandler()
|
previousHandler = Thread.getDefaultUncaughtExceptionHandler()
|
||||||
Thread.setDefaultUncaughtExceptionHandler { thread, throwable ->
|
installedHandler = Thread.UncaughtExceptionHandler { thread, throwable ->
|
||||||
LogStorage.saveCrash(appContext, throwable, appKey, getUserId())
|
LogStorage.saveCrash(appContext, throwable, appKey, getUserId())
|
||||||
prev?.uncaughtException(thread, throwable)
|
previousHandler?.uncaughtException(thread, throwable)
|
||||||
}
|
}
|
||||||
|
Thread.setDefaultUncaughtExceptionHandler(installedHandler)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 仅在当前 handler 仍由本 SDK 持有时恢复,避免覆盖宿主后续安装的 handler。 */
|
||||||
|
fun stop() {
|
||||||
|
if (!started) return
|
||||||
|
if (Thread.getDefaultUncaughtExceptionHandler() === installedHandler) {
|
||||||
|
Thread.setDefaultUncaughtExceptionHandler(previousHandler)
|
||||||
|
}
|
||||||
|
installedHandler = null
|
||||||
|
previousHandler = null
|
||||||
|
started = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,47 +2,59 @@ package com.xuqm.sdk.bugcollect
|
|||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import com.xuqm.sdk.bugcollect.internal.LogUploader
|
import com.google.gson.Gson
|
||||||
|
import com.google.gson.reflect.TypeToken
|
||||||
|
import com.xuqm.sdk.XuqmSDK
|
||||||
import com.xuqm.sdk.bugcollect.internal.LogStorage
|
import com.xuqm.sdk.bugcollect.internal.LogStorage
|
||||||
|
import com.xuqm.sdk.bugcollect.internal.LogUploader
|
||||||
|
import com.xuqm.sdk.bugcollect.internal.UploadResult
|
||||||
|
import com.xuqm.sdk.storage.SecureStore
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.SupervisorJob
|
import kotlinx.coroutines.SupervisorJob
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.isActive
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import org.json.JSONArray
|
import kotlinx.coroutines.sync.Mutex
|
||||||
import org.json.JSONObject
|
import kotlinx.coroutines.sync.withLock
|
||||||
import java.io.File
|
import java.util.concurrent.TimeUnit
|
||||||
|
import kotlin.math.pow
|
||||||
/** [JSONObject.optString] returns non-null on newer Android; this returns null when the key is absent or the value is null. */
|
import kotlin.random.Random
|
||||||
private fun JSONObject.optNullableString(key: String): String? =
|
|
||||||
if (has(key) && !isNull(key)) optString(key) else null
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* BugCollect 的唯一队列实现。
|
||||||
|
*
|
||||||
|
* error/fatal 使用加密存储并最多保留七天;普通事件只存在内存。上传失败最多进行
|
||||||
|
* 三次指数退避重试,之后保留持久错误等待下次进程启动或网络恢复。
|
||||||
|
*/
|
||||||
internal class LogQueue(
|
internal class LogQueue(
|
||||||
private val appKey: String,
|
private val appKey: String,
|
||||||
private val appContext: Context,
|
private val appContext: Context,
|
||||||
) {
|
) {
|
||||||
/** 每次上传时取最新的 URL(平台配置可能在队列创建后才下发)。 */
|
|
||||||
private val logApiUrl: String get() = com.xuqm.sdk.XuqmSDK.bugCollectApiUrl ?: ""
|
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
private const val TAG = "BugCollect"
|
private const val TAG = "BugCollect"
|
||||||
private const val MAX_QUEUE_SIZE = 500
|
private const val MAX_QUEUE_SIZE = 500
|
||||||
private const val BATCH_SIZE = 30
|
private const val BATCH_SIZE = 30
|
||||||
private const val FLUSH_INTERVAL_MS = 10_000L
|
private const val FLUSH_INTERVAL_MS = 10_000L
|
||||||
private const val PREFS_NAME = "xuqm_log_queue"
|
private const val PREFS_NAME = "xuqm_bugcollect_queue"
|
||||||
private const val KEY_ISSUES = "issues"
|
private const val KEY_ISSUES = "issues"
|
||||||
private const val KEY_EVENTS = "events"
|
private val retentionMillis = TimeUnit.DAYS.toMillis(7)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private val gson = Gson()
|
||||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||||
private val pendingIssues = mutableListOf<IssueEvent>()
|
private val pendingIssues = mutableListOf<IssueEvent>()
|
||||||
private val pendingEvents = mutableListOf<LogEvent>()
|
private val pendingEvents = mutableListOf<LogEvent>()
|
||||||
private val lock = Any()
|
private val lock = Any()
|
||||||
|
private val flushMutex = Mutex()
|
||||||
|
@Volatile private var stopped = false
|
||||||
|
private var flushJob: Job? = null
|
||||||
|
|
||||||
init {
|
init {
|
||||||
loadFromDisk()
|
loadFromDisk()
|
||||||
scope.launch {
|
flushJob = scope.launch {
|
||||||
while (true) {
|
while (isActive && !stopped) {
|
||||||
delay(FLUSH_INTERVAL_MS)
|
delay(FLUSH_INTERVAL_MS)
|
||||||
flush()
|
flush()
|
||||||
}
|
}
|
||||||
@ -50,11 +62,15 @@ internal class LogQueue(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun push(event: Any) {
|
fun push(event: Any) {
|
||||||
|
if (stopped) return
|
||||||
synchronized(lock) {
|
synchronized(lock) {
|
||||||
when (event) {
|
when (event) {
|
||||||
is IssueEvent -> {
|
is IssueEvent -> {
|
||||||
if (pendingIssues.size >= MAX_QUEUE_SIZE) pendingIssues.removeAt(0)
|
if (pendingIssues.size >= MAX_QUEUE_SIZE) pendingIssues.removeAt(0)
|
||||||
pendingIssues.add(event)
|
pendingIssues.add(event)
|
||||||
|
if (event.level == "error" || event.level == "fatal") {
|
||||||
|
persistIssuesLocked()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
is LogEvent -> {
|
is LogEvent -> {
|
||||||
if (pendingEvents.size >= MAX_QUEUE_SIZE) pendingEvents.removeAt(0)
|
if (pendingEvents.size >= MAX_QUEUE_SIZE) pendingEvents.removeAt(0)
|
||||||
@ -66,126 +82,139 @@ internal class LogQueue(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun uploadPendingCrashes() {
|
fun uploadPendingCrashes() {
|
||||||
// 上传时才需要 URL,从远端配置取最新值
|
if (stopped) return
|
||||||
val uploadUrl = com.xuqm.sdk.XuqmSDK.bugCollectApiUrl ?: return
|
|
||||||
scope.launch {
|
scope.launch {
|
||||||
val crashDir = LogStorage.crashDir(appContext)
|
for ((key, event) in LogStorage.loadCrashes(appContext, appKey)) {
|
||||||
val files = crashDir.listFiles() ?: return@launch
|
when (uploadWithRetry { LogUploader.uploadIssue(requireUrl(), event) }) {
|
||||||
for (file in files) {
|
UploadResult.Success -> LogStorage.removeCrash(appContext, key)
|
||||||
runCatching {
|
UploadResult.Disabled -> {
|
||||||
val json = file.readText()
|
disableAndClear()
|
||||||
val obj = JSONObject(json)
|
return@launch
|
||||||
val msg = obj.optString("message", "")
|
}
|
||||||
val stk = obj.optString("stack", "")
|
is UploadResult.Failed -> return@launch
|
||||||
val exType = obj.optString("exceptionType", "Exception")
|
|
||||||
val event = IssueEvent(
|
|
||||||
level = "fatal",
|
|
||||||
platform = "android",
|
|
||||||
fingerprint = Fingerprint.compute(exType, msg, stk),
|
|
||||||
appKey = obj.optString("appKey", appKey),
|
|
||||||
timestamp = obj.optLong("timestamp", System.currentTimeMillis()),
|
|
||||||
exception = IssueEvent.ExceptionInfo(
|
|
||||||
type = exType,
|
|
||||||
value = msg,
|
|
||||||
stacktrace = stk,
|
|
||||||
),
|
|
||||||
release = runCatching {
|
|
||||||
appContext.packageManager.getPackageInfo(appContext.packageName, 0).versionName ?: "unknown"
|
|
||||||
}.getOrDefault("unknown"),
|
|
||||||
environment = "production",
|
|
||||||
userId = obj.optNullableString("userId"),
|
|
||||||
user = IssueEvent.UserInfo(id = obj.optNullableString("userId")),
|
|
||||||
)
|
|
||||||
LogUploader.uploadIssue(uploadUrl, event)
|
|
||||||
file.delete()
|
|
||||||
}.onFailure { e ->
|
|
||||||
Log.e(TAG, "Failed to upload crash: ${file.name}", e)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun stopAndClear() {
|
||||||
|
stopped = true
|
||||||
|
flushJob?.cancel()
|
||||||
|
synchronized(lock) {
|
||||||
|
pendingIssues.clear()
|
||||||
|
pendingEvents.clear()
|
||||||
|
secureStore().clear()
|
||||||
|
}
|
||||||
|
LogStorage.clear(appContext)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun clearUserContext() {
|
||||||
|
synchronized(lock) {
|
||||||
|
pendingIssues.replaceAll { it.copy(userId = null, sessionId = null, user = null) }
|
||||||
|
pendingEvents.replaceAll { it.copy(userId = null, sessionId = null, user = null) }
|
||||||
|
persistIssuesLocked()
|
||||||
|
}
|
||||||
|
LogStorage.clearUserContext(appContext)
|
||||||
|
}
|
||||||
|
|
||||||
private fun shouldFlush(): Boolean = synchronized(lock) {
|
private fun shouldFlush(): Boolean = synchronized(lock) {
|
||||||
pendingIssues.size >= BATCH_SIZE || pendingEvents.size >= BATCH_SIZE
|
pendingIssues.size >= BATCH_SIZE || pendingEvents.size >= BATCH_SIZE
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun flush() {
|
private fun flush() {
|
||||||
// URL 未就绪时跳过,等下次定时 flush
|
|
||||||
if (logApiUrl.isBlank()) return
|
|
||||||
val issues: List<IssueEvent>
|
|
||||||
val events: List<LogEvent>
|
|
||||||
synchronized(lock) {
|
|
||||||
if (pendingIssues.isEmpty() && pendingEvents.isEmpty()) return
|
|
||||||
issues = pendingIssues.toList()
|
|
||||||
events = pendingEvents.toList()
|
|
||||||
pendingIssues.clear()
|
|
||||||
pendingEvents.clear()
|
|
||||||
}
|
|
||||||
if (issues.isNotEmpty()) {
|
|
||||||
scope.launch {
|
scope.launch {
|
||||||
runCatching { LogUploader.uploadIssues(logApiUrl, issues) }
|
flushMutex.withLock {
|
||||||
.onFailure { e ->
|
flushOnce()
|
||||||
Log.e(TAG, "Failed to upload issues batch, re-queuing", e)
|
|
||||||
synchronized(lock) {
|
|
||||||
val remaining = MAX_QUEUE_SIZE - pendingIssues.size
|
|
||||||
if (remaining > 0) pendingIssues.addAll(0, issues.take(remaining))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (events.isNotEmpty()) {
|
|
||||||
scope.launch {
|
|
||||||
runCatching { LogUploader.uploadEvents(logApiUrl, events) }
|
|
||||||
.onFailure { e ->
|
|
||||||
Log.e(TAG, "Failed to upload events batch, re-queuing", e)
|
|
||||||
synchronized(lock) {
|
|
||||||
val remaining = MAX_QUEUE_SIZE - pendingEvents.size
|
|
||||||
if (remaining > 0) pendingEvents.addAll(0, events.take(remaining))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun loadFromDisk() {
|
/** 单飞执行;定时器、满批触发和手动触发不能重复上传同一批事件。 */
|
||||||
|
private suspend fun flushOnce() {
|
||||||
|
if (stopped || !XuqmSDK.bugCollectEnabled || !XuqmSDK.privacyConsentGranted) return
|
||||||
|
val issues: List<IssueEvent>
|
||||||
|
val events: List<LogEvent>
|
||||||
|
synchronized(lock) {
|
||||||
|
if (pendingIssues.isEmpty() && pendingEvents.isEmpty()) return
|
||||||
|
issues = pendingIssues.take(BATCH_SIZE)
|
||||||
|
events = pendingEvents.take(BATCH_SIZE)
|
||||||
|
}
|
||||||
|
if (issues.isNotEmpty()) {
|
||||||
|
when (uploadWithRetry { LogUploader.uploadIssues(requireUrl(), issues) }) {
|
||||||
|
UploadResult.Success -> synchronized(lock) {
|
||||||
|
pendingIssues.removeAll(issues.toSet())
|
||||||
|
persistIssuesLocked()
|
||||||
|
}
|
||||||
|
UploadResult.Disabled -> {
|
||||||
|
disableAndClear()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
is UploadResult.Failed -> Unit
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (events.isNotEmpty() && !stopped) {
|
||||||
|
when (uploadWithRetry { LogUploader.uploadEvents(requireUrl(), events) }) {
|
||||||
|
UploadResult.Success -> synchronized(lock) { pendingEvents.removeAll(events.toSet()) }
|
||||||
|
UploadResult.Disabled -> disableAndClear()
|
||||||
|
is UploadResult.Failed -> Unit
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun uploadWithRetry(block: () -> UploadResult): UploadResult {
|
||||||
|
var last: UploadResult = UploadResult.Failed()
|
||||||
|
repeat(3) { attempt ->
|
||||||
|
if (stopped) return UploadResult.Failed()
|
||||||
|
val result = runCatching(block).getOrElse { UploadResult.Failed(it) }
|
||||||
|
if (result !is UploadResult.Failed) return result
|
||||||
|
last = result
|
||||||
|
if (attempt < 2) {
|
||||||
|
val base = 500.0 * 2.0.pow(attempt.toDouble())
|
||||||
|
delay((base + Random.nextLong(0L, 251L)).toLong())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Log.w(TAG, "Background upload deferred after 3 attempts")
|
||||||
|
return last
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun disableAndClear() {
|
||||||
|
stopAndClear()
|
||||||
|
// 固定停用业务码只改变扩展状态,不向宿主线程抛异常。
|
||||||
runCatching {
|
runCatching {
|
||||||
val prefs = appContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
XuqmSDK::class.java.getDeclaredMethod("disableBugCollectFromExtension")
|
||||||
prefs.getString(KEY_ISSUES, null)?.let { json ->
|
.apply { isAccessible = true }
|
||||||
val arr = JSONArray(json)
|
.invoke(XuqmSDK)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun requireUrl(): String =
|
||||||
|
XuqmSDK.bugCollectApiUrl?.takeIf { it.isNotBlank() }
|
||||||
|
?: throw IllegalStateException("BugCollect upload URL is unavailable")
|
||||||
|
|
||||||
|
private fun loadFromDisk() {
|
||||||
|
val type = object : TypeToken<List<IssueEvent>>() {}.type
|
||||||
|
val loaded = runCatching {
|
||||||
|
gson.fromJson<List<IssueEvent>>(secureStore().getString(KEY_ISSUES) ?: "[]", type)
|
||||||
|
}.getOrNull().orEmpty()
|
||||||
|
val minTimestamp = System.currentTimeMillis() - retentionMillis
|
||||||
synchronized(lock) {
|
synchronized(lock) {
|
||||||
for (i in 0 until arr.length()) {
|
pendingIssues.addAll(
|
||||||
val obj = arr.getJSONObject(i)
|
loaded
|
||||||
pendingIssues.add(IssueEvent(
|
.filter { it.timestamp >= minTimestamp }
|
||||||
level = obj.optString("level", "error"),
|
.takeLast(MAX_QUEUE_SIZE)
|
||||||
platform = obj.optString("platform", "android"),
|
,
|
||||||
fingerprint = obj.optString("fingerprint", ""),
|
)
|
||||||
appKey = obj.optString("appKey", appKey),
|
persistIssuesLocked()
|
||||||
timestamp = obj.optLong("timestamp", 0),
|
|
||||||
release = obj.optString("release", ""),
|
|
||||||
environment = obj.optString("environment", "production"),
|
|
||||||
userId = obj.optNullableString("userId"),
|
|
||||||
))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun persistIssuesLocked() {
|
||||||
|
val persistent = pendingIssues
|
||||||
|
.filter { it.level == "error" || it.level == "fatal" }
|
||||||
|
.filter { it.timestamp >= System.currentTimeMillis() - retentionMillis }
|
||||||
|
.takeLast(MAX_QUEUE_SIZE)
|
||||||
|
secureStore().putString(KEY_ISSUES, gson.toJson(persistent))
|
||||||
}
|
}
|
||||||
prefs.getString(KEY_EVENTS, null)?.let { json ->
|
|
||||||
val arr = JSONArray(json)
|
private fun secureStore() = SecureStore.open(appContext, PREFS_NAME)
|
||||||
synchronized(lock) {
|
|
||||||
for (i in 0 until arr.length()) {
|
|
||||||
val obj = arr.getJSONObject(i)
|
|
||||||
pendingEvents.add(LogEvent(
|
|
||||||
name = obj.optString("name", ""),
|
|
||||||
appKey = obj.optString("appKey", appKey),
|
|
||||||
userId = obj.optNullableString("userId"),
|
|
||||||
platform = obj.optString("platform", "android"),
|
|
||||||
release = obj.optString("release", ""),
|
|
||||||
environment = obj.optString("environment", "production"),
|
|
||||||
timestamp = obj.optLong("timestamp", 0),
|
|
||||||
))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
prefs.edit().remove(KEY_ISSUES).remove(KEY_EVENTS).apply()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,54 +0,0 @@
|
|||||||
package com.xuqm.sdk.bugcollect.internal
|
|
||||||
|
|
||||||
import android.content.ContentProvider
|
|
||||||
import android.content.ContentValues
|
|
||||||
import android.database.Cursor
|
|
||||||
import android.net.Uri
|
|
||||||
import android.util.Log
|
|
||||||
import com.xuqm.sdk.XuqmSDK
|
|
||||||
import com.xuqm.sdk.bugcollect.BugCollect
|
|
||||||
import com.xuqm.sdk.bugcollect.CrashCapture
|
|
||||||
|
|
||||||
/**
|
|
||||||
* BugCollect 早期初始化入口(兼容旧版 sdk-core)。
|
|
||||||
*
|
|
||||||
* 新版 sdk-core 的 XuqmMergedProvider 已通过反射调用 BugCollect.startCrashCapture(),
|
|
||||||
* 此 Provider 检查 XuqmSDK.isInitialized() 来判断是否需要补充初始化。
|
|
||||||
*
|
|
||||||
* 逻辑:
|
|
||||||
* - sdk-core 已初始化 → 说明合并 Provider 已处理,跳过
|
|
||||||
* - sdk-core 未初始化 → 旧版 sdk-core 场景,执行原有逻辑
|
|
||||||
*/
|
|
||||||
internal class BugCollectInitProvider : ContentProvider() {
|
|
||||||
|
|
||||||
override fun onCreate(): Boolean {
|
|
||||||
val ctx = context?.applicationContext ?: return true
|
|
||||||
|
|
||||||
// 合并 Provider 已完成初始化(sdk-core 新版),跳过
|
|
||||||
if (XuqmSDK.isInitialized()) {
|
|
||||||
Log.d("BugCollect", "Already initialized by merged provider, skipping")
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
// 旧版 sdk-core 路径:手动读取 config + 注册崩溃拦截
|
|
||||||
val appKey = XuqmSDK.readAppKey(ctx)
|
|
||||||
if (appKey.isNullOrBlank()) {
|
|
||||||
Log.w("BugCollect", "BugCollectInitProvider: no appKey available, skipping")
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
CrashCapture.start(
|
|
||||||
appContext = ctx,
|
|
||||||
appKey = appKey,
|
|
||||||
getUserId = { XuqmSDK.getUserId() ?: XuqmSDK.deviceId },
|
|
||||||
)
|
|
||||||
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun query(uri: Uri, p: Array<out String>?, s: String?, sa: Array<out String>?, so: String?): Cursor? = null
|
|
||||||
override fun getType(uri: Uri): String? = null
|
|
||||||
override fun insert(uri: Uri, values: ContentValues?): Uri? = null
|
|
||||||
override fun delete(uri: Uri, selection: String?, selectionArgs: Array<out String>?): Int = 0
|
|
||||||
override fun update(uri: Uri, values: ContentValues?, selection: String?, selectionArgs: Array<out String>?): Int = 0
|
|
||||||
}
|
|
||||||
@ -0,0 +1,32 @@
|
|||||||
|
package com.xuqm.sdk.bugcollect.internal
|
||||||
|
|
||||||
|
/** BugCollect 唯一的数据脱敏入口,业务事件与异常在进入队列前必须经过此处。 */
|
||||||
|
internal object DataSanitizer {
|
||||||
|
private val sensitiveKey = Regex(
|
||||||
|
"(password|passwd|pwd|pin|token|authorization|cookie|phone|mobile|idcard|identity|secret)",
|
||||||
|
RegexOption.IGNORE_CASE,
|
||||||
|
)
|
||||||
|
private val bearer = Regex("(?i)bearer\\s+[a-z0-9._~+/=-]+")
|
||||||
|
private val phone = Regex("(?<!\\d)1\\d{10}(?!\\d)")
|
||||||
|
private val identity = Regex("(?<!\\d)\\d{14,17}[0-9Xx](?!\\d)")
|
||||||
|
|
||||||
|
fun sanitizeText(value: String): String =
|
||||||
|
value.replace(bearer, "Bearer [REDACTED]")
|
||||||
|
.replace(phone, "[PHONE]")
|
||||||
|
.replace(identity, "[IDENTITY]")
|
||||||
|
|
||||||
|
fun sanitizeMap(source: Map<String, Any?>): Map<String, Any?> =
|
||||||
|
source.mapValues { (key, value) ->
|
||||||
|
if (sensitiveKey.containsMatchIn(key)) "[REDACTED]" else sanitizeValue(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun sanitizeValue(value: Any?): Any? = when (value) {
|
||||||
|
is String -> sanitizeText(value)
|
||||||
|
is Map<*, *> -> sanitizeMap(
|
||||||
|
value.entries.associate { (key, nested) -> key.toString() to nested },
|
||||||
|
)
|
||||||
|
is Iterable<*> -> value.map(::sanitizeValue)
|
||||||
|
is Array<*> -> value.map(::sanitizeValue)
|
||||||
|
else -> value
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,17 @@
|
|||||||
|
package com.xuqm.sdk.bugcollect.internal
|
||||||
|
|
||||||
|
/** Debug 显式单次测试的唯一门禁;不改变自动采集状态。 */
|
||||||
|
internal object DeveloperBugCollectPolicy {
|
||||||
|
fun isAllowed(
|
||||||
|
debuggable: Boolean,
|
||||||
|
buildEnabled: Boolean,
|
||||||
|
privacyGranted: Boolean,
|
||||||
|
platformEnabled: Boolean,
|
||||||
|
hasUploadEndpoint: Boolean,
|
||||||
|
): Boolean =
|
||||||
|
debuggable &&
|
||||||
|
buildEnabled &&
|
||||||
|
privacyGranted &&
|
||||||
|
platformEnabled &&
|
||||||
|
hasUploadEndpoint
|
||||||
|
}
|
||||||
@ -1,20 +1,20 @@
|
|||||||
package com.xuqm.sdk.bugcollect.internal
|
package com.xuqm.sdk.bugcollect.internal
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import org.json.JSONObject
|
import com.google.gson.Gson
|
||||||
import java.io.File
|
import com.xuqm.sdk.bugcollect.Fingerprint
|
||||||
|
import com.xuqm.sdk.bugcollect.IssueEvent
|
||||||
|
import com.xuqm.sdk.storage.SecureStore
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
|
/** 全局崩溃的加密持久化,仅由未捕获异常处理器与上传队列访问。 */
|
||||||
internal object LogStorage {
|
internal object LogStorage {
|
||||||
|
private const val PREFS_NAME = "xuqm_bugcollect_crashes"
|
||||||
|
private const val KEY_PREFIX = "crash_"
|
||||||
|
private const val MAX_CRASHES = 500
|
||||||
|
private val retentionMillis = TimeUnit.DAYS.toMillis(7)
|
||||||
|
private val gson = Gson()
|
||||||
|
|
||||||
private const val CRASH_DIR = "xuqm_crashes"
|
|
||||||
|
|
||||||
fun crashDir(context: Context): File =
|
|
||||||
File(context.filesDir, CRASH_DIR).also { it.mkdirs() }
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 同步写崩溃文件(在 UncaughtExceptionHandler 中调用)。
|
|
||||||
* 不存储上传 URL——URL 在下次启动上传时从 XuqmSDK.bugCollectApiUrl 取得。
|
|
||||||
*/
|
|
||||||
fun saveCrash(
|
fun saveCrash(
|
||||||
context: Context,
|
context: Context,
|
||||||
throwable: Throwable,
|
throwable: Throwable,
|
||||||
@ -22,18 +22,84 @@ internal object LogStorage {
|
|||||||
userId: String?,
|
userId: String?,
|
||||||
) {
|
) {
|
||||||
runCatching {
|
runCatching {
|
||||||
val dir = crashDir(context)
|
val timestamp = System.currentTimeMillis()
|
||||||
val file = File(dir, "${System.currentTimeMillis()}.json")
|
val event = IssueEvent(
|
||||||
val json = JSONObject().apply {
|
level = "fatal",
|
||||||
put("level", "fatal")
|
platform = "android",
|
||||||
put("message", throwable.message ?: throwable.javaClass.name)
|
fingerprint = Fingerprint.compute(
|
||||||
put("stack", throwable.stackTraceToString())
|
throwable.javaClass.simpleName,
|
||||||
put("exceptionType", throwable.javaClass.simpleName)
|
DataSanitizer.sanitizeText(throwable.message ?: throwable.javaClass.name),
|
||||||
put("appKey", appKey)
|
DataSanitizer.sanitizeText(throwable.stackTraceToString()),
|
||||||
put("userId", userId ?: JSONObject.NULL)
|
),
|
||||||
put("timestamp", System.currentTimeMillis())
|
appKey = appKey,
|
||||||
}
|
timestamp = timestamp,
|
||||||
file.writeText(json.toString())
|
release = runCatching {
|
||||||
|
context.packageManager.getPackageInfo(context.packageName, 0).versionName
|
||||||
|
}.getOrNull().orEmpty().ifBlank { "unknown" },
|
||||||
|
exception = IssueEvent.ExceptionInfo(
|
||||||
|
type = throwable.javaClass.simpleName,
|
||||||
|
value = DataSanitizer.sanitizeText(throwable.message ?: throwable.javaClass.name),
|
||||||
|
stacktrace = DataSanitizer.sanitizeText(throwable.stackTraceToString()),
|
||||||
|
),
|
||||||
|
userId = userId,
|
||||||
|
user = IssueEvent.UserInfo(id = userId),
|
||||||
|
)
|
||||||
|
val store = store(context)
|
||||||
|
val crashKeys = store.entries().keys.filter { it.startsWith(KEY_PREFIX) }.sorted()
|
||||||
|
crashKeys.take((crashKeys.size - MAX_CRASHES + 1).coerceAtLeast(0))
|
||||||
|
.forEach(store::remove)
|
||||||
|
store.putString("$KEY_PREFIX$timestamp", gson.toJson(event), synchronous = true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun loadCrashes(context: Context, expectedAppKey: String): List<Pair<String, IssueEvent>> {
|
||||||
|
val cutoff = System.currentTimeMillis() - retentionMillis
|
||||||
|
val store = store(context)
|
||||||
|
val expired = mutableListOf<String>()
|
||||||
|
val result = store.entries().entries
|
||||||
|
.asSequence()
|
||||||
|
.filter { it.key.startsWith(KEY_PREFIX) }
|
||||||
|
.mapNotNull { (key, value) ->
|
||||||
|
val event = runCatching {
|
||||||
|
gson.fromJson(value, IssueEvent::class.java)
|
||||||
|
}.getOrNull()
|
||||||
|
if (event == null || event.timestamp < cutoff || event.appKey != expectedAppKey) {
|
||||||
|
expired += key
|
||||||
|
null
|
||||||
|
} else {
|
||||||
|
key to event
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.sortedBy { it.second.timestamp }
|
||||||
|
.toList()
|
||||||
|
if (expired.isNotEmpty()) {
|
||||||
|
expired.forEach(store::remove)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
fun removeCrash(context: Context, key: String) {
|
||||||
|
store(context).remove(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun clear(context: Context) {
|
||||||
|
store(context).clear()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun clearUserContext(context: Context) {
|
||||||
|
val store = store(context)
|
||||||
|
store.entries().entries
|
||||||
|
.filter { it.key.startsWith(KEY_PREFIX) }
|
||||||
|
.forEach { (key, value) ->
|
||||||
|
val event = runCatching {
|
||||||
|
gson.fromJson(value, IssueEvent::class.java)
|
||||||
|
}.getOrNull() ?: return@forEach
|
||||||
|
store.putString(
|
||||||
|
key,
|
||||||
|
gson.toJson(event.copy(userId = null, sessionId = null, user = null)),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun store(context: Context) = SecureStore.open(context, PREFS_NAME)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,9 +1,8 @@
|
|||||||
package com.xuqm.sdk.bugcollect.internal
|
package com.xuqm.sdk.bugcollect.internal
|
||||||
|
|
||||||
import android.util.Log
|
import com.xuqm.sdk.XuqmSDK
|
||||||
import com.xuqm.sdk.bugcollect.IssueEvent
|
import com.xuqm.sdk.bugcollect.IssueEvent
|
||||||
import com.xuqm.sdk.bugcollect.LogEvent
|
import com.xuqm.sdk.bugcollect.LogEvent
|
||||||
import com.xuqm.sdk.XuqmSDK
|
|
||||||
import okhttp3.MediaType.Companion.toMediaType
|
import okhttp3.MediaType.Companion.toMediaType
|
||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
import okhttp3.Request
|
import okhttp3.Request
|
||||||
@ -15,147 +14,80 @@ import java.util.Locale
|
|||||||
import java.util.TimeZone
|
import java.util.TimeZone
|
||||||
import java.util.concurrent.TimeUnit
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
|
internal sealed interface UploadResult {
|
||||||
|
data object Success : UploadResult
|
||||||
|
data object Disabled : UploadResult
|
||||||
|
data class Failed(val cause: Throwable? = null) : UploadResult
|
||||||
|
}
|
||||||
|
|
||||||
internal object LogUploader {
|
internal object LogUploader {
|
||||||
|
private const val DISABLED_CODE = "BUGCOLLECT_DISABLED"
|
||||||
private const val TAG = "BugCollect"
|
|
||||||
|
|
||||||
private val client = OkHttpClient.Builder()
|
private val client = OkHttpClient.Builder()
|
||||||
.connectTimeout(10, TimeUnit.SECONDS)
|
.connectTimeout(10, TimeUnit.SECONDS)
|
||||||
.writeTimeout(15, TimeUnit.SECONDS)
|
.writeTimeout(15, TimeUnit.SECONDS)
|
||||||
.readTimeout(10, TimeUnit.SECONDS)
|
.readTimeout(10, TimeUnit.SECONDS)
|
||||||
.build()
|
.build()
|
||||||
|
private val jsonMedia = "application/json; charset=utf-8".toMediaType()
|
||||||
|
|
||||||
private val JSON_MEDIA = "application/json; charset=utf-8".toMediaType()
|
fun uploadIssues(logApiUrl: String, issues: List<IssueEvent>): UploadResult {
|
||||||
|
if (issues.isEmpty()) return UploadResult.Success
|
||||||
|
val envelope = createEnvelope().put(
|
||||||
|
"events",
|
||||||
|
JSONArray().apply { issues.forEach { put(issueToJson(it)) } },
|
||||||
|
)
|
||||||
|
return post("${resolveUrl(logApiUrl)}/bugcollect/v1/issues/batch", envelope.toString())
|
||||||
|
}
|
||||||
|
|
||||||
|
fun uploadEvents(logApiUrl: String, events: List<LogEvent>): UploadResult {
|
||||||
|
if (events.isEmpty()) return UploadResult.Success
|
||||||
|
val envelope = createEnvelope().put(
|
||||||
|
"events",
|
||||||
|
JSONArray().apply { events.forEach { put(eventToJson(it)) } },
|
||||||
|
)
|
||||||
|
return post("${resolveUrl(logApiUrl)}/bugcollect/v1/events/batch", envelope.toString())
|
||||||
|
}
|
||||||
|
|
||||||
|
fun uploadIssue(logApiUrl: String, issue: IssueEvent): UploadResult =
|
||||||
|
uploadIssues(logApiUrl, listOf(issue))
|
||||||
|
|
||||||
|
private fun post(url: String, body: String): UploadResult = runCatching {
|
||||||
|
val request = Request.Builder().url(url).post(body.toRequestBody(jsonMedia)).build()
|
||||||
|
client.newCall(request).execute().use { response ->
|
||||||
|
val responseBody = response.body?.string().orEmpty()
|
||||||
|
if (isDisabled(responseBody)) {
|
||||||
|
UploadResult.Disabled
|
||||||
|
} else if (response.isSuccessful) {
|
||||||
|
UploadResult.Success
|
||||||
|
} else {
|
||||||
|
UploadResult.Failed(IllegalStateException("HTTP ${response.code}"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}.getOrElse { UploadResult.Failed(it) }
|
||||||
|
|
||||||
|
private fun isDisabled(body: String): Boolean {
|
||||||
|
if (body.isBlank()) return false
|
||||||
|
val json = runCatching { JSONObject(body) }.getOrNull() ?: return false
|
||||||
|
return sequenceOf("code", "errorCode", "status")
|
||||||
|
.mapNotNull { key -> json.opt(key)?.toString() }
|
||||||
|
.any { it == DISABLED_CODE }
|
||||||
|
}
|
||||||
|
|
||||||
/** 将相对路径补全为绝对 URL(基于平台地址)。 */
|
|
||||||
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
|
||||||
val base = XuqmSDK.platformUrl.trimEnd('/')
|
return "${XuqmSDK.platformUrl.trimEnd('/')}/${trimmed.trimStart('/')}"
|
||||||
return "$base/${trimmed.trimStart('/')}"
|
|
||||||
}
|
|
||||||
|
|
||||||
fun uploadIssues(logApiUrl: String, issues: List<IssueEvent>) {
|
|
||||||
if (issues.isEmpty()) return
|
|
||||||
val url = "${resolveUrl(logApiUrl)}/bugcollect/v1/issues/batch"
|
|
||||||
val envelope = createEnvelope()
|
|
||||||
val events = JSONArray()
|
|
||||||
for (issue in issues) {
|
|
||||||
events.put(issueToJson(issue))
|
|
||||||
}
|
|
||||||
envelope.put("events", events)
|
|
||||||
post(url, envelope.toString())
|
|
||||||
}
|
|
||||||
|
|
||||||
fun uploadEvents(logApiUrl: String, events: List<LogEvent>) {
|
|
||||||
if (events.isEmpty()) return
|
|
||||||
val url = "${resolveUrl(logApiUrl)}/bugcollect/v1/events/batch"
|
|
||||||
val envelope = createEnvelope()
|
|
||||||
val eventsArray = JSONArray()
|
|
||||||
for (event in events) {
|
|
||||||
eventsArray.put(eventToJson(event))
|
|
||||||
}
|
|
||||||
envelope.put("events", eventsArray)
|
|
||||||
post(url, envelope.toString())
|
|
||||||
}
|
|
||||||
|
|
||||||
fun uploadIssue(logApiUrl: String, issue: IssueEvent) {
|
|
||||||
val url = "${resolveUrl(logApiUrl)}/bugcollect/v1/issues/batch"
|
|
||||||
val envelope = createEnvelope()
|
|
||||||
val events = JSONArray().put(issueToJson(issue))
|
|
||||||
envelope.put("events", events)
|
|
||||||
post(url, envelope.toString())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun createEnvelope(): JSONObject = JSONObject().apply {
|
private fun createEnvelope(): JSONObject = JSONObject().apply {
|
||||||
val sdf = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'", Locale.US)
|
val formatter = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'", Locale.US)
|
||||||
sdf.timeZone = TimeZone.getTimeZone("UTC")
|
formatter.timeZone = TimeZone.getTimeZone("UTC")
|
||||||
put("sentAt", sdf.format(System.currentTimeMillis()))
|
put("sentAt", formatter.format(System.currentTimeMillis()))
|
||||||
put("sdk", JSONObject().apply {
|
put("sdk", JSONObject().put("name", "bugcollect.android").put("version", "1.1.0"))
|
||||||
put("name", "bugcollect.android")
|
|
||||||
put("version", "1.0.0")
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun post(url: String, body: String) {
|
private fun issueToJson(issue: IssueEvent): JSONObject =
|
||||||
val request = Request.Builder()
|
JSONObject(com.google.gson.Gson().toJson(issue))
|
||||||
.url(url)
|
|
||||||
.post(body.toRequestBody(JSON_MEDIA))
|
|
||||||
.build()
|
|
||||||
client.newCall(request).execute().use { response ->
|
|
||||||
if (!response.isSuccessful) {
|
|
||||||
Log.w(TAG, "Upload failed: HTTP ${response.code} url=$url")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun issueToJson(issue: IssueEvent): JSONObject = JSONObject().apply {
|
private fun eventToJson(event: LogEvent): JSONObject =
|
||||||
put("eventId", issue.eventId)
|
JSONObject(com.google.gson.Gson().toJson(event))
|
||||||
put("appKey", issue.appKey)
|
|
||||||
put("level", issue.level)
|
|
||||||
put("platform", issue.platform)
|
|
||||||
put("fingerprint", issue.fingerprint)
|
|
||||||
put("timestamp", issue.timestamp)
|
|
||||||
put("release", issue.release)
|
|
||||||
issue.buildId?.let { put("buildId", it) }
|
|
||||||
put("environment", issue.environment)
|
|
||||||
if (issue.exception != null) {
|
|
||||||
put("exception", JSONObject().apply {
|
|
||||||
put("type", issue.exception.type)
|
|
||||||
put("value", issue.exception.value)
|
|
||||||
if (issue.exception.stacktrace != null) put("stacktrace", issue.exception.stacktrace)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
if (issue.breadcrumbs.isNotEmpty()) {
|
|
||||||
val arr = JSONArray()
|
|
||||||
for (crumb in issue.breadcrumbs) {
|
|
||||||
arr.put(JSONObject().apply {
|
|
||||||
put("timestamp", crumb.timestamp)
|
|
||||||
put("category", crumb.category)
|
|
||||||
put("message", crumb.message)
|
|
||||||
put("level", crumb.level)
|
|
||||||
if (crumb.data.isNotEmpty()) {
|
|
||||||
put("data", JSONObject(crumb.data as? Map<String, Any?> ?: emptyMap<String, Any?>()))
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
put("breadcrumbs", arr)
|
|
||||||
}
|
|
||||||
if (issue.userId != null || issue.user?.id != null) {
|
|
||||||
put("user", JSONObject().apply { put("id", issue.userId ?: issue.user?.id) })
|
|
||||||
}
|
|
||||||
if (issue.device != null) {
|
|
||||||
put("device", JSONObject().apply {
|
|
||||||
issue.device.model?.let { put("model", it) }
|
|
||||||
issue.device.manufacturer?.let { put("manufacturer", it) }
|
|
||||||
issue.device.osName?.let { put("osName", it) }
|
|
||||||
issue.device.osVersion?.let { put("osVersion", it) }
|
|
||||||
issue.device.locale?.let { put("locale", it) }
|
|
||||||
issue.device.timezone?.let { put("timezone", it) }
|
|
||||||
issue.device.network?.let { put("network", it) }
|
|
||||||
issue.device.isEmulator?.let { put("isEmulator", it) }
|
|
||||||
issue.device.freeMemoryMb?.let { put("freeMemoryMb", it) }
|
|
||||||
issue.device.buildType?.let { put("buildType", it) }
|
|
||||||
})
|
|
||||||
}
|
|
||||||
if (issue.tags.isNotEmpty()) {
|
|
||||||
put("tags", JSONObject(issue.tags as? Map<String, Any?> ?: emptyMap<String, Any?>()))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun eventToJson(event: LogEvent): JSONObject = JSONObject().apply {
|
|
||||||
event.eventId?.let { put("eventId", it) }
|
|
||||||
put("appKey", event.appKey)
|
|
||||||
put("name", event.name)
|
|
||||||
put("timestamp", event.timestamp)
|
|
||||||
put("platform", event.platform)
|
|
||||||
put("release", event.release)
|
|
||||||
put("environment", event.environment)
|
|
||||||
if (event.properties.isNotEmpty()) {
|
|
||||||
put("properties", JSONObject(event.properties as? Map<String, Any?> ?: emptyMap<String, Any?>()))
|
|
||||||
}
|
|
||||||
if (event.userId != null || event.user?.id != null) {
|
|
||||||
put("user", JSONObject().apply { put("id", event.userId ?: event.user?.id) })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,18 @@
|
|||||||
|
package com.xuqm.sdk.bugcollect
|
||||||
|
|
||||||
|
import org.junit.Assert.assertFalse
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class PublicBridgeApiTest {
|
||||||
|
@Test
|
||||||
|
fun `internal lifecycle bridge is not public API`() {
|
||||||
|
val methods = BugCollect::class.java.methods
|
||||||
|
.filterNot { it.isSynthetic }
|
||||||
|
.map { it.name }
|
||||||
|
.toSet()
|
||||||
|
assertFalse("onSdkConfigurationChanged" in methods)
|
||||||
|
assertFalse("onSdkLogin" in methods)
|
||||||
|
assertFalse("onSdkLogout" in methods)
|
||||||
|
assertFalse("sendDeveloperTestOnce" in methods)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,22 @@
|
|||||||
|
package com.xuqm.sdk.bugcollect.internal
|
||||||
|
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertFalse
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class DataSanitizerTest {
|
||||||
|
@Test
|
||||||
|
fun sensitiveKeysAndCommonIdentityValuesAreRedacted() {
|
||||||
|
val result = DataSanitizer.sanitizeMap(
|
||||||
|
mapOf(
|
||||||
|
"password" to "123456",
|
||||||
|
"nested" to mapOf("token" to "secret", "message" to "call 13800138000"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals("[REDACTED]", result["password"])
|
||||||
|
val nested = result["nested"] as Map<*, *>
|
||||||
|
assertEquals("[REDACTED]", nested["token"])
|
||||||
|
assertFalse(nested["message"].toString().contains("13800138000"))
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,17 @@
|
|||||||
|
package com.xuqm.sdk.bugcollect.internal
|
||||||
|
|
||||||
|
import org.junit.Assert.assertFalse
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class DeveloperBugCollectPolicyTest {
|
||||||
|
@Test
|
||||||
|
fun `developer test is explicit debug only and still respects hard gates`() {
|
||||||
|
assertTrue(DeveloperBugCollectPolicy.isAllowed(true, true, true, true, true))
|
||||||
|
assertFalse(DeveloperBugCollectPolicy.isAllowed(false, true, true, true, true))
|
||||||
|
assertFalse(DeveloperBugCollectPolicy.isAllowed(true, false, true, true, true))
|
||||||
|
assertFalse(DeveloperBugCollectPolicy.isAllowed(true, true, false, true, true))
|
||||||
|
assertFalse(DeveloperBugCollectPolicy.isAllowed(true, true, true, false, true))
|
||||||
|
assertFalse(DeveloperBugCollectPolicy.isAllowed(true, true, true, true, false))
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,53 @@
|
|||||||
|
plugins {
|
||||||
|
kotlin("jvm") version "2.3.10"
|
||||||
|
`java-gradle-plugin`
|
||||||
|
`maven-publish`
|
||||||
|
}
|
||||||
|
|
||||||
|
group = "com.xuqm"
|
||||||
|
|
||||||
|
val pluginVersion: String by lazy {
|
||||||
|
(project.findProperty("SDK_CORE_VERSION") as? String)?.takeIf { it.isNotBlank() }
|
||||||
|
?: (project.findProperty("PUBLISH_VERSION") as? String)?.takeIf { it.isNotBlank() }
|
||||||
|
?: "0.1.0-SNAPSHOT"
|
||||||
|
}
|
||||||
|
|
||||||
|
version = pluginVersion
|
||||||
|
|
||||||
|
java {
|
||||||
|
sourceCompatibility = JavaVersion.VERSION_21
|
||||||
|
targetCompatibility = JavaVersion.VERSION_21
|
||||||
|
}
|
||||||
|
|
||||||
|
kotlin {
|
||||||
|
jvmToolchain(21)
|
||||||
|
}
|
||||||
|
|
||||||
|
gradlePlugin {
|
||||||
|
plugins {
|
||||||
|
create("common") {
|
||||||
|
id = "com.xuqm.common"
|
||||||
|
implementationClass = "com.xuqm.sdk.common.gradle.XuqmCommonPlugin"
|
||||||
|
displayName = "XuqmGroup Common Plugin"
|
||||||
|
description = "Validates the signed Xuqm config and injects immutable build identity."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
compileOnly("com.android.tools.build:gradle:9.1.0")
|
||||||
|
implementation("com.google.code.gson:gson:2.13.2")
|
||||||
|
testImplementation("junit:junit:4.13.2")
|
||||||
|
}
|
||||||
|
|
||||||
|
publishing {
|
||||||
|
repositories {
|
||||||
|
maven {
|
||||||
|
url = uri("https://nexus.xuqinmin.com/repository/android-hosted/")
|
||||||
|
credentials {
|
||||||
|
username = project.findProperty("NEXUS_USER") as? String ?: ""
|
||||||
|
password = project.findProperty("NEXUS_PASSWORD") as? String ?: ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,17 @@
|
|||||||
|
pluginManagement {
|
||||||
|
repositories {
|
||||||
|
google()
|
||||||
|
mavenCentral()
|
||||||
|
gradlePluginPortal()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencyResolutionManagement {
|
||||||
|
repositories {
|
||||||
|
google()
|
||||||
|
mavenCentral()
|
||||||
|
gradlePluginPortal()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
rootProject.name = "sdk-common-plugin"
|
||||||
@ -0,0 +1,272 @@
|
|||||||
|
package com.xuqm.sdk.common.gradle
|
||||||
|
|
||||||
|
import com.android.build.api.variant.ApplicationAndroidComponentsExtension
|
||||||
|
import com.google.gson.JsonObject
|
||||||
|
import com.google.gson.JsonParser
|
||||||
|
import org.gradle.api.DefaultTask
|
||||||
|
import org.gradle.api.GradleException
|
||||||
|
import org.gradle.api.Plugin
|
||||||
|
import org.gradle.api.Project
|
||||||
|
import org.gradle.api.configuration.BuildFeatures
|
||||||
|
import org.gradle.api.provider.Property
|
||||||
|
import org.gradle.api.file.RegularFileProperty
|
||||||
|
import org.gradle.api.tasks.Input
|
||||||
|
import org.gradle.api.tasks.OutputFile
|
||||||
|
import org.gradle.api.tasks.Optional
|
||||||
|
import org.gradle.api.tasks.TaskAction
|
||||||
|
import java.io.File
|
||||||
|
import java.net.HttpURLConnection
|
||||||
|
import java.net.URI
|
||||||
|
import java.util.UUID
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
class XuqmCommonPlugin @Inject constructor(
|
||||||
|
private val buildFeatures: BuildFeatures,
|
||||||
|
) : Plugin<Project> {
|
||||||
|
override fun apply(target: Project) {
|
||||||
|
// 不依赖宿主 plugins 块中的声明顺序;Android Application 插件就绪后再配置变体。
|
||||||
|
target.pluginManager.withPlugin("com.android.application") {
|
||||||
|
configureAndroidApplication(target)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun configureAndroidApplication(target: Project) {
|
||||||
|
val components = target.extensions.getByType(ApplicationAndroidComponentsExtension::class.java)
|
||||||
|
components.onVariants { variant ->
|
||||||
|
val isRelease = variant.buildType.equals("release", ignoreCase = true)
|
||||||
|
val explicitBuildId = target.providers.gradleProperty("XUQM_BUILD_ID")
|
||||||
|
.orElse(target.providers.environmentVariable("XUQM_BUILD_ID"))
|
||||||
|
.orNull
|
||||||
|
?.trim()
|
||||||
|
?.takeIf { it.isNotEmpty() }
|
||||||
|
val configurationCacheRequested =
|
||||||
|
buildFeatures.configurationCache.requested.getOrElse(false)
|
||||||
|
if (isRelease && explicitBuildId == null && configurationCacheRequested) {
|
||||||
|
error("Release without XUQM_BUILD_ID cannot use Gradle configuration cache")
|
||||||
|
}
|
||||||
|
val buildId = explicitBuildId ?: UUID.randomUUID().toString().replace("-", "")
|
||||||
|
val bugCollectBuildEnabled =
|
||||||
|
!target.providers.gradleProperty("xuqm.bugcollect")
|
||||||
|
.orNull.equals("disabled", ignoreCase = true)
|
||||||
|
val suffix = variant.name.replaceFirstChar { it.uppercase() }
|
||||||
|
val generateManifestTask = target.tasks.register(
|
||||||
|
"xuqmGenerateManifest$suffix",
|
||||||
|
XuqmGenerateManifestTask::class.java,
|
||||||
|
) { task ->
|
||||||
|
task.buildId.set(buildId)
|
||||||
|
task.bugCollectBuildEnabled.set(bugCollectBuildEnabled)
|
||||||
|
task.bugCollectAutomaticEnabled.set(isRelease && bugCollectBuildEnabled)
|
||||||
|
task.outputFile.set(
|
||||||
|
target.layout.buildDirectory.file(
|
||||||
|
"generated/xuqm/${variant.name}/AndroidManifest.xml",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
variant.sources.manifests.addGeneratedManifestFile(generateManifestTask) {
|
||||||
|
it.outputFile
|
||||||
|
}
|
||||||
|
|
||||||
|
val configFile = XuqmConfigLocation.mainConfig(target.projectDir)
|
||||||
|
if (isRelease) {
|
||||||
|
target.extensions.extraProperties.set(
|
||||||
|
"xuqm.configFile.${variant.name}",
|
||||||
|
configFile,
|
||||||
|
)
|
||||||
|
target.extensions.extraProperties.set(
|
||||||
|
"xuqm.buildId.${variant.name}",
|
||||||
|
buildId,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (isRelease && configFile.isFile) {
|
||||||
|
val signedConfig = XuqmConfigContract.read(configFile)
|
||||||
|
target.extensions.extraProperties.set(
|
||||||
|
"xuqm.appKey.${variant.name}",
|
||||||
|
signedConfig.appKey,
|
||||||
|
)
|
||||||
|
target.extensions.extraProperties.set(
|
||||||
|
"xuqm.platformUrl.${variant.name}",
|
||||||
|
signedConfig.platformUrl,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val validateTask = target.tasks.register(
|
||||||
|
"xuqmValidateConfig$suffix",
|
||||||
|
XuqmValidateConfigTask::class.java,
|
||||||
|
) { task ->
|
||||||
|
task.group = "xuqm"
|
||||||
|
task.description = "Validate signed Xuqm config for Release (${variant.name})"
|
||||||
|
task.configPath.set(configFile.absolutePath)
|
||||||
|
if (configFile.isFile) task.configFile.set(configFile)
|
||||||
|
task.packageName.set(variant.applicationId)
|
||||||
|
task.buildId.set(buildId)
|
||||||
|
task.onlineValidation.set(isRelease)
|
||||||
|
}
|
||||||
|
target.tasks.matching {
|
||||||
|
it.name in setOf("pre${suffix}Build", "assemble$suffix", "bundle$suffix")
|
||||||
|
}.configureEach { it.dependsOn(validateTask) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
abstract class XuqmGenerateManifestTask : DefaultTask() {
|
||||||
|
@get:Input abstract val buildId: Property<String>
|
||||||
|
@get:Input abstract val bugCollectBuildEnabled: Property<Boolean>
|
||||||
|
@get:Input abstract val bugCollectAutomaticEnabled: Property<Boolean>
|
||||||
|
@get:OutputFile abstract val outputFile: RegularFileProperty
|
||||||
|
|
||||||
|
@TaskAction
|
||||||
|
fun generate() {
|
||||||
|
val file = outputFile.get().asFile
|
||||||
|
file.parentFile.mkdirs()
|
||||||
|
file.writeText(
|
||||||
|
"""
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<application>
|
||||||
|
<meta-data
|
||||||
|
android:name="com.xuqm.common_plugin_applied"
|
||||||
|
android:value="true" />
|
||||||
|
<meta-data
|
||||||
|
android:name="com.xuqm.build_id"
|
||||||
|
android:value="${buildId.get()}" />
|
||||||
|
<meta-data
|
||||||
|
android:name="com.xuqm.bugcollect_build_enabled"
|
||||||
|
android:value="${bugCollectBuildEnabled.get()}" />
|
||||||
|
<meta-data
|
||||||
|
android:name="com.xuqm.bugcollect_automatic_enabled"
|
||||||
|
android:value="${bugCollectAutomaticEnabled.get()}" />
|
||||||
|
</application>
|
||||||
|
</manifest>
|
||||||
|
""".trimIndent(),
|
||||||
|
Charsets.UTF_8,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
abstract class XuqmValidateConfigTask : DefaultTask() {
|
||||||
|
@get:org.gradle.api.tasks.InputFile
|
||||||
|
@get:Optional
|
||||||
|
abstract val configFile: org.gradle.api.file.RegularFileProperty
|
||||||
|
@get:Input abstract val configPath: Property<String>
|
||||||
|
@get:Input abstract val packageName: Property<String>
|
||||||
|
@get:Input abstract val buildId: Property<String>
|
||||||
|
@get:Input abstract val onlineValidation: Property<Boolean>
|
||||||
|
|
||||||
|
@TaskAction
|
||||||
|
fun validateConfig() {
|
||||||
|
// 在任务执行时重新扫描,避免配置阶段之后生成或复制进 src 的文件绕过门禁。
|
||||||
|
val forbidden = XuqmConfigLocation.forbiddenConfigs(project.projectDir)
|
||||||
|
if (forbidden.isNotEmpty()) {
|
||||||
|
throw GradleException(
|
||||||
|
"Only src/main/${XuqmConfigContract.RELATIVE_PATH} is allowed; remove: " +
|
||||||
|
forbidden.joinToString { it.relativeTo(project.projectDir).path },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val file = configFile.orNull?.asFile ?: File(configPath.get())
|
||||||
|
if (!file.isFile) {
|
||||||
|
throw GradleException(
|
||||||
|
"Xuqm SDK requires signed config at " +
|
||||||
|
"src/main/${XuqmConfigContract.RELATIVE_PATH}",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val config = XuqmConfigContract.read(file)
|
||||||
|
if (!config.packageName.isNullOrBlank() && config.packageName != packageName.get()) {
|
||||||
|
throw GradleException(
|
||||||
|
"Config packageName=${config.packageName} does not match ${packageName.get()}",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (onlineValidation.get()) {
|
||||||
|
ReleaseConfigValidator.validate(config.platformUrl, config.content, packageName.get())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal object XuqmConfigLocation {
|
||||||
|
fun mainConfig(projectDir: File): File =
|
||||||
|
File(projectDir, "src/main/${XuqmConfigContract.RELATIVE_PATH}")
|
||||||
|
|
||||||
|
fun forbiddenConfigs(projectDir: File): List<File> {
|
||||||
|
val sourceRoot = File(projectDir, "src")
|
||||||
|
val authoritative = mainConfig(projectDir).absoluteFile.normalize()
|
||||||
|
if (!sourceRoot.isDirectory) return emptyList()
|
||||||
|
return sourceRoot.walkTopDown()
|
||||||
|
.filter(File::isFile)
|
||||||
|
.filter { it.extension == "xuqmconfig" }
|
||||||
|
.map { it.absoluteFile.normalize() }
|
||||||
|
.filter { it != authoritative }
|
||||||
|
.sortedBy(File::getPath)
|
||||||
|
.toList()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal object ReleaseConfigValidator {
|
||||||
|
fun validate(platformUrl: String, content: String, packageName: String) {
|
||||||
|
val endpoint = "${platformUrl.trimEnd('/')}/api/sdk/build/config/validate"
|
||||||
|
val connection = try {
|
||||||
|
URI.create(endpoint).toURL().openConnection() as HttpURLConnection
|
||||||
|
} catch (error: Exception) {
|
||||||
|
throw GradleException("Release config validation service is unreachable", error)
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
connection.requestMethod = "POST"
|
||||||
|
connection.connectTimeout = 10_000
|
||||||
|
connection.readTimeout = 10_000
|
||||||
|
connection.doOutput = true
|
||||||
|
connection.setRequestProperty("Content-Type", "application/json; charset=utf-8")
|
||||||
|
val body = """{"content":${jsonString(content)},"packageName":${jsonString(packageName)}}"""
|
||||||
|
connection.outputStream.use { it.write(body.toByteArray(Charsets.UTF_8)) }
|
||||||
|
val code = connection.responseCode
|
||||||
|
val response = (
|
||||||
|
if (code in 200..299) connection.inputStream else connection.errorStream
|
||||||
|
)?.bufferedReader()?.use { it.readText() }.orEmpty()
|
||||||
|
verifyResponse(code, response)
|
||||||
|
} catch (error: GradleException) {
|
||||||
|
throw error
|
||||||
|
} catch (error: Exception) {
|
||||||
|
throw GradleException("Release config validation service is unreachable", error)
|
||||||
|
} finally {
|
||||||
|
connection.disconnect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun verifyResponse(httpCode: Int, body: String) {
|
||||||
|
if (httpCode !in 200..299) {
|
||||||
|
throw GradleException("Release config validation service failed: HTTP $httpCode")
|
||||||
|
}
|
||||||
|
val root = runCatching { JsonParser.parseString(body).asJsonObject }
|
||||||
|
.getOrElse { throw GradleException("Release config validation returned invalid JSON") }
|
||||||
|
val data = root.get("data")?.takeIf { it.isJsonObject }?.asJsonObject
|
||||||
|
val valid = data?.get("valid")
|
||||||
|
?.takeIf { it.isJsonPrimitive && it.asJsonPrimitive.isBoolean }
|
||||||
|
?.asBoolean
|
||||||
|
if (valid == true) return
|
||||||
|
val code = data.string("errorCode")
|
||||||
|
?: root.string("errorCode")
|
||||||
|
?: "CONFIG_INVALID"
|
||||||
|
val message = (data.string("message") ?: root.string("message") ?: "unknown error")
|
||||||
|
.replace(Regex("[\\p{Cntrl}&&[^\\r\\n\\t]]"), "")
|
||||||
|
.take(200)
|
||||||
|
throw GradleException("Release config rejected ($code): $message")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun JsonObject?.string(name: String): String? =
|
||||||
|
this?.get(name)
|
||||||
|
?.takeIf { it.isJsonPrimitive && it.asJsonPrimitive.isString }
|
||||||
|
?.asString
|
||||||
|
|
||||||
|
private fun jsonString(value: String): String = buildString {
|
||||||
|
append('"')
|
||||||
|
value.forEach { char ->
|
||||||
|
when (char) {
|
||||||
|
'"' -> append("\\\"")
|
||||||
|
'\\' -> append("\\\\")
|
||||||
|
'\b' -> append("\\b")
|
||||||
|
'\u000C' -> append("\\f")
|
||||||
|
'\n' -> append("\\n")
|
||||||
|
'\r' -> append("\\r")
|
||||||
|
'\t' -> append("\\t")
|
||||||
|
else -> if (char.code < 0x20) append("\\u%04x".format(char.code)) else append(char)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
append('"')
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,116 @@
|
|||||||
|
package com.xuqm.sdk.common.gradle
|
||||||
|
|
||||||
|
import com.google.gson.Gson
|
||||||
|
import com.google.gson.JsonElement
|
||||||
|
import com.google.gson.JsonParser
|
||||||
|
import java.io.File
|
||||||
|
import java.security.KeyFactory
|
||||||
|
import java.security.Signature
|
||||||
|
import java.security.spec.X509EncodedKeySpec
|
||||||
|
import java.time.Instant
|
||||||
|
import java.util.Base64
|
||||||
|
import java.util.UUID
|
||||||
|
import javax.crypto.Cipher
|
||||||
|
import javax.crypto.SecretKeyFactory
|
||||||
|
import javax.crypto.spec.GCMParameterSpec
|
||||||
|
import javax.crypto.spec.PBEKeySpec
|
||||||
|
import javax.crypto.spec.SecretKeySpec
|
||||||
|
|
||||||
|
/** 构建期与运行时共享同一 V2 文件协议;构建插件只读取固定相对路径。 */
|
||||||
|
object XuqmConfigContract {
|
||||||
|
const val RELATIVE_PATH = "assets/config/config.xuqmconfig"
|
||||||
|
private const val MAGIC = "XUQM-CONFIG-V2"
|
||||||
|
private const val PASSPHRASE = "xuqm-config-file-v2.2026.internal"
|
||||||
|
private val publicKeys = mapOf(
|
||||||
|
"xuqm-config-dev-2026-07" to
|
||||||
|
"MCowBQYDK2VwAyEAFt9MBN8jNwCfalex4wg7kuy2DRAbXkGxZyA/Jaz+6YA=",
|
||||||
|
)
|
||||||
|
|
||||||
|
data class Metadata(
|
||||||
|
val appKey: String,
|
||||||
|
val platformUrl: String,
|
||||||
|
val packageName: String?,
|
||||||
|
val content: String,
|
||||||
|
)
|
||||||
|
|
||||||
|
fun read(file: File): Metadata {
|
||||||
|
val content = file.readText(Charsets.UTF_8).trim()
|
||||||
|
val parts = content.split(".")
|
||||||
|
require(parts.size == 6 && parts[0] == MAGIC) {
|
||||||
|
"Only XUQM-CONFIG-V2 is accepted: ${file.path}"
|
||||||
|
}
|
||||||
|
val encodedPublicKey = publicKeys[parts[1]]
|
||||||
|
?: error("Unknown config signing key: ${parts[1]}")
|
||||||
|
val verifier = Signature.getInstance("Ed25519")
|
||||||
|
verifier.initVerify(
|
||||||
|
KeyFactory.getInstance("Ed25519").generatePublic(
|
||||||
|
X509EncodedKeySpec(Base64.getDecoder().decode(encodedPublicKey)),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
verifier.update(parts.take(5).joinToString(".").toByteArray(Charsets.US_ASCII))
|
||||||
|
check(verifier.verify(decode(parts[5]))) { "Config signature verification failed" }
|
||||||
|
|
||||||
|
val key = SecretKeySpec(
|
||||||
|
SecretKeyFactory.getInstance("PBKDF2WithHmacSHA256")
|
||||||
|
.generateSecret(PBEKeySpec(PASSPHRASE.toCharArray(), decode(parts[2]), 120_000, 256))
|
||||||
|
.encoded,
|
||||||
|
"AES",
|
||||||
|
)
|
||||||
|
val cipher = Cipher.getInstance("AES/GCM/NoPadding")
|
||||||
|
cipher.init(Cipher.DECRYPT_MODE, key, GCMParameterSpec(128, decode(parts[3])))
|
||||||
|
val json = cipher.doFinal(decode(parts[4])).toString(Charsets.UTF_8)
|
||||||
|
check(json == canonicalize(JsonParser.parseString(json))) {
|
||||||
|
"Config payload must use canonical JSON"
|
||||||
|
}
|
||||||
|
|
||||||
|
val appKey = string(json, "appKey")
|
||||||
|
val platformUrl = string(json, "serverUrl")
|
||||||
|
val packageName = optionalString(json, "packageName")
|
||||||
|
check(number(json, "schemaVersion") == 2L) { "Unsupported config schemaVersion" }
|
||||||
|
UUID.fromString(string(json, "configId"))
|
||||||
|
check(number(json, "revision") > 0L) { "Config revision must be positive" }
|
||||||
|
Instant.parse(string(json, "issuedAt"))
|
||||||
|
optionalString(json, "expiresAt")?.let {
|
||||||
|
check(Instant.parse(it).isAfter(Instant.now())) { "Config has expired at $it" }
|
||||||
|
}
|
||||||
|
check(appKey.isNotBlank() && platformUrl.isNotBlank()) {
|
||||||
|
"Config appKey/serverUrl must not be blank"
|
||||||
|
}
|
||||||
|
return Metadata(appKey, platformUrl, packageName, content)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun string(json: String, key: String): String =
|
||||||
|
optionalString(json, key) ?: error("Missing $key")
|
||||||
|
|
||||||
|
private fun optionalString(json: String, key: String): String? =
|
||||||
|
Regex(""""${Regex.escape(key)}"\s*:\s*"([^"]+)"""")
|
||||||
|
.find(json)?.groupValues?.get(1)
|
||||||
|
|
||||||
|
private fun number(json: String, key: String): Long =
|
||||||
|
Regex(""""${Regex.escape(key)}"\s*:\s*(\d+)""")
|
||||||
|
.find(json)?.groupValues?.get(1)?.toLongOrNull()
|
||||||
|
?: error("Missing $key")
|
||||||
|
|
||||||
|
private fun decode(value: String): ByteArray {
|
||||||
|
val padded = value.replace('-', '+').replace('_', '/')
|
||||||
|
.let { it + "=".repeat((4 - it.length % 4) % 4) }
|
||||||
|
return Base64.getDecoder().decode(padded)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun canonicalize(element: JsonElement): String = when {
|
||||||
|
element.isJsonObject -> element.asJsonObject.entrySet()
|
||||||
|
.asSequence()
|
||||||
|
.filterNot { it.value.isJsonNull }
|
||||||
|
.sortedBy { it.key }
|
||||||
|
.joinToString(separator = ",", prefix = "{", postfix = "}") { (key, value) ->
|
||||||
|
"${Gson().toJson(key)}:${canonicalize(value)}"
|
||||||
|
}
|
||||||
|
element.isJsonArray -> element.asJsonArray.joinToString(
|
||||||
|
separator = ",",
|
||||||
|
prefix = "[",
|
||||||
|
postfix = "]",
|
||||||
|
transform = ::canonicalize,
|
||||||
|
)
|
||||||
|
else -> element.toString()
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,55 @@
|
|||||||
|
package com.xuqm.sdk.common.gradle
|
||||||
|
|
||||||
|
import org.gradle.api.GradleException
|
||||||
|
import org.junit.Assert.assertThrows
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class ReleaseConfigValidatorTest {
|
||||||
|
@Test
|
||||||
|
fun `all rejected config codes fail release`() {
|
||||||
|
listOf("CONFIG_REVOKED", "CONFIG_EXPIRED", "PACKAGE_MISMATCH").forEach { code ->
|
||||||
|
assertThrows(GradleException::class.java) {
|
||||||
|
ReleaseConfigValidator.verifyResponse(
|
||||||
|
200,
|
||||||
|
"""{"data":{"valid":false,"errorCode":"$code"},"message":"rejected"}""",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `valid response passes`() {
|
||||||
|
ReleaseConfigValidator.verifyResponse(200, """{"data":{"valid":true}}""")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `valid text inside message cannot forge acceptance`() {
|
||||||
|
assertThrows(GradleException::class.java) {
|
||||||
|
ReleaseConfigValidator.verifyResponse(
|
||||||
|
200,
|
||||||
|
"""{"message":"diagnostic: \"valid\":true","data":{"valid":false}}""",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `top level valid is rejected`() {
|
||||||
|
assertThrows(GradleException::class.java) {
|
||||||
|
ReleaseConfigValidator.verifyResponse(200, """{"valid":true,"data":{}}""")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `non object data is rejected`() {
|
||||||
|
assertThrows(GradleException::class.java) {
|
||||||
|
ReleaseConfigValidator.verifyResponse(200, """{"data":"{\"valid\":true}"}""")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `string valid is rejected`() {
|
||||||
|
assertThrows(GradleException::class.java) {
|
||||||
|
ReleaseConfigValidator.verifyResponse(200, """{"data":{"valid":"true"}}""")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,54 @@
|
|||||||
|
package com.xuqm.sdk.common.gradle
|
||||||
|
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
import java.io.File
|
||||||
|
import kotlin.io.path.createTempDirectory
|
||||||
|
|
||||||
|
class XuqmConfigLocationTest {
|
||||||
|
@Test
|
||||||
|
fun `only exact main config path is authoritative`() {
|
||||||
|
val projectDir = createTempDirectory("xuqm-config-location-").toFile()
|
||||||
|
try {
|
||||||
|
val main = XuqmConfigLocation.mainConfig(projectDir)
|
||||||
|
main.parentFile.mkdirs()
|
||||||
|
main.writeText("main")
|
||||||
|
assertEquals(main, XuqmConfigLocation.mainConfig(projectDir))
|
||||||
|
assertTrue(XuqmConfigLocation.forbiddenConfigs(projectDir).isEmpty())
|
||||||
|
|
||||||
|
val debug = File(projectDir, "src/debug/${XuqmConfigContract.RELATIVE_PATH}")
|
||||||
|
val renamed = File(projectDir, "src/main/assets/config/tenant.xuqmconfig")
|
||||||
|
val nested = File(projectDir, "src/main/assets/extra/config.xuqmconfig")
|
||||||
|
val sourceRootFile = File(projectDir, "src/custom.xuqmconfig")
|
||||||
|
debug.parentFile.mkdirs()
|
||||||
|
debug.writeText("debug")
|
||||||
|
renamed.parentFile.mkdirs()
|
||||||
|
renamed.writeText("renamed")
|
||||||
|
nested.parentFile.mkdirs()
|
||||||
|
nested.writeText("nested")
|
||||||
|
sourceRootFile.writeText("source-root")
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
listOf(sourceRootFile, debug, renamed, nested).sortedBy(File::getPath),
|
||||||
|
XuqmConfigLocation.forbiddenConfigs(projectDir),
|
||||||
|
)
|
||||||
|
} finally {
|
||||||
|
projectDir.deleteRecursively()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `build output is outside source scan`() {
|
||||||
|
val projectDir = createTempDirectory("xuqm-config-location-build-").toFile()
|
||||||
|
try {
|
||||||
|
val generated = File(projectDir, "build/generated/config.xuqmconfig")
|
||||||
|
generated.parentFile.mkdirs()
|
||||||
|
generated.writeText("generated")
|
||||||
|
|
||||||
|
assertTrue(XuqmConfigLocation.forbiddenConfigs(projectDir).isEmpty())
|
||||||
|
} finally {
|
||||||
|
projectDir.deleteRecursively()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -10,21 +10,21 @@ implementation("com.xuqm:sdk-core:VERSION")
|
|||||||
|
|
||||||
## 初始化
|
## 初始化
|
||||||
|
|
||||||
### 方式 A — ContentProvider 自动初始化(推荐)
|
### 配置文件自动初始化
|
||||||
|
|
||||||
将 `config.xuqm` 放入 `src/main/assets/xuqm/`,SDK 在 App 启动时自动读取并初始化。
|
从租户平台下载签名的 `XUQM-CONFIG-V2` 文件,原样放入:
|
||||||
|
|
||||||
```kotlin
|
```text
|
||||||
// XuqmInitializerProvider 自动触发,无需代码
|
app/src/main/assets/config/config.xuqmconfig
|
||||||
```
|
```
|
||||||
|
|
||||||
### 方式 B — 手动初始化
|
宿主不需要编写初始化代码,也不得修改、复制或自行生成该文件。SDK 先验证平台
|
||||||
|
Ed25519 签名,再解密引导配置。V1、未知 keyId、签名错误和包名不匹配均拒绝使用。
|
||||||
|
`serverUrl` 是签发配置中的唯一平台地址且必填,运行时没有默认地址或跨平台回退。
|
||||||
|
|
||||||
```kotlin
|
只依赖 `sdk-core` 时不会注册初始化 Provider、申请通知权限或合并 FileProvider,
|
||||||
// Application.onCreate() 中:
|
基础 HTTP、下载、摘要、MediaStore、时间和安全存储能力无需初始化或登录。引入扩展 SDK 后才由扩展
|
||||||
XuqmSDK.initialize(context, appKey = "xxx")
|
Manifest 合并唯一初始化 Provider。
|
||||||
XuqmSDK.initialize(context, appKey = "xxx", platformUrl = "https://xxx")
|
|
||||||
```
|
|
||||||
|
|
||||||
## API
|
## API
|
||||||
|
|
||||||
@ -32,9 +32,10 @@ XuqmSDK.initialize(context, appKey = "xxx", platformUrl = "https://xxx")
|
|||||||
|
|
||||||
| API | 说明 |
|
| API | 说明 |
|
||||||
|-----|------|
|
|-----|------|
|
||||||
| `XuqmSDK.initialize(context, appKey, platformUrl?, logLevel?)` | 手动初始化 |
|
|
||||||
| `XuqmSDK.awaitInitialization()` | 等待平台配置拉取(coroutine) |
|
| `XuqmSDK.awaitInitialization()` | 等待平台配置拉取(coroutine) |
|
||||||
| `XuqmSDK.isInitialized()` | 检查是否已初始化 |
|
| `XuqmSDK.isInitialized()` | 检查是否已初始化 |
|
||||||
|
| `XuqmSDK.initializationStatus` | `IDLE / INITIALIZING / READY / DEGRADED / FAILED` |
|
||||||
|
| `XuqmSDK.setPrivacyConsent(granted)` | 同步宿主隐私授权;SDK 不自行弹窗 |
|
||||||
| `XuqmSDK.setUserInfo(info)` | 设置用户信息,触发所有子模块登录 |
|
| `XuqmSDK.setUserInfo(info)` | 设置用户信息,触发所有子模块登录 |
|
||||||
| `XuqmSDK.setUserInfo(null)` | 登出,触发全局登出 |
|
| `XuqmSDK.setUserInfo(null)` | 登出,触发全局登出 |
|
||||||
| `XuqmSDK.getUserId()` | 获取当前 userId |
|
| `XuqmSDK.getUserId()` | 获取当前 userId |
|
||||||
@ -43,8 +44,7 @@ XuqmSDK.initialize(context, appKey = "xxx", platformUrl = "https://xxx")
|
|||||||
| `XuqmSDK.platformConfig` | 平台配置(init 后可用) |
|
| `XuqmSDK.platformConfig` | 平台配置(init 后可用) |
|
||||||
| `XuqmSDK.bugCollectApiUrl` | Bug 收集服务地址(从平台配置获取) |
|
| `XuqmSDK.bugCollectApiUrl` | Bug 收集服务地址(从平台配置获取) |
|
||||||
| `XuqmSDK.bugCollectEnabled` | 是否启用 Bug 收集上报 |
|
| `XuqmSDK.bugCollectEnabled` | 是否启用 Bug 收集上报 |
|
||||||
| `XuqmSDK.useLocalServiceEndpoints(ip)` | 切换本地联调环境 |
|
| `XuqmSDK.tokenStore` | Token 存储(Android Keystore + AES-256-GCM) |
|
||||||
| `XuqmSDK.tokenStore` | Token 存储(EncryptedSharedPreferences) |
|
|
||||||
|
|
||||||
### XuqmUserInfo
|
### XuqmUserInfo
|
||||||
|
|
||||||
@ -60,7 +60,8 @@ data class XuqmUserInfo(
|
|||||||
|
|
||||||
### TokenStore
|
### TokenStore
|
||||||
|
|
||||||
基于 `EncryptedSharedPreferences` 持久化存储。
|
基于统一的 `SecureStore` 持久化:密钥由 Android Keystore 生成且不可导出,
|
||||||
|
值使用 AES-256-GCM 加密并通过 namespace/key 关联数据认证。
|
||||||
|
|
||||||
```kotlin
|
```kotlin
|
||||||
XuqmSDK.tokenStore.saveToken("eyJ...")
|
XuqmSDK.tokenStore.saveToken("eyJ...")
|
||||||
@ -68,26 +69,15 @@ val token = XuqmSDK.tokenStore.getToken()
|
|||||||
XuqmSDK.tokenStore.clear()
|
XuqmSDK.tokenStore.clear()
|
||||||
```
|
```
|
||||||
|
|
||||||
### ApiClient
|
|
||||||
|
|
||||||
基于 OkHttp 5 + Retrofit 3,自动附加 Bearer Token。
|
|
||||||
|
|
||||||
```kotlin
|
|
||||||
val service = RetrofitFactory.create(MyApiService::class.java)
|
|
||||||
```
|
|
||||||
|
|
||||||
### FileSDK
|
### FileSDK
|
||||||
|
|
||||||
文件上传、下载、打开的统一入口(`com.xuqm.sdk.file`)。
|
`FileSDK` 只保留无需初始化、零 Manifest 的本地下载、摘要与 MediaStore 能力。
|
||||||
|
依赖租户平台的上传和依赖 FileProvider 的打开文件能力属于独立 `sdk-file`,
|
||||||
|
统一通过 `PlatformFileSDK` 调用。
|
||||||
|
|
||||||
```kotlin
|
```kotlin
|
||||||
// 上传
|
|
||||||
val result = FileSDK.upload(context, uri, onProgress = { /* 0-100 */ })
|
|
||||||
val result = FileSDK.uploadBytes(fileName, mimeType, bytes, onProgress)
|
|
||||||
|
|
||||||
// 下载
|
// 下载
|
||||||
val file = FileSDK.download(context, url, fileName, destination, notificationTitle, onProgress)
|
val file = FileSDK.download(context, url, fileName, destination, notificationTitle, onProgress)
|
||||||
|
|
||||||
// 打开
|
|
||||||
FileSDK.openFile(context, file)
|
|
||||||
```
|
```
|
||||||
|
平台配置成功后以加密方式保存七天 LKG。平台暂不可用且 LKG 有效时进入
|
||||||
|
`DEGRADED`;没有可用缓存时扩展能力返回结构化错误,宿主核心业务不受影响。
|
||||||
|
|||||||
@ -47,7 +47,7 @@ dependencies {
|
|||||||
api(libs.kotlinx.coroutines.android)
|
api(libs.kotlinx.coroutines.android)
|
||||||
api(libs.kotlinx.serialization.json)
|
api(libs.kotlinx.serialization.json)
|
||||||
api(libs.androidx.core.ktx)
|
api(libs.androidx.core.ktx)
|
||||||
api(libs.androidx.security.crypto)
|
implementation(libs.bouncycastle)
|
||||||
api(libs.androidx.ui)
|
api(libs.androidx.ui)
|
||||||
api(libs.androidx.material3)
|
api(libs.androidx.material3)
|
||||||
testImplementation(libs.junit4)
|
testImplementation(libs.junit4)
|
||||||
|
|||||||
@ -11,9 +11,6 @@
|
|||||||
|
|
||||||
# ── 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.ApiResponse { *; }
|
|
||||||
-keepclassmembers class com.xuqm.sdk.file.ApiResponse { *; }
|
|
||||||
|
|
||||||
# ── 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,26 +1 @@
|
|||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
<manifest />
|
||||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
|
||||||
|
|
||||||
<application>
|
|
||||||
<provider
|
|
||||||
android:name="androidx.core.content.FileProvider"
|
|
||||||
android:authorities="${applicationId}.fileprovider"
|
|
||||||
android:exported="false"
|
|
||||||
android:grantUriPermissions="true">
|
|
||||||
<meta-data
|
|
||||||
android:name="android.support.FILE_PROVIDER_PATHS"
|
|
||||||
android:resource="@xml/xuqm_file_paths" />
|
|
||||||
</provider>
|
|
||||||
|
|
||||||
<!--
|
|
||||||
合并启动入口:替代 core 与扩展 SDK 分别注册的初始化 Provider。
|
|
||||||
主线程只读文件字节(<10ms),PBKDF2 解密 + 初始化全部移到 IO 线程。
|
|
||||||
initOrder=50 尽早执行,但不阻塞主线程。
|
|
||||||
-->
|
|
||||||
<provider
|
|
||||||
android:name="com.xuqm.sdk.internal.XuqmMergedProvider"
|
|
||||||
android:authorities="${applicationId}.xuqm-init"
|
|
||||||
android:exported="false"
|
|
||||||
android:initOrder="50" />
|
|
||||||
</application>
|
|
||||||
</manifest>
|
|
||||||
|
|||||||
@ -0,0 +1,34 @@
|
|||||||
|
package com.xuqm.sdk
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SDK 初始化状态。
|
||||||
|
*
|
||||||
|
* [DEGRADED] 表示租户平台暂不可用,但已使用仍在有效期内的最后成功配置;
|
||||||
|
* 此时宿主核心业务可以继续运行,扩展 SDK 应自行决定是否具备执行条件。
|
||||||
|
*/
|
||||||
|
enum class XuqmInitializationState {
|
||||||
|
IDLE,
|
||||||
|
INITIALIZING,
|
||||||
|
READY,
|
||||||
|
DEGRADED,
|
||||||
|
FAILED,
|
||||||
|
}
|
||||||
|
|
||||||
|
/** SDK 的统一结构化错误码。 */
|
||||||
|
enum class XuqmErrorCode {
|
||||||
|
XUQM_NOT_READY,
|
||||||
|
XUQM_NOT_LOGGED_IN,
|
||||||
|
XUQM_CONFIG_INVALID,
|
||||||
|
XUQM_PLATFORM_UNAVAILABLE,
|
||||||
|
}
|
||||||
|
|
||||||
|
class XuqmSdkException(
|
||||||
|
val code: XuqmErrorCode,
|
||||||
|
message: String,
|
||||||
|
cause: Throwable? = null,
|
||||||
|
) : IllegalStateException(message, cause)
|
||||||
|
|
||||||
|
data class XuqmInitializationStatus(
|
||||||
|
val state: XuqmInitializationState = XuqmInitializationState.IDLE,
|
||||||
|
val error: XuqmSdkException? = null,
|
||||||
|
)
|
||||||
@ -11,23 +11,27 @@ import com.google.gson.Gson
|
|||||||
import com.xuqm.sdk.internal.ConfigFile
|
import com.xuqm.sdk.internal.ConfigFile
|
||||||
import com.xuqm.sdk.internal.ConfigFileCrypto
|
import com.xuqm.sdk.internal.ConfigFileCrypto
|
||||||
import com.xuqm.sdk.internal.ConfigFileReader
|
import com.xuqm.sdk.internal.ConfigFileReader
|
||||||
|
import com.xuqm.sdk.internal.ConfigCache
|
||||||
|
import com.xuqm.sdk.internal.PlatformConfigCache
|
||||||
|
import com.xuqm.sdk.internal.BugCollectRuntimePolicy
|
||||||
import com.xuqm.sdk.network.ApiClient
|
import com.xuqm.sdk.network.ApiClient
|
||||||
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
|
||||||
|
import kotlinx.coroutines.CancellationException
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.SupervisorJob
|
import kotlinx.coroutines.SupervisorJob
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlin.math.pow
|
||||||
|
import kotlin.random.Random
|
||||||
|
import java.util.concurrent.atomic.AtomicLong
|
||||||
|
|
||||||
object XuqmSDK {
|
object XuqmSDK {
|
||||||
|
|
||||||
private const val TAG = "XuqmSDK"
|
private const val TAG = "XuqmSDK"
|
||||||
|
|
||||||
/** 内置默认公有平台地址。使用私有化部署时必须显式传入 platformUrl,不会自动降级到此地址。 */
|
|
||||||
const val DEFAULT_PLATFORM_URL = "https://www.51szyx.com/"
|
|
||||||
|
|
||||||
lateinit var config: SDKConfig
|
lateinit var config: SDKConfig
|
||||||
private set
|
private set
|
||||||
|
|
||||||
@ -42,7 +46,13 @@ object XuqmSDK {
|
|||||||
|
|
||||||
private var initialized = false
|
private var initialized = false
|
||||||
@Volatile private var initializedAppKey: String? = null
|
@Volatile private var initializedAppKey: String? = null
|
||||||
@Volatile private var resolvedPlatformUrl: String = DEFAULT_PLATFORM_URL
|
@Volatile private var resolvedPlatformUrl: String = ""
|
||||||
|
|
||||||
|
@Volatile
|
||||||
|
private var initializationStatusValue = XuqmInitializationStatus()
|
||||||
|
|
||||||
|
/** 当前初始化状态的不可变快照。 */
|
||||||
|
val initializationStatus: XuqmInitializationStatus get() = initializationStatusValue
|
||||||
|
|
||||||
/** 初始化时使用的平台地址(同步可用,远端配置到达前即有值)。 */
|
/** 初始化时使用的平台地址(同步可用,远端配置到达前即有值)。 */
|
||||||
val platformUrl: String get() = resolvedPlatformUrl
|
val platformUrl: String get() = resolvedPlatformUrl
|
||||||
@ -53,6 +63,7 @@ object XuqmSDK {
|
|||||||
|
|
||||||
@Volatile private var loginSession: XuqmLoginSession? = null
|
@Volatile private var loginSession: XuqmLoginSession? = null
|
||||||
@Volatile private var userInfoValue: XuqmUserInfo? = null
|
@Volatile private var userInfoValue: XuqmUserInfo? = null
|
||||||
|
private val userStateGeneration = AtomicLong(0L)
|
||||||
@Volatile var platformConfig: SdkPlatformConfig? = null
|
@Volatile var platformConfig: SdkPlatformConfig? = null
|
||||||
private set
|
private set
|
||||||
|
|
||||||
@ -64,11 +75,21 @@ object XuqmSDK {
|
|||||||
@Volatile var bugCollectEnabled: Boolean = false
|
@Volatile var bugCollectEnabled: Boolean = false
|
||||||
private set
|
private set
|
||||||
|
|
||||||
|
/** 构建时写入且运行期间不可变;应急包从进程启动起就禁止采集。 */
|
||||||
|
@Volatile private var bugCollectBuildEnabled: Boolean = false
|
||||||
|
/** Debug 自动上报固定关闭;显式开发测试不受此自动门禁影响。 */
|
||||||
|
@Volatile private var bugCollectAutomaticEnabled: Boolean = false
|
||||||
|
|
||||||
|
/** 宿主已取得用户隐私授权时为 true;默认不采集任何 BugCollect 数据。 */
|
||||||
|
@Volatile var privacyConsentGranted: Boolean = false
|
||||||
|
private set
|
||||||
|
|
||||||
/** 请求签名密钥,从配置文件读取。 */
|
/** 请求签名密钥,从配置文件读取。 */
|
||||||
@Volatile var signingKey: String? = null
|
@Volatile var signingKey: String? = null
|
||||||
private set
|
private set
|
||||||
|
|
||||||
private val pendingInitCallbacks = mutableListOf<() -> Unit>()
|
private val pendingInitCallbacks = mutableListOf<() -> Unit>()
|
||||||
|
private val pendingReadyCallbacks = mutableListOf<() -> Unit>()
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 当前远程配置拉取任务。失败时 completeExceptionally,[awaitInitialization] 会重新抛出。
|
* 当前远程配置拉取任务。失败时 completeExceptionally,[awaitInitialization] 会重新抛出。
|
||||||
@ -78,109 +99,67 @@ object XuqmSDK {
|
|||||||
|
|
||||||
// ── 初始化 ─────────────────────────────────────────────────────────────────
|
// ── 初始化 ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
|
||||||
* 方式 A:配置文件自动初始化(推荐)。
|
|
||||||
*
|
|
||||||
* 将 config.xuqm 放入 `src/main/assets/xuqm/`,SDK 在模块加载时自动读取并初始化。
|
|
||||||
* 配置文件包含 `appKey` 和可选的 `serverUrl`(私有化部署平台地址)。
|
|
||||||
* App 无需调用任何初始化代码。
|
|
||||||
*/
|
|
||||||
/**
|
|
||||||
* 仅读取 config 文件中的 appKey,不做任何初始化。
|
|
||||||
* 用于 ContentProvider 等需要快速获取 appKey 的场景。
|
|
||||||
*/
|
|
||||||
fun readAppKey(context: Context): String? {
|
|
||||||
return runCatching { ConfigFileReader.read(context.applicationContext)?.appKey }.getOrNull()
|
|
||||||
}
|
|
||||||
|
|
||||||
fun autoInitialize(context: Context, logLevel: LogLevel = LogLevel.WARN) {
|
|
||||||
val configFile = ConfigFileReader.read(context)
|
|
||||||
?: throw IllegalStateException(
|
|
||||||
"No config file found in assets/xuqm/. " +
|
|
||||||
"Download config.xuqm from the tenant platform and place it in src/main/assets/xuqm/."
|
|
||||||
)
|
|
||||||
val configPackageName = configFile.packageName
|
|
||||||
if (!configPackageName.isNullOrBlank() && configPackageName != context.packageName) {
|
|
||||||
throw IllegalStateException(
|
|
||||||
"Config package name mismatch: config=$configPackageName, local=${context.packageName}. " +
|
|
||||||
"Please download the correct config file for this app."
|
|
||||||
)
|
|
||||||
}
|
|
||||||
signingKey = configFile.signingKey
|
|
||||||
initialize(context, configFile.appKey, configFile.serverUrl, logLevel)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 异步自动初始化 — ContentProvider 调用,不阻塞主线程。
|
|
||||||
* 同步阶段只读取 config 文件(~5ms),TokenStore / DeviceId / ApiClient 等重活移到 IO 线程。
|
|
||||||
*/
|
|
||||||
fun autoInitializeAsync(context: Context, logLevel: LogLevel = LogLevel.WARN) {
|
|
||||||
val ctx = context.applicationContext
|
|
||||||
// 同步:读取 config(快,<10ms)
|
|
||||||
val configFile = ConfigFileReader.read(ctx)
|
|
||||||
if (configFile == null) {
|
|
||||||
Log.w("XuqmSDK", "autoInitializeAsync: no config file, skipping")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
val configPackageName = configFile.packageName
|
|
||||||
if (!configPackageName.isNullOrBlank() && configPackageName != ctx.packageName) {
|
|
||||||
Log.w("XuqmSDK", "autoInitializeAsync: package name mismatch, skipping")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
signingKey = configFile.signingKey
|
|
||||||
// 异步:TokenStore / DeviceId / ApiClient / 远端配置
|
|
||||||
sdkScope.launch {
|
|
||||||
runCatching {
|
|
||||||
initialize(ctx, configFile.appKey, configFile.serverUrl, logLevel)
|
|
||||||
}.onFailure { e ->
|
|
||||||
Log.w("XuqmSDK", "autoInitializeAsync background init failed: ${e.message}")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 合并 Provider 调用入口 — 主线程已读好原始字节,IO 线程完成解密+初始化。
|
* 合并 Provider 调用入口 — 主线程已读好原始字节,IO 线程完成解密+初始化。
|
||||||
*
|
*
|
||||||
* 与 [autoInitializeAsync] 的区别:
|
* 这是运行时唯一初始化入口。appKey 与平台 serverUrl 均来自平台签发的
|
||||||
* - 原始字节由调用方传入(主线程只做文件读取,不做 PBKDF2)
|
* `src/main/assets/config/config.xuqmconfig`,宿主不能传参覆盖。
|
||||||
* - 在 IO 线程直接解密,不使用磁盘缓存,signingKey 始终完整读取
|
*
|
||||||
* - 复用 sdkScope,不创建冗余 CoroutineScope
|
* Provider 在主线程只读取原始字节;验签、解密和缓存处理统一在 IO 线程执行,
|
||||||
* - 初始化完成后自动触发 BugCollect 等扩展 SDK 启动
|
* 初始化完成后再通知 BugCollect 等扩展 SDK。
|
||||||
*/
|
*/
|
||||||
fun autoInitializeFromBytes(context: Context, rawBytes: ByteArray, logLevel: LogLevel = LogLevel.WARN) {
|
@JvmSynthetic
|
||||||
|
internal fun autoInitializeFromBytes(
|
||||||
|
context: Context,
|
||||||
|
rawBytes: ByteArray,
|
||||||
|
logLevel: LogLevel = LogLevel.WARN,
|
||||||
|
) {
|
||||||
val ctx = context.applicationContext
|
val ctx = context.applicationContext
|
||||||
|
if (isInitialized()) return
|
||||||
|
updateInitializationStatus(XuqmInitializationState.INITIALIZING)
|
||||||
sdkScope.launch {
|
sdkScope.launch {
|
||||||
runCatching {
|
runCatching {
|
||||||
// 直接解密传入的字节(在 IO 线程执行 PBKDF2,不阻塞主线程,~100ms 可接受)
|
// 直接解密传入的字节(在 IO 线程执行 PBKDF2,不阻塞主线程,~100ms 可接受)
|
||||||
val configFile = runCatching {
|
val configFile = runCatching {
|
||||||
|
ConfigCache.load(ctx, rawBytes) ?: run {
|
||||||
val json = ConfigFileCrypto.decrypt(String(rawBytes, Charsets.UTF_8))
|
val json = ConfigFileCrypto.decrypt(String(rawBytes, Charsets.UTF_8))
|
||||||
Gson().fromJson(json, ConfigFile::class.java)
|
Gson().fromJson(json, ConfigFile::class.java)
|
||||||
|
.also(ConfigFileReader::validate)
|
||||||
|
.also { ConfigCache.save(ctx, rawBytes, it) }
|
||||||
|
}
|
||||||
}.getOrElse {
|
}.getOrElse {
|
||||||
Log.w(TAG, "autoInitializeFromBytes: failed to decrypt config: ${it.message}")
|
throw XuqmSdkException(
|
||||||
return@launch
|
XuqmErrorCode.XUQM_CONFIG_INVALID,
|
||||||
|
"无法验证或解密 config.xuqmconfig",
|
||||||
|
it,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
val configPackageName = configFile.packageName
|
val configPackageName = configFile.packageName
|
||||||
if (!configPackageName.isNullOrBlank() && configPackageName != ctx.packageName) {
|
if (!configPackageName.isNullOrBlank() && configPackageName != ctx.packageName) {
|
||||||
Log.w(TAG, "autoInitializeFromBytes: package name mismatch, skipping")
|
throw XuqmSdkException(
|
||||||
return@launch
|
XuqmErrorCode.XUQM_CONFIG_INVALID,
|
||||||
|
"配置包名与宿主包名不匹配",
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
signingKey = configFile.signingKey
|
signingKey = configFile.signingKey
|
||||||
Log.i(TAG, "Config loaded: appKey=${configFile.appKey} signingKey=${if (configFile.signingKey.isNullOrBlank()) "null" else "[set]"}")
|
val signingConfigured = !configFile.signingKey.isNullOrBlank()
|
||||||
|
Log.i(TAG, "Config loaded: signingConfigured=$signingConfigured")
|
||||||
|
|
||||||
// 2. 初始化 sdk-core
|
// 2. 初始化 sdk-core
|
||||||
initialize(ctx, configFile.appKey, configFile.serverUrl, logLevel)
|
initializeVerifiedConfig(ctx, configFile.appKey, configFile.serverUrl, logLevel)
|
||||||
|
|
||||||
// 3. 初始化 sdk-bugcollect(如果存在)
|
|
||||||
runCatching {
|
|
||||||
initBugCollect(ctx, configFile.appKey)
|
|
||||||
}.onFailure { e ->
|
|
||||||
Log.w(TAG, "BugCollect init failed: ${e.message}")
|
|
||||||
}
|
|
||||||
|
|
||||||
}.onFailure { e ->
|
}.onFailure { e ->
|
||||||
Log.w(TAG, "autoInitializeFromBytes failed: ${e.message}")
|
updateInitializationStatus(
|
||||||
|
XuqmInitializationState.FAILED,
|
||||||
|
XuqmSdkException(
|
||||||
|
XuqmErrorCode.XUQM_CONFIG_INVALID,
|
||||||
|
"SDK 配置文件读取失败",
|
||||||
|
e,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
Log.w(TAG, "Automatic initialization failed: errorType=${e.javaClass.simpleName}")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -188,36 +167,33 @@ object XuqmSDK {
|
|||||||
/**
|
/**
|
||||||
* 通过反射初始化 sdk-bugcollect,避免 sdk-core 对 sdk-bugcollect 的编译时依赖。
|
* 通过反射初始化 sdk-bugcollect,避免 sdk-core 对 sdk-bugcollect 的编译时依赖。
|
||||||
*/
|
*/
|
||||||
private fun initBugCollect(context: Context, appKey: String) {
|
private fun notifyBugCollectConfigurationChanged() {
|
||||||
val clazz = runCatching {
|
val clazz = runCatching {
|
||||||
Class.forName("com.xuqm.sdk.bugcollect.BugCollect")
|
Class.forName("com.xuqm.sdk.bugcollect.BugCollect")
|
||||||
}.getOrNull() ?: return
|
}.getOrNull() ?: return
|
||||||
|
|
||||||
// Kotlin object 单例:通过 INSTANCE 字段获取实例
|
runCatching {
|
||||||
val instance = clazz.getField("INSTANCE").get(null)
|
val instance = clazz.getField("INSTANCE").get(null)
|
||||||
val startMethod = clazz.getMethod("startCrashCapture")
|
clazz.getDeclaredMethod("onSdkConfigurationChanged").apply {
|
||||||
startMethod.invoke(instance)
|
isAccessible = true
|
||||||
|
}.invoke(instance)
|
||||||
|
}.onFailure { error ->
|
||||||
|
Log.w(
|
||||||
|
TAG,
|
||||||
|
"BugCollect configuration callback failed: errorType=${error.javaClass.simpleName}",
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** 已验签配置进入运行时的唯一内部入口。 */
|
||||||
* 方式 B:手动初始化。
|
private fun initializeVerifiedConfig(
|
||||||
*
|
|
||||||
* 调用后立即完成本地初始化,并在后台异步拉取平台服务配置。
|
|
||||||
* 使用子 SDK 前建议先 `await XuqmSDK.awaitInitialization()` 确保配置已就绪。
|
|
||||||
*
|
|
||||||
* @param platformUrl 平台地址(可选)。
|
|
||||||
* - 不传:使用内置公有平台地址([DEFAULT_PLATFORM_URL])。
|
|
||||||
* - 传入:使用指定私有化部署平台地址。
|
|
||||||
* **私有化与公有平台互相独立,不允许在两者之间自动降级。**
|
|
||||||
* @throws IllegalStateException 若已使用不同 appKey 初始化过,或 appKey 为空。
|
|
||||||
*/
|
|
||||||
fun initialize(
|
|
||||||
context: Context,
|
context: Context,
|
||||||
appKey: String,
|
appKey: String,
|
||||||
platformUrl: String? = null,
|
platformUrl: String,
|
||||||
logLevel: LogLevel = LogLevel.WARN,
|
logLevel: LogLevel = LogLevel.WARN,
|
||||||
) {
|
) {
|
||||||
require(appKey.isNotBlank()) { "appKey must not be blank" }
|
require(appKey.isNotBlank()) { "appKey must not be blank" }
|
||||||
|
require(platformUrl.isNotBlank()) { "platformUrl must not be blank" }
|
||||||
val applicationContext = context.applicationContext
|
val applicationContext = context.applicationContext
|
||||||
val deferred: CompletableDeferred<Unit>
|
val deferred: CompletableDeferred<Unit>
|
||||||
synchronized(initLock) {
|
synchronized(initLock) {
|
||||||
@ -235,7 +211,13 @@ object XuqmSDK {
|
|||||||
ApiClient.init(config, tokenStore)
|
ApiClient.init(config, tokenStore)
|
||||||
initializedAppKey = appKey
|
initializedAppKey = appKey
|
||||||
initialized = true
|
initialized = true
|
||||||
resolvedPlatformUrl = platformUrl?.takeIf { it.isNotBlank() } ?: DEFAULT_PLATFORM_URL
|
resolvedPlatformUrl = platformUrl
|
||||||
|
privacyConsentGranted = applicationContext
|
||||||
|
.getSharedPreferences("xuqm_sdk_core", Context.MODE_PRIVATE)
|
||||||
|
.getBoolean("privacy_consent", false)
|
||||||
|
bugCollectBuildEnabled = resolveBugCollectBuildEnabled(applicationContext)
|
||||||
|
bugCollectAutomaticEnabled = resolveBugCollectAutomaticEnabled(applicationContext)
|
||||||
|
updateInitializationStatus(XuqmInitializationState.INITIALIZING)
|
||||||
deferred = CompletableDeferred()
|
deferred = CompletableDeferred()
|
||||||
remoteConfigDeferred = deferred
|
remoteConfigDeferred = deferred
|
||||||
pendingInitCallbacks.forEach { runCatching(it) }
|
pendingInitCallbacks.forEach { runCatching(it) }
|
||||||
@ -253,30 +235,42 @@ object XuqmSDK {
|
|||||||
* @throws Exception 平台配置拉取失败时抛出
|
* @throws Exception 平台配置拉取失败时抛出
|
||||||
*/
|
*/
|
||||||
suspend fun awaitInitialization() {
|
suspend fun awaitInitialization() {
|
||||||
remoteConfigDeferred?.await()
|
while (remoteConfigDeferred == null &&
|
||||||
|
initializationStatusValue.state == XuqmInitializationState.INITIALIZING
|
||||||
|
) {
|
||||||
|
delay(10)
|
||||||
|
}
|
||||||
|
initializationStatusValue.takeIf { it.state == XuqmInitializationState.FAILED }
|
||||||
|
?.error
|
||||||
|
?.let { throw it }
|
||||||
|
val deferred = remoteConfigDeferred
|
||||||
|
?: throw XuqmSdkException(
|
||||||
|
XuqmErrorCode.XUQM_NOT_READY,
|
||||||
|
"XuqmSDK 尚未开始初始化",
|
||||||
|
)
|
||||||
|
deferred.await()
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 重试平台配置拉取(例如首次因网络问题失败后,用户点击重试时调用)。
|
* 重试平台配置拉取(例如首次因网络问题失败后,用户点击重试时调用)。
|
||||||
*
|
*
|
||||||
* 只能在 [initialize] 已调用后使用。不会重置 appKey 或 platformUrl。
|
* 只能在签名配置已经触发自动初始化后使用。不会重置 appKey 或 platformUrl。
|
||||||
*
|
*
|
||||||
* @throws IllegalStateException 若 SDK 尚未调用 [initialize]
|
* @throws IllegalStateException 若 SDK 尚未开始自动初始化
|
||||||
* @throws Exception 重试仍失败时抛出
|
* @throws Exception 重试仍失败时抛出
|
||||||
*/
|
*/
|
||||||
suspend fun retryInitialization() {
|
suspend fun retryInitialization() {
|
||||||
check(initialized) { "XuqmSDK not initialized. Call XuqmSDK.initialize() first." }
|
check(initialized) { "XuqmSDK 尚未通过 config.xuqmconfig 自动初始化" }
|
||||||
val deferred = CompletableDeferred<Unit>()
|
val deferred = CompletableDeferred<Unit>()
|
||||||
synchronized(initLock) {
|
synchronized(initLock) {
|
||||||
remoteConfigDeferred = deferred
|
remoteConfigDeferred = deferred
|
||||||
|
updateInitializationStatus(XuqmInitializationState.INITIALIZING)
|
||||||
}
|
}
|
||||||
runCatching {
|
runCatching {
|
||||||
fetchAndApplyPlatformConfig(resolvedPlatformUrl, appKey)
|
fetchPlatformConfigWithRetry(resolvedPlatformUrl, appKey)
|
||||||
deferred.complete(Unit)
|
deferred.complete(Unit)
|
||||||
}.onFailure { e ->
|
}.onFailure { e ->
|
||||||
deferred.completeExceptionally(
|
completeFromCacheOrFail(resolvedPlatformUrl, appKey, deferred, e)
|
||||||
platformInitException(resolvedPlatformUrl, appKey, e)
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
deferred.await()
|
deferred.await()
|
||||||
}
|
}
|
||||||
@ -284,15 +278,32 @@ object XuqmSDK {
|
|||||||
private fun launchRemoteConfigFetch(platformUrl: String, appKey: String, deferred: CompletableDeferred<Unit>) {
|
private fun launchRemoteConfigFetch(platformUrl: String, appKey: String, deferred: CompletableDeferred<Unit>) {
|
||||||
sdkScope.launch {
|
sdkScope.launch {
|
||||||
runCatching {
|
runCatching {
|
||||||
fetchAndApplyPlatformConfig(platformUrl, appKey)
|
fetchPlatformConfigWithRetry(platformUrl, appKey)
|
||||||
deferred.complete(Unit)
|
deferred.complete(Unit)
|
||||||
}.onFailure { e ->
|
}.onFailure { e ->
|
||||||
// 不降级,直接将错误传递给 awaitInitialization() 的调用者
|
completeFromCacheOrFail(platformUrl, appKey, deferred, e)
|
||||||
deferred.completeExceptionally(platformInitException(platformUrl, appKey, e))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private suspend fun fetchPlatformConfigWithRetry(platformUrl: String, appKey: String) {
|
||||||
|
var lastError: Throwable? = null
|
||||||
|
repeat(3) { attempt ->
|
||||||
|
try {
|
||||||
|
fetchAndApplyPlatformConfig(platformUrl, appKey)
|
||||||
|
return
|
||||||
|
} catch (error: Throwable) {
|
||||||
|
if (error is CancellationException) throw error
|
||||||
|
lastError = error
|
||||||
|
if (attempt < 2) {
|
||||||
|
val base = 500.0 * 2.0.pow(attempt.toDouble())
|
||||||
|
delay((base + Random.nextLong(0L, 251L)).toLong())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw requireNotNull(lastError)
|
||||||
|
}
|
||||||
|
|
||||||
private suspend fun fetchAndApplyPlatformConfig(platformUrl: String, appKey: String) {
|
private suspend fun fetchAndApplyPlatformConfig(platformUrl: String, appKey: String) {
|
||||||
val base = platformUrl.trimEnd('/') + "/"
|
val base = platformUrl.trimEnd('/') + "/"
|
||||||
val api = ApiClient.create(SdkPlatformConfigApi::class.java, base)
|
val api = ApiClient.create(SdkPlatformConfigApi::class.java, base)
|
||||||
@ -302,9 +313,19 @@ object XuqmSDK {
|
|||||||
"Platform returned empty config for appKey=$appKey at $platformUrl. " +
|
"Platform returned empty config for appKey=$appKey at $platformUrl. " +
|
||||||
"Verify the appKey is registered on this platform."
|
"Verify the appKey is registered on this platform."
|
||||||
)
|
)
|
||||||
platformConfig = cfg
|
applyPlatformConfig(platformUrl, cfg, fromRemote = true)
|
||||||
|
PlatformConfigCache.save(appContext, appKey, platformUrl, cfg)
|
||||||
|
updateInitializationStatus(XuqmInitializationState.READY)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun applyPlatformConfig(
|
||||||
|
platformUrl: String,
|
||||||
|
cfg: SdkPlatformConfig,
|
||||||
|
fromRemote: Boolean,
|
||||||
|
) {
|
||||||
|
platformConfig = cfg
|
||||||
// 各服务地址优先使用平台下发的值,回退到同一平台的 base URL(绝不跨平台)
|
// 各服务地址优先使用平台下发的值,回退到同一平台的 base URL(绝不跨平台)
|
||||||
|
val base = platformUrl.trimEnd('/') + "/"
|
||||||
val wsBase = platformUrl.trimEnd('/')
|
val wsBase = platformUrl.trimEnd('/')
|
||||||
.replace("https://", "wss://")
|
.replace("https://", "wss://")
|
||||||
.replace("http://", "ws://")
|
.replace("http://", "ws://")
|
||||||
@ -320,12 +341,41 @@ object XuqmSDK {
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
bugCollectApiUrl = cfg.bugCollectApiUrl
|
bugCollectApiUrl = cfg.bugCollectApiUrl
|
||||||
bugCollectEnabled = cfg.features?.bugCollect ?: false
|
val prefs = appContext.getSharedPreferences("xuqm_sdk_core", Context.MODE_PRIVATE)
|
||||||
Log.i(
|
if (fromRemote) {
|
||||||
TAG,
|
// 只有成功取得的新平台配置才允许解除服务端停用锁。
|
||||||
"Platform config applied [${if (platformUrl == DEFAULT_PLATFORM_URL) "public" else "private"}]:" +
|
prefs.edit().remove("bugcollect_server_disabled").apply()
|
||||||
" apiBase=$apiBase imWsUrl=${cfg.imWsUrl} bugCollectEnabled=$bugCollectEnabled"
|
}
|
||||||
|
refreshBugCollectEnabled()
|
||||||
|
notifyBugCollectConfigurationChanged()
|
||||||
|
Log.i(TAG, "Platform config applied: bugCollectEnabled=$bugCollectEnabled")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun completeFromCacheOrFail(
|
||||||
|
platformUrl: String,
|
||||||
|
appKey: String,
|
||||||
|
deferred: CompletableDeferred<Unit>,
|
||||||
|
cause: Throwable,
|
||||||
|
) {
|
||||||
|
val cached = PlatformConfigCache.load(appContext, appKey, platformUrl)
|
||||||
|
if (cached != null) {
|
||||||
|
applyPlatformConfig(platformUrl, cached, fromRemote = false)
|
||||||
|
updateInitializationStatus(
|
||||||
|
XuqmInitializationState.DEGRADED,
|
||||||
|
XuqmSdkException(
|
||||||
|
XuqmErrorCode.XUQM_PLATFORM_UNAVAILABLE,
|
||||||
|
"租户平台暂不可用,当前使用最后一次成功配置",
|
||||||
|
cause,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
deferred.complete(Unit)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
bugCollectEnabled = false
|
||||||
|
notifyBugCollectConfigurationChanged()
|
||||||
|
val error = platformInitException(platformUrl, appKey, cause)
|
||||||
|
updateInitializationStatus(XuqmInitializationState.FAILED, error)
|
||||||
|
deferred.completeExceptionally(error)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun resolveDeviceId(context: Context): String {
|
private fun resolveDeviceId(context: Context): String {
|
||||||
@ -354,43 +404,62 @@ object XuqmSDK {
|
|||||||
return deviceId
|
return deviceId
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun platformInitException(platformUrl: String, appKey: String, cause: Throwable): Throwable {
|
private fun platformInitException(platformUrl: String, appKey: String, cause: Throwable): XuqmSdkException {
|
||||||
val kind = if (platformUrl == DEFAULT_PLATFORM_URL) "公有平台" else "私有化平台 $platformUrl"
|
return XuqmSdkException(
|
||||||
return IllegalStateException(
|
XuqmErrorCode.XUQM_PLATFORM_UNAVAILABLE,
|
||||||
"XuqmSDK 初始化失败:无法从${kind}获取服务配置(appKey=$appKey)。" +
|
"XuqmSDK 初始化失败:无法从配置平台 $platformUrl 获取服务配置" +
|
||||||
"私有化与公有平台互相独立,不自动降级。原因:${cause.message}",
|
"(appKey=$appKey)。SDK 不会切换或降级到其它地址。原因:${cause.message}",
|
||||||
cause
|
cause
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun updateInitializationStatus(
|
||||||
|
state: XuqmInitializationState,
|
||||||
|
error: XuqmSdkException? = null,
|
||||||
|
) {
|
||||||
|
initializationStatusValue = XuqmInitializationStatus(state, error)
|
||||||
|
if (state == XuqmInitializationState.READY || state == XuqmInitializationState.DEGRADED) {
|
||||||
|
val callbacks = synchronized(initLock) {
|
||||||
|
pendingReadyCallbacks.toList().also { pendingReadyCallbacks.clear() }
|
||||||
|
}
|
||||||
|
callbacks.forEach { runCatching(it) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ── 初始化后工具方法 ────────────────────────────────────────────────────────
|
// ── 初始化后工具方法 ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
fun afterInit(block: () -> Unit) {
|
fun afterInit(block: () -> Unit) {
|
||||||
synchronized(initLock) {
|
val executeNow = synchronized(initLock) {
|
||||||
if (initialized) block() else pendingInitCallbacks.add(block)
|
if (initialized) true else {
|
||||||
|
pendingInitCallbacks.add(block)
|
||||||
|
false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (executeNow) block()
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
private fun afterPlatformReady(block: () -> Unit) {
|
||||||
* 手动覆盖服务端点(用于本地联调)。
|
val executeNow = synchronized(initLock) {
|
||||||
* 注意:若 [awaitInitialization] 尚未完成,此调用的结果会被远程配置覆盖。
|
when (initializationStatusValue.state) {
|
||||||
*/
|
XuqmInitializationState.READY,
|
||||||
fun configureServiceEndpoints(endpoints: ServiceEndpoints) {
|
XuqmInitializationState.DEGRADED,
|
||||||
ServiceEndpointRegistry.configure(endpoints)
|
-> true
|
||||||
loginSession?.let { notifyOptionalModules("onSdkLogin", it) }
|
else -> {
|
||||||
|
pendingReadyCallbacks.add(block)
|
||||||
|
false
|
||||||
}
|
}
|
||||||
|
|
||||||
fun useExternalServiceEndpoints() {
|
|
||||||
configureServiceEndpoints(ServiceEndpoints())
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
fun useLocalServiceEndpoints(host: String) {
|
if (executeNow) block()
|
||||||
ServiceEndpointRegistry.useLocalhost(host)
|
|
||||||
loginSession?.let { notifyOptionalModules("onSdkLogin", it) }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun requireInit() {
|
fun requireInit() {
|
||||||
check(initialized) { "XuqmSDK not initialized. Call XuqmSDK.initialize() first." }
|
if (!initialized) {
|
||||||
|
throw XuqmSdkException(
|
||||||
|
XuqmErrorCode.XUQM_NOT_READY,
|
||||||
|
"XuqmSDK 尚未初始化",
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun isInitialized(): Boolean = synchronized(initLock) { initialized }
|
fun isInitialized(): Boolean = synchronized(initLock) { initialized }
|
||||||
@ -399,10 +468,6 @@ object XuqmSDK {
|
|||||||
|
|
||||||
val appKey: String get() = config.appKey
|
val appKey: String get() = config.appKey
|
||||||
|
|
||||||
/** 当前平台类型:"public" 或 "private:<url>" */
|
|
||||||
val platformType: String
|
|
||||||
get() = if (resolvedPlatformUrl == DEFAULT_PLATFORM_URL) "public" else "private:$resolvedPlatformUrl"
|
|
||||||
|
|
||||||
val currentLoginSession: XuqmLoginSession?
|
val currentLoginSession: XuqmLoginSession?
|
||||||
get() = loginSession
|
get() = loginSession
|
||||||
|
|
||||||
@ -410,6 +475,81 @@ object XuqmSDK {
|
|||||||
|
|
||||||
fun getUserId(): String? = userInfoValue?.userId
|
fun getUserId(): String? = userInfoValue?.userId
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 同步宿主的隐私授权状态。
|
||||||
|
*
|
||||||
|
* SDK 不展示隐私弹窗;未授权或撤回授权时,BugCollect 必须停止采集和上传,
|
||||||
|
* 并清除尚未上传的数据。
|
||||||
|
*/
|
||||||
|
fun setPrivacyConsent(granted: Boolean) {
|
||||||
|
if (privacyConsentGranted == granted) return
|
||||||
|
privacyConsentGranted = granted
|
||||||
|
if (::appContext.isInitialized) {
|
||||||
|
appContext.getSharedPreferences("xuqm_sdk_core", Context.MODE_PRIVATE)
|
||||||
|
.edit()
|
||||||
|
.putBoolean("privacy_consent", granted)
|
||||||
|
.apply()
|
||||||
|
}
|
||||||
|
refreshBugCollectEnabled()
|
||||||
|
notifyBugCollectConfigurationChanged()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* BugCollect 服务端明确返回停用业务码时调用。
|
||||||
|
*
|
||||||
|
* 该状态只影响当前已缓存的平台配置;后续成功拉取到明确启用的新配置后才会恢复。
|
||||||
|
*/
|
||||||
|
private fun markBugCollectDisabledByServer() {
|
||||||
|
if (::appContext.isInitialized) {
|
||||||
|
appContext.getSharedPreferences("xuqm_sdk_core", Context.MODE_PRIVATE)
|
||||||
|
.edit()
|
||||||
|
.putBoolean("bugcollect_server_disabled", true)
|
||||||
|
.apply()
|
||||||
|
}
|
||||||
|
platformConfig = platformConfig?.copy(
|
||||||
|
features = platformConfig?.features?.copy(bugCollect = false),
|
||||||
|
)
|
||||||
|
refreshBugCollectEnabled()
|
||||||
|
notifyBugCollectConfigurationChanged()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 仅供可选 BugCollect 模块通过反射调用,避免把内部状态迁移入口暴露为 SDK API。 */
|
||||||
|
private fun disableBugCollectFromExtension() {
|
||||||
|
markBugCollectDisabledByServer()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun resolveBugCollectBuildEnabled(context: Context): Boolean = runCatching {
|
||||||
|
context.packageManager.getApplicationInfo(
|
||||||
|
context.packageName,
|
||||||
|
android.content.pm.PackageManager.GET_META_DATA,
|
||||||
|
).metaData?.getBoolean("com.xuqm.bugcollect_build_enabled", false) == true
|
||||||
|
}.getOrDefault(false)
|
||||||
|
|
||||||
|
private fun resolveBugCollectAutomaticEnabled(context: Context): Boolean = runCatching {
|
||||||
|
context.packageManager.getApplicationInfo(
|
||||||
|
context.packageName,
|
||||||
|
android.content.pm.PackageManager.GET_META_DATA,
|
||||||
|
).metaData?.getBoolean("com.xuqm.bugcollect_automatic_enabled", false) == true
|
||||||
|
}.getOrDefault(false)
|
||||||
|
|
||||||
|
private fun refreshBugCollectEnabled() {
|
||||||
|
if (!::appContext.isInitialized) {
|
||||||
|
bugCollectEnabled = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val serverDisabled = appContext
|
||||||
|
.getSharedPreferences("xuqm_sdk_core", Context.MODE_PRIVATE)
|
||||||
|
.getBoolean("bugcollect_server_disabled", false)
|
||||||
|
bugCollectEnabled = BugCollectRuntimePolicy.isAutomaticReady(
|
||||||
|
buildEnabled = bugCollectBuildEnabled,
|
||||||
|
automaticEnabled = bugCollectAutomaticEnabled,
|
||||||
|
privacyGranted = privacyConsentGranted,
|
||||||
|
platformEnabled = platformConfig?.features?.bugCollect == true,
|
||||||
|
serverDisabled = serverDisabled,
|
||||||
|
hasUploadEndpoint = !bugCollectApiUrl.isNullOrBlank(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 设置用户信息 — 所有子 SDK 的统一认证入口,登录后调用一次即可。
|
* 设置用户信息 — 所有子 SDK 的统一认证入口,登录后调用一次即可。
|
||||||
*
|
*
|
||||||
@ -424,11 +564,12 @@ object XuqmSDK {
|
|||||||
* ```
|
* ```
|
||||||
*/
|
*/
|
||||||
fun setUserInfo(info: XuqmUserInfo?) {
|
fun setUserInfo(info: XuqmUserInfo?) {
|
||||||
|
val generation = userStateGeneration.incrementAndGet()
|
||||||
if (info == null) {
|
if (info == null) {
|
||||||
val hadSession = loginSession != null
|
val hadSession = loginSession != null
|
||||||
userInfoValue = null
|
userInfoValue = null
|
||||||
loginSession = null
|
loginSession = null
|
||||||
tokenStore.clear()
|
if (::tokenStore.isInitialized) tokenStore.clear()
|
||||||
if (hadSession) {
|
if (hadSession) {
|
||||||
notifyOptionalModulesSync("onSdkLogout")
|
notifyOptionalModulesSync("onSdkLogout")
|
||||||
}
|
}
|
||||||
@ -439,6 +580,8 @@ object XuqmSDK {
|
|||||||
// Defer session creation and sub-SDK notification until SDK is fully initialized;
|
// Defer session creation and sub-SDK notification until SDK is fully initialized;
|
||||||
// if already initialized, afterInit runs synchronously
|
// if already initialized, afterInit runs synchronously
|
||||||
afterInit {
|
afterInit {
|
||||||
|
// 初始化完成前可能已切换用户或登出;旧回调不得恢复过期身份。
|
||||||
|
if (generation != userStateGeneration.get()) return@afterInit
|
||||||
val session = XuqmLoginSession(
|
val session = XuqmLoginSession(
|
||||||
appKey = appKey,
|
appKey = appKey,
|
||||||
userId = info.userId,
|
userId = info.userId,
|
||||||
@ -446,29 +589,10 @@ object XuqmSDK {
|
|||||||
)
|
)
|
||||||
loginSession = session
|
loginSession = session
|
||||||
info.userSig?.takeIf { it.isNotBlank() }?.let { tokenStore.saveToken(it) }
|
info.userSig?.takeIf { it.isNotBlank() }?.let { tokenStore.saveToken(it) }
|
||||||
notifyOptionalModules("onSdkLogin", session)
|
afterPlatformReady {
|
||||||
|
if (loginSession == session) notifyOptionalModules("onSdkLogin", session)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Deprecated(
|
|
||||||
"Use setUserInfo(XuqmUserInfo) instead — it unifies authentication for all sub-SDKs.",
|
|
||||||
ReplaceWith("setUserInfo(XuqmUserInfo(userId = userId, userSig = userSig))")
|
|
||||||
)
|
|
||||||
suspend fun login(userId: String, userSig: String): XuqmLoginSession = withContext(Dispatchers.IO) {
|
|
||||||
requireInit()
|
|
||||||
loginSession?.takeIf {
|
|
||||||
it.appKey == appKey && it.userId == userId && it.userSig == userSig
|
|
||||||
}?.let { return@withContext it }
|
|
||||||
setUserInfo(XuqmUserInfo(userId = userId, userSig = userSig))
|
|
||||||
loginSession!!
|
|
||||||
}
|
|
||||||
|
|
||||||
@Deprecated(
|
|
||||||
"Use setUserInfo(null) instead.",
|
|
||||||
ReplaceWith("setUserInfo(null)")
|
|
||||||
)
|
|
||||||
fun logout() {
|
|
||||||
setUserInfo(null)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── 内部子 SDK 通知(反射)──────────────────────────────────────────────────
|
// ── 内部子 SDK 通知(反射)──────────────────────────────────────────────────
|
||||||
@ -482,11 +606,12 @@ object XuqmSDK {
|
|||||||
runCatching {
|
runCatching {
|
||||||
val clazz = Class.forName(className)
|
val clazz = Class.forName(className)
|
||||||
val instance = clazz.getField("INSTANCE").get(null)
|
val instance = clazz.getField("INSTANCE").get(null)
|
||||||
val method = clazz.methods.firstOrNull { method ->
|
val method = clazz.declaredMethods.firstOrNull { method ->
|
||||||
method.name == methodName &&
|
method.name == methodName &&
|
||||||
method.parameterTypes.size == 1 &&
|
method.parameterTypes.size == 1 &&
|
||||||
method.parameterTypes[0].name == XuqmLoginSession::class.java.name
|
method.parameterTypes[0].name == XuqmLoginSession::class.java.name
|
||||||
} ?: return@runCatching
|
} ?: return@runCatching
|
||||||
|
method.isAccessible = true
|
||||||
method.invoke(instance, session)
|
method.invoke(instance, session)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -501,11 +626,25 @@ object XuqmSDK {
|
|||||||
runCatching {
|
runCatching {
|
||||||
val clazz = Class.forName(className)
|
val clazz = Class.forName(className)
|
||||||
val instance = clazz.getField("INSTANCE").get(null)
|
val instance = clazz.getField("INSTANCE").get(null)
|
||||||
val method = clazz.methods.firstOrNull { method ->
|
val method = clazz.declaredMethods.firstOrNull { method ->
|
||||||
method.name == methodName && method.parameterTypes.isEmpty()
|
method.name == methodName && method.parameterTypes.isEmpty()
|
||||||
} ?: return@runCatching
|
} ?: return@runCatching
|
||||||
|
method.isAccessible = true
|
||||||
method.invoke(instance)
|
method.invoke(instance)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
notifyBugCollectLogout()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun notifyBugCollectLogout() {
|
||||||
|
val clazz = runCatching {
|
||||||
|
Class.forName("com.xuqm.sdk.bugcollect.BugCollect")
|
||||||
|
}.getOrNull() ?: return
|
||||||
|
runCatching {
|
||||||
|
val instance = clazz.getField("INSTANCE").get(null)
|
||||||
|
clazz.getDeclaredMethod("onSdkLogout").apply {
|
||||||
|
isAccessible = true
|
||||||
|
}.invoke(instance)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,29 +1,22 @@
|
|||||||
package com.xuqm.sdk.auth
|
package com.xuqm.sdk.auth
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import androidx.security.crypto.EncryptedSharedPreferences
|
import com.xuqm.sdk.storage.SecureStore
|
||||||
import androidx.security.crypto.MasterKeys
|
|
||||||
|
|
||||||
private const val PREFS_NAME = "xuqm_sdk_secure"
|
private const val NAMESPACE = "login_session"
|
||||||
private const val KEY_IM_TOKEN = "im_token"
|
private const val KEY_IM_TOKEN = "im_token"
|
||||||
|
|
||||||
class TokenStore(context: Context) {
|
class TokenStore(context: Context) {
|
||||||
|
|
||||||
private val prefs = EncryptedSharedPreferences.create(
|
private val store = SecureStore.open(context, NAMESPACE)
|
||||||
PREFS_NAME,
|
|
||||||
MasterKeys.getOrCreate(MasterKeys.AES256_GCM_SPEC),
|
|
||||||
context,
|
|
||||||
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
|
|
||||||
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
|
|
||||||
)
|
|
||||||
|
|
||||||
fun getToken(): String? = prefs.getString(KEY_IM_TOKEN, null)
|
fun getToken(): String? = store.getString(KEY_IM_TOKEN)
|
||||||
|
|
||||||
fun saveToken(token: String) {
|
fun saveToken(token: String) {
|
||||||
prefs.edit().putString(KEY_IM_TOKEN, token).apply()
|
store.putString(KEY_IM_TOKEN, token)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun clear() {
|
fun clear() {
|
||||||
prefs.edit().remove(KEY_IM_TOKEN).apply()
|
store.remove(KEY_IM_TOKEN)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,44 +1,32 @@
|
|||||||
package com.xuqm.sdk.core
|
package com.xuqm.sdk.core
|
||||||
|
|
||||||
data class ServiceEndpoints(
|
data class ServiceEndpoints(
|
||||||
val controlBaseUrl: String = "https://dev.xuqinmin.com/",
|
val controlBaseUrl: String,
|
||||||
val imApiBaseUrl: String = "https://im.dev.xuqinmin.com/",
|
val imApiBaseUrl: String,
|
||||||
val imWsUrl: String = "wss://im.dev.xuqinmin.com/ws/im",
|
val imWsUrl: String,
|
||||||
val pushBaseUrl: String = "https://dev.xuqinmin.com/",
|
val pushBaseUrl: String,
|
||||||
val updateBaseUrl: String = "https://update.dev.xuqinmin.com/",
|
val updateBaseUrl: String,
|
||||||
val fileBaseUrl: String = "https://file.dev.xuqinmin.com/",
|
val fileBaseUrl: String,
|
||||||
)
|
)
|
||||||
|
|
||||||
object ServiceEndpointRegistry {
|
object ServiceEndpointRegistry {
|
||||||
|
|
||||||
@Volatile
|
@Volatile
|
||||||
var current: ServiceEndpoints = ServiceEndpoints()
|
private var current: ServiceEndpoints? = null
|
||||||
private set
|
|
||||||
|
|
||||||
val controlBaseUrl: String get() = current.controlBaseUrl
|
val controlBaseUrl: String get() = requireConfigured().controlBaseUrl
|
||||||
val imApiBaseUrl: String get() = current.imApiBaseUrl
|
val imApiBaseUrl: String get() = requireConfigured().imApiBaseUrl
|
||||||
val imWsUrl: String get() = current.imWsUrl
|
val imWsUrl: String get() = requireConfigured().imWsUrl
|
||||||
val pushBaseUrl: String get() = current.pushBaseUrl
|
val pushBaseUrl: String get() = requireConfigured().pushBaseUrl
|
||||||
val updateBaseUrl: String get() = current.updateBaseUrl
|
val updateBaseUrl: String get() = requireConfigured().updateBaseUrl
|
||||||
val fileBaseUrl: String get() = current.fileBaseUrl
|
val fileBaseUrl: String get() = requireConfigured().fileBaseUrl
|
||||||
|
|
||||||
fun configure(endpoints: ServiceEndpoints) {
|
internal fun configure(endpoints: ServiceEndpoints) {
|
||||||
current = endpoints
|
current = endpoints
|
||||||
}
|
}
|
||||||
|
|
||||||
fun useLocalhost(host: String) {
|
private fun requireConfigured(): ServiceEndpoints =
|
||||||
val normalizedHost = host.removeSuffix("/")
|
requireNotNull(current) {
|
||||||
current = ServiceEndpoints(
|
"Xuqm SDK 服务地址尚未就绪,请先等待 XuqmSDK.awaitInitialization()"
|
||||||
controlBaseUrl = "http://$normalizedHost:8081/",
|
|
||||||
imApiBaseUrl = "http://$normalizedHost:8082/",
|
|
||||||
imWsUrl = "ws://$normalizedHost:8082/ws/im",
|
|
||||||
pushBaseUrl = "http://$normalizedHost:8083/",
|
|
||||||
updateBaseUrl = "http://$normalizedHost:8084/",
|
|
||||||
fileBaseUrl = "http://$normalizedHost:8086/",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun useExternal() {
|
|
||||||
current = ServiceEndpoints()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,9 +1,9 @@
|
|||||||
package com.xuqm.sdk.file
|
package com.xuqm.sdk.file
|
||||||
|
|
||||||
|
import android.annotation.SuppressLint
|
||||||
import android.app.NotificationChannel
|
import android.app.NotificationChannel
|
||||||
import android.app.NotificationManager
|
import android.app.NotificationManager
|
||||||
import android.app.Notification
|
import android.app.Notification
|
||||||
import android.app.PendingIntent
|
|
||||||
import android.Manifest
|
import android.Manifest
|
||||||
import android.content.ContentValues
|
import android.content.ContentValues
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
@ -17,17 +17,8 @@ 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.core.ServiceEndpointRegistry
|
|
||||||
import com.xuqm.sdk.network.ApiClient
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import okhttp3.MediaType.Companion.toMediaTypeOrNull
|
|
||||||
import okhttp3.MultipartBody
|
|
||||||
import okhttp3.RequestBody.Companion.toRequestBody
|
|
||||||
import retrofit2.http.Multipart
|
|
||||||
import retrofit2.http.POST
|
|
||||||
import retrofit2.http.Part
|
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.security.MessageDigest
|
import java.security.MessageDigest
|
||||||
|
|
||||||
@ -38,16 +29,6 @@ sealed class FileDownloadDestination {
|
|||||||
data object PublicDownloads : FileDownloadDestination()
|
data object PublicDownloads : FileDownloadDestination()
|
||||||
}
|
}
|
||||||
|
|
||||||
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,
|
|
||||||
)
|
|
||||||
|
|
||||||
data class FileDownloadProgress(
|
data class FileDownloadProgress(
|
||||||
val bytesDownloaded: Long,
|
val bytesDownloaded: Long,
|
||||||
val totalBytes: Long?,
|
val totalBytes: Long?,
|
||||||
@ -57,78 +38,8 @@ data class FileDownloadProgress(
|
|||||||
?.let { ((bytesDownloaded * 100L) / it).toInt().coerceIn(0, 100) }
|
?.let { ((bytesDownloaded * 100L) / it).toInt().coerceIn(0, 100) }
|
||||||
}
|
}
|
||||||
|
|
||||||
data class ApiResponse<T>(
|
|
||||||
val code: Int,
|
|
||||||
val status: String,
|
|
||||||
val data: T?,
|
|
||||||
val message: String? = null,
|
|
||||||
)
|
|
||||||
|
|
||||||
private interface FileApi {
|
|
||||||
@Multipart
|
|
||||||
@POST("api/file/upload")
|
|
||||||
suspend fun upload(
|
|
||||||
@Part file: MultipartBody.Part,
|
|
||||||
@Part thumbnail: MultipartBody.Part? = null,
|
|
||||||
): ApiResponse<FileUploadResult>
|
|
||||||
}
|
|
||||||
|
|
||||||
object FileSDK {
|
object FileSDK {
|
||||||
|
|
||||||
private val api: FileApi
|
|
||||||
get() = ApiClient.create(FileApi::class.java, ServiceEndpointRegistry.fileBaseUrl)
|
|
||||||
|
|
||||||
suspend fun upload(
|
|
||||||
context: Context,
|
|
||||||
uri: Uri,
|
|
||||||
displayName: String? = null,
|
|
||||||
mimeType: String? = null,
|
|
||||||
thumbnailBytes: ByteArray? = null,
|
|
||||||
onProgress: (Int) -> Unit = {},
|
|
||||||
): FileUploadResult {
|
|
||||||
val resolvedName = displayName?.takeIf { it.isNotBlank() } ?: FileTransfer.resolveDisplayName(context, uri)
|
|
||||||
val resolvedMimeType = mimeType?.takeIf { it.isNotBlank() } ?: context.contentResolver.getType(uri)
|
|
||||||
val filePart = MultipartBody.Part.createFormData(
|
|
||||||
"file",
|
|
||||||
resolvedName,
|
|
||||||
FileTransfer.createUriRequestBody(context, uri, resolvedMimeType, onProgress),
|
|
||||||
)
|
|
||||||
val thumbnailPart = thumbnailBytes?.takeIf { it.isNotEmpty() }?.let { bytes ->
|
|
||||||
MultipartBody.Part.createFormData(
|
|
||||||
"thumbnail",
|
|
||||||
"${resolvedName.substringBeforeLast('.', resolvedName)}_thumb.jpg",
|
|
||||||
bytes.toRequestBody("image/jpeg".toMediaTypeOrNull()),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return requireNotNull(api.upload(filePart, thumbnailPart).data) {
|
|
||||||
"File upload failed"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun uploadBytes(
|
|
||||||
fileName: String,
|
|
||||||
mimeType: String?,
|
|
||||||
bytes: ByteArray,
|
|
||||||
thumbnailBytes: ByteArray? = null,
|
|
||||||
onProgress: (Int) -> Unit = {},
|
|
||||||
): FileUploadResult {
|
|
||||||
val filePart = MultipartBody.Part.createFormData(
|
|
||||||
"file",
|
|
||||||
fileName,
|
|
||||||
FileTransfer.createByteArrayRequestBody(mimeType, bytes, onProgress),
|
|
||||||
)
|
|
||||||
val thumbnailPart = thumbnailBytes?.takeIf { it.isNotEmpty() }?.let { thumbBytes ->
|
|
||||||
MultipartBody.Part.createFormData(
|
|
||||||
"thumbnail",
|
|
||||||
"${fileName.substringBeforeLast('.', fileName)}_thumb.jpg",
|
|
||||||
thumbBytes.toRequestBody("image/jpeg".toMediaTypeOrNull()),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return requireNotNull(api.upload(filePart, thumbnailPart).data) {
|
|
||||||
"File upload failed"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun downloadToFile(
|
suspend fun downloadToFile(
|
||||||
downloadUrl: String,
|
downloadUrl: String,
|
||||||
targetFile: File,
|
targetFile: File,
|
||||||
@ -164,16 +75,6 @@ object FileSDK {
|
|||||||
fun matchesSha256(file: File, expectedSha256: String): Boolean =
|
fun matchesSha256(file: File, expectedSha256: String): Boolean =
|
||||||
expectedSha256.isNotBlank() && sha256(file).equals(expectedSha256.trim(), ignoreCase = true)
|
expectedSha256.isNotBlank() && sha256(file).equals(expectedSha256.trim(), ignoreCase = true)
|
||||||
|
|
||||||
suspend fun upload(
|
|
||||||
file: File,
|
|
||||||
thumbnailBytes: ByteArray? = null,
|
|
||||||
onProgress: (Int) -> Unit = {},
|
|
||||||
): FileUploadResult {
|
|
||||||
val mimeType = java.net.URLConnection.guessContentTypeFromName(file.name)
|
|
||||||
val bytes = file.readBytes()
|
|
||||||
return uploadBytes(file.name, mimeType, bytes, thumbnailBytes, onProgress)
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun downloadToAppFiles(
|
suspend fun downloadToAppFiles(
|
||||||
context: Context,
|
context: Context,
|
||||||
downloadUrl: String,
|
downloadUrl: String,
|
||||||
@ -261,11 +162,14 @@ object FileSDK {
|
|||||||
}
|
}
|
||||||
if (useMediaStore) {
|
if (useMediaStore) {
|
||||||
saveFileToPublicDownloads(context, target, resolvedName)
|
saveFileToPublicDownloads(context, target, resolvedName)
|
||||||
android.util.Log.d("FileSDK", "download: saved to public Downloads via MediaStore: $resolvedName")
|
android.util.Log.d("FileSDK", "Download saved to public Downloads")
|
||||||
}
|
}
|
||||||
} catch (e: Throwable) {
|
} catch (e: Throwable) {
|
||||||
downloadError = e
|
downloadError = e
|
||||||
android.util.Log.e("FileSDK", "download failed: url=$downloadUrl dest=${target.absolutePath}", e)
|
android.util.Log.e(
|
||||||
|
"FileSDK",
|
||||||
|
"Download failed: errorType=${e.javaClass.simpleName}",
|
||||||
|
)
|
||||||
throw e
|
throw e
|
||||||
} finally {
|
} finally {
|
||||||
capturedNotifId?.let { id ->
|
capturedNotifId?.let { id ->
|
||||||
@ -281,7 +185,6 @@ object FileSDK {
|
|||||||
.setSmallIcon(android.R.drawable.stat_sys_download_done)
|
.setSmallIcon(android.R.drawable.stat_sys_download_done)
|
||||||
.setContentTitle(notificationTitle)
|
.setContentTitle(notificationTitle)
|
||||||
.setContentText("下载完成:$resolvedName")
|
.setContentText("下载完成:$resolvedName")
|
||||||
.setContentIntent(buildOpenFilePendingIntent(context, target))
|
|
||||||
.setAutoCancel(true)
|
.setAutoCancel(true)
|
||||||
.build(),
|
.build(),
|
||||||
)
|
)
|
||||||
@ -315,6 +218,7 @@ object FileSDK {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@SuppressLint("MissingPermission")
|
||||||
private fun notifyIfAllowed(context: Context, id: Int, notification: Notification) {
|
private fun notifyIfAllowed(context: Context, id: Int, notification: Notification) {
|
||||||
val manager = NotificationManagerCompat.from(context)
|
val manager = NotificationManagerCompat.from(context)
|
||||||
if (!manager.areNotificationsEnabled()) return
|
if (!manager.areNotificationsEnabled()) return
|
||||||
@ -340,7 +244,10 @@ object FileSDK {
|
|||||||
fileName: String,
|
fileName: String,
|
||||||
destination: FileDownloadDestination = FileDownloadDestination.Sandbox,
|
destination: FileDownloadDestination = FileDownloadDestination.Sandbox,
|
||||||
): File {
|
): File {
|
||||||
android.util.Log.d("XWV", "saveBlobDownload: fileName=$fileName, dest=$destination, b64Len=${base64Data.length}")
|
android.util.Log.d(
|
||||||
|
"XWV",
|
||||||
|
"saveBlobDownload: destination=$destination b64Length=${base64Data.length}",
|
||||||
|
)
|
||||||
val bytes = android.util.Base64.decode(base64Data, android.util.Base64.DEFAULT)
|
val bytes = android.util.Base64.decode(base64Data, android.util.Base64.DEFAULT)
|
||||||
android.util.Log.d("XWV", "saveBlobDownload: decoded ${bytes.size} bytes")
|
android.util.Log.d("XWV", "saveBlobDownload: decoded ${bytes.size} bytes")
|
||||||
|
|
||||||
@ -354,17 +261,20 @@ object FileSDK {
|
|||||||
// 旧版本或 Sandbox 目录直接写文件
|
// 旧版本或 Sandbox 目录直接写文件
|
||||||
val baseDir = resolveBaseDir(context, destination)
|
val baseDir = resolveBaseDir(context, destination)
|
||||||
val target = uniqueFile(baseDir, safeName)
|
val target = uniqueFile(baseDir, safeName)
|
||||||
android.util.Log.d("XWV", "saveBlobDownload: writing to ${target.absolutePath}")
|
android.util.Log.d("XWV", "saveBlobDownload: writing file")
|
||||||
return try {
|
return try {
|
||||||
target.writeBytes(bytes)
|
target.writeBytes(bytes)
|
||||||
android.util.Log.d("XWV", "saveBlobDownload: done, size=${target.length()}")
|
android.util.Log.d("XWV", "saveBlobDownload: done, size=${target.length()}")
|
||||||
target
|
target
|
||||||
} catch (e: java.io.IOException) {
|
} catch (e: java.io.IOException) {
|
||||||
android.util.Log.w("XWV", "saveBlobDownload: primary dir failed (${e.message}), falling back to Sandbox")
|
android.util.Log.w("XWV", "saveBlobDownload: primary dir failed, falling back to Sandbox")
|
||||||
val fallbackDir = resolveBaseDir(context, FileDownloadDestination.Sandbox)
|
val fallbackDir = resolveBaseDir(context, FileDownloadDestination.Sandbox)
|
||||||
val fallbackTarget = uniqueFile(fallbackDir, safeName)
|
val fallbackTarget = uniqueFile(fallbackDir, safeName)
|
||||||
fallbackTarget.writeBytes(bytes)
|
fallbackTarget.writeBytes(bytes)
|
||||||
android.util.Log.d("XWV", "saveBlobDownload: fallback done, ${fallbackTarget.absolutePath}, size=${fallbackTarget.length()}")
|
android.util.Log.d(
|
||||||
|
"XWV",
|
||||||
|
"saveBlobDownload: fallback completed, size=${fallbackTarget.length()}",
|
||||||
|
)
|
||||||
fallbackTarget
|
fallbackTarget
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -403,7 +313,7 @@ object FileSDK {
|
|||||||
/**
|
/**
|
||||||
* 通过 MediaStore 将文件写入公共 Downloads 目录。
|
* 通过 MediaStore 将文件写入公共 Downloads 目录。
|
||||||
* 在华为等设备上直接文件 I/O 会失败,MediaStore 通过 ContentResolver 操作,兼容性更好。
|
* 在华为等设备上直接文件 I/O 会失败,MediaStore 通过 ContentResolver 操作,兼容性更好。
|
||||||
* 返回缓存目录中的副本(真实文件,可被 openFile 使用)。
|
* 返回缓存目录中的副本,便于调用方继续展示、分享或交给可选 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 {
|
||||||
@ -426,15 +336,15 @@ object FileSDK {
|
|||||||
|
|
||||||
resolver.openOutputStream(uri)?.use { out ->
|
resolver.openOutputStream(uri)?.use { out ->
|
||||||
out.write(bytes)
|
out.write(bytes)
|
||||||
} ?: throw java.io.IOException("Cannot open output stream for $uri")
|
} ?: throw java.io.IOException("Cannot open MediaStore output stream")
|
||||||
|
|
||||||
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)
|
||||||
|
|
||||||
android.util.Log.d("XWV", "saveBlobViaMediaStore: saved $fileName, size=${bytes.size}, uri=$uri")
|
android.util.Log.d("XWV", "saveBlobViaMediaStore: saved, size=${bytes.size}")
|
||||||
|
|
||||||
// 同时写入缓存目录,供 openFile 使用
|
// 同时写入缓存目录,供调用方继续处理
|
||||||
val cacheFile = File(context.cacheDir, fileName).apply { writeBytes(bytes) }
|
val cacheFile = File(context.cacheDir, fileName).apply { writeBytes(bytes) }
|
||||||
return cacheFile
|
return cacheFile
|
||||||
}
|
}
|
||||||
@ -456,7 +366,7 @@ object FileSDK {
|
|||||||
runCatching { Runtime.getRuntime().exec(arrayOf("mkdir", "-p", dir.absolutePath)).waitFor() }
|
runCatching { Runtime.getRuntime().exec(arrayOf("mkdir", "-p", dir.absolutePath)).waitFor() }
|
||||||
}
|
}
|
||||||
if (!dir.exists()) {
|
if (!dir.exists()) {
|
||||||
android.util.Log.w("XWV", "resolveBaseDir: cannot create ${dir.absolutePath}, falling back to Sandbox")
|
android.util.Log.w("XWV", "resolveBaseDir: falling back to Sandbox")
|
||||||
context.getExternalFilesDir(null) ?: context.filesDir
|
context.getExternalFilesDir(null) ?: context.filesDir
|
||||||
} else {
|
} else {
|
||||||
dir
|
dir
|
||||||
@ -535,38 +445,6 @@ object FileSDK {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun openFile(context: Context, file: File) {
|
|
||||||
val mimeType = MimeTypeMap.getSingleton()
|
|
||||||
.getMimeTypeFromExtension(file.extension.lowercase())
|
|
||||||
?: "application/octet-stream"
|
|
||||||
val uri = FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", file)
|
|
||||||
val intent = Intent(Intent.ACTION_VIEW).apply {
|
|
||||||
setDataAndType(uri, mimeType)
|
|
||||||
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_ACTIVITY_NEW_TASK)
|
|
||||||
}
|
|
||||||
context.startActivity(Intent.createChooser(intent, null).apply {
|
|
||||||
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun buildOpenFilePendingIntent(context: Context, file: File): PendingIntent? =
|
|
||||||
runCatching {
|
|
||||||
val mimeType = MimeTypeMap.getSingleton()
|
|
||||||
.getMimeTypeFromExtension(file.extension.lowercase())
|
|
||||||
?: "application/octet-stream"
|
|
||||||
val uri = FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", file)
|
|
||||||
val view = Intent(Intent.ACTION_VIEW).apply {
|
|
||||||
setDataAndType(uri, mimeType)
|
|
||||||
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_ACTIVITY_NEW_TASK)
|
|
||||||
}
|
|
||||||
val chooser = Intent.createChooser(view, null).apply {
|
|
||||||
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
|
||||||
}
|
|
||||||
val flags = PendingIntent.FLAG_UPDATE_CURRENT or
|
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) PendingIntent.FLAG_IMMUTABLE else 0
|
|
||||||
PendingIntent.getActivity(context, file.absolutePath.hashCode(), chooser, flags)
|
|
||||||
}.getOrNull()
|
|
||||||
|
|
||||||
private fun uniqueFile(dir: File, name: String): File {
|
private fun uniqueFile(dir: File, name: String): File {
|
||||||
val base = name.substringBeforeLast('.', name)
|
val base = name.substringBeforeLast('.', name)
|
||||||
val ext = name.substringAfterLast('.', "").let { if (it.isEmpty()) "" else ".$it" }
|
val ext = name.substringAfterLast('.', "").let { if (it.isEmpty()) "" else ".$it" }
|
||||||
|
|||||||
@ -1,94 +1,15 @@
|
|||||||
package com.xuqm.sdk.file
|
package com.xuqm.sdk.file
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import android.net.Uri
|
|
||||||
import android.provider.OpenableColumns
|
|
||||||
import kotlinx.coroutines.CancellationException
|
import kotlinx.coroutines.CancellationException
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.currentCoroutineContext
|
import kotlinx.coroutines.currentCoroutineContext
|
||||||
import kotlinx.coroutines.ensureActive
|
import kotlinx.coroutines.ensureActive
|
||||||
import okio.BufferedSink
|
|
||||||
import okio.source
|
|
||||||
import okhttp3.MediaType.Companion.toMediaTypeOrNull
|
|
||||||
import okhttp3.RequestBody
|
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.io.IOException
|
import java.io.IOException
|
||||||
import java.net.HttpURLConnection
|
import java.net.HttpURLConnection
|
||||||
import java.net.URL
|
import java.net.URL
|
||||||
import java.util.Locale
|
|
||||||
|
|
||||||
object FileTransfer {
|
|
||||||
|
|
||||||
fun resolveDisplayName(context: Context, uri: Uri): String {
|
|
||||||
context.contentResolver.query(uri, arrayOf(OpenableColumns.DISPLAY_NAME), null, null, null)
|
|
||||||
?.use { cursor ->
|
|
||||||
val nameIndex = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME)
|
|
||||||
if (nameIndex >= 0 && cursor.moveToFirst()) {
|
|
||||||
val value = cursor.getString(nameIndex)
|
|
||||||
if (!value.isNullOrBlank()) return value
|
|
||||||
}
|
|
||||||
}
|
|
||||||
val fallback = uri.lastPathSegment?.substringAfterLast('/')
|
|
||||||
return fallback?.takeIf { it.isNotBlank() }
|
|
||||||
?: String.format(Locale.US, "upload_%d", System.currentTimeMillis())
|
|
||||||
}
|
|
||||||
|
|
||||||
fun createUriRequestBody(
|
|
||||||
context: Context,
|
|
||||||
uri: Uri,
|
|
||||||
mimeType: String?,
|
|
||||||
onProgress: (Int) -> Unit = {},
|
|
||||||
): RequestBody = object : RequestBody() {
|
|
||||||
override fun contentType() = mimeType?.toMediaTypeOrNull()
|
|
||||||
|
|
||||||
override fun contentLength(): Long = resolveSize(context, uri).coerceAtLeast(-1L)
|
|
||||||
|
|
||||||
override fun writeTo(sink: BufferedSink) {
|
|
||||||
val totalBytes = resolveSize(context, uri)
|
|
||||||
context.contentResolver.openInputStream(uri)?.use { input ->
|
|
||||||
val source = input.source()
|
|
||||||
var uploaded = 0L
|
|
||||||
val bufferSize = 8 * 1024L
|
|
||||||
while (true) {
|
|
||||||
val read = source.read(sink.buffer, bufferSize)
|
|
||||||
if (read == -1L) break
|
|
||||||
uploaded += read
|
|
||||||
sink.flush()
|
|
||||||
if (totalBytes > 0L) {
|
|
||||||
onProgress((uploaded * 100 / totalBytes).toInt().coerceIn(0, 100))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (totalBytes <= 0L) {
|
|
||||||
onProgress(100)
|
|
||||||
}
|
|
||||||
} ?: error("Failed to open input stream for $uri")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun createByteArrayRequestBody(
|
|
||||||
mimeType: String?,
|
|
||||||
bytes: ByteArray,
|
|
||||||
onProgress: (Int) -> Unit = {},
|
|
||||||
): RequestBody = object : RequestBody() {
|
|
||||||
override fun contentType() = mimeType?.toMediaTypeOrNull()
|
|
||||||
|
|
||||||
override fun contentLength(): Long = bytes.size.toLong()
|
|
||||||
|
|
||||||
override fun writeTo(sink: BufferedSink) {
|
|
||||||
var uploaded = 0
|
|
||||||
val bufferSize = 8 * 1024
|
|
||||||
while (uploaded < bytes.size) {
|
|
||||||
val count = minOf(bufferSize, bytes.size - uploaded)
|
|
||||||
sink.write(bytes, uploaded, count)
|
|
||||||
uploaded += count
|
|
||||||
onProgress((uploaded * 100 / bytes.size).coerceIn(0, 100))
|
|
||||||
}
|
|
||||||
if (bytes.isEmpty()) {
|
|
||||||
onProgress(100)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
internal object FileTransfer {
|
||||||
suspend fun downloadToFile(
|
suspend fun downloadToFile(
|
||||||
downloadUrl: String,
|
downloadUrl: String,
|
||||||
targetFile: File,
|
targetFile: File,
|
||||||
@ -150,15 +71,4 @@ object FileTransfer {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun resolveSize(context: Context, uri: Uri): Long {
|
|
||||||
context.contentResolver.query(uri, arrayOf(OpenableColumns.SIZE), null, null, null)
|
|
||||||
?.use { cursor ->
|
|
||||||
val sizeIndex = cursor.getColumnIndex(OpenableColumns.SIZE)
|
|
||||||
if (sizeIndex >= 0 && cursor.moveToFirst()) {
|
|
||||||
val value = cursor.getLong(sizeIndex)
|
|
||||||
if (value > 0L) return value
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return -1L
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,19 @@
|
|||||||
|
package com.xuqm.sdk.internal
|
||||||
|
|
||||||
|
/** BugCollect 自动运行状态的唯一布尔规则。 */
|
||||||
|
internal object BugCollectRuntimePolicy {
|
||||||
|
fun isAutomaticReady(
|
||||||
|
buildEnabled: Boolean,
|
||||||
|
automaticEnabled: Boolean,
|
||||||
|
privacyGranted: Boolean,
|
||||||
|
platformEnabled: Boolean,
|
||||||
|
serverDisabled: Boolean,
|
||||||
|
hasUploadEndpoint: Boolean,
|
||||||
|
): Boolean =
|
||||||
|
buildEnabled &&
|
||||||
|
automaticEnabled &&
|
||||||
|
privacyGranted &&
|
||||||
|
platformEnabled &&
|
||||||
|
!serverDisabled &&
|
||||||
|
hasUploadEndpoint
|
||||||
|
}
|
||||||
@ -1,58 +1,47 @@
|
|||||||
package com.xuqm.sdk.internal
|
package com.xuqm.sdk.internal
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.util.Log
|
import com.google.gson.Gson
|
||||||
import androidx.security.crypto.EncryptedSharedPreferences
|
import com.xuqm.sdk.storage.SecureStore
|
||||||
import androidx.security.crypto.MasterKeys
|
|
||||||
import java.security.MessageDigest
|
import java.security.MessageDigest
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 持久化缓存解密后的配置文件内容。
|
* 持久化缓存解密后的配置文件内容。
|
||||||
*
|
*
|
||||||
* 使用 EncryptedSharedPreferences 存储 appKey / serverUrl / packageName,
|
* 使用 [SecureStore] 存储已完成签名验证的完整引导配置,
|
||||||
* 缓存 key 为配置文件原始内容的 SHA-256。文件变更时自动重新解密。
|
* 缓存 key 为配置文件原始内容的 SHA-256。文件变更时自动重新解密。
|
||||||
*
|
*
|
||||||
* 作用:避免每次冷启动都执行 PBKDF2(120,000 次迭代,~100ms)。
|
* 作用:避免每次冷启动都执行 PBKDF2(120,000 次迭代,~100ms)。
|
||||||
*/
|
*/
|
||||||
internal object ConfigCache {
|
internal object ConfigCache {
|
||||||
|
|
||||||
private const val TAG = "XuqmSDK"
|
private const val NAMESPACE = "bootstrap_config_cache"
|
||||||
private const val PREFS_NAME = "xuqm_config_cache"
|
private const val KEY_ENTRY = "entry"
|
||||||
private const val KEY_HASH = "config_hash"
|
private val gson = Gson()
|
||||||
private const val KEY_APP_KEY = "app_key"
|
private data class CacheEnvelope(val hash: String, val config: ConfigFile)
|
||||||
private const val KEY_SERVER_URL = "server_url"
|
|
||||||
private const val KEY_PACKAGE_NAME = "package_name"
|
|
||||||
|
|
||||||
/** 内存级单例缓存,避免同进程内重复解密。 */
|
/** 内存级单例缓存,避免同进程内重复解密。 */
|
||||||
@Volatile
|
@Volatile
|
||||||
private var memoryCached: CachedConfig? = null
|
private var memoryCached: ConfigFile? = null
|
||||||
|
@Volatile
|
||||||
data class CachedConfig(
|
private var memoryHash: String? = null
|
||||||
val appKey: String,
|
|
||||||
val serverUrl: String?,
|
|
||||||
val packageName: String?,
|
|
||||||
)
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 从缓存读取配置。内存缓存命中直接返回;否则查 EncryptedSharedPreferences。
|
* 从缓存读取配置。内存缓存命中直接返回;否则查统一安全存储。
|
||||||
* 文件内容 hash 不匹配时返回 null(需要重新解密)。
|
* 文件内容 hash 不匹配时返回 null(需要重新解密)。
|
||||||
*/
|
*/
|
||||||
fun load(context: Context, rawContent: ByteArray): CachedConfig? {
|
fun load(context: Context, rawContent: ByteArray): ConfigFile? {
|
||||||
// 1. 内存缓存
|
|
||||||
memoryCached?.let { return it }
|
|
||||||
|
|
||||||
val currentHash = sha256(rawContent)
|
val currentHash = sha256(rawContent)
|
||||||
val prefs = getPrefs(context)
|
// 1. 内存缓存也必须与当前文件摘要绑定,防止同进程替换文件后读到旧配置。
|
||||||
val savedHash = prefs.getString(KEY_HASH, null) ?: return null
|
memoryCached?.takeIf { memoryHash == currentHash }?.let { return it }
|
||||||
|
|
||||||
if (savedHash != currentHash) return null
|
val json = runCatching { store(context).getString(KEY_ENTRY) }.getOrNull() ?: return null
|
||||||
|
val envelope = runCatching { gson.fromJson(json, CacheEnvelope::class.java) }.getOrNull()
|
||||||
val appKey = prefs.getString(KEY_APP_KEY, null) ?: return null
|
?: return null
|
||||||
val serverUrl = prefs.getString(KEY_SERVER_URL, null)
|
if (envelope.hash != currentHash) return null
|
||||||
val packageName = prefs.getString(KEY_PACKAGE_NAME, null)
|
val cached = envelope.config
|
||||||
|
|
||||||
val cached = CachedConfig(appKey, serverUrl, packageName)
|
|
||||||
memoryCached = cached
|
memoryCached = cached
|
||||||
|
memoryHash = currentHash
|
||||||
return cached
|
return cached
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -61,31 +50,23 @@ internal object ConfigCache {
|
|||||||
*/
|
*/
|
||||||
fun save(context: Context, rawContent: ByteArray, config: ConfigFile) {
|
fun save(context: Context, rawContent: ByteArray, config: ConfigFile) {
|
||||||
val hash = sha256(rawContent)
|
val hash = sha256(rawContent)
|
||||||
getPrefs(context).edit().apply {
|
runCatching {
|
||||||
putString(KEY_HASH, hash)
|
store(context).putString(KEY_ENTRY, gson.toJson(CacheEnvelope(hash, config)))
|
||||||
putString(KEY_APP_KEY, config.appKey)
|
|
||||||
putString(KEY_SERVER_URL, config.serverUrl)
|
|
||||||
putString(KEY_PACKAGE_NAME, config.packageName)
|
|
||||||
apply()
|
|
||||||
}
|
}
|
||||||
memoryCached = CachedConfig(config.appKey, config.serverUrl, config.packageName)
|
memoryCached = config
|
||||||
|
memoryHash = hash
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 清除缓存(用于登出或配置重置场景)。
|
* 清除缓存(用于登出或配置重置场景)。
|
||||||
*/
|
*/
|
||||||
fun clear(context: Context) {
|
fun clear(context: Context) {
|
||||||
getPrefs(context).edit().clear().apply()
|
runCatching { store(context).clear() }
|
||||||
memoryCached = null
|
memoryCached = null
|
||||||
|
memoryHash = null
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun getPrefs(context: Context) = EncryptedSharedPreferences.create(
|
private fun store(context: Context) = SecureStore.open(context, NAMESPACE)
|
||||||
PREFS_NAME,
|
|
||||||
MasterKeys.getOrCreate(MasterKeys.AES256_GCM_SPEC),
|
|
||||||
context,
|
|
||||||
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
|
|
||||||
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
|
|
||||||
)
|
|
||||||
|
|
||||||
private fun sha256(data: ByteArray): String {
|
private fun sha256(data: ByteArray): String {
|
||||||
val digest = MessageDigest.getInstance("SHA-256").digest(data)
|
val digest = MessageDigest.getInstance("SHA-256").digest(data)
|
||||||
|
|||||||
@ -3,20 +3,21 @@ package com.xuqm.sdk.internal
|
|||||||
import com.google.gson.annotations.SerializedName
|
import com.google.gson.annotations.SerializedName
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Decrypted content of the init config file (assets/xuqm/config.xuqm).
|
* 平台签发的 `assets/config/config.xuqmconfig` 验签、解密后的引导内容。
|
||||||
* Contains the appKey and optional server URL needed to bootstrap the SDK.
|
* appKey 与 serverUrl 是启动 SDK 的唯一必填身份和平台地址。
|
||||||
* This file only contains SDK bootstrap configuration.
|
|
||||||
*/
|
*/
|
||||||
internal data class ConfigFile(
|
internal data class ConfigFile(
|
||||||
|
@SerializedName("schemaVersion") val schemaVersion: Int,
|
||||||
|
@SerializedName("configId") val configId: String,
|
||||||
|
@SerializedName("revision") val revision: Long,
|
||||||
@SerializedName(value = "appKey", alternate = ["app_key"]) val appKey: String,
|
@SerializedName(value = "appKey", alternate = ["app_key"]) val appKey: String,
|
||||||
@SerializedName(value = "appName", alternate = ["app_name"]) val appName: String? = null,
|
@SerializedName(value = "appName", alternate = ["app_name"]) val appName: String? = null,
|
||||||
@SerializedName(value = "companyName", alternate = ["company_name"]) val companyName: String? = null,
|
@SerializedName(value = "companyName", alternate = ["company_name"]) val companyName: String? = null,
|
||||||
@SerializedName(value = "packageName", alternate = ["package_name"]) val packageName: String? = null,
|
@SerializedName(value = "packageName", alternate = ["package_name"]) val packageName: String? = null,
|
||||||
@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 = "baseUrl", alternate = ["base_url"]) val baseUrl: String? = null,
|
@SerializedName(value = "serverUrl", alternate = ["server_url"]) val serverUrl: String,
|
||||||
@SerializedName(value = "serverUrl", alternate = ["server_url"]) val serverUrl: String? = null,
|
|
||||||
@SerializedName(value = "signingKey", alternate = ["signing_key"]) val signingKey: String? = null,
|
@SerializedName(value = "signingKey", alternate = ["signing_key"]) val signingKey: String? = null,
|
||||||
@SerializedName(value = "issuedAt", alternate = ["issued_at"]) val issuedAt: String? = null,
|
@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,6 +1,12 @@
|
|||||||
package com.xuqm.sdk.internal
|
package com.xuqm.sdk.internal
|
||||||
|
|
||||||
import android.util.Base64
|
import com.google.gson.JsonElement
|
||||||
|
import com.google.gson.JsonParser
|
||||||
|
import java.security.KeyFactory
|
||||||
|
import java.security.PublicKey
|
||||||
|
import java.security.Signature
|
||||||
|
import java.security.spec.X509EncodedKeySpec
|
||||||
|
import org.bouncycastle.jce.provider.BouncyCastleProvider
|
||||||
import javax.crypto.Cipher
|
import javax.crypto.Cipher
|
||||||
import javax.crypto.SecretKeyFactory
|
import javax.crypto.SecretKeyFactory
|
||||||
import javax.crypto.spec.GCMParameterSpec
|
import javax.crypto.spec.GCMParameterSpec
|
||||||
@ -8,33 +14,97 @@ import javax.crypto.spec.PBEKeySpec
|
|||||||
import javax.crypto.spec.SecretKeySpec
|
import javax.crypto.spec.SecretKeySpec
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Decrypts the init config file (format: XUQM-CONFIG-V1.<salt>.<iv>.<ciphertext>).
|
* XUQM-CONFIG-V2 的唯一验证与解密实现。
|
||||||
* Algorithm: AES-256-GCM with PBKDF2-HMAC-SHA256 key derivation (120,000 iterations).
|
*
|
||||||
|
* 格式:
|
||||||
|
* `XUQM-CONFIG-V2.keyId.salt.iv.ciphertext.signature`
|
||||||
|
*
|
||||||
|
* Ed25519 签名覆盖前五段的精确 ASCII,必须先验签再执行 AES 解密。AES 只用于降低
|
||||||
|
* 配置的明文可见性,配置真实性完全由租户平台私钥签名保证。
|
||||||
*/
|
*/
|
||||||
internal object ConfigFileCrypto {
|
internal object ConfigFileCrypto {
|
||||||
private const val MAGIC = "XUQM-CONFIG-V1"
|
private const val MAGIC = "XUQM-CONFIG-V2"
|
||||||
private const val PASSPHRASE = "xuqm-config-file-v1.2026.internal"
|
private const val PASSPHRASE = "xuqm-config-file-v2.2026.internal"
|
||||||
private const val KEY_BITS = 256
|
private const val KEY_BITS = 256
|
||||||
private const val ITERATIONS = 120_000
|
private const val ITERATIONS = 120_000
|
||||||
private const val GCM_TAG_BITS = 128
|
private const val GCM_TAG_BITS = 128
|
||||||
|
// Android API 24/25 的系统 Provider 不保证支持 Ed25519 与
|
||||||
|
// PBKDF2WithHmacSHA256,因此两者统一使用 SDK 随附的现代 BC Provider。
|
||||||
|
private val cryptoProvider = BouncyCastleProvider()
|
||||||
|
|
||||||
fun decrypt(content: String): String {
|
fun decrypt(content: String): String = decrypt(content, ConfigSigningKeys.all)
|
||||||
|
|
||||||
|
internal fun decrypt(content: String, trustedKeys: Map<String, PublicKey>): String {
|
||||||
val parts = content.trim().split(".")
|
val parts = content.trim().split(".")
|
||||||
require(parts.size == 4 && parts[0] == MAGIC) { "Invalid config file format" }
|
require(parts.size == 6 && parts[0] == MAGIC) {
|
||||||
val salt = decode(parts[1])
|
"Only XUQM-CONFIG-V2 is supported"
|
||||||
val iv = decode(parts[2])
|
}
|
||||||
val cipherText = decode(parts[3])
|
val keyId = parts[1]
|
||||||
|
val publicKey = trustedKeys[keyId]
|
||||||
|
?: throw IllegalArgumentException("Unknown config signing key: $keyId")
|
||||||
|
val signedPayload = parts.take(5).joinToString(".").toByteArray(Charsets.US_ASCII)
|
||||||
|
val signature = decode(parts[5])
|
||||||
|
val verifier = Signature.getInstance("Ed25519", cryptoProvider)
|
||||||
|
verifier.initVerify(publicKey)
|
||||||
|
verifier.update(signedPayload)
|
||||||
|
require(verifier.verify(signature)) { "Config signature verification failed" }
|
||||||
|
|
||||||
|
val salt = decode(parts[2])
|
||||||
|
val iv = decode(parts[3])
|
||||||
|
val cipherText = decode(parts[4])
|
||||||
val cipher = Cipher.getInstance("AES/GCM/NoPadding")
|
val cipher = Cipher.getInstance("AES/GCM/NoPadding")
|
||||||
cipher.init(Cipher.DECRYPT_MODE, deriveKey(salt), GCMParameterSpec(GCM_TAG_BITS, iv))
|
cipher.init(Cipher.DECRYPT_MODE, deriveKey(salt), GCMParameterSpec(GCM_TAG_BITS, iv))
|
||||||
return cipher.doFinal(cipherText).toString(Charsets.UTF_8)
|
val json = cipher.doFinal(cipherText).toString(Charsets.UTF_8)
|
||||||
|
require(json == canonicalize(JsonParser.parseString(json))) {
|
||||||
|
"Config payload must use canonical JSON"
|
||||||
|
}
|
||||||
|
return json
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun canonicalize(element: JsonElement): String = when {
|
||||||
|
element.isJsonObject -> element.asJsonObject.entrySet()
|
||||||
|
.asSequence()
|
||||||
|
.filterNot { it.value.isJsonNull }
|
||||||
|
.sortedBy { it.key }
|
||||||
|
.joinToString(separator = ",", prefix = "{", postfix = "}") { (key, value) ->
|
||||||
|
"${com.google.gson.Gson().toJson(key)}:${canonicalize(value)}"
|
||||||
|
}
|
||||||
|
element.isJsonArray -> element.asJsonArray.joinToString(separator = ",", prefix = "[", postfix = "]") {
|
||||||
|
canonicalize(it)
|
||||||
|
}
|
||||||
|
else -> element.toString()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun deriveKey(salt: ByteArray): SecretKeySpec {
|
private fun deriveKey(salt: ByteArray): SecretKeySpec {
|
||||||
val spec = PBEKeySpec(PASSPHRASE.toCharArray(), salt, ITERATIONS, KEY_BITS)
|
val spec = PBEKeySpec(PASSPHRASE.toCharArray(), salt, ITERATIONS, KEY_BITS)
|
||||||
val encoded = SecretKeyFactory.getInstance("PBKDF2WithHmacSHA256").generateSecret(spec).encoded
|
val encoded = SecretKeyFactory.getInstance("PBKDF2WithHmacSHA256", cryptoProvider)
|
||||||
|
.generateSecret(spec)
|
||||||
|
.encoded
|
||||||
return SecretKeySpec(encoded, "AES")
|
return SecretKeySpec(encoded, "AES")
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun decode(value: String): ByteArray =
|
private fun decode(value: String): ByteArray {
|
||||||
Base64.decode(value, Base64.URL_SAFE or Base64.NO_PADDING or Base64.NO_WRAP)
|
val standard = value.replace('-', '+').replace('_', '/')
|
||||||
|
.let { it + "=".repeat((4 - it.length % 4) % 4) }
|
||||||
|
return org.bouncycastle.util.encoders.Base64.decode(standard)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 由发布流程维护的平台公钥集合。只保存 X.509 DER 公钥,不包含任何私钥或服务端秘密。
|
||||||
|
*/
|
||||||
|
internal object ConfigSigningKeys {
|
||||||
|
private val encodedKeys: Map<String, String> = mapOf(
|
||||||
|
"xuqm-config-dev-2026-07" to
|
||||||
|
"MCowBQYDK2VwAyEAFt9MBN8jNwCfalex4wg7kuy2DRAbXkGxZyA/Jaz+6YA=",
|
||||||
|
)
|
||||||
|
|
||||||
|
val all: Map<String, PublicKey> by lazy {
|
||||||
|
val factory = KeyFactory.getInstance("Ed25519", BouncyCastleProvider())
|
||||||
|
encodedKeys.mapValues { (_, encoded) ->
|
||||||
|
factory.generatePublic(
|
||||||
|
X509EncodedKeySpec(org.bouncycastle.util.encoders.Base64.decode(encoded)),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,27 +2,23 @@ package com.xuqm.sdk.internal
|
|||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import com.google.gson.Gson
|
import java.time.Instant
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Reads and decrypts the init config file from assets.
|
* Reads and decrypts the init config file from assets.
|
||||||
*
|
*
|
||||||
* 搜索顺序(对齐 RN SDK 的 src/assets/config/):
|
* 唯一位置:`assets/config/config.xuqmconfig`。
|
||||||
* 1. assets/config/config.xuqmconfig 或 config.xuqm
|
* 文件由租户平台签发,宿主不得复制、改名或自行修改。
|
||||||
* 2. assets/config/\*.xuqmconfig(取第一个)
|
|
||||||
* 3. assets/xuqm/config.xuqm(旧路径兼容)
|
|
||||||
* 4. assets/xuqm/\*.xuqmconfig(旧路径兼容)
|
|
||||||
*
|
*
|
||||||
* 缓存策略:
|
* 缓存策略:
|
||||||
* - [readRawBytes]:只读取文件原始字节,不做解密(主线程安全,<5ms)
|
* - [readRawBytes]:只读取文件原始字节,不做解密(主线程安全,<5ms)
|
||||||
* - [readWithCache]:先查 ConfigCache(内存+磁盘),miss 时解密并写缓存
|
* - Provider 将原始字节交给唯一初始化流程,由该流程完成缓存、验签与解密。
|
||||||
* - [read]:原始读取+解密,无缓存(兼容旧调用)
|
|
||||||
*/
|
*/
|
||||||
internal object ConfigFileReader {
|
internal object ConfigFileReader {
|
||||||
|
|
||||||
private const val TAG = "XuqmSDK"
|
private const val TAG = "XuqmSDK"
|
||||||
private val CONFIG_DIRS = listOf("config", "xuqm")
|
private const val CONFIG_PATH = "config/config.xuqmconfig"
|
||||||
private val gson = Gson()
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 只读取配置文件原始字节,不做 PBKDF2 解密。
|
* 只读取配置文件原始字节,不做 PBKDF2 解密。
|
||||||
@ -37,76 +33,20 @@ internal object ConfigFileReader {
|
|||||||
}.getOrNull()
|
}.getOrNull()
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
internal fun validate(config: ConfigFile) {
|
||||||
* 带缓存的配置读取。优先查 ConfigCache(内存 → EncryptedSharedPreferences),
|
require(config.schemaVersion == 2) { "Unsupported config schemaVersion=${config.schemaVersion}" }
|
||||||
* 缓存 miss 时执行 PBKDF2 解密并写入缓存。
|
require(config.revision > 0) { "Config revision must be positive" }
|
||||||
* 用于 IO 线程,不应在主线程调用。
|
require(config.appKey.isNotBlank()) { "Config appKey must not be blank" }
|
||||||
*/
|
require(config.serverUrl.isNotBlank()) { "Config serverUrl must not be blank" }
|
||||||
fun readWithCache(context: Context): ConfigFile? {
|
UUID.fromString(config.configId)
|
||||||
val rawBytes = readRawBytes(context) ?: return null
|
Instant.parse(config.issuedAt)
|
||||||
|
config.expiresAt?.let(Instant::parse)
|
||||||
// 1. 查缓存
|
|
||||||
ConfigCache.load(context, rawBytes)?.let { cached ->
|
|
||||||
Log.d(TAG, "Config loaded from cache")
|
|
||||||
return ConfigFile(appKey = cached.appKey, serverUrl = cached.serverUrl, packageName = cached.packageName)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. 缓存 miss → 解密
|
|
||||||
return runCatching {
|
|
||||||
val json = ConfigFileCrypto.decrypt(String(rawBytes, Charsets.UTF_8))
|
|
||||||
val config = gson.fromJson(json, ConfigFile::class.java)
|
|
||||||
// 3. 写缓存
|
|
||||||
ConfigCache.save(context, rawBytes, config)
|
|
||||||
Log.d(TAG, "Config decrypted and cached")
|
|
||||||
config
|
|
||||||
}.onFailure { e ->
|
|
||||||
Log.e(TAG, "Failed to decrypt config file", e)
|
|
||||||
}.getOrNull()
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 原始读取+解密,无缓存。兼容旧调用路径。
|
|
||||||
*/
|
|
||||||
fun read(context: Context): ConfigFile? {
|
|
||||||
return try {
|
|
||||||
val path = findConfigPath(context) ?: run {
|
|
||||||
Log.w(TAG, "No config file found in assets/{config,xuqm}/")
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
Log.d(TAG, "Reading config file: $path")
|
|
||||||
context.assets.open(path).use { stream ->
|
|
||||||
val encrypted = stream.bufferedReader().readText()
|
|
||||||
val json = ConfigFileCrypto.decrypt(encrypted)
|
|
||||||
gson.fromJson(json, ConfigFile::class.java)
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Log.e(TAG, "Failed to read/decrypt config file", e)
|
|
||||||
null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun exists(context: Context): Boolean {
|
|
||||||
return findConfigPath(context) != null
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun findConfigPath(context: Context): String? {
|
private fun findConfigPath(context: Context): String? {
|
||||||
for (dir in CONFIG_DIRS) {
|
return runCatching {
|
||||||
// 优先 config.xuqmconfig 或 config.xuqm
|
context.assets.open(CONFIG_PATH).close()
|
||||||
for (name in listOf("config.xuqmconfig", "config.xuqm")) {
|
CONFIG_PATH
|
||||||
val path = "$dir/$name"
|
|
||||||
try {
|
|
||||||
context.assets.open(path).close()
|
|
||||||
return path
|
|
||||||
} catch (_: Exception) { /* not found */ }
|
|
||||||
}
|
|
||||||
// 否则取第一个 .xuqmconfig
|
|
||||||
val found = runCatching {
|
|
||||||
context.assets.list(dir)
|
|
||||||
?.firstOrNull { it.endsWith(".xuqmconfig", ignoreCase = true) }
|
|
||||||
?.let { "$dir/$it" }
|
|
||||||
}.getOrNull()
|
}.getOrNull()
|
||||||
if (found != null) return found
|
|
||||||
}
|
|
||||||
return null
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,81 @@
|
|||||||
|
package com.xuqm.sdk.internal
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import com.google.gson.Gson
|
||||||
|
import com.xuqm.sdk.network.SdkPlatformConfig
|
||||||
|
import com.xuqm.sdk.storage.SecureStore
|
||||||
|
import java.security.MessageDigest
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 租户平台配置的最后成功副本(LKG)。
|
||||||
|
*
|
||||||
|
* 缓存只用于平台暂时不可达时维持扩展 SDK 的既有配置,不包含登录凭据。
|
||||||
|
* 隔离维度为 appKey、宿主包名和平台地址;超过七天或内容损坏时不可使用。
|
||||||
|
*/
|
||||||
|
internal object PlatformConfigCache {
|
||||||
|
private const val NAMESPACE = "platform_config_lkg"
|
||||||
|
private const val KEY_ENTRY = "entry"
|
||||||
|
private val ttlMillis = TimeUnit.DAYS.toMillis(7)
|
||||||
|
private val gson = Gson()
|
||||||
|
private data class CacheEnvelope(
|
||||||
|
val scope: String,
|
||||||
|
val savedAt: Long,
|
||||||
|
val config: SdkPlatformConfig,
|
||||||
|
)
|
||||||
|
|
||||||
|
fun save(
|
||||||
|
context: Context,
|
||||||
|
appKey: String,
|
||||||
|
platformUrl: String,
|
||||||
|
config: SdkPlatformConfig,
|
||||||
|
now: Long = System.currentTimeMillis(),
|
||||||
|
) {
|
||||||
|
runCatching {
|
||||||
|
store(context).putString(
|
||||||
|
KEY_ENTRY,
|
||||||
|
gson.toJson(
|
||||||
|
CacheEnvelope(
|
||||||
|
scope(appKey, context.packageName, platformUrl),
|
||||||
|
now,
|
||||||
|
config,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun load(
|
||||||
|
context: Context,
|
||||||
|
appKey: String,
|
||||||
|
platformUrl: String,
|
||||||
|
now: Long = System.currentTimeMillis(),
|
||||||
|
): SdkPlatformConfig? {
|
||||||
|
val envelope = runCatching {
|
||||||
|
store(context).getString(KEY_ENTRY)
|
||||||
|
?.let { gson.fromJson(it, CacheEnvelope::class.java) }
|
||||||
|
}.getOrNull() ?: return null
|
||||||
|
if (envelope.scope != scope(appKey, context.packageName, platformUrl)) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
val savedAt = envelope.savedAt
|
||||||
|
if (savedAt <= 0L || now - savedAt !in 0..ttlMillis) {
|
||||||
|
clear(context)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return envelope.config
|
||||||
|
}
|
||||||
|
|
||||||
|
fun clear(context: Context) {
|
||||||
|
runCatching { store(context).clear() }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun scope(appKey: String, packageName: String, platformUrl: String): String {
|
||||||
|
val value = "$appKey\n$packageName\n${platformUrl.trimEnd('/')}"
|
||||||
|
return MessageDigest.getInstance("SHA-256")
|
||||||
|
.digest(value.toByteArray(Charsets.UTF_8))
|
||||||
|
.joinToString("") { "%02x".format(it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun store(context: Context) = SecureStore.open(context, NAMESPACE)
|
||||||
|
}
|
||||||
@ -1,36 +0,0 @@
|
|||||||
package com.xuqm.sdk.internal
|
|
||||||
|
|
||||||
import android.content.ContentProvider
|
|
||||||
import android.content.ContentValues
|
|
||||||
import android.database.Cursor
|
|
||||||
import android.net.Uri
|
|
||||||
import android.util.Log
|
|
||||||
import com.xuqm.sdk.XuqmSDK
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 已废弃:由 [XuqmMergedProvider] 替代。
|
|
||||||
*
|
|
||||||
* 保留此类仅为向后兼容(旧宿主可能在 AndroidManifest 中手动注册)。
|
|
||||||
* 新版 sdk-core 的 AndroidManifest 已注册 XuqmMergedProvider,
|
|
||||||
* 此 Provider 不会自动注册,需宿主显式声明才会生效。
|
|
||||||
*/
|
|
||||||
@Deprecated("Use XuqmMergedProvider instead", ReplaceWith("XuqmMergedProvider"))
|
|
||||||
class XuqmInitializerProvider : ContentProvider() {
|
|
||||||
override fun onCreate(): Boolean {
|
|
||||||
val ctx = context?.applicationContext ?: return true
|
|
||||||
runCatching {
|
|
||||||
if (!XuqmSDK.isInitialized()) {
|
|
||||||
XuqmSDK.autoInitializeAsync(ctx)
|
|
||||||
}
|
|
||||||
}.onFailure { e ->
|
|
||||||
Log.w("XuqmSDK", "Auto-initialization skipped: ${e.message}")
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun query(uri: Uri, projection: Array<out String>?, selection: String?, selectionArgs: Array<out String>?, sortOrder: String?): Cursor? = null
|
|
||||||
override fun getType(uri: Uri): String? = null
|
|
||||||
override fun insert(uri: Uri, values: ContentValues?): Uri? = null
|
|
||||||
override fun delete(uri: Uri, selection: String?, selectionArgs: Array<out String>?): Int = 0
|
|
||||||
override fun update(uri: Uri, values: ContentValues?, selection: String?, selectionArgs: Array<out String>?): Int = 0
|
|
||||||
}
|
|
||||||
@ -12,12 +12,12 @@ import com.xuqm.sdk.XuqmSDK
|
|||||||
*
|
*
|
||||||
* 设计原则:主线程零加密运算。
|
* 设计原则:主线程零加密运算。
|
||||||
* Phase 1(同步,主线程,<10ms):
|
* Phase 1(同步,主线程,<10ms):
|
||||||
* - 扫描 assets/config/ 或 assets/xuqm/ 找到 .xuqmconfig 文件
|
* - 读取唯一的 assets/config/config.xuqmconfig
|
||||||
* - 读取文件原始字节到内存
|
* - 读取文件原始字节到内存
|
||||||
* - 启动 IO 协程
|
* - 启动 IO 协程
|
||||||
* Phase 2(异步,IO 线程):
|
* Phase 2(异步,IO 线程):
|
||||||
* - 查 ConfigCache(内存→磁盘),命中则跳过 PBKDF2
|
* - 查 ConfigCache(内存→磁盘),命中则跳过 PBKDF2
|
||||||
* - 缓存 miss 时执行 PBKDF2 解密(~100ms)并写缓存
|
* - 缓存 miss 时先验证 Ed25519 签名,再执行 PBKDF2 解密并写缓存
|
||||||
* - 初始化 sdk-core(TokenStore / DeviceId / ApiClient)
|
* - 初始化 sdk-core(TokenStore / DeviceId / ApiClient)
|
||||||
* - 注册 sdk-bugcollect CrashCapture
|
* - 注册 sdk-bugcollect CrashCapture
|
||||||
* - 拉取远端平台配置
|
* - 拉取远端平台配置
|
||||||
@ -26,6 +26,16 @@ class XuqmMergedProvider : ContentProvider() {
|
|||||||
|
|
||||||
override fun onCreate(): Boolean {
|
override fun onCreate(): Boolean {
|
||||||
val ctx = context?.applicationContext ?: return true
|
val ctx = context?.applicationContext ?: return true
|
||||||
|
val pluginApplied = runCatching {
|
||||||
|
ctx.packageManager.getApplicationInfo(
|
||||||
|
ctx.packageName,
|
||||||
|
android.content.pm.PackageManager.GET_META_DATA,
|
||||||
|
).metaData?.getBoolean("com.xuqm.common_plugin_applied", false) == true
|
||||||
|
}.getOrDefault(false)
|
||||||
|
if (!pluginApplied) {
|
||||||
|
Log.w(TAG, "com.xuqm.common plugin is not applied, skipping auto-init")
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
// ── Phase 1:主线程只读原始字节(<10ms)──────────────────────────
|
// ── Phase 1:主线程只读原始字节(<10ms)──────────────────────────
|
||||||
val rawBytes = ConfigFileReader.readRawBytes(ctx)
|
val rawBytes = ConfigFileReader.readRawBytes(ctx)
|
||||||
|
|||||||
@ -16,7 +16,6 @@ import okhttp3.MultipartBody
|
|||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
import okhttp3.Request
|
import okhttp3.Request
|
||||||
import okhttp3.Response
|
import okhttp3.Response
|
||||||
import okio.Buffer
|
|
||||||
import retrofit2.Retrofit
|
import retrofit2.Retrofit
|
||||||
import retrofit2.converter.gson.GsonConverterFactory
|
import retrofit2.converter.gson.GsonConverterFactory
|
||||||
import java.time.Instant
|
import java.time.Instant
|
||||||
@ -31,7 +30,6 @@ import javax.crypto.spec.SecretKeySpec
|
|||||||
object ApiClient {
|
object ApiClient {
|
||||||
|
|
||||||
private const val TAG = "XuqmApi"
|
private const val TAG = "XuqmApi"
|
||||||
private const val MAX_LOG_BODY_BYTES = 1024 * 1024L
|
|
||||||
|
|
||||||
// Handles server responses that return ISO datetime strings where Long (epoch ms) is expected.
|
// Handles server responses that return ISO datetime strings where Long (epoch ms) is expected.
|
||||||
private val lenientLongAdapter = object : TypeAdapter<Long>() {
|
private val lenientLongAdapter = object : TypeAdapter<Long>() {
|
||||||
@ -145,50 +143,42 @@ object ApiClient {
|
|||||||
logRequest(request)
|
logRequest(request)
|
||||||
val response = chain.proceed(request)
|
val response = chain.proceed(request)
|
||||||
val tookMs = TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - startedNs)
|
val tookMs = TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - startedNs)
|
||||||
Log.d(TAG, "<-- ${response.code} ${request.method} ${request.url} (${tookMs}ms)")
|
Log.d(
|
||||||
logResponseBody(response)
|
TAG,
|
||||||
|
"<-- code=${response.code} method=${request.method} " +
|
||||||
|
"path=${request.url.encodedPath} durationMs=$tookMs",
|
||||||
|
)
|
||||||
return response
|
return response
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun logRequest(request: Request) {
|
private fun logRequest(request: Request) {
|
||||||
val body = request.body
|
val body = request.body
|
||||||
if (body is MultipartBody) {
|
if (body is MultipartBody) {
|
||||||
Log.d(TAG, "--> ${request.method} ${request.url}")
|
Log.d(
|
||||||
|
TAG,
|
||||||
|
"--> method=${request.method} path=${request.url.encodedPath} multipart=true",
|
||||||
|
)
|
||||||
body.parts.forEachIndexed { index, part ->
|
body.parts.forEachIndexed { index, part ->
|
||||||
Log.d(TAG, " ${describePart(index, part)}")
|
Log.d(TAG, " ${describePart(index, part)}")
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
val bodyText = requestBodyAsString(body)
|
val contentLength = runCatching { body?.contentLength() ?: 0L }.getOrDefault(-1L)
|
||||||
if (bodyText.isNullOrBlank()) {
|
Log.d(
|
||||||
Log.d(TAG, "--> ${request.method} ${request.url}")
|
TAG,
|
||||||
} else {
|
"--> method=${request.method} path=${request.url.encodedPath} " +
|
||||||
Log.d(TAG, "--> ${request.method} ${request.url}\n$bodyText")
|
"contentType=${body?.contentType()} contentLength=$contentLength",
|
||||||
}
|
)
|
||||||
}
|
|
||||||
|
|
||||||
private fun requestBodyAsString(body: okhttp3.RequestBody?): String? {
|
|
||||||
if (body == null) return null
|
|
||||||
val contentType = body.contentType()?.toString().orEmpty()
|
|
||||||
if (contentType.startsWith("multipart/", ignoreCase = true)) return null
|
|
||||||
if (!isTextual(contentType)) return null
|
|
||||||
return runCatching {
|
|
||||||
val buffer = Buffer()
|
|
||||||
body.writeTo(buffer)
|
|
||||||
val charset = body.contentType()?.charset(Charsets.UTF_8) ?: Charsets.UTF_8
|
|
||||||
buffer.readString(charset)
|
|
||||||
}.getOrNull()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun describePart(index: Int, part: MultipartBody.Part): String {
|
private fun describePart(index: Int, part: MultipartBody.Part): String {
|
||||||
val headers = part.headers
|
val headers = part.headers
|
||||||
val disposition = headers?.get("Content-Disposition").orEmpty()
|
val disposition = headers?.get("Content-Disposition").orEmpty()
|
||||||
val name = parseDispositionValue(disposition, "name").orEmpty().ifBlank { "-" }
|
val name = parseDispositionValue(disposition, "name").orEmpty().ifBlank { "-" }
|
||||||
val filename = parseDispositionValue(disposition, "filename").orEmpty().ifBlank { "-" }
|
|
||||||
val mimeType = part.body.contentType()?.toString().orEmpty().ifBlank { "-" }
|
val mimeType = part.body.contentType()?.toString().orEmpty().ifBlank { "-" }
|
||||||
val size = runCatching { part.body.contentLength() }.getOrDefault(-1L)
|
val size = runCatching { part.body.contentLength() }.getOrDefault(-1L)
|
||||||
val sizeText = if (size >= 0) size.toString() else "unknown"
|
val sizeText = if (size >= 0) size.toString() else "unknown"
|
||||||
return "part[$index] name=$name filename=$filename contentType=$mimeType size=$sizeText"
|
return "part[$index] name=$name contentType=$mimeType size=$sizeText"
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun parseDispositionValue(disposition: String, key: String): String? {
|
private fun parseDispositionValue(disposition: String, key: String): String? {
|
||||||
@ -201,27 +191,6 @@ object ApiClient {
|
|||||||
return disposition.substring(from, end)
|
return disposition.substring(from, end)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun isTextual(contentType: String): Boolean {
|
|
||||||
return contentType.startsWith("application/json", ignoreCase = true) ||
|
|
||||||
contentType.startsWith("application/xml", ignoreCase = true) ||
|
|
||||||
contentType.startsWith("text/", ignoreCase = true) ||
|
|
||||||
contentType.contains("x-www-form-urlencoded", ignoreCase = true) ||
|
|
||||||
contentType.contains("json", ignoreCase = true)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun logResponseBody(response: Response) {
|
|
||||||
// 204、HEAD 等合法响应可以没有响应体。日志拦截器不得为了读取调试信息
|
|
||||||
// 改变请求结果,更不能在 OkHttp 调度线程触发空指针异常。
|
|
||||||
val body = response.body ?: return
|
|
||||||
val contentType = body.contentType()?.toString().orEmpty()
|
|
||||||
if (!isTextual(contentType)) return
|
|
||||||
val bodyText = runCatching {
|
|
||||||
response.peekBody(MAX_LOG_BODY_BYTES).string()
|
|
||||||
}.getOrNull()?.trim()
|
|
||||||
if (!bodyText.isNullOrBlank()) {
|
|
||||||
Log.d(TAG, " ${bodyText.replace('\n', ' ')}")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -16,7 +16,7 @@ internal interface SdkPlatformConfigApi {
|
|||||||
internal data class SdkPlatformConfigResponse(val data: SdkPlatformConfig? = null)
|
internal data class SdkPlatformConfigResponse(val data: SdkPlatformConfig? = null)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 平台服务地址配置,由 [XuqmSDK.initialize] 从租户平台拉取。
|
* 平台服务地址配置,由 SDK 完成签名配置自动初始化后从租户平台拉取。
|
||||||
* 仅包含各服务的 URL;各子 SDK 在初始化完成后自行请求自己的业务配置。
|
* 仅包含各服务的 URL;各子 SDK 在初始化完成后自行请求自己的业务配置。
|
||||||
*
|
*
|
||||||
* 若 [apiUrl] 为 null,所有 HTTP 服务地址均从初始化时传入的 platformUrl 推导(同一系统内,不跨平台)。
|
* 若 [apiUrl] 为 null,所有 HTTP 服务地址均从初始化时传入的 platformUrl 推导(同一系统内,不跨平台)。
|
||||||
@ -32,6 +32,13 @@ data class SdkPlatformConfig(
|
|||||||
@SerializedName("bugCollectApiUrl") val bugCollectApiUrl: String? = null,
|
@SerializedName("bugCollectApiUrl") val bugCollectApiUrl: String? = null,
|
||||||
/** 各服务开通状态,由平台下发 */
|
/** 各服务开通状态,由平台下发 */
|
||||||
val features: SdkPlatformFeatures? = null,
|
val features: SdkPlatformFeatures? = null,
|
||||||
|
/**
|
||||||
|
* Update 是否要求登录后才允许检查。
|
||||||
|
*
|
||||||
|
* null 表示旧服务端没有返回该字段。客户端必须按“要求登录”处理,和服务端
|
||||||
|
* allowAnonymousUpdateCheck 的安全默认 false 保持一致。
|
||||||
|
*/
|
||||||
|
val updateRequiresLogin: Boolean? = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
/** 平台各服务开通状态。 */
|
/** 平台各服务开通状态。 */
|
||||||
|
|||||||
@ -0,0 +1,140 @@
|
|||||||
|
package com.xuqm.sdk.storage
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.security.keystore.KeyGenParameterSpec
|
||||||
|
import android.security.keystore.KeyProperties
|
||||||
|
import java.security.KeyStore
|
||||||
|
import java.security.MessageDigest
|
||||||
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
|
import javax.crypto.Cipher
|
||||||
|
import javax.crypto.KeyGenerator
|
||||||
|
import javax.crypto.SecretKey
|
||||||
|
import javax.crypto.spec.GCMParameterSpec
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SDK 唯一的敏感键值存储。
|
||||||
|
*
|
||||||
|
* 数据使用 Android Keystore 中不可导出的 AES-256-GCM 密钥加密,再写入普通
|
||||||
|
* SharedPreferences。每个 namespace 与逻辑 key 都作为 AAD 参与认证,密文不能跨位置替换。
|
||||||
|
*/
|
||||||
|
class SecureStore private constructor(
|
||||||
|
context: Context,
|
||||||
|
private val namespace: String,
|
||||||
|
) {
|
||||||
|
private val appContext = context.applicationContext
|
||||||
|
private val prefs = appContext.getSharedPreferences(
|
||||||
|
"xuqm_secure_${sha256(namespace).take(20)}",
|
||||||
|
Context.MODE_PRIVATE,
|
||||||
|
)
|
||||||
|
|
||||||
|
fun getString(key: String): String? {
|
||||||
|
val encoded = prefs.getString(key, null) ?: return null
|
||||||
|
return runCatching {
|
||||||
|
AesGcmCodec.decrypt(masterKey(), encoded, associatedData(key))
|
||||||
|
}.getOrElse {
|
||||||
|
// 自动备份恢复、系统密钥失效或数据损坏时只删除当前值,禁止返回未认证数据。
|
||||||
|
prefs.edit().remove(key).apply()
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun putString(key: String, value: String?, synchronous: Boolean = false) {
|
||||||
|
if (value == null) {
|
||||||
|
remove(key)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val encrypted = AesGcmCodec.encrypt(masterKey(), value, associatedData(key))
|
||||||
|
val editor = prefs.edit().putString(key, encrypted)
|
||||||
|
if (synchronous) editor.commit() else editor.apply()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun remove(key: String) {
|
||||||
|
prefs.edit().remove(key).apply()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun clear() {
|
||||||
|
prefs.edit().clear().apply()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun entries(): Map<String, String> =
|
||||||
|
prefs.all.keys.mapNotNull { key -> getString(key)?.let { key to it } }.toMap()
|
||||||
|
|
||||||
|
private fun associatedData(key: String): ByteArray =
|
||||||
|
"$namespace\u0000$key".toByteArray(Charsets.UTF_8)
|
||||||
|
|
||||||
|
private fun masterKey(): SecretKey = synchronized(KEY_LOCK) {
|
||||||
|
val keyStore = KeyStore.getInstance(ANDROID_KEY_STORE).apply { load(null) }
|
||||||
|
(keyStore.getKey(MASTER_KEY_ALIAS, null) as? SecretKey) ?: KeyGenerator
|
||||||
|
.getInstance(KeyProperties.KEY_ALGORITHM_AES, ANDROID_KEY_STORE)
|
||||||
|
.apply {
|
||||||
|
init(
|
||||||
|
KeyGenParameterSpec.Builder(
|
||||||
|
MASTER_KEY_ALIAS,
|
||||||
|
KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT,
|
||||||
|
)
|
||||||
|
.setBlockModes(KeyProperties.BLOCK_MODE_GCM)
|
||||||
|
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
|
||||||
|
.setKeySize(256)
|
||||||
|
.setRandomizedEncryptionRequired(true)
|
||||||
|
.build(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.generateKey()
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val ANDROID_KEY_STORE = "AndroidKeyStore"
|
||||||
|
private const val MASTER_KEY_ALIAS = "com.xuqm.sdk.secure_store.aes_gcm.v1"
|
||||||
|
private val KEY_LOCK = Any()
|
||||||
|
private val stores = ConcurrentHashMap<String, SecureStore>()
|
||||||
|
|
||||||
|
fun open(context: Context, namespace: String): SecureStore {
|
||||||
|
require(namespace.isNotBlank()) { "SecureStore namespace must not be blank" }
|
||||||
|
val appContext = context.applicationContext
|
||||||
|
val cacheKey = "${appContext.packageName}\u0000$namespace"
|
||||||
|
return stores.getOrPut(cacheKey) { SecureStore(appContext, namespace) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun sha256(value: String): String =
|
||||||
|
MessageDigest.getInstance("SHA-256")
|
||||||
|
.digest(value.toByteArray(Charsets.UTF_8))
|
||||||
|
.joinToString("") { "%02x".format(it) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal object AesGcmCodec {
|
||||||
|
private const val PREFIX = "v1"
|
||||||
|
private const val GCM_TAG_BITS = 128
|
||||||
|
|
||||||
|
fun encrypt(key: SecretKey, plainText: String, aad: ByteArray): String {
|
||||||
|
val cipher = Cipher.getInstance("AES/GCM/NoPadding")
|
||||||
|
cipher.init(Cipher.ENCRYPT_MODE, key)
|
||||||
|
cipher.updateAAD(aad)
|
||||||
|
return listOf(
|
||||||
|
PREFIX,
|
||||||
|
encode(cipher.iv),
|
||||||
|
encode(cipher.doFinal(plainText.toByteArray(Charsets.UTF_8))),
|
||||||
|
).joinToString(".")
|
||||||
|
}
|
||||||
|
|
||||||
|
fun decrypt(key: SecretKey, encoded: String, aad: ByteArray): String {
|
||||||
|
val parts = encoded.split(".")
|
||||||
|
require(parts.size == 3 && parts[0] == PREFIX) { "Unsupported secure value format" }
|
||||||
|
val cipher = Cipher.getInstance("AES/GCM/NoPadding")
|
||||||
|
cipher.init(Cipher.DECRYPT_MODE, key, GCMParameterSpec(GCM_TAG_BITS, decode(parts[1])))
|
||||||
|
cipher.updateAAD(aad)
|
||||||
|
return cipher.doFinal(decode(parts[2])).toString(Charsets.UTF_8)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun encode(value: ByteArray): String =
|
||||||
|
org.bouncycastle.util.encoders.Base64.toBase64String(value)
|
||||||
|
.trimEnd('=')
|
||||||
|
.replace('+', '-')
|
||||||
|
.replace('/', '_')
|
||||||
|
|
||||||
|
private fun decode(value: String): ByteArray {
|
||||||
|
val standard = value.replace('-', '+').replace('_', '/')
|
||||||
|
.let { it + "=".repeat((4 - it.length % 4) % 4) }
|
||||||
|
return org.bouncycastle.util.encoders.Base64.decode(standard)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,30 @@
|
|||||||
|
package com.xuqm.sdk
|
||||||
|
|
||||||
|
import org.junit.Assert.assertFalse
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class PublicInitializationApiTest {
|
||||||
|
@Test
|
||||||
|
fun `manual initialization is not part of public API`() {
|
||||||
|
val publicMethods = XuqmSDK::class.java.methods
|
||||||
|
.filterNot { it.isSynthetic }
|
||||||
|
.map { it.name }
|
||||||
|
.toSet()
|
||||||
|
|
||||||
|
assertFalse("initialize" in publicMethods)
|
||||||
|
assertFalse("autoInitialize" in publicMethods)
|
||||||
|
assertFalse("autoInitializeAsync" in publicMethods)
|
||||||
|
assertFalse(publicMethods.any { it.startsWith("autoInitializeFromBytes") })
|
||||||
|
assertFalse("markBugCollectDisabledByServer" in publicMethods)
|
||||||
|
assertFalse("disableBugCollectFromExtension" in publicMethods)
|
||||||
|
assertFalse("login" in publicMethods)
|
||||||
|
assertFalse("logout" in publicMethods)
|
||||||
|
assertFalse("getPlatformType" in publicMethods)
|
||||||
|
assertFalse("configureServiceEndpoints" in publicMethods)
|
||||||
|
assertFalse("useExternalServiceEndpoints" in publicMethods)
|
||||||
|
assertFalse("useLocalServiceEndpoints" in publicMethods)
|
||||||
|
assertFalse(
|
||||||
|
XuqmSDK::class.java.fields.any { it.name == "DEFAULT_PLATFORM_URL" },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,40 @@
|
|||||||
|
package com.xuqm.sdk.internal
|
||||||
|
|
||||||
|
import org.junit.Assert.assertFalse
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class BugCollectRuntimePolicyTest {
|
||||||
|
@Test
|
||||||
|
fun `automatic collection requires every gate`() {
|
||||||
|
assertTrue(
|
||||||
|
BugCollectRuntimePolicy.isAutomaticReady(
|
||||||
|
buildEnabled = true,
|
||||||
|
automaticEnabled = true,
|
||||||
|
privacyGranted = true,
|
||||||
|
platformEnabled = true,
|
||||||
|
serverDisabled = false,
|
||||||
|
hasUploadEndpoint = true,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
listOf(
|
||||||
|
booleanArrayOf(false, true, true, true, false, true),
|
||||||
|
booleanArrayOf(true, false, true, true, false, true),
|
||||||
|
booleanArrayOf(true, true, false, true, false, true),
|
||||||
|
booleanArrayOf(true, true, true, false, false, true),
|
||||||
|
booleanArrayOf(true, true, true, true, true, true),
|
||||||
|
booleanArrayOf(true, true, true, true, false, false),
|
||||||
|
).forEach { gates ->
|
||||||
|
assertFalse(
|
||||||
|
BugCollectRuntimePolicy.isAutomaticReady(
|
||||||
|
buildEnabled = gates[0],
|
||||||
|
automaticEnabled = gates[1],
|
||||||
|
privacyGranted = gates[2],
|
||||||
|
platformEnabled = gates[3],
|
||||||
|
serverDisabled = gates[4],
|
||||||
|
hasUploadEndpoint = gates[5],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,109 @@
|
|||||||
|
package com.xuqm.sdk.internal
|
||||||
|
|
||||||
|
import org.bouncycastle.jce.provider.BouncyCastleProvider
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertThrows
|
||||||
|
import org.junit.Test
|
||||||
|
import java.security.KeyPairGenerator
|
||||||
|
import java.security.Signature
|
||||||
|
import java.util.Base64
|
||||||
|
import javax.crypto.Cipher
|
||||||
|
import javax.crypto.SecretKeyFactory
|
||||||
|
import javax.crypto.spec.GCMParameterSpec
|
||||||
|
import javax.crypto.spec.PBEKeySpec
|
||||||
|
import javax.crypto.spec.SecretKeySpec
|
||||||
|
|
||||||
|
class ConfigFileCryptoTest {
|
||||||
|
private val provider = BouncyCastleProvider()
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun serverGeneratedV2VectorIsInteroperable() {
|
||||||
|
assertEquals(SERVER_VECTOR_JSON, ConfigFileCrypto.decrypt(SERVER_VECTOR_TOKEN))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun validV2ConfigIsVerifiedBeforeDecryption() {
|
||||||
|
val fixture = fixture()
|
||||||
|
assertEquals(fixture.json, ConfigFileCrypto.canonicalize(com.google.gson.JsonParser.parseString(fixture.json)))
|
||||||
|
assertEquals(fixture.json, ConfigFileCrypto.decrypt(fixture.content, mapOf("test" to fixture.publicKey)))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun tamperedCiphertextIsRejectedBySignature() {
|
||||||
|
val fixture = fixture()
|
||||||
|
val parts = fixture.content.split(".").toMutableList()
|
||||||
|
parts[4] = parts[4].replaceRange(0, 1, if (parts[4][0] == 'A') "B" else "A")
|
||||||
|
|
||||||
|
val error = assertThrows(IllegalArgumentException::class.java) {
|
||||||
|
ConfigFileCrypto.decrypt(parts.joinToString("."), mapOf("test" to fixture.publicKey))
|
||||||
|
}
|
||||||
|
assertEquals("Config signature verification failed", error.message)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun v1AndUnknownSigningKeysAreRejected() {
|
||||||
|
assertThrows(IllegalArgumentException::class.java) {
|
||||||
|
ConfigFileCrypto.decrypt("XUQM-CONFIG-V1.a.b.c", emptyMap())
|
||||||
|
}
|
||||||
|
val fixture = fixture()
|
||||||
|
assertThrows(IllegalArgumentException::class.java) {
|
||||||
|
ConfigFileCrypto.decrypt(fixture.content, emptyMap())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun fixture(): Fixture {
|
||||||
|
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"}"""
|
||||||
|
val salt = ByteArray(16) { (it + 1).toByte() }
|
||||||
|
val iv = ByteArray(12) { (it + 17).toByte() }
|
||||||
|
val keySpec = PBEKeySpec(
|
||||||
|
"xuqm-config-file-v2.2026.internal".toCharArray(),
|
||||||
|
salt,
|
||||||
|
120_000,
|
||||||
|
256,
|
||||||
|
)
|
||||||
|
val key = SecretKeySpec(
|
||||||
|
SecretKeyFactory.getInstance("PBKDF2WithHmacSHA256").generateSecret(keySpec).encoded,
|
||||||
|
"AES",
|
||||||
|
)
|
||||||
|
val cipher = Cipher.getInstance("AES/GCM/NoPadding")
|
||||||
|
cipher.init(Cipher.ENCRYPT_MODE, key, GCMParameterSpec(128, iv))
|
||||||
|
val encoder = Base64.getUrlEncoder().withoutPadding()
|
||||||
|
val keyPair = KeyPairGenerator.getInstance("Ed25519", provider).generateKeyPair()
|
||||||
|
val signedPayload = listOf(
|
||||||
|
"XUQM-CONFIG-V2",
|
||||||
|
"test",
|
||||||
|
encoder.encodeToString(salt),
|
||||||
|
encoder.encodeToString(iv),
|
||||||
|
encoder.encodeToString(cipher.doFinal(json.toByteArray())),
|
||||||
|
).joinToString(".")
|
||||||
|
val signer = Signature.getInstance("Ed25519", provider)
|
||||||
|
signer.initSign(keyPair.private)
|
||||||
|
signer.update(signedPayload.toByteArray(Charsets.US_ASCII))
|
||||||
|
return Fixture(
|
||||||
|
json = json,
|
||||||
|
content = "$signedPayload.${encoder.encodeToString(signer.sign())}",
|
||||||
|
publicKey = keyPair.public,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private data class Fixture(
|
||||||
|
val json: String,
|
||||||
|
val content: String,
|
||||||
|
val publicKey: java.security.PublicKey,
|
||||||
|
)
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
/**
|
||||||
|
* 由服务端 ConfigFileCrypto.encryptAndSign 使用开发测试密钥真实生成。
|
||||||
|
* 测试向量仅包含公开配置、公钥可验证签名和密文,不包含私钥。
|
||||||
|
*/
|
||||||
|
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"}"""
|
||||||
|
private const val SERVER_VECTOR_TOKEN =
|
||||||
|
"XUQM-CONFIG-V2.xuqm-config-dev-2026-07." +
|
||||||
|
"JPlU6fEC8jLVYhSUQZgEGg.fXJwK2yKUyyVQDMe." +
|
||||||
|
"Zqw7v9Y1ysFfbErPGKTg-rtZRgEOefn9-T44XA81YEJpsN219i_uBYiVXYDZUBKgOGg7KWAZz1ReBfiB-JOeU2N2Y-6Dm7KzDJcEDuBKiZLGqrnAKgmTd1IOqewejZLhQxmXa8Xk_YVgxy3CIZ3-F5dVIhN7hUXjfDTgUnhXNUEj56blG5keuB8x-W6Nz3AkYn-P11qLVLXELr7DJhd9BkooYOJHenJ3V62DTb5AoGXnUzn2VyT9xHZiy4kWyG7yH6daCascWP8iCKaLm9yhKresL6EoUiw4WS9rTPKR_0m1N11BaR38RO5J-0V41Y0YCF0PYCDSwjgG8e3aL_eYVMIh1_sZYaZWs7SBSubZtqe420fh2lqF68KyyF4mpIhS4jP3wWcxmFDzOW_XYvbZzSZ9Tpm0ROgOz7pb." +
|
||||||
|
"_fbWIWofH9fTEqnkEIdLr2KoXEIHZg3AmW7CQD-_TrXZHIcEU30PkRPPv8AXguBJPZxX0T-m41dag6_96YVQCw"
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,35 @@
|
|||||||
|
package com.xuqm.sdk.storage
|
||||||
|
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertThrows
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
import javax.crypto.KeyGenerator
|
||||||
|
|
||||||
|
class AesGcmCodecTest {
|
||||||
|
@Test
|
||||||
|
fun valueRoundTripsAndUsesRandomIv() {
|
||||||
|
val key = KeyGenerator.getInstance("AES").apply { init(256) }.generateKey()
|
||||||
|
val aad = "namespace\u0000key".toByteArray()
|
||||||
|
val first = AesGcmCodec.encrypt(key, "secret", aad)
|
||||||
|
val second = AesGcmCodec.encrypt(key, "secret", aad)
|
||||||
|
|
||||||
|
assertTrue(first != second)
|
||||||
|
assertEquals("secret", AesGcmCodec.decrypt(key, first, aad))
|
||||||
|
assertEquals("secret", AesGcmCodec.decrypt(key, second, aad))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun wrongAssociatedDataAndTamperingAreRejected() {
|
||||||
|
val key = KeyGenerator.getInstance("AES").apply { init(256) }.generateKey()
|
||||||
|
val encoded = AesGcmCodec.encrypt(key, "secret", "scope-a".toByteArray())
|
||||||
|
|
||||||
|
assertThrows(Exception::class.java) {
|
||||||
|
AesGcmCodec.decrypt(key, encoded, "scope-b".toByteArray())
|
||||||
|
}
|
||||||
|
val tampered = encoded.dropLast(1) + if (encoded.last() == 'A') "B" else "A"
|
||||||
|
assertThrows(Exception::class.java) {
|
||||||
|
AesGcmCodec.decrypt(key, tampered, "scope-a".toByteArray())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
62
sdk-file/README.md
普通文件
62
sdk-file/README.md
普通文件
@ -0,0 +1,62 @@
|
|||||||
|
# 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)
|
||||||
|
```
|
||||||
|
|
||||||
|
打开本地文件不依赖平台初始化或登录。
|
||||||
|
|
||||||
|
模块唯一声明 `${applicationId}.xuqm.fileprovider`。宿主和其他 SDK 不应重复声明
|
||||||
|
同一用途的 FileProvider。
|
||||||
30
sdk-file/build.gradle.kts
普通文件
30
sdk-file/build.gradle.kts
普通文件
@ -0,0 +1,30 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
6
sdk-file/consumer-rules.pro
普通文件
6
sdk-file/consumer-rules.pro
普通文件
@ -0,0 +1,6 @@
|
|||||||
|
-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>;
|
||||||
|
}
|
||||||
@ -0,0 +1,18 @@
|
|||||||
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<application>
|
||||||
|
<provider
|
||||||
|
android:name="androidx.core.content.FileProvider"
|
||||||
|
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>
|
||||||
@ -0,0 +1,14 @@
|
|||||||
|
package com.xuqm.sdk.file.platform
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 平台文件服务返回的结构化业务错误。
|
||||||
|
*
|
||||||
|
* [code]、[status] 与 [message] 均来自服务端响应;HTTP 层无法解析出业务响应时,
|
||||||
|
* code 使用 HTTP 状态码,status 使用 `HTTP_<状态码>`。异常不会携带或输出原始响应体。
|
||||||
|
*/
|
||||||
|
class PlatformFileException(
|
||||||
|
val code: Int,
|
||||||
|
val status: String,
|
||||||
|
message: String,
|
||||||
|
cause: Throwable? = null,
|
||||||
|
) : IllegalStateException(message, cause)
|
||||||
@ -0,0 +1,116 @@
|
|||||||
|
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)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,101 @@
|
|||||||
|
package com.xuqm.sdk.file.platform
|
||||||
|
|
||||||
|
import com.google.gson.JsonParser
|
||||||
|
import okhttp3.MediaType.Companion.toMediaTypeOrNull
|
||||||
|
import okhttp3.MultipartBody
|
||||||
|
import okhttp3.RequestBody.Companion.toRequestBody
|
||||||
|
import retrofit2.HttpException
|
||||||
|
import retrofit2.http.Multipart
|
||||||
|
import retrofit2.http.POST
|
||||||
|
import retrofit2.http.Part
|
||||||
|
|
||||||
|
internal data class PlatformFileApiResponse<T>(
|
||||||
|
val code: Int? = null,
|
||||||
|
val status: String? = null,
|
||||||
|
val data: T? = null,
|
||||||
|
val message: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
internal interface PlatformFileApi {
|
||||||
|
@Multipart
|
||||||
|
@POST("api/file/upload")
|
||||||
|
suspend fun upload(
|
||||||
|
@Part file: MultipartBody.Part,
|
||||||
|
@Part thumbnail: MultipartBody.Part? = null,
|
||||||
|
): PlatformFileApiResponse<FileUploadResult>
|
||||||
|
}
|
||||||
|
|
||||||
|
internal data class PlatformFileUploadParts(
|
||||||
|
val fileName: String,
|
||||||
|
val file: MultipartBody.Part,
|
||||||
|
val thumbnailBytes: ByteArray?,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 上传的唯一执行边界:先等待 SDK 平台配置就绪,再创建请求体和 API 实例。
|
||||||
|
*
|
||||||
|
* 这样 Uri/File/ByteArray 三个公开入口不会在 Provider 异步初始化期间抢先读取端点,
|
||||||
|
* 也不会在初始化失败时提前访问 Uri 或文件。
|
||||||
|
*/
|
||||||
|
internal class PlatformFileUploadGateway(
|
||||||
|
private val awaitInitialization: suspend () -> Unit,
|
||||||
|
private val apiProvider: () -> PlatformFileApi,
|
||||||
|
) {
|
||||||
|
suspend fun upload(createParts: () -> PlatformFileUploadParts): FileUploadResult {
|
||||||
|
awaitInitialization()
|
||||||
|
val parts = createParts()
|
||||||
|
val thumbnail = parts.thumbnailBytes?.takeIf(ByteArray::isNotEmpty)?.let {
|
||||||
|
MultipartBody.Part.createFormData(
|
||||||
|
"thumbnail",
|
||||||
|
"${parts.fileName.substringBeforeLast('.', parts.fileName)}_thumb.jpg",
|
||||||
|
it.toRequestBody("image/jpeg".toMediaTypeOrNull()),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val response = try {
|
||||||
|
apiProvider().upload(parts.file, thumbnail)
|
||||||
|
} catch (error: HttpException) {
|
||||||
|
throw error.toPlatformFileException()
|
||||||
|
}
|
||||||
|
return response.requireUploadResult()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun PlatformFileApiResponse<FileUploadResult>.requireUploadResult(): FileUploadResult {
|
||||||
|
val responseCode = code ?: UNKNOWN_CODE
|
||||||
|
val responseStatus = status.orEmpty()
|
||||||
|
val responseMessage = message?.takeIf(String::isNotBlank)
|
||||||
|
?: "平台文件服务返回无效响应"
|
||||||
|
if (responseCode != SUCCESS_CODE || responseStatus != SUCCESS_STATUS) {
|
||||||
|
throw PlatformFileException(responseCode, responseStatus, responseMessage)
|
||||||
|
}
|
||||||
|
return data ?: throw PlatformFileException(responseCode, responseStatus, responseMessage)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun HttpException.toPlatformFileException(): PlatformFileException {
|
||||||
|
val httpCode = code()
|
||||||
|
val parsed = runCatching {
|
||||||
|
val body = response()?.errorBody()?.string().orEmpty()
|
||||||
|
val root = JsonParser.parseString(body).takeIf { it.isJsonObject }?.asJsonObject
|
||||||
|
val code = root?.get("code")
|
||||||
|
?.takeIf { it.isJsonPrimitive && it.asJsonPrimitive.isNumber }
|
||||||
|
?.asInt
|
||||||
|
val status = root?.get("status")
|
||||||
|
?.takeIf { it.isJsonPrimitive && it.asJsonPrimitive.isString }
|
||||||
|
?.asString
|
||||||
|
val message = root?.get("message")
|
||||||
|
?.takeIf { it.isJsonPrimitive && it.asJsonPrimitive.isString }
|
||||||
|
?.asString
|
||||||
|
?.takeIf(String::isNotBlank)
|
||||||
|
Triple(code, status, message)
|
||||||
|
}.getOrNull()
|
||||||
|
return PlatformFileException(
|
||||||
|
code = parsed?.first ?: httpCode,
|
||||||
|
status = parsed?.second ?: "HTTP_$httpCode",
|
||||||
|
message = parsed?.third ?: "平台文件服务请求失败(HTTP $httpCode)",
|
||||||
|
cause = this,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private const val SUCCESS_CODE = 200
|
||||||
|
private const val SUCCESS_STATUS = "0"
|
||||||
|
private const val UNKNOWN_CODE = -1
|
||||||
@ -0,0 +1,116 @@
|
|||||||
|
package com.xuqm.sdk.file.platform.internal
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.net.Uri
|
||||||
|
import android.provider.OpenableColumns
|
||||||
|
import okhttp3.MediaType.Companion.toMediaTypeOrNull
|
||||||
|
import okhttp3.RequestBody
|
||||||
|
import okio.BufferedSink
|
||||||
|
import okio.source
|
||||||
|
import java.io.File
|
||||||
|
import java.util.Locale
|
||||||
|
|
||||||
|
/** 平台上传专用的流式请求体,不进入 sdk-core 的公共文件契约。 */
|
||||||
|
internal object UploadRequestBodies {
|
||||||
|
fun resolveDisplayName(context: Context, uri: Uri): String {
|
||||||
|
context.contentResolver.query(uri, arrayOf(OpenableColumns.DISPLAY_NAME), null, null, null)
|
||||||
|
?.use { cursor ->
|
||||||
|
val nameIndex = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME)
|
||||||
|
if (nameIndex >= 0 && cursor.moveToFirst()) {
|
||||||
|
val value = cursor.getString(nameIndex)
|
||||||
|
if (!value.isNullOrBlank()) return value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val fallback = uri.lastPathSegment?.substringAfterLast('/')
|
||||||
|
return fallback?.takeIf { it.isNotBlank() }
|
||||||
|
?: String.format(Locale.US, "upload_%d", System.currentTimeMillis())
|
||||||
|
}
|
||||||
|
|
||||||
|
fun createUri(
|
||||||
|
context: Context,
|
||||||
|
uri: Uri,
|
||||||
|
mimeType: String?,
|
||||||
|
onProgress: (Int) -> Unit,
|
||||||
|
): RequestBody = object : RequestBody() {
|
||||||
|
override fun contentType() = mimeType?.toMediaTypeOrNull()
|
||||||
|
override fun contentLength(): Long = resolveSize(context, uri).coerceAtLeast(-1L)
|
||||||
|
|
||||||
|
override fun writeTo(sink: BufferedSink) {
|
||||||
|
val totalBytes = resolveSize(context, uri)
|
||||||
|
context.contentResolver.openInputStream(uri)?.use { input ->
|
||||||
|
val source = input.source()
|
||||||
|
var uploaded = 0L
|
||||||
|
while (true) {
|
||||||
|
val read = source.read(sink.buffer, BUFFER_SIZE)
|
||||||
|
if (read == -1L) break
|
||||||
|
uploaded += read
|
||||||
|
sink.flush()
|
||||||
|
if (totalBytes > 0L) {
|
||||||
|
onProgress((uploaded * 100L / totalBytes).toInt().coerceIn(0, 100))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (totalBytes <= 0L) onProgress(100)
|
||||||
|
} ?: error("Failed to open input stream for $uri")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun createBytes(
|
||||||
|
mimeType: String?,
|
||||||
|
bytes: ByteArray,
|
||||||
|
onProgress: (Int) -> Unit,
|
||||||
|
): RequestBody = object : RequestBody() {
|
||||||
|
override fun contentType() = mimeType?.toMediaTypeOrNull()
|
||||||
|
override fun contentLength(): Long = bytes.size.toLong()
|
||||||
|
|
||||||
|
override fun writeTo(sink: BufferedSink) {
|
||||||
|
var uploaded = 0
|
||||||
|
while (uploaded < bytes.size) {
|
||||||
|
val count = minOf(BUFFER_SIZE.toInt(), bytes.size - uploaded)
|
||||||
|
sink.write(bytes, uploaded, count)
|
||||||
|
uploaded += count
|
||||||
|
onProgress((uploaded * 100 / bytes.size).coerceIn(0, 100))
|
||||||
|
}
|
||||||
|
if (bytes.isEmpty()) onProgress(100)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun createFile(
|
||||||
|
file: File,
|
||||||
|
mimeType: String?,
|
||||||
|
onProgress: (Int) -> Unit,
|
||||||
|
): RequestBody = object : RequestBody() {
|
||||||
|
override fun contentType() = mimeType?.toMediaTypeOrNull()
|
||||||
|
override fun contentLength(): Long = file.length()
|
||||||
|
|
||||||
|
override fun writeTo(sink: BufferedSink) {
|
||||||
|
file.source().use { source ->
|
||||||
|
val totalBytes = file.length()
|
||||||
|
var uploaded = 0L
|
||||||
|
while (true) {
|
||||||
|
val read = source.read(sink.buffer, BUFFER_SIZE)
|
||||||
|
if (read == -1L) break
|
||||||
|
uploaded += read
|
||||||
|
sink.flush()
|
||||||
|
if (totalBytes > 0L) {
|
||||||
|
onProgress((uploaded * 100L / totalBytes).toInt().coerceIn(0, 100))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (totalBytes <= 0L) onProgress(100)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun resolveSize(context: Context, uri: Uri): Long {
|
||||||
|
context.contentResolver.query(uri, arrayOf(OpenableColumns.SIZE), null, null, null)
|
||||||
|
?.use { cursor ->
|
||||||
|
val sizeIndex = cursor.getColumnIndex(OpenableColumns.SIZE)
|
||||||
|
if (sizeIndex >= 0 && cursor.moveToFirst()) {
|
||||||
|
val value = cursor.getLong(sizeIndex)
|
||||||
|
if (value > 0L) return value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return -1L
|
||||||
|
}
|
||||||
|
|
||||||
|
private const val BUFFER_SIZE = 8 * 1024L
|
||||||
|
}
|
||||||
@ -1,7 +1,6 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<paths xmlns:android="http://schemas.android.com/apk/res/android">
|
<paths xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
<files-path name="files" path="." />
|
|
||||||
<cache-path name="cache" path="." />
|
|
||||||
<external-files-path name="external_files" path="." />
|
<external-files-path name="external_files" path="." />
|
||||||
<external-cache-path name="external_cache" path="." />
|
<files-path name="internal_files" path="." />
|
||||||
|
<cache-path name="cache_files" path="." />
|
||||||
</paths>
|
</paths>
|
||||||
@ -0,0 +1,156 @@
|
|||||||
|
package com.xuqm.sdk.file.platform
|
||||||
|
|
||||||
|
import com.xuqm.sdk.XuqmErrorCode
|
||||||
|
import com.xuqm.sdk.XuqmSdkException
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
|
import okhttp3.MediaType.Companion.toMediaType
|
||||||
|
import okhttp3.MultipartBody
|
||||||
|
import okhttp3.RequestBody.Companion.toRequestBody
|
||||||
|
import okhttp3.ResponseBody.Companion.toResponseBody
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertFalse
|
||||||
|
import org.junit.Assert.assertSame
|
||||||
|
import org.junit.Test
|
||||||
|
import retrofit2.HttpException
|
||||||
|
import retrofit2.Response
|
||||||
|
|
||||||
|
class PlatformFileUploadGatewayTest {
|
||||||
|
@Test
|
||||||
|
fun `successful envelope returns upload data`() {
|
||||||
|
val expected = uploadResult()
|
||||||
|
|
||||||
|
val actual = PlatformFileApiResponse(
|
||||||
|
code = 200,
|
||||||
|
status = "0",
|
||||||
|
data = expected,
|
||||||
|
message = "success",
|
||||||
|
).requireUploadResult()
|
||||||
|
|
||||||
|
assertSame(expected, actual)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `successful envelope without data remains structured error`() {
|
||||||
|
val error = expectPlatformFileException {
|
||||||
|
PlatformFileApiResponse<FileUploadResult>(
|
||||||
|
code = 200,
|
||||||
|
status = "0",
|
||||||
|
data = null,
|
||||||
|
message = "success",
|
||||||
|
).requireUploadResult()
|
||||||
|
}
|
||||||
|
|
||||||
|
assertEquals(200, error.code)
|
||||||
|
assertEquals("0", error.status)
|
||||||
|
assertEquals("success", error.message)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `business failure preserves server code status and message`() {
|
||||||
|
val error = expectPlatformFileException {
|
||||||
|
PlatformFileApiResponse<FileUploadResult>(
|
||||||
|
code = 403,
|
||||||
|
status = "1",
|
||||||
|
data = null,
|
||||||
|
message = "当前租户未开通文件服务",
|
||||||
|
).requireUploadResult()
|
||||||
|
}
|
||||||
|
|
||||||
|
assertEquals(403, error.code)
|
||||||
|
assertEquals("1", error.status)
|
||||||
|
assertEquals("当前租户未开通文件服务", error.message)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `initialization failure prevents request construction and api access`() = runBlocking {
|
||||||
|
val expected = XuqmSdkException(XuqmErrorCode.XUQM_NOT_READY, "SDK 尚未初始化")
|
||||||
|
var partsCreated = false
|
||||||
|
var apiAccessed = false
|
||||||
|
val gateway = PlatformFileUploadGateway(
|
||||||
|
awaitInitialization = { throw expected },
|
||||||
|
apiProvider = {
|
||||||
|
apiAccessed = true
|
||||||
|
fakeApi(PlatformFileApiResponse(200, "0", uploadResult(), "success"))
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
val actual = runCatching {
|
||||||
|
gateway.upload {
|
||||||
|
partsCreated = true
|
||||||
|
uploadParts()
|
||||||
|
}
|
||||||
|
}.exceptionOrNull()
|
||||||
|
|
||||||
|
assertSame(expected, actual)
|
||||||
|
assertFalse(partsCreated)
|
||||||
|
assertFalse(apiAccessed)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `http error parses structured fields without exposing response body`() = runBlocking {
|
||||||
|
val responseBody = """
|
||||||
|
{
|
||||||
|
"code": 42901,
|
||||||
|
"status": "1",
|
||||||
|
"message": "上传次数超过限制",
|
||||||
|
"debugSecret": "do-not-leak"
|
||||||
|
}
|
||||||
|
""".trimIndent().toResponseBody("application/json".toMediaType())
|
||||||
|
val gateway = PlatformFileUploadGateway(
|
||||||
|
awaitInitialization = {},
|
||||||
|
apiProvider = {
|
||||||
|
object : PlatformFileApi {
|
||||||
|
override suspend fun upload(
|
||||||
|
file: MultipartBody.Part,
|
||||||
|
thumbnail: MultipartBody.Part?,
|
||||||
|
): PlatformFileApiResponse<FileUploadResult> {
|
||||||
|
throw HttpException(
|
||||||
|
Response.error<FileUploadResult>(429, responseBody),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
val error = runCatching { gateway.upload(::uploadParts) }
|
||||||
|
.exceptionOrNull() as? PlatformFileException
|
||||||
|
?: error("Expected PlatformFileException")
|
||||||
|
|
||||||
|
assertEquals(42901, error.code)
|
||||||
|
assertEquals("1", error.status)
|
||||||
|
assertEquals("上传次数超过限制", error.message)
|
||||||
|
assertFalse(error.message.orEmpty().contains("do-not-leak"))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun expectPlatformFileException(block: () -> Unit): PlatformFileException =
|
||||||
|
runCatching(block).exceptionOrNull() as? PlatformFileException
|
||||||
|
?: error("Expected PlatformFileException")
|
||||||
|
|
||||||
|
private fun fakeApi(
|
||||||
|
response: PlatformFileApiResponse<FileUploadResult>,
|
||||||
|
): PlatformFileApi = object : PlatformFileApi {
|
||||||
|
override suspend fun upload(
|
||||||
|
file: MultipartBody.Part,
|
||||||
|
thumbnail: MultipartBody.Part?,
|
||||||
|
): PlatformFileApiResponse<FileUploadResult> = response
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun uploadParts() = PlatformFileUploadParts(
|
||||||
|
fileName = "report.pdf",
|
||||||
|
file = MultipartBody.Part.createFormData(
|
||||||
|
"file",
|
||||||
|
"report.pdf",
|
||||||
|
"content".toRequestBody(),
|
||||||
|
),
|
||||||
|
thumbnailBytes = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun uploadResult() = FileUploadResult(
|
||||||
|
url = "https://file.example.test/api/file/hash",
|
||||||
|
hash = "hash",
|
||||||
|
size = 7,
|
||||||
|
originalName = "report.pdf",
|
||||||
|
mimeType = "application/pdf",
|
||||||
|
ext = "pdf",
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -0,0 +1,41 @@
|
|||||||
|
package com.xuqm.sdk.file.platform.internal
|
||||||
|
|
||||||
|
import okio.Buffer
|
||||||
|
import org.junit.Assert.assertArrayEquals
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Test
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
|
class UploadRequestBodiesTest {
|
||||||
|
@Test
|
||||||
|
fun `byte array upload streams the exact payload and completes progress`() {
|
||||||
|
val payload = ByteArray(20_000) { (it % 251).toByte() }
|
||||||
|
val progress = mutableListOf<Int>()
|
||||||
|
val sink = Buffer()
|
||||||
|
|
||||||
|
UploadRequestBodies.createBytes("application/octet-stream", payload, progress::add)
|
||||||
|
.writeTo(sink)
|
||||||
|
|
||||||
|
assertArrayEquals(payload, sink.readByteArray())
|
||||||
|
assertEquals(100, progress.last())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `file upload streams the exact payload without loading through public api`() {
|
||||||
|
val payload = ByteArray(20_000) { (it % 239).toByte() }
|
||||||
|
val file = File.createTempFile("xuqm-upload-", ".bin")
|
||||||
|
try {
|
||||||
|
file.writeBytes(payload)
|
||||||
|
val progress = mutableListOf<Int>()
|
||||||
|
val sink = Buffer()
|
||||||
|
|
||||||
|
UploadRequestBodies.createFile(file, "application/octet-stream", progress::add)
|
||||||
|
.writeTo(sink)
|
||||||
|
|
||||||
|
assertArrayEquals(payload, sink.readByteArray())
|
||||||
|
assertEquals(100, progress.last())
|
||||||
|
} finally {
|
||||||
|
file.delete()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -11,7 +11,9 @@ implementation("com.xuqm:sdk-core:VERSION") // 必须
|
|||||||
|
|
||||||
## 使用
|
## 使用
|
||||||
|
|
||||||
**无需手动初始化。** `XuqmSDK.login(userId, userSig)` 成功后自动完成 IM 登录。
|
**无需手动初始化或单独登录。** 宿主调用
|
||||||
|
`XuqmSDK.setUserInfo(XuqmUserInfo(userId, userSig))` 后自动完成 IM 登录;传入 `null`
|
||||||
|
时统一登出。
|
||||||
|
|
||||||
```kotlin
|
```kotlin
|
||||||
// ImClient 直接使用
|
// ImClient 直接使用
|
||||||
|
|||||||
@ -31,6 +31,7 @@ android {
|
|||||||
}
|
}
|
||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
api(project(":sdk-core"))
|
api(project(":sdk-file"))
|
||||||
implementation(libs.kotlinx.coroutines.android)
|
implementation(libs.kotlinx.coroutines.android)
|
||||||
|
testImplementation(libs.junit4)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,9 @@
|
|||||||
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<application>
|
||||||
|
<provider
|
||||||
|
android:name="com.xuqm.sdk.internal.XuqmMergedProvider"
|
||||||
|
android:authorities="${applicationId}.xuqm-init"
|
||||||
|
android:exported="false"
|
||||||
|
android:initOrder="50" />
|
||||||
|
</application>
|
||||||
|
</manifest>
|
||||||
@ -36,7 +36,7 @@ class ImClient(
|
|||||||
.build()
|
.build()
|
||||||
|
|
||||||
fun connect() {
|
fun connect() {
|
||||||
Log.d(TAG, "connect() wsUrl=$wsUrl appKey=$appKey")
|
Log.d(TAG, "WebSocket connection requested")
|
||||||
disconnect(closeSocket = false)
|
disconnect(closeSocket = false)
|
||||||
val request = Request.Builder()
|
val request = Request.Builder()
|
||||||
.url(wsUrl)
|
.url(wsUrl)
|
||||||
@ -54,13 +54,16 @@ class ImClient(
|
|||||||
|
|
||||||
override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
|
override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
|
||||||
connected = false
|
connected = false
|
||||||
Log.e(TAG, "websocket onFailure connected=false reason=${t.message}", t)
|
Log.e(
|
||||||
|
TAG,
|
||||||
|
"WebSocket connection failed: errorType=${t.javaClass.simpleName}",
|
||||||
|
)
|
||||||
listeners.forEach { it.onDisconnected(t.message) }
|
listeners.forEach { it.onDisconnected(t.message) }
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
|
override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
|
||||||
connected = false
|
connected = false
|
||||||
Log.d(TAG, "websocket onClosed code=$code reason=$reason")
|
Log.d(TAG, "WebSocket closed: code=$code")
|
||||||
listeners.forEach { it.onDisconnected(reason) }
|
listeners.forEach { it.onDisconnected(reason) }
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@ -93,7 +96,11 @@ class ImClient(
|
|||||||
content: String,
|
content: String,
|
||||||
mentionedUserIds: String? = null,
|
mentionedUserIds: String? = null,
|
||||||
): Boolean {
|
): Boolean {
|
||||||
Log.d(TAG, "sendMessage messageId=$messageId toId=$toId chatType=$chatType msgType=$msgType contentLength=${content.length} mentioned=${mentionedUserIds.orEmpty()}")
|
Log.d(
|
||||||
|
TAG,
|
||||||
|
"sendMessage chatType=$chatType msgType=$msgType " +
|
||||||
|
"contentLength=${content.length} hasMentions=${!mentionedUserIds.isNullOrBlank()}",
|
||||||
|
)
|
||||||
val payload = linkedMapOf(
|
val payload = linkedMapOf(
|
||||||
"appKey" to appKey,
|
"appKey" to appKey,
|
||||||
"messageId" to messageId,
|
"messageId" to messageId,
|
||||||
@ -205,15 +212,8 @@ class ImClient(
|
|||||||
val msg = gson.fromJson(body, ImMessage::class.java)
|
val msg = gson.fromJson(body, ImMessage::class.java)
|
||||||
Log.d(
|
Log.d(
|
||||||
TAG,
|
TAG,
|
||||||
buildString {
|
"stomp MESSAGE chatType=${msg.chatType} msgType=${msg.msgType} " +
|
||||||
append("stomp MESSAGE destination=").append(headers["destination"].orEmpty())
|
"status=${msg.status}",
|
||||||
append(" id=").append(msg.id)
|
|
||||||
append(" chatType=").append(msg.chatType)
|
|
||||||
append(" msgType=").append(msg.msgType)
|
|
||||||
append(" from=").append(msg.fromId)
|
|
||||||
append(" to=").append(msg.toId)
|
|
||||||
append(" status=").append(msg.status)
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
if (msg.status.uppercase() == "READ") {
|
if (msg.status.uppercase() == "READ") {
|
||||||
listeners.forEach { it.onRead(msg) }
|
listeners.forEach { it.onRead(msg) }
|
||||||
@ -234,7 +234,7 @@ class ImClient(
|
|||||||
}
|
}
|
||||||
"ERROR" -> {
|
"ERROR" -> {
|
||||||
val reason = body.ifBlank { headers["message"].orEmpty() }
|
val reason = body.ifBlank { headers["message"].orEmpty() }
|
||||||
Log.e(TAG, "stomp ERROR reason=$reason")
|
Log.e(TAG, "STOMP server error received")
|
||||||
listeners.forEach { it.onError(reason.ifBlank { "STOMP error" }) }
|
listeners.forEach { it.onError(reason.ifBlank { "STOMP error" }) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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.FileSDK
|
import com.xuqm.sdk.file.platform.PlatformFileSDK
|
||||||
import com.xuqm.sdk.file.FileUploadResult
|
import com.xuqm.sdk.file.platform.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
|
||||||
@ -111,28 +111,6 @@ object ImSDK {
|
|||||||
XuqmSDK.currentLoginSession?.let { onSdkLogin(it) }
|
XuqmSDK.currentLoginSession?.let { onSdkLogin(it) }
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun login(userId: String, userSig: String) = withContext(Dispatchers.IO) {
|
|
||||||
XuqmSDK.requireInit()
|
|
||||||
if (currentUserId == userId && currentUserSig == userSig) {
|
|
||||||
return@withContext
|
|
||||||
}
|
|
||||||
currentUserId = userId
|
|
||||||
currentUserSig = userSig
|
|
||||||
connectWithToken(userSig)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 刷新 userSig(IM 登录凭证过期时调用)。
|
|
||||||
* 等效于用新 userSig 重新调用 [XuqmSDK.setUserInfo]。
|
|
||||||
*/
|
|
||||||
suspend fun refreshToken(userSig: String) = withContext(Dispatchers.IO) {
|
|
||||||
XuqmSDK.requireInit()
|
|
||||||
if (currentUserId.isBlank()) return@withContext
|
|
||||||
if (currentUserSig == userSig) return@withContext
|
|
||||||
currentUserSig = userSig
|
|
||||||
connectWithToken(userSig)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun sendMessage(
|
fun sendMessage(
|
||||||
toId: String,
|
toId: String,
|
||||||
chatType: String,
|
chatType: String,
|
||||||
@ -156,7 +134,11 @@ object ImSDK {
|
|||||||
content = content,
|
content = content,
|
||||||
mentionedUserIds = mentionedUserIds,
|
mentionedUserIds = mentionedUserIds,
|
||||||
) == true
|
) == true
|
||||||
Log.d(TAG, "sendMessage id=${message.id} toId=$toId chatType=$chatType msgType=$msgType contentLength=${content.length} mentioned=${mentionedUserIds.orEmpty()} sent=$sent")
|
Log.d(
|
||||||
|
TAG,
|
||||||
|
"sendMessage chatType=$chatType msgType=$msgType " +
|
||||||
|
"contentLength=${content.length} hasMentions=${!mentionedUserIds.isNullOrBlank()} sent=$sent",
|
||||||
|
)
|
||||||
return if (sent) message else message.copy(status = "FAILED")
|
return if (sent) message else message.copy(status = "FAILED")
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -182,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 = FileSDK.upload(file)
|
val result = PlatformFileSDK.upload(file)
|
||||||
sendImageMessageWithUploadResult(toId, chatType, result, width, height)
|
sendImageMessageWithUploadResult(toId, chatType, result, width, height)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -218,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 = FileSDK.upload(file)
|
val result = PlatformFileSDK.upload(file)
|
||||||
sendVideoMessageWithUploadResult(toId, chatType, result, width, height, durationMs)
|
sendVideoMessageWithUploadResult(toId, chatType, result, width, height, durationMs)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -253,7 +235,7 @@ object ImSDK {
|
|||||||
chatType: String,
|
chatType: String,
|
||||||
file: File,
|
file: File,
|
||||||
): ImMessage = withContext(Dispatchers.IO) {
|
): ImMessage = withContext(Dispatchers.IO) {
|
||||||
val result = FileSDK.upload(file)
|
val result = PlatformFileSDK.upload(file)
|
||||||
sendFileMessageWithUploadResult(toId, chatType, result)
|
sendFileMessageWithUploadResult(toId, chatType, result)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -282,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 = FileSDK.upload(file)
|
val result = PlatformFileSDK.upload(file)
|
||||||
sendAudioMessageWithUploadResult(toId, chatType, result, durationMs)
|
sendAudioMessageWithUploadResult(toId, chatType, result, durationMs)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -445,7 +427,7 @@ object ImSDK {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun subscribeGroup(groupId: String) {
|
fun subscribeGroup(groupId: String) {
|
||||||
Log.d(TAG, "subscribeGroup groupId=$groupId")
|
Log.d(TAG, "Group subscription requested")
|
||||||
synchronized(activeGroupSubscriptions) {
|
synchronized(activeGroupSubscriptions) {
|
||||||
activeGroupSubscriptions.add(groupId)
|
activeGroupSubscriptions.add(groupId)
|
||||||
}
|
}
|
||||||
@ -453,7 +435,7 @@ object ImSDK {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun unsubscribeGroup(groupId: String) {
|
fun unsubscribeGroup(groupId: String) {
|
||||||
Log.d(TAG, "unsubscribeGroup groupId=$groupId")
|
Log.d(TAG, "Group unsubscription requested")
|
||||||
synchronized(activeGroupSubscriptions) {
|
synchronized(activeGroupSubscriptions) {
|
||||||
activeGroupSubscriptions.remove(groupId)
|
activeGroupSubscriptions.remove(groupId)
|
||||||
}
|
}
|
||||||
@ -817,7 +799,7 @@ object ImSDK {
|
|||||||
disconnectInternal(clearTokenStore = true)
|
disconnectInternal(clearTokenStore = true)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun onSdkLogin(session: XuqmLoginSession) {
|
private fun onSdkLogin(session: XuqmLoginSession) {
|
||||||
XuqmSDK.requireInit()
|
XuqmSDK.requireInit()
|
||||||
if (session.userSig.isBlank()) return // IM 未提供 userSig,跳过连接(Push/Update 仍生效)
|
if (session.userSig.isBlank()) return // IM 未提供 userSig,跳过连接(Push/Update 仍生效)
|
||||||
if (currentUserId == session.userId && currentUserSig == session.userSig) return
|
if (currentUserId == session.userId && currentUserSig == session.userSig) return
|
||||||
@ -826,7 +808,7 @@ object ImSDK {
|
|||||||
connectWithToken(session.userSig)
|
connectWithToken(session.userSig)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun onSdkLogout() {
|
private fun onSdkLogout() {
|
||||||
disconnectInternal(clearTokenStore = false)
|
disconnectInternal(clearTokenStore = false)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -842,7 +824,7 @@ object ImSDK {
|
|||||||
client?.disconnect()
|
client?.disconnect()
|
||||||
_connectionState.value = ImConnectionState.Connecting
|
_connectionState.value = ImConnectionState.Connecting
|
||||||
currentToken = token
|
currentToken = token
|
||||||
Log.d(TAG, "connectWithToken userId=$currentUserId activeGroups=${activeGroupSubscriptions.size}")
|
Log.d(TAG, "Connecting IM: activeGroupCount=${activeGroupSubscriptions.size}")
|
||||||
client = ImClient(ServiceEndpointRegistry.imWsUrl, token, XuqmSDK.appKey)
|
client = ImClient(ServiceEndpointRegistry.imWsUrl, token, XuqmSDK.appKey)
|
||||||
client?.addListener(connectionListener)
|
client?.addListener(connectionListener)
|
||||||
client?.addListener(conversationEventListener)
|
client?.addListener(conversationEventListener)
|
||||||
|
|||||||
@ -0,0 +1,15 @@
|
|||||||
|
package com.xuqm.sdk.im
|
||||||
|
|
||||||
|
import org.junit.Assert.assertFalse
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class PublicLifecycleApiTest {
|
||||||
|
@Test
|
||||||
|
fun `login lifecycle hooks are not public API`() {
|
||||||
|
val methods = ImSDK::class.java.methods.filterNot { it.isSynthetic }.map { it.name }.toSet()
|
||||||
|
assertFalse("login" in methods)
|
||||||
|
assertFalse("refreshToken" in methods)
|
||||||
|
assertFalse("onSdkLogin" in methods)
|
||||||
|
assertFalse("onSdkLogout" in methods)
|
||||||
|
}
|
||||||
|
}
|
||||||
某些文件未显示,因为此 diff 中更改的文件太多 显示更多
正在加载...
在新工单中引用
屏蔽一个用户