package com.xuqm.sdk.bugcollect import android.content.Context import android.util.Log import com.google.gson.Gson import com.google.gson.reflect.TypeToken import com.xuqm.sdk.XuqmSDK import com.xuqm.sdk.bugcollect.internal.LogStorage import com.xuqm.sdk.bugcollect.internal.LogUploader import com.xuqm.sdk.bugcollect.internal.UploadResult import com.xuqm.sdk.storage.SecureStore import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.delay import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import java.util.concurrent.TimeUnit import kotlin.math.pow import kotlin.random.Random /** * BugCollect 的唯一队列实现。 * * error/fatal 使用加密存储并最多保留七天;普通事件只存在内存。上传失败最多进行 * 三次指数退避重试,之后保留持久错误等待下次进程启动或网络恢复。 */ internal class LogQueue( 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_bugcollect_queue" private const val KEY_ISSUES = "issues" private val retentionMillis = TimeUnit.DAYS.toMillis(7) } private val gson = Gson() private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) private val pendingIssues = mutableListOf() private val pendingEvents = mutableListOf() private val lock = Any() private val flushMutex = Mutex() @Volatile private var stopped = false @Volatile private var uploadCooldownUntilMillis = 0L private var flushJob: Job? = null init { loadFromDisk() flushJob = scope.launch { while (isActive && !stopped) { delay(FLUSH_INTERVAL_MS) flush() } } } fun push(event: Any) { if (stopped) return synchronized(lock) { when (event) { is IssueEvent -> { if (pendingIssues.size >= MAX_QUEUE_SIZE) pendingIssues.removeAt(0) pendingIssues.add(event) if (event.level == "error" || event.level == "fatal") { persistIssuesLocked() } } is LogEvent -> { if (pendingEvents.size >= MAX_QUEUE_SIZE) pendingEvents.removeAt(0) pendingEvents.add(event) } } } if (shouldFlush()) scope.launch { flush() } } fun uploadPendingCrashes() { if (stopped) return scope.launch { for ((key, event) in LogStorage.loadCrashes(appContext, appKey)) { when (uploadWithRetry { LogUploader.uploadIssue(requireUrl(), event) }) { UploadResult.Success -> LogStorage.removeCrash(appContext, key) UploadResult.Disabled -> { disableAndClear() return@launch } is UploadResult.RateLimited -> return@launch is UploadResult.Failed -> return@launch } } } } fun stopAndClear() { stopped = true flushJob?.cancel() synchronized(lock) { pendingIssues.clear() pendingEvents.clear() secureStore().clear() } LogStorage.clear(appContext) } fun clearUserContext() { synchronized(lock) { pendingIssues.replaceAll { it.copy(userId = null, sessionId = null, user = null) } pendingEvents.replaceAll { it.copy(userId = null, sessionId = null, user = null) } persistIssuesLocked() } LogStorage.clearUserContext(appContext) } private fun shouldFlush(): Boolean = synchronized(lock) { pendingIssues.size >= BATCH_SIZE || pendingEvents.size >= BATCH_SIZE } private fun flush() { scope.launch { flushMutex.withLock { flushOnce() } } } /** 单飞执行;定时器、满批触发和手动触发不能重复上传同一批事件。 */ private suspend fun flushOnce() { if (stopped || !XuqmSDK.bugCollectEnabled || !XuqmSDK.privacyConsentGranted) return if (System.currentTimeMillis() < uploadCooldownUntilMillis) return val issues: List val events: List synchronized(lock) { if (pendingIssues.isEmpty() && pendingEvents.isEmpty()) return issues = pendingIssues.take(BATCH_SIZE) events = pendingEvents.take(BATCH_SIZE) } if (issues.isNotEmpty()) { when (uploadWithRetry { LogUploader.uploadIssues(requireUrl(), issues) }) { UploadResult.Success -> synchronized(lock) { pendingIssues.removeAll(issues.toSet()) persistIssuesLocked() } UploadResult.Disabled -> { disableAndClear() return } is UploadResult.RateLimited -> return is UploadResult.Failed -> Unit } } if (events.isNotEmpty() && !stopped) { when (uploadWithRetry { LogUploader.uploadEvents(requireUrl(), events) }) { UploadResult.Success -> synchronized(lock) { pendingEvents.removeAll(events.toSet()) } UploadResult.Disabled -> disableAndClear() is UploadResult.RateLimited -> Unit is UploadResult.Failed -> Unit } } } private suspend fun uploadWithRetry(block: () -> UploadResult): UploadResult { var last: UploadResult = UploadResult.Failed() repeat(3) { attempt -> if (stopped) return UploadResult.Failed() val result = runCatching(block).getOrElse { UploadResult.Failed(it) } if (result is UploadResult.RateLimited) { uploadCooldownUntilMillis = System.currentTimeMillis() + result.retryAfterMillis return result } if (result !is UploadResult.Failed) return result last = result if (attempt < 2) { val base = 500.0 * 2.0.pow(attempt.toDouble()) delay((base + Random.nextLong(0L, 251L)).toLong()) } } Log.w(TAG, "Background upload deferred after 3 attempts") return last } private fun disableAndClear() { stopAndClear() // 固定停用业务码只改变扩展状态,不向宿主线程抛异常。 runCatching { XuqmSDK::class.java.getDeclaredMethod("disableBugCollectFromExtension") .apply { isAccessible = true } .invoke(XuqmSDK) } } private fun requireUrl(): String = XuqmSDK.bugCollectApiUrl?.takeIf { it.isNotBlank() } ?: throw IllegalStateException("BugCollect upload URL is unavailable") private fun loadFromDisk() { val type = object : TypeToken>() {}.type val loaded = runCatching { gson.fromJson>(secureStore().getString(KEY_ISSUES) ?: "[]", type) }.getOrNull().orEmpty() val minTimestamp = System.currentTimeMillis() - retentionMillis synchronized(lock) { pendingIssues.addAll( loaded .filter { it.timestamp >= minTimestamp } .takeLast(MAX_QUEUE_SIZE) , ) persistIssuesLocked() } } private fun persistIssuesLocked() { val persistent = pendingIssues .filter { it.level == "error" || it.level == "fatal" } .filter { it.timestamp >= System.currentTimeMillis() - retentionMillis } .takeLast(MAX_QUEUE_SIZE) secureStore().putString(KEY_ISSUES, gson.toJson(persistent)) } private fun secureStore() = SecureStore.open(appContext, PREFS_NAME) }