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,
@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 {
if (type == "system" || type.startsWith("call")) {

View file

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

View file

@ -25,9 +25,32 @@ interface MessageDao {
@Query("DELETE FROM messages WHERE id = :id")
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
suspend fun replaceTempMessageWithSuccess(tempId: String, successEntity: MessageEntity) {
deleteById(tempId)
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 reactions: List<Reaction>?,
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 }
)
// 3. 寫入本地資料庫
messageDao.insertOrUpdate(entity)
// 3. 寫入本地資料庫 (同樣使用保留本地標記的版本)
messageDao.insertOrUpdateListPreservingLocalData(listOf(entity))
Log.d("ChatRoomsRepository", "WebSocket 訊息成功寫入資料庫: ${entity.content}")
} else if (event?.type == "dm:typing") {
@ -192,6 +192,8 @@ class ChatRoomsRepository @Inject constructor(
attachment = entity.attachment
).apply {
aspectRatio = entity.aspectRatio
isDownloaded = entity.isDownloaded // 👈 新增
localPath = entity.localPath // 👈 新增
}
}
@ -244,9 +246,8 @@ class ChatRoomsRepository @Inject constructor(
)
}
// 3. 寫入資料庫
// 因為你寫了 OnConflictStrategy.REPLACE,歷史訊息會被乖乖追加進去,不會影響現有訊息!
messageDao.insertOrUpdateList(entities)
// 3. 寫入資料庫 (使用優化過的版本,保留本地下載狀態)
messageDao.insertOrUpdateListPreservingLocalData(entities)
// 🎯 4. 關鍵:把整個 body 回傳回去(包含 nextCursor 和 hasMore)
// 這樣 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
suspend fun uploadAttachment(roomId: String, fileUri: Uri): Result<Message> {
@ -613,7 +626,7 @@ class ChatRoomsRepository @Inject constructor(
//download file
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
val finalUrl = if (fileUrl.startsWith("http://") || fileUrl.startsWith("https://")) {
fileUrl
@ -651,8 +664,9 @@ class ChatRoomsRepository @Inject constructor(
}
val totalBytes = body.contentLength()
// 🎯 修正後的寫法:直接存入手機系統的公共 Download 資料夾
val targetDirectory = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS)
// 🎯 修正後的寫法:存入 app 專屬資料夾,避免被使用者手動刪除或污染 Download 資料夾
// 如果希望使用者也能看到,可以維持 Environment.DIRECTORY_DOWNLOADS
val targetDirectory = context.getExternalFilesDir(Environment.DIRECTORY_PICTURES) ?: context.filesDir
val targetFile = File(targetDirectory, fileName)
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))
} catch (e: Exception) {

View file

@ -29,7 +29,9 @@ data class MessageUiModel(
val isFile: Boolean,
val mediaUrl: String,
val thumbnailUrl: String? = null,
val aspectRatio: Float? = null
val aspectRatio: Float? = null,
val isDownloaded: Boolean = false, // 👈 新增
val localPath: String? = null // 👈 新增
)
fun Message.toUiModel(): MessageUiModel {
@ -43,6 +45,13 @@ fun Message.toUiModel(): MessageUiModel {
else "$baseUrl${if (raw.startsWith("/")) "" else "/"}$raw"
}
// 🎯 核心邏輯:如果已經下載完成,且本地路徑存在,則 mediaUrl 優先指向本地檔案
val finalMediaUrl = if (isDownloaded && !localPath.isNullOrBlank()) {
"file://$localPath"
} else {
mediaUrl
}
return MessageUiModel(
id = id,
senderId = senderId,
@ -62,8 +71,10 @@ fun Message.toUiModel(): MessageUiModel {
isVideo = isVideo,
isAudio = isAudio,
isFile = isFile,
mediaUrl = mediaUrl,
mediaUrl = finalMediaUrl, // 👈 使用判斷後的 URL
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) {
Log.d("ChatDebug", "🛠️ [ViewModel] initChat 開始 | roomId: $roomId")
viewModelScope.launch {
// 啟動 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)
.onSuccess { response ->
nextCursor = response.nextCursor
hasMore = response.hasMore
Log.d("ChatDebug", "✅ [ViewModel] 同步成功 | nextCursor: $nextCursor")
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 {
fileDownloadStates[messageId] = DownloadStatus.Downloading(0f)
// 呼叫剛剛改好的 URL 版 Flow
repository.downloadFileFromUrlFlow(fileUrl, displayFileName)
// 呼叫剛剛改好的 URL 版 Flow,傳入 messageId
repository.downloadFileFromUrlFlow(messageId, fileUrl, displayFileName)
.collect { progressResult ->
progressResult.onSuccess { progress ->
if (progress >= 1.0f) {

View file

@ -159,6 +159,7 @@ fun SharedTransitionScope.setting_list_Screen(
style = MaterialTheme.typography.titleMedium,
fontSize = 24.sp,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onBackground,
modifier = Modifier
.sharedElement(
sharedContentState = titleState,
@ -173,7 +174,7 @@ fun SharedTransitionScope.setting_list_Screen(
item.description?.let {
Text(text = it,
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 DarkText = Color(0xFF000000)
val sub_WhiteText = Color(0xFFD0D0D0)
val sub_DarkText = Color(0xFFD0D0D0)
//default background
val White_default = Color(0xFF2C2C2C)

View file

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