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