feat(sdk): 初始化 Android SDK 核心功能模块

- 添加 SDK 配置管理、网络请求客户端和令牌存储功能
- 实现即时通讯 IM 模块,包括消息收发、群组管理和会话功能
- 集成推送服务和应用更新功能模块
- 创建示例应用演示 SDK 使用方法
- 配置项目依赖管理和构建设置
这个提交包含在:
XuqmGroup 2026-04-27 17:18:55 +08:00
父节点 3e66380802
当前提交 6dd0fa8f49
共有 37 个文件被更改,包括 2216 次插入265 次删除

查看文件

@ -3,6 +3,8 @@ plugins {
alias(libs.plugins.android.library) apply false alias(libs.plugins.android.library) apply false
alias(libs.plugins.kotlin.compose) apply false alias(libs.plugins.kotlin.compose) apply false
alias(libs.plugins.kotlin.serialization) 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" group = "com.xuqm"

查看文件

@ -16,16 +16,21 @@ okhttp = "5.3.2"
gson = "2.13.2" gson = "2.13.2"
jserialization = "1.9.0" jserialization = "1.9.0"
webkit = "1.14.0" webkit = "1.14.0"
coil = "2.7.0" coil = "3.1.0"
sentryAndroid = "8.39.1"
junit4 = "4.13.2" junit4 = "4.13.2"
androidxJunit = "1.3.0" androidxJunit = "1.3.0"
espresso = "3.7.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] [libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } 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-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-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-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
androidx-activity-ktx = { group = "androidx.activity", name = "activity-ktx", version.ref = "activityKtx" } androidx-activity-ktx = { group = "androidx.activity", name = "activity-ktx", version.ref = "activityKtx" }
androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" } androidx-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-material3 = { group = "androidx.compose.material3", name = "material3" }
androidx-material-icons-extended = { group = "androidx.compose.material", name = "material-icons-extended" } 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-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" } kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "jserialization" }
androidx-webkit = { group = "androidx.webkit", name = "webkit", version.ref = "webkit" } androidx-webkit = { group = "androidx.webkit", name = "webkit", version.ref = "webkit" }
coil-compose = { group = "io.coil-kt", name = "coil-compose", version.ref = "coil" } coil-compose = { group = "io.coil-kt.coil3", name = "coil-compose", version.ref = "coil" }
sentry-android = { group = "io.sentry", name = "sentry-android", version.ref = "sentryAndroid" } 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 = { group = "com.squareup.retrofit2", name = "retrofit", version.ref = "retrofit" }
retrofit-converter-gson = { group = "com.squareup.retrofit2", name = "converter-gson", 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 = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" }
okhttp-logging = { group = "com.squareup.okhttp3", name = "logging-interceptor", 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" } 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" } 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" } junit4 = { group = "junit", name = "junit", version.ref = "junit4" }
androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "androidxJunit" } androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "androidxJunit" }
androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espresso" } 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" } android-library = { id = "com.android.library", version.ref = "agp" }
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", 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" }

查看文件

