From dff83f43e543d5158ae5d5e6f58d7e8daeaa87a5 Mon Sep 17 00:00:00 2001 From: XuqmGroup Date: Fri, 17 Jul 2026 13:35:27 +0800 Subject: [PATCH] feat(update): harden apk installation and remove license sdk --- AGENTS.md | 19 ++ CLAUDE.md | 3 +- Jenkinsfile | 30 +- build.gradle.kts | 2 +- docs/IMPLEMENTATION_HANDOFF.md | 42 +++ docs/架构总览.md | 5 +- gradle.properties | 3 +- gradle/libs.versions.toml | 2 + sample-app/src/main/AndroidManifest.xml | 9 - .../com/xuqm/sdk/sample/ui/main/MainScreen.kt | 18 +- .../xuqm/sdk/sample/ui/update/UpdateScreen.kt | 26 +- sdk-core/build.gradle.kts | 3 + sdk-core/src/main/AndroidManifest.xml | 14 +- .../src/main/java/com/xuqm/sdk/XuqmSDK.kt | 21 +- .../main/java/com/xuqm/sdk/file/FileSDK.kt | 81 ++++- .../java/com/xuqm/sdk/file/FileTransfer.kt | 77 +++-- .../java/com/xuqm/sdk/internal/ConfigFile.kt | 2 +- .../xuqm/sdk/internal/XuqmMergedProvider.kt | 3 +- sdk-core/src/main/res/xml/xuqm_file_paths.xml | 7 + .../java/com/xuqm/sdk/file/FileSDKTest.kt | 21 ++ sdk-license/README.md | 39 --- sdk-license/build.gradle.kts | 38 --- sdk-license/consumer-rules.pro | 25 -- sdk-license/proguard-rules.pro | 1 - sdk-license/src/main/AndroidManifest.xml | 2 - .../com/xuqm/sdk/license/LicenseCallback.kt | 5 - .../com/xuqm/sdk/license/LicenseConfig.kt | 10 - .../com/xuqm/sdk/license/LicenseResult.kt | 10 - .../java/com/xuqm/sdk/license/LicenseSDK.kt | 286 ------------------ .../xuqm/sdk/license/api/LicenseApiService.kt | 24 -- .../license/internal/DeviceInfoProvider.kt | 22 -- .../license/internal/LicenseContextHolder.kt | 13 - .../sdk/license/internal/LicenseFileCrypto.kt | 36 --- .../sdk/license/internal/LicenseFileReader.kt | 59 ---- .../sdk/license/internal/LicenseHttpClient.kt | 24 -- .../internal/LicenseInitializerProvider.kt | 23 -- .../com/xuqm/sdk/license/model/LicenseFile.kt | 20 -- .../xuqm/sdk/license/model/LicenseModels.kt | 54 ---- .../xuqm/sdk/license/store/LicenseStore.kt | 57 ---- sdk-update/build.gradle.kts | 1 + sdk-update/src/main/AndroidManifest.xml | 3 + .../java/com/xuqm/sdk/update/UpdateSDK.kt | 267 +++++++++++----- .../com/xuqm/sdk/update/model/ApkInstall.kt | 53 ++++ .../xuqm/sdk/update/model/ApkInstallTest.kt | 29 ++ settings.gradle.kts | 1 - 45 files changed, 578 insertions(+), 912 deletions(-) create mode 100644 AGENTS.md create mode 100644 docs/IMPLEMENTATION_HANDOFF.md create mode 100644 sdk-core/src/main/res/xml/xuqm_file_paths.xml create mode 100644 sdk-core/src/test/java/com/xuqm/sdk/file/FileSDKTest.kt delete mode 100644 sdk-license/README.md delete mode 100644 sdk-license/build.gradle.kts delete mode 100644 sdk-license/consumer-rules.pro delete mode 100644 sdk-license/proguard-rules.pro delete mode 100644 sdk-license/src/main/AndroidManifest.xml delete mode 100644 sdk-license/src/main/java/com/xuqm/sdk/license/LicenseCallback.kt delete mode 100644 sdk-license/src/main/java/com/xuqm/sdk/license/LicenseConfig.kt delete mode 100644 sdk-license/src/main/java/com/xuqm/sdk/license/LicenseResult.kt delete mode 100644 sdk-license/src/main/java/com/xuqm/sdk/license/LicenseSDK.kt delete mode 100644 sdk-license/src/main/java/com/xuqm/sdk/license/api/LicenseApiService.kt delete mode 100644 sdk-license/src/main/java/com/xuqm/sdk/license/internal/DeviceInfoProvider.kt delete mode 100644 sdk-license/src/main/java/com/xuqm/sdk/license/internal/LicenseContextHolder.kt delete mode 100644 sdk-license/src/main/java/com/xuqm/sdk/license/internal/LicenseFileCrypto.kt delete mode 100644 sdk-license/src/main/java/com/xuqm/sdk/license/internal/LicenseFileReader.kt delete mode 100644 sdk-license/src/main/java/com/xuqm/sdk/license/internal/LicenseHttpClient.kt delete mode 100644 sdk-license/src/main/java/com/xuqm/sdk/license/internal/LicenseInitializerProvider.kt delete mode 100644 sdk-license/src/main/java/com/xuqm/sdk/license/model/LicenseFile.kt delete mode 100644 sdk-license/src/main/java/com/xuqm/sdk/license/model/LicenseModels.kt delete mode 100644 sdk-license/src/main/java/com/xuqm/sdk/license/store/LicenseStore.kt create mode 100644 sdk-update/src/main/AndroidManifest.xml create mode 100644 sdk-update/src/main/java/com/xuqm/sdk/update/model/ApkInstall.kt create mode 100644 sdk-update/src/test/java/com/xuqm/sdk/update/model/ApkInstallTest.kt diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..4c5d5df --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,19 @@ +# Repository Working Agreement + +## Authorization gate + +- For a newly requested change, first inspect requirements and produce a plan. +- Do not modify files, publish artifacts, trigger Jenkins, or change external state until the user explicitly says `开始实施`. +- Once implementation is authorized, keep work inside the frozen scope. A material scope expansion requires a new confirmation. + +## Architecture constraints + +- Android update capabilities exposed to RN must be production-safe: verified downloads, FileProvider installation, permission handling, progress, retry, cancellation, and deterministic error contracts. +- Do not expand push scope for this iteration; the host retains Tencent Push. +- Shared behavior must have one canonical implementation, with public API/test/demo/docs changes delivered together. + +## Delivery constraints + +- Preserve existing uncommitted work as a valid baseline; overlapping code may be deliberately refactored. +- Publish Snapshot artifacts before downstream integration. +- Jenkins tests, lint/static checks, and builds are hard gates; do not swallow failures. diff --git a/CLAUDE.md b/CLAUDE.md index 2565843..22a73fe 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -19,10 +19,9 @@ XuqmGroup Android SDK,Gradle multi-module 项目。为集成宿主 App(如 Y | sdk-bugcollect | `sdk-bugcollect`(新) | 新建中 | | sdk-push | `sdk-push` | 代码冻结(仅文档) | | sdk-im | `sdk-im` | 代码冻结(仅文档) | -| sdk-license | `sdk-license` | 代码冻结(仅文档) | | sample-app | — | 演示 App | -**代码冻结模块(sdk-push/sdk-im/sdk-license):不修改代码,仅补充文档。** +**非当前范围模块(sdk-push/sdk-im):除修复构建阻塞外不扩展功能。** ## 核心 API(sdk-core) diff --git a/Jenkinsfile b/Jenkinsfile index d01aa07..a144412 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -16,7 +16,6 @@ pipeline { booleanParam(name: 'MOD_PUSH', defaultValue: false, description: '发布 sdk-push') booleanParam(name: 'MOD_UPDATE', defaultValue: false, description: '发布 sdk-update') booleanParam(name: 'MOD_WEBVIEW', defaultValue: false, description: '发布 sdk-webview') - booleanParam(name: 'MOD_LICENSE', defaultValue: false, description: '发布 sdk-license') booleanParam(name: 'MOD_BUGCOLLECT', defaultValue: false, description: '发布 sdk-bugcollect + sdk-bugcollect-plugin(自动联动)') } @@ -40,7 +39,6 @@ pipeline { 'sdk-push': params.MOD_PUSH, 'sdk-update': params.MOD_UPDATE, 'sdk-webview': params.MOD_WEBVIEW, - 'sdk-license': params.MOD_LICENSE, 'sdk-bugcollect': params.MOD_BUGCOLLECT, 'sdk-bugcollect-plugin': params.MOD_BUGCOLLECT, ] @@ -103,8 +101,11 @@ pipeline { def newVer if (params.SNAPSHOT) { - // SNAPSHOT:基于下一版本 + -SNAPSHOT,确保高于当前 release - newVer = bumpVersion(baseVer) + '-SNAPSHOT' + // 已声明的 SNAPSHOT 必须原样发布,保证下游依赖可确定解析。 + // 仅当当前是 release 时,才基于下一补丁版本生成 SNAPSHOT。 + newVer = currentVer.endsWith('-SNAPSHOT') + ? currentVer + : bumpVersion(baseVer) + '-SNAPSHOT' echo "SNAPSHOT: ${propName} → ${newVer} (base: ${baseVer})" } else { // Release:升级版本号,写回 gradle.properties @@ -128,6 +129,21 @@ pipeline { } } + stage('Quality Gate') { + steps { + script { + def modules = env.PUBLISH_MODULES.split(',').toList() + for (mod in modules) { + 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" + } + } + } + } + } + stage('Build AARs') { steps { script { @@ -148,9 +164,11 @@ pipeline { steps { script { def modules = env.PUBLISH_MODULES.split(',').toList() - def publishTasks = modules.collect { ":${it}:publish" }.join(' ') def credArgs = "-PNEXUS_USER=%NEXUS_CREDS_USR% -PNEXUS_PASSWORD=%NEXUS_CREDS_PSW%" - bat "gradlew.bat ${publishTasks} ${env.VERSION_ARGS} ${credArgs} --no-daemon" + // 按依赖顺序逐个发布,避免扩展 POM 先于 sdk-core 可见。 + for (mod in modules) { + bat "gradlew.bat :${mod}:publish ${env.VERSION_ARGS} ${credArgs} --no-daemon" + } } } } diff --git a/build.gradle.kts b/build.gradle.kts index f0ab9bb..9fbb479 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -8,7 +8,7 @@ plugins { group = "com.xuqm" // Global fallback — individual modules override via their own SDK__VERSION property. // Publish all at once: ./gradlew publish -PPUBLISH_VERSION=1.0.2 -// Publish one module: ./gradlew :sdk-license:publish -PSDK_LICENSE_VERSION=1.0.3 -PPUBLISH_VERSION=1.0.2 +// Publish one module: ./gradlew :sdk-update:publish -PSDK_UPDATE_VERSION=2.0.0-SNAPSHOT version = providers.gradleProperty("PUBLISH_VERSION").getOrElse("0.1.0-SNAPSHOT") ext["nexusUrl"] = "https://nexus.xuqinmin.com/repository/android-hosted/" diff --git a/docs/IMPLEMENTATION_HANDOFF.md b/docs/IMPLEMENTATION_HANDOFF.md new file mode 100644 index 0000000..697be8d --- /dev/null +++ b/docs/IMPLEMENTATION_HANDOFF.md @@ -0,0 +1,42 @@ +# Android SDK 重构实施接管文档 + +> 更新时间:2026-07-17 +> 分支:`main` +> 发布约束:Maven 制品只允许通过 `https://jenkins.xuqinmin.com/` 发布。 + +## 当前目标 + +- `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。 +- `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-core` 1 个单元测试、`sdk-update` 2 个单元测试通过。 +- 修复通知权限检查、MediaStore API 等级和 `java.time` minSdk 24 反向移植,core/update Release Lint 均通过。 + +## 下一步 + +1. 补充 Android 仪器测试。 +2. 审计 Java 8 对外制品与内部 Java 21 集成边界,完成对应构建门禁。 +3. 通过 Jenkins 先发布 `sdk-core` Snapshot,再发布 `sdk-update:2.0.0-SNAPSHOT`。 +4. 在 RN SDK Android 宿主中编译并验证下载取消、错误码、权限跳转和安装器唤起。 + +## 已验证命令 + +```bash +./gradlew :sdk-core:compileReleaseKotlin :sdk-update:compileReleaseKotlin :sample-app:compileDebugKotlin +./gradlew :sdk-core:testDebugUnitTest :sdk-update:testDebugUnitTest :sample-app:compileDebugKotlin +./gradlew :sdk-core:lintRelease :sdk-update:lintRelease :sdk-update:assembleRelease +``` + +不得在开发机执行 Maven 发布。 diff --git a/docs/架构总览.md b/docs/架构总览.md index 0919a04..5ff85c3 100644 --- a/docs/架构总览.md +++ b/docs/架构总览.md @@ -20,7 +20,6 @@ ├── sdk-push → 厂商推送(FCM/HMS/VIVO/OPPO/Honor/APNs) ├── sdk-im → IM(WebSocket/STOMP 实时通信) ├── sdk-webview → WebView + JSBridge(Compose 组件) - ├── sdk-license → 证书授权 └── sdk-bugcollect → 日志 / Crash 捕获 / 漏斗分析(依赖 sdk-core) ``` @@ -33,7 +32,6 @@ sdk-core (核心,无 SDK 内部依赖) ├── sdk-push (依赖 sdk-core) ├── sdk-im (依赖 sdk-core) ├── sdk-webview (依赖 sdk-core) - ├── sdk-license (依赖 sdk-core) └── sdk-bugcollect(依赖 sdk-core) ``` @@ -43,8 +41,7 @@ sdk-core (核心,无 SDK 内部依赖) XuqmSDK.setUserInfo(info) ├── PushSDK: 检测厂商 → 注册 token → 上报服务端 ├── ImSDK: 若 userSig 存在且 imEnabled → 自动登录 WebSocket - ├── UpdateSDK: 更新 userId - └── LicenseSDK: 更新用户上下文 + └── UpdateSDK: 更新 userId XuqmSDK.setUserInfo(null) ├── PushSDK: 解绑 token diff --git a/gradle.properties b/gradle.properties index f91ae60..214a28e 100644 --- a/gradle.properties +++ b/gradle.properties @@ -6,7 +6,6 @@ PUBLISH_VERSION=1.0.5 SDK_CORE_VERSION=1.1.6-SNAPSHOT SDK_IM_VERSION=1.1.3 SDK_PUSH_VERSION=1.1.4-SNAPSHOT -SDK_UPDATE_VERSION=1.1.8 +SDK_UPDATE_VERSION=2.0.0-SNAPSHOT SDK_WEBVIEW_VERSION=1.1.10-SNAPSHOT -SDK_LICENSE_VERSION=1.1.3 SDK_BUGCOLLECT_VERSION=1.0.11 diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 6ab1f15..d369f4b 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -18,6 +18,7 @@ jserialization = "1.9.0" webkit = "1.14.0" coil = "3.1.0" junit4 = "4.13.2" +desugarJdkLibs = "2.0.3" androidxJunit = "1.3.0" espresso = "3.7.0" hilt = "2.56.2" @@ -65,6 +66,7 @@ kotlinx-coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx- hilt-android = { group = "com.google.dagger", name = "hilt-android", version.ref = "hilt" } 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" } 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/sample-app/src/main/AndroidManifest.xml b/sample-app/src/main/AndroidManifest.xml index 0497073..c8d99e4 100644 --- a/sample-app/src/main/AndroidManifest.xml +++ b/sample-app/src/main/AndroidManifest.xml @@ -23,14 +23,5 @@ - - - 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 f328f66..eb3e634 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 @@ -36,6 +36,7 @@ import androidx.compose.ui.graphics.vector.ImageVector import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.xuqm.sdk.im.ImSDK import com.xuqm.sdk.update.UpdateSDK +import com.xuqm.sdk.update.model.ApkInstallRequest import com.xuqm.sdk.update.model.UpdateInfo import com.xuqm.sdk.sample.ui.contact.ContactScreen import com.xuqm.sdk.sample.ui.conversation.ConversationScreen @@ -144,12 +145,21 @@ fun MainScreen( confirmButton = { Button( onClick = { - if (update.downloadUrl.isNotBlank() && !isDownloading) { + val sha256 = update.apkHash + if (update.downloadUrl.isNotBlank() && !sha256.isNullOrBlank() && !isDownloading) { isDownloading = true scope.launch { - UpdateSDK.downloadAndInstallApk(context, update.downloadUrl, onProgress = { progress -> - downloadProgress = (progress * 100).toInt() - }) + UpdateSDK.downloadAndInstallApk( + context, + ApkInstallRequest( + downloadUrl = update.downloadUrl, + versionCode = update.versionCode, + sha256 = sha256, + ), + onProgress = { progress -> + progress.percent?.let { downloadProgress = it } + }, + ) isDownloading = false pendingUpdate = null downloadProgress = -1 diff --git a/sample-app/src/main/java/com/xuqm/sdk/sample/ui/update/UpdateScreen.kt b/sample-app/src/main/java/com/xuqm/sdk/sample/ui/update/UpdateScreen.kt index 5ff2a5c..7d56fda 100644 --- a/sample-app/src/main/java/com/xuqm/sdk/sample/ui/update/UpdateScreen.kt +++ b/sample-app/src/main/java/com/xuqm/sdk/sample/ui/update/UpdateScreen.kt @@ -24,6 +24,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewModelScope import com.xuqm.sdk.update.UpdateSDK +import com.xuqm.sdk.update.model.ApkInstallRequest import com.xuqm.sdk.update.model.UpdateInfo import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -53,12 +54,27 @@ class UpdateViewModel : ViewModel() { } } - fun downloadAndInstall(context: Context, downloadUrl: String) { + fun downloadAndInstall(context: Context, update: UpdateInfo) { viewModelScope.launch { + val sha256 = update.apkHash + if (sha256.isNullOrBlank()) { + _state.value = _state.value.copy(message = "服务端未提供 APK SHA-256,已拒绝安装") + return@launch + } _state.value = _state.value.copy(downloadProgress = 0) - UpdateSDK.downloadAndInstallApk(context, downloadUrl, onProgress = { progress -> - _state.value = _state.value.copy(downloadProgress = (progress * 100).toInt()) - }) + UpdateSDK.downloadAndInstallApk( + context, + ApkInstallRequest( + downloadUrl = update.downloadUrl, + versionCode = update.versionCode, + sha256 = sha256, + ), + onProgress = { progress -> + progress.percent?.let { percent -> + _state.value = _state.value.copy(downloadProgress = percent) + } + }, + ) } } } @@ -101,7 +117,7 @@ fun UpdateScreen(viewModel: UpdateViewModel = viewModel()) { Button( onClick = { update.downloadUrl.takeIf { it.isNotBlank() } - ?.let { viewModel.downloadAndInstall(context, it) } + ?.let { viewModel.downloadAndInstall(context, update) } }, modifier = Modifier.fillMaxWidth(), ) { Text("立即更新") } diff --git a/sdk-core/build.gradle.kts b/sdk-core/build.gradle.kts index f2335c3..e08ab97 100644 --- a/sdk-core/build.gradle.kts +++ b/sdk-core/build.gradle.kts @@ -21,6 +21,7 @@ android { compileOptions { + isCoreLibraryDesugaringEnabled = true sourceCompatibility = JavaVersion.VERSION_11 targetCompatibility = JavaVersion.VERSION_11 } @@ -45,4 +46,6 @@ dependencies { api(libs.androidx.security.crypto) api(libs.androidx.ui) api(libs.androidx.material3) + testImplementation(libs.junit4) + coreLibraryDesugaring(libs.desugar.jdk.libs) } diff --git a/sdk-core/src/main/AndroidManifest.xml b/sdk-core/src/main/AndroidManifest.xml index 9568103..4a571f7 100644 --- a/sdk-core/src/main/AndroidManifest.xml +++ b/sdk-core/src/main/AndroidManifest.xml @@ -1,7 +1,19 @@ + + + + + + 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 37c1cec..1945e2b 100644 --- a/sdk-core/src/main/java/com/xuqm/sdk/XuqmSDK.kt +++ b/sdk-core/src/main/java/com/xuqm/sdk/XuqmSDK.kt @@ -145,7 +145,7 @@ object XuqmSDK { * - 原始字节由调用方传入(主线程只做文件读取,不做 PBKDF2) * - 在 IO 线程直接解密,不使用磁盘缓存,signingKey 始终完整读取 * - 复用 sdkScope,不创建冗余 CoroutineScope - * - 初始化完成后自动触发 BugCollect / License 等子 SDK 启动 + * - 初始化完成后自动触发 BugCollect 等扩展 SDK 启动 */ fun autoInitializeFromBytes(context: Context, rawBytes: ByteArray, logLevel: LogLevel = LogLevel.WARN) { val ctx = context.applicationContext @@ -179,12 +179,6 @@ object XuqmSDK { Log.w(TAG, "BugCollect init failed: ${e.message}") } - // 4. 初始化 sdk-license(如果存在) - runCatching { - initLicense(ctx) - }.onFailure { e -> - Log.w(TAG, "License init failed: ${e.message}") - } }.onFailure { e -> Log.w(TAG, "autoInitializeFromBytes failed: ${e.message}") } @@ -205,18 +199,6 @@ object XuqmSDK { startMethod.invoke(instance) } - /** - * 通过反射初始化 sdk-license,避免 sdk-core 对 sdk-license 的编译时依赖。 - */ - private fun initLicense(context: Context) { - val clazz = runCatching { - Class.forName("com.xuqm.sdk.license.internal.LicenseContextHolder") - }.getOrNull() ?: return - - val initMethod = clazz.getMethod("init", Context::class.java) - initMethod.invoke(null, context) - } - /** * 方式 B:手动初始化。 * @@ -435,7 +417,6 @@ object XuqmSDK { * - **PushSDK**:检测厂商 → 获取厂商配置 → 注册设备 → 上报 token * - **ImSDK**:若 [XuqmUserInfo.userSig] 存在且平台开通了 IM,自动登录 * - **UpdateSDK**:更新 userId(用于灰度/定向更新) - * - **LicenseSDK**:更新用户上下文 * * 登出时传 `null`,触发所有子 SDK 登出: * ```kotlin 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 1d32f35..587d5d9 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 @@ -2,13 +2,16 @@ package com.xuqm.sdk.file 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 import android.content.Intent import android.net.Uri import android.os.Build import android.os.Environment +import android.content.pm.PackageManager import android.provider.MediaStore import android.webkit.MimeTypeMap import androidx.core.app.NotificationCompat @@ -26,6 +29,7 @@ import retrofit2.http.Multipart import retrofit2.http.POST import retrofit2.http.Part import java.io.File +import java.security.MessageDigest sealed class FileDownloadDestination { /** App-private external files dir (no special permission needed). */ @@ -44,6 +48,15 @@ data class FileUploadResult( val ext: String? = null, ) +data class FileDownloadProgress( + val bytesDownloaded: Long, + val totalBytes: Long?, +) { + val percent: Int? + get() = totalBytes?.takeIf { it > 0L } + ?.let { ((bytesDownloaded * 100L) / it).toInt().coerceIn(0, 100) } +} + data class ApiResponse( val code: Int, val status: String, @@ -121,10 +134,36 @@ object FileSDK { targetFile: File, onProgress: (Int) -> Unit = {}, ): File = withContext(Dispatchers.IO) { - FileTransfer.downloadToFile(downloadUrl, targetFile, onProgress) + FileTransfer.downloadToFile(downloadUrl, targetFile) { progress -> + progress.percent?.let(onProgress) + } targetFile } + suspend fun downloadToFileDetailed( + downloadUrl: String, + targetFile: File, + onProgress: (FileDownloadProgress) -> Unit = {}, + ): File = withContext(Dispatchers.IO) { + FileTransfer.downloadToFile(downloadUrl, targetFile, onProgress) + } + + 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(separator = "") { byte -> "%02x".format(byte) } + } + + fun matchesSha256(file: File, expectedSha256: String): Boolean = + expectedSha256.isNotBlank() && sha256(file).equals(expectedSha256.trim(), ignoreCase = true) + suspend fun upload( file: File, thumbnailBytes: ByteArray? = null, @@ -203,22 +242,22 @@ object FileSDK { .setContentText(resolvedName) .setOngoing(true) .setProgress(100, 0, false) - .also { NotificationManagerCompat.from(context).notify(id, it.build()) } + .also { notifyIfAllowed(context, id, it.build()) } } val capturedNotifId = notifId var downloadError: Throwable? = null try { - FileTransfer.downloadToFile(downloadUrl, target) { progress -> - if (capturedNotifId != null) { - notifBuilder?.let { builder -> - builder.setProgress(100, progress, false) - if (NotificationManagerCompat.from(context).areNotificationsEnabled()) { - NotificationManagerCompat.from(context).notify(capturedNotifId, builder.build()) + FileTransfer.downloadToFile(downloadUrl, target) { downloadProgress -> + downloadProgress.percent?.let { progress -> + if (capturedNotifId != null) { + notifBuilder?.let { builder -> + builder.setProgress(100, progress, false) + notifyIfAllowed(context, capturedNotifId, builder.build()) } } + onProgress(progress) } - onProgress(progress) } if (useMediaStore) { saveFileToPublicDownloads(context, target, resolvedName) @@ -235,7 +274,8 @@ object FileSDK { if (nm.areNotificationsEnabled()) { val err = downloadError if (err == null) { - nm.notify( + notifyIfAllowed( + context, id, NotificationCompat.Builder(context, DOWNLOAD_CHANNEL_ID) .setSmallIcon(android.R.drawable.stat_sys_download_done) @@ -246,7 +286,8 @@ object FileSDK { .build(), ) } else { - nm.notify( + notifyIfAllowed( + context, id, NotificationCompat.Builder(context, DOWNLOAD_CHANNEL_ID) .setSmallIcon(android.R.drawable.stat_notify_error) @@ -274,6 +315,23 @@ object FileSDK { } } + private fun notifyIfAllowed(context: Context, id: Int, notification: Notification) { + val manager = NotificationManagerCompat.from(context) + if (!manager.areNotificationsEnabled()) return + if ( + Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && + ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) != + PackageManager.PERMISSION_GRANTED + ) { + return + } + try { + manager.notify(id, notification) + } catch (_: SecurityException) { + // Permission can be revoked between the explicit check and notify(). + } + } + private const val DOWNLOAD_CHANNEL_ID = "xuqm_downloads" fun saveBlobDownload( @@ -347,6 +405,7 @@ object FileSDK { * 在华为等设备上直接文件 I/O 会失败,MediaStore 通过 ContentResolver 操作,兼容性更好。 * 返回缓存目录中的副本(真实文件,可被 openFile 使用)。 */ + @androidx.annotation.RequiresApi(Build.VERSION_CODES.Q) private fun saveBlobViaMediaStore(context: Context, bytes: ByteArray, fileName: String): File { val mimeType = MimeTypeMap.getSingleton() .getMimeTypeFromExtension(fileName.substringAfterLast('.', "").lowercase()) 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 4a49197..44b0880 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 @@ -3,11 +3,17 @@ 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 @@ -75,7 +81,7 @@ object FileTransfer { val count = minOf(bufferSize, bytes.size - uploaded) sink.write(bytes, uploaded, count) uploaded += count - onProgress((uploaded * 100 / bytes.size).toInt().coerceIn(0, 100)) + onProgress((uploaded * 100 / bytes.size).coerceIn(0, 100)) } if (bytes.isEmpty()) { onProgress(100) @@ -83,27 +89,64 @@ object FileTransfer { } } - fun downloadToFile( + suspend fun downloadToFile( downloadUrl: String, targetFile: File, - onProgress: (Int) -> Unit = {}, - ) { - val connection = URL(downloadUrl).openConnection() - connection.connect() - val totalSize = connection.contentLengthLong - connection.getInputStream().use { input -> - targetFile.outputStream().use { output -> - val buffer = ByteArray(8192) - var downloaded = 0L - var read: Int - while (input.read(buffer).also { read = it } != -1) { - output.write(buffer, 0, read) - downloaded += read - if (totalSize > 0) { - onProgress((downloaded * 100 / totalSize).toInt()) + onProgress: (FileDownloadProgress) -> Unit = {}, + ): File { + val coroutineContext = currentCoroutineContext() + val connection = URL(downloadUrl).openConnection().apply { + connectTimeout = 15_000 + readTimeout = 30_000 + useCaches = false + } + val cancellation = coroutineContext[Job]?.invokeOnCompletion { cause -> + if (cause is CancellationException && connection is HttpURLConnection) { + connection.disconnect() + } + } + targetFile.parentFile?.mkdirs() + val partialFile = File(targetFile.parentFile, "${targetFile.name}.part") + if (partialFile.exists() && !partialFile.delete()) { + cancellation?.dispose() + throw IOException("Unable to remove partial download: ${partialFile.absolutePath}") + } + + try { + connection.connect() + if (connection is HttpURLConnection && connection.responseCode !in 200..299) { + throw IOException("Download failed with HTTP ${connection.responseCode}") + } + val totalBytes = connection.contentLengthLong.takeIf { it > 0L } + connection.getInputStream().use { input -> + partialFile.outputStream().buffered().use { output -> + val buffer = ByteArray(DEFAULT_BUFFER_SIZE) + var downloaded = 0L + while (true) { + coroutineContext.ensureActive() + val read = input.read(buffer) + if (read < 0) break + output.write(buffer, 0, read) + downloaded += read + onProgress(FileDownloadProgress(downloaded, totalBytes)) } } } + coroutineContext.ensureActive() + if (targetFile.exists() && !targetFile.delete()) { + throw IOException("Unable to replace download: ${targetFile.absolutePath}") + } + if (!partialFile.renameTo(targetFile)) { + partialFile.copyTo(targetFile, overwrite = true) + if (!partialFile.delete()) partialFile.deleteOnExit() + } + return targetFile + } catch (error: Throwable) { + partialFile.delete() + throw error + } finally { + cancellation?.dispose() + if (connection is HttpURLConnection) connection.disconnect() } } 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 6e1a49a..811a1f4 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 @@ -5,7 +5,7 @@ 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 is separate from sdk-license's LicenseFile (device activation). + * This file only contains SDK bootstrap configuration. */ internal data class ConfigFile( @SerializedName(value = "appKey", alternate = ["app_key"]) val appKey: String, 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 bc5801d..f4a8d44 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 @@ -8,7 +8,7 @@ import android.util.Log import com.xuqm.sdk.XuqmSDK /** - * 合并启动入口,替代 XuqmInitializerProvider + BugCollectInitProvider + LicenseInitializerProvider。 + * 合并启动入口,替代各扩展 SDK 分别注册的初始化 Provider。 * * 设计原则:主线程零加密运算。 * Phase 1(同步,主线程,<10ms): @@ -20,7 +20,6 @@ import com.xuqm.sdk.XuqmSDK * - 缓存 miss 时执行 PBKDF2 解密(~100ms)并写缓存 * - 初始化 sdk-core(TokenStore / DeviceId / ApiClient) * - 注册 sdk-bugcollect CrashCapture - * - 初始化 sdk-license LicenseContextHolder * - 拉取远端平台配置 */ class XuqmMergedProvider : ContentProvider() { diff --git a/sdk-core/src/main/res/xml/xuqm_file_paths.xml b/sdk-core/src/main/res/xml/xuqm_file_paths.xml new file mode 100644 index 0000000..59e531c --- /dev/null +++ b/sdk-core/src/main/res/xml/xuqm_file_paths.xml @@ -0,0 +1,7 @@ + + + + + + + diff --git a/sdk-core/src/test/java/com/xuqm/sdk/file/FileSDKTest.kt b/sdk-core/src/test/java/com/xuqm/sdk/file/FileSDKTest.kt new file mode 100644 index 0000000..f65cee9 --- /dev/null +++ b/sdk-core/src/test/java/com/xuqm/sdk/file/FileSDKTest.kt @@ -0,0 +1,21 @@ +package com.xuqm.sdk.file + +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import java.io.File + +class FileSDKTest { + @Test + fun `sha256 uses the canonical file implementation`() { + val file = File.createTempFile("xuqm-sha256", ".txt") + try { + file.writeText("abc") + val expected = "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad" + assertTrue(FileSDK.matchesSha256(file, expected.uppercase())) + assertFalse(FileSDK.matchesSha256(file, "")) + } finally { + file.delete() + } + } +} diff --git a/sdk-license/README.md b/sdk-license/README.md deleted file mode 100644 index 55e1e2e..0000000 --- a/sdk-license/README.md +++ /dev/null @@ -1,39 +0,0 @@ -# sdk-license - -XuqmGroup Android SDK 证书授权模块。提供设备 License 验证能力。 - -## 依赖 - -```kotlin -implementation("com.xuqm:sdk-license:VERSION") -implementation("com.xuqm:sdk-core:VERSION") // 必须 -``` - -## 使用 - -**无需独立初始化。** 内部自动等待 `XuqmSDK` 就绪。 - -```kotlin -// 验证 License -LicenseSDK.checkLicense(context, callback = object : LicenseCallback { - override fun onResult(result: LicenseResult) { - when (result) { - is LicenseResult.Success -> { /* 验证通过 */ } - is LicenseResult.Denied -> { /* 验证失败 */ } - is LicenseResult.Error -> { /* 错误 */ } - } - } -}) - -// 获取状态 -val status = LicenseSDK.getStatus() -``` - -## API - -| API | 说明 | -|-----|------| -| `LicenseSDK.checkLicense(context, callback)` | 验证设备 License | -| `LicenseSDK.getStatus()` | 获取当前 License 状态 | -| `LicenseSDK.getDeviceId()` | 获取设备 ID | -| `LicenseSDK.clear()` | 清除本地 License 缓存 | diff --git a/sdk-license/build.gradle.kts b/sdk-license/build.gradle.kts deleted file mode 100644 index 700cc9a..0000000 --- a/sdk-license/build.gradle.kts +++ /dev/null @@ -1,38 +0,0 @@ -plugins { - alias(libs.plugins.android.library) - alias(libs.plugins.kotlin.serialization) -} - -apply(from = rootProject.file("gradle/publish.gradle")) - -version = providers.gradleProperty("SDK_LICENSE_VERSION") - .orElse(providers.gradleProperty("PUBLISH_VERSION")) - .getOrElse("0.1.0-SNAPSHOT") - -android { - namespace = "com.xuqm.sdk.license" - 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")) - api(libs.kotlinx.coroutines.android) - api(libs.kotlinx.serialization.json) -} diff --git a/sdk-license/consumer-rules.pro b/sdk-license/consumer-rules.pro deleted file mode 100644 index b7a7348..0000000 --- a/sdk-license/consumer-rules.pro +++ /dev/null @@ -1,25 +0,0 @@ -# sdk-license consumer ProGuard rules - -# ── Public API entry points ─────────────────────────────────────────────────── --keep class com.xuqm.sdk.license.LicenseSDK { *; } --keep class com.xuqm.sdk.license.LicenseConfig { *; } --keep class com.xuqm.sdk.license.LicenseResult { *; } --keep class com.xuqm.sdk.license.LicenseStatus { *; } --keep class com.xuqm.sdk.license.model.** { *; } - -# ── Gson: preserve field names on all SDK model classes ────────────────────── --keepclassmembers class com.xuqm.sdk.license.** { - @com.google.gson.annotations.SerializedName ; -} - -# ContentProvider: class name referenced in AndroidManifest — must not be renamed --keep class com.xuqm.sdk.license.internal.LicenseInitializerProvider - -# ── Kotlinx serialization ───────────────────────────────────────────────────── --keepclassmembers @kotlinx.serialization.Serializable class ** { *; } --keep class kotlinx.serialization.** { *; } - -# ── Retrofit / OkHttp ──────────────────────────────────────────────────────── --keepattributes Signature --keepattributes *Annotation* --keepattributes InnerClasses, EnclosingMethod diff --git a/sdk-license/proguard-rules.pro b/sdk-license/proguard-rules.pro deleted file mode 100644 index 4e8ed23..0000000 --- a/sdk-license/proguard-rules.pro +++ /dev/null @@ -1 +0,0 @@ -# Library modules do not use isMinifyEnabled. Rules are in consumer-rules.pro. diff --git a/sdk-license/src/main/AndroidManifest.xml b/sdk-license/src/main/AndroidManifest.xml deleted file mode 100644 index a2f47b6..0000000 --- a/sdk-license/src/main/AndroidManifest.xml +++ /dev/null @@ -1,2 +0,0 @@ - - diff --git a/sdk-license/src/main/java/com/xuqm/sdk/license/LicenseCallback.kt b/sdk-license/src/main/java/com/xuqm/sdk/license/LicenseCallback.kt deleted file mode 100644 index 4574f93..0000000 --- a/sdk-license/src/main/java/com/xuqm/sdk/license/LicenseCallback.kt +++ /dev/null @@ -1,5 +0,0 @@ -package com.xuqm.sdk.license - -fun interface LicenseCallback { - fun onResult(isValid: Boolean) -} diff --git a/sdk-license/src/main/java/com/xuqm/sdk/license/LicenseConfig.kt b/sdk-license/src/main/java/com/xuqm/sdk/license/LicenseConfig.kt deleted file mode 100644 index 4937509..0000000 --- a/sdk-license/src/main/java/com/xuqm/sdk/license/LicenseConfig.kt +++ /dev/null @@ -1,10 +0,0 @@ -package com.xuqm.sdk.license - -data class LicenseConfig( - val appKey: String, - val baseUrl: String = "https://auth.dev.xuqinmin.com/", - val deviceName: String? = null, - val cacheWindowMs: Long = 10 * 60 * 1000L, // 10 minutes - /** True when initialized from a license file; package name validated locally before any network call. */ - val fromLicenseFile: Boolean = false, -) diff --git a/sdk-license/src/main/java/com/xuqm/sdk/license/LicenseResult.kt b/sdk-license/src/main/java/com/xuqm/sdk/license/LicenseResult.kt deleted file mode 100644 index d93fb95..0000000 --- a/sdk-license/src/main/java/com/xuqm/sdk/license/LicenseResult.kt +++ /dev/null @@ -1,10 +0,0 @@ -package com.xuqm.sdk.license - -sealed class LicenseResult { - data class Success(val message: String) : LicenseResult() - data class Error(val message: String) : LicenseResult() -} - -enum class LicenseStatus { - OK, DENIED, UNKNOWN -} diff --git a/sdk-license/src/main/java/com/xuqm/sdk/license/LicenseSDK.kt b/sdk-license/src/main/java/com/xuqm/sdk/license/LicenseSDK.kt deleted file mode 100644 index 7cc77a0..0000000 --- a/sdk-license/src/main/java/com/xuqm/sdk/license/LicenseSDK.kt +++ /dev/null @@ -1,286 +0,0 @@ -package com.xuqm.sdk.license - -import android.content.Context -import com.xuqm.sdk.XuqmSDK -import com.xuqm.sdk.license.api.LicenseApiService -import com.xuqm.sdk.license.internal.DeviceInfoProvider -import com.xuqm.sdk.license.internal.LicenseHttpClient -import com.xuqm.sdk.license.model.RegisterRequest -import com.xuqm.sdk.license.model.LicenseUserInfo -import com.xuqm.sdk.license.model.VerifyRequest -import com.xuqm.sdk.license.store.LicenseStore -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import retrofit2.HttpException - -object LicenseSDK { - - private const val STATUS_OK = "ok" - private const val STATUS_DENIED = "denied" - private const val DEFAULT_BASE_URL = "https://auth.dev.xuqinmin.com/" - - private var initialized = false - private lateinit var appContext: Context - private lateinit var config: LicenseConfig - private lateinit var store: LicenseStore - private lateinit var apiService: LicenseApiService - private val sdkScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) - - /** - * 可选的手动初始化。通常不需要调用 — 直接调用 [checkLicense] 即可, - * LicenseSDK 会在内部等待 XuqmSDK 初始化完成后自动获取配置。 - * - * @deprecated LicenseSDK 现在依赖 XuqmSDK 自动初始化,无需单独调用。 - * 直接调用 [checkLicense] 或 [getStatus] 即可。 - */ - @Deprecated( - "LicenseSDK auto-initializes from XuqmSDK. Call checkLicense() directly.", - ReplaceWith("checkLicense()") - ) - @JvmStatic - @JvmOverloads - fun initialize( - context: Context, - appKey: String, - deviceName: String? = null, - baseUrl: String? = null, - ) { - synchronized(this) { - val ctx = context.applicationContext - appContext = ctx - store = LicenseStore(ctx) - - // If appKey changed, clear old data to force re-registration - if (store.appKey != null && store.appKey != appKey) { - store.clear() - } - store.appKey = appKey - - config = LicenseConfig( - appKey = appKey, - baseUrl = normalizeBaseUrl(baseUrl ?: DEFAULT_BASE_URL), - deviceName = deviceName ?: DeviceInfoProvider.getDeviceName(), - ) - - apiService = LicenseHttpClient.create(LicenseApiService::class.java, config.baseUrl) - initialized = true - } - } - - @JvmStatic - @JvmOverloads - fun checkLicense(userInfo: LicenseUserInfo? = null, callback: LicenseCallback) { - sdkScope.launch { - val result = checkLicense(userInfo) - val valid = result is LicenseResult.Success - withContext(Dispatchers.Main) { - callback.onResult(valid) - } - } - } - - /** - * Check license status. This will: - * 1. Return cached OK status if within cache window - * 2. Validate package name (locally for license-file flow; via server for appKey flow) - * 3. Try to verify existing token - * 4. Register new device if no token exists - * - * @return LicenseResult.Success if license is valid, LicenseResult.Error otherwise - */ - @JvmStatic - suspend fun checkLicense(userInfo: LicenseUserInfo? = null): LicenseResult = withContext(Dispatchers.IO) { - ensureInitialized() - - // Check cached status - val cachedStatus = store.status - val cachedTime = store.statusTime - if (cachedStatus == STATUS_OK && System.currentTimeMillis() - cachedTime < config.cacheWindowMs) { - return@withContext LicenseResult.Success("Cached") - } - - // appKey-only flow: fetch configured package names from server and validate locally - if (!config.fromLicenseFile) { - val actualPackage = appContext.packageName - try { - val infoResp = apiService.getAppInfo(config.appKey) - val appInfo = infoResp.data - if (appInfo != null) { - val anyConfigured = !appInfo.androidPackageName.isNullOrBlank() - || !appInfo.iosBundleId.isNullOrBlank() - || !appInfo.harmonyBundleName.isNullOrBlank() - if (anyConfigured && !appInfo.matchesPackageName(actualPackage)) { - persistStatus(STATUS_DENIED) - return@withContext LicenseResult.Error("Package name not authorized: $actualPackage") - } - } - } catch (_: Exception) { - // If app-info fetch fails due to network, fall through and let register/verify handle it - } - } - - val deviceId = getOrCreateDeviceId() - val token = store.token - - try { - if (token != null) { - // Try to verify existing token - val result = apiService.verify( - VerifyRequest( - appKey = config.appKey, - deviceId = deviceId, - token = token, - userInfo = userInfo, - ) - ) - - if (result.data?.valid == true) { - persistStatus(STATUS_OK) - return@withContext LicenseResult.Success("Verified") - } - - // Token invalid, clear and will try register below - store.token = null - } - - // No token or token invalid, register new device - val result = apiService.register( - RegisterRequest( - appKey = config.appKey, - deviceId = deviceId, - deviceName = config.deviceName, - deviceModel = DeviceInfoProvider.getDeviceModel(), - deviceVendor = DeviceInfoProvider.getDeviceVendor(), - osVersion = DeviceInfoProvider.getOsVersion(), - userInfo = userInfo, - ) - ) - - if (result.data?.success == true && result.data.token != null) { - store.token = result.data.token - persistStatus(STATUS_OK) - return@withContext LicenseResult.Success("Registered") - } - - persistStatus(STATUS_DENIED) - return@withContext LicenseResult.Error(result.data?.message ?: "Registration denied") - } catch (e: Exception) { - // HTTP errors (4xx/5xx) are explicit server rejections — never fall back to cache. - // Only genuine network errors (IOException, timeout) allow the offline cache path. - if (e !is HttpException && cachedStatus == STATUS_OK) { - return@withContext LicenseResult.Success("Offline - using cached status") - } - persistStatus(STATUS_DENIED) - return@withContext LicenseResult.Error(e.message ?: "Network error") - } - } - - /** - * Force re-register the device (e.g., after token revocation). - */ - suspend fun reRegister(userInfo: LicenseUserInfo? = null): LicenseResult = withContext(Dispatchers.IO) { - requireInit() - store.token = null - store.status = null - store.statusTime = 0 - checkLicense(userInfo) - } - - /** - * Get current license status without network call. - * Automatically uses appKey from XuqmSDK if not manually initialized. - */ - fun getStatus(): LicenseStatus { - if (!initialized && !ensureInitializedFromSDK()) return LicenseStatus.UNKNOWN - return when (store.status) { - STATUS_OK -> LicenseStatus.OK - STATUS_DENIED -> LicenseStatus.DENIED - else -> LicenseStatus.UNKNOWN - } - } - - /** - * Get the stable device ID used for registration. - * Automatically uses appKey from XuqmSDK if not manually initialized. - */ - fun getDeviceId(): String? { - if (!initialized && !ensureInitializedFromSDK()) return null - return store.deviceId - } - - /** - * Clear all license data (token, device ID, status). - */ - fun clear() { - if (::store.isInitialized) { - store.clear() - } - } - - private fun requireInit() { - check(initialized) { "LicenseSDK not initialized. Call LicenseSDK.initialize() first." } - } - - private fun getOrCreateDeviceId(): String { - store.deviceId?.let { return it } - val newId = DeviceInfoProvider.getDeviceId() - store.deviceId = newId - return newId - } - - private fun persistStatus(status: String) { - store.status = status - store.statusTime = System.currentTimeMillis() - } - - private suspend fun ensureInitialized() { - if (!initialized) { - // Wait for XuqmSDK initialization (poll-based) - val maxWaitMs = 30000L - val start = System.currentTimeMillis() - while (!XuqmSDK.isInitialized()) { - if (System.currentTimeMillis() - start > maxWaitMs) { - throw IllegalStateException("LicenseSDK initialization timed out waiting for XuqmSDK") - } - kotlinx.coroutines.delay(100) - } - ensureInitializedFromSDK() - } - requireInit() - } - - /** - * Initialize from XuqmSDK's config. Called when LicenseSDK is used before manual init. - */ - private fun ensureInitializedFromSDK(): Boolean { - synchronized(this) { - if (initialized) return true - if (!XuqmSDK.isInitialized()) return false - val ctx = XuqmSDK.appContext - val appKey = XuqmSDK.appKey - - appContext = ctx - store = LicenseStore(ctx) - if (store.appKey != null && store.appKey != appKey) { - store.clear() - } - store.appKey = appKey - config = LicenseConfig( - appKey = appKey, - baseUrl = DEFAULT_BASE_URL, - deviceName = DeviceInfoProvider.getDeviceName(), - ) - apiService = LicenseHttpClient.create(LicenseApiService::class.java, config.baseUrl) - initialized = true - return true - } - } - - private fun normalizeBaseUrl(value: String): String { - val trimmed = value.trim().ifBlank { DEFAULT_BASE_URL } - return if (trimmed.endsWith("/")) trimmed else "$trimmed/" - } -} diff --git a/sdk-license/src/main/java/com/xuqm/sdk/license/api/LicenseApiService.kt b/sdk-license/src/main/java/com/xuqm/sdk/license/api/LicenseApiService.kt deleted file mode 100644 index 1d8e2fa..0000000 --- a/sdk-license/src/main/java/com/xuqm/sdk/license/api/LicenseApiService.kt +++ /dev/null @@ -1,24 +0,0 @@ -package com.xuqm.sdk.license.api - -import com.xuqm.sdk.license.model.ApiResponse -import com.xuqm.sdk.license.model.AppInfoResponse -import com.xuqm.sdk.license.model.RegisterRequest -import com.xuqm.sdk.license.model.RegisterResponse -import com.xuqm.sdk.license.model.VerifyRequest -import com.xuqm.sdk.license.model.VerifyResponse -import retrofit2.http.Body -import retrofit2.http.GET -import retrofit2.http.POST -import retrofit2.http.Query - -interface LicenseApiService { - - @POST("api/license/register") - suspend fun register(@Body request: RegisterRequest): ApiResponse - - @POST("api/license/verify") - suspend fun verify(@Body request: VerifyRequest): ApiResponse - - @GET("api/license/app-info") - suspend fun getAppInfo(@Query("appKey") appKey: String): ApiResponse -} diff --git a/sdk-license/src/main/java/com/xuqm/sdk/license/internal/DeviceInfoProvider.kt b/sdk-license/src/main/java/com/xuqm/sdk/license/internal/DeviceInfoProvider.kt deleted file mode 100644 index 7769e23..0000000 --- a/sdk-license/src/main/java/com/xuqm/sdk/license/internal/DeviceInfoProvider.kt +++ /dev/null @@ -1,22 +0,0 @@ -package com.xuqm.sdk.license.internal - -import android.os.Build -import com.xuqm.sdk.utils.DeviceUtils - -/** - * Provides stable device information for license checks. - * deviceId 统一使用 XuqmSDK.deviceId(通过 DeviceUtils)。 - */ -internal object DeviceInfoProvider { - - /** 统一设备 ID,来源:XuqmSDK.deviceId */ - fun getDeviceId(): String = DeviceUtils.getDeviceId() - - fun getDeviceModel(): String = DeviceUtils.getDeviceModel() - - fun getDeviceVendor(): String = Build.MANUFACTURER - - fun getOsVersion(): String = DeviceUtils.getOsVersion() - - fun getDeviceName(): String = getDeviceModel() -} diff --git a/sdk-license/src/main/java/com/xuqm/sdk/license/internal/LicenseContextHolder.kt b/sdk-license/src/main/java/com/xuqm/sdk/license/internal/LicenseContextHolder.kt deleted file mode 100644 index 1dd9e21..0000000 --- a/sdk-license/src/main/java/com/xuqm/sdk/license/internal/LicenseContextHolder.kt +++ /dev/null @@ -1,13 +0,0 @@ -package com.xuqm.sdk.license.internal - -import android.content.Context - -internal object LicenseContextHolder { - @Volatile - var appContext: Context? = null - private set - - fun init(context: Context) { - appContext = context.applicationContext - } -} diff --git a/sdk-license/src/main/java/com/xuqm/sdk/license/internal/LicenseFileCrypto.kt b/sdk-license/src/main/java/com/xuqm/sdk/license/internal/LicenseFileCrypto.kt deleted file mode 100644 index 97767c7..0000000 --- a/sdk-license/src/main/java/com/xuqm/sdk/license/internal/LicenseFileCrypto.kt +++ /dev/null @@ -1,36 +0,0 @@ -package com.xuqm.sdk.license.internal - -import android.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 - -internal object LicenseFileCrypto { - private const val MAGIC = "XUQM-LICENSE-V1" - private const val PASSPHRASE = "xuqm-license-file-v1.2026.internal" - private const val KEY_BITS = 256 - private const val ITERATIONS = 120_000 - private const val GCM_TAG_BITS = 128 - - fun decrypt(content: String): String { - val parts = content.trim().split(".") - require(parts.size == 4 && parts[0] == MAGIC) { "Invalid license file" } - val salt = decode(parts[1]) - val iv = decode(parts[2]) - val cipherText = decode(parts[3]) - 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) - } - - private fun deriveKey(salt: ByteArray): SecretKeySpec { - val spec = PBEKeySpec(PASSPHRASE.toCharArray(), salt, ITERATIONS, KEY_BITS) - val encoded = SecretKeyFactory.getInstance("PBKDF2WithHmacSHA256").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) -} diff --git a/sdk-license/src/main/java/com/xuqm/sdk/license/internal/LicenseFileReader.kt b/sdk-license/src/main/java/com/xuqm/sdk/license/internal/LicenseFileReader.kt deleted file mode 100644 index e2a5a9d..0000000 --- a/sdk-license/src/main/java/com/xuqm/sdk/license/internal/LicenseFileReader.kt +++ /dev/null @@ -1,59 +0,0 @@ -package com.xuqm.sdk.license.internal - -import android.content.Context -import com.google.gson.Gson -import com.xuqm.sdk.license.model.LicenseFile - -/** - * Reads the license file from app assets. - * Expected path: assets/xuqm/license.xuqm - */ -internal object LicenseFileReader { - - private const val LICENSE_PATH = "xuqm/license.xuqm" - private const val LICENSE_DIR = "xuqm" - private val gson = Gson() - - fun read(context: Context): LicenseFile? { - return try { - val path = findLicensePath(context) ?: return null - context.assets.open(path).use { stream -> - parseEncrypted(stream.bufferedReader().readText()) - } - } catch (_: Exception) { - null - } - } - - fun exists(context: Context): Boolean { - return findLicensePath(context) != null - } - - fun readRaw(context: Context): String? { - return try { - val path = findLicensePath(context) ?: return null - context.assets.open(path).use { it.bufferedReader().readText().trim() } - } catch (_: Exception) { - null - } - } - - private fun findLicensePath(context: Context): String? { - try { - context.assets.open(LICENSE_PATH).close() - return LICENSE_PATH - } catch (_: Exception) { - // Try downloaded filenames such as appName.xuqmlicense. - } - return runCatching { - context.assets.list(LICENSE_DIR) - ?.firstOrNull { it.endsWith(".xuqmlicense", ignoreCase = true) } - ?.let { "$LICENSE_DIR/$it" } - }.getOrNull() - } - - private fun parseEncrypted(encrypted: String): LicenseFile { - val json = LicenseFileCrypto.decrypt(encrypted) - return gson.fromJson(json, LicenseFile::class.java) - } -} diff --git a/sdk-license/src/main/java/com/xuqm/sdk/license/internal/LicenseHttpClient.kt b/sdk-license/src/main/java/com/xuqm/sdk/license/internal/LicenseHttpClient.kt deleted file mode 100644 index c2df3c8..0000000 --- a/sdk-license/src/main/java/com/xuqm/sdk/license/internal/LicenseHttpClient.kt +++ /dev/null @@ -1,24 +0,0 @@ -package com.xuqm.sdk.license.internal - -import com.google.gson.GsonBuilder -import okhttp3.OkHttpClient -import retrofit2.Retrofit -import retrofit2.converter.gson.GsonConverterFactory -import java.util.concurrent.TimeUnit - -internal object LicenseHttpClient { - private val gson = GsonBuilder().create() - private val okHttpClient = OkHttpClient.Builder() - .connectTimeout(15, TimeUnit.SECONDS) - .readTimeout(15, TimeUnit.SECONDS) - .build() - - fun create(service: Class, baseUrl: String): T { - return Retrofit.Builder() - .baseUrl(baseUrl) - .client(okHttpClient) - .addConverterFactory(GsonConverterFactory.create(gson)) - .build() - .create(service) - } -} diff --git a/sdk-license/src/main/java/com/xuqm/sdk/license/internal/LicenseInitializerProvider.kt b/sdk-license/src/main/java/com/xuqm/sdk/license/internal/LicenseInitializerProvider.kt deleted file mode 100644 index ba99ef4..0000000 --- a/sdk-license/src/main/java/com/xuqm/sdk/license/internal/LicenseInitializerProvider.kt +++ /dev/null @@ -1,23 +0,0 @@ -package com.xuqm.sdk.license.internal - -import android.content.ContentProvider -import android.content.ContentValues -import android.database.Cursor -import android.net.Uri -import com.xuqm.sdk.XuqmSDK - -class LicenseInitializerProvider : ContentProvider() { - override fun onCreate(): Boolean { - val ctx = context ?: return true - LicenseContextHolder.init(ctx) - // SDK initialization is handled by sdk-core XuqmInitializerProvider. - // This provider only ensures LicenseContextHolder has the application context. - 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-license/src/main/java/com/xuqm/sdk/license/model/LicenseFile.kt b/sdk-license/src/main/java/com/xuqm/sdk/license/model/LicenseFile.kt deleted file mode 100644 index cc1d2ce..0000000 --- a/sdk-license/src/main/java/com/xuqm/sdk/license/model/LicenseFile.kt +++ /dev/null @@ -1,20 +0,0 @@ -package com.xuqm.sdk.license.model - -import com.google.gson.annotations.SerializedName - -/** - * License file structure. Generated by tenant platform when service is enabled. - * Placed in app assets directory (e.g., assets/xuqm/license.xuqm) - */ -data class LicenseFile( - @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 = "issuedAt", alternate = ["issued_at"]) val issuedAt: String? = null, - @SerializedName(value = "expiresAt", alternate = ["expires_at"]) val expiresAt: String? = null, -) diff --git a/sdk-license/src/main/java/com/xuqm/sdk/license/model/LicenseModels.kt b/sdk-license/src/main/java/com/xuqm/sdk/license/model/LicenseModels.kt deleted file mode 100644 index 055d015..0000000 --- a/sdk-license/src/main/java/com/xuqm/sdk/license/model/LicenseModels.kt +++ /dev/null @@ -1,54 +0,0 @@ -package com.xuqm.sdk.license.model - -import com.google.gson.annotations.SerializedName - -data class RegisterRequest( - @SerializedName("appKey") val appKey: String, - @SerializedName("deviceId") val deviceId: String, - @SerializedName("deviceName") val deviceName: String? = null, - @SerializedName("deviceModel") val deviceModel: String? = null, - @SerializedName("deviceVendor") val deviceVendor: String? = null, - @SerializedName("osVersion") val osVersion: String? = null, - @SerializedName("userInfo") val userInfo: LicenseUserInfo? = null, -) - -data class RegisterResponse( - val success: Boolean = false, - val token: String? = null, - val message: String? = null, -) - -data class VerifyRequest( - @SerializedName("appKey") val appKey: String, - @SerializedName("deviceId") val deviceId: String, - val token: String, - @SerializedName("userInfo") val userInfo: LicenseUserInfo? = null, -) - -data class VerifyResponse( - val valid: Boolean = false, - val error: String? = null, -) - -data class ApiResponse( - val code: Int = 0, - val status: String = "0", - val data: T? = null, - val message: String? = null, -) - -data class LicenseUserInfo( - @SerializedName("userId") val userId: String? = null, - @SerializedName("name") val name: String? = null, - @SerializedName("email") val email: String? = null, - @SerializedName("phone") val phone: String? = null, -) - -data class AppInfoResponse( - @SerializedName("androidPackageName") val androidPackageName: String? = null, - @SerializedName("iosBundleId") val iosBundleId: String? = null, - @SerializedName("harmonyBundleName") val harmonyBundleName: String? = null, -) { - fun matchesPackageName(packageName: String): Boolean = - packageName == androidPackageName || packageName == iosBundleId || packageName == harmonyBundleName -} diff --git a/sdk-license/src/main/java/com/xuqm/sdk/license/store/LicenseStore.kt b/sdk-license/src/main/java/com/xuqm/sdk/license/store/LicenseStore.kt deleted file mode 100644 index 89551ef..0000000 --- a/sdk-license/src/main/java/com/xuqm/sdk/license/store/LicenseStore.kt +++ /dev/null @@ -1,57 +0,0 @@ -package com.xuqm.sdk.license.store - -import android.content.Context -import android.content.SharedPreferences -import androidx.security.crypto.EncryptedSharedPreferences -import androidx.security.crypto.MasterKeys - -private const val PREFS_NAME = "xuqm_license_secure" -private const val KEY_DEVICE_ID = "license_device_id" -private const val KEY_TOKEN = "license_token" -private const val KEY_STATUS = "license_status" -private const val KEY_STATUS_TIME = "license_status_time" -private const val KEY_APP_KEY = "license_app_key" - -class LicenseStore(context: Context) { - - private val appContext = context.applicationContext - - private val prefs: SharedPreferences = try { - EncryptedSharedPreferences.create( - PREFS_NAME, - MasterKeys.getOrCreate(MasterKeys.AES256_GCM_SPEC), - appContext, - EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, - EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM, - ) - } catch (_: Exception) { - // Fallback to regular SharedPreferences if encrypted fails - appContext.getSharedPreferences("${PREFS_NAME}_fallback", Context.MODE_PRIVATE) - } - - var deviceId: String? - get() = prefs.getString(KEY_DEVICE_ID, null) - set(value) = prefs.edit().putString(KEY_DEVICE_ID, value).apply() - - var token: String? - get() = prefs.getString(KEY_TOKEN, null) - set(value) = prefs.edit().putString(KEY_TOKEN, value).apply() - - var status: String? - get() = prefs.getString(KEY_STATUS, null) - set(value) = prefs.edit().putString(KEY_STATUS, value).apply() - - var statusTime: Long - get() = prefs.getLong(KEY_STATUS_TIME, 0) - set(value) = prefs.edit().putLong(KEY_STATUS_TIME, value).apply() - - var appKey: String? - get() = prefs.getString(KEY_APP_KEY, null) - set(value) = prefs.edit().putString(KEY_APP_KEY, value).apply() - - fun clear() { - prefs.edit().clear().apply() - } - - fun isSameAppKey(key: String): Boolean = appKey == key -} diff --git a/sdk-update/build.gradle.kts b/sdk-update/build.gradle.kts index 756a511..fe22e00 100644 --- a/sdk-update/build.gradle.kts +++ b/sdk-update/build.gradle.kts @@ -34,4 +34,5 @@ dependencies { api(project(":sdk-core")) implementation(libs.kotlinx.coroutines.android) implementation("com.google.code.gson:gson:2.10.1") + testImplementation(libs.junit4) } diff --git a/sdk-update/src/main/AndroidManifest.xml b/sdk-update/src/main/AndroidManifest.xml new file mode 100644 index 0000000..941d653 --- /dev/null +++ b/sdk-update/src/main/AndroidManifest.xml @@ -0,0 +1,3 @@ + + + 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 7d7bf0c..dc1e875 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 @@ -4,6 +4,9 @@ import android.content.Context import android.content.Intent import android.net.Uri import android.os.Build +import android.os.Handler +import android.os.Looper +import android.provider.Settings import android.util.Log import androidx.core.content.FileProvider import com.xuqm.sdk.XuqmSDK @@ -11,14 +14,26 @@ import com.xuqm.sdk.file.FileSDK import com.xuqm.sdk.core.ServiceEndpointRegistry import com.xuqm.sdk.network.ApiClient import com.xuqm.sdk.update.api.UpdateApi +import com.xuqm.sdk.update.model.ApkInstallRequest +import com.xuqm.sdk.update.model.UpdateDownloadProgress import com.xuqm.sdk.update.model.UpdateInfo +import com.xuqm.sdk.update.model.UpdateInstallCallback +import com.xuqm.sdk.update.model.UpdateInstallErrorCode +import com.xuqm.sdk.update.model.UpdateInstallException +import com.xuqm.sdk.update.model.UpdateInstallTask +import com.xuqm.sdk.update.model.UpdateProgressListener +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import java.io.File -import java.security.MessageDigest object UpdateSDK { + private val sha256Pattern = Regex("^[a-fA-F0-9]{64}$") + private val api: UpdateApi get() = ApiClient.create(UpdateApi::class.java, ServiceEndpointRegistry.updateBaseUrl) private fun resolveUserId(): String? = XuqmSDK.getUserId() @@ -70,13 +85,9 @@ object UpdateSDK { // APK 本地文件管理 // ───────────────────────────────────────────────────────────────────────── - /** 版本化 APK 文件路径 */ + /** 版本化 APK 文件路径。 */ private fun versionedApkFile(context: Context, versionCode: Int): File = - File(context.getExternalFilesDir(null), "update_$versionCode.apk") - - /** 兼容旧版:无版本号的 APK 文件 */ - private fun legacyApkFile(context: Context): File = - File(context.getExternalFilesDir(null), "update.apk") + File(context.getExternalFilesDir(null) ?: context.filesDir, "update_$versionCode.apk") /** * 检查指定版本的 APK 是否已下载到本地。 @@ -85,41 +96,34 @@ object UpdateSDK { * * @param context Android Context * @param versionCode 版本号 - * @param expectedHash 服务端返回的 APK 文件 SHA-256 哈希(可为空,为空时仅检查文件存在性) + * @param expectedHash 服务端返回的 APK 文件 SHA-256;缺失时不得认定文件可安装 */ - fun isApkDownloaded(context: Context, versionCode: Int, expectedHash: String = ""): Boolean { + fun isApkDownloaded(context: Context, versionCode: Int, expectedHash: String): Boolean { val file = resolveDownloadedApk(context, versionCode) ?: return false - if (expectedHash.isBlank()) return true // 无哈希信息,仅检查文件存在性 - return computeFileHash(file) == expectedHash.lowercase() - } - - /** - * 计算文件的 SHA-256 哈希值(小写十六进制)。 - */ - private fun computeFileHash(file: File): String { - val md = MessageDigest.getInstance("SHA-256") - file.inputStream().use { input -> - val buffer = ByteArray(8192) - var read: Int - while (input.read(buffer).also { read = it } != -1) { - md.update(buffer, 0, read) - } - } - val bytes = md.digest() - val sb = StringBuilder(bytes.size * 2) - for (b in bytes) { - sb.append(String.format("%02x", b.toInt() and 0xFF)) - } - return sb.toString() + return FileSDK.matchesSha256(file, expectedHash) } /** * 直接安装已下载的 APK,无需重新下载。 - * @throws IllegalStateException 如果 APK 文件不存在 + * @throws UpdateInstallException 文件缺失或哈希不匹配 */ - suspend fun installDownloadedApk(context: Context, versionCode: Int) = withContext(Dispatchers.Main) { + suspend fun installDownloadedApk( + context: Context, + versionCode: Int, + expectedSha256: String, + ) = withContext(Dispatchers.Main) { val apkFile = resolveDownloadedApk(context, versionCode) - ?: throw IllegalStateException("APK for version $versionCode not found locally") + ?: throw UpdateInstallException( + UpdateInstallErrorCode.DOWNLOAD_FAILED, + "APK for version $versionCode not found locally", + ) + if (!FileSDK.matchesSha256(apkFile, expectedSha256)) { + apkFile.delete() + throw UpdateInstallException( + UpdateInstallErrorCode.HASH_MISMATCH, + "APK SHA-256 does not match version $versionCode", + ) + } installApk(context, apkFile) } @@ -129,11 +133,15 @@ object UpdateSDK { private fun resolveDownloadedApk(context: Context, versionCode: Int): File? { val versioned = versionedApkFile(context, versionCode) if (versioned.exists() && versioned.length() > 0) return versioned - val legacy = legacyApkFile(context) - if (legacy.exists() && legacy.length() > 0) return legacy return null } + private fun isVerifiedApkDownloaded(context: Context, info: UpdateInfo): Boolean = + info.apkHash + ?.takeIf { it.isNotBlank() } + ?.let { hash -> isApkDownloaded(context, info.versionCode, hash) } + ?: false + // ───────────────────────────────────────────────────────────────────────── // 检测更新 // ───────────────────────────────────────────────────────────────────────── @@ -176,8 +184,9 @@ object UpdateSDK { cached } if (afterIgnore.needsUpdate && afterIgnore.versionCode > 0) { - return@withContext afterIgnore.copy(alreadyDownloaded = isApkDownloaded( - context, afterIgnore.versionCode, afterIgnore.apkHash ?: "")) + return@withContext afterIgnore.copy( + alreadyDownloaded = isVerifiedApkDownloaded(context, afterIgnore), + ) } return@withContext afterIgnore } @@ -212,8 +221,9 @@ object UpdateSDK { normalized } val result = if (afterIgnore.needsUpdate && afterIgnore.versionCode > 0) { - afterIgnore.copy(alreadyDownloaded = isApkDownloaded( - context, afterIgnore.versionCode, afterIgnore.apkHash ?: "")) + afterIgnore.copy( + alreadyDownloaded = isVerifiedApkDownloaded(context, afterIgnore), + ) } else { afterIgnore } @@ -250,48 +260,106 @@ object UpdateSDK { // 下载与安装 // ───────────────────────────────────────────────────────────────────────── - /** - * 下载 APK 并调起系统安装器(对应 spec 中的 downloadAndInstallApk)。 - * 如果该版本的 APK 已下载到本地,将跳过下载直接安装。 - * - * @param downloadUrl APK 下载地址(来自 [UpdateInfo.downloadUrl]) - * @param versionCode 版本号(来自 [UpdateInfo.versionCode]),用于本地文件命名和已下载检测 - * @param onProgress 下载进度回调 (0~1),跳过下载时不会调用 - * @param sha256 APK 文件的 SHA-256 校验值(可选),有则验证 - */ + /** Downloads, verifies, and opens the system package installer. */ suspend fun downloadAndInstallApk( context: Context, - downloadUrl: String, - versionCode: Int = 0, - onProgress: ((Float) -> Unit)? = null, - sha256: String? = null, + request: ApkInstallRequest, + onProgress: ((UpdateDownloadProgress) -> Unit)? = null, ) = withContext(Dispatchers.IO) { - if (versionCode > 0 && isApkDownloaded(context, versionCode, sha256.orEmpty())) { - val apkFile = resolveDownloadedApk(context, versionCode)!! + if (request.downloadUrl.isBlank() || request.versionCode <= 0) { + throw UpdateInstallException( + UpdateInstallErrorCode.DOWNLOAD_FAILED, + "downloadUrl must not be blank and versionCode must be positive", + ) + } + if (!request.sha256.matches(sha256Pattern)) { + throw UpdateInstallException( + UpdateInstallErrorCode.HASH_MISMATCH, + "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 = if (versionCode > 0) versionedApkFile(context, versionCode) else legacyApkFile(context) - FileSDK.downloadToFile(downloadUrl, apkFile) { progressInt -> - onProgress?.invoke(progressInt / 100f) + + 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}", + ) + } + 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)) + } } - if (versionCode > 0) { - val legacy = legacyApkFile(context) - if (legacy.exists()) runCatching { legacy.delete() } - } - withContext(Dispatchers.Main) { installApk(context, apkFile) } + throw UpdateInstallException( + UpdateInstallErrorCode.DOWNLOAD_FAILED, + "Unable to download APK after ${retryCount + 1} attempt(s)", + lastDownloadError, + ) } - @Deprecated( - "Use downloadAndInstallApk instead.", - ReplaceWith("downloadAndInstallApk(context, downloadUrl, versionCode, { onProgress(it.toInt()) })") - ) - suspend fun downloadAndInstall( + /** Java/RN entry point backed by the same suspending implementation. */ + @JvmStatic + fun startDownloadAndInstall( context: Context, - downloadUrl: String, - versionCode: Int = 0, - onProgress: (Int) -> Unit = {}, - ) = downloadAndInstallApk(context, downloadUrl, versionCode, { onProgress((it * 100).toInt()) }) + request: ApkInstallRequest, + progressListener: UpdateProgressListener?, + callback: UpdateInstallCallback, + ): UpdateInstallTask { + val mainHandler = Handler(Looper.getMainLooper()) + val job = CoroutineScope(Dispatchers.Main.immediate).launch { + try { + downloadAndInstallApk(context.applicationContext, request) { progress -> + progressListener?.let { listener -> + mainHandler.post { listener.onProgress(progress) } + } + } + callback.onSuccess() + } catch (error: UpdateInstallException) { + callback.onError(error.errorCode.wireValue, error.message.orEmpty()) + } catch (error: CancellationException) { + callback.onError( + UpdateInstallErrorCode.DOWNLOAD_CANCELLED.wireValue, + "APK download was cancelled", + ) + } catch (error: Throwable) { + callback.onError( + UpdateInstallErrorCode.INSTALL_FAILED.wireValue, + error.message ?: "APK installation failed", + ) + } + } + return UpdateInstallTask(job) + } // ───────────────────────────────────────────────────────────────────────── // WebSocket 实时通知 @@ -322,12 +390,55 @@ object UpdateSDK { // 安装 // ───────────────────────────────────────────────────────────────────────── - private fun installApk(context: Context, apkFile: File) { - val intent = 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) - setDataAndType(uri, "application/vnd.android.package-archive") + @JvmStatic + fun createInstallPermissionIntent(context: Context): Intent? = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + Intent(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES).apply { + data = Uri.parse("package:${context.packageName}") + flags = Intent.FLAG_ACTIVITY_NEW_TASK + } + } else { + null + } + + @JvmStatic + fun canRequestPackageInstalls(context: Context): Boolean = + Build.VERSION.SDK_INT < Build.VERSION_CODES.O || context.packageManager.canRequestPackageInstalls() + + private fun installApk(context: Context, apkFile: File) { + if (!canRequestPackageInstalls(context)) { + throw UpdateInstallException( + UpdateInstallErrorCode.INSTALL_PERMISSION_REQUIRED, + "Permission to install unknown apps is required", + ) + } + 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) + setDataAndType(uri, "application/vnd.android.package-archive") + } + } catch (error: Exception) { + throw UpdateInstallException( + UpdateInstallErrorCode.INSTALL_FAILED, + "Unable to create APK installation request", + error, + ) + } + if (intent.resolveActivity(context.packageManager) == null) { + throw UpdateInstallException( + UpdateInstallErrorCode.INSTALLER_UNAVAILABLE, + "No package installer is available", + ) + } + try { + context.startActivity(intent) + } catch (error: Exception) { + throw UpdateInstallException( + UpdateInstallErrorCode.INSTALL_FAILED, + "Unable to open the package installer", + error, + ) } - context.startActivity(intent) } } 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 new file mode 100644 index 0000000..a790b97 --- /dev/null +++ b/sdk-update/src/main/java/com/xuqm/sdk/update/model/ApkInstall.kt @@ -0,0 +1,53 @@ +package com.xuqm.sdk.update.model + +import kotlinx.coroutines.Job + +enum class UpdateInstallErrorCode(val wireValue: String) { + DOWNLOAD_FAILED("DOWNLOAD_FAILED"), + DOWNLOAD_CANCELLED("DOWNLOAD_CANCELLED"), + HASH_MISMATCH("HASH_MISMATCH"), + INSTALL_PERMISSION_REQUIRED("INSTALL_PERMISSION_REQUIRED"), + INSTALLER_UNAVAILABLE("INSTALLER_UNAVAILABLE"), + INSTALL_FAILED("INSTALL_FAILED"), +} + +class UpdateInstallException( + val errorCode: UpdateInstallErrorCode, + message: String, + cause: Throwable? = null, +) : Exception(message, cause) + +data class UpdateDownloadProgress( + val bytesDownloaded: Long, + val totalBytes: Long?, +) { + val percent: Int? + get() = totalBytes?.takeIf { it > 0L } + ?.let { ((bytesDownloaded * 100L) / it).toInt().coerceIn(0, 100) } +} + +data class ApkInstallRequest( + val downloadUrl: String, + val versionCode: Int, + val sha256: String, + /** Number of retries after the initial download attempt. */ + val retryCount: Int = 1, +) + +fun interface UpdateProgressListener { + fun onProgress(progress: UpdateDownloadProgress) +} + +interface UpdateInstallCallback { + fun onSuccess() + fun onError(code: String, message: String) +} + +class UpdateInstallTask internal constructor(private val job: Job) { + fun cancel() { + job.cancel() + } + + val isActive: Boolean + get() = job.isActive +} 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 new file mode 100644 index 0000000..274bb49 --- /dev/null +++ b/sdk-update/src/test/java/com/xuqm/sdk/update/model/ApkInstallTest.kt @@ -0,0 +1,29 @@ +package com.xuqm.sdk.update.model + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class ApkInstallTest { + @Test + fun `progress exposes a nullable percentage`() { + assertEquals(50, UpdateDownloadProgress(50, 100).percent) + assertEquals(100, UpdateDownloadProgress(120, 100).percent) + assertNull(UpdateDownloadProgress(50, null).percent) + } + + @Test + fun `wire error values remain deterministic for bridges`() { + assertEquals( + listOf( + "DOWNLOAD_FAILED", + "DOWNLOAD_CANCELLED", + "HASH_MISMATCH", + "INSTALL_PERMISSION_REQUIRED", + "INSTALLER_UNAVAILABLE", + "INSTALL_FAILED", + ), + UpdateInstallErrorCode.entries.map { it.wireValue }, + ) + } +} diff --git a/settings.gradle.kts b/settings.gradle.kts index e362db5..1f64f58 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -29,7 +29,6 @@ include(":sdk-im") include(":sdk-push") include(":sdk-update") include(":sdk-webview") -include(":sdk-license") include(":sdk-bugcollect") include(":sdk-bugcollect-plugin") include(":sample-app")