feat: sdk-log v1.0.0 新建 + sdk-core logApiUrl 扩展 + sdk-update 进度回调
Agent 7 — sdk-core: - SdkPlatformConfig 新增 logApiUrl、logEnabled 字段 - XuqmSDK 新增 logApiUrl/logEnabled 属性 - fetchAndApplyPlatformConfig 填充新字段 Agent 7 — sdk-log v1.0.0: - XLog 主入口:event/captureError/warn/info/startCrashCapture - LogQueue:SharedPreferences 存储 + 批量 OkHttp 上报 - CrashCapture:Thread.setDefaultUncaughtExceptionHandler - Fingerprint:SHA-256 指纹去重 - FunnelTracker:漏斗分析 - Gradle Plugin:com.xuqm.log — assembleRelease 后自动上传 mapping Agent 7 — sdk-update: - downloadApk/downloadPlugin 新增 onProgress 进度回调 - checkAppUpdate 版本缓存(30分钟 TTL)
这个提交包含在:
父节点
9a4e6c0091
当前提交
fbafc8d802
@ -9,3 +9,4 @@ SDK_PUSH_VERSION=1.1.0
|
|||||||
SDK_UPDATE_VERSION=1.1.3
|
SDK_UPDATE_VERSION=1.1.3
|
||||||
SDK_WEBVIEW_VERSION=1.1.1
|
SDK_WEBVIEW_VERSION=1.1.1
|
||||||
SDK_LICENSE_VERSION=1.1.0
|
SDK_LICENSE_VERSION=1.1.0
|
||||||
|
SDK_LOG_VERSION=1.0.0-SNAPSHOT
|
||||||
|
|||||||
@ -45,6 +45,14 @@ object XuqmSDK {
|
|||||||
@Volatile var platformConfig: SdkPlatformConfig? = null
|
@Volatile var platformConfig: SdkPlatformConfig? = null
|
||||||
private set
|
private set
|
||||||
|
|
||||||
|
/** 日志上报服务地址,由平台配置下发;未开通日志服务时为 null。 */
|
||||||
|
@Volatile var logApiUrl: String? = null
|
||||||
|
private set
|
||||||
|
|
||||||
|
/** 是否启用客户端日志上报。 */
|
||||||
|
@Volatile var logEnabled: Boolean = false
|
||||||
|
private set
|
||||||
|
|
||||||
private val pendingInitCallbacks = mutableListOf<() -> Unit>()
|
private val pendingInitCallbacks = mutableListOf<() -> Unit>()
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -197,10 +205,12 @@ object XuqmSDK {
|
|||||||
fileBaseUrl = cfg.fileServiceUrl?.trimEnd('/')?.plus("/") ?: base,
|
fileBaseUrl = cfg.fileServiceUrl?.trimEnd('/')?.plus("/") ?: base,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
logApiUrl = cfg.logApiUrl
|
||||||
|
logEnabled = cfg.logEnabled ?: false
|
||||||
Log.i(
|
Log.i(
|
||||||
TAG,
|
TAG,
|
||||||
"Platform config applied [${if (platformUrl == DEFAULT_PLATFORM_URL) "public" else "private"}]:" +
|
"Platform config applied [${if (platformUrl == DEFAULT_PLATFORM_URL) "public" else "private"}]:" +
|
||||||
" apiBase=$apiBase imWsUrl=${cfg.imWsUrl}"
|
" apiBase=$apiBase imWsUrl=${cfg.imWsUrl} logEnabled=$logEnabled"
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -26,4 +26,8 @@ data class SdkPlatformConfig(
|
|||||||
val imWsUrl: String? = null,
|
val imWsUrl: String? = null,
|
||||||
/** 文件服务地址 */
|
/** 文件服务地址 */
|
||||||
val fileServiceUrl: String? = null,
|
val fileServiceUrl: String? = null,
|
||||||
|
/** 日志上报服务地址(旧服务端不返回时为 null) */
|
||||||
|
val logApiUrl: String? = null,
|
||||||
|
/** 是否启用客户端日志上报(旧服务端不返回时为 null,视为 false) */
|
||||||
|
val logEnabled: Boolean? = null,
|
||||||
)
|
)
|
||||||
|
|||||||
53
sdk-log/build.gradle.kts
普通文件
53
sdk-log/build.gradle.kts
普通文件
@ -0,0 +1,53 @@
|
|||||||
|
plugins {
|
||||||
|
id("com.android.library")
|
||||||
|
id("org.jetbrains.kotlin.android")
|
||||||
|
id("maven-publish")
|
||||||
|
}
|
||||||
|
|
||||||
|
group = rootProject.group
|
||||||
|
|
||||||
|
android {
|
||||||
|
namespace = "com.xuqm.sdk.log"
|
||||||
|
compileSdk = 35
|
||||||
|
defaultConfig { minSdk = 24 }
|
||||||
|
publishing { singleVariant("release") { withSourcesJar() } }
|
||||||
|
|
||||||
|
compileOptions {
|
||||||
|
sourceCompatibility = JavaVersion.VERSION_11
|
||||||
|
targetCompatibility = JavaVersion.VERSION_11
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val sdkLogVersion: String by lazy {
|
||||||
|
(project.findProperty("SDK_LOG_VERSION") as? String)?.takeIf { it.isNotBlank() }
|
||||||
|
?: (project.findProperty("PUBLISH_VERSION") as? String)?.takeIf { it.isNotBlank() }
|
||||||
|
?: "0.0.1-SNAPSHOT"
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEvaluate {
|
||||||
|
publishing {
|
||||||
|
publications {
|
||||||
|
create<MavenPublication>("release") {
|
||||||
|
from(components["release"])
|
||||||
|
groupId = "com.xuqm"
|
||||||
|
artifactId = "sdk-log"
|
||||||
|
version = sdkLogVersion
|
||||||
|
}
|
||||||
|
}
|
||||||
|
repositories {
|
||||||
|
maven {
|
||||||
|
url = uri(rootProject.ext["nexusUrl"] as String)
|
||||||
|
credentials {
|
||||||
|
username = project.findProperty("NEXUS_USER") as? String ?: ""
|
||||||
|
password = project.findProperty("NEXUS_PASSWORD") as? String ?: ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
implementation(project(":sdk-core"))
|
||||||
|
implementation("com.squareup.okhttp3:okhttp:4.12.0")
|
||||||
|
implementation("com.google.code.gson:gson:2.10.1")
|
||||||
|
}
|
||||||
@ -0,0 +1,13 @@
|
|||||||
|
package com.xuqm.sdk.log
|
||||||
|
|
||||||
|
import com.xuqm.sdk.log.internal.LogStorage
|
||||||
|
|
||||||
|
internal object CrashCapture {
|
||||||
|
fun start(logApiUrl: String, appKey: String, getUserId: () -> String?) {
|
||||||
|
val prev = Thread.getDefaultUncaughtExceptionHandler()
|
||||||
|
Thread.setDefaultUncaughtExceptionHandler { thread, throwable ->
|
||||||
|
LogStorage.saveCrash(throwable, logApiUrl, appKey, getUserId())
|
||||||
|
prev?.uncaughtException(thread, throwable)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,14 @@
|
|||||||
|
package com.xuqm.sdk.log
|
||||||
|
|
||||||
|
object Fingerprint {
|
||||||
|
fun compute(type: String, message: String, stack: String): String {
|
||||||
|
val top3 = stack.lines().filter { it.trim().startsWith("at ") }.take(3).joinToString("|")
|
||||||
|
val normalized = message.replace(Regex("\\b\\d{4,}\\b"), "N")
|
||||||
|
return sha256("$type:$normalized:$top3")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun sha256(s: String): String =
|
||||||
|
java.security.MessageDigest.getInstance("SHA-256")
|
||||||
|
.digest(s.toByteArray())
|
||||||
|
.joinToString("") { "%02x".format(it) }
|
||||||
|
}
|
||||||
@ -0,0 +1,19 @@
|
|||||||
|
package com.xuqm.sdk.log
|
||||||
|
|
||||||
|
object FunnelTracker {
|
||||||
|
private val funnels = mutableMapOf<String, List<String>>()
|
||||||
|
private val progress = mutableMapOf<String, MutableList<String>>()
|
||||||
|
|
||||||
|
fun define(id: String, steps: List<String>) {
|
||||||
|
funnels[id] = steps
|
||||||
|
progress[id] = mutableListOf()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun track(eventName: String, @Suppress("UNUSED_PARAMETER") properties: Map<String, Any?>) {
|
||||||
|
for ((id, steps) in funnels) {
|
||||||
|
val done = progress[id] ?: continue
|
||||||
|
val next = steps.getOrNull(done.size) ?: continue
|
||||||
|
if (next == eventName) done.add(eventName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,15 @@
|
|||||||
|
package com.xuqm.sdk.log
|
||||||
|
|
||||||
|
data class IssueEvent(
|
||||||
|
val type: String,
|
||||||
|
val message: String,
|
||||||
|
val stack: String,
|
||||||
|
val fingerprint: String,
|
||||||
|
val appKey: String,
|
||||||
|
val userId: String?,
|
||||||
|
val platform: String,
|
||||||
|
val appVersion: String,
|
||||||
|
val metadata: Map<String, Any?> = emptyMap(),
|
||||||
|
val environment: String,
|
||||||
|
val timestamp: Long = System.currentTimeMillis(),
|
||||||
|
)
|
||||||
@ -0,0 +1,12 @@
|
|||||||
|
package com.xuqm.sdk.log
|
||||||
|
|
||||||
|
data class LogEvent(
|
||||||
|
val name: String,
|
||||||
|
val properties: Map<String, Any?> = emptyMap(),
|
||||||
|
val appKey: String,
|
||||||
|
val userId: String?,
|
||||||
|
val platform: String,
|
||||||
|
val appVersion: String,
|
||||||
|
val environment: String,
|
||||||
|
val timestamp: Long = System.currentTimeMillis(),
|
||||||
|
)
|
||||||
@ -0,0 +1,3 @@
|
|||||||
|
package com.xuqm.sdk.log
|
||||||
|
|
||||||
|
enum class LogLevel { DEBUG, INFO, WARN, ERROR, NONE }
|
||||||
@ -0,0 +1,180 @@
|
|||||||
|
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<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.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<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.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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,80 @@
|
|||||||
|
package com.xuqm.sdk.log
|
||||||
|
|
||||||
|
import com.xuqm.sdk.XuqmSDK
|
||||||
|
|
||||||
|
object XLog {
|
||||||
|
|
||||||
|
private var logLevel: LogLevel = LogLevel.WARN
|
||||||
|
private var environment: String = "production"
|
||||||
|
@Volatile private var queue: LogQueue? = null
|
||||||
|
@Volatile private var crashCaptureStarted = false
|
||||||
|
|
||||||
|
fun setLogLevel(level: LogLevel) { logLevel = level }
|
||||||
|
fun setEnvironment(env: String) { environment = env }
|
||||||
|
|
||||||
|
fun event(name: String, properties: Map<String, Any?> = emptyMap()) {
|
||||||
|
if (!isReady()) return
|
||||||
|
queue().push(LogEvent(
|
||||||
|
name = name, properties = properties,
|
||||||
|
appKey = XuqmSDK.appKey, userId = XuqmSDK.getUserId(),
|
||||||
|
platform = "android", appVersion = appVersion(), environment = environment,
|
||||||
|
))
|
||||||
|
FunnelTracker.track(name, properties)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun captureError(error: Throwable, metadata: Map<String, Any?> = emptyMap()) {
|
||||||
|
if (!isReady()) return
|
||||||
|
queue().push(IssueEvent(
|
||||||
|
type = "android_error",
|
||||||
|
message = error.message ?: error.javaClass.name,
|
||||||
|
stack = error.stackTraceToString(),
|
||||||
|
fingerprint = Fingerprint.compute("error", error.message ?: "", error.stackTraceToString()),
|
||||||
|
appKey = XuqmSDK.appKey, userId = XuqmSDK.getUserId(),
|
||||||
|
platform = "android", appVersion = appVersion(),
|
||||||
|
metadata = metadata, environment = environment,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
fun warn(message: String, metadata: Map<String, Any?> = emptyMap()) {
|
||||||
|
if (logLevel.ordinal > LogLevel.WARN.ordinal) return
|
||||||
|
captureError(Exception(message), metadata + ("level" to "warn"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fun info(message: String, metadata: Map<String, Any?> = emptyMap()) {
|
||||||
|
if (logLevel.ordinal > LogLevel.INFO.ordinal) return
|
||||||
|
event("__log_info", metadata + ("message" to message))
|
||||||
|
}
|
||||||
|
|
||||||
|
fun startCrashCapture() {
|
||||||
|
if (crashCaptureStarted) return
|
||||||
|
val logApiUrl = XuqmSDK.logApiUrl ?: return
|
||||||
|
crashCaptureStarted = true
|
||||||
|
CrashCapture.start(
|
||||||
|
logApiUrl = logApiUrl,
|
||||||
|
appKey = XuqmSDK.appKey,
|
||||||
|
getUserId = { XuqmSDK.getUserId() },
|
||||||
|
)
|
||||||
|
queue().uploadPendingCrashes()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun defineFunnel(id: String, steps: List<String>) {
|
||||||
|
FunnelTracker.define(id, steps)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun isReady() = XuqmSDK.isInitialized() && XuqmSDK.logEnabled
|
||||||
|
|
||||||
|
private fun queue(): LogQueue {
|
||||||
|
return queue ?: synchronized(this) {
|
||||||
|
queue ?: LogQueue(
|
||||||
|
logApiUrl = XuqmSDK.logApiUrl ?: "",
|
||||||
|
appKey = XuqmSDK.appKey,
|
||||||
|
appContext = XuqmSDK.appContext,
|
||||||
|
).also { queue = it }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun appVersion(): String = runCatching {
|
||||||
|
val ctx = XuqmSDK.appContext
|
||||||
|
ctx.packageManager.getPackageInfo(ctx.packageName, 0).versionName ?: "unknown"
|
||||||
|
}.getOrDefault("unknown")
|
||||||
|
}
|
||||||
@ -0,0 +1,35 @@
|
|||||||
|
package com.xuqm.sdk.log.gradle
|
||||||
|
|
||||||
|
import com.android.build.gradle.AppExtension
|
||||||
|
import org.gradle.api.Plugin
|
||||||
|
import org.gradle.api.Project
|
||||||
|
|
||||||
|
class XuqmLogPlugin : Plugin<Project> {
|
||||||
|
override fun apply(target: Project) {
|
||||||
|
val android = target.extensions.findByType(AppExtension::class.java) ?: return
|
||||||
|
|
||||||
|
android.applicationVariants.all { variant ->
|
||||||
|
if (!variant.buildType.isMinifyEnabled) return@all
|
||||||
|
|
||||||
|
val taskName = "xuqmUploadMapping${variant.name.replaceFirstChar { it.uppercase() }}"
|
||||||
|
val uploadTask = target.tasks.register(taskName, XuqmUploadMappingTask::class.java) { task ->
|
||||||
|
task.group = "xuqm"
|
||||||
|
task.description = "Upload ProGuard mapping to XuqmLog service (${variant.name})"
|
||||||
|
|
||||||
|
task.appKey.set(
|
||||||
|
target.findProperty("XUQM_APP_KEY")?.toString()
|
||||||
|
?: target.findProperty("xuqm.appKey")?.toString()
|
||||||
|
)
|
||||||
|
task.platformUrl.set(
|
||||||
|
target.findProperty("XUQM_PLATFORM_URL")?.toString()
|
||||||
|
?: "https://www.51szyx.com"
|
||||||
|
)
|
||||||
|
task.appVersion.set(variant.versionName)
|
||||||
|
task.platform.set("android")
|
||||||
|
task.mappingFile.set(variant.mappingFileProvider)
|
||||||
|
}
|
||||||
|
|
||||||
|
variant.assembleProvider.configure { it.finalizedBy(uploadTask) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,57 @@
|
|||||||
|
package com.xuqm.sdk.log.gradle
|
||||||
|
|
||||||
|
import org.gradle.api.DefaultTask
|
||||||
|
import org.gradle.api.file.RegularFileProperty
|
||||||
|
import org.gradle.api.provider.Property
|
||||||
|
import org.gradle.api.tasks.Input
|
||||||
|
import org.gradle.api.tasks.InputFile
|
||||||
|
import org.gradle.api.tasks.Optional
|
||||||
|
import org.gradle.api.tasks.TaskAction
|
||||||
|
import java.io.File
|
||||||
|
import java.net.HttpURLConnection
|
||||||
|
import java.net.URL
|
||||||
|
|
||||||
|
abstract class XuqmUploadMappingTask : DefaultTask() {
|
||||||
|
@get:Input abstract val appKey: Property<String>
|
||||||
|
@get:Input abstract val platformUrl: Property<String>
|
||||||
|
@get:Input abstract val appVersion: Property<String>
|
||||||
|
@get:Input abstract val platform: Property<String>
|
||||||
|
@get:InputFile @get:Optional abstract val mappingFile: RegularFileProperty
|
||||||
|
|
||||||
|
@TaskAction
|
||||||
|
fun upload() {
|
||||||
|
val key = appKey.orNull ?: run { logger.warn("[XuqmLog] XUQM_APP_KEY not set, skip upload"); return }
|
||||||
|
val file = mappingFile.orNull?.asFile ?: run { logger.warn("[XuqmLog] No mapping file found"); return }
|
||||||
|
if (!file.exists()) { logger.warn("[XuqmLog] Mapping file not found: ${file.path}"); return }
|
||||||
|
|
||||||
|
val configUrl = "${platformUrl.get().trimEnd('/')}/api/sdk/config?appKey=$key"
|
||||||
|
val logApiUrl = fetchLogApiUrl(configUrl) ?: run { logger.warn("[XuqmLog] Cannot fetch logApiUrl"); return }
|
||||||
|
|
||||||
|
uploadFile("$logApiUrl/log/v1/sourcemaps/upload", key, file, appVersion.get(), platform.get())
|
||||||
|
logger.lifecycle("[XuqmLog] Mapping uploaded: ${file.name} (android v${appVersion.get()})")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun fetchLogApiUrl(url: String): String? = runCatching {
|
||||||
|
val conn = URL(url).openConnection() as HttpURLConnection
|
||||||
|
conn.requestMethod = "GET"
|
||||||
|
val body = conn.inputStream.bufferedReader().readText()
|
||||||
|
Regex(""""logApiUrl"\s*:\s*"([^"]+)"""").find(body)?.groupValues?.get(1)
|
||||||
|
}.getOrNull()
|
||||||
|
|
||||||
|
private fun uploadFile(url: String, appKey: String, file: File, version: String, platform: String) {
|
||||||
|
val boundary = "XuqmBoundary${System.currentTimeMillis()}"
|
||||||
|
val conn = URL(url).openConnection() as HttpURLConnection
|
||||||
|
conn.requestMethod = "POST"
|
||||||
|
conn.doOutput = true
|
||||||
|
conn.setRequestProperty("Content-Type", "multipart/form-data; boundary=$boundary")
|
||||||
|
conn.outputStream.use { out ->
|
||||||
|
fun field(name: String, value: String) =
|
||||||
|
out.write("--$boundary\r\nContent-Disposition: form-data; name=\"$name\"\r\n\r\n$value\r\n".toByteArray())
|
||||||
|
field("appKey", appKey); field("platform", platform); field("appVersion", version)
|
||||||
|
out.write("--$boundary\r\nContent-Disposition: form-data; name=\"file\"; filename=\"${file.name}\"\r\nContent-Type: text/plain\r\n\r\n".toByteArray())
|
||||||
|
out.write(file.readBytes())
|
||||||
|
out.write("\r\n--$boundary--\r\n".toByteArray())
|
||||||
|
}
|
||||||
|
check(conn.responseCode in 200..299) { "Upload failed: HTTP ${conn.responseCode}" }
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,75 @@
|
|||||||
|
package com.xuqm.sdk.log.internal
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import org.json.JSONObject
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
|
internal object LogStorage {
|
||||||
|
|
||||||
|
private const val CRASH_DIR = "xuqm_crashes"
|
||||||
|
|
||||||
|
fun crashDir(context: Context): File =
|
||||||
|
File(context.filesDir, CRASH_DIR).also { it.mkdirs() }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 同步写崩溃信息到文件系统(UncaughtExceptionHandler 中调用)。
|
||||||
|
* 文件将在下次启动时由 [com.xuqm.sdk.log.LogQueue.uploadPendingCrashes] 上报。
|
||||||
|
*/
|
||||||
|
fun saveCrash(
|
||||||
|
throwable: Throwable,
|
||||||
|
logApiUrl: String,
|
||||||
|
appKey: String,
|
||||||
|
userId: String?,
|
||||||
|
) {
|
||||||
|
runCatching {
|
||||||
|
val dir = File(
|
||||||
|
// 使用系统属性获取 filesDir 替代 Context(在 UncaughtExceptionHandler 中 Context 可能不可用)
|
||||||
|
android.os.Environment.getDataDirectory().absolutePath +
|
||||||
|
"/data/${getPackageName()}/files/$CRASH_DIR"
|
||||||
|
).also { it.mkdirs() }
|
||||||
|
val file = File(dir, "${System.currentTimeMillis()}.json")
|
||||||
|
val json = JSONObject().apply {
|
||||||
|
put("type", "native_crash")
|
||||||
|
put("message", throwable.message ?: throwable.javaClass.name)
|
||||||
|
put("stack", throwable.stackTraceToString())
|
||||||
|
put("logApiUrl", logApiUrl)
|
||||||
|
put("appKey", appKey)
|
||||||
|
put("userId", userId ?: JSONObject.NULL)
|
||||||
|
put("timestamp", System.currentTimeMillis())
|
||||||
|
}
|
||||||
|
file.writeText(json.toString())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 在 Application 中通过 Context 保存崩溃(更可靠)。
|
||||||
|
*/
|
||||||
|
fun saveCrash(
|
||||||
|
context: Context,
|
||||||
|
throwable: Throwable,
|
||||||
|
logApiUrl: String,
|
||||||
|
appKey: String,
|
||||||
|
userId: String?,
|
||||||
|
) {
|
||||||
|
runCatching {
|
||||||
|
val dir = crashDir(context)
|
||||||
|
val file = File(dir, "${System.currentTimeMillis()}.json")
|
||||||
|
val json = JSONObject().apply {
|
||||||
|
put("type", "native_crash")
|
||||||
|
put("message", throwable.message ?: throwable.javaClass.name)
|
||||||
|
put("stack", throwable.stackTraceToString())
|
||||||
|
put("logApiUrl", logApiUrl)
|
||||||
|
put("appKey", appKey)
|
||||||
|
put("userId", userId ?: JSONObject.NULL)
|
||||||
|
put("timestamp", System.currentTimeMillis())
|
||||||
|
}
|
||||||
|
file.writeText(json.toString())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getPackageName(): String = runCatching {
|
||||||
|
val clazz = Class.forName("android.app.ActivityThread")
|
||||||
|
val method = clazz.getMethod("currentPackageName")
|
||||||
|
method.invoke(null) as? String ?: ""
|
||||||
|
}.getOrDefault("")
|
||||||
|
}
|
||||||
@ -0,0 +1,88 @@
|
|||||||
|
package com.xuqm.sdk.log.internal
|
||||||
|
|
||||||
|
import android.util.Log
|
||||||
|
import com.xuqm.sdk.log.IssueEvent
|
||||||
|
import com.xuqm.sdk.log.LogEvent
|
||||||
|
import okhttp3.MediaType.Companion.toMediaType
|
||||||
|
import okhttp3.OkHttpClient
|
||||||
|
import okhttp3.Request
|
||||||
|
import okhttp3.RequestBody.Companion.toRequestBody
|
||||||
|
import org.json.JSONArray
|
||||||
|
import org.json.JSONObject
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
|
internal object LogUploader {
|
||||||
|
|
||||||
|
private const val TAG = "XLog"
|
||||||
|
|
||||||
|
private val client = OkHttpClient.Builder()
|
||||||
|
.connectTimeout(10, TimeUnit.SECONDS)
|
||||||
|
.writeTimeout(15, TimeUnit.SECONDS)
|
||||||
|
.readTimeout(10, TimeUnit.SECONDS)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
private val JSON_MEDIA = "application/json; charset=utf-8".toMediaType()
|
||||||
|
|
||||||
|
fun uploadIssues(logApiUrl: String, issues: List<IssueEvent>) {
|
||||||
|
if (issues.isEmpty()) return
|
||||||
|
val url = "${logApiUrl.trimEnd('/')}/log/v1/issues/batch"
|
||||||
|
val array = JSONArray()
|
||||||
|
for (issue in issues) {
|
||||||
|
array.put(issueToJson(issue))
|
||||||
|
}
|
||||||
|
post(url, array.toString())
|
||||||
|
}
|
||||||
|
|
||||||
|
fun uploadEvents(logApiUrl: String, events: List<LogEvent>) {
|
||||||
|
if (events.isEmpty()) return
|
||||||
|
val url = "${logApiUrl.trimEnd('/')}/log/v1/events/batch"
|
||||||
|
val array = JSONArray()
|
||||||
|
for (event in events) {
|
||||||
|
array.put(eventToJson(event))
|
||||||
|
}
|
||||||
|
post(url, array.toString())
|
||||||
|
}
|
||||||
|
|
||||||
|
fun uploadIssue(logApiUrl: String, issue: IssueEvent) {
|
||||||
|
val url = "${logApiUrl.trimEnd('/')}/log/v1/issues/batch"
|
||||||
|
val array = JSONArray().put(issueToJson(issue))
|
||||||
|
post(url, array.toString())
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun post(url: String, body: String) {
|
||||||
|
val request = Request.Builder()
|
||||||
|
.url(url)
|
||||||
|
.post(body.toRequestBody(JSON_MEDIA))
|
||||||
|
.build()
|
||||||
|
client.newCall(request).execute().use { response ->
|
||||||
|
if (!response.isSuccessful) {
|
||||||
|
Log.w(TAG, "Upload failed: HTTP ${response.code} url=$url")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun issueToJson(issue: IssueEvent): JSONObject = JSONObject().apply {
|
||||||
|
put("type", issue.type)
|
||||||
|
put("message", issue.message)
|
||||||
|
put("stack", issue.stack)
|
||||||
|
put("fingerprint", issue.fingerprint)
|
||||||
|
put("appKey", issue.appKey)
|
||||||
|
put("userId", issue.userId ?: JSONObject.NULL)
|
||||||
|
put("platform", issue.platform)
|
||||||
|
put("appVersion", issue.appVersion)
|
||||||
|
put("environment", issue.environment)
|
||||||
|
put("timestamp", issue.timestamp)
|
||||||
|
put("metadata", JSONObject(issue.metadata as? Map<String, Any?> ?: emptyMap()))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun eventToJson(event: LogEvent): JSONObject = JSONObject().apply {
|
||||||
|
put("name", event.name)
|
||||||
|
put("properties", JSONObject(event.properties as? Map<String, Any?> ?: emptyMap()))
|
||||||
|
put("appKey", event.appKey)
|
||||||
|
put("userId", event.userId ?: JSONObject.NULL)
|
||||||
|
put("platform", event.platform)
|
||||||
|
put("appVersion", event.appVersion)
|
||||||
|
put("environment", event.environment)
|
||||||
|
put("timestamp", event.timestamp)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1 @@
|
|||||||
|
implementation-class=com.xuqm.sdk.log.gradle.XuqmLogPlugin
|
||||||
@ -32,4 +32,5 @@ android {
|
|||||||
dependencies {
|
dependencies {
|
||||||
api(project(":sdk-core"))
|
api(project(":sdk-core"))
|
||||||
implementation(libs.kotlinx.coroutines.android)
|
implementation(libs.kotlinx.coroutines.android)
|
||||||
|
implementation("com.google.code.gson:gson:2.10.1")
|
||||||
}
|
}
|
||||||
|
|||||||
@ -39,6 +39,20 @@ object UpdateSDK {
|
|||||||
private fun prefs(context: Context) =
|
private fun prefs(context: Context) =
|
||||||
context.applicationContext.getSharedPreferences("xuqm_update_prefs", android.content.Context.MODE_PRIVATE)
|
context.applicationContext.getSharedPreferences("xuqm_update_prefs", android.content.Context.MODE_PRIVATE)
|
||||||
|
|
||||||
|
private fun getCachedUpdateInfo(prefs: android.content.SharedPreferences, cacheKey: String): UpdateInfo? {
|
||||||
|
val ts = prefs.getLong("${cacheKey}_ts", 0)
|
||||||
|
if (System.currentTimeMillis() - ts > 30 * 60 * 1000) return null
|
||||||
|
val json = prefs.getString("${cacheKey}_data", null) ?: return null
|
||||||
|
return runCatching { com.google.gson.Gson().fromJson(json, UpdateInfo::class.java) }.getOrNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun putCachedUpdateInfo(prefs: android.content.SharedPreferences, cacheKey: String, info: UpdateInfo) {
|
||||||
|
prefs.edit()
|
||||||
|
.putString("${cacheKey}_data", com.google.gson.Gson().toJson(info))
|
||||||
|
.putLong("${cacheKey}_ts", System.currentTimeMillis())
|
||||||
|
.apply()
|
||||||
|
}
|
||||||
|
|
||||||
/** 忽略指定版本,下次检测到该版本时不再提示(强制更新版本不受此影响) */
|
/** 忽略指定版本,下次检测到该版本时不再提示(强制更新版本不受此影响) */
|
||||||
fun ignoreVersion(context: Context, versionCode: Int) {
|
fun ignoreVersion(context: Context, versionCode: Int) {
|
||||||
prefs(context).edit().putBoolean("ignored_v$versionCode", true).apply()
|
prefs(context).edit().putBoolean("ignored_v$versionCode", true).apply()
|
||||||
@ -148,10 +162,30 @@ object UpdateSDK {
|
|||||||
}
|
}
|
||||||
val userId = resolveUserId()
|
val userId = resolveUserId()
|
||||||
val url = ServiceEndpointRegistry.updateBaseUrl
|
val url = ServiceEndpointRegistry.updateBaseUrl
|
||||||
|
val prefs = prefs(context)
|
||||||
|
val cacheKey = "${XuqmSDK.appKey}_${versionCode}_${userId.orEmpty()}"
|
||||||
|
|
||||||
|
// Try cache first (skip cache when bypassIgnore=true to force fresh check)
|
||||||
|
if (!bypassIgnore) {
|
||||||
|
val cached = getCachedUpdateInfo(prefs, cacheKey)
|
||||||
|
if (cached != null) {
|
||||||
|
Log.d("UpdateSDK", "Using cached update info for cacheKey=$cacheKey")
|
||||||
|
val afterIgnore = if (cached.needsUpdate && !cached.forceUpdate && isVersionIgnored(context, cached.versionCode)) {
|
||||||
|
cached.copy(needsUpdate = false)
|
||||||
|
} else {
|
||||||
|
cached
|
||||||
|
}
|
||||||
|
if (afterIgnore.needsUpdate && afterIgnore.versionCode > 0) {
|
||||||
|
return@withContext afterIgnore.copy(alreadyDownloaded = isApkDownloaded(
|
||||||
|
context, afterIgnore.versionCode, afterIgnore.apkHash ?: ""))
|
||||||
|
}
|
||||||
|
return@withContext afterIgnore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
runCatching {
|
runCatching {
|
||||||
api.checkUpdate(XuqmSDK.appKey, "ANDROID", versionCode, userId).data?.let { info ->
|
api.checkUpdate(XuqmSDK.appKey, "ANDROID", versionCode, userId).data?.let { info ->
|
||||||
val normalized = info.copy(downloadUrl = normalizeDownloadUrl(info.downloadUrl) ?: info.downloadUrl)
|
val normalized = info.copy(downloadUrl = normalizeDownloadUrl(info.downloadUrl) ?: info.downloadUrl)
|
||||||
// 静默检查时跳过已忽略版本;主动检查(bypassIgnore=true)始终展示
|
|
||||||
val afterIgnore = if (!bypassIgnore && normalized.needsUpdate && !normalized.forceUpdate
|
val afterIgnore = if (!bypassIgnore && normalized.needsUpdate && !normalized.forceUpdate
|
||||||
&& isVersionIgnored(context, normalized.versionCode)
|
&& isVersionIgnored(context, normalized.versionCode)
|
||||||
) {
|
) {
|
||||||
@ -159,13 +193,15 @@ object UpdateSDK {
|
|||||||
} else {
|
} else {
|
||||||
normalized
|
normalized
|
||||||
}
|
}
|
||||||
// 检查该版本 APK 是否已下载到本地(含哈希校验)
|
val result = if (afterIgnore.needsUpdate && afterIgnore.versionCode > 0) {
|
||||||
if (afterIgnore.needsUpdate && afterIgnore.versionCode > 0) {
|
|
||||||
afterIgnore.copy(alreadyDownloaded = isApkDownloaded(
|
afterIgnore.copy(alreadyDownloaded = isApkDownloaded(
|
||||||
context, afterIgnore.versionCode, afterIgnore.apkHash ?: ""))
|
context, afterIgnore.versionCode, afterIgnore.apkHash ?: ""))
|
||||||
} else {
|
} else {
|
||||||
afterIgnore
|
afterIgnore
|
||||||
}
|
}
|
||||||
|
// Cache the result (30 min TTL)
|
||||||
|
putCachedUpdateInfo(prefs, cacheKey, result)
|
||||||
|
result
|
||||||
}
|
}
|
||||||
}.onFailure { e ->
|
}.onFailure { e ->
|
||||||
Log.e("UpdateSDK", "checkUpdate failed [url=$url appKey=${XuqmSDK.appKey} versionCode=$versionCode userId=$userId]: ${e.message}", e)
|
Log.e("UpdateSDK", "checkUpdate failed [url=$url appKey=${XuqmSDK.appKey} versionCode=$versionCode userId=$userId]: ${e.message}", e)
|
||||||
|
|||||||
@ -26,4 +26,5 @@ include(":sdk-push")
|
|||||||
include(":sdk-update")
|
include(":sdk-update")
|
||||||
include(":sdk-webview")
|
include(":sdk-webview")
|
||||||
include(":sdk-license")
|
include(":sdk-license")
|
||||||
|
include(":sdk-log")
|
||||||
include(":sample-app")
|
include(":sample-app")
|
||||||
|
|||||||
正在加载...
在新工单中引用
屏蔽一个用户