diff --git a/sdk-license/README.md b/sdk-license/README.md new file mode 100644 index 0000000..c52ab5d --- /dev/null +++ b/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. diff --git a/sdk-license/build.gradle.kts b/sdk-license/build.gradle.kts new file mode 100644 index 0000000..585a467 --- /dev/null +++ b/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) +} diff --git a/sdk-license/consumer-rules.pro b/sdk-license/consumer-rules.pro new file mode 100644 index 0000000..3bb56c3 --- /dev/null +++ b/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 ; +} + +# ── Retrofit / OkHttp ──────────────────────────────────────────────────────── +-keepattributes Signature +-keepattributes *Annotation* diff --git a/sdk-license/src/main/AndroidManifest.xml b/sdk-license/src/main/AndroidManifest.xml new file mode 100644 index 0000000..0eeb054 --- /dev/null +++ b/sdk-license/src/main/AndroidManifest.xml @@ -0,0 +1,9 @@ + + + + + diff --git a/sdk-license/src/main/java/com/xuqm/sdk/license/LicenseCallback.kt b/sdk-license/src/main/java/com/xuqm/sdk/license/LicenseCallback.kt new file mode 100644 index 0000000..4574f93 --- /dev/null +++ b/sdk-license/src/main/java/com/xuqm/sdk/license/LicenseCallback.kt @@ -0,0 +1,5 @@ +package com.xuqm.sdk.license + +fun interface LicenseCallback { + fun onResult(isValid: Boolean) +} diff --git a/sdk-license/src/main/java/com/xuqm/sdk/license/LicenseConfig.kt b/sdk-license/src/main/java/com/xuqm/sdk/license/LicenseConfig.kt new file mode 100644 index 0000000..97f99d1 --- /dev/null +++ b/sdk-license/src/main/java/com/xuqm/sdk/license/LicenseConfig.kt @@ -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 +) diff --git a/sdk-license/src/main/java/com/xuqm/sdk/license/LicenseResult.kt b/sdk-license/src/main/java/com/xuqm/sdk/license/LicenseResult.kt new file mode 100644 index 0000000..d93fb95 --- /dev/null +++ b/sdk-license/src/main/java/com/xuqm/sdk/license/LicenseResult.kt @@ -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 +} 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 new file mode 100644 index 0000000..7288d7c --- /dev/null +++ b/sdk-license/src/main/java/com/xuqm/sdk/license/LicenseSDK.kt @@ -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/" + } +} diff --git a/sdk-license/src/main/java/com/xuqm/sdk/license/api/LicenseApiService.kt b/sdk-license/src/main/java/com/xuqm/sdk/license/api/LicenseApiService.kt new file mode 100644 index 0000000..9263ed5 --- /dev/null +++ b/sdk-license/src/main/java/com/xuqm/sdk/license/api/LicenseApiService.kt @@ -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 + + @POST("api/license/verify") + suspend fun verify(@Body request: VerifyRequest): ApiResponse +} diff --git a/sdk-license/src/main/java/com/xuqm/sdk/license/internal/DeviceInfoProvider.kt b/sdk-license/src/main/java/com/xuqm/sdk/license/internal/DeviceInfoProvider.kt new file mode 100644 index 0000000..8f07cbd --- /dev/null +++ b/sdk-license/src/main/java/com/xuqm/sdk/license/internal/DeviceInfoProvider.kt @@ -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() +} diff --git a/sdk-license/src/main/java/com/xuqm/sdk/license/internal/LicenseContextHolder.kt b/sdk-license/src/main/java/com/xuqm/sdk/license/internal/LicenseContextHolder.kt new file mode 100644 index 0000000..1dd9e21 --- /dev/null +++ b/sdk-license/src/main/java/com/xuqm/sdk/license/internal/LicenseContextHolder.kt @@ -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 + } +} diff --git a/sdk-license/src/main/java/com/xuqm/sdk/license/internal/LicenseFileCrypto.kt b/sdk-license/src/main/java/com/xuqm/sdk/license/internal/LicenseFileCrypto.kt new file mode 100644 index 0000000..97767c7 --- /dev/null +++ b/sdk-license/src/main/java/com/xuqm/sdk/license/internal/LicenseFileCrypto.kt @@ -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) +} diff --git a/sdk-license/src/main/java/com/xuqm/sdk/license/internal/LicenseFileReader.kt b/sdk-license/src/main/java/com/xuqm/sdk/license/internal/LicenseFileReader.kt new file mode 100644 index 0000000..0a2a73e --- /dev/null +++ b/sdk-license/src/main/java/com/xuqm/sdk/license/internal/LicenseFileReader.kt @@ -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) + } +} diff --git a/sdk-license/src/main/java/com/xuqm/sdk/license/internal/LicenseHttpClient.kt b/sdk-license/src/main/java/com/xuqm/sdk/license/internal/LicenseHttpClient.kt new file mode 100644 index 0000000..c2df3c8 --- /dev/null +++ b/sdk-license/src/main/java/com/xuqm/sdk/license/internal/LicenseHttpClient.kt @@ -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 create(service: Class, baseUrl: String): T { + return Retrofit.Builder() + .baseUrl(baseUrl) + .client(okHttpClient) + .addConverterFactory(GsonConverterFactory.create(gson)) + .build() + .create(service) + } +} diff --git a/sdk-license/src/main/java/com/xuqm/sdk/license/internal/LicenseInitializerProvider.kt b/sdk-license/src/main/java/com/xuqm/sdk/license/internal/LicenseInitializerProvider.kt new file mode 100644 index 0000000..c0f29c6 --- /dev/null +++ b/sdk-license/src/main/java/com/xuqm/sdk/license/internal/LicenseInitializerProvider.kt @@ -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?, selection: String?, selectionArgs: Array?, 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?): Int = 0 + override fun update(uri: Uri, values: ContentValues?, selection: String?, selectionArgs: Array?): Int = 0 +} diff --git a/sdk-license/src/main/java/com/xuqm/sdk/license/model/LicenseFile.kt b/sdk-license/src/main/java/com/xuqm/sdk/license/model/LicenseFile.kt new file mode 100644 index 0000000..da04d0e --- /dev/null +++ b/sdk-license/src/main/java/com/xuqm/sdk/license/model/LicenseFile.kt @@ -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, +) diff --git a/sdk-license/src/main/java/com/xuqm/sdk/license/model/LicenseModels.kt b/sdk-license/src/main/java/com/xuqm/sdk/license/model/LicenseModels.kt new file mode 100644 index 0000000..2c034bd --- /dev/null +++ b/sdk-license/src/main/java/com/xuqm/sdk/license/model/LicenseModels.kt @@ -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( + val code: Int = 0, + val status: String = "0", + val data: T? = null, + val message: String? = null, +) diff --git a/sdk-license/src/main/java/com/xuqm/sdk/license/store/LicenseStore.kt b/sdk-license/src/main/java/com/xuqm/sdk/license/store/LicenseStore.kt new file mode 100644 index 0000000..89551ef --- /dev/null +++ b/sdk-license/src/main/java/com/xuqm/sdk/license/store/LicenseStore.kt @@ -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 +} diff --git a/settings.gradle.kts b/settings.gradle.kts index ebd9a21..c47b4ed 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -25,4 +25,5 @@ include(":sdk-im") include(":sdk-push") include(":sdk-update") include(":sdk-webview") +include(":sdk-license") include(":sample-app")