diff --git a/sdk-core/src/main/java/com/xuqm/sdk/XuqmSDK.kt b/sdk-core/src/main/java/com/xuqm/sdk/XuqmSDK.kt index c89a48a..ede2714 100644 --- a/sdk-core/src/main/java/com/xuqm/sdk/XuqmSDK.kt +++ b/sdk-core/src/main/java/com/xuqm/sdk/XuqmSDK.kt @@ -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? = 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 - }.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) { diff --git a/sdk-core/src/main/java/com/xuqm/sdk/internal/ConfigFile.kt b/sdk-core/src/main/java/com/xuqm/sdk/internal/ConfigFile.kt new file mode 100644 index 0000000..9ba910d --- /dev/null +++ b/sdk-core/src/main/java/com/xuqm/sdk/internal/ConfigFile.kt @@ -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, +) diff --git a/sdk-core/src/main/java/com/xuqm/sdk/internal/ConfigFileCrypto.kt b/sdk-core/src/main/java/com/xuqm/sdk/internal/ConfigFileCrypto.kt new file mode 100644 index 0000000..a2bd603 --- /dev/null +++ b/sdk-core/src/main/java/com/xuqm/sdk/internal/ConfigFileCrypto.kt @@ -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...). + * 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) +} diff --git a/sdk-core/src/main/java/com/xuqm/sdk/internal/ConfigFileReader.kt b/sdk-core/src/main/java/com/xuqm/sdk/internal/ConfigFileReader.kt new file mode 100644 index 0000000..0ed76eb --- /dev/null +++ b/sdk-core/src/main/java/com/xuqm/sdk/internal/ConfigFileReader.kt @@ -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() + } +} diff --git a/sdk-license/src/main/AndroidManifest.xml b/sdk-license/src/main/AndroidManifest.xml index 0eeb054..a2f47b6 100644 --- a/sdk-license/src/main/AndroidManifest.xml +++ b/sdk-license/src/main/AndroidManifest.xml @@ -1,9 +1,2 @@ - - - diff --git a/sdk-license/src/main/java/com/xuqm/sdk/license/LicenseSDK.kt b/sdk-license/src/main/java/com/xuqm/sdk/license/LicenseSDK.kt index 95eb9f3..8463b7f 100644 --- a/sdk-license/src/main/java/com/xuqm/sdk/license/LicenseSDK.kt +++ b/sdk-license/src/main/java/com/xuqm/sdk/license/LicenseSDK.kt @@ -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? { - 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") - 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) + // Wait for XuqmSDK initialization (poll-based) + val maxWaitMs = 30000L + val start = System.currentTimeMillis() + 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