XuqmGroup-AndroidSDK/sdk-bugcollect/src/main/java/com/xuqm/sdk/bugcollect/LogQueue.kt

185 行
7.5 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 event = IssueEvent(
type = obj.optString("type", "native_crash"),
message = obj.optString("message", ""),
stack = obj.optString("stack", ""),
fingerprint = Fingerprint.compute("native_crash", obj.optString("message", ""), obj.optString("stack", "")),
appKey = obj.optString("appKey", appKey),
userId = obj.optNullableString("userId"),
platform = "android",
appVersion = runCatching {
appContext.packageManager.getPackageInfo(appContext.packageName, 0).versionName ?: "unknown"
}.getOrDefault("unknown"),
metadata = emptyMap(),
environment = "production",
timestamp = obj.optLong("timestamp", System.currentTimeMillis()),
)
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(
type = obj.optString("type", ""),
message = obj.optString("message", ""),
stack = obj.optString("stack", ""),
fingerprint = obj.optString("fingerprint", ""),
appKey = obj.optString("appKey", appKey),
userId = obj.optNullableString("userId"),
platform = obj.optString("platform", "android"),
appVersion = obj.optString("appVersion", ""),
metadata = emptyMap(),
environment = obj.optString("environment", "production"),
timestamp = obj.optLong("timestamp", 0),
))
}
}
}
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"),
appVersion = obj.optString("appVersion", ""),
environment = obj.optString("environment", "production"),
timestamp = obj.optLong("timestamp", 0),
))
}
}
}
prefs.edit().remove(KEY_ISSUES).remove(KEY_EVENTS).apply()
}
}
}