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-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):除修复构建阻塞外不扩展功能。**
|
||||||
|
|
||||||
## 核心 API(sdk-core)
|
## 核心 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_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/"
|
||||||
|
|||||||
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-push → 厂商推送(FCM/HMS/VIVO/OPPO/Honor/APNs)
|
||||||
├── sdk-im → IM(WebSocket/STOMP 实时通信)
|
├── sdk-im → IM(WebSocket/STOMP 实时通信)
|
||||||
├── sdk-webview → WebView + JSBridge(Compose 组件)
|
├── sdk-webview → WebView + JSBridge(Compose 组件)
|
||||||
├── 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。
|
||||||
主线程只读文件字节(<10ms),PBKDF2 解密 + 初始化全部移到 IO 线程。
|
主线程只读文件字节(<10ms),PBKDF2 解密 + 初始化全部移到 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-core(TokenStore / DeviceId / ApiClient)
|
* - 初始化 sdk-core(TokenStore / 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
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"))
|
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")
|
||||||
|
|||||||
正在加载...
在新工单中引用
屏蔽一个用户