@ -1,8 +1,8 @@
plugins { plugins {
alias(libs.plugins.android.application) alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.compose) alias(libs.plugins.kotlin.compose)
alias(libs.plugins.kotlin.kapt)
id("io.sentry.android.gradle") version "6.4.0" alias(libs.plugins.hilt.android)
} }
android { android {
@ -10,7 +10,7 @@ android {
compileSdk = libs.versions.compileSdk.get().toInt() compileSdk = libs.versions.compileSdk.get().toInt()
defaultConfig { defaultConfig {
applicationId = "com.xuqm.sdk.sample" applicationId = "com.xuqm.demo"
minSdk = libs.versions.minSdk.get().toInt() minSdk = libs.versions.minSdk.get().toInt()
targetSdk = libs.versions.targetSdk.get().toInt() targetSdk = libs.versions.targetSdk.get().toInt()
versionCode = 1 versionCode = 1
@ -25,10 +25,16 @@ android {
} }
compileOptions { compileOptions {
sourceCompatibility = JavaVersion.VERSION_11 sourceCompatibility = JavaVersion.VERSION_21
targetCompatibility = JavaVersion.VERSION_11 targetCompatibility = JavaVersion.VERSION_21
}
kotlinOptions { jvmTarget = "21" }
buildFeatures {
compose = true
buildConfig = true
} }
buildFeatures { compose = true }
} }
dependencies { dependencies {
@ -36,10 +42,21 @@ dependencies {
implementation(project(":sdk-im")) implementation(project(":sdk-im"))
implementation(project(":sdk-push")) implementation(project(":sdk-push"))
implementation(project(":sdk-update")) implementation(project(":sdk-update"))
implementation(libs.sentry.android)
implementation(platform(libs.androidx.compose.bom)) implementation(platform(libs.androidx.compose.bom))
implementation(libs.bundles.compose) implementation(libs.bundles.compose)
implementation(libs.androidx.activity.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) debugImplementation(libs.bundles.compose.debug)
} }

查看文件

@ -5,13 +5,14 @@
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" /> <uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
<application <application
android:allowBackup="true"
android:label="XuqmSDK Demo"
android:name=".XuqmSampleApp" android:name=".XuqmSampleApp"
android:theme="@android:style/Theme.Material.Light.NoActionBar"> android:allowBackup="true"
android:label="XuqmGroup Demo"
android:theme="@style/Theme.XuqmDemo">
<activity <activity
android:name=".MainActivity" android:name=".MainActivity"
android:exported="true"> android:exported="true"
android:windowSoftInputMode="adjustResize">
<intent-filter> <intent-filter>
<action android:name="android.intent.action.MAIN" /> <action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" /> <category android:name="android.intent.category.LAUNCHER" />
@ -26,6 +27,5 @@
android:name="android.support.FILE_PROVIDER_PATHS" android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" /> android:resource="@xml/file_paths" />
</provider> </provider>
</application> </application>
</manifest> </manifest>

查看文件

@ -3,151 +3,25 @@ package com.xuqm.sdk.sample
import android.os.Bundle import android.os.Bundle
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.compose.foundation.layout.* import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.rememberScrollState import com.xuqm.sdk.sample.data.repo.AuthRepository
import androidx.compose.foundation.verticalScroll import com.xuqm.sdk.sample.navigation.AppNavGraph
import androidx.compose.material3.* import com.xuqm.sdk.sample.ui.theme.XuqmTheme
import androidx.compose.runtime.* import dagger.hilt.android.AndroidEntryPoint
import androidx.compose.ui.Alignment import javax.inject.Inject
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
@AndroidEntryPoint
class MainActivity : ComponentActivity() { class MainActivity : ComponentActivity() {
@Inject
lateinit var authRepository: AuthRepository
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
enableEdgeToEdge()
XuqmSDK.init(
context = this,
appKey = "ak_your_app_key",
appSecret = "your_app_secret",
apiBaseUrl = "http://10.0.2.2:8082",
imBaseUrl = "ws://10.0.2.2:8082/ws/im",
debug = true,
)
setContent { setContent {
MaterialTheme { XuqmTheme {
Surface(modifier = Modifier.fillMaxSize()) { AppNavGraph(authRepository = authRepository)
SdkDemoScreen()
}
}
}
}
}
@Composable
fun SdkDemoScreen() {
val context = LocalContext.current
val scope = rememberCoroutineScope()
val messages = remember { mutableStateListOf<String>() }
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) }
} }
} }
} }

查看文件

@ -1,9 +1,19 @@
package com.xuqm.sdk.sample package com.xuqm.sdk.sample
import android.app.Application import android.app.Application
import com.xuqm.sdk.XuqmSDK
import com.xuqm.sdk.core.LogLevel
import dagger.hilt.android.HiltAndroidApp
@HiltAndroidApp
class XuqmSampleApp : Application() { class XuqmSampleApp : Application() {
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
XuqmSDK.initialize(
context = this,
appId = "your_app_id",
logLevel = if (BuildConfig.DEBUG) LogLevel.DEBUG else LogLevel.WARN,
)
} }
} }

查看文件

