XuqmGroup-AndroidSDK/sdk-bugcollect/src/main/java/com/xuqm/sdk/bugcollect/LogQueue.kt
2026-07-28 20:41:05 +08:00

230 行
8.3 KiB
Kotlin

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<IssueEvent>()
private val pendingEvents = mutableListOf<LogEvent>()
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<IssueEvent>
val events: List<LogEvent>
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<List<IssueEvent>>() {}.type
val loaded = runCatching {
gson.fromJson<List<IssueEvent>>(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)
}