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.ServiceEndpointRegistry
import com.xuqm.sdk.core.ServiceEndpoints import com.xuqm.sdk.core.ServiceEndpoints
import com.xuqm.sdk.core.SDKConfig import com.xuqm.sdk.core.SDKConfig
import com.xuqm.sdk.internal.ConfigFileReader
import com.xuqm.sdk.network.ApiClient import com.xuqm.sdk.network.ApiClient
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
@ -31,32 +32,32 @@ object XuqmSDK {
private val pendingInitCallbacks = mutableListOf<() -> Unit>() private val pendingInitCallbacks = mutableListOf<() -> Unit>()
/** /**
* Initializes the SDK automatically from the license file embedded in assets/xuqm/. * Initializes the SDK automatically from the init config file embedded in assets/xuqm/.
* Place the license.xuqm file downloaded from the tenant platform into your app's * 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. * 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 * endpoints are configured automatically. For public deployments the default endpoints
* (dev.xuqinmin.com) are used. * (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. * If they don't match, an exception is thrown.
*/ */
fun autoInitialize(context: Context, logLevel: LogLevel = LogLevel.WARN) { fun autoInitialize(context: Context, logLevel: LogLevel = LogLevel.WARN) {
val licenseData = readLicenseFileData(context) val configFile = ConfigFileReader.read(context)
?: throw IllegalStateException( ?: throw IllegalStateException(
"No license file found in assets/xuqm/. " + "No config file found in assets/xuqm/. " +
"Download license.xuqm from the tenant platform and place it in src/main/assets/xuqm/." "Download config.xuqm from the tenant platform and place it in src/main/assets/xuqm/."
) )
val appKey = licenseData.first val appKey = configFile.appKey
val serverUrl = licenseData.second val serverUrl = configFile.serverUrl
val licensePackageName = licenseData.third val configPackageName = configFile.packageName
val localPackageName = context.packageName val localPackageName = context.packageName
if (licensePackageName != null && licensePackageName.isNotBlank() && licensePackageName != localPackageName) { if (!configPackageName.isNullOrBlank() && configPackageName != localPackageName) {
throw IllegalStateException( throw IllegalStateException(
"License package name mismatch: license=$licensePackageName, local=$localPackageName. " + "Config package name mismatch: config=$configPackageName, local=$localPackageName. " +
"Please download the correct license file for this app." "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) { private fun configurePrivateServer(context: Context, appKey: String, serverUrl: String) {
val base = serverUrl.trimEnd('/') + "/" val base = serverUrl.trimEnd('/') + "/"
val wsBase = serverUrl.trimEnd('/') val wsBase = serverUrl.trimEnd('/')
@ -134,17 +127,6 @@ object XuqmSDK {
updateBaseUrl = base, 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) { 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"> <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> </manifest>

查看文件

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