@ -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<T>(
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<LoginData>
@POST("api/user/register")
suspend fun register(@Body request: RegisterRequest): DemoResponse<LoginData>
@GET("api/user/profile")
suspend fun getProfile(): DemoResponse<UserData>
@PUT("api/user/profile")
suspend fun updateProfile(@Body request: UpdateProfileRequest): DemoResponse<UserData>
@POST("api/user/reset-password")
suspend fun resetPassword(@Body request: ResetPasswordRequest): DemoResponse<Unit>
@GET("api/user/users")
suspend fun searchUsers(@Query("keyword") keyword: String): DemoResponse<List<UserData>>
}

查看文件

@ -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<UserData> =
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<UserData> =
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<UserData> =
withContext(Dispatchers.IO) {
runCatching {
requireNotNull(api.getProfile().data) { "Failed to get profile" }
}
}
suspend fun updateProfile(nickname: String, avatar: String?): Result<UserData> =
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<Unit> =
withContext(Dispatchers.IO) {
runCatching { api.resetPassword(ResetPasswordRequest(oldPassword, newPassword)) }
}
suspend fun searchUsers(keyword: String): Result<List<UserData>> =
withContext(Dispatchers.IO) {
runCatching { api.searchUsers(keyword).data ?: emptyList() }
}
fun logout() {
ImSDK.disconnect()
prefs.edit().clear().apply()
}
}

查看文件

@ -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()
}

查看文件

@ -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() },
)
}
}
}

查看文件

@ -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("没有账号?注册")
}
}
}

查看文件

@ -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>(LoginState.Idle)
val state: StateFlow<LoginState> = _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") }
}
}
}

查看文件

@ -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>(LoginState.Idle)
val state: StateFlow<LoginState> = _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("注册")
}
}
}
}

查看文件

@ -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
}
}

查看文件

@ -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<List<ImMessage>>(emptyList())
val messages: StateFlow<List<ImMessage>> = _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)
}
}

查看文件

@ -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<List<UserProfile>>(emptyList())
val friends: StateFlow<List<UserProfile>> = _friends
private val _searchResults = MutableStateFlow<List<UserData>>(emptyList())
val searchResults: StateFlow<List<UserData>> = _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)
}
}
}

查看文件

@ -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))
}

查看文件

@ -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<List<ConversationData>>(emptyList())
val conversations: StateFlow<List<ConversationData>> = _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)
}
}

查看文件

@ -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<String>) -> 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("退出群聊") }
}
}
}
}

查看文件

@ -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<List<ImGroup>>(emptyList())
val groups: StateFlow<List<ImGroup>> = _groups
private val _currentGroup = MutableStateFlow<ImGroup?>(null)
val currentGroup: StateFlow<ImGroup?> = _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<String>, 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() }
}
}
}

查看文件

@ -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)
}
}
}
}

查看文件

@ -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<ProfileUiState> = _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("退出登录")
}
}
}

查看文件

@ -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,
)
}

查看文件

@ -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<UpdateUiState> = _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("检查更新")
}
}
}
}
}
}

查看文件

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Theme.XuqmDemo" parent="Theme.Material3.DayNight.NoActionBar" />
</resources>

查看文件

@ -22,6 +22,6 @@ dependencies {
api(libs.bundles.network) api(libs.bundles.network)
api(libs.kotlinx.coroutines.android) api(libs.kotlinx.coroutines.android)
api(libs.kotlinx.serialization.json) api(libs.kotlinx.serialization.json)
api(libs.androidx.datastore.preferences)
api(libs.androidx.core.ktx) api(libs.androidx.core.ktx)
api(libs.androidx.security.crypto)
} }

查看文件

@ -2,6 +2,7 @@ package com.xuqm.sdk
import android.content.Context import android.content.Context
import com.xuqm.sdk.auth.TokenStore import com.xuqm.sdk.auth.TokenStore
import com.xuqm.sdk.core.LogLevel
import com.xuqm.sdk.core.SDKConfig import com.xuqm.sdk.core.SDKConfig
import com.xuqm.sdk.network.ApiClient import com.xuqm.sdk.network.ApiClient
@ -15,21 +16,20 @@ object XuqmSDK {
private var initialized = false private var initialized = false
fun init( fun initialize(
context: Context, context: Context,
appKey: String, appId: String,
appSecret: String, logLevel: LogLevel = LogLevel.WARN,
apiBaseUrl: String = "https://api.xuqm.com",
imBaseUrl: String = "wss://im.xuqm.com",
debug: Boolean = false,
) { ) {
config = SDKConfig(appKey, appSecret, apiBaseUrl, imBaseUrl, debug) config = SDKConfig(appId, logLevel)
tokenStore = TokenStore(context.applicationContext) tokenStore = TokenStore(context.applicationContext)
ApiClient.init(config, tokenStore) ApiClient.init(config, tokenStore)
initialized = true initialized = true
} }
fun requireInit() { 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
} }

