feat(bugcollect): 为每次打包生成独立 buildId,支持相同版本多包溯源

- Gradle 插件在配置阶段生成 yyyyMMddHHmmss 格式的 buildId,通过
  BuildConfig.XUQM_BUILD_ID 注入 APK,并作为 buildId 字段上传 sourcemap
- SDK 通过反射读取 BuildConfig.XUQM_BUILD_ID,在所有事件
  (captureError/captureCrash/warn) 的 IssueEvent 中携带 buildId
- LogUploader 序列化时将 buildId 写入上报 JSON
- IssueEvent 新增 buildId 可选字段

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
这个提交包含在:
XuqmGroup 2026-06-22 16:46:41 +08:00
父节点 8e0193ceec
当前提交 06f7b9d44d
共有 6 个文件被更改,包括 112 次插入37 次删除

查看文件

@ -9,4 +9,4 @@ SDK_PUSH_VERSION=1.1.1
SDK_UPDATE_VERSION=1.1.6
SDK_WEBVIEW_VERSION=1.1.3
SDK_LICENSE_VERSION=1.1.1
SDK_BUGCOLLECT_VERSION=1.0.6
SDK_BUGCOLLECT_VERSION=1.0.7-SNAPSHOT

查看文件

@ -1,10 +1,16 @@
package com.xuqm.sdk.bugcollect.gradle
import com.android.build.gradle.AppExtension
import com.android.build.api.artifact.SingleArtifact
import com.android.build.api.dsl.ApplicationExtension
import com.android.build.api.variant.ApplicationAndroidComponentsExtension
import com.android.build.api.variant.BuildConfigField
import org.gradle.api.Plugin
import org.gradle.api.Project
import java.io.File
import java.text.SimpleDateFormat
import java.util.Base64
import java.util.Date
import java.util.Locale
import javax.crypto.Cipher
import javax.crypto.SecretKeyFactory
import javax.crypto.spec.GCMParameterSpec
@ -20,15 +26,11 @@ class XuqmBugCollectPlugin : Plugin<Project> {
}
/**
* Read appKey/platformUrl from the SDK Mode A encrypted config file:
* app/src/main/assets/xuqm/config.xuqm
* Read appKey/platformUrl from a SDK Mode A encrypted config file.
* Format: XUQM-CONFIG-V1.{salt_b64url}.{iv_b64url}.{ciphertext_b64url}
* Decrypted JSON contains: appKey, baseUrl, serverUrl, ...
*/
private fun readEncryptedConfigFile(projectDir: File): Pair<String?, String?> {
val f = File(projectDir, "src/main/assets/xuqm/config.xuqm").takeIf { it.exists() }
?: return null to null
val content = runCatching { f.readText().trim() }.getOrElse { return null to null }
private fun readEncryptedConfigFile(file: File): Pair<String?, String?> {
val content = runCatching { file.readText().trim() }.getOrElse { return null to null }
val parts = content.split(".")
if (parts.size != 4 || parts[0] != "XUQM-CONFIG-V1") return null to null
@ -62,24 +64,56 @@ class XuqmBugCollectPlugin : Plugin<Project> {
return appKey to platformUrl
}
/**
* Search flavor-specific and main asset source sets for a xuqm config file.
* Accepts config.xuqm or any *.xuqmconfig file under assets/xuqm/.
*/
private fun findVariantConfigFile(projectDir: File, flavorName: String?, buildType: String?): File? {
val candidates = buildList {
flavorName?.takeIf { it.isNotEmpty() }?.let { add(File(projectDir, "src/$it/assets")) }
buildType?.takeIf { it.isNotEmpty() }?.let { add(File(projectDir, "src/$it/assets")) }
add(File(projectDir, "src/main/assets"))
}
for (assetsDir in candidates) {
val xuqmDir = File(assetsDir, "xuqm")
if (!xuqmDir.isDirectory) continue
val configFile = File(xuqmDir, "config.xuqm").takeIf { it.exists() }
?: xuqmDir.listFiles { f -> f.extension == "xuqmconfig" }?.firstOrNull()
if (configFile != null) return configFile
}
return null
}
override fun apply(target: Project) {
val android = target.extensions.findByType(AppExtension::class.java) ?: return
val androidComponents = target.extensions.findByType(ApplicationAndroidComponentsExtension::class.java)
?: return
val androidDsl = target.extensions.findByType(ApplicationExtension::class.java)
?: return
// Priority: config.xuqm (Mode A encrypted) > xuqm.config.json > gradle.properties
val (encAppKey, encPlatformUrl) = readEncryptedConfigFile(target.projectDir)
val (fileAppKey, filePlatformUrl) = if (encAppKey != null) encAppKey to encPlatformUrl
else readConfigFile(target.rootDir)
androidComponents.onVariants { variant ->
val buildType = androidDsl.buildTypes.findByName(variant.buildType ?: "") ?: return@onVariants
if (!buildType.isMinifyEnabled) return@onVariants
@Suppress("DEPRECATION")
android.applicationVariants.all { variant ->
if (!variant.buildType.isMinifyEnabled) return@all
// Generate a per-build identifier stamped at configuration time.
val buildId = SimpleDateFormat("yyyyMMddHHmmss", Locale.US).format(Date())
variant.buildConfigFields?.put(
"XUQM_BUILD_ID",
BuildConfigField("String", "\"$buildId\"", "XuqmGroup BugCollect build ID")
)
val configFile = findVariantConfigFile(target.projectDir, variant.flavorName, variant.buildType)
val (fileAppKey, filePlatformUrl) = when {
configFile != null -> readEncryptedConfigFile(configFile)
else -> readConfigFile(target.rootDir)
}
val productFlavor = androidDsl.productFlavors.findByName(variant.flavorName ?: "")
val versionName = productFlavor?.versionName ?: androidDsl.defaultConfig.versionName ?: ""
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 BugCollect service (${variant.name})"
// Priority: config.xuqm > xuqm.config.json > gradle.properties XUQM_APP_KEY > gradle.properties xuqm.appKey
task.appKey.set(
fileAppKey
?: target.findProperty("XUQM_APP_KEY")?.toString()
@ -90,16 +124,14 @@ class XuqmBugCollectPlugin : Plugin<Project> {
?: target.findProperty("XUQM_PLATFORM_URL")?.toString()
?: "https://dev.xuqinmin.com"
)
task.appVersion.set(variant.versionName)
task.appVersion.set(versionName)
task.buildId.set(buildId)
task.platform.set("android")
task.mappingFile.set(
variant.mappingFileProvider.map { files ->
target.layout.projectDirectory.file(files.singleFile.absolutePath)
}
)
task.mappingFile.set(variant.artifacts.get(SingleArtifact.OBFUSCATION_MAPPING_FILE))
}
variant.assembleProvider.configure { it.finalizedBy(uploadTask) }
val assembleTaskName = "assemble${variant.name.replaceFirstChar { it.uppercase() }}"
target.tasks.matching { it.name == assembleTaskName }.configureEach { it.finalizedBy(uploadTask) }
}
}
}

查看文件

@ -15,6 +15,7 @@ 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 @get:Optional abstract val buildId: Property<String>
@get:Input abstract val platform: Property<String>
@get:InputFile @get:Optional abstract val mappingFile: RegularFileProperty
@ -25,20 +26,43 @@ abstract class XuqmUploadMappingTask : DefaultTask() {
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 }
val config = fetchConfig(configUrl) ?: run { logger.warn("[XuqmLog] Skip sourcemap upload: SDK config unavailable"); return }
uploadFile("$logApiUrl/bugcollect/v1/sourcemaps/upload", key, file, appVersion.get(), platform.get())
logger.lifecycle("[XuqmLog] Mapping uploaded: ${file.name} (android v${appVersion.get()})")
if (config.bugCollectEnabled != true) {
logger.lifecycle("[XuqmLog] BugCollect not enabled for appKey=$key, skip sourcemap upload")
return
}
private fun fetchLogApiUrl(url: String): String? = runCatching {
val bugCollectApiUrl = config.bugCollectApiUrl ?: run { logger.warn("[XuqmLog] bugCollectApiUrl missing in config, skip upload"); return }
val uploadUrl = "${bugCollectApiUrl.trimEnd('/')}/bugcollect/v1/sourcemaps/upload"
val bid = buildId.orNull
uploadFile(uploadUrl, key, file, appVersion.get(), platform.get(), bid)
logger.lifecycle("[XuqmLog] Mapping uploaded: ${file.name} (android v${appVersion.get()} buildId=$bid)")
}
private data class SdkConfig(val bugCollectEnabled: Boolean?, val bugCollectApiUrl: String?)
private fun fetchConfig(url: String): SdkConfig? {
return runCatching {
val conn = URL(url).openConnection() as HttpURLConnection
conn.requestMethod = "GET"
val code = conn.responseCode
if (code !in 200..299) {
val err = conn.errorStream?.bufferedReader()?.readText() ?: ""
logger.warn("[XuqmLog] Config request failed HTTP $code$err")
return null
}
val body = conn.inputStream.bufferedReader().readText()
Regex(""""logApiUrl"\s*:\s*"([^"]+)"""").find(body)?.groupValues?.get(1)
}.getOrNull()
val bugCollectEnabled = Regex(""""bugCollect"\s*:\s*(true|false)""").find(body)?.groupValues?.get(1)?.toBoolean()
val bugCollectApiUrl = Regex(""""bugCollectApiUrl"\s*:\s*"([^"]+)"""").find(body)?.groupValues?.get(1)
SdkConfig(bugCollectEnabled, bugCollectApiUrl)
}.getOrElse { e ->
logger.warn("[XuqmLog] Cannot fetch SDK config: ${e.message}")
null
}
}
private fun uploadFile(url: String, appKey: String, file: File, version: String, platform: String) {
private fun uploadFile(url: String, appKey: String, file: File, version: String, platform: String, buildId: String?) {
val boundary = "XuqmBoundary${System.currentTimeMillis()}"
val conn = URL(url).openConnection() as HttpURLConnection
conn.requestMethod = "POST"
@ -48,7 +72,9 @@ abstract class XuqmUploadMappingTask : DefaultTask() {
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())
if (!buildId.isNullOrEmpty()) field("buildId", buildId)
val uploadName = if (file.name.endsWith(".map")) file.name else "${file.nameWithoutExtension}.map"
out.write("--$boundary\r\nContent-Disposition: form-data; name=\"file\"; filename=\"$uploadName\"\r\nContent-Type: text/plain\r\n\r\n".toByteArray())
out.write(file.readBytes())
out.write("\r\n--$boundary--\r\n".toByteArray())
}

查看文件

@ -80,6 +80,7 @@ object BugCollect {
),
breadcrumbs = emptyList(),
release = appVersion(),
buildId = buildId(),
environment = environment,
userId = "unknown",
user = IssueEvent.UserInfo(id = "unknown"),
@ -108,6 +109,7 @@ object BugCollect {
),
breadcrumbs = crumbs,
release = appVersion(),
buildId = buildId(),
environment = environment,
userId = XuqmSDK.getUserId() ?: XuqmSDK.deviceId,
user = IssueEvent.UserInfo(id = XuqmSDK.getUserId() ?: XuqmSDK.deviceId),
@ -142,6 +144,7 @@ object BugCollect {
),
breadcrumbs = crumbs,
release = appVersion(),
buildId = buildId(),
environment = environment,
userId = XuqmSDK.getUserId() ?: XuqmSDK.deviceId,
user = IssueEvent.UserInfo(id = XuqmSDK.getUserId() ?: XuqmSDK.deviceId),
@ -160,6 +163,7 @@ object BugCollect {
appKey = XuqmSDK.appKey,
exception = IssueEvent.ExceptionInfo(type = "Warning", value = message),
release = appVersion(),
buildId = buildId(),
environment = environment,
userId = XuqmSDK.getUserId() ?: XuqmSDK.deviceId,
user = IssueEvent.UserInfo(id = XuqmSDK.getUserId() ?: XuqmSDK.deviceId),
@ -238,6 +242,17 @@ object BugCollect {
ctx.packageManager.getPackageInfo(ctx.packageName, 0).versionName ?: "unknown"
}.getOrDefault("unknown")
/**
* Read the build ID injected by the XuqmGroup BugCollect Gradle plugin.
* The plugin injects BuildConfig.XUQM_BUILD_ID (format yyyyMMddHHmmss) into the app at compile time.
* We access it via reflection so the SDK doesn't depend on the app's BuildConfig class directly.
*/
private fun buildId(): String? = runCatching {
if (!XuqmSDK.isInitialized()) return null
val pkg = XuqmSDK.appContext.packageName
Class.forName("$pkg.BuildConfig").getField("XUQM_BUILD_ID").get(null) as? String
}.getOrNull()
private fun buildDeviceInfo(): IssueEvent.DeviceInfo {
if (!XuqmSDK.isInitialized()) {
return IssueEvent.DeviceInfo(

查看文件

@ -10,6 +10,7 @@ data class IssueEvent(
val exception: ExceptionInfo? = null,
val breadcrumbs: List<Breadcrumb> = emptyList(),
val release: String,
val buildId: String? = null,
val environment: String = "production",
val userId: String? = null,
val sessionId: String? = null,

查看文件

@ -97,6 +97,7 @@ internal object LogUploader {
put("fingerprint", issue.fingerprint)
put("timestamp", issue.timestamp)
put("release", issue.release)
issue.buildId?.let { put("buildId", it) }
put("environment", issue.environment)
if (issue.exception != null) {
put("exception", JSONObject().apply {