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() 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.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 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.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() } } }