feat(update): harden apk installation and remove license sdk
这个提交包含在:
父节点
d8ac97255d
当前提交
dff83f43e5
19
AGENTS.md
普通文件
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-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)
|
||||
|
||||
|
||||
30
Jenkinsfile
vendored
30
Jenkinsfile
vendored
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -8,7 +8,7 @@ plugins {
|
||||
group = "com.xuqm"
|
||||
// Global fallback — individual modules override via their own SDK_<NAME>_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/"
|
||||
|
||||
42
docs/IMPLEMENTATION_HANDOFF.md
普通文件
42
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 发布。
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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" }
|
||||
|
||||
|
||||
@ -23,14 +23,5 @@
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</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>
|
||||
</manifest>
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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("立即更新") }
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -1,7 +1,19 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
|
||||
<application>
|
||||
<provider
|
||||
android:name="androidx.core.content.FileProvider"
|
||||
android:authorities="${applicationId}.fileprovider"
|
||||
android:exported="false"
|
||||
android:grantUriPermissions="true">
|
||||
<meta-data
|
||||
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||
android:resource="@xml/xuqm_file_paths" />
|
||||
</provider>
|
||||
|
||||
<!--
|
||||
合并启动入口:替代 XuqmInitializerProvider + BugCollectInitProvider + LicenseInitializerProvider。
|
||||
合并启动入口:替代 core 与扩展 SDK 分别注册的初始化 Provider。
|
||||
主线程只读文件字节(<10ms),PBKDF2 解密 + 初始化全部移到 IO 线程。
|
||||
initOrder=50 尽早执行,但不阻塞主线程。
|
||||
-->
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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<T>(
|
||||
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,23 +242,23 @@ 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 ->
|
||||
FileTransfer.downloadToFile(downloadUrl, target) { downloadProgress ->
|
||||
downloadProgress.percent?.let { progress ->
|
||||
if (capturedNotifId != null) {
|
||||
notifBuilder?.let { builder ->
|
||||
builder.setProgress(100, progress, false)
|
||||
if (NotificationManagerCompat.from(context).areNotificationsEnabled()) {
|
||||
NotificationManagerCompat.from(context).notify(capturedNotifId, builder.build())
|
||||
}
|
||||
notifyIfAllowed(context, capturedNotifId, builder.build())
|
||||
}
|
||||
}
|
||||
onProgress(progress)
|
||||
}
|
||||
}
|
||||
if (useMediaStore) {
|
||||
saveFileToPublicDownloads(context, target, resolvedName)
|
||||
android.util.Log.d("FileSDK", "download: saved to public Downloads via MediaStore: $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())
|
||||
|
||||
@ -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()
|
||||
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()
|
||||
val totalSize = connection.contentLengthLong
|
||||
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 ->
|
||||
targetFile.outputStream().use { output ->
|
||||
val buffer = ByteArray(8192)
|
||||
partialFile.outputStream().buffered().use { output ->
|
||||
val buffer = ByteArray(DEFAULT_BUFFER_SIZE)
|
||||
var downloaded = 0L
|
||||
var read: Int
|
||||
while (input.read(buffer).also { read = it } != -1) {
|
||||
while (true) {
|
||||
coroutineContext.ensureActive()
|
||||
val read = input.read(buffer)
|
||||
if (read < 0) break
|
||||
output.write(buffer, 0, read)
|
||||
downloaded += read
|
||||
if (totalSize > 0) {
|
||||
onProgress((downloaded * 100 / totalSize).toInt())
|
||||
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).
|
||||
* 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,
|
||||
|
||||
@ -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() {
|
||||
|
||||
@ -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
sdk-license/proguard-rules.pro
vendored
1
sdk-license/proguard-rules.pro
vendored
@ -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"))
|
||||
implementation(libs.kotlinx.coroutines.android)
|
||||
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.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 (versionCode > 0) {
|
||||
val legacy = legacyApkFile(context)
|
||||
if (legacy.exists()) runCatching { legacy.delete() }
|
||||
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))
|
||||
}
|
||||
}
|
||||
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 {
|
||||
// 安装
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@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) {
|
||||
val intent = Intent(Intent.ACTION_VIEW).apply {
|
||||
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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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-update")
|
||||
include(":sdk-webview")
|
||||
include(":sdk-license")
|
||||
include(":sdk-bugcollect")
|
||||
include(":sdk-bugcollect-plugin")
|
||||
include(":sample-app")
|
||||
|
||||
正在加载...
在新工单中引用
屏蔽一个用户