refactor(sdk): unify configuration and extension lifecycle

这个提交包含在:
XuqmGroup 2026-07-27 09:46:13 +08:00
父节点 f784f3354c
当前提交 fcdc94b309
共有 134 个文件被更改,包括 4339 次插入2306 次删除

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

查看文件

@ -25,23 +25,14 @@ XuqmGroup Android SDK,Gradle multi-module 项目。为集成宿主 App如 Y
## 核心 APIsdk-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 采集 SDKsdk-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
查看文件

@ -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
查看文件

@ -6,7 +6,8 @@
```
XuqmGroup-AndroidSDK/
├── sdk-core/ # 核心初始化、HTTP、Token 存储、文件上传、通用工具/组件
├── sdk-core/ # 核心:初始化、基础 HTTP、本地文件、时间、安全存储
├── sdk-file/ # 平台文件上传与 FileProvider 打开能力
├── sdk-bugcollect/ # 日志监控错误上报、Crash 捕获、漏斗分析(含 Gradle Plugin
├── sdk-im/ # IMWebSocket 实时通信
├── 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 处理)

查看文件

@ -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。
---

查看文件

@ -3,14 +3,16 @@
## 整体架构
```
宿主 AppYwxMobileApp 等)
└── 初始化方式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() → 通知所有子模块(反射 / 接口回调)
├── ApiClientOkHttp 5 + Retrofit 3
├── TokenStoreEncryptedSharedPreferences
├── ApiClientOkHttp 4.12 + Retrofit 3
├── TokenStoreAndroid Keystore + AES-256-GCM
├── FileSDK上传 / 下载 / 打开文件)
├── ServiceEndpointRegistry各服务地址注册表
└── bugCollectApiUrl / bugCollectEnabled从平台配置获取
@ -51,30 +53,18 @@ XuqmSDK.setUserInfo(null)
## 初始化流程
### 方式 AContentProvider 自动)
```
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
失败且无有效 LKGFAILED;扩展停用,宿主核心业务继续
```
## 技术栈
@ -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 中创建的新鲜 GROUPtestGroupId
@ -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"
* 占位存入 pendingErrorsflush 时用真实值替换避免服务端因找不到应用而丢弃事件
/**
* 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 中调用
* 不存储上传 URLURL 在下次启动上传时从 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。
主线程只读文件字节(<10msPBKDF2 + 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 文件~5msTokenStore / 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文件变更时自动重新解密
*
* 作用避免每次冷启动都执行 PBKDF2120,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-coreTokenStore / 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 普通文件
查看文件

@ -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 普通文件
查看文件

@ -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)
}

查看文件

@ -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)
}
/**
* 刷新 userSigIM 登录凭证过期时调用
* 等效于用新 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 中更改的文件太多 显示更多