fix(file): 解决文件下载和时间戳处理问题

- 实现文件名扩展名推断逻辑,从URL路径自动提取并添加扩展名
- 添加下载错误处理和日志记录功能
- 在下载失败时显示错误通知而非成功通知
- 将服务器时间戳处理统一调整为CST(亚洲/上海)时区
- 更新sdk-core版本号至1.1.6-SNAPSHOT
这个提交包含在:
XuqmGroup 2026-07-13 17:17:47 +08:00
父节点 fbc6f089c1
当前提交 d8ac97255d
共有 5 个文件被更改,包括 117 次插入10 次删除

查看文件

@ -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 {