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_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 {
|
||||||
|
|||||||
正在加载...
在新工单中引用
屏蔽一个用户