diff --git a/sdk-bugcollect/src/main/java/com/xuqm/sdk/bugcollect/internal/BugCollectInitProvider.kt b/sdk-bugcollect/src/main/java/com/xuqm/sdk/bugcollect/internal/BugCollectInitProvider.kt
index f320718..4fdc9fc 100644
--- a/sdk-bugcollect/src/main/java/com/xuqm/sdk/bugcollect/internal/BugCollectInitProvider.kt
+++ b/sdk-bugcollect/src/main/java/com/xuqm/sdk/bugcollect/internal/BugCollectInitProvider.kt
@@ -2,43 +2,35 @@ package com.xuqm.sdk.bugcollect.internal
import android.content.ContentProvider
import android.content.ContentValues
-import android.content.Context
import android.database.Cursor
import android.net.Uri
import android.util.Log
import com.xuqm.sdk.XuqmSDK
import com.xuqm.sdk.bugcollect.BugCollect
-import com.xuqm.sdk.bugcollect.CrashCapture
-import kotlinx.coroutines.CoroutineScope
-import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.SupervisorJob
-import kotlinx.coroutines.launch
/**
- * 崩溃收集的早期初始化入口。
+ * BugCollect 早期初始化入口(兼容旧版 sdk-core)。
*
- * initOrder=89,在 sdk-core XuqmInitializerProvider(90) 之后运行,此时
- * XuqmSDK.appKey 已由本地 config 文件同步设置。
+ * 新版 sdk-core 的 XuqmMergedProvider 已通过反射调用 BugCollect.startCrashCapture(),
+ * 此 Provider 检查 XuqmSDK.isInitialized() 来判断是否需要补充初始化。
*
- * 职责分离:
- * Phase 1(同步):只注册 UncaughtExceptionHandler,把崩溃落盘。
- * 不需要上传 URL,注册与 URL 完全解耦。
- * Phase 2(异步):等待 XuqmSDK.awaitInitialization() 完成,
- * 远端配置下发 bugCollectApiUrl 后再上传积压的崩溃文件。
+ * 逻辑:
+ * - sdk-core 已初始化 → 说明合并 Provider 已处理,跳过
+ * - sdk-core 未初始化 → 旧版 sdk-core 场景,执行原有逻辑
*/
internal class BugCollectInitProvider : ContentProvider() {
override fun onCreate(): Boolean {
val ctx = context?.applicationContext ?: return true
- // ── Phase 1:注册崩溃拦截器(只需 appKey,无需 SDK 完全初始化)──────────
- // SDK 可能正在后台初始化中,直接读取 config 文件获取 appKey
- val appKey = if (XuqmSDK.isInitialized()) {
- XuqmSDK.appKey
- } else {
- XuqmSDK.readAppKey(ctx)
+ // 合并 Provider 已完成初始化(sdk-core 新版),跳过
+ if (XuqmSDK.isInitialized()) {
+ Log.d("BugCollect", "Already initialized by merged provider, skipping")
+ return true
}
+ // 旧版 sdk-core 路径:手动读取 config + 注册崩溃拦截
+ val appKey = XuqmSDK.readAppKey(ctx)
if (appKey.isNullOrBlank()) {
Log.w("BugCollect", "BugCollectInitProvider: no appKey available, skipping")
return true
@@ -50,16 +42,6 @@ internal class BugCollectInitProvider : ContentProvider() {
getUserId = { XuqmSDK.getUserId() ?: XuqmSDK.deviceId },
)
- // ── Phase 2:远端配置就绪后上传 ─────────────────────────────────────────
- CoroutineScope(SupervisorJob() + Dispatchers.IO).launch {
- runCatching {
- XuqmSDK.awaitInitialization()
- BugCollect.uploadPendingCrashes()
- }.onFailure { e ->
- Log.w("BugCollect", "Pending crash upload failed after init: ${e.message}")
- }
- }
-
return true
}
diff --git a/sdk-core/src/main/AndroidManifest.xml b/sdk-core/src/main/AndroidManifest.xml
index f8741b0..9568103 100644
--- a/sdk-core/src/main/AndroidManifest.xml
+++ b/sdk-core/src/main/AndroidManifest.xml
@@ -1,9 +1,14 @@
+
+ android:initOrder="50" />
diff --git a/sdk-core/src/main/java/com/xuqm/sdk/XuqmSDK.kt b/sdk-core/src/main/java/com/xuqm/sdk/XuqmSDK.kt
index 1844e8d..fc0e252 100644
--- a/sdk-core/src/main/java/com/xuqm/sdk/XuqmSDK.kt
+++ b/sdk-core/src/main/java/com/xuqm/sdk/XuqmSDK.kt
@@ -120,7 +120,7 @@ object XuqmSDK {
return
}
// 异步:TokenStore / DeviceId / ApiClient / 远端配置
- kotlinx.coroutines.CoroutineScope(kotlinx.coroutines.Dispatchers.IO + kotlinx.coroutines.SupervisorJob()).launch {
+ sdkScope.launch {
runCatching {
initialize(ctx, configFile.appKey, configFile.serverUrl, logLevel)
}.onFailure { e ->
@@ -129,6 +129,79 @@ object XuqmSDK {
}
}
+ /**
+ * 合并 Provider 调用入口 — 主线程已读好原始字节,IO 线程完成解密+初始化。
+ *
+ * 与 [autoInitializeAsync] 的区别:
+ * - 原始字节由调用方传入(主线程只做文件读取,不做 PBKDF2)
+ * - 解密走 ConfigCache(内存→磁盘),后续启动跳过 PBKDF2
+ * - 复用 sdkScope,不创建冗余 CoroutineScope
+ * - 初始化完成后自动触发 BugCollect / License 等子 SDK 启动
+ */
+ fun autoInitializeFromBytes(context: Context, rawBytes: ByteArray, logLevel: LogLevel = LogLevel.WARN) {
+ val ctx = context.applicationContext
+ sdkScope.launch {
+ runCatching {
+ // 1. 带缓存的解密(PBKDF2 只在首次或文件变更时执行)
+ val configFile = ConfigFileReader.readWithCache(ctx)
+ if (configFile == null) {
+ Log.w(TAG, "autoInitializeFromBytes: failed to read/decrypt config")
+ return@launch
+ }
+
+ val configPackageName = configFile.packageName
+ if (!configPackageName.isNullOrBlank() && configPackageName != ctx.packageName) {
+ Log.w(TAG, "autoInitializeFromBytes: package name mismatch, skipping")
+ return@launch
+ }
+
+ // 2. 初始化 sdk-core
+ initialize(ctx, configFile.appKey, configFile.serverUrl, logLevel)
+
+ // 3. 初始化 sdk-bugcollect(如果存在)
+ runCatching {
+ initBugCollect(ctx, configFile.appKey)
+ }.onFailure { e ->
+ Log.w(TAG, "BugCollect init failed: ${e.message}")
+ }
+
+ // 4. 初始化 sdk-license(如果存在)
+ runCatching {
+ initLicense(ctx)
+ }.onFailure { e ->
+ Log.w(TAG, "License init failed: ${e.message}")
+ }
+ }.onFailure { e ->
+ Log.w(TAG, "autoInitializeFromBytes failed: ${e.message}")
+ }
+ }
+ }
+
+ /**
+ * 通过反射初始化 sdk-bugcollect,避免 sdk-core 对 sdk-bugcollect 的编译时依赖。
+ */
+ private fun initBugCollect(context: Context, appKey: String) {
+ val clazz = runCatching {
+ Class.forName("com.xuqm.sdk.bugcollect.BugCollect")
+ }.getOrNull() ?: return
+
+ // BugCollect.startCrashCapture() — 内部检查 XuqmSDK.isInitialized()
+ val startMethod = clazz.getMethod("startCrashCapture")
+ startMethod.invoke(null)
+ }
+
+ /**
+ * 通过反射初始化 sdk-license,避免 sdk-core 对 sdk-license 的编译时依赖。
+ */
+ private fun initLicense(context: Context) {
+ val clazz = runCatching {
+ Class.forName("com.xuqm.sdk.license.internal.LicenseContextHolder")
+ }.getOrNull() ?: return
+
+ val initMethod = clazz.getMethod("init", Context::class.java)
+ initMethod.invoke(null, context)
+ }
+
/**
* 方式 B:手动初始化。
*
diff --git a/sdk-core/src/main/java/com/xuqm/sdk/internal/ConfigCache.kt b/sdk-core/src/main/java/com/xuqm/sdk/internal/ConfigCache.kt
new file mode 100644
index 0000000..854726a
--- /dev/null
+++ b/sdk-core/src/main/java/com/xuqm/sdk/internal/ConfigCache.kt
@@ -0,0 +1,94 @@
+package com.xuqm.sdk.internal
+
+import android.content.Context
+import android.util.Log
+import androidx.security.crypto.EncryptedSharedPreferences
+import androidx.security.crypto.MasterKey
+import java.security.MessageDigest
+
+/**
+ * 持久化缓存解密后的配置文件内容。
+ *
+ * 使用 EncryptedSharedPreferences 存储 appKey / serverUrl / packageName,
+ * 缓存 key 为配置文件原始内容的 SHA-256。文件变更时自动重新解密。
+ *
+ * 作用:避免每次冷启动都执行 PBKDF2(120,000 次迭代,~100ms)。
+ */
+internal object ConfigCache {
+
+ private const val TAG = "XuqmSDK"
+ private const val PREFS_NAME = "xuqm_config_cache"
+ private const val KEY_HASH = "config_hash"
+ private const val KEY_APP_KEY = "app_key"
+ private const val KEY_SERVER_URL = "server_url"
+ private const val KEY_PACKAGE_NAME = "package_name"
+
+ /** 内存级单例缓存,避免同进程内重复解密。 */
+ @Volatile
+ private var memoryCached: CachedConfig? = null
+
+ data class CachedConfig(
+ val appKey: String,
+ val serverUrl: String?,
+ val packageName: String?,
+ )
+
+ /**
+ * 从缓存读取配置。内存缓存命中直接返回;否则查 EncryptedSharedPreferences。
+ * 文件内容 hash 不匹配时返回 null(需要重新解密)。
+ */
+ fun load(context: Context, rawContent: ByteArray): CachedConfig? {
+ // 1. 内存缓存
+ memoryCached?.let { return it }
+
+ val currentHash = sha256(rawContent)
+ val prefs = getPrefs(context)
+ val savedHash = prefs.getString(KEY_HASH, null) ?: return null
+
+ if (savedHash != currentHash) return null
+
+ val appKey = prefs.getString(KEY_APP_KEY, null) ?: return null
+ val serverUrl = prefs.getString(KEY_SERVER_URL, null)
+ val packageName = prefs.getString(KEY_PACKAGE_NAME, null)
+
+ val cached = CachedConfig(appKey, serverUrl, packageName)
+ memoryCached = cached
+ return cached
+ }
+
+ /**
+ * 将解密后的配置写入缓存。
+ */
+ fun save(context: Context, rawContent: ByteArray, config: ConfigFile) {
+ val hash = sha256(rawContent)
+ getPrefs(context).edit().apply {
+ putString(KEY_HASH, hash)
+ putString(KEY_APP_KEY, config.appKey)
+ putString(KEY_SERVER_URL, config.serverUrl)
+ putString(KEY_PACKAGE_NAME, config.packageName)
+ apply()
+ }
+ memoryCached = CachedConfig(config.appKey, config.serverUrl, config.packageName)
+ }
+
+ /**
+ * 清除缓存(用于登出或配置重置场景)。
+ */
+ fun clear(context: Context) {
+ getPrefs(context).edit().clear().apply()
+ memoryCached = null
+ }
+
+ private fun getPrefs(context: Context) = EncryptedSharedPreferences.create(
+ context,
+ PREFS_NAME,
+ MasterKey.Builder(context).setKeyScheme(MasterKey.KeyScheme.AES256_GCM).build(),
+ EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
+ EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
+ )
+
+ private fun sha256(data: ByteArray): String {
+ val digest = MessageDigest.getInstance("SHA-256").digest(data)
+ return digest.joinToString("") { "%02x".format(it) }
+ }
+}
diff --git a/sdk-core/src/main/java/com/xuqm/sdk/internal/ConfigFileReader.kt b/sdk-core/src/main/java/com/xuqm/sdk/internal/ConfigFileReader.kt
index 54d56fc..4658ed8 100644
--- a/sdk-core/src/main/java/com/xuqm/sdk/internal/ConfigFileReader.kt
+++ b/sdk-core/src/main/java/com/xuqm/sdk/internal/ConfigFileReader.kt
@@ -9,9 +9,14 @@ import com.google.gson.Gson
*
* 搜索顺序(对齐 RN SDK 的 src/assets/config/):
* 1. assets/config/config.xuqmconfig 或 config.xuqm
- * 2. assets/config/\*.xuqmconfig(取第一个)
+ * 2. assets/config/*.xuqmconfig(取第一个)
* 3. assets/xuqm/config.xuqm(旧路径兼容)
- * 4. assets/xuqm/\*.xuqmconfig(旧路径兼容)
+ * 4. assets/xuqm/*.xuqmconfig(旧路径兼容)
+ *
+ * 缓存策略:
+ * - [readRawBytes]:只读取文件原始字节,不做解密(主线程安全,<5ms)
+ * - [readWithCache]:先查 ConfigCache(内存+磁盘),miss 时解密并写缓存
+ * - [read]:原始读取+解密,无缓存(兼容旧调用)
*/
internal object ConfigFileReader {
@@ -19,6 +24,49 @@ internal object ConfigFileReader {
private val CONFIG_DIRS = listOf("config", "xuqm")
private val gson = Gson()
+ /**
+ * 只读取配置文件原始字节,不做 PBKDF2 解密。
+ * 主线程安全,耗时 <5ms。用于合并 Provider 启动阶段。
+ */
+ fun readRawBytes(context: Context): ByteArray? {
+ val path = findConfigPath(context) ?: return null
+ return runCatching {
+ context.assets.open(path).use { it.readBytes() }
+ }.onFailure { e ->
+ Log.e(TAG, "Failed to read config file: $path", e)
+ }.getOrNull()
+ }
+
+ /**
+ * 带缓存的配置读取。优先查 ConfigCache(内存 → EncryptedSharedPreferences),
+ * 缓存 miss 时执行 PBKDF2 解密并写入缓存。
+ * 用于 IO 线程,不应在主线程调用。
+ */
+ fun readWithCache(context: Context): ConfigFile? {
+ val rawBytes = readRawBytes(context) ?: return null
+
+ // 1. 查缓存
+ ConfigCache.load(context, rawBytes)?.let { cached ->
+ Log.d(TAG, "Config loaded from cache")
+ return ConfigFile(appKey = cached.appKey, serverUrl = cached.serverUrl, packageName = cached.packageName)
+ }
+
+ // 2. 缓存 miss → 解密
+ return runCatching {
+ val json = ConfigFileCrypto.decrypt(String(rawBytes, Charsets.UTF_8))
+ val config = gson.fromJson(json, ConfigFile::class.java)
+ // 3. 写缓存
+ ConfigCache.save(context, rawBytes, config)
+ Log.d(TAG, "Config decrypted and cached")
+ config
+ }.onFailure { e ->
+ Log.e(TAG, "Failed to decrypt config file", e)
+ }.getOrNull()
+ }
+
+ /**
+ * 原始读取+解密,无缓存。兼容旧调用路径。
+ */
fun read(context: Context): ConfigFile? {
return try {
val path = findConfigPath(context) ?: run {
@@ -62,4 +110,3 @@ internal object ConfigFileReader {
return null
}
}
-
diff --git a/sdk-core/src/main/java/com/xuqm/sdk/internal/XuqmInitializerProvider.kt b/sdk-core/src/main/java/com/xuqm/sdk/internal/XuqmInitializerProvider.kt
index e0fc111..a6ae117 100644
--- a/sdk-core/src/main/java/com/xuqm/sdk/internal/XuqmInitializerProvider.kt
+++ b/sdk-core/src/main/java/com/xuqm/sdk/internal/XuqmInitializerProvider.kt
@@ -8,15 +8,16 @@ import android.util.Log
import com.xuqm.sdk.XuqmSDK
/**
- * Auto-initializes XuqmSDK at app startup when a config file is present.
- * Registered in sdk-core AndroidManifest so any module (im/update/push/license)
- * triggers one-shot initialization without explicit init calls from the app.
+ * 已废弃:由 [XuqmMergedProvider] 替代。
+ *
+ * 保留此类仅为向后兼容(旧宿主可能在 AndroidManifest 中手动注册)。
+ * 新版 sdk-core 的 AndroidManifest 已注册 XuqmMergedProvider,
+ * 此 Provider 不会自动注册,需宿主显式声明才会生效。
*/
+@Deprecated("Use XuqmMergedProvider instead", ReplaceWith("XuqmMergedProvider"))
class XuqmInitializerProvider : ContentProvider() {
override fun onCreate(): Boolean {
val ctx = context?.applicationContext ?: return true
- // 同步阶段只做最小工作:读取 config 文件设置 appKey(约 5ms)
- // TokenStore / DeviceId / ApiClient 等重初始化移到后台线程
runCatching {
if (!XuqmSDK.isInitialized()) {
XuqmSDK.autoInitializeAsync(ctx)
diff --git a/sdk-core/src/main/java/com/xuqm/sdk/internal/XuqmMergedProvider.kt b/sdk-core/src/main/java/com/xuqm/sdk/internal/XuqmMergedProvider.kt
new file mode 100644
index 0000000..bc5801d
--- /dev/null
+++ b/sdk-core/src/main/java/com/xuqm/sdk/internal/XuqmMergedProvider.kt
@@ -0,0 +1,52 @@
+package com.xuqm.sdk.internal
+
+import android.content.ContentProvider
+import android.content.ContentValues
+import android.database.Cursor
+import android.net.Uri
+import android.util.Log
+import com.xuqm.sdk.XuqmSDK
+
+/**
+ * 合并启动入口,替代 XuqmInitializerProvider + BugCollectInitProvider + LicenseInitializerProvider。
+ *
+ * 设计原则:主线程零加密运算。
+ * Phase 1(同步,主线程,<10ms):
+ * - 扫描 assets/config/ 或 assets/xuqm/ 找到 .xuqmconfig 文件
+ * - 读取文件原始字节到内存
+ * - 启动 IO 协程
+ * Phase 2(异步,IO 线程):
+ * - 查 ConfigCache(内存→磁盘),命中则跳过 PBKDF2
+ * - 缓存 miss 时执行 PBKDF2 解密(~100ms)并写缓存
+ * - 初始化 sdk-core(TokenStore / DeviceId / ApiClient)
+ * - 注册 sdk-bugcollect CrashCapture
+ * - 初始化 sdk-license LicenseContextHolder
+ * - 拉取远端平台配置
+ */
+class XuqmMergedProvider : ContentProvider() {
+
+ override fun onCreate(): Boolean {
+ val ctx = context?.applicationContext ?: return true
+
+ // ── Phase 1:主线程只读原始字节(<10ms)──────────────────────────
+ val rawBytes = ConfigFileReader.readRawBytes(ctx)
+ if (rawBytes == null) {
+ Log.w(TAG, "No config file found, skipping auto-init")
+ return true
+ }
+
+ // ── Phase 2:IO 线程完成所有重活 ─────────────────────────────────
+ XuqmSDK.autoInitializeFromBytes(ctx, rawBytes)
+ return true
+ }
+
+ override fun query(uri: Uri, projection: Array?, selection: String?, selectionArgs: Array?, sortOrder: String?): Cursor? = null
+ override fun getType(uri: Uri): String? = null
+ override fun insert(uri: Uri, values: ContentValues?): Uri? = null
+ override fun delete(uri: Uri, selection: String?, selectionArgs: Array?): Int = 0
+ override fun update(uri: Uri, values: ContentValues?, selection: String?, selectionArgs: Array?): Int = 0
+
+ companion object {
+ private const val TAG = "XuqmSDK"
+ }
+}