add delta sync for dm-v22

This commit is contained in:
eric 2026-08-10 04:19:21 +08:00
parent 177c888c84
commit 2ee12043af
10 changed files with 119 additions and 25 deletions

View file

@ -124,7 +124,13 @@ data class Message(
var aspectRatio: Float? = null, var aspectRatio: Float? = null,
@kotlin.jvm.Transient @kotlin.jvm.Transient
var callLogInfo: CallLogInfo? = null var callLogInfo: CallLogInfo? = null,
@kotlin.jvm.Transient
var isDownloaded: Boolean = false,
@kotlin.jvm.Transient
var localPath: String? = null
) { ) {
init { init {
if (type == "system" || type.startsWith("call")) { if (type == "system" || type.startsWith("call")) {

View file

@ -19,7 +19,7 @@ import com.example.login_v3.data.local.entities.RoomLocalEntity
RoomLocalEntity::class, RoomLocalEntity::class,
BlockedUserLocalEntity::class BlockedUserLocalEntity::class
], ],
version = 2, version = 3,
exportSchema = false exportSchema = false
) )
@TypeConverters(RoomConverters::class) @TypeConverters(RoomConverters::class)

View file

@ -25,9 +25,32 @@ interface MessageDao {
@Query("DELETE FROM messages WHERE id = :id") @Query("DELETE FROM messages WHERE id = :id")
suspend fun deleteById(id: String) suspend fun deleteById(id: String)
@Query("SELECT id FROM messages WHERE chatRoomId = :roomId ORDER BY createdAt DESC LIMIT 1")
suspend fun getLatestMessageId(roomId: String): String?
@Transaction @Transaction
suspend fun replaceTempMessageWithSuccess(tempId: String, successEntity: MessageEntity) { suspend fun replaceTempMessageWithSuccess(tempId: String, successEntity: MessageEntity) {
deleteById(tempId) deleteById(tempId)
insertOrUpdate(successEntity) insertOrUpdate(successEntity)
} }
@Transaction
suspend fun insertOrUpdateListPreservingLocalData(messages: List<MessageEntity>) {
messages.forEach { msg ->
val existing = getMessageById(msg.id)
if (existing != null) {
// 🎯 核心:保留本地已下載的標記與路徑,其餘由網路資料覆蓋
val updated = msg.copy(
isDownloaded = existing.isDownloaded,
localPath = existing.localPath
)
insertOrUpdate(updated)
} else {
insertOrUpdate(msg)
}
}
}
@Query("UPDATE messages SET isDownloaded = :isDownloaded, localPath = :localPath WHERE id = :messageId")
suspend fun updateDownloadStatus(messageId: String, isDownloaded: Boolean, localPath: String?)
} }

View file

@ -21,5 +21,7 @@ data class MessageEntity(
val status: MessageStatus, val status: MessageStatus,
val reactions: List<Reaction>?, val reactions: List<Reaction>?,
val attachment: Attachment? = null, val attachment: Attachment? = null,
val aspectRatio: Float? = null val aspectRatio: Float? = null,
val isDownloaded: Boolean = false, // 👈 新增:標記媒體/訊息是否已下載到本地
val localPath: String? = null // 👈 新增:儲存本地檔案路徑
) )

View file

