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`)。**
|
**两种平台互相独立,不允许自动降级到默认公有平台(`DEFAULT_PLATFORM_URL`)。**
|
||||||
|
|
||||||
### 用户信息
|
### 用户信息
|
||||||
|
|
||||||
```kotlin
|
```kotlin
|
||||||
XuqmSDK.setUserInfo(XuqmUserInfo(
|
XuqmSDK.setUserInfo(XuqmUserInfo(
|
||||||
userId = "u001",
|
userId = "u001",
|
||||||
userSig = "sig", // IM 登录凭证(可选)
|
userSig = "sig", // IM 登录凭证(仅 IM 强制,其它 SDK 均可不传)
|
||||||
name = "张三", // 可选
|
name = "张三", // 可选
|
||||||
))
|
))
|
||||||
XuqmSDK.setUserInfo(null) // 登出,触发所有子 SDK 登出
|
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 完成后)
|
### 平台配置读取(init 完成后)
|
||||||
```kotlin
|
```kotlin
|
||||||
XuqmSDK.platformConfig?.bugCollectApiUrl // Bug 采集服务地址
|
XuqmSDK.platformConfig?.bugCollectApiUrl // Bug 采集服务地址
|
||||||
@ -114,3 +131,34 @@ NEXUS_PASSWORD=your_password
|
|||||||
```
|
```
|
||||||
|
|
||||||
发布至 `https://nexus.xuqinmin.com/repository/android-hosted/`,版本号在各模块 `build.gradle.kts` 中维护。
|
发布至 `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
|
if (urlExt != null) "$rawName.$urlExt" else rawName
|
||||||
} else rawName
|
} else rawName
|
||||||
|
|
||||||
val baseDir = when (destination) {
|
// Android 10+ can't write to public Downloads via FileOutputStream; use cache dir + MediaStore.
|
||||||
FileDownloadDestination.PublicDownloads -> resolveBaseDir(context, destination)
|
val useMediaStore = destination == FileDownloadDestination.PublicDownloads
|
||||||
FileDownloadDestination.Sandbox -> {
|
&& Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q
|
||||||
if (directoryName.isNullOrBlank()) {
|
|
||||||
context.getExternalFilesDir(null) ?: context.filesDir
|
val target: File = if (useMediaStore) {
|
||||||
} else {
|
File(context.cacheDir, resolvedName).also { if (it.exists()) it.delete() }
|
||||||
File(context.getExternalFilesDir(null) ?: context.filesDir, directoryName).apply { mkdirs() }
|
} 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) {
|
val notifId = if (notificationTitle != null) {
|
||||||
ensureDownloadNotificationChannel(context)
|
ensureDownloadNotificationChannel(context)
|
||||||
System.currentTimeMillis().toInt()
|
System.currentTimeMillis().toInt()
|
||||||
@ -212,6 +220,10 @@ object FileSDK {
|
|||||||
}
|
}
|
||||||
onProgress(progress)
|
onProgress(progress)
|
||||||
}
|
}
|
||||||
|
if (useMediaStore) {
|
||||||
|
saveFileToPublicDownloads(context, target, resolvedName)
|
||||||
|
android.util.Log.d("FileSDK", "download: saved to public Downloads via MediaStore: $resolvedName")
|
||||||
|
}
|
||||||
} catch (e: Throwable) {
|
} catch (e: Throwable) {
|
||||||
downloadError = e
|
downloadError = e
|
||||||
android.util.Log.e("FileSDK", "download failed: url=$downloadUrl dest=${target.absolutePath}", 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 目录。
|
* 通过 MediaStore 将文件写入公共 Downloads 目录。
|
||||||
* 在华为等设备上直接文件 I/O 会失败,MediaStore 通过 ContentResolver 操作,兼容性更好。
|
* 在华为等设备上直接文件 I/O 会失败,MediaStore 通过 ContentResolver 操作,兼容性更好。
|
||||||
|
|||||||
@ -21,7 +21,8 @@ object DeviceUtils {
|
|||||||
*/
|
*/
|
||||||
fun getRomVersion(): String? {
|
fun getRomVersion(): String? {
|
||||||
val props = listOf(
|
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.build.version.emui", // EMUI / HarmonyOS
|
||||||
"ro.vivo.os.version", // OriginOS (VIVO)
|
"ro.vivo.os.version", // OriginOS (VIVO)
|
||||||
"ro.funtouch.os_version", // FuntouchOS (旧 VIVO)
|
"ro.funtouch.os_version", // FuntouchOS (旧 VIVO)
|
||||||
|
|||||||
@ -20,6 +20,11 @@ data class XWebViewConfig(
|
|||||||
val onPageError: ((errorCode: Int, description: String, failingUrl: String) -> Unit)? = null,
|
val onPageError: ((errorCode: Int, description: String, failingUrl: String) -> Unit)? = null,
|
||||||
/** 页面加载进度回调(0-100)。 */
|
/** 页面加载进度回调(0-100)。 */
|
||||||
val onProgressChanged: ((Int) -> Unit)? = null,
|
val onProgressChanged: ((Int) -> Unit)? = null,
|
||||||
|
/**
|
||||||
|
* 冷启动首屏是否强制回源拉取最新(绕过 WebView 缓存),首屏加载完成后自动恢复正常缓存。
|
||||||
|
* 默认 true:保证每次启动加载到最新页面,使用过程中的页内跳转/子资源仍走缓存。
|
||||||
|
*/
|
||||||
|
val freshOnColdStart: Boolean = true,
|
||||||
)
|
)
|
||||||
|
|
||||||
interface XWebViewController {
|
interface XWebViewController {
|
||||||
|
|||||||
@ -21,6 +21,7 @@ import android.webkit.WebChromeClient
|
|||||||
import android.webkit.WebResourceError
|
import android.webkit.WebResourceError
|
||||||
import android.webkit.WebResourceRequest
|
import android.webkit.WebResourceRequest
|
||||||
import android.webkit.WebViewClient
|
import android.webkit.WebViewClient
|
||||||
|
import android.webkit.WebSettings
|
||||||
import android.webkit.WebView
|
import android.webkit.WebView
|
||||||
import android.widget.FrameLayout
|
import android.widget.FrameLayout
|
||||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
@ -678,6 +679,9 @@ fun XWebViewView(
|
|||||||
settings.domStorageEnabled = true
|
settings.domStorageEnabled = true
|
||||||
settings.useWideViewPort = true
|
settings.useWideViewPort = true
|
||||||
settings.loadWithOverviewMode = 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 }
|
config.userAgent?.let { settings.userAgentString = it }
|
||||||
|
|
||||||
// JS → Native bridge. Must be added before loadUrl.
|
// JS → Native bridge. Must be added before loadUrl.
|
||||||
@ -687,9 +691,15 @@ fun XWebViewView(
|
|||||||
)
|
)
|
||||||
|
|
||||||
webViewClient = object : WebViewClient() {
|
webViewClient = object : WebViewClient() {
|
||||||
|
private var coldStartHandled = false
|
||||||
override fun onPageFinished(view: WebView?, url: String?) {
|
override fun onPageFinished(view: WebView?, url: String?) {
|
||||||
currentUrl = url ?: view?.url
|
currentUrl = url ?: view?.url
|
||||||
super.onPageFinished(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.
|
// Inject DIALOG_OVERRIDE_JS + user script after every page load.
|
||||||
view?.evaluateJavascript(buildInjectedJs(config), null)
|
view?.evaluateJavascript(buildInjectedJs(config), null)
|
||||||
mainHandler.post {
|
mainHandler.post {
|
||||||
|
|||||||
正在加载...
在新工单中引用
屏蔽一个用户