Add Android license SDK

这个提交包含在:
XuqmGroup 2026-05-15 21:00:24 +08:00
父节点 0fdfc048e7
当前提交 cbc8ed56cd
共有 19 个文件被更改,包括 724 次插入0 次删除

79
sdk-license/README.md 普通文件
查看文件

@ -0,0 +1,79 @@
# Xuqm License SDK
Android SDK for device license registration and verification.
## Features
- **Stable Device ID**: Uses `ANDROID_ID` (stable per app signing key + device + user), with UUID fallback
- **Token Caching**: Caches valid status for configurable window (default 10 min) to reduce network calls
- **Auto Recovery**: Automatically re-registers when token is invalid or revoked
- **Offline Support**: Returns cached OK status when network is unavailable
- **Encrypted License File**: Reads `assets/xuqm/license.xuqm`; the file does not expose appKey or server URL as readable text
- **AppKey Change Detection**: Automatically clears old data when decrypted appKey changes
## Integration
```kotlin
dependencies {
implementation("com.xuqm:sdk-license:0.1.0-SNAPSHOT")
}
```
## Usage
### License File
Download the encrypted License file from the tenant platform and place it at:
```text
app/src/main/assets/xuqm/license.xuqm
```
The SDK also accepts a tenant-platform downloaded file such as
`app/src/main/assets/xuqm/MyApp.xuqmlicense`.
No explicit initialization code is required.
### Check License
```kotlin
LicenseSDK.checkLicense { isValid ->
if (isValid) {
// allow app usage
} else {
// block app usage or show warning
}
}
```
### Get Status (no network)
```kotlin
val status = LicenseSDK.getStatus() // OK, DENIED, or UNKNOWN
```
### Re-register (force)
```kotlin
lifecycleScope.launch {
val result = LicenseSDK.reRegister()
}
```
### Get Device ID
```kotlin
val deviceId = LicenseSDK.getDeviceId()
```
## Configuration
| Parameter | Default | Description |
|-----------|---------|-------------|
| `appKey` | from encrypted file | Company appKey from tenant platform |
| `deviceName` | `${MANUFACTURER} ${MODEL}` | Device display name |
| `baseUrl` | `https://auto.dev.xuqinmin.com/` | License server base URL |
## Data Storage
All data (device ID, token, status) is stored in `EncryptedSharedPreferences` with AES-256 encryption.

查看文件

@ -0,0 +1,33 @@
plugins {
alias(libs.plugins.android.library)
alias(libs.plugins.kotlin.serialization)
}
apply(from = rootProject.file("gradle/publish.gradle"))
android {
namespace = "com.xuqm.sdk.license"
compileSdk = libs.versions.compileSdk.get().toInt()
defaultConfig {
minSdk = libs.versions.minSdk.get().toInt()
consumerProguardFiles("consumer-rules.pro")
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_11
targetCompatibility = JavaVersion.VERSION_11
}
publishing {
singleVariant("release") {
withSourcesJar()
}
}
}
dependencies {
api(project(":sdk-core"))
api(libs.kotlinx.coroutines.android)
api(libs.kotlinx.serialization.json)
}

查看文件

@ -0,0 +1,17 @@
# sdk-license consumer ProGuard rules
# ── Public API entry points ───────────────────────────────────────────────────
-keep class com.xuqm.sdk.license.LicenseSDK { *; }
-keep class com.xuqm.sdk.license.LicenseConfig { *; }
-keep class com.xuqm.sdk.license.LicenseResult { *; }
-keep class com.xuqm.sdk.license.LicenseStatus { *; }
-keep class com.xuqm.sdk.license.model.** { *; }
# ── Gson: preserve field names on all SDK model classes ──────────────────────
-keepclassmembers class com.xuqm.sdk.license.** {
@com.google.gson.annotations.SerializedName <fields>;
}
# ── Retrofit / OkHttp ────────────────────────────────────────────────────────
-keepattributes Signature
-keepattributes *Annotation*

查看文件

@ -0,0 +1,9 @@
<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>

查看文件

@ -0,0 +1,5 @@
package com.xuqm.sdk.license
fun interface LicenseCallback {
fun onResult(isValid: Boolean)
}

查看文件

@ -0,0 +1,8 @@
package com.xuqm.sdk.license
data class LicenseConfig(
val appKey: String,
val baseUrl: String = "https://auto.dev.xuqinmin.com/",
val deviceName: String? = null,
val cacheWindowMs: Long = 10 * 60 * 1000L, // 10 minutes
)

查看文件

@ -0,0 +1,10 @@
package com.xuqm.sdk.license
sealed class LicenseResult {
data class Success(val message: String) : LicenseResult()
data class Error(val message: String) : LicenseResult()
}
enum class LicenseStatus {
OK, DENIED, UNKNOWN
}

查看文件

