refactor(sdk): unify configuration and extension lifecycle
这个提交包含在:
父节点
f784f3354c
当前提交
fcdc94b309
8
.gitignore
vendored
8
.gitignore
vendored
@ -5,6 +5,7 @@ dist/
|
||||
target/
|
||||
build/
|
||||
.gradle/
|
||||
.kotlin/
|
||||
*.iml
|
||||
.idea/
|
||||
*.log
|
||||
@ -13,3 +14,10 @@ build/
|
||||
sentry.properties
|
||||
/.gradle-home/
|
||||
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)
|
||||
|
||||
### 初始化(两种方式,均不得修改签名)
|
||||
### 初始化(唯一入口)
|
||||
|
||||
**方式 A — ContentProvider 自动初始化(推荐)**
|
||||
将 `config.xuqm` 放入 `src/main/assets/xuqm/`,App 无需调用任何初始化代码。
|
||||
`XuqmInitializerProvider` 在 App 启动时自动触发。
|
||||
将租户平台签发的 `config.xuqmconfig` 原样放入
|
||||
`src/main/assets/config/config.xuqmconfig`。集成任一需要初始化的扩展 SDK 后,
|
||||
合并 Provider 在 App 启动时自动触发;宿主不得手动传 appKey 初始化。
|
||||
|
||||
**方式 B — 手动初始化**
|
||||
```kotlin
|
||||
// Application.onCreate() 中:
|
||||
XuqmSDK.initialize(context, appKey = "xxx") // 公有平台
|
||||
XuqmSDK.initialize(context, appKey = "xxx", platformUrl = "https://xxx") // 私有化平台
|
||||
|
||||
// 等待平台配置(在 coroutine 中):
|
||||
XuqmSDK.awaitInitialization()
|
||||
```
|
||||
|
||||
**两种平台互相独立,不允许自动降级到默认公有平台(`DEFAULT_PLATFORM_URL`)。**
|
||||
`serverUrl` 是配置签发时确定的唯一平台地址。运行时不存在默认平台地址,也不允许
|
||||
宿主覆盖、跨平台降级或维护第二套初始化配置。
|
||||
|
||||
### 用户信息
|
||||
|
||||
@ -68,7 +59,8 @@ XuqmSDK.setUserInfo(null) // 登出,触发所有子 SDK 登出
|
||||
- 平台托管用户(有 userSig):完整功能,包含 IM
|
||||
- **禁止在 push / update / bugcollect 等 SDK 内部校验 userSig 是否存在**
|
||||
|
||||
**注意:** push 设备注册依赖请求签名(`signingKey` in config.xuqm),与 userSig 无关。`signingKey` 未配置时,设备注册请求会被服务端拒绝(HTTP 401)。需在平台后台为该 appKey 生成 signingKey 并重新下载 config.xuqm。
|
||||
**注意:** push 设备注册依赖 `config.xuqmconfig` 中的平台请求签名信息,与 userSig
|
||||
无关。未配置时设备注册请求会被服务端拒绝,需在平台后台重新生成并下载配置文件。
|
||||
|
||||
### 平台配置读取(init 完成后)
|
||||
```kotlin
|
||||
@ -80,7 +72,7 @@ XuqmSDK.appKey // 当前 appKey
|
||||
## Bug 采集 SDK(sdk-bugcollect)
|
||||
|
||||
```kotlin
|
||||
// Application.onCreate() 中(XuqmSDK.initialize 之后):
|
||||
// 平台配置自动初始化完成且宿主已取得隐私授权后:
|
||||
BugCollect.setLogLevel(LogLevel.INFO)
|
||||
BugCollect.setEnvironment("production")
|
||||
BugCollect.startCrashCapture() // 开启 UncaughtExceptionHandler
|
||||
@ -97,9 +89,8 @@ BugCollect.defineFunnel("checkout", listOf("cart_view", "checkout_start", "payme
|
||||
|
||||
`bugCollectApiUrl` 由 SDK 在 init 后从平台配置自动获取,无需 App 传入。
|
||||
|
||||
## 向下兼容约束
|
||||
## API 约束
|
||||
|
||||
- `XuqmSDK.initialize()` 现有参数不得删除或修改类型
|
||||
- `XuqmSDK.setUserInfo()` 现有 `XuqmUserInfo` 字段不得删除
|
||||
- `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_FILE', defaultValue: false, description: '发布 sdk-file')
|
||||
booleanParam(name: 'MOD_IM', defaultValue: false, description: '发布 sdk-im')
|
||||
booleanParam(name: 'MOD_PUSH', defaultValue: false, description: '发布 sdk-push')
|
||||
booleanParam(name: 'MOD_UPDATE', defaultValue: false, description: '发布 sdk-update')
|
||||
@ -34,7 +35,9 @@ pipeline {
|
||||
])
|
||||
script {
|
||||
def moduleChecks = [
|
||||
'sdk-common-plugin': params.MOD_CORE,
|
||||
'sdk-core': params.MOD_CORE,
|
||||
'sdk-file': params.MOD_FILE,
|
||||
'sdk-im': params.MOD_IM,
|
||||
'sdk-push': params.MOD_PUSH,
|
||||
'sdk-update': params.MOD_UPDATE,
|
||||
@ -86,7 +89,8 @@ pipeline {
|
||||
def modules = env.PUBLISH_MODULES.split(',').toList()
|
||||
// sdk-bugcollect-plugin 随 sdk-bugcollect 自动联动,共享版本号
|
||||
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]
|
||||
}
|
||||
def uniquePropNames = propNameByModule.values().toList().unique()
|
||||
@ -134,7 +138,9 @@ pipeline {
|
||||
script {
|
||||
def modules = env.PUBLISH_MODULES.split(',').toList()
|
||||
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"
|
||||
} else {
|
||||
bat "gradlew.bat :${mod}:lintRelease :${mod}:testDebugUnitTest ${env.VERSION_ARGS} --no-daemon"
|
||||
@ -149,7 +155,9 @@ pipeline {
|
||||
script {
|
||||
def modules = env.PUBLISH_MODULES.split(',').toList()
|
||||
// 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()) {
|
||||
bat 'chcp 65001 >nul && echo 没有AAR模块需要构建'
|
||||
} else {
|
||||
@ -167,11 +175,15 @@ pipeline {
|
||||
def credArgs = "-PNEXUS_USER=%NEXUS_CREDS_USR% -PNEXUS_PASSWORD=%NEXUS_CREDS_PSW%"
|
||||
// 按依赖顺序逐个发布,避免扩展 POM 先于 sdk-core 可见。
|
||||
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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
stage('Commit Version Bump') {
|
||||
when { expression { !params.SNAPSHOT } }
|
||||
|
||||
143
README.md
143
README.md
@ -6,7 +6,8 @@
|
||||
|
||||
```
|
||||
XuqmGroup-AndroidSDK/
|
||||
├── sdk-core/ # 核心:初始化、HTTP、Token 存储、文件上传、通用工具/组件
|
||||
├── sdk-core/ # 核心:初始化、基础 HTTP、本地文件、时间、安全存储
|
||||
├── sdk-file/ # 平台文件上传与 FileProvider 打开能力
|
||||
├── sdk-bugcollect/ # 日志监控:错误上报、Crash 捕获、漏斗分析(含 Gradle Plugin)
|
||||
├── sdk-im/ # IM:WebSocket 实时通信
|
||||
├── 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` 或环境变量中配置:
|
||||
```properties
|
||||
NEXUS_USER=your_username
|
||||
@ -43,12 +60,13 @@ NEXUS_PASSWORD=your_password
|
||||
引入依赖:
|
||||
```kotlin
|
||||
dependencies {
|
||||
implementation("com.xuqm:sdk-core:0.4.2")
|
||||
implementation("com.xuqm:sdk-bugcollect:0.4.2") // 日志监控(推荐)
|
||||
implementation("com.xuqm:sdk-im:0.4.2") // 可选
|
||||
implementation("com.xuqm:sdk-push:0.4.2") // 可选
|
||||
implementation("com.xuqm:sdk-update:0.4.2") // 可选
|
||||
implementation("com.xuqm:sdk-webview:0.4.2") // 可选
|
||||
implementation("com.xuqm:sdk-core:VERSION")
|
||||
implementation("com.xuqm:sdk-file:VERSION") // 平台文件能力可选
|
||||
implementation("com.xuqm:sdk-bugcollect:VERSION") // 可选
|
||||
implementation("com.xuqm:sdk-im:VERSION") // 可选
|
||||
implementation("com.xuqm:sdk-push:VERSION") // 可选
|
||||
implementation("com.xuqm:sdk-update:VERSION") // 可选
|
||||
implementation("com.xuqm:sdk-webview:VERSION") // 可选
|
||||
}
|
||||
```
|
||||
|
||||
@ -56,27 +74,22 @@ dependencies {
|
||||
|
||||
### 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()` 中调用:
|
||||
|
||||
```kotlin
|
||||
XuqmSDK.initialize(
|
||||
context = this,
|
||||
appKey = "ak_your_app_key",
|
||||
logLevel = if (BuildConfig.DEBUG) LogLevel.DEBUG else LogLevel.WARN
|
||||
)
|
||||
```
|
||||
仅依赖 `sdk-core` 时不会合并初始化 Provider、通知权限或 FileProvider,也不要求配置
|
||||
文件、初始化或登录。引入任一需要平台能力的扩展后,各 AAR 中同名声明会合并为唯一
|
||||
`XuqmMergedProvider`;Provider 只有检测到 common 构建插件生成的标记后才会自动读取
|
||||
上述配置,避免未应用插件的宿主发生隐式初始化。
|
||||
|
||||
### 2. 用户登录后初始化 IM
|
||||
|
||||
```kotlin
|
||||
// 调用业务登录接口,拿到 userSig 后只需要登录一次 SDK
|
||||
val userSig = api.getUserSig(userId)
|
||||
XuqmSDK.login(
|
||||
userId = userId,
|
||||
userSig = userSig,
|
||||
)
|
||||
XuqmSDK.setUserInfo(XuqmUserInfo(userId = userId, userSig = userSig))
|
||||
|
||||
// 如果工程里集成了 sdk-im / sdk-push / sdk-update,
|
||||
// SDK 会自动完成对应模块的登录与初始化
|
||||
@ -110,27 +123,6 @@ openXWebView(
|
||||
// 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
|
||||
|
||||
### SDKConfig
|
||||
@ -142,7 +134,28 @@ sample-app 里也提供了一个“环境设置”页面,可以直接在外网
|
||||
|
||||
### 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
|
||||
// 存储
|
||||
@ -155,31 +168,28 @@ val token = XuqmSDK.tokenStore.getToken()
|
||||
XuqmSDK.tokenStore.clear()
|
||||
```
|
||||
|
||||
### ApiClient
|
||||
### 网络边界
|
||||
|
||||
基于 OkHttp 5 + Retrofit 3,自动附加 Bearer Token,统一解析 `ApiResponse<T>`。
|
||||
|
||||
```kotlin
|
||||
// 通过 RetrofitFactory 获取接口实例
|
||||
val service = RetrofitFactory.create(MyApiService::class.java)
|
||||
```
|
||||
`sdk-core` 的 `CommonHttpClient` 与下载/摘要能力无需 SDK 初始化;依赖租户平台认证、
|
||||
服务地址和 FileProvider 的上传、打开能力统一由 `sdk-file` 的 `PlatformFileSDK` 提供。
|
||||
|
||||
### FileSDK
|
||||
|
||||
文件上传、下载、打开的统一入口,位于 `com.xuqm.sdk.file`。
|
||||
纯本地能力位于 `com.xuqm.sdk.file.FileSDK`;平台上传与 FileProvider 打开位于
|
||||
`com.xuqm.sdk.file.platform.PlatformFileSDK`。
|
||||
|
||||
#### 上传
|
||||
|
||||
```kotlin
|
||||
// 从 Uri(文件选择器返回值)上传,自动解析文件名和 MIME 类型
|
||||
val result: FileUploadResult = FileSDK.upload(
|
||||
val result: FileUploadResult = PlatformFileSDK.upload(
|
||||
context = context,
|
||||
uri = uri,
|
||||
onProgress = { progress -> /* 0–100 */ },
|
||||
)
|
||||
|
||||
// 直接上传字节数组(如相机拍照后的 ByteArray)
|
||||
val result = FileSDK.uploadBytes(
|
||||
val result = PlatformFileSDK.uploadBytes(
|
||||
fileName = "photo.jpg",
|
||||
mimeType = "image/jpeg",
|
||||
bytes = byteArray,
|
||||
@ -187,10 +197,12 @@ val result = FileSDK.uploadBytes(
|
||||
)
|
||||
|
||||
// 上传 File 对象
|
||||
val result = FileSDK.upload(file = file)
|
||||
val result = PlatformFileSDK.upload(file = file)
|
||||
```
|
||||
|
||||
`FileUploadResult` 字段:`url`、`thumbnailUrl`、`hash`、`size`、`originalName`、`mimeType`、`ext`
|
||||
。上传入口会自动等待平台初始化;服务端业务失败或响应结构错误统一抛出保留
|
||||
`code/status/message` 的 `PlatformFileException`,不会暴露原始响应体。
|
||||
|
||||
#### 下载
|
||||
|
||||
@ -218,22 +230,14 @@ val file: File = FileSDK.download(
|
||||
|
||||
```kotlin
|
||||
// 用系统应用打开本地文件(通过 FileProvider + ACTION_VIEW)
|
||||
FileSDK.openFile(context, file)
|
||||
PlatformFileSDK.openFile(context, file)
|
||||
```
|
||||
|
||||
需在 `AndroidManifest.xml` 中配置 `FileProvider`:
|
||||
`openFile` 是纯本地操作,不要求 SDK 初始化。
|
||||
|
||||
```xml
|
||||
<provider
|
||||
android:name="androidx.core.content.FileProvider"
|
||||
android:authorities="${applicationId}.fileprovider"
|
||||
android:exported="false"
|
||||
android:grantUriPermissions="true">
|
||||
<meta-data
|
||||
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||
android:resource="@xml/file_paths" />
|
||||
</provider>
|
||||
```
|
||||
`sdk-file` 是该 FileProvider 的唯一所有者;`sdk-update`、`sdk-im` 和 `sdk-webview`
|
||||
均复用它。只集成 `sdk-core` 不会向宿主注入 Provider,也不存在 core 内的上传或打开
|
||||
转发 API。
|
||||
|
||||
---
|
||||
|
||||
@ -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` 等)结尾
|
||||
- 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["nexusUser"] = providers.gradleProperty("NEXUS_USER").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 重构实施接管文档
|
||||
|
||||
> 更新时间:2026-07-18
|
||||
> 更新时间:2026-07-26
|
||||
> 分支:`main`
|
||||
> 发布约束:Maven 制品只允许通过 `https://jenkins.xuqinmin.com/` 发布。
|
||||
|
||||
@ -9,19 +9,18 @@
|
||||
- `sdk-core` 是配置、会话、网络、文件、时间等公共能力的唯一实现。
|
||||
- `sdk-update` 只提供更新领域 API;宿主与 RN 桥接不复制下载、校验和安装代码。
|
||||
- APK 更新必须支持 SHA-256、原子临时文件、进度、取消、有限重试、FileProvider、未知来源安装权限及确定错误码。
|
||||
- 废弃的离线激活 `sdk-license` 已彻底删除,不得恢复反射初始化或兼容入口。
|
||||
|
||||
## 2026-07-17 已完成
|
||||
|
||||
- `FileSDK` 新增详细下载进度、SHA-256 与哈希匹配公共 API。
|
||||
- 下载改为 `.part` 临时文件完成后替换目标文件;HTTP 非 2xx 失败,协程取消会中断连接并清理临时文件。
|
||||
- `sdk-core` 自动合并 FileProvider,扩展 SDK 不再要求宿主重复声明 provider。
|
||||
- 当时的 core FileProvider 归属已被后续终态替代:当前仅 `sdk-file` 声明,core 零 Manifest。
|
||||
- `sdk-update` 删除无版本 APK 兼容路径和弃用下载入口。
|
||||
- `sdk-update` 2.0.0-SNAPSHOT 强制要求合法 SHA-256,并提供下载重试、取消、缓存文件复验和确定错误码。
|
||||
- 增加 Java/RN 可直接使用的 `startDownloadAndInstall`、`UpdateInstallTask`、进度/结果回调和安装权限设置 Intent。
|
||||
- `sdk-core`、`sdk-update`、`sample-app` 编译通过。
|
||||
- `sdk-license` 模块、源码、初始化反射、Gradle/Jenkins 参数和架构文档引用已全部删除。
|
||||
- 本轮同时删除本地遗留的 `sdk-license` 构建目录及其空源码包,并收敛 update/push/im/sample 的空 package/resource 目录;源码树禁止保留空目录或 `.gitkeep` 占位。
|
||||
- 收敛 update/push/im/sample 的空 package/resource 目录;源码树禁止保留空目录或
|
||||
`.gitkeep` 占位。
|
||||
- 清理后 `:sdk-core:testDebugUnitTest :sdk-update:testDebugUnitTest` 重新执行并通过。
|
||||
- `sdk-core` 1 个单元测试、`sdk-update` 2 个单元测试通过。
|
||||
- 修复通知权限检查、MediaStore API 等级和 `java.time` minSdk 24 反向移植,core/update Release Lint 均通过。
|
||||
@ -37,12 +36,12 @@
|
||||
`com.squareup.okhttp3` 组件版本一致。
|
||||
- `sdk-update` 的 Gson 改为复用版本目录定义,删除模块内的重复版本事实来源。
|
||||
|
||||
## 下一步
|
||||
## 后续外部验收
|
||||
|
||||
1. 补充 Android 仪器测试。
|
||||
2. 审计 Java 8 对外制品与内部 Java 21 集成边界,完成对应构建门禁。
|
||||
3. 通过 Jenkins 先发布 `sdk-core` Snapshot,再发布 `sdk-update:2.0.0-SNAPSHOT`。
|
||||
4. 在 RN SDK Android 宿主中编译并验证下载取消、错误码、权限跳转和安装器唤起。
|
||||
1. 从租户平台下载与 Sample 包名匹配的真实 `config.xuqmconfig`,再执行 Sample
|
||||
Debug/Release 整包和仪器测试。
|
||||
2. 在目标宿主中验证下载取消、错误码、权限跳转和安装器唤起。
|
||||
3. 发布只允许通过 Jenkins 执行;当前开发机未发布任何 Maven 制品。
|
||||
|
||||
## 已验证命令
|
||||
|
||||
@ -53,3 +52,127 @@
|
||||
```
|
||||
|
||||
不得在开发机执行 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)`:
|
||||
- `bypassIgnore = false`(默认,静默检查):用户已忽略的版本不再弹窗,适合启动时后台检查。
|
||||
- `bypassIgnore = true`(主动检查):绕过忽略记录,始终返回真实更新状态;无更新时由调用方显示提示。
|
||||
- 移除了旧版 `userIdOverride` 参数,userId 统一通过 `XuqmSDK.login()` 建立 session 后自动传递。
|
||||
- 移除了旧版 `userIdOverride` 参数,userId 统一通过 `XuqmSDK.setUserInfo()` 同步后自动传递。
|
||||
|
||||
### 兼容性
|
||||
|
||||
- `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 等)
|
||||
└── 初始化(方式A: ContentProvider 自动 / 方式B: Application.onCreate 手动)
|
||||
宿主 App
|
||||
└── 放置租户平台签发的 assets/config/config.xuqmconfig
|
||||
↓
|
||||
sdk-core(核心层)
|
||||
├── XuqmSDK.initialize() → 拉取 /api/sdk/config
|
||||
sdk-core(核心层;单独集成时零初始化、零权限、零 Provider)
|
||||
├── XuqmMergedProvider 实现(仅由扩展 Manifest 注册)
|
||||
├── 初始化状态:IDLE / INITIALIZING / READY / DEGRADED / FAILED
|
||||
├── 七天加密 LKG(仅平台暂不可用时降级使用)
|
||||
├── XuqmSDK.setUserInfo() → 通知所有子模块(反射 / 接口回调)
|
||||
├── ApiClient(OkHttp 5 + Retrofit 3)
|
||||
├── TokenStore(EncryptedSharedPreferences)
|
||||
├── ApiClient(OkHttp 4.12 + Retrofit 3)
|
||||
├── TokenStore(Android Keystore + AES-256-GCM)
|
||||
├── FileSDK(上传 / 下载 / 打开文件)
|
||||
├── ServiceEndpointRegistry(各服务地址注册表)
|
||||
└── bugCollectApiUrl / bugCollectEnabled(从平台配置获取)
|
||||
@ -51,30 +53,18 @@ XuqmSDK.setUserInfo(null)
|
||||
|
||||
## 初始化流程
|
||||
|
||||
### 方式 A(ContentProvider 自动)
|
||||
|
||||
```
|
||||
App 启动
|
||||
↓
|
||||
XuqmInitializerProvider.onCreate()
|
||||
↓ 读取 assets/xuqm/config.xuqm
|
||||
ConfigFileReader.read(context)
|
||||
↓ 解析 appKey + serverUrl
|
||||
XuqmSDK.initialize(context, appKey, serverUrl)
|
||||
任一扩展合并的唯一 XuqmMergedProvider.onCreate()
|
||||
↓ 主线程仅读取 assets/config/config.xuqmconfig
|
||||
IO 线程验证 Ed25519 签名 → 解密 canonical JSON → 校验包身份
|
||||
↓ HTTP GET
|
||||
/api/sdk/config → 返回 { apiUrl, imWsUrl, fileServiceUrl, ... }
|
||||
```
|
||||
|
||||
### 方式 B(手动)
|
||||
|
||||
```
|
||||
Application.onCreate()
|
||||
↓
|
||||
XuqmSDK.initialize(context, appKey = "xxx")
|
||||
↓ HTTP GET
|
||||
/api/sdk/config → 解析平台配置
|
||||
↓
|
||||
各子模块就绪(等待 setUserInfo 触发具体功能)
|
||||
成功:READY 并原子保存 LKG
|
||||
失败且七天 LKG 有效:DEGRADED
|
||||
失败且无有效 LKG:FAILED;扩展停用,宿主核心业务继续
|
||||
```
|
||||
|
||||
## 技术栈
|
||||
@ -86,7 +76,7 @@ XuqmSDK.initialize(context, appKey = "xxx")
|
||||
| minSdk | 24 |
|
||||
| compileSdk | 36 |
|
||||
| Java | 21 |
|
||||
| OkHttp | 5.x |
|
||||
| OkHttp | 4.12.x(与 React Native 宿主二进制兼容) |
|
||||
| Retrofit | 3.x |
|
||||
| Coroutines | 1.x |
|
||||
|
||||
|
||||
@ -4,6 +4,7 @@ kotlin.code.style=official
|
||||
android.nonTransitiveRClass=true
|
||||
PUBLISH_VERSION=1.0.5
|
||||
SDK_CORE_VERSION=1.1.6-SNAPSHOT
|
||||
SDK_FILE_VERSION=1.0.0-SNAPSHOT
|
||||
SDK_IM_VERSION=1.1.3
|
||||
SDK_PUSH_VERSION=1.1.4-SNAPSHOT
|
||||
SDK_UPDATE_VERSION=2.0.0-SNAPSHOT
|
||||
|
||||
@ -26,11 +26,11 @@ espresso = "3.7.0"
|
||||
hilt = "2.56.2"
|
||||
ksp = "2.3.7"
|
||||
navigationCompose = "2.9.0"
|
||||
securityCrypto = "1.0.0"
|
||||
hiltNavigationCompose = "1.2.0"
|
||||
viewmodelCompose = "2.10.0"
|
||||
material = "1.13.0"
|
||||
firebaseBom = "34.7.0"
|
||||
bouncyCastle = "1.84"
|
||||
|
||||
[libraries]
|
||||
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-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-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" }
|
||||
androidx-webkit = { group = "androidx.webkit", name = "webkit", version.ref = "webkit" }
|
||||
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" }
|
||||
junit4 = { group = "junit", name = "junit", version.ref = "junit4" }
|
||||
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-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espresso" }
|
||||
|
||||
|
||||
@ -5,17 +5,17 @@ afterEvaluate {
|
||||
publications {
|
||||
release(MavenPublication) {
|
||||
from components.release
|
||||
groupId rootProject.group
|
||||
artifactId project.name
|
||||
version project.version
|
||||
groupId = rootProject.group
|
||||
artifactId = project.name
|
||||
version = project.version
|
||||
}
|
||||
}
|
||||
repositories {
|
||||
maven {
|
||||
url rootProject.ext['nexusUrl']
|
||||
url = rootProject.ext['nexusUrl']
|
||||
credentials {
|
||||
username rootProject.ext['nexusUser']
|
||||
password rootProject.ext['nexusPassword']
|
||||
username = rootProject.ext['nexusUser']
|
||||
password = rootProject.ext['nexusPassword']
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,6 +1,17 @@
|
||||
plugins {
|
||||
alias(libs.plugins.android.application)
|
||||
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 {
|
||||
@ -17,26 +28,26 @@ android {
|
||||
}
|
||||
|
||||
signingConfigs {
|
||||
create("ywq") {
|
||||
storeFile = file("/Users/xuqinmin/Projects/TrustProjects/YiwangxinApp/android/keystores/wsecx.keystore")
|
||||
storePassword = "bjca123456"
|
||||
keyAlias = "bjcawsecx"
|
||||
keyPassword = "bjca123456"
|
||||
if (hasReleaseSigning) {
|
||||
create("releaseFromProperties") {
|
||||
storeFile = file(releaseSigningProperties.getValue("storeFile").get())
|
||||
storePassword = releaseSigningProperties.getValue("storePassword").get()
|
||||
keyAlias = releaseSigningProperties.getValue("keyAlias").get()
|
||||
keyPassword = releaseSigningProperties.getValue("keyPassword").get()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
isMinifyEnabled = false
|
||||
signingConfig = signingConfigs.getByName("ywq")
|
||||
signingConfig = signingConfigs.findByName("releaseFromProperties")
|
||||
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
|
||||
}
|
||||
debug {
|
||||
signingConfig = signingConfigs.getByName("ywq")
|
||||
}
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
isCoreLibraryDesugaringEnabled = true
|
||||
sourceCompatibility = 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 {
|
||||
coreLibraryDesugaring(libs.desugar.jdk.libs)
|
||||
implementation(project(":sdk-core"))
|
||||
implementation(project(":sdk-file"))
|
||||
implementation(project(":sdk-bugcollect"))
|
||||
implementation(project(":sdk-im"))
|
||||
implementation(project(":sdk-push"))
|
||||
implementation(project(":sdk-update"))
|
||||
|
||||
@ -7,7 +7,6 @@ import com.xuqm.sdk.XuqmSDK
|
||||
import com.xuqm.sdk.XuqmUserInfo
|
||||
import com.xuqm.sdk.im.ImSDK
|
||||
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.DemoApiFactory
|
||||
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_TOTAL_MS = 40_000L
|
||||
|
||||
private fun initSdkOnce(ctx: Context) {
|
||||
XuqmSDK.initialize(ctx, DEMO_APP_ID)
|
||||
private fun awaitSdkInitialization() {
|
||||
runBlocking { XuqmSDK.awaitInitialization() }
|
||||
XuqmSDK.setUserInfo(null)
|
||||
Thread.sleep(1_500)
|
||||
XuqmSDK.setUserInfo(null)
|
||||
SampleEnvironmentConfig.useExternal()
|
||||
Thread.sleep(500)
|
||||
}
|
||||
|
||||
private suspend fun loginAndConnect(userId: String, ctx: Context): String {
|
||||
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}" }
|
||||
XuqmSDK.setUserInfo(XuqmUserInfo(userId = userId, userSig = token))
|
||||
withTimeoutOrNull(CONNECT_TIMEOUT_MS) {
|
||||
@ -77,7 +76,7 @@ class CrossDeviceSenderTest {
|
||||
@BeforeClass @JvmStatic
|
||||
fun init() {
|
||||
appCtx = InstrumentationRegistry.getInstrumentation().targetContext
|
||||
initSdkOnce(appCtx)
|
||||
awaitSdkInitialization()
|
||||
}
|
||||
}
|
||||
|
||||
@ -141,7 +140,7 @@ class CrossDeviceReceiverTest {
|
||||
@BeforeClass @JvmStatic
|
||||
fun init() {
|
||||
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.im.ImSDK
|
||||
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.DemoApiFactory
|
||||
import com.xuqm.sdk.sample.data.api.LoginRequest
|
||||
@ -38,7 +37,6 @@ class NetworkResilienceTest {
|
||||
|
||||
companion object {
|
||||
private const val USER_A = "user_a"
|
||||
private const val PASSWORD = "123456"
|
||||
private const val BASE_URL = "https://dev.xuqinmin.com/"
|
||||
private const val CONNECT_TIMEOUT_MS = 20_000L
|
||||
private const val RECONNECT_TIMEOUT_MS = 15_000L // 首次退避 1s,留 15s 余量
|
||||
@ -48,12 +46,10 @@ class NetworkResilienceTest {
|
||||
@BeforeClass @JvmStatic
|
||||
fun initSdk() {
|
||||
appCtx = InstrumentationRegistry.getInstrumentation().targetContext
|
||||
XuqmSDK.initialize(appCtx, DEMO_APP_ID)
|
||||
runBlocking { XuqmSDK.awaitInitialization() }
|
||||
XuqmSDK.setUserInfo(null)
|
||||
Thread.sleep(1_500)
|
||||
XuqmSDK.setUserInfo(null)
|
||||
SampleEnvironmentConfig.useExternal()
|
||||
Thread.sleep(500)
|
||||
}
|
||||
}
|
||||
|
||||
@ -61,7 +57,9 @@ class NetworkResilienceTest {
|
||||
fun setUp() {
|
||||
runBlocking {
|
||||
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}" }
|
||||
XuqmSDK.setUserInfo(XuqmUserInfo(userId = USER_A, userSig = token))
|
||||
withTimeoutOrNull(CONNECT_TIMEOUT_MS) {
|
||||
|
||||
@ -8,7 +8,6 @@ import com.xuqm.sdk.XuqmSDK
|
||||
import com.xuqm.sdk.XuqmUserInfo
|
||||
import com.xuqm.sdk.push.PushSDK
|
||||
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.DemoApiFactory
|
||||
import com.xuqm.sdk.sample.data.api.LoginRequest
|
||||
@ -33,7 +32,6 @@ class PushSdkTest {
|
||||
|
||||
companion object {
|
||||
private const val USER_A = "user_a"
|
||||
private const val PASSWORD = "123456"
|
||||
private const val BASE_URL = "https://dev.xuqinmin.com/"
|
||||
|
||||
lateinit var appCtx: Context
|
||||
@ -42,19 +40,19 @@ class PushSdkTest {
|
||||
@JvmStatic
|
||||
fun initSdk() {
|
||||
appCtx = InstrumentationRegistry.getInstrumentation().targetContext
|
||||
XuqmSDK.initialize(appCtx, DEMO_APP_ID)
|
||||
runBlocking { XuqmSDK.awaitInitialization() }
|
||||
XuqmSDK.setUserInfo(null)
|
||||
Thread.sleep(1_500)
|
||||
XuqmSDK.setUserInfo(null)
|
||||
SampleEnvironmentConfig.useExternal()
|
||||
Thread.sleep(500)
|
||||
}
|
||||
}
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
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}" }
|
||||
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 com.xuqm.sdk.XuqmSDK
|
||||
import com.xuqm.sdk.XuqmUserInfo
|
||||
import com.xuqm.sdk.core.LogLevel
|
||||
import com.xuqm.sdk.im.ImSDK
|
||||
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.DemoApiFactory
|
||||
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 集成测试
|
||||
* 运行环境: external (https://dev.xuqinmin.com)
|
||||
* 测试账号: user_a / user_b (password: 123456, appKey: ak_demo_chat)
|
||||
* 测试账号与密码由运行环境注入,源码不保存凭据。
|
||||
*
|
||||
* 设计原则:
|
||||
* - tc05/tc11a/tc11b/tc14/tc99 使用 @BeforeClass 中创建的新鲜 GROUP(testGroupId),
|
||||
@ -51,7 +49,6 @@ class SdkIntegrationTest {
|
||||
companion object {
|
||||
private const val USER_A = "user_a"
|
||||
private const val USER_B = "user_b"
|
||||
private const val PASSWORD = "123456"
|
||||
private const val BASE_URL = "https://dev.xuqinmin.com/"
|
||||
private const val CONNECT_TIMEOUT_MS = 20_000L
|
||||
|
||||
@ -64,21 +61,19 @@ class SdkIntegrationTest {
|
||||
@JvmStatic
|
||||
fun initSdk() {
|
||||
appCtx = InstrumentationRegistry.getInstrumentation().targetContext
|
||||
XuqmSDK.initialize(appCtx, DEMO_APP_ID, LogLevel.DEBUG)
|
||||
// logout BEFORE useExternal: configureServiceEndpoints re-triggers onSdkLogin
|
||||
// with sample app's cached testuser1 session if loginSession is not null.
|
||||
runBlocking { XuqmSDK.awaitInitialization() }
|
||||
XuqmSDK.setUserInfo(null)
|
||||
Thread.sleep(1_500) // let any in-flight 1s reconnect timers fire and be blocked
|
||||
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
|
||||
// stale conversation state from previous runs (e.g., a prior deleteConversation
|
||||
// on a SINGLE conversation permanently removing it from the server).
|
||||
runBlocking {
|
||||
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 ->
|
||||
XuqmSDK.setUserInfo(XuqmUserInfo(userId = USER_A, userSig = tokenA))
|
||||
withTimeoutOrNull(CONNECT_TIMEOUT_MS) {
|
||||
@ -100,7 +95,9 @@ class SdkIntegrationTest {
|
||||
private fun buildDemoApi() = DemoApiFactory.create(BASE_URL) { null }
|
||||
|
||||
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}" }
|
||||
XuqmSDK.setUserInfo(XuqmUserInfo(userId = userId, userSig = data.imToken))
|
||||
return data.imToken
|
||||
@ -215,7 +212,9 @@ class SdkIntegrationTest {
|
||||
assertTrue("@Before 后应处于 Connected 状态", stateBefore is ImConnectionState.Connected)
|
||||
|
||||
// 不登出,直接重新获取 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 失败" }
|
||||
XuqmSDK.setUserInfo(XuqmUserInfo(userId = USER_A, userSig = newToken))
|
||||
|
||||
|
||||
@ -2,22 +2,22 @@ package com.xuqm.sdk.sample
|
||||
|
||||
import android.app.Application
|
||||
import com.xuqm.sdk.XuqmSDK
|
||||
import com.xuqm.sdk.core.LogLevel
|
||||
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() {
|
||||
private val applicationScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
AppDependencies.init(this)
|
||||
XuqmSDK.initialize(
|
||||
context = this,
|
||||
appKey = "ak_c6fce237cae94ef5ab71fda6",
|
||||
logLevel = if (BuildConfig.DEBUG) LogLevel.DEBUG else LogLevel.WARN,
|
||||
)
|
||||
runBlocking {
|
||||
XuqmSDK.afterInit {
|
||||
applicationScope.launch {
|
||||
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
|
||||
|
||||
import android.content.Context
|
||||
import androidx.security.crypto.EncryptedSharedPreferences
|
||||
import androidx.security.crypto.MasterKeys
|
||||
import com.xuqm.sdk.XuqmSDK
|
||||
import com.xuqm.sdk.XuqmUserInfo
|
||||
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.UpdateProfileRequest
|
||||
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.Job
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
@ -30,25 +28,19 @@ import java.util.concurrent.atomic.AtomicBoolean
|
||||
class AuthRepository(context: Context) {
|
||||
|
||||
private val appScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
private val prefs = EncryptedSharedPreferences.create(
|
||||
"xuqm_demo_auth",
|
||||
MasterKeys.getOrCreate(MasterKeys.AES256_GCM_SPEC),
|
||||
context,
|
||||
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
|
||||
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
|
||||
)
|
||||
private val store = SecureStore.open(context, "sample.auth")
|
||||
|
||||
private val api: DemoApi
|
||||
get() = DemoApiFactory.create(
|
||||
baseUrl = SampleEnvironmentConfig.current.demoBaseUrl,
|
||||
baseUrl = XuqmSDK.platformUrl,
|
||||
tokenProvider = ::getDemoToken,
|
||||
)
|
||||
|
||||
fun getDemoToken(): String? = prefs.getString("demo_token", null)
|
||||
fun getCurrentUserId(): String? = prefs.getString("user_id", null)
|
||||
fun getCurrentNickname(): String? = prefs.getString("nickname", null)
|
||||
fun getCurrentAvatar(): String? = prefs.getString("avatar", null)
|
||||
fun getCurrentUserSig(): String? = prefs.getString("user_sig", null)
|
||||
fun getDemoToken(): String? = store.getString("demo_token")
|
||||
fun getCurrentUserId(): String? = store.getString("user_id")
|
||||
fun getCurrentNickname(): String? = store.getString("nickname")
|
||||
fun getCurrentAvatar(): String? = store.getString("avatar")
|
||||
fun getCurrentUserSig(): String? = store.getString("user_sig")
|
||||
fun isLoggedIn(): Boolean = hasUsableSession()
|
||||
|
||||
private fun hasUsableSession(): Boolean {
|
||||
@ -62,13 +54,11 @@ class AuthRepository(context: Context) {
|
||||
|
||||
private fun saveSession(result: AuthResult) {
|
||||
val profile = result.profile
|
||||
prefs.edit()
|
||||
.putString("demo_token", result.demoToken)
|
||||
.putString("user_id", profile.userId)
|
||||
.putString("nickname", profile.nickname)
|
||||
.putString("avatar", profile.avatar)
|
||||
.putString("user_sig", result.imToken)
|
||||
.apply()
|
||||
store.putString("demo_token", result.demoToken)
|
||||
store.putString("user_id", profile.userId)
|
||||
store.putString("nickname", profile.nickname)
|
||||
store.putString("avatar", profile.avatar)
|
||||
store.putString("user_sig", result.imToken)
|
||||
}
|
||||
|
||||
suspend fun login(userId: String, password: String): Result<UserData> =
|
||||
@ -123,7 +113,8 @@ class AuthRepository(context: Context) {
|
||||
withContext(Dispatchers.IO) {
|
||||
runCatching {
|
||||
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
|
||||
}
|
||||
}
|
||||
@ -162,7 +153,7 @@ class AuthRepository(context: Context) {
|
||||
|
||||
fun logout() {
|
||||
XuqmSDK.setUserInfo(null)
|
||||
prefs.edit().clear().apply()
|
||||
store.clear()
|
||||
}
|
||||
|
||||
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.AttachmentRepository
|
||||
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 kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.asSharedFlow
|
||||
@ -16,8 +15,6 @@ object AppDependencies {
|
||||
|
||||
lateinit var authRepository: AuthRepository
|
||||
private set
|
||||
lateinit var environmentRepository: EnvironmentRepository
|
||||
private set
|
||||
lateinit var localImCache: LocalImCache
|
||||
private set
|
||||
lateinit var localContactCache: LocalContactCache
|
||||
@ -29,8 +26,6 @@ object AppDependencies {
|
||||
val conversationRefreshRequests = _conversationRefreshRequests.asSharedFlow()
|
||||
|
||||
fun init(context: Context) {
|
||||
environmentRepository = EnvironmentRepository(context)
|
||||
environmentRepository.current()
|
||||
localImCache = LocalImCache(context)
|
||||
localContactCache = LocalContactCache(context)
|
||||
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.RegisterScreen
|
||||
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.main.MainScreen
|
||||
import com.xuqm.sdk.webview.XWebViewScreen
|
||||
@ -35,7 +34,6 @@ fun AppNavGraph(
|
||||
}
|
||||
},
|
||||
onNavigateToRegister = { navController.navigate("register") },
|
||||
onOpenEnvironment = { navController.navigate("environment") },
|
||||
)
|
||||
}
|
||||
composable("register") {
|
||||
@ -62,7 +60,6 @@ fun AppNavGraph(
|
||||
onOpenWebView = {
|
||||
navController.navigate("xwebview")
|
||||
},
|
||||
onOpenEnvironment = { navController.navigate("environment") },
|
||||
onLogout = {
|
||||
navController.navigate("auth") {
|
||||
popUpTo("main") { inclusive = true }
|
||||
@ -102,11 +99,5 @@ fun AppNavGraph(
|
||||
onNavigateBack = { navController.popBackStack() },
|
||||
)
|
||||
}
|
||||
|
||||
composable("environment") {
|
||||
EnvironmentScreen(
|
||||
onNavigateBack = { navController.popBackStack() },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -39,7 +39,6 @@ import android.widget.Toast
|
||||
fun LoginScreen(
|
||||
onLoginSuccess: () -> Unit,
|
||||
onNavigateToRegister: () -> Unit,
|
||||
onOpenEnvironment: () -> Unit,
|
||||
viewModel: LoginViewModel = viewModel(
|
||||
factory = viewModelFactory {
|
||||
initializer { LoginViewModel(AppDependencies.authRepository) }
|
||||
@ -120,10 +119,6 @@ fun LoginScreen(
|
||||
Text("没有账号?注册")
|
||||
}
|
||||
|
||||
TextButton(onClick = onOpenEnvironment) {
|
||||
Text("环境设置")
|
||||
}
|
||||
|
||||
if (BuildConfig.DEBUG) {
|
||||
Spacer(Modifier.height(8.dp))
|
||||
TextButton(onClick = { viewModel.login("testuser1", "test123") }) {
|
||||
|
||||
@ -541,7 +541,7 @@ private fun createCaptureUri(
|
||||
val file = File(dir, "$prefix$suffix")
|
||||
return FileProvider.getUriForFile(
|
||||
context,
|
||||
"${context.packageName}.fileprovider",
|
||||
"${context.packageName}.xuqm.fileprovider",
|
||||
file,
|
||||
)
|
||||
}
|
||||
|
||||
@ -182,11 +182,11 @@ class ChatViewModel : ViewModel() {
|
||||
private suspend fun fetchHistory(page: Int): List<ImMessage> {
|
||||
val remote = if (chatType == "GROUP") {
|
||||
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()
|
||||
} else {
|
||||
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()
|
||||
}
|
||||
if (remote != null) {
|
||||
@ -216,7 +216,7 @@ class ChatViewModel : ViewModel() {
|
||||
}
|
||||
.onFailure {
|
||||
_events.tryEmit("消息编辑失败,请检查网络后重试")
|
||||
Log.w(TAG, "editMessage failed messageId=${editingTarget.id}", it)
|
||||
Log.w(TAG, "editMessage failed")
|
||||
}
|
||||
}
|
||||
return
|
||||
@ -238,7 +238,7 @@ class ChatViewModel : ViewModel() {
|
||||
appendOutgoingMessage(sent)
|
||||
if (sent.status.uppercase() == "FAILED") {
|
||||
_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 {
|
||||
trackPendingDelivery(sent.id)
|
||||
_draftText.value = ""
|
||||
@ -248,7 +248,7 @@ class ChatViewModel : ViewModel() {
|
||||
}
|
||||
} catch (t: Throwable) {
|
||||
_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) }
|
||||
.onFailure {
|
||||
_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)
|
||||
if (sent.message.status.uppercase() == "FAILED") {
|
||||
_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 {
|
||||
trackPendingDelivery(sent.message.id)
|
||||
}
|
||||
}.onFailure {
|
||||
_events.tryEmit("语音发送失败,请检查网络后重试")
|
||||
Log.w(TAG, "sendAudioRecording threw targetId=$targetId chatType=$chatType fileName=${recording.fileName}", it)
|
||||
Log.w(TAG, "sendAudioRecording failed: chatType=$chatType")
|
||||
}
|
||||
} finally {
|
||||
_isSendingAttachment.value = false
|
||||
@ -429,7 +429,7 @@ class ChatViewModel : ViewModel() {
|
||||
.filter { it.nickname.isNotBlank() }
|
||||
.sortedBy { it.nickname.ifBlank { it.userId } }
|
||||
}.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)
|
||||
if (sent.message.status.uppercase() == "FAILED") {
|
||||
_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 {
|
||||
trackPendingDelivery(sent.message.id)
|
||||
}
|
||||
}.onFailure {
|
||||
_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 {
|
||||
_isSendingAttachment.value = false
|
||||
@ -524,13 +524,13 @@ class ChatViewModel : ViewModel() {
|
||||
appendOutgoingMessage(sent.message)
|
||||
if (sent.message.status.uppercase() == "FAILED") {
|
||||
_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 {
|
||||
trackPendingDelivery(sent.message.id)
|
||||
}
|
||||
}.onFailure {
|
||||
_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 {
|
||||
_isSendingAttachment.value = false
|
||||
@ -556,7 +556,8 @@ class ChatViewModel : ViewModel() {
|
||||
}
|
||||
Log.d(
|
||||
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
|
||||
}
|
||||
@ -574,13 +575,14 @@ class ChatViewModel : ViewModel() {
|
||||
private fun handleIncomingMessage(message: ImMessage) {
|
||||
Log.d(
|
||||
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 (message.fromId == currentUserId) {
|
||||
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))
|
||||
cache.mergeHistory(targetId, chatType, _messages.value)
|
||||
cache.upsertConversation(
|
||||
@ -684,7 +686,7 @@ class ChatViewModel : ViewModel() {
|
||||
if (stillPending != null) {
|
||||
updateMessageStatus(messageId, "FAILED")
|
||||
_events.tryEmit("消息发送失败,请检查网络后重试")
|
||||
Log.w(TAG, "delivery timeout messageId=$messageId")
|
||||
Log.w(TAG, "Message delivery timed out")
|
||||
}
|
||||
pendingDeliveryTimeouts.remove(messageId)
|
||||
}
|
||||
|
||||
@ -160,7 +160,7 @@ class ContactViewModel(
|
||||
}
|
||||
.onFailure {
|
||||
_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() }
|
||||
.onFailure {
|
||||
_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() }
|
||||
.onFailure {
|
||||
_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)
|
||||
rebuildFriendList()
|
||||
}.onFailure {
|
||||
android.util.Log.w(TAG, "loadMembersInternal failed", it)
|
||||
android.util.Log.w(TAG, "loadMembersInternal failed")
|
||||
val cached = cache.loadProfiles()
|
||||
.filter { it.userId != currentUserId }
|
||||
.sortedBy { it.userId }
|
||||
@ -267,7 +267,7 @@ class ContactViewModel(
|
||||
cache.saveFriendIds(list.toList())
|
||||
rebuildFriendList()
|
||||
}.onFailure {
|
||||
android.util.Log.w(TAG, "loadFriendsInternal failed", it)
|
||||
android.util.Log.w(TAG, "loadFriendsInternal failed")
|
||||
friendIds = cache.loadFriendIds().toSet()
|
||||
rebuildFriendList()
|
||||
}
|
||||
@ -280,7 +280,7 @@ class ContactViewModel(
|
||||
request.status.equals("PENDING", ignoreCase = true)
|
||||
}
|
||||
}.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() }
|
||||
.onSuccess { _blacklist.value = it }
|
||||
.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 {
|
||||
override fun onMessage(message: ImMessage) {
|
||||
Log.d(
|
||||
TAG,
|
||||
"incoming single message refresh request id=${message.id} from=${message.fromId} to=${message.toId} msgType=${message.msgType}",
|
||||
)
|
||||
Log.d(TAG, "Incoming single message: msgType=${message.msgType}")
|
||||
refresh()
|
||||
}
|
||||
|
||||
override fun onGroupMessage(message: ImMessage) {
|
||||
Log.d(
|
||||
TAG,
|
||||
"incoming group message refresh request id=${message.id} from=${message.fromId} to=${message.toId} msgType=${message.msgType}",
|
||||
)
|
||||
Log.d(TAG, "Incoming group message: msgType=${message.msgType}")
|
||||
refresh()
|
||||
}
|
||||
|
||||
override fun onRead(message: ImMessage) {
|
||||
Log.d(
|
||||
TAG,
|
||||
"incoming read refresh request id=${message.id} from=${message.fromId} to=${message.toId} msgType=${message.msgType}",
|
||||
)
|
||||
Log.d(TAG, "Incoming read event: msgType=${message.msgType}")
|
||||
refresh()
|
||||
}
|
||||
|
||||
override fun onRevoke(message: ImMessage) {
|
||||
Log.d(
|
||||
TAG,
|
||||
"incoming revoke refresh request id=${message.id} from=${message.fromId} to=${message.toId} msgType=${message.msgType}",
|
||||
)
|
||||
Log.d(TAG, "Incoming revoke event: msgType=${message.msgType}")
|
||||
refresh()
|
||||
}
|
||||
}
|
||||
@ -122,7 +110,7 @@ class ConversationViewModel : ViewModel() {
|
||||
fun deleteConversation(targetId: String, chatType: String) {
|
||||
viewModelScope.launch {
|
||||
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)
|
||||
_conversations.value = cache.loadConversations().sortedByDescending { it.lastMsgTime }
|
||||
_totalUnreadCount.value = _conversations.value.sumOf { it.unreadCount }
|
||||
@ -131,13 +119,13 @@ class ConversationViewModel : ViewModel() {
|
||||
|
||||
suspend fun setPinned(targetId: String, chatType: String, pinned: Boolean) {
|
||||
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()
|
||||
}
|
||||
|
||||
suspend fun setMuted(targetId: String, chatType: String, muted: Boolean) {
|
||||
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()
|
||||
}
|
||||
|
||||
|
||||
@ -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)
|
||||
}
|
||||
.onFailure {
|
||||
Log.w(TAG, "loadGroupInfo failed groupId=$groupId", it)
|
||||
Log.w(TAG, "loadGroupInfo failed")
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -102,7 +102,7 @@ class GroupViewModel : ViewModel() {
|
||||
viewModelScope.launch {
|
||||
runCatching { ImSDK.createGroup(name, memberIds, groupType) }
|
||||
.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 {
|
||||
runCatching { ImSDK.updateGroupInfo(groupId, name, announcement) }
|
||||
.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 {
|
||||
runCatching { ImSDK.setGroupRole(groupId, userId, role) }
|
||||
.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 {
|
||||
runCatching { ImSDK.addGroupMember(groupId, userId) }
|
||||
.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 {
|
||||
runCatching { ImSDK.muteGroupMember(groupId, userId, minutes) }
|
||||
.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 {
|
||||
runCatching { ImSDK.dismissGroup(groupId) }
|
||||
.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 {
|
||||
runCatching { ImSDK.removeGroupMember(groupId, userId) }
|
||||
.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 {
|
||||
runCatching { ImSDK.leaveGroup(groupId) }
|
||||
.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) {
|
||||
viewModelScope.launch {
|
||||
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 {
|
||||
runCatching { ImSDK.listGroupJoinRequests(groupId) }
|
||||
.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 {
|
||||
runCatching { ImSDK.acceptGroupJoinRequest(groupId, requestId) }
|
||||
.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 {
|
||||
runCatching { ImSDK.rejectGroupJoinRequest(groupId, requestId) }
|
||||
.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 {
|
||||
_members.value = it
|
||||
}.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() }
|
||||
}
|
||||
.onFailure {
|
||||
Log.w(TAG, "loadGroupMembersInternal failed groupId=$groupId", it)
|
||||
Log.w(TAG, "loadGroupMembersInternal failed")
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun loadGroupsInternal() {
|
||||
runCatching { ImSDK.listGroups() }
|
||||
.onSuccess { _groups.value = it }
|
||||
.onFailure { Log.w(TAG, "loadGroupsInternal failed", it) }
|
||||
.onFailure { Log.w(TAG, "loadGroupsInternal failed") }
|
||||
}
|
||||
|
||||
private suspend fun loadPublicGroupsInternal(keyword: String) {
|
||||
runCatching { ImSDK.listPublicGroups(keyword.ifBlank { null }) }
|
||||
.onSuccess { _publicGroups.value = it }
|
||||
.onFailure { Log.w(TAG, "loadPublicGroupsInternal failed keyword=$keyword", it) }
|
||||
.onFailure { Log.w(TAG, "loadPublicGroupsInternal failed") }
|
||||
}
|
||||
|
||||
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.People
|
||||
import androidx.compose.material.icons.filled.Person
|
||||
import androidx.compose.material.icons.filled.Settings
|
||||
import androidx.compose.material.icons.filled.SystemUpdate
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.AlertDialog
|
||||
@ -18,7 +17,6 @@ import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.NavigationBar
|
||||
import androidx.compose.material3.NavigationBarItem
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
@ -64,7 +62,6 @@ fun MainScreen(
|
||||
onOpenChat: (targetId: String, chatType: String, targetName: String) -> Unit,
|
||||
onGroupSettings: (groupId: String) -> Unit,
|
||||
onOpenWebView: () -> Unit,
|
||||
onOpenEnvironment: () -> Unit,
|
||||
onLogout: () -> Unit,
|
||||
) {
|
||||
var selectedTab by remember { mutableIntStateOf(0) }
|
||||
@ -88,11 +85,6 @@ fun MainScreen(
|
||||
androidx.compose.foundation.layout.Column {
|
||||
TopAppBar(
|
||||
title = { Text(tabs[selectedTab].label) },
|
||||
actions = {
|
||||
IconButton(onClick = onOpenEnvironment) {
|
||||
Icon(Icons.Default.Settings, contentDescription = "环境设置")
|
||||
}
|
||||
}
|
||||
)
|
||||
ConnectionStatusBanner(connectionState)
|
||||
}
|
||||
|
||||
@ -38,6 +38,9 @@ import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import androidx.lifecycle.viewmodel.initializer
|
||||
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(
|
||||
val userId: String = "",
|
||||
@ -89,6 +92,7 @@ fun ProfileScreen(
|
||||
),
|
||||
) {
|
||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||
var developerTestResult by remember { mutableStateOf<String?>(null) }
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
@ -144,6 +148,26 @@ fun ProfileScreen(
|
||||
|
||||
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(
|
||||
onClick = { viewModel.logout(onLogout) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
package com.xuqm.sdk.sample.ui.webview
|
||||
|
||||
import android.content.ClipData
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
@ -18,16 +19,18 @@ import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
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.text.AnnotatedString
|
||||
import com.xuqm.sdk.webview.XWebViewConfig
|
||||
import com.xuqm.sdk.webview.XWebViewControl
|
||||
import com.xuqm.sdk.webview.XWebViewView
|
||||
import com.xuqm.sdk.webview.openXWebView
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
private enum class WebViewMode { Embedded, Page }
|
||||
|
||||
@ -41,7 +44,8 @@ fun WebViewEntryScreen(
|
||||
var statusVersion by rememberSaveable { mutableIntStateOf(0) }
|
||||
val selectedMode = WebViewMode.entries[mode]
|
||||
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) {
|
||||
XWebViewControl.currentUrl() ?: config.url
|
||||
}
|
||||
@ -125,7 +129,16 @@ fun WebViewEntryScreen(
|
||||
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
Button(onClick = {
|
||||
clipboard.setText(AnnotatedString(currentUrl.ifBlank { config.url }))
|
||||
coroutineScope.launch {
|
||||
clipboard.setClipEntry(
|
||||
ClipEntry(
|
||||
ClipData.newPlainText(
|
||||
"URL",
|
||||
currentUrl.ifBlank { config.url },
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
statusVersion++
|
||||
}) { Text("复制 URL") }
|
||||
|
||||
|
||||
@ -36,6 +36,7 @@ gradlePlugin {
|
||||
|
||||
dependencies {
|
||||
compileOnly("com.android.tools.build:gradle:9.1.0")
|
||||
testImplementation("junit:junit:4.13.2")
|
||||
}
|
||||
|
||||
publishing {
|
||||
|
||||
@ -5,130 +5,64 @@ import com.android.build.api.dsl.ApplicationExtension
|
||||
import com.android.build.api.variant.ApplicationAndroidComponentsExtension
|
||||
import org.gradle.api.Plugin
|
||||
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> {
|
||||
|
||||
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) {
|
||||
val androidComponents = target.extensions.findByType(ApplicationAndroidComponentsExtension::class.java)
|
||||
?: return
|
||||
val androidDsl = target.extensions.findByType(ApplicationExtension::class.java)
|
||||
?: return
|
||||
|
||||
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)
|
||||
check(target.pluginManager.hasPlugin("com.xuqm.common")) {
|
||||
"Apply com.xuqm.common before com.xuqm.bugcollect"
|
||||
}
|
||||
target.pluginManager.withPlugin("com.android.application") {
|
||||
configureAndroidApplication(target)
|
||||
}
|
||||
}
|
||||
|
||||
val productFlavor = androidDsl.productFlavors.findByName(variant.flavorName ?: "")
|
||||
val versionName = productFlavor?.versionName ?: androidDsl.defaultConfig.versionName ?: ""
|
||||
|
||||
val taskName = "xuqmUploadMapping${variant.name.replaceFirstChar { it.uppercase() }}"
|
||||
val uploadTask = target.tasks.register(taskName, XuqmUploadMappingTask::class.java) { task ->
|
||||
private fun configureAndroidApplication(target: Project) {
|
||||
val components = target.extensions.getByType(ApplicationAndroidComponentsExtension::class.java)
|
||||
val android = target.extensions.getByType(ApplicationExtension::class.java)
|
||||
components.onVariants { variant ->
|
||||
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.description = "Upload ProGuard mapping to BugCollect service (${variant.name})"
|
||||
task.appKey.set(
|
||||
fileAppKey
|
||||
?: 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.description = "Upload R8 mapping to BugCollect (${variant.name})"
|
||||
task.appKey.set(appKey)
|
||||
task.platformUrl.set(platformUrl)
|
||||
task.appVersion.set(versionName)
|
||||
task.buildId.set(buildId)
|
||||
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.emergencyDisabled.set(
|
||||
target.providers.gradleProperty("xuqm.bugcollect")
|
||||
.map { it.equals("disabled", ignoreCase = true) }
|
||||
.orElse(false),
|
||||
)
|
||||
}
|
||||
|
||||
val assembleTaskName = "assemble${variant.name.replaceFirstChar { it.uppercase() }}"
|
||||
target.tasks.matching { it.name == assembleTaskName }.configureEach { it.finalizedBy(uploadTask) }
|
||||
target.tasks.matching {
|
||||
it.name == "assemble$suffix" || it.name == "bundle$suffix"
|
||||
}.configureEach { it.finalizedBy(uploadTask) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,15 +1,18 @@
|
||||
package com.xuqm.sdk.bugcollect.gradle
|
||||
|
||||
import org.gradle.api.DefaultTask
|
||||
import org.gradle.api.GradleException
|
||||
import org.gradle.api.file.RegularFileProperty
|
||||
import org.gradle.api.provider.Property
|
||||
import org.gradle.api.tasks.Input
|
||||
import org.gradle.api.tasks.InputFile
|
||||
import org.gradle.api.tasks.Internal
|
||||
import org.gradle.api.tasks.Optional
|
||||
import org.gradle.api.tasks.TaskAction
|
||||
import java.io.File
|
||||
import java.net.HttpURLConnection
|
||||
import java.net.URL
|
||||
import java.net.URI
|
||||
import java.security.MessageDigest
|
||||
|
||||
abstract class XuqmUploadMappingTask : DefaultTask() {
|
||||
@get:Input abstract val appKey: Property<String>
|
||||
@ -17,34 +20,57 @@ abstract class XuqmUploadMappingTask : DefaultTask() {
|
||||
@get:Input abstract val appVersion: Property<String>
|
||||
@get:Input @get:Optional abstract val buildId: 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
|
||||
|
||||
@TaskAction
|
||||
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 }
|
||||
if (!file.exists()) { logger.warn("[XuqmLog] Mapping file not found: ${file.path}"); return }
|
||||
|
||||
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) {
|
||||
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
|
||||
}
|
||||
|
||||
val bugCollectApiUrl = config.bugCollectApiUrl ?: run { logger.warn("[XuqmLog] bugCollectApiUrl missing in config, skip upload"); return }
|
||||
val uploadUrl = "${bugCollectApiUrl.trimEnd('/')}/bugcollect/v1/sourcemaps/upload"
|
||||
val bid = buildId.orNull
|
||||
uploadFile(uploadUrl, key, file, appVersion.get(), platform.get(), bid)
|
||||
logger.lifecycle("[XuqmLog] Mapping uploaded: ${file.name} (android v${appVersion.get()} buildId=$bid)")
|
||||
val token = apiToken.orNull?.takeIf { it.isNotBlank() }
|
||||
?: error("XUQM_API_TOKEN is required when BugCollect mapping upload is enabled")
|
||||
val bid = buildId.orNull?.takeIf { it.isNotBlank() }
|
||||
?: 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 fun fetchConfig(url: String): SdkConfig? {
|
||||
return runCatching {
|
||||
val conn = URL(url).openConnection() as HttpURLConnection
|
||||
val conn = URI.create(url).toURL().openConnection() as HttpURLConnection
|
||||
conn.requestMethod = "GET"
|
||||
val code = conn.responseCode
|
||||
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 conn = URL(url).openConnection() as HttpURLConnection
|
||||
val conn = URI.create(url).toURL().openConnection() as HttpURLConnection
|
||||
conn.requestMethod = "POST"
|
||||
conn.connectTimeout = 10_000
|
||||
conn.readTimeout = 30_000
|
||||
conn.doOutput = true
|
||||
conn.setRequestProperty("Authorization", "Bearer $apiToken")
|
||||
conn.setRequestProperty("Content-Type", "multipart/form-data; boundary=$boundary")
|
||||
conn.outputStream.use { out ->
|
||||
fun field(name: String, value: String) =
|
||||
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)
|
||||
if (!buildId.isNullOrEmpty()) field("buildId", buildId)
|
||||
val uploadName = if (file.name.endsWith(".map")) file.name else "${file.nameWithoutExtension}.map"
|
||||
out.write("--$boundary\r\nContent-Disposition: form-data; name=\"file\"; filename=\"$uploadName\"\r\nContent-Type: text/plain\r\n\r\n".toByteArray())
|
||||
field("artifactType", ARTIFACT_TYPE)
|
||||
field("appKey", appKey)
|
||||
field("platform", platform)
|
||||
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("\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") // 必须
|
||||
```
|
||||
|
||||
Release 构建先应用 `com.xuqm.common`,开启 R8 mapping 上传时再应用
|
||||
`com.xuqm.bugcollect`。配置验证与 buildId 只由 common 插件负责,BugCollect 插件
|
||||
不会维护第二套配置路径或初始化定义。Debug 固定关闭自动采集与上传;隐藏开发测试
|
||||
入口仅允许在可调试、非应急禁用、隐私已同意且平台已开通时显式发送一次,不改变自动状态。
|
||||
|
||||
## 快速开始
|
||||
|
||||
```kotlin
|
||||
// Application.onCreate() 中(XuqmSDK.initialize 之后):
|
||||
// 宿主取得隐私同意后同步一次;撤回时传 false。
|
||||
XuqmSDK.setPrivacyConsent(true)
|
||||
BugCollect.setLogLevel(LogLevel.INFO)
|
||||
BugCollect.setEnvironment("production")
|
||||
BugCollect.startCrashCapture()
|
||||
|
||||
// 业务代码中
|
||||
BugCollect.event("page_view", mapOf("page" to "home"))
|
||||
@ -30,7 +35,6 @@ BugCollect.captureError(exception)
|
||||
|-----|------|
|
||||
| `BugCollect.setLogLevel(level)` | 设置日志级别(`DEBUG` / `INFO` / `WARN` / `ERROR`) |
|
||||
| `BugCollect.setEnvironment(env)` | 设置环境标签 |
|
||||
| `BugCollect.startCrashCapture()` | 开启全局 UncaughtExceptionHandler |
|
||||
| `BugCollect.event(name, properties?)` | 记录自定义事件 |
|
||||
| `BugCollect.captureError(error, 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),服务端去重聚合
|
||||
- **FunnelTracker**:客户端维护漏斗进度,服务端跨 session 聚合
|
||||
|
||||
### 配置
|
||||
|
||||
`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 {
|
||||
implementation(project(":sdk-core"))
|
||||
implementation("com.squareup.okhttp3:okhttp:4.12.0")
|
||||
implementation("com.google.code.gson:gson:2.10.1")
|
||||
implementation(platform(libs.okhttp.bom))
|
||||
implementation(libs.okhttp)
|
||||
implementation(libs.gson)
|
||||
testImplementation(libs.junit4)
|
||||
}
|
||||
|
||||
@ -3,6 +3,10 @@
|
||||
public *;
|
||||
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.Breadcrumb { public *; }
|
||||
-keep public class com.xuqm.sdk.bugcollect.FunnelTracker { public *; }
|
||||
@ -10,7 +14,6 @@
|
||||
-keep public class com.xuqm.sdk.bugcollect.LogEvent { *; }
|
||||
|
||||
# ContentProvider: class name referenced in AndroidManifest — must not be renamed
|
||||
-keep class com.xuqm.sdk.bugcollect.internal.BugCollectInitProvider
|
||||
|
||||
-keepattributes *Annotation*, Signature, InnerClasses, EnclosingMethod
|
||||
|
||||
|
||||
@ -1,13 +1,10 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<application>
|
||||
<!--
|
||||
Build ID injected by the XuqmGroup BugCollect Gradle plugin at compile time.
|
||||
The plugin fills ${xuqmBuildId} via variant.manifestPlaceholders.
|
||||
SDK reads it via PackageManager.getApplicationInfo(GET_META_DATA).
|
||||
-->
|
||||
<meta-data
|
||||
android:name="com.xuqm.build_id"
|
||||
android:value="${xuqmBuildId}" />
|
||||
<provider
|
||||
android:name="com.xuqm.sdk.internal.XuqmMergedProvider"
|
||||
android:authorities="${applicationId}.xuqm-init"
|
||||
android:exported="false"
|
||||
android:initOrder="50" />
|
||||
</application>
|
||||
</manifest>
|
||||
|
||||
@ -5,13 +5,16 @@ import android.content.Context
|
||||
import android.content.pm.ApplicationInfo
|
||||
import android.os.Build
|
||||
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.Dispatchers
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import java.util.LinkedList
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
|
||||
object BugCollect {
|
||||
|
||||
@ -19,12 +22,11 @@ object BugCollect {
|
||||
private var environment: String = "production"
|
||||
@Volatile private var queue: LogQueue? = null
|
||||
@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 breadcrumbLock = Any()
|
||||
// SDK 未就绪时暂存的错误事件,就绪后自动 flush
|
||||
private val pendingErrors = LinkedList<IssueEvent>()
|
||||
private val pendingErrorsLock = Any()
|
||||
private const val MAX_BREADCRUMBS = 50
|
||||
private const val SDK_NAME = "bugcollect.android"
|
||||
private const val SDK_VERSION = "1.1.0"
|
||||
@ -38,22 +40,23 @@ object BugCollect {
|
||||
level: String = "info",
|
||||
data: Map<String, Any?> = emptyMap(),
|
||||
) {
|
||||
if (!isReady()) return
|
||||
synchronized(breadcrumbLock) {
|
||||
if (breadcrumbBuffer.size >= MAX_BREADCRUMBS) breadcrumbBuffer.removeFirst()
|
||||
breadcrumbBuffer.addLast(Breadcrumb(
|
||||
timestamp = System.currentTimeMillis(),
|
||||
category = category,
|
||||
message = message,
|
||||
message = DataSanitizer.sanitizeText(message),
|
||||
level = level,
|
||||
data = data,
|
||||
data = DataSanitizer.sanitizeMap(data),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
fun event(name: String, properties: Map<String, Any?> = emptyMap()) {
|
||||
if (!isReady()) return
|
||||
queue().push(LogEvent(
|
||||
name = name, properties = properties,
|
||||
enqueue(LogEvent(
|
||||
name = name, properties = DataSanitizer.sanitizeMap(properties),
|
||||
appKey = XuqmSDK.appKey, userId = XuqmSDK.getUserId() ?: XuqmSDK.deviceId,
|
||||
platform = "android", release = appVersion(), environment = environment,
|
||||
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) {
|
||||
// SDK 未初始化时,将错误加入待处理队列
|
||||
if (!XuqmSDK.isInitialized()) {
|
||||
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
|
||||
}
|
||||
// 隐私授权、平台开关和初始化状态任一未满足时均不得采集或暂存。
|
||||
if (!isReady()) return
|
||||
|
||||
val crumbs = synchronized(breadcrumbLock) { breadcrumbBuffer.toList() }
|
||||
val fp = fingerprint ?: Fingerprint.compute(
|
||||
error.javaClass.simpleName,
|
||||
error.message ?: error.javaClass.name,
|
||||
error.stackTraceToString(),
|
||||
DataSanitizer.sanitizeText(error.message ?: error.javaClass.name),
|
||||
DataSanitizer.sanitizeText(error.stackTraceToString()),
|
||||
)
|
||||
val event = IssueEvent(
|
||||
level = "error",
|
||||
@ -104,8 +81,8 @@ object BugCollect {
|
||||
appKey = XuqmSDK.appKey,
|
||||
exception = IssueEvent.ExceptionInfo(
|
||||
type = error.javaClass.simpleName,
|
||||
value = error.message ?: error.javaClass.name,
|
||||
stacktrace = error.stackTraceToString(),
|
||||
value = DataSanitizer.sanitizeText(error.message ?: error.javaClass.name),
|
||||
stacktrace = DataSanitizer.sanitizeText(error.stackTraceToString()),
|
||||
),
|
||||
breadcrumbs = crumbs,
|
||||
release = appVersion(),
|
||||
@ -114,33 +91,79 @@ object BugCollect {
|
||||
userId = XuqmSDK.getUserId() ?: XuqmSDK.deviceId,
|
||||
user = IssueEvent.UserInfo(id = XuqmSDK.getUserId() ?: XuqmSDK.deviceId),
|
||||
device = buildDeviceInfo(),
|
||||
tags = tags,
|
||||
tags = DataSanitizer.sanitizeMap(tags),
|
||||
)
|
||||
if (!isReady()) {
|
||||
synchronized(pendingErrorsLock) { pendingErrors.add(event) }
|
||||
return
|
||||
enqueue(event)
|
||||
}
|
||||
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()) {
|
||||
// SDK 未初始化时直接返回,崩溃事件无法缓存
|
||||
if (!XuqmSDK.isInitialized() || !isReady()) return
|
||||
val crumbs = synchronized(breadcrumbLock) { breadcrumbBuffer.toList() }
|
||||
queue().push(IssueEvent(
|
||||
enqueue(IssueEvent(
|
||||
level = "fatal",
|
||||
platform = "android",
|
||||
fingerprint = Fingerprint.compute(
|
||||
error.javaClass.simpleName,
|
||||
error.message ?: error.javaClass.name,
|
||||
error.stackTraceToString(),
|
||||
DataSanitizer.sanitizeText(error.message ?: error.javaClass.name),
|
||||
DataSanitizer.sanitizeText(error.stackTraceToString()),
|
||||
),
|
||||
appKey = XuqmSDK.appKey,
|
||||
exception = IssueEvent.ExceptionInfo(
|
||||
type = error.javaClass.simpleName,
|
||||
value = error.message ?: error.javaClass.name,
|
||||
stacktrace = error.stackTraceToString(),
|
||||
value = DataSanitizer.sanitizeText(error.message ?: error.javaClass.name),
|
||||
stacktrace = DataSanitizer.sanitizeText(error.stackTraceToString()),
|
||||
),
|
||||
breadcrumbs = crumbs,
|
||||
release = appVersion(),
|
||||
@ -149,25 +172,28 @@ object BugCollect {
|
||||
userId = XuqmSDK.getUserId() ?: XuqmSDK.deviceId,
|
||||
user = IssueEvent.UserInfo(id = XuqmSDK.getUserId() ?: XuqmSDK.deviceId),
|
||||
device = buildDeviceInfo(),
|
||||
tags = tags,
|
||||
tags = DataSanitizer.sanitizeMap(tags),
|
||||
))
|
||||
}
|
||||
|
||||
fun warn(message: String, tags: Map<String, Any?> = emptyMap()) {
|
||||
if (logLevel.ordinal > LogLevel.WARN.ordinal) return
|
||||
if (!isReady()) return
|
||||
queue().push(IssueEvent(
|
||||
enqueue(IssueEvent(
|
||||
level = "warning",
|
||||
platform = "android",
|
||||
fingerprint = Fingerprint.compute("Warning", message, ""),
|
||||
fingerprint = Fingerprint.compute("Warning", DataSanitizer.sanitizeText(message), ""),
|
||||
appKey = XuqmSDK.appKey,
|
||||
exception = IssueEvent.ExceptionInfo(type = "Warning", value = message),
|
||||
exception = IssueEvent.ExceptionInfo(
|
||||
type = "Warning",
|
||||
value = DataSanitizer.sanitizeText(message),
|
||||
),
|
||||
release = appVersion(),
|
||||
buildId = buildId(),
|
||||
environment = environment,
|
||||
userId = 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))
|
||||
}
|
||||
|
||||
/**
|
||||
* 注册全局崩溃拦截器(幂等)。
|
||||
* 通常由 BugCollectInitProvider 在 app 启动时自动调用,无需 app 代码主动触发。
|
||||
* 如需在特定时机手动注册,调用此方法也是安全的。
|
||||
*/
|
||||
/** 注册全局崩溃拦截器(幂等);未满足运行条件时不会启动。 */
|
||||
fun startCrashCapture() {
|
||||
if (crashCaptureStarted) return
|
||||
if (!XuqmSDK.isInitialized()) return
|
||||
crashCaptureStarted = true
|
||||
if (!isReady()) return
|
||||
runCatching {
|
||||
CrashCapture.start(
|
||||
appContext = XuqmSDK.appContext,
|
||||
appKey = XuqmSDK.appKey,
|
||||
getUserId = { XuqmSDK.getUserId() ?: XuqmSDK.deviceId },
|
||||
)
|
||||
flushPendingErrors()
|
||||
crashCaptureStarted = true
|
||||
queue().uploadPendingCrashes()
|
||||
// 平台配置可能在初始化之后才下发,延迟重试 flush
|
||||
GlobalScope.launch(Dispatchers.IO) {
|
||||
for (i in 1..10) {
|
||||
delay(2000)
|
||||
if (!XuqmSDK.bugCollectApiUrl.isNullOrBlank()) {
|
||||
flushPendingErrors()
|
||||
queue().uploadPendingCrashes()
|
||||
break
|
||||
}
|
||||
}
|
||||
}.onFailure {
|
||||
CrashCapture.stop()
|
||||
crashCaptureStarted = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 将 SDK 未就绪时暂存的错误事件 flush 到队列。
|
||||
*
|
||||
* 早于 SDK 初始化完成前调用 captureError 时,事件以 appKey="pending"、userId="unknown"
|
||||
* 占位存入 pendingErrors。flush 时用真实值替换,避免服务端因找不到应用而丢弃事件。
|
||||
/**
|
||||
* sdk-core 在平台配置或隐私授权变化后调用的唯一状态同步入口。
|
||||
* 停用时恢复宿主原有崩溃处理器,并清除所有未上传数据。
|
||||
*/
|
||||
private fun flushPendingErrors() {
|
||||
val events = synchronized(pendingErrorsLock) {
|
||||
if (pendingErrors.isEmpty()) return
|
||||
val list = pendingErrors.toList()
|
||||
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)
|
||||
private fun onSdkConfigurationChanged() {
|
||||
if (isReady()) {
|
||||
startCrashCapture()
|
||||
return
|
||||
}
|
||||
CrashCapture.stop()
|
||||
crashCaptureStarted = false
|
||||
synchronized(breadcrumbLock) { breadcrumbBuffer.clear() }
|
||||
queue?.stopAndClear()
|
||||
queue = null
|
||||
}
|
||||
|
||||
/** 上传上次崩溃落盘的文件(远端配置就绪后由 BugCollectInitProvider 自动调用)。 */
|
||||
/** 退出登录后保留匿名诊断,但移除队列中与账号相关的身份字段。 */
|
||||
private fun onSdkLogout() {
|
||||
queue?.clearUserContext()
|
||||
}
|
||||
|
||||
/** 上传上次崩溃的加密持久化记录。 */
|
||||
fun uploadPendingCrashes() {
|
||||
if (isReady()) queue().uploadPendingCrashes()
|
||||
if (isReady()) runCatching { queue().uploadPendingCrashes() }
|
||||
}
|
||||
|
||||
fun defineFunnel(id: String, steps: List<String>) {
|
||||
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 {
|
||||
return queue ?: synchronized(this) {
|
||||
@ -253,6 +265,11 @@ object BugCollect {
|
||||
}
|
||||
}
|
||||
|
||||
/** 采集能力自身的存储异常不得传播给宿主业务调用栈。 */
|
||||
private fun enqueue(event: Any) {
|
||||
runCatching { queue().push(event) }
|
||||
}
|
||||
|
||||
private fun appVersion(): String = runCatching {
|
||||
if (!XuqmSDK.isInitialized()) return "unknown"
|
||||
val ctx = XuqmSDK.appContext
|
||||
@ -304,4 +321,12 @@ object BugCollect {
|
||||
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 {
|
||||
|
||||
@Volatile private var started = false
|
||||
private var installedHandler: Thread.UncaughtExceptionHandler? = null
|
||||
private var previousHandler: Thread.UncaughtExceptionHandler? = null
|
||||
|
||||
/** 注册全局崩溃拦截器,只需 appContext + appKey,不依赖上传 URL。 */
|
||||
fun start(appContext: Context, appKey: String, getUserId: () -> String?) {
|
||||
if (started) return
|
||||
started = true
|
||||
val prev = Thread.getDefaultUncaughtExceptionHandler()
|
||||
Thread.setDefaultUncaughtExceptionHandler { thread, throwable ->
|
||||
previousHandler = Thread.getDefaultUncaughtExceptionHandler()
|
||||
installedHandler = Thread.UncaughtExceptionHandler { thread, throwable ->
|
||||
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.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.LogUploader
|
||||
import com.xuqm.sdk.bugcollect.internal.UploadResult
|
||||
import com.xuqm.sdk.storage.SecureStore
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import java.io.File
|
||||
|
||||
/** [JSONObject.optString] returns non-null on newer Android; this returns null when the key is absent or the value is null. */
|
||||
private fun JSONObject.optNullableString(key: String): String? =
|
||||
if (has(key) && !isNull(key)) optString(key) else null
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import java.util.concurrent.TimeUnit
|
||||
import kotlin.math.pow
|
||||
import kotlin.random.Random
|
||||
|
||||
/**
|
||||
* BugCollect 的唯一队列实现。
|
||||
*
|
||||
* error/fatal 使用加密存储并最多保留七天;普通事件只存在内存。上传失败最多进行
|
||||
* 三次指数退避重试,之后保留持久错误等待下次进程启动或网络恢复。
|
||||
*/
|
||||
internal class LogQueue(
|
||||
private val appKey: String,
|
||||
private val appContext: Context,
|
||||
) {
|
||||
/** 每次上传时取最新的 URL(平台配置可能在队列创建后才下发)。 */
|
||||
private val logApiUrl: String get() = com.xuqm.sdk.XuqmSDK.bugCollectApiUrl ?: ""
|
||||
|
||||
companion object {
|
||||
private const val TAG = "BugCollect"
|
||||
private const val MAX_QUEUE_SIZE = 500
|
||||
private const val BATCH_SIZE = 30
|
||||
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_EVENTS = "events"
|
||||
private val retentionMillis = TimeUnit.DAYS.toMillis(7)
|
||||
}
|
||||
|
||||
private val gson = Gson()
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
private val pendingIssues = mutableListOf<IssueEvent>()
|
||||
private val pendingEvents = mutableListOf<LogEvent>()
|
||||
private val lock = Any()
|
||||
private val flushMutex = Mutex()
|
||||
@Volatile private var stopped = false
|
||||
private var flushJob: Job? = null
|
||||
|
||||
init {
|
||||
loadFromDisk()
|
||||
scope.launch {
|
||||
while (true) {
|
||||
flushJob = scope.launch {
|
||||
while (isActive && !stopped) {
|
||||
delay(FLUSH_INTERVAL_MS)
|
||||
flush()
|
||||
}
|
||||
@ -50,11 +62,15 @@ internal class LogQueue(
|
||||
}
|
||||
|
||||
fun push(event: Any) {
|
||||
if (stopped) return
|
||||
synchronized(lock) {
|
||||
when (event) {
|
||||
is IssueEvent -> {
|
||||
if (pendingIssues.size >= MAX_QUEUE_SIZE) pendingIssues.removeAt(0)
|
||||
pendingIssues.add(event)
|
||||
if (event.level == "error" || event.level == "fatal") {
|
||||
persistIssuesLocked()
|
||||
}
|
||||
}
|
||||
is LogEvent -> {
|
||||
if (pendingEvents.size >= MAX_QUEUE_SIZE) pendingEvents.removeAt(0)
|
||||
@ -66,126 +82,139 @@ internal class LogQueue(
|
||||
}
|
||||
|
||||
fun uploadPendingCrashes() {
|
||||
// 上传时才需要 URL,从远端配置取最新值
|
||||
val uploadUrl = com.xuqm.sdk.XuqmSDK.bugCollectApiUrl ?: return
|
||||
if (stopped) return
|
||||
scope.launch {
|
||||
val crashDir = LogStorage.crashDir(appContext)
|
||||
val files = crashDir.listFiles() ?: return@launch
|
||||
for (file in files) {
|
||||
runCatching {
|
||||
val json = file.readText()
|
||||
val obj = JSONObject(json)
|
||||
val msg = obj.optString("message", "")
|
||||
val stk = obj.optString("stack", "")
|
||||
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)
|
||||
for ((key, event) in LogStorage.loadCrashes(appContext, appKey)) {
|
||||
when (uploadWithRetry { LogUploader.uploadIssue(requireUrl(), event) }) {
|
||||
UploadResult.Success -> LogStorage.removeCrash(appContext, key)
|
||||
UploadResult.Disabled -> {
|
||||
disableAndClear()
|
||||
return@launch
|
||||
}
|
||||
is UploadResult.Failed -> return@launch
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
pendingIssues.size >= BATCH_SIZE || pendingEvents.size >= BATCH_SIZE
|
||||
}
|
||||
|
||||
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 {
|
||||
runCatching { LogUploader.uploadIssues(logApiUrl, issues) }
|
||||
.onFailure { e ->
|
||||
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))
|
||||
}
|
||||
}
|
||||
flushMutex.withLock {
|
||||
flushOnce()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
val prefs = appContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||
prefs.getString(KEY_ISSUES, null)?.let { json ->
|
||||
val arr = JSONArray(json)
|
||||
XuqmSDK::class.java.getDeclaredMethod("disableBugCollectFromExtension")
|
||||
.apply { isAccessible = true }
|
||||
.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) {
|
||||
for (i in 0 until arr.length()) {
|
||||
val obj = arr.getJSONObject(i)
|
||||
pendingIssues.add(IssueEvent(
|
||||
level = obj.optString("level", "error"),
|
||||
platform = obj.optString("platform", "android"),
|
||||
fingerprint = obj.optString("fingerprint", ""),
|
||||
appKey = obj.optString("appKey", appKey),
|
||||
timestamp = obj.optLong("timestamp", 0),
|
||||
release = obj.optString("release", ""),
|
||||
environment = obj.optString("environment", "production"),
|
||||
userId = obj.optNullableString("userId"),
|
||||
))
|
||||
pendingIssues.addAll(
|
||||
loaded
|
||||
.filter { it.timestamp >= minTimestamp }
|
||||
.takeLast(MAX_QUEUE_SIZE)
|
||||
,
|
||||
)
|
||||
persistIssuesLocked()
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
private fun secureStore() = SecureStore.open(appContext, PREFS_NAME)
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
import android.content.Context
|
||||
import org.json.JSONObject
|
||||
import java.io.File
|
||||
import com.google.gson.Gson
|
||||
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 {
|
||||
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(
|
||||
context: Context,
|
||||
throwable: Throwable,
|
||||
@ -22,18 +22,84 @@ internal object LogStorage {
|
||||
userId: String?,
|
||||
) {
|
||||
runCatching {
|
||||
val dir = crashDir(context)
|
||||
val file = File(dir, "${System.currentTimeMillis()}.json")
|
||||
val json = JSONObject().apply {
|
||||
put("level", "fatal")
|
||||
put("message", throwable.message ?: throwable.javaClass.name)
|
||||
put("stack", throwable.stackTraceToString())
|
||||
put("exceptionType", throwable.javaClass.simpleName)
|
||||
put("appKey", appKey)
|
||||
put("userId", userId ?: JSONObject.NULL)
|
||||
put("timestamp", System.currentTimeMillis())
|
||||
}
|
||||
file.writeText(json.toString())
|
||||
val timestamp = System.currentTimeMillis()
|
||||
val event = IssueEvent(
|
||||
level = "fatal",
|
||||
platform = "android",
|
||||
fingerprint = Fingerprint.compute(
|
||||
throwable.javaClass.simpleName,
|
||||
DataSanitizer.sanitizeText(throwable.message ?: throwable.javaClass.name),
|
||||
DataSanitizer.sanitizeText(throwable.stackTraceToString()),
|
||||
),
|
||||
appKey = appKey,
|
||||
timestamp = timestamp,
|
||||
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
|
||||
|
||||
import android.util.Log
|
||||
import com.xuqm.sdk.XuqmSDK
|
||||
import com.xuqm.sdk.bugcollect.IssueEvent
|
||||
import com.xuqm.sdk.bugcollect.LogEvent
|
||||
import com.xuqm.sdk.XuqmSDK
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
@ -15,147 +14,80 @@ import java.util.Locale
|
||||
import java.util.TimeZone
|
||||
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 {
|
||||
|
||||
private const val TAG = "BugCollect"
|
||||
|
||||
private const val DISABLED_CODE = "BUGCOLLECT_DISABLED"
|
||||
private val client = OkHttpClient.Builder()
|
||||
.connectTimeout(10, TimeUnit.SECONDS)
|
||||
.writeTimeout(15, TimeUnit.SECONDS)
|
||||
.readTimeout(10, TimeUnit.SECONDS)
|
||||
.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 {
|
||||
val trimmed = path.trimEnd('/')
|
||||
if (trimmed.startsWith("http://") || trimmed.startsWith("https://")) return trimmed
|
||||
val base = XuqmSDK.platformUrl.trimEnd('/')
|
||||
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())
|
||||
return "${XuqmSDK.platformUrl.trimEnd('/')}/${trimmed.trimStart('/')}"
|
||||
}
|
||||
|
||||
private fun createEnvelope(): JSONObject = JSONObject().apply {
|
||||
val sdf = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'", Locale.US)
|
||||
sdf.timeZone = TimeZone.getTimeZone("UTC")
|
||||
put("sentAt", sdf.format(System.currentTimeMillis()))
|
||||
put("sdk", JSONObject().apply {
|
||||
put("name", "bugcollect.android")
|
||||
put("version", "1.0.0")
|
||||
})
|
||||
val formatter = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'", Locale.US)
|
||||
formatter.timeZone = TimeZone.getTimeZone("UTC")
|
||||
put("sentAt", formatter.format(System.currentTimeMillis()))
|
||||
put("sdk", JSONObject().put("name", "bugcollect.android").put("version", "1.1.0"))
|
||||
}
|
||||
|
||||
private fun post(url: String, body: String) {
|
||||
val request = Request.Builder()
|
||||
.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(com.google.gson.Gson().toJson(issue))
|
||||
|
||||
private fun issueToJson(issue: IssueEvent): JSONObject = JSONObject().apply {
|
||||
put("eventId", issue.eventId)
|
||||
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) })
|
||||
}
|
||||
}
|
||||
private fun eventToJson(event: LogEvent): JSONObject =
|
||||
JSONObject(com.google.gson.Gson().toJson(event))
|
||||
}
|
||||
|
||||
@ -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
|
||||
// XuqmInitializerProvider 自动触发,无需代码
|
||||
```text
|
||||
app/src/main/assets/config/config.xuqmconfig
|
||||
```
|
||||
|
||||
### 方式 B — 手动初始化
|
||||
宿主不需要编写初始化代码,也不得修改、复制或自行生成该文件。SDK 先验证平台
|
||||
Ed25519 签名,再解密引导配置。V1、未知 keyId、签名错误和包名不匹配均拒绝使用。
|
||||
`serverUrl` 是签发配置中的唯一平台地址且必填,运行时没有默认地址或跨平台回退。
|
||||
|
||||
```kotlin
|
||||
// Application.onCreate() 中:
|
||||
XuqmSDK.initialize(context, appKey = "xxx")
|
||||
XuqmSDK.initialize(context, appKey = "xxx", platformUrl = "https://xxx")
|
||||
```
|
||||
只依赖 `sdk-core` 时不会注册初始化 Provider、申请通知权限或合并 FileProvider,
|
||||
基础 HTTP、下载、摘要、MediaStore、时间和安全存储能力无需初始化或登录。引入扩展 SDK 后才由扩展
|
||||
Manifest 合并唯一初始化 Provider。
|
||||
|
||||
## API
|
||||
|
||||
@ -32,9 +32,10 @@ XuqmSDK.initialize(context, appKey = "xxx", platformUrl = "https://xxx")
|
||||
|
||||
| API | 说明 |
|
||||
|-----|------|
|
||||
| `XuqmSDK.initialize(context, appKey, platformUrl?, logLevel?)` | 手动初始化 |
|
||||
| `XuqmSDK.awaitInitialization()` | 等待平台配置拉取(coroutine) |
|
||||
| `XuqmSDK.isInitialized()` | 检查是否已初始化 |
|
||||
| `XuqmSDK.initializationStatus` | `IDLE / INITIALIZING / READY / DEGRADED / FAILED` |
|
||||
| `XuqmSDK.setPrivacyConsent(granted)` | 同步宿主隐私授权;SDK 不自行弹窗 |
|
||||
| `XuqmSDK.setUserInfo(info)` | 设置用户信息,触发所有子模块登录 |
|
||||
| `XuqmSDK.setUserInfo(null)` | 登出,触发全局登出 |
|
||||
| `XuqmSDK.getUserId()` | 获取当前 userId |
|
||||
@ -43,8 +44,7 @@ XuqmSDK.initialize(context, appKey = "xxx", platformUrl = "https://xxx")
|
||||
| `XuqmSDK.platformConfig` | 平台配置(init 后可用) |
|
||||
| `XuqmSDK.bugCollectApiUrl` | Bug 收集服务地址(从平台配置获取) |
|
||||
| `XuqmSDK.bugCollectEnabled` | 是否启用 Bug 收集上报 |
|
||||
| `XuqmSDK.useLocalServiceEndpoints(ip)` | 切换本地联调环境 |
|
||||
| `XuqmSDK.tokenStore` | Token 存储(EncryptedSharedPreferences) |
|
||||
| `XuqmSDK.tokenStore` | Token 存储(Android Keystore + AES-256-GCM) |
|
||||
|
||||
### XuqmUserInfo
|
||||
|
||||
@ -60,7 +60,8 @@ data class XuqmUserInfo(
|
||||
|
||||
### TokenStore
|
||||
|
||||
基于 `EncryptedSharedPreferences` 持久化存储。
|
||||
基于统一的 `SecureStore` 持久化:密钥由 Android Keystore 生成且不可导出,
|
||||
值使用 AES-256-GCM 加密并通过 namespace/key 关联数据认证。
|
||||
|
||||
```kotlin
|
||||
XuqmSDK.tokenStore.saveToken("eyJ...")
|
||||
@ -68,26 +69,15 @@ val token = XuqmSDK.tokenStore.getToken()
|
||||
XuqmSDK.tokenStore.clear()
|
||||
```
|
||||
|
||||
### ApiClient
|
||||
|
||||
基于 OkHttp 5 + Retrofit 3,自动附加 Bearer Token。
|
||||
|
||||
```kotlin
|
||||
val service = RetrofitFactory.create(MyApiService::class.java)
|
||||
```
|
||||
|
||||
### FileSDK
|
||||
|
||||
文件上传、下载、打开的统一入口(`com.xuqm.sdk.file`)。
|
||||
`FileSDK` 只保留无需初始化、零 Manifest 的本地下载、摘要与 MediaStore 能力。
|
||||
依赖租户平台的上传和依赖 FileProvider 的打开文件能力属于独立 `sdk-file`,
|
||||
统一通过 `PlatformFileSDK` 调用。
|
||||
|
||||
```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)
|
||||
|
||||
// 打开
|
||||
FileSDK.openFile(context, file)
|
||||
```
|
||||
平台配置成功后以加密方式保存七天 LKG。平台暂不可用且 LKG 有效时进入
|
||||
`DEGRADED`;没有可用缓存时扩展能力返回结构化错误,宿主核心业务不受影响。
|
||||
|
||||
@ -47,7 +47,7 @@ dependencies {
|
||||
api(libs.kotlinx.coroutines.android)
|
||||
api(libs.kotlinx.serialization.json)
|
||||
api(libs.androidx.core.ktx)
|
||||
api(libs.androidx.security.crypto)
|
||||
implementation(libs.bouncycastle)
|
||||
api(libs.androidx.ui)
|
||||
api(libs.androidx.material3)
|
||||
testImplementation(libs.junit4)
|
||||
|
||||
@ -11,9 +11,6 @@
|
||||
|
||||
# ── File SDK ──────────────────────────────────────────────────────────────────
|
||||
-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 ──────────────────────
|
||||
-keepclassmembers class com.xuqm.sdk.** {
|
||||
|
||||
@ -1,26 +1 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<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>
|
||||
<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.ConfigFileCrypto
|
||||
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.SdkPlatformConfig
|
||||
import com.xuqm.sdk.network.SdkPlatformConfigApi
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
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 {
|
||||
|
||||
private const val TAG = "XuqmSDK"
|
||||
|
||||
/** 内置默认公有平台地址。使用私有化部署时必须显式传入 platformUrl,不会自动降级到此地址。 */
|
||||
const val DEFAULT_PLATFORM_URL = "https://www.51szyx.com/"
|
||||
|
||||
lateinit var config: SDKConfig
|
||||
private set
|
||||
|
||||
@ -42,7 +46,13 @@ object XuqmSDK {
|
||||
|
||||
private var initialized = false
|
||||
@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
|
||||
@ -53,6 +63,7 @@ object XuqmSDK {
|
||||
|
||||
@Volatile private var loginSession: XuqmLoginSession? = null
|
||||
@Volatile private var userInfoValue: XuqmUserInfo? = null
|
||||
private val userStateGeneration = AtomicLong(0L)
|
||||
@Volatile var platformConfig: SdkPlatformConfig? = null
|
||||
private set
|
||||
|
||||
@ -64,11 +75,21 @@ object XuqmSDK {
|
||||
@Volatile var bugCollectEnabled: Boolean = false
|
||||
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
|
||||
private set
|
||||
|
||||
private val pendingInitCallbacks = mutableListOf<() -> Unit>()
|
||||
private val pendingReadyCallbacks = mutableListOf<() -> Unit>()
|
||||
|
||||
/**
|
||||
* 当前远程配置拉取任务。失败时 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 线程完成解密+初始化。
|
||||
*
|
||||
* 与 [autoInitializeAsync] 的区别:
|
||||
* - 原始字节由调用方传入(主线程只做文件读取,不做 PBKDF2)
|
||||
* - 在 IO 线程直接解密,不使用磁盘缓存,signingKey 始终完整读取
|
||||
* - 复用 sdkScope,不创建冗余 CoroutineScope
|
||||
* - 初始化完成后自动触发 BugCollect 等扩展 SDK 启动
|
||||
* 这是运行时唯一初始化入口。appKey 与平台 serverUrl 均来自平台签发的
|
||||
* `src/main/assets/config/config.xuqmconfig`,宿主不能传参覆盖。
|
||||
*
|
||||
* Provider 在主线程只读取原始字节;验签、解密和缓存处理统一在 IO 线程执行,
|
||||
* 初始化完成后再通知 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
|
||||
if (isInitialized()) return
|
||||
updateInitializationStatus(XuqmInitializationState.INITIALIZING)
|
||||
sdkScope.launch {
|
||||
runCatching {
|
||||
// 直接解密传入的字节(在 IO 线程执行 PBKDF2,不阻塞主线程,~100ms 可接受)
|
||||
val configFile = runCatching {
|
||||
ConfigCache.load(ctx, rawBytes) ?: run {
|
||||
val json = ConfigFileCrypto.decrypt(String(rawBytes, Charsets.UTF_8))
|
||||
Gson().fromJson(json, ConfigFile::class.java)
|
||||
.also(ConfigFileReader::validate)
|
||||
.also { ConfigCache.save(ctx, rawBytes, it) }
|
||||
}
|
||||
}.getOrElse {
|
||||
Log.w(TAG, "autoInitializeFromBytes: failed to decrypt config: ${it.message}")
|
||||
return@launch
|
||||
throw XuqmSdkException(
|
||||
XuqmErrorCode.XUQM_CONFIG_INVALID,
|
||||
"无法验证或解密 config.xuqmconfig",
|
||||
it,
|
||||
)
|
||||
}
|
||||
|
||||
val configPackageName = configFile.packageName
|
||||
if (!configPackageName.isNullOrBlank() && configPackageName != ctx.packageName) {
|
||||
Log.w(TAG, "autoInitializeFromBytes: package name mismatch, skipping")
|
||||
return@launch
|
||||
throw XuqmSdkException(
|
||||
XuqmErrorCode.XUQM_CONFIG_INVALID,
|
||||
"配置包名与宿主包名不匹配",
|
||||
)
|
||||
}
|
||||
|
||||
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
|
||||
initialize(ctx, configFile.appKey, configFile.serverUrl, logLevel)
|
||||
|
||||
// 3. 初始化 sdk-bugcollect(如果存在)
|
||||
runCatching {
|
||||
initBugCollect(ctx, configFile.appKey)
|
||||
}.onFailure { e ->
|
||||
Log.w(TAG, "BugCollect init failed: ${e.message}")
|
||||
}
|
||||
initializeVerifiedConfig(ctx, configFile.appKey, configFile.serverUrl, logLevel)
|
||||
|
||||
}.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 的编译时依赖。
|
||||
*/
|
||||
private fun initBugCollect(context: Context, appKey: String) {
|
||||
private fun notifyBugCollectConfigurationChanged() {
|
||||
val clazz = runCatching {
|
||||
Class.forName("com.xuqm.sdk.bugcollect.BugCollect")
|
||||
}.getOrNull() ?: return
|
||||
|
||||
// Kotlin object 单例:通过 INSTANCE 字段获取实例
|
||||
runCatching {
|
||||
val instance = clazz.getField("INSTANCE").get(null)
|
||||
val startMethod = clazz.getMethod("startCrashCapture")
|
||||
startMethod.invoke(instance)
|
||||
clazz.getDeclaredMethod("onSdkConfigurationChanged").apply {
|
||||
isAccessible = true
|
||||
}.invoke(instance)
|
||||
}.onFailure { error ->
|
||||
Log.w(
|
||||
TAG,
|
||||
"BugCollect configuration callback failed: errorType=${error.javaClass.simpleName}",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 方式 B:手动初始化。
|
||||
*
|
||||
* 调用后立即完成本地初始化,并在后台异步拉取平台服务配置。
|
||||
* 使用子 SDK 前建议先 `await XuqmSDK.awaitInitialization()` 确保配置已就绪。
|
||||
*
|
||||
* @param platformUrl 平台地址(可选)。
|
||||
* - 不传:使用内置公有平台地址([DEFAULT_PLATFORM_URL])。
|
||||
* - 传入:使用指定私有化部署平台地址。
|
||||
* **私有化与公有平台互相独立,不允许在两者之间自动降级。**
|
||||
* @throws IllegalStateException 若已使用不同 appKey 初始化过,或 appKey 为空。
|
||||
*/
|
||||
fun initialize(
|
||||
/** 已验签配置进入运行时的唯一内部入口。 */
|
||||
private fun initializeVerifiedConfig(
|
||||
context: Context,
|
||||
appKey: String,
|
||||
platformUrl: String? = null,
|
||||
platformUrl: String,
|
||||
logLevel: LogLevel = LogLevel.WARN,
|
||||
) {
|
||||
require(appKey.isNotBlank()) { "appKey must not be blank" }
|
||||
require(platformUrl.isNotBlank()) { "platformUrl must not be blank" }
|
||||
val applicationContext = context.applicationContext
|
||||
val deferred: CompletableDeferred<Unit>
|
||||
synchronized(initLock) {
|
||||
@ -235,7 +211,13 @@ object XuqmSDK {
|
||||
ApiClient.init(config, tokenStore)
|
||||
initializedAppKey = appKey
|
||||
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()
|
||||
remoteConfigDeferred = deferred
|
||||
pendingInitCallbacks.forEach { runCatching(it) }
|
||||
@ -253,30 +235,42 @@ object XuqmSDK {
|
||||
* @throws Exception 平台配置拉取失败时抛出
|
||||
*/
|
||||
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 重试仍失败时抛出
|
||||
*/
|
||||
suspend fun retryInitialization() {
|
||||
check(initialized) { "XuqmSDK not initialized. Call XuqmSDK.initialize() first." }
|
||||
check(initialized) { "XuqmSDK 尚未通过 config.xuqmconfig 自动初始化" }
|
||||
val deferred = CompletableDeferred<Unit>()
|
||||
synchronized(initLock) {
|
||||
remoteConfigDeferred = deferred
|
||||
updateInitializationStatus(XuqmInitializationState.INITIALIZING)
|
||||
}
|
||||
runCatching {
|
||||
fetchAndApplyPlatformConfig(resolvedPlatformUrl, appKey)
|
||||
fetchPlatformConfigWithRetry(resolvedPlatformUrl, appKey)
|
||||
deferred.complete(Unit)
|
||||
}.onFailure { e ->
|
||||
deferred.completeExceptionally(
|
||||
platformInitException(resolvedPlatformUrl, appKey, e)
|
||||
)
|
||||
completeFromCacheOrFail(resolvedPlatformUrl, appKey, deferred, e)
|
||||
}
|
||||
deferred.await()
|
||||
}
|
||||
@ -284,15 +278,32 @@ object XuqmSDK {
|
||||
private fun launchRemoteConfigFetch(platformUrl: String, appKey: String, deferred: CompletableDeferred<Unit>) {
|
||||
sdkScope.launch {
|
||||
runCatching {
|
||||
fetchAndApplyPlatformConfig(platformUrl, appKey)
|
||||
fetchPlatformConfigWithRetry(platformUrl, appKey)
|
||||
deferred.complete(Unit)
|
||||
}.onFailure { e ->
|
||||
// 不降级,直接将错误传递给 awaitInitialization() 的调用者
|
||||
deferred.completeExceptionally(platformInitException(platformUrl, appKey, e))
|
||||
completeFromCacheOrFail(platformUrl, appKey, deferred, 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) {
|
||||
val base = platformUrl.trimEnd('/') + "/"
|
||||
val api = ApiClient.create(SdkPlatformConfigApi::class.java, base)
|
||||
@ -302,9 +313,19 @@ object XuqmSDK {
|
||||
"Platform returned empty config for appKey=$appKey at $platformUrl. " +
|
||||
"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(绝不跨平台)
|
||||
val base = platformUrl.trimEnd('/') + "/"
|
||||
val wsBase = platformUrl.trimEnd('/')
|
||||
.replace("https://", "wss://")
|
||||
.replace("http://", "ws://")
|
||||
@ -320,12 +341,41 @@ object XuqmSDK {
|
||||
)
|
||||
)
|
||||
bugCollectApiUrl = cfg.bugCollectApiUrl
|
||||
bugCollectEnabled = cfg.features?.bugCollect ?: false
|
||||
Log.i(
|
||||
TAG,
|
||||
"Platform config applied [${if (platformUrl == DEFAULT_PLATFORM_URL) "public" else "private"}]:" +
|
||||
" apiBase=$apiBase imWsUrl=${cfg.imWsUrl} bugCollectEnabled=$bugCollectEnabled"
|
||||
val prefs = appContext.getSharedPreferences("xuqm_sdk_core", Context.MODE_PRIVATE)
|
||||
if (fromRemote) {
|
||||
// 只有成功取得的新平台配置才允许解除服务端停用锁。
|
||||
prefs.edit().remove("bugcollect_server_disabled").apply()
|
||||
}
|
||||
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 {
|
||||
@ -354,43 +404,62 @@ object XuqmSDK {
|
||||
return deviceId
|
||||
}
|
||||
|
||||
private fun platformInitException(platformUrl: String, appKey: String, cause: Throwable): Throwable {
|
||||
val kind = if (platformUrl == DEFAULT_PLATFORM_URL) "公有平台" else "私有化平台 $platformUrl"
|
||||
return IllegalStateException(
|
||||
"XuqmSDK 初始化失败:无法从${kind}获取服务配置(appKey=$appKey)。" +
|
||||
"私有化与公有平台互相独立,不自动降级。原因:${cause.message}",
|
||||
private fun platformInitException(platformUrl: String, appKey: String, cause: Throwable): XuqmSdkException {
|
||||
return XuqmSdkException(
|
||||
XuqmErrorCode.XUQM_PLATFORM_UNAVAILABLE,
|
||||
"XuqmSDK 初始化失败:无法从配置平台 $platformUrl 获取服务配置" +
|
||||
"(appKey=$appKey)。SDK 不会切换或降级到其它地址。原因:${cause.message}",
|
||||
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) {
|
||||
synchronized(initLock) {
|
||||
if (initialized) block() else pendingInitCallbacks.add(block)
|
||||
val executeNow = synchronized(initLock) {
|
||||
if (initialized) true else {
|
||||
pendingInitCallbacks.add(block)
|
||||
false
|
||||
}
|
||||
}
|
||||
if (executeNow) block()
|
||||
}
|
||||
|
||||
/**
|
||||
* 手动覆盖服务端点(用于本地联调)。
|
||||
* 注意:若 [awaitInitialization] 尚未完成,此调用的结果会被远程配置覆盖。
|
||||
*/
|
||||
fun configureServiceEndpoints(endpoints: ServiceEndpoints) {
|
||||
ServiceEndpointRegistry.configure(endpoints)
|
||||
loginSession?.let { notifyOptionalModules("onSdkLogin", it) }
|
||||
private fun afterPlatformReady(block: () -> Unit) {
|
||||
val executeNow = synchronized(initLock) {
|
||||
when (initializationStatusValue.state) {
|
||||
XuqmInitializationState.READY,
|
||||
XuqmInitializationState.DEGRADED,
|
||||
-> true
|
||||
else -> {
|
||||
pendingReadyCallbacks.add(block)
|
||||
false
|
||||
}
|
||||
|
||||
fun useExternalServiceEndpoints() {
|
||||
configureServiceEndpoints(ServiceEndpoints())
|
||||
}
|
||||
|
||||
fun useLocalServiceEndpoints(host: String) {
|
||||
ServiceEndpointRegistry.useLocalhost(host)
|
||||
loginSession?.let { notifyOptionalModules("onSdkLogin", it) }
|
||||
}
|
||||
if (executeNow) block()
|
||||
}
|
||||
|
||||
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 }
|
||||
@ -399,10 +468,6 @@ object XuqmSDK {
|
||||
|
||||
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?
|
||||
get() = loginSession
|
||||
|
||||
@ -410,6 +475,81 @@ object XuqmSDK {
|
||||
|
||||
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 的统一认证入口,登录后调用一次即可。
|
||||
*
|
||||
@ -424,11 +564,12 @@ object XuqmSDK {
|
||||
* ```
|
||||
*/
|
||||
fun setUserInfo(info: XuqmUserInfo?) {
|
||||
val generation = userStateGeneration.incrementAndGet()
|
||||
if (info == null) {
|
||||
val hadSession = loginSession != null
|
||||
userInfoValue = null
|
||||
loginSession = null
|
||||
tokenStore.clear()
|
||||
if (::tokenStore.isInitialized) tokenStore.clear()
|
||||
if (hadSession) {
|
||||
notifyOptionalModulesSync("onSdkLogout")
|
||||
}
|
||||
@ -439,6 +580,8 @@ object XuqmSDK {
|
||||
// Defer session creation and sub-SDK notification until SDK is fully initialized;
|
||||
// if already initialized, afterInit runs synchronously
|
||||
afterInit {
|
||||
// 初始化完成前可能已切换用户或登出;旧回调不得恢复过期身份。
|
||||
if (generation != userStateGeneration.get()) return@afterInit
|
||||
val session = XuqmLoginSession(
|
||||
appKey = appKey,
|
||||
userId = info.userId,
|
||||
@ -446,29 +589,10 @@ object XuqmSDK {
|
||||
)
|
||||
loginSession = session
|
||||
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 通知(反射)──────────────────────────────────────────────────
|
||||
@ -482,11 +606,12 @@ object XuqmSDK {
|
||||
runCatching {
|
||||
val clazz = Class.forName(className)
|
||||
val instance = clazz.getField("INSTANCE").get(null)
|
||||
val method = clazz.methods.firstOrNull { method ->
|
||||
val method = clazz.declaredMethods.firstOrNull { method ->
|
||||
method.name == methodName &&
|
||||
method.parameterTypes.size == 1 &&
|
||||
method.parameterTypes[0].name == XuqmLoginSession::class.java.name
|
||||
} ?: return@runCatching
|
||||
method.isAccessible = true
|
||||
method.invoke(instance, session)
|
||||
}
|
||||
}
|
||||
@ -501,11 +626,25 @@ object XuqmSDK {
|
||||
runCatching {
|
||||
val clazz = Class.forName(className)
|
||||
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()
|
||||
} ?: return@runCatching
|
||||
method.isAccessible = true
|
||||
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
|
||||
|
||||
import android.content.Context
|
||||
import androidx.security.crypto.EncryptedSharedPreferences
|
||||
import androidx.security.crypto.MasterKeys
|
||||
import com.xuqm.sdk.storage.SecureStore
|
||||
|
||||
private const val PREFS_NAME = "xuqm_sdk_secure"
|
||||
private const val NAMESPACE = "login_session"
|
||||
private const val KEY_IM_TOKEN = "im_token"
|
||||
|
||||
class TokenStore(context: Context) {
|
||||
|
||||
private val prefs = EncryptedSharedPreferences.create(
|
||||
PREFS_NAME,
|
||||
MasterKeys.getOrCreate(MasterKeys.AES256_GCM_SPEC),
|
||||
context,
|
||||
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
|
||||
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
|
||||
)
|
||||
private val store = SecureStore.open(context, NAMESPACE)
|
||||
|
||||
fun getToken(): String? = prefs.getString(KEY_IM_TOKEN, null)
|
||||
fun getToken(): String? = store.getString(KEY_IM_TOKEN)
|
||||
|
||||
fun saveToken(token: String) {
|
||||
prefs.edit().putString(KEY_IM_TOKEN, token).apply()
|
||||
store.putString(KEY_IM_TOKEN, token)
|
||||
}
|
||||
|
||||
fun clear() {
|
||||
prefs.edit().remove(KEY_IM_TOKEN).apply()
|
||||
store.remove(KEY_IM_TOKEN)
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,44 +1,32 @@
|
||||
package com.xuqm.sdk.core
|
||||
|
||||
data class ServiceEndpoints(
|
||||
val controlBaseUrl: String = "https://dev.xuqinmin.com/",
|
||||
val imApiBaseUrl: String = "https://im.dev.xuqinmin.com/",
|
||||
val imWsUrl: String = "wss://im.dev.xuqinmin.com/ws/im",
|
||||
val pushBaseUrl: String = "https://dev.xuqinmin.com/",
|
||||
val updateBaseUrl: String = "https://update.dev.xuqinmin.com/",
|
||||
val fileBaseUrl: String = "https://file.dev.xuqinmin.com/",
|
||||
val controlBaseUrl: String,
|
||||
val imApiBaseUrl: String,
|
||||
val imWsUrl: String,
|
||||
val pushBaseUrl: String,
|
||||
val updateBaseUrl: String,
|
||||
val fileBaseUrl: String,
|
||||
)
|
||||
|
||||
object ServiceEndpointRegistry {
|
||||
|
||||
@Volatile
|
||||
var current: ServiceEndpoints = ServiceEndpoints()
|
||||
private set
|
||||
private var current: ServiceEndpoints? = null
|
||||
|
||||
val controlBaseUrl: String get() = current.controlBaseUrl
|
||||
val imApiBaseUrl: String get() = current.imApiBaseUrl
|
||||
val imWsUrl: String get() = current.imWsUrl
|
||||
val pushBaseUrl: String get() = current.pushBaseUrl
|
||||
val updateBaseUrl: String get() = current.updateBaseUrl
|
||||
val fileBaseUrl: String get() = current.fileBaseUrl
|
||||
val controlBaseUrl: String get() = requireConfigured().controlBaseUrl
|
||||
val imApiBaseUrl: String get() = requireConfigured().imApiBaseUrl
|
||||
val imWsUrl: String get() = requireConfigured().imWsUrl
|
||||
val pushBaseUrl: String get() = requireConfigured().pushBaseUrl
|
||||
val updateBaseUrl: String get() = requireConfigured().updateBaseUrl
|
||||
val fileBaseUrl: String get() = requireConfigured().fileBaseUrl
|
||||
|
||||
fun configure(endpoints: ServiceEndpoints) {
|
||||
internal fun configure(endpoints: ServiceEndpoints) {
|
||||
current = endpoints
|
||||
}
|
||||
|
||||
fun useLocalhost(host: String) {
|
||||
val normalizedHost = host.removeSuffix("/")
|
||||
current = ServiceEndpoints(
|
||||
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()
|
||||
private fun requireConfigured(): ServiceEndpoints =
|
||||
requireNotNull(current) {
|
||||
"Xuqm SDK 服务地址尚未就绪,请先等待 XuqmSDK.awaitInitialization()"
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,9 +1,9 @@
|
||||
package com.xuqm.sdk.file
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.Notification
|
||||
import android.app.PendingIntent
|
||||
import android.Manifest
|
||||
import android.content.ContentValues
|
||||
import android.content.Context
|
||||
@ -17,17 +17,8 @@ import android.webkit.MimeTypeMap
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.content.FileProvider
|
||||
import com.xuqm.sdk.core.ServiceEndpointRegistry
|
||||
import com.xuqm.sdk.network.ApiClient
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
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.security.MessageDigest
|
||||
|
||||
@ -38,16 +29,6 @@ sealed class 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(
|
||||
val bytesDownloaded: Long,
|
||||
val totalBytes: Long?,
|
||||
@ -57,78 +38,8 @@ data class FileDownloadProgress(
|
||||
?.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 {
|
||||
|
||||
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(
|
||||
downloadUrl: String,
|
||||
targetFile: File,
|
||||
@ -164,16 +75,6 @@ object FileSDK {
|
||||
fun matchesSha256(file: File, expectedSha256: String): Boolean =
|
||||
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(
|
||||
context: Context,
|
||||
downloadUrl: String,
|
||||
@ -261,11 +162,14 @@ object FileSDK {
|
||||
}
|
||||
if (useMediaStore) {
|
||||
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) {
|
||||
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
|
||||
} finally {
|
||||
capturedNotifId?.let { id ->
|
||||
@ -281,7 +185,6 @@ object FileSDK {
|
||||
.setSmallIcon(android.R.drawable.stat_sys_download_done)
|
||||
.setContentTitle(notificationTitle)
|
||||
.setContentText("下载完成:$resolvedName")
|
||||
.setContentIntent(buildOpenFilePendingIntent(context, target))
|
||||
.setAutoCancel(true)
|
||||
.build(),
|
||||
)
|
||||
@ -315,6 +218,7 @@ object FileSDK {
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressLint("MissingPermission")
|
||||
private fun notifyIfAllowed(context: Context, id: Int, notification: Notification) {
|
||||
val manager = NotificationManagerCompat.from(context)
|
||||
if (!manager.areNotificationsEnabled()) return
|
||||
@ -340,7 +244,10 @@ object FileSDK {
|
||||
fileName: String,
|
||||
destination: FileDownloadDestination = FileDownloadDestination.Sandbox,
|
||||
): 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)
|
||||
android.util.Log.d("XWV", "saveBlobDownload: decoded ${bytes.size} bytes")
|
||||
|
||||
@ -354,17 +261,20 @@ object FileSDK {
|
||||
// 旧版本或 Sandbox 目录直接写文件
|
||||
val baseDir = resolveBaseDir(context, destination)
|
||||
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 {
|
||||
target.writeBytes(bytes)
|
||||
android.util.Log.d("XWV", "saveBlobDownload: done, size=${target.length()}")
|
||||
target
|
||||
} 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 fallbackTarget = uniqueFile(fallbackDir, safeName)
|
||||
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
|
||||
}
|
||||
}
|
||||
@ -403,7 +313,7 @@ object FileSDK {
|
||||
/**
|
||||
* 通过 MediaStore 将文件写入公共 Downloads 目录。
|
||||
* 在华为等设备上直接文件 I/O 会失败,MediaStore 通过 ContentResolver 操作,兼容性更好。
|
||||
* 返回缓存目录中的副本(真实文件,可被 openFile 使用)。
|
||||
* 返回缓存目录中的副本,便于调用方继续展示、分享或交给可选 sdk-file 打开。
|
||||
*/
|
||||
@androidx.annotation.RequiresApi(Build.VERSION_CODES.Q)
|
||||
private fun saveBlobViaMediaStore(context: Context, bytes: ByteArray, fileName: String): File {
|
||||
@ -426,15 +336,15 @@ object FileSDK {
|
||||
|
||||
resolver.openOutputStream(uri)?.use { out ->
|
||||
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.put(MediaStore.Downloads.IS_PENDING, 0)
|
||||
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) }
|
||||
return cacheFile
|
||||
}
|
||||
@ -456,7 +366,7 @@ object FileSDK {
|
||||
runCatching { Runtime.getRuntime().exec(arrayOf("mkdir", "-p", dir.absolutePath)).waitFor() }
|
||||
}
|
||||
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
|
||||
} else {
|
||||
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 {
|
||||
val base = name.substringBeforeLast('.', name)
|
||||
val ext = name.substringAfterLast('.', "").let { if (it.isEmpty()) "" else ".$it" }
|
||||
|
||||
@ -1,94 +1,15 @@
|
||||
package com.xuqm.sdk.file
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.provider.OpenableColumns
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.currentCoroutineContext
|
||||
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.IOException
|
||||
import java.net.HttpURLConnection
|
||||
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(
|
||||
downloadUrl: String,
|
||||
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
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import androidx.security.crypto.EncryptedSharedPreferences
|
||||
import androidx.security.crypto.MasterKeys
|
||||
import com.google.gson.Gson
|
||||
import com.xuqm.sdk.storage.SecureStore
|
||||
import java.security.MessageDigest
|
||||
|
||||
/**
|
||||
* 持久化缓存解密后的配置文件内容。
|
||||
*
|
||||
* 使用 EncryptedSharedPreferences 存储 appKey / serverUrl / packageName,
|
||||
* 使用 [SecureStore] 存储已完成签名验证的完整引导配置,
|
||||
* 缓存 key 为配置文件原始内容的 SHA-256。文件变更时自动重新解密。
|
||||
*
|
||||
* 作用:避免每次冷启动都执行 PBKDF2(120,000 次迭代,~100ms)。
|
||||
*/
|
||||
internal object ConfigCache {
|
||||
|
||||
private const val TAG = "XuqmSDK"
|
||||
private const val PREFS_NAME = "xuqm_config_cache"
|
||||
private const val KEY_HASH = "config_hash"
|
||||
private const val KEY_APP_KEY = "app_key"
|
||||
private const val KEY_SERVER_URL = "server_url"
|
||||
private const val KEY_PACKAGE_NAME = "package_name"
|
||||
private const val NAMESPACE = "bootstrap_config_cache"
|
||||
private const val KEY_ENTRY = "entry"
|
||||
private val gson = Gson()
|
||||
private data class CacheEnvelope(val hash: String, val config: ConfigFile)
|
||||
|
||||
/** 内存级单例缓存,避免同进程内重复解密。 */
|
||||
@Volatile
|
||||
private var memoryCached: CachedConfig? = null
|
||||
|
||||
data class CachedConfig(
|
||||
val appKey: String,
|
||||
val serverUrl: String?,
|
||||
val packageName: String?,
|
||||
)
|
||||
private var memoryCached: ConfigFile? = null
|
||||
@Volatile
|
||||
private var memoryHash: String? = null
|
||||
|
||||
/**
|
||||
* 从缓存读取配置。内存缓存命中直接返回;否则查 EncryptedSharedPreferences。
|
||||
* 从缓存读取配置。内存缓存命中直接返回;否则查统一安全存储。
|
||||
* 文件内容 hash 不匹配时返回 null(需要重新解密)。
|
||||
*/
|
||||
fun load(context: Context, rawContent: ByteArray): CachedConfig? {
|
||||
// 1. 内存缓存
|
||||
memoryCached?.let { return it }
|
||||
|
||||
fun load(context: Context, rawContent: ByteArray): ConfigFile? {
|
||||
val currentHash = sha256(rawContent)
|
||||
val prefs = getPrefs(context)
|
||||
val savedHash = prefs.getString(KEY_HASH, null) ?: return null
|
||||
// 1. 内存缓存也必须与当前文件摘要绑定,防止同进程替换文件后读到旧配置。
|
||||
memoryCached?.takeIf { memoryHash == currentHash }?.let { return it }
|
||||
|
||||
if (savedHash != currentHash) return null
|
||||
|
||||
val appKey = prefs.getString(KEY_APP_KEY, null) ?: return null
|
||||
val serverUrl = prefs.getString(KEY_SERVER_URL, null)
|
||||
val packageName = prefs.getString(KEY_PACKAGE_NAME, null)
|
||||
|
||||
val cached = CachedConfig(appKey, serverUrl, packageName)
|
||||
val json = runCatching { store(context).getString(KEY_ENTRY) }.getOrNull() ?: return null
|
||||
val envelope = runCatching { gson.fromJson(json, CacheEnvelope::class.java) }.getOrNull()
|
||||
?: return null
|
||||
if (envelope.hash != currentHash) return null
|
||||
val cached = envelope.config
|
||||
memoryCached = cached
|
||||
memoryHash = currentHash
|
||||
return cached
|
||||
}
|
||||
|
||||
@ -61,31 +50,23 @@ internal object ConfigCache {
|
||||
*/
|
||||
fun save(context: Context, rawContent: ByteArray, config: ConfigFile) {
|
||||
val hash = sha256(rawContent)
|
||||
getPrefs(context).edit().apply {
|
||||
putString(KEY_HASH, hash)
|
||||
putString(KEY_APP_KEY, config.appKey)
|
||||
putString(KEY_SERVER_URL, config.serverUrl)
|
||||
putString(KEY_PACKAGE_NAME, config.packageName)
|
||||
apply()
|
||||
runCatching {
|
||||
store(context).putString(KEY_ENTRY, gson.toJson(CacheEnvelope(hash, config)))
|
||||
}
|
||||
memoryCached = CachedConfig(config.appKey, config.serverUrl, config.packageName)
|
||||
memoryCached = config
|
||||
memoryHash = hash
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除缓存(用于登出或配置重置场景)。
|
||||
*/
|
||||
fun clear(context: Context) {
|
||||
getPrefs(context).edit().clear().apply()
|
||||
runCatching { store(context).clear() }
|
||||
memoryCached = null
|
||||
memoryHash = null
|
||||
}
|
||||
|
||||
private fun getPrefs(context: Context) = EncryptedSharedPreferences.create(
|
||||
PREFS_NAME,
|
||||
MasterKeys.getOrCreate(MasterKeys.AES256_GCM_SPEC),
|
||||
context,
|
||||
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
|
||||
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
|
||||
)
|
||||
private fun store(context: Context) = SecureStore.open(context, NAMESPACE)
|
||||
|
||||
private fun sha256(data: ByteArray): String {
|
||||
val digest = MessageDigest.getInstance("SHA-256").digest(data)
|
||||
|
||||
@ -3,20 +3,21 @@ package com.xuqm.sdk.internal
|
||||
import com.google.gson.annotations.SerializedName
|
||||
|
||||
/**
|
||||
* Decrypted content of the init config file (assets/xuqm/config.xuqm).
|
||||
* Contains the appKey and optional server URL needed to bootstrap the SDK.
|
||||
* This file only contains SDK bootstrap configuration.
|
||||
* 平台签发的 `assets/config/config.xuqmconfig` 验签、解密后的引导内容。
|
||||
* appKey 与 serverUrl 是启动 SDK 的唯一必填身份和平台地址。
|
||||
*/
|
||||
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 = "appName", alternate = ["app_name"]) val appName: String? = null,
|
||||
@SerializedName(value = "companyName", alternate = ["company_name"]) val companyName: 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 = "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? = null,
|
||||
@SerializedName(value = "serverUrl", alternate = ["server_url"]) val serverUrl: String,
|
||||
@SerializedName(value = "signingKey", alternate = ["signing_key"]) val signingKey: String? = null,
|
||||
@SerializedName(value = "issuedAt", alternate = ["issued_at"]) val issuedAt: String? = null,
|
||||
@SerializedName(value = "issuedAt", alternate = ["issued_at"]) val issuedAt: String,
|
||||
@SerializedName(value = "expiresAt", alternate = ["expires_at"]) val expiresAt: String? = null,
|
||||
)
|
||||
|
||||
@ -1,6 +1,12 @@
|
||||
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.SecretKeyFactory
|
||||
import javax.crypto.spec.GCMParameterSpec
|
||||
@ -8,33 +14,97 @@ import javax.crypto.spec.PBEKeySpec
|
||||
import javax.crypto.spec.SecretKeySpec
|
||||
|
||||
/**
|
||||
* Decrypts the init config file (format: XUQM-CONFIG-V1.<salt>.<iv>.<ciphertext>).
|
||||
* Algorithm: AES-256-GCM with PBKDF2-HMAC-SHA256 key derivation (120,000 iterations).
|
||||
* XUQM-CONFIG-V2 的唯一验证与解密实现。
|
||||
*
|
||||
* 格式:
|
||||
* `XUQM-CONFIG-V2.keyId.salt.iv.ciphertext.signature`
|
||||
*
|
||||
* Ed25519 签名覆盖前五段的精确 ASCII,必须先验签再执行 AES 解密。AES 只用于降低
|
||||
* 配置的明文可见性,配置真实性完全由租户平台私钥签名保证。
|
||||
*/
|
||||
internal object ConfigFileCrypto {
|
||||
private const val MAGIC = "XUQM-CONFIG-V1"
|
||||
private const val PASSPHRASE = "xuqm-config-file-v1.2026.internal"
|
||||
private const val MAGIC = "XUQM-CONFIG-V2"
|
||||
private const val PASSPHRASE = "xuqm-config-file-v2.2026.internal"
|
||||
private const val KEY_BITS = 256
|
||||
private const val ITERATIONS = 120_000
|
||||
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(".")
|
||||
require(parts.size == 4 && parts[0] == MAGIC) { "Invalid config file format" }
|
||||
val salt = decode(parts[1])
|
||||
val iv = decode(parts[2])
|
||||
val cipherText = decode(parts[3])
|
||||
require(parts.size == 6 && parts[0] == MAGIC) {
|
||||
"Only XUQM-CONFIG-V2 is supported"
|
||||
}
|
||||
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")
|
||||
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 {
|
||||
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")
|
||||
}
|
||||
|
||||
private fun decode(value: String): ByteArray =
|
||||
Base64.decode(value, Base64.URL_SAFE or Base64.NO_PADDING or Base64.NO_WRAP)
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 由发布流程维护的平台公钥集合。只保存 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.util.Log
|
||||
import com.google.gson.Gson
|
||||
import java.time.Instant
|
||||
import java.util.UUID
|
||||
|
||||
/**
|
||||
* Reads and decrypts the init config file from assets.
|
||||
*
|
||||
* 搜索顺序(对齐 RN SDK 的 src/assets/config/):
|
||||
* 1. assets/config/config.xuqmconfig 或 config.xuqm
|
||||
* 2. assets/config/\*.xuqmconfig(取第一个)
|
||||
* 3. assets/xuqm/config.xuqm(旧路径兼容)
|
||||
* 4. assets/xuqm/\*.xuqmconfig(旧路径兼容)
|
||||
* 唯一位置:`assets/config/config.xuqmconfig`。
|
||||
* 文件由租户平台签发,宿主不得复制、改名或自行修改。
|
||||
*
|
||||
* 缓存策略:
|
||||
* - [readRawBytes]:只读取文件原始字节,不做解密(主线程安全,<5ms)
|
||||
* - [readWithCache]:先查 ConfigCache(内存+磁盘),miss 时解密并写缓存
|
||||
* - [read]:原始读取+解密,无缓存(兼容旧调用)
|
||||
* - Provider 将原始字节交给唯一初始化流程,由该流程完成缓存、验签与解密。
|
||||
*/
|
||||
internal object ConfigFileReader {
|
||||
|
||||
private const val TAG = "XuqmSDK"
|
||||
private val CONFIG_DIRS = listOf("config", "xuqm")
|
||||
private val gson = Gson()
|
||||
private const val CONFIG_PATH = "config/config.xuqmconfig"
|
||||
|
||||
/**
|
||||
* 只读取配置文件原始字节,不做 PBKDF2 解密。
|
||||
@ -37,76 +33,20 @@ internal object ConfigFileReader {
|
||||
}.getOrNull()
|
||||
}
|
||||
|
||||
/**
|
||||
* 带缓存的配置读取。优先查 ConfigCache(内存 → EncryptedSharedPreferences),
|
||||
* 缓存 miss 时执行 PBKDF2 解密并写入缓存。
|
||||
* 用于 IO 线程,不应在主线程调用。
|
||||
*/
|
||||
fun readWithCache(context: Context): ConfigFile? {
|
||||
val rawBytes = readRawBytes(context) ?: return null
|
||||
|
||||
// 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
|
||||
internal fun validate(config: ConfigFile) {
|
||||
require(config.schemaVersion == 2) { "Unsupported config schemaVersion=${config.schemaVersion}" }
|
||||
require(config.revision > 0) { "Config revision must be positive" }
|
||||
require(config.appKey.isNotBlank()) { "Config appKey must not be blank" }
|
||||
require(config.serverUrl.isNotBlank()) { "Config serverUrl must not be blank" }
|
||||
UUID.fromString(config.configId)
|
||||
Instant.parse(config.issuedAt)
|
||||
config.expiresAt?.let(Instant::parse)
|
||||
}
|
||||
|
||||
private fun findConfigPath(context: Context): String? {
|
||||
for (dir in CONFIG_DIRS) {
|
||||
// 优先 config.xuqmconfig 或 config.xuqm
|
||||
for (name in listOf("config.xuqmconfig", "config.xuqm")) {
|
||||
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" }
|
||||
return runCatching {
|
||||
context.assets.open(CONFIG_PATH).close()
|
||||
CONFIG_PATH
|
||||
}.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):
|
||||
* - 扫描 assets/config/ 或 assets/xuqm/ 找到 .xuqmconfig 文件
|
||||
* - 读取唯一的 assets/config/config.xuqmconfig
|
||||
* - 读取文件原始字节到内存
|
||||
* - 启动 IO 协程
|
||||
* Phase 2(异步,IO 线程):
|
||||
* - 查 ConfigCache(内存→磁盘),命中则跳过 PBKDF2
|
||||
* - 缓存 miss 时执行 PBKDF2 解密(~100ms)并写缓存
|
||||
* - 缓存 miss 时先验证 Ed25519 签名,再执行 PBKDF2 解密并写缓存
|
||||
* - 初始化 sdk-core(TokenStore / DeviceId / ApiClient)
|
||||
* - 注册 sdk-bugcollect CrashCapture
|
||||
* - 拉取远端平台配置
|
||||
@ -26,6 +26,16 @@ class XuqmMergedProvider : ContentProvider() {
|
||||
|
||||
override fun onCreate(): Boolean {
|
||||
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)──────────────────────────
|
||||
val rawBytes = ConfigFileReader.readRawBytes(ctx)
|
||||
|
||||
@ -16,7 +16,6 @@ import okhttp3.MultipartBody
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
import okio.Buffer
|
||||
import retrofit2.Retrofit
|
||||
import retrofit2.converter.gson.GsonConverterFactory
|
||||
import java.time.Instant
|
||||
@ -31,7 +30,6 @@ import javax.crypto.spec.SecretKeySpec
|
||||
object ApiClient {
|
||||
|
||||
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.
|
||||
private val lenientLongAdapter = object : TypeAdapter<Long>() {
|
||||
@ -145,50 +143,42 @@ object ApiClient {
|
||||
logRequest(request)
|
||||
val response = chain.proceed(request)
|
||||
val tookMs = TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - startedNs)
|
||||
Log.d(TAG, "<-- ${response.code} ${request.method} ${request.url} (${tookMs}ms)")
|
||||
logResponseBody(response)
|
||||
Log.d(
|
||||
TAG,
|
||||
"<-- code=${response.code} method=${request.method} " +
|
||||
"path=${request.url.encodedPath} durationMs=$tookMs",
|
||||
)
|
||||
return response
|
||||
}
|
||||
|
||||
private fun logRequest(request: Request) {
|
||||
val body = request.body
|
||||
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 ->
|
||||
Log.d(TAG, " ${describePart(index, part)}")
|
||||
}
|
||||
return
|
||||
}
|
||||
val bodyText = requestBodyAsString(body)
|
||||
if (bodyText.isNullOrBlank()) {
|
||||
Log.d(TAG, "--> ${request.method} ${request.url}")
|
||||
} else {
|
||||
Log.d(TAG, "--> ${request.method} ${request.url}\n$bodyText")
|
||||
}
|
||||
}
|
||||
|
||||
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()
|
||||
val contentLength = runCatching { body?.contentLength() ?: 0L }.getOrDefault(-1L)
|
||||
Log.d(
|
||||
TAG,
|
||||
"--> method=${request.method} path=${request.url.encodedPath} " +
|
||||
"contentType=${body?.contentType()} contentLength=$contentLength",
|
||||
)
|
||||
}
|
||||
|
||||
private fun describePart(index: Int, part: MultipartBody.Part): String {
|
||||
val headers = part.headers
|
||||
val disposition = headers?.get("Content-Disposition").orEmpty()
|
||||
val name = parseDispositionValue(disposition, "name").orEmpty().ifBlank { "-" }
|
||||
val filename = parseDispositionValue(disposition, "filename").orEmpty().ifBlank { "-" }
|
||||
val mimeType = part.body.contentType()?.toString().orEmpty().ifBlank { "-" }
|
||||
val size = runCatching { part.body.contentLength() }.getOrDefault(-1L)
|
||||
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? {
|
||||
@ -201,27 +191,6 @@ object ApiClient {
|
||||
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)
|
||||
|
||||
/**
|
||||
* 平台服务地址配置,由 [XuqmSDK.initialize] 从租户平台拉取。
|
||||
* 平台服务地址配置,由 SDK 完成签名配置自动初始化后从租户平台拉取。
|
||||
* 仅包含各服务的 URL;各子 SDK 在初始化完成后自行请求自己的业务配置。
|
||||
*
|
||||
* 若 [apiUrl] 为 null,所有 HTTP 服务地址均从初始化时传入的 platformUrl 推导(同一系统内,不跨平台)。
|
||||
@ -32,6 +32,13 @@ data class SdkPlatformConfig(
|
||||
@SerializedName("bugCollectApiUrl") val bugCollectApiUrl: String? = 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"?>
|
||||
<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-cache-path name="external_cache" path="." />
|
||||
<files-path name="internal_files" path="." />
|
||||
<cache-path name="cache_files" path="." />
|
||||
</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
|
||||
// ImClient 直接使用
|
||||
|
||||
@ -31,6 +31,7 @@ android {
|
||||
}
|
||||
|
||||
dependencies {
|
||||
api(project(":sdk-core"))
|
||||
api(project(":sdk-file"))
|
||||
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()
|
||||
|
||||
fun connect() {
|
||||
Log.d(TAG, "connect() wsUrl=$wsUrl appKey=$appKey")
|
||||
Log.d(TAG, "WebSocket connection requested")
|
||||
disconnect(closeSocket = false)
|
||||
val request = Request.Builder()
|
||||
.url(wsUrl)
|
||||
@ -54,13 +54,16 @@ class ImClient(
|
||||
|
||||
override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
|
||||
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) }
|
||||
}
|
||||
|
||||
override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
|
||||
connected = false
|
||||
Log.d(TAG, "websocket onClosed code=$code reason=$reason")
|
||||
Log.d(TAG, "WebSocket closed: code=$code")
|
||||
listeners.forEach { it.onDisconnected(reason) }
|
||||
}
|
||||
})
|
||||
@ -93,7 +96,11 @@ class ImClient(
|
||||
content: String,
|
||||
mentionedUserIds: String? = null,
|
||||
): 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(
|
||||
"appKey" to appKey,
|
||||
"messageId" to messageId,
|
||||
@ -205,15 +212,8 @@ class ImClient(
|
||||
val msg = gson.fromJson(body, ImMessage::class.java)
|
||||
Log.d(
|
||||
TAG,
|
||||
buildString {
|
||||
append("stomp MESSAGE destination=").append(headers["destination"].orEmpty())
|
||||
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)
|
||||
},
|
||||
"stomp MESSAGE chatType=${msg.chatType} msgType=${msg.msgType} " +
|
||||
"status=${msg.status}",
|
||||
)
|
||||
if (msg.status.uppercase() == "READ") {
|
||||
listeners.forEach { it.onRead(msg) }
|
||||
@ -234,7 +234,7 @@ class ImClient(
|
||||
}
|
||||
"ERROR" -> {
|
||||
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" }) }
|
||||
}
|
||||
}
|
||||
|
||||
@ -30,8 +30,8 @@ import com.xuqm.sdk.im.model.PageResult
|
||||
import com.xuqm.sdk.im.model.UserProfile
|
||||
import com.xuqm.sdk.im.model.BlacklistEntry
|
||||
import com.xuqm.sdk.im.model.FriendRequest
|
||||
import com.xuqm.sdk.file.FileSDK
|
||||
import com.xuqm.sdk.file.FileUploadResult
|
||||
import com.xuqm.sdk.file.platform.PlatformFileSDK
|
||||
import com.xuqm.sdk.file.platform.FileUploadResult
|
||||
import com.xuqm.sdk.network.ApiClient
|
||||
import android.util.Log
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
@ -111,28 +111,6 @@ object ImSDK {
|
||||
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(
|
||||
toId: String,
|
||||
chatType: String,
|
||||
@ -156,7 +134,11 @@ object ImSDK {
|
||||
content = content,
|
||||
mentionedUserIds = mentionedUserIds,
|
||||
) == 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")
|
||||
}
|
||||
|
||||
@ -182,7 +164,7 @@ object ImSDK {
|
||||
width: Int? = null,
|
||||
height: Int? = null,
|
||||
): ImMessage = withContext(Dispatchers.IO) {
|
||||
val result = FileSDK.upload(file)
|
||||
val result = PlatformFileSDK.upload(file)
|
||||
sendImageMessageWithUploadResult(toId, chatType, result, width, height)
|
||||
}
|
||||
|
||||
@ -218,7 +200,7 @@ object ImSDK {
|
||||
height: Int? = null,
|
||||
durationMs: Long? = null,
|
||||
): ImMessage = withContext(Dispatchers.IO) {
|
||||
val result = FileSDK.upload(file)
|
||||
val result = PlatformFileSDK.upload(file)
|
||||
sendVideoMessageWithUploadResult(toId, chatType, result, width, height, durationMs)
|
||||
}
|
||||
|
||||
@ -253,7 +235,7 @@ object ImSDK {
|
||||
chatType: String,
|
||||
file: File,
|
||||
): ImMessage = withContext(Dispatchers.IO) {
|
||||
val result = FileSDK.upload(file)
|
||||
val result = PlatformFileSDK.upload(file)
|
||||
sendFileMessageWithUploadResult(toId, chatType, result)
|
||||
}
|
||||
|
||||
@ -282,7 +264,7 @@ object ImSDK {
|
||||
file: File,
|
||||
durationMs: Long? = null,
|
||||
): ImMessage = withContext(Dispatchers.IO) {
|
||||
val result = FileSDK.upload(file)
|
||||
val result = PlatformFileSDK.upload(file)
|
||||
sendAudioMessageWithUploadResult(toId, chatType, result, durationMs)
|
||||
}
|
||||
|
||||
@ -445,7 +427,7 @@ object ImSDK {
|
||||
}
|
||||
|
||||
fun subscribeGroup(groupId: String) {
|
||||
Log.d(TAG, "subscribeGroup groupId=$groupId")
|
||||
Log.d(TAG, "Group subscription requested")
|
||||
synchronized(activeGroupSubscriptions) {
|
||||
activeGroupSubscriptions.add(groupId)
|
||||
}
|
||||
@ -453,7 +435,7 @@ object ImSDK {
|
||||
}
|
||||
|
||||
fun unsubscribeGroup(groupId: String) {
|
||||
Log.d(TAG, "unsubscribeGroup groupId=$groupId")
|
||||
Log.d(TAG, "Group unsubscription requested")
|
||||
synchronized(activeGroupSubscriptions) {
|
||||
activeGroupSubscriptions.remove(groupId)
|
||||
}
|
||||
@ -817,7 +799,7 @@ object ImSDK {
|
||||
disconnectInternal(clearTokenStore = true)
|
||||
}
|
||||
|
||||
fun onSdkLogin(session: XuqmLoginSession) {
|
||||
private fun onSdkLogin(session: XuqmLoginSession) {
|
||||
XuqmSDK.requireInit()
|
||||
if (session.userSig.isBlank()) return // IM 未提供 userSig,跳过连接(Push/Update 仍生效)
|
||||
if (currentUserId == session.userId && currentUserSig == session.userSig) return
|
||||
@ -826,7 +808,7 @@ object ImSDK {
|
||||
connectWithToken(session.userSig)
|
||||
}
|
||||
|
||||
fun onSdkLogout() {
|
||||
private fun onSdkLogout() {
|
||||
disconnectInternal(clearTokenStore = false)
|
||||
}
|
||||
|
||||
@ -842,7 +824,7 @@ object ImSDK {
|
||||
client?.disconnect()
|
||||
_connectionState.value = ImConnectionState.Connecting
|
||||
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?.addListener(connectionListener)
|
||||
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 中更改的文件太多 显示更多
正在加载...
在新工单中引用
屏蔽一个用户