diff --git a/README.md b/README.md
index c6e4dc1..ccab012 100644
--- a/README.md
+++ b/README.md
@@ -416,6 +416,10 @@ WebView 内 `` 和 `` 均已内置
- 带 `download` 属性的 `` 标签,或链接以可下载扩展名(`.pdf`、`.zip`、`.docx` 等)结尾
- Blob URL(自动转 base64 后传给 native 处理)
+公共 Downloads 复用 `FileSDK` 的 MediaStore 实现,不申请
+`MANAGE_EXTERNAL_STORAGE`,也不会跳转“所有文件访问权限”设置页。Android 9 及以下
+仍按系统规范处理 `WRITE_EXTERNAL_STORAGE` 运行时权限。
+
```kotlin
XWebViewView(
config = XWebViewConfig(
diff --git a/docs/IMPLEMENTATION_HANDOFF.md b/docs/IMPLEMENTATION_HANDOFF.md
index d7f1f9d..aca725b 100644
--- a/docs/IMPLEMENTATION_HANDOFF.md
+++ b/docs/IMPLEMENTATION_HANDOFF.md
@@ -4,6 +4,41 @@
> 分支:`main`
> 发布约束:Maven 制品只允许通过 `https://jenkins.xuqinmin.com/` 发布。
+## 2026-07-28 Update 原生权威契约与客户端秘密清理
+
+- 新签发配置不再包含 `signingKey/appSecret`;Android 客户端删除可提取长期秘密及
+ HMAC 请求头生成。配置真实性仍由 Ed25519 签名保证,登录请求只使用宿主同步的短期令牌。
+- `NetworkMonitor` 成为唯一网络状态来源,可独立于 SDK 初始化使用,公开
+ `NONE/WIFI/CELLULAR/CELLULAR_4G/CELLULAR_5G/ETHERNET/OTHER` 与是否计费网络。
+ 宿主自行决定是否允许蜂窝网络下载,租户平台不保存网络建议。
+- `checkAppUpdate` 返回 `UpdateCheckResult`,明确区分
+ `UPDATE_AVAILABLE/NO_UPDATE/LOGIN_REQUIRED/SERVICE_DISABLED/FAILED`,并携带网络快照;
+ 更新服务失败、未登录或未开通不再用 `null` 混淆,也不阻断宿主业务。
+- 原生 Update 同时提供检查、只下载、只安装、下载并安装和应用商店跳转。下载任务不跨
+ 进程恢复;取消和失败清理 `.part`。安装前校验 SHA-256、包名与签名证书。
+- 已校验 APK 可按宿主选择额外导出到系统 Downloads。Android 10+ 使用 MediaStore;
+ Android 9 及以下遵循系统运行时存储权限,不申请所有文件访问权限。
+- 删除未接入正式构建链且会读取明文发布参数、在本机直接发布的
+ `sdk-update/scripts/xuqm_release.gradle.kts`。
+- `sdk-webview` 已删除所有文件访问权限门禁与设置页跳转,下载统一复用 core FileSDK。
+
+本批已验证:
+
+```bash
+./gradlew :sdk-core:testDebugUnitTest \
+ :sdk-update:testDebugUnitTest \
+ :sdk-update:assembleRelease \
+ :sdk-webview:testDebugUnitTest \
+ :sdk-webview:lintRelease \
+ verifySdkManifestOwnership
+```
+
+上述命令在增加包名/签名校验与 Java/RN 回调后最终复验通过(233 个 Gradle task,
+68 executed、165 up-to-date)。
+Sample 全量编译被真实配置门禁按预期拦截:仓库未包含租户平台签发的
+`sample-app/src/main/assets/config/config.xuqmconfig`。未伪造配置,真机、Jenkins 和
+Maven 发布尚未执行。
+
## 当前目标
- `sdk-core` 是配置、会话、网络、文件、时间等公共能力的唯一实现。
diff --git a/sample-app/src/androidTest/java/com/xuqm/sdk/sample/SdkIntegrationTest.kt b/sample-app/src/androidTest/java/com/xuqm/sdk/sample/SdkIntegrationTest.kt
index 2360721..fa18654 100644
--- a/sample-app/src/androidTest/java/com/xuqm/sdk/sample/SdkIntegrationTest.kt
+++ b/sample-app/src/androidTest/java/com/xuqm/sdk/sample/SdkIntegrationTest.kt
@@ -193,13 +193,13 @@ class SdkIntegrationTest {
*/
@Test
fun tc07_updateCheck() = runBlocking {
- val updateInfo = UpdateSDK.checkAppUpdate(appCtx)
- // updateInfo 为 null 表示当前已是最新版,非 null 则包含版本详情
- if (updateInfo != null) {
+ val result = UpdateSDK.checkAppUpdate(appCtx)
+ val updateInfo = result.update
+ if (result.hasUpdate && updateInfo != null) {
assertTrue("versionCode 应大于 0(如有更新)", updateInfo.versionCode > 0)
assertTrue("versionName 应非空", updateInfo.versionName.isNotBlank())
}
- // 关键断言: 接口调用不抛异常,UpdateInfo 结构正确
+ // 关键断言:检查始终返回明确状态,不再以 null 混淆无需更新、登录门禁与错误。
}
/**
diff --git a/sample-app/src/main/java/com/xuqm/sdk/sample/ui/main/MainScreen.kt b/sample-app/src/main/java/com/xuqm/sdk/sample/ui/main/MainScreen.kt
index 79d1749..a45ebe6 100644
--- a/sample-app/src/main/java/com/xuqm/sdk/sample/ui/main/MainScreen.kt
+++ b/sample-app/src/main/java/com/xuqm/sdk/sample/ui/main/MainScreen.kt
@@ -36,6 +36,7 @@ import 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.update.model.UpdateCheckStatus
import com.xuqm.sdk.sample.ui.contact.ContactScreen
import com.xuqm.sdk.sample.ui.conversation.ConversationScreen
import com.xuqm.sdk.sample.ui.group.GroupListScreen
@@ -76,7 +77,10 @@ fun MainScreen(
LaunchedEffect(Unit) {
if (!updateChecked) {
updateChecked = true
- pendingUpdate = UpdateSDK.checkAppUpdate(context)?.takeIf { it.needsUpdate }
+ val result = UpdateSDK.checkAppUpdate(context)
+ pendingUpdate = result.update?.takeIf {
+ result.status == UpdateCheckStatus.UPDATE_AVAILABLE
+ }
}
}
diff --git a/sample-app/src/main/java/com/xuqm/sdk/sample/ui/update/UpdateScreen.kt b/sample-app/src/main/java/com/xuqm/sdk/sample/ui/update/UpdateScreen.kt
index 7d56fda..37c36d4 100644
--- a/sample-app/src/main/java/com/xuqm/sdk/sample/ui/update/UpdateScreen.kt
+++ b/sample-app/src/main/java/com/xuqm/sdk/sample/ui/update/UpdateScreen.kt
@@ -26,6 +26,7 @@ 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 com.xuqm.sdk.update.model.UpdateCheckStatus
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
@@ -45,11 +46,17 @@ class UpdateViewModel : ViewModel() {
fun checkAppUpdate(context: Context) {
viewModelScope.launch {
_state.value = _state.value.copy(isChecking = true, message = null)
- val info = UpdateSDK.checkAppUpdate(context)
+ val result = UpdateSDK.checkAppUpdate(context)
_state.value = _state.value.copy(
isChecking = false,
- appUpdate = info,
- message = if (info?.needsUpdate == true) null else "已是最新版本",
+ appUpdate = result.update,
+ message = when (result.status) {
+ UpdateCheckStatus.UPDATE_AVAILABLE -> null
+ UpdateCheckStatus.NO_UPDATE -> "已是最新版本"
+ UpdateCheckStatus.LOGIN_REQUIRED -> "登录后可检查灰度更新"
+ UpdateCheckStatus.SERVICE_DISABLED -> "当前未启用版本更新"
+ UpdateCheckStatus.FAILED -> result.message ?: "检查更新失败"
+ },
)
}
}
diff --git a/sdk-core/README.md b/sdk-core/README.md
index bb9557c..513a474 100644
--- a/sdk-core/README.md
+++ b/sdk-core/README.md
@@ -21,6 +21,7 @@ app/src/main/assets/config/config.xuqmconfig
宿主不需要编写初始化代码,也不得修改、复制或自行生成该文件。SDK 先验证平台
Ed25519 签名,再解密引导配置。V1、未知 keyId、签名错误和包名不匹配均拒绝使用。
`serverUrl` 是签发配置中的唯一平台地址且必填,运行时没有默认地址或跨平台回退。
+配置只包含公开应用身份与平台地址,不包含 `appSecret` 或其它客户端长期认证秘密。
只依赖 `sdk-core` 时不会注册初始化 Provider、申请通知权限或合并 FileProvider,
基础 HTTP、下载、摘要、MediaStore、时间和安全存储能力无需初始化或登录。引入扩展 SDK 后才由扩展
@@ -45,6 +46,8 @@ Manifest 合并唯一初始化 Provider。
| `XuqmSDK.bugCollectApiUrl` | Bug 收集服务地址(从平台配置获取) |
| `XuqmSDK.bugCollectEnabled` | 是否启用 Bug 收集上报 |
| `XuqmSDK.tokenStore` | Token 存储(Android Keystore + AES-256-GCM) |
+| `NetworkMonitor.observe(context)` | 独立启动网络观察并返回当前状态 |
+| `NetworkMonitor.status` | 网络状态 `StateFlow` |
### XuqmUserInfo
@@ -71,7 +74,7 @@ XuqmSDK.tokenStore.clear()
### FileSDK
-`FileSDK` 只保留无需初始化、零 Manifest 的本地下载、摘要与 MediaStore 能力。
+`FileSDK` 只保留无需初始化的本地下载、摘要与 MediaStore 能力。
依赖租户平台的上传和依赖 FileProvider 的打开文件能力属于独立 `sdk-file`,
统一通过 `PlatformFileSDK` 调用。
@@ -79,5 +82,10 @@ XuqmSDK.tokenStore.clear()
// 下载
val file = FileSDK.download(context, url, fileName, destination, notificationTitle, onProgress)
```
+保存到公共 Downloads 时,Android 10+ 使用 MediaStore;Android 9 及以下由宿主按系统
+规范申请 `WRITE_EXTERNAL_STORAGE`。SDK 不申请“所有文件访问权限”,也不执行 shell 命令。
+
+`NetworkMonitor` 不要求 SDK 初始化。它只申请 `ACCESS_NETWORK_STATE`,不会为了细分
+蜂窝网络代际申请电话或定位权限;系统不允许读取时稳定返回 `CELLULAR`。
平台配置成功后以加密方式保存七天 LKG。平台暂不可用且 LKG 有效时进入
`DEGRADED`;没有可用缓存时扩展能力返回结构化错误,宿主核心业务不受影响。
diff --git a/sdk-core/src/main/AndroidManifest.xml b/sdk-core/src/main/AndroidManifest.xml
index cc947c5..c9cca5b 100644
--- a/sdk-core/src/main/AndroidManifest.xml
+++ b/sdk-core/src/main/AndroidManifest.xml
@@ -1 +1,8 @@
-
+
+
+
+
+
+
diff --git a/sdk-core/src/main/java/com/xuqm/sdk/XuqmSDK.kt b/sdk-core/src/main/java/com/xuqm/sdk/XuqmSDK.kt
index 396169e..937c6f6 100644
--- a/sdk-core/src/main/java/com/xuqm/sdk/XuqmSDK.kt
+++ b/sdk-core/src/main/java/com/xuqm/sdk/XuqmSDK.kt
@@ -15,6 +15,7 @@ import com.xuqm.sdk.internal.ConfigCache
import com.xuqm.sdk.internal.PlatformConfigCache
import com.xuqm.sdk.internal.BugCollectRuntimePolicy
import com.xuqm.sdk.network.ApiClient
+import com.xuqm.sdk.network.NetworkMonitor
import com.xuqm.sdk.network.SdkPlatformConfig
import com.xuqm.sdk.network.SdkPlatformConfigApi
import kotlinx.coroutines.CompletableDeferred
@@ -84,10 +85,6 @@ object XuqmSDK {
@Volatile var privacyConsentGranted: Boolean = false
private set
- /** 请求签名密钥,从配置文件读取。 */
- @Volatile var signingKey: String? = null
- private set
-
private val pendingInitCallbacks = mutableListOf<() -> Unit>()
private val pendingReadyCallbacks = mutableListOf<() -> Unit>()
@@ -143,9 +140,7 @@ object XuqmSDK {
)
}
- signingKey = configFile.signingKey
- val signingConfigured = !configFile.signingKey.isNullOrBlank()
- Log.i(TAG, "Config loaded: signingConfigured=$signingConfigured")
+ Log.i(TAG, "Verified config loaded")
// 2. 初始化 sdk-core
initializeVerifiedConfig(ctx, configFile.appKey, configFile.serverUrl, logLevel)
@@ -208,6 +203,7 @@ object XuqmSDK {
appContext = applicationContext
tokenStore = TokenStore(applicationContext)
deviceId = resolveDeviceId(applicationContext)
+ NetworkMonitor.observe(applicationContext)
ApiClient.init(config, tokenStore)
initializedAppKey = appKey
initialized = true
diff --git a/sdk-core/src/main/java/com/xuqm/sdk/core/SDKConfig.kt b/sdk-core/src/main/java/com/xuqm/sdk/core/SDKConfig.kt
index be9b47d..dda4a3e 100644
--- a/sdk-core/src/main/java/com/xuqm/sdk/core/SDKConfig.kt
+++ b/sdk-core/src/main/java/com/xuqm/sdk/core/SDKConfig.kt
@@ -3,7 +3,6 @@ package com.xuqm.sdk.core
data class SDKConfig(
val appKey: String,
val logLevel: LogLevel = LogLevel.WARN,
- val signingKey: String? = null,
)
enum class LogLevel { DEBUG, INFO, WARN, ERROR, NONE }
diff --git a/sdk-core/src/main/java/com/xuqm/sdk/file/FileSDK.kt b/sdk-core/src/main/java/com/xuqm/sdk/file/FileSDK.kt
index bd41b9b..823a6da 100644
--- a/sdk-core/src/main/java/com/xuqm/sdk/file/FileSDK.kt
+++ b/sdk-core/src/main/java/com/xuqm/sdk/file/FileSDK.kt
@@ -284,7 +284,7 @@ object FileSDK {
* 直接 FileOutputStream 在 Android 10+ 无权限(EACCES),必须走 MediaStore。
*/
@androidx.annotation.RequiresApi(Build.VERSION_CODES.Q)
- private fun saveFileToPublicDownloads(context: Context, file: File, fileName: String) {
+ private fun saveFileToPublicDownloads(context: Context, file: File, fileName: String): Uri {
val mimeType = MimeTypeMap.getSingleton()
.getMimeTypeFromExtension(fileName.substringAfterLast('.', "").lowercase())
?: "application/octet-stream"
@@ -308,6 +308,43 @@ object FileSDK {
values.clear()
values.put(MediaStore.Downloads.IS_PENDING, 0)
resolver.update(uri, values, null, null)
+ return uri
+ }
+
+ /**
+ * 将已校验的文件额外导出到系统 Downloads。
+ *
+ * Android 10+ 使用 MediaStore;Android 9 及以下需要宿主先取得
+ * WRITE_EXTERNAL_STORAGE 运行时权限。返回系统可访问的 Uri。
+ */
+ fun exportToPublicDownloads(
+ context: Context,
+ file: File,
+ displayName: String = file.name,
+ ): Uri {
+ require(file.isFile) { "Source file does not exist" }
+ return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
+ saveFileToPublicDownloads(context, file, displayName)
+ } else {
+ if (
+ ContextCompat.checkSelfPermission(
+ context,
+ Manifest.permission.WRITE_EXTERNAL_STORAGE,
+ ) != PackageManager.PERMISSION_GRANTED
+ ) {
+ throw SecurityException(
+ "Android 9 及以下导出到 Downloads 需要 WRITE_EXTERNAL_STORAGE 权限",
+ )
+ }
+ @Suppress("DEPRECATION")
+ val downloads = Environment.getExternalStoragePublicDirectory(
+ Environment.DIRECTORY_DOWNLOADS,
+ )
+ check(downloads.exists() || downloads.mkdirs()) {
+ "Unable to create public Downloads directory"
+ }
+ Uri.fromFile(file.copyTo(uniqueFile(downloads, displayName)))
+ }
}
/**
@@ -358,53 +395,16 @@ object FileSDK {
),
appName,
)
- // mkdirs() 在部分华为/小米设备上静默失败,多重尝试确保目录存在
- if (!dir.exists()) {
- dir.mkdirs()
- }
- if (!dir.exists()) {
- runCatching { Runtime.getRuntime().exec(arrayOf("mkdir", "-p", dir.absolutePath)).waitFor() }
- }
- if (!dir.exists()) {
- android.util.Log.w("XWV", "resolveBaseDir: falling back to Sandbox")
- context.getExternalFilesDir(null) ?: context.filesDir
- } else {
- dir
+ check(dir.exists() || dir.mkdirs()) {
+ "Unable to create public Downloads directory"
}
+ dir
}
FileDownloadDestination.Sandbox -> {
context.getExternalFilesDir(null) ?: context.filesDir
}
}
- /**
- * 检查是否拥有"所有文件访问权限"(MANAGE_EXTERNAL_STORAGE)。
- * Android 11+ 需要此权限才能写入公共 Downloads 目录。
- * Android 10 及以下始终返回 true(使用 WRITE_EXTERNAL_STORAGE)。
- */
- fun isManageStorageGranted(context: Context): Boolean {
- return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
- Environment.isExternalStorageManager()
- } else {
- true
- }
- }
-
- /**
- * 返回跳转到"所有文件访问权限"设置页的 Intent。
- * 调用方应使用 startActivityForResult 或 registerForActivityResult 处理返回。
- */
- fun requestManageStorageIntent(context: Context): Intent? {
- if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) return null
- return try {
- Intent(android.provider.Settings.ACTION_MANAGE_APP_ALL_FILES_ACCESS_PERMISSION).apply {
- data = Uri.parse("package:${context.packageName}")
- }
- } catch (_: Exception) {
- Intent(android.provider.Settings.ACTION_MANAGE_ALL_FILES_ACCESS_PERMISSION)
- }
- }
-
/**
* Saves an image file to the system photo gallery (MediaStore).
* Returns true if the file is an image and was saved successfully.
diff --git a/sdk-core/src/main/java/com/xuqm/sdk/internal/ConfigFile.kt b/sdk-core/src/main/java/com/xuqm/sdk/internal/ConfigFile.kt
index 95d6ca5..5056528 100644
--- a/sdk-core/src/main/java/com/xuqm/sdk/internal/ConfigFile.kt
+++ b/sdk-core/src/main/java/com/xuqm/sdk/internal/ConfigFile.kt
@@ -17,7 +17,6 @@ internal data class ConfigFile(
@SerializedName(value = "iosBundleId", alternate = ["ios_bundle_id"]) val iosBundleId: String? = null,
@SerializedName(value = "harmonyBundleName", alternate = ["harmony_bundle_name"]) val harmonyBundleName: String? = null,
@SerializedName(value = "serverUrl", alternate = ["server_url"]) val serverUrl: String,
- @SerializedName(value = "signingKey", alternate = ["signing_key"]) val signingKey: String? = null,
@SerializedName(value = "issuedAt", alternate = ["issued_at"]) val issuedAt: String,
@SerializedName(value = "expiresAt", alternate = ["expires_at"]) val expiresAt: String? = null,
)
diff --git a/sdk-core/src/main/java/com/xuqm/sdk/network/ApiClient.kt b/sdk-core/src/main/java/com/xuqm/sdk/network/ApiClient.kt
index 84f39ee..f3c71f1 100644
--- a/sdk-core/src/main/java/com/xuqm/sdk/network/ApiClient.kt
+++ b/sdk-core/src/main/java/com/xuqm/sdk/network/ApiClient.kt
@@ -1,7 +1,6 @@
package com.xuqm.sdk.network
import android.util.Log
-import com.xuqm.sdk.XuqmSDK
import com.xuqm.sdk.auth.TokenStore
import com.xuqm.sdk.core.ServiceEndpointRegistry
import com.xuqm.sdk.core.LogLevel
@@ -22,10 +21,7 @@ import java.time.Instant
import java.time.LocalDateTime
import java.time.ZoneOffset
import java.time.format.DateTimeFormatter
-import java.util.UUID
import java.util.concurrent.TimeUnit
-import javax.crypto.Mac
-import javax.crypto.spec.SecretKeySpec
object ApiClient {
@@ -84,32 +80,6 @@ object ApiClient {
} else chain.request()
chain.proceed(req)
}
- .addInterceptor { chain ->
- val request = chain.request()
- val signingKey = XuqmSDK.signingKey
- if (signingKey.isNullOrBlank()) {
- chain.proceed(request)
- } else {
- val appKey = XuqmSDK.config.appKey
- val timestamp = Instant.now().epochSecond.toString()
- val nonce = UUID.randomUUID().toString()
- val userId = XuqmSDK.getUserId() ?: ""
- val payload = "$appKey\n$userId\n$timestamp\n$nonce"
- val signature = hmacSha256(signingKey, payload)
- val signedRequest = request.newBuilder()
- .header("X-App-Key", appKey)
- .header("X-Timestamp", timestamp)
- .header("X-Nonce", nonce)
- .header("X-Signature", signature)
- .apply {
- if (userId.isNotBlank()) {
- header("X-User-Id", userId)
- }
- }
- .build()
- chain.proceed(signedRequest)
- }
- }
.build()
synchronized(this) {
@@ -117,13 +87,6 @@ object ApiClient {
}
}
- private fun hmacSha256(secret: String, data: String): String {
- val mac = Mac.getInstance("HmacSHA256")
- mac.init(SecretKeySpec(secret.toByteArray(Charsets.UTF_8), "HmacSHA256"))
- val digest = mac.doFinal(data.toByteArray(Charsets.UTF_8))
- return android.util.Base64.encodeToString(digest, android.util.Base64.URL_SAFE or android.util.Base64.NO_PADDING or android.util.Base64.NO_WRAP)
- }
-
fun create(service: Class, baseUrl: String = ServiceEndpointRegistry.controlBaseUrl): T {
val retrofit = synchronized(this) {
retrofitCache[baseUrl] ?: Retrofit.Builder()
diff --git a/sdk-core/src/main/java/com/xuqm/sdk/network/NetworkMonitor.kt b/sdk-core/src/main/java/com/xuqm/sdk/network/NetworkMonitor.kt
new file mode 100644
index 0000000..314ea9e
--- /dev/null
+++ b/sdk-core/src/main/java/com/xuqm/sdk/network/NetworkMonitor.kt
@@ -0,0 +1,102 @@
+package com.xuqm.sdk.network
+
+import android.Manifest
+import android.annotation.SuppressLint
+import android.content.Context
+import android.content.pm.PackageManager
+import android.net.ConnectivityManager
+import android.net.Network
+import android.net.NetworkCapabilities
+import android.telephony.TelephonyManager
+import androidx.core.content.ContextCompat
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+
+/**
+ * Xuqm SDK 唯一网络状态来源。
+ *
+ * [observe] 可以在不初始化 XuqmSDK 的情况下独立使用,重复调用不会重复注册系统回调。
+ * 宿主只根据返回状态决定是否提示或允许下载;SDK 不替宿主制定 Wi-Fi/流量策略。
+ */
+object NetworkMonitor {
+ private val mutableStatus = MutableStateFlow(NetworkStatus.Disconnected)
+ private val lock = Any()
+
+ @Volatile
+ private var connectivityManager: ConnectivityManager? = null
+ @Volatile
+ private var applicationContext: Context? = null
+
+ private val callback = object : ConnectivityManager.NetworkCallback() {
+ override fun onAvailable(network: Network) = refresh()
+ override fun onLost(network: Network) = refresh()
+ override fun onCapabilitiesChanged(network: Network, capabilities: NetworkCapabilities) = refresh()
+ }
+
+ val status: StateFlow = mutableStatus.asStateFlow()
+
+ /** 开始观察并返回当前快照;无需 XuqmSDK 初始化。 */
+ @JvmStatic
+ fun observe(context: Context): NetworkStatus {
+ val manager = synchronized(lock) {
+ applicationContext = context.applicationContext
+ connectivityManager ?: (
+ context.applicationContext.getSystemService(Context.CONNECTIVITY_SERVICE)
+ as ConnectivityManager
+ ).also { manager ->
+ manager.registerDefaultNetworkCallback(callback)
+ connectivityManager = manager
+ }
+ }
+ return resolve(context.applicationContext, manager).also { mutableStatus.value = it }
+ }
+
+ /** 返回最后一次快照;首次使用建议调用 [observe]。 */
+ @JvmStatic
+ fun current(): NetworkStatus = mutableStatus.value
+
+ private fun refresh() {
+ val manager = connectivityManager ?: return
+ // 系统回调只需要及时更新连接类型;蜂窝代际识别失败时安全回退为 CELLULAR。
+ mutableStatus.value = resolve(applicationContext, manager)
+ }
+
+ private fun resolve(context: Context?, manager: ConnectivityManager): NetworkStatus {
+ val network = manager.activeNetwork ?: return NetworkStatus.Disconnected
+ val capabilities = manager.getNetworkCapabilities(network) ?: return NetworkStatus.Disconnected
+ val connected = capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) &&
+ capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED)
+ if (!connected) return NetworkStatus.Disconnected
+
+ val type = when {
+ capabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) -> NetworkType.WIFI
+ capabilities.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET) -> NetworkType.ETHERNET
+ capabilities.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) ->
+ resolveCellularType(context)
+ else -> NetworkType.OTHER
+ }
+ return NetworkStatus(type, isConnected = true, isMetered = manager.isActiveNetworkMetered)
+ }
+
+ @SuppressLint("MissingPermission")
+ private fun resolveCellularType(context: Context?): NetworkType {
+ if (context == null ||
+ ContextCompat.checkSelfPermission(context, Manifest.permission.READ_PHONE_STATE) !=
+ PackageManager.PERMISSION_GRANTED
+ ) {
+ return NetworkType.CELLULAR
+ }
+ val manager = context.getSystemService(Context.TELEPHONY_SERVICE) as? TelephonyManager
+ ?: return NetworkType.CELLULAR
+ val networkType = runCatching {
+ @Suppress("DEPRECATION")
+ manager.dataNetworkType
+ }.getOrNull() ?: return NetworkType.CELLULAR
+ return when (networkType) {
+ TelephonyManager.NETWORK_TYPE_LTE -> NetworkType.CELLULAR_4G
+ TelephonyManager.NETWORK_TYPE_NR -> NetworkType.CELLULAR_5G
+ else -> NetworkType.CELLULAR
+ }
+ }
+}
diff --git a/sdk-core/src/main/java/com/xuqm/sdk/network/NetworkStatus.kt b/sdk-core/src/main/java/com/xuqm/sdk/network/NetworkStatus.kt
new file mode 100644
index 0000000..5c16c21
--- /dev/null
+++ b/sdk-core/src/main/java/com/xuqm/sdk/network/NetworkStatus.kt
@@ -0,0 +1,31 @@
+package com.xuqm.sdk.network
+
+/**
+ * 当前默认网络的稳定公共模型。
+ *
+ * 蜂窝网络代际只有在系统允许无额外授权读取时才细分为 4G/5G;SDK 不会为了更新检查
+ * 申请电话或定位权限,无法可靠识别时返回 [CELLULAR]。
+ */
+enum class NetworkType {
+ NONE,
+ WIFI,
+ CELLULAR,
+ CELLULAR_4G,
+ CELLULAR_5G,
+ ETHERNET,
+ OTHER,
+}
+
+data class NetworkStatus(
+ val type: NetworkType,
+ val isConnected: Boolean,
+ val isMetered: Boolean,
+) {
+ companion object {
+ val Disconnected = NetworkStatus(
+ type = NetworkType.NONE,
+ isConnected = false,
+ isMetered = false,
+ )
+ }
+}
diff --git a/sdk-core/src/test/java/com/xuqm/sdk/internal/ConfigFileCryptoTest.kt b/sdk-core/src/test/java/com/xuqm/sdk/internal/ConfigFileCryptoTest.kt
index f9b850b..39212b3 100644
--- a/sdk-core/src/test/java/com/xuqm/sdk/internal/ConfigFileCryptoTest.kt
+++ b/sdk-core/src/test/java/com/xuqm/sdk/internal/ConfigFileCryptoTest.kt
@@ -3,6 +3,7 @@ package com.xuqm.sdk.internal
import org.bouncycastle.jce.provider.BouncyCastleProvider
import org.junit.Assert.assertEquals
import org.junit.Assert.assertThrows
+import org.junit.Assert.assertFalse
import org.junit.Test
import java.security.KeyPairGenerator
import java.security.Signature
@@ -17,7 +18,7 @@ class ConfigFileCryptoTest {
private val provider = BouncyCastleProvider()
@Test
- fun serverGeneratedV2VectorIsInteroperable() {
+ fun legacyServerVectorRemainsCryptographicallyReadable() {
assertEquals(SERVER_VECTOR_JSON, ConfigFileCrypto.decrypt(SERVER_VECTOR_TOKEN))
}
@@ -28,6 +29,13 @@ class ConfigFileCryptoTest {
assertEquals(fixture.json, ConfigFileCrypto.decrypt(fixture.content, mapOf("test" to fixture.publicKey)))
}
+ @Test
+ fun configDomainModelDoesNotExposeClientSecret() {
+ val fields = ConfigFile::class.java.declaredFields.map { it.name }.toSet()
+ assertFalse("signingKey" in fields)
+ assertFalse("appSecret" in fields)
+ }
+
@Test
fun tamperedCiphertextIsRejectedBySignature() {
val fixture = fixture()
@@ -53,7 +61,7 @@ class ConfigFileCryptoTest {
private fun fixture(): Fixture {
val json =
- """{"appKey":"ak_test","appName":"Test","configId":"123e4567-e89b-12d3-a456-426614174000","issuedAt":"2026-07-26T00:00:00Z","revision":1,"schemaVersion":2,"serverUrl":"https://dev.example.com/","signingKey":"fixture"}"""
+ """{"appKey":"ak_test","appName":"Test","configId":"123e4567-e89b-12d3-a456-426614174000","issuedAt":"2026-07-26T00:00:00Z","revision":1,"schemaVersion":2,"serverUrl":"https://dev.example.com/"}"""
val salt = ByteArray(16) { (it + 1).toByte() }
val iv = ByteArray(12) { (it + 17).toByte() }
val keySpec = PBEKeySpec(
@@ -95,8 +103,8 @@ class ConfigFileCryptoTest {
companion object {
/**
- * 由服务端 ConfigFileCrypto.encryptAndSign 使用开发测试密钥真实生成。
- * 测试向量仅包含公开配置、公钥可验证签名和密文,不包含私钥。
+ * 历史跨端向量只验证 V2 密码协议。明文中的旧 signingKey 已不进入 ConfigFile
+ * 领域模型;新签发配置由服务端测试明确断言不含客户端秘密。
*/
private const val SERVER_VECTOR_JSON =
"""{"appKey":"ak_vector","appName":"Vector App","configId":"00000000-0000-4000-8000-000000000001","expiresAt":"2030-01-01T00:00:00Z","issuedAt":"2026-07-26T00:00:00Z","packageName":"com.xuqm.vector","revision":1,"schemaVersion":2,"serverUrl":"https://dev.xuqinmin.com/","signingKey":"test-signing-key"}"""
diff --git a/sdk-update/README.md b/sdk-update/README.md
index f180662..14b8f7e 100644
--- a/sdk-update/README.md
+++ b/sdk-update/README.md
@@ -14,10 +14,11 @@ implementation("com.xuqm:sdk-core:VERSION") // 必须
```kotlin
val result = UpdateSDK.checkAppUpdate(context)
-if (result.needsUpdate) {
+if (result.status == UpdateCheckStatus.UPDATE_AVAILABLE) {
+ val update = requireNotNull(result.update)
UpdateSDK.downloadAndInstallApk(
context,
- ApkInstallRequest(result.downloadUrl, result.versionCode, requireNotNull(result.apkHash)),
+ ApkInstallRequest(update.downloadUrl, update.versionCode, requireNotNull(update.apkHash)),
)
}
```
@@ -27,19 +28,21 @@ if (result.needsUpdate) {
| API | 说明 |
|-----|------|
| `UpdateSDK.checkAppUpdate(context, bypassIgnore)` | 检查 App 更新 |
+| `UpdateSDK.downloadApk(context, request, onProgress)` | 只下载并校验,可选额外保存到系统 Downloads |
+| `UpdateSDK.installDownloadedApk(context, versionCode, sha256)` | 安装当前检查已授权且校验通过的 APK |
| `UpdateSDK.downloadAndInstallApk(context, request, onProgress)` | 下载、校验并调起系统安装器 |
+| `UpdateSDK.openStore(context, updateInfo)` | 优先打开设备商店,失败时使用平台通用下载页 |
| `UpdateSDK.setLoginUpdateListener(listener)` | 接收登录后自动补检结果 |
-### UpdateResult
+### UpdateCheckResult
```kotlin
-data class UpdateResult(
- val needsUpdate: Boolean,
- val versionName: String?,
- val versionCode: Int?,
- val downloadUrl: String?,
- val changeLog: String?,
- val forceUpdate: Boolean?,
+data class UpdateCheckResult(
+ val status: UpdateCheckStatus,
+ val network: NetworkStatus,
+ val update: UpdateInfo?,
+ val errorCode: String?,
+ val message: String?,
)
```
@@ -47,6 +50,10 @@ data class UpdateResult(
- `downloadAndInstall` 将 APK 下载到 `getExternalFilesDir(null)`,通过 `FileProvider` 触发系统安装
- 安装所需 FileProvider 由依赖的 `sdk-file` 唯一声明,Update 不复制 Manifest 或 paths 资源
+- 安装前依次校验 SHA-256、宿主 packageName 和 APK 签名证书;只允许安装本次检查授权的版本
+- 下载任务取消或进程退出后删除 `.part`,下次启动不会自动继续
+- `PUBLIC_DOWNLOADS` 仅额外导出已校验 APK,不改变 SDK 私有安装源
- 支持 WebSocket 实时推送更新通知
-- 是否要求登录由租户平台配置决定;要求登录时,未登录检查会静默跳过
+- 是否要求登录由租户平台配置决定;要求登录时返回 `LOGIN_REQUIRED`,不抛错、不阻断宿主
+- `network` 只提供当前网络事实,是否允许蜂窝网络下载完全由宿主决定
- 账号切换会取消旧会话检查并清理灰度判断缓存,新会话只自动补检一次
diff --git a/sdk-update/scripts/xuqm_release.gradle.kts b/sdk-update/scripts/xuqm_release.gradle.kts
deleted file mode 100644
index 296129d..0000000
--- a/sdk-update/scripts/xuqm_release.gradle.kts
+++ /dev/null
@@ -1,389 +0,0 @@
-/**
- * XuqmGroup Update Service — Android Gradle Release Task
- *
- * Copy this file to your app module directory and apply it:
- * apply(from = "xuqm_release.gradle.kts")
- *
- * Then run:
- * ./gradlew xuqmRelease
- *
- * Config: xuqm.properties in the project root (or module root)
- * ---
- * xuqm.serverUrl=https://update.dev.xuqinmin.com
- * xuqm.appKey=your-app-key
- * xuqm.apiToken=your-api-token
- * xuqm.storeTargets=HUAWEI,MI,OPPO # optional
- * xuqm.autoPublishAfterReview=false
- * xuqm.publishImmediately=false # optional: publish update-service record immediately
- * xuqm.scheduledPublishAt= # optional ISO datetime
- * xuqm.webhookUrl= # optional webhook for review status changes
- * ---
- *
- * The task:
- * 1. Reads versionName / versionCode from the android extension
- * 2. Checks the latest version on the update server
- * 3. Aborts if the local versionCode is not greater than the server's
- * 4. Assembles the release APK (assembleRelease)
- * 5. Uploads the APK + metadata to the update service as a DRAFT
- * 6. Optionally triggers server-side store submission
- */
-
-import org.gradle.api.DefaultTask
-import org.gradle.api.GradleException
-import org.gradle.api.tasks.TaskAction
-import java.io.File
-import java.net.URI
-import java.net.http.HttpClient
-import java.net.http.HttpRequest
-import java.net.http.HttpResponse
-import java.util.Properties
-import java.util.UUID
-
-// ── Config loading ────────────────────────────────────────────────────────
-
-fun loadXuqmConfig(projectDir: File): Properties {
- val props = Properties()
- listOf(
- File(projectDir, "xuqm.properties"),
- File(projectDir.parentFile, "xuqm.properties"),
- ).firstOrNull { it.exists() }?.inputStream()?.use(props::load)
- ?: throw GradleException("xuqm.properties not found in module or project root")
- return props
-}
-
-// ── HTTP helper ───────────────────────────────────────────────────────────
-
-fun httpGet(url: String, token: String): String {
- val client = HttpClient.newHttpClient()
- val builder = HttpRequest.newBuilder(URI.create(url))
- if (token.isNotBlank()) {
- builder.header("Authorization", "Bearer $token")
- }
- val req = builder.GET().build()
- return client.send(req, HttpResponse.BodyHandlers.ofString()).body()
-}
-
-/**
- * Multipart POST — minimal implementation without external dependencies.
- * Returns response body string.
- */
-fun httpMultipartPost(url: String, token: String, parts: Map): String {
- val boundary = UUID.randomUUID().toString()
- val baos = java.io.ByteArrayOutputStream()
-
- fun writeln(s: String) = baos.write(("$s\r\n").toByteArray())
-
- for ((name, value) in parts) {
- writeln("--$boundary")
- when (value) {
- is File -> {
- writeln("Content-Disposition: form-data; name=\"$name\"; filename=\"${value.name}\"")
- writeln("Content-Type: application/octet-stream")
- writeln("")
- baos.write(value.readBytes())
- writeln("")
- }
- else -> {
- writeln("Content-Disposition: form-data; name=\"$name\"")
- writeln("")
- writeln(value.toString())
- }
- }
- }
- writeln("--$boundary--")
-
- val body = baos.toByteArray()
- val client = HttpClient.newHttpClient()
- val builder = HttpRequest.newBuilder(URI.create(url))
- .header("Content-Type", "multipart/form-data; boundary=$boundary")
- if (token.isNotBlank()) {
- builder.header("Authorization", "Bearer $token")
- }
- val req = builder.POST(HttpRequest.BodyPublishers.ofByteArray(body)).build()
- return client.send(req, HttpResponse.BodyHandlers.ofString()).body()
-}
-
-fun parseJson(json: String, key: String): String? =
- Regex("\"$key\"\\s*:\\s*\"([^\"]+)\"").find(json)?.groupValues?.get(1)
-fun parseJsonInt(json: String, key: String): Int? =
- Regex("\"$key\"\\s*:\\s*(\\d+)").find(json)?.groupValues?.get(1)?.toIntOrNull()
-
-fun parseJsonBool(json: String, key: String, default: Boolean = false): Boolean =
- Regex("\"$key\"\\s*:\\s*(true|false)").find(json)?.groupValues?.get(1)?.toBooleanStrictOrNull() ?: default
-
-fun parseCsv(value: String?): List =
- value?.split(",")?.map { it.trim() }?.filter { it.isNotBlank() } ?: emptyList()
-
-fun promptLine(message: String): String? {
- val console = System.console() ?: return null
- print(message)
- return console.readLine()
-}
-
-fun promptYesNo(message: String, default: Boolean = false): Boolean {
- val suffix = if (default) " [Y/n]: " else " [y/N]: "
- val answer = promptLine(message + suffix)?.trim().orEmpty()
- return when {
- answer.isBlank() -> default
- answer.equals("y", ignoreCase = true) || answer.equals("yes", ignoreCase = true) -> true
- answer.equals("n", ignoreCase = true) || answer.equals("no", ignoreCase = true) -> false
- else -> default
- }
-}
-
-fun promptReleaseMode(): String {
- val answer = promptLine(
- "Publish mode? [1=manual, 2=publish now, 3=scheduled, 4=auto after review, 5=dry-run]: "
- )?.trim().orEmpty()
- return when (answer) {
- "2" -> "NOW"
- "3" -> "SCHEDULED"
- "4" -> "AUTO_REVIEW"
- "5" -> "DRY_RUN"
- else -> "MANUAL"
- }
-}
-
-data class ReleaseDefaults(
- val enabled: Boolean = true,
- val defaultStoreTargets: List = emptyList(),
- val defaultPublishMode: String = "MANUAL",
- val defaultPublishImmediately: Boolean = false,
- val defaultScheduledPublishAt: String = "",
- val defaultAutoPublishAfterReview: Boolean = false,
- val defaultWebhookUrl: String = "",
- val defaultForceUpdate: Boolean = false,
- val defaultGrayEnabled: Boolean = false,
- val defaultGrayPercent: Int = 0,
- val defaultPackageName: String = "",
- val defaultAppStoreUrl: String = "",
- val defaultMarketUrl: String = "",
-)
-
-fun fetchReleaseDefaults(tenantUrl: String, appKey: String): ReleaseDefaults? {
- if (tenantUrl.isBlank()) return null
- val url = "$tenantUrl/api/sdk/config?appKey=$appKey&platform=ANDROID"
- val json = runCatching { httpGet(url, "") }.getOrNull() ?: return null
- if (!parseJsonBool(json, "updateEnabled", false)) return ReleaseDefaults(enabled = false)
- return ReleaseDefaults(
- enabled = true,
- defaultStoreTargets = parseCsv(parseJson(json, "updateDefaultStoreTargets")),
- defaultPublishMode = parseJson(json, "updateDefaultPublishMode") ?: "MANUAL",
- defaultPublishImmediately = parseJsonBool(json, "updateDefaultPublishImmediately"),
- defaultScheduledPublishAt = parseJson(json, "updateDefaultScheduledPublishAt").orEmpty(),
- defaultAutoPublishAfterReview = parseJsonBool(json, "updateDefaultAutoPublishAfterReview"),
- defaultWebhookUrl = parseJson(json, "updateDefaultWebhookUrl").orEmpty(),
- defaultForceUpdate = parseJsonBool(json, "updateDefaultForceUpdate"),
- defaultGrayEnabled = parseJsonBool(json, "updateDefaultGrayEnabled"),
- defaultGrayPercent = parseJsonInt(json, "updateDefaultGrayPercent") ?: 0,
- defaultPackageName = parseJson(json, "updateDefaultPackageName").orEmpty(),
- defaultAppStoreUrl = parseJson(json, "updateDefaultAppStoreUrl").orEmpty(),
- defaultMarketUrl = parseJson(json, "updateDefaultMarketUrl").orEmpty(),
- )
-}
-
-// ── Task registration ─────────────────────────────────────────────────────
-
-tasks.register("xuqmRelease") {
- group = "xuqm"
- description = "Build release APK and upload to XuqmGroup Update Service"
-
- // Ensure assembleRelease runs first
- dependsOn("assembleRelease")
-
- doLast {
- val cfg = loadXuqmConfig(projectDir)
- val serverUrl = cfg.getProperty("xuqm.serverUrl") ?: throw GradleException("xuqm.serverUrl missing")
- val appKey = cfg.getProperty("xuqm.appKey") ?: throw GradleException("xuqm.appKey missing")
- val apiToken = cfg.getProperty("xuqm.apiToken") ?: throw GradleException("xuqm.apiToken missing")
- val tenantUrl = cfg.getProperty("xuqm.tenantUrl", "").trim()
- val dryRunMode = cfg.getProperty("xuqm.dryRun", "false").toBoolean()
-
- val tenantDefaults = fetchReleaseDefaults(tenantUrl, appKey)
- val updateEnabled = tenantDefaults?.enabled ?: true
- val defaultStoreTargets = tenantDefaults?.defaultStoreTargets?.joinToString(",").orEmpty()
- val defaultPublishMode = tenantDefaults?.defaultPublishMode ?: "MANUAL"
- val defaultPublishImmediately = tenantDefaults?.defaultPublishImmediately ?: false
- val defaultScheduledAt = tenantDefaults?.defaultScheduledPublishAt.orEmpty()
- val defaultAutoPublishAfterReview = tenantDefaults?.defaultAutoPublishAfterReview ?: false
- val defaultWebhookUrl = tenantDefaults?.defaultWebhookUrl.orEmpty()
- val defaultForceUpdate = tenantDefaults?.defaultForceUpdate ?: false
- val defaultGrayEnabled = tenantDefaults?.defaultGrayEnabled ?: false
- val defaultGrayPercent = tenantDefaults?.defaultGrayPercent ?: 0
-
- var storeTargets = cfg.getProperty("xuqm.storeTargets", defaultStoreTargets)
- var autoPublish = cfg.getProperty("xuqm.autoPublishAfterReview", defaultAutoPublishAfterReview.toString()).toBoolean()
- var publishImmediately = cfg.getProperty("xuqm.publishImmediately", defaultPublishImmediately.toString()).toBoolean()
- var scheduledAt = cfg.getProperty("xuqm.scheduledPublishAt", defaultScheduledAt)
- var webhookUrl = cfg.getProperty("xuqm.webhookUrl", defaultWebhookUrl)
- var publishMode = cfg.getProperty("xuqm.publishMode", defaultPublishMode).trim().uppercase()
- val forceUpdate = cfg.getProperty("xuqm.forceUpdate", defaultForceUpdate.toString()).toBoolean()
- val grayEnabled = cfg.getProperty("xuqm.grayEnabled", defaultGrayEnabled.toString()).toBoolean()
- val grayPercent = cfg.getProperty("xuqm.grayPercent", defaultGrayPercent.toString()).toIntOrNull()?.coerceIn(1, 100) ?: defaultGrayPercent
- val allowVersionMismatch = cfg.getProperty("xuqm.allowVersionMismatch", "false").toBoolean()
- var dryRun = dryRunMode || publishMode == "DRY_RUN" || !updateEnabled
-
- if (publishMode.isBlank() && !publishImmediately && scheduledAt.isBlank() && !autoPublish && System.console() != null) {
- publishMode = promptReleaseMode()
- if (publishMode == "SCHEDULED" && scheduledAt.isBlank()) {
- scheduledAt = promptLine("Scheduled publish time (ISO datetime): ")?.trim().orEmpty()
- }
- }
- if (publishMode == "DRY_RUN") {
- dryRun = true
- println("[xuqm] Dry-run mode enabled: release will stop after validation and packaging.")
- }
- if (scheduledAt.isNotBlank()) {
- publishImmediately = false
- autoPublish = false
- }
- if (publishMode == "NOW") {
- publishImmediately = true
- autoPublish = false
- } else if (publishMode == "AUTO_REVIEW") {
- autoPublish = true
- publishImmediately = false
- }
-
- // ── 1. Read local version ──────────────────────────────────────────
- val android = project.extensions.findByName("android")
- ?: throw GradleException("This task must run in an Android module")
- val defaultConfig = android::class.java.getMethod("getDefaultConfig").invoke(android)
- val versionName = defaultConfig::class.java.getMethod("getVersionName").invoke(defaultConfig) as? String
- ?: throw GradleException("versionName not set")
- val versionCode = (defaultConfig::class.java.getMethod("getVersionCode").invoke(defaultConfig) as? Int)
- ?: throw GradleException("versionCode not set")
- val applicationId = defaultConfig::class.java.getMethod("getApplicationId").invoke(defaultConfig) as? String ?: ""
- println("[xuqm] Local version: $versionName ($versionCode), packageName: $applicationId")
-
- // ── 2. Check server latest ─────────────────────────────────────────
- val listResp = httpGet("$serverUrl/api/v1/updates/app/list?appKey=$appKey&platform=ANDROID", apiToken)
- // Find highest published versionCode
- val serverVersionCode = Regex("\"versionCode\"\\s*:\\s*(\\d+)").findAll(listResp)
- .mapNotNull { it.groupValues[1].toIntOrNull() }
- .maxOrNull() ?: 0
- println("[xuqm] Server latest versionCode: $serverVersionCode")
-
- var releaseVersionName = versionName
- var releaseVersionCode = versionCode
- if (releaseVersionCode <= serverVersionCode) {
- if (System.console() != null && !allowVersionMismatch) {
- println("[xuqm] Local version is not greater than server latest. Please enter corrected release version info.")
- val inputVersionName = promptLine("Release versionName [$versionName]: ")?.trim().orEmpty()
- val inputVersionCode = promptLine("Release versionCode [$versionCode]: ")?.trim().orEmpty()
- if (inputVersionName.isNotBlank()) releaseVersionName = inputVersionName
- if (inputVersionCode.isNotBlank()) releaseVersionCode = inputVersionCode.toInt()
- }
- if (releaseVersionCode <= serverVersionCode) {
- throw GradleException(
- "[xuqm] Release versionCode ($releaseVersionCode) must be greater than server ($serverVersionCode). " +
- "Bump versionCode in build.gradle.kts or pass xuqm.allowVersionMismatch=true if you deliberately override."
- )
- }
- }
- if (releaseVersionName != versionName || releaseVersionCode != versionCode) {
- println("[xuqm] Using release override: $releaseVersionName ($releaseVersionCode)")
- }
-
- if (storeTargets.isBlank() && System.console() != null && !dryRun) {
- val inputTargets = promptLine("Store targets (comma separated, blank for none): ")?.trim().orEmpty()
- if (inputTargets.isNotBlank()) {
- storeTargets = inputTargets
- }
- }
- val storeTargetList = parseCsv(storeTargets)
-
- // ── 3. Locate APK ─────────────────────────────────────────────────
- val apkDir = File(buildDir, "outputs/apk/release")
- val apkFile = apkDir.listFiles { f -> f.extension == "apk" }?.firstOrNull()
- ?: throw GradleException("No APK found in ${apkDir.absolutePath}. Did assembleRelease succeed?")
- println("[xuqm] APK prepared: ${apkFile.name}")
-
- if (dryRun) {
- println("[xuqm] Dry-run summary:")
- println(" updateEnabled=$updateEnabled")
- println(" releaseVersion=$releaseVersionName ($releaseVersionCode)")
- println(" publishMode=$publishMode")
- println(" publishImmediately=$publishImmediately")
- println(" autoPublishAfterReview=$autoPublish")
- println(" scheduledPublishAt=${scheduledAt.ifBlank { "-" }}")
- println(" webhookConfigured=${webhookUrl.isNotBlank()}")
- println(" forceUpdate=$forceUpdate")
- println(" grayEnabled=$grayEnabled, grayPercent=$grayPercent")
- println(" storeTargets=${storeTargetList.joinToString(",").ifBlank { "-" }}")
- println(" packageName=${applicationId.ifBlank { "-" }}")
- println("[xuqm] Dry-run completed. No upload performed.")
- return@doLast
- }
-
- // ── 4. Upload to update service ───────────────────────────────────
- val parts = mutableMapOf(
- "appKey" to appKey,
- "platform" to "ANDROID",
- "versionName" to releaseVersionName,
- "versionCode" to releaseVersionCode,
- "forceUpdate" to forceUpdate.toString(),
- "autoPublishAfterReview" to autoPublish.toString(),
- "apkFile" to apkFile,
- )
- if (applicationId.isNotBlank()) parts["packageName"] = applicationId
- if (storeTargetList.isNotEmpty()) parts["storeSubmitTargets"] = "[\"${storeTargetList.joinToString("\",\"")}\"]"
- if (scheduledAt.isNotBlank()) parts["scheduledPublishAt"] = scheduledAt
- if (webhookUrl.isNotBlank()) parts["webhookUrl"] = webhookUrl
- if (publishMode == "NOW") parts["publishImmediately"] = "true"
- if (publishMode == "AUTO_REVIEW") parts["autoPublishAfterReview"] = "true"
-
- println("[xuqm] Uploading APK...")
- val uploadResp = httpMultipartPost("$serverUrl/api/v1/updates/app/upload", apiToken, parts)
- val versionId = parseJson(uploadResp, "id")
- ?: throw GradleException("[xuqm] Upload failed:\n$uploadResp")
- println("[xuqm] Uploaded, version ID: $versionId")
-
- // ── 5. Trigger server-side store submission ────────────────────────
- if (storeTargetList.isNotEmpty()) {
- println("[xuqm] Triggering store submission: ${storeTargetList.joinToString(",")} ...")
- val storeBody = """{"storeTypes":[${storeTargetList.joinToString(",") { "\"$it\"" }}]}"""
- val client = HttpClient.newHttpClient()
- val req = HttpRequest.newBuilder(URI.create("$serverUrl/api/v1/updates/store/app/$versionId/execute-submit"))
- .header("Authorization", "Bearer $apiToken")
- .header("Content-Type", "application/json")
- .POST(HttpRequest.BodyPublishers.ofString(storeBody))
- .build()
- val storeResp = client.send(req, HttpResponse.BodyHandlers.ofString())
- println("[xuqm] Store submission triggered (HTTP ${storeResp.statusCode()})")
- }
-
- if (grayEnabled && (publishImmediately || publishMode == "NOW")) {
- val client = HttpClient.newHttpClient()
- val grayBody = """{"enabled":true,"percent":$grayPercent}"""
- val req = HttpRequest.newBuilder(URI.create("$serverUrl/api/v1/updates/app/$versionId/gray"))
- .header("Authorization", "Bearer $apiToken")
- .header("Content-Type", "application/json")
- .POST(HttpRequest.BodyPublishers.ofString(grayBody))
- .build()
- val grayResp = client.send(req, HttpResponse.BodyHandlers.ofString())
- println("[xuqm] Gray release configured (HTTP ${grayResp.statusCode()})")
- }
-
- if (publishImmediately || publishMode == "NOW") {
- println("[xuqm] Published immediately in update service.")
- } else if (publishMode == "SCHEDULED" || scheduledAt.isNotBlank()) {
- println("[xuqm] Will auto-publish at: ${scheduledAt.ifBlank { "(use update service scheduled publish config)" }}")
- } else if (autoPublish) {
- println("[xuqm] Will auto-publish after all store reviews pass.")
- } else if (System.console() != null && promptYesNo("[xuqm] Publish now?", false)) {
- val client = HttpClient.newHttpClient()
- val req = HttpRequest.newBuilder(URI.create("$serverUrl/api/v1/updates/app/$versionId/publish"))
- .header("Authorization", "Bearer $apiToken")
- .POST(HttpRequest.BodyPublishers.noBody())
- .build()
- val publishResp = client.send(req, HttpResponse.BodyHandlers.ofString())
- println("[xuqm] Publish HTTP ${publishResp.statusCode()}")
- }
-
- println("[xuqm] Done. Version is in DRAFT state.")
- if (!publishImmediately && publishMode != "NOW" && scheduledAt.isBlank() && !autoPublish) {
- println("[xuqm] Publish manually: POST $serverUrl/api/v1/updates/app/$versionId/publish")
- }
- }
-}
diff --git a/sdk-update/src/main/java/com/xuqm/sdk/update/UpdateSDK.kt b/sdk-update/src/main/java/com/xuqm/sdk/update/UpdateSDK.kt
index 3266216..2e15269 100644
--- a/sdk-update/src/main/java/com/xuqm/sdk/update/UpdateSDK.kt
+++ b/sdk-update/src/main/java/com/xuqm/sdk/update/UpdateSDK.kt
@@ -15,10 +15,17 @@ import com.xuqm.sdk.XuqmLoginSession
import com.xuqm.sdk.file.FileSDK
import com.xuqm.sdk.core.ServiceEndpointRegistry
import com.xuqm.sdk.network.ApiClient
+import com.xuqm.sdk.network.NetworkMonitor
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.DownloadedApk
+import com.xuqm.sdk.update.model.UpdateDownloadDestination
+import com.xuqm.sdk.update.model.UpdateCheckCallback
+import com.xuqm.sdk.update.model.UpdateDownloadCallback
import com.xuqm.sdk.update.model.UpdateInfo
+import com.xuqm.sdk.update.model.UpdateCheckResult
+import com.xuqm.sdk.update.model.UpdateCheckStatus
import com.xuqm.sdk.update.model.UpdateInstallCallback
import com.xuqm.sdk.update.model.UpdateInstallErrorCode
import com.xuqm.sdk.update.model.UpdateInstallException
@@ -112,8 +119,8 @@ object UpdateSDK {
val listener = loginUpdateListener ?: return@launch
Handler(Looper.getMainLooper()).post {
runCatching {
- if (result?.needsUpdate == true) {
- listener.onUpdateAvailable(result)
+ if (result.status == UpdateCheckStatus.UPDATE_AVAILABLE && result.update != null) {
+ listener.onUpdateAvailable(result.update)
} else {
listener.onNoUpdate()
}
@@ -235,12 +242,82 @@ object UpdateSDK {
* 返回的 [UpdateInfo.alreadyDownloaded] 为 true 时,可直接调用 [installDownloadedApk] 安装,
* 无需再次调用 [downloadAndInstall]。
*/
- suspend fun checkAppUpdate(context: Context, bypassIgnore: Boolean = false): UpdateInfo? = withContext(Dispatchers.IO) {
- awaitInitialization()
- if (!UpdateLoginPolicy.shouldCheck(XuqmSDK.platformConfig, resolveUserId())) {
- // 平台明确要求登录时,未登录属于“跳过”,不是错误。
- return@withContext null
+ suspend fun checkAppUpdate(
+ context: Context,
+ bypassIgnore: Boolean = false,
+ ): UpdateCheckResult = withContext(Dispatchers.IO) {
+ val network = NetworkMonitor.observe(context)
+ if (!network.isConnected) {
+ return@withContext UpdateCheckResult(
+ status = UpdateCheckStatus.FAILED,
+ network = network,
+ errorCode = "NETWORK_UNAVAILABLE",
+ message = "当前网络不可用",
+ )
}
+ try {
+ awaitInitialization()
+ } catch (error: Throwable) {
+ return@withContext UpdateCheckResult(
+ status = UpdateCheckStatus.FAILED,
+ network = network,
+ errorCode = "SDK_NOT_READY",
+ message = error.message ?: "SDK 初始化未完成",
+ )
+ }
+ if (!UpdateLoginPolicy.isEnabled(XuqmSDK.platformConfig)) {
+ return@withContext UpdateCheckResult(
+ status = UpdateCheckStatus.SERVICE_DISABLED,
+ network = network,
+ message = "更新服务未启用",
+ )
+ }
+ if (!UpdateLoginPolicy.shouldCheck(XuqmSDK.platformConfig, resolveUserId())) {
+ return@withContext UpdateCheckResult(
+ status = UpdateCheckStatus.LOGIN_REQUIRED,
+ network = network,
+ message = "当前发布策略要求登录后检查更新",
+ )
+ }
+ val info = try {
+ checkAppUpdateInfo(context, bypassIgnore)
+ } catch (error: UpdateCheckException) {
+ return@withContext UpdateCheckResult(
+ status = if (error.code == "SERVICE_DISABLED") {
+ UpdateCheckStatus.SERVICE_DISABLED
+ } else {
+ UpdateCheckStatus.FAILED
+ },
+ network = network,
+ errorCode = error.code,
+ message = error.message,
+ )
+ } catch (error: Throwable) {
+ return@withContext UpdateCheckResult(
+ status = UpdateCheckStatus.FAILED,
+ network = network,
+ errorCode = "UPDATE_CHECK_FAILED",
+ message = error.message ?: "更新检查失败",
+ )
+ }
+ return@withContext when {
+ info.needsUpdate -> UpdateCheckResult(
+ status = UpdateCheckStatus.UPDATE_AVAILABLE,
+ network = network,
+ update = info,
+ )
+ else -> UpdateCheckResult(
+ status = UpdateCheckStatus.NO_UPDATE,
+ network = network,
+ update = info,
+ )
+ }
+ }
+
+ private suspend fun checkAppUpdateInfo(
+ context: Context,
+ bypassIgnore: Boolean,
+ ): UpdateInfo {
val packageInfo = context.packageManager.getPackageInfo(context.packageName, 0)
val versionCode = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
packageInfo.longVersionCode.toInt()
@@ -263,17 +340,17 @@ object UpdateSDK {
cached
}
if (afterIgnore.needsUpdate && afterIgnore.versionCode > 0) {
- return@withContext authorizeInstall(
+ return authorizeInstall(
afterIgnore.copy(
alreadyDownloaded = isVerifiedApkDownloaded(context, afterIgnore),
),
)
}
- return@withContext authorizeInstall(afterIgnore)
+ return authorizeInstall(afterIgnore)
}
}
- runCatching {
+ return try {
val deviceInfo = com.xuqm.sdk.utils.DeviceUtils.getDeviceInfo()
val versionName = try {
context.packageManager.getPackageInfo(context.packageName, 0).versionName ?: ""
@@ -290,7 +367,7 @@ object UpdateSDK {
currentVersionName = versionName,
)
if (response.code == 40404) {
- throw IllegalStateException("更新服务未开通 (code=40404)")
+ throw UpdateCheckException("SERVICE_DISABLED", response.message)
}
response.data?.toUpdateInfo()?.let { info ->
val normalized = info.copy(downloadUrl = normalizeDownloadUrl(info.downloadUrl) ?: info.downloadUrl)
@@ -311,13 +388,17 @@ object UpdateSDK {
// Cache the result (30 min TTL)
putCachedUpdateInfo(prefs, cacheKey, result)
authorizeInstall(result)
- }
- }.onFailure { e ->
+ } ?: throw UpdateCheckException(
+ code = "INVALID_RESPONSE",
+ message = response.message.ifBlank { "更新服务返回空数据" },
+ )
+ } catch (error: Throwable) {
Log.e(
"UpdateSDK",
- "Update check failed: versionCode=$versionCode errorType=${e.javaClass.simpleName}",
+ "Update check failed: versionCode=$versionCode errorType=${error.javaClass.simpleName}",
)
- }.getOrNull()
+ throw error
+ }
}
private suspend fun awaitInitialization() {
@@ -341,6 +422,17 @@ object UpdateSDK {
Log.d("UpdateSDK", "XuqmSDK initialized, proceeding with update check")
}
+ /** Java/RN 检查入口;结果始终回到主线程且不会以异常代替业务状态。 */
+ @JvmStatic
+ fun startCheckAppUpdate(
+ context: Context,
+ bypassIgnore: Boolean,
+ callback: UpdateCheckCallback,
+ ): Job = backgroundScope.launch {
+ val result = checkAppUpdate(context.applicationContext, bypassIgnore)
+ Handler(Looper.getMainLooper()).post { callback.onResult(result) }
+ }
+
private fun invalidateSessionChecks(context: Context) {
val prefs = prefs(context)
val editor = prefs.edit()
@@ -354,12 +446,12 @@ object UpdateSDK {
// 下载与安装
// ─────────────────────────────────────────────────────────────────────────
- /** Downloads, verifies, and opens the system package installer. */
- suspend fun downloadAndInstallApk(
+ /** 只下载并校验 APK,不弹安装器,也不会在进程重启后自动续传。 */
+ suspend fun downloadApk(
context: Context,
request: ApkInstallRequest,
onProgress: ((UpdateDownloadProgress) -> Unit)? = null,
- ) = withContext(Dispatchers.IO) {
+ ): DownloadedApk = withContext(Dispatchers.IO) {
if (request.downloadUrl.isBlank() || request.versionCode <= 0) {
throw UpdateInstallException(
UpdateInstallErrorCode.DOWNLOAD_FAILED,
@@ -378,9 +470,8 @@ object UpdateSDK {
try {
if (isApkDownloaded(context, request.versionCode, request.sha256)) {
assertAuthorizationCurrent(authorization)
- val apkFile = requireNotNull(resolveDownloadedApk(context, request.versionCode))
- withContext(Dispatchers.Main) { installApk(context, apkFile) }
- return@withContext
+ val cached = requireNotNull(resolveDownloadedApk(context, request.versionCode))
+ return@withContext downloadedResult(context, cached, request)
}
val apkFile = versionedApkFile(context, request.versionCode)
@@ -404,8 +495,7 @@ object UpdateSDK {
)
}
assertAuthorizationCurrent(authorization)
- withContext(Dispatchers.Main) { installApk(context, apkFile) }
- return@withContext
+ return@withContext downloadedResult(context, apkFile, request)
} catch (error: CancellationException) {
throw UpdateInstallException(
UpdateInstallErrorCode.DOWNLOAD_CANCELLED,
@@ -429,6 +519,35 @@ object UpdateSDK {
}
}
+ /** 下载、校验后打开系统安装器。 */
+ suspend fun downloadAndInstallApk(
+ context: Context,
+ request: ApkInstallRequest,
+ onProgress: ((UpdateDownloadProgress) -> Unit)? = null,
+ ) {
+ val apkFile = downloadApk(context, request, onProgress).localFile
+ withContext(Dispatchers.Main) {
+ installApk(context, apkFile)
+ }
+ }
+
+ private fun downloadedResult(
+ context: Context,
+ apkFile: File,
+ request: ApkInstallRequest,
+ ): DownloadedApk {
+ val publicUri = if (request.destination == UpdateDownloadDestination.PUBLIC_DOWNLOADS) {
+ FileSDK.exportToPublicDownloads(
+ context = context,
+ file = apkFile,
+ displayName = "${context.packageName}-${request.versionCode}.apk",
+ )
+ } else {
+ null
+ }
+ return DownloadedApk(apkFile, publicUri)
+ }
+
/** Java/RN entry point backed by the same suspending implementation. */
@JvmStatic
fun startDownloadAndInstall(
@@ -463,6 +582,64 @@ object UpdateSDK {
return UpdateInstallTask(job)
}
+ /** Java/RN 只下载入口;与下载并安装共用同一校验和授权链。 */
+ @JvmStatic
+ fun startDownload(
+ context: Context,
+ request: ApkInstallRequest,
+ progressListener: UpdateProgressListener?,
+ callback: UpdateDownloadCallback,
+ ): UpdateInstallTask {
+ val mainHandler = Handler(Looper.getMainLooper())
+ val job = CoroutineScope(Dispatchers.Main.immediate).launch {
+ try {
+ val result = downloadApk(context.applicationContext, request) { progress ->
+ progressListener?.let { listener ->
+ mainHandler.post { listener.onProgress(progress) }
+ }
+ }
+ callback.onSuccess(result.localFile.absolutePath, result.publicUri?.toString())
+ } 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.DOWNLOAD_FAILED.wireValue,
+ error.message ?: "APK download failed",
+ )
+ }
+ }
+ return UpdateInstallTask(job)
+ }
+
+ /** Java/RN 安装已校验下载入口。 */
+ @JvmStatic
+ fun startInstallDownloaded(
+ context: Context,
+ versionCode: Int,
+ expectedSha256: String,
+ callback: UpdateInstallCallback,
+ ): UpdateInstallTask {
+ val job = CoroutineScope(Dispatchers.Main.immediate).launch {
+ try {
+ installDownloadedApk(context.applicationContext, versionCode, expectedSha256)
+ callback.onSuccess()
+ } catch (error: UpdateInstallException) {
+ callback.onError(error.errorCode.wireValue, error.message.orEmpty())
+ } catch (error: Throwable) {
+ callback.onError(
+ UpdateInstallErrorCode.INSTALL_FAILED.wireValue,
+ error.message ?: "APK installation failed",
+ )
+ }
+ }
+ return UpdateInstallTask(job)
+ }
+
// ─────────────────────────────────────────────────────────────────────────
// WebSocket 实时通知
// ─────────────────────────────────────────────────────────────────────────
@@ -507,7 +684,34 @@ object UpdateSDK {
fun canRequestPackageInstalls(context: Context): Boolean =
Build.VERSION.SDK_INT < Build.VERSION_CODES.O || context.packageManager.canRequestPackageInstalls()
+ /**
+ * 打开租户平台返回的设备商店地址;无法处理时回退通用下载页。
+ *
+ * SDK 不展示 UI,返回 false 时由宿主决定是否提示“无法提供下载服务”。
+ */
+ @JvmStatic
+ fun openStore(context: Context, updateInfo: UpdateInfo): Boolean {
+ val candidates = listOf(updateInfo.marketUrl, updateInfo.appStoreUrl)
+ .map(String::trim)
+ .filter(String::isNotBlank)
+ .distinct()
+ return candidates.any { url ->
+ val intent = Intent(Intent.ACTION_VIEW, Uri.parse(url)).apply {
+ addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
+ }
+ runCatching {
+ if (intent.resolveActivity(context.packageManager) == null) {
+ false
+ } else {
+ context.startActivity(intent)
+ true
+ }
+ }.getOrDefault(false)
+ }
+ }
+
private fun installApk(context: Context, apkFile: File) {
+ verifyApkIdentity(context, apkFile)
if (!canRequestPackageInstalls(context)) {
throw UpdateInstallException(
UpdateInstallErrorCode.INSTALL_PERMISSION_REQUIRED,
@@ -548,6 +752,55 @@ object UpdateSDK {
}
}
+ private fun verifyApkIdentity(context: Context, apkFile: File) {
+ val manager = context.packageManager
+ val flags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
+ android.content.pm.PackageManager.GET_SIGNING_CERTIFICATES
+ } else {
+ @Suppress("DEPRECATION")
+ android.content.pm.PackageManager.GET_SIGNATURES
+ }
+ val archive = manager.getPackageArchiveInfo(apkFile.absolutePath, flags)
+ ?: throw UpdateInstallException(
+ UpdateInstallErrorCode.PACKAGE_MISMATCH,
+ "无法读取更新包信息",
+ )
+ if (archive.packageName != context.packageName) {
+ throw UpdateInstallException(
+ UpdateInstallErrorCode.PACKAGE_MISMATCH,
+ "更新包与当前应用不匹配",
+ )
+ }
+ val installed = manager.getPackageInfo(context.packageName, flags)
+ val trusted = packageSignatures(installed).map(::signatureDigest).toSet()
+ val candidate = packageSignatures(archive)
+ if (candidate.isEmpty() || candidate.none { signatureDigest(it) in trusted }) {
+ throw UpdateInstallException(
+ UpdateInstallErrorCode.SIGNATURE_MISMATCH,
+ "更新包签名与当前应用不一致",
+ )
+ }
+ }
+
+ private fun packageSignatures(info: android.content.pm.PackageInfo): Array {
+ return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
+ val signingInfo = info.signingInfo ?: return emptyArray()
+ if (signingInfo.hasMultipleSigners()) {
+ signingInfo.apkContentsSigners
+ } else {
+ signingInfo.signingCertificateHistory
+ }
+ } else {
+ @Suppress("DEPRECATION")
+ info.signatures ?: emptyArray()
+ }
+ }
+
+ private fun signatureDigest(signature: android.content.pm.Signature): String =
+ java.security.MessageDigest.getInstance("SHA-256")
+ .digest(signature.toByteArray())
+ .joinToString(separator = "") { byte -> "%02x".format(byte) }
+
private fun authorizationKey(versionCode: Int, sha256: String): String =
"$versionCode:${sha256.lowercase()}"
@@ -595,6 +848,11 @@ object UpdateSDK {
}
}
+private class UpdateCheckException(
+ val code: String,
+ message: String,
+) : IllegalStateException(message)
+
/**
* Update 登录门禁的唯一规则。旧租户配置缺少字段时必须安全地要求登录。
*/
diff --git a/sdk-update/src/main/java/com/xuqm/sdk/update/UpdateWebSocket.kt b/sdk-update/src/main/java/com/xuqm/sdk/update/UpdateWebSocket.kt
index 5563a03..34d312c 100644
--- a/sdk-update/src/main/java/com/xuqm/sdk/update/UpdateWebSocket.kt
+++ b/sdk-update/src/main/java/com/xuqm/sdk/update/UpdateWebSocket.kt
@@ -4,6 +4,7 @@ import android.content.Context
import android.util.Log
import com.google.gson.Gson
import com.xuqm.sdk.core.ServiceEndpointRegistry
+import com.xuqm.sdk.update.model.UpdateCheckStatus
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.Response
@@ -155,13 +156,18 @@ class UpdateWebSocket internal constructor(
private fun checkUpdateAndNotify() {
Thread {
try {
- val updateInfo = kotlinx.coroutines.runBlocking {
+ val result = kotlinx.coroutines.runBlocking {
UpdateSDK.checkAppUpdate(context, bypassIgnore = false)
}
android.os.Handler(android.os.Looper.getMainLooper()).post {
- if (updateInfo != null && updateInfo.needsUpdate) {
+ val updateInfo = result.update
+ if (result.status == UpdateCheckStatus.UPDATE_AVAILABLE && updateInfo != null) {
Log.d(TAG, "Update available: ${updateInfo.versionName} (${updateInfo.versionCode})")
listener.onUpdateAvailable(updateInfo)
+ } else if (result.status == UpdateCheckStatus.FAILED) {
+ listener.onError(
+ IllegalStateException(result.message ?: "Update check failed"),
+ )
} else {
Log.d(TAG, "No update available")
listener.onNoUpdate()
diff --git a/sdk-update/src/main/java/com/xuqm/sdk/update/model/ApkInstall.kt b/sdk-update/src/main/java/com/xuqm/sdk/update/model/ApkInstall.kt
index f3a45a3..d99d4a6 100644
--- a/sdk-update/src/main/java/com/xuqm/sdk/update/model/ApkInstall.kt
+++ b/sdk-update/src/main/java/com/xuqm/sdk/update/model/ApkInstall.kt
@@ -1,11 +1,15 @@
package com.xuqm.sdk.update.model
import kotlinx.coroutines.Job
+import android.net.Uri
+import java.io.File
enum class UpdateInstallErrorCode(val wireValue: String) {
DOWNLOAD_FAILED("DOWNLOAD_FAILED"),
DOWNLOAD_CANCELLED("DOWNLOAD_CANCELLED"),
HASH_MISMATCH("HASH_MISMATCH"),
+ PACKAGE_MISMATCH("PACKAGE_MISMATCH"),
+ SIGNATURE_MISMATCH("SIGNATURE_MISMATCH"),
INSTALL_PERMISSION_REQUIRED("INSTALL_PERMISSION_REQUIRED"),
INSTALLER_UNAVAILABLE("INSTALLER_UNAVAILABLE"),
INSTALL_FAILED("INSTALL_FAILED"),
@@ -33,6 +37,18 @@ data class ApkInstallRequest(
val sha256: String,
/** Number of retries after the initial download attempt. */
val retryCount: Int = 1,
+ /** 是否在校验通过后额外保存一份到系统 Downloads。 */
+ val destination: UpdateDownloadDestination = UpdateDownloadDestination.SDK_PRIVATE,
+)
+
+enum class UpdateDownloadDestination {
+ SDK_PRIVATE,
+ PUBLIC_DOWNLOADS,
+}
+
+data class DownloadedApk(
+ val localFile: File,
+ val publicUri: Uri? = null,
)
fun interface UpdateProgressListener {
@@ -44,6 +60,15 @@ interface UpdateInstallCallback {
fun onError(code: String, message: String)
}
+fun interface UpdateCheckCallback {
+ fun onResult(result: UpdateCheckResult)
+}
+
+interface UpdateDownloadCallback {
+ fun onSuccess(localPath: String, publicUri: String?)
+ fun onError(code: String, message: String)
+}
+
class UpdateInstallTask internal constructor(private val job: Job) {
fun cancel() {
job.cancel()
diff --git a/sdk-update/src/main/java/com/xuqm/sdk/update/model/UpdateInfo.kt b/sdk-update/src/main/java/com/xuqm/sdk/update/model/UpdateInfo.kt
index 3381246..a3aed72 100644
--- a/sdk-update/src/main/java/com/xuqm/sdk/update/model/UpdateInfo.kt
+++ b/sdk-update/src/main/java/com/xuqm/sdk/update/model/UpdateInfo.kt
@@ -1,5 +1,7 @@
package com.xuqm.sdk.update.model
+import com.xuqm.sdk.network.NetworkStatus
+
data class UpdateInfo(
val needsUpdate: Boolean,
val versionName: String = "",
@@ -16,3 +18,23 @@ data class UpdateInfo(
/** APK 文件的 SHA-256 哈希值,用于校验本地文件完整性;服务端未返回时为 null */
val apkHash: String? = null,
)
+
+/** 更新检查的完整状态;宿主无需再用 null 或异常猜测业务含义。 */
+enum class UpdateCheckStatus {
+ UPDATE_AVAILABLE,
+ NO_UPDATE,
+ LOGIN_REQUIRED,
+ SERVICE_DISABLED,
+ FAILED,
+}
+
+data class UpdateCheckResult(
+ val status: UpdateCheckStatus,
+ val network: NetworkStatus,
+ val update: UpdateInfo? = null,
+ val errorCode: String? = null,
+ val message: String? = null,
+) {
+ val hasUpdate: Boolean
+ get() = status == UpdateCheckStatus.UPDATE_AVAILABLE && update != null
+}
diff --git a/sdk-update/src/test/java/com/xuqm/sdk/update/model/ApkInstallTest.kt b/sdk-update/src/test/java/com/xuqm/sdk/update/model/ApkInstallTest.kt
index 648aeb2..0c879b2 100644
--- a/sdk-update/src/test/java/com/xuqm/sdk/update/model/ApkInstallTest.kt
+++ b/sdk-update/src/test/java/com/xuqm/sdk/update/model/ApkInstallTest.kt
@@ -19,6 +19,8 @@ class ApkInstallTest {
"DOWNLOAD_FAILED",
"DOWNLOAD_CANCELLED",
"HASH_MISMATCH",
+ "PACKAGE_MISMATCH",
+ "SIGNATURE_MISMATCH",
"INSTALL_PERMISSION_REQUIRED",
"INSTALLER_UNAVAILABLE",
"INSTALL_FAILED",
diff --git a/sdk-webview/README.md b/sdk-webview/README.md
index e67536c..875966c 100644
--- a/sdk-webview/README.md
+++ b/sdk-webview/README.md
@@ -67,3 +67,12 @@ window.addEventListener('__xwvDownloadDone', (e) => { ... })
- `` + `accept="image/*"` + `capture` → 系统相机
- `` + `.docx/.xlsx` → 文件选择器
- `getUserMedia()` WebRTC → 自动请求 CAMERA 权限
+
+## 公共 Downloads
+
+`downloadDestination = FileDownloadDestination.PublicDownloads` 时,WebView 直接复用
+common 文件能力:Android 10 及以上通过 MediaStore 写入系统 Downloads。该流程不会
+申请 `MANAGE_EXTERNAL_STORAGE`,不会跳转“所有文件访问权限”设置页,也不会因未授予
+该敏感权限而中断下载。Android 9 及以下仍遵循系统的
+`WRITE_EXTERNAL_STORAGE` 运行时权限规则。只有启用下载通知且运行于 Android 13
+及以上时,宿主才可能需要处理通知权限。
diff --git a/sdk-webview/build.gradle.kts b/sdk-webview/build.gradle.kts
index 114f91d..da747df 100644
--- a/sdk-webview/build.gradle.kts
+++ b/sdk-webview/build.gradle.kts
@@ -46,4 +46,9 @@ dependencies {
api(libs.androidx.webkit)
implementation(libs.androidx.activity.compose)
implementation(libs.androidx.lifecycle.runtime.compose)
+ testImplementation(libs.junit4)
+}
+
+android.testOptions.unitTests.all {
+ it.systemProperty("xuqm.sdkWebViewProjectDir", project.projectDir.absolutePath)
}
diff --git a/sdk-webview/src/main/AndroidManifest.xml b/sdk-webview/src/main/AndroidManifest.xml
index 8bc6f65..df55fec 100644
--- a/sdk-webview/src/main/AndroidManifest.xml
+++ b/sdk-webview/src/main/AndroidManifest.xml
@@ -9,7 +9,6 @@
diff --git a/sdk-webview/src/main/java/com/xuqm/sdk/webview/XWebViewView.kt b/sdk-webview/src/main/java/com/xuqm/sdk/webview/XWebViewView.kt
index 1d7dbeb..25ea9b0 100644
--- a/sdk-webview/src/main/java/com/xuqm/sdk/webview/XWebViewView.kt
+++ b/sdk-webview/src/main/java/com/xuqm/sdk/webview/XWebViewView.kt
@@ -41,9 +41,6 @@ import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.ui.viewinterop.AndroidView
-import androidx.lifecycle.Lifecycle
-import androidx.lifecycle.LifecycleEventObserver
-import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.core.content.ContextCompat
import androidx.core.content.FileProvider
import android.provider.MediaStore
@@ -330,10 +327,6 @@ fun XWebViewView(
var currentUrl by remember { mutableStateOf(config.url.ifBlank { null }) }
val coroutineScope = rememberCoroutineScope()
var showImageSourceDialog by remember { mutableStateOf(false) }
- var showStoragePermissionDialog by remember { mutableStateOf(false) }
- val pendingUrlDownload = remember { mutableStateOf?>(null) }
- // blobdownload pending: (url, filename, base64Data)
- val pendingBlobDownload = remember { mutableStateOf?>(null) }
val notificationPermissionLauncher = rememberLauncherForActivityResult(
ActivityResultContracts.RequestPermission()
@@ -358,17 +351,8 @@ fun XWebViewView(
"download" -> {
val url = payload.optString("url").takeIf { it.isNotBlank() } ?: return@handler
val filename = payload.optString("filename").takeIf { it.isNotBlank() }
- // On API 30+, writing to public Downloads requires MANAGE_EXTERNAL_STORAGE.
- // Show a rationale dialog; after the user grants permission and returns, the
- // lifecycle observer will resume the download automatically.
- if (config.downloadDestination == FileDownloadDestination.PublicDownloads &&
- Build.VERSION.SDK_INT >= Build.VERSION_CODES.R &&
- !FileSDK.isManageStorageGranted(context)
- ) {
- pendingUrlDownload.value = url to filename
- showStoragePermissionDialog = true
- return@handler
- }
+ // PublicDownloads 由 common 文件能力按系统版本选择 MediaStore/公共目录;
+ // WebView 不申请“所有文件访问权限”,也不维护返回设置页后的续传状态。
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU &&
config.downloadNotificationTitle != null &&
ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED
@@ -407,15 +391,7 @@ fun XWebViewView(
android.util.Log.e("XWV", "blobdownload: empty data")
return@handler
}
- // On API 30+, writing to public Downloads requires MANAGE_EXTERNAL_STORAGE.
- if (config.downloadDestination == FileDownloadDestination.PublicDownloads &&
- Build.VERSION.SDK_INT >= Build.VERSION_CODES.R &&
- !FileSDK.isManageStorageGranted(context)
- ) {
- pendingBlobDownload.value = Triple(url, filename, b64)
- showStoragePermissionDialog = true
- return@handler
- }
+ // Blob 与普通 URL 下载共享同一存储策略,不能形成第二套权限门禁。
android.util.Log.d(
"XWV",
"Blob download requested: bytesBase64Length=${b64.length} " +
@@ -584,74 +560,6 @@ fun XWebViewView(
}
}
- // When returning from the MANAGE_EXTERNAL_STORAGE settings page, resume pending download.
- val lifecycleOwner = LocalLifecycleOwner.current
- DisposableEffect(lifecycleOwner) {
- val observer = LifecycleEventObserver { _, event ->
- if (event == Lifecycle.Event.ON_RESUME) {
- val pd = pendingUrlDownload.value
- val pbd = pendingBlobDownload.value
- if (pd != null && FileSDK.isManageStorageGranted(context)) {
- val (url, filename) = pd
- pendingUrlDownload.value = null
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU &&
- config.downloadNotificationTitle != null &&
- ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED
- ) {
- notificationPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS)
- }
- coroutineScope.launch(Dispatchers.IO) {
- runCatching {
- FileSDK.download(
- context = context,
- downloadUrl = url,
- fileName = filename,
- destination = config.downloadDestination,
- notificationTitle = config.downloadNotificationTitle,
- ) { progress ->
- mainHandler.post {
- dispatchDownloadEvent("__xwvDownloadProgress", url, ",progress:$progress")
- }
- }
- }.onSuccess { file ->
- withContext(Dispatchers.Main) {
- dispatchDownloadEvent("__xwvDownloadDone", url, ",success:true")
- PlatformFileSDK.openFile(context, file)
- }
- }.onFailure { e ->
- withContext(Dispatchers.Main) {
- dispatchDownloadEvent("__xwvDownloadDone", url, ",success:false,error:'${e.message?.escapeJs()}'")
- }
- }
- }
- }
- if (pbd != null && FileSDK.isManageStorageGranted(context)) {
- val (url, filename, b64) = pbd
- pendingBlobDownload.value = null
- coroutineScope.launch(Dispatchers.IO) {
- runCatching {
- FileSDK.saveBlobDownload(context, b64, filename, config.downloadDestination)
- }.onSuccess { file ->
- android.util.Log.d("XWV", "Blob download saved: size=${file.length()}")
- val savedToGallery = runCatching { FileSDK.saveImageToGallery(context, file) }.getOrDefault(false)
- withContext(Dispatchers.Main) {
- dispatchDownloadEvent("__xwvDownloadDone", url, ",success:true")
- if (!savedToGallery) PlatformFileSDK.openFile(context, file)
- }
- }.onFailure { e ->
- android.util.Log.e("XWV", "blobdownload FAILED", e)
- withContext(Dispatchers.Main) {
- dispatchDownloadEvent("__xwvDownloadDone", url, ",success:false,error:'${e.message?.escapeJs()}'")
- }
- }
- }
- }
- }
- }
- lifecycleOwner.lifecycle.addObserver(observer)
- onDispose { lifecycleOwner.lifecycle.removeObserver(observer) }
- }
-
// Checks storage permissions before launching the file picker.
// Requests missing permissions on first use; launches the picker after the result either way.
val launchPickerWithPermission: (Array) -> Unit = { mimes ->
@@ -756,6 +664,7 @@ fun XWebViewView(
}
}
+ @androidx.annotation.RequiresApi(Build.VERSION_CODES.O)
override fun onRenderProcessGone(view: WebView, detail: RenderProcessGoneDetail): Boolean {
// WebView renderer (sandboxed process) crashed or was OOM-killed.
// Return true to keep the main app alive; the crashed WebView instance
@@ -852,31 +761,6 @@ fun XWebViewView(
},
)
- if (showStoragePermissionDialog) {
- AlertDialog(
- onDismissRequest = {
- showStoragePermissionDialog = false
- pendingUrlDownload.value = null
- pendingBlobDownload.value = null
- },
- title = { Text("需要存储权限") },
- text = { Text("下载文件需要「所有文件访问权限」,请在设置中授权后将自动继续下载。") },
- confirmButton = {
- TextButton(onClick = {
- showStoragePermissionDialog = false
- FileSDK.requestManageStorageIntent(context)?.let { context.startActivity(it) }
- }) { Text("前往设置") }
- },
- dismissButton = {
- TextButton(onClick = {
- showStoragePermissionDialog = false
- pendingUrlDownload.value = null
- pendingBlobDownload.value = null
- }) { Text("取消") }
- },
- )
- }
-
if (showImageSourceDialog) {
AlertDialog(
onDismissRequest = {
diff --git a/sdk-webview/src/test/java/com/xuqm/sdk/webview/PublicDownloadsPermissionContractTest.kt b/sdk-webview/src/test/java/com/xuqm/sdk/webview/PublicDownloadsPermissionContractTest.kt
new file mode 100644
index 0000000..aea7ae3
--- /dev/null
+++ b/sdk-webview/src/test/java/com/xuqm/sdk/webview/PublicDownloadsPermissionContractTest.kt
@@ -0,0 +1,41 @@
+package com.xuqm.sdk.webview
+
+import java.io.File
+import org.junit.Assert.assertFalse
+import org.junit.Test
+
+/**
+ * 公共 Downloads 在 Android 10+ 由 common 文件能力通过 MediaStore 写入。
+ *
+ * WebView 不得重新引入“所有文件访问权限”门禁,否则会把普通下载升级为敏感权限,
+ * 并在 Android 11+ 中断本应直接执行的下载流程。
+ */
+class PublicDownloadsPermissionContractTest {
+
+ private val projectDir = File(
+ requireNotNull(System.getProperty("xuqm.sdkWebViewProjectDir")) {
+ "sdk-webview project directory is not configured"
+ },
+ )
+
+ @Test
+ fun `webview download does not request broad storage access`() {
+ val source = projectDir
+ .resolve("src/main/java/com/xuqm/sdk/webview/XWebViewView.kt")
+ .readText(Charsets.UTF_8)
+
+ assertFalse(source.contains("MANAGE_EXTERNAL_STORAGE"))
+ assertFalse(source.contains("isManageStorageGranted"))
+ assertFalse(source.contains("requestManageStorageIntent"))
+ assertFalse(source.contains("ACTION_MANAGE_ALL_FILES_ACCESS_PERMISSION"))
+ assertFalse(source.contains("ACTION_MANAGE_APP_ALL_FILES_ACCESS_PERMISSION"))
+ }
+
+ @Test
+ fun `sdk manifest does not declare broad storage permission`() {
+ val manifest = projectDir.resolve("src/main/AndroidManifest.xml")
+ .readText(Charsets.UTF_8)
+
+ assertFalse(manifest.contains("android.permission.MANAGE_EXTERNAL_STORAGE"))
+ }
+}