feat(sdk): 将许可证文件替换为初始化配置文件
- 将 license.xuqm 文件替换为 config.xuqm 配置文件 - 实现 ConfigFileReader 来读取和解密配置文件 - 添加 ConfigFileCrypto 用于配置文件加密解密 - 更新 autoInitialize 方法以从配置文件自动初始化 - 移除对 sdk-license 的反射依赖 - 在 HarmonySDK 中实现配置端点动态配置 - 更新 iOS SDK 中的配置文件读取逻辑 - 统一各平台配置文件格式和处理方式
这个提交包含在:
父节点
3aee02a2cd
当前提交
5fe859e975
@ -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
|
||||
|
||||
正在加载...
在新工单中引用
屏蔽一个用户