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,
|
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")) {
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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?)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 // 👈 新增:儲存本地檔案路徑
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 邏輯
|
||||||
repository.refreshChatMessages(roomId, cursor = null, limit = 20)
|
try {
|
||||||
.onSuccess { response ->
|
Log.d("ChatDebug", "🔍 [ViewModel] 正在檢查本地資料一致性...")
|
||||||
nextCursor = response.nextCursor
|
val currentRoom = repository.getChatRoomFlow(roomId).first()
|
||||||
hasMore = response.hasMore
|
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)
|
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) {
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue