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.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
|
||||||
|
|||||||
正在加载...
在新工单中引用
屏蔽一个用户