feat(update): harden apk installation and remove license sdk

这个提交包含在:
XuqmGroup 2026-07-17 13:35:27 +08:00
父节点 d8ac97255d
当前提交 dff83f43e5
共有 45 个文件被更改,包括 578 次插入912 次删除

19
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.

查看文件

@ -19,10 +19,9 @@ XuqmGroup Android SDK,Gradle multi-module 项目。为集成宿主 App如 Y
| sdk-bugcollect | `sdk-bugcollect`(新) | 新建中 | | sdk-bugcollect | `sdk-bugcollect`(新) | 新建中 |
| sdk-push | `sdk-push` | 代码冻结(仅文档) | | sdk-push | `sdk-push` | 代码冻结(仅文档) |
| sdk-im | `sdk-im` | 代码冻结(仅文档) | | sdk-im | `sdk-im` | 代码冻结(仅文档) |
| sdk-license | `sdk-license` | 代码冻结(仅文档) |
| sample-app | — | 演示 App | | sample-app | — | 演示 App |
**代码冻结模块sdk-push/sdk-im/sdk-license不修改代码,仅补充文档。** **非当前范围模块sdk-push/sdk-im除修复构建阻塞外不扩展功能。**
## 核心 APIsdk-core ## 核心 APIsdk-core

30
Jenkinsfile vendored
查看文件

