feat(sample): 添加示例应用的核心功能模块
- 集成依赖管理配置文件 libs.versions.toml,统一管理项目依赖版本 - 实现演示 API 接口定义,包含登录、注册、用户管理等 RESTful 端点 - 创建认证仓库 AuthRepository,处理用户会话管理和加密存储 - 开发登录和注册界面,实现用户身份验证流程 - 构建聊天界面 ChatScreen,支持消息收发和历史记录显示 - 实现联系人管理功能,包含好友搜索和添加删除操作 - 添加会话列表界面,展示最近聊天记录和未读消息提示
这个提交包含在:
父节点
6dd0fa8f49
当前提交
00f2ad04b7
12145
.gitignore
vendored
12145
.gitignore
vendored
文件差异内容过多而无法显示
加载差异
36
README.md
36
README.md
@ -6,7 +6,7 @@
|
||||
|
||||
```
|
||||
XuqmGroup-AndroidSDK/
|
||||
├── sdk-core/ # 核心:初始化、HTTP、Token 存储
|
||||
├── sdk-core/ # 核心:初始化、HTTP、Token 存储、通用工具/组件
|
||||
├── sdk-im/ # IM:WebSocket 实时通信
|
||||
├── sdk-push/ # 推送:设备 Token 注册
|
||||
├── sdk-update/ # 版本管理:检查更新、下载安装、RN 热更新
|
||||
@ -53,21 +53,21 @@ dependencies {
|
||||
### 1. 初始化(Application.onCreate)
|
||||
|
||||
```kotlin
|
||||
XuqmSDK.init(
|
||||
XuqmSDK.initialize(
|
||||
context = this,
|
||||
appKey = "ak_your_app_key",
|
||||
appSecret = "as_your_app_secret",
|
||||
apiBaseUrl = "https://api.xuqm.com",
|
||||
imBaseUrl = "wss://im.xuqm.com",
|
||||
appId = "ak_your_app_id",
|
||||
debug = BuildConfig.DEBUG
|
||||
)
|
||||
```
|
||||
|
||||
### 2. 用户登录后设置 Token
|
||||
### 2. 用户登录后初始化 IM
|
||||
|
||||
```kotlin
|
||||
// 调用你的业务登录接口获得 token 后
|
||||
XuqmSDK.tokenStore.save(token)
|
||||
// 调用业务登录接口,拿到 userSig 后再登录 IM
|
||||
val userSig = api.getUserSig(userId)
|
||||
ImSDK.login(userId = userId, userSig = userSig)
|
||||
|
||||
// Push 设备注册在 PushSDK.initialize() 内部自动完成
|
||||
```
|
||||
|
||||
---
|
||||
@ -78,10 +78,7 @@ XuqmSDK.tokenStore.save(token)
|
||||
|
||||
| 参数 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `appKey` | String | 应用 Key(租户平台获取) |
|
||||
| `appSecret` | String | 应用 Secret |
|
||||
| `apiBaseUrl` | String | 后端 API 地址 |
|
||||
| `imBaseUrl` | String | IM WebSocket 地址(wss://) |
|
||||
| `appId` | String | 应用标识(租户平台获取) |
|
||||
| `debug` | Boolean | 开启日志 |
|
||||
|
||||
### TokenStore
|
||||
@ -172,18 +169,9 @@ data class ImMessage(
|
||||
|
||||
## sdk-push
|
||||
|
||||
### 注册推送 Token
|
||||
### 推送接入
|
||||
|
||||
在获取到厂商推送 token 后调用:
|
||||
|
||||
```kotlin
|
||||
PushSDK.registerToken(
|
||||
appId = "ak_xxx",
|
||||
userId = "user_001",
|
||||
vendor = Vendor.HUAWEI, // HUAWEI / XIAOMI / OPPO / VIVO / HONOR / APNS
|
||||
token = "device_push_token"
|
||||
)
|
||||
```
|
||||
在 `PushSDK.initialize()` 后由 SDK 自动完成厂商初始化、设备注册与 token 上传,不再要求业务侧单独调用注册接口。
|
||||
|
||||
### 与 IM 联动
|
||||
|
||||
|
||||
@ -3,8 +3,6 @@ plugins {
|
||||
alias(libs.plugins.android.library) apply false
|
||||
alias(libs.plugins.kotlin.compose) apply false
|
||||
alias(libs.plugins.kotlin.serialization) apply false
|
||||
alias(libs.plugins.kotlin.kapt) apply false
|
||||
alias(libs.plugins.hilt.android) apply false
|
||||
}
|
||||
|
||||
group = "com.xuqm"
|
||||
|
||||
@ -21,10 +21,12 @@ junit4 = "4.13.2"
|
||||
androidxJunit = "1.3.0"
|
||||
espresso = "3.7.0"
|
||||
hilt = "2.56.2"
|
||||
ksp = "2.3.7"
|
||||
navigationCompose = "2.9.0"
|
||||
securityCrypto = "1.0.0"
|
||||
hiltNavigationCompose = "1.2.0"
|
||||
viewmodelCompose = "2.10.0"
|
||||
material = "1.13.0"
|
||||
|
||||
[libraries]
|
||||
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
|
||||
@ -41,6 +43,7 @@ androidx-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-toolin
|
||||
androidx-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" }
|
||||
androidx-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" }
|
||||
androidx-material3 = { group = "androidx.compose.material3", name = "material3" }
|
||||
google-material = { group = "com.google.android.material", name = "material", version.ref = "material" }
|
||||
androidx-material-icons-extended = { group = "androidx.compose.material", name = "material-icons-extended" }
|
||||
androidx-datastore-preferences = { group = "androidx.datastore", name = "datastore-preferences", version.ref = "datastore" }
|
||||
androidx-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigationCompose" }
|
||||
@ -87,5 +90,5 @@ android-application = { id = "com.android.application", version.ref = "agp" }
|
||||
android-library = { id = "com.android.library", version.ref = "agp" }
|
||||
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
|
||||
kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
|
||||
kotlin-kapt = { id = "org.jetbrains.kotlin.kapt", version.ref = "kotlin" }
|
||||
ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }
|
||||
hilt-android = { id = "com.google.dagger.hilt.android", version.ref = "hilt" }
|
||||
|
||||
@ -1,8 +1,6 @@
|
||||
plugins {
|
||||
alias(libs.plugins.android.application)
|
||||
alias(libs.plugins.kotlin.compose)
|
||||
alias(libs.plugins.kotlin.kapt)
|
||||
alias(libs.plugins.hilt.android)
|
||||
}
|
||||
|
||||
android {
|
||||
@ -25,12 +23,10 @@ android {
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_21
|
||||
targetCompatibility = JavaVersion.VERSION_21
|
||||
sourceCompatibility = JavaVersion.VERSION_11
|
||||
targetCompatibility = JavaVersion.VERSION_11
|
||||
}
|
||||
|
||||
kotlinOptions { jvmTarget = "21" }
|
||||
|
||||
buildFeatures {
|
||||
compose = true
|
||||
buildConfig = true
|
||||
@ -48,13 +44,10 @@ dependencies {
|
||||
implementation(libs.androidx.activity.compose)
|
||||
implementation(libs.androidx.lifecycle.runtime.compose)
|
||||
implementation(libs.androidx.lifecycle.viewmodel.compose)
|
||||
implementation(libs.google.material)
|
||||
|
||||
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)
|
||||
|
||||
|
||||
@ -4,24 +4,18 @@ import android.os.Bundle
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import com.xuqm.sdk.sample.data.repo.AuthRepository
|
||||
import com.xuqm.sdk.sample.di.AppDependencies
|
||||
import com.xuqm.sdk.sample.navigation.AppNavGraph
|
||||
import com.xuqm.sdk.sample.ui.theme.XuqmTheme
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import javax.inject.Inject
|
||||
|
||||
@AndroidEntryPoint
|
||||
class MainActivity : ComponentActivity() {
|
||||
|
||||
@Inject
|
||||
lateinit var authRepository: AuthRepository
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
enableEdgeToEdge()
|
||||
setContent {
|
||||
XuqmTheme {
|
||||
AppNavGraph(authRepository = authRepository)
|
||||
AppNavGraph(authRepository = AppDependencies.authRepository)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -3,16 +3,16 @@ package com.xuqm.sdk.sample
|
||||
import android.app.Application
|
||||
import com.xuqm.sdk.XuqmSDK
|
||||
import com.xuqm.sdk.core.LogLevel
|
||||
import dagger.hilt.android.HiltAndroidApp
|
||||
import com.xuqm.sdk.sample.di.AppDependencies
|
||||
|
||||
@HiltAndroidApp
|
||||
class XuqmSampleApp : Application() {
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
AppDependencies.init(this)
|
||||
XuqmSDK.initialize(
|
||||
context = this,
|
||||
appId = "your_app_id",
|
||||
appId = "ak_demo_chat",
|
||||
logLevel = if (BuildConfig.DEBUG) LogLevel.DEBUG else LogLevel.WARN,
|
||||
)
|
||||
}
|
||||
|
||||
@ -1,46 +1,105 @@
|
||||
package com.xuqm.sdk.sample.data.api
|
||||
|
||||
import okhttp3.Interceptor
|
||||
import okhttp3.OkHttpClient
|
||||
import retrofit2.Retrofit
|
||||
import retrofit2.converter.gson.GsonConverterFactory
|
||||
import retrofit2.http.Body
|
||||
import retrofit2.http.GET
|
||||
import retrofit2.http.POST
|
||||
import retrofit2.http.PUT
|
||||
import retrofit2.http.Query
|
||||
|
||||
const val DEMO_BASE_URL = "https://dev.xuqinmin.com/"
|
||||
const val DEMO_APP_ID = "ak_demo_chat"
|
||||
|
||||
data class DemoResponse<T>(
|
||||
val code: Int = 0,
|
||||
val message: String? = null,
|
||||
val status: String? = null,
|
||||
val data: T? = null,
|
||||
val message: String? = null,
|
||||
)
|
||||
|
||||
data class LoginRequest(val userId: String, val password: String)
|
||||
data class LoginRequest(val appId: String, val userId: String, val password: String)
|
||||
|
||||
data class RegisterRequest(val userId: String, val password: String, val nickname: String)
|
||||
data class RegisterRequest(val appId: String, 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 ResetPasswordRequest(val appId: String, val userId: String, val newPassword: String)
|
||||
|
||||
data class UserData(val userId: String, val nickname: String, val avatar: String?)
|
||||
data class AuthProfile(
|
||||
val appId: String,
|
||||
val userId: String,
|
||||
val nickname: String,
|
||||
val avatar: String? = null,
|
||||
val gender: String? = null,
|
||||
)
|
||||
|
||||
data class UpdateProfileRequest(val nickname: String, val avatar: String?)
|
||||
data class AuthResult(
|
||||
val demoToken: String,
|
||||
val imToken: String? = null,
|
||||
val profile: AuthProfile,
|
||||
)
|
||||
|
||||
data class ResetPasswordRequest(val oldPassword: String, val newPassword: String)
|
||||
data class UserData(
|
||||
val userId: String,
|
||||
val nickname: String,
|
||||
val avatar: String? = null,
|
||||
val gender: String? = null,
|
||||
)
|
||||
|
||||
data class UpdateProfileRequest(
|
||||
val nickname: String,
|
||||
val avatar: String? = null,
|
||||
val gender: String? = null,
|
||||
)
|
||||
|
||||
data class ChangePasswordRequest(val oldPassword: String, val newPassword: String)
|
||||
|
||||
interface DemoApi {
|
||||
|
||||
@POST("api/user/login")
|
||||
suspend fun login(@Body request: LoginRequest): DemoResponse<LoginData>
|
||||
@POST("api/demo/auth/login")
|
||||
suspend fun login(@Body request: LoginRequest): DemoResponse<AuthResult>
|
||||
|
||||
@POST("api/user/register")
|
||||
suspend fun register(@Body request: RegisterRequest): DemoResponse<LoginData>
|
||||
@POST("api/demo/auth/register")
|
||||
suspend fun register(@Body request: RegisterRequest): DemoResponse<AuthResult>
|
||||
|
||||
@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")
|
||||
@POST("api/demo/auth/reset-password")
|
||||
suspend fun resetPassword(@Body request: ResetPasswordRequest): DemoResponse<Unit>
|
||||
|
||||
@GET("api/user/users")
|
||||
suspend fun searchUsers(@Query("keyword") keyword: String): DemoResponse<List<UserData>>
|
||||
@GET("api/demo/user/profile")
|
||||
suspend fun getProfile(@Query("appId") appId: String = DEMO_APP_ID): DemoResponse<UserData>
|
||||
|
||||
@PUT("api/demo/user/profile")
|
||||
suspend fun updateProfile(@Body request: UpdateProfileRequest): DemoResponse<UserData>
|
||||
|
||||
@POST("api/demo/user/change-password")
|
||||
suspend fun changePassword(@Body request: ChangePasswordRequest): DemoResponse<Unit>
|
||||
|
||||
@GET("api/demo/users/search")
|
||||
suspend fun searchUsers(
|
||||
@Query("appId") appId: String = DEMO_APP_ID,
|
||||
@Query("keyword") keyword: String,
|
||||
): DemoResponse<List<UserData>>
|
||||
}
|
||||
|
||||
object DemoApiFactory {
|
||||
fun create(tokenProvider: () -> String?): DemoApi {
|
||||
val authInterceptor = Interceptor { chain ->
|
||||
val request = chain.request().newBuilder().apply {
|
||||
tokenProvider()?.takeIf { it.isNotBlank() }?.let { header("Authorization", "Bearer $it") }
|
||||
}.build()
|
||||
chain.proceed(request)
|
||||
}
|
||||
|
||||
val okHttpClient = OkHttpClient.Builder()
|
||||
.addInterceptor(authInterceptor)
|
||||
.build()
|
||||
|
||||
return Retrofit.Builder()
|
||||
.baseUrl(DEMO_BASE_URL)
|
||||
.client(okHttpClient)
|
||||
.addConverterFactory(GsonConverterFactory.create())
|
||||
.build()
|
||||
.create(DemoApi::class.java)
|
||||
}
|
||||
}
|
||||
|
||||
@ -2,75 +2,76 @@ package com.xuqm.sdk.sample.data.repo
|
||||
|
||||
import android.content.Context
|
||||
import androidx.security.crypto.EncryptedSharedPreferences
|
||||
import androidx.security.crypto.MasterKey
|
||||
import androidx.security.crypto.MasterKeys
|
||||
import com.xuqm.sdk.im.ImSDK
|
||||
import com.xuqm.sdk.sample.data.api.DemoApi
|
||||
import com.xuqm.sdk.sample.data.api.DEMO_APP_ID
|
||||
import com.xuqm.sdk.sample.data.api.DemoApiFactory
|
||||
import com.xuqm.sdk.sample.data.api.AuthResult
|
||||
import com.xuqm.sdk.sample.data.api.ChangePasswordRequest
|
||||
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,
|
||||
) {
|
||||
class AuthRepository(context: Context) {
|
||||
|
||||
private val prefs = EncryptedSharedPreferences.create(
|
||||
context,
|
||||
"xuqm_demo_auth",
|
||||
MasterKey.Builder(context).setKeyScheme(MasterKey.KeyScheme.AES256_GCM).build(),
|
||||
MasterKeys.getOrCreate(MasterKeys.AES256_GCM_SPEC),
|
||||
context,
|
||||
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
|
||||
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
|
||||
)
|
||||
|
||||
private val api: DemoApi = DemoApiFactory.create(::getDemoToken)
|
||||
|
||||
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?) {
|
||||
private fun saveSession(result: AuthResult) {
|
||||
val profile = result.profile
|
||||
prefs.edit()
|
||||
.putString("demo_token", token)
|
||||
.putString("user_id", userId)
|
||||
.putString("nickname", nickname)
|
||||
.putString("avatar", avatar)
|
||||
.putString("demo_token", result.demoToken)
|
||||
.putString("user_id", profile.userId)
|
||||
.putString("nickname", profile.nickname)
|
||||
.putString("avatar", profile.avatar)
|
||||
.apply()
|
||||
}
|
||||
|
||||
suspend fun login(userId: String, password: String): Result<UserData> =
|
||||
withContext(Dispatchers.IO) {
|
||||
runCatching {
|
||||
val res = api.login(LoginRequest(userId, password))
|
||||
val res = api.login(LoginRequest(DEMO_APP_ID, 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)
|
||||
saveSession(data)
|
||||
data.imToken?.takeIf { it.isNotBlank() }?.let { ImSDK.loginWithToken(data.profile.userId, it) }
|
||||
?: ImSDK.login(data.profile.userId, data.profile.nickname, data.profile.avatar)
|
||||
UserData(data.profile.userId, data.profile.nickname, data.profile.avatar, data.profile.gender)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun register(userId: String, password: String, nickname: String): Result<UserData> =
|
||||
withContext(Dispatchers.IO) {
|
||||
runCatching {
|
||||
val res = api.register(RegisterRequest(userId, password, nickname))
|
||||
val res = api.register(RegisterRequest(DEMO_APP_ID, 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)
|
||||
saveSession(data)
|
||||
data.imToken?.takeIf { it.isNotBlank() }?.let { ImSDK.loginWithToken(data.profile.userId, it) }
|
||||
?: ImSDK.login(data.profile.userId, data.profile.nickname, data.profile.avatar)
|
||||
UserData(data.profile.userId, data.profile.nickname, data.profile.avatar, data.profile.gender)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun getProfile(): Result<UserData> =
|
||||
withContext(Dispatchers.IO) {
|
||||
runCatching {
|
||||
requireNotNull(api.getProfile().data) { "Failed to get profile" }
|
||||
}
|
||||
runCatching { requireNotNull(api.getProfile().data) { "Failed to get profile" } }
|
||||
}
|
||||
|
||||
suspend fun updateProfile(nickname: String, avatar: String?): Result<UserData> =
|
||||
@ -84,12 +85,12 @@ class AuthRepository @Inject constructor(
|
||||
|
||||
suspend fun resetPassword(oldPassword: String, newPassword: String): Result<Unit> =
|
||||
withContext(Dispatchers.IO) {
|
||||
runCatching { api.resetPassword(ResetPasswordRequest(oldPassword, newPassword)) }
|
||||
runCatching { api.changePassword(ChangePasswordRequest(oldPassword, newPassword)) }.map { }
|
||||
}
|
||||
|
||||
suspend fun searchUsers(keyword: String): Result<List<UserData>> =
|
||||
withContext(Dispatchers.IO) {
|
||||
runCatching { api.searchUsers(keyword).data ?: emptyList() }
|
||||
runCatching { api.searchUsers(keyword = keyword).data ?: emptyList() }
|
||||
}
|
||||
|
||||
fun logout() {
|
||||
|
||||
@ -0,0 +1,15 @@
|
||||
package com.xuqm.sdk.sample.di
|
||||
|
||||
import android.content.Context
|
||||
import com.xuqm.sdk.sample.data.api.DemoApi
|
||||
import com.xuqm.sdk.sample.data.repo.AuthRepository
|
||||
|
||||
object AppDependencies {
|
||||
|
||||
lateinit var authRepository: AuthRepository
|
||||
private set
|
||||
|
||||
fun init(context: Context) {
|
||||
authRepository = AuthRepository(context)
|
||||
}
|
||||
}
|
||||
@ -1,18 +0,0 @@
|
||||
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()
|
||||
}
|
||||
@ -26,14 +26,21 @@ 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
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import androidx.lifecycle.viewmodel.initializer
|
||||
import androidx.lifecycle.viewmodel.viewModelFactory
|
||||
import com.xuqm.sdk.sample.di.AppDependencies
|
||||
|
||||
@Composable
|
||||
fun LoginScreen(
|
||||
onLoginSuccess: () -> Unit,
|
||||
onNavigateToRegister: () -> Unit,
|
||||
viewModel: LoginViewModel = hiltViewModel(),
|
||||
viewModel: LoginViewModel = viewModel(
|
||||
factory = viewModelFactory {
|
||||
initializer { LoginViewModel(AppDependencies.authRepository) }
|
||||
}
|
||||
),
|
||||
) {
|
||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||
|
||||
|
||||
@ -3,11 +3,9 @@ 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
|
||||
@ -16,10 +14,7 @@ sealed interface LoginState {
|
||||
data class Error(val message: String) : LoginState
|
||||
}
|
||||
|
||||
@HiltViewModel
|
||||
class LoginViewModel @Inject constructor(
|
||||
private val authRepository: AuthRepository,
|
||||
) : ViewModel() {
|
||||
class LoginViewModel(private val authRepository: AuthRepository) : ViewModel() {
|
||||
|
||||
private val _state = MutableStateFlow<LoginState>(LoginState.Idle)
|
||||
val state: StateFlow<LoginState> = _state
|
||||
|
||||
@ -29,21 +29,19 @@ 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.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import androidx.lifecycle.viewmodel.initializer
|
||||
import androidx.lifecycle.viewmodel.viewModelFactory
|
||||
import com.xuqm.sdk.sample.data.repo.AuthRepository
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import com.xuqm.sdk.sample.di.AppDependencies
|
||||
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() {
|
||||
class RegisterViewModel(private val authRepository: AuthRepository) : ViewModel() {
|
||||
|
||||
private val _state = MutableStateFlow<LoginState>(LoginState.Idle)
|
||||
val state: StateFlow<LoginState> = _state
|
||||
@ -63,7 +61,11 @@ class RegisterViewModel @Inject constructor(
|
||||
fun RegisterScreen(
|
||||
onRegisterSuccess: () -> Unit,
|
||||
onNavigateBack: () -> Unit,
|
||||
viewModel: RegisterViewModel = hiltViewModel(),
|
||||
viewModel: RegisterViewModel = viewModel(
|
||||
factory = viewModelFactory {
|
||||
initializer { RegisterViewModel(AppDependencies.authRepository) }
|
||||
}
|
||||
),
|
||||
) {
|
||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||
|
||||
|
||||
@ -14,7 +14,6 @@ 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
|
||||
@ -39,9 +38,10 @@ 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 androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.xuqm.sdk.im.model.ImMessage
|
||||
import com.xuqm.sdk.ui.InitialAvatar
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
@ -51,7 +51,7 @@ fun ChatScreen(
|
||||
targetName: String,
|
||||
onNavigateBack: () -> Unit,
|
||||
onGroupSettings: (() -> Unit)? = null,
|
||||
viewModel: ChatViewModel = hiltViewModel(),
|
||||
viewModel: ChatViewModel = viewModel(),
|
||||
) {
|
||||
val messages by viewModel.messages.collectAsStateWithLifecycle()
|
||||
val listState = rememberLazyListState()
|
||||
@ -166,14 +166,7 @@ private fun MessageBubble(message: ImMessage, isOwn: Boolean) {
|
||||
|
||||
@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)
|
||||
}
|
||||
}
|
||||
InitialAvatar(text = userId, modifier = Modifier.size(32.dp))
|
||||
}
|
||||
|
||||
private fun parseContent(message: ImMessage): String {
|
||||
|
||||
@ -5,14 +5,11 @@ 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() {
|
||||
class ChatViewModel : ViewModel() {
|
||||
|
||||
private val _messages = MutableStateFlow<List<ImMessage>>(emptyList())
|
||||
val messages: StateFlow<List<ImMessage>> = _messages
|
||||
|
||||
@ -8,45 +8,39 @@ 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.compose.viewModel
|
||||
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 com.xuqm.sdk.sample.di.AppDependencies
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
import androidx.lifecycle.viewmodel.initializer
|
||||
import androidx.lifecycle.viewmodel.viewModelFactory
|
||||
import com.xuqm.sdk.ui.SearchBarField
|
||||
|
||||
@HiltViewModel
|
||||
class ContactViewModel @Inject constructor(
|
||||
class ContactViewModel(
|
||||
private val authRepository: AuthRepository,
|
||||
) : ViewModel() {
|
||||
|
||||
private val _friends = MutableStateFlow<List<UserProfile>>(emptyList())
|
||||
val friends: StateFlow<List<UserProfile>> = _friends
|
||||
private val _friends = MutableStateFlow<List<UserData>>(emptyList())
|
||||
val friends: StateFlow<List<UserData>> = _friends
|
||||
|
||||
private val _searchResults = MutableStateFlow<List<UserData>>(emptyList())
|
||||
val searchResults: StateFlow<List<UserData>> = _searchResults
|
||||
@ -55,8 +49,12 @@ class ContactViewModel @Inject constructor(
|
||||
|
||||
fun loadFriends() {
|
||||
viewModelScope.launch {
|
||||
runCatching { ImSDK.listFriends() }
|
||||
.onSuccess { _friends.value = it }
|
||||
runCatching {
|
||||
ImSDK.listFriends().mapNotNull { friendId ->
|
||||
authRepository.searchUsers(friendId).getOrDefault(emptyList())
|
||||
.firstOrNull { it.userId == friendId }
|
||||
}
|
||||
}.onSuccess { _friends.value = it }
|
||||
}
|
||||
}
|
||||
|
||||
@ -86,20 +84,24 @@ class ContactViewModel @Inject constructor(
|
||||
@Composable
|
||||
fun ContactScreen(
|
||||
onOpenChat: (userId: String) -> Unit,
|
||||
viewModel: ContactViewModel = hiltViewModel(),
|
||||
viewModel: ContactViewModel = viewModel(
|
||||
factory = viewModelFactory {
|
||||
initializer { ContactViewModel(AppDependencies.authRepository) }
|
||||
}
|
||||
),
|
||||
) {
|
||||
val friends by viewModel.friends.collectAsStateWithLifecycle()
|
||||
val searchResults by viewModel.searchResults.collectAsStateWithLifecycle()
|
||||
var keyword by remember { mutableStateOf("") }
|
||||
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
OutlinedTextField(
|
||||
SearchBarField(
|
||||
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,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(12.dp),
|
||||
placeholder = "搜索用户 ID 或昵称",
|
||||
)
|
||||
|
||||
if (keyword.isBlank()) {
|
||||
|
||||
@ -13,13 +13,11 @@ 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
|
||||
@ -32,19 +30,18 @@ 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 androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import coil3.compose.AsyncImage
|
||||
import com.xuqm.sdk.im.model.ConversationData
|
||||
import com.xuqm.sdk.ui.InitialAvatar
|
||||
import com.xuqm.sdk.utils.TimeFormatters
|
||||
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(),
|
||||
viewModel: ConversationViewModel = viewModel(),
|
||||
) {
|
||||
val conversations by viewModel.conversations.collectAsStateWithLifecycle()
|
||||
val scope = rememberCoroutineScope()
|
||||
@ -84,17 +81,7 @@ private fun ConversationItem(
|
||||
.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,
|
||||
)
|
||||
}
|
||||
}
|
||||
InitialAvatar(text = conversation.targetId, modifier = Modifier.size(48.dp))
|
||||
|
||||
Spacer(Modifier.width(12.dp))
|
||||
|
||||
@ -110,7 +97,7 @@ private fun ConversationItem(
|
||||
color = MaterialTheme.colorScheme.primary)
|
||||
}
|
||||
Text(
|
||||
formatTime(conversation.lastMsgTime),
|
||||
TimeFormatters.formatConversationTime(conversation.lastMsgTime),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.outline,
|
||||
)
|
||||
@ -143,8 +130,3 @@ private fun ConversationItem(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun formatTime(timestamp: Long): String {
|
||||
if (timestamp == 0L) return ""
|
||||
return SimpleDateFormat("HH:mm", Locale.getDefault()).format(Date(timestamp))
|
||||
}
|
||||
|
||||
@ -3,17 +3,14 @@ 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.listener.ImEventListener
|
||||
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() {
|
||||
class ConversationViewModel : ViewModel() {
|
||||
|
||||
private val _conversations = MutableStateFlow<List<ConversationData>>(emptyList())
|
||||
val conversations: StateFlow<List<ConversationData>> = _conversations
|
||||
|
||||
@ -37,7 +37,7 @@ 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.compose.viewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.xuqm.sdk.im.ImSDK
|
||||
import com.xuqm.sdk.im.model.ImGroup
|
||||
@ -47,7 +47,7 @@ import com.xuqm.sdk.im.model.ImGroup
|
||||
fun GroupListScreen(
|
||||
onOpenGroupChat: (groupId: String, groupName: String) -> Unit,
|
||||
onGroupSettings: (groupId: String) -> Unit,
|
||||
viewModel: GroupViewModel = hiltViewModel(),
|
||||
viewModel: GroupViewModel = viewModel(),
|
||||
) {
|
||||
val groups by viewModel.groups.collectAsStateWithLifecycle()
|
||||
var showCreateDialog by remember { mutableStateOf(false) }
|
||||
@ -150,7 +150,7 @@ private fun CreateGroupDialog(onDismiss: () -> Unit, onCreate: (String, List<Str
|
||||
fun GroupSettingsScreen(
|
||||
groupId: String,
|
||||
onNavigateBack: () -> Unit,
|
||||
viewModel: GroupViewModel = hiltViewModel(),
|
||||
viewModel: GroupViewModel = viewModel(),
|
||||
) {
|
||||
val group by viewModel.currentGroup.collectAsStateWithLifecycle()
|
||||
|
||||
|
||||
@ -4,14 +4,11 @@ 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() {
|
||||
class GroupViewModel : ViewModel() {
|
||||
|
||||
private val _groups = MutableStateFlow<List<ImGroup>>(emptyList())
|
||||
val groups: StateFlow<List<ImGroup>> = _groups
|
||||
|
||||
@ -27,16 +27,17 @@ 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.compose.viewModel
|
||||
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 com.xuqm.sdk.sample.di.AppDependencies
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
import androidx.lifecycle.viewmodel.initializer
|
||||
import androidx.lifecycle.viewmodel.viewModelFactory
|
||||
|
||||
data class ProfileUiState(
|
||||
val userId: String = "",
|
||||
@ -46,8 +47,7 @@ data class ProfileUiState(
|
||||
val error: String? = null,
|
||||
)
|
||||
|
||||
@HiltViewModel
|
||||
class ProfileViewModel @Inject constructor(
|
||||
class ProfileViewModel(
|
||||
private val authRepository: AuthRepository,
|
||||
) : ViewModel() {
|
||||
|
||||
@ -82,7 +82,11 @@ class ProfileViewModel @Inject constructor(
|
||||
@Composable
|
||||
fun ProfileScreen(
|
||||
onLogout: () -> Unit,
|
||||
viewModel: ProfileViewModel = hiltViewModel(),
|
||||
viewModel: ProfileViewModel = viewModel(
|
||||
factory = viewModelFactory {
|
||||
initializer { ProfileViewModel(AppDependencies.authRepository) }
|
||||
}
|
||||
),
|
||||
) {
|
||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||
|
||||
|
||||
@ -19,17 +19,16 @@ 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.compose.viewModel
|
||||
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 com.xuqm.sdk.sample.di.AppDependencies
|
||||
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,
|
||||
@ -38,8 +37,7 @@ data class UpdateUiState(
|
||||
val message: String? = null,
|
||||
)
|
||||
|
||||
@HiltViewModel
|
||||
class UpdateViewModel @Inject constructor() : ViewModel() {
|
||||
class UpdateViewModel : ViewModel() {
|
||||
|
||||
private val _state = MutableStateFlow(UpdateUiState())
|
||||
val state: StateFlow<UpdateUiState> = _state
|
||||
@ -67,7 +65,7 @@ class UpdateViewModel @Inject constructor() : ViewModel() {
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun UpdateScreen(viewModel: UpdateViewModel = hiltViewModel()) {
|
||||
fun UpdateScreen(viewModel: UpdateViewModel = viewModel()) {
|
||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||
val context = LocalContext.current
|
||||
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
plugins {
|
||||
alias(libs.plugins.android.library)
|
||||
alias(libs.plugins.kotlin.compose)
|
||||
alias(libs.plugins.kotlin.serialization)
|
||||
}
|
||||
|
||||
@ -16,12 +17,19 @@ android {
|
||||
sourceCompatibility = JavaVersion.VERSION_11
|
||||
targetCompatibility = JavaVersion.VERSION_11
|
||||
}
|
||||
|
||||
buildFeatures {
|
||||
compose = true
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
api(platform(libs.androidx.compose.bom))
|
||||
api(libs.bundles.network)
|
||||
api(libs.kotlinx.coroutines.android)
|
||||
api(libs.kotlinx.serialization.json)
|
||||
api(libs.androidx.core.ktx)
|
||||
api(libs.androidx.security.crypto)
|
||||
api(libs.androidx.ui)
|
||||
api(libs.androidx.material3)
|
||||
}
|
||||
|
||||
@ -2,7 +2,7 @@ package com.xuqm.sdk.auth
|
||||
|
||||
import android.content.Context
|
||||
import androidx.security.crypto.EncryptedSharedPreferences
|
||||
import androidx.security.crypto.MasterKey
|
||||
import androidx.security.crypto.MasterKeys
|
||||
|
||||
private const val PREFS_NAME = "xuqm_sdk_secure"
|
||||
private const val KEY_IM_TOKEN = "im_token"
|
||||
@ -10,9 +10,9 @@ private const val KEY_IM_TOKEN = "im_token"
|
||||
class TokenStore(context: Context) {
|
||||
|
||||
private val prefs = EncryptedSharedPreferences.create(
|
||||
context,
|
||||
PREFS_NAME,
|
||||
MasterKey.Builder(context).setKeyScheme(MasterKey.KeyScheme.AES256_GCM).build(),
|
||||
MasterKeys.getOrCreate(MasterKeys.AES256_GCM_SPEC),
|
||||
context,
|
||||
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
|
||||
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
|
||||
)
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
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"
|
||||
const val BASE_URL = "https://dev.xuqinmin.com/"
|
||||
const val WS_URL = "wss://dev.xuqinmin.com/ws/im"
|
||||
|
||||
data class SDKConfig(
|
||||
val appId: String,
|
||||
|
||||
@ -0,0 +1,33 @@
|
||||
package com.xuqm.sdk.ui
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
|
||||
@Composable
|
||||
fun InitialAvatar(
|
||||
text: String,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Box(
|
||||
modifier = modifier
|
||||
.clip(CircleShape)
|
||||
.background(MaterialTheme.colorScheme.secondaryContainer),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(
|
||||
text = text.trim().take(1).uppercase().ifBlank { "?" },
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.onSecondaryContainer,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,23 @@
|
||||
package com.xuqm.sdk.ui
|
||||
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
|
||||
@Composable
|
||||
fun SearchBarField(
|
||||
value: String,
|
||||
onValueChange: (String) -> Unit,
|
||||
placeholder: String,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = value,
|
||||
onValueChange = onValueChange,
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
placeholder = { Text(placeholder) },
|
||||
singleLine = true,
|
||||
)
|
||||
}
|
||||
@ -0,0 +1,20 @@
|
||||
package com.xuqm.sdk.ui
|
||||
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
|
||||
@Composable
|
||||
fun StatusHint(
|
||||
text: String,
|
||||
modifier: Modifier = Modifier,
|
||||
isError: Boolean = false,
|
||||
) {
|
||||
Text(
|
||||
text = text,
|
||||
modifier = modifier,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = if (isError) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.outline,
|
||||
)
|
||||
}
|
||||
@ -8,7 +8,7 @@ object DeviceUtils {
|
||||
|
||||
fun getDeviceId(context: Context): String =
|
||||
Settings.Secure.getString(context.contentResolver, Settings.Secure.ANDROID_ID)
|
||||
?: Build.SERIAL
|
||||
?: Build.FINGERPRINT
|
||||
|
||||
fun getDeviceModel(): String = "${Build.MANUFACTURER} ${Build.MODEL}"
|
||||
|
||||
|
||||
@ -0,0 +1,15 @@
|
||||
package com.xuqm.sdk.utils
|
||||
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
|
||||
object TimeFormatters {
|
||||
|
||||
private val timeOnly = SimpleDateFormat("HH:mm", Locale.getDefault())
|
||||
|
||||
fun formatConversationTime(timestamp: Long): String {
|
||||
if (timestamp <= 0L) return ""
|
||||
return timeOnly.format(Date(timestamp))
|
||||
}
|
||||
}
|
||||
@ -13,7 +13,6 @@ import com.xuqm.sdk.im.listener.ImEventListener
|
||||
import com.xuqm.sdk.im.model.ConversationData
|
||||
import com.xuqm.sdk.im.model.ImGroup
|
||||
import com.xuqm.sdk.im.model.ImMessage
|
||||
import com.xuqm.sdk.im.model.UserProfile
|
||||
import com.xuqm.sdk.network.ApiClient
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
@ -31,10 +30,15 @@ object ImSDK {
|
||||
XuqmSDK.requireInit()
|
||||
val res = api.login(LoginRequest(XuqmSDK.appId, userId, nickname, avatar))
|
||||
val token = requireNotNull(res.data?.token) { "IM login failed: ${res.message}" }
|
||||
XuqmSDK.tokenStore.saveToken(token)
|
||||
currentUserId = userId
|
||||
client = ImClient(WS_URL, token, XuqmSDK.appId)
|
||||
client?.connect()
|
||||
connectWithToken(token)
|
||||
}
|
||||
|
||||
suspend fun loginWithToken(userId: String, token: String) =
|
||||
withContext(Dispatchers.IO) {
|
||||
XuqmSDK.requireInit()
|
||||
currentUserId = userId
|
||||
connectWithToken(token)
|
||||
}
|
||||
|
||||
fun sendMessage(toId: String, chatType: String, msgType: String, content: String) {
|
||||
@ -43,19 +47,19 @@ object ImSDK {
|
||||
|
||||
suspend fun fetchHistory(toId: String, page: Int = 0, size: Int = 20): List<ImMessage> =
|
||||
withContext(Dispatchers.IO) {
|
||||
api.fetchHistory(toId, "SINGLE", page, size).data ?: emptyList()
|
||||
api.fetchHistory(toId, XuqmSDK.appId, 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()
|
||||
api.fetchGroupHistory(groupId, XuqmSDK.appId, page, size).data ?: emptyList()
|
||||
}
|
||||
|
||||
suspend fun listGroups(): List<ImGroup> =
|
||||
withContext(Dispatchers.IO) { api.listGroups().data ?: emptyList() }
|
||||
withContext(Dispatchers.IO) { api.listGroups(XuqmSDK.appId).data ?: emptyList() }
|
||||
|
||||
suspend fun createGroup(name: String, memberIds: List<String>): ImGroup? =
|
||||
withContext(Dispatchers.IO) { api.createGroup(CreateGroupRequest(name, memberIds)).data }
|
||||
withContext(Dispatchers.IO) { api.createGroup(XuqmSDK.appId, CreateGroupRequest(name, memberIds)).data }
|
||||
|
||||
suspend fun getGroupInfo(groupId: String): ImGroup? =
|
||||
withContext(Dispatchers.IO) { api.getGroupInfo(groupId).data }
|
||||
@ -70,19 +74,19 @@ object ImSDK {
|
||||
withContext(Dispatchers.IO) { api.removeGroupMember(groupId, userId) }
|
||||
|
||||
suspend fun leaveGroup(groupId: String) =
|
||||
withContext(Dispatchers.IO) { api.leaveGroup(groupId) }
|
||||
withContext(Dispatchers.IO) { api.removeGroupMember(groupId, currentUserId) }
|
||||
|
||||
suspend fun listFriends(): List<UserProfile> =
|
||||
withContext(Dispatchers.IO) { api.listFriends().data ?: emptyList() }
|
||||
suspend fun listFriends(): List<String> =
|
||||
withContext(Dispatchers.IO) { api.listFriends(XuqmSDK.appId).data ?: emptyList() }
|
||||
|
||||
suspend fun addFriend(friendId: String) =
|
||||
withContext(Dispatchers.IO) { api.addFriend(friendId) }
|
||||
withContext(Dispatchers.IO) { api.addFriend(XuqmSDK.appId, friendId) }
|
||||
|
||||
suspend fun removeFriend(friendId: String) =
|
||||
withContext(Dispatchers.IO) { api.removeFriend(friendId) }
|
||||
withContext(Dispatchers.IO) { api.removeFriend(friendId, XuqmSDK.appId) }
|
||||
|
||||
suspend fun listConversations(): List<ConversationData> =
|
||||
withContext(Dispatchers.IO) { api.listConversations().data ?: emptyList() }
|
||||
withContext(Dispatchers.IO) { api.listConversations(XuqmSDK.appId).data ?: emptyList() }
|
||||
|
||||
suspend fun setConversationPinned(targetId: String, chatType: String, pinned: Boolean) =
|
||||
withContext(Dispatchers.IO) {
|
||||
@ -95,10 +99,7 @@ object ImSDK {
|
||||
}
|
||||
|
||||
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 }
|
||||
withContext(Dispatchers.IO) { api.markRead(targetId, XuqmSDK.appId, chatType) }
|
||||
|
||||
fun addListener(listener: ImEventListener) = client?.addListener(listener)
|
||||
fun removeListener(listener: ImEventListener) = client?.removeListener(listener)
|
||||
@ -109,4 +110,11 @@ object ImSDK {
|
||||
currentUserId = ""
|
||||
XuqmSDK.tokenStore.clear()
|
||||
}
|
||||
|
||||
private fun connectWithToken(token: String) {
|
||||
XuqmSDK.tokenStore.saveToken(token)
|
||||
client?.disconnect()
|
||||
client = ImClient(WS_URL, token, XuqmSDK.appId)
|
||||
client?.connect()
|
||||
}
|
||||
}
|
||||
|
||||
@ -3,7 +3,6 @@ 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.UserProfile
|
||||
import retrofit2.http.Body
|
||||
import retrofit2.http.DELETE
|
||||
import retrofit2.http.GET
|
||||
@ -43,26 +42,30 @@ interface ImApi {
|
||||
@POST("api/im/auth/login")
|
||||
suspend fun login(@Body request: LoginRequest): ApiResponse<LoginResponse>
|
||||
|
||||
@GET("api/im/messages")
|
||||
@GET("api/im/messages/history/{toId}")
|
||||
suspend fun fetchHistory(
|
||||
@Query("toId") toId: String,
|
||||
@Query("chatType") chatType: String,
|
||||
@Path("toId") toId: String,
|
||||
@Query("appId") appId: String,
|
||||
@Query("page") page: Int,
|
||||
@Query("size") size: Int,
|
||||
): ApiResponse<List<ImMessage>>
|
||||
|
||||
@GET("api/im/groups/{groupId}/messages")
|
||||
@GET("api/im/messages/group-history/{groupId}")
|
||||
suspend fun fetchGroupHistory(
|
||||
@Path("groupId") groupId: String,
|
||||
@Query("appId") appId: String,
|
||||
@Query("page") page: Int,
|
||||
@Query("size") size: Int,
|
||||
): ApiResponse<List<ImMessage>>
|
||||
|
||||
@GET("api/im/groups")
|
||||
suspend fun listGroups(): ApiResponse<List<ImGroup>>
|
||||
suspend fun listGroups(@Query("appId") appId: String): ApiResponse<List<ImGroup>>
|
||||
|
||||
@POST("api/im/groups")
|
||||
suspend fun createGroup(@Body request: CreateGroupRequest): ApiResponse<ImGroup>
|
||||
suspend fun createGroup(
|
||||
@Query("appId") appId: String,
|
||||
@Body request: CreateGroupRequest,
|
||||
): ApiResponse<ImGroup>
|
||||
|
||||
@GET("api/im/groups/{groupId}")
|
||||
suspend fun getGroupInfo(@Path("groupId") groupId: String): ApiResponse<ImGroup>
|
||||
@ -89,16 +92,22 @@ interface ImApi {
|
||||
suspend fun leaveGroup(@Path("groupId") groupId: String): ApiResponse<Unit>
|
||||
|
||||
@GET("api/im/friends")
|
||||
suspend fun listFriends(): ApiResponse<List<UserProfile>>
|
||||
suspend fun listFriends(@Query("appId") appId: String): ApiResponse<List<String>>
|
||||
|
||||
@POST("api/im/friends/{friendId}")
|
||||
suspend fun addFriend(@Path("friendId") friendId: String): ApiResponse<Unit>
|
||||
@POST("api/im/friends")
|
||||
suspend fun addFriend(
|
||||
@Query("appId") appId: String,
|
||||
@Query("friendId") friendId: String,
|
||||
): ApiResponse<Unit>
|
||||
|
||||
@DELETE("api/im/friends/{friendId}")
|
||||
suspend fun removeFriend(@Path("friendId") friendId: String): ApiResponse<Unit>
|
||||
suspend fun removeFriend(
|
||||
@Path("friendId") friendId: String,
|
||||
@Query("appId") appId: String,
|
||||
): ApiResponse<Unit>
|
||||
|
||||
@GET("api/im/conversations")
|
||||
suspend fun listConversations(): ApiResponse<List<ConversationData>>
|
||||
suspend fun listConversations(@Query("appId") appId: String): ApiResponse<List<ConversationData>>
|
||||
|
||||
@PUT("api/im/conversations/{targetId}/pinned")
|
||||
suspend fun setConversationPinned(
|
||||
@ -117,9 +126,7 @@ interface ImApi {
|
||||
@PUT("api/im/conversations/{targetId}/read")
|
||||
suspend fun markRead(
|
||||
@Path("targetId") targetId: String,
|
||||
@Query("appId") appId: String,
|
||||
@Query("chatType") chatType: String,
|
||||
): ApiResponse<Unit>
|
||||
|
||||
@GET("api/im/users/{userId}")
|
||||
suspend fun getUserProfile(@Path("userId") userId: String): ApiResponse<UserProfile>
|
||||
}
|
||||
|
||||
@ -4,7 +4,6 @@ import android.content.Context
|
||||
import com.xuqm.sdk.XuqmSDK
|
||||
import com.xuqm.sdk.network.ApiClient
|
||||
import com.xuqm.sdk.push.api.PushApi
|
||||
import com.xuqm.sdk.push.api.RegisterDeviceRequest
|
||||
import com.xuqm.sdk.utils.DeviceUtils
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
@ -22,14 +21,10 @@ object PushSDK {
|
||||
scope.launch {
|
||||
runCatching {
|
||||
api.registerDevice(
|
||||
RegisterDeviceRequest(
|
||||
userId = userId,
|
||||
appId = XuqmSDK.appId,
|
||||
platform = "Android",
|
||||
userId = userId,
|
||||
vendor = vendor,
|
||||
pushToken = "",
|
||||
deviceId = deviceId,
|
||||
)
|
||||
token = deviceId,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -16,8 +16,13 @@ data class RegisterDeviceRequest(
|
||||
|
||||
interface PushApi {
|
||||
|
||||
@POST("api/push/device/register")
|
||||
suspend fun registerDevice(@Body request: RegisterDeviceRequest)
|
||||
@POST("api/push/register")
|
||||
suspend fun registerDevice(
|
||||
@Query("appId") appId: String,
|
||||
@Query("userId") userId: String,
|
||||
@Query("vendor") vendor: String,
|
||||
@Query("token") token: String,
|
||||
)
|
||||
|
||||
@DELETE("api/push/device/unregister")
|
||||
suspend fun unregisterDevice(
|
||||
|
||||
@ -18,12 +18,31 @@ object UpdateSDK {
|
||||
|
||||
private val api: UpdateApi by lazy { ApiClient.create() }
|
||||
|
||||
private fun normalizeDownloadUrl(rawUrl: String?): String? {
|
||||
if (rawUrl.isNullOrBlank()) return rawUrl
|
||||
|
||||
if (rawUrl.contains("/api/v1/updates/api/v1/rn/files/")) {
|
||||
return rawUrl.replace("/api/v1/updates/api/v1/rn/files/", "/api/v1/rn/files/")
|
||||
}
|
||||
|
||||
return runCatching {
|
||||
val uri = Uri.parse(rawUrl)
|
||||
if (uri.path?.startsWith("/files/apk/") == true) {
|
||||
"${uri.scheme}://${uri.authority}/api/v1/updates${uri.path}"
|
||||
} else {
|
||||
rawUrl
|
||||
}
|
||||
}.getOrDefault(rawUrl)
|
||||
}
|
||||
|
||||
suspend fun checkAppUpdate(context: Context): UpdateInfo? = withContext(Dispatchers.IO) {
|
||||
XuqmSDK.requireInit()
|
||||
val versionCode = context.packageManager
|
||||
.getPackageInfo(context.packageName, 0).longVersionCode.toInt()
|
||||
runCatching {
|
||||
api.checkUpdate(XuqmSDK.appId, "ANDROID", versionCode).data
|
||||
api.checkUpdate(XuqmSDK.appId, "ANDROID", versionCode).data?.let {
|
||||
it.copy(downloadUrl = normalizeDownloadUrl(it.downloadUrl) ?: it.downloadUrl)
|
||||
}
|
||||
}.getOrNull()
|
||||
}
|
||||
|
||||
@ -66,7 +85,9 @@ object UpdateSDK {
|
||||
withContext(Dispatchers.IO) {
|
||||
XuqmSDK.requireInit()
|
||||
runCatching {
|
||||
api.checkRnUpdate(XuqmSDK.appId, moduleId, "ANDROID", currentVersion).data
|
||||
api.checkRnUpdate(XuqmSDK.appId, moduleId, "ANDROID", currentVersion).data?.let {
|
||||
it.copy(downloadUrl = normalizeDownloadUrl(it.downloadUrl) ?: it.downloadUrl)
|
||||
}
|
||||
}.getOrNull()
|
||||
}
|
||||
}
|
||||
|
||||
正在加载...
在新工单中引用
屏蔽一个用户