sdk: auto-init from license file, cross-module init waiting

- XuqmSDK: autoInitialize() validates license package name; afterInit() callback
- LicenseSDK: await XuqmSDK init via reflection with timeout before operations
- UpdateSDK: await XuqmSDK init before checkAppUpdate
- LicenseFileReader: return Triple(appKey, serverUrl, packageName)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
这个提交包含在:
XuqmGroup 2026-05-22 17:56:42 +08:00
父节点 7b18d279bf
当前提交 63da94c670
共有 5 个文件被更改,包括 99 次插入15 次删除

查看文件

@ -27,6 +27,9 @@ object XuqmSDK {
@Volatile @Volatile
private var loginSession: XuqmLoginSession? = null private var loginSession: XuqmLoginSession? = null
private val initLock = Any()
private val pendingInitCallbacks = mutableListOf<() -> Unit>()
/** /**
* Initializes the SDK automatically from the license file embedded in assets/xuqm/. * 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 * Place the license.xuqm file downloaded from the tenant platform into your app's
@ -35,16 +38,37 @@ object XuqmSDK {
* For private deployments the license file contains the server URL and all service * For private deployments the license 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.
* 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 (appKey, serverUrl) = readLicenseFileData(context) val licenseData = readLicenseFileData(context)
?: throw IllegalStateException( ?: throw IllegalStateException(
"No license file found in assets/xuqm/. " + "No license file found in assets/xuqm/. " +
"Download license.xuqm from the tenant platform and place it in src/main/assets/xuqm/." "Download license.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 localPackageName = context.packageName
if (licensePackageName != null && licensePackageName.isNotBlank() && licensePackageName != localPackageName) {
throw IllegalStateException(
"License package name mismatch: license=$licensePackageName, local=$localPackageName. " +
"Please download the correct license file for this app."
)
}
initialize(context, appKey, serverUrl, logLevel) initialize(context, appKey, serverUrl, logLevel)
} }
/**
* Manual initialization without license file.
* The SDK will validate the appKey against the server by calling /api/sdk/config
* with the local package name. If the package name does not match the registered one,
* an exception is thrown.
*/
fun initialize( fun initialize(
context: Context, context: Context,
appKey: String, appKey: String,
@ -52,7 +76,7 @@ object XuqmSDK {
logLevel: LogLevel = LogLevel.WARN, logLevel: LogLevel = LogLevel.WARN,
) { ) {
val applicationContext = context.applicationContext val applicationContext = context.applicationContext
synchronized(this) { synchronized(initLock) {
if (initialized) { if (initialized) {
check(initializedAppKey == appKey) { check(initializedAppKey == appKey) {
"XuqmSDK already initialized with appKey=$initializedAppKey" "XuqmSDK already initialized with appKey=$initializedAppKey"
@ -66,16 +90,33 @@ object XuqmSDK {
ApiClient.init(config, tokenStore) ApiClient.init(config, tokenStore)
initializedAppKey = appKey initializedAppKey = appKey
initialized = true initialized = true
pendingInitCallbacks.forEach { runCatching(it) }
pendingInitCallbacks.clear()
} }
serverUrl?.takeIf { it.isNotBlank() }?.let { configurePrivateServer(context, appKey, it) } serverUrl?.takeIf { it.isNotBlank() }?.let { configurePrivateServer(context, appKey, it) }
} }
/**
* Execute the given block after initialization is complete.
* If already initialized, executes immediately.
* If not initialized, waits until initialization completes.
*/
fun afterInit(block: () -> Unit) {
synchronized(initLock) {
if (initialized) {
block()
} else {
pendingInitCallbacks.add(block)
}
}
}
@Suppress("UNCHECKED_CAST") @Suppress("UNCHECKED_CAST")
private fun readLicenseFileData(context: Context): Pair<String, String?>? = runCatching { private fun readLicenseFileData(context: Context): Triple<String, String?, String?>? = runCatching {
val clazz = Class.forName("com.xuqm.sdk.license.LicenseSDK") val clazz = Class.forName("com.xuqm.sdk.license.LicenseSDK")
val instance = clazz.getField("INSTANCE").get(null) val instance = clazz.getField("INSTANCE").get(null)
val method = clazz.getMethod("readLicenseFileData", Context::class.java) val method = clazz.getMethod("readLicenseFileData", Context::class.java)
method.invoke(instance, context.applicationContext) as? Pair<String, String?> method.invoke(instance, context.applicationContext) as? Triple<String, String?, String?>
}.getOrNull() }.getOrNull()
private fun configurePrivateServer(context: Context, appKey: String, serverUrl: String) { private fun configurePrivateServer(context: Context, appKey: String, serverUrl: String) {
@ -124,6 +165,8 @@ object XuqmSDK {
check(initialized) { "XuqmSDK not initialized. Call XuqmSDK.initialize() first." } check(initialized) { "XuqmSDK not initialized. Call XuqmSDK.initialize() first." }
} }
fun isInitialized(): Boolean = synchronized(initLock) { initialized }
val appKey: String get() = config.appKey val appKey: String get() = config.appKey
val currentLoginSession: XuqmLoginSession? val currentLoginSession: XuqmLoginSession?

查看文件

@ -73,10 +73,8 @@ object LicenseSDK {
@JvmOverloads @JvmOverloads
fun checkLicense(userInfo: LicenseUserInfo? = null, callback: LicenseCallback) { fun checkLicense(userInfo: LicenseUserInfo? = null, callback: LicenseCallback) {
sdkScope.launch { sdkScope.launch {
val valid = when (checkLicense(userInfo)) { val result = checkLicense(userInfo)
is LicenseResult.Success -> true val valid = result is LicenseResult.Success
is LicenseResult.Error -> false
}
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
callback.onResult(valid) callback.onResult(valid)
} }
@ -190,13 +188,13 @@ object LicenseSDK {
} }
/** /**
* Returns (appKey, serverUrl) from the embedded license file, or null if no file is present. * 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. * Called by XuqmSDK.autoInitialize() via reflection sdk-core cannot depend on sdk-license directly.
*/ */
fun readLicenseFileData(context: Context): Pair<String, String?>? { fun readLicenseFileData(context: Context): Triple<String, String?, String?>? {
val licenseFile = LicenseFileReader.read(context) ?: return null val licenseFile = LicenseFileReader.read(context) ?: return null
val appKey = licenseFile.appKey.takeIf { it.isNotBlank() } ?: return null val appKey = licenseFile.appKey.takeIf { it.isNotBlank() } ?: return null
return appKey to licenseFile.serverUrl?.takeIf { it.isNotBlank() } return Triple(appKey, licenseFile.serverUrl?.takeIf { it.isNotBlank() }, licenseFile.packageName)
} }
/** /**
@ -224,10 +222,26 @@ object LicenseSDK {
store.statusTime = System.currentTimeMillis() store.statusTime = System.currentTimeMillis()
} }
private fun ensureInitialized() { 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")
val maxWaitMs = 30000L
val start = System.currentTimeMillis()
while (!(isInitMethod.invoke(null) as Boolean)) {
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) { if (!initialized) {
tryAutoInitialize() tryAutoInitialize()
} }
}
requireInit() requireInit()
} }

查看文件

@ -29,6 +29,15 @@ internal object LicenseFileReader {
return findLicensePath(context) != null return findLicensePath(context) != null
} }
fun readRaw(context: Context): String? {
return try {
val path = findLicensePath(context) ?: return null
context.assets.open(path).use { it.bufferedReader().readText().trim() }
} catch (_: Exception) {
null
}
}
private fun findLicensePath(context: Context): String? { private fun findLicensePath(context: Context): String? {
try { try {
context.assets.open(LICENSE_PATH).close() context.assets.open(LICENSE_PATH).close()

查看文件

@ -10,8 +10,10 @@ data class LicenseFile(
@SerializedName(value = "appKey", alternate = ["app_key"]) val appKey: String, @SerializedName(value = "appKey", alternate = ["app_key"]) val appKey: String,
@SerializedName(value = "appName", alternate = ["app_name"]) val appName: String? = null, @SerializedName(value = "appName", alternate = ["app_name"]) val appName: String? = null,
@SerializedName(value = "companyName", alternate = ["company_name"]) val companyName: 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 = "baseUrl", alternate = ["base_url"]) val baseUrl: String? = null,
// Set only for private deployments; used by XuqmSDK.autoInitialize() to configure all service endpoints.
@SerializedName(value = "serverUrl", alternate = ["server_url"]) val serverUrl: String? = null, @SerializedName(value = "serverUrl", alternate = ["server_url"]) val serverUrl: String? = null,
@SerializedName(value = "issuedAt", alternate = ["issued_at"]) val issuedAt: String? = null, @SerializedName(value = "issuedAt", alternate = ["issued_at"]) val issuedAt: String? = null,
@SerializedName(value = "expiresAt", alternate = ["expires_at"]) val expiresAt: String? = null, @SerializedName(value = "expiresAt", alternate = ["expires_at"]) val expiresAt: String? = null,

查看文件

@ -33,7 +33,7 @@ object UpdateSDK {
} }
suspend fun checkAppUpdate(context: Context): UpdateInfo? = withContext(Dispatchers.IO) { suspend fun checkAppUpdate(context: Context): UpdateInfo? = withContext(Dispatchers.IO) {
XuqmSDK.requireInit() awaitInitialization()
val packageInfo = context.packageManager.getPackageInfo(context.packageName, 0) val packageInfo = context.packageManager.getPackageInfo(context.packageName, 0)
val versionCode = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { val versionCode = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
packageInfo.longVersionCode.toInt() packageInfo.longVersionCode.toInt()
@ -49,6 +49,22 @@ object UpdateSDK {
}.getOrNull() }.getOrNull()
} }
private suspend fun awaitInitialization() {
val xuqmClazz = runCatching { Class.forName("com.xuqm.sdk.XuqmSDK") }.getOrNull()
if (xuqmClazz != null) {
val isInitMethod = xuqmClazz.getMethod("isInitialized")
val maxWaitMs = 30000L
val start = System.currentTimeMillis()
while (!(isInitMethod.invoke(null) as Boolean)) {
if (System.currentTimeMillis() - start > maxWaitMs) {
throw IllegalStateException("UpdateSDK initialization timed out waiting for XuqmSDK")
}
kotlinx.coroutines.delay(100)
}
}
XuqmSDK.requireInit()
}
suspend fun downloadAndInstall( suspend fun downloadAndInstall(
context: Context, context: Context,
downloadUrl: String, downloadUrl: String,