diff --git a/.gitignore b/.gitignore index 6056603..353a9be 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/CLAUDE.md b/CLAUDE.md index 22a73fe..842ed50 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -25,23 +25,14 @@ XuqmGroup Android SDK,Gradle multi-module 项目。为集成宿主 App(如 Y ## 核心 API(sdk-core) -### 初始化(两种方式,均不得修改签名) +### 初始化(唯一入口) -**方式 A — ContentProvider 自动初始化(推荐)** -将 `config.xuqm` 放入 `src/main/assets/xuqm/`,App 无需调用任何初始化代码。 -`XuqmInitializerProvider` 在 App 启动时自动触发。 +将租户平台签发的 `config.xuqmconfig` 原样放入 +`src/main/assets/config/config.xuqmconfig`。集成任一需要初始化的扩展 SDK 后, +合并 Provider 在 App 启动时自动触发;宿主不得手动传 appKey 初始化。 -**方式 B — 手动初始化** -```kotlin -// Application.onCreate() 中: -XuqmSDK.initialize(context, appKey = "xxx") // 公有平台 -XuqmSDK.initialize(context, appKey = "xxx", platformUrl = "https://xxx") // 私有化平台 - -// 等待平台配置(在 coroutine 中): -XuqmSDK.awaitInitialization() -``` - -**两种平台互相独立,不允许自动降级到默认公有平台(`DEFAULT_PLATFORM_URL`)。** +`serverUrl` 是配置签发时确定的唯一平台地址。运行时不存在默认平台地址,也不允许 +宿主覆盖、跨平台降级或维护第二套初始化配置。 ### 用户信息 @@ -68,7 +59,8 @@ XuqmSDK.setUserInfo(null) // 登出,触发所有子 SDK 登出 - 平台托管用户(有 userSig):完整功能,包含 IM - **禁止在 push / update / bugcollect 等 SDK 内部校验 userSig 是否存在** -**注意:** push 设备注册依赖请求签名(`signingKey` in config.xuqm),与 userSig 无关。`signingKey` 未配置时,设备注册请求会被服务端拒绝(HTTP 401)。需在平台后台为该 appKey 生成 signingKey 并重新下载 config.xuqm。 +**注意:** push 设备注册依赖 `config.xuqmconfig` 中的平台请求签名信息,与 userSig +无关。未配置时设备注册请求会被服务端拒绝,需在平台后台重新生成并下载配置文件。 ### 平台配置读取(init 完成后) ```kotlin @@ -80,7 +72,7 @@ XuqmSDK.appKey // 当前 appKey ## Bug 采集 SDK(sdk-bugcollect) ```kotlin -// Application.onCreate() 中(XuqmSDK.initialize 之后): +// 平台配置自动初始化完成且宿主已取得隐私授权后: BugCollect.setLogLevel(LogLevel.INFO) BugCollect.setEnvironment("production") BugCollect.startCrashCapture() // 开启 UncaughtExceptionHandler @@ -97,9 +89,8 @@ BugCollect.defineFunnel("checkout", listOf("cart_view", "checkout_start", "payme `bugCollectApiUrl` 由 SDK 在 init 后从平台配置自动获取,无需 App 传入。 -## 向下兼容约束 +## API 约束 -- `XuqmSDK.initialize()` 现有参数不得删除或修改类型 - `XuqmSDK.setUserInfo()` 现有 `XuqmUserInfo` 字段不得删除 - `SdkPlatformConfig` 新增字段一律为可选(`val xxx: Type? = null`) - 旧服务端不返回新字段时,客户端使用合理默认值 diff --git a/Jenkinsfile b/Jenkinsfile index a144412..64ceff3 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -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,7 +175,11 @@ pipeline { def credArgs = "-PNEXUS_USER=%NEXUS_CREDS_USR% -PNEXUS_PASSWORD=%NEXUS_CREDS_PSW%" // 按依赖顺序逐个发布,避免扩展 POM 先于 sdk-core 可见。 for (mod in modules) { - bat "gradlew.bat :${mod}:publish ${env.VERSION_ARGS} ${credArgs} --no-daemon" + 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" + } } } } diff --git a/README.md b/README.md index ca78ef9..df2b8a5 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,8 @@ ``` XuqmGroup-AndroidSDK/ -├── sdk-core/ # 核心:初始化、HTTP、Token 存储、文件上传、通用工具/组件 +├── sdk-core/ # 核心:初始化、基础 HTTP、本地文件、时间、安全存储 +├── sdk-file/ # 平台文件上传与 FileProvider 打开能力 ├── sdk-bugcollect/ # 日志监控:错误上报、Crash 捕获、漏斗分析(含 Gradle Plugin) ├── sdk-im/ # IM:WebSocket 实时通信 ├── sdk-push/ # 推送:设备 Token 注册 @@ -34,6 +35,22 @@ dependencyResolutionManagement { } ``` +所有宿主变体必须应用 common 构建插件。它会扫描宿主 `src/**`,只接受唯一固定路径 +`src/main/assets/config/config.xuqmconfig`;任何改名、嵌套或其它 sourceSet 中的 +`.xuqmconfig` 文件都会直接失败。 +Debug 执行本地 V2 验签,Release 额外执行租户平台在线有效性校验,并负责不可变 buildId 和 +BugCollect 构建门禁,并生成扩展自动初始化所需的唯一 Manifest 标记;BugCollect 插件 +仅负责 R8 mapping 上传。Debug 自动采集固定关闭,但非应急禁用的可调试宿主可通过内部 +开发入口显式发送一次测试事件。 + +```kotlin +plugins { + id("com.xuqm.common") version "VERSION" + // 使用 BugCollect 且 Release 开启 R8 时再添加: + id("com.xuqm.bugcollect") version "VERSION" +} +``` + 在 `gradle.properties` 或环境变量中配置: ```properties NEXUS_USER=your_username @@ -43,12 +60,13 @@ NEXUS_PASSWORD=your_password 引入依赖: ```kotlin dependencies { - implementation("com.xuqm:sdk-core:0.4.2") - implementation("com.xuqm:sdk-bugcollect:0.4.2") // 日志监控(推荐) - implementation("com.xuqm:sdk-im:0.4.2") // 可选 - implementation("com.xuqm:sdk-push:0.4.2") // 可选 - implementation("com.xuqm:sdk-update:0.4.2") // 可选 - implementation("com.xuqm:sdk-webview:0.4.2") // 可选 + implementation("com.xuqm:sdk-core:VERSION") + implementation("com.xuqm:sdk-file:VERSION") // 平台文件能力可选 + implementation("com.xuqm:sdk-bugcollect:VERSION") // 可选 + implementation("com.xuqm:sdk-im:VERSION") // 可选 + implementation("com.xuqm:sdk-push:VERSION") // 可选 + implementation("com.xuqm:sdk-update:VERSION") // 可选 + implementation("com.xuqm:sdk-webview:VERSION") // 可选 } ``` @@ -56,27 +74,22 @@ dependencies { ### 1. 初始化 -**方式 A(推荐):** 将加密配置文件放置到 `src/main/assets/xuqm/config.xuqm`,ContentProvider 自动完成初始化,无需代码。 +从租户平台下载签名的 `config.xuqmconfig`,原样放入 +`app/src/main/assets/config/config.xuqmconfig`。ContentProvider 会自动初始化,宿主无需 +编写初始化代码,也不得修改、复制、自行生成或在 flavor/buildType 下放置第二份文件。 +配置中的 `serverUrl` 是唯一平台地址且必填,运行时没有默认地址或跨平台回退。 -**方式 B(手动):** 在 `Application.onCreate()` 中调用: - -```kotlin -XuqmSDK.initialize( - context = this, - appKey = "ak_your_app_key", - logLevel = if (BuildConfig.DEBUG) LogLevel.DEBUG else LogLevel.WARN -) -``` +仅依赖 `sdk-core` 时不会合并初始化 Provider、通知权限或 FileProvider,也不要求配置 +文件、初始化或登录。引入任一需要平台能力的扩展后,各 AAR 中同名声明会合并为唯一 +`XuqmMergedProvider`;Provider 只有检测到 common 构建插件生成的标记后才会自动读取 +上述配置,避免未应用插件的宿主发生隐式初始化。 ### 2. 用户登录后初始化 IM ```kotlin // 调用业务登录接口,拿到 userSig 后只需要登录一次 SDK val userSig = api.getUserSig(userId) -XuqmSDK.login( - userId = userId, - userSig = userSig, -) +XuqmSDK.setUserInfo(XuqmUserInfo(userId = userId, userSig = userSig)) // 如果工程里集成了 sdk-im / sdk-push / sdk-update, // SDK 会自动完成对应模块的登录与初始化 @@ -110,27 +123,6 @@ openXWebView( // navigate("xwebview") 后使用 XWebViewScreen() ``` -### 3. 切换联调环境 - -默认使用外网域名。若要本地联调,可在 `Application.onCreate()` 里切换: - -```kotlin -XuqmSDK.useLocalServiceEndpoints("192.168.113.37") -// 物理设备可改成你的电脑局域网 IP -``` - -如果是 sample-app,也可以直接调用: - -```kotlin -SampleEnvironmentConfig.useLocalhost("192.168.113.37") -``` - -切换后,HTTP API 会立即走新端点;如果 IM 已登录,SDK 会自动重新连接。 - -sample-app 里也提供了一个“环境设置”页面,可以直接在外网和本地联调之间切换。 - ---- - ## sdk-core ### SDKConfig @@ -142,7 +134,28 @@ sample-app 里也提供了一个“环境设置”页面,可以直接在外网 ### TokenStore -基于 `EncryptedSharedPreferences` 持久化存储。 +基于统一的 `SecureStore` 持久化:密钥由 Android Keystore 生成且不可导出, +值使用 AES-256-GCM 加密并通过 namespace/key 关联数据认证。 + +## Sample Release 签名 + +示例应用的 Release 签名只从用户级 `~/.gradle/gradle.properties` 读取,仓库不保存 +密钥库路径或凭据。需要配置的属性名为: + +- `YIWANGXIN_STORE_FILE` +- `YIWANGXIN_STORE_PASSWORD` +- `YIWANGXIN_ALIAS` +- `YIWANGXIN_KEY_PASSWORD` + +缺少属性或密钥库文件不可读时,Release 构建会在签名前失败;Debug 使用 Android +默认调试签名,不读取生产签名材料。 + +Sample 还要求把租户平台签发且包名匹配的文件放到 +`sample-app/src/main/assets/config/config.xuqmconfig`。该文件被 Git 忽略;Debug 与 +Release 构建都会在文件缺失时给出明确错误,禁止提交、伪造或手工修改配置。 + +运行 Sample 仪器集成测试时,测试密码通过 +`-e XUQM_TEST_PASSWORD ` 注入,禁止写入源码或 Gradle 配置。 ```kotlin // 存储 @@ -155,31 +168,28 @@ val token = XuqmSDK.tokenStore.getToken() XuqmSDK.tokenStore.clear() ``` -### ApiClient +### 网络边界 -基于 OkHttp 5 + Retrofit 3,自动附加 Bearer Token,统一解析 `ApiResponse`。 - -```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 - - - -``` +`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 内 `` 和 `` 均已内置 ### 下载拦截 -注入的 JS 自动拦截以下两种场景,下载完成后调用 `FileSDK.openFile()` 打开文件: +注入的 JS 自动拦截以下两种场景,下载完成后调用 `PlatformFileSDK.openFile()` 打开文件: - 带 `download` 属性的 `` 标签,或链接以可下载扩展名(`.pdf`、`.zip`、`.docx` 等)结尾 - Blob URL(自动转 base64 后传给 native 处理) diff --git a/build.gradle.kts b/build.gradle.kts index 9fbb479..d6df70a 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -14,3 +14,168 @@ version = providers.gradleProperty("PUBLISH_VERSION").getOrElse("0.1.0-SNAPSHOT" ext["nexusUrl"] = "https://nexus.xuqinmin.com/repository/android-hosted/" ext["nexusUser"] = providers.gradleProperty("NEXUS_USER").getOrElse("") ext["nexusPassword"] = providers.gradleProperty("NEXUS_PASSWORD").getOrElse("") + +val verifyNoSensitiveLogging by tasks.registering { + group = "verification" + description = "阻止日志、标准输出和异常文案包含凭据或用户标识实际值。" + doLast { + val sensitiveIdentifiers = Regex( + """(?i)\b(accessToken|token|userSig|pushToken|password|apiKey|signingKey|""" + + """userId|groupId|targetId|messageId|requestId|toId|fromId|phone|email|regId)\b""", + ) + val sensitiveInterpolation = Regex( + """(?i)\$(?:\{[^}]*\b(accessToken|token|userSig|pushToken|password|apiKey|""" + + """signingKey|userId|groupId|targetId|messageId|requestId|toId|fromId|""" + + """phone|email|regId)\b[^}]*}|(accessToken|token|userSig|pushToken|password|""" + + """apiKey|signingKey|userId|groupId|targetId|messageId|requestId|toId|""" + + """fromId|phone|email|regId)\b)""", + ) + val sinkStart = Regex( + """(?:(?:android\.util\.)?Log\.[vdiew]|println|System\.out\.print(?:ln)?|""" + + """[A-Za-z_][A-Za-z0-9_]*Exception|error)""" + + """\s*\(""", + ) + + fun invocationAt(source: String, start: Int): String { + val open = source.indexOf('(', start) + if (open < 0) return source.substring(start) + var depth = 0 + var quote: Char? = null + var escaped = false + for (index in open until source.length) { + val char = source[index] + if (quote != null) { + if (escaped) { + escaped = false + } else if (char == '\\') { + escaped = true + } else if (char == quote) { + quote = null + } + continue + } + if (char == '"' || char == '\'') { + quote = char + continue + } + if (char == '(') depth++ + if (char == ')' && --depth == 0) return source.substring(start, index + 1) + } + return source.substring(start) + } + + fun withoutStringLiterals(value: String): String { + val result = StringBuilder(value.length) + var quote: Char? = null + var escaped = false + value.forEach { char -> + if (quote != null) { + if (escaped) { + escaped = false + } else if (char == '\\') { + escaped = true + } else if (char == quote) { + quote = null + } + result.append(' ') + } else if (char == '"' || char == '\'') { + quote = char + result.append(' ') + } else { + result.append(char) + } + } + return result.toString() + } + + val violations = mutableListOf() + 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) + } +} diff --git a/docs/IMPLEMENTATION_HANDOFF.md b/docs/IMPLEMENTATION_HANDOFF.md index c519637..deda3b5 100644 --- a/docs/IMPLEMENTATION_HANDOFF.md +++ b/docs/IMPLEMENTATION_HANDOFF.md @@ -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 中的值保持不变。 diff --git a/docs/sdk-update-CHANGELOG.md b/docs/sdk-update-CHANGELOG.md index 9de0783..4c9129e 100644 --- a/docs/sdk-update-CHANGELOG.md +++ b/docs/sdk-update-CHANGELOG.md @@ -13,15 +13,17 @@ - `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。 --- ## 1.0.9 -- 历史版本。 \ No newline at end of file +- 历史版本。 diff --git a/docs/架构总览.md b/docs/架构总览.md index 5ff85c3..5da4590 100644 --- a/docs/架构总览.md +++ b/docs/架构总览.md @@ -3,14 +3,16 @@ ## 整体架构 ``` -宿主 App(YwxMobileApp 等) - └── 初始化(方式A: ContentProvider 自动 / 方式B: Application.onCreate 手动) +宿主 App + └── 放置租户平台签发的 assets/config/config.xuqmconfig ↓ - sdk-core(核心层) - ├── XuqmSDK.initialize() → 拉取 /api/sdk/config + sdk-core(核心层;单独集成时零初始化、零权限、零 Provider) + ├── XuqmMergedProvider 实现(仅由扩展 Manifest 注册) + ├── 初始化状态:IDLE / INITIALIZING / READY / DEGRADED / FAILED + ├── 七天加密 LKG(仅平台暂不可用时降级使用) ├── XuqmSDK.setUserInfo() → 通知所有子模块(反射 / 接口回调) - ├── ApiClient(OkHttp 5 + Retrofit 3) - ├── TokenStore(EncryptedSharedPreferences) + ├── ApiClient(OkHttp 4.12 + Retrofit 3) + ├── TokenStore(Android Keystore + AES-256-GCM) ├── FileSDK(上传 / 下载 / 打开文件) ├── ServiceEndpointRegistry(各服务地址注册表) └── bugCollectApiUrl / bugCollectEnabled(从平台配置获取) @@ -51,30 +53,18 @@ XuqmSDK.setUserInfo(null) ## 初始化流程 -### 方式 A(ContentProvider 自动) - ``` App 启动 ↓ -XuqmInitializerProvider.onCreate() - ↓ 读取 assets/xuqm/config.xuqm -ConfigFileReader.read(context) - ↓ 解析 appKey + serverUrl -XuqmSDK.initialize(context, appKey, serverUrl) +任一扩展合并的唯一 XuqmMergedProvider.onCreate() + ↓ 主线程仅读取 assets/config/config.xuqmconfig +IO 线程验证 Ed25519 签名 → 解密 canonical JSON → 校验包身份 ↓ HTTP GET /api/sdk/config → 返回 { apiUrl, imWsUrl, fileServiceUrl, ... } -``` - -### 方式 B(手动) - -``` -Application.onCreate() ↓ -XuqmSDK.initialize(context, appKey = "xxx") - ↓ HTTP GET -/api/sdk/config → 解析平台配置 - ↓ -各子模块就绪(等待 setUserInfo 触发具体功能) +成功:READY 并原子保存 LKG +失败且七天 LKG 有效:DEGRADED +失败且无有效 LKG:FAILED;扩展停用,宿主核心业务继续 ``` ## 技术栈 @@ -86,7 +76,7 @@ XuqmSDK.initialize(context, appKey = "xxx") | minSdk | 24 | | compileSdk | 36 | | Java | 21 | -| OkHttp | 5.x | +| OkHttp | 4.12.x(与 React Native 宿主二进制兼容) | | Retrofit | 3.x | | Coroutines | 1.x | diff --git a/gradle.properties b/gradle.properties index 214a28e..8e2cae7 100644 --- a/gradle.properties +++ b/gradle.properties @@ -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 diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index e352dd5..49857eb 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -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" } diff --git a/gradle/publish.gradle b/gradle/publish.gradle index 1219de9..44446d7 100644 --- a/gradle/publish.gradle +++ b/gradle/publish.gradle @@ -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'] } } } diff --git a/sample-app/build.gradle.kts b/sample-app/build.gradle.kts index 41b84f2..218ed24 100644 --- a/sample-app/build.gradle.kts +++ b/sample-app/build.gradle.kts @@ -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")) diff --git a/sample-app/src/androidTest/java/com/xuqm/sdk/sample/CrossDeviceTest.kt b/sample-app/src/androidTest/java/com/xuqm/sdk/sample/CrossDeviceTest.kt index 3a227ea..a367a67 100644 --- a/sample-app/src/androidTest/java/com/xuqm/sdk/sample/CrossDeviceTest.kt +++ b/sample-app/src/androidTest/java/com/xuqm/sdk/sample/CrossDeviceTest.kt @@ -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() } } diff --git a/sample-app/src/androidTest/java/com/xuqm/sdk/sample/IntegrationTestCredentials.kt b/sample-app/src/androidTest/java/com/xuqm/sdk/sample/IntegrationTestCredentials.kt new file mode 100644 index 0000000..9e58774 --- /dev/null +++ b/sample-app/src/androidTest/java/com/xuqm/sdk/sample/IntegrationTestCredentials.kt @@ -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") +} diff --git a/sample-app/src/androidTest/java/com/xuqm/sdk/sample/NetworkResilienceTest.kt b/sample-app/src/androidTest/java/com/xuqm/sdk/sample/NetworkResilienceTest.kt index 3d5b74d..5e3f5de 100644 --- a/sample-app/src/androidTest/java/com/xuqm/sdk/sample/NetworkResilienceTest.kt +++ b/sample-app/src/androidTest/java/com/xuqm/sdk/sample/NetworkResilienceTest.kt @@ -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) { diff --git a/sample-app/src/androidTest/java/com/xuqm/sdk/sample/PushSdkTest.kt b/sample-app/src/androidTest/java/com/xuqm/sdk/sample/PushSdkTest.kt index f683889..6dc1ebf 100644 --- a/sample-app/src/androidTest/java/com/xuqm/sdk/sample/PushSdkTest.kt +++ b/sample-app/src/androidTest/java/com/xuqm/sdk/sample/PushSdkTest.kt @@ -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)) } @@ -117,4 +115,4 @@ class PushSdkTest { Thread.sleep(500) PushSDK.setOfflinePushEnabled(true) } -} \ No newline at end of file +} diff --git a/sample-app/src/androidTest/java/com/xuqm/sdk/sample/SdkIntegrationTest.kt b/sample-app/src/androidTest/java/com/xuqm/sdk/sample/SdkIntegrationTest.kt index cf79a2e..2360721 100644 --- a/sample-app/src/androidTest/java/com/xuqm/sdk/sample/SdkIntegrationTest.kt +++ b/sample-app/src/androidTest/java/com/xuqm/sdk/sample/SdkIntegrationTest.kt @@ -5,10 +5,8 @@ import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.platform.app.InstrumentationRegistry import com.xuqm.sdk.XuqmSDK import com.xuqm.sdk.XuqmUserInfo -import com.xuqm.sdk.core.LogLevel import com.xuqm.sdk.im.ImSDK import com.xuqm.sdk.im.model.ImConnectionState -import com.xuqm.sdk.sample.config.SampleEnvironmentConfig import com.xuqm.sdk.sample.data.api.DEMO_APP_ID import com.xuqm.sdk.sample.data.api.DemoApiFactory import com.xuqm.sdk.sample.data.api.LoginRequest @@ -35,7 +33,7 @@ import java.io.File /** * TC-05 / TC-07 / TC-08 / TC-11~15 集成测试 * 运行环境: external (https://dev.xuqinmin.com) - * 测试账号: user_a / user_b (password: 123456, appKey: ak_demo_chat) + * 测试账号与密码由运行环境注入,源码不保存凭据。 * * 设计原则: * - tc05/tc11a/tc11b/tc14/tc99 使用 @BeforeClass 中创建的新鲜 GROUP(testGroupId), @@ -51,7 +49,6 @@ class SdkIntegrationTest { companion object { private const val USER_A = "user_a" private const val USER_B = "user_b" - private const val PASSWORD = "123456" private const val BASE_URL = "https://dev.xuqinmin.com/" private const val CONNECT_TIMEOUT_MS = 20_000L @@ -64,21 +61,19 @@ class SdkIntegrationTest { @JvmStatic fun initSdk() { appCtx = InstrumentationRegistry.getInstrumentation().targetContext - XuqmSDK.initialize(appCtx, DEMO_APP_ID, LogLevel.DEBUG) - // logout BEFORE useExternal: configureServiceEndpoints re-triggers onSdkLogin - // with sample app's cached testuser1 session if loginSession is not null. + runBlocking { XuqmSDK.awaitInitialization() } XuqmSDK.setUserInfo(null) Thread.sleep(1_500) // let any in-flight 1s reconnect timers fire and be blocked XuqmSDK.setUserInfo(null) // second safety net - SampleEnvironmentConfig.useExternal() - Thread.sleep(500) // Create a fresh GROUP for this test run. A new group each run avoids any // stale conversation state from previous runs (e.g., a prior deleteConversation // on a SINGLE conversation permanently removing it from the server). runBlocking { val api = DemoApiFactory.create(BASE_URL) { null } - val resA = api.login(LoginRequest(DEMO_APP_ID, USER_A, PASSWORD)) + val resA = api.login( + LoginRequest(DEMO_APP_ID, USER_A, IntegrationTestCredentials.password()), + ) resA.data?.imToken?.let { tokenA -> XuqmSDK.setUserInfo(XuqmUserInfo(userId = USER_A, userSig = tokenA)) withTimeoutOrNull(CONNECT_TIMEOUT_MS) { @@ -100,7 +95,9 @@ class SdkIntegrationTest { private fun buildDemoApi() = DemoApiFactory.create(BASE_URL) { null } private suspend fun loginAs(userId: String): String { - val res = buildDemoApi().login(LoginRequest(DEMO_APP_ID, userId, PASSWORD)) + val res = buildDemoApi().login( + LoginRequest(DEMO_APP_ID, userId, IntegrationTestCredentials.password()), + ) val data = requireNotNull(res.data) { "Demo login failed for $userId: ${res.message}" } XuqmSDK.setUserInfo(XuqmUserInfo(userId = userId, userSig = data.imToken)) return data.imToken @@ -215,7 +212,9 @@ class SdkIntegrationTest { assertTrue("@Before 后应处于 Connected 状态", stateBefore is ImConnectionState.Connected) // 不登出,直接重新获取 token 并调用 login() - val newToken = buildDemoApi().login(LoginRequest(DEMO_APP_ID, USER_A, PASSWORD)).data?.imToken + val newToken = buildDemoApi().login( + LoginRequest(DEMO_APP_ID, USER_A, IntegrationTestCredentials.password()), + ).data?.imToken requireNotNull(newToken) { "重登录 API 失败" } XuqmSDK.setUserInfo(XuqmUserInfo(userId = USER_A, userSig = newToken)) diff --git a/sample-app/src/main/java/com/xuqm/sdk/sample/XuqmSampleApp.kt b/sample-app/src/main/java/com/xuqm/sdk/sample/XuqmSampleApp.kt index 2e2cb1b..86acd00 100644 --- a/sample-app/src/main/java/com/xuqm/sdk/sample/XuqmSampleApp.kt +++ b/sample-app/src/main/java/com/xuqm/sdk/sample/XuqmSampleApp.kt @@ -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 { - AppDependencies.authRepository.restoreSdkSession() + XuqmSDK.afterInit { + applicationScope.launch { + AppDependencies.authRepository.restoreSdkSession() + } } } } diff --git a/sample-app/src/main/java/com/xuqm/sdk/sample/config/SampleEnvironment.kt b/sample-app/src/main/java/com/xuqm/sdk/sample/config/SampleEnvironment.kt deleted file mode 100644 index 433a7a8..0000000 --- a/sample-app/src/main/java/com/xuqm/sdk/sample/config/SampleEnvironment.kt +++ /dev/null @@ -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) - } -} diff --git a/sample-app/src/main/java/com/xuqm/sdk/sample/data/repo/AuthRepository.kt b/sample-app/src/main/java/com/xuqm/sdk/sample/data/repo/AuthRepository.kt index 2c31198..044302b 100644 --- a/sample-app/src/main/java/com/xuqm/sdk/sample/data/repo/AuthRepository.kt +++ b/sample-app/src/main/java/com/xuqm/sdk/sample/data/repo/AuthRepository.kt @@ -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 = @@ -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 Result.mapFailureMessage(transform: (Throwable) -> String): Result = diff --git a/sample-app/src/main/java/com/xuqm/sdk/sample/data/repo/EnvironmentRepository.kt b/sample-app/src/main/java/com/xuqm/sdk/sample/data/repo/EnvironmentRepository.kt deleted file mode 100644 index 61297f8..0000000 --- a/sample-app/src/main/java/com/xuqm/sdk/sample/data/repo/EnvironmentRepository.kt +++ /dev/null @@ -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" - } -} diff --git a/sample-app/src/main/java/com/xuqm/sdk/sample/di/AppDependencies.kt b/sample-app/src/main/java/com/xuqm/sdk/sample/di/AppDependencies.kt index fdcea72..2954112 100644 --- a/sample-app/src/main/java/com/xuqm/sdk/sample/di/AppDependencies.kt +++ b/sample-app/src/main/java/com/xuqm/sdk/sample/di/AppDependencies.kt @@ -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) diff --git a/sample-app/src/main/java/com/xuqm/sdk/sample/navigation/AppNavGraph.kt b/sample-app/src/main/java/com/xuqm/sdk/sample/navigation/AppNavGraph.kt index 1c9e550..a98bcef 100644 --- a/sample-app/src/main/java/com/xuqm/sdk/sample/navigation/AppNavGraph.kt +++ b/sample-app/src/main/java/com/xuqm/sdk/sample/navigation/AppNavGraph.kt @@ -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() }, - ) - } } } diff --git a/sample-app/src/main/java/com/xuqm/sdk/sample/ui/auth/LoginScreen.kt b/sample-app/src/main/java/com/xuqm/sdk/sample/ui/auth/LoginScreen.kt index 5981cd9..dc390eb 100644 --- a/sample-app/src/main/java/com/xuqm/sdk/sample/ui/auth/LoginScreen.kt +++ b/sample-app/src/main/java/com/xuqm/sdk/sample/ui/auth/LoginScreen.kt @@ -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") }) { diff --git a/sample-app/src/main/java/com/xuqm/sdk/sample/ui/chat/ChatScreen.kt b/sample-app/src/main/java/com/xuqm/sdk/sample/ui/chat/ChatScreen.kt index 772b122..81a0116 100644 --- a/sample-app/src/main/java/com/xuqm/sdk/sample/ui/chat/ChatScreen.kt +++ b/sample-app/src/main/java/com/xuqm/sdk/sample/ui/chat/ChatScreen.kt @@ -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, ) } diff --git a/sample-app/src/main/java/com/xuqm/sdk/sample/ui/chat/ChatViewModel.kt b/sample-app/src/main/java/com/xuqm/sdk/sample/ui/chat/ChatViewModel.kt index 01a35f3..f0d9029 100644 --- a/sample-app/src/main/java/com/xuqm/sdk/sample/ui/chat/ChatViewModel.kt +++ b/sample-app/src/main/java/com/xuqm/sdk/sample/ui/chat/ChatViewModel.kt @@ -182,11 +182,11 @@ class ChatViewModel : ViewModel() { private suspend fun fetchHistory(page: Int): List { 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) } diff --git a/sample-app/src/main/java/com/xuqm/sdk/sample/ui/contact/ContactScreen.kt b/sample-app/src/main/java/com/xuqm/sdk/sample/ui/contact/ContactScreen.kt index 9c395c3..6d997a7 100644 --- a/sample-app/src/main/java/com/xuqm/sdk/sample/ui/contact/ContactScreen.kt +++ b/sample-app/src/main/java/com/xuqm/sdk/sample/ui/contact/ContactScreen.kt @@ -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") } } } diff --git a/sample-app/src/main/java/com/xuqm/sdk/sample/ui/conversation/ConversationViewModel.kt b/sample-app/src/main/java/com/xuqm/sdk/sample/ui/conversation/ConversationViewModel.kt index 12ebdd0..466f4c3 100644 --- a/sample-app/src/main/java/com/xuqm/sdk/sample/ui/conversation/ConversationViewModel.kt +++ b/sample-app/src/main/java/com/xuqm/sdk/sample/ui/conversation/ConversationViewModel.kt @@ -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() } diff --git a/sample-app/src/main/java/com/xuqm/sdk/sample/ui/environment/EnvironmentScreen.kt b/sample-app/src/main/java/com/xuqm/sdk/sample/ui/environment/EnvironmentScreen.kt deleted file mode 100644 index 8ad8e92..0000000 --- a/sample-app/src/main/java/com/xuqm/sdk/sample/ui/environment/EnvironmentScreen.kt +++ /dev/null @@ -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 = _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(null) } - var snapshot by remember { mutableStateOf(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, - ) - } - } -} diff --git a/sample-app/src/main/java/com/xuqm/sdk/sample/ui/group/GroupViewModel.kt b/sample-app/src/main/java/com/xuqm/sdk/sample/ui/group/GroupViewModel.kt index 5913c15..c7cc137 100644 --- a/sample-app/src/main/java/com/xuqm/sdk/sample/ui/group/GroupViewModel.kt +++ b/sample-app/src/main/java/com/xuqm/sdk/sample/ui/group/GroupViewModel.kt @@ -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 { diff --git a/sample-app/src/main/java/com/xuqm/sdk/sample/ui/main/MainScreen.kt b/sample-app/src/main/java/com/xuqm/sdk/sample/ui/main/MainScreen.kt index eb3e634..79d1749 100644 --- a/sample-app/src/main/java/com/xuqm/sdk/sample/ui/main/MainScreen.kt +++ b/sample-app/src/main/java/com/xuqm/sdk/sample/ui/main/MainScreen.kt @@ -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) } diff --git a/sample-app/src/main/java/com/xuqm/sdk/sample/ui/profile/ProfileScreen.kt b/sample-app/src/main/java/com/xuqm/sdk/sample/ui/profile/ProfileScreen.kt index 660d57e..48aeebe 100644 --- a/sample-app/src/main/java/com/xuqm/sdk/sample/ui/profile/ProfileScreen.kt +++ b/sample-app/src/main/java/com/xuqm/sdk/sample/ui/profile/ProfileScreen.kt @@ -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(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(), diff --git a/sample-app/src/main/java/com/xuqm/sdk/sample/ui/webview/WebViewScreen.kt b/sample-app/src/main/java/com/xuqm/sdk/sample/ui/webview/WebViewScreen.kt index 423a877..306d858 100644 --- a/sample-app/src/main/java/com/xuqm/sdk/sample/ui/webview/WebViewScreen.kt +++ b/sample-app/src/main/java/com/xuqm/sdk/sample/ui/webview/WebViewScreen.kt @@ -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") } diff --git a/sdk-bugcollect-plugin/build.gradle.kts b/sdk-bugcollect-plugin/build.gradle.kts index 1c95478..775559f 100644 --- a/sdk-bugcollect-plugin/build.gradle.kts +++ b/sdk-bugcollect-plugin/build.gradle.kts @@ -36,6 +36,7 @@ gradlePlugin { dependencies { compileOnly("com.android.tools.build:gradle:9.1.0") + testImplementation("junit:junit:4.13.2") } publishing { diff --git a/sdk-bugcollect-plugin/src/main/kotlin/com/xuqm/sdk/bugcollect/gradle/XuqmBugCollectPlugin.kt b/sdk-bugcollect-plugin/src/main/kotlin/com/xuqm/sdk/bugcollect/gradle/XuqmBugCollectPlugin.kt index d29d28e..b9b0ef0 100644 --- a/sdk-bugcollect-plugin/src/main/kotlin/com/xuqm/sdk/bugcollect/gradle/XuqmBugCollectPlugin.kt +++ b/sdk-bugcollect-plugin/src/main/kotlin/com/xuqm/sdk/bugcollect/gradle/XuqmBugCollectPlugin.kt @@ -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 { - - 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 { - 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 { - 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 + check(target.pluginManager.hasPlugin("com.xuqm.common")) { + "Apply com.xuqm.common before com.xuqm.bugcollect" + } + target.pluginManager.withPlugin("com.android.application") { + configureAndroidApplication(target) + } + } - androidComponents.onVariants { variant -> - // Always inject buildId — the AAR manifest placeholder must be resolved for every variant. - val buildId = SimpleDateFormat("yyyyMMddHHmmss", Locale.US).format(Date()) - variant.manifestPlaceholders.put("xuqmBuildId", buildId) - - // Mapping upload only makes sense for minified (release) builds. - val buildType = androidDsl.buildTypes.findByName(variant.buildType ?: "") ?: return@onVariants + 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 configFile = findVariantConfigFile(target.projectDir, variant.flavorName, variant.buildType) - val (fileAppKey, filePlatformUrl) = when { - configFile != null -> readEncryptedConfigFile(configFile) - else -> readConfigFile(target.rootDir) - } - - val productFlavor = androidDsl.productFlavors.findByName(variant.flavorName ?: "") - 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 -> + 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) } } } } diff --git a/sdk-bugcollect-plugin/src/main/kotlin/com/xuqm/sdk/bugcollect/gradle/XuqmUploadMappingTask.kt b/sdk-bugcollect-plugin/src/main/kotlin/com/xuqm/sdk/bugcollect/gradle/XuqmUploadMappingTask.kt index 9ed8f9f..bbf4448 100644 --- a/sdk-bugcollect-plugin/src/main/kotlin/com/xuqm/sdk/bugcollect/gradle/XuqmUploadMappingTask.kt +++ b/sdk-bugcollect-plugin/src/main/kotlin/com/xuqm/sdk/bugcollect/gradle/XuqmUploadMappingTask.kt @@ -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 @@ -17,34 +20,57 @@ abstract class XuqmUploadMappingTask : DefaultTask() { @get:Input abstract val appVersion: Property @get:Input @get:Optional abstract val buildId: Property @get:Input abstract val platform: Property + @get:Input abstract val emergencyDisabled: Property + /** 认证令牌属于构建机秘密,不参与 Gradle 输入快照,也不得写入日志。 */ + @get:Internal abstract val apiToken: Property @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() + } } } diff --git a/sdk-bugcollect-plugin/src/test/kotlin/com/xuqm/sdk/bugcollect/gradle/NativeArtifactUploaderTest.kt b/sdk-bugcollect-plugin/src/test/kotlin/com/xuqm/sdk/bugcollect/gradle/NativeArtifactUploaderTest.kt new file mode 100644 index 0000000..ee1edf6 --- /dev/null +++ b/sdk-bugcollect-plugin/src/test/kotlin/com/xuqm/sdk/bugcollect/gradle/NativeArtifactUploaderTest.kt @@ -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() + val requestBody = AtomicReference() + val authorization = AtomicReference() + 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() + } + } +} diff --git a/sdk-bugcollect/README.md b/sdk-bugcollect/README.md index d926336..a76f879 100644 --- a/sdk-bugcollect/README.md +++ b/sdk-bugcollect/README.md @@ -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` 应急构建不上传且不要求令牌。 diff --git a/sdk-bugcollect/build.gradle.kts b/sdk-bugcollect/build.gradle.kts index 99bf336..ec7e98a 100644 --- a/sdk-bugcollect/build.gradle.kts +++ b/sdk-bugcollect/build.gradle.kts @@ -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) } diff --git a/sdk-bugcollect/consumer-rules.pro b/sdk-bugcollect/consumer-rules.pro index e2ba34b..9430460 100644 --- a/sdk-bugcollect/consumer-rules.pro +++ b/sdk-bugcollect/consumer-rules.pro @@ -3,6 +3,10 @@ public *; static ; } +-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 diff --git a/sdk-bugcollect/src/main/AndroidManifest.xml b/sdk-bugcollect/src/main/AndroidManifest.xml index 39bed9d..4917a74 100644 --- a/sdk-bugcollect/src/main/AndroidManifest.xml +++ b/sdk-bugcollect/src/main/AndroidManifest.xml @@ -1,13 +1,10 @@ - - + diff --git a/sdk-bugcollect/src/main/java/com/xuqm/sdk/bugcollect/BugCollect.kt b/sdk-bugcollect/src/main/java/com/xuqm/sdk/bugcollect/BugCollect.kt index fa3cca2..5af22e5 100644 --- a/sdk-bugcollect/src/main/java/com/xuqm/sdk/bugcollect/BugCollect.kt +++ b/sdk-bugcollect/src/main/java/com/xuqm/sdk/bugcollect/BugCollect.kt @@ -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 = LinkedList() private val breadcrumbLock = Any() - // SDK 未就绪时暂存的错误事件,就绪后自动 flush - private val pendingErrors = LinkedList() - 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 = 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 = 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 = 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) + } + + /** + * 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() + } } - flushPendingErrors() - queue().push(event) + return true } fun captureCrash(error: Throwable, tags: Map = 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 = 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 - CrashCapture.start( - appContext = XuqmSDK.appContext, - appKey = XuqmSDK.appKey, - getUserId = { XuqmSDK.getUserId() ?: XuqmSDK.deviceId }, - ) - flushPendingErrors() - queue().uploadPendingCrashes() - // 平台配置可能在初始化之后才下发,延迟重试 flush - GlobalScope.launch(Dispatchers.IO) { - for (i in 1..10) { - delay(2000) - if (!XuqmSDK.bugCollectApiUrl.isNullOrBlank()) { - flushPendingErrors() - queue().uploadPendingCrashes() - break - } - } + if (!isReady()) return + runCatching { + CrashCapture.start( + appContext = XuqmSDK.appContext, + appKey = XuqmSDK.appKey, + getUserId = { XuqmSDK.getUserId() ?: XuqmSDK.deviceId }, + ) + crashCaptureStarted = true + queue().uploadPendingCrashes() + }.onFailure { + CrashCapture.stop() + crashCaptureStarted = false } } - /** 将 SDK 未就绪时暂存的错误事件 flush 到队列。 - * - * 早于 SDK 初始化完成前调用 captureError 时,事件以 appKey="pending"、userId="unknown" - * 占位存入 pendingErrors。flush 时用真实值替换,避免服务端因找不到应用而丢弃事件。 + /** + * sdk-core 在平台配置或隐私授权变化后调用的唯一状态同步入口。 + * 停用时恢复宿主原有崩溃处理器,并清除所有未上传数据。 */ - private fun flushPendingErrors() { - val events = synchronized(pendingErrorsLock) { - if (pendingErrors.isEmpty()) return - val list = pendingErrors.toList() - pendingErrors.clear() - list - } - val realAppKey = if (XuqmSDK.isInitialized()) XuqmSDK.appKey else return - val realUserId = XuqmSDK.getUserId() ?: XuqmSDK.deviceId - events.forEach { event -> - val fixed = if (event.appKey == "pending") { - event.copy( - appKey = realAppKey, - userId = realUserId, - user = IssueEvent.UserInfo(id = realUserId), - release = appVersion(), - buildId = buildId(), - ) - } else event - queue().push(fixed) + private fun onSdkConfigurationChanged() { + if (isReady()) { + startCrashCapture() + return } + CrashCapture.stop() + crashCaptureStarted = false + synchronized(breadcrumbLock) { breadcrumbBuffer.clear() } + queue?.stopAndClear() + queue = null } - /** 上传上次崩溃落盘的文件(远端配置就绪后由 BugCollectInitProvider 自动调用)。 */ + /** 退出登录后保留匿名诊断,但移除队列中与账号相关的身份字段。 */ + private fun onSdkLogout() { + queue?.clearUserContext() + } + + /** 上传上次崩溃的加密持久化记录。 */ fun uploadPendingCrashes() { - if (isReady()) queue().uploadPendingCrashes() + if (isReady()) runCatching { queue().uploadPendingCrashes() } } fun defineFunnel(id: String, steps: List) { 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) + } + } } diff --git a/sdk-bugcollect/src/main/java/com/xuqm/sdk/bugcollect/CrashCapture.kt b/sdk-bugcollect/src/main/java/com/xuqm/sdk/bugcollect/CrashCapture.kt index 60d081e..ddc9796 100644 --- a/sdk-bugcollect/src/main/java/com/xuqm/sdk/bugcollect/CrashCapture.kt +++ b/sdk-bugcollect/src/main/java/com/xuqm/sdk/bugcollect/CrashCapture.kt @@ -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 } } diff --git a/sdk-bugcollect/src/main/java/com/xuqm/sdk/bugcollect/LogQueue.kt b/sdk-bugcollect/src/main/java/com/xuqm/sdk/bugcollect/LogQueue.kt index 89cc229..cbeec35 100644 --- a/sdk-bugcollect/src/main/java/com/xuqm/sdk/bugcollect/LogQueue.kt +++ b/sdk-bugcollect/src/main/java/com/xuqm/sdk/bugcollect/LogQueue.kt @@ -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() private val pendingEvents = mutableListOf() 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 - val events: List - 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)) - } - } + scope.launch { + flushMutex.withLock { + flushOnce() } } } - private fun loadFromDisk() { - runCatching { - val prefs = appContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) - prefs.getString(KEY_ISSUES, null)?.let { json -> - val arr = JSONArray(json) - 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"), - )) - } + /** 单飞执行;定时器、满批触发和手动触发不能重复上传同一批事件。 */ + private suspend fun flushOnce() { + if (stopped || !XuqmSDK.bugCollectEnabled || !XuqmSDK.privacyConsentGranted) return + val issues: List + val events: List + 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() } - } - 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), - )) - } + 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 } - prefs.edit().remove(KEY_ISSUES).remove(KEY_EVENTS).apply() } } + + 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 { + 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>() {}.type + val loaded = runCatching { + gson.fromJson>(secureStore().getString(KEY_ISSUES) ?: "[]", type) + }.getOrNull().orEmpty() + val minTimestamp = System.currentTimeMillis() - retentionMillis + synchronized(lock) { + 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)) + } + + private fun secureStore() = SecureStore.open(appContext, PREFS_NAME) } diff --git a/sdk-bugcollect/src/main/java/com/xuqm/sdk/bugcollect/internal/BugCollectInitProvider.kt b/sdk-bugcollect/src/main/java/com/xuqm/sdk/bugcollect/internal/BugCollectInitProvider.kt deleted file mode 100644 index b76b8b4..0000000 --- a/sdk-bugcollect/src/main/java/com/xuqm/sdk/bugcollect/internal/BugCollectInitProvider.kt +++ /dev/null @@ -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?, s: String?, sa: Array?, 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?): Int = 0 - override fun update(uri: Uri, values: ContentValues?, selection: String?, selectionArgs: Array?): Int = 0 -} diff --git a/sdk-bugcollect/src/main/java/com/xuqm/sdk/bugcollect/internal/DataSanitizer.kt b/sdk-bugcollect/src/main/java/com/xuqm/sdk/bugcollect/internal/DataSanitizer.kt new file mode 100644 index 0000000..d6ecd0d --- /dev/null +++ b/sdk-bugcollect/src/main/java/com/xuqm/sdk/bugcollect/internal/DataSanitizer.kt @@ -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("(?): Map = + 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 + } +} diff --git a/sdk-bugcollect/src/main/java/com/xuqm/sdk/bugcollect/internal/DeveloperBugCollectPolicy.kt b/sdk-bugcollect/src/main/java/com/xuqm/sdk/bugcollect/internal/DeveloperBugCollectPolicy.kt new file mode 100644 index 0000000..bfb3500 --- /dev/null +++ b/sdk-bugcollect/src/main/java/com/xuqm/sdk/bugcollect/internal/DeveloperBugCollectPolicy.kt @@ -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 +} diff --git a/sdk-bugcollect/src/main/java/com/xuqm/sdk/bugcollect/internal/LogStorage.kt b/sdk-bugcollect/src/main/java/com/xuqm/sdk/bugcollect/internal/LogStorage.kt index 62a4444..f7f3a16 100644 --- a/sdk-bugcollect/src/main/java/com/xuqm/sdk/bugcollect/internal/LogStorage.kt +++ b/sdk-bugcollect/src/main/java/com/xuqm/sdk/bugcollect/internal/LogStorage.kt @@ -1,20 +1,20 @@ package com.xuqm.sdk.bugcollect.internal import android.content.Context -import org.json.JSONObject -import java.io.File +import com.google.gson.Gson +import com.xuqm.sdk.bugcollect.Fingerprint +import com.xuqm.sdk.bugcollect.IssueEvent +import com.xuqm.sdk.storage.SecureStore +import java.util.concurrent.TimeUnit +/** 全局崩溃的加密持久化,仅由未捕获异常处理器与上传队列访问。 */ internal object LogStorage { + private const val PREFS_NAME = "xuqm_bugcollect_crashes" + private const val KEY_PREFIX = "crash_" + private const val MAX_CRASHES = 500 + private val retentionMillis = TimeUnit.DAYS.toMillis(7) + private val gson = Gson() - private const val CRASH_DIR = "xuqm_crashes" - - fun crashDir(context: Context): File = - File(context.filesDir, CRASH_DIR).also { it.mkdirs() } - - /** - * 同步写崩溃文件(在 UncaughtExceptionHandler 中调用)。 - * 不存储上传 URL——URL 在下次启动上传时从 XuqmSDK.bugCollectApiUrl 取得。 - */ fun saveCrash( context: Context, throwable: Throwable, @@ -22,18 +22,84 @@ internal object LogStorage { userId: String?, ) { runCatching { - val dir = crashDir(context) - val file = File(dir, "${System.currentTimeMillis()}.json") - val json = JSONObject().apply { - put("level", "fatal") - put("message", throwable.message ?: throwable.javaClass.name) - put("stack", throwable.stackTraceToString()) - put("exceptionType", throwable.javaClass.simpleName) - put("appKey", appKey) - put("userId", userId ?: JSONObject.NULL) - put("timestamp", System.currentTimeMillis()) - } - file.writeText(json.toString()) + val timestamp = System.currentTimeMillis() + val event = IssueEvent( + level = "fatal", + platform = "android", + fingerprint = Fingerprint.compute( + throwable.javaClass.simpleName, + DataSanitizer.sanitizeText(throwable.message ?: throwable.javaClass.name), + DataSanitizer.sanitizeText(throwable.stackTraceToString()), + ), + appKey = appKey, + timestamp = timestamp, + release = runCatching { + context.packageManager.getPackageInfo(context.packageName, 0).versionName + }.getOrNull().orEmpty().ifBlank { "unknown" }, + exception = IssueEvent.ExceptionInfo( + type = throwable.javaClass.simpleName, + value = DataSanitizer.sanitizeText(throwable.message ?: throwable.javaClass.name), + stacktrace = DataSanitizer.sanitizeText(throwable.stackTraceToString()), + ), + userId = userId, + user = IssueEvent.UserInfo(id = userId), + ) + val store = store(context) + val crashKeys = store.entries().keys.filter { it.startsWith(KEY_PREFIX) }.sorted() + crashKeys.take((crashKeys.size - MAX_CRASHES + 1).coerceAtLeast(0)) + .forEach(store::remove) + store.putString("$KEY_PREFIX$timestamp", gson.toJson(event), synchronous = true) } } + + fun loadCrashes(context: Context, expectedAppKey: String): List> { + val cutoff = System.currentTimeMillis() - retentionMillis + val store = store(context) + val expired = mutableListOf() + 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) } diff --git a/sdk-bugcollect/src/main/java/com/xuqm/sdk/bugcollect/internal/LogUploader.kt b/sdk-bugcollect/src/main/java/com/xuqm/sdk/bugcollect/internal/LogUploader.kt index 3ffe86f..06b20a4 100644 --- a/sdk-bugcollect/src/main/java/com/xuqm/sdk/bugcollect/internal/LogUploader.kt +++ b/sdk-bugcollect/src/main/java/com/xuqm/sdk/bugcollect/internal/LogUploader.kt @@ -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): 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): 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) { - 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) { - 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 ?: emptyMap())) - } - }) - } - 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 ?: emptyMap())) - } - } - - 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 ?: emptyMap())) - } - 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)) } diff --git a/sdk-bugcollect/src/test/java/com/xuqm/sdk/bugcollect/PublicBridgeApiTest.kt b/sdk-bugcollect/src/test/java/com/xuqm/sdk/bugcollect/PublicBridgeApiTest.kt new file mode 100644 index 0000000..1e4bb6b --- /dev/null +++ b/sdk-bugcollect/src/test/java/com/xuqm/sdk/bugcollect/PublicBridgeApiTest.kt @@ -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) + } +} diff --git a/sdk-bugcollect/src/test/java/com/xuqm/sdk/bugcollect/internal/DataSanitizerTest.kt b/sdk-bugcollect/src/test/java/com/xuqm/sdk/bugcollect/internal/DataSanitizerTest.kt new file mode 100644 index 0000000..8a8bf31 --- /dev/null +++ b/sdk-bugcollect/src/test/java/com/xuqm/sdk/bugcollect/internal/DataSanitizerTest.kt @@ -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")) + } +} diff --git a/sdk-bugcollect/src/test/java/com/xuqm/sdk/bugcollect/internal/DeveloperBugCollectPolicyTest.kt b/sdk-bugcollect/src/test/java/com/xuqm/sdk/bugcollect/internal/DeveloperBugCollectPolicyTest.kt new file mode 100644 index 0000000..2322361 --- /dev/null +++ b/sdk-bugcollect/src/test/java/com/xuqm/sdk/bugcollect/internal/DeveloperBugCollectPolicyTest.kt @@ -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)) + } +} diff --git a/sdk-common-plugin/build.gradle.kts b/sdk-common-plugin/build.gradle.kts new file mode 100644 index 0000000..7f65bf7 --- /dev/null +++ b/sdk-common-plugin/build.gradle.kts @@ -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 ?: "" + } + } + } +} diff --git a/sdk-common-plugin/settings.gradle.kts b/sdk-common-plugin/settings.gradle.kts new file mode 100644 index 0000000..9b6cc6a --- /dev/null +++ b/sdk-common-plugin/settings.gradle.kts @@ -0,0 +1,17 @@ +pluginManagement { + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} + +dependencyResolutionManagement { + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} + +rootProject.name = "sdk-common-plugin" diff --git a/sdk-common-plugin/src/main/kotlin/com/xuqm/sdk/common/gradle/XuqmCommonPlugin.kt b/sdk-common-plugin/src/main/kotlin/com/xuqm/sdk/common/gradle/XuqmCommonPlugin.kt new file mode 100644 index 0000000..baacbc5 --- /dev/null +++ b/sdk-common-plugin/src/main/kotlin/com/xuqm/sdk/common/gradle/XuqmCommonPlugin.kt @@ -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 { + 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 + @get:Input abstract val bugCollectBuildEnabled: Property + @get:Input abstract val bugCollectAutomaticEnabled: Property + @get:OutputFile abstract val outputFile: RegularFileProperty + + @TaskAction + fun generate() { + val file = outputFile.get().asFile + file.parentFile.mkdirs() + file.writeText( + """ + + + + + + + + + + """.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 + @get:Input abstract val packageName: Property + @get:Input abstract val buildId: Property + @get:Input abstract val onlineValidation: Property + + @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 { + 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('"') + } +} diff --git a/sdk-common-plugin/src/main/kotlin/com/xuqm/sdk/common/gradle/XuqmConfigContract.kt b/sdk-common-plugin/src/main/kotlin/com/xuqm/sdk/common/gradle/XuqmConfigContract.kt new file mode 100644 index 0000000..3f42c2f --- /dev/null +++ b/sdk-common-plugin/src/main/kotlin/com/xuqm/sdk/common/gradle/XuqmConfigContract.kt @@ -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() + } +} diff --git a/sdk-common-plugin/src/test/kotlin/com/xuqm/sdk/common/gradle/ReleaseConfigValidatorTest.kt b/sdk-common-plugin/src/test/kotlin/com/xuqm/sdk/common/gradle/ReleaseConfigValidatorTest.kt new file mode 100644 index 0000000..8bd4b48 --- /dev/null +++ b/sdk-common-plugin/src/test/kotlin/com/xuqm/sdk/common/gradle/ReleaseConfigValidatorTest.kt @@ -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"}}""") + } + } +} diff --git a/sdk-common-plugin/src/test/kotlin/com/xuqm/sdk/common/gradle/XuqmConfigLocationTest.kt b/sdk-common-plugin/src/test/kotlin/com/xuqm/sdk/common/gradle/XuqmConfigLocationTest.kt new file mode 100644 index 0000000..198705d --- /dev/null +++ b/sdk-common-plugin/src/test/kotlin/com/xuqm/sdk/common/gradle/XuqmConfigLocationTest.kt @@ -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() + } + } +} diff --git a/sdk-core/README.md b/sdk-core/README.md index b14fe20..bb9557c 100644 --- a/sdk-core/README.md +++ b/sdk-core/README.md @@ -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`;没有可用缓存时扩展能力返回结构化错误,宿主核心业务不受影响。 diff --git a/sdk-core/build.gradle.kts b/sdk-core/build.gradle.kts index 552836e..8e83462 100644 --- a/sdk-core/build.gradle.kts +++ b/sdk-core/build.gradle.kts @@ -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) diff --git a/sdk-core/consumer-rules.pro b/sdk-core/consumer-rules.pro index 9e12c10..0f84539 100644 --- a/sdk-core/consumer-rules.pro +++ b/sdk-core/consumer-rules.pro @@ -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.** { diff --git a/sdk-core/src/main/AndroidManifest.xml b/sdk-core/src/main/AndroidManifest.xml index 4a571f7..cc947c5 100644 --- a/sdk-core/src/main/AndroidManifest.xml +++ b/sdk-core/src/main/AndroidManifest.xml @@ -1,26 +1 @@ - - - - - - - - - - - - + diff --git a/sdk-core/src/main/java/com/xuqm/sdk/XuqmInitialization.kt b/sdk-core/src/main/java/com/xuqm/sdk/XuqmInitialization.kt new file mode 100644 index 0000000..82afba8 --- /dev/null +++ b/sdk-core/src/main/java/com/xuqm/sdk/XuqmInitialization.kt @@ -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, +) diff --git a/sdk-core/src/main/java/com/xuqm/sdk/XuqmSDK.kt b/sdk-core/src/main/java/com/xuqm/sdk/XuqmSDK.kt index 1945e2b..396169e 100644 --- a/sdk-core/src/main/java/com/xuqm/sdk/XuqmSDK.kt +++ b/sdk-core/src/main/java/com/xuqm/sdk/XuqmSDK.kt @@ -11,23 +11,27 @@ import com.google.gson.Gson import com.xuqm.sdk.internal.ConfigFile import com.xuqm.sdk.internal.ConfigFileCrypto import com.xuqm.sdk.internal.ConfigFileReader +import com.xuqm.sdk.internal.ConfigCache +import com.xuqm.sdk.internal.PlatformConfigCache +import com.xuqm.sdk.internal.BugCollectRuntimePolicy import com.xuqm.sdk.network.ApiClient import com.xuqm.sdk.network.SdkPlatformConfig import com.xuqm.sdk.network.SdkPlatformConfigApi import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext +import kotlinx.coroutines.delay +import kotlin.math.pow +import kotlin.random.Random +import java.util.concurrent.atomic.AtomicLong object XuqmSDK { private const val TAG = "XuqmSDK" - /** 内置默认公有平台地址。使用私有化部署时必须显式传入 platformUrl,不会自动降级到此地址。 */ - const val DEFAULT_PLATFORM_URL = "https://www.51szyx.com/" - lateinit var config: SDKConfig private set @@ -42,7 +46,13 @@ object XuqmSDK { private var initialized = false @Volatile private var initializedAppKey: String? = null - @Volatile private var resolvedPlatformUrl: String = DEFAULT_PLATFORM_URL + @Volatile private var resolvedPlatformUrl: String = "" + + @Volatile + private var initializationStatusValue = XuqmInitializationStatus() + + /** 当前初始化状态的不可变快照。 */ + val initializationStatus: XuqmInitializationStatus get() = initializationStatusValue /** 初始化时使用的平台地址(同步可用,远端配置到达前即有值)。 */ val platformUrl: String get() = resolvedPlatformUrl @@ -53,6 +63,7 @@ object XuqmSDK { @Volatile private var loginSession: XuqmLoginSession? = null @Volatile private var userInfoValue: XuqmUserInfo? = null + private val userStateGeneration = AtomicLong(0L) @Volatile var platformConfig: SdkPlatformConfig? = null private set @@ -64,11 +75,21 @@ object XuqmSDK { @Volatile var bugCollectEnabled: Boolean = false private set + /** 构建时写入且运行期间不可变;应急包从进程启动起就禁止采集。 */ + @Volatile private var bugCollectBuildEnabled: Boolean = false + /** Debug 自动上报固定关闭;显式开发测试不受此自动门禁影响。 */ + @Volatile private var bugCollectAutomaticEnabled: Boolean = false + + /** 宿主已取得用户隐私授权时为 true;默认不采集任何 BugCollect 数据。 */ + @Volatile var privacyConsentGranted: Boolean = false + private set + /** 请求签名密钥,从配置文件读取。 */ @Volatile var signingKey: String? = null private set private val pendingInitCallbacks = mutableListOf<() -> Unit>() + private val pendingReadyCallbacks = mutableListOf<() -> Unit>() /** * 当前远程配置拉取任务。失败时 completeExceptionally,[awaitInitialization] 会重新抛出。 @@ -78,109 +99,67 @@ object XuqmSDK { // ── 初始化 ───────────────────────────────────────────────────────────────── - /** - * 方式 A:配置文件自动初始化(推荐)。 - * - * 将 config.xuqm 放入 `src/main/assets/xuqm/`,SDK 在模块加载时自动读取并初始化。 - * 配置文件包含 `appKey` 和可选的 `serverUrl`(私有化部署平台地址)。 - * App 无需调用任何初始化代码。 - */ - /** - * 仅读取 config 文件中的 appKey,不做任何初始化。 - * 用于 ContentProvider 等需要快速获取 appKey 的场景。 - */ - fun readAppKey(context: Context): String? { - return runCatching { ConfigFileReader.read(context.applicationContext)?.appKey }.getOrNull() - } - - fun autoInitialize(context: Context, logLevel: LogLevel = LogLevel.WARN) { - val configFile = ConfigFileReader.read(context) - ?: throw IllegalStateException( - "No config file found in assets/xuqm/. " + - "Download config.xuqm from the tenant platform and place it in src/main/assets/xuqm/." - ) - val configPackageName = configFile.packageName - if (!configPackageName.isNullOrBlank() && configPackageName != context.packageName) { - throw IllegalStateException( - "Config package name mismatch: config=$configPackageName, local=${context.packageName}. " + - "Please download the correct config file for this app." - ) - } - signingKey = configFile.signingKey - initialize(context, configFile.appKey, configFile.serverUrl, logLevel) - } - - /** - * 异步自动初始化 — ContentProvider 调用,不阻塞主线程。 - * 同步阶段只读取 config 文件(~5ms),TokenStore / DeviceId / ApiClient 等重活移到 IO 线程。 - */ - fun autoInitializeAsync(context: Context, logLevel: LogLevel = LogLevel.WARN) { - val ctx = context.applicationContext - // 同步:读取 config(快,<10ms) - val configFile = ConfigFileReader.read(ctx) - if (configFile == null) { - Log.w("XuqmSDK", "autoInitializeAsync: no config file, skipping") - return - } - val configPackageName = configFile.packageName - if (!configPackageName.isNullOrBlank() && configPackageName != ctx.packageName) { - Log.w("XuqmSDK", "autoInitializeAsync: package name mismatch, skipping") - return - } - signingKey = configFile.signingKey - // 异步:TokenStore / DeviceId / ApiClient / 远端配置 - sdkScope.launch { - runCatching { - initialize(ctx, configFile.appKey, configFile.serverUrl, logLevel) - }.onFailure { e -> - Log.w("XuqmSDK", "autoInitializeAsync background init failed: ${e.message}") - } - } - } - /** * 合并 Provider 调用入口 — 主线程已读好原始字节,IO 线程完成解密+初始化。 * - * 与 [autoInitializeAsync] 的区别: - * - 原始字节由调用方传入(主线程只做文件读取,不做 PBKDF2) - * - 在 IO 线程直接解密,不使用磁盘缓存,signingKey 始终完整读取 - * - 复用 sdkScope,不创建冗余 CoroutineScope - * - 初始化完成后自动触发 BugCollect 等扩展 SDK 启动 + * 这是运行时唯一初始化入口。appKey 与平台 serverUrl 均来自平台签发的 + * `src/main/assets/config/config.xuqmconfig`,宿主不能传参覆盖。 + * + * Provider 在主线程只读取原始字节;验签、解密和缓存处理统一在 IO 线程执行, + * 初始化完成后再通知 BugCollect 等扩展 SDK。 */ - fun autoInitializeFromBytes(context: Context, rawBytes: ByteArray, logLevel: LogLevel = LogLevel.WARN) { + @JvmSynthetic + internal fun autoInitializeFromBytes( + context: Context, + rawBytes: ByteArray, + logLevel: LogLevel = LogLevel.WARN, + ) { val ctx = context.applicationContext + if (isInitialized()) return + updateInitializationStatus(XuqmInitializationState.INITIALIZING) sdkScope.launch { runCatching { // 直接解密传入的字节(在 IO 线程执行 PBKDF2,不阻塞主线程,~100ms 可接受) val configFile = runCatching { - val json = ConfigFileCrypto.decrypt(String(rawBytes, Charsets.UTF_8)) - Gson().fromJson(json, ConfigFile::class.java) + 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 字段获取实例 - val instance = clazz.getField("INSTANCE").get(null) - val startMethod = clazz.getMethod("startCrashCapture") - startMethod.invoke(instance) + runCatching { + val instance = clazz.getField("INSTANCE").get(null) + 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 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() 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) { 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, + 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) } - } - - fun useExternalServiceEndpoints() { - configureServiceEndpoints(ServiceEndpoints()) - } - - fun useLocalServiceEndpoints(host: String) { - ServiceEndpointRegistry.useLocalhost(host) - 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 + } + } + } + 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:" */ - 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,31 +589,12 @@ 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 通知(反射)────────────────────────────────────────────────── private fun notifyOptionalModules(methodName: String, session: XuqmLoginSession) { @@ -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) + } } } diff --git a/sdk-core/src/main/java/com/xuqm/sdk/auth/TokenStore.kt b/sdk-core/src/main/java/com/xuqm/sdk/auth/TokenStore.kt index c0a29ba..fd2536f 100644 --- a/sdk-core/src/main/java/com/xuqm/sdk/auth/TokenStore.kt +++ b/sdk-core/src/main/java/com/xuqm/sdk/auth/TokenStore.kt @@ -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) } } diff --git a/sdk-core/src/main/java/com/xuqm/sdk/core/ServiceEndpoints.kt b/sdk-core/src/main/java/com/xuqm/sdk/core/ServiceEndpoints.kt index 7c9a0fa..28c91ed 100644 --- a/sdk-core/src/main/java/com/xuqm/sdk/core/ServiceEndpoints.kt +++ b/sdk-core/src/main/java/com/xuqm/sdk/core/ServiceEndpoints.kt @@ -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()" + } } diff --git a/sdk-core/src/main/java/com/xuqm/sdk/file/FileSDK.kt b/sdk-core/src/main/java/com/xuqm/sdk/file/FileSDK.kt index 587d5d9..bd41b9b 100644 --- a/sdk-core/src/main/java/com/xuqm/sdk/file/FileSDK.kt +++ b/sdk-core/src/main/java/com/xuqm/sdk/file/FileSDK.kt @@ -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( - 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 -} - 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" } diff --git a/sdk-core/src/main/java/com/xuqm/sdk/file/FileTransfer.kt b/sdk-core/src/main/java/com/xuqm/sdk/file/FileTransfer.kt index 44b0880..0bbabef 100644 --- a/sdk-core/src/main/java/com/xuqm/sdk/file/FileTransfer.kt +++ b/sdk-core/src/main/java/com/xuqm/sdk/file/FileTransfer.kt @@ -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 - } } diff --git a/sdk-core/src/main/java/com/xuqm/sdk/internal/BugCollectRuntimePolicy.kt b/sdk-core/src/main/java/com/xuqm/sdk/internal/BugCollectRuntimePolicy.kt new file mode 100644 index 0000000..461d658 --- /dev/null +++ b/sdk-core/src/main/java/com/xuqm/sdk/internal/BugCollectRuntimePolicy.kt @@ -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 +} diff --git a/sdk-core/src/main/java/com/xuqm/sdk/internal/ConfigCache.kt b/sdk-core/src/main/java/com/xuqm/sdk/internal/ConfigCache.kt index be935c2..95e6be3 100644 --- a/sdk-core/src/main/java/com/xuqm/sdk/internal/ConfigCache.kt +++ b/sdk-core/src/main/java/com/xuqm/sdk/internal/ConfigCache.kt @@ -1,58 +1,47 @@ package com.xuqm.sdk.internal import android.content.Context -import android.util.Log -import androidx.security.crypto.EncryptedSharedPreferences -import androidx.security.crypto.MasterKeys +import com.google.gson.Gson +import com.xuqm.sdk.storage.SecureStore import java.security.MessageDigest /** * 持久化缓存解密后的配置文件内容。 * - * 使用 EncryptedSharedPreferences 存储 appKey / serverUrl / packageName, + * 使用 [SecureStore] 存储已完成签名验证的完整引导配置, * 缓存 key 为配置文件原始内容的 SHA-256。文件变更时自动重新解密。 * * 作用:避免每次冷启动都执行 PBKDF2(120,000 次迭代,~100ms)。 */ internal object ConfigCache { - private const val TAG = "XuqmSDK" - private const val PREFS_NAME = "xuqm_config_cache" - private const val KEY_HASH = "config_hash" - private const val KEY_APP_KEY = "app_key" - private const val KEY_SERVER_URL = "server_url" - private const val KEY_PACKAGE_NAME = "package_name" + private const val NAMESPACE = "bootstrap_config_cache" + private const val KEY_ENTRY = "entry" + private val gson = Gson() + private data class CacheEnvelope(val hash: String, val config: ConfigFile) /** 内存级单例缓存,避免同进程内重复解密。 */ @Volatile - private var memoryCached: CachedConfig? = null - - data class CachedConfig( - val appKey: String, - val serverUrl: String?, - val packageName: String?, - ) + private var memoryCached: ConfigFile? = null + @Volatile + private var memoryHash: String? = null /** - * 从缓存读取配置。内存缓存命中直接返回;否则查 EncryptedSharedPreferences。 + * 从缓存读取配置。内存缓存命中直接返回;否则查统一安全存储。 * 文件内容 hash 不匹配时返回 null(需要重新解密)。 */ - fun load(context: Context, rawContent: ByteArray): CachedConfig? { - // 1. 内存缓存 - memoryCached?.let { return it } - + fun load(context: Context, rawContent: ByteArray): ConfigFile? { val currentHash = sha256(rawContent) - val prefs = getPrefs(context) - val savedHash = prefs.getString(KEY_HASH, null) ?: return null + // 1. 内存缓存也必须与当前文件摘要绑定,防止同进程替换文件后读到旧配置。 + memoryCached?.takeIf { memoryHash == currentHash }?.let { return it } - if (savedHash != currentHash) return null - - val appKey = prefs.getString(KEY_APP_KEY, null) ?: return null - val serverUrl = prefs.getString(KEY_SERVER_URL, null) - val packageName = prefs.getString(KEY_PACKAGE_NAME, null) - - val cached = CachedConfig(appKey, serverUrl, packageName) + val json = runCatching { store(context).getString(KEY_ENTRY) }.getOrNull() ?: return null + val envelope = runCatching { gson.fromJson(json, CacheEnvelope::class.java) }.getOrNull() + ?: return null + if (envelope.hash != currentHash) return null + val cached = envelope.config memoryCached = cached + memoryHash = currentHash return cached } @@ -61,31 +50,23 @@ internal object ConfigCache { */ fun save(context: Context, rawContent: ByteArray, config: ConfigFile) { val hash = sha256(rawContent) - getPrefs(context).edit().apply { - putString(KEY_HASH, hash) - putString(KEY_APP_KEY, config.appKey) - putString(KEY_SERVER_URL, config.serverUrl) - putString(KEY_PACKAGE_NAME, config.packageName) - apply() + runCatching { + store(context).putString(KEY_ENTRY, gson.toJson(CacheEnvelope(hash, config))) } - memoryCached = CachedConfig(config.appKey, config.serverUrl, config.packageName) + memoryCached = config + memoryHash = hash } /** * 清除缓存(用于登出或配置重置场景)。 */ fun clear(context: Context) { - getPrefs(context).edit().clear().apply() + runCatching { store(context).clear() } memoryCached = null + memoryHash = null } - private fun getPrefs(context: Context) = EncryptedSharedPreferences.create( - PREFS_NAME, - MasterKeys.getOrCreate(MasterKeys.AES256_GCM_SPEC), - context, - EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, - EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM, - ) + private fun store(context: Context) = SecureStore.open(context, NAMESPACE) private fun sha256(data: ByteArray): String { val digest = MessageDigest.getInstance("SHA-256").digest(data) diff --git a/sdk-core/src/main/java/com/xuqm/sdk/internal/ConfigFile.kt b/sdk-core/src/main/java/com/xuqm/sdk/internal/ConfigFile.kt index 811a1f4..95d6ca5 100644 --- a/sdk-core/src/main/java/com/xuqm/sdk/internal/ConfigFile.kt +++ b/sdk-core/src/main/java/com/xuqm/sdk/internal/ConfigFile.kt @@ -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, ) diff --git a/sdk-core/src/main/java/com/xuqm/sdk/internal/ConfigFileCrypto.kt b/sdk-core/src/main/java/com/xuqm/sdk/internal/ConfigFileCrypto.kt index a2bd603..ca616b0 100644 --- a/sdk-core/src/main/java/com/xuqm/sdk/internal/ConfigFileCrypto.kt +++ b/sdk-core/src/main/java/com/xuqm/sdk/internal/ConfigFileCrypto.kt @@ -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...). - * 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 { 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 = mapOf( + "xuqm-config-dev-2026-07" to + "MCowBQYDK2VwAyEAFt9MBN8jNwCfalex4wg7kuy2DRAbXkGxZyA/Jaz+6YA=", + ) + + val all: Map by lazy { + val factory = KeyFactory.getInstance("Ed25519", BouncyCastleProvider()) + encodedKeys.mapValues { (_, encoded) -> + factory.generatePublic( + X509EncodedKeySpec(org.bouncycastle.util.encoders.Base64.decode(encoded)), + ) + } + } } diff --git a/sdk-core/src/main/java/com/xuqm/sdk/internal/ConfigFileReader.kt b/sdk-core/src/main/java/com/xuqm/sdk/internal/ConfigFileReader.kt index 6a6f695..6c4deb3 100644 --- a/sdk-core/src/main/java/com/xuqm/sdk/internal/ConfigFileReader.kt +++ b/sdk-core/src/main/java/com/xuqm/sdk/internal/ConfigFileReader.kt @@ -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" } - }.getOrNull() - if (found != null) return found - } - return null + return runCatching { + context.assets.open(CONFIG_PATH).close() + CONFIG_PATH + }.getOrNull() } } diff --git a/sdk-core/src/main/java/com/xuqm/sdk/internal/PlatformConfigCache.kt b/sdk-core/src/main/java/com/xuqm/sdk/internal/PlatformConfigCache.kt new file mode 100644 index 0000000..ccd0a7a --- /dev/null +++ b/sdk-core/src/main/java/com/xuqm/sdk/internal/PlatformConfigCache.kt @@ -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) +} diff --git a/sdk-core/src/main/java/com/xuqm/sdk/internal/XuqmInitializerProvider.kt b/sdk-core/src/main/java/com/xuqm/sdk/internal/XuqmInitializerProvider.kt deleted file mode 100644 index a6ae117..0000000 --- a/sdk-core/src/main/java/com/xuqm/sdk/internal/XuqmInitializerProvider.kt +++ /dev/null @@ -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?, selection: String?, selectionArgs: Array?, 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?): Int = 0 - override fun update(uri: Uri, values: ContentValues?, selection: String?, selectionArgs: Array?): Int = 0 -} diff --git a/sdk-core/src/main/java/com/xuqm/sdk/internal/XuqmMergedProvider.kt b/sdk-core/src/main/java/com/xuqm/sdk/internal/XuqmMergedProvider.kt index f4a8d44..972eae3 100644 --- a/sdk-core/src/main/java/com/xuqm/sdk/internal/XuqmMergedProvider.kt +++ b/sdk-core/src/main/java/com/xuqm/sdk/internal/XuqmMergedProvider.kt @@ -12,12 +12,12 @@ import com.xuqm.sdk.XuqmSDK * * 设计原则:主线程零加密运算。 * Phase 1(同步,主线程,<10ms): - * - 扫描 assets/config/ 或 assets/xuqm/ 找到 .xuqmconfig 文件 + * - 读取唯一的 assets/config/config.xuqmconfig * - 读取文件原始字节到内存 * - 启动 IO 协程 * Phase 2(异步,IO 线程): * - 查 ConfigCache(内存→磁盘),命中则跳过 PBKDF2 - * - 缓存 miss 时执行 PBKDF2 解密(~100ms)并写缓存 + * - 缓存 miss 时先验证 Ed25519 签名,再执行 PBKDF2 解密并写缓存 * - 初始化 sdk-core(TokenStore / DeviceId / ApiClient) * - 注册 sdk-bugcollect CrashCapture * - 拉取远端平台配置 @@ -26,6 +26,16 @@ class XuqmMergedProvider : ContentProvider() { override fun onCreate(): Boolean { val ctx = context?.applicationContext ?: return true + val pluginApplied = runCatching { + ctx.packageManager.getApplicationInfo( + ctx.packageName, + android.content.pm.PackageManager.GET_META_DATA, + ).metaData?.getBoolean("com.xuqm.common_plugin_applied", false) == true + }.getOrDefault(false) + if (!pluginApplied) { + Log.w(TAG, "com.xuqm.common plugin is not applied, skipping auto-init") + return true + } // ── Phase 1:主线程只读原始字节(<10ms)────────────────────────── val rawBytes = ConfigFileReader.readRawBytes(ctx) diff --git a/sdk-core/src/main/java/com/xuqm/sdk/network/ApiClient.kt b/sdk-core/src/main/java/com/xuqm/sdk/network/ApiClient.kt index 99b1d90..84f39ee 100644 --- a/sdk-core/src/main/java/com/xuqm/sdk/network/ApiClient.kt +++ b/sdk-core/src/main/java/com/xuqm/sdk/network/ApiClient.kt @@ -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() { @@ -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', ' ')}") - } - } } } diff --git a/sdk-core/src/main/java/com/xuqm/sdk/network/SdkPlatformConfigApi.kt b/sdk-core/src/main/java/com/xuqm/sdk/network/SdkPlatformConfigApi.kt index e6f08a7..1d59c28 100644 --- a/sdk-core/src/main/java/com/xuqm/sdk/network/SdkPlatformConfigApi.kt +++ b/sdk-core/src/main/java/com/xuqm/sdk/network/SdkPlatformConfigApi.kt @@ -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, ) /** 平台各服务开通状态。 */ diff --git a/sdk-core/src/main/java/com/xuqm/sdk/storage/SecureStore.kt b/sdk-core/src/main/java/com/xuqm/sdk/storage/SecureStore.kt new file mode 100644 index 0000000..64c82ef --- /dev/null +++ b/sdk-core/src/main/java/com/xuqm/sdk/storage/SecureStore.kt @@ -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 = + 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() + + 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) + } +} diff --git a/sdk-core/src/test/java/com/xuqm/sdk/PublicInitializationApiTest.kt b/sdk-core/src/test/java/com/xuqm/sdk/PublicInitializationApiTest.kt new file mode 100644 index 0000000..7e54082 --- /dev/null +++ b/sdk-core/src/test/java/com/xuqm/sdk/PublicInitializationApiTest.kt @@ -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" }, + ) + } +} diff --git a/sdk-core/src/test/java/com/xuqm/sdk/internal/BugCollectRuntimePolicyTest.kt b/sdk-core/src/test/java/com/xuqm/sdk/internal/BugCollectRuntimePolicyTest.kt new file mode 100644 index 0000000..8b2f1e8 --- /dev/null +++ b/sdk-core/src/test/java/com/xuqm/sdk/internal/BugCollectRuntimePolicyTest.kt @@ -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], + ), + ) + } + } +} diff --git a/sdk-core/src/test/java/com/xuqm/sdk/internal/ConfigFileCryptoTest.kt b/sdk-core/src/test/java/com/xuqm/sdk/internal/ConfigFileCryptoTest.kt new file mode 100644 index 0000000..f9b850b --- /dev/null +++ b/sdk-core/src/test/java/com/xuqm/sdk/internal/ConfigFileCryptoTest.kt @@ -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" + } +} diff --git a/sdk-core/src/test/java/com/xuqm/sdk/storage/AesGcmCodecTest.kt b/sdk-core/src/test/java/com/xuqm/sdk/storage/AesGcmCodecTest.kt new file mode 100644 index 0000000..f47ebe4 --- /dev/null +++ b/sdk-core/src/test/java/com/xuqm/sdk/storage/AesGcmCodecTest.kt @@ -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()) + } + } +} diff --git a/sdk-file/README.md b/sdk-file/README.md new file mode 100644 index 0000000..2a6b550 --- /dev/null +++ b/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。 diff --git a/sdk-file/build.gradle.kts b/sdk-file/build.gradle.kts new file mode 100644 index 0000000..251275a --- /dev/null +++ b/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) +} diff --git a/sdk-file/consumer-rules.pro b/sdk-file/consumer-rules.pro new file mode 100644 index 0000000..90c4fce --- /dev/null +++ b/sdk-file/consumer-rules.pro @@ -0,0 +1,6 @@ +-keep class com.xuqm.sdk.file.platform.PlatformFileSDK { *; } +-keep class com.xuqm.sdk.file.platform.FileUploadResult { *; } +-keep class com.xuqm.sdk.file.platform.PlatformFileException { *; } +-keepclassmembers class com.xuqm.sdk.file.platform.PlatformFileApiResponse { + ; +} diff --git a/sdk-file/src/main/AndroidManifest.xml b/sdk-file/src/main/AndroidManifest.xml new file mode 100644 index 0000000..a1bb7be --- /dev/null +++ b/sdk-file/src/main/AndroidManifest.xml @@ -0,0 +1,18 @@ + + + + + + + + diff --git a/sdk-file/src/main/java/com/xuqm/sdk/file/platform/PlatformFileException.kt b/sdk-file/src/main/java/com/xuqm/sdk/file/platform/PlatformFileException.kt new file mode 100644 index 0000000..d365f89 --- /dev/null +++ b/sdk-file/src/main/java/com/xuqm/sdk/file/platform/PlatformFileException.kt @@ -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) diff --git a/sdk-file/src/main/java/com/xuqm/sdk/file/platform/PlatformFileSDK.kt b/sdk-file/src/main/java/com/xuqm/sdk/file/platform/PlatformFileSDK.kt new file mode 100644 index 0000000..e65dce2 --- /dev/null +++ b/sdk-file/src/main/java/com/xuqm/sdk/file/platform/PlatformFileSDK.kt @@ -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) + }) + } +} diff --git a/sdk-file/src/main/java/com/xuqm/sdk/file/platform/PlatformFileUploadGateway.kt b/sdk-file/src/main/java/com/xuqm/sdk/file/platform/PlatformFileUploadGateway.kt new file mode 100644 index 0000000..45fc2c8 --- /dev/null +++ b/sdk-file/src/main/java/com/xuqm/sdk/file/platform/PlatformFileUploadGateway.kt @@ -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( + 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 +} + +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.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 diff --git a/sdk-file/src/main/java/com/xuqm/sdk/file/platform/internal/UploadRequestBodies.kt b/sdk-file/src/main/java/com/xuqm/sdk/file/platform/internal/UploadRequestBodies.kt new file mode 100644 index 0000000..e061636 --- /dev/null +++ b/sdk-file/src/main/java/com/xuqm/sdk/file/platform/internal/UploadRequestBodies.kt @@ -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 +} diff --git a/sdk-core/src/main/res/xml/xuqm_file_paths.xml b/sdk-file/src/main/res/xml/xuqm_file_paths.xml similarity index 55% rename from sdk-core/src/main/res/xml/xuqm_file_paths.xml rename to sdk-file/src/main/res/xml/xuqm_file_paths.xml index 59e531c..4c80e5a 100644 --- a/sdk-core/src/main/res/xml/xuqm_file_paths.xml +++ b/sdk-file/src/main/res/xml/xuqm_file_paths.xml @@ -1,7 +1,6 @@ - - - + + diff --git a/sdk-file/src/test/java/com/xuqm/sdk/file/platform/PlatformFileUploadGatewayTest.kt b/sdk-file/src/test/java/com/xuqm/sdk/file/platform/PlatformFileUploadGatewayTest.kt new file mode 100644 index 0000000..1b81ee8 --- /dev/null +++ b/sdk-file/src/test/java/com/xuqm/sdk/file/platform/PlatformFileUploadGatewayTest.kt @@ -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( + 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( + 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 { + throw HttpException( + Response.error(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, + ): PlatformFileApi = object : PlatformFileApi { + override suspend fun upload( + file: MultipartBody.Part, + thumbnail: MultipartBody.Part?, + ): PlatformFileApiResponse = 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", + ) +} diff --git a/sdk-file/src/test/java/com/xuqm/sdk/file/platform/internal/UploadRequestBodiesTest.kt b/sdk-file/src/test/java/com/xuqm/sdk/file/platform/internal/UploadRequestBodiesTest.kt new file mode 100644 index 0000000..b40974a --- /dev/null +++ b/sdk-file/src/test/java/com/xuqm/sdk/file/platform/internal/UploadRequestBodiesTest.kt @@ -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() + 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() + val sink = Buffer() + + UploadRequestBodies.createFile(file, "application/octet-stream", progress::add) + .writeTo(sink) + + assertArrayEquals(payload, sink.readByteArray()) + assertEquals(100, progress.last()) + } finally { + file.delete() + } + } +} diff --git a/sdk-im/README.md b/sdk-im/README.md index e16e593..8b2fd9c 100644 --- a/sdk-im/README.md +++ b/sdk-im/README.md @@ -11,7 +11,9 @@ implementation("com.xuqm:sdk-core:VERSION") // 必须 ## 使用 -**无需手动初始化。** `XuqmSDK.login(userId, userSig)` 成功后自动完成 IM 登录。 +**无需手动初始化或单独登录。** 宿主调用 +`XuqmSDK.setUserInfo(XuqmUserInfo(userId, userSig))` 后自动完成 IM 登录;传入 `null` +时统一登出。 ```kotlin // ImClient 直接使用 diff --git a/sdk-im/build.gradle.kts b/sdk-im/build.gradle.kts index e6e6313..9365aa3 100644 --- a/sdk-im/build.gradle.kts +++ b/sdk-im/build.gradle.kts @@ -31,6 +31,7 @@ android { } dependencies { - api(project(":sdk-core")) + api(project(":sdk-file")) implementation(libs.kotlinx.coroutines.android) + testImplementation(libs.junit4) } diff --git a/sdk-im/src/main/AndroidManifest.xml b/sdk-im/src/main/AndroidManifest.xml new file mode 100644 index 0000000..f9c40f3 --- /dev/null +++ b/sdk-im/src/main/AndroidManifest.xml @@ -0,0 +1,9 @@ + + + + + diff --git a/sdk-im/src/main/java/com/xuqm/sdk/im/ImClient.kt b/sdk-im/src/main/java/com/xuqm/sdk/im/ImClient.kt index 1b1a848..9f372e3 100644 --- a/sdk-im/src/main/java/com/xuqm/sdk/im/ImClient.kt +++ b/sdk-im/src/main/java/com/xuqm/sdk/im/ImClient.kt @@ -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" }) } } } diff --git a/sdk-im/src/main/java/com/xuqm/sdk/im/ImSDK.kt b/sdk-im/src/main/java/com/xuqm/sdk/im/ImSDK.kt index a7187af..7162e6b 100644 --- a/sdk-im/src/main/java/com/xuqm/sdk/im/ImSDK.kt +++ b/sdk-im/src/main/java/com/xuqm/sdk/im/ImSDK.kt @@ -30,8 +30,8 @@ import com.xuqm.sdk.im.model.PageResult import com.xuqm.sdk.im.model.UserProfile import com.xuqm.sdk.im.model.BlacklistEntry import com.xuqm.sdk.im.model.FriendRequest -import com.xuqm.sdk.file.FileSDK -import com.xuqm.sdk.file.FileUploadResult +import com.xuqm.sdk.file.platform.PlatformFileSDK +import com.xuqm.sdk.file.platform.FileUploadResult import com.xuqm.sdk.network.ApiClient import android.util.Log import kotlinx.coroutines.CoroutineScope @@ -111,28 +111,6 @@ object ImSDK { XuqmSDK.currentLoginSession?.let { onSdkLogin(it) } } - suspend fun login(userId: String, userSig: String) = withContext(Dispatchers.IO) { - XuqmSDK.requireInit() - if (currentUserId == userId && currentUserSig == userSig) { - return@withContext - } - currentUserId = userId - currentUserSig = userSig - connectWithToken(userSig) - } - - /** - * 刷新 userSig(IM 登录凭证过期时调用)。 - * 等效于用新 userSig 重新调用 [XuqmSDK.setUserInfo]。 - */ - suspend fun refreshToken(userSig: String) = withContext(Dispatchers.IO) { - XuqmSDK.requireInit() - if (currentUserId.isBlank()) return@withContext - if (currentUserSig == userSig) return@withContext - currentUserSig = userSig - connectWithToken(userSig) - } - fun sendMessage( toId: String, chatType: String, @@ -156,7 +134,11 @@ object ImSDK { content = content, mentionedUserIds = mentionedUserIds, ) == true - Log.d(TAG, "sendMessage id=${message.id} toId=$toId chatType=$chatType msgType=$msgType contentLength=${content.length} mentioned=${mentionedUserIds.orEmpty()} sent=$sent") + Log.d( + TAG, + "sendMessage chatType=$chatType msgType=$msgType " + + "contentLength=${content.length} hasMentions=${!mentionedUserIds.isNullOrBlank()} sent=$sent", + ) return if (sent) message else message.copy(status = "FAILED") } @@ -182,7 +164,7 @@ object ImSDK { width: Int? = null, height: Int? = null, ): ImMessage = withContext(Dispatchers.IO) { - val result = FileSDK.upload(file) + val result = PlatformFileSDK.upload(file) sendImageMessageWithUploadResult(toId, chatType, result, width, height) } @@ -218,7 +200,7 @@ object ImSDK { height: Int? = null, durationMs: Long? = null, ): ImMessage = withContext(Dispatchers.IO) { - val result = FileSDK.upload(file) + val result = PlatformFileSDK.upload(file) sendVideoMessageWithUploadResult(toId, chatType, result, width, height, durationMs) } @@ -253,7 +235,7 @@ object ImSDK { chatType: String, file: File, ): ImMessage = withContext(Dispatchers.IO) { - val result = FileSDK.upload(file) + val result = PlatformFileSDK.upload(file) sendFileMessageWithUploadResult(toId, chatType, result) } @@ -282,7 +264,7 @@ object ImSDK { file: File, durationMs: Long? = null, ): ImMessage = withContext(Dispatchers.IO) { - val result = FileSDK.upload(file) + val result = PlatformFileSDK.upload(file) sendAudioMessageWithUploadResult(toId, chatType, result, durationMs) } @@ -445,7 +427,7 @@ object ImSDK { } fun subscribeGroup(groupId: String) { - Log.d(TAG, "subscribeGroup groupId=$groupId") + Log.d(TAG, "Group subscription requested") synchronized(activeGroupSubscriptions) { activeGroupSubscriptions.add(groupId) } @@ -453,7 +435,7 @@ object ImSDK { } fun unsubscribeGroup(groupId: String) { - Log.d(TAG, "unsubscribeGroup groupId=$groupId") + Log.d(TAG, "Group unsubscription requested") synchronized(activeGroupSubscriptions) { activeGroupSubscriptions.remove(groupId) } @@ -817,7 +799,7 @@ object ImSDK { disconnectInternal(clearTokenStore = true) } - fun onSdkLogin(session: XuqmLoginSession) { + private fun onSdkLogin(session: XuqmLoginSession) { XuqmSDK.requireInit() if (session.userSig.isBlank()) return // IM 未提供 userSig,跳过连接(Push/Update 仍生效) if (currentUserId == session.userId && currentUserSig == session.userSig) return @@ -826,7 +808,7 @@ object ImSDK { connectWithToken(session.userSig) } - fun onSdkLogout() { + private fun onSdkLogout() { disconnectInternal(clearTokenStore = false) } @@ -842,7 +824,7 @@ object ImSDK { client?.disconnect() _connectionState.value = ImConnectionState.Connecting currentToken = token - Log.d(TAG, "connectWithToken userId=$currentUserId activeGroups=${activeGroupSubscriptions.size}") + Log.d(TAG, "Connecting IM: activeGroupCount=${activeGroupSubscriptions.size}") client = ImClient(ServiceEndpointRegistry.imWsUrl, token, XuqmSDK.appKey) client?.addListener(connectionListener) client?.addListener(conversationEventListener) diff --git a/sdk-im/src/test/java/com/xuqm/sdk/im/PublicLifecycleApiTest.kt b/sdk-im/src/test/java/com/xuqm/sdk/im/PublicLifecycleApiTest.kt new file mode 100644 index 0000000..e52fa69 --- /dev/null +++ b/sdk-im/src/test/java/com/xuqm/sdk/im/PublicLifecycleApiTest.kt @@ -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 --git a/sdk-push/README.md b/sdk-push/README.md index 6cefec0..c5f1c9c 100644 --- a/sdk-push/README.md +++ b/sdk-push/README.md @@ -11,7 +11,8 @@ implementation("com.xuqm:sdk-core:VERSION") // 必须 ## 使用 -**无需手动初始化。** `XuqmSDK.login()` 成功后自动完成推送 token 注册。`logout()` 自动解绑。 +**无需手动初始化或单独登录。** 宿主调用 `XuqmSDK.setUserInfo(...)` 后自动完成推送 +token 注册;传入 `null` 时自动解绑。 ```kotlin // 开启/关闭接收推送 diff --git a/sdk-push/build.gradle.kts b/sdk-push/build.gradle.kts index 9783712..b6b9af4 100644 --- a/sdk-push/build.gradle.kts +++ b/sdk-push/build.gradle.kts @@ -43,4 +43,5 @@ dependencies { api("io.github.hebeiliang.mipush:Push:2.0.0") api("com.umeng.umsdk:oppo-push:3.0.0") api("com.umeng.umsdk:vivo-push:4.0.6.0") + testImplementation(libs.junit4) } diff --git a/sdk-push/src/main/AndroidManifest.xml b/sdk-push/src/main/AndroidManifest.xml index 41ac8a4..0c05f7a 100644 --- a/sdk-push/src/main/AndroidManifest.xml +++ b/sdk-push/src/main/AndroidManifest.xml @@ -6,6 +6,7 @@ + @@ -31,6 +32,11 @@ + diff --git a/sdk-push/src/main/java/com/xuqm/sdk/push/PushSDK.kt b/sdk-push/src/main/java/com/xuqm/sdk/push/PushSDK.kt index ffd7c2c..0a2ef68 100644 --- a/sdk-push/src/main/java/com/xuqm/sdk/push/PushSDK.kt +++ b/sdk-push/src/main/java/com/xuqm/sdk/push/PushSDK.kt @@ -98,14 +98,6 @@ object PushSDK { store(XuqmSDK.appContext).clearQuietHours() } - /** - * 登出推送(解绑当前设备 token)。 - * 通常不需要手动调用,[XuqmSDK.setUserInfo(null)] 会自动触发。 - */ - fun logout() { - onSdkLogout() - } - /** 返回通知渠道 ID,可用于自定义通知点击行为。 */ fun notificationChannelIdFor(context: Context, routeType: String): String? = PushNotificationChannelManager.channelIdFor(context.applicationContext, routeType) @@ -118,7 +110,10 @@ object PushSDK { * 同时启动宿主 App,携带原始 params extras 供冷启动场景下读取。 */ internal fun dispatchNotificationClick(context: Context, event: PushNotificationClickEvent) { - Log.d("XuqmPushSDK", "Notification clicked: vendor=${event.vendor}, params=${event.params}") + Log.d( + "XuqmPushSDK", + "Notification clicked: vendor=${event.vendor} hasParams=${event.params.isNotEmpty()}", + ) notificationClickListener?.invoke(event) val pm = context.packageManager val intent = pm.getLaunchIntentForPackage(context.packageName) ?: return @@ -133,15 +128,16 @@ object PushSDK { // ── Internal hooks called by XuqmSDK via reflection ─────────────────────── - fun onSdkLogin(session: XuqmLoginSession) { + private fun onSdkLogin(session: XuqmLoginSession) { val context = runCatching { XuqmSDK.appContext }.getOrNull() ?: return - Log.d("XuqmPushSDK", "onSdkLogin: userId=${session.userId} registeredUserId=${registeredUserId.get()}") - if (registeredUserId.get() == session.userId) return + val alreadyRegistered = registeredUserId.get() == session.userId + Log.d("XuqmPushSDK", "SDK login observed: alreadyRegistered=$alreadyRegistered") + if (alreadyRegistered) return initializeVendorsInternal(context) bindImUserInternal(context, session.userId) } - fun onSdkLogout() { + private fun onSdkLogout() { val userId = registeredUserId.getAndSet(null) ?: return lastRegisteredDeviceKey.set(null) registeringDeviceKey.set(null) @@ -151,7 +147,7 @@ object PushSDK { // ── Internal push registration flow ─────────────────────────────────────── internal fun updateNativePushToken(context: Context, vendor: PushVendor, pushToken: String) { - Log.i("XuqmPushSDK", "updateNativePushToken called: vendor=$vendor token=${pushToken.take(12)}...") + Log.i("XuqmPushSDK", "Native push token received: vendor=${vendor.name}") XuqmSDK.requireInit() val detectedVendor = detectVendor() if (vendor != detectedVendor) { @@ -162,7 +158,10 @@ object PushSDK { require(normalizedToken.isNotBlank()) { "pushToken must not be blank" } store(context).save(vendor, normalizedToken) val sessionUserId = XuqmSDK.getUserId() - Log.i("XuqmPushSDK", "updateNativePushToken: saved token, sessionUserId=$sessionUserId") + Log.i( + "XuqmPushSDK", + "Native push token persisted: vendor=${vendor.name} hasActiveSession=${sessionUserId != null}", + ) if (sessionUserId != null) { bindImUserInternal(context, sessionUserId) } @@ -223,7 +222,10 @@ object PushSDK { } private fun bindImUserInternal(context: Context, userId: String) { - Log.i("XuqmPushSDK", "bindImUserInternal: userId=$userId receivePushEnabled=${isReceivePushEnabled(context)}") + Log.i( + "XuqmPushSDK", + "Push binding requested: receivePushEnabled=${isReceivePushEnabled(context)}", + ) if (!isReceivePushEnabled(context)) return ensureNativePushTokenInternal(context) registerDeviceInternal(context, userId) @@ -242,18 +244,21 @@ object PushSDK { } val registrationKey = listOf(userId, vendor.name, pushToken, deviceId).joinToString("|") if (lastRegisteredDeviceKey.get() == registrationKey) { - Log.i("XuqmPushSDK", "Skipping duplicate push device registration for userId=$userId vendor=${vendor.name}") + Log.i("XuqmPushSDK", "Skipping duplicate push registration: vendor=${vendor.name}") return } if (!registeringDeviceKey.compareAndSet(null, registrationKey)) { if (registeringDeviceKey.get() == registrationKey) { - Log.d("XuqmPushSDK", "Push device registration already in progress for userId=$userId vendor=${vendor.name}") + Log.d( + "XuqmPushSDK", + "Push registration already in progress: vendor=${vendor.name}", + ) return } } scope.launch { runCatching { - Log.i("XuqmPushSDK", "Registering push device: userId=$userId vendor=${vendor.name} token=${pushToken.take(12)}...") + Log.i("XuqmPushSDK", "Registering push device: vendor=${vendor.name}") api.registerDevice( appKey = XuqmSDK.appKey, userId = userId, @@ -269,9 +274,13 @@ object PushSDK { registeredUserId.set(userId) lastRegisteredDeviceKey.set(registrationKey) store(context).updateLastUserId(userId) - Log.i("XuqmPushSDK", "Registered push device for userId=$userId vendor=${vendor.name}") + Log.i("XuqmPushSDK", "Push device registered: vendor=${vendor.name}") }.onFailure { e -> - Log.e("XuqmPushSDK", "Push device registration failed for userId=$userId: ${e.message}", e) + Log.e( + "XuqmPushSDK", + "Push device registration failed: vendor=${vendor.name} " + + "errorType=${e.javaClass.simpleName}", + ) }.also { registeringDeviceKey.compareAndSet(registrationKey, null) } @@ -352,8 +361,11 @@ object PushSDK { honorAppId = pushConfig?.getAsJsonObject("honor")?.get("appId")?.asString.orEmpty(), ) }.getOrElse { error -> - Log.e("XuqmPushSDK", "Failed to load push vendor config from control plane: ${error.message}") - throw IllegalStateException("Push vendor config unavailable: ${error.message}", error) + Log.e( + "XuqmPushSDK", + "Failed to load push vendor config: errorType=${error.javaClass.simpleName}", + ) + throw IllegalStateException("Push vendor config unavailable") } cachedVendorConfig = loaded cachedConfigAt = now diff --git a/sdk-push/src/main/java/com/xuqm/sdk/push/fcm/XuqmFirebaseMessagingService.kt b/sdk-push/src/main/java/com/xuqm/sdk/push/fcm/XuqmFirebaseMessagingService.kt index 5eea808..77e4d4c 100644 --- a/sdk-push/src/main/java/com/xuqm/sdk/push/fcm/XuqmFirebaseMessagingService.kt +++ b/sdk-push/src/main/java/com/xuqm/sdk/push/fcm/XuqmFirebaseMessagingService.kt @@ -10,7 +10,7 @@ class XuqmFirebaseMessagingService : FirebaseMessagingService() { override fun onNewToken(token: String) { super.onNewToken(token) - Log.d(TAG, "FCM token refreshed length=${token.length}") + Log.d(TAG, "FCM token refreshed") PushSDK.updateNativePushToken(applicationContext, PushVendor.FCM, token) } @@ -18,7 +18,7 @@ class XuqmFirebaseMessagingService : FirebaseMessagingService() { super.onMessageReceived(message) Log.d( TAG, - "FCM message from=${message.from.orEmpty()} dataKeys=${message.data.keys.joinToString(",")}", + "FCM message received: dataFieldCount=${message.data.size}", ) } diff --git a/sdk-push/src/main/java/com/xuqm/sdk/push/honor/XuqmHonorPushService.kt b/sdk-push/src/main/java/com/xuqm/sdk/push/honor/XuqmHonorPushService.kt index e3facc6..d749b2d 100644 --- a/sdk-push/src/main/java/com/xuqm/sdk/push/honor/XuqmHonorPushService.kt +++ b/sdk-push/src/main/java/com/xuqm/sdk/push/honor/XuqmHonorPushService.kt @@ -12,8 +12,8 @@ class XuqmHonorPushService : HonorMessageService() { if (token.isNullOrBlank()) return runCatching { PushSDK.updateNativePushToken(applicationContext, PushVendor.HONOR, token) - }.onFailure { error -> - Log.w(TAG, "Unable to persist Honor push token: ${error.message}") + }.onFailure { + Log.w(TAG, "Unable to persist Honor push token") } } diff --git a/sdk-push/src/main/java/com/xuqm/sdk/push/huawei/XuqmHuaweiPushService.kt b/sdk-push/src/main/java/com/xuqm/sdk/push/huawei/XuqmHuaweiPushService.kt index 8ee35c9..26aefc9 100644 --- a/sdk-push/src/main/java/com/xuqm/sdk/push/huawei/XuqmHuaweiPushService.kt +++ b/sdk-push/src/main/java/com/xuqm/sdk/push/huawei/XuqmHuaweiPushService.kt @@ -28,8 +28,8 @@ class XuqmHuaweiPushService : HmsMessageService() { if (token.isNullOrBlank()) return runCatching { PushSDK.updateNativePushToken(applicationContext, PushVendor.HUAWEI, token) - }.onFailure { error -> - Log.w(TAG, "Unable to persist Huawei push token: ${error.message}") + }.onFailure { + Log.w(TAG, "Unable to persist Huawei push token") } } diff --git a/sdk-push/src/main/java/com/xuqm/sdk/push/storage/PushRegistrationStore.kt b/sdk-push/src/main/java/com/xuqm/sdk/push/storage/PushRegistrationStore.kt index 301cf00..d807ba7 100644 --- a/sdk-push/src/main/java/com/xuqm/sdk/push/storage/PushRegistrationStore.kt +++ b/sdk-push/src/main/java/com/xuqm/sdk/push/storage/PushRegistrationStore.kt @@ -1,26 +1,19 @@ package com.xuqm.sdk.push.storage import android.content.Context -import androidx.security.crypto.EncryptedSharedPreferences -import androidx.security.crypto.MasterKeys import com.xuqm.sdk.push.model.PushRegistrationSnapshot import com.xuqm.sdk.push.model.PushVendor +import com.xuqm.sdk.storage.SecureStore internal class PushRegistrationStore(context: Context) { - private val prefs = EncryptedSharedPreferences.create( - PREFS_NAME, - MasterKeys.getOrCreate(MasterKeys.AES256_GCM_SPEC), - context.applicationContext, - EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, - EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM, - ) + private val store = SecureStore.open(context, NAMESPACE) fun load(deviceId: String, fallbackVendor: PushVendor): PushRegistrationSnapshot? { - val vendorName = prefs.getString(KEY_VENDOR, null) ?: fallbackVendor.name - val pushToken = prefs.getString(KEY_PUSH_TOKEN, null).orEmpty() - val receivePush = prefs.getBoolean(KEY_RECEIVE_PUSH, true) - val lastUserId = prefs.getString(KEY_LAST_USER_ID, null) + val vendorName = store.getString(KEY_VENDOR) ?: fallbackVendor.name + val pushToken = store.getString(KEY_PUSH_TOKEN).orEmpty() + val receivePush = store.getString(KEY_RECEIVE_PUSH)?.toBooleanStrictOrNull() ?: true + val lastUserId = store.getString(KEY_LAST_USER_ID) val vendor = runCatching { PushVendor.valueOf(vendorName) }.getOrDefault(fallbackVendor) if (pushToken.isBlank() && lastUserId.isNullOrBlank()) return null return PushRegistrationSnapshot( @@ -33,49 +26,36 @@ internal class PushRegistrationStore(context: Context) { } fun save(vendor: PushVendor, pushToken: String) { - prefs.edit() - .putString(KEY_VENDOR, vendor.name) - .putString(KEY_PUSH_TOKEN, pushToken) - .apply() + store.putString(KEY_VENDOR, vendor.name) + store.putString(KEY_PUSH_TOKEN, pushToken) } fun setReceivePush(enabled: Boolean) { - prefs.edit() - .putBoolean(KEY_RECEIVE_PUSH, enabled) - .apply() + store.putString(KEY_RECEIVE_PUSH, enabled.toString()) } fun updateLastUserId(userId: String?) { - prefs.edit() - .apply { - if (userId.isNullOrBlank()) remove(KEY_LAST_USER_ID) else putString(KEY_LAST_USER_ID, userId) - } - .apply() + if (userId.isNullOrBlank()) store.remove(KEY_LAST_USER_ID) + else store.putString(KEY_LAST_USER_ID, userId) } fun setQuietHours(start: String, end: String) { - prefs.edit() - .putString(KEY_QUIET_HOURS_START, start) - .putString(KEY_QUIET_HOURS_END, end) - .apply() + store.putString(KEY_QUIET_HOURS_START, start) + store.putString(KEY_QUIET_HOURS_END, end) } fun clearQuietHours() { - prefs.edit() - .remove(KEY_QUIET_HOURS_START) - .remove(KEY_QUIET_HOURS_END) - .apply() + store.remove(KEY_QUIET_HOURS_START) + store.remove(KEY_QUIET_HOURS_END) } fun clearToken() { - prefs.edit() - .remove(KEY_VENDOR) - .remove(KEY_PUSH_TOKEN) - .apply() + store.remove(KEY_VENDOR) + store.remove(KEY_PUSH_TOKEN) } companion object { - private const val PREFS_NAME = "xuqm_push_settings" + private const val NAMESPACE = "push.registration" private const val KEY_VENDOR = "push_vendor" private const val KEY_PUSH_TOKEN = "push_token" private const val KEY_RECEIVE_PUSH = "receive_push" diff --git a/sdk-push/src/main/java/com/xuqm/sdk/push/vendor/FcmPushService.kt b/sdk-push/src/main/java/com/xuqm/sdk/push/vendor/FcmPushService.kt index 5966b76..2da5fc9 100644 --- a/sdk-push/src/main/java/com/xuqm/sdk/push/vendor/FcmPushService.kt +++ b/sdk-push/src/main/java/com/xuqm/sdk/push/vendor/FcmPushService.kt @@ -50,8 +50,8 @@ class FcmPushService : PushVendorInterface { addOnSuccess.invoke(task, proxy) } Log.i(TAG, "FCM token refresh requested") - }.onFailure { error -> - Log.w(TAG, "FCM token refresh failed: ${error.message}") + }.onFailure { + Log.w(TAG, "FCM token refresh failed") } } @@ -61,8 +61,8 @@ class FcmPushService : PushVendorInterface { val instance = fcmClass.getMethod("getInstance").invoke(null) fcmClass.getMethod("deleteToken").invoke(instance) Log.i(TAG, "FCM token deleted") - }.onFailure { error -> - Log.w(TAG, "FCM unregistration failed: ${error.message}") + }.onFailure { + Log.w(TAG, "FCM unregistration failed") } } diff --git a/sdk-push/src/main/java/com/xuqm/sdk/push/vendor/HonorPushService.kt b/sdk-push/src/main/java/com/xuqm/sdk/push/vendor/HonorPushService.kt index 4a668a0..97673c9 100644 --- a/sdk-push/src/main/java/com/xuqm/sdk/push/vendor/HonorPushService.kt +++ b/sdk-push/src/main/java/com/xuqm/sdk/push/vendor/HonorPushService.kt @@ -37,6 +37,7 @@ class HonorPushService : PushVendorInterface { runCatching { val honorPushClass = Class.forName("com.hihonor.push.sdk.HonorPushClient") val instance = honorPushClass.getMethod("getInstance").invoke(null) + ?: error("HonorPushClient.getInstance returned null") val supported = runCatching { honorPushClass.getMethod("checkSupportHonorPush", Context::class.java) .invoke(instance, context) as? Boolean @@ -45,12 +46,12 @@ class HonorPushService : PushVendorInterface { Log.w(TAG, "Honor push is not supported on this device") return } - honorPushClass.getMethod("init", Context::class.java, Boolean::class.javaPrimitiveType) + honorPushClass.getMethod("init", Context::class.java, java.lang.Boolean.TYPE) .invoke(instance, context, false) requestToken(context, honorPushClass, instance) Log.i(TAG, "Honor push registration requested") - }.onFailure { error -> - Log.w(TAG, "Honor push registration failed: ${error.message}") + }.onFailure { + Log.w(TAG, "Honor push registration failed") } } @@ -58,10 +59,11 @@ class HonorPushService : PushVendorInterface { runCatching { val honorPushClass = Class.forName("com.hihonor.push.sdk.HonorPushClient") val instance = honorPushClass.getMethod("getInstance").invoke(null) + ?: error("HonorPushClient.getInstance returned null") honorPushClass.getMethod("turnOffPush").invoke(instance) Log.i(TAG, "Honor push unregistered") - }.onFailure { error -> - Log.w(TAG, "Honor push unregistration failed: ${error.message}") + }.onFailure { + Log.w(TAG, "Honor push unregistration failed") } } @@ -88,16 +90,14 @@ class HonorPushService : PushVendorInterface { Log.i(TAG, "Honor push token acquired") } } - "onFailure" -> { - Log.w(TAG, "Honor push token failed: ${args?.joinToString()}") - } + "onFailure" -> Log.w(TAG, "Honor push token request failed") } null } honorPushClass.getMethod("getPushToken", callbackClass) .invoke(instance, callback) - }.onFailure { error -> - Log.w(TAG, "Honor push token request failed: ${error.message}") + }.onFailure { + Log.w(TAG, "Honor push token request failed") } } } diff --git a/sdk-push/src/main/java/com/xuqm/sdk/push/vendor/HuaweiPushService.kt b/sdk-push/src/main/java/com/xuqm/sdk/push/vendor/HuaweiPushService.kt index e609e24..2a99719 100644 --- a/sdk-push/src/main/java/com/xuqm/sdk/push/vendor/HuaweiPushService.kt +++ b/sdk-push/src/main/java/com/xuqm/sdk/push/vendor/HuaweiPushService.kt @@ -56,8 +56,8 @@ class HuaweiPushService : PushVendorInterface { } else { Log.w(TAG, "Huawei appId not found, skipping registration") } - }.onFailure { error -> - Log.w(TAG, "Huawei push registration failed: ${error.message}") + }.onFailure { + Log.w(TAG, "Huawei push registration failed") } } @@ -75,8 +75,8 @@ class HuaweiPushService : PushVendorInterface { ).invoke(instance, appId, "HCM") Log.i(TAG, "Huawei push unregistered") } - }.onFailure { error -> - Log.w(TAG, "Huawei push unregistration failed: ${error.message}") + }.onFailure { + Log.w(TAG, "Huawei push unregistration failed") } } diff --git a/sdk-push/src/main/java/com/xuqm/sdk/push/vendor/OppoPushService.kt b/sdk-push/src/main/java/com/xuqm/sdk/push/vendor/OppoPushService.kt index 21d7c66..9b880a8 100644 --- a/sdk-push/src/main/java/com/xuqm/sdk/push/vendor/OppoPushService.kt +++ b/sdk-push/src/main/java/com/xuqm/sdk/push/vendor/OppoPushService.kt @@ -15,7 +15,7 @@ class OppoPushService : PushVendorInterface { override fun isAvailable(context: Context): Boolean = runCatching { HeytapPushManager.isSupportPush(context.applicationContext) } - .onFailure { Log.w(TAG, "OPPO push availability check failed: ${it.message}") } + .onFailure { Log.w(TAG, "OPPO push availability check failed") } .getOrDefault(false) override fun register(context: Context, config: PushVendorConfig) { @@ -37,7 +37,7 @@ class OppoPushService : PushVendorInterface { runCatching { HeytapPushManager.requestNotificationPermission() } Log.i(TAG, "OPPO push registration requested") }.onFailure { error -> - Log.e(TAG, "OPPO push registration failed: ${error.message}", error) + Log.e(TAG, "OPPO push registration failed: errorType=${error.javaClass.simpleName}") } } @@ -45,8 +45,8 @@ class OppoPushService : PushVendorInterface { runCatching { HeytapPushManager.unRegister() Log.i(TAG, "OPPO push unregistered") - }.onFailure { error -> - Log.w(TAG, "OPPO push unregistration failed: ${error.message}") + }.onFailure { + Log.w(TAG, "OPPO push unregistration failed") } } @@ -56,7 +56,7 @@ class OppoPushService : PushVendorInterface { PushSDK.updateNativePushToken(context, vendor, regId) Log.i(TAG, "OPPO push token acquired") } else { - Log.e(TAG, "OPPO push register callback failed: code=$code, message=$regId") + Log.e(TAG, "OPPO push register callback failed: code=$code") } } @@ -65,7 +65,7 @@ class OppoPushService : PushVendorInterface { } override fun onSetPushTime(code: Int, message: String?) { - Log.d(TAG, "OPPO set push time callback: code=$code, message=$message") + Log.d(TAG, "OPPO set push time callback: code=$code") } override fun onGetPushStatus(code: Int, status: Int) { @@ -77,7 +77,7 @@ class OppoPushService : PushVendorInterface { } override fun onError(code: Int, message: String?) { - Log.e(TAG, "OPPO push error: code=$code, message=$message") + Log.e(TAG, "OPPO push error: code=$code") } } diff --git a/sdk-push/src/main/java/com/xuqm/sdk/push/vendor/VivoPushService.kt b/sdk-push/src/main/java/com/xuqm/sdk/push/vendor/VivoPushService.kt index 438cc8d..5c490f6 100644 --- a/sdk-push/src/main/java/com/xuqm/sdk/push/vendor/VivoPushService.kt +++ b/sdk-push/src/main/java/com/xuqm/sdk/push/vendor/VivoPushService.kt @@ -46,7 +46,7 @@ class VivoPushService : PushVendorInterface { .invoke(builder, true) true }.getOrElse { e -> - Log.w(TAG, "agreePrivacyStatement not available in this SDK version: ${e.message}") + Log.w(TAG, "agreePrivacyStatement is unavailable in this SDK version") false } Log.i(TAG, "Vivo push PushConfig built: agreePrivacyStatement=$privacySet") @@ -71,8 +71,8 @@ class VivoPushService : PushVendorInterface { } pushClientClass.getMethod("turnOnPush", listenerClass).invoke(instance, listener) Log.i(TAG, "Vivo push registration requested") - }.onFailure { error -> - Log.e(TAG, "Vivo push registration failed: ${error.message}") + }.onFailure { + Log.e(TAG, "Vivo push registration failed") } } @@ -83,8 +83,8 @@ class VivoPushService : PushVendorInterface { .invoke(null, context) pushClientClass.getMethod("turnOffPush").invoke(instance) Log.i(TAG, "Vivo push unregistered") - }.onFailure { error -> - Log.w(TAG, "Vivo push unregistration failed: ${error.message}") + }.onFailure { + Log.w(TAG, "Vivo push unregistration failed") } } diff --git a/sdk-push/src/main/java/com/xuqm/sdk/push/vendor/XiaomiPushService.kt b/sdk-push/src/main/java/com/xuqm/sdk/push/vendor/XiaomiPushService.kt index 3fe51c2..cd476af 100644 --- a/sdk-push/src/main/java/com/xuqm/sdk/push/vendor/XiaomiPushService.kt +++ b/sdk-push/src/main/java/com/xuqm/sdk/push/vendor/XiaomiPushService.kt @@ -31,8 +31,8 @@ class XiaomiPushService : PushVendorInterface { String::class.java, ).invoke(null, context, appId, appKey) Log.i(TAG, "Xiaomi push registration requested") - }.onFailure { error -> - Log.e(TAG, "Xiaomi push registration failed: ${error.message}") + }.onFailure { + Log.e(TAG, "Xiaomi push registration failed") } } @@ -42,8 +42,8 @@ class XiaomiPushService : PushVendorInterface { miPushClass.getMethod("unregisterPush", Context::class.java) .invoke(null, context) Log.i(TAG, "Xiaomi push unregistered") - }.onFailure { error -> - Log.w(TAG, "Xiaomi push unregistration failed: ${error.message}") + }.onFailure { + Log.w(TAG, "Xiaomi push unregistration failed") } } diff --git a/sdk-push/src/main/java/com/xuqm/sdk/push/vivo/XuqmVivoPushReceiver.kt b/sdk-push/src/main/java/com/xuqm/sdk/push/vivo/XuqmVivoPushReceiver.kt index f6cee17..c0b30f7 100644 --- a/sdk-push/src/main/java/com/xuqm/sdk/push/vivo/XuqmVivoPushReceiver.kt +++ b/sdk-push/src/main/java/com/xuqm/sdk/push/vivo/XuqmVivoPushReceiver.kt @@ -13,16 +13,16 @@ class XuqmVivoPushReceiver : OpenClientPushMessageReceiver() { override fun onReceiveRegId(context: Context, regId: String) { if (regId.isBlank()) return - Log.i(TAG, "Vivo push registered: regId=$regId") + Log.i(TAG, "Vivo push token received") runCatching { PushSDK.updateNativePushToken(context.applicationContext, PushVendor.VIVO, regId) - }.onFailure { error -> - Log.e(TAG, "Unable to persist vivo push token: ${error.message}") + }.onFailure { + Log.e(TAG, "Unable to persist Vivo push token") } } override fun onTransmissionMessage(context: Context, message: UnvarnishedMessage) { - Log.d(TAG, "Vivo pass-through message received: ${message.message}") + Log.d(TAG, "Vivo pass-through message received") } override fun onNotificationMessageClicked(context: Context, message: UPSNotificationMessage) { diff --git a/sdk-push/src/main/java/com/xuqm/sdk/push/xiaomi/XuqmXiaomiPushReceiver.kt b/sdk-push/src/main/java/com/xuqm/sdk/push/xiaomi/XuqmXiaomiPushReceiver.kt index 2f02bc1..f68da9f 100644 --- a/sdk-push/src/main/java/com/xuqm/sdk/push/xiaomi/XuqmXiaomiPushReceiver.kt +++ b/sdk-push/src/main/java/com/xuqm/sdk/push/xiaomi/XuqmXiaomiPushReceiver.kt @@ -13,24 +13,24 @@ class XuqmXiaomiPushReceiver : PushMessageReceiver() { override fun onReceiveRegisterResult(context: Context, message: MiPushCommandMessage) { if (message.resultCode != 0L) { - Log.w(TAG, "Xiaomi push registration failed: code=${message.resultCode} reason=${message.reason}") + Log.w(TAG, "Xiaomi push registration failed: code=${message.resultCode}") return } val regId = message.commandArguments?.firstOrNull()?.takeIf { it.isNotBlank() } ?: return - Log.i(TAG, "Xiaomi push registered: regId=$regId") + Log.i(TAG, "Xiaomi push token received") runCatching { PushSDK.updateNativePushToken(context.applicationContext, PushVendor.XIAOMI, regId) - }.onFailure { error -> - Log.w(TAG, "Unable to persist Xiaomi push token: ${error.message}") + }.onFailure { + Log.w(TAG, "Unable to persist Xiaomi push token") } } override fun onReceivePassThroughMessage(context: Context, message: MiPushMessage) { - Log.d(TAG, "Xiaomi pass-through message received: ${message.content}") + Log.d(TAG, "Xiaomi pass-through message received") } override fun onNotificationMessageClicked(context: Context, message: MiPushMessage) { - Log.d(TAG, "Xiaomi notification clicked: ${message.title}") + Log.d(TAG, "Xiaomi notification clicked") val params = mutableMapOf() // Extract URL from payload JSON val content = message.content?.takeIf { it.isNotBlank() } diff --git a/sdk-push/src/test/java/com/xuqm/sdk/push/PublicLifecycleApiTest.kt b/sdk-push/src/test/java/com/xuqm/sdk/push/PublicLifecycleApiTest.kt new file mode 100644 index 0000000..da98984 --- /dev/null +++ b/sdk-push/src/test/java/com/xuqm/sdk/push/PublicLifecycleApiTest.kt @@ -0,0 +1,14 @@ +package com.xuqm.sdk.push + +import org.junit.Assert.assertFalse +import org.junit.Test + +class PublicLifecycleApiTest { + @Test + fun `login lifecycle hooks are not public API`() { + val methods = PushSDK::class.java.methods.filterNot { it.isSynthetic }.map { it.name }.toSet() + assertFalse("logout" in methods) + assertFalse("onSdkLogin" in methods) + assertFalse("onSdkLogout" in methods) + } +} diff --git a/sdk-update/README.md b/sdk-update/README.md index 9c2fccd..f180662 100644 --- a/sdk-update/README.md +++ b/sdk-update/README.md @@ -12,13 +12,13 @@ implementation("com.xuqm:sdk-core:VERSION") // 必须 ## 快速开始 ```kotlin -val result = UpdateSDK.checkUpdate( - appKey = XuqmSDK.appKey, - platform = Platform.ANDROID, -) +val result = UpdateSDK.checkAppUpdate(context) if (result.needsUpdate) { - UpdateSDK.downloadAndInstall(context, result.downloadUrl) + UpdateSDK.downloadAndInstallApk( + context, + ApkInstallRequest(result.downloadUrl, result.versionCode, requireNotNull(result.apkHash)), + ) } ``` @@ -26,8 +26,9 @@ if (result.needsUpdate) { | API | 说明 | |-----|------| -| `UpdateSDK.checkUpdate(appKey, platform)` | 检查 App 更新 | -| `UpdateSDK.downloadAndInstall(context, url)` | 下载 APK 并调起系统安装器 | +| `UpdateSDK.checkAppUpdate(context, bypassIgnore)` | 检查 App 更新 | +| `UpdateSDK.downloadAndInstallApk(context, request, onProgress)` | 下载、校验并调起系统安装器 | +| `UpdateSDK.setLoginUpdateListener(listener)` | 接收登录后自动补检结果 | ### UpdateResult @@ -45,5 +46,7 @@ data class UpdateResult( ## 工作原理 - `downloadAndInstall` 将 APK 下载到 `getExternalFilesDir(null)`,通过 `FileProvider` 触发系统安装 -- AndroidManifest 中已配置 `@xml/file_paths`(`external-files-path`) +- 安装所需 FileProvider 由依赖的 `sdk-file` 唯一声明,Update 不复制 Manifest 或 paths 资源 - 支持 WebSocket 实时推送更新通知 +- 是否要求登录由租户平台配置决定;要求登录时,未登录检查会静默跳过 +- 账号切换会取消旧会话检查并清理灰度判断缓存,新会话只自动补检一次 diff --git a/sdk-update/build.gradle.kts b/sdk-update/build.gradle.kts index 0605bdd..5b7fd0e 100644 --- a/sdk-update/build.gradle.kts +++ b/sdk-update/build.gradle.kts @@ -31,7 +31,7 @@ android { } dependencies { - api(project(":sdk-core")) + api(project(":sdk-file")) implementation(libs.kotlinx.coroutines.android) implementation(libs.gson) testImplementation(libs.junit4) diff --git a/sdk-update/scripts/xuqm_release.gradle.kts b/sdk-update/scripts/xuqm_release.gradle.kts index d3bad32..296129d 100644 --- a/sdk-update/scripts/xuqm_release.gradle.kts +++ b/sdk-update/scripts/xuqm_release.gradle.kts @@ -254,7 +254,7 @@ tasks.register("xuqmRelease") { val versionCode = (defaultConfig::class.java.getMethod("getVersionCode").invoke(defaultConfig) as? Int) ?: throw GradleException("versionCode not set") val applicationId = defaultConfig::class.java.getMethod("getApplicationId").invoke(defaultConfig) as? String ?: "" - println("[xuqm] Local version: $versionName ($versionCode), packageName: $applicationId, appKey: $appKey") + println("[xuqm] Local version: $versionName ($versionCode), packageName: $applicationId") // ── 2. Check server latest ───────────────────────────────────────── val listResp = httpGet("$serverUrl/api/v1/updates/app/list?appKey=$appKey&platform=ANDROID", apiToken) @@ -297,7 +297,7 @@ tasks.register("xuqmRelease") { val apkDir = File(buildDir, "outputs/apk/release") val apkFile = apkDir.listFiles { f -> f.extension == "apk" }?.firstOrNull() ?: throw GradleException("No APK found in ${apkDir.absolutePath}. Did assembleRelease succeed?") - println("[xuqm] APK: ${apkFile.absolutePath}") + println("[xuqm] APK prepared: ${apkFile.name}") if (dryRun) { println("[xuqm] Dry-run summary:") @@ -307,7 +307,7 @@ tasks.register("xuqmRelease") { println(" publishImmediately=$publishImmediately") println(" autoPublishAfterReview=$autoPublish") println(" scheduledPublishAt=${scheduledAt.ifBlank { "-" }}") - println(" webhookUrl=${webhookUrl.ifBlank { "-" }}") + println(" webhookConfigured=${webhookUrl.isNotBlank()}") println(" forceUpdate=$forceUpdate") println(" grayEnabled=$grayEnabled, grayPercent=$grayPercent") println(" storeTargets=${storeTargetList.joinToString(",").ifBlank { "-" }}") diff --git a/sdk-update/src/main/AndroidManifest.xml b/sdk-update/src/main/AndroidManifest.xml index 941d653..af3e785 100644 --- a/sdk-update/src/main/AndroidManifest.xml +++ b/sdk-update/src/main/AndroidManifest.xml @@ -1,3 +1,11 @@ + + + + diff --git a/sdk-update/src/main/java/com/xuqm/sdk/update/UpdateSDK.kt b/sdk-update/src/main/java/com/xuqm/sdk/update/UpdateSDK.kt index dc1e875..3266216 100644 --- a/sdk-update/src/main/java/com/xuqm/sdk/update/UpdateSDK.kt +++ b/sdk-update/src/main/java/com/xuqm/sdk/update/UpdateSDK.kt @@ -10,6 +10,8 @@ import android.provider.Settings import android.util.Log import androidx.core.content.FileProvider import com.xuqm.sdk.XuqmSDK +import com.xuqm.sdk.XuqmInitializationState +import com.xuqm.sdk.XuqmLoginSession import com.xuqm.sdk.file.FileSDK import com.xuqm.sdk.core.ServiceEndpointRegistry import com.xuqm.sdk.network.ApiClient @@ -27,14 +29,29 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.launch +import kotlinx.coroutines.Job import kotlinx.coroutines.withContext +import kotlinx.coroutines.currentCoroutineContext import java.io.File +import java.util.concurrent.ConcurrentHashMap object UpdateSDK { private val sha256Pattern = Regex("^[a-fA-F0-9]{64}$") private val api: UpdateApi get() = ApiClient.create(UpdateApi::class.java, ServiceEndpointRegistry.updateBaseUrl) + private val backgroundScope = CoroutineScope(kotlinx.coroutines.SupervisorJob() + Dispatchers.IO) + @Volatile private var loginGeneration: Long = 0L + @Volatile private var loginRecheckJob: Job? = null + @Volatile private var loginUpdateListener: UpdateListener? = null + @Volatile private var activeUserId: String? = null + private val activeInstallJobs = ConcurrentHashMap.newKeySet() + private val installAuthorizations = ConcurrentHashMap() + + private data class InstallAuthorization( + val generation: Long, + val userId: String?, + ) private fun resolveUserId(): String? = XuqmSDK.getUserId() @@ -68,6 +85,64 @@ object UpdateSDK { .apply() } + /** + * 注册登录后自动补检监听器。SDK 只回调检查结果,宿主仍完全负责更新 UI 与安装时机。 + */ + fun setLoginUpdateListener(listener: UpdateListener?) { + loginUpdateListener = listener + } + + /** sdk-core 的共享登录通知入口;同一会话只自动补检一次。 */ + private fun onSdkLogin(session: XuqmLoginSession) { + val generation = synchronized(this) { + if (!UpdateSessionPolicy.shouldTransition(activeUserId, session.userId)) return + activeUserId = session.userId + loginGeneration += 1 + loginRecheckJob?.cancel() + invalidateInstallAuthorizations() + loginGeneration + } + invalidateSessionChecks(XuqmSDK.appContext) + loginRecheckJob = backgroundScope.launch { + runCatching { + XuqmSDK.awaitInitialization() + if (!UpdateLoginPolicy.shouldRecheckAfterLogin(XuqmSDK.platformConfig)) return@launch + val result = checkAppUpdate(XuqmSDK.appContext) + if (generation != loginGeneration || XuqmSDK.getUserId() != session.userId) return@launch + val listener = loginUpdateListener ?: return@launch + Handler(Looper.getMainLooper()).post { + runCatching { + if (result?.needsUpdate == true) { + listener.onUpdateAvailable(result) + } else { + listener.onNoUpdate() + } + } + } + }.onFailure { error -> + if (generation == loginGeneration) { + // 后台补检失败不得传播到宿主主线程。 + Handler(Looper.getMainLooper()).post { + runCatching { loginUpdateListener?.onError(error) } + } + } + } + } + } + + /** 退出登录立即作废灰度判断与正在执行的登录后检查。 */ + private fun onSdkLogout() { + synchronized(this) { + if (!UpdateSessionPolicy.shouldTransition(activeUserId, null)) return + activeUserId = null + loginGeneration += 1 + loginRecheckJob?.cancel() + loginRecheckJob = null + invalidateInstallAuthorizations() + } + if (XuqmSDK.isInitialized()) invalidateSessionChecks(XuqmSDK.appContext) + } + /** 忽略指定版本,下次检测到该版本时不再提示(强制更新版本不受此影响) */ fun ignoreVersion(context: Context, versionCode: Int) { prefs(context).edit().putBoolean("ignored_v$versionCode", true).apply() @@ -112,6 +187,7 @@ object UpdateSDK { versionCode: Int, expectedSha256: String, ) = withContext(Dispatchers.Main) { + requireInstallAuthorization(versionCode, expectedSha256) val apkFile = resolveDownloadedApk(context, versionCode) ?: throw UpdateInstallException( UpdateInstallErrorCode.DOWNLOAD_FAILED, @@ -161,6 +237,10 @@ object UpdateSDK { */ suspend fun checkAppUpdate(context: Context, bypassIgnore: Boolean = false): UpdateInfo? = withContext(Dispatchers.IO) { awaitInitialization() + if (!UpdateLoginPolicy.shouldCheck(XuqmSDK.platformConfig, resolveUserId())) { + // 平台明确要求登录时,未登录属于“跳过”,不是错误。 + return@withContext null + } val packageInfo = context.packageManager.getPackageInfo(context.packageName, 0) val versionCode = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { packageInfo.longVersionCode.toInt() @@ -169,7 +249,6 @@ object UpdateSDK { packageInfo.versionCode } val userId = resolveUserId() - val url = ServiceEndpointRegistry.updateBaseUrl val prefs = prefs(context) val cacheKey = "${XuqmSDK.appKey}_${versionCode}_${userId.orEmpty()}" @@ -177,18 +256,20 @@ object UpdateSDK { if (!bypassIgnore) { val cached = getCachedUpdateInfo(prefs, cacheKey) if (cached != null) { - Log.d("UpdateSDK", "Using cached update info for cacheKey=$cacheKey") + Log.d("UpdateSDK", "Using cached update info") val afterIgnore = if (cached.needsUpdate && !cached.forceUpdate && isVersionIgnored(context, cached.versionCode)) { cached.copy(needsUpdate = false) } else { cached } if (afterIgnore.needsUpdate && afterIgnore.versionCode > 0) { - return@withContext afterIgnore.copy( + return@withContext authorizeInstall( + afterIgnore.copy( alreadyDownloaded = isVerifiedApkDownloaded(context, afterIgnore), + ), ) } - return@withContext afterIgnore + return@withContext authorizeInstall(afterIgnore) } } @@ -209,7 +290,7 @@ object UpdateSDK { currentVersionName = versionName, ) if (response.code == 40404) { - throw IllegalStateException("更新服务未开通 (code=40404) [appKey=${XuqmSDK.appKey}]") + throw IllegalStateException("更新服务未开通 (code=40404)") } response.data?.toUpdateInfo()?.let { info -> val normalized = info.copy(downloadUrl = normalizeDownloadUrl(info.downloadUrl) ?: info.downloadUrl) @@ -229,10 +310,13 @@ object UpdateSDK { } // Cache the result (30 min TTL) putCachedUpdateInfo(prefs, cacheKey, result) - result + authorizeInstall(result) } }.onFailure { e -> - Log.e("UpdateSDK", "checkUpdate failed [url=$url appKey=${XuqmSDK.appKey} versionCode=$versionCode userId=$userId]: ${e.message}", e) + Log.e( + "UpdateSDK", + "Update check failed: versionCode=$versionCode errorType=${e.javaClass.simpleName}", + ) }.getOrNull() } @@ -244,18 +328,28 @@ object UpdateSDK { kotlinx.coroutines.delay(100) } } ?: run { - val msg = "XuqmSDK init timeout (15s). Check that config.xuqm is present and package name matches." + val msg = "XuqmSDK init timeout (15s). Check assets/config/config.xuqmconfig and package identity." Log.e("UpdateSDK", msg) throw IllegalStateException(msg) } } - // 等待远程平台配置拉取完成(bugCollectEnabled 等字段在此之后才生效) - kotlinx.coroutines.withTimeoutOrNull(10_000L) { - runCatching { XuqmSDK.awaitInitialization() } - } + kotlinx.coroutines.withTimeout(10_000L) { XuqmSDK.awaitInitialization() } + check( + XuqmSDK.initializationStatus.state == XuqmInitializationState.READY || + XuqmSDK.initializationStatus.state == XuqmInitializationState.DEGRADED, + ) { "XuqmSDK platform configuration is not ready" } Log.d("UpdateSDK", "XuqmSDK initialized, proceeding with update check") } + private fun invalidateSessionChecks(context: Context) { + val prefs = prefs(context) + val editor = prefs.edit() + prefs.all.keys + .filter { it.endsWith("_data") || it.endsWith("_ts") } + .forEach(editor::remove) + editor.apply() + } + // ───────────────────────────────────────────────────────────────────────── // 下载与安装 // ───────────────────────────────────────────────────────────────────────── @@ -278,53 +372,61 @@ object UpdateSDK { "sha256 must contain exactly 64 hexadecimal characters", ) } - - if (isApkDownloaded(context, request.versionCode, request.sha256)) { - val apkFile = requireNotNull(resolveDownloadedApk(context, request.versionCode)) - withContext(Dispatchers.Main) { installApk(context, apkFile) } - return@withContext - } - - val apkFile = versionedApkFile(context, request.versionCode) - val retryCount = request.retryCount.coerceIn(0, 5) - var lastDownloadError: Throwable? = null - repeat(retryCount + 1) { attempt -> - try { - FileSDK.downloadToFileDetailed(request.downloadUrl, apkFile) { progress -> - onProgress?.invoke( - UpdateDownloadProgress( - bytesDownloaded = progress.bytesDownloaded, - totalBytes = progress.totalBytes, - ), - ) - } - if (!FileSDK.matchesSha256(apkFile, request.sha256)) { - apkFile.delete() - throw UpdateInstallException( - UpdateInstallErrorCode.HASH_MISMATCH, - "Downloaded APK SHA-256 does not match version ${request.versionCode}", - ) - } + val authorization = requireInstallAuthorization(request.versionCode, request.sha256) + val runningJob = currentCoroutineContext()[Job] + runningJob?.let(activeInstallJobs::add) + try { + if (isApkDownloaded(context, request.versionCode, request.sha256)) { + assertAuthorizationCurrent(authorization) + val apkFile = requireNotNull(resolveDownloadedApk(context, request.versionCode)) withContext(Dispatchers.Main) { installApk(context, apkFile) } return@withContext - } catch (error: CancellationException) { - throw UpdateInstallException( - UpdateInstallErrorCode.DOWNLOAD_CANCELLED, - "APK download was cancelled", - error, - ) - } catch (error: UpdateInstallException) { - throw error - } catch (error: Exception) { - lastDownloadError = error - if (attempt < retryCount) delay(300L * (attempt + 1)) } + + val apkFile = versionedApkFile(context, request.versionCode) + val retryCount = request.retryCount.coerceIn(0, 5) + var lastDownloadError: Throwable? = null + repeat(retryCount + 1) { attempt -> + try { + FileSDK.downloadToFileDetailed(request.downloadUrl, apkFile) { progress -> + onProgress?.invoke( + UpdateDownloadProgress( + bytesDownloaded = progress.bytesDownloaded, + totalBytes = progress.totalBytes, + ), + ) + } + if (!FileSDK.matchesSha256(apkFile, request.sha256)) { + apkFile.delete() + throw UpdateInstallException( + UpdateInstallErrorCode.HASH_MISMATCH, + "Downloaded APK SHA-256 does not match version ${request.versionCode}", + ) + } + assertAuthorizationCurrent(authorization) + withContext(Dispatchers.Main) { installApk(context, apkFile) } + return@withContext + } catch (error: CancellationException) { + throw UpdateInstallException( + UpdateInstallErrorCode.DOWNLOAD_CANCELLED, + "APK download was cancelled", + error, + ) + } catch (error: UpdateInstallException) { + throw error + } catch (error: Exception) { + lastDownloadError = error + if (attempt < retryCount) delay(300L * (attempt + 1)) + } + } + throw UpdateInstallException( + UpdateInstallErrorCode.DOWNLOAD_FAILED, + "Unable to download APK after ${retryCount + 1} attempt(s)", + lastDownloadError, + ) + } finally { + runningJob?.let(activeInstallJobs::remove) } - throw UpdateInstallException( - UpdateInstallErrorCode.DOWNLOAD_FAILED, - "Unable to download APK after ${retryCount + 1} attempt(s)", - lastDownloadError, - ) } /** Java/RN entry point backed by the same suspending implementation. */ @@ -415,7 +517,11 @@ object UpdateSDK { val intent = try { Intent(Intent.ACTION_VIEW).apply { flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_GRANT_READ_URI_PERMISSION - val uri = FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", apkFile) + val uri = FileProvider.getUriForFile( + context, + "${context.packageName}.xuqm.fileprovider", + apkFile, + ) setDataAndType(uri, "application/vnd.android.package-archive") } } catch (error: Exception) { @@ -441,4 +547,70 @@ object UpdateSDK { ) } } + + private fun authorizationKey(versionCode: Int, sha256: String): String = + "$versionCode:${sha256.lowercase()}" + + private fun authorizeInstall(info: UpdateInfo): UpdateInfo { + val hash = info.apkHash?.takeIf { it.matches(sha256Pattern) } + if (info.needsUpdate && info.versionCode > 0 && hash != null) { + installAuthorizations[authorizationKey(info.versionCode, hash)] = + InstallAuthorization(loginGeneration, resolveUserId()) + } + return info + } + + private fun requireInstallAuthorization( + versionCode: Int, + sha256: String, + ): InstallAuthorization { + val authorization = installAuthorizations[authorizationKey(versionCode, sha256)] + ?: throw UpdateInstallException( + UpdateInstallErrorCode.AUTHORIZATION_EXPIRED, + "Update authorization is missing or expired; check the version again", + ) + assertAuthorizationCurrent(authorization) + return authorization + } + + private fun assertAuthorizationCurrent(authorization: InstallAuthorization) { + if (!UpdateSessionPolicy.isAuthorizationCurrent( + authorization.generation, + authorization.userId, + loginGeneration, + resolveUserId(), + ) + ) { + throw UpdateInstallException( + UpdateInstallErrorCode.AUTHORIZATION_EXPIRED, + "Update authorization expired because the login session changed", + ) + } + } + + private fun invalidateInstallAuthorizations() { + installAuthorizations.clear() + activeInstallJobs.toList().forEach(Job::cancel) + activeInstallJobs.clear() + } +} + +/** + * Update 登录门禁的唯一规则。旧租户配置缺少字段时必须安全地要求登录。 + */ +internal object UpdateLoginPolicy { + fun isEnabled(config: com.xuqm.sdk.network.SdkPlatformConfig?): Boolean = + config?.features?.update == true + + fun requiresLogin(config: com.xuqm.sdk.network.SdkPlatformConfig?): Boolean = + config?.updateRequiresLogin != false + + fun shouldCheck( + config: com.xuqm.sdk.network.SdkPlatformConfig?, + userId: String?, + ): Boolean = isEnabled(config) && + (!requiresLogin(config) || !userId.isNullOrBlank()) + + fun shouldRecheckAfterLogin(config: com.xuqm.sdk.network.SdkPlatformConfig?): Boolean = + isEnabled(config) && requiresLogin(config) } diff --git a/sdk-update/src/main/java/com/xuqm/sdk/update/UpdateSessionPolicy.kt b/sdk-update/src/main/java/com/xuqm/sdk/update/UpdateSessionPolicy.kt new file mode 100644 index 0000000..1020ca3 --- /dev/null +++ b/sdk-update/src/main/java/com/xuqm/sdk/update/UpdateSessionPolicy.kt @@ -0,0 +1,15 @@ +package com.xuqm.sdk.update + +/** Update 会话身份与授权快照的唯一比较规则。 */ +internal object UpdateSessionPolicy { + fun shouldTransition(currentUserId: String?, nextUserId: String?): Boolean = + currentUserId != nextUserId + + fun isAuthorizationCurrent( + authorizationGeneration: Long, + authorizationUserId: String?, + currentGeneration: Long, + currentUserId: String?, + ): Boolean = + authorizationGeneration == currentGeneration && authorizationUserId == currentUserId +} diff --git a/sdk-update/src/main/java/com/xuqm/sdk/update/UpdateWebSocket.kt b/sdk-update/src/main/java/com/xuqm/sdk/update/UpdateWebSocket.kt index 0c036a3..5563a03 100644 --- a/sdk-update/src/main/java/com/xuqm/sdk/update/UpdateWebSocket.kt +++ b/sdk-update/src/main/java/com/xuqm/sdk/update/UpdateWebSocket.kt @@ -90,7 +90,7 @@ class UpdateWebSocket internal constructor( .trimEnd('/') val url = "$baseUrl/ws/updates?appKey=$appKey" - Log.d(TAG, "Connecting to $url") + Log.d(TAG, "Connecting update WebSocket") val request = Request.Builder() .url(url) @@ -105,23 +105,26 @@ class UpdateWebSocket internal constructor( } override fun onMessage(webSocket: WebSocket, text: String) { - Log.d(TAG, "Received: $text") + Log.d(TAG, "Update WebSocket message received: length=${text.length}") handleMessage(text) } override fun onClosing(webSocket: WebSocket, code: Int, reason: String) { - Log.d(TAG, "Server closing: $code $reason") + Log.d(TAG, "Update WebSocket server closing: code=$code") webSocket.close(code, reason) } override fun onClosed(webSocket: WebSocket, code: Int, reason: String) { - Log.d(TAG, "Closed: $code $reason") + Log.d(TAG, "Update WebSocket closed: code=$code") connected = false scheduleReconnect() } override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) { - Log.w(TAG, "Connection failed: ${t.message}") + Log.w( + TAG, + "Update WebSocket connection failed: errorType=${t.javaClass.simpleName}", + ) connected = false scheduleReconnect() } @@ -141,7 +144,7 @@ class UpdateWebSocket internal constructor( checkUpdateAndNotify() } } catch (e: Exception) { - Log.w(TAG, "Failed to parse message: ${e.message}") + Log.w(TAG, "Failed to parse update WebSocket message") } } @@ -165,7 +168,7 @@ class UpdateWebSocket internal constructor( } } } catch (e: Exception) { - Log.w(TAG, "checkUpdate failed: ${e.message}") + Log.w(TAG, "Update check failed: errorType=${e.javaClass.simpleName}") android.os.Handler(android.os.Looper.getMainLooper()).post { listener.onError(e) } diff --git a/sdk-update/src/main/java/com/xuqm/sdk/update/model/ApkInstall.kt b/sdk-update/src/main/java/com/xuqm/sdk/update/model/ApkInstall.kt index a790b97..f3a45a3 100644 --- a/sdk-update/src/main/java/com/xuqm/sdk/update/model/ApkInstall.kt +++ b/sdk-update/src/main/java/com/xuqm/sdk/update/model/ApkInstall.kt @@ -9,6 +9,7 @@ enum class UpdateInstallErrorCode(val wireValue: String) { INSTALL_PERMISSION_REQUIRED("INSTALL_PERMISSION_REQUIRED"), INSTALLER_UNAVAILABLE("INSTALLER_UNAVAILABLE"), INSTALL_FAILED("INSTALL_FAILED"), + AUTHORIZATION_EXPIRED("AUTHORIZATION_EXPIRED"), } class UpdateInstallException( diff --git a/sdk-update/src/test/java/com/xuqm/sdk/update/PublicLifecycleApiTest.kt b/sdk-update/src/test/java/com/xuqm/sdk/update/PublicLifecycleApiTest.kt new file mode 100644 index 0000000..40005c0 --- /dev/null +++ b/sdk-update/src/test/java/com/xuqm/sdk/update/PublicLifecycleApiTest.kt @@ -0,0 +1,13 @@ +package com.xuqm.sdk.update + +import org.junit.Assert.assertFalse +import org.junit.Test + +class PublicLifecycleApiTest { + @Test + fun `login lifecycle hooks are not public API`() { + val methods = UpdateSDK::class.java.methods.filterNot { it.isSynthetic }.map { it.name }.toSet() + assertFalse("onSdkLogin" in methods) + assertFalse("onSdkLogout" in methods) + } +} diff --git a/sdk-update/src/test/java/com/xuqm/sdk/update/UpdateLoginPolicyTest.kt b/sdk-update/src/test/java/com/xuqm/sdk/update/UpdateLoginPolicyTest.kt new file mode 100644 index 0000000..c3b1d7b --- /dev/null +++ b/sdk-update/src/test/java/com/xuqm/sdk/update/UpdateLoginPolicyTest.kt @@ -0,0 +1,42 @@ +package com.xuqm.sdk.update + +import com.google.gson.Gson +import com.xuqm.sdk.network.SdkPlatformConfig +import com.xuqm.sdk.network.SdkPlatformFeatures +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class UpdateLoginPolicyTest { + @Test + fun `missing remote field skips anonymous check`() { + val legacyConfig = Gson().fromJson("{}", SdkPlatformConfig::class.java) + + assertTrue(UpdateLoginPolicy.requiresLogin(legacyConfig)) + assertFalse(UpdateLoginPolicy.shouldCheck(legacyConfig, null)) + assertFalse(UpdateLoginPolicy.shouldCheck(legacyConfig, "user-1")) + assertFalse(UpdateLoginPolicy.shouldRecheckAfterLogin(legacyConfig)) + } + + @Test + fun `explicit anonymous policy allows check without login`() { + val config = SdkPlatformConfig( + updateRequiresLogin = false, + features = SdkPlatformFeatures(update = true), + ) + + assertFalse(UpdateLoginPolicy.requiresLogin(config)) + assertTrue(UpdateLoginPolicy.shouldCheck(config, null)) + assertFalse(UpdateLoginPolicy.shouldRecheckAfterLogin(config)) + } + + @Test + fun `disabled update feature wins over login policy`() { + val config = SdkPlatformConfig( + updateRequiresLogin = false, + features = SdkPlatformFeatures(update = false), + ) + assertFalse(UpdateLoginPolicy.shouldCheck(config, "user-1")) + assertFalse(UpdateLoginPolicy.shouldRecheckAfterLogin(config)) + } +} diff --git a/sdk-update/src/test/java/com/xuqm/sdk/update/UpdateSessionPolicyTest.kt b/sdk-update/src/test/java/com/xuqm/sdk/update/UpdateSessionPolicyTest.kt new file mode 100644 index 0000000..4d5cb59 --- /dev/null +++ b/sdk-update/src/test/java/com/xuqm/sdk/update/UpdateSessionPolicyTest.kt @@ -0,0 +1,21 @@ +package com.xuqm.sdk.update + +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class UpdateSessionPolicyTest { + @Test + fun `same user does not create a new session generation`() { + assertFalse(UpdateSessionPolicy.shouldTransition("user-a", "user-a")) + assertTrue(UpdateSessionPolicy.shouldTransition("user-a", "user-b")) + assertTrue(UpdateSessionPolicy.shouldTransition("user-a", null)) + } + + @Test + fun `authorization requires both generation and user identity`() { + assertTrue(UpdateSessionPolicy.isAuthorizationCurrent(2, "user-a", 2, "user-a")) + assertFalse(UpdateSessionPolicy.isAuthorizationCurrent(1, "user-a", 2, "user-a")) + assertFalse(UpdateSessionPolicy.isAuthorizationCurrent(2, "user-a", 2, "user-b")) + } +} diff --git a/sdk-update/src/test/java/com/xuqm/sdk/update/model/ApkInstallTest.kt b/sdk-update/src/test/java/com/xuqm/sdk/update/model/ApkInstallTest.kt index 274bb49..648aeb2 100644 --- a/sdk-update/src/test/java/com/xuqm/sdk/update/model/ApkInstallTest.kt +++ b/sdk-update/src/test/java/com/xuqm/sdk/update/model/ApkInstallTest.kt @@ -22,6 +22,7 @@ class ApkInstallTest { "INSTALL_PERMISSION_REQUIRED", "INSTALLER_UNAVAILABLE", "INSTALL_FAILED", + "AUTHORIZATION_EXPIRED", ), UpdateInstallErrorCode.entries.map { it.wireValue }, ) diff --git a/sdk-webview/build.gradle.kts b/sdk-webview/build.gradle.kts index 2acb445..114f91d 100644 --- a/sdk-webview/build.gradle.kts +++ b/sdk-webview/build.gradle.kts @@ -36,7 +36,7 @@ android { } dependencies { - api(project(":sdk-core")) + api(project(":sdk-file")) api(platform(libs.androidx.compose.bom)) api(libs.androidx.ui) api(libs.androidx.ui.graphics) @@ -45,4 +45,5 @@ dependencies { api(libs.androidx.material.icons.extended) api(libs.androidx.webkit) implementation(libs.androidx.activity.compose) + implementation(libs.androidx.lifecycle.runtime.compose) } diff --git a/sdk-webview/src/main/AndroidManifest.xml b/sdk-webview/src/main/AndroidManifest.xml index cef4c1f..8bc6f65 100644 --- a/sdk-webview/src/main/AndroidManifest.xml +++ b/sdk-webview/src/main/AndroidManifest.xml @@ -1,6 +1,11 @@ + - \ No newline at end of file + diff --git a/sdk-webview/src/main/java/com/xuqm/sdk/webview/XWebViewStandardHandlers.kt b/sdk-webview/src/main/java/com/xuqm/sdk/webview/XWebViewStandardHandlers.kt index e661c0c..33cf38b 100644 --- a/sdk-webview/src/main/java/com/xuqm/sdk/webview/XWebViewStandardHandlers.kt +++ b/sdk-webview/src/main/java/com/xuqm/sdk/webview/XWebViewStandardHandlers.kt @@ -48,7 +48,7 @@ object XWebViewStandardHandlers { val userInfo = XuqmSDK.userInfo if (userInfo == null) return errorResponse("not logged in") val data = JSONObject().apply { - put("userId", userInfo.userId ?: "") + put("userId", userInfo.userId) put("nickname", userInfo.name ?: "") put("avatar", userInfo.avatar ?: "") put("phone", userInfo.phone ?: "") diff --git a/sdk-webview/src/main/java/com/xuqm/sdk/webview/XWebViewView.kt b/sdk-webview/src/main/java/com/xuqm/sdk/webview/XWebViewView.kt index 55bd6c6..1d7dbeb 100644 --- a/sdk-webview/src/main/java/com/xuqm/sdk/webview/XWebViewView.kt +++ b/sdk-webview/src/main/java/com/xuqm/sdk/webview/XWebViewView.kt @@ -37,18 +37,19 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.platform.LocalLifecycleOwner import androidx.compose.material3.AlertDialog import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.ui.viewinterop.AndroidView import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleEventObserver +import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.core.content.ContextCompat import androidx.core.content.FileProvider import android.provider.MediaStore import com.xuqm.sdk.file.FileDownloadDestination import com.xuqm.sdk.file.FileSDK +import com.xuqm.sdk.file.platform.PlatformFileSDK import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @@ -222,13 +223,13 @@ internal class XWebViewJsBridge( ) { @JavascriptInterface fun postMessage(data: String) { - android.util.Log.d("XWV", "postMessage: ${data.take(200)}") + android.util.Log.d("XWV", "H5 message received: length=${data.length}") mainHandler.post { val json = runCatching { JSONObject(data) }.getOrNull() val xwv = json?.optString("__xwv")?.takeIf { it.isNotEmpty() } - android.util.Log.d("XWV", "postMessage parsed: xwv=$xwv") - if (xwv != null && json != null) { - onXwvMessage()?.invoke(xwv, json) + android.util.Log.d("XWV", "H5 message parsed: internal=${xwv != null}") + if (xwv != null) { + onXwvMessage()?.invoke(xwv, requireNotNull(json)) } else { onMessage()?.invoke(data) } @@ -306,10 +307,13 @@ internal fun copyUriToWebViewCache(context: Context, uri: Uri, mimeType: String? dest.outputStream().use { out -> input.copyTo(out) } } ?: return null - android.util.Log.d("XWV", "copyUriToWebViewCache: $uri → ${dest.name} ($bytesRead bytes)") + android.util.Log.d("XWV", "WebView upload cache copy completed: bytes=$bytesRead") Uri.fromFile(dest) }.getOrElse { e -> - android.util.Log.w("XWV", "copyUriToWebViewCache failed for $uri: ${e.message}") + android.util.Log.w( + "XWV", + "WebView upload cache copy failed: errorType=${e.javaClass.simpleName}", + ) null } } @@ -349,7 +353,7 @@ fun XWebViewView( // Handles __xwv: 'download' / 'blobdownload' messages from the injected JS. val xwvMessageHandler: (String, JSONObject) -> Unit = handler@{ type, payload -> - android.util.Log.d("XWV", "xwvMessage type=$type, payload=${payload.toString().take(200)}") + android.util.Log.d("XWV", "Internal H5 message received: type=$type") when (type) { "download" -> { val url = payload.optString("url").takeIf { it.isNotBlank() } ?: return@handler @@ -387,7 +391,7 @@ fun XWebViewView( }.onSuccess { file -> withContext(Dispatchers.Main) { dispatchDownloadEvent("__xwvDownloadDone", url, ",success:true") - FileSDK.openFile(context, file) + PlatformFileSDK.openFile(context, file) } }.onFailure { e -> withContext(Dispatchers.Main) { @@ -412,19 +416,23 @@ fun XWebViewView( showStoragePermissionDialog = true return@handler } - android.util.Log.d("XWV", "blobdownload: filename=$filename, b64Len=${b64.length}, dest=${config.downloadDestination}") + android.util.Log.d( + "XWV", + "Blob download requested: bytesBase64Length=${b64.length} " + + "destination=${config.downloadDestination}", + ) coroutineScope.launch(Dispatchers.IO) { runCatching { FileSDK.saveBlobDownload(context, b64, filename, config.downloadDestination) }.onSuccess { file -> - android.util.Log.d("XWV", "blobdownload saved: ${file.absolutePath}, size=${file.length()}") + android.util.Log.d("XWV", "Blob download saved: size=${file.length()}") // For image files, save to the system gallery (相册) so they appear in Photos val savedToGallery = runCatching { FileSDK.saveImageToGallery(context, file) }.getOrDefault(false) android.util.Log.d("XWV", "blobdownload savedToGallery=$savedToGallery") withContext(Dispatchers.Main) { dispatchDownloadEvent("__xwvDownloadDone", url, ",success:true") // Only open with file viewer when not saved to gallery (e.g. zip, docx) - if (!savedToGallery) FileSDK.openFile(context, file) + if (!savedToGallery) PlatformFileSDK.openFile(context, file) } }.onFailure { e -> android.util.Log.e("XWV", "blobdownload FAILED", e) @@ -472,7 +480,7 @@ fun XWebViewView( val pickContentLauncher = rememberLauncherForActivityResult(GetContentWithMimeTypes()) { uri -> val cb = pendingFileCallback.value pendingFileCallback.value = null - android.util.Log.d(XWV_FILE, "pickContentLauncher result: uri=$uri") + android.util.Log.d(XWV_FILE, "File picker returned: hasSelection=${uri != null}") if (uri == null) { android.util.Log.d(XWV_FILE, "pickContentLauncher: user cancelled (uri=null)") cb?.onReceiveValue(null) @@ -492,7 +500,10 @@ fun XWebViewView( if (cursor.moveToFirst()) cursor.getString(0)?.substringAfterLast('/') else null } }.getOrNull() - android.util.Log.d(XWV_FILE, " displayName=$displayName rawMime=$rawMime acceptedMimes=$acceptedMimes") + android.util.Log.d( + XWV_FILE, + "File metadata resolved: rawMime=$rawMime acceptedMimes=$acceptedMimes", + ) val ext = displayName?.substringAfterLast('.', "")?.lowercase(Locale.ROOT)?.takeIf { it.isNotEmpty() } val extMime = ext?.let { MimeTypeMap.getSingleton().getMimeTypeFromExtension(it) } android.util.Log.d(XWV_FILE, " ext=$ext extMime=$extMime") @@ -515,11 +526,11 @@ fun XWebViewView( return@rememberLauncherForActivityResult } coroutineScope.launch(Dispatchers.IO) { - android.util.Log.d(XWV_FILE, "copyUriToWebViewCache start: uri=$uri mime=$actualMime") + android.util.Log.d(XWV_FILE, "WebView upload cache copy started: mime=$actualMime") val localUri = copyUriToWebViewCache(context, uri, actualMime) ?: uri - android.util.Log.d(XWV_FILE, "copyUriToWebViewCache done: localUri=$localUri") + android.util.Log.d(XWV_FILE, "WebView upload cache copy finished") withContext(Dispatchers.Main) { - android.util.Log.d(XWV_FILE, "onReceiveValue: ${arrayOf(localUri).toList()}") + android.util.Log.d(XWV_FILE, "File picker result delivered") cb?.onReceiveValue(arrayOf(localUri)) } } @@ -546,7 +557,11 @@ fun XWebViewView( if (granted && launch) { runCatching { val imageFile = File.createTempFile("cam_", ".jpg", context.cacheDir) - val uri = FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", imageFile) + val uri = FileProvider.getUriForFile( + context, + "${context.packageName}.xuqm.fileprovider", + imageFile, + ) pendingCameraUri.value = uri takePictureLauncher.launch(uri) }.onFailure { @@ -601,7 +616,7 @@ fun XWebViewView( }.onSuccess { file -> withContext(Dispatchers.Main) { dispatchDownloadEvent("__xwvDownloadDone", url, ",success:true") - FileSDK.openFile(context, file) + PlatformFileSDK.openFile(context, file) } }.onFailure { e -> withContext(Dispatchers.Main) { @@ -617,11 +632,11 @@ fun XWebViewView( runCatching { FileSDK.saveBlobDownload(context, b64, filename, config.downloadDestination) }.onSuccess { file -> - android.util.Log.d("XWV", "blobdownload saved: ${file.absolutePath}, size=${file.length()}") + android.util.Log.d("XWV", "Blob download saved: size=${file.length()}") val savedToGallery = runCatching { FileSDK.saveImageToGallery(context, file) }.getOrDefault(false) withContext(Dispatchers.Main) { dispatchDownloadEvent("__xwvDownloadDone", url, ",success:true") - if (!savedToGallery) FileSDK.openFile(context, file) + if (!savedToGallery) PlatformFileSDK.openFile(context, file) } }.onFailure { e -> android.util.Log.e("XWV", "blobdownload FAILED", e) @@ -657,7 +672,11 @@ fun XWebViewView( if (ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED) { runCatching { val imageFile = File.createTempFile("cam_", ".jpg", context.cacheDir) - val uri = FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", imageFile) + val uri = FileProvider.getUriForFile( + context, + "${context.packageName}.xuqm.fileprovider", + imageFile, + ) pendingCameraUri.value = uri takePictureLauncher.launch(uri) }.onFailure { @@ -679,6 +698,11 @@ fun XWebViewView( settings.domStorageEnabled = true settings.useWideViewPort = true settings.loadWithOverviewMode = true + settings.allowFileAccess = true + settings.allowContentAccess = true + settings.javaScriptCanOpenWindowsAutomatically = true + @SuppressLint("SetJavaScriptEnabled") + settings.mixedContentMode = android.webkit.WebSettings.MIXED_CONTENT_ALWAYS_ALLOW // 冷启动首屏强制回源拉最新;首屏加载完成后在 onPageFinished 中恢复 LOAD_DEFAULT,使用过程走缓存。 settings.cacheMode = if (config.freshOnColdStart) WebSettings.LOAD_NO_CACHE else WebSettings.LOAD_DEFAULT @@ -727,7 +751,7 @@ fun XWebViewView( val code = error?.errorCode ?: -1 val desc = error?.description?.toString() ?: "Unknown error" val url = request.url?.toString() ?: "" - android.util.Log.e("XWV", "onReceivedError: code=$code, desc=$desc, url=$url") + android.util.Log.e("XWV", "Main-frame load failed: code=$code") mainHandler.post { config.onPageError?.invoke(code, desc, url) } } } @@ -821,7 +845,7 @@ fun XWebViewView( container }, update = { container -> - val wv = (container as? FrameLayout)?.getChildAt(0) as? WebView ?: return@AndroidView + val wv = container.getChildAt(0) as? WebView ?: return@AndroidView if (wv.url.isNullOrBlank() && config.url.isNotBlank()) { wv.loadUrl(config.url) } diff --git a/settings.gradle.kts b/settings.gradle.kts index 1f64f58..b8e6553 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -1,4 +1,5 @@ pluginManagement { + includeBuild("sdk-common-plugin") repositories { maven(url = "https://nexus.xuqinmin.com/repository/android/") maven(url = "https://developer.hihonor.com/repo") @@ -25,6 +26,7 @@ dependencyResolutionManagement { rootProject.name = "XuqmGroupAndroidSDK" include(":sdk-core") +include(":sdk-file") include(":sdk-im") include(":sdk-push") include(":sdk-update")