package com.xuqm.sdk.bugcollect import android.app.ActivityManager import android.content.Context import android.content.pm.ApplicationInfo import android.os.Build import com.xuqm.sdk.XuqmSDK import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.delay import kotlinx.coroutines.launch import java.util.LinkedList object BugCollect { private var logLevel: LogLevel = LogLevel.WARN private var environment: String = "production" @Volatile private var queue: LogQueue? = null @Volatile private var crashCaptureStarted = false private val breadcrumbBuffer: LinkedList = LinkedList() private val breadcrumbLock = Any() // SDK 未就绪时暂存的错误事件,就绪后自动 flush private val pendingErrors = LinkedList() private val pendingErrorsLock = Any() private const val MAX_BREADCRUMBS = 50 private const val SDK_NAME = "bugcollect.android" private const val SDK_VERSION = "1.1.0" fun setLogLevel(level: LogLevel) { logLevel = level } fun setEnvironment(env: String) { environment = env } fun addBreadcrumb( category: String, message: String, level: String = "info", data: Map = emptyMap(), ) { synchronized(breadcrumbLock) { if (breadcrumbBuffer.size >= MAX_BREADCRUMBS) breadcrumbBuffer.removeFirst() breadcrumbBuffer.addLast(Breadcrumb( timestamp = System.currentTimeMillis(), category = category, message = message, level = level, data = data, )) } } fun event(name: String, properties: Map = emptyMap()) { if (!isReady()) return queue().push(LogEvent( name = name, properties = properties, appKey = XuqmSDK.appKey, userId = XuqmSDK.getUserId() ?: XuqmSDK.deviceId, platform = "android", release = appVersion(), environment = environment, sdk = IssueEvent.SdkInfo(SDK_NAME, SDK_VERSION), )) FunnelTracker.track(name, properties) } fun captureError(error: Throwable, tags: Map = emptyMap(), fingerprint: String? = null) { // SDK 未初始化时,将错误加入待处理队列 if (!XuqmSDK.isInitialized()) { val event = IssueEvent( level = "error", platform = "android", fingerprint = fingerprint ?: Fingerprint.compute( error.javaClass.simpleName, error.message ?: error.javaClass.name, error.stackTraceToString(), ), appKey = "pending", exception = IssueEvent.ExceptionInfo( type = error.javaClass.simpleName, value = error.message ?: error.javaClass.name, stacktrace = error.stackTraceToString(), ), breadcrumbs = emptyList(), release = appVersion(), environment = environment, userId = "unknown", user = IssueEvent.UserInfo(id = "unknown"), device = buildDeviceInfo(), tags = tags, ) synchronized(pendingErrorsLock) { pendingErrors.add(event) } return } val crumbs = synchronized(breadcrumbLock) { breadcrumbBuffer.toList() } val fp = fingerprint ?: Fingerprint.compute( error.javaClass.simpleName, error.message ?: error.javaClass.name, error.stackTraceToString(), ) val event = IssueEvent( level = "error", platform = "android", fingerprint = fp, appKey = XuqmSDK.appKey, exception = IssueEvent.ExceptionInfo( type = error.javaClass.simpleName, value = error.message ?: error.javaClass.name, stacktrace = error.stackTraceToString(), ), breadcrumbs = crumbs, release = appVersion(), environment = environment, userId = XuqmSDK.getUserId() ?: XuqmSDK.deviceId, user = IssueEvent.UserInfo(id = XuqmSDK.getUserId() ?: XuqmSDK.deviceId), device = buildDeviceInfo(), tags = tags, ) if (!isReady()) { synchronized(pendingErrorsLock) { pendingErrors.add(event) } return } flushPendingErrors() queue().push(event) } fun captureCrash(error: Throwable, tags: Map = emptyMap()) { // SDK 未初始化时直接返回,崩溃事件无法缓存 if (!XuqmSDK.isInitialized() || !isReady()) return val crumbs = synchronized(breadcrumbLock) { breadcrumbBuffer.toList() } queue().push(IssueEvent( level = "fatal", platform = "android", fingerprint = Fingerprint.compute( error.javaClass.simpleName, error.message ?: error.javaClass.name, error.stackTraceToString(), ), appKey = XuqmSDK.appKey, exception = IssueEvent.ExceptionInfo( type = error.javaClass.simpleName, value = error.message ?: error.javaClass.name, stacktrace = error.stackTraceToString(), ), breadcrumbs = crumbs, release = appVersion(), environment = environment, userId = XuqmSDK.getUserId() ?: XuqmSDK.deviceId, user = IssueEvent.UserInfo(id = XuqmSDK.getUserId() ?: XuqmSDK.deviceId), device = buildDeviceInfo(), tags = tags, )) } fun warn(message: String, tags: Map = emptyMap()) { if (logLevel.ordinal > LogLevel.WARN.ordinal) return if (!isReady()) return queue().push(IssueEvent( level = "warning", platform = "android", fingerprint = Fingerprint.compute("Warning", message, ""), appKey = XuqmSDK.appKey, exception = IssueEvent.ExceptionInfo(type = "Warning", value = message), release = appVersion(), environment = environment, userId = XuqmSDK.getUserId() ?: XuqmSDK.deviceId, user = IssueEvent.UserInfo(id = XuqmSDK.getUserId() ?: XuqmSDK.deviceId), tags = tags, )) } fun info(message: String, tags: Map = emptyMap()) { if (logLevel.ordinal > LogLevel.INFO.ordinal) return event("__log_info", tags + ("message" to message)) } /** * 注册全局崩溃拦截器(幂等)。 * 通常由 BugCollectInitProvider 在 app 启动时自动调用,无需 app 代码主动触发。 * 如需在特定时机手动注册,调用此方法也是安全的。 */ fun startCrashCapture() { if (crashCaptureStarted) return if (!XuqmSDK.isInitialized()) return crashCaptureStarted = true CrashCapture.start( appContext = XuqmSDK.appContext, appKey = XuqmSDK.appKey, getUserId = { XuqmSDK.getUserId() ?: XuqmSDK.deviceId }, ) flushPendingErrors() queue().uploadPendingCrashes() // 平台配置可能在初始化之后才下发,延迟重试 flush GlobalScope.launch(Dispatchers.IO) { for (i in 1..10) { delay(2000) if (!XuqmSDK.bugCollectApiUrl.isNullOrBlank()) { flushPendingErrors() queue().uploadPendingCrashes() break } } } } /** 将 SDK 未就绪时暂存的错误事件 flush 到队列。 */ private fun flushPendingErrors() { val events = synchronized(pendingErrorsLock) { if (pendingErrors.isEmpty()) return val list = pendingErrors.toList() pendingErrors.clear() list } events.forEach { queue().push(it) } } /** 上传上次崩溃落盘的文件(远端配置就绪后由 BugCollectInitProvider 自动调用)。 */ fun uploadPendingCrashes() { if (isReady()) queue().uploadPendingCrashes() } fun defineFunnel(id: String, steps: List) { FunnelTracker.define(id, steps) } private fun isReady() = XuqmSDK.isInitialized() && XuqmSDK.bugCollectEnabled private fun queue(): LogQueue { return queue ?: synchronized(this) { queue ?: LogQueue( appKey = XuqmSDK.appKey, appContext = XuqmSDK.appContext, ).also { queue = it } } } private fun appVersion(): String = runCatching { if (!XuqmSDK.isInitialized()) return "unknown" val ctx = XuqmSDK.appContext ctx.packageManager.getPackageInfo(ctx.packageName, 0).versionName ?: "unknown" }.getOrDefault("unknown") private fun buildDeviceInfo(): IssueEvent.DeviceInfo { if (!XuqmSDK.isInitialized()) { return IssueEvent.DeviceInfo( model = Build.MODEL, manufacturer = Build.MANUFACTURER, osName = "Android", osVersion = Build.VERSION.RELEASE, locale = java.util.Locale.getDefault().toString(), timezone = java.util.TimeZone.getDefault().id, isEmulator = false, freeMemoryMb = 0, buildType = "unknown", ) } val ctx = XuqmSDK.appContext val actMgr = ctx.getSystemService(Context.ACTIVITY_SERVICE) as? ActivityManager val memInfo = ActivityManager.MemoryInfo().also { actMgr?.getMemoryInfo(it) } val freeMemMb = (memInfo.availMem / (1024L * 1024L)).toInt() val isDebug = ctx.applicationInfo.flags and ApplicationInfo.FLAG_DEBUGGABLE != 0 val isEmulator = Build.FINGERPRINT.startsWith("generic") || Build.FINGERPRINT.startsWith("unknown") || Build.MODEL.contains("Emulator", ignoreCase = true) || Build.MODEL.contains("Android SDK", ignoreCase = true) return IssueEvent.DeviceInfo( model = Build.MODEL, manufacturer = Build.MANUFACTURER, osName = "Android", osVersion = Build.VERSION.RELEASE, locale = java.util.Locale.getDefault().toString(), timezone = java.util.TimeZone.getDefault().id, isEmulator = isEmulator, freeMemoryMb = freeMemMb, buildType = if (isDebug) "debug" else "release", ) } }