XuqmGroup-AndroidSDK/sdk-license/src/main/java/com/xuqm/sdk/license/LicenseSDK.kt

244 行
8.3 KiB
Kotlin

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/"
}
}