refactor: 重构数据持久化方案

- 将 ObjectBox 替换为 Room 作为本地数据库
- 优化部分代码结构,提高可维护性
This commit is contained in:
糕小菜 2025-05-31 11:10:25 +08:00
parent 2e9e115c7e
commit ff751834e4
134 changed files with 3082 additions and 777 deletions

View File

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="AndroidProjectSystem">
<option name="providerId" value="com.android.tools.idea.GradleProjectSystem" />
</component>
</project>

View File

@ -11,9 +11,10 @@
<set>
<option value="$PROJECT_DIR$" />
<option value="$PROJECT_DIR$/app" />
<option value="$PROJECT_DIR$/core_common" />
<option value="$PROJECT_DIR$/core_network" />
</set>
</option>
<option name="resolveExternalAnnotations" value="false" />
</GradleProjectSettings>
</option>
</component>

View File

@ -1,4 +1,3 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ExternalStorageConfigurationManager" enabled="true" />
<component name="ProjectRootManager" version="2" languageLevel="JDK_21" project-jdk-name="jbr-21" project-jdk-type="JavaSDK">

View File

@ -4,7 +4,7 @@ plugins {
id("com.google.devtools.ksp")
// id("therouter")
id("org.jetbrains.kotlin.plugin.serialization")
id("kotlin-kapt")
// id("kotlin-kapt")
}
android {
@ -17,7 +17,7 @@ android {
defaultConfig {
applicationId = "com.kaixed.kchat"
minSdk = 28
minSdk = 30
targetSdk = 34
versionCode = 1
versionName = "0.0.1"
@ -36,16 +36,17 @@ android {
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
sourceCompatibility = JavaVersion.VERSION_11
targetCompatibility = JavaVersion.VERSION_11
}
kotlinOptions {
jvmTarget = "17"
jvmTarget = "11"
}
}
dependencies {
implementation(project(":core_common"))
implementation(libs.androidx.core.ktx)
implementation(libs.appcompat)
@ -59,13 +60,15 @@ dependencies {
implementation(libs.emoji2)
implementation(libs.preference)
implementation(libs.androidx.startup.runtime)
implementation(libs.okhttp)
implementation(libs.mmkv)
implementation(libs.gson)
implementation(libs.objectbox.kotlin)
debugImplementation(libs.objectbox.android.objectbrowser)
releaseImplementation(libs.objectbox.android)
// implementation(libs.objectbox.kotlin)
// debugImplementation(libs.objectbox.android.objectbrowser)
// releaseImplementation(libs.objectbox.android)
implementation(libs.glide)
implementation(libs.lottie)
@ -97,6 +100,15 @@ dependencies {
implementation(libs.scanplus)
// implementation(libs.therouter)
// ksp(libs.therouter.ksp)
// Room
implementation(libs.androidx.room.runtime)
ksp(libs.androidx.room.compiler)
implementation(libs.androidx.room.ktx)
implementation(libs.androidx.room.paging)
implementation(libs.androidx.paging.runtime.ktx)
debugImplementation(libs.leakcanary.android)
}
apply(plugin = "io.objectbox")
//apply(plugin = "io.objectbox")

View File

@ -154,7 +154,7 @@
},
{
"id": "5:2885532406154205395",
"lastPropertyId": "7:7996607163318458427",
"lastPropertyId": "9:1671273767790122969",
"name": "UserInfo",
"properties": [
{
@ -194,6 +194,16 @@
"id": "7:7996607163318458427",
"name": "status",
"type": 9
},
{
"id": "8:6299664727899448104",
"name": "accessToken",
"type": 9
},
{
"id": "9:1671273767790122969",
"name": "refreshToken",
"type": 9
}
],
"relations": []

View File

@ -2,6 +2,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<!-- 相机权限 -->
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.CAMERA" /> <!-- 文件读取权限Android 12及更低版本申请 -->
<uses-permission
android:name="android.permission.READ_EXTERNAL_STORAGE"
@ -21,9 +22,6 @@
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.SEND_SMS" />
<uses-permission
android:name="android.permission.READ_EXTERNAL_STORAGE"
android:maxSdkVersion="32" />
<uses-permission
android:name="android.permission.WRITE_EXTERNAL_STORAGE"
android:maxSdkVersion="32"
@ -35,6 +33,7 @@
android:appComponentFactory="androidx.core.app.CoreComponentFactory"
android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_rules"
android:hardwareAccelerated="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:networkSecurityConfig="@xml/network_security_config"
@ -42,7 +41,6 @@
android:supportsRtl="true"
android:theme="@style/Theme.KChatAndroid"
android:usesCleartextTraffic="true"
android:hardwareAccelerated="true"
tools:targetApi="31">
<activity
android:name=".ui.activity.QrCodeActivity"
@ -159,6 +157,19 @@
android:exported="false" />
<service android:name=".service.WebSocketService" />
<provider
android:name="androidx.startup.InitializationProvider"
android:authorities="${applicationId}.androidx-startup"
android:exported="false"
tools:node="merge">
<meta-data
android:name="com.kaixed.kchat.startup.MMKVInitializer"
android:value="androidx.startup" />
<meta-data
android:name="com.kaixed.kchat.startup.ScreenUtilsInitializer"
android:value="androidx.startup" />
</provider>
</application>
</manifest>

View File

@ -1,11 +1,10 @@
package com.kaixed.kchat
import android.app.Application
import com.kaixed.kchat.data.local.box.ObjectBox
import com.kaixed.kchat.data.local.box.ObjectBox.getBoxStore
import android.content.Context
import com.kaixed.kchat.manager.NetworkManager
import com.kaixed.kchat.utils.ScreenUtils
import com.tencent.mmkv.MMKV
import io.objectbox.android.Admin
/**
@ -14,12 +13,16 @@ import io.objectbox.android.Admin
*/
class App : Application() {
companion object {
private lateinit var appContext: Context
fun getAppContext(): Context {
return appContext
}
}
override fun onCreate() {
super.onCreate()
MMKV.initialize(this)
ObjectBox.init(this)
Admin(getBoxStore()).start(this)
ScreenUtils.init(this)
appContext = this
}
}

View File

@ -1,30 +1,47 @@
package com.kaixed.kchat.data
import com.kaixed.kchat.data.local.box.ObjectBox.getBoxStore
import com.kaixed.kchat.data.local.AppDatabase
import com.kaixed.kchat.data.local.entity.Contact
import com.kaixed.kchat.data.local.entity.Conversation
import com.kaixed.kchat.data.local.entity.Messages
import com.kaixed.kchat.data.local.entity.UserInfo
import io.objectbox.Box
import io.objectbox.kotlin.boxFor
import com.kaixed.kchat.utils.ConstantsUtils.getUsername
import kotlinx.coroutines.runBlocking
/**
* @Author: kaixed
* @Date: 2024/12/14 23:06
*/
object DataBase {
val contactBox: Box<Contact> by lazy { getBoxStore().boxFor() }
val messagesBox: Box<Messages> by lazy { getBoxStore().boxFor() }
private val contactDao = AppDatabase.getDatabase().contactDao()
private val userInfoDao = AppDatabase.getDatabase().userInfoDao()
private val conversationDao = AppDatabase.getDatabase().conversationDao()
private val messagesDao = AppDatabase.getDatabase().messagesDao()
val conversationBox: Box<Conversation> by lazy { getBoxStore().boxFor() }
fun clearAllDatabase() {
val db = AppDatabase.getDatabase() // 获取 Room 数据库实例
db.clearAllTables() // 清空所有表的数据
}
val userInfoBox: Box<UserInfo> by lazy { getBoxStore().boxFor() }
fun getUserInfo(): UserInfo? {
return runBlocking {
userInfoDao.getUserInfoByUsername(getUsername())
}
}
fun cleanAllData() {
contactBox.removeAll()
messagesBox.removeAll()
conversationBox.removeAll()
userInfoBox.removeAll()
fun deleteMsgByLocalId(localId: Long) {
}
suspend fun getContactByUsername(contactId: String): Contact? {
return contactDao.getContactByUsername(contactId)
}
fun saveUserInfo(info: UserInfo) {
}
fun isMyFriend(string: String): Boolean {
return runBlocking {
contactDao.isMyFriend(string)
}
}
}

View File

@ -1,68 +0,0 @@
package com.kaixed.kchat.data
import com.kaixed.kchat.data.local.entity.Contact
import com.kaixed.kchat.data.local.entity.Contact_
import com.kaixed.kchat.data.local.entity.Conversation_
import com.kaixed.kchat.data.local.entity.Messages
import com.kaixed.kchat.data.local.entity.Messages_
import com.kaixed.kchat.data.local.entity.UserInfo
import io.objectbox.query.QueryBuilder
/**
* @Author: kaixed
* @Date: 2024/11/24 13:34
*/
object LocalDatabase {
private val contactBox = DataBase.contactBox
private val messagesBox = DataBase.messagesBox
private val conversationBox = DataBase.conversationBox
private val userInfoBox = DataBase.userInfoBox
fun saveUserInfo(userInfo: UserInfo) {
userInfoBox.put(userInfo)
}
fun cleanChatHistory(contactId: String) {
messagesBox.query(Messages_.talkerId.equal(contactId)).build().remove()
conversationBox.query(Conversation_.talkerId.equal(contactId)).build().remove()
}
fun isMyFriend(contactId: String): Boolean {
return getContactByUsername(contactId) != null
}
fun getContactByUsername(contactId: String): Contact? {
return contactBox.query(Contact_.username.equal(contactId)).build().findFirst()
}
fun getMessagesWithContact(contactId: String, offset: Long, limit: Long): List<Messages> {
val query = messagesBox
.query(Messages_.talkerId.equal(contactId))
.order(Messages_.timestamp, QueryBuilder.DESCENDING)
.build()
return query.find(offset, limit)
}
fun getMoreMessages(contactId: String, msgLocalId: Long, limit: Long): List<Messages> {
val query = messagesBox
.query(Messages_.talkerId.equal(contactId))
.lessOrEqual(Messages_.msgLocalId, msgLocalId)
.order(Messages_.timestamp, QueryBuilder.DESCENDING)
.build()
val offset = 0
return query.find(offset.toLong(), limit)
}
fun getAllHistoryMessages(contactId: String, msgLocalId: Long): List<Messages> {
val query = messagesBox
.query(Messages_.talkerId.equal(contactId))
.greaterOrEqual(Messages_.msgLocalId, msgLocalId)
.order(Messages_.timestamp, QueryBuilder.DESCENDING)
.build()
return query.find()
}
}

View File

@ -0,0 +1,46 @@
package com.kaixed.kchat.data.local
import androidx.room.Database
import androidx.room.Room
import androidx.room.RoomDatabase
import com.kaixed.kchat.App
import com.kaixed.kchat.data.local.dao.ContactDao
import com.kaixed.kchat.data.local.dao.ConversationDao
import com.kaixed.kchat.data.local.dao.MessagesDao
import com.kaixed.kchat.data.local.dao.UserInfoDao
import com.kaixed.kchat.data.local.entity.Contact
import com.kaixed.kchat.data.local.entity.Conversation
import com.kaixed.kchat.data.local.entity.Messages
import com.kaixed.kchat.data.local.entity.UserInfo
@Database(
entities = [
Contact::class,
Conversation::class,
Messages::class,
UserInfo::class
], version = 1, exportSchema = false
)
abstract class AppDatabase : RoomDatabase() {
abstract fun userInfoDao(): UserInfoDao
abstract fun contactDao(): ContactDao
abstract fun messagesDao(): MessagesDao
abstract fun conversationDao(): ConversationDao
companion object {
@Volatile
private var INSTANCE: AppDatabase? = null
fun getDatabase(): AppDatabase {
return INSTANCE ?: synchronized(this) {
val instance = Room.databaseBuilder(
App.getAppContext(),
AppDatabase::class.java,
"app_database"
).build()
INSTANCE = instance
instance
}
}
}
}

View File

@ -1,27 +0,0 @@
package com.kaixed.kchat.data.local.box
import android.content.Context
import com.kaixed.kchat.data.local.entity.MyObjectBox
import io.objectbox.Box
import io.objectbox.BoxStore
import io.objectbox.kotlin.boxFor
/**
* @Author: kaixed
* @Date: 2024/10/24 16:57
*/
object ObjectBox {
private lateinit var boxStore: BoxStore
fun init(context: Context) {
boxStore = MyObjectBox.builder()
.androidContext(context)
.build()
}
fun getBoxStore(): BoxStore = boxStore
fun <T> getBox(entityClass: Class<T>): Box<T> {
return boxStore.boxFor(entityClass)
}
}

View File

@ -0,0 +1,35 @@
package com.kaixed.kchat.data.local.dao
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.Query
import androidx.room.Update
import com.kaixed.kchat.data.local.entity.Contact
@Dao
interface ContactDao {
@Query("SELECT * FROM contact WHERE username = :contactId LIMIT 1")
suspend fun getContactByUsername(contactId: String): Contact?
@Query("SELECT * FROM contact")
suspend fun getAllContacts(): List<Contact>
@Query("SELECT EXISTS (SELECT 1 FROM contact WHERE username = :contactId)")
suspend fun isMyFriend(contactId: String): Boolean
@Query("DELETE FROM contact WHERE username = :contactId")
suspend fun deleteContactByUsername(contactId: String): Int
// 更新联系人
@Update
suspend fun updateContact(contact: Contact)
@Update
suspend fun updateContacts(contacts: List<Contact>)
@Insert
suspend fun insertContact(contact: Contact)
@Insert
suspend fun insertContacts(contacts: List<Contact>)
}

View File

@ -0,0 +1,29 @@
package com.kaixed.kchat.data.local.dao
import androidx.room.Dao
import androidx.room.Delete
import androidx.room.Insert
import androidx.room.Query
import androidx.room.Update
import com.kaixed.kchat.data.local.entity.Conversation
@Dao
interface ConversationDao {
@Query("DELETE FROM conversation WHERE talkerId = :contactId")
suspend fun deleteConversationsByContactId(contactId: String)
@Query("SELECT * FROM conversation WHERE isShow = 1 ORDER BY timestamp DESC")
suspend fun getConversations(): List<Conversation>
@Query("SELECT * FROM conversation WHERE talkerId = :contactId LIMIT 1")
suspend fun getConversationByUsername(contactId: String): Conversation?
@Insert
suspend fun insertConversation(conversation: Conversation)
@Delete
suspend fun deleteConversation(conversation: Conversation)
@Update
suspend fun updateConversation(conversation: Conversation)
}

View File

@ -0,0 +1,72 @@
package com.kaixed.kchat.data.local.dao
import androidx.paging.PagingSource
import androidx.room.Dao
import androidx.room.Query
import androidx.room.Delete
import androidx.room.Insert
import androidx.room.Update
import com.kaixed.kchat.data.local.entity.Messages
@Dao
interface MessagesDao {
@Query("select * from messages where talkerId = :contactId and msgLocalId <= :msgLocalId order by timestamp desc limit :limit")
suspend fun getMessageByContactId(
contactId: String,
msgLocalId: Long,
limit: Long
): List<Messages>
@Query("select * from messages where talkerId = :contactId order by timestamp desc limit :limit")
suspend fun getMessageByContactId(contactId: String, limit: Long): List<Messages>
@Query("SELECT * FROM messages WHERE talkerId = :contactId ORDER BY timestamp DESC LIMIT :limit OFFSET :offset")
suspend fun getMessagesWithContact(contactId: String, offset: Long, limit: Long): List<Messages>
@Query("SELECT * FROM messages WHERE talkerId = :contactId AND msgLocalId <= :msgLocalId ORDER BY timestamp DESC LIMIT :limit")
suspend fun getMoreMessages(contactId: String, msgLocalId: Long, limit: Long): List<Messages>
@Query("SELECT * FROM messages WHERE talkerId = :contactId AND msgLocalId >= :msgLocalId ORDER BY timestamp DESC")
suspend fun getAllHistoryMessages(contactId: String, msgLocalId: Long): List<Messages>
@Query("DELETE FROM messages WHERE talkerId = :contactId")
suspend fun deleteMessagesByContactId(contactId: String)
@Query("SELECT * FROM messages WHERE talkerId = :contactId ORDER BY timestamp ASC")
fun selectHistoryMessages(contactId: String): PagingSource<Int, Messages>
// ASC 更早的消息在最上面
@Query("SELECT * FROM messages WHERE talkerId = :contactId ORDER BY timestamp ASC")
fun getMessagesWithContact(contactId: String): PagingSource<Int, Messages>
@Query("SELECT * FROM messages WHERE talkerId = :contactId AND msgLocalId < :maxMsgId ORDER BY timestamp ASC LIMIT :limit")
fun getMessagesBefore(contactId: String, maxMsgId: Long, limit: Int): List<Messages>
@Insert
suspend fun insertAll(messages: List<Messages>)
@Insert
suspend fun insert(messages: Messages): Long
@Query("SELECT * FROM messages ORDER BY timestamp DESC")
fun getMessagesPaged(): PagingSource<Int, Messages>
@Query("SELECT * FROM messages ORDER BY timestamp DESC")
fun getPagedMessages(): PagingSource<Int, Messages>
@Query("SELECT * FROM messages WHERE content LIKE :keyword")
suspend fun getMessagesContainingKeyword(keyword: String): List<Messages>
@Query("SELECT * FROM messages WHERE talkerId = :talkerId AND timestamp <= :currentTimestamp ORDER BY timestamp DESC LIMIT 1")
suspend fun getLatestMessage(talkerId: String, currentTimestamp: Long): Messages?
@Query("SELECT * FROM messages WHERE msgLocalId = :localId")
suspend fun getMessageByLocalId(localId: Long): Messages
@Update
suspend fun updateMessage(messages: Messages)
@Query("SELECT * FROM messages WHERE talkerId = :talkerId ORDER BY timestamp DESC")
fun getPagedMessages(talkerId: String): PagingSource<Int, Messages>
}

View File

@ -0,0 +1,16 @@
package com.kaixed.kchat.data.local.dao
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import com.kaixed.kchat.data.local.entity.UserInfo
@Dao
interface UserInfoDao {
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun saveUserInfo(userInfo: UserInfo)
@Query("SELECT * FROM user_info WHERE username = :username LIMIT 1")
suspend fun getUserInfoByUsername(username: String): UserInfo?
}

View File

@ -1,21 +1,19 @@
package com.kaixed.kchat.data.local.entity
import io.objectbox.annotation.Entity
import io.objectbox.annotation.Id
import io.objectbox.annotation.Index
import kotlinx.serialization.Serializable
import androidx.room.Entity
import androidx.room.PrimaryKey
/**
* @Author: kaixed
* @Date: 2024/11/4 13:39
*/
@Entity
@Serializable
@Entity(tableName = "contact")
data class Contact(
@Id var id: Long = 0L,
@Index var username: String,
@Index var nickname: String,
@Index var remark: String? = null,
@PrimaryKey(autoGenerate = true)
var id: Long = 0L,
var username: String,
var nickname: String,
var remark: String? = null,
var signature: String? = null,
var avatarUrl: String? = null,
var status: String? = null,

View File

@ -1,19 +1,17 @@
package com.kaixed.kchat.data.local.entity
import io.objectbox.annotation.Entity
import io.objectbox.annotation.Id
import io.objectbox.annotation.Index
import androidx.room.Entity
import androidx.room.PrimaryKey
/**
* @Author: kaixed
* @Date: 2024/10/24 17:07
*/
@Entity
@Entity(tableName = "conversation")
data class Conversation(
@Id
@PrimaryKey(autoGenerate = true)
var id: Long = 0L,
@Index
var talkerId: String,
var nickname: String,
var avatarUrl: String,

View File

@ -1,7 +1,7 @@
package com.kaixed.kchat.data.local.entity
import io.objectbox.annotation.Entity
import io.objectbox.annotation.Id
import androidx.room.Entity
import androidx.room.PrimaryKey
import kotlinx.serialization.Serializable
/**
@ -9,10 +9,9 @@ import kotlinx.serialization.Serializable
* @Date: 2024/10/24 17:08
*/
@Entity
@Serializable
@Entity(tableName = "messages")
data class Messages(
@Id
@PrimaryKey(autoGenerate = true)
var msgLocalId: Long = 0L,
var msgSvrId: Long = 0L,
var content: String,

View File

@ -1,8 +1,7 @@
package com.kaixed.kchat.data.local.entity
import io.objectbox.annotation.Entity
import io.objectbox.annotation.Id
import io.objectbox.annotation.Index
import androidx.room.Entity
import androidx.room.PrimaryKey
import kotlinx.serialization.Serializable
/**
@ -10,12 +9,10 @@ import kotlinx.serialization.Serializable
* @Date: 2024/11/6 19:24
*/
@Entity
@Serializable
@Entity(tableName = "user_info")
data class UserInfo(
@Id
@PrimaryKey(autoGenerate = true)
var id: Long = 0,
@Index
var username: String,
var nickname: String,
var avatarUrl: String,

View File

@ -1,25 +1,19 @@
package com.kaixed.kchat.data.repository
import com.kaixed.kchat.data.local.box.ObjectBox.getBoxStore
import com.kaixed.core.common.utils.Pinyin4jUtil
import com.kaixed.kchat.data.local.dao.ContactDao
import com.kaixed.kchat.data.local.entity.Contact
import com.kaixed.kchat.data.local.entity.Contact_
import com.kaixed.kchat.data.model.friend.FriendRequestItem
import com.kaixed.kchat.data.model.search.SearchUser
import com.kaixed.kchat.network.ApiCall.apiCall
import com.kaixed.kchat.network.RetrofitClient
import com.kaixed.kchat.utils.ConstantsUtils.getUsername
import com.kaixed.kchat.utils.Pinyin4jUtil
import com.kaixed.kchat.utils.handle.ContactUtil
import io.objectbox.Box
class ContactRepository {
class ContactRepository(private val contactDao: ContactDao) {
private val friendApiService = RetrofitClient.friendApiService
private val contactBox: Box<Contact> by lazy {
getBoxStore().boxFor(Contact::class.java)
}
// 获取联系人请求列表
suspend fun getContactRequestList(username: String): Result<List<FriendRequestItem>?> {
return apiCall(
@ -53,9 +47,7 @@ class ContactRepository {
apiCall = { friendApiService.deleteContact(username, contactId) },
errorMessage = "删除好友失败"
).onSuccess {
val con =
contactBox.query(Contact_.username.equal(contactId)).build().findFirst()
contactBox.remove(con!!)
contactDao.deleteContactByUsername(contactId)
}
}
@ -106,16 +98,26 @@ class ContactRepository {
}
}
suspend fun getContactsInDb(): List<Contact>? {
val contacts = contactDao.getAllContacts()
return if (contacts.isNotEmpty()) {
contacts.sortedWith(::compareContacts)
} else {
null
}
}
suspend fun setRemark(userId: String, contactId: String, remark: String): Result<String?> {
return apiCall(
apiCall = { friendApiService.setRemark(userId, contactId, remark) },
errorMessage = "设置备注失败"
).onSuccess {
val con = contactBox.query(Contact_.username.equal(contactId)).build().findFirst()
if (con != null) {
con.remark = remark
con.remarkquanpin = Pinyin4jUtil.toPinyin(remark)
contactBox.put(con)
val contact = contactDao.getContactByUsername(contactId)
contact?.apply {
this.remark = remark
this.remarkquanpin = Pinyin4jUtil.toPinyin(remark)
}?.let { contact ->
contactDao.updateContact(contact)
}
}
}

View File

@ -1,9 +1,28 @@
package com.kaixed.kchat.data.repository
import androidx.paging.Pager
import androidx.paging.PagingConfig
import androidx.paging.PagingData
import com.kaixed.kchat.data.local.dao.MessagesDao
import com.kaixed.kchat.data.local.entity.Messages
import kotlinx.coroutines.flow.Flow
/**
* @Author: kaixed
* @Date: 2024/12/11 9:43
*/
class MessagesRepository {
class MessagesRepository(private val messagesDao: MessagesDao) {
fun getMessageFlow(): Flow<PagingData<Messages>> {
return Pager(
config = PagingConfig(
pageSize = 20, // 每页加载的数量
enablePlaceholders = false,
prefetchDistance = 5, // 当距离边缘还有5个项目时开始加载下一页
initialLoadSize = 40 // 首次加载的数量
),
pagingSourceFactory = { messagesDao.getMessagesPaged() }
).flow
}
}

View File

@ -1,26 +1,22 @@
package com.kaixed.kchat.data.repository
import com.kaixed.kchat.data.local.box.ObjectBox.getBoxStore
import com.kaixed.kchat.data.local.dao.UserInfoDao
import com.kaixed.kchat.data.local.entity.UserInfo
import com.kaixed.kchat.data.local.entity.UserInfo_
import com.kaixed.kchat.data.model.request.RegisterRequest
import com.kaixed.kchat.data.model.response.register.Register
import com.kaixed.kchat.network.ApiCall.apiCall
import com.kaixed.kchat.network.RetrofitClient
import com.kaixed.kchat.utils.Constants.MMKV_USER_SESSION
import com.tencent.mmkv.MMKV
import io.objectbox.Box
/**
* @Author: kaixed
* @Date: 2024/11/23 14:15
*/
class UserAuthRepository {
class UserAuthRepository(private val userInfoDao: UserInfoDao) {
private val authApiService = RetrofitClient.authApiService
private val userInfoBox: Box<UserInfo> by lazy { getBoxStore().boxFor(UserInfo::class.java) }
private val mmkv by lazy { MMKV.mmkvWithID(MMKV_USER_SESSION) }
/**
@ -72,7 +68,7 @@ class UserAuthRepository {
}
}
private fun insertUserInfo(
private suspend fun insertUserInfo(
register: Register,
telephone: String
) {
@ -83,22 +79,21 @@ class UserAuthRepository {
signature = "",
telephone = telephone
)
userInfoBox.put(userInfo)
userInfoDao.saveUserInfo(userInfo)
}
private fun updateDb(userInfo: UserInfo) {
private suspend fun updateDb(userInfo: UserInfo) {
mmkv.apply {
encode("username", userInfo.username)
encode("nickname", userInfo.nickname)
encode("avatarUrl", userInfo.avatarUrl)
}
MMKV.defaultMMKV().encode("userLoginStatus", true)
val user = userInfoBox
.query(UserInfo_.username.equal(userInfo.username))
.build()
.findFirst()
if (user == null) {
userInfoBox.put(userInfo)
val existingUser = userInfoDao.getUserInfoByUsername(userInfo.username)
if (existingUser == null) {
// 如果用户不存在,插入新的用户信息
userInfoDao.saveUserInfo(userInfo)
}
}
}

View File

@ -1,8 +1,7 @@
package com.kaixed.kchat.data.repository
import com.kaixed.kchat.data.local.box.ObjectBox.getBoxStore
import com.kaixed.kchat.data.local.dao.UserInfoDao
import com.kaixed.kchat.data.local.entity.UserInfo
import com.kaixed.kchat.data.local.entity.UserInfo_
import com.kaixed.kchat.data.model.request.UpdatePasswordRequest
import com.kaixed.kchat.data.model.request.UserRequest
import com.kaixed.kchat.data.model.search.SearchUser
@ -18,7 +17,7 @@ import okhttp3.MultipartBody
* @Author: kaixed
* @Date: 2024/11/23 14:15
*/
class UserProfileRepository {
class UserProfileRepository(private val userInfoDao: UserInfoDao) {
private val userApiService = RetrofitClient.userApiService
@ -67,29 +66,22 @@ class UserProfileRepository {
)
}
private fun updateNickname(newNickname: String) {
val userInfoBox = getBoxStore().boxFor(UserInfo::class.java)
val user = userInfoBox
.query(UserInfo_.username.equal(getUsername()))
.build()
.findFirst()
private suspend fun updateNickname(newNickname: String) {
val userInfo = userInfoDao.getUserInfoByUsername(getUsername())
MMKV.mmkvWithID(MMKV_USER_SESSION).putString(NICKNAME_KEY, newNickname)
user?.nickname = newNickname
if (user != null) {
userInfoBox.put(user)
userInfo?.nickname = newNickname
userInfo?.apply {
nickname = newNickname
userInfoDao.saveUserInfo(this)
}
}
private fun updateDb(url: String) {
val userInfoBox = getBoxStore().boxFor(UserInfo::class.java)
val userInfo =
userInfoBox.query(UserInfo_.username.equal(getUsername())).build().findFirst()
if (userInfo != null) {
userInfo.avatarUrl = url
userInfoBox.put(userInfo)
private suspend fun updateDb(url: String) {
val userInfo = userInfoDao.getUserInfoByUsername(getUsername())
userInfo?.apply {
avatarUrl = url
userInfoDao.saveUserInfo(this)
}
}
}

View File

@ -1,6 +1,7 @@
package com.kaixed.kchat.data.repository
import com.kaixed.kchat.data.model.response.search.User
import com.kaixed.kchat.network.ApiCall.apiCall
import com.kaixed.kchat.network.RetrofitClient
/**
@ -12,20 +13,11 @@ class UserSearchRepository {
private val userApiService = RetrofitClient.userApiService
// 获取用户列表
suspend fun getUserList(username: String): Result<List<User>> {
suspend fun getUserList(username: String): Result<List<User>?> {
val requestParams = mapOf("username" to username)
return try {
val response = userApiService.fetchUserList(requestParams)
if (response.isSuccess()) {
val userList = response.getResponseData()
userList?.let {
Result.success(userList)
} ?: Result.failure(Exception("用户列表为空"))
} else {
Result.failure(Exception(response.getResponseMsg()))
}
} catch (e: Exception) {
Result.failure(e)
}
return apiCall(
apiCall = { userApiService.fetchUserList(requestParams) },
errorMessage = "用户列表为空"
)
}
}

View File

@ -2,16 +2,16 @@ package com.kaixed.kchat.manager
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import com.kaixed.kchat.data.DataBase
import com.kaixed.kchat.data.LocalDatabase.getContactByUsername
import com.kaixed.kchat.data.DataBase.getContactByUsername
import com.kaixed.kchat.data.event.UnreadEvent
import com.kaixed.kchat.data.local.entity.Contact_
import com.kaixed.kchat.data.local.AppDatabase
import com.kaixed.kchat.data.local.entity.Conversation
import com.kaixed.kchat.data.local.entity.Conversation_
import com.kaixed.kchat.data.local.entity.Messages
import com.kaixed.kchat.data.local.entity.Messages_
import com.kaixed.kchat.utils.ConstantsUtils.getCurrentContactId
import com.kaixed.kchat.utils.ConstantsUtils.getUsername
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import org.greenrobot.eventbus.EventBus
/**
@ -23,12 +23,10 @@ object ConversationManager {
private val _conversationLiveData = MutableLiveData<List<Conversation>?>()
val conversations: LiveData<List<Conversation>?> get() = _conversationLiveData
private val conversationBox by lazy { DataBase.conversationBox }
private val contactBox by lazy { DataBase.contactBox }
private val messagesBox by lazy { DataBase.messagesBox }
private val conversationMap: MutableMap<String, Conversation> = mutableMapOf()
private val conversationDao = AppDatabase.getDatabase().conversationDao()
private var unreadCount = 0
val unReadMsgCount get() = unreadCount
@ -37,25 +35,27 @@ object ConversationManager {
}
private fun loadConversations() {
val conversations = conversationBox.query(Conversation_.isShow.equal(true))
.orderDesc(Conversation_.timestamp).build().find()
val scope = CoroutineScope(Dispatchers.IO)
scope.launch {
val conversations = conversationDao.getConversations()
conversations.forEach { conversation ->
if (conversation.unreadCount > 0) {
unreadCount += conversation.unreadCount
conversations.forEach { conversation ->
if (conversation.unreadCount > 0) {
unreadCount += conversation.unreadCount
}
conversationMap[conversation.talkerId] = conversation
}
conversationMap[conversation.talkerId] = conversation
updateLiveData()
}
updateLiveData()
}
fun handleMessages(messages: Messages) {
suspend fun handleMessages(messages: Messages) {
val talkerId = getCurrentContactId()
if (messages.senderId != getUsername() && messages.talkerId != talkerId) {
unreadCount++
}
val updatedConversation = updateConversation(messages)
conversationBox.put(updatedConversation)
conversationDao.updateConversation(updatedConversation)
updateLiveData()
}
@ -78,7 +78,7 @@ object ConversationManager {
updateLiveData()
}
fun deleteConversation(conversation: Conversation) {
suspend fun deleteConversation(conversation: Conversation) {
val count = conversationMap[conversation.talkerId]?.unreadCount
count?.let {
if (it > 0) {
@ -86,48 +86,50 @@ object ConversationManager {
}
}
conversationMap.remove(conversation.talkerId)
conversationBox.query(Conversation_.talkerId.equal(conversation.talkerId)).build().remove()
messagesBox.query(Messages_.talkerId.equal(conversation.talkerId)).build().remove()
conversationDao.deleteConversationsByContactId(conversation.talkerId)
val messagesDao = AppDatabase.getDatabase().messagesDao()
messagesDao.deleteMessagesByContactId(conversation.talkerId)
updateLiveData()
}
fun cleanUnreadCount(talkerId: String) {
suspend fun cleanUnreadCount(talkerId: String) {
val con = conversationMap[talkerId]
unreadCount -= con?.unreadCount ?: 0
conversationMap[talkerId].apply {
this?.unreadCount = 0
}
val conversation =
conversationBox.query(Conversation_.talkerId.equal(talkerId)).build().findFirst()
val conversation = conversationDao.getConversationByUsername(talkerId)
conversation?.let {
it.unreadCount = 0
conversationBox.put(it)
conversation?.apply {
unreadCount = 0
conversationDao.updateConversation(this)
}
updateLiveData()
}
fun updateDisturbStatus(contactId: String, isDisturb: Boolean) {
private val contactDao = AppDatabase.getDatabase().contactDao()
suspend fun updateDisturbStatus(contactId: String, isDisturb: Boolean) {
conversationMap[contactId]?.apply {
this.doNotDisturb = isDisturb
doNotDisturb = isDisturb
conversationDao.updateConversation(this)
}
conversationBox.put(conversationMap[contactId]!!)
val dbContact =
contactBox.query(Contact_.username.equal(contactId)).build().findFirst().apply {
contactDao.getContactByUsername(contactId).apply {
this?.doNotDisturb = isDisturb
}
contactBox.put(dbContact!!)
contactDao.updateContact(dbContact!!)
updateLiveData()
}
fun updateUnreadCount(talkerId: String, isRead: Boolean) {
suspend fun updateUnreadCount(talkerId: String, isRead: Boolean) {
unreadCount = if (isRead) unreadCount - 1 else unreadCount + 1
val conversation = conversationMap[talkerId]?.apply {
this.unreadCount = if (isRead) 1 else 0
}
if (conversation != null) {
conversationBox.put(conversation)
conversationDao.insertConversation(conversation)
}
updateLiveData()
}
@ -138,7 +140,7 @@ object ConversationManager {
_conversationLiveData.postValue(conversationMap.values.sortedByDescending { it.timestamp })
}
private fun updateConversation(messages: Messages): Conversation {
private suspend fun updateConversation(messages: Messages): Conversation {
val existingConversation = conversationMap[messages.talkerId]
return existingConversation?.apply {
lastContent = if (messages.type == "4") "[图片]" else messages.content
@ -148,7 +150,7 @@ object ConversationManager {
} ?: createConversation(messages)
}
private fun createConversation(messages: Messages): Conversation {
private suspend fun createConversation(messages: Messages): Conversation {
val contact = getContactByUsername(messages.talkerId)
return Conversation(
talkerId = messages.talkerId,

View File

@ -1,13 +1,13 @@
package com.kaixed.kchat.manager
import com.kaixed.kchat.data.DataBase
import com.kaixed.kchat.data.LocalDatabase
import com.kaixed.kchat.data.LocalDatabase.getAllHistoryMessages
import com.kaixed.kchat.data.LocalDatabase.getMessagesWithContact
import com.kaixed.kchat.data.LocalDatabase.getMoreMessages
import com.kaixed.kchat.data.local.AppDatabase
import com.kaixed.kchat.data.local.entity.Messages
import com.kaixed.kchat.data.repository.MessagesRepository
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.collectLatest
import kotlin.collections.isNotEmpty
/**
* @Author: kaixed
@ -22,41 +22,57 @@ object MessagesManager {
val messages: StateFlow<List<Messages>> get() = _messages
private var contactId: String = ""
private var isHasHistory = false
private var loading = false
private val messagesDao = AppDatabase.getDatabase().messagesDao()
private var tempIndex: Long = 0
fun setContactId(contactId: String) {
this.contactId = contactId
}
suspend fun firstLoadMessages() {
val messages = messagesDao.getMessageByContactId(contactId, LIMIT + 1)
handleMessages(messages)
}
suspend fun loadMoreMessages() {
if (!isHasHistory) {
return
}
val messages = messagesDao.getMessageByContactId(contactId, tempIndex, LIMIT + 1)
handleMessages(messages)
}
private fun handleMessages(messages: List<Messages>) {
if (messages.isNotEmpty()) {
val size = messages.size
isHasHistory = size > LIMIT
_messages.value += if (isHasHistory) {
tempIndex = messages[size - 1].msgLocalId
messages.subList(0, LIMIT.toInt())
} else {
messages
}
}
}
fun deleteMessage(msgLocalId: Long) {
DataBase.messagesBox.remove(msgLocalId)
DataBase.deleteMsgByLocalId(msgLocalId)
val msg = _messages.value.toMutableList().apply {
removeIf { it.msgLocalId == msgLocalId }
}
_messages.value = msg
}
fun queryHistory(msgLocalId: Long): Int {
_messages.value = emptyList()
val msg = getAllHistoryMessages(contactId, msgLocalId)
_messages.value = msg
return msg.size
}
fun setContactId(contactId: String) {
this.contactId = contactId
}
fun resetMessages() {
_messages.value = emptyList()
}
fun cleanMessages(timestamp: Long) {
val messages = _messages.value.toMutableList()
_messages.value = messages.apply {
removeIf { it.timestamp < timestamp }
}
LocalDatabase.cleanChatHistory(contactId)
}
fun receiveMessage(messages: Messages) {
if (messages.talkerId != contactId) return
if (_messages.value.first() == messages) return
@ -66,49 +82,9 @@ object MessagesManager {
}
fun sendMessages(messages: Messages) {
_messages.value = _messages.value.toMutableList().apply {
add(0, messages)
}
}
fun firstLoadMessages() {
val messages = getMessagesWithContact(contactId, 0, LIMIT + 1)
if (messages.isNotEmpty()) {
val size = messages.size
isHasHistory = size > LIMIT
if (isHasHistory) {
val messages1 = messages.subList(0, LIMIT.toInt())
_messages.value = messages1
tempIndex = messages[size - 1].msgLocalId
} else {
_messages.value = messages
}
}
}
fun loadMoreMessages() {
if (loading) return
if (!isHasHistory) return
loading = true
val newMessages: List<Messages> =
getMoreMessages(contactId, tempIndex, LIMIT + 1)
val size = newMessages.size
tempIndex = newMessages[size - 1].msgLocalId
isHasHistory = size > LIMIT
if (newMessages.isNotEmpty()) {
val messages1 = if (isHasHistory) {
newMessages.subList(0, LIMIT.toInt()).toMutableList()
} else {
newMessages.subList(0, newMessages.size).toMutableList()
}
_messages.value += messages1
}
loading = false
_messages.value = mutableListOf(messages) + _messages.value
// _messages.value = _messages.value.toMutableList().apply {
// add(0, messages)
// }
}
}

View File

@ -0,0 +1,19 @@
package com.kaixed.kchat.manager
import android.content.Context
object NetworkManager {
private lateinit var networkStateManager: NetworkStateManager
fun init(context: Context) {
networkStateManager = NetworkStateManager(context.applicationContext)
}
fun start() {
networkStateManager.startListening()
}
fun release() {
networkStateManager.stopListening()
}
}

View File

@ -0,0 +1,86 @@
package com.kaixed.kchat.manager
import android.content.Context
import android.net.ConnectivityManager
import android.net.Network
import android.net.NetworkCapabilities
import android.net.NetworkRequest
import com.kaixed.kchat.App
class NetworkStateManager(context: Context) {
private val connectivityManager =
context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
private val networkCallback = object : ConnectivityManager.NetworkCallback() {
override fun onAvailable(network: Network) {
currentNetworkState = NetworkState.CONNECTED
notifyListeners()
}
override fun onLost(network: Network) {
currentNetworkState = NetworkState.DISCONNECTED
notifyListeners()
}
override fun onCapabilitiesChanged(network: Network, capabilities: NetworkCapabilities) {
// 判断网络类型和质量
currentNetworkState = when {
capabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) -> NetworkState.WIFI
capabilities.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) -> NetworkState.CELLULAR
else -> NetworkState.CONNECTED
}
// 判断信号强度
signalStrength = when {
capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_CONGESTED)
&& capabilities.linkDownstreamBandwidthKbps > 2000 -> SignalStrength.STRONG
capabilities.linkDownstreamBandwidthKbps < 500 -> SignalStrength.WEAK
else -> SignalStrength.NORMAL
}
notifyListeners()
}
}
private var currentNetworkState: NetworkState = NetworkState.UNKNOWN
private var signalStrength: SignalStrength = SignalStrength.UNKNOWN
private val listeners = mutableListOf<NetworkStateListener>()
fun startListening() {
val request = NetworkRequest.Builder().build()
connectivityManager.registerNetworkCallback(request, networkCallback)
}
fun stopListening() {
connectivityManager.unregisterNetworkCallback(networkCallback)
}
fun getCurrentNetworkInfo(): Pair<NetworkState, SignalStrength> {
return Pair(currentNetworkState, signalStrength)
}
fun addListener(listener: NetworkStateListener) {
listeners.add(listener)
}
fun removeListener(listener: NetworkStateListener) {
listeners.remove(listener)
}
private fun notifyListeners() {
listeners.forEach { it.onNetworkStateChanged(currentNetworkState, signalStrength) }
}
enum class NetworkState {
UNKNOWN, CONNECTED, DISCONNECTED, WIFI, CELLULAR
}
enum class SignalStrength {
UNKNOWN, WEAK, NORMAL, STRONG
}
interface NetworkStateListener {
fun onNetworkStateChanged(state: NetworkState, strength: SignalStrength)
}
}

View File

@ -7,7 +7,7 @@ package com.kaixed.kchat.network
object NetworkInterface {
// private const val URL = "app.kaixed.com/kchat"
private const val URL = "192.168.31.18:6196"
private const val URL = "192.168.225.209:6196"
// private const val URL = "49.233.105.103:6000"
// const val SERVER_URL = "https://$URL"
const val SERVER_URL = "http://$URL"
@ -44,7 +44,7 @@ object NetworkInterface {
// 获取好友列表
const val GET_FRIENDS = "friends/list"
// 获取好友请求列表
const val GET_FRIEND_LIST = "friends/requests/fetch"
const val GET_FRIEND_REQUEST_LIST = "friends/requests/fetch"
// 删除好友
const val DELETE_FRIEND = "friends/delete-friend"
// 设置好友备注

View File

@ -1,7 +1,5 @@
package com.kaixed.kchat.network
import com.kaixed.kchat.network.interceptor.SignInterceptor
import com.kaixed.kchat.network.interceptor.TokenRefreshInterceptor
import com.kaixed.kchat.network.service.AuthApiService
import com.kaixed.kchat.network.service.FileApiService
import com.kaixed.kchat.network.service.FriendApiService
@ -49,8 +47,8 @@ object RetrofitClient {
private val client = OkHttpClient.Builder()
.addInterceptor(loggingInterceptor)
.pingInterval(15, TimeUnit.SECONDS)
.addInterceptor(SignInterceptor()) // 签名拦截器
.addInterceptor(TokenRefreshInterceptor()) // Token 拦截器
// .addInterceptor(SignInterceptor()) // 签名拦截器
// .addInterceptor(TokenRefreshInterceptor()) // Token 拦截器
.build()
// 创建 Retrofit 实例(用于普通 API 调用)

View File

@ -1,5 +1,6 @@
package com.kaixed.kchat.network.interceptor
import com.kaixed.kchat.utils.Constants
import okhttp3.FormBody
import okhttp3.Interceptor
import okhttp3.Request
@ -10,8 +11,6 @@ import java.util.*
class SignInterceptor : Interceptor {
private val secretKey = "YourSecretKey" // 后端秘钥
@Throws(IOException::class)
override fun intercept(chain: Interceptor.Chain): Response {
val request = chain.request()
@ -26,7 +25,7 @@ class SignInterceptor : Interceptor {
val params = getRequestParams(request)
// 生成签名
val sign = generateServerSign(params, timestamp, nonce, secretKey)
val sign = generateServerSign(params, timestamp, nonce)
// 构造新的请求,添加请求头
val newRequest = request.newBuilder()
@ -41,43 +40,46 @@ class SignInterceptor : Interceptor {
// 获取请求的参数,处理 POST 请求的 Map 格式数据,并确保按字典顺序排序
private fun getRequestParams(request: Request): Map<String, String> {
val params = mutableMapOf<String, String>()
val params = TreeMap<String, String>()
// 处理 GET 请求
if (request.method == "GET") {
val url = request.url
for (i in 0 until url.querySize) {
params[url.queryParameterName(i)] = url.queryParameterValue(i) ?: ""
}
}
// 处理 POST 请求Map 格式)
else if (request.method == "POST") {
val body = request.body
if (body is FormBody) {
// 获取所有参数,并确保按字典顺序排序
for (i in 0 until body.size) {
params[body.name(i)] = body.value(i)
when (request.method) {
"GET" -> {
request.url.queryParameterNames.forEach { name ->
params[name] = request.url.queryParameter(name) ?: ""
}
}
}
// 按字典顺序排序参数
return params.toSortedMap()
"POST" -> {
val body = request.body
if (body is FormBody) {
// 获取所有参数,并确保按字典顺序排序
for (i in 0 until body.size) {
params[body.name(i)] = body.value(i)
}
}
}
else -> {
// 其他请求方法不处理
}
}
return params
}
// 生成服务端签名
private fun generateServerSign(params: Map<String, String>, timestamp: String, nonce: String, secretKey: String): String {
val sortedParams = params.toSortedMap() // 确保是按字典顺序排序
private fun generateServerSign(
sortedParams: Map<String, String>,
timestamp: String,
nonce: String,
): String {
// 拼接参数
val sb = StringBuilder()
for ((key, value) in sortedParams) {
sortedParams.forEach { (key, value) ->
sb.append("$key=$value&")
}
sb.append("timestamp=$timestamp&")
sb.append("nonce=$nonce&")
sb.append("secretKey=$secretKey")
sb.append("secretKey=${Constants.Sign.SECRET_KEY}")
// 使用 SHA-256 进行加密
return hashWithSHA256(sb.toString())

View File

@ -28,7 +28,6 @@ class TokenRefreshInterceptor() : Interceptor {
val expiration = parseTokenManually(accessToken)
val refreshTokenExpiration = parseTokenManually(refreshToken)
val now = System.currentTimeMillis() / 1000
Log.d("haha", now.toString())
// 如果 Token 距离过期时间小于 5 分钟,刷新 Token
if (expiration - now < 300) {

View File

@ -6,7 +6,7 @@ import com.kaixed.kchat.data.model.search.SearchUser
import com.kaixed.kchat.network.ApiResponse
import com.kaixed.kchat.network.NetworkInterface.ACCEPT_FRIEND_REQUEST
import com.kaixed.kchat.network.NetworkInterface.DELETE_FRIEND
import com.kaixed.kchat.network.NetworkInterface.GET_FRIEND_LIST
import com.kaixed.kchat.network.NetworkInterface.GET_FRIEND_REQUEST_LIST
import com.kaixed.kchat.network.NetworkInterface.SEND_FRIEND_REQUEST
import com.kaixed.kchat.network.NetworkInterface.SET_FRIEND_REMARK
import retrofit2.http.Body
@ -23,9 +23,9 @@ import retrofit2.http.Path
interface FriendApiService {
// 获取好友请求列表
@FormUrlEncoded
@POST(GET_FRIEND_LIST)
@POST(GET_FRIEND_REQUEST_LIST)
suspend fun getContactRequestList(
@Field("userId") username: String
@Field("userAccount") username: String
): ApiResponse<List<FriendRequestItem>?>
// 接受好友请求

View File

@ -1,9 +1,11 @@
package com.kaixed.kchat.processor
import com.kaixed.kchat.data.local.box.ObjectBox.getBox
import com.kaixed.kchat.data.local.AppDatabase
import com.kaixed.kchat.data.local.entity.Messages
import com.kaixed.kchat.data.local.entity.Messages_
import io.objectbox.Box
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
/**
* @Author: kaixed
@ -13,20 +15,21 @@ object MessageProcessor {
private const val INTERVAL = 5
private val messagesBox: Box<Messages> by lazy { getBox(Messages::class.java) }
private val messagesDao = AppDatabase.getDatabase().messagesDao()
fun processorMsg(messages: Messages): Messages {
suspend fun processorMsg(messages: Messages): Messages {
val curTimestamp = System.currentTimeMillis()
val localMsg = messagesBox.query(Messages_.talkerId.equal(messages.talkerId))
.lessOrEqual(Messages_.timestamp, curTimestamp)
.orderDesc(Messages_.timestamp).build().findFirst()
var showTimer = true
localMsg?.let {
showTimer = curTimestamp - it.timestamp >= INTERVAL * 1L * 60 * 1000
return withContext(Dispatchers.IO) {
val localMsg = messagesDao.getLatestMessage(messages.talkerId, curTimestamp)
var showTimer = true
localMsg?.let {
showTimer = curTimestamp - it.timestamp >= INTERVAL * 1L * 60 * 1000
}
messages.isShowTimer = showTimer
messages
}
messages.isShowTimer = showTimer
return messages
}
}

View File

@ -0,0 +1,22 @@
package com.kaixed.kchat.send
// 模拟持久化存储可考虑使用Room数据库代替。
object MessageRepo {
private val taskStore = mutableMapOf<Long, MessageTask>()
fun save(task: MessageTask) {
taskStore[task.id] = task
}
fun update(task: MessageTask) {
taskStore[task.id] = task
}
fun remove(taskId: Long) {
taskStore.remove(taskId)
}
fun loadAll(): List<MessageTask> {
return taskStore.values.toList()
}
}

View File

@ -0,0 +1,14 @@
package com.kaixed.kchat.send
data class MessageTask(
val id: Long,
val payload: String,
val maxRetry: Int = 5,
var retryCount: Int = 0,
var nextRetryTime: Long = System.currentTimeMillis(),
var state: MessageState = MessageState.PENDING
)
enum class MessageState {
PENDING, SENDING, SUCCESS, FAILED
}

View File

@ -0,0 +1,16 @@
package com.kaixed.kchat.send
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
// NetworkMonitor 用于监听网络状态变化,并通知 MessageUploadManager
object NetworkMonitor {
// 这里可以用 LiveData 或 Flow 来实时通知变化
private val _networkAvailable = MutableStateFlow(true)
val networkAvailable: StateFlow<Boolean> get() = _networkAvailable
// 模拟切换,实际需集成 ConnectivityManager 回调
fun setNetworkAvailable(available: Boolean) {
_networkAvailable.value = available
}
}

View File

@ -0,0 +1,37 @@
package com.kaixed.kchat.send
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
class RetryQueueManager {
// 使用 MutableStateFlow 保存当前任务列表
private val _taskFlow = MutableStateFlow<List<MessageTask>>(emptyList())
val taskFlow: StateFlow<List<MessageTask>> get() = _taskFlow
// 添加任务到队列,并持久化
fun addTask(task: MessageTask) {
MessageRepo.save(task)
_taskFlow.value = _taskFlow.value + task
}
// 更新任务状态
fun updateTask(task: MessageTask) {
MessageRepo.update(task)
_taskFlow.value = _taskFlow.value.map { if (it.id == task.id) task else it }
}
// 从队列中移除任务
fun removeTask(task: MessageTask) {
MessageRepo.remove(task.id)
_taskFlow.value = _taskFlow.value.filter { it.id != task.id }
}
// 获取当前已到达可上传时间的任务(按 nextRetryTime 排序)
fun getReadyTask(): MessageTask? {
val now = System.currentTimeMillis()
return _taskFlow.value
.filter { it.state == MessageState.PENDING && it.nextRetryTime <= now }
.sortedWith(compareBy<MessageTask> { it.nextRetryTime })
.firstOrNull()
}
}

View File

@ -0,0 +1,125 @@
package com.kaixed.kchat.send
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Semaphore
import kotlinx.coroutines.withTimeoutOrNull
import kotlin.math.pow
import kotlin.random.Random
class UploadWorker(private val semaphore: Semaphore) {
// 模拟消息上传接口
suspend fun upload(task: MessageTask): Boolean {
// 模拟上传耗时 & 网络请求,可用 Retrofit 或 OkHttp 实现
delay(500L) // 模拟网络延迟
// 模拟成功率 80%
return Random.nextInt(100) < 80
}
}
class MessageUploadManager(
private val retryQueue: RetryQueueManager,
private val worker: UploadWorker,
private val ackTimeout: Long = 5000L // ACK 超时设为 5 秒
) {
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
// 限制并发上传数量,例如最多 3 个同时上传
private val semaphore = Semaphore(3)
// 启动调度流程,监视重传队列
fun start() {
// 持续监听任务队列
scope.launch {
while (isActive) {
// 检查网络状态(这里应嵌入 NetworkMonitor 判断,可扩展为挂起函数)
if (!isNetworkAvailable()) {
delay(2000L)
continue
}
// 从队列中选择可上传任务
val task = retryQueue.getReadyTask()
if (task != null) {
// 进入发送状态
task.state = MessageState.SENDING
retryQueue.updateTask(task)
// 限流并发执行上传任务
semaphore.acquire()
launch {
val success = worker.upload(task)
// 模拟等待 ACK 的逻辑
// 如果上传成功,则等待 ackTimeout 时间后检查是否收到 ACK
if (success) {
// 调用 ackWaitTimer 监听 ACK
withTimeoutOrNull(ackTimeout) {
waitForAck(task.id.toString())
}?.let {
// 成功收到 ACK
task.state = MessageState.SUCCESS
retryQueue.updateTask(task)
retryQueue.removeTask(task)
} ?: run {
// 超时未收到 ACK则作为失败处理
retryTask(task)
}
} else {
// 上传失败处理
retryTask(task)
}
semaphore.release()
}
} else {
// 无任务时稍作延迟
delay(1000L)
}
}
}
}
// 模拟网络状态检测函数,可结合 ConnectivityManager 使用
private fun isNetworkAvailable(): Boolean {
// 实际应检测网络连接
return true
}
// 模拟等待 ACK实际可通过 Channel 或 WebSocket 收到 ack 后返回
private suspend fun waitForAck(taskId: String) {
// 这里模拟 ack 延迟,实际情况中应由 AckHandler 回调触发
delay(300L) // 模拟短延时后收到 ACK
}
// 重试逻辑:更新重试次数、计算下次重试时间,然后回到队列
private fun retryTask(task: MessageTask) {
task.retryCount++
if (task.retryCount > task.maxRetry) {
task.state = MessageState.FAILED
retryQueue.updateTask(task)
// 可在此处上报失败或通知用户
} else {
// 计算指数退避时间(如基于 2000ms 基数,再加上少量随机抖动)
val newDelay =
(2000L * 2.0.pow(task.retryCount.toDouble())).toLong() + Random.nextLong(500)
task.nextRetryTime = System.currentTimeMillis() + newDelay
task.state = MessageState.PENDING
retryQueue.updateTask(task)
}
}
// 当收到 ACK 时调用,由 AckHandler 或网络消息回调触发
fun onAckReceived(taskId: Long) {
// 查找任务并更新状态
val task = retryQueue.taskFlow.value.find { it.id == taskId }
if (task != null) {
task.state = MessageState.SUCCESS
retryQueue.updateTask(task)
retryQueue.removeTask(task)
}
}
}

View File

@ -0,0 +1,24 @@
package com.kaixed.kchat.service
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.launch
class ChatViewModel : ViewModel() {
fun sendChatMessage(content: String, msgLocalId: Long) {
viewModelScope.launch {
MessageRepository.sendMessage(content, msgLocalId).fold(
onSuccess = { /* 消息发送成功 */ },
onFailure = { /* 消息发送失败 */ }
)
}
}
fun observeMessages() {
viewModelScope.launch {
MessageRepository.observeMessages().collect { message ->
println("Received message: $message")
}
}
}
}

View File

@ -0,0 +1,16 @@
package com.kaixed.kchat.service
data class MessageModel(
var msgLocalId: Long = 0L,
var msgSvrId: Long = 0L,
var content: String,
var timestamp: Long,
var status: String = "normal",
var senderId: String,
var avatarUrl: String = "",
var talkerId: String,
var type: String,
var show: Boolean = true,
var isShowTimer: Boolean = false,
var isSender: Boolean = false
)

View File

@ -0,0 +1,7 @@
package com.kaixed.kchat.service
import kotlinx.coroutines.flow.Flow
interface MessageReceiver {
fun observeMessages(): Flow<String>
}

View File

@ -0,0 +1,46 @@
package com.kaixed.kchat.service
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.asSharedFlow
object MessageRepository : MessageSender, MessageReceiver {
private val webSocketClient: WebSocketClient = OkHttpWebSocketClient
// 创建一个 Flow 来发送 WebSocket 的消息
private val _outgoingMessages = MutableSharedFlow<String>(
replay = 0,
extraBufferCapacity = 10
)
// 创建一个 Flow 来接收 WebSocket 的消息
private val _incomingMessages = MutableSharedFlow<String>(
replay = 0,
extraBufferCapacity = 10
)
init {
// 设置 WebSocket 消息监听器
webSocketClient.setMessageListener { text ->
_incomingMessages.tryEmit(text)
}
}
override suspend fun sendMessage(message: String, msgLocalId: Long): Result<Unit> {
return try {
if (webSocketClient.send(message, msgLocalId)) {
_outgoingMessages.emit(message)
Result.success(Unit)
} else {
Result.failure(Exception("WebSocket not connected"))
}
} catch (e: Exception) {
Result.failure(e)
}
}
override fun observeMessages(): Flow<String> = _incomingMessages.asSharedFlow()
fun observeOutgoingMessages(): Flow<String> = _outgoingMessages.asSharedFlow()
}

View File

@ -0,0 +1,5 @@
package com.kaixed.kchat.service
interface MessageSender {
suspend fun sendMessage(message: String, msgLocalId: Long): Result<Unit>
}

View File

@ -0,0 +1,124 @@
package com.kaixed.kchat.service
import android.util.Log
import com.google.gson.Gson
import com.kaixed.kchat.data.local.entity.Messages
import com.kaixed.kchat.network.NetworkInterface.WEBSOCKET
import com.kaixed.kchat.network.NetworkInterface.WEBSOCKET_SERVER_URL
import com.kaixed.kchat.network.OkhttpHelper
import com.kaixed.kchat.utils.Constants.MMKV_COMMON_DATA
import com.kaixed.kchat.utils.ConstantsUtils
import com.kaixed.kchat.utils.ConstantsUtils.getUsername
import com.tencent.mmkv.MMKV
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import okhttp3.Request
import okhttp3.Response
import okhttp3.WebSocket
import okhttp3.WebSocketListener
object OkHttpWebSocketClient : WebSocketClient {
private val scope: CoroutineScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
private val url: String = "$WEBSOCKET_SERVER_URL$WEBSOCKET${getUsername()}"
private lateinit var webSocket: WebSocket
private var messageListener: ((String) -> Unit)? = null
private var isConnected = false
private const val TAG = "WebSocketService"
private const val WEBSOCKET_CLOSE_CODE = 1000
override fun connect() {
establishConnection()
}
override fun send(message: String, msgLocalId: Long): Boolean {
return if (isConnected) {
val kv = MMKV.mmkvWithID(MMKV_COMMON_DATA)
kv.putLong("msgLocalId", msgLocalId)
webSocket.send(message)
} else {
false
}
}
override fun close(code: Int, reason: String) {
webSocket.close(code, reason)
isConnected = false
}
override fun setMessageListener(listener: (String) -> Unit) {
this.messageListener = listener
}
override fun isConnected(): Boolean = isConnected
private fun reconnect(attempt: Int = 1) {
scope.launch {
val delayMs = minOf(attempt * 1000L, 30000L) // 指数退避,最大 30 秒
delay(delayMs)
establishConnection()
if (!isConnected()) {
reconnect(attempt + 1)
}
}
}
private fun establishConnection() {
val request = Request.Builder()
.url(url).build()
val listener = EchoWebSocketListener()
val client = OkhttpHelper.getInstance()
webSocket = client.newWebSocket(request, listener)
}
private class EchoWebSocketListener : WebSocketListener() {
override fun onOpen(webSocket: WebSocket, response: Response) {
Log.d(TAG, "WebSocket Opened")
isConnected = true
}
override fun onMessage(webSocket: WebSocket, text: String) {
messageListener?.invoke(text)
val messages = Gson().fromJson(text, Messages::class.java)
// serviceScope.launch {
// if ("ack" == messages.type) {
// val existedMessage =
// messagesDao.getMessageByLocalId(messages.msgLocalId)
//
// existedMessage.apply {
// timestamp = messages.timestamp
// if (messages.msgSvrId == 0L) {
// messages.msgSvrId = messages.msgLocalId
// }
// messagesDao.updateMessage(this)
// }
// } else {
// messages.talkerId = messages.senderId
// _messagesMutableLiveData.postValue(messages)
// MessageProcessor.processorMsg(messages)
// messagesDao.insert(messages)
// ConversationManager.handleMessages(messages)
// }
// }
}
override fun onClosing(webSocket: WebSocket, code: Int, reason: String) {
isConnected = false
webSocket.close(WEBSOCKET_CLOSE_CODE, null)
Log.d(TAG, "WebSocket closing: $reason")
establishConnection()
}
override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
isConnected = false
Log.d(TAG, "WebSocket closing: ${t.cause}")
Log.d(TAG, "WebSocket closing: $t")
establishConnection()
}
}
}

View File

@ -0,0 +1,9 @@
package com.kaixed.kchat.service
interface WebSocketClient {
fun send(message: String, msgLocalId: Long): Boolean
fun close(code: Int, reason: String)
fun setMessageListener(listener: (String) -> Unit)
fun connect()
fun isConnected(): Boolean
}

View File

@ -6,9 +6,9 @@ import android.os.Binder
import android.os.IBinder
import android.util.Log
import com.google.gson.Gson
import com.kaixed.kchat.data.local.box.ObjectBox.getBoxStore
import com.kaixed.kchat.data.local.entity.Contact
import com.kaixed.kchat.data.local.entity.Contact_
import com.kaixed.core.common.utils.SingleLiveEvent
import com.kaixed.kchat.data.DataBase
import com.kaixed.kchat.data.local.AppDatabase
import com.kaixed.kchat.data.local.entity.Messages
import com.kaixed.kchat.manager.ConversationManager
import com.kaixed.kchat.manager.MessagesManager
@ -16,16 +16,17 @@ import com.kaixed.kchat.network.NetworkInterface.WEBSOCKET
import com.kaixed.kchat.network.NetworkInterface.WEBSOCKET_SERVER_URL
import com.kaixed.kchat.network.OkhttpHelper
import com.kaixed.kchat.processor.MessageProcessor
import com.kaixed.kchat.service.OkHttpWebSocketClient
import com.kaixed.kchat.utils.Constants.MMKV_COMMON_DATA
import com.kaixed.kchat.utils.ConstantsUtils.getUsername
import com.kaixed.kchat.utils.SingleLiveEvent
import com.tencent.mmkv.MMKV
import io.objectbox.Box
import io.objectbox.kotlin.boxFor
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Dispatchers.IO
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import kotlinx.serialization.json.Json
import okhttp3.Request
import okhttp3.Response
import okhttp3.WebSocket
@ -42,26 +43,16 @@ class WebSocketService : Service() {
private const val WEBSOCKET_CLOSE_CODE = 1000
}
private val messagesBox: Box<Messages> by lazy { getBoxStore().boxFor() }
private val contactBox: Box<Contact> by lazy { getBoxStore().boxFor() }
private lateinit var username: String
private val webSocketClient: WebSocketClient = OkHttpWebSocketClient
private val binder = LocalBinder()
private var webSocket: WebSocket? = null
private var heartbeatJob: Job? = null
private val serviceJob = Job()
private val serviceScope = CoroutineScope(IO + serviceJob)
private val _messagesMutableLiveData = SingleLiveEvent<Messages?>()
val messageLivedata: SingleLiveEvent<Messages?> get() = _messagesMutableLiveData
private val scope = CoroutineScope(IO + SupervisorJob())
inner class LocalBinder : Binder() {
fun getService(): WebSocketService {
return this@WebSocketService
@ -74,99 +65,38 @@ class WebSocketService : Service() {
override fun onCreate() {
super.onCreate()
username = getUsername()
if (!EventBus.getDefault().isRegistered(this)) {
EventBus.getDefault().register(this)
webSocketClient.connect()
collectOutgoingMessages()
}
private fun collectOutgoingMessages() {
scope.launch {
MessageRepository.observeOutgoingMessages().collect { message ->
// val json = Json.encodeToString(MessageModel.serializer(), message)
val id = 0L
webSocketClient.send(message, id)
}
}
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
establishConnection()
webSocketClient.connect()
return START_STICKY
}
@Subscribe(threadMode = ThreadMode.MAIN)
fun onMessageEvent(contactId: String) {
val timestamp = System.currentTimeMillis()
MessagesManager.cleanMessages(timestamp)
ConversationManager.hideConversation(contactId)
}
fun sendMessage(jsonObject: String, msgLocalId: Long) {
webSocket?.let {
it.send(jsonObject)
val kv = MMKV.mmkvWithID(MMKV_COMMON_DATA)
kv.putLong("msgLocalId", msgLocalId)
}
}
fun storeOwnerMsg(messages: Messages) {
if (messages.avatarUrl == "") {
val contact =
contactBox.query(Contact_.username.equal(messages.talkerId)).build().findFirst()
messages.avatarUrl = contact?.avatarUrl ?: ""
}
ConversationManager.handleMessages(messages)
}
private fun establishConnection() {
if (webSocket == null) {
val request = Request.Builder()
.url("$WEBSOCKET_SERVER_URL$WEBSOCKET$username").build()
val listener = EchoWebSocketListener()
val client = OkhttpHelper.getInstance()
webSocket = client.newWebSocket(request, listener)
}
}
private inner class EchoWebSocketListener : WebSocketListener() {
override fun onOpen(webSocket: WebSocket, response: Response) {
Log.d(TAG, "WebSocket Opened")
}
override fun onMessage(webSocket: WebSocket, text: String) {
val messages = Gson().fromJson(text, Messages::class.java)
serviceScope.launch {
if ("ack" == messages.type) {
val existingMessage = messagesBox.get(messages.msgLocalId)
existingMessage?.let {
it.timestamp = messages.timestamp
it.msgSvrId = messages.msgSvrId
messagesBox.put(it)
} ?: run {
messagesBox.put(messages)
}
} else {
messages.talkerId = messages.senderId
_messagesMutableLiveData.postValue(messages)
MessageProcessor.processorMsg(messages)
messagesBox.put(messages)
ConversationManager.handleMessages(messages)
}
scope.launch {
if (messages.avatarUrl == "") {
val contact = DataBase.getContactByUsername(messages.talkerId)
messages.avatarUrl = contact?.avatarUrl ?: ""
}
}
override fun onClosing(webSocket: WebSocket, code: Int, reason: String) {
webSocket.close(WEBSOCKET_CLOSE_CODE, null)
Log.d(TAG, "WebSocket closing: $reason")
establishConnection()
}
override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
Log.d(TAG, "WebSocket closing: ${t.cause}")
Log.d(TAG, "WebSocket closing: $t")
establishConnection()
ConversationManager.handleMessages(messages)
}
}
override fun onDestroy() {
super.onDestroy()
if (EventBus.getDefault().isRegistered(this)) {
EventBus.getDefault().unregister(this)
}
serviceJob.cancel()
heartbeatJob?.cancel()
webSocket?.close(WEBSOCKET_CLOSE_CODE, "App exited")
webSocketClient.close(WEBSOCKET_CLOSE_CODE, "App exited")
}
}

View File

@ -0,0 +1,18 @@
package com.kaixed.kchat.startup
import android.content.Context
import android.util.Log
import androidx.startup.Initializer
import com.tencent.mmkv.MMKV
class MMKVInitializer : Initializer<String> {
override fun create(context: Context): String {
Log.d("StartupTest", "MMKV Initialized")
val rootDir = MMKV.initialize(context)
return rootDir
}
override fun dependencies(): List<Class<out Initializer<*>>> {
return emptyList()
}
}

View File

@ -0,0 +1,18 @@
package com.kaixed.kchat.startup
import android.content.Context
import android.util.Log
import androidx.startup.Initializer
import com.kaixed.kchat.utils.ScreenUtils
class ScreenUtilsInitializer : Initializer<ScreenUtils> {
override fun create(context: Context): ScreenUtils {
Log.d("StartupTest", "ScreenUtils Initialized")
ScreenUtils.init(context.applicationContext)
return ScreenUtils
}
override fun dependencies(): List<Class<out Initializer<*>>> =
listOf(MMKVInitializer::class.java)
}

View File

@ -7,13 +7,6 @@ import androidx.activity.enableEdgeToEdge
import com.kaixed.kchat.databinding.ActivityAddFriendsBinding
import com.kaixed.kchat.ui.base.BaseActivity
/**
* 该文件包含用于兼容不同 Android 版本的 Intent 获取 Parcelable 数据的扩展函数
* 通过扩展函数 getParcelableExtraCompat 解决了低版本和高版本 Android 系统间的差异
*
* @author kaixed
* @since 2025年1月26日
*/
class AddFriendsActivity : BaseActivity<ActivityAddFriendsBinding>() {
private val context: Context by lazy { this }

View File

@ -2,21 +2,23 @@ package com.kaixed.kchat.ui.activity
import android.os.Bundle
import androidx.activity.enableEdgeToEdge
import androidx.activity.viewModels
import com.kaixed.core.common.base.BaseActivity
import com.kaixed.kchat.databinding.ActivityApplyAddFriendBinding
import com.kaixed.kchat.ui.base.BaseActivity
import com.kaixed.kchat.viewmodel.ContactViewModel
class ApplyAddFriendActivity : BaseActivity<ActivityApplyAddFriendBinding>() {
private val contactViewModel: ContactViewModel by viewModels()
class ApplyAddFriendActivity : BaseActivity<ActivityApplyAddFriendBinding, ContactViewModel>() {
private lateinit var contactId: String
private lateinit var contactNickname: String
override fun inflateBinding(): ActivityApplyAddFriendBinding =
ActivityApplyAddFriendBinding.inflate(layoutInflater)
override fun getViewModelClass(): Class<ContactViewModel> {
return ContactViewModel::class.java
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
@ -28,7 +30,7 @@ class ApplyAddFriendActivity : BaseActivity<ActivityApplyAddFriendBinding>() {
}
override fun observeData() {
contactViewModel.addContactResult
viewModel.addContactResult
.observe(this) { result ->
result.onSuccess {
toast(result.getOrNull().toString())
@ -52,7 +54,7 @@ class ApplyAddFriendActivity : BaseActivity<ActivityApplyAddFriendBinding>() {
private fun sendContactRequest(contactId: String?) {
contactId?.let {
contactViewModel.addContact(contactId, binding.etMessage.text.toString())
viewModel.addContact(contactId, binding.etMessage.text.toString())
}
}
}

View File

@ -4,9 +4,9 @@ import android.content.Intent
import android.os.Bundle
import androidx.activity.enableEdgeToEdge
import com.bumptech.glide.Glide
import com.kaixed.core.common.extension.getParcelableExtraCompat
import com.kaixed.kchat.data.model.friend.FriendRequestItem
import com.kaixed.kchat.databinding.ActivityApproveDetailBinding
import com.kaixed.kchat.extensions.getParcelableExtraCompat
import com.kaixed.kchat.ui.base.BaseActivity
class ApproveDetailActivity : BaseActivity<ActivityApproveDetailBinding>() {

View File

@ -6,7 +6,6 @@ import android.content.Context
import android.content.Intent
import android.content.ServiceConnection
import android.graphics.Color
import android.graphics.drawable.ColorDrawable
import android.os.Bundle
import android.os.Handler
import android.os.IBinder
@ -21,6 +20,7 @@ import android.widget.LinearLayout
import androidx.activity.OnBackPressedCallback
import androidx.activity.enableEdgeToEdge
import androidx.activity.viewModels
import androidx.core.graphics.drawable.toDrawable
import androidx.core.widget.addTextChangedListener
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.GridLayoutManager
@ -28,22 +28,21 @@ import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import com.drake.softinput.getSoftInputHeight
import com.drake.softinput.hasSoftInput
import com.drake.softinput.setWindowSoftInput
import com.kaixed.core.common.base.BaseActivity
import com.kaixed.kchat.R
import com.kaixed.kchat.data.event.UnreadEvent
import com.kaixed.kchat.data.local.box.ObjectBox.getBoxStore
import com.kaixed.kchat.data.local.AppDatabase
import com.kaixed.kchat.data.local.entity.Messages
import com.kaixed.kchat.data.model.FunctionItem
import com.kaixed.kchat.databinding.ActivityChatBinding
import com.kaixed.kchat.manager.ConversationManager
import com.kaixed.kchat.manager.MessagesManager
import com.kaixed.kchat.processor.MessageProcessor
import com.kaixed.kchat.service.ChatViewModel
import com.kaixed.kchat.service.WebSocketService
import com.kaixed.kchat.service.WebSocketService.LocalBinder
import com.kaixed.kchat.ui.adapter.ChatAdapter
import com.kaixed.kchat.ui.adapter.EmojiAdapter
import com.kaixed.kchat.ui.adapter.FunctionPanelAdapter
import com.kaixed.kchat.ui.base.BaseActivity
import com.kaixed.kchat.ui.i.IOnItemClickListener
import com.kaixed.kchat.ui.i.OnItemClickListener
import com.kaixed.kchat.ui.widget.LoadingDialogFragment
@ -54,31 +53,27 @@ import com.kaixed.kchat.utils.ConstantsUtils.getKeyboardHeight
import com.kaixed.kchat.utils.ConstantsUtils.getUsername
import com.kaixed.kchat.utils.ImageEngines
import com.kaixed.kchat.utils.ImageSpanUtil.insertEmoji
import com.kaixed.kchat.utils.ScreenUtils
import com.kaixed.kchat.viewmodel.FileViewModel
import com.luck.picture.lib.basic.PictureSelector
import com.luck.picture.lib.config.SelectMimeType
import com.luck.picture.lib.entity.LocalMedia
import com.luck.picture.lib.interfaces.OnResultCallbackListener
import com.tencent.mmkv.MMKV
import io.objectbox.Box
import io.objectbox.kotlin.boxFor
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import org.greenrobot.eventbus.EventBus
import org.greenrobot.eventbus.Subscribe
import org.greenrobot.eventbus.ThreadMode
import kotlinx.coroutines.withContext
import org.json.JSONObject
import java.io.File
import kotlin.math.max
class ChatActivity : BaseActivity<ActivityChatBinding>(), OnItemClickListener,
class ChatActivity : BaseActivity<ActivityChatBinding, ChatViewModel>(), OnItemClickListener,
IOnItemClickListener {
private var chatAdapter: ChatAdapter? = null
private var webSocketService: WebSocketService? = null
private val messagesBox: Box<Messages> by lazy { getBoxStore().boxFor() }
private val messagesDao = AppDatabase.getDatabase().messagesDao()
private var contactId: String = ""
@ -112,24 +107,36 @@ class ChatActivity : BaseActivity<ActivityChatBinding>(), OnItemClickListener,
return ActivityChatBinding.inflate(layoutInflater)
}
override fun getViewModelClass(): Class<ChatViewModel> {
return ChatViewModel::class.java
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
EventBus.getDefault().register(this)
firstLoadData()
MessagesManager.setContactId(contactId)
firstLoadMessage()
bindWebSocketService()
setPanelChange()
if (isSearchHistory) {
val size = MessagesManager.queryHistory(msgLocalId)
binding.recycleChatList.smoothScrollToPosition(size - 1)
// if (isSearchHistory) {
// val size = MessagesManager.queryHistory(msgLocalId)
// binding.rvChatList.smoothScrollToPosition(size - 1)
// }
}
private fun firstLoadMessage() {
lifecycleScope.launch {
withContext(Dispatchers.IO) {
MessagesManager.firstLoadMessages()
}
}
}
override fun onResume() {
super.onResume()
if (!isSearchHistory) {
MessagesManager.firstLoadMessages()
}
// if (!isSearchHistory) {
// firstLoadMessage()
// }
}
private val connection: ServiceConnection = object : ServiceConnection {
@ -210,17 +217,21 @@ class ChatActivity : BaseActivity<ActivityChatBinding>(), OnItemClickListener,
}
private fun lockContentViewHeight() {
val layoutParams = binding.recycleChatList.layoutParams as LinearLayout.LayoutParams
layoutParams.height = binding.recycleChatList.height
layoutParams.weight = 0f
binding.recycleChatList.layoutParams = layoutParams
val layoutParams = binding.rvChatList.layoutParams as LinearLayout.LayoutParams
layoutParams.apply {
height = binding.rvChatList.height
weight = 0f
binding.rvChatList.layoutParams = this
}
}
private fun unlockContentViewHeight() {
binding.recycleChatList.postDelayed({
val layoutParams = binding.recycleChatList.layoutParams as LinearLayout.LayoutParams
layoutParams.weight = 1f
binding.recycleChatList.layoutParams = layoutParams
binding.rvChatList.postDelayed({
val layoutParams = binding.rvChatList.layoutParams as LinearLayout.LayoutParams
layoutParams.apply {
weight = 1F
binding.rvChatList.layoutParams = this
}
}, UNBLOCK_DELAY_TIME)
}
@ -328,31 +339,31 @@ class ChatActivity : BaseActivity<ActivityChatBinding>(), OnItemClickListener,
private fun setRecycleView() {
val layoutManager = LinearLayoutManager(this)
layoutManager.reverseLayout = true
binding.recycleChatList.layoutManager = layoutManager
binding.rvChatList.layoutManager = layoutManager
chatAdapter = ChatAdapter(this)
binding.recycleChatList.adapter = chatAdapter
}
private fun firstLoadData() {
MessagesManager.setContactId(contactId)
MessagesManager.firstLoadMessages()
binding.rvChatList.adapter = chatAdapter
binding.rvChatList.itemAnimator = null
}
private fun loadMoreMessages() {
MessagesManager.loadMoreMessages()
lifecycleScope.launch {
withContext(Dispatchers.IO) {
MessagesManager.loadMoreMessages()
}
}
}
override fun observeData() {
webSocketService!!.messageLivedata.observe(this) {
it?.let {
handleMsg(it)
}
}
// webSocketService!!.messageLivedata.observe(this) {
// it?.let {
// handleMsg(it)
// }
// }
lifecycleScope.launch {
MessagesManager.messages.collect {
chatAdapter?.submitList(it)
binding.recycleChatList.post {
binding.recycleChatList.smoothScrollToPosition(0)
binding.rvChatList.post {
binding.rvChatList.smoothScrollToPosition(0)
}
}
}
@ -360,31 +371,26 @@ class ChatActivity : BaseActivity<ActivityChatBinding>(), OnItemClickListener,
override fun setupListeners() {
binding.etInput.setOnClickListener {
if (hasSoftInput()){
if (hasSoftInput()) {
MMKV.defaultMMKV().encode(KEYBOARD_HEIGHT, max(getSoftInputHeight(), 300))
}
}
setBackPressListener()
binding.gvFunctionPanel.selector = ColorDrawable(Color.TRANSPARENT)
binding.gvFunctionPanel.selector = Color.TRANSPARENT.toDrawable()
binding.tvSend.setOnClickListener {
val message = binding.etInput.text.toString().trim()
sendMessage(contactId, message, "0")
}
binding.recycleChatList.addOnScrollListener(object : RecyclerView.OnScrollListener() {
binding.rvChatList.addOnScrollListener(object : RecyclerView.OnScrollListener() {
override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
super.onScrolled(recyclerView, dx, dy)
if (recyclerView.canScrollVertically(-1)) {
if (!recyclerView.canScrollVertically(-1)) {
loadMoreMessages()
}
// val layoutManager = checkNotNull(recyclerView.layoutManager as LinearLayoutManager?)
// val firstVisiblePosition = layoutManager.findLastVisibleItemPosition()
// if (tempIndex + 1 == messagesList[firstVisiblePosition].msgLocalId && hasHistory && !loading) {
// loadMoreMessages()
// }
}
})
@ -416,11 +422,6 @@ class ChatActivity : BaseActivity<ActivityChatBinding>(), OnItemClickListener,
}
}
private fun handleMsg(messages: Messages) {
MessagesManager.receiveMessage(messages)
binding.recycleChatList.smoothScrollToPosition(0)
}
private fun sendMessage(talkerId: String, message: String, type: String) {
val messages = Messages(
content = message,
@ -429,23 +430,33 @@ class ChatActivity : BaseActivity<ActivityChatBinding>(), OnItemClickListener,
talkerId = talkerId,
type = type,
)
val msg = MessageProcessor.processorMsg(messages)
val id: Long = messagesBox.put(msg)
lifecycleScope.launch {
val jsonObject = JSONObject()
val jsonObject2 = JSONObject()
jsonObject2.put("receiverId", contactId)
jsonObject2.put("content", message)
jsonObject2.put("msgLocalId", id)
jsonObject.put("type", if (type == "4") "image" else "text")
jsonObject.put("body", jsonObject2)
withContext(Dispatchers.IO) {
val msg = MessageProcessor.processorMsg(messages)
val id: Long = messagesDao.insert(msg)
webSocketService!!.sendMessage(jsonObject.toString(), id)
webSocketService!!.storeOwnerMsg(messages)
val jsonObject = JSONObject()
val jsonObject2 = JSONObject()
jsonObject2.put("receiverId", contactId)
jsonObject2.put("content", message)
jsonObject2.put("msgLocalId", id)
jsonObject.put("type", if (type == "4") "image" else "text")
jsonObject.put("body", jsonObject2)
MessagesManager.sendMessages(messages)
viewModel.sendChatMessage(jsonObject.toString(), id)
// webSocketService!!.storeOwnerMsg(messages)
ConversationManager.handleMessages(messages)
MessagesManager.sendMessages(messages)
}
binding.etInput.setText("")
binding.rvChatList.post {
binding.rvChatList.smoothScrollToPosition(0)
}
}
binding.etInput.setText("")
}
private fun bindWebSocketService() {
@ -456,15 +467,8 @@ class ChatActivity : BaseActivity<ActivityChatBinding>(), OnItemClickListener,
)
}
@Subscribe(threadMode = ThreadMode.MAIN)
fun onMessageEvent(unreadEvent: UnreadEvent) {
val unreadCount = unreadEvent.unreadCount
binding.ctb.setUnReadCount(unreadCount)
}
override fun onDestroy() {
super.onDestroy()
EventBus.getDefault().unregister(this)
MessagesManager.resetMessages()
mmkv.putString(CURRENT_CONTACT_ID, "")
if (bound) {
@ -497,7 +501,6 @@ class ChatActivity : BaseActivity<ActivityChatBinding>(), OnItemClickListener,
fileViewModel.uploadFile(file)
}
//TODO: 待优化成微信选择上传模式,当前上传模式耗费用户等待时间
private fun selectPicture() {
PictureSelector.create(this).openGallery(SelectMimeType.ofImage())

View File

@ -3,20 +3,24 @@ package com.kaixed.kchat.ui.activity
import android.content.Intent
import android.os.Bundle
import androidx.activity.enableEdgeToEdge
import androidx.lifecycle.lifecycleScope
import com.bumptech.glide.Glide
import com.kaixed.kchat.data.LocalDatabase.getContactByUsername
import com.kaixed.kchat.data.DataBase
import com.kaixed.kchat.data.local.entity.Contact
import com.kaixed.kchat.databinding.ActivityChatDetailBinding
import com.kaixed.kchat.manager.ConversationManager
import com.kaixed.kchat.manager.MessagesManager
import com.kaixed.kchat.ui.base.BaseActivity
import com.kaixed.kchat.ui.widget.SwitchButton
import kotlinx.coroutines.async
import kotlinx.coroutines.launch
import org.greenrobot.eventbus.EventBus
class ChatDetailActivity : BaseActivity<ActivityChatDetailBinding>() {
private lateinit var contactId: String
private val contact by lazy { getContactByUsername(contactId) }
private lateinit var contact: Contact
override fun inflateBinding(): ActivityChatDetailBinding {
return ActivityChatDetailBinding.inflate(layoutInflater)
@ -24,7 +28,7 @@ class ChatDetailActivity : BaseActivity<ActivityChatDetailBinding>() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
}
override fun initView() {
@ -34,7 +38,10 @@ class ChatDetailActivity : BaseActivity<ActivityChatDetailBinding>() {
override fun initData() {
contactId = intent.getStringExtra("contactId") ?: ""
binding.ciSetDisturb.setSwitchChecked(contact?.doNotDisturb ?: false)
lifecycleScope.launch {
contact = DataBase.getContactByUsername(contactId)!!
binding.ciSetDisturb.setSwitchChecked(contact?.doNotDisturb ?: false)
}
}
override fun observeData() {
@ -45,7 +52,9 @@ class ChatDetailActivity : BaseActivity<ActivityChatDetailBinding>() {
binding.ciSetDisturb.sbSwitch.setOnCheckedChangeListener(object :
SwitchButton.OnCheckedChangeListener {
override fun onCheckedChanged(button: SwitchButton, isChecked: Boolean) {
ConversationManager.updateDisturbStatus(contactId, isChecked)
lifecycleScope.launch {
ConversationManager.updateDisturbStatus(contactId, isChecked)
}
}
})
binding.ciCleanChatHistory.setOnClickListener {

View File

@ -5,15 +5,18 @@ import android.content.Intent
import android.os.Bundle
import android.view.View
import androidx.activity.enableEdgeToEdge
import androidx.lifecycle.lifecycleScope
import com.bumptech.glide.Glide
import com.kaixed.kchat.data.DataBase
import com.kaixed.kchat.data.local.entity.Contact
import com.kaixed.kchat.data.local.entity.Contact_
import com.kaixed.kchat.databinding.ActivityContactsDetailBinding
import com.kaixed.kchat.ui.base.BaseActivity
import com.kaixed.kchat.utils.ConstantsUtils.getAvatarUrl
import com.kaixed.kchat.utils.ConstantsUtils.getNickName
import com.kaixed.kchat.utils.ConstantsUtils.getUsername
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
class ContactsDetailActivity : BaseActivity<ActivityContactsDetailBinding>() {
@ -95,19 +98,21 @@ class ContactsDetailActivity : BaseActivity<ActivityContactsDetailBinding>() {
binding.ciSetRemarkAndLabel.visibility = View.GONE
return
}
val contact: Contact by lazy {
getContactInfo(contactId!!)
}
binding.tvContactId.text = "星联号: $contactId"
binding.tvContactSignature.text = contact.signature
if (contact.remark?.isNotEmpty() == true) {
binding.tvRemark.text = contact.remark
contactNickname = contact.remark
binding.tvContactName.text = "昵称:${contact.nickname}"
} else {
contactNickname = contact.nickname
binding.tvRemark.text = contact.nickname
binding.tvContactName.visibility = View.GONE
lifecycleScope.launch {
val contact = getContactInfo(contactId!!)
// 使用获取到的联系人信息更新UI这部分已在主线程执行
binding.tvContactId.text = "星联号: $contactId"
binding.tvContactSignature.text = contact?.signature
if (contact?.remark?.isNotEmpty() == true) {
binding.tvRemark.text = contact.remark
contactNickname = contact.remark
binding.tvContactName.text = "昵称:${contact.nickname}"
} else {
contactNickname = contact?.nickname
binding.tvRemark.text = contact?.nickname
binding.tvContactName.visibility = View.GONE
}
}
}
@ -116,10 +121,9 @@ class ContactsDetailActivity : BaseActivity<ActivityContactsDetailBinding>() {
updateContent()
}
private fun getContactInfo(contactId: String): Contact {
return DataBase.contactBox
.query(Contact_.username.equal(contactId))
.build()
.findFirst()!!
private suspend fun getContactInfo(contactId: String): Contact? {
return withContext(Dispatchers.IO) {
DataBase.getContactByUsername(contactId)
}
}
}

View File

@ -12,6 +12,7 @@ import androidx.core.view.updatePadding
import androidx.core.widget.NestedScrollView
import androidx.recyclerview.widget.LinearLayoutManager
import com.bumptech.glide.Glide
import com.kaixed.core.common.utils.TextUtil
import com.kaixed.kchat.R
import com.kaixed.kchat.data.model.dynamic.Comment
import com.kaixed.kchat.data.model.dynamic.FriendCircleItem
@ -22,7 +23,6 @@ import com.kaixed.kchat.utils.ConstantsUtils
import com.kaixed.kchat.utils.ConstantsUtils.getAvatarUrl
import com.kaixed.kchat.utils.ConstantsUtils.getNickName
import com.kaixed.kchat.utils.ScreenUtils.dp2px
import com.kaixed.kchat.utils.TextUtil
import kotlin.math.max
import kotlin.math.min
import kotlin.random.Random

View File

@ -12,7 +12,7 @@ import androidx.core.content.ContextCompat
import com.drake.softinput.getSoftInputHeight
import com.drake.softinput.hasSoftInput
import com.kaixed.kchat.R
import com.kaixed.kchat.data.LocalDatabase
import com.kaixed.kchat.data.DataBase
import com.kaixed.kchat.data.local.entity.UserInfo
import com.kaixed.kchat.databinding.ActivityLoginBinding
import com.kaixed.kchat.ui.base.BaseActivity
@ -153,8 +153,9 @@ class LoginActivity : BaseActivity<ActivityLoginBinding>() {
return true
}
//TODO 存储用户信息
private fun saveUserInfo(user: UserInfo) {
LocalDatabase.saveUserInfo(user)
DataBase.saveUserInfo(user)
MMKV.defaultMMKV().putString(ACCESS_TOKEN, user.accessToken)
MMKV.defaultMMKV().putString(REFRESH_TOKEN, user.refreshToken)
}

View File

@ -15,14 +15,13 @@ import androidx.fragment.app.Fragment
import androidx.lifecycle.lifecycleScope
import androidx.viewpager2.adapter.FragmentStateAdapter
import androidx.viewpager2.widget.ViewPager2
import com.bumptech.glide.Glide
import com.huawei.hms.hmsscankit.ScanUtil
import com.huawei.hms.ml.scan.HmsScan
import com.kaixed.kchat.R
import com.kaixed.kchat.data.LocalDatabase
import com.kaixed.kchat.data.DataBase
import com.kaixed.kchat.data.event.UnreadEvent
import com.kaixed.kchat.data.local.box.ObjectBox.getBox
import com.kaixed.kchat.data.local.entity.Contact
import com.kaixed.kchat.data.local.AppDatabase
import com.kaixed.kchat.data.local.dao.MessagesDao
import com.kaixed.kchat.databinding.ActivityMainBinding
import com.kaixed.kchat.ui.base.BaseActivity
import com.kaixed.kchat.ui.fragment.ContactFragment
@ -30,14 +29,13 @@ import com.kaixed.kchat.ui.fragment.DiscoveryFragment
import com.kaixed.kchat.ui.fragment.HomeFragment
import com.kaixed.kchat.ui.fragment.MineFragment
import com.kaixed.kchat.ui.widget.LoadingDialogFragment
import com.kaixed.kchat.utils.ConstantsUtils.getAvatarUrl
import com.kaixed.kchat.utils.ConstantsUtils.getUsername
import com.kaixed.kchat.utils.ConstantsUtils.isFirstLaunchApp
import com.kaixed.kchat.viewmodel.ContactViewModel
import io.objectbox.Box
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.greenrobot.eventbus.EventBus
import org.greenrobot.eventbus.Subscribe
import org.greenrobot.eventbus.ThreadMode
@ -49,14 +47,14 @@ class MainActivity : BaseActivity<ActivityMainBinding>() {
private val colorBlack: Int by lazy { ContextCompat.getColor(this, R.color.black) }
private val contactBox: Box<Contact> by lazy { getBox(Contact::class.java) }
private val contactViewModel: ContactViewModel by viewModels()
private val navBinding by lazy { binding.bottomNavContainer }
private val loadingDialogFragment by lazy { LoadingDialogFragment.newInstance("同步数据中") }
private val messagesDao: MessagesDao = AppDatabase.getDatabase().messagesDao()
private val navItems by lazy {
listOf(
NavItem(navBinding.ivHome, navBinding.tvHome),
@ -106,7 +104,9 @@ class MainActivity : BaseActivity<ActivityMainBinding>() {
ScanUtil.SUCCESS -> {
val hmsScan: HmsScan? = data.getParcelableExtra(ScanUtil.RESULT)
hmsScan?.let {
handleResult(it.showResult)
lifecycleScope.launch {
handleResult(it.showResult)
}
}
}
ScanUtil.ERROR_NO_READ_PERMISSION -> {
@ -122,7 +122,7 @@ class MainActivity : BaseActivity<ActivityMainBinding>() {
}
// 处理扫描结果
private fun handleResult(result: String) {
private suspend fun handleResult(result: String) {
if (result.startsWith("kchat@")) {
val contactId = result.substringAfter("kchat@")
if (contactId == getUsername()) {
@ -131,7 +131,7 @@ class MainActivity : BaseActivity<ActivityMainBinding>() {
putExtra("isMine", true)
})
}
if (LocalDatabase.isMyFriend(contactId)) {
if (DataBase.isMyFriend(contactId)) {
startActivity(Intent(this, ContactsDetailActivity::class.java).apply {
putExtra("contactId", contactId)
})
@ -152,8 +152,14 @@ class MainActivity : BaseActivity<ActivityMainBinding>() {
private fun setListener() {
contactViewModel.contactListResult.observe(this@MainActivity) { result ->
result.onSuccess {
contactBox.put(it ?: emptyList())
loadingDialogFragment.dismissLoading()
//TODO 优化,使用协程
val contactDao = AppDatabase.getDatabase().contactDao()
lifecycleScope.launch {
withContext(Dispatchers.IO) {
contactDao.insertContacts(it ?: emptyList())
loadingDialogFragment.dismissLoading()
}
}
}
}
contactViewModel.searchContactResult.observe(this@MainActivity) { result ->

View File

@ -14,19 +14,16 @@ import androidx.activity.viewModels
import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat
import com.bumptech.glide.Glide
import com.kaixed.kchat.data.local.box.ObjectBox.getBoxStore
import com.kaixed.core.common.utils.TextUtil.extractDimensionsAndPrefix
import com.kaixed.kchat.data.local.entity.UserInfo
import com.kaixed.kchat.data.local.entity.UserInfo_
import com.kaixed.kchat.databinding.ActivityProfileDetailBinding
import com.kaixed.kchat.ui.base.BaseActivity
import com.kaixed.kchat.ui.widget.LoadingDialogFragment
import com.kaixed.kchat.utils.Constants.AVATAR_URL
import com.kaixed.kchat.utils.Constants.MMKV_USER_SESSION
import com.kaixed.kchat.utils.ConstantsUtils.getUsername
import com.kaixed.kchat.utils.TextUtil.extractDimensionsAndPrefix
import com.kaixed.kchat.viewmodel.UserViewModel
import com.tencent.mmkv.MMKV
import io.objectbox.Box
import okhttp3.MediaType.Companion.toMediaTypeOrNull
import okhttp3.MultipartBody
import okhttp3.RequestBody.Companion.asRequestBody
@ -35,8 +32,6 @@ import java.io.File
class ProfileDetailActivity : BaseActivity<ActivityProfileDetailBinding>() {
private val userInfoBox: Box<UserInfo> by lazy { getBoxStore().boxFor(UserInfo::class.java) }
private val userSessionMMKV by lazy { MMKV.mmkvWithID(MMKV_USER_SESSION) }
private val userViewModel: UserViewModel by viewModels()
@ -184,14 +179,15 @@ class ProfileDetailActivity : BaseActivity<ActivityProfileDetailBinding>() {
updateContent(username)
}
//TODO 更新用户信息
private fun updateContent(username: String) {
val userInfo = userInfoBox.query(UserInfo_.username.equal(username)).build().findFirst()
if (userInfo != null) {
binding.ciKid.setItemDesc(userInfo.username)
binding.ciNickname.setItemDesc(userInfo.nickname)
val avatarUrl =
extractDimensionsAndPrefix(userInfo.avatarUrl)?.first ?: userInfo.avatarUrl
binding.ciAvatar.setItemIcon(avatarUrl)
}
// val userInfo = userInfoBox.query(UserInfo_.username.equal(username)).build().findFirst()
// if (userInfo != null) {
// binding.ciKid.setItemDesc(userInfo.username)
// binding.ciNickname.setItemDesc(userInfo.nickname)
// val avatarUrl =
// extractDimensionsAndPrefix(userInfo.avatarUrl)?.first ?: userInfo.avatarUrl
// binding.ciAvatar.setItemIcon(avatarUrl)
// }
}
}

View File

@ -6,8 +6,6 @@ import android.os.Looper
import androidx.activity.enableEdgeToEdge
import androidx.activity.viewModels
import androidx.core.widget.addTextChangedListener
import com.kaixed.kchat.data.local.box.ObjectBox.getBoxStore
import com.kaixed.kchat.data.local.entity.UserInfo
import com.kaixed.kchat.data.model.request.UserRequest
import com.kaixed.kchat.databinding.ActivityRenameBinding
import com.kaixed.kchat.ui.base.BaseActivity
@ -15,7 +13,6 @@ import com.kaixed.kchat.ui.widget.LoadingDialogFragment
import com.kaixed.kchat.utils.ConstantsUtils.getNickName
import com.kaixed.kchat.utils.ConstantsUtils.getUsername
import com.kaixed.kchat.viewmodel.UserViewModel
import io.objectbox.Box
class RenameActivity : BaseActivity<ActivityRenameBinding>() {

View File

@ -9,16 +9,16 @@ import androidx.activity.enableEdgeToEdge
import androidx.core.text.buildSpannedString
import androidx.core.text.inSpans
import androidx.core.widget.addTextChangedListener
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.LinearLayoutManager
import com.kaixed.kchat.data.local.box.ObjectBox.getBox
import com.kaixed.kchat.data.local.AppDatabase
import com.kaixed.kchat.data.local.entity.Messages
import com.kaixed.kchat.data.local.entity.Messages_
import com.kaixed.kchat.data.model.search.ChatHistoryItem
import com.kaixed.kchat.databinding.ActivitySearchChatHistoryBinding
import com.kaixed.kchat.ui.adapter.ChatHistoryAdapter
import com.kaixed.kchat.ui.base.BaseActivity
import com.kaixed.kchat.utils.ConstantsUtils.getUsername
import io.objectbox.Box
import kotlinx.coroutines.launch
class SearchChatHistory : BaseActivity<ActivitySearchChatHistoryBinding>() {
@ -28,8 +28,6 @@ class SearchChatHistory : BaseActivity<ActivitySearchChatHistoryBinding>() {
private const val VIEW_SELECT = 2
}
private val messageBox: Box<Messages> by lazy { getBox(Messages::class.java) }
private val chatHistoryAdapter by lazy {
ChatHistoryAdapter(
contactAvatarUrl,
@ -62,10 +60,14 @@ class SearchChatHistory : BaseActivity<ActivitySearchChatHistoryBinding>() {
chatHistoryAdapter.setContactId(contactId)
chatHistoryAdapter.let { adapter ->
val viewHolder = adapter.createViewHolder(binding.rvChatHistory, adapter.getItemViewType(0))
val viewHolder =
adapter.createViewHolder(binding.rvChatHistory, adapter.getItemViewType(0))
val itemView = viewHolder.itemView
itemView.measure(
View.MeasureSpec.makeMeasureSpec(binding.rvChatHistory.width, View.MeasureSpec.EXACTLY),
View.MeasureSpec.makeMeasureSpec(
binding.rvChatHistory.width,
View.MeasureSpec.EXACTLY
),
View.MeasureSpec.UNSPECIFIED
)
val itemHeight = itemView.measuredHeight
@ -76,15 +78,18 @@ class SearchChatHistory : BaseActivity<ActivitySearchChatHistoryBinding>() {
private fun setListener() {
binding.tvCancel.setOnClickListener { finish() }
binding.etSearch.addTextChangedListener(afterTextChanged =
{
if (it?.length == 0) {
chatHistoryAdapter.submitList(null)
showView(VIEW_SELECT)
} else {
getData(it.toString())
}
})
binding.etSearch.addTextChangedListener(
afterTextChanged =
{
if (it?.length == 0) {
chatHistoryAdapter.submitList(null)
showView(VIEW_SELECT)
} else {
lifecycleScope.launch {
getData(it.toString())
}
}
})
}
private fun showView(viewToShowIndex: Int) {
@ -94,7 +99,7 @@ class SearchChatHistory : BaseActivity<ActivitySearchChatHistoryBinding>() {
}
}
private fun getData(matchedField: String) {
private suspend fun getData(matchedField: String) {
val data = loadDataFromDb(matchedField)
val items = data.map {
@ -130,9 +135,10 @@ class SearchChatHistory : BaseActivity<ActivitySearchChatHistoryBinding>() {
}
}
private fun loadDataFromDb(content: String): List<Messages> {
return messageBox.query(Messages_.content.contains(content)).orderDesc(Messages_.timestamp)
.build().find()
private suspend fun loadDataFromDb(content: String): List<Messages> {
val messagesDao = AppDatabase.getDatabase().messagesDao()
return messagesDao.getMessagesContainingKeyword(content)
}
override fun initData() {

View File

@ -12,7 +12,7 @@ import androidx.activity.enableEdgeToEdge
import androidx.activity.viewModels
import androidx.core.widget.addTextChangedListener
import com.bumptech.glide.Glide
import com.kaixed.kchat.data.LocalDatabase
import com.kaixed.kchat.data.DataBase
import com.kaixed.kchat.data.model.search.SearchUser
import com.kaixed.kchat.databinding.ActivitySearchFriendsBinding
import com.kaixed.kchat.ui.base.BaseActivity
@ -106,7 +106,7 @@ class SearchFriendsActivity : BaseActivity<ActivitySearchFriendsBinding>() {
result.onSuccess {
it?.let {
isSearchedMine = it.username == getUsername()
if (LocalDatabase.isMyFriend(it.username)) {
if (DataBase.isMyFriend(it.username)) {
val username = it.username
val intent =
Intent(this, ContactsDetailActivity::class.java).apply {

View File

@ -12,8 +12,7 @@ import androidx.core.widget.addTextChangedListener
import androidx.lifecycle.lifecycleScope
import androidx.localbroadcastmanager.content.LocalBroadcastManager
import com.kaixed.kchat.R
import com.kaixed.kchat.data.local.box.ObjectBox.getBoxStore
import com.kaixed.kchat.data.LocalDatabase
import com.kaixed.kchat.data.DataBase
import com.kaixed.kchat.data.local.entity.Contact
import com.kaixed.kchat.databinding.ActivitySetRemarkAndLabelBinding
import com.kaixed.kchat.ui.base.BaseActivity
@ -22,6 +21,7 @@ import com.kaixed.kchat.utils.ConstantsUtils.getUsername
import com.kaixed.kchat.viewmodel.ContactViewModel
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
class SetRemarkAndLabelActivity : BaseActivity<ActivitySetRemarkAndLabelBinding>() {
@ -32,7 +32,9 @@ class SetRemarkAndLabelActivity : BaseActivity<ActivitySetRemarkAndLabelBinding>
private var remark = ""
private val contact: Contact? by lazy {
LocalDatabase.getContactByUsername(contactId!!)
runBlocking {
DataBase.getContactByUsername(contactId!!)
}
}
private val contactViewModel by lazy { ContactViewModel() }

View File

@ -19,7 +19,7 @@ class AccountSecurityActivity : BaseActivity<ActivityAccountSecurityBinding>() {
}
override fun initData() {
val userInfo = DataBase.userInfoBox.query().build().findFirst()
val userInfo = DataBase.getUserInfo()
userInfo?.let {
binding.ciAccount.setItemDesc(userInfo.username)
val telephone = userInfo.telephone.take(3) + "******" + userInfo.telephone.takeLast(2)

View File

@ -24,14 +24,11 @@ class UpdateTelephoneActivity : BaseActivity<ActivityUpdateTelephoneBinding>() {
}
override fun initView() {
TODO("Not yet implemented")
}
override fun setupListeners() {
TODO("Not yet implemented")
}
override fun observeData() {
TODO("Not yet implemented")
}
}

View File

@ -1,19 +1,21 @@
package com.kaixed.kchat.ui.adapter
import android.content.Context
import android.util.Log
import android.view.LayoutInflater
import android.view.ViewGroup
import android.widget.ImageView
import android.widget.TextView
import androidx.core.view.updateLayoutParams
import androidx.paging.PagingDataAdapter
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.ListAdapter
import androidx.recyclerview.widget.RecyclerView
import com.bumptech.glide.Glide
import com.drake.spannable.replaceSpan
import com.drake.spannable.span.CenterImageSpan
import com.kaixed.core.common.utils.TextUtil.extractDimensionsAndPrefix
import com.kaixed.kchat.R
import com.kaixed.kchat.data.local.box.ObjectBox.getBox
import com.kaixed.kchat.data.local.entity.Messages
import com.kaixed.kchat.databinding.ChatRecycleItemCustomNormalBinding
import com.kaixed.kchat.databinding.ChatRecycleItemImageNormalBinding
@ -22,24 +24,13 @@ import com.kaixed.kchat.manager.MessagesManager
import com.kaixed.kchat.utils.ConstantsUtils
import com.kaixed.kchat.utils.ConstantsUtils.getUsername
import com.kaixed.kchat.utils.PopWindowUtil.showPopupWindow
import com.kaixed.kchat.utils.TextUtil.extractDimensionsAndPrefix
import com.kaixed.kchat.utils.ViewUtil.changeTimerVisibility
import com.kaixed.kchat.utils.ViewUtil.setViewVisibility
import io.objectbox.Box
class ChatAdapter(
private val context: Context
) : ListAdapter<Messages, RecyclerView.ViewHolder>(object : DiffUtil.ItemCallback<Messages>() {
override fun areItemsTheSame(oldItem: Messages, newItem: Messages): Boolean {
return oldItem.msgLocalId == newItem.msgLocalId
}
override fun areContentsTheSame(oldItem: Messages, newItem: Messages): Boolean {
return oldItem == newItem
}
}) {
) : ListAdapter<Messages, RecyclerView.ViewHolder>(DIFF_CALLBACK) {
companion object {
const val CUSTOM = 0
@ -50,6 +41,25 @@ class ChatAdapter(
const val EMOJI = 10
const val RED_PACKET = 12
private const val TAG = "ChatAdapter"
// 移动到外部作为静态对象
private val DIFF_CALLBACK = object : DiffUtil.ItemCallback<Messages>() {
override fun areItemsTheSame(oldItem: Messages, newItem: Messages): Boolean {
val isSame = oldItem.msgLocalId == newItem.msgLocalId
if (!isSame) {
Log.d(TAG, "Item changed: old=${oldItem.msgLocalId}, new=${newItem.msgLocalId}")
}
return isSame
}
override fun areContentsTheSame(oldItem: Messages, newItem: Messages): Boolean {
val changed = oldItem != newItem
if (changed) {
Log.d(TAG, "Item content changed: id=${oldItem.msgLocalId}")
}
return !changed
}
}
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
@ -81,7 +91,7 @@ class ChatAdapter(
}
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
val singleMessage = getItem(position)
val singleMessage = getItem(position) ?: return
when (holder) {
is CustomViewHolder -> {
holder.bindData(singleMessage)
@ -209,11 +219,6 @@ class ChatAdapter(
}
override fun getItemViewType(position: Int): Int {
return getItem(position).type.toInt()
}
private fun updateDb(message: Messages) {
val messagesBox: Box<Messages> = getBox(Messages::class.java)
messagesBox.put(message)
return getItem(position)?.type?.toInt() ?: 0
}
}

View File

@ -10,11 +10,11 @@ import androidx.recyclerview.widget.RecyclerView.ViewHolder
import com.bumptech.glide.Glide
import com.drake.spannable.replaceSpanFirst
import com.drake.spannable.span.HighlightSpan
import com.kaixed.core.common.utils.TextUtil
import com.kaixed.kchat.data.model.search.ChatHistoryItem
import com.kaixed.kchat.databinding.ChatHistoryRecycleItemBinding
import com.kaixed.kchat.ui.activity.ChatActivity
import com.kaixed.kchat.utils.ConstantsUtils
import com.kaixed.kchat.utils.TextUtil
/**
* @Author: kaixed

View File

@ -14,12 +14,12 @@ import com.bumptech.glide.Glide
import com.bumptech.glide.request.RequestOptions
import com.drake.spannable.replaceSpan
import com.drake.spannable.span.CenterImageSpan
import com.kaixed.core.common.utils.TextUtil
import com.kaixed.kchat.R
import com.kaixed.kchat.data.local.entity.Conversation
import com.kaixed.kchat.databinding.ChatMainItemBinding
import com.kaixed.kchat.ui.activity.ChatActivity
import com.kaixed.kchat.ui.i.OnItemListener
import com.kaixed.kchat.utils.TextUtil
/**
* @Author: kaixed

View File

@ -5,11 +5,11 @@ import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView
import com.bumptech.glide.Glide
import com.kaixed.core.common.utils.TextUtil
import com.kaixed.kchat.data.model.dynamic.FriendCircleItem
import com.kaixed.kchat.databinding.ItemFriendCircleBinding
import com.kaixed.kchat.utils.ScreenUtils.dp2px
import com.kaixed.kchat.utils.ScreenUtils.getScreenWidth
import com.kaixed.kchat.utils.TextUtil
/**
* @Author: kaixed

View File

@ -2,6 +2,7 @@ package com.kaixed.kchat.ui.base
import android.os.Bundle
import android.widget.Toast
import androidx.activity.enableEdgeToEdge
import androidx.appcompat.app.AppCompatActivity
import androidx.viewbinding.ViewBinding
import com.kaixed.kchat.manager.AppManager
@ -16,6 +17,7 @@ abstract class BaseActivity<VB : ViewBinding> : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
binding = inflateBinding()
setContentView(binding.root)
initData()

View File

@ -11,17 +11,15 @@ import android.view.ViewGroup
import androidx.fragment.app.viewModels
import androidx.localbroadcastmanager.content.LocalBroadcastManager
import androidx.recyclerview.widget.LinearLayoutManager
import com.kaixed.core.common.base.BaseFragment
import com.kaixed.kchat.data.local.entity.Contact
import com.kaixed.kchat.databinding.FragmentContactBinding
import com.kaixed.kchat.ui.adapter.FriendListAdapter
import com.kaixed.kchat.ui.base.BaseFragment
import com.kaixed.kchat.utils.ConstantsUtils.getUsername
import com.kaixed.kchat.viewmodel.ContactViewModel
class ContactFragment : BaseFragment<FragmentContactBinding>() {
private val contactViewModel: ContactViewModel by viewModels()
class ContactFragment : BaseFragment<FragmentContactBinding, ContactViewModel>() {
private var loading = false
@ -64,8 +62,26 @@ class ContactFragment : BaseFragment<FragmentContactBinding>() {
LocalBroadcastManager.getInstance(requireActivity()).registerReceiver(mReceiver, filter)
}
override fun getViewModelClass(): Class<ContactViewModel> {
return ContactViewModel::class.java
}
private fun setObservation() {
contactViewModel.contactRequestListResult.observe(viewLifecycleOwner) { result ->
// 监听本地联系人列表加载结果
viewModel.contactListInDbResult.observe(viewLifecycleOwner) { contacts ->
val allItems = mutableListOf<Contact>().apply {
addAll(getDefaultItems())
addAll(contacts)
}
friendAdapter.updateData(allItems)
binding.includeLoading.main.visibility = View.GONE
binding.recycleFriendList.visibility = View.VISIBLE
loading = false
setupRecyclerView()
}
// 监听请求列表
viewModel.contactRequestListResult.observe(viewLifecycleOwner) { result ->
result.onSuccess {
val items = result.getOrNull()?.toMutableList() ?: emptyList()
if (items.isNotEmpty()) {
@ -92,7 +108,7 @@ class ContactFragment : BaseFragment<FragmentContactBinding>() {
}
private fun loadFriendRequest() {
contactViewModel.getContactRequestList(getUsername())
viewModel.getContactRequestList(getUsername())
}
private fun loadData() {
@ -100,16 +116,7 @@ class ContactFragment : BaseFragment<FragmentContactBinding>() {
binding.includeLoading.main.visibility = View.VISIBLE
binding.recycleFriendList.visibility = View.INVISIBLE
val contacts = contactViewModel.loadFriendListInDb()
val allItems = mutableListOf<Contact>().apply {
addAll(getDefaultItems())
addAll(contacts)
}
friendAdapter.updateData(allItems)
binding.includeLoading.main.visibility = View.GONE
binding.recycleFriendList.visibility = View.VISIBLE
loading = false
setupRecyclerView()
viewModel.loadFriendListInDb() // 启动加载
}
private fun setupRecyclerView() {

View File

@ -26,6 +26,7 @@ import androidx.annotation.RequiresApi
import androidx.appcompat.app.AppCompatActivity.BIND_AUTO_CREATE
import androidx.core.content.ContextCompat
import androidx.core.view.updateLayoutParams
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.LinearLayoutManager
import com.huawei.hms.hmsscankit.ScanUtil
import com.huawei.hms.ml.scan.HmsScan
@ -47,6 +48,7 @@ import com.kaixed.kchat.ui.base.BaseFragment
import com.kaixed.kchat.ui.i.OnDialogFragmentClickListener
import com.kaixed.kchat.ui.i.OnItemListener
import com.kaixed.kchat.ui.widget.HomeDialogFragment
import kotlinx.coroutines.launch
class HomeFragment : BaseFragment<FragmentHomeBinding>(), OnItemListener,
OnDialogFragmentClickListener {
@ -322,7 +324,9 @@ class HomeFragment : BaseFragment<FragmentHomeBinding>(), OnItemListener,
override fun onItemClick(talkerId: String) {
this.talkerId = talkerId
ConversationManager.cleanUnreadCount(talkerId)
lifecycleScope.launch {
ConversationManager.cleanUnreadCount(talkerId)
}
}
private var longClickTalkerId = ""
@ -350,7 +354,9 @@ class HomeFragment : BaseFragment<FragmentHomeBinding>(), OnItemListener,
override fun onClickSetUnread() {
if (con != null) {
ConversationManager.updateUnreadCount(con!!.talkerId, con!!.unreadCount == 0)
lifecycleScope.launch {
ConversationManager.updateUnreadCount(con!!.talkerId, con!!.unreadCount == 0)
}
}
}
@ -364,6 +370,8 @@ class HomeFragment : BaseFragment<FragmentHomeBinding>(), OnItemListener,
override fun onClickDeleteConversation() {
val con = getConversation()
ConversationManager.deleteConversation(con)
lifecycleScope.launch {
ConversationManager.deleteConversation(con)
}
}
}

View File

@ -95,7 +95,7 @@ class MyBottomSheetFragment : BottomSheetDialogFragment() {
val mmkv = MMKV.mmkvWithID(MMKV_USER_SESSION)
mmkv.clearAll()
MMKV.defaultMMKV().putBoolean(USER_LOGIN_STATUS, false)
DataBase.cleanAllData()
DataBase.clearAllDatabase()
}
override fun onDestroyView() {

View File

@ -1,9 +0,0 @@
package com.kaixed.kchat.utils
/**
* @Author: kaixed
* @Date: 2025/1/24 20:41
*/
class AccountUtils {
}

View File

@ -7,6 +7,7 @@ import com.tencent.mmkv.MMKV
* @Date: 2025/1/22 18:06
*/
object CacheUtils {
fun getAccessToken(): String {
val accessToken = MMKV.defaultMMKV().getString(Constants.ACCESS_TOKEN, "")
return accessToken ?: ""

View File

@ -6,6 +6,10 @@ package com.kaixed.kchat.utils
*/
object Constants {
object Sign {
const val SECRET_KEY = "123"
}
// mmkv
const val MMKV_USER_SESSION: String = "userSession"
const val MMKV_COMMON_DATA: String = "commonData"

View File

@ -1,5 +1,6 @@
package com.kaixed.kchat.utils
import com.kaixed.core.common.utils.TextUtil.extractUrl
import com.kaixed.kchat.utils.Constants.AVATAR_URL
import com.kaixed.kchat.utils.Constants.CURRENT_CONTACT_ID
import com.kaixed.kchat.utils.Constants.FIRST_LAUNCH_APP
@ -31,7 +32,7 @@ object ConstantsUtils {
fun getAvatarUrl(): String {
val avatarUrl = userSessionMMKV.getString(AVATAR_URL, "") ?: ""
return TextUtil.extractUrl(avatarUrl)
return extractUrl(avatarUrl)
}
fun getStatusBarHeight(): Int =

View File

@ -0,0 +1,99 @@
package com.kaixed.kchat.utils
import okhttp3.*
import java.io.File
import java.io.IOException
import java.io.RandomAccessFile
class DownloadManager(
private val url: String,
private val destinationFile: File,
private val callback: DownloadCallback
) {
private val client = OkHttpClient()
private var downloadedBytes: Long = 0L
private var totalBytes: Long = 0L
init {
// 如果目标文件已存在,则获取已下载的字节数
if (destinationFile.exists()) {
downloadedBytes = destinationFile.length()
}
}
// 开始下载文件
fun startDownload() {
val request = Request.Builder()
.url(url)
.header("Range", "bytes=$downloadedBytes-") // 请求从已下载的字节数继续下载
.build()
client.newCall(request).enqueue(object : Callback {
override fun onResponse(call: Call, response: Response) {
if (response.isSuccessful) {
// 获取文件总大小
val contentRange = response.header("Content-Range")
if (contentRange != null) {
totalBytes = contentRange.split("/")[1].toLong()
}
// 获取响应体的输入流
val body = response.body ?: return
// 使用 RandomAccessFile 写入文件
val raf = RandomAccessFile(destinationFile, "rw")
raf.seek(downloadedBytes)
val inputStream = body.byteStream()
val buffer = ByteArray(8192)
var bytesRead: Int
try {
// 开始下载并写入文件
while (inputStream.read(buffer).also { bytesRead = it } != -1) {
raf.write(buffer, 0, bytesRead)
downloadedBytes += bytesRead
// 计算下载进度
val progress = (downloadedBytes * 100) / totalBytes
// 回调进度更新
callback.onProgress(progress)
}
// 下载完成
callback.onDownloadComplete()
} catch (e: IOException) {
// 发生错误时回调
callback.onError(e.message ?: "Unknown error")
} finally {
raf.close()
inputStream.close()
body.close()
}
} else {
callback.onError("Failed to download: ${response.code}")
}
}
override fun onFailure(call: Call, e: IOException) {
// 网络请求失败时回调
callback.onError("Download failed: ${e.message}")
}
})
}
// 取消下载
fun cancelDownload() {
// 可以扩展实现下载取消的功能,关闭请求等
}
// 下载进度回调接口
interface DownloadCallback {
fun onProgress(progress: Long) // 下载进度(百分比)
fun onDownloadComplete() // 下载完成
fun onError(errorMessage: String) // 错误信息
}
}

View File

@ -20,9 +20,12 @@ object ScreenUtils {
private val defaultMMKV by lazy { MMKV.defaultMMKV() }
fun init(context: Context) {
appContext = context
if (!::appContext.isInitialized) {
appContext = context.applicationContext
}
}
fun getScreenHeight(): Int {
val screenHeight = defaultMMKV.getInt(SCREEN_HEIGHT, 0)
if (screenHeight != 0) {

View File

@ -1,10 +1,10 @@
package com.kaixed.kchat.utils
import android.util.Log
import android.view.View
import android.widget.TextView
import androidx.constraintlayout.widget.ConstraintLayout
import androidx.constraintlayout.widget.ConstraintSet
import com.kaixed.core.common.utils.TextUtil.getTimestampString
import com.kaixed.kchat.data.local.entity.Messages
/**
@ -48,11 +48,11 @@ object ViewUtil {
if (showTimer) {
tvTimer.visibility = View.VISIBLE
tvTimer.text = TextUtil.getTimestampString(singleMessage.timestamp)
tvTimer.text = getTimestampString(singleMessage.timestamp)
} else {
tvTimer.visibility = View.GONE
}
tvTimer.visibility = if (showTimer) View.VISIBLE else View.GONE
tvTimer.text = TextUtil.getTimestampString(singleMessage.timestamp)
tvTimer.text = getTimestampString(singleMessage.timestamp)
}
}

View File

@ -1,9 +1,8 @@
package com.kaixed.kchat.utils.handle
import com.kaixed.kchat.data.local.box.ObjectBox.getBoxStore
import com.kaixed.core.common.utils.Pinyin4jUtil
import com.kaixed.kchat.data.local.AppDatabase
import com.kaixed.kchat.data.local.entity.Contact
import com.kaixed.kchat.utils.Pinyin4jUtil
import io.objectbox.Box
/**
* @Author: kaixed
@ -11,9 +10,9 @@ import io.objectbox.Box
*/
object ContactUtil {
private val contactBox: Box<Contact> by lazy { getBoxStore().boxFor(Contact::class.java) }
private val contactDao = AppDatabase.getDatabase().contactDao()
fun handleContact(contact: Contact) {
suspend fun handleContact(contact: Contact) {
val contactLists = getDbContactLists()
contactLists.add(contact)
contact.apply {
@ -28,10 +27,10 @@ object ContactUtil {
index - 1
))
}
contactBox.put(contactLists)
contactDao.updateContacts(contactLists)
}
private fun getDbContactLists(): MutableList<Contact> {
return contactBox.all.toMutableList()
private suspend fun getDbContactLists(): MutableList<Contact> {
return contactDao.getAllContacts() as MutableList<Contact>
}
}

View File

@ -4,18 +4,16 @@ import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.kaixed.kchat.data.local.box.ObjectBox.getBox
import com.kaixed.kchat.data.local.AppDatabase
import com.kaixed.kchat.data.local.entity.Contact
import com.kaixed.kchat.data.model.friend.FriendRequestItem
import com.kaixed.kchat.data.model.search.SearchUser
import com.kaixed.kchat.data.repository.ContactRepository
import com.kaixed.kchat.utils.SingleLiveEvent
import io.objectbox.Box
import kotlinx.coroutines.launch
class ContactViewModel : ViewModel() {
private val contactRepository = ContactRepository()
private val contactRepository = ContactRepository(AppDatabase.getDatabase().contactDao())
// 获取联系人请求列表
private val _contactRequestListResult =
@ -96,38 +94,14 @@ class ContactViewModel : ViewModel() {
}
}
fun loadFriendListInDb(): List<Contact> {
val contactBox: Box<Contact> = getBox(Contact::class.java)
private val _contactListInDbResult = MutableLiveData<List<Contact>>()
val contactListInDbResult: LiveData<List<Contact>> = _contactListInDbResult
val sortedContacts = contactBox.query()
.sort { contact1, contact2 ->
val str1 = contact1.remarkquanpin ?: contact1.quanpin ?: contact1.nickname
val str2 = contact2.remarkquanpin ?: contact2.quanpin ?: contact2.nickname
val type1 = when {
str1.matches(Regex("[a-zA-Z]+")) -> 1 // 字母
str1.matches(Regex("[0-9]+")) -> 2 // 数字
else -> 3 // 特殊字符
}
val type2 = when {
str2.matches(Regex("[a-zA-Z]+")) -> 1 // 字母
str2.matches(Regex("[0-9]+")) -> 2 // 数字
else -> 3 // 特殊字符
}
// 比较类型,如果相同再按字母升序排序
if (type1 != type2) {
type1 - type2 // 按类型排序
} else {
str1.compareTo(str2) // 如果类型相同,按字母顺序排序
}
}
.build()
.find()
return sortedContacts.toMutableList()
fun loadFriendListInDb() {
viewModelScope.launch {
val result = contactRepository.getContactsInDb()
_contactListInDbResult.postValue(result ?: emptyList())
}
}
fun loadFriendList(username: String) {

View File

@ -0,0 +1,28 @@
package com.kaixed.kchat.viewmodel
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.paging.Pager
import androidx.paging.PagingConfig
import androidx.paging.PagingData
import androidx.paging.cachedIn
import com.kaixed.kchat.data.local.dao.MessagesDao
import com.kaixed.kchat.data.local.entity.Messages
import kotlinx.coroutines.flow.Flow
class MessagesViewModel(
private val messagesDao: MessagesDao
) : ViewModel() {
private val pagingConfig = PagingConfig(
pageSize = 20, // 每页加载的数量
enablePlaceholders = false,
prefetchDistance = 5, // 当距离边缘还有5个项目时开始加载下一页
initialLoadSize = 40 // 首次加载的数量
)
// 接收当前联系人ID的消息流
fun getMessageFlow(contactId: String): Flow<PagingData<Messages>> = Pager(
config = pagingConfig,
pagingSourceFactory = { messagesDao.getPagedMessages(contactId) }
).flow.cachedIn(viewModelScope)
}

View File

@ -4,6 +4,7 @@ import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.kaixed.kchat.data.local.AppDatabase
import com.kaixed.kchat.data.local.entity.UserInfo
import com.kaixed.kchat.data.model.request.RegisterRequest
import com.kaixed.kchat.data.model.request.UpdatePasswordRequest
@ -19,9 +20,9 @@ import okhttp3.MultipartBody
class UserViewModel : ViewModel() {
private val userAuthRepo = UserAuthRepository()
private val userAuthRepo = UserAuthRepository(AppDatabase.getDatabase().userInfoDao())
private val userProfileRepo = UserProfileRepository()
private val userProfileRepo = UserProfileRepository(AppDatabase.getDatabase().userInfoDao())
private val userSearchRepo = UserSearchRepository()
@ -65,8 +66,8 @@ class UserViewModel : ViewModel() {
}
}
private val _userListResult = MutableLiveData<Result<List<User>>>()
val userListResult: LiveData<Result<List<User>>> = _userListResult
private val _userListResult = MutableLiveData<Result<List<User>?>>()
val userListResult: LiveData<Result<List<User>?>> = _userListResult
// 获取用户列表
fun getUserList(username: String) {

View File

@ -0,0 +1,16 @@
package com.kaixed.kchat.viewmodel.factory
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import com.kaixed.kchat.data.local.dao.MessagesDao
import com.kaixed.kchat.viewmodel.MessagesViewModel
class MessagesViewModelFactory(private val messagesDao: MessagesDao) : ViewModelProvider.Factory {
override fun <T : ViewModel> create(modelClass: Class<T>): T {
if (modelClass.isAssignableFrom(MessagesViewModel::class.java)) {
@Suppress("UNCHECKED_CAST")
return MessagesViewModel(messagesDao) as T
}
throw IllegalArgumentException("Unknown ViewModel class")
}
}

View File

@ -17,7 +17,7 @@
app:titleName="contact" />
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/recycle_chat_list"
android:id="@+id/rv_chat_list"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1"

View File

@ -2,6 +2,6 @@
<network-security-config>
<domain-config cleartextTrafficPermitted="true">
<domain includeSubdomains="true">49.233.105.103</domain>
<domain includeSubdomains="true">192.168.31.18</domain>
<domain includeSubdomains="true">192.168.225.209</domain>
</domain-config>
</network-security-config>

View File

@ -5,6 +5,7 @@ plugins {
id("com.google.devtools.ksp") version "1.9.23-1.0.19" apply false
// id ("cn.therouter.agp8") version "1.2.1" apply false
id ("org.jetbrains.kotlin.plugin.serialization") version "1.9.23"
alias(libs.plugins.androidLibrary) apply false
}
buildscript {
@ -14,7 +15,7 @@ buildscript {
mavenCentral()
}
dependencies {
classpath(libs.objectbox.gradle.plugin)
// classpath(libs.objectbox.gradle.plugin)
// classpath("com.android.tools.build:gradle:8.6.0")
classpath(libs.agcp)
}

1
core_common/.gitignore vendored Normal file
View File

@ -0,0 +1 @@
/build

View File

@ -0,0 +1,51 @@
plugins {
alias(libs.plugins.androidLibrary)
alias(libs.plugins.jetbrainsKotlinAndroid)
}
android {
namespace = "com.kaixed.core.common"
compileSdk = 34
defaultConfig {
minSdk = 30
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
consumerProguardFiles("consumer-rules.pro")
}
viewBinding {
enable = true
}
buildTypes {
release {
isMinifyEnabled = false
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_11
targetCompatibility = JavaVersion.VERSION_11
}
kotlinOptions {
jvmTarget = "11"
}
}
dependencies {
implementation(libs.androidx.core.ktx)
implementation(libs.appcompat)
implementation(libs.material)
testImplementation(libs.junit)
androidTestImplementation(libs.ext.junit)
androidTestImplementation(libs.espresso.core)
implementation(libs.pinyin4j)
implementation(libs.mmkv)
}

View File

21
core_common/proguard-rules.pro vendored Normal file
View File

@ -0,0 +1,21 @@
# Add project specific ProGuard rules here.
# You can control the set of applied configuration files using the
# proguardFiles setting in build.gradle.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html
# If your project uses WebView with JS, uncomment the following
# and specify the fully qualified class name to the JavaScript interface
# class:
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
# public *;
#}
# Uncomment this to preserve the line number information for
# debugging stack traces.
#-keepattributes SourceFile,LineNumberTable
# If you keep the line number information, uncomment this to
# hide the original source file name.
#-renamesourcefileattribute SourceFile

View File

@ -0,0 +1,24 @@
package com.kaixed.core.common
import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.ext.junit.runners.AndroidJUnit4
import org.junit.Test
import org.junit.runner.RunWith
import org.junit.Assert.*
/**
* Instrumented test, which will execute on an Android device.
*
* See [testing documentation](http://d.android.com/tools/testing).
*/
@RunWith(AndroidJUnit4::class)
class ExampleInstrumentedTest {
@Test
fun useAppContext() {
// Context of the app under test.
val appContext = InstrumentationRegistry.getInstrumentation().targetContext
assertEquals("com.kaixed.core.common.test", appContext.packageName)
}
}

View File

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
</manifest>

View File

@ -0,0 +1,57 @@
package com.kaixed.core.common.base
import android.os.Bundle
import android.widget.Toast
import androidx.activity.enableEdgeToEdge
import androidx.appcompat.app.AppCompatActivity
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.viewbinding.ViewBinding
/**
* @Author: kaixed
* @Date: 2024/11/4 10:43
*/
abstract class BaseActivity<VB : ViewBinding, VM : ViewModel> : AppCompatActivity() {
protected lateinit var binding: VB
protected val viewModel: VM by lazy { ViewModelProvider(this)[getViewModelClass()] }
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
binding = inflateBinding()
setContentView(binding.root)
initData()
initView()
setupListeners()
observeData()
// AppManager.instance.addActivity(this)
}
override fun onDestroy() {
super.onDestroy()
// AppManager.instance.finishActivity(this)
}
abstract fun initData()
abstract fun initView()
abstract fun setupListeners()
abstract fun observeData()
fun toast(msg: String) {
Toast.makeText(this, msg, Toast.LENGTH_SHORT).show()
}
fun toast() {
Toast.makeText(this, "暂未开发", Toast.LENGTH_SHORT).show()
}
abstract fun inflateBinding(): VB
abstract fun getViewModelClass(): Class<VM>
}

View File

@ -0,0 +1,53 @@
package com.kaixed.core.common.base
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.core.view.updatePadding
import androidx.fragment.app.Fragment
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.viewbinding.ViewBinding
import com.kaixed.core.common.utils.ConstantsUtils.getStatusBarHeight
/**
* @Author: kaixed
* @Date: 2024/10/31 11:04
*/
abstract class BaseFragment<VB : ViewBinding, VM : ViewModel> : Fragment() {
private var _binding: VB? = null
protected val binding get() = _binding!!
protected val viewModel: VM by lazy { ViewModelProvider(this)[getViewModelClass()] }
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
_binding = inflateBinding(inflater, container)
return binding.root
}
abstract fun inflateBinding(inflater: LayoutInflater, container: ViewGroup?): VB
// abstract fun isSetStatusBarPadding(): Boolean
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
setupStatusBarPadding(binding.root)
}
private fun setupStatusBarPadding(rootView: View) {
val statusBarHeight = getStatusBarHeight()
rootView.updatePadding(top = statusBarHeight)
}
override fun onDestroyView() {
super.onDestroyView()
_binding = null
}
abstract fun getViewModelClass(): Class<VM>
}

View File

@ -1,4 +1,4 @@
package com.kaixed.kchat.extensions
package com.kaixed.core.common.extension
import android.content.Intent
import android.os.Build
@ -16,4 +16,8 @@ inline fun <reified T : Parcelable> Intent.getParcelableExtraCompat(key: String)
@Suppress("DEPRECATION")
getParcelableExtra(key)
}
}
fun Int.dp2px(density: Float): Int {
return (this * density + 0.5f).toInt()
}

View File

@ -0,0 +1,35 @@
package com.kaixed.core.common.utils
/**
* @Author: kaixed
* @Date: 2024/10/24 22:03
*/
object Constants {
object Sign {
const val SECRET_KEY = "123"
}
// mmkv
const val MMKV_USER_SESSION: String = "userSession"
const val MMKV_COMMON_DATA: String = "commonData"
const val USERNAME_KEY = "username"
const val NICKNAME_KEY = "nickname"
const val AVATAR_URL = "avatarUrl"
const val FIRST_LAUNCH_APP = "firstLaunchApp"
const val USER_LOGIN_STATUS = "userLoginStatus"
const val STATUS_BAR_HEIGHT = "status_bar_height"
const val KEYBOARD_HEIGHT = "keyboardHeight"
const val CURRENT_CONTACT_ID = "currentContactId"
const val SCREEN_HEIGHT = "screenHeight"
const val SCREEN_WIDTH = "screenWidth"
const val ACCESS_TOKEN = "accessToken"
const val REFRESH_TOKEN = "refreshToken"
const val KEYBOARD_HEIGHT_RATIO = 0.15F
const val KEYBOARD_DEFAULT_HEIGHT = 300
const val STATUS_BAR_DEFAULT_HEIGHT: Int = 50
}

View File

@ -0,0 +1,55 @@
package com.kaixed.core.common.utils
import com.kaixed.core.common.utils.Constants.AVATAR_URL
import com.kaixed.core.common.utils.Constants.CURRENT_CONTACT_ID
import com.kaixed.core.common.utils.Constants.FIRST_LAUNCH_APP
import com.kaixed.core.common.utils.Constants.KEYBOARD_DEFAULT_HEIGHT
import com.kaixed.core.common.utils.Constants.KEYBOARD_HEIGHT
import com.kaixed.core.common.utils.Constants.MMKV_COMMON_DATA
import com.kaixed.core.common.utils.Constants.MMKV_USER_SESSION
import com.kaixed.core.common.utils.Constants.NICKNAME_KEY
import com.kaixed.core.common.utils.Constants.STATUS_BAR_DEFAULT_HEIGHT
import com.kaixed.core.common.utils.Constants.STATUS_BAR_HEIGHT
import com.kaixed.core.common.utils.Constants.USERNAME_KEY
import com.kaixed.core.common.utils.TextUtil.extractUrl
import com.tencent.mmkv.MMKV
import kotlin.getValue
/**
* @Author: kaixed
* @Date: 2024/10/24 22:04
*/
object ConstantsUtils {
private val commonDataMMKV by lazy { MMKV.mmkvWithID(MMKV_COMMON_DATA) }
private val userSessionMMKV by lazy { MMKV.mmkvWithID(MMKV_USER_SESSION) }
private val defaultMMKV by lazy { MMKV.defaultMMKV() }
fun getKeyboardHeight(): Int =
defaultMMKV.getInt(KEYBOARD_HEIGHT, KEYBOARD_DEFAULT_HEIGHT)
fun getUsername(): String =
userSessionMMKV.getString(USERNAME_KEY, "") ?: ""
fun getAvatarUrl(): String {
val avatarUrl = userSessionMMKV.getString(AVATAR_URL, "") ?: ""
return extractUrl(avatarUrl)
}
fun getStatusBarHeight(): Int =
commonDataMMKV.getInt(STATUS_BAR_HEIGHT, STATUS_BAR_DEFAULT_HEIGHT)
fun getNickName(): String =
userSessionMMKV.getString(NICKNAME_KEY, "") ?: ""
fun getCurrentContactId(): String =
userSessionMMKV.getString(CURRENT_CONTACT_ID, "") ?: ""
fun isFirstLaunchApp(): Boolean {
val isFirstLaunch = defaultMMKV.getBoolean(FIRST_LAUNCH_APP, true)
if (isFirstLaunch) {
defaultMMKV.putBoolean(FIRST_LAUNCH_APP, false)
}
return isFirstLaunch
}
}

View File

@ -0,0 +1,55 @@
package com.kaixed.core.common.utils
/**
* @Author: kaixed
* @Date: 2024/10/14 13:46
*/
object NetworkInterface {
// private const val URL = "app.kaixed.com/kchat"
private const val URL = "192.168.225.209:6196"
// private const val URL = "49.233.105.103:6000"
// const val SERVER_URL = "https://$URL"
const val SERVER_URL = "http://$URL"
const val WEBSOCKET_SERVER_URL = "ws://$URL"
const val WEBSOCKET = "/websocket/single/"
// 获取access-token
const val ACCESS_TOKEN = "auth/access-token"
// 获取refresh-token
const val REFRESH_TOKEN = "auth/refresh-token"
// 登录接口(根据用户名登录)
const val LOGIN_BY_USERNAME = "auth/login/username"
// 登录接口(根据电话登录)
const val LOGIN_BY_TELEPHONE = "auth/login/telephone"
// 注册接口
const val REGISTER = "auth/register"
// 上传文件
const val UPLOAD_FILE = "file/upload"
// 更新用户密码
const val UPDATE_PASSWORD = "user/password/update"
// 获取用户信息
const val GET_USER_INFO = "user/info/fetch"
// 更新用户信息
const val UPDATE_USER_INFO = "user/info/update"
// 上传用户头像
const val UPLOAD_AVATAR = "user/upload-avatar"
// 发送好友请求
const val SEND_FRIEND_REQUEST = "friends/requests/send"
// 接受好友请求
const val ACCEPT_FRIEND_REQUEST = "friends/requests/accept"
// 获取好友列表
const val GET_FRIENDS = "friends/list"
// 获取好友请求列表
const val GET_FRIEND_REQUEST_LIST = "friends/requests/fetch"
// 删除好友
const val DELETE_FRIEND = "friends/delete-friend"
// 设置好友备注
const val SET_FRIEND_REMARK = "friends/set-remark"
//撤回消息
const val RECALL_MESSAGE = "message/withdraw"
}

Some files were not shown because too many files have changed in this diff Show More