Add Android license SDK
这个提交包含在:
父节点
0fdfc048e7
当前提交
cbc8ed56cd
79
sdk-license/README.md
普通文件
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.
|
||||
33
sdk-license/build.gradle.kts
普通文件
33
sdk-license/build.gradle.kts
普通文件
@ -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)
|
||||
}
|
||||
17
sdk-license/consumer-rules.pro
普通文件
17
sdk-license/consumer-rules.pro
普通文件
@ -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-update")
|
||||
include(":sdk-webview")
|
||||
include(":sdk-license")
|
||||
include(":sample-app")
|
||||
|
||||
正在加载...
在新工单中引用
屏蔽一个用户