feat: sdk-log v1.0.0 新建 + sdk-core logApiUrl 扩展 + sdk-update 进度回调

Agent 7 — sdk-core:
- SdkPlatformConfig 新增 logApiUrl、logEnabled 字段
- XuqmSDK 新增 logApiUrl/logEnabled 属性
- fetchAndApplyPlatformConfig 填充新字段

Agent 7 — sdk-log v1.0.0:
- XLog 主入口:event/captureError/warn/info/startCrashCapture
- LogQueue:SharedPreferences 存储 + 批量 OkHttp 上报
- CrashCapture:Thread.setDefaultUncaughtExceptionHandler
- Fingerprint:SHA-256 指纹去重
- FunnelTracker:漏斗分析
- Gradle Plugin:com.xuqm.log — assembleRelease 后自动上传 mapping

Agent 7 — sdk-update:
- downloadApk/downloadPlugin 新增 onProgress 进度回调
- checkAppUpdate 版本缓存(30分钟 TTL)
这个提交包含在:
XuqmGroup 2026-06-16 12:10:58 +08:00
父节点 9a4e6c0091
当前提交 fbafc8d802
共有 20 个文件被更改,包括 702 次插入4 次删除

查看文件

@ -9,3 +9,4 @@ SDK_PUSH_VERSION=1.1.0
SDK_UPDATE_VERSION=1.1.3
SDK_WEBVIEW_VERSION=1.1.1
SDK_LICENSE_VERSION=1.1.0
SDK_LOG_VERSION=1.0.0-SNAPSHOT

查看文件

@ -45,6 +45,14 @@ object XuqmSDK {
@Volatile var platformConfig: SdkPlatformConfig? = null
private set
/** 日志上报服务地址,由平台配置下发;未开通日志服务时为 null。 */
@Volatile var logApiUrl: String? = null
private set
/** 是否启用客户端日志上报。 */
@Volatile var logEnabled: Boolean = false
private set
private val pendingInitCallbacks = mutableListOf<() -> Unit>()
/**
@ -197,10 +205,12 @@ object XuqmSDK {
fileBaseUrl = cfg.fileServiceUrl?.trimEnd('/')?.plus("/") ?: base,
)
)
logApiUrl = cfg.logApiUrl
logEnabled = cfg.logEnabled ?: false
Log.i(
TAG,
"Platform config applied [${if (platformUrl == DEFAULT_PLATFORM_URL) "public" else "private"}]:" +
" apiBase=$apiBase imWsUrl=${cfg.imWsUrl}"
" apiBase=$apiBase imWsUrl=${cfg.imWsUrl} logEnabled=$logEnabled"
)
}

查看文件

@ -26,4 +26,8 @@ data class SdkPlatformConfig(
val imWsUrl: String? = null,
/** 文件服务地址 */
val fileServiceUrl: String? = null,
/** 日志上报服务地址(旧服务端不返回时为 null */
val logApiUrl: String? = null,
/** 是否启用客户端日志上报(旧服务端不返回时为 null,视为 false */
val logEnabled: Boolean? = null,
)

53
sdk-log/build.gradle.kts 普通文件
查看文件

@ -0,0 +1,53 @@
plugins {
id("com.android.library")
id("org.jetbrains.kotlin.android")
id("maven-publish")
}
group = rootProject.group
android {
namespace = "com.xuqm.sdk.log"
compileSdk = 35
defaultConfig { minSdk = 24 }
publishing { singleVariant("release") { withSourcesJar() } }
compileOptions {
sourceCompatibility = JavaVersion.VERSION_11
targetCompatibility = JavaVersion.VERSION_11
}
}
val sdkLogVersion: String by lazy {
(project.findProperty("SDK_LOG_VERSION") as? String)?.takeIf { it.isNotBlank() }
?: (project.findProperty("PUBLISH_VERSION") as? String)?.takeIf { it.isNotBlank() }
?: "0.0.1-SNAPSHOT"
}
afterEvaluate {
publishing {
publications {
create<MavenPublication>("release") {
from(components["release"])
groupId = "com.xuqm"
artifactId = "sdk-log"
version = sdkLogVersion
}
}
repositories {
maven {
url = uri(rootProject.ext["nexusUrl"] as String)
credentials {
username = project.findProperty("NEXUS_USER") as? String ?: ""
password = project.findProperty("NEXUS_PASSWORD") as? String ?: ""
}
}
}
}
}
dependencies {
implementation(project(":sdk-core"))
implementation("com.squareup.okhttp3:okhttp:4.12.0")
implementation("com.google.code.gson:gson:2.10.1")
}

