feat(sample): 添加示例应用的核心功能模块

- 集成依赖管理配置文件 libs.versions.toml,统一管理项目依赖版本
- 实现演示 API 接口定义,包含登录、注册、用户管理等 RESTful 端点
- 创建认证仓库 AuthRepository,处理用户会话管理和加密存储
- 开发登录和注册界面,实现用户身份验证流程
- 构建聊天界面 ChatScreen,支持消息收发和历史记录显示
- 实现联系人管理功能,包含好友搜索和添加删除操作
- 添加会话列表界面,展示最近聊天记录和未读消息提示
这个提交包含在:
XuqmGroup 2026-04-27 19:00:54 +08:00
父节点 6dd0fa8f49
当前提交 00f2ad04b7
共有 36 个文件被更改,包括 412 次插入12413 次删除

12145
.gitignore vendored

文件差异内容过多而无法显示 加载差异

查看文件

@ -6,7 +6,7 @@
``` ```
XuqmGroup-AndroidSDK/ XuqmGroup-AndroidSDK/
├── sdk-core/ # 核心初始化、HTTP、Token 存储 ├── sdk-core/ # 核心初始化、HTTP、Token 存储、通用工具/组件
├── sdk-im/ # IMWebSocket 实时通信 ├── sdk-im/ # IMWebSocket 实时通信
├── sdk-push/ # 推送:设备 Token 注册 ├── sdk-push/ # 推送:设备 Token 注册
├── sdk-update/ # 版本管理检查更新、下载安装、RN 热更新 ├── sdk-update/ # 版本管理检查更新、下载安装、RN 热更新
@ -53,21 +53,21 @@ dependencies {
### 1. 初始化Application.onCreate ### 1. 初始化Application.onCreate
```kotlin ```kotlin
XuqmSDK.init( XuqmSDK.initialize(
context = this, context = this,
appKey = "ak_your_app_key", appId = "ak_your_app_id",
appSecret = "as_your_app_secret",
apiBaseUrl = "https://api.xuqm.com",
imBaseUrl = "wss://im.xuqm.com",
debug = BuildConfig.DEBUG debug = BuildConfig.DEBUG
) )
``` ```
### 2. 用户登录后设置 Token ### 2. 用户登录后初始化 IM
```kotlin ```kotlin
// 调用你的业务登录接口获得 token 后 // 调用业务登录接口,拿到 userSig 后再登录 IM
XuqmSDK.tokenStore.save(token) 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租户平台获取 | | `appId` | String | 应用标识(租户平台获取) |
| `appSecret` | String | 应用 Secret |
| `apiBaseUrl` | String | 后端 API 地址 |
| `imBaseUrl` | String | IM WebSocket 地址wss:// |
| `debug` | Boolean | 开启日志 | | `debug` | Boolean | 开启日志 |
### TokenStore ### TokenStore
@ -172,18 +169,9 @@ data class ImMessage(
## sdk-push ## sdk-push
### 注册推送 Token ### 推送接入
在获取到厂商推送 token 后调用: `PushSDK.initialize()` 后由 SDK 自动完成厂商初始化、设备注册与 token 上传,不再要求业务侧单独调用注册接口。
```kotlin
PushSDK.registerToken(
appId = "ak_xxx",
userId = "user_001",
vendor = Vendor.HUAWEI, // HUAWEI / XIAOMI / OPPO / VIVO / HONOR / APNS
token = "device_push_token"
)
```
### 与 IM 联动 ### 与 IM 联动

查看文件

@ -3,8 +3,6 @@ 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"

查看文件

@ -21,10 +21,12 @@ junit4 = "4.13.2"
androidxJunit = "1.3.0" androidxJunit = "1.3.0"
espresso = "3.7.0" espresso = "3.7.0"
hilt = "2.56.2" hilt = "2.56.2"
ksp = "2.3.7"
navigationCompose = "2.9.0" navigationCompose = "2.9.0"
securityCrypto = "1.0.0" securityCrypto = "1.0.0"
hiltNavigationCompose = "1.2.0" hiltNavigationCompose = "1.2.0"
viewmodelCompose = "2.10.0" viewmodelCompose = "2.10.0"
material = "1.13.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" }
@ -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-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" }
androidx-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" } androidx-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" }
androidx-material3 = { group = "androidx.compose.material3", name = "material3" } 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-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-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" } 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" } ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }
hilt-android = { id = "com.google.dagger.hilt.android", version.ref = "hilt" } hilt-android = { id = "com.google.dagger.hilt.android", version.ref = "hilt" }

查看文件

@ -1,8 +1,6 @@
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)
alias(libs.plugins.hilt.android)
} }
android { android {
@ -25,12 +23,10 @@ android {
} }
compileOptions { compileOptions {
sourceCompatibility = JavaVersion.VERSION_21 sourceCompatibility = JavaVersion.VERSION_11
targetCompatibility = JavaVersion.VERSION_21 targetCompatibility = JavaVersion.VERSION_11
} }
kotlinOptions { jvmTarget = "21" }
buildFeatures { buildFeatures {
compose = true compose = true
buildConfig = true buildConfig = true
@ -48,13 +44,10 @@ dependencies {
implementation(libs.androidx.activity.compose) implementation(libs.androidx.activity.compose)
implementation(libs.androidx.lifecycle.runtime.compose) implementation(libs.androidx.lifecycle.runtime.compose)
implementation(libs.androidx.lifecycle.viewmodel.compose) implementation(libs.androidx.lifecycle.viewmodel.compose)
implementation(libs.google.material)
implementation(libs.androidx.navigation.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.compose)
implementation(libs.coil.network.okhttp) implementation(libs.coil.network.okhttp)

查看文件

@ -4,24 +4,18 @@ import android.os.Bundle
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge 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.navigation.AppNavGraph
import com.xuqm.sdk.sample.ui.theme.XuqmTheme import com.xuqm.sdk.sample.ui.theme.XuqmTheme
import dagger.hilt.android.AndroidEntryPoint
import javax.inject.Inject
@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() enableEdgeToEdge()
setContent { setContent {
XuqmTheme { XuqmTheme {
AppNavGraph(authRepository = authRepository) AppNavGraph(authRepository = AppDependencies.authRepository)
} }
} }
} }

查看文件

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

查看文件

@ -1,46 +1,105 @@
package com.xuqm.sdk.sample.data.api 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.Body
import retrofit2.http.GET import retrofit2.http.GET
import retrofit2.http.POST import retrofit2.http.POST
import retrofit2.http.PUT import retrofit2.http.PUT
import retrofit2.http.Query 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>( data class DemoResponse<T>(
val code: Int = 0, val code: Int = 0,
val message: String? = null, val status: String? = null,
val data: T? = 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 { interface DemoApi {
@POST("api/user/login") @POST("api/demo/auth/login")
suspend fun login(@Body request: LoginRequest): DemoResponse<LoginData> suspend fun login(@Body request: LoginRequest): DemoResponse<AuthResult>
@POST("api/user/register") @POST("api/demo/auth/register")
suspend fun register(@Body request: RegisterRequest): DemoResponse<LoginData> suspend fun register(@Body request: RegisterRequest): DemoResponse<AuthResult>
@GET("api/user/profile") @POST("api/demo/auth/reset-password")
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> suspend fun resetPassword(@Body request: ResetPasswordRequest): DemoResponse<Unit>
@GET("api/user/users") @GET("api/demo/user/profile")
suspend fun searchUsers(@Query("keyword") keyword: String): DemoResponse<List<UserData>> 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 android.content.Context
import androidx.security.crypto.EncryptedSharedPreferences 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.im.ImSDK
import com.xuqm.sdk.sample.data.api.DemoApi 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.LoginRequest
import com.xuqm.sdk.sample.data.api.RegisterRequest import com.xuqm.sdk.sample.data.api.RegisterRequest
import com.xuqm.sdk.sample.data.api.ResetPasswordRequest import com.xuqm.sdk.sample.data.api.ResetPasswordRequest
import com.xuqm.sdk.sample.data.api.UpdateProfileRequest import com.xuqm.sdk.sample.data.api.UpdateProfileRequest
import com.xuqm.sdk.sample.data.api.UserData import com.xuqm.sdk.sample.data.api.UserData
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import javax.inject.Inject
import javax.inject.Singleton
@Singleton class AuthRepository(context: Context) {
class AuthRepository @Inject constructor(
@ApplicationContext private val context: Context,
private val api: DemoApi,
) {
private val prefs = EncryptedSharedPreferences.create( private val prefs = EncryptedSharedPreferences.create(
context,
"xuqm_demo_auth", "xuqm_demo_auth",
MasterKey.Builder(context).setKeyScheme(MasterKey.KeyScheme.AES256_GCM).build(), MasterKeys.getOrCreate(MasterKeys.AES256_GCM_SPEC),
context,
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM, EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
) )
private val api: DemoApi = DemoApiFactory.create(::getDemoToken)
fun getDemoToken(): String? = prefs.getString("demo_token", null) fun getDemoToken(): String? = prefs.getString("demo_token", null)
fun getCurrentUserId(): String? = prefs.getString("user_id", null) fun getCurrentUserId(): String? = prefs.getString("user_id", null)
fun getCurrentNickname(): String? = prefs.getString("nickname", null) fun getCurrentNickname(): String? = prefs.getString("nickname", null)
fun getCurrentAvatar(): String? = prefs.getString("avatar", null) fun getCurrentAvatar(): String? = prefs.getString("avatar", null)
fun isLoggedIn(): Boolean = getDemoToken() != 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() prefs.edit()
.putString("demo_token", token) .putString("demo_token", result.demoToken)
.putString("user_id", userId) .putString("user_id", profile.userId)
.putString("nickname", nickname) .putString("nickname", profile.nickname)
.putString("avatar", avatar) .putString("avatar", profile.avatar)
.apply() .apply()
} }
suspend fun login(userId: String, password: String): Result<UserData> = suspend fun login(userId: String, password: String): Result<UserData> =
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
runCatching { 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" } val data = requireNotNull(res.data) { res.message ?: "Login failed" }
saveSession(data.token, data.userId, data.nickname, data.avatar) saveSession(data)
ImSDK.login(data.userId, data.nickname, data.avatar) data.imToken?.takeIf { it.isNotBlank() }?.let { ImSDK.loginWithToken(data.profile.userId, it) }
UserData(data.userId, data.nickname, data.avatar) ?: 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> = suspend fun register(userId: String, password: String, nickname: String): Result<UserData> =
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
runCatching { 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" } val data = requireNotNull(res.data) { res.message ?: "Register failed" }
saveSession(data.token, data.userId, data.nickname, data.avatar) saveSession(data)
ImSDK.login(data.userId, data.nickname, data.avatar) data.imToken?.takeIf { it.isNotBlank() }?.let { ImSDK.loginWithToken(data.profile.userId, it) }
UserData(data.userId, data.nickname, data.avatar) ?: 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> = suspend fun getProfile(): Result<UserData> =
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
runCatching { runCatching { requireNotNull(api.getProfile().data) { "Failed to get profile" } }
requireNotNull(api.getProfile().data) { "Failed to get profile" }
}
} }
suspend fun updateProfile(nickname: String, avatar: String?): Result<UserData> = 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> = suspend fun resetPassword(oldPassword: String, newPassword: String): Result<Unit> =
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
runCatching { api.resetPassword(ResetPasswordRequest(oldPassword, newPassword)) } runCatching { api.changePassword(ChangePasswordRequest(oldPassword, newPassword)) }.map { }
} }
suspend fun searchUsers(keyword: String): Result<List<UserData>> = suspend fun searchUsers(keyword: String): Result<List<UserData>> =
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
runCatching { api.searchUsers(keyword).data ?: emptyList() } runCatching { api.searchUsers(keyword = keyword).data ?: emptyList() }
} }
fun logout() { 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.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle 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 @Composable
fun LoginScreen( fun LoginScreen(
onLoginSuccess: () -> Unit, onLoginSuccess: () -> Unit,
onNavigateToRegister: () -> Unit, onNavigateToRegister: () -> Unit,
viewModel: LoginViewModel = hiltViewModel(), viewModel: LoginViewModel = viewModel(
factory = viewModelFactory {
initializer { LoginViewModel(AppDependencies.authRepository) }
}
),
) { ) {
val state by viewModel.state.collectAsStateWithLifecycle() val state by viewModel.state.collectAsStateWithLifecycle()

查看文件

@ -3,11 +3,9 @@ package com.xuqm.sdk.sample.ui.auth
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.xuqm.sdk.sample.data.repo.AuthRepository import com.xuqm.sdk.sample.data.repo.AuthRepository
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import javax.inject.Inject
sealed interface LoginState { sealed interface LoginState {
data object Idle : LoginState data object Idle : LoginState
@ -16,10 +14,7 @@ sealed interface LoginState {
data class Error(val message: String) : LoginState data class Error(val message: String) : LoginState
} }
@HiltViewModel class LoginViewModel(private val authRepository: AuthRepository) : ViewModel() {
class LoginViewModel @Inject constructor(
private val authRepository: AuthRepository,
) : ViewModel() {
private val _state = MutableStateFlow<LoginState>(LoginState.Idle) private val _state = MutableStateFlow<LoginState>(LoginState.Idle)
val state: StateFlow<LoginState> = _state val state: StateFlow<LoginState> = _state

查看文件

@ -29,21 +29,19 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewModelScope 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 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.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import javax.inject.Inject
@HiltViewModel class RegisterViewModel(private val authRepository: AuthRepository) : ViewModel() {
class RegisterViewModel @Inject constructor(
private val authRepository: AuthRepository,
) : ViewModel() {
private val _state = MutableStateFlow<LoginState>(LoginState.Idle) private val _state = MutableStateFlow<LoginState>(LoginState.Idle)
val state: StateFlow<LoginState> = _state val state: StateFlow<LoginState> = _state
@ -63,7 +61,11 @@ class RegisterViewModel @Inject constructor(
fun RegisterScreen( fun RegisterScreen(
onRegisterSuccess: () -> Unit, onRegisterSuccess: () -> Unit,
onNavigateBack: () -> Unit, onNavigateBack: () -> Unit,
viewModel: RegisterViewModel = hiltViewModel(), viewModel: RegisterViewModel = viewModel(
factory = viewModelFactory {
initializer { RegisterViewModel(AppDependencies.authRepository) }
}
),
) { ) {
val state by viewModel.state.collectAsStateWithLifecycle() 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.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack 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.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import com.xuqm.sdk.im.model.ImMessage import com.xuqm.sdk.im.model.ImMessage
import com.xuqm.sdk.ui.InitialAvatar
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
@ -51,7 +51,7 @@ fun ChatScreen(
targetName: String, targetName: String,
onNavigateBack: () -> Unit, onNavigateBack: () -> Unit,
onGroupSettings: (() -> Unit)? = null, onGroupSettings: (() -> Unit)? = null,
viewModel: ChatViewModel = hiltViewModel(), viewModel: ChatViewModel = viewModel(),
) { ) {
val messages by viewModel.messages.collectAsStateWithLifecycle() val messages by viewModel.messages.collectAsStateWithLifecycle()
val listState = rememberLazyListState() val listState = rememberLazyListState()
@ -166,14 +166,7 @@ private fun MessageBubble(message: ImMessage, isOwn: Boolean) {
@Composable @Composable
private fun AvatarPlaceholder(userId: String) { private fun AvatarPlaceholder(userId: String) {
Surface( InitialAvatar(text = userId, modifier = Modifier.size(32.dp))
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 { 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.ImSDK
import com.xuqm.sdk.im.listener.ImEventListener import com.xuqm.sdk.im.listener.ImEventListener
import com.xuqm.sdk.im.model.ImMessage import com.xuqm.sdk.im.model.ImMessage
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import javax.inject.Inject
@HiltViewModel class ChatViewModel : ViewModel() {
class ChatViewModel @Inject constructor() : ViewModel() {
private val _messages = MutableStateFlow<List<ImMessage>>(emptyList()) private val _messages = MutableStateFlow<List<ImMessage>>(emptyList())
val messages: StateFlow<List<ImMessage>> = _messages 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.layout.padding
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items 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.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp 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.ViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.xuqm.sdk.im.ImSDK 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.api.UserData
import com.xuqm.sdk.sample.data.repo.AuthRepository 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.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch 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(
class ContactViewModel @Inject constructor(
private val authRepository: AuthRepository, private val authRepository: AuthRepository,
) : ViewModel() { ) : ViewModel() {
private val _friends = MutableStateFlow<List<UserProfile>>(emptyList()) private val _friends = MutableStateFlow<List<UserData>>(emptyList())
val friends: StateFlow<List<UserProfile>> = _friends val friends: StateFlow<List<UserData>> = _friends
private val _searchResults = MutableStateFlow<List<UserData>>(emptyList()) private val _searchResults = MutableStateFlow<List<UserData>>(emptyList())
val searchResults: StateFlow<List<UserData>> = _searchResults val searchResults: StateFlow<List<UserData>> = _searchResults
@ -55,8 +49,12 @@ class ContactViewModel @Inject constructor(
fun loadFriends() { fun loadFriends() {
viewModelScope.launch { viewModelScope.launch {
runCatching { ImSDK.listFriends() } runCatching {
.onSuccess { _friends.value = it } 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 @Composable
fun ContactScreen( fun ContactScreen(
onOpenChat: (userId: String) -> Unit, onOpenChat: (userId: String) -> Unit,
viewModel: ContactViewModel = hiltViewModel(), viewModel: ContactViewModel = viewModel(
factory = viewModelFactory {
initializer { ContactViewModel(AppDependencies.authRepository) }
}
),
) { ) {
val friends by viewModel.friends.collectAsStateWithLifecycle() val friends by viewModel.friends.collectAsStateWithLifecycle()
val searchResults by viewModel.searchResults.collectAsStateWithLifecycle() val searchResults by viewModel.searchResults.collectAsStateWithLifecycle()
var keyword by remember { mutableStateOf("") } var keyword by remember { mutableStateOf("") }
Column(modifier = Modifier.fillMaxSize()) { Column(modifier = Modifier.fillMaxSize()) {
OutlinedTextField( SearchBarField(
value = keyword, value = keyword,
onValueChange = { keyword = it; viewModel.search(it) }, onValueChange = { keyword = it; viewModel.search(it) },
modifier = Modifier.fillMaxWidth().padding(12.dp), modifier = Modifier
placeholder = { Text("搜索用户 ID 或昵称") }, .fillMaxWidth()
leadingIcon = { Icon(Icons.Default.Search, contentDescription = null) }, .padding(12.dp),
singleLine = true, placeholder = "搜索用户 ID 或昵称",
) )
if (keyword.isBlank()) { if (keyword.isBlank()) {

查看文件

@ -13,13 +13,11 @@ import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.Badge import androidx.compose.material3.Badge
import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
@ -32,19 +30,18 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import coil3.compose.AsyncImage import coil3.compose.AsyncImage
import com.xuqm.sdk.im.model.ConversationData import com.xuqm.sdk.im.model.ConversationData
import com.xuqm.sdk.ui.InitialAvatar
import com.xuqm.sdk.utils.TimeFormatters
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
@Composable @Composable
fun ConversationScreen( fun ConversationScreen(
onOpenChat: (targetId: String, chatType: String, targetName: String) -> Unit, onOpenChat: (targetId: String, chatType: String, targetName: String) -> Unit,
viewModel: ConversationViewModel = hiltViewModel(), viewModel: ConversationViewModel = viewModel(),
) { ) {
val conversations by viewModel.conversations.collectAsStateWithLifecycle() val conversations by viewModel.conversations.collectAsStateWithLifecycle()
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
@ -84,17 +81,7 @@ private fun ConversationItem(
.padding(horizontal = 16.dp, vertical = 10.dp), .padding(horizontal = 16.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
Surface( InitialAvatar(text = conversation.targetId, modifier = Modifier.size(48.dp))
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)) Spacer(Modifier.width(12.dp))
@ -110,7 +97,7 @@ private fun ConversationItem(
color = MaterialTheme.colorScheme.primary) color = MaterialTheme.colorScheme.primary)
} }
Text( Text(
formatTime(conversation.lastMsgTime), TimeFormatters.formatConversationTime(conversation.lastMsgTime),
style = MaterialTheme.typography.labelSmall, style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.outline, 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.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.xuqm.sdk.im.ImSDK 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.ConversationData
import com.xuqm.sdk.im.model.ImMessage 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.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import javax.inject.Inject
@HiltViewModel class ConversationViewModel : ViewModel() {
class ConversationViewModel @Inject constructor() : ViewModel() {
private val _conversations = MutableStateFlow<List<ConversationData>>(emptyList()) private val _conversations = MutableStateFlow<List<ConversationData>>(emptyList())
val conversations: StateFlow<List<ConversationData>> = _conversations val conversations: StateFlow<List<ConversationData>> = _conversations

查看文件

@ -37,7 +37,7 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.xuqm.sdk.im.ImSDK import com.xuqm.sdk.im.ImSDK
import com.xuqm.sdk.im.model.ImGroup import com.xuqm.sdk.im.model.ImGroup
@ -47,7 +47,7 @@ import com.xuqm.sdk.im.model.ImGroup
fun GroupListScreen( fun GroupListScreen(
onOpenGroupChat: (groupId: String, groupName: String) -> Unit, onOpenGroupChat: (groupId: String, groupName: String) -> Unit,
onGroupSettings: (groupId: String) -> Unit, onGroupSettings: (groupId: String) -> Unit,
viewModel: GroupViewModel = hiltViewModel(), viewModel: GroupViewModel = viewModel(),
) { ) {
val groups by viewModel.groups.collectAsStateWithLifecycle() val groups by viewModel.groups.collectAsStateWithLifecycle()
var showCreateDialog by remember { mutableStateOf(false) } var showCreateDialog by remember { mutableStateOf(false) }
@ -150,7 +150,7 @@ private fun CreateGroupDialog(onDismiss: () -> Unit, onCreate: (String, List<Str
fun GroupSettingsScreen( fun GroupSettingsScreen(
groupId: String, groupId: String,
onNavigateBack: () -> Unit, onNavigateBack: () -> Unit,
viewModel: GroupViewModel = hiltViewModel(), viewModel: GroupViewModel = viewModel(),
) { ) {
val group by viewModel.currentGroup.collectAsStateWithLifecycle() val group by viewModel.currentGroup.collectAsStateWithLifecycle()

查看文件

@ -4,14 +4,11 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.xuqm.sdk.im.ImSDK import com.xuqm.sdk.im.ImSDK
import com.xuqm.sdk.im.model.ImGroup import com.xuqm.sdk.im.model.ImGroup
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import javax.inject.Inject
@HiltViewModel class GroupViewModel : ViewModel() {
class GroupViewModel @Inject constructor() : ViewModel() {
private val _groups = MutableStateFlow<List<ImGroup>>(emptyList()) private val _groups = MutableStateFlow<List<ImGroup>>(emptyList())
val groups: StateFlow<List<ImGroup>> = _groups val groups: StateFlow<List<ImGroup>> = _groups

查看文件

@ -27,16 +27,17 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.unit.dp 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.ViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.xuqm.sdk.sample.data.repo.AuthRepository 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.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import javax.inject.Inject import androidx.lifecycle.viewmodel.initializer
import androidx.lifecycle.viewmodel.viewModelFactory
data class ProfileUiState( data class ProfileUiState(
val userId: String = "", val userId: String = "",
@ -46,8 +47,7 @@ data class ProfileUiState(
val error: String? = null, val error: String? = null,
) )
@HiltViewModel class ProfileViewModel(
class ProfileViewModel @Inject constructor(
private val authRepository: AuthRepository, private val authRepository: AuthRepository,
) : ViewModel() { ) : ViewModel() {
@ -82,7 +82,11 @@ class ProfileViewModel @Inject constructor(
@Composable @Composable
fun ProfileScreen( fun ProfileScreen(
onLogout: () -> Unit, onLogout: () -> Unit,
viewModel: ProfileViewModel = hiltViewModel(), viewModel: ProfileViewModel = viewModel(
factory = viewModelFactory {
initializer { ProfileViewModel(AppDependencies.authRepository) }
}
),
) { ) {
val state by viewModel.state.collectAsStateWithLifecycle() val state by viewModel.state.collectAsStateWithLifecycle()

查看文件

@ -19,17 +19,16 @@ import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp 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.ViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.xuqm.sdk.update.UpdateSDK import com.xuqm.sdk.update.UpdateSDK
import com.xuqm.sdk.update.model.UpdateInfo 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.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import javax.inject.Inject
data class UpdateUiState( data class UpdateUiState(
val isChecking: Boolean = false, val isChecking: Boolean = false,
@ -38,8 +37,7 @@ data class UpdateUiState(
val message: String? = null, val message: String? = null,
) )
@HiltViewModel class UpdateViewModel : ViewModel() {
class UpdateViewModel @Inject constructor() : ViewModel() {
private val _state = MutableStateFlow(UpdateUiState()) private val _state = MutableStateFlow(UpdateUiState())
val state: StateFlow<UpdateUiState> = _state val state: StateFlow<UpdateUiState> = _state
@ -67,7 +65,7 @@ class UpdateViewModel @Inject constructor() : ViewModel() {
} }
@Composable @Composable
fun UpdateScreen(viewModel: UpdateViewModel = hiltViewModel()) { fun UpdateScreen(viewModel: UpdateViewModel = viewModel()) {
val state by viewModel.state.collectAsStateWithLifecycle() val state by viewModel.state.collectAsStateWithLifecycle()
val context = LocalContext.current val context = LocalContext.current

查看文件

@ -1,5 +1,6 @@
plugins { plugins {
alias(libs.plugins.android.library) alias(libs.plugins.android.library)
alias(libs.plugins.kotlin.compose)
alias(libs.plugins.kotlin.serialization) alias(libs.plugins.kotlin.serialization)
} }
@ -16,12 +17,19 @@ android {
sourceCompatibility = JavaVersion.VERSION_11 sourceCompatibility = JavaVersion.VERSION_11
targetCompatibility = JavaVersion.VERSION_11 targetCompatibility = JavaVersion.VERSION_11
} }
buildFeatures {
compose = true
}
} }
dependencies { dependencies {
api(platform(libs.androidx.compose.bom))
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.core.ktx) api(libs.androidx.core.ktx)
api(libs.androidx.security.crypto) 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 android.content.Context
import androidx.security.crypto.EncryptedSharedPreferences 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 PREFS_NAME = "xuqm_sdk_secure"
private const val KEY_IM_TOKEN = "im_token" private const val KEY_IM_TOKEN = "im_token"
@ -10,9 +10,9 @@ private const val KEY_IM_TOKEN = "im_token"
class TokenStore(context: Context) { class TokenStore(context: Context) {
private val prefs = EncryptedSharedPreferences.create( private val prefs = EncryptedSharedPreferences.create(
context,
PREFS_NAME, PREFS_NAME,
MasterKey.Builder(context).setKeyScheme(MasterKey.KeyScheme.AES256_GCM).build(), MasterKeys.getOrCreate(MasterKeys.AES256_GCM_SPEC),
context,
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM, EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
) )

查看文件

@ -1,7 +1,7 @@
package com.xuqm.sdk.core package com.xuqm.sdk.core
internal const val BASE_URL = "https://dev.xuqinmin.com/" const val BASE_URL = "https://dev.xuqinmin.com/"
internal const val WS_URL = "wss://dev.xuqinmin.com/ws/im" const val WS_URL = "wss://dev.xuqinmin.com/ws/im"
data class SDKConfig( data class SDKConfig(
val appId: String, 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 = fun getDeviceId(context: Context): String =
Settings.Secure.getString(context.contentResolver, Settings.Secure.ANDROID_ID) Settings.Secure.getString(context.contentResolver, Settings.Secure.ANDROID_ID)
?: Build.SERIAL ?: Build.FINGERPRINT
fun getDeviceModel(): String = "${Build.MANUFACTURER} ${Build.MODEL}" 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.ConversationData
import com.xuqm.sdk.im.model.ImGroup import com.xuqm.sdk.im.model.ImGroup
import com.xuqm.sdk.im.model.ImMessage 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.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
@ -31,10 +30,15 @@ object ImSDK {
XuqmSDK.requireInit() XuqmSDK.requireInit()
val res = api.login(LoginRequest(XuqmSDK.appId, userId, nickname, avatar)) val res = api.login(LoginRequest(XuqmSDK.appId, userId, nickname, avatar))
val token = requireNotNull(res.data?.token) { "IM login failed: ${res.message}" } val token = requireNotNull(res.data?.token) { "IM login failed: ${res.message}" }
XuqmSDK.tokenStore.saveToken(token)
currentUserId = userId currentUserId = userId
client = ImClient(WS_URL, token, XuqmSDK.appId) connectWithToken(token)
client?.connect() }
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) { 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> = suspend fun fetchHistory(toId: String, page: Int = 0, size: Int = 20): List<ImMessage> =
withContext(Dispatchers.IO) { 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> = suspend fun fetchGroupHistory(groupId: String, page: Int = 0, size: Int = 20): List<ImMessage> =
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
api.fetchGroupHistory(groupId, page, size).data ?: emptyList() api.fetchGroupHistory(groupId, XuqmSDK.appId, page, size).data ?: emptyList()
} }
suspend fun listGroups(): List<ImGroup> = 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? = 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? = suspend fun getGroupInfo(groupId: String): ImGroup? =
withContext(Dispatchers.IO) { api.getGroupInfo(groupId).data } withContext(Dispatchers.IO) { api.getGroupInfo(groupId).data }
@ -70,19 +74,19 @@ object ImSDK {
withContext(Dispatchers.IO) { api.removeGroupMember(groupId, userId) } withContext(Dispatchers.IO) { api.removeGroupMember(groupId, userId) }
suspend fun leaveGroup(groupId: String) = suspend fun leaveGroup(groupId: String) =
withContext(Dispatchers.IO) { api.leaveGroup(groupId) } withContext(Dispatchers.IO) { api.removeGroupMember(groupId, currentUserId) }
suspend fun listFriends(): List<UserProfile> = suspend fun listFriends(): List<String> =
withContext(Dispatchers.IO) { api.listFriends().data ?: emptyList() } withContext(Dispatchers.IO) { api.listFriends(XuqmSDK.appId).data ?: emptyList() }
suspend fun addFriend(friendId: String) = suspend fun addFriend(friendId: String) =
withContext(Dispatchers.IO) { api.addFriend(friendId) } withContext(Dispatchers.IO) { api.addFriend(XuqmSDK.appId, friendId) }
suspend fun removeFriend(friendId: String) = suspend fun removeFriend(friendId: String) =
withContext(Dispatchers.IO) { api.removeFriend(friendId) } withContext(Dispatchers.IO) { api.removeFriend(friendId, XuqmSDK.appId) }
suspend fun listConversations(): List<ConversationData> = 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) = suspend fun setConversationPinned(targetId: String, chatType: String, pinned: Boolean) =
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
@ -95,10 +99,7 @@ object ImSDK {
} }
suspend fun markRead(targetId: String, chatType: String = "SINGLE") = suspend fun markRead(targetId: String, chatType: String = "SINGLE") =
withContext(Dispatchers.IO) { api.markRead(targetId, chatType) } withContext(Dispatchers.IO) { api.markRead(targetId, XuqmSDK.appId, 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)
@ -109,4 +110,11 @@ object ImSDK {
currentUserId = "" currentUserId = ""
XuqmSDK.tokenStore.clear() 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.ConversationData
import com.xuqm.sdk.im.model.ImGroup import com.xuqm.sdk.im.model.ImGroup
import com.xuqm.sdk.im.model.ImMessage import com.xuqm.sdk.im.model.ImMessage
import com.xuqm.sdk.im.model.UserProfile
import retrofit2.http.Body import retrofit2.http.Body
import retrofit2.http.DELETE import retrofit2.http.DELETE
import retrofit2.http.GET import retrofit2.http.GET
@ -43,26 +42,30 @@ interface ImApi {
@POST("api/im/auth/login") @POST("api/im/auth/login")
suspend fun login(@Body request: LoginRequest): ApiResponse<LoginResponse> suspend fun login(@Body request: LoginRequest): ApiResponse<LoginResponse>
@GET("api/im/messages") @GET("api/im/messages/history/{toId}")
suspend fun fetchHistory( suspend fun fetchHistory(
@Query("toId") toId: String, @Path("toId") toId: String,
@Query("chatType") chatType: String, @Query("appId") appId: String,
@Query("page") page: Int, @Query("page") page: Int,
@Query("size") size: Int, @Query("size") size: Int,
): ApiResponse<List<ImMessage>> ): ApiResponse<List<ImMessage>>
@GET("api/im/groups/{groupId}/messages") @GET("api/im/messages/group-history/{groupId}")
suspend fun fetchGroupHistory( suspend fun fetchGroupHistory(
@Path("groupId") groupId: String, @Path("groupId") groupId: String,
@Query("appId") appId: String,
@Query("page") page: Int, @Query("page") page: Int,
@Query("size") size: Int, @Query("size") size: Int,
): ApiResponse<List<ImMessage>> ): ApiResponse<List<ImMessage>>
@GET("api/im/groups") @GET("api/im/groups")
suspend fun listGroups(): ApiResponse<List<ImGroup>> suspend fun listGroups(@Query("appId") appId: String): ApiResponse<List<ImGroup>>
@POST("api/im/groups") @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}") @GET("api/im/groups/{groupId}")
suspend fun getGroupInfo(@Path("groupId") groupId: String): ApiResponse<ImGroup> suspend fun getGroupInfo(@Path("groupId") groupId: String): ApiResponse<ImGroup>
@ -89,16 +92,22 @@ interface ImApi {
suspend fun leaveGroup(@Path("groupId") groupId: String): ApiResponse<Unit> suspend fun leaveGroup(@Path("groupId") groupId: String): ApiResponse<Unit>
@GET("api/im/friends") @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}") @POST("api/im/friends")
suspend fun addFriend(@Path("friendId") friendId: String): ApiResponse<Unit> suspend fun addFriend(
@Query("appId") appId: String,
@Query("friendId") friendId: String,
): ApiResponse<Unit>
@DELETE("api/im/friends/{friendId}") @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") @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") @PUT("api/im/conversations/{targetId}/pinned")
suspend fun setConversationPinned( suspend fun setConversationPinned(
@ -117,9 +126,7 @@ interface ImApi {
@PUT("api/im/conversations/{targetId}/read") @PUT("api/im/conversations/{targetId}/read")
suspend fun markRead( suspend fun markRead(
@Path("targetId") targetId: String, @Path("targetId") targetId: String,
@Query("appId") appId: String,
@Query("chatType") chatType: String, @Query("chatType") chatType: String,
): ApiResponse<Unit> ): 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.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
@ -22,14 +21,10 @@ object PushSDK {
scope.launch { scope.launch {
runCatching { runCatching {
api.registerDevice( api.registerDevice(
RegisterDeviceRequest(
userId = userId,
appId = XuqmSDK.appId, appId = XuqmSDK.appId,
platform = "Android", userId = userId,
vendor = vendor, vendor = vendor,
pushToken = "", token = deviceId,
deviceId = deviceId,
)
) )
} }
} }

查看文件

@ -16,8 +16,13 @@ data class RegisterDeviceRequest(
interface PushApi { interface PushApi {
@POST("api/push/device/register") @POST("api/push/register")
suspend fun registerDevice(@Body request: RegisterDeviceRequest) suspend fun registerDevice(
@Query("appId") appId: String,
@Query("userId") userId: String,
@Query("vendor") vendor: String,
@Query("token") token: String,
)
@DELETE("api/push/device/unregister") @DELETE("api/push/device/unregister")
suspend fun unregisterDevice( suspend fun unregisterDevice(

查看文件

@ -18,12 +18,31 @@ object UpdateSDK {
private val api: UpdateApi by lazy { ApiClient.create() } 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) { 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 { 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() }.getOrNull()
} }
@ -66,7 +85,9 @@ object UpdateSDK {
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
XuqmSDK.requireInit() XuqmSDK.requireInit()
runCatching { 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() }.getOrNull()
} }
} }