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 android.content.Context
import androidx.room.Room 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.MessageDao
import com.example.login_v3.data.local.dao.RoomLocalDao
import dagger.Module import dagger.Module
import dagger.Provides import dagger.Provides
import dagger.hilt.InstallIn import dagger.hilt.InstallIn
@ -31,7 +34,24 @@ object DatabaseModule {
@Provides @Provides
@Singleton @Singleton
fun provideMessageDao(database: AppDatabase): MessageDao { fun provideMessageDao(database: AppDatabase): MessageDao {
// 💡 告訴 Hilt:當有人要 MessageDao 時,就從 database 裡面把 messageDao() 拿出來提供給他!
return 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.PendingFriendApiModel
import com.example.login_v3.data.api.api_class.UserDetail 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.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 retrofit2.Response
import javax.inject.Inject import javax.inject.Inject
class FriendsRepository @Inject constructor( 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>> { suspend fun getFriendList(): Result<List<Friend>> {
return try { return try {
val response = apiService.getFriends() val response = apiService.getFriends()

View file

@ -1,5 +1,6 @@
package com.example.login_v3.data.repository.basic package com.example.login_v3.data.repository.basic
import android.util.Log
import androidx.room.withTransaction import androidx.room.withTransaction
import com.example.login_v3.data.api.TecnologiaApi import com.example.login_v3.data.api.TecnologiaApi
import com.example.login_v3.data.di.AppDatabase 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.FriendLocalEntity
import com.example.login_v3.data.local.entities.RoomLocalEntity import com.example.login_v3.data.local.entities.RoomLocalEntity
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
@ -21,22 +24,38 @@ class SyncRepository @Inject constructor(
private val roomDao = db.roomLocalDao() private val roomDao = db.roomLocalDao()
private val blockDao = db.blockedUserDao() private val blockDao = db.blockedUserDao()
// 🔒 使用 Mutex 確保同一時間只有一個同步任務在執行,避免重複請求
private val syncMutex = Mutex()
suspend fun performSync(): Result<Unit> { suspend fun performSync(): Result<Unit> {
return try { // 如果已經有同步在跑,直接回傳成功(避免重複排隊)
if (syncMutex.isLocked) {
Log.d("SyncDebug", "⏳ [Sync] 偵測到已有同步任務正在進行,跳過本次請求")
return Result.success(Unit)
}
return syncMutex.withLock {
try {
val since = userPrefs.syncSinceFlow.first() val since = userPrefs.syncSinceFlow.first()
Log.d("SyncDebug", "🚀 [Sync] 開始同步 | since: $since")
val response = api.sync(since) val response = api.sync(since)
if (response.isSuccessful) { if (response.isSuccessful) {
val body = response.body() ?: return Result.failure(Exception("Sync body is null")) 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 { db.withTransaction {
if (body.fullSnapshot) { if (body.fullSnapshot) {
Log.w("SyncDebug", "⚠️ [Sync] 收到 FullSnapshot,正在清空本地舊資料...")
friendDao.deleteAll() friendDao.deleteAll()
roomDao.deleteAll() roomDao.deleteAll()
blockDao.deleteAll() blockDao.deleteAll()
} }
// Process Blocks // 1. 處理黑名單
body.blocks.forEach { block -> body.blocks.forEach { block ->
if (block.removed) { if (block.removed) {
blockDao.deleteById(block.userId) blockDao.deleteById(block.userId)
@ -53,7 +72,7 @@ class SyncRepository @Inject constructor(
} }
} }
// Process Friends // 2. 處理好友
body.friends.forEach { friend -> body.friends.forEach { friend ->
if (friend.removed) { if (friend.removed) {
friendDao.deleteById(friend.userId) friendDao.deleteById(friend.userId)
@ -71,7 +90,7 @@ class SyncRepository @Inject constructor(
} }
} }
// Process Rooms // 3. 處理聊天室
body.rooms.forEach { room -> body.rooms.forEach { room ->
if (room.removed) { if (room.removed) {
roomDao.deleteById(room.roomId) roomDao.deleteById(room.roomId)
@ -93,13 +112,32 @@ class SyncRepository @Inject constructor(
} }
userPrefs.saveSyncSince(body.nextSince) userPrefs.saveSyncSince(body.nextSince)
Log.d("SyncDebug", "💾 [Sync] 同步完成,已更新 nextSince 為 ${body.nextSince}")
} }
Result.success(Unit) Result.success(Unit)
} else { } else {
Log.e("SyncDebug", "❌ [Sync] API 失敗 | Code: ${response.code()}")
Result.failure(Exception("Sync API failed: ${response.code()}")) Result.failure(Exception("Sync API failed: ${response.code()}"))
} }
} catch (e: Exception) { } catch (e: Exception) {
Log.e("SyncDebug", "❌ [Sync] 發生異常", e)
Result.failure(e) 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.di.ChatWebSocketManager
import com.example.login_v3.data.repository.basic.TokenManager 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.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.MessageEntity
import com.example.login_v3.data.local.entities.MessageStatus 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.Json
import com.squareup.moshi.JsonClass import com.squareup.moshi.JsonClass
import com.squareup.moshi.Moshi import com.squareup.moshi.Moshi
@ -52,12 +54,31 @@ import kotlin.jvm.java
class ChatRoomsRepository @Inject constructor( class ChatRoomsRepository @Inject constructor(
private val api: TecnologiaApi, private val api: TecnologiaApi,
private val messageDao: MessageDao, private val messageDao: MessageDao,
private val roomLocalDao: RoomLocalDao, // 👈 新增:注入房間 DAO
private val webSocketManager: ChatWebSocketManager, // 👈 1. 注入 Socket 管理器 private val webSocketManager: ChatWebSocketManager, // 👈 1. 注入 Socket 管理器
private val moshi: Moshi, // 👈 用於解析 Socket 傳來的 JSON private val moshi: Moshi, // 👈 用於解析 Socket 傳來的 JSON
private val tokenManager: TokenManager, private val tokenManager: TokenManager,
@ApplicationContext private val context: Context // 注入 ApplicationContext 用於處理 Uri 檔案 @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 // 建立一個跟著 Repository 生命週期走的 Scope
private val repositoryScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) 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.PendingFriendApiModel
import com.example.login_v3.data.api.api_class.UserDetail 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.FriendsRepository
import com.example.login_v3.data.repository.basic.SyncRepository
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
@ -31,32 +32,42 @@ data class ContactUiState(
) )
@HiltViewModel @HiltViewModel
class ContactListViewModel @Inject constructor( class ContactListViewModel @Inject constructor(
private val repository: FriendsRepository private val repository: FriendsRepository,
private val syncRepository: SyncRepository
) : ViewModel() { ) : ViewModel() {
private val _uiState = MutableStateFlow(ContactUiState()) private val _uiState = MutableStateFlow(ContactUiState())
val uiState: StateFlow<ContactUiState> = _uiState.asStateFlow() val uiState: StateFlow<ContactUiState> = _uiState.asStateFlow()
init { init {
// 1. 監聽本地好友 Flow,一旦資料庫變動,UI 自動刷新
observeFriends()
// 2. 啟動時跑一次同步
refreshAll() refreshAll()
} }
private fun observeFriends() {
viewModelScope.launch {
repository.friendsFlow.collect { friends ->
_uiState.update { it.copy(friends = friends) }
}
}
}
fun refreshAll() { fun refreshAll() {
viewModelScope.launch { viewModelScope.launch {
// 1. 開始 Loading 並清除舊錯誤
_uiState.update { it.copy(isLoading = true, error = null) } _uiState.update { it.copy(isLoading = true, error = null) }
// 2. 使用 zip 或單獨 fetch,這裡示範簡單的循序或並行處理 // 🎯 同步核心:跑 Delta-Sync
val friendsResult = repository.getFriendList() val syncResult = syncRepository.performSync()
// 同時還是要抓一下 Pending Request (目前 Sync API 沒給這塊)
val pendingResult = repository.getPendingRequests() val pendingResult = repository.getPendingRequests()
// 3. 統一處理結果
_uiState.update { state -> _uiState.update { state ->
state.copy( state.copy(
isLoading = false, isLoading = false,
friends = friendsResult.getOrDefault(state.friends),
pendingRequests = pendingResult.getOrDefault(state.pendingRequests), 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.ChatRoom
import com.example.login_v3.data.api.api_class.Message 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.dm.ChatRoomsRepository
import com.example.login_v3.data.repository.basic.SyncRepository
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import javax.inject.Inject import javax.inject.Inject
@HiltViewModel @HiltViewModel
class ChatRoomsViewModel @Inject constructor( class ChatRoomsViewModel @Inject constructor(
private val repository: ChatRoomsRepository private val repository: ChatRoomsRepository,
private val syncRepository: SyncRepository
) : ViewModel() { ) : ViewModel() {
private val _roomsState = MutableStateFlow<List<ChatRoom>>(emptyList()) private val _roomsState = MutableStateFlow<List<ChatRoom>>(emptyList())
val roomsState: StateFlow<List<ChatRoom>> = _roomsState val roomsState: StateFlow<List<ChatRoom>> = _roomsState
init {
observeRooms()
loadRooms()
}
private fun observeRooms() {
viewModelScope.launch {
repository.chatRoomsFlow.collectLatest { rooms ->
_roomsState.value = rooms
}
}
}
fun loadRooms() { fun loadRooms() {
viewModelScope.launch { viewModelScope.launch {
Log.d("ChatRoomsDebug", "1. 開始呼叫 loadRooms()...") Log.d("ChatRoomsDebug", "📡 [ChatRoomsViewModel] 觸發同步...")
val result = repository.fetchRooms() val result = syncRepository.performSync()
result.onFailure { error ->
result.onSuccess { response -> Log.e("ChatRoomsDebug", "❌ 同步失敗", error)
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)
} }
} }
} }