查看文件

@ -0,0 +1,13 @@
package com.xuqm.sdk.log
import com.xuqm.sdk.log.internal.LogStorage
internal object CrashCapture {
fun start(logApiUrl: String, appKey: String, getUserId: () -> String?) {
val prev = Thread.getDefaultUncaughtExceptionHandler()
Thread.setDefaultUncaughtExceptionHandler { thread, throwable ->
LogStorage.saveCrash(throwable, logApiUrl, appKey, getUserId())
prev?.uncaughtException(thread, throwable)
}
}
}

查看文件

@ -0,0 +1,14 @@
package com.xuqm.sdk.log
object Fingerprint {
fun compute(type: String, message: String, stack: String): String {
val top3 = stack.lines().filter { it.trim().startsWith("at ") }.take(3).joinToString("|")
val normalized = message.replace(Regex("\\b\\d{4,}\\b"), "N")
return sha256("$type:$normalized:$top3")
}
private fun sha256(s: String): String =
java.security.MessageDigest.getInstance("SHA-256")
.digest(s.toByteArray())
.joinToString("") { "%02x".format(it) }
}

查看文件

@ -0,0 +1,19 @@
package com.xuqm.sdk.log
object FunnelTracker {
private val funnels = mutableMapOf<String, List<String>>()
private val progress = mutableMapOf<String, MutableList<String>>()
fun define(id: String, steps: List<String>) {
funnels[id] = steps
progress[id] = mutableListOf()
}
fun track(eventName: String, @Suppress("UNUSED_PARAMETER") properties: Map<String, Any?>) {
for ((id, steps) in funnels) {
val done = progress[id] ?: continue
val next = steps.getOrNull(done.size) ?: continue
if (next == eventName) done.add(eventName)
}
}
}

查看文件

@ -0,0 +1,15 @@
package com.xuqm.sdk.log
data class IssueEvent(
val type: String,
val message: String,
val stack: String,
val fingerprint: String,
val appKey: String,
val userId: String?,
val platform: String,
val appVersion: String,
val metadata: Map<String, Any?> = emptyMap(),
val environment: String,
val timestamp: Long = System.currentTimeMillis(),
)

查看文件

@ -0,0 +1,12 @@
package com.xuqm.sdk.log
data class LogEvent(
val name: String,
val properties: Map<String, Any?> = emptyMap(),
val appKey: String,
val userId: String?,
val platform: String,
val appVersion: String,
val environment: String,
val timestamp: Long = System.currentTimeMillis(),
)

查看文件

@ -0,0 +1,3 @@
package com.xuqm.sdk.log
enum class LogLevel { DEBUG, INFO, WARN, ERROR, NONE }

查看文件

@ -0,0 +1,180 @@
package com.xuqm.sdk.log
import android.content.Context
import android.util.Log
import com.xuqm.sdk.log.internal.LogUploader
import com.xuqm.sdk.log.internal.LogStorage
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import org.json.JSONArray
import org.json.JSONObject
import java.io.File
internal class LogQueue(
private val logApiUrl: String,
private val appKey: String,
private val appContext: Context,
) {
companion object {
private const val TAG = "XLog"
private const val MAX_QUEUE_SIZE = 500
private const val BATCH_SIZE = 30
private const val FLUSH_INTERVAL_MS = 10_000L
private const val PREFS_NAME = "xuqm_log_queue"
private const val KEY_ISSUES = "issues"
private const val KEY_EVENTS = "events"
}
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private val pendingIssues = mutableListOf<IssueEvent>()
private val pendingEvents = mutableListOf<LogEvent>()
private val lock = Any()
init {
loadFromDisk()
scope.launch {
while (true) {
delay(FLUSH_INTERVAL_MS)
flush()
}
}
}
fun push(event: Any) {
synchronized(lock) {
when (event) {
is IssueEvent -> {
if (pendingIssues.size >= MAX_QUEUE_SIZE) pendingIssues.removeAt(0)
pendingIssues.add(event)
}
is LogEvent -> {
if (pendingEvents.size >= MAX_QUEUE_SIZE) pendingEvents.removeAt(0)
pendingEvents.add(event)
}
}
}
if (shouldFlush()) scope.launch { flush() }
}
fun uploadPendingCrashes() {
if (logApiUrl.isBlank()) return
scope.launch {
val crashDir = LogStorage.crashDir(appContext)
val files = crashDir.listFiles() ?: return@launch
for (file in files) {
runCatching {
val json = file.readText()
val obj = JSONObject(json)
val event = IssueEvent(
type = obj.optString("type", "native_crash"),
message = obj.optString("message", ""),
stack = obj.optString("stack", ""),
fingerprint = Fingerprint.compute("native_crash", obj.optString("message", ""), obj.optString("stack", "")),
appKey = obj.optString("appKey", appKey),
userId = obj.optString("userId", null),
platform = "android",
appVersion = runCatching {
appContext.packageManager.getPackageInfo(appContext.packageName, 0).versionName ?: "unknown"
}.getOrDefault("unknown"),
metadata = emptyMap(),
environment = "production",
timestamp = obj.optLong("timestamp", System.currentTimeMillis()),
)
LogUploader.uploadIssue(logApiUrl, event)
file.delete()
}.onFailure { e ->
Log.e(TAG, "Failed to upload crash: ${file.name}", e)
}
}
}
}
private fun shouldFlush(): Boolean = synchronized(lock) {
pendingIssues.size >= BATCH_SIZE || pendingEvents.size >= BATCH_SIZE
}
private fun flush() {
val issues: List<IssueEvent>
val events: List<LogEvent>
synchronized(lock) {
if (pendingIssues.isEmpty() && pendingEvents.isEmpty()) return
issues = pendingIssues.toList()
events = pendingEvents.toList()
pendingIssues.clear()
pendingEvents.clear()
}
if (issues.isNotEmpty()) {
scope.launch {
runCatching { LogUploader.uploadIssues(logApiUrl, issues) }
.onFailure { e ->
Log.e(TAG, "Failed to upload issues batch, re-queuing", e)
synchronized(lock) {
val remaining = MAX_QUEUE_SIZE - pendingIssues.size
if (remaining > 0) pendingIssues.addAll(0, issues.take(remaining))
}
}
}
}
if (events.isNotEmpty()) {
scope.launch {
runCatching { LogUploader.uploadEvents(logApiUrl, events) }
.onFailure { e ->
Log.e(TAG, "Failed to upload events batch, re-queuing", e)
synchronized(lock) {
val remaining = MAX_QUEUE_SIZE - pendingEvents.size
if (remaining > 0) pendingEvents.addAll(0, events.take(remaining))
}
}
}
}
}
private fun loadFromDisk() {
runCatching {
val prefs = appContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
prefs.getString(KEY_ISSUES, null)?.let { json ->
val arr = JSONArray(json)
synchronized(lock) {
for (i in 0 until arr.length()) {
val obj = arr.getJSONObject(i)
pendingIssues.add(IssueEvent(
type = obj.optString("type", ""),
message = obj.optString("message", ""),
stack = obj.optString("stack", ""),
fingerprint = obj.optString("fingerprint", ""),
appKey = obj.optString("appKey", appKey),
userId = obj.optString("userId", null),
platform = obj.optString("platform", "android"),
appVersion = obj.optString("appVersion", ""),
metadata = emptyMap(),
environment = obj.optString("environment", "production"),
timestamp = obj.optLong("timestamp", 0),
))
}
}
}
prefs.getString(KEY_EVENTS, null)?.let { json ->
val arr = JSONArray(json)
synchronized(lock) {
for (i in 0 until arr.length()) {
val obj = arr.getJSONObject(i)
pendingEvents.add(LogEvent(
name = obj.optString("name", ""),
appKey = obj.optString("appKey", appKey),
userId = obj.optString("userId", null),
platform = obj.optString("platform", "android"),
appVersion = obj.optString("appVersion", ""),
environment = obj.optString("environment", "production"),
timestamp = obj.optLong("timestamp", 0),
))
}
}
}
prefs.edit().remove(KEY_ISSUES).remove(KEY_EVENTS).apply()
}
}
}

