add delta sync for dm-v22
This commit is contained in:
parent
177c888c84
commit
2ee12043af
10 changed files with 119 additions and 25 deletions
|
|
@ -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")) {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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?)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 // 👈 新增:儲存本地檔案路徑
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
||||
// 背景同步
|
||||
repository.refreshChatMessages(roomId, cursor = null, limit = 20)
|
||||
.onSuccess { response ->
|
||||
nextCursor = response.nextCursor
|
||||
hasMore = response.hasMore
|
||||
// 🎯 核心優化: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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue