add delta sync for dm-v2

This commit is contained in:
eric 2026-07-23 15:39:08 +08:00
parent 6466a36547
commit 9df448b79d
6 changed files with 206 additions and 105 deletions

View file

@ -2,7 +2,10 @@ package com.example.login_v3.data.di
import android.content.Context
import androidx.room.Room
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 dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
@ -31,7 +34,24 @@ object DatabaseModule {
@Provides
@Singleton
fun provideMessageDao(database: AppDatabase): MessageDao {
// 💡 告訴 Hilt:當有人要 MessageDao 時,就從 database 裡面把 messageDao() 拿出來提供給他!
return database.messageDao()
}
@Provides
@Singleton
fun provideFriendDao(database: AppDatabase): FriendDao {
return database.friendDao()
}
@Provides
@Singleton
fun provideRoomLocalDao(database: AppDatabase): RoomLocalDao {
return database.roomLocalDao()
}
@Provides
@Singleton
fun provideBlockedUserDao(database: AppDatabase): BlockedUserDao {
return database.blockedUserDao()
}
}

View file

@ -7,13 +7,31 @@ import com.example.login_v3.data.api.api_class.Friend
import com.example.login_v3.data.api.api_class.PendingFriendApiModel
import com.example.login_v3.data.api.api_class.UserDetail
import com.example.login_v3.data.api.api_class.UserSearchResponse
import com.example.login_v3.data.local.dao.FriendDao
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import retrofit2.Response
import javax.inject.Inject
class FriendsRepository @Inject constructor(
private val apiService: TecnologiaApi
private val apiService: TecnologiaApi,
private val friendDao: FriendDao
) {
// 🌟 核心:監聽本地資料庫的好友列表 Flow
val friendsFlow: Flow<List<Friend>> = friendDao.getAllFriendsFlow().map { entities ->
entities.map { entity ->
Friend(
friendId = entity.userId,
username = entity.username,
displayName = entity.displayName,
avatarUrl = entity.avatarUrl,
status = entity.status,
acceptedAt = null // Sync API 目前沒給這個,先設為 null
)
}
}
suspend fun getFriendList(): Result<List<Friend>> {
return try {
val response = apiService.getFriends()

View file

@ -1,5 +1,6 @@
package com.example.login_v3.data.repository.basic
import android.util.Log
import androidx.room.withTransaction
import com.example.login_v3.data.api.TecnologiaApi
import com.example.login_v3.data.di.AppDatabase
@ -8,6 +9,8 @@ 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 kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import javax.inject.Inject
import javax.inject.Singleton
@ -21,85 +24,120 @@ class SyncRepository @Inject constructor(
private val roomDao = db.roomLocalDao()
private val blockDao = db.blockedUserDao()
// 🔒 使用 Mutex 確保同一時間只有一個同步任務在執行,避免重複請求
private val syncMutex = Mutex()
suspend fun performSync(): Result<Unit> {
return try {
val since = userPrefs.syncSinceFlow.first()
val response = api.sync(since)
// 如果已經有同步在跑,直接回傳成功(避免重複排隊)
if (syncMutex.isLocked) {
Log.d("SyncDebug", "⏳ [Sync] 偵測到已有同步任務正在進行,跳過本次請求")
return Result.success(Unit)
}
if (response.isSuccessful) {
val body = response.body() ?: return Result.failure(Exception("Sync body is null"))
return syncMutex.withLock {
try {
val since = userPrefs.syncSinceFlow.first()
Log.d("SyncDebug", "🚀 [Sync] 開始同步 | since: $since")
db.withTransaction {
if (body.fullSnapshot) {
friendDao.deleteAll()
roomDao.deleteAll()
blockDao.deleteAll()
}
val response = api.sync(since)
// 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
)
))
if (response.isSuccessful) {
val body = response.body() ?: return Result.failure(Exception("Sync body is null"))
Log.d("SyncDebug", "✅ [Sync] API 成功 | FullSnapshot: ${body.fullSnapshot} | nextSince: ${body.nextSince}")
Log.d("SyncDebug", "📦 [Sync] 收到: Blocks(${body.blocks.size}), Friends(${body.friends.size}), Rooms(${body.rooms.size})")
db.withTransaction {
if (body.fullSnapshot) {
Log.w("SyncDebug", "⚠️ [Sync] 收到 FullSnapshot,正在清空本地舊資料...")
friendDao.deleteAll()
roomDao.deleteAll()
blockDao.deleteAll()
}
}
// 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
)
))
// 1. 處理黑名單
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 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
)
))
// 2. 處理好友
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
)
))
}
}
}
userPrefs.saveSyncSince(body.nextSince)
// 3. 處理聊天室
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)
Log.d("SyncDebug", "💾 [Sync] 同步完成,已更新 nextSince 為 ${body.nextSince}")
}
Result.success(Unit)
} else {
Log.e("SyncDebug", "❌ [Sync] API 失敗 | Code: ${response.code()}")
Result.failure(Exception("Sync API failed: ${response.code()}"))
}
Result.success(Unit)
} else {
Result.failure(Exception("Sync API failed: ${response.code()}"))
} catch (e: Exception) {
Log.e("SyncDebug", "❌ [Sync] 發生異常", e)
Result.failure(e)
}
} catch (e: Exception) {
Result.failure(e)
}
}
/**
* 🛠️ 調試專用:重置同步狀態並清空本地資料
* 呼叫此方法後,下一次同步將會從 since=0 開始抓取完整資料
*/
suspend fun resetSyncAndClearData() {
Log.w("SyncDebug", "🧨 [Debug] 正在重置同步狀態並清空資料庫...")
db.withTransaction {
friendDao.deleteAll()
roomDao.deleteAll()
blockDao.deleteAll()
userPrefs.saveSyncSince(0L)
}
Log.d("SyncDebug", "✅ [Debug] 重置完成")
}
}