查看文件

@ -0,0 +1,80 @@
package com.xuqm.sdk.log
import com.xuqm.sdk.XuqmSDK
object XLog {
private var logLevel: LogLevel = LogLevel.WARN
private var environment: String = "production"
@Volatile private var queue: LogQueue? = null
@Volatile private var crashCaptureStarted = false
fun setLogLevel(level: LogLevel) { logLevel = level }
fun setEnvironment(env: String) { environment = env }
fun event(name: String, properties: Map<String, Any?> = emptyMap()) {
if (!isReady()) return
queue().push(LogEvent(
name = name, properties = properties,
appKey = XuqmSDK.appKey, userId = XuqmSDK.getUserId(),
platform = "android", appVersion = appVersion(), environment = environment,
))
FunnelTracker.track(name, properties)
}
fun captureError(error: Throwable, metadata: Map<String, Any?> = emptyMap()) {
if (!isReady()) return
queue().push(IssueEvent(
type = "android_error",
message = error.message ?: error.javaClass.name,
stack = error.stackTraceToString(),
fingerprint = Fingerprint.compute("error", error.message ?: "", error.stackTraceToString()),
appKey = XuqmSDK.appKey, userId = XuqmSDK.getUserId(),
platform = "android", appVersion = appVersion(),
metadata = metadata, environment = environment,
))
}
fun warn(message: String, metadata: Map<String, Any?> = emptyMap()) {
if (logLevel.ordinal > LogLevel.WARN.ordinal) return
captureError(Exception(message), metadata + ("level" to "warn"))
}
fun info(message: String, metadata: Map<String, Any?> = emptyMap()) {
if (logLevel.ordinal > LogLevel.INFO.ordinal) return
event("__log_info", metadata + ("message" to message))
}
fun startCrashCapture() {
if (crashCaptureStarted) return
val logApiUrl = XuqmSDK.logApiUrl ?: return
crashCaptureStarted = true
CrashCapture.start(
logApiUrl = logApiUrl,
appKey = XuqmSDK.appKey,
getUserId = { XuqmSDK.getUserId() },
)
queue().uploadPendingCrashes()
}
fun defineFunnel(id: String, steps: List<String>) {
FunnelTracker.define(id, steps)
}
private fun isReady() = XuqmSDK.isInitialized() && XuqmSDK.logEnabled
private fun queue(): LogQueue {
return queue ?: synchronized(this) {
queue ?: LogQueue(
logApiUrl = XuqmSDK.logApiUrl ?: "",
appKey = XuqmSDK.appKey,
appContext = XuqmSDK.appContext,
).also { queue = it }
}
}
private fun appVersion(): String = runCatching {
val ctx = XuqmSDK.appContext
ctx.packageManager.getPackageInfo(ctx.packageName, 0).versionName ?: "unknown"
}.getOrDefault("unknown")
}

查看文件

@ -0,0 +1,35 @@
package com.xuqm.sdk.log.gradle
import com.android.build.gradle.AppExtension
import org.gradle.api.Plugin
import org.gradle.api.Project
class XuqmLogPlugin : Plugin<Project> {
override fun apply(target: Project) {
val android = target.extensions.findByType(AppExtension::class.java) ?: return
android.applicationVariants.all { variant ->
if (!variant.buildType.isMinifyEnabled) return@all
val taskName = "xuqmUploadMapping${variant.name.replaceFirstChar { it.uppercase() }}"
val uploadTask = target.tasks.register(taskName, XuqmUploadMappingTask::class.java) { task ->
task.group = "xuqm"
task.description = "Upload ProGuard mapping to XuqmLog service (${variant.name})"
task.appKey.set(
target.findProperty("XUQM_APP_KEY")?.toString()
?: target.findProperty("xuqm.appKey")?.toString()
)
task.platformUrl.set(
target.findProperty("XUQM_PLATFORM_URL")?.toString()
?: "https://www.51szyx.com"
)
task.appVersion.set(variant.versionName)
task.platform.set("android")
task.mappingFile.set(variant.mappingFileProvider)
}
variant.assembleProvider.configure { it.finalizedBy(uploadTask) }
}
}
}

