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>
这个提交包含在:
父节点
8e0193ceec
当前提交
06f7b9d44d
@ -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 {
|
||||
|
||||
正在加载...
在新工单中引用
屏蔽一个用户