From d05b85b02467c701f73caee85ad8e9145db29a20 Mon Sep 17 00:00:00 2001 From: XuqmGroup Date: Tue, 28 Jul 2026 20:14:50 +0800 Subject: [PATCH] refactor(update): make native update flow authoritative --- README.md | 4 + docs/IMPLEMENTATION_HANDOFF.md | 35 ++ .../com/xuqm/sdk/sample/SdkIntegrationTest.kt | 8 +- .../com/xuqm/sdk/sample/ui/main/MainScreen.kt | 6 +- .../xuqm/sdk/sample/ui/update/UpdateScreen.kt | 13 +- sdk-core/README.md | 10 +- sdk-core/src/main/AndroidManifest.xml | 9 +- .../src/main/java/com/xuqm/sdk/XuqmSDK.kt | 10 +- .../main/java/com/xuqm/sdk/core/SDKConfig.kt | 1 - .../main/java/com/xuqm/sdk/file/FileSDK.kt | 82 ++-- .../java/com/xuqm/sdk/internal/ConfigFile.kt | 1 - .../java/com/xuqm/sdk/network/ApiClient.kt | 37 -- .../com/xuqm/sdk/network/NetworkMonitor.kt | 102 +++++ .../com/xuqm/sdk/network/NetworkStatus.kt | 31 ++ .../xuqm/sdk/internal/ConfigFileCryptoTest.kt | 16 +- sdk-update/README.md | 29 +- sdk-update/scripts/xuqm_release.gradle.kts | 389 ------------------ .../java/com/xuqm/sdk/update/UpdateSDK.kt | 304 ++++++++++++-- .../com/xuqm/sdk/update/UpdateWebSocket.kt | 10 +- .../com/xuqm/sdk/update/model/ApkInstall.kt | 25 ++ .../com/xuqm/sdk/update/model/UpdateInfo.kt | 22 + .../xuqm/sdk/update/model/ApkInstallTest.kt | 2 + sdk-webview/README.md | 9 + sdk-webview/build.gradle.kts | 5 + sdk-webview/src/main/AndroidManifest.xml | 1 - .../java/com/xuqm/sdk/webview/XWebViewView.kt | 124 +----- .../PublicDownloadsPermissionContractTest.kt | 41 ++ 27 files changed, 679 insertions(+), 647 deletions(-) create mode 100644 sdk-core/src/main/java/com/xuqm/sdk/network/NetworkMonitor.kt create mode 100644 sdk-core/src/main/java/com/xuqm/sdk/network/NetworkStatus.kt delete mode 100644 sdk-update/scripts/xuqm_release.gradle.kts create mode 100644 sdk-webview/src/test/java/com/xuqm/sdk/webview/PublicDownloadsPermissionContractTest.kt 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")) + } +}