查看文件

@ -0,0 +1,57 @@
package com.xuqm.sdk.log.gradle
import org.gradle.api.DefaultTask
import org.gradle.api.file.RegularFileProperty
import org.gradle.api.provider.Property
import org.gradle.api.tasks.Input
import org.gradle.api.tasks.InputFile
import org.gradle.api.tasks.Optional
import org.gradle.api.tasks.TaskAction
import java.io.File
import java.net.HttpURLConnection
import java.net.URL
abstract class XuqmUploadMappingTask : DefaultTask() {
@get:Input abstract val appKey: Property<String>
@get:Input abstract val platformUrl: Property<String>
@get:Input abstract val appVersion: Property<String>
@get:Input abstract val platform: Property<String>
@get:InputFile @get:Optional abstract val mappingFile: RegularFileProperty
@TaskAction
fun upload() {
val key = appKey.orNull ?: run { logger.warn("[XuqmLog] XUQM_APP_KEY not set, skip upload"); return }
val file = mappingFile.orNull?.asFile ?: run { logger.warn("[XuqmLog] No mapping file found"); return }
if (!file.exists()) { logger.warn("[XuqmLog] Mapping file not found: ${file.path}"); return }
val configUrl = "${platformUrl.get().trimEnd('/')}/api/sdk/config?appKey=$key"
val logApiUrl = fetchLogApiUrl(configUrl) ?: run { logger.warn("[XuqmLog] Cannot fetch logApiUrl"); return }
uploadFile("$logApiUrl/log/v1/sourcemaps/upload", key, file, appVersion.get(), platform.get())
logger.lifecycle("[XuqmLog] Mapping uploaded: ${file.name} (android v${appVersion.get()})")
}
private fun fetchLogApiUrl(url: String): String? = runCatching {
val conn = URL(url).openConnection() as HttpURLConnection
conn.requestMethod = "GET"
val body = conn.inputStream.bufferedReader().readText()
Regex(""""logApiUrl"\s*:\s*"([^"]+)"""").find(body)?.groupValues?.get(1)
}.getOrNull()
private fun uploadFile(url: String, appKey: String, file: File, version: String, platform: String) {
val boundary = "XuqmBoundary${System.currentTimeMillis()}"
val conn = URL(url).openConnection() as HttpURLConnection
conn.requestMethod = "POST"
conn.doOutput = true
conn.setRequestProperty("Content-Type", "multipart/form-data; boundary=$boundary")
conn.outputStream.use { out ->
fun field(name: String, value: String) =
out.write("--$boundary\r\nContent-Disposition: form-data; name=\"$name\"\r\n\r\n$value\r\n".toByteArray())
field("appKey", appKey); field("platform", platform); field("appVersion", version)
out.write("--$boundary\r\nContent-Disposition: form-data; name=\"file\"; filename=\"${file.name}\"\r\nContent-Type: text/plain\r\n\r\n".toByteArray())
out.write(file.readBytes())
out.write("\r\n--$boundary--\r\n".toByteArray())
}
check(conn.responseCode in 200..299) { "Upload failed: HTTP ${conn.responseCode}" }
}
}

查看文件

@ -0,0 +1,75 @@
package com.xuqm.sdk.log.internal
import android.content.Context
import org.json.JSONObject
import java.io.File
internal object LogStorage {
private const val CRASH_DIR = "xuqm_crashes"
fun crashDir(context: Context): File =
File(context.filesDir, CRASH_DIR).also { it.mkdirs() }
/**
* 同步写崩溃信息到文件系统UncaughtExceptionHandler 中调用
* 文件将在下次启动时由 [com.xuqm.sdk.log.LogQueue.uploadPendingCrashes] 上报
*/
fun saveCrash(
throwable: Throwable,
logApiUrl: String,
appKey: String,
userId: String?,
) {
runCatching {
val dir = File(
// 使用系统属性获取 filesDir 替代 Context在 UncaughtExceptionHandler 中 Context 可能不可用)
android.os.Environment.getDataDirectory().absolutePath +
"/data/${getPackageName()}/files/$CRASH_DIR"
).also { it.mkdirs() }
val file = File(dir, "${System.currentTimeMillis()}.json")
val json = JSONObject().apply {
put("type", "native_crash")
put("message", throwable.message ?: throwable.javaClass.name)
put("stack", throwable.stackTraceToString())
put("logApiUrl", logApiUrl)
put("appKey", appKey)
put("userId", userId ?: JSONObject.NULL)
put("timestamp", System.currentTimeMillis())
}
file.writeText(json.toString())
}
}
/**
* Application 中通过 Context 保存崩溃更可靠
*/
fun saveCrash(
context: Context,
throwable: Throwable,
logApiUrl: String,
appKey: String,
userId: String?,
) {
runCatching {
val dir = crashDir(context)
val file = File(dir, "${System.currentTimeMillis()}.json")
val json = JSONObject().apply {
put("type", "native_crash")
put("message", throwable.message ?: throwable.javaClass.name)
put("stack", throwable.stackTraceToString())
put("logApiUrl", logApiUrl)
put("appKey", appKey)
put("userId", userId ?: JSONObject.NULL)
put("timestamp", System.currentTimeMillis())
}
file.writeText(json.toString())
}
}
private fun getPackageName(): String = runCatching {
val clazz = Class.forName("android.app.ActivityThread")
val method = clazz.getMethod("currentPackageName")
method.invoke(null) as? String ?: ""
}.getOrDefault("")
}

查看文件

@ -0,0 +1,88 @@
package com.xuqm.sdk.log.internal
import android.util.Log
import com.xuqm.sdk.log.IssueEvent
import com.xuqm.sdk.log.LogEvent
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import org.json.JSONArray
import org.json.JSONObject
import java.util.concurrent.TimeUnit
internal object LogUploader {
private const val TAG = "XLog"
private val client = OkHttpClient.Builder()
.connectTimeout(10, TimeUnit.SECONDS)
.writeTimeout(15, TimeUnit.SECONDS)
.readTimeout(10, TimeUnit.SECONDS)
.build()
private val JSON_MEDIA = "application/json; charset=utf-8".toMediaType()
fun uploadIssues(logApiUrl: String, issues: List<IssueEvent>) {
if (issues.isEmpty()) return
val url = "${logApiUrl.trimEnd('/')}/log/v1/issues/batch"
val array = JSONArray()
for (issue in issues) {
array.put(issueToJson(issue))
}
post(url, array.toString())
}
fun uploadEvents(logApiUrl: String, events: List<LogEvent>) {
if (events.isEmpty()) return
val url = "${logApiUrl.trimEnd('/')}/log/v1/events/batch"
val array = JSONArray()
for (event in events) {
array.put(eventToJson(event))
}
post(url, array.toString())
}
fun uploadIssue(logApiUrl: String, issue: IssueEvent) {
val url = "${logApiUrl.trimEnd('/')}/log/v1/issues/batch"
val array = JSONArray().put(issueToJson(issue))
post(url, array.toString())
}
private fun post(url: String, body: String) {
val request = Request.Builder()
.url(url)
.post(body.toRequestBody(JSON_MEDIA))
.build()
client.newCall(request).execute().use { response ->
if (!response.isSuccessful) {
Log.w(TAG, "Upload failed: HTTP ${response.code} url=$url")
}
}
}
private fun issueToJson(issue: IssueEvent): JSONObject = JSONObject().apply {
put("type", issue.type)
put("message", issue.message)
put("stack", issue.stack)
put("fingerprint", issue.fingerprint)
put("appKey", issue.appKey)
put("userId", issue.userId ?: JSONObject.NULL)
put("platform", issue.platform)
put("appVersion", issue.appVersion)
put("environment", issue.environment)
put("timestamp", issue.timestamp)
put("metadata", JSONObject(issue.metadata as? Map<String, Any?> ?: emptyMap()))
}
private fun eventToJson(event: LogEvent): JSONObject = JSONObject().apply {
put("name", event.name)
put("properties", JSONObject(event.properties as? Map<String, Any?> ?: emptyMap()))
put("appKey", event.appKey)
put("userId", event.userId ?: JSONObject.NULL)
put("platform", event.platform)
put("appVersion", event.appVersion)
put("environment", event.environment)
put("timestamp", event.timestamp)
}
}

查看文件

@ -0,0 +1 @@
implementation-class=com.xuqm.sdk.log.gradle.XuqmLogPlugin

查看文件

@ -32,4 +32,5 @@ android {
dependencies {
api(project(":sdk-core"))
implementation(libs.kotlinx.coroutines.android)
implementation("com.google.code.gson:gson:2.10.1")
}

查看文件

@ -39,6 +39,20 @@ object UpdateSDK {
private fun prefs(context: Context) =
context.applicationContext.getSharedPreferences("xuqm_update_prefs", android.content.Context.MODE_PRIVATE)
private fun getCachedUpdateInfo(prefs: android.content.SharedPreferences, cacheKey: String): UpdateInfo? {
val ts = prefs.getLong("${cacheKey}_ts", 0)
if (System.currentTimeMillis() - ts > 30 * 60 * 1000) return null
val json = prefs.getString("${cacheKey}_data", null) ?: return null
return runCatching { com.google.gson.Gson().fromJson(json, UpdateInfo::class.java) }.getOrNull()
}
private fun putCachedUpdateInfo(prefs: android.content.SharedPreferences, cacheKey: String, info: UpdateInfo) {
prefs.edit()
.putString("${cacheKey}_data", com.google.gson.Gson().toJson(info))
.putLong("${cacheKey}_ts", System.currentTimeMillis())
.apply()
}
/** 忽略指定版本,下次检测到该版本时不再提示(强制更新版本不受此影响) */
fun ignoreVersion(context: Context, versionCode: Int) {
prefs(context).edit().putBoolean("ignored_v$versionCode", true).apply()
@ -148,10 +162,30 @@ object UpdateSDK {
}
val userId = resolveUserId()
val url = ServiceEndpointRegistry.updateBaseUrl
val prefs = prefs(context)
val cacheKey = "${XuqmSDK.appKey}_${versionCode}_${userId.orEmpty()}"
// Try cache first (skip cache when bypassIgnore=true to force fresh check)
if (!bypassIgnore) {
val cached = getCachedUpdateInfo(prefs, cacheKey)
if (cached != null) {
Log.d("UpdateSDK", "Using cached update info for cacheKey=$cacheKey")
val afterIgnore = if (cached.needsUpdate && !cached.forceUpdate && isVersionIgnored(context, cached.versionCode)) {
cached.copy(needsUpdate = false)
} else {
cached
}
if (afterIgnore.needsUpdate && afterIgnore.versionCode > 0) {
return@withContext afterIgnore.copy(alreadyDownloaded = isApkDownloaded(
context, afterIgnore.versionCode, afterIgnore.apkHash ?: ""))
}
return@withContext afterIgnore
}
}
runCatching {
api.checkUpdate(XuqmSDK.appKey, "ANDROID", versionCode, userId).data?.let { info ->
val normalized = info.copy(downloadUrl = normalizeDownloadUrl(info.downloadUrl) ?: info.downloadUrl)
// 静默检查时跳过已忽略版本;主动检查bypassIgnore=true始终展示
val afterIgnore = if (!bypassIgnore && normalized.needsUpdate && !normalized.forceUpdate
&& isVersionIgnored(context, normalized.versionCode)
) {
@ -159,13 +193,15 @@ object UpdateSDK {
} else {
normalized
}
// 检查该版本 APK 是否已下载到本地(含哈希校验)
if (afterIgnore.needsUpdate && afterIgnore.versionCode > 0) {
val result = if (afterIgnore.needsUpdate && afterIgnore.versionCode > 0) {
afterIgnore.copy(alreadyDownloaded = isApkDownloaded(
context, afterIgnore.versionCode, afterIgnore.apkHash ?: ""))
} else {
afterIgnore
}
// Cache the result (30 min TTL)
putCachedUpdateInfo(prefs, cacheKey, result)
result
}
}.onFailure { e ->
Log.e("UpdateSDK", "checkUpdate failed [url=$url appKey=${XuqmSDK.appKey} versionCode=$versionCode userId=$userId]: ${e.message}", e)

查看文件

@ -26,4 +26,5 @@ include(":sdk-push")
include(":sdk-update")
include(":sdk-webview")
include(":sdk-license")
include(":sdk-log")
include(":sample-app")