feat(sdk): 将许可证文件替换为初始化配置文件

- 将 license.xuqm 文件替换为 config.xuqm 配置文件
- 实现 ConfigFileReader 来读取和解密配置文件
- 添加 ConfigFileCrypto 用于配置文件加密解密
- 更新 autoInitialize 方法以从配置文件自动初始化
- 移除对 sdk-license 的反射依赖
- 在 HarmonySDK 中实现配置端点动态配置
- 更新 iOS SDK 中的配置文件读取逻辑
- 统一各平台配置文件格式和处理方式
这个提交包含在:
XuqmGroup 2026-06-02 17:15:49 +08:00
父节点 3aee02a2cd
当前提交 5fe859e975
共有 6 个文件被更改,包括 145 次插入82 次删除

查看文件

@ -6,6 +6,7 @@ import com.xuqm.sdk.core.LogLevel
import com.xuqm.sdk.core.ServiceEndpointRegistry
import com.xuqm.sdk.core.ServiceEndpoints
import com.xuqm.sdk.core.SDKConfig
import com.xuqm.sdk.internal.ConfigFileReader
import com.xuqm.sdk.network.ApiClient
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
@ -31,32 +32,32 @@ object XuqmSDK {
private val pendingInitCallbacks = mutableListOf<() -> Unit>()
/**
* Initializes the SDK automatically from the license file embedded in assets/xuqm/.
* Place the license.xuqm file downloaded from the tenant platform into your app's
* Initializes the SDK automatically from the init config file embedded in assets/xuqm/.
* Place the config.xuqm file downloaded from the tenant platform into your app's
* src/main/assets/xuqm/ directory no hardcoded appKey or serverUrl needed.
*
* For private deployments the license file contains the server URL and all service
* For private deployments the config file contains the server URL and all service
* endpoints are configured automatically. For public deployments the default endpoints
* (dev.xuqinmin.com) are used.
*
* The license file's packageName/bundleId is validated against the local app package name.
* The config file's packageName/bundleId is validated against the local app package name.
* If they don't match, an exception is thrown.
*/
fun autoInitialize(context: Context, logLevel: LogLevel = LogLevel.WARN) {
val licenseData = readLicenseFileData(context)
val configFile = ConfigFileReader.read(context)
?: throw IllegalStateException(
"No license file found in assets/xuqm/. " +
"Download license.xuqm from the tenant platform and place it in src/main/assets/xuqm/."
"No config file found in assets/xuqm/. " +
"Download config.xuqm from the tenant platform and place it in src/main/assets/xuqm/."
)
val appKey = licenseData.first
val serverUrl = licenseData.second
val licensePackageName = licenseData.third
val appKey = configFile.appKey
val serverUrl = configFile.serverUrl
val configPackageName = configFile.packageName
val localPackageName = context.packageName
if (licensePackageName != null && licensePackageName.isNotBlank() && licensePackageName != localPackageName) {
if (!configPackageName.isNullOrBlank() && configPackageName != localPackageName) {
throw IllegalStateException(
"License package name mismatch: license=$licensePackageName, local=$localPackageName. " +
"Please download the correct license file for this app."
"Config package name mismatch: config=$configPackageName, local=$localPackageName. " +
"Please download the correct config file for this app."
)
}
@ -111,14 +112,6 @@ object XuqmSDK {
}
}
@Suppress("UNCHECKED_CAST")
private fun readLicenseFileData(context: Context): Triple<String, String?, String?>? = runCatching {
val clazz = Class.forName("com.xuqm.sdk.license.LicenseSDK")
val instance = clazz.getField("INSTANCE").get(null)
val method = clazz.getMethod("readLicenseFileData", Context::class.java)
method.invoke(instance, context.applicationContext) as? Triple<String, String?, String?>
}.getOrNull()
private fun configurePrivateServer(context: Context, appKey: String, serverUrl: String) {
val base = serverUrl.trimEnd('/') + "/"
val wsBase = serverUrl.trimEnd('/')
@ -134,17 +127,6 @@ object XuqmSDK {
updateBaseUrl = base,
)
)
// Initialize LicenseSDK via reflection — sdk-core cannot depend on sdk-license
runCatching {
val clazz = Class.forName("com.xuqm.sdk.license.LicenseSDK")
val instance = clazz.getField("INSTANCE").get(null)
val method = clazz.methods.firstOrNull { m ->
m.name == "initialize" &&
m.parameterTypes.size == 4 &&
m.parameterTypes[3] == String::class.java
}
method?.invoke(instance, context.applicationContext, appKey, null, base)
}
}
fun configureServiceEndpoints(endpoints: ServiceEndpoints) {

查看文件

@ -0,0 +1,21 @@
package com.xuqm.sdk.internal
import com.google.gson.annotations.SerializedName
/**
* Decrypted content of the init config file (assets/xuqm/config.xuqm).
* Contains the appKey and optional server URL needed to bootstrap the SDK.
* This is separate from sdk-license's LicenseFile (device activation).
*/
internal data class ConfigFile(
@SerializedName(value = "appKey", alternate = ["app_key"]) val appKey: String,
@SerializedName(value = "appName", alternate = ["app_name"]) val appName: String? = null,
@SerializedName(value = "companyName", alternate = ["company_name"]) val companyName: String? = null,
@SerializedName(value = "packageName", alternate = ["package_name"]) val packageName: String? = null,
@SerializedName(value = "iosBundleId", alternate = ["ios_bundle_id"]) val iosBundleId: String? = null,
@SerializedName(value = "harmonyBundleName", alternate = ["harmony_bundle_name"]) val harmonyBundleName: String? = null,
@SerializedName(value = "baseUrl", alternate = ["base_url"]) val baseUrl: String? = null,
@SerializedName(value = "serverUrl", alternate = ["server_url"]) val serverUrl: String? = null,
@SerializedName(value = "issuedAt", alternate = ["issued_at"]) val issuedAt: String? = null,
@SerializedName(value = "expiresAt", alternate = ["expires_at"]) val expiresAt: String? = null,
)

查看文件

@ -0,0 +1,40 @@
package com.xuqm.sdk.internal
import android.util.Base64
import javax.crypto.Cipher
import javax.crypto.SecretKeyFactory
import javax.crypto.spec.GCMParameterSpec
import javax.crypto.spec.PBEKeySpec
import javax.crypto.spec.SecretKeySpec
/**
* Decrypts the init config file (format: XUQM-CONFIG-V1.<salt>.<iv>.<ciphertext>).
* Algorithm: AES-256-GCM with PBKDF2-HMAC-SHA256 key derivation (120,000 iterations).
*/
internal object ConfigFileCrypto {
private const val MAGIC = "XUQM-CONFIG-V1"
private const val PASSPHRASE = "xuqm-config-file-v1.2026.internal"
private const val KEY_BITS = 256
private const val ITERATIONS = 120_000
private const val GCM_TAG_BITS = 128
fun decrypt(content: String): String {
val parts = content.trim().split(".")
require(parts.size == 4 && parts[0] == MAGIC) { "Invalid config file format" }
val salt = decode(parts[1])
val iv = decode(parts[2])
val cipherText = decode(parts[3])
val cipher = Cipher.getInstance("AES/GCM/NoPadding")
cipher.init(Cipher.DECRYPT_MODE, deriveKey(salt), GCMParameterSpec(GCM_TAG_BITS, iv))
return cipher.doFinal(cipherText).toString(Charsets.UTF_8)
}
private fun deriveKey(salt: ByteArray): SecretKeySpec {
val spec = PBEKeySpec(PASSPHRASE.toCharArray(), salt, ITERATIONS, KEY_BITS)
val encoded = SecretKeyFactory.getInstance("PBKDF2WithHmacSHA256").generateSecret(spec).encoded
return SecretKeySpec(encoded, "AES")
}
private fun decode(value: String): ByteArray =
Base64.decode(value, Base64.URL_SAFE or Base64.NO_PADDING or Base64.NO_WRAP)
}

查看文件

@ -0,0 +1,47 @@
package com.xuqm.sdk.internal
import android.content.Context
import com.google.gson.Gson
/**
* Reads and decrypts the init config file from assets/xuqm/.
* Looks for config.xuqm first, then falls back to any *.xuqmconfig file.
* This is used by XuqmSDK.autoInitialize() and does NOT depend on sdk-license.
*/
internal object ConfigFileReader {
private const val CONFIG_PATH = "xuqm/config.xuqm"
private const val CONFIG_DIR = "xuqm"
private val gson = Gson()
fun read(context: Context): ConfigFile? {
return try {
val path = findConfigPath(context) ?: return null
context.assets.open(path).use { stream ->
val encrypted = stream.bufferedReader().readText()
val json = ConfigFileCrypto.decrypt(encrypted)
gson.fromJson(json, ConfigFile::class.java)
}
} catch (_: Exception) {
null
}
}
fun exists(context: Context): Boolean {
return findConfigPath(context) != null
}
private fun findConfigPath(context: Context): String? {
try {
context.assets.open(CONFIG_PATH).close()
return CONFIG_PATH
} catch (_: Exception) {
// Try downloaded filenames such as appName.xuqmconfig.
}
return runCatching {
context.assets.list(CONFIG_DIR)
?.firstOrNull { it.endsWith(".xuqmconfig", ignoreCase = true) }
?.let { "$CONFIG_DIR/$it" }
}.getOrNull()
}
}

查看文件

@ -1,9 +1,2 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application>
<provider
android:name=".internal.LicenseInitializerProvider"
android:authorities="${applicationId}.xuqm-license-init"
android:exported="false"
android:initOrder="100" />
</application>
</manifest>

查看文件

@ -1,10 +1,9 @@
package com.xuqm.sdk.license
import android.content.Context
import com.xuqm.sdk.XuqmSDK
import com.xuqm.sdk.license.api.LicenseApiService
import com.xuqm.sdk.license.internal.DeviceInfoProvider
import com.xuqm.sdk.license.internal.LicenseContextHolder
import com.xuqm.sdk.license.internal.LicenseFileReader
import com.xuqm.sdk.license.internal.LicenseHttpClient
import com.xuqm.sdk.license.model.RegisterRequest
import com.xuqm.sdk.license.model.LicenseUserInfo
@ -31,8 +30,8 @@ object LicenseSDK {
private val sdkScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
/**
* Optional manual initialization. Normal apps only need to place the encrypted
* license file under assets/xuqm/license.xuqm and call checkLicense().
* Optional manual initialization. Typically not needed call checkLicense() directly
* after XuqmSDK is initialized (via config file or manual init).
*
* @param context Application context
* @param appKey The company appKey (e.g., "f713d051-0fbe-4f2d-bec9-bf7b96fc9ce4")
@ -190,9 +189,10 @@ object LicenseSDK {
/**
* Get current license status without network call.
* Automatically uses appKey from XuqmSDK if not manually initialized.
*/
fun getStatus(): LicenseStatus {
if (!initialized && !tryAutoInitialize()) return LicenseStatus.UNKNOWN
if (!initialized && !ensureInitializedFromSDK()) return LicenseStatus.UNKNOWN
return when (store.status) {
STATUS_OK -> LicenseStatus.OK
STATUS_DENIED -> LicenseStatus.DENIED
@ -202,22 +202,13 @@ object LicenseSDK {
/**
* Get the stable device ID used for registration.
* Automatically uses appKey from XuqmSDK if not manually initialized.
*/
fun getDeviceId(): String? {
if (!initialized && !tryAutoInitialize()) return null
if (!initialized && !ensureInitializedFromSDK()) return null
return store.deviceId
}
/**
* Returns (appKey, serverUrl, packageName) from the embedded license file, or null if no file is present.
* Called by XuqmSDK.autoInitialize() via reflection sdk-core cannot depend on sdk-license directly.
*/
fun readLicenseFileData(context: Context): Triple<String, String?, String?>? {
val licenseFile = LicenseFileReader.read(context) ?: return null
val appKey = licenseFile.appKey.takeIf { it.isNotBlank() } ?: return null
return Triple(appKey, licenseFile.serverUrl?.takeIf { it.isNotBlank() }, licenseFile.packageName)
}
/**
* Clear all license data (token, device ID, status).
*/
@ -245,39 +236,29 @@ object LicenseSDK {
private suspend fun ensureInitialized() {
if (!initialized) {
// Wait for XuqmSDK initialization via reflection (poll-based)
val xuqmClazz = runCatching { Class.forName("com.xuqm.sdk.XuqmSDK") }.getOrNull()
if (xuqmClazz != null) {
val isInitMethod = xuqmClazz.getMethod("isInitialized")
// Wait for XuqmSDK initialization (poll-based)
val maxWaitMs = 30000L
val start = System.currentTimeMillis()
while (!(isInitMethod.invoke(null) as Boolean)) {
while (!XuqmSDK.isInitialized()) {
if (System.currentTimeMillis() - start > maxWaitMs) {
throw IllegalStateException("LicenseSDK initialization timed out waiting for XuqmSDK")
}
kotlinx.coroutines.delay(100)
}
}
// After XuqmSDK is initialized, try to auto-initialize LicenseSDK
if (!initialized) {
tryAutoInitialize()
}
ensureInitializedFromSDK()
}
requireInit()
}
private fun tryAutoInitialize(): Boolean {
/**
* Initialize from XuqmSDK's config. Called when LicenseSDK is used before manual init.
*/
private fun ensureInitializedFromSDK(): Boolean {
synchronized(this) {
if (initialized) return true
val ctx = LicenseContextHolder.appContext ?: return false
val licenseFile = LicenseFileReader.read(ctx) ?: return false
val appKey = licenseFile.appKey.takeIf { it.isNotBlank() } ?: return false
// Validate package name locally before any network call
val configuredPackageName = licenseFile.packageName
if (!configuredPackageName.isNullOrBlank() && configuredPackageName != ctx.packageName) {
return false
}
if (!XuqmSDK.isInitialized()) return false
val ctx = XuqmSDK.appContext
val appKey = XuqmSDK.appKey
appContext = ctx
store = LicenseStore(ctx)
@ -287,9 +268,8 @@ object LicenseSDK {
store.appKey = appKey
config = LicenseConfig(
appKey = appKey,
baseUrl = normalizeBaseUrl(licenseFile.baseUrl ?: DEFAULT_BASE_URL),
baseUrl = DEFAULT_BASE_URL,
deviceName = DeviceInfoProvider.getDeviceName(),
fromLicenseFile = true,
)
apiService = LicenseHttpClient.create(LicenseApiService::class.java, config.baseUrl)
initialized = true