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_UPDATE_VERSION=1.1.6
SDK_WEBVIEW_VERSION=1.1.3 SDK_WEBVIEW_VERSION=1.1.3
SDK_LICENSE_VERSION=1.1.1 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 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.Plugin
import org.gradle.api.Project import org.gradle.api.Project
import java.io.File import java.io.File
import java.text.SimpleDateFormat
import java.util.Base64 import java.util.Base64
import java.util.Date
import java.util.Locale
import javax.crypto.Cipher import javax.crypto.Cipher
import javax.crypto.SecretKeyFactory import javax.crypto.SecretKeyFactory
import javax.crypto.spec.GCMParameterSpec import javax.crypto.spec.GCMParameterSpec
@ -20,15 +26,11 @@ class XuqmBugCollectPlugin : Plugin<Project> {
} }
/** /**
* Read appKey/platformUrl from the SDK Mode A encrypted config file: * Read appKey/platformUrl from a SDK Mode A encrypted config file.
* app/src/main/assets/xuqm/config.xuqm
* Format: XUQM-CONFIG-V1.{salt_b64url}.{iv_b64url}.{ciphertext_b64url} * Format: XUQM-CONFIG-V1.{salt_b64url}.{iv_b64url}.{ciphertext_b64url}
* Decrypted JSON contains: appKey, baseUrl, serverUrl, ...
*/ */
private fun readEncryptedConfigFile(projectDir: File): Pair<String?, String?> { private fun readEncryptedConfigFile(file: File): Pair<String?, String?> {
val f = File(projectDir, "src/main/assets/xuqm/config.xuqm").takeIf { it.exists() } val content = runCatching { file.readText().trim() }.getOrElse { return null to null }
?: return null to null
val content = runCatching { f.readText().trim() }.getOrElse { return null to null }
val parts = content.split(".") val parts = content.split(".")
if (parts.size != 4 || parts[0] != "XUQM-CONFIG-V1") return null to null 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 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) { 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 androidComponents.onVariants { variant ->
val (encAppKey, encPlatformUrl) = readEncryptedConfigFile(target.projectDir) val buildType = androidDsl.buildTypes.findByName(variant.buildType ?: "") ?: return@onVariants
val (fileAppKey, filePlatformUrl) = if (encAppKey != null) encAppKey to encPlatformUrl if (!buildType.isMinifyEnabled) return@onVariants
else readConfigFile(target.rootDir)
@Suppress("DEPRECATION") // Generate a per-build identifier stamped at configuration time.
android.applicationVariants.all { variant -> val buildId = SimpleDateFormat("yyyyMMddHHmmss", Locale.US).format(Date())
if (!variant.buildType.isMinifyEnabled) return@all 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 taskName = "xuqmUploadMapping${variant.name.replaceFirstChar { it.uppercase() }}"
val uploadTask = target.tasks.register(taskName, XuqmUploadMappingTask::class.java) { task -> val uploadTask = target.tasks.register(taskName, XuqmUploadMappingTask::class.java) { task ->
task.group = "xuqm" task.group = "xuqm"
task.description = "Upload ProGuard mapping to BugCollect service (${variant.name})" 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( task.appKey.set(
fileAppKey fileAppKey
?: target.findProperty("XUQM_APP_KEY")?.toString() ?: target.findProperty("XUQM_APP_KEY")?.toString()
@ -90,16 +124,14 @@ class XuqmBugCollectPlugin : Plugin<Project> {
?: target.findProperty("XUQM_PLATFORM_URL")?.toString() ?: target.findProperty("XUQM_PLATFORM_URL")?.toString()
?: "https://dev.xuqinmin.com" ?: "https://dev.xuqinmin.com"
) )
task.appVersion.set(variant.versionName) task.appVersion.set(versionName)
task.buildId.set(buildId)
task.platform.set("android") task.platform.set("android")
task.mappingFile.set( task.mappingFile.set(variant.artifacts.get(SingleArtifact.OBFUSCATION_MAPPING_FILE))
variant.mappingFileProvider.map { files ->
target.layout.projectDirectory.file(files.singleFile.absolutePath)
}
)
} }
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 appKey: Property<String>
@get:Input abstract val platformUrl: Property<String> @get:Input abstract val platformUrl: Property<String>
@get:Input abstract val appVersion: 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:Input abstract val platform: Property<String>
@get:InputFile @get:Optional abstract val mappingFile: RegularFileProperty @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 } if (!file.exists()) { logger.warn("[XuqmLog] Mapping file not found: ${file.path}"); return }
val configUrl = "${platformUrl.get().trimEnd('/')}/api/sdk/config?appKey=$key" 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()) if (config.bugCollectEnabled != true) {
logger.lifecycle("[XuqmLog] Mapping uploaded: ${file.name} (android v${appVersion.get()})") logger.lifecycle("[XuqmLog] BugCollect not enabled for appKey=$key, skip sourcemap upload")
return
}
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 fun fetchLogApiUrl(url: String): String? = runCatching { private data class SdkConfig(val bugCollectEnabled: Boolean?, val bugCollectApiUrl: String?)
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) { 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()
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, buildId: String?) {
val boundary = "XuqmBoundary${System.currentTimeMillis()}" val boundary = "XuqmBoundary${System.currentTimeMillis()}"
val conn = URL(url).openConnection() as HttpURLConnection val conn = URL(url).openConnection() as HttpURLConnection
conn.requestMethod = "POST" conn.requestMethod = "POST"
@ -48,7 +72,9 @@ abstract class XuqmUploadMappingTask : DefaultTask() {
fun field(name: String, value: String) = fun field(name: String, value: String) =
out.write("--$boundary\r\nContent-Disposition: form-data; name=\"$name\"\r\n\r\n$value\r\n".toByteArray()) 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) 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(file.readBytes())
out.write("\r\n--$boundary--\r\n".toByteArray()) out.write("\r\n--$boundary--\r\n".toByteArray())
} }

查看文件

@ -80,6 +80,7 @@ object BugCollect {
), ),
breadcrumbs = emptyList(), breadcrumbs = emptyList(),
release = appVersion(), release = appVersion(),
buildId = buildId(),
environment = environment, environment = environment,
userId = "unknown", userId = "unknown",
user = IssueEvent.UserInfo(id = "unknown"), user = IssueEvent.UserInfo(id = "unknown"),
@ -108,6 +109,7 @@ object BugCollect {
), ),
breadcrumbs = crumbs, breadcrumbs = crumbs,
release = appVersion(), release = appVersion(),
buildId = buildId(),
environment = environment, environment = environment,
userId = XuqmSDK.getUserId() ?: XuqmSDK.deviceId, userId = XuqmSDK.getUserId() ?: XuqmSDK.deviceId,
user = IssueEvent.UserInfo(id = XuqmSDK.getUserId() ?: XuqmSDK.deviceId), user = IssueEvent.UserInfo(id = XuqmSDK.getUserId() ?: XuqmSDK.deviceId),
@ -142,6 +144,7 @@ object BugCollect {
), ),
breadcrumbs = crumbs, breadcrumbs = crumbs,
release = appVersion(), release = appVersion(),
buildId = buildId(),
environment = environment, environment = environment,
userId = XuqmSDK.getUserId() ?: XuqmSDK.deviceId, userId = XuqmSDK.getUserId() ?: XuqmSDK.deviceId,
user = IssueEvent.UserInfo(id = XuqmSDK.getUserId() ?: XuqmSDK.deviceId), user = IssueEvent.UserInfo(id = XuqmSDK.getUserId() ?: XuqmSDK.deviceId),
@ -160,6 +163,7 @@ object BugCollect {
appKey = XuqmSDK.appKey, appKey = XuqmSDK.appKey,
exception = IssueEvent.ExceptionInfo(type = "Warning", value = message), exception = IssueEvent.ExceptionInfo(type = "Warning", value = message),
release = appVersion(), release = appVersion(),
buildId = buildId(),
environment = environment, environment = environment,
userId = XuqmSDK.getUserId() ?: XuqmSDK.deviceId, userId = XuqmSDK.getUserId() ?: XuqmSDK.deviceId,
user = IssueEvent.UserInfo(id = 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" ctx.packageManager.getPackageInfo(ctx.packageName, 0).versionName ?: "unknown"
}.getOrDefault("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 { private fun buildDeviceInfo(): IssueEvent.DeviceInfo {
if (!XuqmSDK.isInitialized()) { if (!XuqmSDK.isInitialized()) {
return IssueEvent.DeviceInfo( return IssueEvent.DeviceInfo(

查看文件

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

查看文件

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