diff --git a/CLAUDE.md b/CLAUDE.md index 5cb8657..2565843 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -45,15 +45,32 @@ XuqmSDK.awaitInitialization() **两种平台互相独立,不允许自动降级到默认公有平台(`DEFAULT_PLATFORM_URL`)。** ### 用户信息 + ```kotlin XuqmSDK.setUserInfo(XuqmUserInfo( userId = "u001", - userSig = "sig", // IM 登录凭证(可选) + userSig = "sig", // IM 登录凭证(仅 IM 强制,其它 SDK 均可不传) name = "张三", // 可选 )) XuqmSDK.setUserInfo(null) // 登出,触发所有子 SDK 登出 ``` +**`userSig` 设计规则(必须遵守):** + +| SDK | userSig 要求 | +|-----|------------| +| sdk-im | **强制** — 没有 userSig 不能登录 IM | +| sdk-push | **可选** — 只有 userId 也可完成 push 设备注册 | +| sdk-update | **可选** — 只有 userId 即可使用 | +| sdk-bugcollect | **可选** — 只有 userId 即可使用 | +| sdk-webview | **可选** — 只有 userId 即可使用 | + +- 外部用户(无 userSig):只需 `userId`,可使用除 IM 外的所有 SDK 功能 +- 平台托管用户(有 userSig):完整功能,包含 IM +- **禁止在 push / update / bugcollect 等 SDK 内部校验 userSig 是否存在** + +**注意:** push 设备注册依赖请求签名(`signingKey` in config.xuqm),与 userSig 无关。`signingKey` 未配置时,设备注册请求会被服务端拒绝(HTTP 401)。需在平台后台为该 appKey 生成 signingKey 并重新下载 config.xuqm。 + ### 平台配置读取(init 完成后) ```kotlin XuqmSDK.platformConfig?.bugCollectApiUrl // Bug 采集服务地址 @@ -114,3 +131,34 @@ NEXUS_PASSWORD=your_password ``` 发布至 `https://nexus.xuqinmin.com/repository/android-hosted/`,版本号在各模块 `build.gradle.kts` 中维护。 + +## 版本号与发布规则(严禁违反) + +### 核心原则 + +1. **开发阶段只允许发布 SNAPSHOT**:所有 `gradle.properties` 中的 `SDK_*_VERSION` 必须以 `-SNAPSHOT` 结尾。 +2. **禁止手动修改版本号为正式版本**:不得手动将 `1.x.x-SNAPSHOT` 改为 `1.x.x`(去掉 `-SNAPSHOT`)。 +3. **所有正式发版必须走 Jenkins**:Jenkins 自动完成版本号提升、去掉 `-SNAPSHOT`、发布到 Nexus、打 Git tag 等操作。 + +### 违禁示例 + +```properties +# ❌ 禁止在代码中手动修改为正式版本 +SDK_PUSH_VERSION=1.1.4 # 错误:手动去掉了 -SNAPSHOT +SDK_CORE_VERSION=1.1.6 # 错误 + +# ✅ 正确:开发阶段保持 SNAPSHOT +SDK_PUSH_VERSION=1.1.4-SNAPSHOT +SDK_CORE_VERSION=1.1.6-SNAPSHOT +``` + +### 允许的手动操作 + +- 新功能开发需要升版本号时:可以将 `1.x.x-SNAPSHOT` 改为 `1.x+1.0-SNAPSHOT`(只提升次版本,保留 `-SNAPSHOT`) +- 发布 SNAPSHOT 到 Nexus 用于集成测试:`./gradlew :sdk-xxx:publish` + +### Jenkins 负责的操作(禁止手动代替) + +- 去掉 `-SNAPSHOT` 发布正式版 +- 打 Git release tag +- 更新下一个开发周期的 SNAPSHOT 版本号 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 8508ecf..1d32f35 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 @@ -171,18 +171,26 @@ object FileSDK { if (urlExt != null) "$rawName.$urlExt" else rawName } else rawName - val baseDir = when (destination) { - FileDownloadDestination.PublicDownloads -> resolveBaseDir(context, destination) - FileDownloadDestination.Sandbox -> { - if (directoryName.isNullOrBlank()) { - context.getExternalFilesDir(null) ?: context.filesDir - } else { - File(context.getExternalFilesDir(null) ?: context.filesDir, directoryName).apply { mkdirs() } + // Android 10+ can't write to public Downloads via FileOutputStream; use cache dir + MediaStore. + val useMediaStore = destination == FileDownloadDestination.PublicDownloads + && Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q + + val target: File = if (useMediaStore) { + File(context.cacheDir, resolvedName).also { if (it.exists()) it.delete() } + } else { + val baseDir = when (destination) { + FileDownloadDestination.PublicDownloads -> resolveBaseDir(context, destination) + FileDownloadDestination.Sandbox -> { + if (directoryName.isNullOrBlank()) { + context.getExternalFilesDir(null) ?: context.filesDir + } else { + File(context.getExternalFilesDir(null) ?: context.filesDir, directoryName).apply { mkdirs() } + } } } + uniqueFile(baseDir, resolvedName) } - val target = uniqueFile(baseDir, resolvedName) val notifId = if (notificationTitle != null) { ensureDownloadNotificationChannel(context) System.currentTimeMillis().toInt() @@ -212,6 +220,10 @@ object FileSDK { } onProgress(progress) } + if (useMediaStore) { + saveFileToPublicDownloads(context, target, resolvedName) + android.util.Log.d("FileSDK", "download: saved to public Downloads via MediaStore: $resolvedName") + } } catch (e: Throwable) { downloadError = e android.util.Log.e("FileSDK", "download failed: url=$downloadUrl dest=${target.absolutePath}", e) @@ -299,6 +311,37 @@ object FileSDK { } } + /** + * 将已下载到 cache 的文件通过 MediaStore 写入公共 Downloads 目录(Android 10+)。 + * 直接 FileOutputStream 在 Android 10+ 无权限(EACCES),必须走 MediaStore。 + */ + @androidx.annotation.RequiresApi(Build.VERSION_CODES.Q) + private fun saveFileToPublicDownloads(context: Context, file: File, fileName: String) { + val mimeType = MimeTypeMap.getSingleton() + .getMimeTypeFromExtension(fileName.substringAfterLast('.', "").lowercase()) + ?: "application/octet-stream" + val appName = context.applicationInfo.loadLabel(context.packageManager).toString() + + val values = ContentValues().apply { + put(MediaStore.Downloads.DISPLAY_NAME, fileName) + put(MediaStore.Downloads.MIME_TYPE, mimeType) + put(MediaStore.Downloads.RELATIVE_PATH, "${Environment.DIRECTORY_DOWNLOADS}/$appName") + put(MediaStore.Downloads.IS_PENDING, 1) + } + + val resolver = context.contentResolver + val uri = resolver.insert(MediaStore.Downloads.EXTERNAL_CONTENT_URI, values) + ?: throw java.io.IOException("MediaStore insert returned null for $fileName") + + resolver.openOutputStream(uri)?.use { out -> + file.inputStream().use { it.copyTo(out) } + } ?: throw java.io.IOException("Cannot open MediaStore output stream for $uri") + + values.clear() + values.put(MediaStore.Downloads.IS_PENDING, 0) + resolver.update(uri, values, null, null) + } + /** * 通过 MediaStore 将文件写入公共 Downloads 目录。 * 在华为等设备上直接文件 I/O 会失败,MediaStore 通过 ContentResolver 操作,兼容性更好。 diff --git a/sdk-core/src/main/java/com/xuqm/sdk/utils/DeviceUtils.kt b/sdk-core/src/main/java/com/xuqm/sdk/utils/DeviceUtils.kt index e53f6a8..54525d8 100644 --- a/sdk-core/src/main/java/com/xuqm/sdk/utils/DeviceUtils.kt +++ b/sdk-core/src/main/java/com/xuqm/sdk/utils/DeviceUtils.kt @@ -21,7 +21,8 @@ object DeviceUtils { */ fun getRomVersion(): String? { val props = listOf( - "ro.miui.ui.version.name", // MIUI + "ro.mi.os.version.name", // HyperOS / 澎湃OS(小米新系统,优先检查) + "ro.miui.ui.version.name", // MIUI(旧版小米) "ro.build.version.emui", // EMUI / HarmonyOS "ro.vivo.os.version", // OriginOS (VIVO) "ro.funtouch.os_version", // FuntouchOS (旧 VIVO) diff --git a/sdk-webview/src/main/java/com/xuqm/sdk/webview/XWebViewTypes.kt b/sdk-webview/src/main/java/com/xuqm/sdk/webview/XWebViewTypes.kt index 88ba494..a96ef6b 100644 --- a/sdk-webview/src/main/java/com/xuqm/sdk/webview/XWebViewTypes.kt +++ b/sdk-webview/src/main/java/com/xuqm/sdk/webview/XWebViewTypes.kt @@ -20,6 +20,11 @@ data class XWebViewConfig( val onPageError: ((errorCode: Int, description: String, failingUrl: String) -> Unit)? = null, /** 页面加载进度回调(0-100)。 */ val onProgressChanged: ((Int) -> Unit)? = null, + /** + * 冷启动首屏是否强制回源拉取最新(绕过 WebView 缓存),首屏加载完成后自动恢复正常缓存。 + * 默认 true:保证每次启动加载到最新页面,使用过程中的页内跳转/子资源仍走缓存。 + */ + val freshOnColdStart: Boolean = true, ) interface XWebViewController { 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 64c65e8..55bd6c6 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 @@ -21,6 +21,7 @@ import android.webkit.WebChromeClient import android.webkit.WebResourceError import android.webkit.WebResourceRequest import android.webkit.WebViewClient +import android.webkit.WebSettings import android.webkit.WebView import android.widget.FrameLayout import androidx.activity.compose.rememberLauncherForActivityResult @@ -678,6 +679,9 @@ fun XWebViewView( settings.domStorageEnabled = true settings.useWideViewPort = true settings.loadWithOverviewMode = true + // 冷启动首屏强制回源拉最新;首屏加载完成后在 onPageFinished 中恢复 LOAD_DEFAULT,使用过程走缓存。 + settings.cacheMode = + if (config.freshOnColdStart) WebSettings.LOAD_NO_CACHE else WebSettings.LOAD_DEFAULT config.userAgent?.let { settings.userAgentString = it } // JS → Native bridge. Must be added before loadUrl. @@ -687,9 +691,15 @@ fun XWebViewView( ) webViewClient = object : WebViewClient() { + private var coldStartHandled = false override fun onPageFinished(view: WebView?, url: String?) { currentUrl = url ?: view?.url super.onPageFinished(view, url) + // 首屏加载完成后恢复正常缓存:之后页内跳转/AJAX/子资源走缓存(仅切一次)。 + if (config.freshOnColdStart && !coldStartHandled) { + view?.settings?.cacheMode = WebSettings.LOAD_DEFAULT + coldStartHandled = true + } // Inject DIALOG_OVERRIDE_JS + user script after every page load. view?.evaluateJavascript(buildInjectedJs(config), null) mainHandler.post {