@ -121,8 +121,8 @@ class ChatRoomsRepository @Inject constructor(
aspectRatio = payload.attachment?.let { if (it.width > 0 && it.height > 0) it.width.toFloat() / it.height.toFloat() else null } aspectRatio = payload.attachment?.let { if (it.width > 0 && it.height > 0) it.width.toFloat() / it.height.toFloat() else null }
) )
// 3. 寫入本地資料庫 // 3. 寫入本地資料庫 (同樣使用保留本地標記的版本)
messageDao.insertOrUpdate(entity) messageDao.insertOrUpdateListPreservingLocalData(listOf(entity))
Log.d("ChatRoomsRepository", "WebSocket 訊息成功寫入資料庫: ${entity.content}") Log.d("ChatRoomsRepository", "WebSocket 訊息成功寫入資料庫: ${entity.content}")
} else if (event?.type == "dm:typing") { } else if (event?.type == "dm:typing") {
@ -192,6 +192,8 @@ class ChatRoomsRepository @Inject constructor(
attachment = entity.attachment attachment = entity.attachment
).apply { ).apply {
aspectRatio = entity.aspectRatio aspectRatio = entity.aspectRatio
isDownloaded = entity.isDownloaded // 👈 新增
localPath = entity.localPath // 👈 新增
} }
} }
@ -244,9 +246,8 @@ class ChatRoomsRepository @Inject constructor(
) )
} }
// 3. 寫入資料庫 // 3. 寫入資料庫 (使用優化過的版本,保留本地下載狀態)
// 因為你寫了 OnConflictStrategy.REPLACE,歷史訊息會被乖乖追加進去,不會影響現有訊息! messageDao.insertOrUpdateListPreservingLocalData(entities)
messageDao.insertOrUpdateList(entities)
// 🎯 4. 關鍵:把整個 body 回傳回去(包含 nextCursor 和 hasMore) // 🎯 4. 關鍵:把整個 body 回傳回去(包含 nextCursor 和 hasMore)
// 這樣 ViewModel 才能知道下一次往上滑時,要帶哪一個 cursor! // 這樣 ViewModel 才能知道下一次往上滑時,要帶哪一個 cursor!
@ -366,6 +367,18 @@ class ChatRoomsRepository @Inject constructor(
} }
} }
suspend fun getLatestMessageId(roomId: String): String? {
Log.d("ChatDebug", "💾 [Repository] 正在查詢本地最新訊息 ID... roomId: $roomId")
return try {
val id = messageDao.getLatestMessageId(roomId)
Log.d("ChatDebug", "💾 [Repository] 本地最新 ID: $id")
id
} catch (e: Exception) {
Log.e("ChatDebug", "❌ [Repository] 查詢最新 ID 失敗", e)
null
}
}
//upload file //upload file
suspend fun uploadAttachment(roomId: String, fileUri: Uri): Result<Message> { suspend fun uploadAttachment(roomId: String, fileUri: Uri): Result<Message> {
@ -613,7 +626,7 @@ class ChatRoomsRepository @Inject constructor(
//download file //download file
private val httpClient = OkHttpClient() private val httpClient = OkHttpClient()
fun downloadFileFromUrlFlow(fileUrl: String, fileName: String): Flow<Result<Float>> = flow { fun downloadFileFromUrlFlow(messageId: String, fileUrl: String, fileName: String): Flow<Result<Float>> = flow {
// 🎯 1. 檢查並自動拼接完整的 Base URL // 🎯 1. 檢查並自動拼接完整的 Base URL
val finalUrl = if (fileUrl.startsWith("http://") || fileUrl.startsWith("https://")) { val finalUrl = if (fileUrl.startsWith("http://") || fileUrl.startsWith("https://")) {
fileUrl fileUrl
@ -651,8 +664,9 @@ class ChatRoomsRepository @Inject constructor(
} }
val totalBytes = body.contentLength() val totalBytes = body.contentLength()
// 🎯 修正後的寫法:直接存入手機系統的公共 Download 資料夾 // 🎯 修正後的寫法:存入 app 專屬資料夾,避免被使用者手動刪除或污染 Download 資料夾
val targetDirectory = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS) // 如果希望使用者也能看到,可以維持 Environment.DIRECTORY_DOWNLOADS
val targetDirectory = context.getExternalFilesDir(Environment.DIRECTORY_PICTURES) ?: context.filesDir
val targetFile = File(targetDirectory, fileName) val targetFile = File(targetDirectory, fileName)
var bytesCopied = 0L var bytesCopied = 0L
@ -675,6 +689,10 @@ class ChatRoomsRepository @Inject constructor(
} }
} }
// 🎯 關鍵:下載成功後更新資料庫狀態
messageDao.updateDownloadStatus(messageId, true, targetFile.absolutePath)
Log.d("ChatDebug", "✅ 檔案下載完成且已更新資料庫: ${targetFile.absolutePath}")
emit(Result.success(1.0f)) emit(Result.success(1.0f))
} catch (e: Exception) { } catch (e: Exception) {

View file

@ -29,7 +29,9 @@ data class MessageUiModel(
val isFile: Boolean, val isFile: Boolean,
val mediaUrl: String, val mediaUrl: String,
val thumbnailUrl: String? = null, val thumbnailUrl: String? = null,
val aspectRatio: Float? = null val aspectRatio: Float? = null,
val isDownloaded: Boolean = false, // 👈 新增
val localPath: String? = null // 👈 新增
) )
fun Message.toUiModel(): MessageUiModel { fun Message.toUiModel(): MessageUiModel {
@ -43,6 +45,13 @@ fun Message.toUiModel(): MessageUiModel {
else "$baseUrl${if (raw.startsWith("/")) "" else "/"}$raw" else "$baseUrl${if (raw.startsWith("/")) "" else "/"}$raw"
} }
// 🎯 核心邏輯:如果已經下載完成,且本地路徑存在,則 mediaUrl 優先指向本地檔案
val finalMediaUrl = if (isDownloaded && !localPath.isNullOrBlank()) {
"file://$localPath"
} else {
mediaUrl
}
return MessageUiModel( return MessageUiModel(
id = id, id = id,
senderId = senderId, senderId = senderId,
@ -62,8 +71,10 @@ fun Message.toUiModel(): MessageUiModel {
isVideo = isVideo, isVideo = isVideo,
isAudio = isAudio, isAudio = isAudio,
isFile = isFile, isFile = isFile,
mediaUrl = mediaUrl, mediaUrl = finalMediaUrl, // 👈 使用判斷後的 URL
thumbnailUrl = finalThumbnailUrl, thumbnailUrl = finalThumbnailUrl,
aspectRatio = finalAspectRatio aspectRatio = finalAspectRatio,
isDownloaded = isDownloaded,
localPath = localPath
) )
} }

View file

@ -202,17 +202,45 @@ class ChatViewModel @Inject constructor(
} }
private fun initChat(roomId: String) { private fun initChat(roomId: String) {
Log.d("ChatDebug", "🛠️ [ViewModel] initChat 開始 | roomId: $roomId")
viewModelScope.launch { viewModelScope.launch {
// 啟動 Session // 啟動 Session
try { repository.startChatSession(roomId) } catch (e: Exception) { Log.e("ChatViewModel", "WS Error", e) } try {
Log.d("ChatDebug", "🌐 [ViewModel] 嘗試啟動 WebSocket Session...")
repository.startChatSession(roomId)
} catch (e: Exception) {
Log.e("ChatDebug", "❌ [ViewModel] WS Error", e)
}
// 🎯 核心優化:Delta-Sync 邏輯
try {
Log.d("ChatDebug", "🔍 [ViewModel] 正在檢查本地資料一致性...")
val currentRoom = repository.getChatRoomFlow(roomId).first()
val localLatestId = repository.getLatestMessageId(roomId)
val serverLatestId = currentRoom?.lastMessage?.id
Log.d("ChatDebug", "📊 [ViewModel] 比較結果 - LocalLatest: $localLatestId | ServerLatest: $serverLatestId")
if (localLatestId == null || localLatestId != serverLatestId) {
Log.d("ChatDebug", "🔄 [ViewModel] 資料不一致,發起增量同步...")
// 背景同步 // 背景同步
repository.refreshChatMessages(roomId, cursor = null, limit = 20) repository.refreshChatMessages(roomId, cursor = null, limit = 20)
.onSuccess { response -> .onSuccess { response ->
nextCursor = response.nextCursor nextCursor = response.nextCursor
hasMore = response.hasMore hasMore = response.hasMore
Log.d("ChatDebug", "✅ [ViewModel] 同步成功 | nextCursor: $nextCursor")
repository.markAsRead(roomId) repository.markAsRead(roomId)
} }
.onFailure { e ->
Log.e("ChatDebug", "❌ [ViewModel] 同步失敗: ${e.message}")
}
} else {
Log.d("ChatDebug", "✅ [ViewModel] 資料已是最新,跳過 refresh")
repository.markAsRead(roomId)
}
} catch (e: Exception) {
Log.e("ChatDebug", "❌ [ViewModel] initChat 執行過程發生異常", e)
}
} }
} }
@ -536,8 +564,8 @@ class ChatViewModel @Inject constructor(
viewModelScope.launch { viewModelScope.launch {
fileDownloadStates[messageId] = DownloadStatus.Downloading(0f) fileDownloadStates[messageId] = DownloadStatus.Downloading(0f)
// 呼叫剛剛改好的 URL 版 Flow // 呼叫剛剛改好的 URL 版 Flow,傳入 messageId
repository.downloadFileFromUrlFlow(fileUrl, displayFileName) repository.downloadFileFromUrlFlow(messageId, fileUrl, displayFileName)
.collect { progressResult -> .collect { progressResult ->
progressResult.onSuccess { progress -> progressResult.onSuccess { progress ->
if (progress >= 1.0f) { if (progress >= 1.0f) {

View file

@ -159,6 +159,7 @@ fun SharedTransitionScope.setting_list_Screen(
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.titleMedium,
fontSize = 24.sp, fontSize = 24.sp,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onBackground,
modifier = Modifier modifier = Modifier
.sharedElement( .sharedElement(
sharedContentState = titleState, sharedContentState = titleState,
@ -173,7 +174,7 @@ fun SharedTransitionScope.setting_list_Screen(
item.description?.let { item.description?.let {
Text(text = it, Text(text = it,
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = Color.White color = MaterialTheme.colorScheme.onSecondary
) )
} }
} }

View file

@ -19,6 +19,9 @@ val DarkGlass = Color(0x14000000)
val WhiteText = Color(0xFFFFFFFF) val WhiteText = Color(0xFFFFFFFF)
val DarkText = Color(0xFF000000) val DarkText = Color(0xFF000000)
val sub_WhiteText = Color(0xFFD0D0D0)
val sub_DarkText = Color(0xFFD0D0D0)
//default background //default background
val White_default = Color(0xFF2C2C2C) val White_default = Color(0xFF2C2C2C)

View file

@ -17,7 +17,8 @@ private val DarkColorScheme = darkColorScheme(
secondary = light_orange, secondary = light_orange,
tertiary = Pink80, tertiary = Pink80,
background = Color(0xFF121212), background = Color(0xFF121212),
onBackground = DarkText onBackground = WhiteText,
onSecondary = sub_DarkText,
) )
private val LightColorScheme = lightColorScheme( private val LightColorScheme = lightColorScheme(
@ -25,7 +26,8 @@ private val LightColorScheme = lightColorScheme(
secondary = light_orange, secondary = light_orange,
tertiary = Pink40, tertiary = Pink40,
background = Color(0xFF121212), background = Color(0xFF121212),
onBackground = WhiteText onBackground = DarkText,
onSecondary = sub_WhiteText,
/* Other default colors to override /* Other default colors to override