查看文件

@ -1,27 +1,29 @@
package com.xuqm.sdk.auth package com.xuqm.sdk.auth
import android.content.Context import android.content.Context
import androidx.datastore.preferences.core.edit import androidx.security.crypto.EncryptedSharedPreferences
import androidx.datastore.preferences.core.stringPreferencesKey import androidx.security.crypto.MasterKey
import androidx.datastore.preferences.preferencesDataStore
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.runBlocking
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 { fun getToken(): String? = prefs.getString(KEY_IM_TOKEN, null)
context.dataStore.data.first()[TOKEN_KEY]
fun saveToken(token: String) {
prefs.edit().putString(KEY_IM_TOKEN, token).apply()
} }
suspend fun saveToken(token: String) { fun clear() {
context.dataStore.edit { prefs -> prefs[TOKEN_KEY] = token } prefs.edit().remove(KEY_IM_TOKEN).apply()
}
suspend fun clear() {
context.dataStore.edit { prefs -> prefs.remove(TOKEN_KEY) }
} }
} }

查看文件

@ -1,9 +1,11 @@
package com.xuqm.sdk.core 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( data class SDKConfig(
val appKey: String, val appId: String,
val appSecret: String, val logLevel: LogLevel = LogLevel.WARN,
val apiBaseUrl: String = "https://api.xuqm.com",
val imBaseUrl: String = "wss://im.xuqm.com",
val debug: Boolean = false,
) )
enum class LogLevel { DEBUG, INFO, WARN, ERROR, NONE }

查看文件