View file

@ -18,8 +18,10 @@ 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.data.local.dao.MessageDao
import com.example.login_v3.data.local.dao.RoomLocalDao
import com.example.login_v3.data.local.entities.MessageEntity
import com.example.login_v3.data.local.entities.MessageStatus
import com.example.login_v3.data.local.entities.RoomLocalEntity
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
import com.squareup.moshi.Moshi
@ -52,12 +54,31 @@ import kotlin.jvm.java
class ChatRoomsRepository @Inject constructor(
private val api: TecnologiaApi,
private val messageDao: MessageDao,
private val roomLocalDao: RoomLocalDao, // 👈 新增:注入房間 DAO
private val webSocketManager: ChatWebSocketManager, // 👈 1. 注入 Socket 管理器
private val moshi: Moshi, // 👈 用於解析 Socket 傳來的 JSON
private val tokenManager: TokenManager,
@ApplicationContext private val context: Context // 注入 ApplicationContext 用於處理 Uri 檔案
) {
// 🌟 核心:監聽本地資料庫的聊天室列表 Flow
val chatRoomsFlow: Flow<List<ChatRoom>> = roomLocalDao.getAllRoomsFlow().map { entities ->
entities.map { entity ->
ChatRoom(
roomId = entity.roomId,
roomName = entity.nickname, // 或者 entity.partner?.displayName ? 根據 DTO 結構映射
roomType = null, // Entity 暫時沒存,可視需求補上
roomIconUrl = null, // Entity 暫時沒存,可視需求補上
isMuted = entity.isMuted,
isPinned = entity.isPinned,
unreadCount = entity.unreadCount,
mentionCount = entity.mentionCount,
partner = entity.partner,
lastMessage = entity.lastMessage
)
}
}
// 建立一個跟著 Repository 生命週期走的 Scope
private val repositoryScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)

View file

@ -7,6 +7,7 @@ import com.example.login_v3.data.api.api_class.Friend
import com.example.login_v3.data.api.api_class.PendingFriendApiModel
import com.example.login_v3.data.api.api_class.UserDetail
import com.example.login_v3.data.repository.basic.FriendsRepository
import com.example.login_v3.data.repository.basic.SyncRepository
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
@ -31,32 +32,42 @@ data class ContactUiState(
)
@HiltViewModel
class ContactListViewModel @Inject constructor(
private val repository: FriendsRepository
private val repository: FriendsRepository,
private val syncRepository: SyncRepository
) : ViewModel() {
private val _uiState = MutableStateFlow(ContactUiState())
val uiState: StateFlow<ContactUiState> = _uiState.asStateFlow()
init {
// 1. 監聽本地好友 Flow,一旦資料庫變動,UI 自動刷新
observeFriends()
// 2. 啟動時跑一次同步
refreshAll()
}
private fun observeFriends() {
viewModelScope.launch {
repository.friendsFlow.collect { friends ->
_uiState.update { it.copy(friends = friends) }
}
}
}
fun refreshAll() {
viewModelScope.launch {
// 1. 開始 Loading 並清除舊錯誤
_uiState.update { it.copy(isLoading = true, error = null) }
// 2. 使用 zip 或單獨 fetch,這裡示範簡單的循序或並行處理
val friendsResult = repository.getFriendList()
// 🎯 同步核心:跑 Delta-Sync
val syncResult = syncRepository.performSync()
// 同時還是要抓一下 Pending Request (目前 Sync API 沒給這塊)
val pendingResult = repository.getPendingRequests()
// 3. 統一處理結果
_uiState.update { state ->
state.copy(
isLoading = false,
friends = friendsResult.getOrDefault(state.friends),
pendingRequests = pendingResult.getOrDefault(state.pendingRequests),
error = (friendsResult.exceptionOrNull() ?: pendingResult.exceptionOrNull())?.message
error = (syncResult.exceptionOrNull() ?: pendingResult.exceptionOrNull())?.message
)
}
}

View file

@ -9,49 +9,42 @@ import com.example.login_v3.R
import com.example.login_v3.data.api.api_class.ChatRoom
import com.example.login_v3.data.api.api_class.Message
import com.example.login_v3.data.repository.dm.ChatRoomsRepository
import com.example.login_v3.data.repository.basic.SyncRepository
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
import javax.inject.Inject
@HiltViewModel
class ChatRoomsViewModel @Inject constructor(
private val repository: ChatRoomsRepository
private val repository: ChatRoomsRepository,
private val syncRepository: SyncRepository
) : ViewModel() {
private val _roomsState = MutableStateFlow<List<ChatRoom>>(emptyList())
val roomsState: StateFlow<List<ChatRoom>> = _roomsState
init {
observeRooms()
loadRooms()
}
private fun observeRooms() {
viewModelScope.launch {
repository.chatRoomsFlow.collectLatest { rooms ->
_roomsState.value = rooms
}
}
}
fun loadRooms() {
viewModelScope.launch {
Log.d("ChatRoomsDebug", "1. 開始呼叫 loadRooms()...")
val result = repository.fetchRooms()
result.onSuccess { response ->
val roomsList = response.rooms
Log.d("ChatRoomsDebug", "2. API 請求成功!拿到 ${roomsList?.size ?: 0} 個聊天室")
if (roomsList.isNullOrEmpty()) {
Log.w("ChatRoomsDebug", "⚠️ 注意:後端回傳的 rooms 列表是空的(或為 null)!")
} else {
roomsList.forEach { room ->
Log.d("ChatRoomsDebug", "-> 房間 ID: ${room.roomId}, 名稱: ${room.roomName}, Partner: ${room.partner?.displayName}, 狀態: ${room.partner?.status}")
}
}
// 更新狀態
_roomsState.value = roomsList ?: emptyList()
Log.d("ChatRoomsDebug", "3. _roomsState 已更新,當前長度: ${_roomsState.value.size}")
}.onFailure { error ->
// ⭐ 這裡非常重要,一定要印出 stackTrace 才知道為什麼失敗(例如:JSON 解析失敗、404、網路斷線)
Log.e("ChatRoomsDebug", "❌ API 請求失敗 (onFailure)!!", error)
Log.d("ChatRoomsDebug", "📡 [ChatRoomsViewModel] 觸發同步...")
val result = syncRepository.performSync()
result.onFailure { error ->
Log.e("ChatRoomsDebug", "❌ 同步失敗", error)
}
}
}