@ -0,0 +1,243 @@
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"
private const val DEFAULT_BASE_URL = "https://auto.dev.xuqinmin.com/"
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
* @param baseUrl Optional custom license server base URL. Defaults to https://auto.dev.xuqinmin.com/
*/
@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/"
}
}

查看文件

@ -0,0 +1,18 @@
package com.xuqm.sdk.license.api
import com.xuqm.sdk.license.model.ApiResponse
import com.xuqm.sdk.license.model.RegisterRequest
import com.xuqm.sdk.license.model.RegisterResponse
import com.xuqm.sdk.license.model.VerifyRequest
import com.xuqm.sdk.license.model.VerifyResponse
import retrofit2.http.Body
import retrofit2.http.POST
interface LicenseApiService {
@POST("api/license/register")
suspend fun register(@Body request: RegisterRequest): ApiResponse<RegisterResponse>
@POST("api/license/verify")
suspend fun verify(@Body request: VerifyRequest): ApiResponse<VerifyResponse>
}

查看文件

@ -0,0 +1,50 @@
package com.xuqm.sdk.license.internal
import android.content.Context
import android.os.Build
import android.provider.Settings
import java.util.UUID
/**
* Provides stable device information for license checks.
*/
internal object DeviceInfoProvider {
private const val FALLBACK_PREFS = "xuqm_license_device_fallback"
private const val KEY_FALLBACK_ID = "fallback_device_id"
/**
* Get stable device ID.
* Priority:
* 1. ANDROID_ID (stable per app signing key + device + user, survives reinstall with same signer)
* 2. Persisted fallback UUID
*
* Note: ANDROID_ID changes on factory reset or if app is reinstalled with different signing key.
*/
fun getDeviceId(context: Context): String {
val androidId = try {
Settings.Secure.getString(context.contentResolver, Settings.Secure.ANDROID_ID)
} catch (_: Exception) { null }
if (!androidId.isNullOrBlank() && androidId != "9774d56d682e549c") {
return androidId
}
// Fallback to persisted UUID
val prefs = context.getSharedPreferences(FALLBACK_PREFS, Context.MODE_PRIVATE)
var fallbackId = prefs.getString(KEY_FALLBACK_ID, null)
if (fallbackId == null) {
fallbackId = UUID.randomUUID().toString()
prefs.edit().putString(KEY_FALLBACK_ID, fallbackId).apply()
}
return fallbackId
}
fun getDeviceModel(): String = "${Build.MANUFACTURER} ${Build.MODEL}"
fun getDeviceVendor(): String = Build.MANUFACTURER
fun getOsVersion(): String = "Android ${Build.VERSION.RELEASE}"
fun getDeviceName(): String = getDeviceModel()
}

查看文件

@ -0,0 +1,13 @@
package com.xuqm.sdk.license.internal
import android.content.Context
internal object LicenseContextHolder {
@Volatile
var appContext: Context? = null
private set
fun init(context: Context) {
appContext = context.applicationContext
}
}

查看文件

@ -0,0 +1,36 @@
package com.xuqm.sdk.license.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
internal object LicenseFileCrypto {
private const val MAGIC = "XUQM-LICENSE-V1"
private const val PASSPHRASE = "xuqm-license-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 license file" }
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,50 @@
package com.xuqm.sdk.license.internal
import android.content.Context
import com.google.gson.Gson
import com.xuqm.sdk.license.model.LicenseFile
/**
* Reads the license file from app assets.
* Expected path: assets/xuqm/license.xuqm
*/
internal object LicenseFileReader {
private const val LICENSE_PATH = "xuqm/license.xuqm"
private const val LICENSE_DIR = "xuqm"
private val gson = Gson()
fun read(context: Context): LicenseFile? {
return try {
val path = findLicensePath(context) ?: return null
context.assets.open(path).use { stream ->
parseEncrypted(stream.bufferedReader().readText())
}
} catch (_: Exception) {
null
}
}
fun exists(context: Context): Boolean {
return findLicensePath(context) != null
}
private fun findLicensePath(context: Context): String? {
try {
context.assets.open(LICENSE_PATH).close()
return LICENSE_PATH
} catch (_: Exception) {
// Try downloaded filenames such as appName.xuqmlicense.
}
return runCatching {
context.assets.list(LICENSE_DIR)
?.firstOrNull { it.endsWith(".xuqmlicense", ignoreCase = true) }
?.let { "$LICENSE_DIR/$it" }
}.getOrNull()
}
private fun parseEncrypted(encrypted: String): LicenseFile {
val json = LicenseFileCrypto.decrypt(encrypted)
return gson.fromJson(json, LicenseFile::class.java)
}
}

查看文件

