diff --git a/build.gradle.kts b/build.gradle.kts
index 00e35e3..f0ade34 100644
--- a/build.gradle.kts
+++ b/build.gradle.kts
@@ -3,6 +3,8 @@ plugins {
alias(libs.plugins.android.library) apply false
alias(libs.plugins.kotlin.compose) apply false
alias(libs.plugins.kotlin.serialization) apply false
+ alias(libs.plugins.kotlin.kapt) apply false
+ alias(libs.plugins.hilt.android) apply false
}
group = "com.xuqm"
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index b4e4099..ffb408c 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -16,16 +16,21 @@ okhttp = "5.3.2"
gson = "2.13.2"
jserialization = "1.9.0"
webkit = "1.14.0"
-coil = "2.7.0"
-sentryAndroid = "8.39.1"
+coil = "3.1.0"
junit4 = "4.13.2"
androidxJunit = "1.3.0"
espresso = "3.7.0"
+hilt = "2.56.2"
+navigationCompose = "2.9.0"
+securityCrypto = "1.0.0"
+hiltNavigationCompose = "1.2.0"
+viewmodelCompose = "2.10.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-lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "viewmodelCompose" }
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" }
@@ -38,16 +43,21 @@ androidx-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit
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" }
+androidx-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigationCompose" }
+androidx-hilt-navigation-compose = { group = "androidx.hilt", name = "hilt-navigation-compose", version.ref = "hiltNavigationCompose" }
+androidx-security-crypto = { group = "androidx.security", name = "security-crypto", version.ref = "securityCrypto" }
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" }
-sentry-android = { group = "io.sentry", name = "sentry-android", version.ref = "sentryAndroid" }
+coil-compose = { group = "io.coil-kt.coil3", name = "coil-compose", version.ref = "coil" }
+coil-network-okhttp = { group = "io.coil-kt.coil3", name = "coil-network-okhttp", 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" }
+hilt-android = { group = "com.google.dagger", name = "hilt-android", version.ref = "hilt" }
+hilt-compiler = { group = "com.google.dagger", name = "hilt-compiler", version.ref = "hilt" }
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" }
@@ -77,3 +87,5 @@ 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" }
+kotlin-kapt = { id = "org.jetbrains.kotlin.kapt", version.ref = "kotlin" }
+hilt-android = { id = "com.google.dagger.hilt.android", version.ref = "hilt" }
diff --git a/sample-app/build.gradle.kts b/sample-app/build.gradle.kts
index e50820c..e3968d3 100644
--- a/sample-app/build.gradle.kts
+++ b/sample-app/build.gradle.kts
@@ -1,8 +1,8 @@
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.compose)
-
- id("io.sentry.android.gradle") version "6.4.0"
+ alias(libs.plugins.kotlin.kapt)
+ alias(libs.plugins.hilt.android)
}
android {
@@ -10,7 +10,7 @@ android {
compileSdk = libs.versions.compileSdk.get().toInt()
defaultConfig {
- applicationId = "com.xuqm.sdk.sample"
+ applicationId = "com.xuqm.demo"
minSdk = libs.versions.minSdk.get().toInt()
targetSdk = libs.versions.targetSdk.get().toInt()
versionCode = 1
@@ -25,10 +25,16 @@ android {
}
compileOptions {
- sourceCompatibility = JavaVersion.VERSION_11
- targetCompatibility = JavaVersion.VERSION_11
+ sourceCompatibility = JavaVersion.VERSION_21
+ targetCompatibility = JavaVersion.VERSION_21
+ }
+
+ kotlinOptions { jvmTarget = "21" }
+
+ buildFeatures {
+ compose = true
+ buildConfig = true
}
- buildFeatures { compose = true }
}
dependencies {
@@ -36,10 +42,21 @@ dependencies {
implementation(project(":sdk-im"))
implementation(project(":sdk-push"))
implementation(project(":sdk-update"))
- implementation(libs.sentry.android)
+
implementation(platform(libs.androidx.compose.bom))
implementation(libs.bundles.compose)
implementation(libs.androidx.activity.compose)
- implementation(libs.androidx.lifecycle.runtime.ktx)
+ implementation(libs.androidx.lifecycle.runtime.compose)
+ implementation(libs.androidx.lifecycle.viewmodel.compose)
+
+ implementation(libs.androidx.navigation.compose)
+
+ implementation(libs.hilt.android)
+ kapt(libs.hilt.compiler)
+ implementation(libs.androidx.hilt.navigation.compose)
+
+ implementation(libs.coil.compose)
+ implementation(libs.coil.network.okhttp)
+
debugImplementation(libs.bundles.compose.debug)
}
diff --git a/sample-app/src/main/AndroidManifest.xml b/sample-app/src/main/AndroidManifest.xml
index 246834e..9e6d2e5 100644
--- a/sample-app/src/main/AndroidManifest.xml
+++ b/sample-app/src/main/AndroidManifest.xml
@@ -5,13 +5,14 @@
+ android:allowBackup="true"
+ android:label="XuqmGroup Demo"
+ android:theme="@style/Theme.XuqmDemo">
+ android:exported="true"
+ android:windowSoftInputMode="adjustResize">
@@ -26,6 +27,5 @@
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
-
-
+
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
index 98b061b..a6ec023 100644
--- a/sample-app/src/main/java/com/xuqm/sdk/sample/MainActivity.kt
+++ b/sample-app/src/main/java/com/xuqm/sdk/sample/MainActivity.kt
@@ -3,151 +3,25 @@ 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
-import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.withContext
+import androidx.activity.enableEdgeToEdge
+import com.xuqm.sdk.sample.data.repo.AuthRepository
+import com.xuqm.sdk.sample.navigation.AppNavGraph
+import com.xuqm.sdk.sample.ui.theme.XuqmTheme
+import dagger.hilt.android.AndroidEntryPoint
+import javax.inject.Inject
+@AndroidEntryPoint
class MainActivity : ComponentActivity() {
+
+ @Inject
+ lateinit var authRepository: AuthRepository
+
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,
- )
-
+ enableEdgeToEdge()
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), verticalArrangement = Arrangement.spacedBy(8.dp)) {
- Text("Sentry 测试", style = MaterialTheme.typography.titleMedium)
- Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
- Button(onClick = {
- scope.launch {
- val result = withContext(Dispatchers.IO) {
- val eventId = Sentry.captureException(
- IllegalStateException("This app uses Sentry! :)")
- ) { eventScope ->
- eventScope.setTag("source", "main_activity_button")
- eventScope.setLevel(SentryLevel.ERROR)
- }
- val flushed = Sentry.flush(5_000)
- eventId to flushed
- }
- messages.add("[Sentry] 已发送: ${result.first}, flush=${result.second}")
- }
- }) { Text("上报异常") }
-
- Button(onClick = {
- throw RuntimeException("Uncaught crash test for Sentry")
- }, colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.error)) {
- Text("闪退测试")
- }
- }
- }
- }
-
- 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) }
+ XuqmTheme {
+ AppNavGraph(authRepository = authRepository)
}
}
}
diff --git a/sample-app/src/main/java/com/xuqm/sdk/sample/XuqmSampleApp.kt b/sample-app/src/main/java/com/xuqm/sdk/sample/XuqmSampleApp.kt
index 47c541c..837744a 100644
--- a/sample-app/src/main/java/com/xuqm/sdk/sample/XuqmSampleApp.kt
+++ b/sample-app/src/main/java/com/xuqm/sdk/sample/XuqmSampleApp.kt
@@ -1,9 +1,19 @@
package com.xuqm.sdk.sample
import android.app.Application
+import com.xuqm.sdk.XuqmSDK
+import com.xuqm.sdk.core.LogLevel
+import dagger.hilt.android.HiltAndroidApp
+@HiltAndroidApp
class XuqmSampleApp : Application() {
+
override fun onCreate() {
super.onCreate()
+ XuqmSDK.initialize(
+ context = this,
+ appId = "your_app_id",
+ logLevel = if (BuildConfig.DEBUG) LogLevel.DEBUG else LogLevel.WARN,
+ )
}
}
diff --git a/sample-app/src/main/java/com/xuqm/sdk/sample/data/api/DemoApi.kt b/sample-app/src/main/java/com/xuqm/sdk/sample/data/api/DemoApi.kt
new file mode 100644
index 0000000..da860de
--- /dev/null
+++ b/sample-app/src/main/java/com/xuqm/sdk/sample/data/api/DemoApi.kt
@@ -0,0 +1,46 @@
+package com.xuqm.sdk.sample.data.api
+
+import retrofit2.http.Body
+import retrofit2.http.GET
+import retrofit2.http.POST
+import retrofit2.http.PUT
+import retrofit2.http.Query
+
+data class DemoResponse(
+ val code: Int = 0,
+ val message: String? = null,
+ val data: T? = null,
+)
+
+data class LoginRequest(val userId: String, val password: String)
+
+data class RegisterRequest(val userId: String, val password: String, val nickname: String)
+
+data class LoginData(val token: String, val userId: String, val nickname: String, val avatar: String?)
+
+data class UserData(val userId: String, val nickname: String, val avatar: String?)
+
+data class UpdateProfileRequest(val nickname: String, val avatar: String?)
+
+data class ResetPasswordRequest(val oldPassword: String, val newPassword: String)
+
+interface DemoApi {
+
+ @POST("api/user/login")
+ suspend fun login(@Body request: LoginRequest): DemoResponse
+
+ @POST("api/user/register")
+ suspend fun register(@Body request: RegisterRequest): DemoResponse
+
+ @GET("api/user/profile")
+ suspend fun getProfile(): DemoResponse
+
+ @PUT("api/user/profile")
+ suspend fun updateProfile(@Body request: UpdateProfileRequest): DemoResponse
+
+ @POST("api/user/reset-password")
+ suspend fun resetPassword(@Body request: ResetPasswordRequest): DemoResponse
+
+ @GET("api/user/users")
+ suspend fun searchUsers(@Query("keyword") keyword: String): DemoResponse>
+}
diff --git a/sample-app/src/main/java/com/xuqm/sdk/sample/data/repo/AuthRepository.kt b/sample-app/src/main/java/com/xuqm/sdk/sample/data/repo/AuthRepository.kt
new file mode 100644
index 0000000..d4aae4c
--- /dev/null
+++ b/sample-app/src/main/java/com/xuqm/sdk/sample/data/repo/AuthRepository.kt
@@ -0,0 +1,99 @@
+package com.xuqm.sdk.sample.data.repo
+
+import android.content.Context
+import androidx.security.crypto.EncryptedSharedPreferences
+import androidx.security.crypto.MasterKey
+import com.xuqm.sdk.im.ImSDK
+import com.xuqm.sdk.sample.data.api.DemoApi
+import com.xuqm.sdk.sample.data.api.LoginRequest
+import com.xuqm.sdk.sample.data.api.RegisterRequest
+import com.xuqm.sdk.sample.data.api.ResetPasswordRequest
+import com.xuqm.sdk.sample.data.api.UpdateProfileRequest
+import com.xuqm.sdk.sample.data.api.UserData
+import dagger.hilt.android.qualifiers.ApplicationContext
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+import javax.inject.Inject
+import javax.inject.Singleton
+
+@Singleton
+class AuthRepository @Inject constructor(
+ @ApplicationContext private val context: Context,
+ private val api: DemoApi,
+) {
+ private val prefs = EncryptedSharedPreferences.create(
+ context,
+ "xuqm_demo_auth",
+ MasterKey.Builder(context).setKeyScheme(MasterKey.KeyScheme.AES256_GCM).build(),
+ EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
+ EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
+ )
+
+ fun getDemoToken(): String? = prefs.getString("demo_token", null)
+ fun getCurrentUserId(): String? = prefs.getString("user_id", null)
+ fun getCurrentNickname(): String? = prefs.getString("nickname", null)
+ fun getCurrentAvatar(): String? = prefs.getString("avatar", null)
+ fun isLoggedIn(): Boolean = getDemoToken() != null
+
+ private fun saveSession(token: String, userId: String, nickname: String, avatar: String?) {
+ prefs.edit()
+ .putString("demo_token", token)
+ .putString("user_id", userId)
+ .putString("nickname", nickname)
+ .putString("avatar", avatar)
+ .apply()
+ }
+
+ suspend fun login(userId: String, password: String): Result =
+ withContext(Dispatchers.IO) {
+ runCatching {
+ val res = api.login(LoginRequest(userId, password))
+ val data = requireNotNull(res.data) { res.message ?: "Login failed" }
+ saveSession(data.token, data.userId, data.nickname, data.avatar)
+ ImSDK.login(data.userId, data.nickname, data.avatar)
+ UserData(data.userId, data.nickname, data.avatar)
+ }
+ }
+
+ suspend fun register(userId: String, password: String, nickname: String): Result =
+ withContext(Dispatchers.IO) {
+ runCatching {
+ val res = api.register(RegisterRequest(userId, password, nickname))
+ val data = requireNotNull(res.data) { res.message ?: "Register failed" }
+ saveSession(data.token, data.userId, data.nickname, data.avatar)
+ ImSDK.login(data.userId, data.nickname, data.avatar)
+ UserData(data.userId, data.nickname, data.avatar)
+ }
+ }
+
+ suspend fun getProfile(): Result =
+ withContext(Dispatchers.IO) {
+ runCatching {
+ requireNotNull(api.getProfile().data) { "Failed to get profile" }
+ }
+ }
+
+ suspend fun updateProfile(nickname: String, avatar: String?): Result =
+ withContext(Dispatchers.IO) {
+ runCatching {
+ val data = requireNotNull(api.updateProfile(UpdateProfileRequest(nickname, avatar)).data)
+ prefs.edit().putString("nickname", data.nickname).putString("avatar", data.avatar).apply()
+ data
+ }
+ }
+
+ suspend fun resetPassword(oldPassword: String, newPassword: String): Result =
+ withContext(Dispatchers.IO) {
+ runCatching { api.resetPassword(ResetPasswordRequest(oldPassword, newPassword)) }
+ }
+
+ suspend fun searchUsers(keyword: String): Result> =
+ withContext(Dispatchers.IO) {
+ runCatching { api.searchUsers(keyword).data ?: emptyList() }
+ }
+
+ fun logout() {
+ ImSDK.disconnect()
+ prefs.edit().clear().apply()
+ }
+}
diff --git a/sample-app/src/main/java/com/xuqm/sdk/sample/di/AppModule.kt b/sample-app/src/main/java/com/xuqm/sdk/sample/di/AppModule.kt
new file mode 100644
index 0000000..1a682ae
--- /dev/null
+++ b/sample-app/src/main/java/com/xuqm/sdk/sample/di/AppModule.kt
@@ -0,0 +1,18 @@
+package com.xuqm.sdk.sample.di
+
+import com.xuqm.sdk.network.ApiClient
+import com.xuqm.sdk.sample.data.api.DemoApi
+import dagger.Module
+import dagger.Provides
+import dagger.hilt.InstallIn
+import dagger.hilt.components.SingletonComponent
+import javax.inject.Singleton
+
+@Module
+@InstallIn(SingletonComponent::class)
+object AppModule {
+
+ @Provides
+ @Singleton
+ fun provideDemoApi(): DemoApi = ApiClient.create()
+}
diff --git a/sample-app/src/main/java/com/xuqm/sdk/sample/navigation/AppNavGraph.kt b/sample-app/src/main/java/com/xuqm/sdk/sample/navigation/AppNavGraph.kt
new file mode 100644
index 0000000..f0e8fdc
--- /dev/null
+++ b/sample-app/src/main/java/com/xuqm/sdk/sample/navigation/AppNavGraph.kt
@@ -0,0 +1,92 @@
+package com.xuqm.sdk.sample.navigation
+
+import androidx.compose.runtime.Composable
+import androidx.navigation.NavHostController
+import androidx.navigation.compose.NavHost
+import androidx.navigation.compose.composable
+import androidx.navigation.compose.navigation
+import androidx.navigation.compose.rememberNavController
+import com.xuqm.sdk.sample.data.repo.AuthRepository
+import com.xuqm.sdk.sample.ui.auth.LoginScreen
+import com.xuqm.sdk.sample.ui.auth.RegisterScreen
+import com.xuqm.sdk.sample.ui.chat.ChatScreen
+import com.xuqm.sdk.sample.ui.group.GroupSettingsScreen
+import com.xuqm.sdk.sample.ui.main.MainScreen
+import java.net.URLDecoder
+import java.net.URLEncoder
+
+@Composable
+fun AppNavGraph(
+ authRepository: AuthRepository,
+ navController: NavHostController = rememberNavController(),
+) {
+ val startDestination = if (authRepository.isLoggedIn()) "main" else "auth"
+
+ NavHost(navController = navController, startDestination = startDestination) {
+
+ navigation(startDestination = "login", route = "auth") {
+ composable("login") {
+ LoginScreen(
+ onLoginSuccess = {
+ navController.navigate("main") {
+ popUpTo("auth") { inclusive = true }
+ }
+ },
+ onNavigateToRegister = { navController.navigate("register") },
+ )
+ }
+ composable("register") {
+ RegisterScreen(
+ onRegisterSuccess = {
+ navController.navigate("main") {
+ popUpTo("auth") { inclusive = true }
+ }
+ },
+ onNavigateBack = { navController.popBackStack() },
+ )
+ }
+ }
+
+ composable("main") {
+ MainScreen(
+ onOpenChat = { targetId, chatType, targetName ->
+ val encodedName = URLEncoder.encode(targetName, "UTF-8")
+ navController.navigate("chat/$chatType/$targetId/$encodedName")
+ },
+ onGroupSettings = { groupId ->
+ navController.navigate("group_settings/$groupId")
+ },
+ onLogout = {
+ navController.navigate("auth") {
+ popUpTo("main") { inclusive = true }
+ }
+ },
+ )
+ }
+
+ composable("chat/{chatType}/{targetId}/{targetName}") { backStackEntry ->
+ val chatType = backStackEntry.arguments?.getString("chatType") ?: "SINGLE"
+ val targetId = backStackEntry.arguments?.getString("targetId") ?: ""
+ val targetName = URLDecoder.decode(
+ backStackEntry.arguments?.getString("targetName") ?: targetId, "UTF-8"
+ )
+ ChatScreen(
+ targetId = targetId,
+ chatType = chatType,
+ targetName = targetName,
+ onNavigateBack = { navController.popBackStack() },
+ onGroupSettings = if (chatType == "GROUP") ({
+ navController.navigate("group_settings/$targetId")
+ }) else null,
+ )
+ }
+
+ composable("group_settings/{groupId}") { backStackEntry ->
+ val groupId = backStackEntry.arguments?.getString("groupId") ?: ""
+ GroupSettingsScreen(
+ groupId = groupId,
+ onNavigateBack = { navController.popBackStack() },
+ )
+ }
+ }
+}
diff --git a/sample-app/src/main/java/com/xuqm/sdk/sample/ui/auth/LoginScreen.kt b/sample-app/src/main/java/com/xuqm/sdk/sample/ui/auth/LoginScreen.kt
new file mode 100644
index 0000000..61f18d7
--- /dev/null
+++ b/sample-app/src/main/java/com/xuqm/sdk/sample/ui/auth/LoginScreen.kt
@@ -0,0 +1,108 @@
+package com.xuqm.sdk.sample.ui.auth
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.imePadding
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.text.KeyboardOptions
+import androidx.compose.material3.Button
+import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.OutlinedTextField
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.text.input.KeyboardType
+import androidx.compose.ui.text.input.PasswordVisualTransformation
+import androidx.compose.ui.unit.dp
+import androidx.hilt.navigation.compose.hiltViewModel
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+
+@Composable
+fun LoginScreen(
+ onLoginSuccess: () -> Unit,
+ onNavigateToRegister: () -> Unit,
+ viewModel: LoginViewModel = hiltViewModel(),
+) {
+ val state by viewModel.state.collectAsStateWithLifecycle()
+
+ var userId by remember { mutableStateOf("") }
+ var password by remember { mutableStateOf("") }
+
+ LaunchedEffect(state) {
+ if (state is LoginState.Success) onLoginSuccess()
+ }
+
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(24.dp)
+ .imePadding(),
+ verticalArrangement = Arrangement.Center,
+ horizontalAlignment = Alignment.CenterHorizontally,
+ ) {
+ Text("XuqmGroup IM", style = MaterialTheme.typography.headlineMedium)
+
+ Spacer(Modifier.height(32.dp))
+
+ OutlinedTextField(
+ value = userId,
+ onValueChange = { userId = it },
+ label = { Text("用户 ID") },
+ modifier = Modifier.fillMaxWidth(),
+ singleLine = true,
+ )
+
+ Spacer(Modifier.height(12.dp))
+
+ OutlinedTextField(
+ value = password,
+ onValueChange = { password = it },
+ label = { Text("密码") },
+ modifier = Modifier.fillMaxWidth(),
+ singleLine = true,
+ visualTransformation = PasswordVisualTransformation(),
+ keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
+ )
+
+ if (state is LoginState.Error) {
+ Spacer(Modifier.height(8.dp))
+ Text(
+ (state as LoginState.Error).message,
+ color = MaterialTheme.colorScheme.error,
+ style = MaterialTheme.typography.bodySmall,
+ )
+ }
+
+ Spacer(Modifier.height(24.dp))
+
+ Button(
+ onClick = { viewModel.login(userId.trim(), password) },
+ modifier = Modifier.fillMaxWidth(),
+ enabled = state !is LoginState.Loading && userId.isNotBlank() && password.isNotBlank(),
+ ) {
+ if (state is LoginState.Loading) {
+ CircularProgressIndicator(modifier = Modifier.height(20.dp))
+ } else {
+ Text("登录")
+ }
+ }
+
+ Spacer(Modifier.height(12.dp))
+
+ TextButton(onClick = onNavigateToRegister) {
+ Text("没有账号?注册")
+ }
+ }
+}
diff --git a/sample-app/src/main/java/com/xuqm/sdk/sample/ui/auth/LoginViewModel.kt b/sample-app/src/main/java/com/xuqm/sdk/sample/ui/auth/LoginViewModel.kt
new file mode 100644
index 0000000..c8bf9e4
--- /dev/null
+++ b/sample-app/src/main/java/com/xuqm/sdk/sample/ui/auth/LoginViewModel.kt
@@ -0,0 +1,35 @@
+package com.xuqm.sdk.sample.ui.auth
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import com.xuqm.sdk.sample.data.repo.AuthRepository
+import dagger.hilt.android.lifecycle.HiltViewModel
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.launch
+import javax.inject.Inject
+
+sealed interface LoginState {
+ data object Idle : LoginState
+ data object Loading : LoginState
+ data object Success : LoginState
+ data class Error(val message: String) : LoginState
+}
+
+@HiltViewModel
+class LoginViewModel @Inject constructor(
+ private val authRepository: AuthRepository,
+) : ViewModel() {
+
+ private val _state = MutableStateFlow(LoginState.Idle)
+ val state: StateFlow = _state
+
+ fun login(userId: String, password: String) {
+ viewModelScope.launch {
+ _state.value = LoginState.Loading
+ authRepository.login(userId, password)
+ .onSuccess { _state.value = LoginState.Success }
+ .onFailure { _state.value = LoginState.Error(it.message ?: "Unknown error") }
+ }
+ }
+}
diff --git a/sample-app/src/main/java/com/xuqm/sdk/sample/ui/auth/RegisterScreen.kt b/sample-app/src/main/java/com/xuqm/sdk/sample/ui/auth/RegisterScreen.kt
new file mode 100644
index 0000000..0a74fa6
--- /dev/null
+++ b/sample-app/src/main/java/com/xuqm/sdk/sample/ui/auth/RegisterScreen.kt
@@ -0,0 +1,144 @@
+package com.xuqm.sdk.sample.ui.auth
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.imePadding
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.filled.ArrowBack
+import androidx.compose.material3.Button
+import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.OutlinedTextField
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Text
+import androidx.compose.material3.TopAppBar
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.text.input.PasswordVisualTransformation
+import androidx.compose.ui.unit.dp
+import androidx.hilt.navigation.compose.hiltViewModel
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import com.xuqm.sdk.sample.data.repo.AuthRepository
+import dagger.hilt.android.lifecycle.HiltViewModel
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.launch
+import javax.inject.Inject
+
+@HiltViewModel
+class RegisterViewModel @Inject constructor(
+ private val authRepository: AuthRepository,
+) : ViewModel() {
+
+ private val _state = MutableStateFlow(LoginState.Idle)
+ val state: StateFlow = _state
+
+ fun register(userId: String, password: String, nickname: String) {
+ viewModelScope.launch {
+ _state.value = LoginState.Loading
+ authRepository.register(userId, password, nickname)
+ .onSuccess { _state.value = LoginState.Success }
+ .onFailure { _state.value = LoginState.Error(it.message ?: "Registration failed") }
+ }
+ }
+}
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun RegisterScreen(
+ onRegisterSuccess: () -> Unit,
+ onNavigateBack: () -> Unit,
+ viewModel: RegisterViewModel = hiltViewModel(),
+) {
+ val state by viewModel.state.collectAsStateWithLifecycle()
+
+ var userId by remember { mutableStateOf("") }
+ var nickname by remember { mutableStateOf("") }
+ var password by remember { mutableStateOf("") }
+
+ LaunchedEffect(state) {
+ if (state is LoginState.Success) onRegisterSuccess()
+ }
+
+ Scaffold(
+ topBar = {
+ TopAppBar(
+ title = { Text("注册") },
+ navigationIcon = {
+ IconButton(onClick = onNavigateBack) {
+ Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null)
+ }
+ },
+ )
+ },
+ ) { padding ->
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(padding)
+ .padding(horizontal = 24.dp)
+ .imePadding(),
+ verticalArrangement = Arrangement.spacedBy(12.dp),
+ ) {
+ Spacer(Modifier.height(8.dp))
+
+ OutlinedTextField(
+ value = userId,
+ onValueChange = { userId = it },
+ label = { Text("用户 ID") },
+ modifier = Modifier.fillMaxWidth(),
+ singleLine = true,
+ )
+
+ OutlinedTextField(
+ value = nickname,
+ onValueChange = { nickname = it },
+ label = { Text("昵称") },
+ modifier = Modifier.fillMaxWidth(),
+ singleLine = true,
+ )
+
+ OutlinedTextField(
+ value = password,
+ onValueChange = { password = it },
+ label = { Text("密码") },
+ modifier = Modifier.fillMaxWidth(),
+ singleLine = true,
+ visualTransformation = PasswordVisualTransformation(),
+ )
+
+ if (state is LoginState.Error) {
+ Text(
+ (state as LoginState.Error).message,
+ color = MaterialTheme.colorScheme.error,
+ style = MaterialTheme.typography.bodySmall,
+ )
+ }
+
+ Button(
+ onClick = { viewModel.register(userId.trim(), password, nickname.trim()) },
+ modifier = Modifier.fillMaxWidth(),
+ enabled = state !is LoginState.Loading
+ && userId.isNotBlank() && password.isNotBlank() && nickname.isNotBlank(),
+ ) {
+ if (state is LoginState.Loading) CircularProgressIndicator(Modifier.height(20.dp))
+ else Text("注册")
+ }
+ }
+ }
+}
diff --git a/sample-app/src/main/java/com/xuqm/sdk/sample/ui/chat/ChatScreen.kt b/sample-app/src/main/java/com/xuqm/sdk/sample/ui/chat/ChatScreen.kt
new file mode 100644
index 0000000..3c2af83
--- /dev/null
+++ b/sample-app/src/main/java/com/xuqm/sdk/sample/ui/chat/ChatScreen.kt
@@ -0,0 +1,194 @@
+package com.xuqm.sdk.sample.ui.chat
+
+import androidx.compose.foundation.ExperimentalFoundationApi
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.imePadding
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.widthIn
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.foundation.lazy.rememberLazyListState
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.filled.ArrowBack
+import androidx.compose.material.icons.automirrored.filled.Send
+import androidx.compose.material.icons.filled.Settings
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.OutlinedTextField
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
+import androidx.compose.material3.TopAppBar
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.unit.dp
+import androidx.hilt.navigation.compose.hiltViewModel
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import com.xuqm.sdk.im.model.ImMessage
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun ChatScreen(
+ targetId: String,
+ chatType: String,
+ targetName: String,
+ onNavigateBack: () -> Unit,
+ onGroupSettings: (() -> Unit)? = null,
+ viewModel: ChatViewModel = hiltViewModel(),
+) {
+ val messages by viewModel.messages.collectAsStateWithLifecycle()
+ val listState = rememberLazyListState()
+ var input by remember { mutableStateOf("") }
+
+ LaunchedEffect(Unit) { viewModel.init(targetId, chatType) }
+ LaunchedEffect(messages.size) {
+ if (messages.isNotEmpty()) listState.animateScrollToItem(0)
+ }
+
+ Scaffold(
+ topBar = {
+ TopAppBar(
+ title = { Text(targetName) },
+ navigationIcon = {
+ IconButton(onClick = onNavigateBack) {
+ Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null)
+ }
+ },
+ actions = {
+ if (chatType == "GROUP" && onGroupSettings != null) {
+ IconButton(onClick = onGroupSettings) {
+ Icon(Icons.Default.Settings, contentDescription = null)
+ }
+ }
+ },
+ )
+ },
+ bottomBar = {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .imePadding()
+ .padding(8.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ OutlinedTextField(
+ value = input,
+ onValueChange = { input = it },
+ modifier = Modifier.weight(1f),
+ placeholder = { Text("输入消息…") },
+ maxLines = 4,
+ shape = RoundedCornerShape(24.dp),
+ )
+ IconButton(
+ onClick = { viewModel.sendText(input); input = "" },
+ enabled = input.isNotBlank(),
+ ) {
+ Icon(Icons.AutoMirrored.Filled.Send, contentDescription = null)
+ }
+ }
+ },
+ ) { padding ->
+ LazyColumn(
+ state = listState,
+ reverseLayout = true,
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(padding),
+ ) {
+ items(messages, key = { it.id }) { msg ->
+ MessageBubble(
+ message = msg,
+ isOwn = msg.fromId == viewModel.currentUserId,
+ )
+ }
+ }
+ }
+}
+
+@Composable
+private fun MessageBubble(message: ImMessage, isOwn: Boolean) {
+ val arrangement = if (isOwn) Arrangement.End else Arrangement.Start
+ val bubbleColor = if (isOwn) MaterialTheme.colorScheme.primaryContainer
+ else MaterialTheme.colorScheme.surfaceVariant
+
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 12.dp, vertical = 4.dp),
+ horizontalArrangement = arrangement,
+ verticalAlignment = Alignment.Bottom,
+ ) {
+ if (!isOwn) {
+ AvatarPlaceholder(message.fromId)
+ }
+
+ Surface(
+ shape = RoundedCornerShape(
+ topStart = if (isOwn) 16.dp else 4.dp,
+ topEnd = if (isOwn) 4.dp else 16.dp,
+ bottomStart = 16.dp,
+ bottomEnd = 16.dp,
+ ),
+ color = bubbleColor,
+ modifier = Modifier
+ .widthIn(max = 280.dp)
+ .padding(horizontal = 4.dp),
+ ) {
+ Text(
+ text = parseContent(message),
+ modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp),
+ style = MaterialTheme.typography.bodyMedium,
+ )
+ }
+
+ if (isOwn) {
+ AvatarPlaceholder(message.fromId)
+ }
+ }
+}
+
+@Composable
+private fun AvatarPlaceholder(userId: String) {
+ Surface(
+ modifier = Modifier.size(32.dp).clip(CircleShape),
+ color = MaterialTheme.colorScheme.secondaryContainer,
+ ) {
+ Box(contentAlignment = Alignment.Center) {
+ Text(userId.take(1).uppercase(), style = MaterialTheme.typography.labelSmall)
+ }
+ }
+}
+
+private fun parseContent(message: ImMessage): String {
+ return when (message.msgType) {
+ "TEXT" -> runCatching {
+ org.json.JSONObject(message.content).getString("text")
+ }.getOrDefault(message.content)
+ "IMAGE" -> "[图片]"
+ "AUDIO" -> "[语音]"
+ "VIDEO" -> "[视频]"
+ "FILE" -> "[文件]"
+ "REVOKED" -> "[消息已撤回]"
+ "NOTIFY" -> runCatching {
+ org.json.JSONObject(message.content).getString("content")
+ }.getOrDefault("[通知]")
+ else -> message.content
+ }
+}
diff --git a/sample-app/src/main/java/com/xuqm/sdk/sample/ui/chat/ChatViewModel.kt b/sample-app/src/main/java/com/xuqm/sdk/sample/ui/chat/ChatViewModel.kt
new file mode 100644
index 0000000..a08744c
--- /dev/null
+++ b/sample-app/src/main/java/com/xuqm/sdk/sample/ui/chat/ChatViewModel.kt
@@ -0,0 +1,67 @@
+package com.xuqm.sdk.sample.ui.chat
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import com.xuqm.sdk.im.ImSDK
+import com.xuqm.sdk.im.listener.ImEventListener
+import com.xuqm.sdk.im.model.ImMessage
+import dagger.hilt.android.lifecycle.HiltViewModel
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.launch
+import javax.inject.Inject
+
+@HiltViewModel
+class ChatViewModel @Inject constructor() : ViewModel() {
+
+ private val _messages = MutableStateFlow>(emptyList())
+ val messages: StateFlow> = _messages
+
+ val currentUserId: String get() = ImSDK.currentUserId
+
+ private lateinit var targetId: String
+ private lateinit var chatType: String
+
+ private val listener = object : ImEventListener {
+ override fun onMessage(message: ImMessage) {
+ if (message.fromId == targetId || message.toId == targetId) {
+ _messages.value = listOf(message) + _messages.value
+ }
+ }
+ override fun onGroupMessage(message: ImMessage) {
+ if (message.toId == targetId) {
+ _messages.value = listOf(message) + _messages.value
+ }
+ }
+ }
+
+ fun init(targetId: String, chatType: String) {
+ this.targetId = targetId
+ this.chatType = chatType
+ ImSDK.addListener(listener)
+ loadHistory()
+ viewModelScope.launch {
+ runCatching { ImSDK.markRead(targetId, chatType) }
+ }
+ }
+
+ fun loadHistory() {
+ viewModelScope.launch {
+ val history = if (chatType == "GROUP") {
+ runCatching { ImSDK.fetchGroupHistory(targetId) }.getOrDefault(emptyList())
+ } else {
+ runCatching { ImSDK.fetchHistory(targetId) }.getOrDefault(emptyList())
+ }
+ _messages.value = history
+ }
+ }
+
+ fun sendText(content: String) {
+ if (content.isBlank()) return
+ ImSDK.sendMessage(targetId, chatType, "TEXT", content)
+ }
+
+ override fun onCleared() {
+ ImSDK.removeListener(listener)
+ }
+}
diff --git a/sample-app/src/main/java/com/xuqm/sdk/sample/ui/contact/ContactScreen.kt b/sample-app/src/main/java/com/xuqm/sdk/sample/ui/contact/ContactScreen.kt
new file mode 100644
index 0000000..b5ba098
--- /dev/null
+++ b/sample-app/src/main/java/com/xuqm/sdk/sample/ui/contact/ContactScreen.kt
@@ -0,0 +1,167 @@
+package com.xuqm.sdk.sample.ui.contact
+
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Search
+import androidx.compose.material3.HorizontalDivider
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.OutlinedTextField
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.dp
+import androidx.hilt.navigation.compose.hiltViewModel
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import androidx.lifecycle.viewModelScope
+import com.xuqm.sdk.im.ImSDK
+import com.xuqm.sdk.im.model.UserProfile
+import com.xuqm.sdk.sample.data.api.UserData
+import com.xuqm.sdk.sample.data.repo.AuthRepository
+import dagger.hilt.android.lifecycle.HiltViewModel
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.launch
+import javax.inject.Inject
+
+@HiltViewModel
+class ContactViewModel @Inject constructor(
+ private val authRepository: AuthRepository,
+) : ViewModel() {
+
+ private val _friends = MutableStateFlow>(emptyList())
+ val friends: StateFlow> = _friends
+
+ private val _searchResults = MutableStateFlow>(emptyList())
+ val searchResults: StateFlow> = _searchResults
+
+ init { loadFriends() }
+
+ fun loadFriends() {
+ viewModelScope.launch {
+ runCatching { ImSDK.listFriends() }
+ .onSuccess { _friends.value = it }
+ }
+ }
+
+ fun search(keyword: String) {
+ if (keyword.isBlank()) { _searchResults.value = emptyList(); return }
+ viewModelScope.launch {
+ authRepository.searchUsers(keyword)
+ .onSuccess { _searchResults.value = it }
+ }
+ }
+
+ fun addFriend(userId: String) {
+ viewModelScope.launch {
+ runCatching { ImSDK.addFriend(userId) }
+ .onSuccess { loadFriends() }
+ }
+ }
+
+ fun removeFriend(userId: String) {
+ viewModelScope.launch {
+ runCatching { ImSDK.removeFriend(userId) }
+ .onSuccess { loadFriends() }
+ }
+ }
+}
+
+@Composable
+fun ContactScreen(
+ onOpenChat: (userId: String) -> Unit,
+ viewModel: ContactViewModel = hiltViewModel(),
+) {
+ val friends by viewModel.friends.collectAsStateWithLifecycle()
+ val searchResults by viewModel.searchResults.collectAsStateWithLifecycle()
+ var keyword by remember { mutableStateOf("") }
+
+ Column(modifier = Modifier.fillMaxSize()) {
+ OutlinedTextField(
+ value = keyword,
+ onValueChange = { keyword = it; viewModel.search(it) },
+ modifier = Modifier.fillMaxWidth().padding(12.dp),
+ placeholder = { Text("搜索用户 ID 或昵称") },
+ leadingIcon = { Icon(Icons.Default.Search, contentDescription = null) },
+ singleLine = true,
+ )
+
+ if (keyword.isBlank()) {
+ Text(
+ "联系人(${friends.size})",
+ style = MaterialTheme.typography.labelMedium,
+ modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp),
+ color = MaterialTheme.colorScheme.outline,
+ )
+ LazyColumn {
+ items(friends, key = { it.userId }) { user ->
+ FriendItem(
+ userId = user.userId,
+ nickname = user.nickname,
+ onChat = { onOpenChat(user.userId) },
+ onRemove = { viewModel.removeFriend(user.userId) },
+ )
+ HorizontalDivider()
+ }
+ }
+ } else {
+ LazyColumn {
+ items(searchResults, key = { it.userId }) { user ->
+ val isFriend = friends.any { it.userId == user.userId }
+ Row(
+ modifier = Modifier.fillMaxWidth().padding(16.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Column(modifier = Modifier.weight(1f)) {
+ Text(user.nickname, style = MaterialTheme.typography.titleSmall)
+ Text(user.userId, style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.outline)
+ }
+ if (!isFriend) {
+ TextButton(onClick = { viewModel.addFriend(user.userId) }) { Text("添加好友") }
+ } else {
+ TextButton(onClick = { onOpenChat(user.userId) }) { Text("发消息") }
+ }
+ }
+ HorizontalDivider()
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun FriendItem(userId: String, nickname: String, onChat: () -> Unit, onRemove: () -> Unit) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .clickable(onClick = onChat)
+ .padding(16.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Column(modifier = Modifier.weight(1f)) {
+ Text(nickname, style = MaterialTheme.typography.titleSmall)
+ Text(userId, style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.outline)
+ }
+ TextButton(onClick = onRemove) {
+ Text("删除", color = MaterialTheme.colorScheme.error)
+ }
+ }
+}
diff --git a/sample-app/src/main/java/com/xuqm/sdk/sample/ui/conversation/ConversationScreen.kt b/sample-app/src/main/java/com/xuqm/sdk/sample/ui/conversation/ConversationScreen.kt
new file mode 100644
index 0000000..84219a0
--- /dev/null
+++ b/sample-app/src/main/java/com/xuqm/sdk/sample/ui/conversation/ConversationScreen.kt
@@ -0,0 +1,150 @@
+package com.xuqm.sdk.sample.ui.conversation
+
+import androidx.compose.foundation.ExperimentalFoundationApi
+import androidx.compose.foundation.combinedClickable
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.material3.Badge
+import androidx.compose.material3.DropdownMenu
+import androidx.compose.material3.DropdownMenuItem
+import androidx.compose.material3.HorizontalDivider
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.dp
+import androidx.hilt.navigation.compose.hiltViewModel
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import coil3.compose.AsyncImage
+import com.xuqm.sdk.im.model.ConversationData
+import kotlinx.coroutines.launch
+import java.text.SimpleDateFormat
+import java.util.Date
+import java.util.Locale
+
+@Composable
+fun ConversationScreen(
+ onOpenChat: (targetId: String, chatType: String, targetName: String) -> Unit,
+ viewModel: ConversationViewModel = hiltViewModel(),
+) {
+ val conversations by viewModel.conversations.collectAsStateWithLifecycle()
+ val scope = rememberCoroutineScope()
+
+ LazyColumn(modifier = Modifier.fillMaxSize()) {
+ items(conversations, key = { "${it.chatType}_${it.targetId}" }) { conv ->
+ ConversationItem(
+ conversation = conv,
+ onClick = { onOpenChat(conv.targetId, conv.chatType, conv.targetId) },
+ onPinToggle = {
+ scope.launch { viewModel.setPinned(conv.targetId, conv.chatType, !conv.isPinned) }
+ },
+ onMuteToggle = {
+ scope.launch { viewModel.setMuted(conv.targetId, conv.chatType, !conv.isMuted) }
+ },
+ )
+ HorizontalDivider(modifier = Modifier.padding(start = 72.dp))
+ }
+ }
+}
+
+@OptIn(ExperimentalFoundationApi::class)
+@Composable
+private fun ConversationItem(
+ conversation: ConversationData,
+ onClick: () -> Unit,
+ onPinToggle: () -> Unit,
+ onMuteToggle: () -> Unit,
+) {
+ var showMenu by remember { mutableStateOf(false) }
+
+ Box {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .combinedClickable(onClick = onClick, onLongClick = { showMenu = true })
+ .padding(horizontal = 16.dp, vertical = 10.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Surface(
+ modifier = Modifier.size(48.dp).clip(CircleShape),
+ color = MaterialTheme.colorScheme.secondaryContainer,
+ ) {
+ Box(contentAlignment = Alignment.Center) {
+ Text(
+ conversation.targetId.take(1).uppercase(),
+ style = MaterialTheme.typography.titleMedium,
+ )
+ }
+ }
+
+ Spacer(Modifier.width(12.dp))
+
+ Column(modifier = Modifier.weight(1f)) {
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ Text(
+ conversation.targetId,
+ style = MaterialTheme.typography.titleSmall,
+ modifier = Modifier.weight(1f),
+ )
+ if (conversation.isPinned) {
+ Text("置顶", style = MaterialTheme.typography.labelSmall,
+ color = MaterialTheme.colorScheme.primary)
+ }
+ Text(
+ formatTime(conversation.lastMsgTime),
+ style = MaterialTheme.typography.labelSmall,
+ color = MaterialTheme.colorScheme.outline,
+ )
+ }
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ Text(
+ conversation.lastMsgContent ?: "",
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.outline,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ modifier = Modifier.weight(1f),
+ )
+ if (conversation.unreadCount > 0 && !conversation.isMuted) {
+ Badge { Text("${conversation.unreadCount}") }
+ }
+ }
+ }
+ }
+
+ DropdownMenu(expanded = showMenu, onDismissRequest = { showMenu = false }) {
+ DropdownMenuItem(
+ text = { Text(if (conversation.isPinned) "取消置顶" else "置顶") },
+ onClick = { showMenu = false; onPinToggle() },
+ )
+ DropdownMenuItem(
+ text = { Text(if (conversation.isMuted) "取消免打扰" else "免打扰") },
+ onClick = { showMenu = false; onMuteToggle() },
+ )
+ }
+ }
+}
+
+private fun formatTime(timestamp: Long): String {
+ if (timestamp == 0L) return ""
+ return SimpleDateFormat("HH:mm", Locale.getDefault()).format(Date(timestamp))
+}
diff --git a/sample-app/src/main/java/com/xuqm/sdk/sample/ui/conversation/ConversationViewModel.kt b/sample-app/src/main/java/com/xuqm/sdk/sample/ui/conversation/ConversationViewModel.kt
new file mode 100644
index 0000000..08f1838
--- /dev/null
+++ b/sample-app/src/main/java/com/xuqm/sdk/sample/ui/conversation/ConversationViewModel.kt
@@ -0,0 +1,53 @@
+package com.xuqm.sdk.sample.ui.conversation
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import com.xuqm.sdk.im.ImSDK
+import com.xuqm.sdk.im.model.ConversationData
+import com.xuqm.sdk.im.model.ImMessage
+import com.xuqm.sdk.im.listener.ImEventListener
+import dagger.hilt.android.lifecycle.HiltViewModel
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.launch
+import javax.inject.Inject
+
+@HiltViewModel
+class ConversationViewModel @Inject constructor() : ViewModel() {
+
+ private val _conversations = MutableStateFlow>(emptyList())
+ val conversations: StateFlow> = _conversations
+
+ private val listener = object : ImEventListener {
+ override fun onMessage(message: ImMessage) { refresh() }
+ override fun onGroupMessage(message: ImMessage) { refresh() }
+ }
+
+ init {
+ ImSDK.addListener(listener)
+ refresh()
+ }
+
+ fun refresh() {
+ viewModelScope.launch {
+ runCatching { ImSDK.listConversations() }
+ .onSuccess { list ->
+ _conversations.value = list.sortedByDescending { it.lastMsgTime }
+ }
+ }
+ }
+
+ suspend fun setPinned(targetId: String, chatType: String, pinned: Boolean) {
+ runCatching { ImSDK.setConversationPinned(targetId, chatType, pinned) }
+ refresh()
+ }
+
+ suspend fun setMuted(targetId: String, chatType: String, muted: Boolean) {
+ runCatching { ImSDK.setConversationMuted(targetId, chatType, muted) }
+ refresh()
+ }
+
+ override fun onCleared() {
+ ImSDK.removeListener(listener)
+ }
+}
diff --git a/sample-app/src/main/java/com/xuqm/sdk/sample/ui/group/GroupScreen.kt b/sample-app/src/main/java/com/xuqm/sdk/sample/ui/group/GroupScreen.kt
new file mode 100644
index 0000000..4a9569c
--- /dev/null
+++ b/sample-app/src/main/java/com/xuqm/sdk/sample/ui/group/GroupScreen.kt
@@ -0,0 +1,207 @@
+package com.xuqm.sdk.sample.ui.group
+
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.filled.ArrowBack
+import androidx.compose.material.icons.filled.Add
+import androidx.compose.material3.AlertDialog
+import androidx.compose.material3.Button
+import androidx.compose.material3.ButtonDefaults
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.FloatingActionButton
+import androidx.compose.material3.HorizontalDivider
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.OutlinedTextField
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.material3.TopAppBar
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.dp
+import androidx.hilt.navigation.compose.hiltViewModel
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import com.xuqm.sdk.im.ImSDK
+import com.xuqm.sdk.im.model.ImGroup
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun GroupListScreen(
+ onOpenGroupChat: (groupId: String, groupName: String) -> Unit,
+ onGroupSettings: (groupId: String) -> Unit,
+ viewModel: GroupViewModel = hiltViewModel(),
+) {
+ val groups by viewModel.groups.collectAsStateWithLifecycle()
+ var showCreateDialog by remember { mutableStateOf(false) }
+
+ Scaffold(
+ floatingActionButton = {
+ FloatingActionButton(onClick = { showCreateDialog = true }) {
+ Icon(Icons.Default.Add, contentDescription = null)
+ }
+ },
+ ) { padding ->
+ LazyColumn(
+ modifier = Modifier.fillMaxSize().padding(padding),
+ ) {
+ items(groups, key = { it.id }) { group ->
+ GroupItem(
+ group = group,
+ onClick = { onOpenGroupChat(group.id, group.name) },
+ onSettings = { onGroupSettings(group.id) },
+ )
+ HorizontalDivider()
+ }
+ }
+ }
+
+ if (showCreateDialog) {
+ CreateGroupDialog(
+ onDismiss = { showCreateDialog = false },
+ onCreate = { name, memberIds ->
+ showCreateDialog = false
+ viewModel.createGroup(name, memberIds) { group ->
+ onOpenGroupChat(group.id, group.name)
+ }
+ },
+ )
+ }
+}
+
+@Composable
+private fun GroupItem(group: ImGroup, onClick: () -> Unit, onSettings: () -> Unit) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .clickable(onClick = onClick)
+ .padding(16.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Column(modifier = Modifier.weight(1f)) {
+ Text(group.name, style = MaterialTheme.typography.titleSmall)
+ Text(
+ "${group.memberIds.split(",").size} 位成员",
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.outline,
+ )
+ }
+ TextButton(onClick = onSettings) { Text("设置") }
+ }
+}
+
+@Composable
+private fun CreateGroupDialog(onDismiss: () -> Unit, onCreate: (String, List) -> Unit) {
+ var name by remember { mutableStateOf("") }
+ var memberInput by remember { mutableStateOf("") }
+
+ AlertDialog(
+ onDismissRequest = onDismiss,
+ title = { Text("创建群组") },
+ text = {
+ Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
+ OutlinedTextField(
+ value = name,
+ onValueChange = { name = it },
+ label = { Text("群名称") },
+ modifier = Modifier.fillMaxWidth(),
+ )
+ OutlinedTextField(
+ value = memberInput,
+ onValueChange = { memberInput = it },
+ label = { Text("成员 ID(逗号分隔)") },
+ modifier = Modifier.fillMaxWidth(),
+ )
+ }
+ },
+ confirmButton = {
+ Button(
+ onClick = {
+ val members = memberInput.split(",").map { it.trim() }.filter { it.isNotBlank() }
+ val allMembers = (members + listOf(ImSDK.currentUserId)).distinct()
+ onCreate(name.trim(), allMembers)
+ },
+ enabled = name.isNotBlank(),
+ ) { Text("创建") }
+ },
+ dismissButton = { TextButton(onClick = onDismiss) { Text("取消") } },
+ )
+}
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun GroupSettingsScreen(
+ groupId: String,
+ onNavigateBack: () -> Unit,
+ viewModel: GroupViewModel = hiltViewModel(),
+) {
+ val group by viewModel.currentGroup.collectAsStateWithLifecycle()
+
+ LaunchedEffect(groupId) { viewModel.loadGroupInfo(groupId) }
+
+ Scaffold(
+ topBar = {
+ TopAppBar(
+ title = { Text(group?.name ?: "群设置") },
+ navigationIcon = {
+ IconButton(onClick = onNavigateBack) {
+ Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null)
+ }
+ },
+ )
+ },
+ ) { padding ->
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(padding)
+ .padding(16.dp),
+ ) {
+ group?.let { g ->
+ Text("群 ID: ${g.id}", style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.outline)
+ Spacer(Modifier.height(8.dp))
+ Text("群公告: ${g.announcement ?: "暂无"}", style = MaterialTheme.typography.bodyMedium)
+ Spacer(Modifier.height(16.dp))
+ Text("成员", style = MaterialTheme.typography.titleSmall)
+ val memberIds = g.memberIds.split(",").filter { it.isNotBlank() }
+ memberIds.forEach { memberId ->
+ Row(
+ modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Text(memberId, modifier = Modifier.weight(1f))
+ if (g.creatorId == ImSDK.currentUserId && memberId != ImSDK.currentUserId) {
+ TextButton(onClick = { viewModel.removeMember(g.id, memberId) }) {
+ Text("移除", color = MaterialTheme.colorScheme.error)
+ }
+ }
+ }
+ }
+ Spacer(Modifier.height(24.dp))
+ Button(
+ onClick = { viewModel.leaveGroup(g.id) { onNavigateBack() } },
+ colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.error),
+ modifier = Modifier.fillMaxWidth(),
+ ) { Text("退出群聊") }
+ }
+ }
+ }
+}
diff --git a/sample-app/src/main/java/com/xuqm/sdk/sample/ui/group/GroupViewModel.kt b/sample-app/src/main/java/com/xuqm/sdk/sample/ui/group/GroupViewModel.kt
new file mode 100644
index 0000000..8c1afa5
--- /dev/null
+++ b/sample-app/src/main/java/com/xuqm/sdk/sample/ui/group/GroupViewModel.kt
@@ -0,0 +1,65 @@
+package com.xuqm.sdk.sample.ui.group
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import com.xuqm.sdk.im.ImSDK
+import com.xuqm.sdk.im.model.ImGroup
+import dagger.hilt.android.lifecycle.HiltViewModel
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.launch
+import javax.inject.Inject
+
+@HiltViewModel
+class GroupViewModel @Inject constructor() : ViewModel() {
+
+ private val _groups = MutableStateFlow>(emptyList())
+ val groups: StateFlow> = _groups
+
+ private val _currentGroup = MutableStateFlow(null)
+ val currentGroup: StateFlow = _currentGroup
+
+ init { loadGroups() }
+
+ fun loadGroups() {
+ viewModelScope.launch {
+ runCatching { ImSDK.listGroups() }
+ .onSuccess { _groups.value = it }
+ }
+ }
+
+ fun loadGroupInfo(groupId: String) {
+ viewModelScope.launch {
+ runCatching { ImSDK.getGroupInfo(groupId) }
+ .onSuccess { _currentGroup.value = it }
+ }
+ }
+
+ fun createGroup(name: String, memberIds: List, onSuccess: (ImGroup) -> Unit) {
+ viewModelScope.launch {
+ runCatching { ImSDK.createGroup(name, memberIds) }
+ .onSuccess { group -> group?.let { onSuccess(it); loadGroups() } }
+ }
+ }
+
+ fun updateGroup(groupId: String, name: String?, announcement: String?) {
+ viewModelScope.launch {
+ runCatching { ImSDK.updateGroupInfo(groupId, name, announcement) }
+ .onSuccess { loadGroupInfo(groupId) }
+ }
+ }
+
+ fun removeMember(groupId: String, userId: String) {
+ viewModelScope.launch {
+ runCatching { ImSDK.removeGroupMember(groupId, userId) }
+ .onSuccess { loadGroupInfo(groupId) }
+ }
+ }
+
+ fun leaveGroup(groupId: String, onSuccess: () -> Unit) {
+ viewModelScope.launch {
+ runCatching { ImSDK.leaveGroup(groupId) }
+ .onSuccess { loadGroups(); onSuccess() }
+ }
+ }
+}
diff --git a/sample-app/src/main/java/com/xuqm/sdk/sample/ui/main/MainScreen.kt b/sample-app/src/main/java/com/xuqm/sdk/sample/ui/main/MainScreen.kt
new file mode 100644
index 0000000..f31348d
--- /dev/null
+++ b/sample-app/src/main/java/com/xuqm/sdk/sample/ui/main/MainScreen.kt
@@ -0,0 +1,83 @@
+package com.xuqm.sdk.sample.ui.main
+
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.ChatBubble
+import androidx.compose.material.icons.filled.Group
+import androidx.compose.material.icons.filled.People
+import androidx.compose.material.icons.filled.Person
+import androidx.compose.material.icons.filled.SystemUpdate
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.Icon
+import androidx.compose.material3.NavigationBar
+import androidx.compose.material3.NavigationBarItem
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Text
+import androidx.compose.material3.TopAppBar
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableIntStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.vector.ImageVector
+import com.xuqm.sdk.sample.ui.contact.ContactScreen
+import com.xuqm.sdk.sample.ui.conversation.ConversationScreen
+import com.xuqm.sdk.sample.ui.group.GroupListScreen
+import com.xuqm.sdk.sample.ui.profile.ProfileScreen
+import com.xuqm.sdk.sample.ui.update.UpdateScreen
+
+private data class BottomTab(val label: String, val icon: ImageVector)
+
+private val tabs = listOf(
+ BottomTab("消息", Icons.Default.ChatBubble),
+ BottomTab("群组", Icons.Default.Group),
+ BottomTab("联系人", Icons.Default.People),
+ BottomTab("更新", Icons.Default.SystemUpdate),
+ BottomTab("我", Icons.Default.Person),
+)
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun MainScreen(
+ onOpenChat: (targetId: String, chatType: String, targetName: String) -> Unit,
+ onGroupSettings: (groupId: String) -> Unit,
+ onLogout: () -> Unit,
+) {
+ var selectedTab by remember { mutableIntStateOf(0) }
+
+ Scaffold(
+ topBar = {
+ TopAppBar(title = { Text(tabs[selectedTab].label) })
+ },
+ bottomBar = {
+ NavigationBar {
+ tabs.forEachIndexed { index, tab ->
+ NavigationBarItem(
+ selected = selectedTab == index,
+ onClick = { selectedTab = index },
+ icon = { Icon(tab.icon, contentDescription = null) },
+ label = { Text(tab.label) },
+ )
+ }
+ }
+ },
+ ) { padding ->
+ Box(modifier = Modifier.fillMaxSize().padding(padding)) {
+ when (selectedTab) {
+ 0 -> ConversationScreen(onOpenChat = onOpenChat)
+ 1 -> GroupListScreen(
+ onOpenGroupChat = { groupId, groupName ->
+ onOpenChat(groupId, "GROUP", groupName)
+ },
+ onGroupSettings = onGroupSettings,
+ )
+ 2 -> ContactScreen(onOpenChat = { userId -> onOpenChat(userId, "SINGLE", userId) })
+ 3 -> UpdateScreen()
+ 4 -> ProfileScreen(onLogout = onLogout)
+ }
+ }
+ }
+}
diff --git a/sample-app/src/main/java/com/xuqm/sdk/sample/ui/profile/ProfileScreen.kt b/sample-app/src/main/java/com/xuqm/sdk/sample/ui/profile/ProfileScreen.kt
new file mode 100644
index 0000000..592b8cf
--- /dev/null
+++ b/sample-app/src/main/java/com/xuqm/sdk/sample/ui/profile/ProfileScreen.kt
@@ -0,0 +1,151 @@
+package com.xuqm.sdk.sample.ui.profile
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.material3.Button
+import androidx.compose.material3.ButtonDefaults
+import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.material3.HorizontalDivider
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.OutlinedButton
+import androidx.compose.material3.OutlinedTextField
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.unit.dp
+import androidx.hilt.navigation.compose.hiltViewModel
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import androidx.lifecycle.viewModelScope
+import com.xuqm.sdk.sample.data.repo.AuthRepository
+import dagger.hilt.android.lifecycle.HiltViewModel
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.launch
+import javax.inject.Inject
+
+data class ProfileUiState(
+ val userId: String = "",
+ val nickname: String = "",
+ val avatar: String? = null,
+ val isSaving: Boolean = false,
+ val error: String? = null,
+)
+
+@HiltViewModel
+class ProfileViewModel @Inject constructor(
+ private val authRepository: AuthRepository,
+) : ViewModel() {
+
+ private val _state = MutableStateFlow(
+ ProfileUiState(
+ userId = authRepository.getCurrentUserId() ?: "",
+ nickname = authRepository.getCurrentNickname() ?: "",
+ avatar = authRepository.getCurrentAvatar(),
+ )
+ )
+ val state: StateFlow = _state
+
+ fun updateNickname(nickname: String) {
+ _state.value = _state.value.copy(nickname = nickname)
+ }
+
+ fun save() {
+ viewModelScope.launch {
+ _state.value = _state.value.copy(isSaving = true, error = null)
+ authRepository.updateProfile(_state.value.nickname, _state.value.avatar)
+ .onSuccess { _state.value = _state.value.copy(isSaving = false) }
+ .onFailure { _state.value = _state.value.copy(isSaving = false, error = it.message) }
+ }
+ }
+
+ fun logout(onLoggedOut: () -> Unit) {
+ authRepository.logout()
+ onLoggedOut()
+ }
+}
+
+@Composable
+fun ProfileScreen(
+ onLogout: () -> Unit,
+ viewModel: ProfileViewModel = hiltViewModel(),
+) {
+ val state by viewModel.state.collectAsStateWithLifecycle()
+
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(24.dp),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.spacedBy(12.dp),
+ ) {
+ Spacer(Modifier.height(16.dp))
+
+ Surface(
+ modifier = Modifier.size(80.dp).clip(CircleShape),
+ color = MaterialTheme.colorScheme.primaryContainer,
+ ) {
+ Column(
+ modifier = Modifier.fillMaxSize(),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.Center,
+ ) {
+ Text(
+ state.nickname.take(1).uppercase(),
+ style = MaterialTheme.typography.headlineMedium,
+ )
+ }
+ }
+
+ Text(state.userId, style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.outline)
+
+ HorizontalDivider()
+
+ OutlinedTextField(
+ value = state.nickname,
+ onValueChange = viewModel::updateNickname,
+ label = { Text("昵称") },
+ modifier = Modifier.fillMaxWidth(),
+ singleLine = true,
+ )
+
+ if (state.error != null) {
+ Text(state.error!!, color = MaterialTheme.colorScheme.error,
+ style = MaterialTheme.typography.bodySmall)
+ }
+
+ Button(
+ onClick = viewModel::save,
+ modifier = Modifier.fillMaxWidth(),
+ enabled = !state.isSaving && state.nickname.isNotBlank(),
+ ) {
+ if (state.isSaving) CircularProgressIndicator(Modifier.size(20.dp))
+ else Text("保存")
+ }
+
+ Spacer(Modifier.height(8.dp))
+
+ OutlinedButton(
+ onClick = { viewModel.logout(onLogout) },
+ modifier = Modifier.fillMaxWidth(),
+ colors = ButtonDefaults.outlinedButtonColors(contentColor = MaterialTheme.colorScheme.error),
+ ) {
+ Text("退出登录")
+ }
+ }
+}
diff --git a/sample-app/src/main/java/com/xuqm/sdk/sample/ui/theme/Theme.kt b/sample-app/src/main/java/com/xuqm/sdk/sample/ui/theme/Theme.kt
new file mode 100644
index 0000000..ea2147f
--- /dev/null
+++ b/sample-app/src/main/java/com/xuqm/sdk/sample/ui/theme/Theme.kt
@@ -0,0 +1,17 @@
+package com.xuqm.sdk.sample.ui.theme
+
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.darkColorScheme
+import androidx.compose.material3.dynamicDarkColorScheme
+import androidx.compose.material3.dynamicLightColorScheme
+import androidx.compose.material3.lightColorScheme
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.platform.LocalContext
+
+@Composable
+fun XuqmTheme(content: @Composable () -> Unit) {
+ MaterialTheme(
+ colorScheme = lightColorScheme(),
+ content = content,
+ )
+}
diff --git a/sample-app/src/main/java/com/xuqm/sdk/sample/ui/update/UpdateScreen.kt b/sample-app/src/main/java/com/xuqm/sdk/sample/ui/update/UpdateScreen.kt
new file mode 100644
index 0000000..8440366
--- /dev/null
+++ b/sample-app/src/main/java/com/xuqm/sdk/sample/ui/update/UpdateScreen.kt
@@ -0,0 +1,120 @@
+package com.xuqm.sdk.sample.ui.update
+
+import android.content.Context
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.Button
+import androidx.compose.material3.Card
+import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.material3.LinearProgressIndicator
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.unit.dp
+import androidx.hilt.navigation.compose.hiltViewModel
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import androidx.lifecycle.viewModelScope
+import com.xuqm.sdk.update.UpdateSDK
+import com.xuqm.sdk.update.model.UpdateInfo
+import dagger.hilt.android.lifecycle.HiltViewModel
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.launch
+import javax.inject.Inject
+
+data class UpdateUiState(
+ val isChecking: Boolean = false,
+ val appUpdate: UpdateInfo? = null,
+ val downloadProgress: Int = -1,
+ val message: String? = null,
+)
+
+@HiltViewModel
+class UpdateViewModel @Inject constructor() : ViewModel() {
+
+ private val _state = MutableStateFlow(UpdateUiState())
+ val state: StateFlow = _state
+
+ fun checkAppUpdate(context: Context) {
+ viewModelScope.launch {
+ _state.value = _state.value.copy(isChecking = true, message = null)
+ val info = UpdateSDK.checkAppUpdate(context)
+ _state.value = _state.value.copy(
+ isChecking = false,
+ appUpdate = info,
+ message = if (info?.needsUpdate == true) null else "已是最新版本",
+ )
+ }
+ }
+
+ fun downloadAndInstall(context: Context, downloadUrl: String) {
+ viewModelScope.launch {
+ _state.value = _state.value.copy(downloadProgress = 0)
+ UpdateSDK.downloadAndInstall(context, downloadUrl) { progress ->
+ _state.value = _state.value.copy(downloadProgress = progress)
+ }
+ }
+ }
+}
+
+@Composable
+fun UpdateScreen(viewModel: UpdateViewModel = hiltViewModel()) {
+ val state by viewModel.state.collectAsStateWithLifecycle()
+ val context = LocalContext.current
+
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(16.dp),
+ verticalArrangement = Arrangement.spacedBy(16.dp),
+ ) {
+ Text("版本更新", style = MaterialTheme.typography.headlineSmall)
+
+ Card {
+ Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
+ Text("App 原生更新", style = MaterialTheme.typography.titleMedium)
+
+ val update = state.appUpdate
+ if (update?.needsUpdate == true) {
+ Text("发现新版本: ${update.versionName}")
+ Text("更新说明: ${update.changeLog.ifBlank { "无" }}", style = MaterialTheme.typography.bodySmall)
+
+ if (state.downloadProgress in 0..99) {
+ LinearProgressIndicator(
+ progress = { state.downloadProgress / 100f },
+ modifier = Modifier.fillMaxWidth(),
+ )
+ Text("下载中 ${state.downloadProgress}%")
+ } else {
+ Button(
+ onClick = {
+ update.downloadUrl.takeIf { it.isNotBlank() }
+ ?.let { viewModel.downloadAndInstall(context, it) }
+ },
+ modifier = Modifier.fillMaxWidth(),
+ ) { Text("立即更新") }
+ }
+ } else {
+ state.message?.let { Text(it, style = MaterialTheme.typography.bodyMedium) }
+ Button(
+ onClick = { viewModel.checkAppUpdate(context) },
+ modifier = Modifier.fillMaxWidth(),
+ enabled = !state.isChecking,
+ ) {
+ if (state.isChecking) CircularProgressIndicator(Modifier.height(20.dp))
+ else Text("检查更新")
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/sample-app/src/main/res/values/themes.xml b/sample-app/src/main/res/values/themes.xml
new file mode 100644
index 0000000..2ef7101
--- /dev/null
+++ b/sample-app/src/main/res/values/themes.xml
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/sdk-core/build.gradle.kts b/sdk-core/build.gradle.kts
index d848d9c..8ba6210 100644
--- a/sdk-core/build.gradle.kts
+++ b/sdk-core/build.gradle.kts
@@ -22,6 +22,6 @@ 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)
+ api(libs.androidx.security.crypto)
}
diff --git a/sdk-core/src/main/java/com/xuqm/sdk/XuqmSDK.kt b/sdk-core/src/main/java/com/xuqm/sdk/XuqmSDK.kt
index 6f02c94..73e1050 100644
--- a/sdk-core/src/main/java/com/xuqm/sdk/XuqmSDK.kt
+++ b/sdk-core/src/main/java/com/xuqm/sdk/XuqmSDK.kt
@@ -2,6 +2,7 @@ package com.xuqm.sdk
import android.content.Context
import com.xuqm.sdk.auth.TokenStore
+import com.xuqm.sdk.core.LogLevel
import com.xuqm.sdk.core.SDKConfig
import com.xuqm.sdk.network.ApiClient
@@ -15,21 +16,20 @@ object XuqmSDK {
private var initialized = false
- fun init(
+ fun initialize(
context: Context,
- appKey: String,
- appSecret: String,
- apiBaseUrl: String = "https://api.xuqm.com",
- imBaseUrl: String = "wss://im.xuqm.com",
- debug: Boolean = false,
+ appId: String,
+ logLevel: LogLevel = LogLevel.WARN,
) {
- config = SDKConfig(appKey, appSecret, apiBaseUrl, imBaseUrl, debug)
+ config = SDKConfig(appId, logLevel)
tokenStore = TokenStore(context.applicationContext)
ApiClient.init(config, tokenStore)
initialized = true
}
fun requireInit() {
- check(initialized) { "XuqmSDK is not initialized. Call XuqmSDK.init() first." }
+ check(initialized) { "XuqmSDK not initialized. Call XuqmSDK.initialize() first." }
}
+
+ val appId: String get() = config.appId
}
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
index 2c72cc6..33c5687 100644
--- a/sdk-core/src/main/java/com/xuqm/sdk/auth/TokenStore.kt
+++ b/sdk-core/src/main/java/com/xuqm/sdk/auth/TokenStore.kt
@@ -1,27 +1,29 @@
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
+import androidx.security.crypto.EncryptedSharedPreferences
+import androidx.security.crypto.MasterKey
-private val Context.dataStore by preferencesDataStore(name = "xuqm_sdk_prefs")
+private const val PREFS_NAME = "xuqm_sdk_secure"
+private const val KEY_IM_TOKEN = "im_token"
-class TokenStore(private val context: Context) {
+class TokenStore(context: Context) {
- private val TOKEN_KEY = stringPreferencesKey("access_token")
+ private val prefs = EncryptedSharedPreferences.create(
+ context,
+ PREFS_NAME,
+ MasterKey.Builder(context).setKeyScheme(MasterKey.KeyScheme.AES256_GCM).build(),
+ EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
+ EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
+ )
- fun getToken(): String? = runBlocking {
- context.dataStore.data.first()[TOKEN_KEY]
+ fun getToken(): String? = prefs.getString(KEY_IM_TOKEN, null)
+
+ fun saveToken(token: String) {
+ prefs.edit().putString(KEY_IM_TOKEN, token).apply()
}
- suspend fun saveToken(token: String) {
- context.dataStore.edit { prefs -> prefs[TOKEN_KEY] = token }
- }
-
- suspend fun clear() {
- context.dataStore.edit { prefs -> prefs.remove(TOKEN_KEY) }
+ fun clear() {
+ prefs.edit().remove(KEY_IM_TOKEN).apply()
}
}
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
index 5b38dba..891dff6 100644
--- a/sdk-core/src/main/java/com/xuqm/sdk/core/SDKConfig.kt
+++ b/sdk-core/src/main/java/com/xuqm/sdk/core/SDKConfig.kt
@@ -1,9 +1,11 @@
package com.xuqm.sdk.core
+internal const val BASE_URL = "https://dev.xuqinmin.com/"
+internal const val WS_URL = "wss://dev.xuqinmin.com/ws/im"
+
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,
+ val appId: String,
+ val logLevel: LogLevel = LogLevel.WARN,
)
+
+enum class LogLevel { DEBUG, INFO, WARN, ERROR, NONE }
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
index 60088ff..ce5aa98 100644
--- a/sdk-core/src/main/java/com/xuqm/sdk/network/ApiClient.kt
+++ b/sdk-core/src/main/java/com/xuqm/sdk/network/ApiClient.kt
@@ -1,6 +1,8 @@
package com.xuqm.sdk.network
import com.xuqm.sdk.auth.TokenStore
+import com.xuqm.sdk.core.BASE_URL
+import com.xuqm.sdk.core.LogLevel
import com.xuqm.sdk.core.SDKConfig
import okhttp3.OkHttpClient
import okhttp3.Request
@@ -11,17 +13,15 @@ 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
+ level = if (cfg.logLevel == LogLevel.DEBUG) HttpLoggingInterceptor.Level.BODY
else HttpLoggingInterceptor.Level.NONE
}
@@ -41,7 +41,7 @@ object ApiClient {
.build()
retrofit = Retrofit.Builder()
- .baseUrl(cfg.apiBaseUrl.trimEnd('/') + "/")
+ .baseUrl(BASE_URL)
.client(okhttp)
.addConverterFactory(GsonConverterFactory.create())
.build()
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
index 89d1ecf..f842287 100644
--- a/sdk-im/src/main/java/com/xuqm/sdk/im/ImClient.kt
+++ b/sdk-im/src/main/java/com/xuqm/sdk/im/ImClient.kt
@@ -2,13 +2,10 @@ 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
@@ -43,14 +40,14 @@ class ImClient(
}
override fun onMessage(ws: WebSocket, text: String) {
- try {
+ runCatching {
val msg = gson.fromJson(text, ImMessage::class.java)
- if (msg.chatType == ChatType.GROUP) {
+ if (msg.chatType == "GROUP") {
listeners.forEach { it.onGroupMessage(msg) }
} else {
listeners.forEach { it.onMessage(msg) }
}
- } catch (e: Exception) {
+ }.onFailure { e ->
listeners.forEach { it.onError("Parse error: ${e.message}") }
}
}
@@ -65,10 +62,10 @@ class ImClient(
})
}
- fun sendMessage(toId: String, chatType: ChatType, msgType: MsgType, content: String) {
+ fun sendMessage(toId: String, chatType: String, msgType: String, content: String) {
val payload = mapOf(
"appId" to appId, "toId" to toId,
- "chatType" to chatType.name, "msgType" to msgType.name,
+ "chatType" to chatType, "msgType" to msgType,
"content" to content,
)
webSocket?.send(gson.toJson(mapOf("type" to "chat.send", "data" to payload)))
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
index a1daecc..4f4e533 100644
--- a/sdk-im/src/main/java/com/xuqm/sdk/im/ImSDK.kt
+++ b/sdk-im/src/main/java/com/xuqm/sdk/im/ImSDK.kt
@@ -1,39 +1,112 @@
package com.xuqm.sdk.im
import com.xuqm.sdk.XuqmSDK
+import com.xuqm.sdk.core.WS_URL
+import com.xuqm.sdk.im.api.AddMemberRequest
+import com.xuqm.sdk.im.api.CreateGroupRequest
import com.xuqm.sdk.im.api.ImApi
+import com.xuqm.sdk.im.api.LoginRequest
+import com.xuqm.sdk.im.api.SetMutedRequest
+import com.xuqm.sdk.im.api.SetPinnedRequest
+import com.xuqm.sdk.im.api.UpdateGroupRequest
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.im.model.ConversationData
+import com.xuqm.sdk.im.model.ImGroup
+import com.xuqm.sdk.im.model.ImMessage
+import com.xuqm.sdk.im.model.UserProfile
import com.xuqm.sdk.network.ApiClient
-import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
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()
- }
+ var currentUserId: String = ""
+ private set
+
+ suspend fun login(userId: String, nickname: String? = null, avatar: String? = null) =
+ withContext(Dispatchers.IO) {
+ XuqmSDK.requireInit()
+ val res = api.login(LoginRequest(XuqmSDK.appId, userId, nickname, avatar))
+ val token = requireNotNull(res.data?.token) { "IM login failed: ${res.message}" }
+ XuqmSDK.tokenStore.saveToken(token)
+ currentUserId = userId
+ client = ImClient(WS_URL, token, XuqmSDK.appId)
+ client?.connect()
}
- }
- fun sendMessage(toId: String, chatType: ChatType, msgType: MsgType, content: String) {
+ fun sendMessage(toId: String, chatType: String, msgType: String, content: String) {
client?.sendMessage(toId, chatType, msgType, content)
}
+ suspend fun fetchHistory(toId: String, page: Int = 0, size: Int = 20): List =
+ withContext(Dispatchers.IO) {
+ api.fetchHistory(toId, "SINGLE", page, size).data ?: emptyList()
+ }
+
+ suspend fun fetchGroupHistory(groupId: String, page: Int = 0, size: Int = 20): List =
+ withContext(Dispatchers.IO) {
+ api.fetchGroupHistory(groupId, page, size).data ?: emptyList()
+ }
+
+ suspend fun listGroups(): List =
+ withContext(Dispatchers.IO) { api.listGroups().data ?: emptyList() }
+
+ suspend fun createGroup(name: String, memberIds: List): ImGroup? =
+ withContext(Dispatchers.IO) { api.createGroup(CreateGroupRequest(name, memberIds)).data }
+
+ suspend fun getGroupInfo(groupId: String): ImGroup? =
+ withContext(Dispatchers.IO) { api.getGroupInfo(groupId).data }
+
+ suspend fun updateGroupInfo(groupId: String, name: String? = null, announcement: String? = null) =
+ withContext(Dispatchers.IO) { api.updateGroupInfo(groupId, UpdateGroupRequest(name, announcement)) }
+
+ suspend fun addGroupMember(groupId: String, userId: String) =
+ withContext(Dispatchers.IO) { api.addGroupMember(groupId, AddMemberRequest(userId)) }
+
+ suspend fun removeGroupMember(groupId: String, userId: String) =
+ withContext(Dispatchers.IO) { api.removeGroupMember(groupId, userId) }
+
+ suspend fun leaveGroup(groupId: String) =
+ withContext(Dispatchers.IO) { api.leaveGroup(groupId) }
+
+ suspend fun listFriends(): List =
+ withContext(Dispatchers.IO) { api.listFriends().data ?: emptyList() }
+
+ suspend fun addFriend(friendId: String) =
+ withContext(Dispatchers.IO) { api.addFriend(friendId) }
+
+ suspend fun removeFriend(friendId: String) =
+ withContext(Dispatchers.IO) { api.removeFriend(friendId) }
+
+ suspend fun listConversations(): List =
+ withContext(Dispatchers.IO) { api.listConversations().data ?: emptyList() }
+
+ suspend fun setConversationPinned(targetId: String, chatType: String, pinned: Boolean) =
+ withContext(Dispatchers.IO) {
+ api.setConversationPinned(targetId, chatType, SetPinnedRequest(pinned))
+ }
+
+ suspend fun setConversationMuted(targetId: String, chatType: String, muted: Boolean) =
+ withContext(Dispatchers.IO) {
+ api.setConversationMuted(targetId, chatType, SetMutedRequest(muted))
+ }
+
+ suspend fun markRead(targetId: String, chatType: String = "SINGLE") =
+ withContext(Dispatchers.IO) { api.markRead(targetId, chatType) }
+
+ suspend fun getUserProfile(userId: String): UserProfile? =
+ withContext(Dispatchers.IO) { api.getUserProfile(userId).data }
+
fun addListener(listener: ImEventListener) = client?.addListener(listener)
fun removeListener(listener: ImEventListener) = client?.removeListener(listener)
- fun disconnect() = client?.disconnect()
+
+ fun disconnect() {
+ client?.disconnect()
+ client = null
+ currentUserId = ""
+ XuqmSDK.tokenStore.clear()
+ }
}
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
index 1571393..ac412fd 100644
--- 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
@@ -1,26 +1,125 @@
package com.xuqm.sdk.im.api
+import com.xuqm.sdk.im.model.ConversationData
+import com.xuqm.sdk.im.model.ImGroup
import com.xuqm.sdk.im.model.ImMessage
-import retrofit2.http.POST
+import com.xuqm.sdk.im.model.UserProfile
+import retrofit2.http.Body
+import retrofit2.http.DELETE
import retrofit2.http.GET
+import retrofit2.http.POST
+import retrofit2.http.PUT
+import retrofit2.http.Path
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,
+data class ApiResponse(
+ val code: Int,
+ val status: String,
+ val data: T?,
+ val message: String? = null,
)
+data class LoginRequest(
+ val appId: String,
+ val userId: String,
+ val nickname: String? = null,
+ val avatar: String? = null,
+)
+
+data class LoginResponse(val token: String)
+
+data class CreateGroupRequest(val name: String, val memberIds: List)
+
+data class UpdateGroupRequest(val name: String? = null, val announcement: String? = null)
+
+data class AddMemberRequest(val userId: String)
+
+data class SetPinnedRequest(val pinned: Boolean)
+
+data class SetMutedRequest(val muted: Boolean)
+
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
+ suspend fun login(@Body request: LoginRequest): ApiResponse
+
+ @GET("api/im/messages")
+ suspend fun fetchHistory(
+ @Query("toId") toId: String,
+ @Query("chatType") chatType: String,
+ @Query("page") page: Int,
+ @Query("size") size: Int,
+ ): ApiResponse>
+
+ @GET("api/im/groups/{groupId}/messages")
+ suspend fun fetchGroupHistory(
+ @Path("groupId") groupId: String,
+ @Query("page") page: Int,
+ @Query("size") size: Int,
+ ): ApiResponse>
+
+ @GET("api/im/groups")
+ suspend fun listGroups(): ApiResponse>
+
+ @POST("api/im/groups")
+ suspend fun createGroup(@Body request: CreateGroupRequest): ApiResponse
+
+ @GET("api/im/groups/{groupId}")
+ suspend fun getGroupInfo(@Path("groupId") groupId: String): ApiResponse
+
+ @PUT("api/im/groups/{groupId}")
+ suspend fun updateGroupInfo(
+ @Path("groupId") groupId: String,
+ @Body request: UpdateGroupRequest,
+ ): ApiResponse
+
+ @POST("api/im/groups/{groupId}/members")
+ suspend fun addGroupMember(
+ @Path("groupId") groupId: String,
+ @Body request: AddMemberRequest,
+ ): ApiResponse
+
+ @DELETE("api/im/groups/{groupId}/members/{userId}")
+ suspend fun removeGroupMember(
+ @Path("groupId") groupId: String,
+ @Path("userId") userId: String,
+ ): ApiResponse
+
+ @DELETE("api/im/groups/{groupId}/members/me")
+ suspend fun leaveGroup(@Path("groupId") groupId: String): ApiResponse
+
+ @GET("api/im/friends")
+ suspend fun listFriends(): ApiResponse>
+
+ @POST("api/im/friends/{friendId}")
+ suspend fun addFriend(@Path("friendId") friendId: String): ApiResponse
+
+ @DELETE("api/im/friends/{friendId}")
+ suspend fun removeFriend(@Path("friendId") friendId: String): ApiResponse
+
+ @GET("api/im/conversations")
+ suspend fun listConversations(): ApiResponse>
+
+ @PUT("api/im/conversations/{targetId}/pinned")
+ suspend fun setConversationPinned(
+ @Path("targetId") targetId: String,
+ @Query("chatType") chatType: String,
+ @Body request: SetPinnedRequest,
+ ): ApiResponse
+
+ @PUT("api/im/conversations/{targetId}/muted")
+ suspend fun setConversationMuted(
+ @Path("targetId") targetId: String,
+ @Query("chatType") chatType: String,
+ @Body request: SetMutedRequest,
+ ): ApiResponse
+
+ @PUT("api/im/conversations/{targetId}/read")
+ suspend fun markRead(
+ @Path("targetId") targetId: String,
+ @Query("chatType") chatType: String,
+ ): ApiResponse
+
+ @GET("api/im/users/{userId}")
+ suspend fun getUserProfile(@Path("userId") userId: String): ApiResponse
}
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
index 6ffc043..e6d6ac8 100644
--- 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
@@ -3,14 +3,41 @@ package com.xuqm.sdk.im.model
data class ImMessage(
val id: String,
val appId: String,
- val fromUserId: String,
+ val fromId: String,
val toId: String,
- val chatType: ChatType,
- val msgType: MsgType,
+ val chatType: String,
+ val msgType: String,
val content: String,
- val status: MsgStatus,
- val mentionedUserIds: String?,
- val createdAt: String,
+ val status: String,
+ val mentionedUserIds: String? = null,
+ val createdAt: Long,
+)
+
+data class ConversationData(
+ val targetId: String,
+ val chatType: String,
+ val lastMsgContent: String? = null,
+ val lastMsgType: String? = null,
+ val lastMsgTime: Long = 0,
+ val unreadCount: Int = 0,
+ val isMuted: Boolean = false,
+ val isPinned: Boolean = false,
+)
+
+data class ImGroup(
+ val id: String,
+ val name: String,
+ val creatorId: String,
+ val memberIds: String,
+ val adminIds: String,
+ val announcement: String? = null,
+ val createdAt: Long,
+)
+
+data class UserProfile(
+ val userId: String,
+ val nickname: String,
+ val avatar: String? = null,
)
enum class ChatType { SINGLE, GROUP }
@@ -20,4 +47,4 @@ enum class MsgType {
RICH_TEXT, CALL_AUDIO, CALL_VIDEO, REVOKED, FORWARD
}
-enum class MsgStatus { SENT, DELIVERED, READ, REVOKED }
+enum class MsgStatus { SENDING, SENT, DELIVERED, READ, FAILED, REVOKED }
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
index aea7bec..846f0fd 100644
--- a/sdk-push/src/main/java/com/xuqm/sdk/push/PushSDK.kt
+++ b/sdk-push/src/main/java/com/xuqm/sdk/push/PushSDK.kt
@@ -4,6 +4,7 @@ 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.push.api.RegisterDeviceRequest
import com.xuqm.sdk.utils.DeviceUtils
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
@@ -14,22 +15,30 @@ 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) {
+ fun registerDevice(context: Context, userId: String) {
XuqmSDK.requireInit()
val vendor = DeviceUtils.getVendor()
+ val deviceId = DeviceUtils.getDeviceId(context)
scope.launch {
runCatching {
- api.registerToken(appId, userId, vendor, token)
+ api.registerDevice(
+ RegisterDeviceRequest(
+ userId = userId,
+ appId = XuqmSDK.appId,
+ platform = "Android",
+ vendor = vendor,
+ pushToken = "",
+ deviceId = deviceId,
+ )
+ )
}
}
}
- fun unregisterToken(appId: String, userId: String) {
+ fun unregisterDevice(userId: String) {
XuqmSDK.requireInit()
scope.launch {
- runCatching {
- api.unregisterToken(appId, userId)
- }
+ runCatching { api.unregisterDevice(XuqmSDK.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
index b3cf02d..41cca85 100644
--- 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
@@ -1,20 +1,26 @@
package com.xuqm.sdk.push.api
+import retrofit2.http.Body
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,
- )
+data class RegisterDeviceRequest(
+ val userId: String,
+ val appId: String,
+ val platform: String,
+ val vendor: String,
+ val pushToken: String,
+ val deviceId: String,
+)
- @DELETE("api/push/unregister")
- suspend fun unregisterToken(
+interface PushApi {
+
+ @POST("api/push/device/register")
+ suspend fun registerDevice(@Body request: RegisterDeviceRequest)
+
+ @DELETE("api/push/device/unregister")
+ suspend fun unregisterDevice(
@Query("appId") appId: String,
@Query("userId") userId: String,
)
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
index 782fcc5..091af21 100644
--- a/sdk-update/src/main/java/com/xuqm/sdk/update/UpdateSDK.kt
+++ b/sdk-update/src/main/java/com/xuqm/sdk/update/UpdateSDK.kt
@@ -3,7 +3,6 @@ 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
@@ -19,11 +18,13 @@ object UpdateSDK {
private val api: UpdateApi by lazy { ApiClient.create() }
- suspend fun checkUpdate(context: Context, appId: String): UpdateInfo? = withContext(Dispatchers.IO) {
+ suspend fun checkAppUpdate(context: Context): UpdateInfo? = withContext(Dispatchers.IO) {
XuqmSDK.requireInit()
val versionCode = context.packageManager
.getPackageInfo(context.packageName, 0).longVersionCode.toInt()
- runCatching { api.checkUpdate(appId, "ANDROID", versionCode).data }.getOrNull()
+ runCatching {
+ api.checkUpdate(XuqmSDK.appId, "ANDROID", versionCode).data
+ }.getOrNull()
}
suspend fun downloadAndInstall(
@@ -49,10 +50,7 @@ object UpdateSDK {
}
}
}
-
- withContext(Dispatchers.Main) {
- installApk(context, apkFile)
- }
+ withContext(Dispatchers.Main) { installApk(context, apkFile) }
}
private fun installApk(context: Context, apkFile: File) {
@@ -64,8 +62,11 @@ object UpdateSDK {
context.startActivity(intent)
}
- suspend fun checkRnUpdate(appId: String, moduleId: String, currentVersion: String): RnUpdateInfo? =
+ suspend fun checkRnUpdate(moduleId: String, currentVersion: String): RnUpdateInfo? =
withContext(Dispatchers.IO) {
- runCatching { api.checkRnUpdate(appId, moduleId, "ANDROID", currentVersion).data }.getOrNull()
+ XuqmSDK.requireInit()
+ runCatching {
+ api.checkRnUpdate(XuqmSDK.appId, moduleId, "ANDROID", currentVersion).data
+ }.getOrNull()
}
}