fix(file): 解决文件下载和时间戳处理问题
- 实现文件名扩展名推断逻辑,从URL路径自动提取并添加扩展名 - 添加下载错误处理和日志记录功能 - 在下载失败时显示错误通知而非成功通知 - 将服务器时间戳处理统一调整为CST(亚洲/上海)时区 - 更新sdk-core版本号至1.1.6-SNAPSHOT
这个提交包含在:
父节点
fbc6f089c1
当前提交
d8ac97255d
50
CLAUDE.md
50
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 版本号
|
||||
|
||||
@ -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 操作,兼容性更好。
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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 {
|
||||
|
||||
正在加载...
在新工单中引用
屏蔽一个用户