@ -0,0 +1,24 @@
package com.xuqm.sdk.license.internal
import com.google.gson.GsonBuilder
import okhttp3.OkHttpClient
import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory
import java.util.concurrent.TimeUnit
internal object LicenseHttpClient {
private val gson = GsonBuilder().create()
private val okHttpClient = OkHttpClient.Builder()
.connectTimeout(15, TimeUnit.SECONDS)
.readTimeout(15, TimeUnit.SECONDS)
.build()
fun <T : Any> create(service: Class<T>, baseUrl: String): T {
return Retrofit.Builder()
.baseUrl(baseUrl)
.client(okHttpClient)
.addConverterFactory(GsonConverterFactory.create(gson))
.build()
.create(service)
}
}

查看文件

@ -0,0 +1,19 @@
package com.xuqm.sdk.license.internal
import android.content.ContentProvider
import android.content.ContentValues
import android.database.Cursor
import android.net.Uri
class LicenseInitializerProvider : ContentProvider() {
override fun onCreate(): Boolean {
context?.let(LicenseContextHolder::init)
return true
}
override fun query(uri: Uri, projection: Array<out String>?, selection: String?, selectionArgs: Array<out String>?, sortOrder: String?): Cursor? = null
override fun getType(uri: Uri): String? = null
override fun insert(uri: Uri, values: ContentValues?): Uri? = null
override fun delete(uri: Uri, selection: String?, selectionArgs: Array<out String>?): Int = 0
override fun update(uri: Uri, values: ContentValues?, selection: String?, selectionArgs: Array<out String>?): Int = 0
}

查看文件

@ -0,0 +1,16 @@
package com.xuqm.sdk.license.model
import com.google.gson.annotations.SerializedName
/**
* License file structure. Generated by tenant platform when service is enabled.
* Placed in app assets directory (e.g., assets/xuqm/license.xuqm)
*/
data class LicenseFile(
@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 = "baseUrl", alternate = ["base_url"]) val baseUrl: 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,36 @@
package com.xuqm.sdk.license.model
import com.google.gson.annotations.SerializedName
data class RegisterRequest(
@SerializedName("companyId") val companyId: String,
@SerializedName("deviceId") val deviceId: String,
@SerializedName("deviceName") val deviceName: String? = null,
@SerializedName("deviceModel") val deviceModel: String? = null,
@SerializedName("deviceVendor") val deviceVendor: String? = null,
@SerializedName("osVersion") val osVersion: String? = null,
)
data class RegisterResponse(
val success: Boolean = false,
val token: String? = null,
val message: String? = null,
)
data class VerifyRequest(
@SerializedName("companyId") val companyId: String,
@SerializedName("deviceId") val deviceId: String,
val token: String,
)
data class VerifyResponse(
val valid: Boolean = false,
val error: String? = null,
)
data class ApiResponse<T>(
val code: Int = 0,
val status: String = "0",
val data: T? = null,
val message: String? = null,
)

查看文件

@ -0,0 +1,57 @@
package com.xuqm.sdk.license.store
import android.content.Context
import android.content.SharedPreferences
import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKeys
private const val PREFS_NAME = "xuqm_license_secure"
private const val KEY_DEVICE_ID = "license_device_id"
private const val KEY_TOKEN = "license_token"
private const val KEY_STATUS = "license_status"
private const val KEY_STATUS_TIME = "license_status_time"
private const val KEY_APP_KEY = "license_app_key"
class LicenseStore(context: Context) {
private val appContext = context.applicationContext
private val prefs: SharedPreferences = try {
EncryptedSharedPreferences.create(
PREFS_NAME,
MasterKeys.getOrCreate(MasterKeys.AES256_GCM_SPEC),
appContext,
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
)
} catch (_: Exception) {
// Fallback to regular SharedPreferences if encrypted fails
appContext.getSharedPreferences("${PREFS_NAME}_fallback", Context.MODE_PRIVATE)
}
var deviceId: String?
get() = prefs.getString(KEY_DEVICE_ID, null)
set(value) = prefs.edit().putString(KEY_DEVICE_ID, value).apply()
var token: String?
get() = prefs.getString(KEY_TOKEN, null)
set(value) = prefs.edit().putString(KEY_TOKEN, value).apply()
var status: String?
get() = prefs.getString(KEY_STATUS, null)
set(value) = prefs.edit().putString(KEY_STATUS, value).apply()
var statusTime: Long
get() = prefs.getLong(KEY_STATUS_TIME, 0)
set(value) = prefs.edit().putLong(KEY_STATUS_TIME, value).apply()
var appKey: String?
get() = prefs.getString(KEY_APP_KEY, null)
set(value) = prefs.edit().putString(KEY_APP_KEY, value).apply()
fun clear() {
prefs.edit().clear().apply()
}
fun isSameAppKey(key: String): Boolean = appKey == key
}

查看文件

@ -25,4 +25,5 @@ include(":sdk-im")
include(":sdk-push") include(":sdk-push")
include(":sdk-update") include(":sdk-update")
include(":sdk-webview") include(":sdk-webview")
include(":sdk-license")
include(":sample-app") include(":sample-app")