diff --git a/gradle.properties b/gradle.properties index 51d4042..49e4e6c 100644 --- a/gradle.properties +++ b/gradle.properties @@ -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 diff --git a/sdk-bugcollect-plugin/src/main/kotlin/com/xuqm/sdk/bugcollect/gradle/XuqmBugCollectPlugin.kt b/sdk-bugcollect-plugin/src/main/kotlin/com/xuqm/sdk/bugcollect/gradle/XuqmBugCollectPlugin.kt index 1fb23ee..1980ac8 100644 --- a/sdk-bugcollect-plugin/src/main/kotlin/com/xuqm/sdk/bugcollect/gradle/XuqmBugCollectPlugin.kt +++ b/sdk-bugcollect-plugin/src/main/kotlin/com/xuqm/sdk/bugcollect/gradle/XuqmBugCollectPlugin.kt @@ -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 { } /** - * 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 { - 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 { + 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 { 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 { ?: 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) } } } } diff --git a/sdk-bugcollect-plugin/src/main/kotlin/com/xuqm/sdk/bugcollect/gradle/XuqmUploadMappingTask.kt b/sdk-bugcollect-plugin/src/main/kotlin/com/xuqm/sdk/bugcollect/gradle/XuqmUploadMappingTask.kt index 7f6f9dd..9ed8f9f 100644 --- a/sdk-bugcollect-plugin/src/main/kotlin/com/xuqm/sdk/bugcollect/gradle/XuqmUploadMappingTask.kt +++ b/sdk-bugcollect-plugin/src/main/kotlin/com/xuqm/sdk/bugcollect/gradle/XuqmUploadMappingTask.kt @@ -15,6 +15,7 @@ abstract class XuqmUploadMappingTask : DefaultTask() { @get:Input abstract val appKey: Property @get:Input abstract val platformUrl: Property @get:Input abstract val appVersion: Property + @get:Input @get:Optional abstract val buildId: Property @get:Input abstract val platform: Property @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 + } + + 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 { - 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 data class SdkConfig(val bugCollectEnabled: Boolean?, val bugCollectApiUrl: String?) - 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 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()) } diff --git a/sdk-bugcollect/src/main/java/com/xuqm/sdk/bugcollect/BugCollect.kt b/sdk-bugcollect/src/main/java/com/xuqm/sdk/bugcollect/BugCollect.kt index dd98d2b..dae2345 100644 --- a/sdk-bugcollect/src/main/java/com/xuqm/sdk/bugcollect/BugCollect.kt +++ b/sdk-bugcollect/src/main/java/com/xuqm/sdk/bugcollect/BugCollect.kt @@ -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( diff --git a/sdk-bugcollect/src/main/java/com/xuqm/sdk/bugcollect/IssueEvent.kt b/sdk-bugcollect/src/main/java/com/xuqm/sdk/bugcollect/IssueEvent.kt index 0e6de1b..7751086 100644 --- a/sdk-bugcollect/src/main/java/com/xuqm/sdk/bugcollect/IssueEvent.kt +++ b/sdk-bugcollect/src/main/java/com/xuqm/sdk/bugcollect/IssueEvent.kt @@ -10,6 +10,7 @@ data class IssueEvent( val exception: ExceptionInfo? = null, val breadcrumbs: List = emptyList(), val release: String, + val buildId: String? = null, val environment: String = "production", val userId: String? = null, val sessionId: String? = null, diff --git a/sdk-bugcollect/src/main/java/com/xuqm/sdk/bugcollect/internal/LogUploader.kt b/sdk-bugcollect/src/main/java/com/xuqm/sdk/bugcollect/internal/LogUploader.kt index 150277a..3ffe86f 100644 --- a/sdk-bugcollect/src/main/java/com/xuqm/sdk/bugcollect/internal/LogUploader.kt +++ b/sdk-bugcollect/src/main/java/com/xuqm/sdk/bugcollect/internal/LogUploader.kt @@ -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 {