diff --git a/app/src/main/java/com/example/login_v3/data/di/DatabaseModule.kt b/app/src/main/java/com/example/login_v3/data/di/DatabaseModule.kt index e54ba23..0173217 100644 --- a/app/src/main/java/com/example/login_v3/data/di/DatabaseModule.kt +++ b/app/src/main/java/com/example/login_v3/data/di/DatabaseModule.kt @@ -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() } -} \ No newline at end of file + + @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() + } +} diff --git a/app/src/main/java/com/example/login_v3/data/repository/basic/Friends_Repository.kt b/app/src/main/java/com/example/login_v3/data/repository/basic/Friends_Repository.kt index 9252d1e..d66445a 100644 --- a/app/src/main/java/com/example/login_v3/data/repository/basic/Friends_Repository.kt +++ b/app/src/main/java/com/example/login_v3/data/repository/basic/Friends_Repository.kt @@ -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> = 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> { return try { val response = apiService.getFriends() diff --git a/app/src/main/java/com/example/login_v3/data/repository/basic/SyncRepository.kt b/app/src/main/java/com/example/login_v3/data/repository/basic/SyncRepository.kt index cb83022..26645d7 100644 --- a/app/src/main/java/com/example/login_v3/data/repository/basic/SyncRepository.kt +++ b/app/src/main/java/com/example/login_v3/data/repository/basic/SyncRepository.kt @@ -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 @@ -20,86 +23,121 @@ class SyncRepository @Inject constructor( private val friendDao = db.friendDao() private val roomDao = db.roomLocalDao() private val blockDao = db.blockedUserDao() + + // 🔒 使用 Mutex 確保同一時間只有一個同步任務在執行,避免重複請求 + private val syncMutex = Mutex() suspend fun performSync(): Result { - 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] 重置完成") + } } diff --git a/app/src/main/java/com/example/login_v3/data/repository/dm/DM_Basic_Repository.kt b/app/src/main/java/com/example/login_v3/data/repository/dm/DM_Basic_Repository.kt index 9ceefb1..af670a4 100644 --- a/app/src/main/java/com/example/login_v3/data/repository/dm/DM_Basic_Repository.kt +++ b/app/src/main/java/com/example/login_v3/data/repository/dm/DM_Basic_Repository.kt @@ -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> = 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) diff --git a/app/src/main/java/com/example/login_v3/home/Message/ViewModel/Detail/Contact_list_ViewModel.kt b/app/src/main/java/com/example/login_v3/home/Message/ViewModel/Detail/Contact_list_ViewModel.kt index 3b18327..98c3996 100644 --- a/app/src/main/java/com/example/login_v3/home/Message/ViewModel/Detail/Contact_list_ViewModel.kt +++ b/app/src/main/java/com/example/login_v3/home/Message/ViewModel/Detail/Contact_list_ViewModel.kt @@ -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 = _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 ) } } diff --git a/app/src/main/java/com/example/login_v3/home/Message/ViewModel/Message_ViewModel.kt b/app/src/main/java/com/example/login_v3/home/Message/ViewModel/Message_ViewModel.kt index 3b3fb97..43286e2 100644 --- a/app/src/main/java/com/example/login_v3/home/Message/ViewModel/Message_ViewModel.kt +++ b/app/src/main/java/com/example/login_v3/home/Message/ViewModel/Message_ViewModel.kt @@ -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>(emptyList()) val roomsState: StateFlow> = _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) } } }