@ -16,7 +16,6 @@ pipeline {
booleanParam(name: 'MOD_PUSH', defaultValue: false, description: '发布 sdk-push') booleanParam(name: 'MOD_PUSH', defaultValue: false, description: '发布 sdk-push')
booleanParam(name: 'MOD_UPDATE', defaultValue: false, description: '发布 sdk-update') booleanParam(name: 'MOD_UPDATE', defaultValue: false, description: '发布 sdk-update')
booleanParam(name: 'MOD_WEBVIEW', defaultValue: false, description: '发布 sdk-webview') 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自动联动') booleanParam(name: 'MOD_BUGCOLLECT', defaultValue: false, description: '发布 sdk-bugcollect + sdk-bugcollect-plugin自动联动')
} }
@ -40,7 +39,6 @@ pipeline {
'sdk-push': params.MOD_PUSH, 'sdk-push': params.MOD_PUSH,
'sdk-update': params.MOD_UPDATE, 'sdk-update': params.MOD_UPDATE,
'sdk-webview': params.MOD_WEBVIEW, 'sdk-webview': params.MOD_WEBVIEW,
'sdk-license': params.MOD_LICENSE,
'sdk-bugcollect': params.MOD_BUGCOLLECT, 'sdk-bugcollect': params.MOD_BUGCOLLECT,
'sdk-bugcollect-plugin': params.MOD_BUGCOLLECT, 'sdk-bugcollect-plugin': params.MOD_BUGCOLLECT,
] ]
@ -103,8 +101,11 @@ pipeline {
def newVer def newVer
if (params.SNAPSHOT) { if (params.SNAPSHOT) {
// SNAPSHOT基于下一版本 + -SNAPSHOT,确保高于当前 release // 已声明的 SNAPSHOT 必须原样发布,保证下游依赖可确定解析。
newVer = bumpVersion(baseVer) + '-SNAPSHOT' // 仅当当前是 release 时,才基于下一补丁版本生成 SNAPSHOT。
newVer = currentVer.endsWith('-SNAPSHOT')
? currentVer
: bumpVersion(baseVer) + '-SNAPSHOT'
echo "SNAPSHOT: ${propName} → ${newVer} (base: ${baseVer})" echo "SNAPSHOT: ${propName} → ${newVer} (base: ${baseVer})"
} else { } else {
// Release升级版本号,写回 gradle.properties // 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') { stage('Build AARs') {
steps { steps {
script { script {
@ -148,9 +164,11 @@ pipeline {
steps { steps {
script { script {
def modules = env.PUBLISH_MODULES.split(',').toList() 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%" 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"
}
} }
} }
} }

查看文件

@ -8,7 +8,7 @@ plugins {
group = "com.xuqm" group = "com.xuqm"
// Global fallback — individual modules override via their own SDK_<NAME>_VERSION property. // Global fallback — individual modules override via their own SDK_<NAME>_VERSION property.
// Publish all at once: ./gradlew publish -PPUBLISH_VERSION=1.0.2 // 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") version = providers.gradleProperty("PUBLISH_VERSION").getOrElse("0.1.0-SNAPSHOT")
ext["nexusUrl"] = "https://nexus.xuqinmin.com/repository/android-hosted/" ext["nexusUrl"] = "https://nexus.xuqinmin.com/repository/android-hosted/"

查看文件

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

查看文件

@ -20,7 +20,6 @@
├── sdk-push → 厂商推送FCM/HMS/VIVO/OPPO/Honor/APNs ├── sdk-push → 厂商推送FCM/HMS/VIVO/OPPO/Honor/APNs
├── sdk-im → IMWebSocket/STOMP 实时通信) ├── sdk-im → IMWebSocket/STOMP 实时通信)
├── sdk-webview → WebView + JSBridgeCompose 组件) ├── sdk-webview → WebView + JSBridgeCompose 组件)
├── sdk-license → 证书授权
└── sdk-bugcollect → 日志 / Crash 捕获 / 漏斗分析(依赖 sdk-core └── sdk-bugcollect → 日志 / Crash 捕获 / 漏斗分析(依赖 sdk-core
``` ```
@ -33,7 +32,6 @@ sdk-core (核心,无 SDK 内部依赖)
├── sdk-push (依赖 sdk-core ├── sdk-push (依赖 sdk-core
├── sdk-im (依赖 sdk-core ├── sdk-im (依赖 sdk-core
├── sdk-webview (依赖 sdk-core ├── sdk-webview (依赖 sdk-core
├── sdk-license (依赖 sdk-core
└── sdk-bugcollect依赖 sdk-core └── sdk-bugcollect依赖 sdk-core
``` ```
@ -43,8 +41,7 @@ sdk-core (核心,无 SDK 内部依赖)
XuqmSDK.setUserInfo(info) XuqmSDK.setUserInfo(info)
├── PushSDK: 检测厂商 → 注册 token → 上报服务端 ├── PushSDK: 检测厂商 → 注册 token → 上报服务端
├── ImSDK: 若 userSig 存在且 imEnabled → 自动登录 WebSocket ├── ImSDK: 若 userSig 存在且 imEnabled → 自动登录 WebSocket
├── UpdateSDK: 更新 userId └── UpdateSDK: 更新 userId
└── LicenseSDK: 更新用户上下文
XuqmSDK.setUserInfo(null) XuqmSDK.setUserInfo(null)
├── PushSDK: 解绑 token ├── PushSDK: 解绑 token

查看文件

@ -6,7 +6,6 @@ PUBLISH_VERSION=1.0.5
SDK_CORE_VERSION=1.1.6-SNAPSHOT SDK_CORE_VERSION=1.1.6-SNAPSHOT
SDK_IM_VERSION=1.1.3 SDK_IM_VERSION=1.1.3
SDK_PUSH_VERSION=1.1.4-SNAPSHOT SDK_PUSH_VERSION=1.1.4-SNAPSHOT
SDK_UPDATE_VERSION=1.1.8 SDK_UPDATE_VERSION=2.0.0-SNAPSHOT
SDK_WEBVIEW_VERSION=1.1.10-SNAPSHOT SDK_WEBVIEW_VERSION=1.1.10-SNAPSHOT
SDK_LICENSE_VERSION=1.1.3
SDK_BUGCOLLECT_VERSION=1.0.11 SDK_BUGCOLLECT_VERSION=1.0.11

查看文件

@ -18,6 +18,7 @@ jserialization = "1.9.0"
webkit = "1.14.0" webkit = "1.14.0"
coil = "3.1.0" coil = "3.1.0"
junit4 = "4.13.2" junit4 = "4.13.2"
desugarJdkLibs = "2.0.3"
androidxJunit = "1.3.0" androidxJunit = "1.3.0"
espresso = "3.7.0" espresso = "3.7.0"
hilt = "2.56.2" 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-android = { group = "com.google.dagger", name = "hilt-android", version.ref = "hilt" }
hilt-compiler = { group = "com.google.dagger", name = "hilt-compiler", version.ref = "hilt" } hilt-compiler = { group = "com.google.dagger", name = "hilt-compiler", version.ref = "hilt" }
junit4 = { group = "junit", name = "junit", version.ref = "junit4" } junit4 = { group = "junit", name = "junit", version.ref = "junit4" }
desugar-jdk-libs = { group = "com.android.tools", name = "desugar_jdk_libs", version.ref = "desugarJdkLibs" }
androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "androidxJunit" } androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "androidxJunit" }
androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espresso" } androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espresso" }

查看文件

@ -23,14 +23,5 @@
<category android:name="android.intent.category.LAUNCHER" /> <category android:name="android.intent.category.LAUNCHER" />
</intent-filter> </intent-filter>
</activity> </activity>
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
</provider>
</application> </application>
</manifest> </manifest>

查看文件

@ -36,6 +36,7 @@ import androidx.compose.ui.graphics.vector.ImageVector
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.xuqm.sdk.im.ImSDK import com.xuqm.sdk.im.ImSDK
import com.xuqm.sdk.update.UpdateSDK import com.xuqm.sdk.update.UpdateSDK
import com.xuqm.sdk.update.model.ApkInstallRequest
import com.xuqm.sdk.update.model.UpdateInfo import com.xuqm.sdk.update.model.UpdateInfo
import com.xuqm.sdk.sample.ui.contact.ContactScreen import com.xuqm.sdk.sample.ui.contact.ContactScreen
import com.xuqm.sdk.sample.ui.conversation.ConversationScreen import com.xuqm.sdk.sample.ui.conversation.ConversationScreen
@ -144,12 +145,21 @@ fun MainScreen(
confirmButton = { confirmButton = {
Button( Button(
onClick = { onClick = {
if (update.downloadUrl.isNotBlank() && !isDownloading) { val sha256 = update.apkHash
if (update.downloadUrl.isNotBlank() && !sha256.isNullOrBlank() && !isDownloading) {
isDownloading = true isDownloading = true
scope.launch { scope.launch {
UpdateSDK.downloadAndInstallApk(context, update.downloadUrl, onProgress = { progress -> UpdateSDK.downloadAndInstallApk(
downloadProgress = (progress * 100).toInt() context,
}) ApkInstallRequest(
downloadUrl = update.downloadUrl,
versionCode = update.versionCode,
sha256 = sha256,
),
onProgress = { progress ->
progress.percent?.let { downloadProgress = it }
},
)
isDownloading = false isDownloading = false
pendingUpdate = null pendingUpdate = null
downloadProgress = -1 downloadProgress = -1

查看文件

@ -24,6 +24,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.xuqm.sdk.update.UpdateSDK import com.xuqm.sdk.update.UpdateSDK
import com.xuqm.sdk.update.model.ApkInstallRequest
import com.xuqm.sdk.update.model.UpdateInfo import com.xuqm.sdk.update.model.UpdateInfo
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow 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 { 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) _state.value = _state.value.copy(downloadProgress = 0)
UpdateSDK.downloadAndInstallApk(context, downloadUrl, onProgress = { progress -> UpdateSDK.downloadAndInstallApk(
_state.value = _state.value.copy(downloadProgress = (progress * 100).toInt()) 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( Button(
onClick = { onClick = {
update.downloadUrl.takeIf { it.isNotBlank() } update.downloadUrl.takeIf { it.isNotBlank() }
?.let { viewModel.downloadAndInstall(context, it) } ?.let { viewModel.downloadAndInstall(context, update) }
}, },
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
) { Text("立即更新") } ) { Text("立即更新") }

查看文件

@ -21,6 +21,7 @@ android {
compileOptions { compileOptions {
isCoreLibraryDesugaringEnabled = true
sourceCompatibility = JavaVersion.VERSION_11 sourceCompatibility = JavaVersion.VERSION_11
targetCompatibility = JavaVersion.VERSION_11 targetCompatibility = JavaVersion.VERSION_11
} }
@ -45,4 +46,6 @@ dependencies {
api(libs.androidx.security.crypto) api(libs.androidx.security.crypto)
api(libs.androidx.ui) api(libs.androidx.ui)
api(libs.androidx.material3) api(libs.androidx.material3)
testImplementation(libs.junit4)
coreLibraryDesugaring(libs.desugar.jdk.libs)
} }

查看文件

@ -1,7 +1,19 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"> <manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<application> <application>
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/xuqm_file_paths" />
</provider>
<!-- <!--
合并启动入口:替代 XuqmInitializerProvider + BugCollectInitProvider + LicenseInitializerProvider。 合并启动入口:替代 core 与扩展 SDK 分别注册的初始化 Provider。
主线程只读文件字节(<10msPBKDF2 + IO 线 主线程只读文件字节(<10msPBKDF2 + IO 线
initOrder=50 尽早执行,但不阻塞主线程。 initOrder=50 尽早执行,但不阻塞主线程。
--> -->

查看文件

@ -145,7 +145,7 @@ object XuqmSDK {
* - 原始字节由调用方传入主线程只做文件读取不做 PBKDF2 * - 原始字节由调用方传入主线程只做文件读取不做 PBKDF2
* - IO 线程直接解密不使用磁盘缓存signingKey 始终完整读取 * - IO 线程直接解密不使用磁盘缓存signingKey 始终完整读取
* - 复用 sdkScope不创建冗余 CoroutineScope * - 复用 sdkScope不创建冗余 CoroutineScope
* - 初始化完成后自动触发 BugCollect / License 等子 SDK 启动 * - 初始化完成后自动触发 BugCollect 等扩展 SDK 启动
*/ */
fun autoInitializeFromBytes(context: Context, rawBytes: ByteArray, logLevel: LogLevel = LogLevel.WARN) { fun autoInitializeFromBytes(context: Context, rawBytes: ByteArray, logLevel: LogLevel = LogLevel.WARN) {
val ctx = context.applicationContext val ctx = context.applicationContext
@ -179,12 +179,6 @@ object XuqmSDK {
Log.w(TAG, "BugCollect init failed: ${e.message}") 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 -> }.onFailure { e ->
Log.w(TAG, "autoInitializeFromBytes failed: ${e.message}") Log.w(TAG, "autoInitializeFromBytes failed: ${e.message}")
} }
@ -205,18 +199,6 @@ object XuqmSDK {
startMethod.invoke(instance) 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手动初始化 * 方式 B手动初始化
* *
@ -435,7 +417,6 @@ object XuqmSDK {
* - **PushSDK**检测厂商 获取厂商配置 注册设备 上报 token * - **PushSDK**检测厂商 获取厂商配置 注册设备 上报 token
* - **ImSDK** [XuqmUserInfo.userSig] 存在且平台开通了 IM自动登录 * - **ImSDK** [XuqmUserInfo.userSig] 存在且平台开通了 IM自动登录
* - **UpdateSDK**更新 userId用于灰度/定向更新 * - **UpdateSDK**更新 userId用于灰度/定向更新
* - **LicenseSDK**更新用户上下文
* *
* 登出时传 `null`触发所有子 SDK 登出 * 登出时传 `null`触发所有子 SDK 登出
* ```kotlin * ```kotlin

查看文件

@ -2,13 +2,16 @@ package com.xuqm.sdk.file
import android.app.NotificationChannel import android.app.NotificationChannel
import android.app.NotificationManager import android.app.NotificationManager
import android.app.Notification
import android.app.PendingIntent import android.app.PendingIntent
import android.Manifest
import android.content.ContentValues import android.content.ContentValues
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.net.Uri import android.net.Uri
import android.os.Build import android.os.Build
import android.os.Environment import android.os.Environment
import android.content.pm.PackageManager
import android.provider.MediaStore import android.provider.MediaStore
import android.webkit.MimeTypeMap import android.webkit.MimeTypeMap
import androidx.core.app.NotificationCompat import androidx.core.app.NotificationCompat
@ -26,6 +29,7 @@ import retrofit2.http.Multipart
import retrofit2.http.POST import retrofit2.http.POST
import retrofit2.http.Part import retrofit2.http.Part
import java.io.File import java.io.File
import java.security.MessageDigest
sealed class FileDownloadDestination { sealed class FileDownloadDestination {
/** App-private external files dir (no special permission needed). */ /** App-private external files dir (no special permission needed). */
@ -44,6 +48,15 @@ data class FileUploadResult(
val ext: String? = null, 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<T>( data class ApiResponse<T>(
val code: Int, val code: Int,
val status: String, val status: String,
@ -121,10 +134,36 @@ object FileSDK {
targetFile: File, targetFile: File,
onProgress: (Int) -> Unit = {}, onProgress: (Int) -> Unit = {},
): File = withContext(Dispatchers.IO) { ): File = withContext(Dispatchers.IO) {
FileTransfer.downloadToFile(downloadUrl, targetFile, onProgress) FileTransfer.downloadToFile(downloadUrl, targetFile) { progress ->
progress.percent?.let(onProgress)
}
targetFile 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( suspend fun upload(
file: File, file: File,
thumbnailBytes: ByteArray? = null, thumbnailBytes: ByteArray? = null,
@ -203,22 +242,22 @@ object FileSDK {
.setContentText(resolvedName) .setContentText(resolvedName)
.setOngoing(true) .setOngoing(true)
.setProgress(100, 0, false) .setProgress(100, 0, false)
.also { NotificationManagerCompat.from(context).notify(id, it.build()) } .also { notifyIfAllowed(context, id, it.build()) }
} }
val capturedNotifId = notifId val capturedNotifId = notifId
var downloadError: Throwable? = null var downloadError: Throwable? = null
try { try {
FileTransfer.downloadToFile(downloadUrl, target) { progress -> FileTransfer.downloadToFile(downloadUrl, target) { downloadProgress ->
if (capturedNotifId != null) { downloadProgress.percent?.let { progress ->
notifBuilder?.let { builder -> if (capturedNotifId != null) {
builder.setProgress(100, progress, false) notifBuilder?.let { builder ->
if (NotificationManagerCompat.from(context).areNotificationsEnabled()) { builder.setProgress(100, progress, false)
NotificationManagerCompat.from(context).notify(capturedNotifId, builder.build()) notifyIfAllowed(context, capturedNotifId, builder.build())
} }
} }
onProgress(progress)
} }
onProgress(progress)
} }
if (useMediaStore) { if (useMediaStore) {
saveFileToPublicDownloads(context, target, resolvedName) saveFileToPublicDownloads(context, target, resolvedName)
@ -235,7 +274,8 @@ object FileSDK {
if (nm.areNotificationsEnabled()) { if (nm.areNotificationsEnabled()) {
val err = downloadError val err = downloadError
if (err == null) { if (err == null) {
nm.notify( notifyIfAllowed(
context,
id, id,
NotificationCompat.Builder(context, DOWNLOAD_CHANNEL_ID) NotificationCompat.Builder(context, DOWNLOAD_CHANNEL_ID)
.setSmallIcon(android.R.drawable.stat_sys_download_done) .setSmallIcon(android.R.drawable.stat_sys_download_done)
@ -246,7 +286,8 @@ object FileSDK {
.build(), .build(),
) )
} else { } else {
nm.notify( notifyIfAllowed(
context,
id, id,
NotificationCompat.Builder(context, DOWNLOAD_CHANNEL_ID) NotificationCompat.Builder(context, DOWNLOAD_CHANNEL_ID)
.setSmallIcon(android.R.drawable.stat_notify_error) .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" private const val DOWNLOAD_CHANNEL_ID = "xuqm_downloads"
fun saveBlobDownload( fun saveBlobDownload(
@ -347,6 +405,7 @@ object FileSDK {
* 在华为等设备上直接文件 I/O 会失败MediaStore 通过 ContentResolver 操作兼容性更好 * 在华为等设备上直接文件 I/O 会失败MediaStore 通过 ContentResolver 操作兼容性更好
* 返回缓存目录中的副本真实文件可被 openFile 使用 * 返回缓存目录中的副本真实文件可被 openFile 使用
*/ */
@androidx.annotation.RequiresApi(Build.VERSION_CODES.Q)
private fun saveBlobViaMediaStore(context: Context, bytes: ByteArray, fileName: String): File { private fun saveBlobViaMediaStore(context: Context, bytes: ByteArray, fileName: String): File {
val mimeType = MimeTypeMap.getSingleton() val mimeType = MimeTypeMap.getSingleton()
.getMimeTypeFromExtension(fileName.substringAfterLast('.', "").lowercase()) .getMimeTypeFromExtension(fileName.substringAfterLast('.', "").lowercase())

查看文件

@ -3,11 +3,17 @@ package com.xuqm.sdk.file
import android.content.Context import android.content.Context
import android.net.Uri import android.net.Uri
import android.provider.OpenableColumns 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.BufferedSink
import okio.source import okio.source
import okhttp3.MediaType.Companion.toMediaTypeOrNull import okhttp3.MediaType.Companion.toMediaTypeOrNull
import okhttp3.RequestBody import okhttp3.RequestBody
import java.io.File import java.io.File
import java.io.IOException
import java.net.HttpURLConnection
import java.net.URL import java.net.URL
import java.util.Locale import java.util.Locale
@ -75,7 +81,7 @@ object FileTransfer {
val count = minOf(bufferSize, bytes.size - uploaded) val count = minOf(bufferSize, bytes.size - uploaded)
sink.write(bytes, uploaded, count) sink.write(bytes, uploaded, count)
uploaded += count uploaded += count
onProgress((uploaded * 100 / bytes.size).toInt().coerceIn(0, 100)) onProgress((uploaded * 100 / bytes.size).coerceIn(0, 100))
} }
if (bytes.isEmpty()) { if (bytes.isEmpty()) {
onProgress(100) onProgress(100)
@ -83,27 +89,64 @@ object FileTransfer {
} }
} }
fun downloadToFile( suspend fun downloadToFile(
downloadUrl: String, downloadUrl: String,
targetFile: File, targetFile: File,
onProgress: (Int) -> Unit = {}, onProgress: (FileDownloadProgress) -> Unit = {},
) { ): File {
val connection = URL(downloadUrl).openConnection() val coroutineContext = currentCoroutineContext()
connection.connect() val connection = URL(downloadUrl).openConnection().apply {
val totalSize = connection.contentLengthLong connectTimeout = 15_000
connection.getInputStream().use { input -> readTimeout = 30_000
targetFile.outputStream().use { output -> useCaches = false
val buffer = ByteArray(8192) }
var downloaded = 0L val cancellation = coroutineContext[Job]?.invokeOnCompletion { cause ->
var read: Int if (cause is CancellationException && connection is HttpURLConnection) {
while (input.read(buffer).also { read = it } != -1) { connection.disconnect()
output.write(buffer, 0, read) }
downloaded += read }
if (totalSize > 0) { targetFile.parentFile?.mkdirs()
onProgress((downloaded * 100 / totalSize).toInt()) 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()
} }
} }

查看文件

@ -5,7 +5,7 @@ import com.google.gson.annotations.SerializedName
/** /**
* Decrypted content of the init config file (assets/xuqm/config.xuqm). * Decrypted content of the init config file (assets/xuqm/config.xuqm).
* Contains the appKey and optional server URL needed to bootstrap the SDK. * 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( internal data class ConfigFile(
@SerializedName(value = "appKey", alternate = ["app_key"]) val appKey: String, @SerializedName(value = "appKey", alternate = ["app_key"]) val appKey: String,

查看文件

@ -8,7 +8,7 @@ import android.util.Log
import com.xuqm.sdk.XuqmSDK import com.xuqm.sdk.XuqmSDK
/** /**
* 合并启动入口替代 XuqmInitializerProvider + BugCollectInitProvider + LicenseInitializerProvider * 合并启动入口替代各扩展 SDK 分别注册的初始化 Provider
* *
* 设计原则主线程零加密运算 * 设计原则主线程零加密运算
* Phase 1同步主线程<10ms * Phase 1同步主线程<10ms
@ -20,7 +20,6 @@ import com.xuqm.sdk.XuqmSDK
* - 缓存 miss 时执行 PBKDF2 解密~100ms并写缓存 * - 缓存 miss 时执行 PBKDF2 解密~100ms并写缓存
* - 初始化 sdk-coreTokenStore / DeviceId / ApiClient * - 初始化 sdk-coreTokenStore / DeviceId / ApiClient
* - 注册 sdk-bugcollect CrashCapture * - 注册 sdk-bugcollect CrashCapture
* - 初始化 sdk-license LicenseContextHolder
* - 拉取远端平台配置 * - 拉取远端平台配置
*/ */
class XuqmMergedProvider : ContentProvider() { class XuqmMergedProvider : ContentProvider() {

查看文件

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<paths xmlns:android="http://schemas.android.com/apk/res/android">
<files-path name="files" path="." />
<cache-path name="cache" path="." />
<external-files-path name="external_files" path="." />
<external-cache-path name="external_cache" path="." />
</paths>

查看文件

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

查看文件

@ -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 缓存 |

查看文件

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

查看文件

@ -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 <fields>;
}
# 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

查看文件

@ -1 +0,0 @@
# Library modules do not use isMinifyEnabled. Rules are in consumer-rules.pro.

查看文件

@ -1,2 +0,0 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
</manifest>

查看文件

@ -1,5 +0,0 @@
package com.xuqm.sdk.license
fun interface LicenseCallback {
fun onResult(isValid: Boolean)
}

查看文件

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

查看文件

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

查看文件

@ -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/"
}
}

查看文件

@ -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<RegisterResponse>
@POST("api/license/verify")
suspend fun verify(@Body request: VerifyRequest): ApiResponse<VerifyResponse>
@GET("api/license/app-info")
suspend fun getAppInfo(@Query("appKey") appKey: String): ApiResponse<AppInfoResponse>
}

查看文件

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

查看文件

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

查看文件

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

查看文件

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

查看文件

@ -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 <T : Any> create(service: Class<T>, baseUrl: String): T {
return Retrofit.Builder()
.baseUrl(baseUrl)
.client(okHttpClient)
.addConverterFactory(GsonConverterFactory.create(gson))
.build()
.create(service)
}
}

查看文件

@ -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<out String>?, selection: String?, selectionArgs: Array<out String>?, sortOrder: String?): Cursor? = null
override fun getType(uri: Uri): String? = null
override fun insert(uri: Uri, values: ContentValues?): Uri? = null
override fun delete(uri: Uri, selection: String?, selectionArgs: Array<out String>?): Int = 0
override fun update(uri: Uri, values: ContentValues?, selection: String?, selectionArgs: Array<out String>?): Int = 0
}

查看文件

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

查看文件

@ -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<T>(
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
}

查看文件

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

查看文件

@ -34,4 +34,5 @@ dependencies {
api(project(":sdk-core")) api(project(":sdk-core"))
implementation(libs.kotlinx.coroutines.android) implementation(libs.kotlinx.coroutines.android)
implementation("com.google.code.gson:gson:2.10.1") implementation("com.google.code.gson:gson:2.10.1")
testImplementation(libs.junit4)
} }

查看文件

@ -0,0 +1,3 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
</manifest>

查看文件

@ -4,6 +4,9 @@ import android.content.Context
import android.content.Intent import android.content.Intent
import android.net.Uri import android.net.Uri
import android.os.Build import android.os.Build
import android.os.Handler
import android.os.Looper
import android.provider.Settings
import android.util.Log import android.util.Log
import androidx.core.content.FileProvider import androidx.core.content.FileProvider
import com.xuqm.sdk.XuqmSDK 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.core.ServiceEndpointRegistry
import com.xuqm.sdk.network.ApiClient import com.xuqm.sdk.network.ApiClient
import com.xuqm.sdk.update.api.UpdateApi 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.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.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import java.io.File import java.io.File
import java.security.MessageDigest
object UpdateSDK { 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 api: UpdateApi get() = ApiClient.create(UpdateApi::class.java, ServiceEndpointRegistry.updateBaseUrl)
private fun resolveUserId(): String? = XuqmSDK.getUserId() private fun resolveUserId(): String? = XuqmSDK.getUserId()
@ -70,13 +85,9 @@ object UpdateSDK {
// APK 本地文件管理 // APK 本地文件管理
// ───────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────
/** 版本化 APK 文件路径 */ /** 版本化 APK 文件路径 */
private fun versionedApkFile(context: Context, versionCode: Int): File = private fun versionedApkFile(context: Context, versionCode: Int): File =
File(context.getExternalFilesDir(null), "update_$versionCode.apk") File(context.getExternalFilesDir(null) ?: context.filesDir, "update_$versionCode.apk")
/** 兼容旧版:无版本号的 APK 文件 */
private fun legacyApkFile(context: Context): File =
File(context.getExternalFilesDir(null), "update.apk")
/** /**
* 检查指定版本的 APK 是否已下载到本地 * 检查指定版本的 APK 是否已下载到本地
@ -85,41 +96,34 @@ object UpdateSDK {
* *
* @param context Android Context * @param context Android Context
* @param versionCode 版本号 * @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 val file = resolveDownloadedApk(context, versionCode) ?: return false
if (expectedHash.isBlank()) return true // 无哈希信息,仅检查文件存在性 return FileSDK.matchesSha256(file, expectedHash)
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()
} }
/** /**
* 直接安装已下载的 APK无需重新下载 * 直接安装已下载的 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) 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) installApk(context, apkFile)
} }
@ -129,11 +133,15 @@ object UpdateSDK {
private fun resolveDownloadedApk(context: Context, versionCode: Int): File? { private fun resolveDownloadedApk(context: Context, versionCode: Int): File? {
val versioned = versionedApkFile(context, versionCode) val versioned = versionedApkFile(context, versionCode)
if (versioned.exists() && versioned.length() > 0) return versioned if (versioned.exists() && versioned.length() > 0) return versioned
val legacy = legacyApkFile(context)
if (legacy.exists() && legacy.length() > 0) return legacy
return null 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 cached
} }
if (afterIgnore.needsUpdate && afterIgnore.versionCode > 0) { if (afterIgnore.needsUpdate && afterIgnore.versionCode > 0) {
return@withContext afterIgnore.copy(alreadyDownloaded = isApkDownloaded( return@withContext afterIgnore.copy(
context, afterIgnore.versionCode, afterIgnore.apkHash ?: "")) alreadyDownloaded = isVerifiedApkDownloaded(context, afterIgnore),
)
} }
return@withContext afterIgnore return@withContext afterIgnore
} }
@ -212,8 +221,9 @@ object UpdateSDK {
normalized normalized
} }
val result = if (afterIgnore.needsUpdate && afterIgnore.versionCode > 0) { val result = if (afterIgnore.needsUpdate && afterIgnore.versionCode > 0) {
afterIgnore.copy(alreadyDownloaded = isApkDownloaded( afterIgnore.copy(
context, afterIgnore.versionCode, afterIgnore.apkHash ?: "")) alreadyDownloaded = isVerifiedApkDownloaded(context, afterIgnore),
)
} else { } else {
afterIgnore afterIgnore
} }
@ -250,48 +260,106 @@ object UpdateSDK {
// 下载与安装 // 下载与安装
// ───────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────
/** /** Downloads, verifies, and opens the system package installer. */
* 下载 APK 并调起系统安装器对应 spec 中的 downloadAndInstallApk
* 如果该版本的 APK 已下载到本地将跳过下载直接安装
*
* @param downloadUrl APK 下载地址来自 [UpdateInfo.downloadUrl]
* @param versionCode 版本号来自 [UpdateInfo.versionCode]用于本地文件命名和已下载检测
* @param onProgress 下载进度回调 (0~1)跳过下载时不会调用
* @param sha256 APK 文件的 SHA-256 校验值可选有则验证
*/
suspend fun downloadAndInstallApk( suspend fun downloadAndInstallApk(
context: Context, context: Context,
downloadUrl: String, request: ApkInstallRequest,
versionCode: Int = 0, onProgress: ((UpdateDownloadProgress) -> Unit)? = null,
onProgress: ((Float) -> Unit)? = null,
sha256: String? = null,
) = withContext(Dispatchers.IO) { ) = withContext(Dispatchers.IO) {
if (versionCode > 0 && isApkDownloaded(context, versionCode, sha256.orEmpty())) { if (request.downloadUrl.isBlank() || request.versionCode <= 0) {
val apkFile = resolveDownloadedApk(context, versionCode)!! 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) } withContext(Dispatchers.Main) { installApk(context, apkFile) }
return@withContext return@withContext
} }
val apkFile = if (versionCode > 0) versionedApkFile(context, versionCode) else legacyApkFile(context)
FileSDK.downloadToFile(downloadUrl, apkFile) { progressInt -> val apkFile = versionedApkFile(context, request.versionCode)
onProgress?.invoke(progressInt / 100f) 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) { throw UpdateInstallException(
val legacy = legacyApkFile(context) UpdateInstallErrorCode.DOWNLOAD_FAILED,
if (legacy.exists()) runCatching { legacy.delete() } "Unable to download APK after ${retryCount + 1} attempt(s)",
} lastDownloadError,
withContext(Dispatchers.Main) { installApk(context, apkFile) } )
} }
@Deprecated( /** Java/RN entry point backed by the same suspending implementation. */
"Use downloadAndInstallApk instead.", @JvmStatic
ReplaceWith("downloadAndInstallApk(context, downloadUrl, versionCode, { onProgress(it.toInt()) })") fun startDownloadAndInstall(
)
suspend fun downloadAndInstall(
context: Context, context: Context,
downloadUrl: String, request: ApkInstallRequest,
versionCode: Int = 0, progressListener: UpdateProgressListener?,
onProgress: (Int) -> Unit = {}, callback: UpdateInstallCallback,
) = downloadAndInstallApk(context, downloadUrl, versionCode, { onProgress((it * 100).toInt()) }) ): 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 实时通知 // WebSocket 实时通知
@ -322,12 +390,55 @@ object UpdateSDK {
// 安装 // 安装
// ───────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────
private fun installApk(context: Context, apkFile: File) { @JvmStatic
val intent = Intent(Intent.ACTION_VIEW).apply { fun createInstallPermissionIntent(context: Context): Intent? =
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_GRANT_READ_URI_PERMISSION if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val uri = FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", apkFile) Intent(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES).apply {
setDataAndType(uri, "application/vnd.android.package-archive") 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)
} }
} }

查看文件

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

查看文件

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

查看文件

@ -29,7 +29,6 @@ include(":sdk-im")
include(":sdk-push") include(":sdk-push")
include(":sdk-update") include(":sdk-update")
include(":sdk-webview") include(":sdk-webview")
include(":sdk-license")
include(":sdk-bugcollect") include(":sdk-bugcollect")
include(":sdk-bugcollect-plugin") include(":sdk-bugcollect-plugin")
include(":sample-app") include(":sample-app")