From 43cbd0f09839e7033ac5daf7b4ab590a51427cda Mon Sep 17 00:00:00 2001 From: XuqmGroup Date: Tue, 21 Apr 2026 22:07:29 +0800 Subject: [PATCH] chore: initial commit --- .gitignore | 10 ++ build.gradle.kts | 13 ++ gradle.properties | 5 + gradle/libs.versions.toml | 77 +++++++++++ gradle/publish.gradle.kts | 25 ++++ sample-app/build.gradle.kts | 44 +++++++ sample-app/src/main/AndroidManifest.xml | 29 +++++ .../java/com/xuqm/sdk/sample/MainActivity.kt | 123 ++++++++++++++++++ sample-app/src/main/res/xml/file_paths.xml | 4 + sdk-core/build.gradle.kts | 29 +++++ .../src/main/java/com/xuqm/sdk/XuqmSDK.kt | 35 +++++ .../main/java/com/xuqm/sdk/auth/TokenStore.kt | 27 ++++ .../main/java/com/xuqm/sdk/core/SDKConfig.kt | 9 ++ .../java/com/xuqm/sdk/network/ApiClient.kt | 51 ++++++++ .../java/com/xuqm/sdk/network/ApiResult.kt | 14 ++ .../java/com/xuqm/sdk/utils/DeviceUtils.kt | 24 ++++ sdk-im/build.gradle.kts | 25 ++++ .../src/main/java/com/xuqm/sdk/im/ImClient.kt | 84 ++++++++++++ sdk-im/src/main/java/com/xuqm/sdk/im/ImSDK.kt | 39 ++++++ .../main/java/com/xuqm/sdk/im/api/ImApi.kt | 26 ++++ .../xuqm/sdk/im/listener/ImEventListener.kt | 11 ++ .../java/com/xuqm/sdk/im/model/ImMessage.kt | 23 ++++ sdk-push/build.gradle.kts | 24 ++++ .../main/java/com/xuqm/sdk/push/PushSDK.kt | 35 +++++ .../java/com/xuqm/sdk/push/api/PushApi.kt | 21 +++ sdk-update/build.gradle.kts | 25 ++++ .../java/com/xuqm/sdk/update/UpdateSDK.kt | 71 ++++++++++ .../java/com/xuqm/sdk/update/api/UpdateApi.kt | 25 ++++ .../com/xuqm/sdk/update/model/UpdateInfo.kt | 21 +++ settings.gradle.kts | 25 ++++ 30 files changed, 974 insertions(+) create mode 100644 .gitignore create mode 100644 build.gradle.kts create mode 100644 gradle.properties create mode 100644 gradle/libs.versions.toml create mode 100644 gradle/publish.gradle.kts create mode 100644 sample-app/build.gradle.kts create mode 100644 sample-app/src/main/AndroidManifest.xml create mode 100644 sample-app/src/main/java/com/xuqm/sdk/sample/MainActivity.kt create mode 100644 sample-app/src/main/res/xml/file_paths.xml create mode 100644 sdk-core/build.gradle.kts create mode 100644 sdk-core/src/main/java/com/xuqm/sdk/XuqmSDK.kt create mode 100644 sdk-core/src/main/java/com/xuqm/sdk/auth/TokenStore.kt create mode 100644 sdk-core/src/main/java/com/xuqm/sdk/core/SDKConfig.kt create mode 100644 sdk-core/src/main/java/com/xuqm/sdk/network/ApiClient.kt create mode 100644 sdk-core/src/main/java/com/xuqm/sdk/network/ApiResult.kt create mode 100644 sdk-core/src/main/java/com/xuqm/sdk/utils/DeviceUtils.kt create mode 100644 sdk-im/build.gradle.kts create mode 100644 sdk-im/src/main/java/com/xuqm/sdk/im/ImClient.kt create mode 100644 sdk-im/src/main/java/com/xuqm/sdk/im/ImSDK.kt create mode 100644 sdk-im/src/main/java/com/xuqm/sdk/im/api/ImApi.kt create mode 100644 sdk-im/src/main/java/com/xuqm/sdk/im/listener/ImEventListener.kt create mode 100644 sdk-im/src/main/java/com/xuqm/sdk/im/model/ImMessage.kt create mode 100644 sdk-push/build.gradle.kts create mode 100644 sdk-push/src/main/java/com/xuqm/sdk/push/PushSDK.kt create mode 100644 sdk-push/src/main/java/com/xuqm/sdk/push/api/PushApi.kt create mode 100644 sdk-update/build.gradle.kts create mode 100644 sdk-update/src/main/java/com/xuqm/sdk/update/UpdateSDK.kt create mode 100644 sdk-update/src/main/java/com/xuqm/sdk/update/api/UpdateApi.kt create mode 100644 sdk-update/src/main/java/com/xuqm/sdk/update/model/UpdateInfo.kt create mode 100644 settings.gradle.kts diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..10dfc90 --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +node_modules/ +dist/ +.DS_Store +*.class +target/ +build/ +.gradle/ +*.iml +.idea/ +*.log diff --git a/build.gradle.kts b/build.gradle.kts new file mode 100644 index 0000000..00e35e3 --- /dev/null +++ b/build.gradle.kts @@ -0,0 +1,13 @@ +plugins { + alias(libs.plugins.android.application) apply false + alias(libs.plugins.android.library) apply false + alias(libs.plugins.kotlin.compose) apply false + alias(libs.plugins.kotlin.serialization) apply false +} + +group = "com.xuqm" +version = providers.gradleProperty("PUBLISH_VERSION").getOrElse("0.1.0-SNAPSHOT") + +ext["nexusUrl"] = "https://nexus.xuqinmin.com/repository/android-hosted/" +ext["nexusUser"] = providers.gradleProperty("NEXUS_USER").getOrElse("") +ext["nexusPassword"] = providers.gradleProperty("NEXUS_PASSWORD").getOrElse("") diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 0000000..e131bbb --- /dev/null +++ b/gradle.properties @@ -0,0 +1,5 @@ +org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 +android.useAndroidX=true +kotlin.code.style=official +android.nonTransitiveRClass=true +PUBLISH_VERSION=0.1.0-SNAPSHOT diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml new file mode 100644 index 0000000..aa89ea2 --- /dev/null +++ b/gradle/libs.versions.toml @@ -0,0 +1,77 @@ +[versions] +agp = "9.1.0" +kotlin = "2.3.10" +compileSdk = "36" +targetSdk = "36" +minSdk = "24" +coreKtx = "1.18.0" +lifecycle = "2.10.0" +activityCompose = "1.13.0" +activityKtx = "1.13.0" +composeBom = "2026.03.00" +coroutines = "1.10.2" +datastore = "1.1.7" +retrofit = "3.0.0" +okhttp = "5.3.2" +gson = "2.13.2" +jserialization = "1.9.0" +webkit = "1.14.0" +coil = "2.7.0" +junit4 = "4.13.2" +androidxJunit = "1.3.0" +espresso = "3.7.0" + +[libraries] +androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } +androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycle" } +androidx-lifecycle-runtime-compose = { group = "androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "lifecycle" } +androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" } +androidx-activity-ktx = { group = "androidx.activity", name = "activity-ktx", version.ref = "activityKtx" } +androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" } +androidx-ui = { group = "androidx.compose.ui", name = "ui" } +androidx-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" } +androidx-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" } +androidx-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" } +androidx-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" } +androidx-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" } +androidx-material3 = { group = "androidx.compose.material3", name = "material3" } +androidx-material-icons-extended = { group = "androidx.compose.material", name = "material-icons-extended" } +androidx-datastore-preferences = { group = "androidx.datastore", name = "datastore-preferences", version.ref = "datastore" } +kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "jserialization" } +androidx-webkit = { group = "androidx.webkit", name = "webkit", version.ref = "webkit" } +coil-compose = { group = "io.coil-kt", name = "coil-compose", version.ref = "coil" } +retrofit = { group = "com.squareup.retrofit2", name = "retrofit", version.ref = "retrofit" } +retrofit-converter-gson = { group = "com.squareup.retrofit2", name = "converter-gson", version.ref = "retrofit" } +okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" } +okhttp-logging = { group = "com.squareup.okhttp3", name = "logging-interceptor", version.ref = "okhttp" } +gson = { group = "com.google.code.gson", name = "gson", version.ref = "gson" } +kotlinx-coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "coroutines" } +junit4 = { group = "junit", name = "junit", version.ref = "junit4" } +androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "androidxJunit" } +androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espresso" } + +[bundles] +compose = [ + "androidx-ui", + "androidx-ui-graphics", + "androidx-ui-tooling-preview", + "androidx-material3", + "androidx-material-icons-extended" +] +compose-debug = [ + "androidx-ui-tooling", + "androidx-ui-test-manifest" +] +network = [ + "retrofit", + "retrofit-converter-gson", + "okhttp", + "okhttp-logging", + "gson" +] + +[plugins] +android-application = { id = "com.android.application", version.ref = "agp" } +android-library = { id = "com.android.library", version.ref = "agp" } +kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } +kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } diff --git a/gradle/publish.gradle.kts b/gradle/publish.gradle.kts new file mode 100644 index 0000000..1889b05 --- /dev/null +++ b/gradle/publish.gradle.kts @@ -0,0 +1,25 @@ +apply(plugin = "maven-publish") + +afterEvaluate { + (extensions.findByType(com.android.build.gradle.LibraryExtension::class.java))?.let { + extensions.configure { + publications { + register("release") { + from(components["release"]) + groupId = rootProject.group.toString() + artifactId = project.name + version = rootProject.version.toString() + } + } + repositories { + maven { + url = uri(rootProject.ext["nexusUrl"] as String) + credentials { + username = rootProject.ext["nexusUser"] as String + password = rootProject.ext["nexusPassword"] as String + } + } + } + } + } +} diff --git a/sample-app/build.gradle.kts b/sample-app/build.gradle.kts new file mode 100644 index 0000000..a3f47ff --- /dev/null +++ b/sample-app/build.gradle.kts @@ -0,0 +1,44 @@ +plugins { + alias(libs.plugins.android.application) + alias(libs.plugins.kotlin.compose) + id("org.jetbrains.kotlin.android") version "2.3.10" +} + +android { + namespace = "com.xuqm.sdk.sample" + compileSdk = libs.versions.compileSdk.get().toInt() + + defaultConfig { + applicationId = "com.xuqm.sdk.sample" + minSdk = libs.versions.minSdk.get().toInt() + targetSdk = libs.versions.targetSdk.get().toInt() + versionCode = 1 + versionName = "1.0.0" + } + + buildTypes { + release { + isMinifyEnabled = false + proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro") + } + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 + } + kotlinOptions { jvmTarget = "11" } + buildFeatures { compose = true } +} + +dependencies { + implementation(project(":sdk-core")) + implementation(project(":sdk-im")) + implementation(project(":sdk-push")) + implementation(project(":sdk-update")) + implementation(platform(libs.androidx.compose.bom)) + implementation(libs.bundles.compose) + implementation(libs.androidx.activity.compose) + implementation(libs.androidx.lifecycle.runtime.ktx) + debugImplementation(libs.bundles.compose.debug) +} diff --git a/sample-app/src/main/AndroidManifest.xml b/sample-app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..a1d7d19 --- /dev/null +++ b/sample-app/src/main/AndroidManifest.xml @@ -0,0 +1,29 @@ + + + + + + + + + + + + + + + + + + diff --git a/sample-app/src/main/java/com/xuqm/sdk/sample/MainActivity.kt b/sample-app/src/main/java/com/xuqm/sdk/sample/MainActivity.kt new file mode 100644 index 0000000..3e3e409 --- /dev/null +++ b/sample-app/src/main/java/com/xuqm/sdk/sample/MainActivity.kt @@ -0,0 +1,123 @@ +package com.xuqm.sdk.sample + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.dp +import com.xuqm.sdk.XuqmSDK +import com.xuqm.sdk.im.ImSDK +import com.xuqm.sdk.im.listener.ImEventListener +import com.xuqm.sdk.im.model.ChatType +import com.xuqm.sdk.im.model.ImMessage +import com.xuqm.sdk.im.model.MsgType +import com.xuqm.sdk.update.UpdateSDK +import kotlinx.coroutines.launch + +class MainActivity : ComponentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + XuqmSDK.init( + context = this, + appKey = "ak_your_app_key", + appSecret = "your_app_secret", + apiBaseUrl = "http://10.0.2.2:8082", + imBaseUrl = "ws://10.0.2.2:8082/ws/im", + debug = true, + ) + + setContent { + MaterialTheme { + Surface(modifier = Modifier.fillMaxSize()) { + SdkDemoScreen() + } + } + } + } +} + +@Composable +fun SdkDemoScreen() { + val context = LocalContext.current + val scope = rememberCoroutineScope() + val messages = remember { mutableStateListOf() } + var msgInput by remember { mutableStateOf("") } + var userId by remember { mutableStateOf("user_001") } + var connected by remember { mutableStateOf(false) } + var updateInfo by remember { mutableStateOf("") } + + val listener = remember { + object : ImEventListener { + override fun onConnected() { messages.add("[IM] 已连接"); connected = true } + override fun onDisconnected(reason: String?) { messages.add("[IM] 断开: $reason"); connected = false } + override fun onMessage(message: ImMessage) { messages.add("[消息] ${message.fromUserId}: ${message.content}") } + } + } + + Column( + modifier = Modifier + .fillMaxSize() + .padding(16.dp) + .verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + Text("XuqmSDK Demo", style = MaterialTheme.typography.headlineSmall) + + Card { + Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text("IM 测试", style = MaterialTheme.typography.titleMedium) + OutlinedTextField(value = userId, onValueChange = { userId = it }, label = { Text("UserId") }, modifier = Modifier.fillMaxWidth()) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Button(onClick = { + ImSDK.addListener(listener) + ImSDK.login("your_app_id", userId) + }) { Text("连接") } + Button(onClick = { ImSDK.disconnect(); connected = false }, + colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.error)) { + Text("断开") + } + } + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + OutlinedTextField(value = msgInput, onValueChange = { msgInput = it }, + label = { Text("消息内容") }, modifier = Modifier.weight(1f)) + Button(onClick = { + if (msgInput.isNotBlank()) { + ImSDK.sendMessage("user_002", ChatType.SINGLE, MsgType.TEXT, msgInput) + msgInput = "" + } + }) { Text("发送") } + } + } + } + + Card { + Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text("版本更新", style = MaterialTheme.typography.titleMedium) + Button(onClick = { + scope.launch { + val info = UpdateSDK.checkUpdate(context, "your_app_id") + updateInfo = if (info?.needsUpdate == true) + "发现新版本: ${info.versionName}" else "已是最新版本" + } + }) { Text("检查更新") } + if (updateInfo.isNotBlank()) Text(updateInfo) + } + } + + Card { + Column(Modifier.padding(12.dp)) { + Text("消息日志", style = MaterialTheme.typography.titleMedium) + Spacer(Modifier.height(8.dp)) + messages.forEach { msg -> Text(msg, style = MaterialTheme.typography.bodySmall) } + } + } + } +} diff --git a/sample-app/src/main/res/xml/file_paths.xml b/sample-app/src/main/res/xml/file_paths.xml new file mode 100644 index 0000000..cd69644 --- /dev/null +++ b/sample-app/src/main/res/xml/file_paths.xml @@ -0,0 +1,4 @@ + + + + diff --git a/sdk-core/build.gradle.kts b/sdk-core/build.gradle.kts new file mode 100644 index 0000000..604946b --- /dev/null +++ b/sdk-core/build.gradle.kts @@ -0,0 +1,29 @@ +plugins { + alias(libs.plugins.android.library) + alias(libs.plugins.kotlin.serialization) + id("org.jetbrains.kotlin.android") version "2.3.10" +} + +android { + namespace = "com.xuqm.sdk.core" + 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 + } + kotlinOptions { jvmTarget = "11" } +} + +dependencies { + api(libs.bundles.network) + api(libs.kotlinx.coroutines.android) + api(libs.kotlinx.serialization.json) + api(libs.androidx.datastore.preferences) + api(libs.androidx.core.ktx) +} diff --git a/sdk-core/src/main/java/com/xuqm/sdk/XuqmSDK.kt b/sdk-core/src/main/java/com/xuqm/sdk/XuqmSDK.kt new file mode 100644 index 0000000..6f02c94 --- /dev/null +++ b/sdk-core/src/main/java/com/xuqm/sdk/XuqmSDK.kt @@ -0,0 +1,35 @@ +package com.xuqm.sdk + +import android.content.Context +import com.xuqm.sdk.auth.TokenStore +import com.xuqm.sdk.core.SDKConfig +import com.xuqm.sdk.network.ApiClient + +object XuqmSDK { + + lateinit var config: SDKConfig + private set + + lateinit var tokenStore: TokenStore + private set + + private var initialized = false + + fun init( + context: Context, + appKey: String, + appSecret: String, + apiBaseUrl: String = "https://api.xuqm.com", + imBaseUrl: String = "wss://im.xuqm.com", + debug: Boolean = false, + ) { + config = SDKConfig(appKey, appSecret, apiBaseUrl, imBaseUrl, debug) + tokenStore = TokenStore(context.applicationContext) + ApiClient.init(config, tokenStore) + initialized = true + } + + fun requireInit() { + check(initialized) { "XuqmSDK is not initialized. Call XuqmSDK.init() first." } + } +} diff --git a/sdk-core/src/main/java/com/xuqm/sdk/auth/TokenStore.kt b/sdk-core/src/main/java/com/xuqm/sdk/auth/TokenStore.kt new file mode 100644 index 0000000..2c72cc6 --- /dev/null +++ b/sdk-core/src/main/java/com/xuqm/sdk/auth/TokenStore.kt @@ -0,0 +1,27 @@ +package com.xuqm.sdk.auth + +import android.content.Context +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.stringPreferencesKey +import androidx.datastore.preferences.preferencesDataStore +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking + +private val Context.dataStore by preferencesDataStore(name = "xuqm_sdk_prefs") + +class TokenStore(private val context: Context) { + + private val TOKEN_KEY = stringPreferencesKey("access_token") + + fun getToken(): String? = runBlocking { + context.dataStore.data.first()[TOKEN_KEY] + } + + suspend fun saveToken(token: String) { + context.dataStore.edit { prefs -> prefs[TOKEN_KEY] = token } + } + + suspend fun clear() { + context.dataStore.edit { prefs -> prefs.remove(TOKEN_KEY) } + } +} diff --git a/sdk-core/src/main/java/com/xuqm/sdk/core/SDKConfig.kt b/sdk-core/src/main/java/com/xuqm/sdk/core/SDKConfig.kt new file mode 100644 index 0000000..5b38dba --- /dev/null +++ b/sdk-core/src/main/java/com/xuqm/sdk/core/SDKConfig.kt @@ -0,0 +1,9 @@ +package com.xuqm.sdk.core + +data class SDKConfig( + val appKey: String, + val appSecret: String, + val apiBaseUrl: String = "https://api.xuqm.com", + val imBaseUrl: String = "wss://im.xuqm.com", + val debug: Boolean = false, +) diff --git a/sdk-core/src/main/java/com/xuqm/sdk/network/ApiClient.kt b/sdk-core/src/main/java/com/xuqm/sdk/network/ApiClient.kt new file mode 100644 index 0000000..60088ff --- /dev/null +++ b/sdk-core/src/main/java/com/xuqm/sdk/network/ApiClient.kt @@ -0,0 +1,51 @@ +package com.xuqm.sdk.network + +import com.xuqm.sdk.auth.TokenStore +import com.xuqm.sdk.core.SDKConfig +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.logging.HttpLoggingInterceptor +import retrofit2.Retrofit +import retrofit2.converter.gson.GsonConverterFactory +import java.util.concurrent.TimeUnit + +object ApiClient { + + private lateinit var config: SDKConfig + private var tokenStore: TokenStore? = null + lateinit var retrofit: Retrofit + private set + + fun init(cfg: SDKConfig, store: TokenStore) { + config = cfg + tokenStore = store + + val logging = HttpLoggingInterceptor().apply { + level = if (cfg.debug) HttpLoggingInterceptor.Level.BODY + else HttpLoggingInterceptor.Level.NONE + } + + val okhttp = OkHttpClient.Builder() + .connectTimeout(30, TimeUnit.SECONDS) + .readTimeout(30, TimeUnit.SECONDS) + .addInterceptor(logging) + .addInterceptor { chain -> + val token = store.getToken() + val req: Request = if (token != null) { + chain.request().newBuilder() + .header("Authorization", "Bearer $token") + .build() + } else chain.request() + chain.proceed(req) + } + .build() + + retrofit = Retrofit.Builder() + .baseUrl(cfg.apiBaseUrl.trimEnd('/') + "/") + .client(okhttp) + .addConverterFactory(GsonConverterFactory.create()) + .build() + } + + inline fun create(): T = retrofit.create(T::class.java) +} diff --git a/sdk-core/src/main/java/com/xuqm/sdk/network/ApiResult.kt b/sdk-core/src/main/java/com/xuqm/sdk/network/ApiResult.kt new file mode 100644 index 0000000..de3cd10 --- /dev/null +++ b/sdk-core/src/main/java/com/xuqm/sdk/network/ApiResult.kt @@ -0,0 +1,14 @@ +package com.xuqm.sdk.network + +sealed class ApiResult { + data class Success(val data: T) : ApiResult() + data class Error(val code: Int, val message: String, val cause: Throwable? = null) : ApiResult() +} + +suspend fun safeApiCall(block: suspend () -> T): ApiResult = try { + ApiResult.Success(block()) +} catch (e: retrofit2.HttpException) { + ApiResult.Error(e.code(), e.message(), e) +} catch (e: Exception) { + ApiResult.Error(-1, e.message ?: "Unknown error", e) +} diff --git a/sdk-core/src/main/java/com/xuqm/sdk/utils/DeviceUtils.kt b/sdk-core/src/main/java/com/xuqm/sdk/utils/DeviceUtils.kt new file mode 100644 index 0000000..07a672e --- /dev/null +++ b/sdk-core/src/main/java/com/xuqm/sdk/utils/DeviceUtils.kt @@ -0,0 +1,24 @@ +package com.xuqm.sdk.utils + +import android.content.Context +import android.os.Build +import android.provider.Settings + +object DeviceUtils { + + fun getDeviceId(context: Context): String = + Settings.Secure.getString(context.contentResolver, Settings.Secure.ANDROID_ID) + ?: Build.SERIAL + + fun getDeviceModel(): String = "${Build.MANUFACTURER} ${Build.MODEL}" + + fun getOsVersion(): String = "Android ${Build.VERSION.RELEASE}" + + fun getVendor(): String = when (Build.MANUFACTURER.lowercase()) { + "huawei", "honor" -> if (Build.MANUFACTURER.lowercase() == "honor") "HONOR" else "HUAWEI" + "xiaomi", "redmi" -> "XIAOMI" + "oppo", "realme", "oneplus" -> "OPPO" + "vivo", "iqoo" -> "VIVO" + else -> "FCM" + } +} diff --git a/sdk-im/build.gradle.kts b/sdk-im/build.gradle.kts new file mode 100644 index 0000000..638569d --- /dev/null +++ b/sdk-im/build.gradle.kts @@ -0,0 +1,25 @@ +plugins { + alias(libs.plugins.android.library) + id("org.jetbrains.kotlin.android") version "2.3.10" +} + +android { + namespace = "com.xuqm.sdk.im" + 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 + } + kotlinOptions { jvmTarget = "11" } +} + +dependencies { + api(project(":sdk-core")) + implementation(libs.kotlinx.coroutines.android) +} diff --git a/sdk-im/src/main/java/com/xuqm/sdk/im/ImClient.kt b/sdk-im/src/main/java/com/xuqm/sdk/im/ImClient.kt new file mode 100644 index 0000000..89d1ecf --- /dev/null +++ b/sdk-im/src/main/java/com/xuqm/sdk/im/ImClient.kt @@ -0,0 +1,84 @@ +package com.xuqm.sdk.im + +import com.google.gson.Gson +import com.xuqm.sdk.im.listener.ImEventListener +import com.xuqm.sdk.im.model.ChatType +import com.xuqm.sdk.im.model.ImMessage +import com.xuqm.sdk.im.model.MsgType +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.launch +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.Response +import okhttp3.WebSocket +import okhttp3.WebSocketListener +import java.util.concurrent.CopyOnWriteArrayList +import java.util.concurrent.TimeUnit + +class ImClient( + private val wsUrl: String, + private val token: String, + private val appId: String, +) { + private var webSocket: WebSocket? = null + private val listeners = CopyOnWriteArrayList() + private val scope = CoroutineScope(Dispatchers.IO + Job()) + private val gson = Gson() + + private val okhttp = OkHttpClient.Builder() + .connectTimeout(10, TimeUnit.SECONDS) + .readTimeout(0, TimeUnit.SECONDS) + .build() + + fun connect() { + val request = Request.Builder() + .url(wsUrl) + .header("Authorization", "Bearer $token") + .build() + webSocket = okhttp.newWebSocket(request, object : WebSocketListener() { + override fun onOpen(ws: WebSocket, response: Response) { + listeners.forEach { it.onConnected() } + } + + override fun onMessage(ws: WebSocket, text: String) { + try { + val msg = gson.fromJson(text, ImMessage::class.java) + if (msg.chatType == ChatType.GROUP) { + listeners.forEach { it.onGroupMessage(msg) } + } else { + listeners.forEach { it.onMessage(msg) } + } + } catch (e: Exception) { + listeners.forEach { it.onError("Parse error: ${e.message}") } + } + } + + override fun onFailure(ws: WebSocket, t: Throwable, response: Response?) { + listeners.forEach { it.onDisconnected(t.message) } + } + + override fun onClosed(ws: WebSocket, code: Int, reason: String) { + listeners.forEach { it.onDisconnected(reason) } + } + }) + } + + fun sendMessage(toId: String, chatType: ChatType, msgType: MsgType, content: String) { + val payload = mapOf( + "appId" to appId, "toId" to toId, + "chatType" to chatType.name, "msgType" to msgType.name, + "content" to content, + ) + webSocket?.send(gson.toJson(mapOf("type" to "chat.send", "data" to payload))) + } + + fun addListener(listener: ImEventListener) = listeners.add(listener) + fun removeListener(listener: ImEventListener) = listeners.remove(listener) + + fun disconnect() { + webSocket?.close(1000, "User disconnect") + webSocket = null + } +} diff --git a/sdk-im/src/main/java/com/xuqm/sdk/im/ImSDK.kt b/sdk-im/src/main/java/com/xuqm/sdk/im/ImSDK.kt new file mode 100644 index 0000000..a1daecc --- /dev/null +++ b/sdk-im/src/main/java/com/xuqm/sdk/im/ImSDK.kt @@ -0,0 +1,39 @@ +package com.xuqm.sdk.im + +import com.xuqm.sdk.XuqmSDK +import com.xuqm.sdk.im.api.ImApi +import com.xuqm.sdk.im.listener.ImEventListener +import com.xuqm.sdk.im.model.ChatType +import com.xuqm.sdk.im.model.MsgType +import com.xuqm.sdk.network.ApiClient +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch + +object ImSDK { + + private var client: ImClient? = null + private val api: ImApi by lazy { ApiClient.create() } + private val scope = CoroutineScope(Dispatchers.IO) + + fun login(appId: String, userId: String, nickname: String? = null, avatar: String? = null) { + XuqmSDK.requireInit() + scope.launch { + val res = api.login(appId, userId, nickname, avatar) + res.data?.token?.let { token -> + XuqmSDK.tokenStore.saveToken(token) + val wsUrl = XuqmSDK.config.imBaseUrl + client = ImClient(wsUrl, token, appId) + client?.connect() + } + } + } + + fun sendMessage(toId: String, chatType: ChatType, msgType: MsgType, content: String) { + client?.sendMessage(toId, chatType, msgType, content) + } + + fun addListener(listener: ImEventListener) = client?.addListener(listener) + fun removeListener(listener: ImEventListener) = client?.removeListener(listener) + fun disconnect() = client?.disconnect() +} diff --git a/sdk-im/src/main/java/com/xuqm/sdk/im/api/ImApi.kt b/sdk-im/src/main/java/com/xuqm/sdk/im/api/ImApi.kt new file mode 100644 index 0000000..1571393 --- /dev/null +++ b/sdk-im/src/main/java/com/xuqm/sdk/im/api/ImApi.kt @@ -0,0 +1,26 @@ +package com.xuqm.sdk.im.api + +import com.xuqm.sdk.im.model.ImMessage +import retrofit2.http.POST +import retrofit2.http.GET +import retrofit2.http.Query + +data class ApiResponse(val code: Int, val status: String, val data: T?, val message: String) +data class LoginResponse(val token: String) +data class SendMessageRequest( + val toId: String, + val chatType: String, + val msgType: String, + val content: String, + val mentionedUserIds: String? = null, +) + +interface ImApi { + @POST("api/im/auth/login") + suspend fun login( + @Query("appId") appId: String, + @Query("userId") userId: String, + @Query("nickname") nickname: String? = null, + @Query("avatar") avatar: String? = null, + ): ApiResponse +} diff --git a/sdk-im/src/main/java/com/xuqm/sdk/im/listener/ImEventListener.kt b/sdk-im/src/main/java/com/xuqm/sdk/im/listener/ImEventListener.kt new file mode 100644 index 0000000..71941e7 --- /dev/null +++ b/sdk-im/src/main/java/com/xuqm/sdk/im/listener/ImEventListener.kt @@ -0,0 +1,11 @@ +package com.xuqm.sdk.im.listener + +import com.xuqm.sdk.im.model.ImMessage + +interface ImEventListener { + fun onConnected() {} + fun onDisconnected(reason: String?) {} + fun onMessage(message: ImMessage) {} + fun onGroupMessage(message: ImMessage) {} + fun onError(error: String) {} +} diff --git a/sdk-im/src/main/java/com/xuqm/sdk/im/model/ImMessage.kt b/sdk-im/src/main/java/com/xuqm/sdk/im/model/ImMessage.kt new file mode 100644 index 0000000..6ffc043 --- /dev/null +++ b/sdk-im/src/main/java/com/xuqm/sdk/im/model/ImMessage.kt @@ -0,0 +1,23 @@ +package com.xuqm.sdk.im.model + +data class ImMessage( + val id: String, + val appId: String, + val fromUserId: String, + val toId: String, + val chatType: ChatType, + val msgType: MsgType, + val content: String, + val status: MsgStatus, + val mentionedUserIds: String?, + val createdAt: String, +) + +enum class ChatType { SINGLE, GROUP } + +enum class MsgType { + TEXT, IMAGE, VIDEO, AUDIO, FILE, CUSTOM, LOCATION, NOTIFY, + RICH_TEXT, CALL_AUDIO, CALL_VIDEO, REVOKED, FORWARD +} + +enum class MsgStatus { SENT, DELIVERED, READ, REVOKED } diff --git a/sdk-push/build.gradle.kts b/sdk-push/build.gradle.kts new file mode 100644 index 0000000..79760e2 --- /dev/null +++ b/sdk-push/build.gradle.kts @@ -0,0 +1,24 @@ +plugins { + alias(libs.plugins.android.library) + id("org.jetbrains.kotlin.android") version "2.3.10" +} + +android { + namespace = "com.xuqm.sdk.push" + 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 + } + kotlinOptions { jvmTarget = "11" } +} + +dependencies { + api(project(":sdk-core")) +} diff --git a/sdk-push/src/main/java/com/xuqm/sdk/push/PushSDK.kt b/sdk-push/src/main/java/com/xuqm/sdk/push/PushSDK.kt new file mode 100644 index 0000000..aea7bec --- /dev/null +++ b/sdk-push/src/main/java/com/xuqm/sdk/push/PushSDK.kt @@ -0,0 +1,35 @@ +package com.xuqm.sdk.push + +import android.content.Context +import com.xuqm.sdk.XuqmSDK +import com.xuqm.sdk.network.ApiClient +import com.xuqm.sdk.push.api.PushApi +import com.xuqm.sdk.utils.DeviceUtils +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch + +object PushSDK { + + private val api: PushApi by lazy { ApiClient.create() } + private val scope = CoroutineScope(Dispatchers.IO) + + fun registerToken(context: Context, appId: String, userId: String, token: String) { + XuqmSDK.requireInit() + val vendor = DeviceUtils.getVendor() + scope.launch { + runCatching { + api.registerToken(appId, userId, vendor, token) + } + } + } + + fun unregisterToken(appId: String, userId: String) { + XuqmSDK.requireInit() + scope.launch { + runCatching { + api.unregisterToken(appId, userId) + } + } + } +} diff --git a/sdk-push/src/main/java/com/xuqm/sdk/push/api/PushApi.kt b/sdk-push/src/main/java/com/xuqm/sdk/push/api/PushApi.kt new file mode 100644 index 0000000..b3cf02d --- /dev/null +++ b/sdk-push/src/main/java/com/xuqm/sdk/push/api/PushApi.kt @@ -0,0 +1,21 @@ +package com.xuqm.sdk.push.api + +import retrofit2.http.DELETE +import retrofit2.http.POST +import retrofit2.http.Query + +interface PushApi { + @POST("api/push/register") + suspend fun registerToken( + @Query("appId") appId: String, + @Query("userId") userId: String, + @Query("vendor") vendor: String, + @Query("token") token: String, + ) + + @DELETE("api/push/unregister") + suspend fun unregisterToken( + @Query("appId") appId: String, + @Query("userId") userId: String, + ) +} diff --git a/sdk-update/build.gradle.kts b/sdk-update/build.gradle.kts new file mode 100644 index 0000000..30e1da6 --- /dev/null +++ b/sdk-update/build.gradle.kts @@ -0,0 +1,25 @@ +plugins { + alias(libs.plugins.android.library) + id("org.jetbrains.kotlin.android") version "2.3.10" +} + +android { + namespace = "com.xuqm.sdk.update" + 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 + } + kotlinOptions { jvmTarget = "11" } +} + +dependencies { + api(project(":sdk-core")) + implementation(libs.kotlinx.coroutines.android) +} diff --git a/sdk-update/src/main/java/com/xuqm/sdk/update/UpdateSDK.kt b/sdk-update/src/main/java/com/xuqm/sdk/update/UpdateSDK.kt new file mode 100644 index 0000000..782fcc5 --- /dev/null +++ b/sdk-update/src/main/java/com/xuqm/sdk/update/UpdateSDK.kt @@ -0,0 +1,71 @@ +package com.xuqm.sdk.update + +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.os.Build +import androidx.core.content.FileProvider +import com.xuqm.sdk.XuqmSDK +import com.xuqm.sdk.network.ApiClient +import com.xuqm.sdk.update.api.UpdateApi +import com.xuqm.sdk.update.model.RnUpdateInfo +import com.xuqm.sdk.update.model.UpdateInfo +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.io.File +import java.net.URL + +object UpdateSDK { + + private val api: UpdateApi by lazy { ApiClient.create() } + + suspend fun checkUpdate(context: Context, appId: String): UpdateInfo? = withContext(Dispatchers.IO) { + XuqmSDK.requireInit() + val versionCode = context.packageManager + .getPackageInfo(context.packageName, 0).longVersionCode.toInt() + runCatching { api.checkUpdate(appId, "ANDROID", versionCode).data }.getOrNull() + } + + suspend fun downloadAndInstall( + context: Context, + downloadUrl: String, + onProgress: (Int) -> Unit = {}, + ) = withContext(Dispatchers.IO) { + val apkFile = File(context.getExternalFilesDir(null), "update.apk") + val url = URL(downloadUrl) + val connection = url.openConnection() + connection.connect() + val totalSize = connection.contentLengthLong + + connection.getInputStream().use { input -> + apkFile.outputStream().use { output -> + val buffer = ByteArray(8192) + var downloaded = 0L + var read: Int + while (input.read(buffer).also { read = it } != -1) { + output.write(buffer, 0, read) + downloaded += read + if (totalSize > 0) onProgress((downloaded * 100 / totalSize).toInt()) + } + } + } + + withContext(Dispatchers.Main) { + installApk(context, apkFile) + } + } + + private fun installApk(context: Context, apkFile: File) { + val intent = Intent(Intent.ACTION_VIEW).apply { + flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_GRANT_READ_URI_PERMISSION + val uri = FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", apkFile) + setDataAndType(uri, "application/vnd.android.package-archive") + } + context.startActivity(intent) + } + + suspend fun checkRnUpdate(appId: String, moduleId: String, currentVersion: String): RnUpdateInfo? = + withContext(Dispatchers.IO) { + runCatching { api.checkRnUpdate(appId, moduleId, "ANDROID", currentVersion).data }.getOrNull() + } +} diff --git a/sdk-update/src/main/java/com/xuqm/sdk/update/api/UpdateApi.kt b/sdk-update/src/main/java/com/xuqm/sdk/update/api/UpdateApi.kt new file mode 100644 index 0000000..f88d408 --- /dev/null +++ b/sdk-update/src/main/java/com/xuqm/sdk/update/api/UpdateApi.kt @@ -0,0 +1,25 @@ +package com.xuqm.sdk.update.api + +import com.xuqm.sdk.update.model.UpdateInfo +import com.xuqm.sdk.update.model.RnUpdateInfo +import retrofit2.http.GET +import retrofit2.http.Query + +data class ApiResponse(val code: Int, val data: T?, val message: String) + +interface UpdateApi { + @GET("api/v1/updates/app/check") + suspend fun checkUpdate( + @Query("appId") appId: String, + @Query("platform") platform: String, + @Query("currentVersionCode") currentVersionCode: Int, + ): ApiResponse + + @GET("api/v1/rn/update/check") + suspend fun checkRnUpdate( + @Query("appId") appId: String, + @Query("moduleId") moduleId: String, + @Query("platform") platform: String, + @Query("currentVersion") currentVersion: String, + ): ApiResponse +} diff --git a/sdk-update/src/main/java/com/xuqm/sdk/update/model/UpdateInfo.kt b/sdk-update/src/main/java/com/xuqm/sdk/update/model/UpdateInfo.kt new file mode 100644 index 0000000..daa079f --- /dev/null +++ b/sdk-update/src/main/java/com/xuqm/sdk/update/model/UpdateInfo.kt @@ -0,0 +1,21 @@ +package com.xuqm.sdk.update.model + +data class UpdateInfo( + val needsUpdate: Boolean, + val versionName: String = "", + val versionCode: Int = 0, + val downloadUrl: String = "", + val changeLog: String = "", + val forceUpdate: Boolean = false, + val appStoreUrl: String = "", + val marketUrl: String = "", +) + +data class RnUpdateInfo( + val needsUpdate: Boolean, + val latestVersion: String = "", + val downloadUrl: String = "", + val md5: String = "", + val minCommonVersion: String = "0.0.0", + val note: String = "", +) diff --git a/settings.gradle.kts b/settings.gradle.kts new file mode 100644 index 0000000..b28fa77 --- /dev/null +++ b/settings.gradle.kts @@ -0,0 +1,25 @@ +pluginManagement { + repositories { + maven(url = "https://nexus.xuqinmin.com/repository/android/") + google() + mavenCentral() + gradlePluginPortal() + } +} + +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + repositories { + maven(url = "https://nexus.xuqinmin.com/repository/android/") + google() + mavenCentral() + } +} + +rootProject.name = "XuqmGroupAndroidSDK" + +include(":sdk-core") +include(":sdk-im") +include(":sdk-push") +include(":sdk-update") +include(":sample-app")