add delta sync for dm-v1

This commit is contained in:
eric 2026-07-22 14:16:24 +08:00
parent 61926d1f61
commit 6466a36547
15 changed files with 400 additions and 74 deletions

10
.idea/planningMode.xml Normal file
View file

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="PlanningModeManager">
<option name="approvalStates">
<map>
<entry key="53d32dd4-9fa5-47db-9a92-2613918d056e" value="false" />
</map>
</option>
</component>
</project>

View file

@ -30,6 +30,10 @@ data class RefreshResponse(
interface TecnologiaApi {
//sync dm data
@GET("api/sync")
suspend fun sync(@Query("since") since: Long): Response<SyncResponse>
@GET("api/health")
suspend fun checkHealth(): Response<Unit>

View file

@ -1,7 +1,7 @@
package com.example.login_v3.data.api.api_class
import com.example.login_v3.R
import com.example.login_v3.home.Message.ViewModel.Detail.MessageStatus
import com.example.login_v3.data.local.entities.MessageStatus
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
import com.squareup.moshi.Moshi
@ -162,7 +162,52 @@ data class MessageReactionUsersResponse(
//voice call
// --- Delta-Sync 相關 ---
@JsonClass(generateAdapter = true)
data class SyncResponse(
@Json(name = "blocks") val blocks: List<SyncBlock>,
@Json(name = "friends") val friends: List<SyncFriend>,
@Json(name = "full_snapshot") val fullSnapshot: Boolean,
@Json(name = "next_since") val nextSince: Long,
@Json(name = "rooms") val rooms: List<SyncRoom>
)
@JsonClass(generateAdapter = true)
data class SyncBlock(
@Json(name = "avatar_url") val avatarUrl: String?,
@Json(name = "blocked_at") val blockedAt: String,
@Json(name = "display_name") val displayName: String,
@Json(name = "removed") val removed: Boolean = false,
@Json(name = "user_id") val userId: String,
@Json(name = "username") val username: String
)
@JsonClass(generateAdapter = true)
data class SyncFriend(
@Json(name = "avatar_url") val avatarUrl: String?,
@Json(name = "display_name") val displayName: String,
@Json(name = "presence_status") val presenceStatus: String,
@Json(name = "removed") val removed: Boolean = false,
@Json(name = "status") val status: String,
@Json(name = "user_id") val userId: String,
@Json(name = "username") val username: String
)
@JsonClass(generateAdapter = true)
data class SyncRoom(
@Json(name = "is_hidden") val isHidden: Boolean,
@Json(name = "is_muted") val isMuted: Boolean,
@Json(name = "is_pinned") val isPinned: Boolean,
@Json(name = "last_message") val lastMessage: LastMessage?,
@Json(name = "mention_count") val mentionCount: Int,
@Json(name = "nickname") val nickname: String?,
@Json(name = "partner") val partner: Partner?,
@Json(name = "removed") val removed: Boolean = false,
@Json(name = "room_id") val roomId: String,
@Json(name = "unread_count") val unreadCount: Int
)
// --- Voice Call 相關 ---
@JsonClass(generateAdapter = true)
data class CallLogInfo(
@Json(name = "type") val type: String, // 例如: call_missed, call_rejected

View file

@ -3,11 +3,29 @@ package com.example.login_v3.data.di
import androidx.room.Database
import androidx.room.RoomDatabase
import androidx.room.TypeConverters
import com.example.login_v3.home.Message.ViewModel.Detail.MessageDao
import com.example.login_v3.home.Message.ViewModel.Detail.MessageEntity
import com.example.login_v3.data.local.dao.BlockedUserDao
import com.example.login_v3.data.local.dao.FriendDao
import com.example.login_v3.data.local.dao.MessageDao
import com.example.login_v3.data.local.dao.RoomLocalDao
import com.example.login_v3.data.local.entities.BlockedUserLocalEntity
import com.example.login_v3.data.local.entities.FriendLocalEntity
import com.example.login_v3.data.local.entities.MessageEntity
import com.example.login_v3.data.local.entities.RoomLocalEntity
@Database(entities = [MessageEntity::class], version = 1, exportSchema = false)
@Database(
entities = [
MessageEntity::class,
FriendLocalEntity::class,
RoomLocalEntity::class,
BlockedUserLocalEntity::class
],
version = 2,
exportSchema = false
)
@TypeConverters(RoomConverters::class)
abstract class AppDatabase : RoomDatabase() {
abstract fun messageDao(): MessageDao
abstract fun friendDao(): FriendDao
abstract fun roomLocalDao(): RoomLocalDao
abstract fun blockedUserDao(): BlockedUserDao
}

View file

@ -2,7 +2,7 @@ package com.example.login_v3.data.di
import android.content.Context
import androidx.room.Room
import com.example.login_v3.home.Message.ViewModel.Detail.MessageDao
import com.example.login_v3.data.local.dao.MessageDao
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
@ -24,7 +24,7 @@ object DatabaseModule {
AppDatabase::class.java,
"tecnologia_chat_db" // 資料庫檔案名稱
)
// .fallbackToDestructiveMigration() // 💡 提示:未來如果修改 Entity 欄位導致崩潰,可以先取消這行註解來清除舊資料庫
.fallbackToDestructiveMigration()
.build()
}

View file

@ -4,29 +4,62 @@ import androidx.room.TypeConverter
import com.squareup.moshi.Moshi
import com.squareup.moshi.Types
import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory // 👈 確保有 import 這個
import com.example.login_v3.data.api.api_class.LastMessage
import com.example.login_v3.data.api.api_class.Partner
import com.example.login_v3.data.api.api_class.Reaction
class RoomConverters {
// 💡 核心修正:加入 KotlinJsonAdapterFactory 賦予 Moshi 反射辨識 Kotlin Data Class 的能力
private val moshi = Moshi.Builder()
.addLast(KotlinJsonAdapterFactory())
.build()
private val reactionListType = Types.newParameterizedType(List::class.java, Reaction::class.java)
private val adapter = moshi.adapter<List<Reaction>>(reactionListType)
private val reactionAdapter = moshi.adapter<List<Reaction>>(reactionListType)
private val partnerAdapter = moshi.adapter(Partner::class.java)
private val lastMessageAdapter = moshi.adapter(LastMessage::class.java)
@TypeConverter
fun fromReactionList(reactions: List<Reaction>?): String? {
return adapter.toJson(reactions ?: emptyList())
return reactionAdapter.toJson(reactions ?: emptyList())
}
@TypeConverter
fun toReactionList(json: String?): List<Reaction>? {
if (json.isNullOrBlank()) return emptyList()
return try {
adapter.fromJson(json)
reactionAdapter.fromJson(json)
} catch (e: Exception) {
emptyList()
}
}
@TypeConverter
fun fromPartner(partner: Partner?): String? {
return partnerAdapter.toJson(partner)
}
@TypeConverter
fun toPartner(json: String?): Partner? {
if (json.isNullOrBlank()) return null
return try {
partnerAdapter.fromJson(json)
} catch (e: Exception) {
null
}
}
@TypeConverter
fun fromLastMessage(lastMessage: LastMessage?): String? {
return lastMessageAdapter.toJson(lastMessage)
}
@TypeConverter
fun toLastMessage(json: String?): LastMessage? {
if (json.isNullOrBlank()) return null
return try {
lastMessageAdapter.fromJson(json)
} catch (e: Exception) {
null
}
}
}

View file

@ -21,6 +21,26 @@ class UserPreferencesRepository(private val dataStore: DataStore<Preferences>) {
val THEME_MODE = stringPreferencesKey("theme_mode")
// 新增:儲存自訂壁紙檔案路徑的 Key
val CUSTOM_WALLPAPER_PATH = stringPreferencesKey("custom_wallpaper_path")
val SYNC_SINCE = androidx.datastore.preferences.core.longPreferencesKey("sync_since")
}
// 讀取同步時間
val syncSinceFlow: Flow<Long> = dataStore.data
.catch { exception ->
if (exception is IOException) {
emit(emptyPreferences())
} else {
throw exception
}
}
.map { preferences ->
preferences[PreferencesKeys.SYNC_SINCE] ?: 0L
}
suspend fun saveSyncSince(since: Long) {
dataStore.edit { preferences ->
preferences[PreferencesKeys.SYNC_SINCE] = since
}
}
// 讀取設定:直接在宣告時賦值,簡潔又安全

View file

@ -0,0 +1,33 @@
package com.example.login_v3.data.local.dao
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import androidx.room.Transaction
import com.example.login_v3.data.local.entities.MessageEntity
import kotlinx.coroutines.flow.Flow
@Dao
interface MessageDao {
@Query("SELECT * FROM messages WHERE chatRoomId = :roomId ORDER BY createdAt DESC")
fun getMessagesFlow(roomId: String): Flow<List<MessageEntity>>
@Query("SELECT * FROM messages WHERE id = :messageId LIMIT 1")
suspend fun getMessageById(messageId: String): MessageEntity?
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertOrUpdate(message: MessageEntity)
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertOrUpdateList(messages: List<MessageEntity>)
@Query("DELETE FROM messages WHERE id = :id")
suspend fun deleteById(id: String)
@Transaction
suspend fun replaceTempMessageWithSuccess(tempId: String, successEntity: MessageEntity) {
deleteById(tempId)
insertOrUpdate(successEntity)
}
}

View file

@ -0,0 +1,55 @@
package com.example.login_v3.data.local.dao
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import com.example.login_v3.data.local.entities.BlockedUserLocalEntity
import com.example.login_v3.data.local.entities.FriendLocalEntity
import com.example.login_v3.data.local.entities.RoomLocalEntity
import kotlinx.coroutines.flow.Flow
@Dao
interface FriendDao {
@Query("SELECT * FROM friends")
fun getAllFriendsFlow(): Flow<List<FriendLocalEntity>>
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertOrUpdate(friends: List<FriendLocalEntity>)
@Query("DELETE FROM friends WHERE userId = :userId")
suspend fun deleteById(userId: String)
@Query("DELETE FROM friends")
suspend fun deleteAll()
}
@Dao
interface RoomLocalDao {
@Query("SELECT * FROM rooms ORDER BY isPinned DESC")
fun getAllRoomsFlow(): Flow<List<RoomLocalEntity>>
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertOrUpdate(rooms: List<RoomLocalEntity>)
@Query("DELETE FROM rooms WHERE roomId = :roomId")
suspend fun deleteById(roomId: String)
@Query("DELETE FROM rooms")
suspend fun deleteAll()
}
@Dao
interface BlockedUserDao {
@Query("SELECT * FROM blocks")
fun getAllBlocksFlow(): Flow<List<BlockedUserLocalEntity>>
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertOrUpdate(blocks: List<BlockedUserLocalEntity>)
@Query("DELETE FROM blocks WHERE userId = :userId")
suspend fun deleteById(userId: String)
@Query("DELETE FROM blocks")
suspend fun deleteAll()
}

View file

@ -0,0 +1,22 @@
package com.example.login_v3.data.local.entities
import androidx.room.Entity
import androidx.room.PrimaryKey
import com.example.login_v3.data.api.api_class.Reaction
enum class MessageStatus { SENDING, SUCCESS, FAILED }
@Entity(tableName = "messages")
data class MessageEntity(
@PrimaryKey val id: String,
val chatRoomId: String,
val senderId: String,
val content: String,
val type: String,
val createdAt: String,
val isEdited: Boolean,
val isDeleted: Boolean,
val replyToId: String?,
val status: MessageStatus,
val reactions: List<Reaction>?
)

View file

@ -0,0 +1,38 @@
package com.example.login_v3.data.local.entities
import androidx.room.Entity
import androidx.room.PrimaryKey
import com.example.login_v3.data.api.api_class.LastMessage
import com.example.login_v3.data.api.api_class.Partner
@Entity(tableName = "friends")
data class FriendLocalEntity(
@PrimaryKey val userId: String,
val username: String,
val displayName: String,
val avatarUrl: String?,
val status: String,
val presenceStatus: String
)
@Entity(tableName = "rooms")
data class RoomLocalEntity(
@PrimaryKey val roomId: String,
val nickname: String?,
val isHidden: Boolean,
val isMuted: Boolean,
val isPinned: Boolean,
val unreadCount: Int,
val mentionCount: Int,
val partner: Partner?,
val lastMessage: LastMessage?
)
@Entity(tableName = "blocks")
data class BlockedUserLocalEntity(
@PrimaryKey val userId: String,
val username: String,
val displayName: String,
val avatarUrl: String?,
val blockedAt: String
)

View file

@ -0,0 +1,105 @@
package com.example.login_v3.data.repository.basic
import androidx.room.withTransaction
import com.example.login_v3.data.api.TecnologiaApi
import com.example.login_v3.data.di.AppDatabase
import com.example.login_v3.data.local.UserPreferences.UserPreferencesRepository
import com.example.login_v3.data.local.entities.BlockedUserLocalEntity
import com.example.login_v3.data.local.entities.FriendLocalEntity
import com.example.login_v3.data.local.entities.RoomLocalEntity
import kotlinx.coroutines.flow.first
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class SyncRepository @Inject constructor(
private val api: TecnologiaApi,
private val db: AppDatabase,
private val userPrefs: UserPreferencesRepository
) {
private val friendDao = db.friendDao()
private val roomDao = db.roomLocalDao()
private val blockDao = db.blockedUserDao()
suspend fun performSync(): Result<Unit> {
return try {
val since = userPrefs.syncSinceFlow.first()
val response = api.sync(since)
if (response.isSuccessful) {
val body = response.body() ?: return Result.failure(Exception("Sync body is null"))
db.withTransaction {
if (body.fullSnapshot) {
friendDao.deleteAll()
roomDao.deleteAll()
blockDao.deleteAll()
}
// Process Blocks
body.blocks.forEach { block ->
if (block.removed) {
blockDao.deleteById(block.userId)
} else {
blockDao.insertOrUpdate(listOf(
BlockedUserLocalEntity(
userId = block.userId,
username = block.username,
displayName = block.displayName,
avatarUrl = block.avatarUrl,
blockedAt = block.blockedAt
)
))
}
}
// Process Friends
body.friends.forEach { friend ->
if (friend.removed) {
friendDao.deleteById(friend.userId)
} else {
friendDao.insertOrUpdate(listOf(
FriendLocalEntity(
userId = friend.userId,
username = friend.username,
displayName = friend.displayName,
avatarUrl = friend.avatarUrl,
status = friend.status,
presenceStatus = friend.presenceStatus
)
))
}
}
// Process Rooms
body.rooms.forEach { room ->
if (room.removed) {
roomDao.deleteById(room.roomId)
} else {
roomDao.insertOrUpdate(listOf(
RoomLocalEntity(
roomId = room.roomId,
nickname = room.nickname,
isHidden = room.isHidden,
isMuted = room.isMuted,
isPinned = room.isPinned,
unreadCount = room.unreadCount,
mentionCount = room.mentionCount,
partner = room.partner,
lastMessage = room.lastMessage
)
))
}
}
userPrefs.saveSyncSince(body.nextSince)
}
Result.success(Unit)
} else {
Result.failure(Exception("Sync API failed: ${response.code()}"))
}
} catch (e: Exception) {
Result.failure(e)
}
}
}

View file

@ -17,9 +17,9 @@ import com.example.login_v3.data.api.api_class.SendMessageRequest
import com.example.login_v3.data.api.api_class.WebSocketEventResponse
import com.example.login_v3.data.di.ChatWebSocketManager
import com.example.login_v3.data.repository.basic.TokenManager
import com.example.login_v3.home.Message.ViewModel.Detail.MessageDao
import com.example.login_v3.home.Message.ViewModel.Detail.MessageEntity
import com.example.login_v3.home.Message.ViewModel.Detail.MessageStatus
import com.example.login_v3.data.local.dao.MessageDao
import com.example.login_v3.data.local.entities.MessageEntity
import com.example.login_v3.data.local.entities.MessageStatus
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
import com.squareup.moshi.Moshi

View file

@ -72,7 +72,7 @@ import com.example.login_v3.home.Message.UI.Detail.Message_Component.ReactionRow
import com.example.login_v3.home.Message.UI.Detail.Message_Component.UserAvatar
import com.example.login_v3.home.Message.ViewModel.Detail.ChatViewModel
import com.example.login_v3.home.Message.ViewModel.Detail.DeleteMessageState
import com.example.login_v3.home.Message.ViewModel.Detail.MessageStatus
import com.example.login_v3.data.local.entities.MessageStatus
import com.example.login_v3.home.Message.ViewModel.Detail.MessagesUiState
import com.example.login_v3.home.Message.ViewModel.Detail.SendMessageState
import com.example.login_v3.navigation.BottomBarViewModel

View file

@ -1,57 +0,0 @@
package com.example.login_v3.home.Message.ViewModel.Detail
import androidx.room.Dao
import androidx.room.Entity
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.PrimaryKey
import kotlinx.coroutines.flow.Flow
import androidx.room.Query
import androidx.room.Transaction
import com.example.login_v3.data.api.api_class.Reaction
enum class MessageStatus { SENDING, SUCCESS, FAILED }
@Entity(tableName = "messages")
data class MessageEntity(
@PrimaryKey val id: String,
val chatRoomId: String, // 👈 對齊你的 @Json(name = "chat_room_id")
val senderId: String,
val content: String,
val type: String, // 👈 新增:文字、圖片等類型
val createdAt: String, // 👈 核心修正:改成與 API 一致的 String 格式
val isEdited: Boolean, // 👈 新增
val isDeleted: Boolean, // 👈 新增
val replyToId: String?, // 👈 新增
val status: MessageStatus, // 👈 樂觀更新狀態控制 (SENDING, SUCCESS, FAILED)
val reactions: List<Reaction>?
)
@Dao
interface MessageDao {
// 1. 根據 roomId 監聽整個聊天室的訊息 Flow
@Query("SELECT * FROM messages WHERE chatRoomId = :roomId ORDER BY createdAt DESC")
fun getMessagesFlow(roomId: String): Flow<List<MessageEntity>>
// 2. ✨【新增】根據訊息 id 查詢單筆資料(樂觀更新備份用)
@Query("SELECT * FROM messages WHERE id = :messageId LIMIT 1")
suspend fun getMessageById(messageId: String): MessageEntity?
// 3. 插入或更新單筆訊息ssss
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertOrUpdate(message: MessageEntity)
// 4. ✨【優化】支援批次插入或更新(可以順便把 refreshChatMessages 裡面的 forEach 換掉,效能更好)
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertOrUpdateList(messages: List<MessageEntity>)
// 5. 根據 id 刪除特定訊息
@Query("DELETE FROM messages WHERE id = :id")
suspend fun deleteById(id: String)
@Transaction
suspend fun replaceTempMessageWithSuccess(tempId: String, successEntity: MessageEntity) {
deleteById(tempId) // 1. 先刪除本地暫存 ID
insertOrUpdate(successEntity) // 2. 立刻寫入後端正式 ID
}
}