From fbafc8d802008fb4e5cde030d4bef79c27a690a7 Mon Sep 17 00:00:00 2001 From: XuqmGroup Date: Tue, 16 Jun 2026 12:10:58 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20sdk-log=20v1.0.0=20=E6=96=B0=E5=BB=BA?= =?UTF-8?q?=20+=20sdk-core=20logApiUrl=20=E6=89=A9=E5=B1=95=20+=20sdk-upda?= =?UTF-8?q?te=20=E8=BF=9B=E5=BA=A6=E5=9B=9E=E8=B0=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- gradle.properties | 1 + .../src/main/java/com/xuqm/sdk/XuqmSDK.kt | 12 +- .../xuqm/sdk/network/SdkPlatformConfigApi.kt | 4 + sdk-log/build.gradle.kts | 53 ++++++ .../java/com/xuqm/sdk/log/CrashCapture.kt | 13 ++ .../main/java/com/xuqm/sdk/log/Fingerprint.kt | 14 ++ .../java/com/xuqm/sdk/log/FunnelTracker.kt | 19 ++ .../main/java/com/xuqm/sdk/log/IssueEvent.kt | 15 ++ .../main/java/com/xuqm/sdk/log/LogEvent.kt | 12 ++ .../main/java/com/xuqm/sdk/log/LogLevel.kt | 3 + .../main/java/com/xuqm/sdk/log/LogQueue.kt | 180 ++++++++++++++++++ .../src/main/java/com/xuqm/sdk/log/XLog.kt | 80 ++++++++ .../com/xuqm/sdk/log/gradle/XuqmLogPlugin.kt | 35 ++++ .../sdk/log/gradle/XuqmUploadMappingTask.kt | 57 ++++++ .../com/xuqm/sdk/log/internal/LogStorage.kt | 75 ++++++++ .../com/xuqm/sdk/log/internal/LogUploader.kt | 88 +++++++++ .../gradle-plugins/com.xuqm.log.properties | 1 + sdk-update/build.gradle.kts | 1 + .../java/com/xuqm/sdk/update/UpdateSDK.kt | 42 +++- settings.gradle.kts | 1 + 20 files changed, 702 insertions(+), 4 deletions(-) create mode 100644 sdk-log/build.gradle.kts create mode 100644 sdk-log/src/main/java/com/xuqm/sdk/log/CrashCapture.kt create mode 100644 sdk-log/src/main/java/com/xuqm/sdk/log/Fingerprint.kt create mode 100644 sdk-log/src/main/java/com/xuqm/sdk/log/FunnelTracker.kt create mode 100644 sdk-log/src/main/java/com/xuqm/sdk/log/IssueEvent.kt create mode 100644 sdk-log/src/main/java/com/xuqm/sdk/log/LogEvent.kt create mode 100644 sdk-log/src/main/java/com/xuqm/sdk/log/LogLevel.kt create mode 100644 sdk-log/src/main/java/com/xuqm/sdk/log/LogQueue.kt create mode 100644 sdk-log/src/main/java/com/xuqm/sdk/log/XLog.kt create mode 100644 sdk-log/src/main/java/com/xuqm/sdk/log/gradle/XuqmLogPlugin.kt create mode 100644 sdk-log/src/main/java/com/xuqm/sdk/log/gradle/XuqmUploadMappingTask.kt create mode 100644 sdk-log/src/main/java/com/xuqm/sdk/log/internal/LogStorage.kt create mode 100644 sdk-log/src/main/java/com/xuqm/sdk/log/internal/LogUploader.kt create mode 100644 sdk-log/src/main/resources/META-INF/gradle-plugins/com.xuqm.log.properties diff --git a/gradle.properties b/gradle.properties index a619df0..a0b1618 100644 --- a/gradle.properties +++ b/gradle.properties @@ -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 diff --git a/sdk-core/src/main/java/com/xuqm/sdk/XuqmSDK.kt b/sdk-core/src/main/java/com/xuqm/sdk/XuqmSDK.kt index fee08df..d5db59a 100644 --- a/sdk-core/src/main/java/com/xuqm/sdk/XuqmSDK.kt +++ b/sdk-core/src/main/java/com/xuqm/sdk/XuqmSDK.kt @@ -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" ) } diff --git a/sdk-core/src/main/java/com/xuqm/sdk/network/SdkPlatformConfigApi.kt b/sdk-core/src/main/java/com/xuqm/sdk/network/SdkPlatformConfigApi.kt index 9e5ecae..4402947 100644 --- a/sdk-core/src/main/java/com/xuqm/sdk/network/SdkPlatformConfigApi.kt +++ b/sdk-core/src/main/java/com/xuqm/sdk/network/SdkPlatformConfigApi.kt @@ -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, ) diff --git a/sdk-log/build.gradle.kts b/sdk-log/build.gradle.kts new file mode 100644 index 0000000..558243a --- /dev/null +++ b/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("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") +} diff --git a/sdk-log/src/main/java/com/xuqm/sdk/log/CrashCapture.kt b/sdk-log/src/main/java/com/xuqm/sdk/log/CrashCapture.kt new file mode 100644 index 0000000..136d115 --- /dev/null +++ b/sdk-log/src/main/java/com/xuqm/sdk/log/CrashCapture.kt @@ -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) + } + } +} diff --git a/sdk-log/src/main/java/com/xuqm/sdk/log/Fingerprint.kt b/sdk-log/src/main/java/com/xuqm/sdk/log/Fingerprint.kt new file mode 100644 index 0000000..8106594 --- /dev/null +++ b/sdk-log/src/main/java/com/xuqm/sdk/log/Fingerprint.kt @@ -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) } +} diff --git a/sdk-log/src/main/java/com/xuqm/sdk/log/FunnelTracker.kt b/sdk-log/src/main/java/com/xuqm/sdk/log/FunnelTracker.kt new file mode 100644 index 0000000..907a4e0 --- /dev/null +++ b/sdk-log/src/main/java/com/xuqm/sdk/log/FunnelTracker.kt @@ -0,0 +1,19 @@ +package com.xuqm.sdk.log + +object FunnelTracker { + private val funnels = mutableMapOf>() + private val progress = mutableMapOf>() + + fun define(id: String, steps: List) { + funnels[id] = steps + progress[id] = mutableListOf() + } + + fun track(eventName: String, @Suppress("UNUSED_PARAMETER") properties: Map) { + for ((id, steps) in funnels) { + val done = progress[id] ?: continue + val next = steps.getOrNull(done.size) ?: continue + if (next == eventName) done.add(eventName) + } + } +} diff --git a/sdk-log/src/main/java/com/xuqm/sdk/log/IssueEvent.kt b/sdk-log/src/main/java/com/xuqm/sdk/log/IssueEvent.kt new file mode 100644 index 0000000..e4a59a0 --- /dev/null +++ b/sdk-log/src/main/java/com/xuqm/sdk/log/IssueEvent.kt @@ -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 = emptyMap(), + val environment: String, + val timestamp: Long = System.currentTimeMillis(), +) diff --git a/sdk-log/src/main/java/com/xuqm/sdk/log/LogEvent.kt b/sdk-log/src/main/java/com/xuqm/sdk/log/LogEvent.kt new file mode 100644 index 0000000..aabd22a --- /dev/null +++ b/sdk-log/src/main/java/com/xuqm/sdk/log/LogEvent.kt @@ -0,0 +1,12 @@ +package com.xuqm.sdk.log + +data class LogEvent( + val name: String, + val properties: Map = emptyMap(), + val appKey: String, + val userId: String?, + val platform: String, + val appVersion: String, + val environment: String, + val timestamp: Long = System.currentTimeMillis(), +) diff --git a/sdk-log/src/main/java/com/xuqm/sdk/log/LogLevel.kt b/sdk-log/src/main/java/com/xuqm/sdk/log/LogLevel.kt new file mode 100644 index 0000000..8cfd5c1 --- /dev/null +++ b/sdk-log/src/main/java/com/xuqm/sdk/log/LogLevel.kt @@ -0,0 +1,3 @@ +package com.xuqm.sdk.log + +enum class LogLevel { DEBUG, INFO, WARN, ERROR, NONE } diff --git a/sdk-log/src/main/java/com/xuqm/sdk/log/LogQueue.kt b/sdk-log/src/main/java/com/xuqm/sdk/log/LogQueue.kt new file mode 100644 index 0000000..c278e0b --- /dev/null +++ b/sdk-log/src/main/java/com/xuqm/sdk/log/LogQueue.kt @@ -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() + private val pendingEvents = mutableListOf() + 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 + val events: List + 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() + } + } +} diff --git a/sdk-log/src/main/java/com/xuqm/sdk/log/XLog.kt b/sdk-log/src/main/java/com/xuqm/sdk/log/XLog.kt new file mode 100644 index 0000000..fc83521 --- /dev/null +++ b/sdk-log/src/main/java/com/xuqm/sdk/log/XLog.kt @@ -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 = 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 = 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 = emptyMap()) { + if (logLevel.ordinal > LogLevel.WARN.ordinal) return + captureError(Exception(message), metadata + ("level" to "warn")) + } + + fun info(message: String, metadata: Map = 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) { + 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") +} diff --git a/sdk-log/src/main/java/com/xuqm/sdk/log/gradle/XuqmLogPlugin.kt b/sdk-log/src/main/java/com/xuqm/sdk/log/gradle/XuqmLogPlugin.kt new file mode 100644 index 0000000..f703b00 --- /dev/null +++ b/sdk-log/src/main/java/com/xuqm/sdk/log/gradle/XuqmLogPlugin.kt @@ -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 { + 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) } + } + } +} diff --git a/sdk-log/src/main/java/com/xuqm/sdk/log/gradle/XuqmUploadMappingTask.kt b/sdk-log/src/main/java/com/xuqm/sdk/log/gradle/XuqmUploadMappingTask.kt new file mode 100644 index 0000000..46b3c6a --- /dev/null +++ b/sdk-log/src/main/java/com/xuqm/sdk/log/gradle/XuqmUploadMappingTask.kt @@ -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 + @get:Input abstract val platformUrl: Property + @get:Input abstract val appVersion: Property + @get:Input abstract val platform: Property + @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}" } + } +} diff --git a/sdk-log/src/main/java/com/xuqm/sdk/log/internal/LogStorage.kt b/sdk-log/src/main/java/com/xuqm/sdk/log/internal/LogStorage.kt new file mode 100644 index 0000000..10ef84f --- /dev/null +++ b/sdk-log/src/main/java/com/xuqm/sdk/log/internal/LogStorage.kt @@ -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("") +} diff --git a/sdk-log/src/main/java/com/xuqm/sdk/log/internal/LogUploader.kt b/sdk-log/src/main/java/com/xuqm/sdk/log/internal/LogUploader.kt new file mode 100644 index 0000000..4b5c620 --- /dev/null +++ b/sdk-log/src/main/java/com/xuqm/sdk/log/internal/LogUploader.kt @@ -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) { + 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) { + 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 ?: emptyMap())) + } + + private fun eventToJson(event: LogEvent): JSONObject = JSONObject().apply { + put("name", event.name) + put("properties", JSONObject(event.properties as? Map ?: 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) + } +} diff --git a/sdk-log/src/main/resources/META-INF/gradle-plugins/com.xuqm.log.properties b/sdk-log/src/main/resources/META-INF/gradle-plugins/com.xuqm.log.properties new file mode 100644 index 0000000..38ce18b --- /dev/null +++ b/sdk-log/src/main/resources/META-INF/gradle-plugins/com.xuqm.log.properties @@ -0,0 +1 @@ +implementation-class=com.xuqm.sdk.log.gradle.XuqmLogPlugin diff --git a/sdk-update/build.gradle.kts b/sdk-update/build.gradle.kts index a86e1ee..aa11e7c 100644 --- a/sdk-update/build.gradle.kts +++ b/sdk-update/build.gradle.kts @@ -32,4 +32,5 @@ android { dependencies { api(project(":sdk-core")) implementation(libs.kotlinx.coroutines.android) + implementation("com.google.code.gson:gson:2.10.1") } diff --git a/sdk-update/src/main/java/com/xuqm/sdk/update/UpdateSDK.kt b/sdk-update/src/main/java/com/xuqm/sdk/update/UpdateSDK.kt index 8a6a4a3..5d030e9 100644 --- a/sdk-update/src/main/java/com/xuqm/sdk/update/UpdateSDK.kt +++ b/sdk-update/src/main/java/com/xuqm/sdk/update/UpdateSDK.kt @@ -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) diff --git a/settings.gradle.kts b/settings.gradle.kts index c47b4ed..f746c7d 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -26,4 +26,5 @@ include(":sdk-push") include(":sdk-update") include(":sdk-webview") include(":sdk-license") +include(":sdk-log") include(":sample-app")