package com.xuqm.sdk.log import android.content.Context import android.util.Log import com.xuqm.sdk.log.internal.LogUploader import com.xuqm.sdk.log.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 internal class LogQueue( private val logApiUrl: String, private val appKey: String, private val appContext: Context, ) { companion object { private const val TAG = "XLog" 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() private val pendingEvents = mutableListOf() 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.optString("userId", null), 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 val events: List 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.optString("userId", null), 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.optString("userId", null), 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() } } }