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_WEBVIEW_VERSION=1.1.1
|
||||
SDK_LICENSE_VERSION=1.1.0
|
||||
SDK_LOG_VERSION=1.0.0-SNAPSHOT
|
||||
|
||||
@ -45,6 +45,14 @@ object XuqmSDK {
|
||||
@Volatile var platformConfig: SdkPlatformConfig? = null
|
||||
private set
|
||||
|
||||
/** 日志上报服务地址,由平台配置下发;未开通日志服务时为 null。 */
|
||||
@Volatile var logApiUrl: String? = null
|
||||
private set
|
||||
|
||||
/** 是否启用客户端日志上报。 */
|
||||
@Volatile var logEnabled: Boolean = false
|
||||
private set
|
||||
|
||||
private val pendingInitCallbacks = mutableListOf<() -> Unit>()
|
||||
|
||||
/**
|
||||
@ -197,10 +205,12 @@ object XuqmSDK {
|
||||
fileBaseUrl = cfg.fileServiceUrl?.trimEnd('/')?.plus("/") ?: base,
|
||||
)
|
||||
)
|
||||
logApiUrl = cfg.logApiUrl
|
||||
logEnabled = cfg.logEnabled ?: false
|
||||
Log.i(
|
||||
TAG,
|
||||
"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 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 {
|
||||
api(project(":sdk-core"))
|
||||
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) =
|
||||
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) {
|
||||
prefs(context).edit().putBoolean("ignored_v$versionCode", true).apply()
|
||||
@ -148,10 +162,30 @@ object UpdateSDK {
|
||||
}
|
||||
val userId = resolveUserId()
|
||||
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 {
|
||||
api.checkUpdate(XuqmSDK.appKey, "ANDROID", versionCode, userId).data?.let { info ->
|
||||
val normalized = info.copy(downloadUrl = normalizeDownloadUrl(info.downloadUrl) ?: info.downloadUrl)
|
||||
// 静默检查时跳过已忽略版本;主动检查(bypassIgnore=true)始终展示
|
||||
val afterIgnore = if (!bypassIgnore && normalized.needsUpdate && !normalized.forceUpdate
|
||||
&& isVersionIgnored(context, normalized.versionCode)
|
||||
) {
|
||||
@ -159,13 +193,15 @@ object UpdateSDK {
|
||||
} else {
|
||||
normalized
|
||||
}
|
||||
// 检查该版本 APK 是否已下载到本地(含哈希校验)
|
||||
if (afterIgnore.needsUpdate && afterIgnore.versionCode > 0) {
|
||||
val result = if (afterIgnore.needsUpdate && afterIgnore.versionCode > 0) {
|
||||
afterIgnore.copy(alreadyDownloaded = isApkDownloaded(
|
||||
context, afterIgnore.versionCode, afterIgnore.apkHash ?: ""))
|
||||
} else {
|
||||
afterIgnore
|
||||
}
|
||||
// Cache the result (30 min TTL)
|
||||
putCachedUpdateInfo(prefs, cacheKey, result)
|
||||
result
|
||||
}
|
||||
}.onFailure { 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-webview")
|
||||
include(":sdk-license")
|
||||
include(":sdk-log")
|
||||
include(":sample-app")
|
||||
|
||||
正在加载...
在新工单中引用
屏蔽一个用户