@ -1,6 +1,8 @@
package com.xuqm.sdk.network package com.xuqm.sdk.network
import com.xuqm.sdk.auth.TokenStore 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 com.xuqm.sdk.core.SDKConfig
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import okhttp3.Request import okhttp3.Request
@ -11,17 +13,15 @@ import java.util.concurrent.TimeUnit
object ApiClient { object ApiClient {
private lateinit var config: SDKConfig
private var tokenStore: TokenStore? = null private var tokenStore: TokenStore? = null
lateinit var retrofit: Retrofit lateinit var retrofit: Retrofit
private set private set
fun init(cfg: SDKConfig, store: TokenStore) { fun init(cfg: SDKConfig, store: TokenStore) {
config = cfg
tokenStore = store tokenStore = store
val logging = HttpLoggingInterceptor().apply { val logging = HttpLoggingInterceptor().apply {
level = if (cfg.debug) HttpLoggingInterceptor.Level.BODY level = if (cfg.logLevel == LogLevel.DEBUG) HttpLoggingInterceptor.Level.BODY
else HttpLoggingInterceptor.Level.NONE else HttpLoggingInterceptor.Level.NONE
} }
@ -41,7 +41,7 @@ object ApiClient {
.build() .build()
retrofit = Retrofit.Builder() retrofit = Retrofit.Builder()
.baseUrl(cfg.apiBaseUrl.trimEnd('/') + "/") .baseUrl(BASE_URL)
.client(okhttp) .client(okhttp)
.addConverterFactory(GsonConverterFactory.create()) .addConverterFactory(GsonConverterFactory.create())
.build() .build()

查看文件

@ -2,13 +2,10 @@ package com.xuqm.sdk.im
import com.google.gson.Gson import com.google.gson.Gson
import com.xuqm.sdk.im.listener.ImEventListener 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.ImMessage
import com.xuqm.sdk.im.model.MsgType
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import okhttp3.Request import okhttp3.Request
import okhttp3.Response import okhttp3.Response
@ -43,14 +40,14 @@ class ImClient(
} }
override fun onMessage(ws: WebSocket, text: String) { override fun onMessage(ws: WebSocket, text: String) {
try { runCatching {
val msg = gson.fromJson(text, ImMessage::class.java) val msg = gson.fromJson(text, ImMessage::class.java)
if (msg.chatType == ChatType.GROUP) { if (msg.chatType == "GROUP") {
listeners.forEach { it.onGroupMessage(msg) } listeners.forEach { it.onGroupMessage(msg) }
} else { } else {
listeners.forEach { it.onMessage(msg) } listeners.forEach { it.onMessage(msg) }
} }
} catch (e: Exception) { }.onFailure { e ->
listeners.forEach { it.onError("Parse error: ${e.message}") } 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( val payload = mapOf(
"appId" to appId, "toId" to toId, "appId" to appId, "toId" to toId,
"chatType" to chatType.name, "msgType" to msgType.name, "chatType" to chatType, "msgType" to msgType,
"content" to content, "content" to content,
) )
webSocket?.send(gson.toJson(mapOf("type" to "chat.send", "data" to payload))) webSocket?.send(gson.toJson(mapOf("type" to "chat.send", "data" to payload)))

查看文件

@ -1,39 +1,112 @@
package com.xuqm.sdk.im package com.xuqm.sdk.im
import com.xuqm.sdk.XuqmSDK 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.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.listener.ImEventListener
import com.xuqm.sdk.im.model.ChatType import com.xuqm.sdk.im.model.ConversationData
import com.xuqm.sdk.im.model.MsgType 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 com.xuqm.sdk.network.ApiClient
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.withContext
object ImSDK { object ImSDK {
private var client: ImClient? = null private var client: ImClient? = null
private val api: ImApi by lazy { ApiClient.create() } 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) { var currentUserId: String = ""
private set
suspend fun login(userId: String, nickname: String? = null, avatar: String? = null) =
withContext(Dispatchers.IO) {
XuqmSDK.requireInit() XuqmSDK.requireInit()
scope.launch { val res = api.login(LoginRequest(XuqmSDK.appId, userId, nickname, avatar))
val res = api.login(appId, userId, nickname, avatar) val token = requireNotNull(res.data?.token) { "IM login failed: ${res.message}" }
res.data?.token?.let { token ->
XuqmSDK.tokenStore.saveToken(token) XuqmSDK.tokenStore.saveToken(token)
val wsUrl = XuqmSDK.config.imBaseUrl currentUserId = userId
client = ImClient(wsUrl, token, appId) client = ImClient(WS_URL, token, XuqmSDK.appId)
client?.connect() 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) client?.sendMessage(toId, chatType, msgType, content)
} }
suspend fun fetchHistory(toId: String, page: Int = 0, size: Int = 20): List<ImMessage> =
withContext(Dispatchers.IO) {
api.fetchHistory(toId, "SINGLE", page, size).data ?: emptyList()
}
suspend fun fetchGroupHistory(groupId: String, page: Int = 0, size: Int = 20): List<ImMessage> =
withContext(Dispatchers.IO) {
api.fetchGroupHistory(groupId, page, size).data ?: emptyList()
}
suspend fun listGroups(): List<ImGroup> =
withContext(Dispatchers.IO) { api.listGroups().data ?: emptyList() }
suspend fun createGroup(name: String, memberIds: List<String>): 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<UserProfile> =
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<ConversationData> =
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 addListener(listener: ImEventListener) = client?.addListener(listener)
fun removeListener(listener: ImEventListener) = client?.removeListener(listener) fun removeListener(listener: ImEventListener) = client?.removeListener(listener)
fun disconnect() = client?.disconnect()
fun disconnect() {
client?.disconnect()
client = null
currentUserId = ""
XuqmSDK.tokenStore.clear()
}
} }

查看文件

@ -1,26 +1,125 @@
package com.xuqm.sdk.im.api 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 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.GET
import retrofit2.http.POST
import retrofit2.http.PUT
import retrofit2.http.Path
import retrofit2.http.Query import retrofit2.http.Query
data class ApiResponse<T>(val code: Int, val status: String, val data: T?, val message: String) data class ApiResponse<T>(
data class LoginResponse(val token: String) val code: Int,
data class SendMessageRequest( val status: String,
val toId: String, val data: T?,
val chatType: String, val message: String? = null,
val msgType: String,
val content: String,
val mentionedUserIds: 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<String>)
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 { interface ImApi {
@POST("api/im/auth/login") @POST("api/im/auth/login")
suspend fun login( suspend fun login(@Body request: LoginRequest): ApiResponse<LoginResponse>
@Query("appId") appId: String,
@Query("userId") userId: String, @GET("api/im/messages")
@Query("nickname") nickname: String? = null, suspend fun fetchHistory(
@Query("avatar") avatar: String? = null, @Query("toId") toId: String,
): ApiResponse<LoginResponse> @Query("chatType") chatType: String,
@Query("page") page: Int,
@Query("size") size: Int,
): ApiResponse<List<ImMessage>>
@GET("api/im/groups/{groupId}/messages")
suspend fun fetchGroupHistory(
@Path("groupId") groupId: String,
@Query("page") page: Int,
@Query("size") size: Int,
): ApiResponse<List<ImMessage>>
@GET("api/im/groups")
suspend fun listGroups(): ApiResponse<List<ImGroup>>
@POST("api/im/groups")
suspend fun createGroup(@Body request: CreateGroupRequest): ApiResponse<ImGroup>
@GET("api/im/groups/{groupId}")
suspend fun getGroupInfo(@Path("groupId") groupId: String): ApiResponse<ImGroup>
@PUT("api/im/groups/{groupId}")
suspend fun updateGroupInfo(
@Path("groupId") groupId: String,
@Body request: UpdateGroupRequest,
): ApiResponse<ImGroup>
@POST("api/im/groups/{groupId}/members")
suspend fun addGroupMember(
@Path("groupId") groupId: String,
@Body request: AddMemberRequest,
): ApiResponse<Unit>
@DELETE("api/im/groups/{groupId}/members/{userId}")
suspend fun removeGroupMember(
@Path("groupId") groupId: String,
@Path("userId") userId: String,
): ApiResponse<Unit>
@DELETE("api/im/groups/{groupId}/members/me")
suspend fun leaveGroup(@Path("groupId") groupId: String): ApiResponse<Unit>
@GET("api/im/friends")
suspend fun listFriends(): ApiResponse<List<UserProfile>>
@POST("api/im/friends/{friendId}")
suspend fun addFriend(@Path("friendId") friendId: String): ApiResponse<Unit>
@DELETE("api/im/friends/{friendId}")
suspend fun removeFriend(@Path("friendId") friendId: String): ApiResponse<Unit>
@GET("api/im/conversations")
suspend fun listConversations(): ApiResponse<List<ConversationData>>
@PUT("api/im/conversations/{targetId}/pinned")
suspend fun setConversationPinned(
@Path("targetId") targetId: String,
@Query("chatType") chatType: String,
@Body request: SetPinnedRequest,
): ApiResponse<Unit>
@PUT("api/im/conversations/{targetId}/muted")
suspend fun setConversationMuted(
@Path("targetId") targetId: String,
@Query("chatType") chatType: String,
@Body request: SetMutedRequest,
): ApiResponse<Unit>
@PUT("api/im/conversations/{targetId}/read")
suspend fun markRead(
@Path("targetId") targetId: String,
@Query("chatType") chatType: String,
): ApiResponse<Unit>
@GET("api/im/users/{userId}")
suspend fun getUserProfile(@Path("userId") userId: String): ApiResponse<UserProfile>
} }

查看文件

@ -3,14 +3,41 @@ package com.xuqm.sdk.im.model
data class ImMessage( data class ImMessage(
val id: String, val id: String,
val appId: String, val appId: String,
val fromUserId: String, val fromId: String,
val toId: String, val toId: String,
val chatType: ChatType, val chatType: String,
val msgType: MsgType, val msgType: String,
val content: String, val content: String,
val status: MsgStatus, val status: String,
val mentionedUserIds: String?, val mentionedUserIds: String? = null,
val createdAt: String, 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 } enum class ChatType { SINGLE, GROUP }
@ -20,4 +47,4 @@ enum class MsgType {
RICH_TEXT, CALL_AUDIO, CALL_VIDEO, REVOKED, FORWARD RICH_TEXT, CALL_AUDIO, CALL_VIDEO, REVOKED, FORWARD
} }
enum class MsgStatus { SENT, DELIVERED, READ, REVOKED } enum class MsgStatus { SENDING, SENT, DELIVERED, READ, FAILED, REVOKED }

查看文件

@ -4,6 +4,7 @@ import android.content.Context
import com.xuqm.sdk.XuqmSDK import com.xuqm.sdk.XuqmSDK
import com.xuqm.sdk.network.ApiClient import com.xuqm.sdk.network.ApiClient
import com.xuqm.sdk.push.api.PushApi import com.xuqm.sdk.push.api.PushApi
import com.xuqm.sdk.push.api.RegisterDeviceRequest
import com.xuqm.sdk.utils.DeviceUtils import com.xuqm.sdk.utils.DeviceUtils
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@ -14,22 +15,30 @@ object PushSDK {
private val api: PushApi by lazy { ApiClient.create() } private val api: PushApi by lazy { ApiClient.create() }
private val scope = CoroutineScope(Dispatchers.IO) private val scope = CoroutineScope(Dispatchers.IO)
fun registerToken(context: Context, appId: String, userId: String, token: String) { fun registerDevice(context: Context, userId: String) {
XuqmSDK.requireInit() XuqmSDK.requireInit()
val vendor = DeviceUtils.getVendor() val vendor = DeviceUtils.getVendor()
val deviceId = DeviceUtils.getDeviceId(context)
scope.launch { scope.launch {
runCatching { 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() XuqmSDK.requireInit()
scope.launch { scope.launch {
runCatching { runCatching { api.unregisterDevice(XuqmSDK.appId, userId) }
api.unregisterToken(appId, userId)
}
} }
} }
} }

查看文件

@ -1,20 +1,26 @@
package com.xuqm.sdk.push.api package com.xuqm.sdk.push.api
import retrofit2.http.Body
import retrofit2.http.DELETE import retrofit2.http.DELETE
import retrofit2.http.POST import retrofit2.http.POST
import retrofit2.http.Query import retrofit2.http.Query
interface PushApi { data class RegisterDeviceRequest(
@POST("api/push/register") val userId: String,
suspend fun registerToken( val appId: String,
@Query("appId") appId: String, val platform: String,
@Query("userId") userId: String, val vendor: String,
@Query("vendor") vendor: String, val pushToken: String,
@Query("token") token: String, val deviceId: String,
) )
@DELETE("api/push/unregister") interface PushApi {
suspend fun unregisterToken(
@POST("api/push/device/register")
suspend fun registerDevice(@Body request: RegisterDeviceRequest)
@DELETE("api/push/device/unregister")
suspend fun unregisterDevice(
@Query("appId") appId: String, @Query("appId") appId: String,
@Query("userId") userId: String, @Query("userId") userId: String,
) )

查看文件

@ -3,7 +3,6 @@ package com.xuqm.sdk.update
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.net.Uri import android.net.Uri
import android.os.Build
import androidx.core.content.FileProvider import androidx.core.content.FileProvider
import com.xuqm.sdk.XuqmSDK import com.xuqm.sdk.XuqmSDK
import com.xuqm.sdk.network.ApiClient import com.xuqm.sdk.network.ApiClient
@ -19,11 +18,13 @@ object UpdateSDK {
private val api: UpdateApi by lazy { ApiClient.create() } 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() XuqmSDK.requireInit()
val versionCode = context.packageManager val versionCode = context.packageManager
.getPackageInfo(context.packageName, 0).longVersionCode.toInt() .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( 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) { private fun installApk(context: Context, apkFile: File) {
@ -64,8 +62,11 @@ object UpdateSDK {
context.startActivity(intent) context.startActivity(intent)
} }
suspend fun checkRnUpdate(appId: String, moduleId: String, currentVersion: String): RnUpdateInfo? = suspend fun checkRnUpdate(moduleId: String, currentVersion: String): RnUpdateInfo? =
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
runCatching { api.checkRnUpdate(appId, moduleId, "ANDROID", currentVersion).data }.getOrNull() XuqmSDK.requireInit()
runCatching {
api.checkRnUpdate(XuqmSDK.appId, moduleId, "ANDROID", currentVersion).data
}.getOrNull()
} }
} }