2026-05-15 21:00:24 +08:00
|
|
|
package com.xuqm.sdk.license
|
|
|
|
|
|
|
|
|
|
import android.content.Context
|
|
|
|
|
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.VerifyRequest
|
|
|
|
|
import com.xuqm.sdk.license.store.LicenseStore
|
|
|
|
|
import kotlinx.coroutines.CoroutineScope
|
|
|
|
|
import kotlinx.coroutines.Dispatchers
|
|
|
|
|
import kotlinx.coroutines.SupervisorJob
|
|
|
|
|
import kotlinx.coroutines.launch
|
|
|
|
|
import kotlinx.coroutines.withContext
|
|
|
|
|
|
|
|
|
|
object LicenseSDK {
|
|
|
|
|
|
|
|
|
|
private const val STATUS_OK = "ok"
|
|
|
|
|
private const val STATUS_DENIED = "denied"
|
2026-05-15 21:09:32 +08:00
|
|
|
private const val DEFAULT_BASE_URL = "https://auth.dev.xuqinmin.com/"
|
2026-05-15 21:00:24 +08:00
|
|
|
|
|
|
|
|
private var initialized = false
|
|
|
|
|
private lateinit var appContext: Context
|
|
|
|
|
private lateinit var config: LicenseConfig
|
|
|
|
|
private lateinit var store: LicenseStore
|
|
|
|
|
private lateinit var apiService: LicenseApiService
|
|
|
|
|
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().
|
|
|
|
|
*
|
|
|
|
|
* @param context Application context
|
|
|
|
|
* @param appKey The company appKey (e.g., "f713d051-0fbe-4f2d-bec9-bf7b96fc9ce4")
|
|
|
|
|
* @param deviceName Optional device name for identification
|
2026-05-15 21:09:32 +08:00
|
|
|
* @param baseUrl Optional custom license server base URL. Defaults to https://auth.dev.xuqinmin.com/
|
2026-05-15 21:00:24 +08:00
|
|
|
*/
|
|
|
|
|
@JvmStatic
|
|
|
|
|
@JvmOverloads
|
|
|
|
|
fun initialize(
|
|
|
|
|
context: Context,
|
|
|
|
|
appKey: String,
|
|
|
|
|
deviceName: String? = null,
|
|
|
|
|
baseUrl: String? = null,
|
|
|
|
|
) {
|
|
|
|
|
synchronized(this) {
|
|
|
|
|
val ctx = context.applicationContext
|
|
|
|
|
appContext = ctx
|
|
|
|
|
store = LicenseStore(ctx)
|
|
|
|
|
|
|
|
|
|
// If appKey changed, clear old data to force re-registration
|
|
|
|
|
if (store.appKey != null && store.appKey != appKey) {
|
|
|
|
|
store.clear()
|
|
|
|
|
}
|
|
|
|
|
store.appKey = appKey
|
|
|
|
|
|
|
|
|
|
config = LicenseConfig(
|
|
|
|
|
appKey = appKey,
|
|
|
|
|
baseUrl = normalizeBaseUrl(baseUrl ?: DEFAULT_BASE_URL),
|
|
|
|
|
deviceName = deviceName ?: DeviceInfoProvider.getDeviceName(),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
apiService = LicenseHttpClient.create(LicenseApiService::class.java, config.baseUrl)
|
|
|
|
|
initialized = true
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@JvmStatic
|
|
|
|
|
fun checkLicense(callback: LicenseCallback) {
|
|
|
|
|
sdkScope.launch {
|
|
|
|
|
val valid = when (checkLicense()) {
|
|
|
|
|
is LicenseResult.Success -> true
|
|
|
|
|
is LicenseResult.Error -> false
|
|
|
|
|
}
|
|
|
|
|
withContext(Dispatchers.Main) {
|
|
|
|
|
callback.onResult(valid)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Check license status. This will:
|
|
|
|
|
* 1. Return cached OK status if within cache window
|
|
|
|
|
* 2. Try to verify existing token
|
|
|
|
|
* 3. Register new device if no token exists
|
|
|
|
|
*
|
|
|
|
|
* @return LicenseResult.Success if license is valid, LicenseResult.Error otherwise
|
|
|
|
|
*/
|
|
|
|
|
suspend fun checkLicense(): LicenseResult = withContext(Dispatchers.IO) {
|
|
|
|
|
ensureInitialized()
|
|
|
|
|
|
|
|
|
|
// Check cached status
|
|
|
|
|
val cachedStatus = store.status
|
|
|
|
|
val cachedTime = store.statusTime
|
|
|
|
|
if (cachedStatus == STATUS_OK && System.currentTimeMillis() - cachedTime < config.cacheWindowMs) {
|
|
|
|
|
return@withContext LicenseResult.Success("Cached")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
val deviceId = getOrCreateDeviceId()
|
|
|
|
|
val token = store.token
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
if (token != null) {
|
|
|
|
|
// Try to verify existing token
|
|
|
|
|
val result = apiService.verify(
|
|
|
|
|
VerifyRequest(
|
|
|
|
|
companyId = config.appKey,
|
|
|
|
|
deviceId = deviceId,
|
|
|
|
|
token = token,
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
if (result.data?.valid == true) {
|
|
|
|
|
persistStatus(STATUS_OK)
|
|
|
|
|
return@withContext LicenseResult.Success("Verified")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Token invalid, clear and will try register below
|
|
|
|
|
store.token = null
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// No token or token invalid, register new device
|
|
|
|
|
val result = apiService.register(
|
|
|
|
|
RegisterRequest(
|
|
|
|
|
companyId = config.appKey,
|
|
|
|
|
deviceId = deviceId,
|
|
|
|
|
deviceName = config.deviceName,
|
|
|
|
|
deviceModel = DeviceInfoProvider.getDeviceModel(),
|
|
|
|
|
deviceVendor = DeviceInfoProvider.getDeviceVendor(),
|
|
|
|
|
osVersion = DeviceInfoProvider.getOsVersion(),
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
if (result.data?.success == true && result.data.token != null) {
|
|
|
|
|
store.token = result.data.token
|
|
|
|
|
persistStatus(STATUS_OK)
|
|
|
|
|
return@withContext LicenseResult.Success("Registered")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
persistStatus(STATUS_DENIED)
|
|
|
|
|
return@withContext LicenseResult.Error(result.data?.message ?: "Registration denied")
|
|
|
|
|
} catch (e: Exception) {
|
|
|
|
|
// Network error: use cached status if available
|
|
|
|
|
if (cachedStatus == STATUS_OK) {
|
|
|
|
|
return@withContext LicenseResult.Success("Offline - using cached status")
|
|
|
|
|
}
|
|
|
|
|
return@withContext LicenseResult.Error(e.message ?: "Network error")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Force re-register the device (e.g., after token revocation).
|
|
|
|
|
*/
|
|
|
|
|
suspend fun reRegister(): LicenseResult = withContext(Dispatchers.IO) {
|
|
|
|
|
requireInit()
|
|
|
|
|
store.token = null
|
|
|
|
|
store.status = null
|
|
|
|
|
store.statusTime = 0
|
|
|
|
|
checkLicense()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Get current license status without network call.
|
|
|
|
|
*/
|
|
|
|
|
fun getStatus(): LicenseStatus {
|
|
|
|
|
if (!initialized && !tryAutoInitialize()) return LicenseStatus.UNKNOWN
|
|
|
|
|
return when (store.status) {
|
|
|
|
|
STATUS_OK -> LicenseStatus.OK
|
|
|
|
|
STATUS_DENIED -> LicenseStatus.DENIED
|
|
|
|
|
else -> LicenseStatus.UNKNOWN
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Get the stable device ID used for registration.
|
|
|
|
|
*/
|
|
|
|
|
fun getDeviceId(): String? {
|
|
|
|
|
if (!initialized && !tryAutoInitialize()) return null
|
|
|
|
|
return store.deviceId
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Clear all license data (token, device ID, status).
|
|
|
|
|
*/
|
|
|
|
|
fun clear() {
|
|
|
|
|
if (::store.isInitialized) {
|
|
|
|
|
store.clear()
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private fun requireInit() {
|
|
|
|
|
check(initialized) { "LicenseSDK not initialized. Call LicenseSDK.initialize() first." }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private fun getOrCreateDeviceId(): String {
|
|
|
|
|
store.deviceId?.let { return it }
|
|
|
|
|
val newId = DeviceInfoProvider.getDeviceId(appContext)
|
|
|
|
|
store.deviceId = newId
|
|
|
|
|
return newId
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private fun persistStatus(status: String) {
|
|
|
|
|
store.status = status
|
|
|
|
|
store.statusTime = System.currentTimeMillis()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private fun ensureInitialized() {
|
|
|
|
|
if (!initialized) {
|
|
|
|
|
tryAutoInitialize()
|
|
|
|
|
}
|
|
|
|
|
requireInit()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private fun tryAutoInitialize(): 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
|
|
|
|
|
appContext = ctx
|
|
|
|
|
store = LicenseStore(ctx)
|
|
|
|
|
if (store.appKey != null && store.appKey != appKey) {
|
|
|
|
|
store.clear()
|
|
|
|
|
}
|
|
|
|
|
store.appKey = appKey
|
|
|
|
|
config = LicenseConfig(
|
|
|
|
|
appKey = appKey,
|
|
|
|
|
baseUrl = normalizeBaseUrl(licenseFile.baseUrl ?: DEFAULT_BASE_URL),
|
|
|
|
|
deviceName = DeviceInfoProvider.getDeviceName(),
|
|
|
|
|
)
|
|
|
|
|
apiService = LicenseHttpClient.create(LicenseApiService::class.java, config.baseUrl)
|
|
|
|
|
initialized = true
|
|
|
|
|
return true
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private fun normalizeBaseUrl(value: String): String {
|
|
|
|
|
val trimmed = value.trim().ifBlank { DEFAULT_BASE_URL }
|
|
|
|
|
return if (trimmed.endsWith("/")) trimmed else "$trimmed/"
|
|
|
|
|
}
|
|
|
|
|
}
|