2026-06-16 12:10:58 +08:00
|
|
|
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
|
|
|
|
|
|
2026-06-16 13:46:01 +08:00
|
|
|
/** [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
|
|
|
|
|
|
2026-06-16 12:10:58 +08:00
|
|
|
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<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),
|
2026-06-16 13:46:01 +08:00
|
|
|
userId = obj.optNullableString("userId"),
|
2026-06-16 12:10:58 +08:00
|
|
|
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),
|
2026-06-16 13:46:01 +08:00
|
|
|
userId = obj.optNullableString("userId"),
|
2026-06-16 12:10:58 +08:00
|
|
|
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),
|
2026-06-16 13:46:01 +08:00
|
|
|
userId = obj.optNullableString("userId"),
|
2026-06-16 12:10:58 +08:00
|
|
|
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()
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|