230 行
8.3 KiB
Kotlin
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)
|
|
}
|