XuqmGroup-AndroidSDK/sdk-bugcollect/src/main/java/com/xuqm/sdk/bugcollect/LogQueue.kt
XuqmGroup 4896f24af8 feat(bugcollect): SDK v1.1.0 — 修复 packageName 缺失导致 captureError 静默失败
Root cause: SdkPlatformConfigApi.fetchConfig 未传 packageName → 服务端返回400
→ bugCollectEnabled=false → captureError 是空操作

修复内容:
- SdkPlatformConfigApi: 增加 packageName 查询参数
- SdkPlatformConfig: 修正字段名 bugCollectApiUrl/features.bugCollect
- XuqmSDK: 传入 appContext.packageName,读取 features?.bugCollect
- Fingerprint: 使用 exceptionType(类名)替代 level 字符串,避免同一崩溃按级别分桶
- IssueEvent: 增加 eventId、breadcrumbs、DeviceInfo 扩展字段
- BugCollect: 增加 addBreadcrumb(),captureError/captureCrash 附加面包屑和设备信息
- LogUploader: 序列化 eventId/breadcrumbs/expanded device
- LogQueue: 崩溃恢复使用 exceptionType 重建 fingerprint

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-17 15:30:40 +08:00

189 行
7.6 KiB
Kotlin

package com.xuqm.sdk.bugcollect
import android.content.Context
import android.util.Log
import com.xuqm.sdk.bugcollect.internal.LogUploader
import com.xuqm.sdk.bugcollect.internal.LogStorage
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import org.json.JSONArray
import org.json.JSONObject
import java.io.File
/** [JSONObject.optString] returns non-null on newer Android; this returns null when the key is absent or the value is null. */
private fun JSONObject.optNullableString(key: String): String? =
if (has(key) && !isNull(key)) optString(key) else null
internal class LogQueue(
private val logApiUrl: String,
private val appKey: String,
private val appContext: Context,
) {
companion object {
private const val TAG = "BugCollect"
private const val MAX_QUEUE_SIZE = 500
private const val BATCH_SIZE = 30
private const val FLUSH_INTERVAL_MS = 10_000L
private const val PREFS_NAME = "xuqm_log_queue"
private const val KEY_ISSUES = "issues"
private const val KEY_EVENTS = "events"
}
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private val pendingIssues = mutableListOf<IssueEvent>()
private val pendingEvents = mutableListOf<LogEvent>()
private val lock = Any()
init {
loadFromDisk()
scope.launch {
while (true) {
delay(FLUSH_INTERVAL_MS)
flush()
}
}
}
fun push(event: Any) {
synchronized(lock) {
when (event) {
is IssueEvent -> {
if (pendingIssues.size >= MAX_QUEUE_SIZE) pendingIssues.removeAt(0)
pendingIssues.add(event)
}
is LogEvent -> {
if (pendingEvents.size >= MAX_QUEUE_SIZE) pendingEvents.removeAt(0)
pendingEvents.add(event)
}
}
}
if (shouldFlush()) scope.launch { flush() }
}
fun uploadPendingCrashes() {
if (logApiUrl.isBlank()) return
scope.launch {
val crashDir = LogStorage.crashDir(appContext)
val files = crashDir.listFiles() ?: return@launch
for (file in files) {
runCatching {
val json = file.readText()
val obj = JSONObject(json)
val msg = obj.optString("message", "")
val stk = obj.optString("stack", "")
val exType = obj.optString("exceptionType", "Exception")
val event = IssueEvent(
level = "fatal",
platform = "android",
fingerprint = Fingerprint.compute(exType, msg, stk),
appKey = obj.optString("appKey", appKey),
timestamp = obj.optLong("timestamp", System.currentTimeMillis()),
exception = IssueEvent.ExceptionInfo(
type = exType,
value = msg,
stacktrace = stk,
),
release = runCatching {
appContext.packageManager.getPackageInfo(appContext.packageName, 0).versionName ?: "unknown"
}.getOrDefault("unknown"),
environment = "production",
userId = obj.optNullableString("userId"),
user = IssueEvent.UserInfo(id = obj.optNullableString("userId")),
sdk = IssueEvent.SdkInfo("bugcollect.android", "1.0.0"),
)
LogUploader.uploadIssue(logApiUrl, event)
file.delete()
}.onFailure { e ->
Log.e(TAG, "Failed to upload crash: ${file.name}", e)
}
}
}
}
private fun shouldFlush(): Boolean = synchronized(lock) {
pendingIssues.size >= BATCH_SIZE || pendingEvents.size >= BATCH_SIZE
}
private fun flush() {
val issues: List<IssueEvent>
val events: List<LogEvent>
synchronized(lock) {
if (pendingIssues.isEmpty() && pendingEvents.isEmpty()) return
issues = pendingIssues.toList()
events = pendingEvents.toList()
pendingIssues.clear()
pendingEvents.clear()
}
if (issues.isNotEmpty()) {
scope.launch {
runCatching { LogUploader.uploadIssues(logApiUrl, issues) }
.onFailure { e ->
Log.e(TAG, "Failed to upload issues batch, re-queuing", e)
synchronized(lock) {
val remaining = MAX_QUEUE_SIZE - pendingIssues.size
if (remaining > 0) pendingIssues.addAll(0, issues.take(remaining))
}
}
}
}
if (events.isNotEmpty()) {
scope.launch {
runCatching { LogUploader.uploadEvents(logApiUrl, events) }
.onFailure { e ->
Log.e(TAG, "Failed to upload events batch, re-queuing", e)
synchronized(lock) {
val remaining = MAX_QUEUE_SIZE - pendingEvents.size
if (remaining > 0) pendingEvents.addAll(0, events.take(remaining))
}
}
}
}
}
private fun loadFromDisk() {
runCatching {
val prefs = appContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
prefs.getString(KEY_ISSUES, null)?.let { json ->
val arr = JSONArray(json)
synchronized(lock) {
for (i in 0 until arr.length()) {
val obj = arr.getJSONObject(i)
pendingIssues.add(IssueEvent(
level = obj.optString("level", "error"),
platform = obj.optString("platform", "android"),
fingerprint = obj.optString("fingerprint", ""),
appKey = obj.optString("appKey", appKey),
timestamp = obj.optLong("timestamp", 0),
release = obj.optString("release", ""),
environment = obj.optString("environment", "production"),
userId = obj.optNullableString("userId"),
))
}
}
}
prefs.getString(KEY_EVENTS, null)?.let { json ->
val arr = JSONArray(json)
synchronized(lock) {
for (i in 0 until arr.length()) {
val obj = arr.getJSONObject(i)
pendingEvents.add(LogEvent(
name = obj.optString("name", ""),
appKey = obj.optString("appKey", appKey),
userId = obj.optNullableString("userId"),
platform = obj.optString("platform", "android"),
release = obj.optString("release", ""),
environment = obj.optString("environment", "production"),
timestamp = obj.optLong("timestamp", 0),
))
}
}
}
prefs.edit().remove(KEY_ISSUES).remove(KEY_EVENTS).apply()
}
}
}