feat(sdk): 初始化 Android SDK 核心功能模块
- 添加 SDK 配置管理、网络请求客户端和令牌存储功能 - 实现即时通讯 IM 模块,包括消息收发、群组管理和会话功能 - 集成推送服务和应用更新功能模块 - 创建示例应用演示 SDK 使用方法 - 配置项目依赖管理和构建设置
这个提交包含在:
父节点
3e66380802
当前提交
6dd0fa8f49
@ -3,6 +3,8 @@ plugins {
|
||||
alias(libs.plugins.android.library) apply false
|
||||
alias(libs.plugins.kotlin.compose) apply false
|
||||
alias(libs.plugins.kotlin.serialization) apply false
|
||||
alias(libs.plugins.kotlin.kapt) apply false
|
||||
alias(libs.plugins.hilt.android) apply false
|
||||
}
|
||||
|
||||
group = "com.xuqm"
|
||||
|
||||
@ -16,16 +16,21 @@ okhttp = "5.3.2"
|
||||
gson = "2.13.2"
|
||||
jserialization = "1.9.0"
|
||||
webkit = "1.14.0"
|
||||
coil = "2.7.0"
|
||||
sentryAndroid = "8.39.1"
|
||||
coil = "3.1.0"
|
||||
junit4 = "4.13.2"
|
||||
androidxJunit = "1.3.0"
|
||||
espresso = "3.7.0"
|
||||
hilt = "2.56.2"
|
||||
navigationCompose = "2.9.0"
|
||||
securityCrypto = "1.0.0"
|
||||
hiltNavigationCompose = "1.2.0"
|
||||
viewmodelCompose = "2.10.0"
|
||||
|
||||
[libraries]
|
||||
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
|
||||
androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycle" }
|
||||
androidx-lifecycle-runtime-compose = { group = "androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "lifecycle" }
|
||||
androidx-lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "viewmodelCompose" }
|
||||
androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
|
||||
androidx-activity-ktx = { group = "androidx.activity", name = "activity-ktx", version.ref = "activityKtx" }
|
||||
androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" }
|
||||
@ -38,16 +43,21 @@ androidx-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit
|
||||
androidx-material3 = { group = "androidx.compose.material3", name = "material3" }
|
||||
androidx-material-icons-extended = { group = "androidx.compose.material", name = "material-icons-extended" }
|
||||
androidx-datastore-preferences = { group = "androidx.datastore", name = "datastore-preferences", version.ref = "datastore" }
|
||||
androidx-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigationCompose" }
|
||||
androidx-hilt-navigation-compose = { group = "androidx.hilt", name = "hilt-navigation-compose", version.ref = "hiltNavigationCompose" }
|
||||
androidx-security-crypto = { group = "androidx.security", name = "security-crypto", version.ref = "securityCrypto" }
|
||||
kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "jserialization" }
|
||||
androidx-webkit = { group = "androidx.webkit", name = "webkit", version.ref = "webkit" }
|
||||
coil-compose = { group = "io.coil-kt", name = "coil-compose", version.ref = "coil" }
|
||||
sentry-android = { group = "io.sentry", name = "sentry-android", version.ref = "sentryAndroid" }
|
||||
coil-compose = { group = "io.coil-kt.coil3", name = "coil-compose", version.ref = "coil" }
|
||||
coil-network-okhttp = { group = "io.coil-kt.coil3", name = "coil-network-okhttp", version.ref = "coil" }
|
||||
retrofit = { group = "com.squareup.retrofit2", name = "retrofit", version.ref = "retrofit" }
|
||||
retrofit-converter-gson = { group = "com.squareup.retrofit2", name = "converter-gson", version.ref = "retrofit" }
|
||||
okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" }
|
||||
okhttp-logging = { group = "com.squareup.okhttp3", name = "logging-interceptor", version.ref = "okhttp" }
|
||||
gson = { group = "com.google.code.gson", name = "gson", version.ref = "gson" }
|
||||
kotlinx-coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "coroutines" }
|
||||
hilt-android = { group = "com.google.dagger", name = "hilt-android", version.ref = "hilt" }
|
||||
hilt-compiler = { group = "com.google.dagger", name = "hilt-compiler", version.ref = "hilt" }
|
||||
junit4 = { group = "junit", name = "junit", version.ref = "junit4" }
|
||||
androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "androidxJunit" }
|
||||
androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espresso" }
|
||||
@ -77,3 +87,5 @@ android-application = { id = "com.android.application", version.ref = "agp" }
|
||||
android-library = { id = "com.android.library", version.ref = "agp" }
|
||||
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
|
||||
kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
|
||||
kotlin-kapt = { id = "org.jetbrains.kotlin.kapt", version.ref = "kotlin" }
|
||||
hilt-android = { id = "com.google.dagger.hilt.android", version.ref = "hilt" }
|
||||
|
||||
@ -1,8 +1,8 @@
|
||||
plugins {
|
||||
alias(libs.plugins.android.application)
|
||||
alias(libs.plugins.kotlin.compose)
|
||||
|
||||
id("io.sentry.android.gradle") version "6.4.0"
|
||||
alias(libs.plugins.kotlin.kapt)
|
||||
alias(libs.plugins.hilt.android)
|
||||
}
|
||||
|
||||
android {
|
||||
@ -10,7 +10,7 @@ android {
|
||||
compileSdk = libs.versions.compileSdk.get().toInt()
|
||||
|
||||
defaultConfig {
|
||||
applicationId = "com.xuqm.sdk.sample"
|
||||
applicationId = "com.xuqm.demo"
|
||||
minSdk = libs.versions.minSdk.get().toInt()
|
||||
targetSdk = libs.versions.targetSdk.get().toInt()
|
||||
versionCode = 1
|
||||
@ -25,10 +25,16 @@ android {
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_11
|
||||
targetCompatibility = JavaVersion.VERSION_11
|
||||
sourceCompatibility = JavaVersion.VERSION_21
|
||||
targetCompatibility = JavaVersion.VERSION_21
|
||||
}
|
||||
|
||||
kotlinOptions { jvmTarget = "21" }
|
||||
|
||||
buildFeatures {
|
||||
compose = true
|
||||
buildConfig = true
|
||||
}
|
||||
buildFeatures { compose = true }
|
||||
}
|
||||
|
||||
dependencies {
|
||||
@ -36,10 +42,21 @@ dependencies {
|
||||
implementation(project(":sdk-im"))
|
||||
implementation(project(":sdk-push"))
|
||||
implementation(project(":sdk-update"))
|
||||
implementation(libs.sentry.android)
|
||||
|
||||
implementation(platform(libs.androidx.compose.bom))
|
||||
implementation(libs.bundles.compose)
|
||||
implementation(libs.androidx.activity.compose)
|
||||
implementation(libs.androidx.lifecycle.runtime.ktx)
|
||||
implementation(libs.androidx.lifecycle.runtime.compose)
|
||||
implementation(libs.androidx.lifecycle.viewmodel.compose)
|
||||
|
||||
implementation(libs.androidx.navigation.compose)
|
||||
|
||||
implementation(libs.hilt.android)
|
||||
kapt(libs.hilt.compiler)
|
||||
implementation(libs.androidx.hilt.navigation.compose)
|
||||
|
||||
implementation(libs.coil.compose)
|
||||
implementation(libs.coil.network.okhttp)
|
||||
|
||||
debugImplementation(libs.bundles.compose.debug)
|
||||
}
|
||||
|
||||
@ -5,13 +5,14 @@
|
||||
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
|
||||
|
||||
<application
|
||||
android:allowBackup="true"
|
||||
android:label="XuqmSDK Demo"
|
||||
android:name=".XuqmSampleApp"
|
||||
android:theme="@android:style/Theme.Material.Light.NoActionBar">
|
||||
android:allowBackup="true"
|
||||
android:label="XuqmGroup Demo"
|
||||
android:theme="@style/Theme.XuqmDemo">
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true">
|
||||
android:exported="true"
|
||||
android:windowSoftInputMode="adjustResize">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
@ -26,6 +27,5 @@
|
||||
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||
android:resource="@xml/file_paths" />
|
||||
</provider>
|
||||
|
||||
</application>
|
||||
</manifest>
|
||||
|
||||
@ -3,151 +3,25 @@ package com.xuqm.sdk.sample
|
||||
import android.os.Bundle
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.xuqm.sdk.XuqmSDK
|
||||
import com.xuqm.sdk.im.ImSDK
|
||||
import com.xuqm.sdk.im.listener.ImEventListener
|
||||
import com.xuqm.sdk.im.model.ChatType
|
||||
import com.xuqm.sdk.im.model.ImMessage
|
||||
import com.xuqm.sdk.im.model.MsgType
|
||||
import com.xuqm.sdk.update.UpdateSDK
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import com.xuqm.sdk.sample.data.repo.AuthRepository
|
||||
import com.xuqm.sdk.sample.navigation.AppNavGraph
|
||||
import com.xuqm.sdk.sample.ui.theme.XuqmTheme
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import javax.inject.Inject
|
||||
|
||||
@AndroidEntryPoint
|
||||
class MainActivity : ComponentActivity() {
|
||||
|
||||
@Inject
|
||||
lateinit var authRepository: AuthRepository
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
XuqmSDK.init(
|
||||
context = this,
|
||||
appKey = "ak_your_app_key",
|
||||
appSecret = "your_app_secret",
|
||||
apiBaseUrl = "http://10.0.2.2:8082",
|
||||
imBaseUrl = "ws://10.0.2.2:8082/ws/im",
|
||||
debug = true,
|
||||
)
|
||||
|
||||
enableEdgeToEdge()
|
||||
setContent {
|
||||
MaterialTheme {
|
||||
Surface(modifier = Modifier.fillMaxSize()) {
|
||||
SdkDemoScreen()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SdkDemoScreen() {
|
||||
val context = LocalContext.current
|
||||
val scope = rememberCoroutineScope()
|
||||
val messages = remember { mutableStateListOf<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) }
|
||||
XuqmTheme {
|
||||
AppNavGraph(authRepository = authRepository)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,9 +1,19 @@
|
||||
package com.xuqm.sdk.sample
|
||||
|
||||
import android.app.Application
|
||||
import com.xuqm.sdk.XuqmSDK
|
||||
import com.xuqm.sdk.core.LogLevel
|
||||
import dagger.hilt.android.HiltAndroidApp
|
||||
|
||||
@HiltAndroidApp
|
||||
class XuqmSampleApp : Application() {
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
XuqmSDK.initialize(
|
||||
context = this,
|
||||
appId = "your_app_id",
|
||||
logLevel = if (BuildConfig.DEBUG) LogLevel.DEBUG else LogLevel.WARN,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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.kotlinx.coroutines.android)
|
||||
api(libs.kotlinx.serialization.json)
|
||||
api(libs.androidx.datastore.preferences)
|
||||
api(libs.androidx.core.ktx)
|
||||
api(libs.androidx.security.crypto)
|
||||
}
|
||||
|
||||
@ -2,6 +2,7 @@ package com.xuqm.sdk
|
||||
|
||||
import android.content.Context
|
||||
import com.xuqm.sdk.auth.TokenStore
|
||||
import com.xuqm.sdk.core.LogLevel
|
||||
import com.xuqm.sdk.core.SDKConfig
|
||||
import com.xuqm.sdk.network.ApiClient
|
||||
|
||||
@ -15,21 +16,20 @@ object XuqmSDK {
|
||||
|
||||
private var initialized = false
|
||||
|
||||
fun init(
|
||||
fun initialize(
|
||||
context: Context,
|
||||
appKey: String,
|
||||
appSecret: String,
|
||||
apiBaseUrl: String = "https://api.xuqm.com",
|
||||
imBaseUrl: String = "wss://im.xuqm.com",
|
||||
debug: Boolean = false,
|
||||
appId: String,
|
||||
logLevel: LogLevel = LogLevel.WARN,
|
||||
) {
|
||||
config = SDKConfig(appKey, appSecret, apiBaseUrl, imBaseUrl, debug)
|
||||
config = SDKConfig(appId, logLevel)
|
||||
tokenStore = TokenStore(context.applicationContext)
|
||||
ApiClient.init(config, tokenStore)
|
||||
initialized = true
|
||||
}
|
||||
|
||||
fun requireInit() {
|
||||
check(initialized) { "XuqmSDK is not initialized. Call XuqmSDK.init() first." }
|
||||
check(initialized) { "XuqmSDK not initialized. Call XuqmSDK.initialize() first." }
|
||||
}
|
||||
|
||||
val appId: String get() = config.appId
|
||||
}
|
||||
|
||||
@ -1,27 +1,29 @@
|
||||
package com.xuqm.sdk.auth
|
||||
|
||||
import android.content.Context
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import androidx.datastore.preferences.preferencesDataStore
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import androidx.security.crypto.EncryptedSharedPreferences
|
||||
import androidx.security.crypto.MasterKey
|
||||
|
||||
private val Context.dataStore by preferencesDataStore(name = "xuqm_sdk_prefs")
|
||||
private const val PREFS_NAME = "xuqm_sdk_secure"
|
||||
private const val KEY_IM_TOKEN = "im_token"
|
||||
|
||||
class TokenStore(private val context: Context) {
|
||||
class TokenStore(context: Context) {
|
||||
|
||||
private val TOKEN_KEY = stringPreferencesKey("access_token")
|
||||
private val prefs = EncryptedSharedPreferences.create(
|
||||
context,
|
||||
PREFS_NAME,
|
||||
MasterKey.Builder(context).setKeyScheme(MasterKey.KeyScheme.AES256_GCM).build(),
|
||||
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
|
||||
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
|
||||
)
|
||||
|
||||
fun getToken(): String? = runBlocking {
|
||||
context.dataStore.data.first()[TOKEN_KEY]
|
||||
fun getToken(): String? = prefs.getString(KEY_IM_TOKEN, null)
|
||||
|
||||
fun saveToken(token: String) {
|
||||
prefs.edit().putString(KEY_IM_TOKEN, token).apply()
|
||||
}
|
||||
|
||||
suspend fun saveToken(token: String) {
|
||||
context.dataStore.edit { prefs -> prefs[TOKEN_KEY] = token }
|
||||
}
|
||||
|
||||
suspend fun clear() {
|
||||
context.dataStore.edit { prefs -> prefs.remove(TOKEN_KEY) }
|
||||
fun clear() {
|
||||
prefs.edit().remove(KEY_IM_TOKEN).apply()
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,9 +1,11 @@
|
||||
package com.xuqm.sdk.core
|
||||
|
||||
internal const val BASE_URL = "https://dev.xuqinmin.com/"
|
||||
internal const val WS_URL = "wss://dev.xuqinmin.com/ws/im"
|
||||
|
||||
data class SDKConfig(
|
||||
val appKey: String,
|
||||
val appSecret: String,
|
||||
val apiBaseUrl: String = "https://api.xuqm.com",
|
||||
val imBaseUrl: String = "wss://im.xuqm.com",
|
||||
val debug: Boolean = false,
|
||||
val appId: String,
|
||||
val logLevel: LogLevel = LogLevel.WARN,
|
||||
)
|
||||
|
||||
enum class LogLevel { DEBUG, INFO, WARN, ERROR, NONE }
|
||||
|
||||
@ -1,6 +1,8 @@
|
||||
package com.xuqm.sdk.network
|
||||
|
||||
import com.xuqm.sdk.auth.TokenStore
|
||||
import com.xuqm.sdk.core.BASE_URL
|
||||
import com.xuqm.sdk.core.LogLevel
|
||||
import com.xuqm.sdk.core.SDKConfig
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
@ -11,17 +13,15 @@ import java.util.concurrent.TimeUnit
|
||||
|
||||
object ApiClient {
|
||||
|
||||
private lateinit var config: SDKConfig
|
||||
private var tokenStore: TokenStore? = null
|
||||
lateinit var retrofit: Retrofit
|
||||
private set
|
||||
|
||||
fun init(cfg: SDKConfig, store: TokenStore) {
|
||||
config = cfg
|
||||
tokenStore = store
|
||||
|
||||
val logging = HttpLoggingInterceptor().apply {
|
||||
level = if (cfg.debug) HttpLoggingInterceptor.Level.BODY
|
||||
level = if (cfg.logLevel == LogLevel.DEBUG) HttpLoggingInterceptor.Level.BODY
|
||||
else HttpLoggingInterceptor.Level.NONE
|
||||
}
|
||||
|
||||
@ -41,7 +41,7 @@ object ApiClient {
|
||||
.build()
|
||||
|
||||
retrofit = Retrofit.Builder()
|
||||
.baseUrl(cfg.apiBaseUrl.trimEnd('/') + "/")
|
||||
.baseUrl(BASE_URL)
|
||||
.client(okhttp)
|
||||
.addConverterFactory(GsonConverterFactory.create())
|
||||
.build()
|
||||
|
||||
@ -2,13 +2,10 @@ package com.xuqm.sdk.im
|
||||
|
||||
import com.google.gson.Gson
|
||||
import com.xuqm.sdk.im.listener.ImEventListener
|
||||
import com.xuqm.sdk.im.model.ChatType
|
||||
import com.xuqm.sdk.im.model.ImMessage
|
||||
import com.xuqm.sdk.im.model.MsgType
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.launch
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
@ -43,14 +40,14 @@ class ImClient(
|
||||
}
|
||||
|
||||
override fun onMessage(ws: WebSocket, text: String) {
|
||||
try {
|
||||
runCatching {
|
||||
val msg = gson.fromJson(text, ImMessage::class.java)
|
||||
if (msg.chatType == ChatType.GROUP) {
|
||||
if (msg.chatType == "GROUP") {
|
||||
listeners.forEach { it.onGroupMessage(msg) }
|
||||
} else {
|
||||
listeners.forEach { it.onMessage(msg) }
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
}.onFailure { e ->
|
||||
listeners.forEach { it.onError("Parse error: ${e.message}") }
|
||||
}
|
||||
}
|
||||
@ -65,10 +62,10 @@ class ImClient(
|
||||
})
|
||||
}
|
||||
|
||||
fun sendMessage(toId: String, chatType: ChatType, msgType: MsgType, content: String) {
|
||||
fun sendMessage(toId: String, chatType: String, msgType: String, content: String) {
|
||||
val payload = mapOf(
|
||||
"appId" to appId, "toId" to toId,
|
||||
"chatType" to chatType.name, "msgType" to msgType.name,
|
||||
"chatType" to chatType, "msgType" to msgType,
|
||||
"content" to content,
|
||||
)
|
||||
webSocket?.send(gson.toJson(mapOf("type" to "chat.send", "data" to payload)))
|
||||
|
||||
@ -1,39 +1,112 @@
|
||||
package com.xuqm.sdk.im
|
||||
|
||||
import com.xuqm.sdk.XuqmSDK
|
||||
import com.xuqm.sdk.core.WS_URL
|
||||
import com.xuqm.sdk.im.api.AddMemberRequest
|
||||
import com.xuqm.sdk.im.api.CreateGroupRequest
|
||||
import com.xuqm.sdk.im.api.ImApi
|
||||
import com.xuqm.sdk.im.api.LoginRequest
|
||||
import com.xuqm.sdk.im.api.SetMutedRequest
|
||||
import com.xuqm.sdk.im.api.SetPinnedRequest
|
||||
import com.xuqm.sdk.im.api.UpdateGroupRequest
|
||||
import com.xuqm.sdk.im.listener.ImEventListener
|
||||
import com.xuqm.sdk.im.model.ChatType
|
||||
import com.xuqm.sdk.im.model.MsgType
|
||||
import com.xuqm.sdk.im.model.ConversationData
|
||||
import com.xuqm.sdk.im.model.ImGroup
|
||||
import com.xuqm.sdk.im.model.ImMessage
|
||||
import com.xuqm.sdk.im.model.UserProfile
|
||||
import com.xuqm.sdk.network.ApiClient
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
object ImSDK {
|
||||
|
||||
private var client: ImClient? = null
|
||||
private val api: ImApi by lazy { ApiClient.create() }
|
||||
private val scope = CoroutineScope(Dispatchers.IO)
|
||||
|
||||
fun login(appId: String, userId: String, nickname: String? = null, avatar: String? = null) {
|
||||
var currentUserId: String = ""
|
||||
private set
|
||||
|
||||
suspend fun login(userId: String, nickname: String? = null, avatar: String? = null) =
|
||||
withContext(Dispatchers.IO) {
|
||||
XuqmSDK.requireInit()
|
||||
scope.launch {
|
||||
val res = api.login(appId, userId, nickname, avatar)
|
||||
res.data?.token?.let { token ->
|
||||
val res = api.login(LoginRequest(XuqmSDK.appId, userId, nickname, avatar))
|
||||
val token = requireNotNull(res.data?.token) { "IM login failed: ${res.message}" }
|
||||
XuqmSDK.tokenStore.saveToken(token)
|
||||
val wsUrl = XuqmSDK.config.imBaseUrl
|
||||
client = ImClient(wsUrl, token, appId)
|
||||
currentUserId = userId
|
||||
client = ImClient(WS_URL, token, XuqmSDK.appId)
|
||||
client?.connect()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun sendMessage(toId: String, chatType: ChatType, msgType: MsgType, content: String) {
|
||||
fun sendMessage(toId: String, chatType: String, msgType: String, content: String) {
|
||||
client?.sendMessage(toId, chatType, msgType, content)
|
||||
}
|
||||
|
||||
suspend fun fetchHistory(toId: String, page: Int = 0, size: Int = 20): List<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 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
|
||||
|
||||
import com.xuqm.sdk.im.model.ConversationData
|
||||
import com.xuqm.sdk.im.model.ImGroup
|
||||
import com.xuqm.sdk.im.model.ImMessage
|
||||
import retrofit2.http.POST
|
||||
import com.xuqm.sdk.im.model.UserProfile
|
||||
import retrofit2.http.Body
|
||||
import retrofit2.http.DELETE
|
||||
import retrofit2.http.GET
|
||||
import retrofit2.http.POST
|
||||
import retrofit2.http.PUT
|
||||
import retrofit2.http.Path
|
||||
import retrofit2.http.Query
|
||||
|
||||
data class ApiResponse<T>(val code: Int, val status: String, val data: T?, val message: String)
|
||||
data class LoginResponse(val token: String)
|
||||
data class SendMessageRequest(
|
||||
val toId: String,
|
||||
val chatType: String,
|
||||
val msgType: String,
|
||||
val content: String,
|
||||
val mentionedUserIds: String? = null,
|
||||
data class ApiResponse<T>(
|
||||
val code: Int,
|
||||
val status: String,
|
||||
val data: T?,
|
||||
val message: String? = null,
|
||||
)
|
||||
|
||||
data class LoginRequest(
|
||||
val appId: String,
|
||||
val userId: String,
|
||||
val nickname: String? = null,
|
||||
val avatar: String? = null,
|
||||
)
|
||||
|
||||
data class LoginResponse(val token: String)
|
||||
|
||||
data class CreateGroupRequest(val name: String, val memberIds: List<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 {
|
||||
|
||||
@POST("api/im/auth/login")
|
||||
suspend fun login(
|
||||
@Query("appId") appId: String,
|
||||
@Query("userId") userId: String,
|
||||
@Query("nickname") nickname: String? = null,
|
||||
@Query("avatar") avatar: String? = null,
|
||||
): ApiResponse<LoginResponse>
|
||||
suspend fun login(@Body request: LoginRequest): ApiResponse<LoginResponse>
|
||||
|
||||
@GET("api/im/messages")
|
||||
suspend fun fetchHistory(
|
||||
@Query("toId") toId: String,
|
||||
@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(
|
||||
val id: String,
|
||||
val appId: String,
|
||||
val fromUserId: String,
|
||||
val fromId: String,
|
||||
val toId: String,
|
||||
val chatType: ChatType,
|
||||
val msgType: MsgType,
|
||||
val chatType: String,
|
||||
val msgType: String,
|
||||
val content: String,
|
||||
val status: MsgStatus,
|
||||
val mentionedUserIds: String?,
|
||||
val createdAt: String,
|
||||
val status: String,
|
||||
val mentionedUserIds: String? = null,
|
||||
val createdAt: Long,
|
||||
)
|
||||
|
||||
data class ConversationData(
|
||||
val targetId: String,
|
||||
val chatType: String,
|
||||
val lastMsgContent: String? = null,
|
||||
val lastMsgType: String? = null,
|
||||
val lastMsgTime: Long = 0,
|
||||
val unreadCount: Int = 0,
|
||||
val isMuted: Boolean = false,
|
||||
val isPinned: Boolean = false,
|
||||
)
|
||||
|
||||
data class ImGroup(
|
||||
val id: String,
|
||||
val name: String,
|
||||
val creatorId: String,
|
||||
val memberIds: String,
|
||||
val adminIds: String,
|
||||
val announcement: String? = null,
|
||||
val createdAt: Long,
|
||||
)
|
||||
|
||||
data class UserProfile(
|
||||
val userId: String,
|
||||
val nickname: String,
|
||||
val avatar: String? = null,
|
||||
)
|
||||
|
||||
enum class ChatType { SINGLE, GROUP }
|
||||
@ -20,4 +47,4 @@ enum class MsgType {
|
||||
RICH_TEXT, CALL_AUDIO, CALL_VIDEO, REVOKED, FORWARD
|
||||
}
|
||||
|
||||
enum class MsgStatus { SENT, DELIVERED, READ, REVOKED }
|
||||
enum class MsgStatus { SENDING, SENT, DELIVERED, READ, FAILED, REVOKED }
|
||||
|
||||
@ -4,6 +4,7 @@ import android.content.Context
|
||||
import com.xuqm.sdk.XuqmSDK
|
||||
import com.xuqm.sdk.network.ApiClient
|
||||
import com.xuqm.sdk.push.api.PushApi
|
||||
import com.xuqm.sdk.push.api.RegisterDeviceRequest
|
||||
import com.xuqm.sdk.utils.DeviceUtils
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
@ -14,22 +15,30 @@ object PushSDK {
|
||||
private val api: PushApi by lazy { ApiClient.create() }
|
||||
private val scope = CoroutineScope(Dispatchers.IO)
|
||||
|
||||
fun registerToken(context: Context, appId: String, userId: String, token: String) {
|
||||
fun registerDevice(context: Context, userId: String) {
|
||||
XuqmSDK.requireInit()
|
||||
val vendor = DeviceUtils.getVendor()
|
||||
val deviceId = DeviceUtils.getDeviceId(context)
|
||||
scope.launch {
|
||||
runCatching {
|
||||
api.registerToken(appId, userId, vendor, token)
|
||||
api.registerDevice(
|
||||
RegisterDeviceRequest(
|
||||
userId = userId,
|
||||
appId = XuqmSDK.appId,
|
||||
platform = "Android",
|
||||
vendor = vendor,
|
||||
pushToken = "",
|
||||
deviceId = deviceId,
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun unregisterToken(appId: String, userId: String) {
|
||||
fun unregisterDevice(userId: String) {
|
||||
XuqmSDK.requireInit()
|
||||
scope.launch {
|
||||
runCatching {
|
||||
api.unregisterToken(appId, userId)
|
||||
}
|
||||
runCatching { api.unregisterDevice(XuqmSDK.appId, userId) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,20 +1,26 @@
|
||||
package com.xuqm.sdk.push.api
|
||||
|
||||
import retrofit2.http.Body
|
||||
import retrofit2.http.DELETE
|
||||
import retrofit2.http.POST
|
||||
import retrofit2.http.Query
|
||||
|
||||
interface PushApi {
|
||||
@POST("api/push/register")
|
||||
suspend fun registerToken(
|
||||
@Query("appId") appId: String,
|
||||
@Query("userId") userId: String,
|
||||
@Query("vendor") vendor: String,
|
||||
@Query("token") token: String,
|
||||
data class RegisterDeviceRequest(
|
||||
val userId: String,
|
||||
val appId: String,
|
||||
val platform: String,
|
||||
val vendor: String,
|
||||
val pushToken: String,
|
||||
val deviceId: String,
|
||||
)
|
||||
|
||||
@DELETE("api/push/unregister")
|
||||
suspend fun unregisterToken(
|
||||
interface PushApi {
|
||||
|
||||
@POST("api/push/device/register")
|
||||
suspend fun registerDevice(@Body request: RegisterDeviceRequest)
|
||||
|
||||
@DELETE("api/push/device/unregister")
|
||||
suspend fun unregisterDevice(
|
||||
@Query("appId") appId: String,
|
||||
@Query("userId") userId: String,
|
||||
)
|
||||
|
||||
@ -3,7 +3,6 @@ package com.xuqm.sdk.update
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import androidx.core.content.FileProvider
|
||||
import com.xuqm.sdk.XuqmSDK
|
||||
import com.xuqm.sdk.network.ApiClient
|
||||
@ -19,11 +18,13 @@ object UpdateSDK {
|
||||
|
||||
private val api: UpdateApi by lazy { ApiClient.create() }
|
||||
|
||||
suspend fun checkUpdate(context: Context, appId: String): UpdateInfo? = withContext(Dispatchers.IO) {
|
||||
suspend fun checkAppUpdate(context: Context): UpdateInfo? = withContext(Dispatchers.IO) {
|
||||
XuqmSDK.requireInit()
|
||||
val versionCode = context.packageManager
|
||||
.getPackageInfo(context.packageName, 0).longVersionCode.toInt()
|
||||
runCatching { api.checkUpdate(appId, "ANDROID", versionCode).data }.getOrNull()
|
||||
runCatching {
|
||||
api.checkUpdate(XuqmSDK.appId, "ANDROID", versionCode).data
|
||||
}.getOrNull()
|
||||
}
|
||||
|
||||
suspend fun downloadAndInstall(
|
||||
@ -49,10 +50,7 @@ object UpdateSDK {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
withContext(Dispatchers.Main) {
|
||||
installApk(context, apkFile)
|
||||
}
|
||||
withContext(Dispatchers.Main) { installApk(context, apkFile) }
|
||||
}
|
||||
|
||||
private fun installApk(context: Context, apkFile: File) {
|
||||
@ -64,8 +62,11 @@ object UpdateSDK {
|
||||
context.startActivity(intent)
|
||||
}
|
||||
|
||||
suspend fun checkRnUpdate(appId: String, moduleId: String, currentVersion: String): RnUpdateInfo? =
|
||||
suspend fun checkRnUpdate(moduleId: String, currentVersion: String): RnUpdateInfo? =
|
||||
withContext(Dispatchers.IO) {
|
||||
runCatching { api.checkRnUpdate(appId, moduleId, "ANDROID", currentVersion).data }.getOrNull()
|
||||
XuqmSDK.requireInit()
|
||||
runCatching {
|
||||
api.checkRnUpdate(XuqmSDK.appId, moduleId, "ANDROID", currentVersion).data
|
||||
}.getOrNull()
|
||||
}
|
||||
}
|
||||
|
||||
正在加载...
在新工单中引用
屏蔽一个用户