add delta sync for dm-v10

This commit is contained in:
eric 2026-08-04 15:09:53 +08:00
parent e0b1133568
commit afff839122
7 changed files with 216 additions and 21 deletions

View file

@ -121,8 +121,10 @@ data class Message(
val status: MessageStatus = MessageStatus.SUCCESS, val status: MessageStatus = MessageStatus.SUCCESS,
@kotlin.jvm.Transient @kotlin.jvm.Transient
var callLogInfo: CallLogInfo? = null var aspectRatio: Float? = null,
@kotlin.jvm.Transient
var callLogInfo: CallLogInfo? = null
) { ) {
init { init {
if (type == "system" || type.startsWith("call")) { if (type == "system" || type.startsWith("call")) {
@ -182,7 +184,8 @@ data class WebSocketMessagePayload(
@Json(name = "is_edited") val isEdited: Boolean, @Json(name = "is_edited") val isEdited: Boolean,
@Json(name = "is_deleted") val isDeleted: Boolean, @Json(name = "is_deleted") val isDeleted: Boolean,
@Json(name = "reply_to_id") val replyToId: String? = null, @Json(name = "reply_to_id") val replyToId: String? = null,
@Json(name = "reactions") val reactions: List<Reaction>? = emptyList() @Json(name = "reactions") val reactions: List<Reaction>? = emptyList(),
@Json(name = "attachment") val attachment: Attachment? = null
) )
//send message //send message

View file

@ -7,6 +7,7 @@ import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory // 👈 確保
import com.example.login_v3.data.api.api_class.LastMessage import com.example.login_v3.data.api.api_class.LastMessage
import com.example.login_v3.data.api.api_class.Partner import com.example.login_v3.data.api.api_class.Partner
import com.example.login_v3.data.api.api_class.Reaction import com.example.login_v3.data.api.api_class.Reaction
import com.example.login_v3.data.api.api_class.Attachment
class RoomConverters { class RoomConverters {
private val moshi = Moshi.Builder() private val moshi = Moshi.Builder()
@ -17,6 +18,7 @@ class RoomConverters {
private val reactionAdapter = moshi.adapter<List<Reaction>>(reactionListType) private val reactionAdapter = moshi.adapter<List<Reaction>>(reactionListType)
private val partnerAdapter = moshi.adapter(Partner::class.java) private val partnerAdapter = moshi.adapter(Partner::class.java)
private val lastMessageAdapter = moshi.adapter(LastMessage::class.java) private val lastMessageAdapter = moshi.adapter(LastMessage::class.java)
private val attachmentAdapter = moshi.adapter(Attachment::class.java)
@TypeConverter @TypeConverter
fun fromReactionList(reactions: List<Reaction>?): String? { fun fromReactionList(reactions: List<Reaction>?): String? {
@ -62,4 +64,19 @@ class RoomConverters {
null null
} }
} }
@TypeConverter
fun fromAttachment(attachment: Attachment?): String? {
return attachmentAdapter.toJson(attachment)
}
@TypeConverter
fun toAttachment(json: String?): Attachment? {
if (json.isNullOrBlank()) return null
return try {
attachmentAdapter.fromJson(json)
} catch (e: Exception) {
null
}
}
} }

View file

@ -3,6 +3,7 @@ package com.example.login_v3.data.local.entities
import androidx.room.Entity import androidx.room.Entity
import androidx.room.PrimaryKey import androidx.room.PrimaryKey
import com.example.login_v3.data.api.api_class.Reaction import com.example.login_v3.data.api.api_class.Reaction
import com.example.login_v3.data.api.api_class.Attachment
enum class MessageStatus { SENDING, SUCCESS, FAILED } enum class MessageStatus { SENDING, SUCCESS, FAILED }
@ -18,5 +19,7 @@ data class MessageEntity(
val isDeleted: Boolean, val isDeleted: Boolean,
val replyToId: String?, val replyToId: String?,
val status: MessageStatus, val status: MessageStatus,
val reactions: List<Reaction>? val reactions: List<Reaction>?,
val attachment: Attachment? = null,
val aspectRatio: Float? = null
) )

View file

@ -115,7 +115,9 @@ class ChatRoomsRepository @Inject constructor(
isDeleted = payload.isDeleted, isDeleted = payload.isDeleted,
replyToId = payload.replyToId, replyToId = payload.replyToId,
status = MessageStatus.SUCCESS, status = MessageStatus.SUCCESS,
reactions = payload.reactions reactions = payload.reactions,
attachment = payload.attachment,
aspectRatio = payload.attachment?.let { if (it.width > 0 && it.height > 0) it.width.toFloat() / it.height.toFloat() else null }
) )
// 3. 寫入本地資料庫 // 3. 寫入本地資料庫
@ -185,8 +187,11 @@ class ChatRoomsRepository @Inject constructor(
isDeleted = entity.isDeleted, isDeleted = entity.isDeleted,
replyToId = entity.replyToId, replyToId = entity.replyToId,
status = entity.status, // 👈 把 Room 儲存的狀態倒出來給 UI status = entity.status, // 👈 把 Room 儲存的狀態倒出來給 UI
reactions = entity.reactions reactions = entity.reactions,
) attachment = entity.attachment
).apply {
aspectRatio = entity.aspectRatio
}
} }
// 你原本的處理回覆邏輯 // 你原本的處理回覆邏輯
@ -232,7 +237,9 @@ class ChatRoomsRepository @Inject constructor(
isDeleted = networkMessage.isDeleted, isDeleted = networkMessage.isDeleted,
replyToId = networkMessage.replyToId, replyToId = networkMessage.replyToId,
status = MessageStatus.SUCCESS, status = MessageStatus.SUCCESS,
reactions = networkMessage.reactions reactions = networkMessage.reactions,
attachment = networkMessage.attachment,
aspectRatio = networkMessage.attachment?.let { if (it.width > 0 && it.height > 0) it.width.toFloat() / it.height.toFloat() else null }
) )
} }
@ -300,7 +307,9 @@ class ChatRoomsRepository @Inject constructor(
isDeleted = body.isDeleted, isDeleted = body.isDeleted,
replyToId = body.replyToId, replyToId = body.replyToId,
status = MessageStatus.SUCCESS, // 👈 狀態轉為成功 status = MessageStatus.SUCCESS, // 👈 狀態轉為成功
reactions = body.reactions reactions = body.reactions,
attachment = body.attachment,
aspectRatio = body.attachment?.let { if (it.width > 0 && it.height > 0) it.width.toFloat() / it.height.toFloat() else null }
) )
// 🎯 2. 【關鍵大優化】利用剛寫好的 Transaction 一口氣替換! // 🎯 2. 【關鍵大優化】利用剛寫好的 Transaction 一口氣替換!

View file

@ -28,10 +28,21 @@ data class MessageUiModel(
val isAudio: Boolean, val isAudio: Boolean,
val isFile: Boolean, val isFile: Boolean,
val mediaUrl: String, val mediaUrl: String,
val thumbnailUrl: String? = null,
val aspectRatio: Float? = null val aspectRatio: Float? = null
) )
fun Message.toUiModel(): MessageUiModel { fun Message.toUiModel(): MessageUiModel {
val finalAspectRatio = aspectRatio ?: attachment?.let {
if (it.width > 0 && it.height > 0) it.width.toFloat() / it.height.toFloat() else null
}
val baseUrl = "https://tg.technologia-tw.com"
val finalThumbnailUrl = attachment?.thumbnailUrl?.let { raw ->
if (raw.startsWith("http")) raw
else "$baseUrl${if (raw.startsWith("/")) "" else "/"}$raw"
}
return MessageUiModel( return MessageUiModel(
id = id, id = id,
senderId = senderId, senderId = senderId,
@ -52,6 +63,7 @@ fun Message.toUiModel(): MessageUiModel {
isAudio = isAudio, isAudio = isAudio,
isFile = isFile, isFile = isFile,
mediaUrl = mediaUrl, mediaUrl = mediaUrl,
aspectRatio = null // Future: parse from metadata if available thumbnailUrl = finalThumbnailUrl,
aspectRatio = finalAspectRatio
) )
} }

View file

@ -107,6 +107,9 @@ import com.example.login_v3.home.Message.UI.Detail.Message_Component.MessageAtta
import com.example.login_v3.home.Message.UI.Detail.Message_Component.UserStatusDot import com.example.login_v3.home.Message.UI.Detail.Message_Component.UserStatusDot
import com.example.login_v3.home.Message.UI.Detail.Message_Component.VideoLightbox import com.example.login_v3.home.Message.UI.Detail.Message_Component.VideoLightbox
import com.example.login_v3.home.Message.ViewModel.Detail.DownloadStatus import com.example.login_v3.home.Message.ViewModel.Detail.DownloadStatus
import com.example.login_v3.utils.BlurHashDecoder
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.graphics.painter.BitmapPainter
//bottom bar state //bottom bar state
@ -810,14 +813,25 @@ private fun RepliedMessagePreview(
.height(22.dp) .height(22.dp)
.background(if (isMe) Color(0xFF4CAF50) else Color(0xFF78909C)) .background(if (isMe) Color(0xFF4CAF50) else Color(0xFF78909C))
) )
Column(modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp)) { Column(modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp).weight(1f)) {
Text( Text(
text = if (replied.attachment?.mimeType?.startsWith("image/") == true) "[圖片]" else replied.content ?: "", text = if (replied.isImage) "[圖片]" else if (replied.isVideo) "[影片]" else replied.content ?: "",
style = MaterialTheme.typography.bodySmall.copy(fontSize = 14.sp), style = MaterialTheme.typography.bodySmall.copy(fontSize = 14.sp),
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis overflow = TextOverflow.Ellipsis
) )
} }
if (replied.isImage || replied.isVideo) {
AsyncImage(
model = replied.thumbnailUrl ?: replied.mediaUrl,
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier
.size(32.dp)
.clip(RoundedCornerShape(4.dp))
)
}
} }
} }
@ -845,27 +859,45 @@ private fun ImageMessageContent(
var isImageLoading by remember { mutableStateOf(true) } var isImageLoading by remember { mutableStateOf(true) }
// BlurHash Placeholder
val blurhashPainter = remember(message.attachment?.blurhash) {
message.attachment?.blurhash?.let { hash ->
BlurHashDecoder.decode(hash, 32, 32)?.let { bitmap ->
BitmapPainter(bitmap.asImageBitmap())
}
}
}
Box( Box(
modifier = Modifier modifier = Modifier
.padding(top = 4.dp) .padding(top = 4.dp)
.widthIn(max = 240.dp) .sizeIn(maxWidth = 240.dp, maxHeight = 320.dp) // 🎯 限制最大尺寸
.then( .then(
if (message.aspectRatio != null) { if (message.aspectRatio != null) {
Modifier.aspectRatio(message.aspectRatio) Modifier.aspectRatio(message.aspectRatio)
} else { } else {
Modifier.height(180.dp) Modifier.height(180.dp).width(240.dp)
} }
) )
.clip(RoundedCornerShape(12.dp)) .clip(RoundedCornerShape(12.dp))
.background(Color(0xFFF5F5F5)) .background(Color(0xFFF5F5F5))
.shimmer(visible = isImageLoading) .shimmer(visible = isImageLoading && blurhashPainter == null)
) { ) {
// 如果有 BlurHash,先顯示它
blurhashPainter?.let {
androidx.compose.foundation.Image(
painter = it,
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier.fillMaxSize()
)
}
AsyncImage( AsyncImage(
model = message.mediaUrl, model = message.thumbnailUrl ?: message.mediaUrl,
contentDescription = if (message.isGif) "動態 GIF" else "聊天圖片", contentDescription = if (message.isGif) "動態 GIF" else "聊天圖片",
contentScale = ContentScale.Fit, contentScale = ContentScale.Crop, // 🎯 改為 Crop 確保填滿
onLoading = { isImageLoading = true }, onLoading = { isImageLoading = true },
onSuccess = { isImageLoading = false },
onError = { errorState -> onError = { errorState ->
isImageLoading = false isImageLoading = false
Log.e("ChatImageError", "原因: ${errorState.result.throwable}") Log.e("ChatImageError", "原因: ${errorState.result.throwable}")
@ -900,16 +932,25 @@ private fun VideoMessageContent(
var isVideoThumbLoading by remember { mutableStateOf(true) } var isVideoThumbLoading by remember { mutableStateOf(true) }
// BlurHash Placeholder
val blurhashPainter = remember(message.attachment?.blurhash) {
message.attachment?.blurhash?.let { hash ->
BlurHashDecoder.decode(hash, 32, 32)?.let { bitmap ->
BitmapPainter(bitmap.asImageBitmap())
}
}
}
// 影片佈局 // 影片佈局
Box( Box(
modifier = Modifier modifier = Modifier
.padding(top = 4.dp) .padding(top = 4.dp)
.width(240.dp) .sizeIn(maxWidth = 240.dp, maxHeight = 320.dp) // 🎯 限制最大尺寸
.then( .then(
if (message.aspectRatio != null) { if (message.aspectRatio != null) {
Modifier.aspectRatio(message.aspectRatio) Modifier.aspectRatio(message.aspectRatio)
} else { } else {
Modifier.height(160.dp) Modifier.height(160.dp).width(240.dp)
} }
) )
.clip(RoundedCornerShape(12.dp)) .clip(RoundedCornerShape(12.dp))
@ -919,11 +960,21 @@ private fun VideoMessageContent(
color = Color.LightGray.copy(alpha = 0.4f), color = Color.LightGray.copy(alpha = 0.4f),
shape = RoundedCornerShape(12.dp) shape = RoundedCornerShape(12.dp)
) )
.shimmer(visible = isVideoThumbLoading), .shimmer(visible = isVideoThumbLoading && blurhashPainter == null),
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
// 如果有 BlurHash,先顯示它
blurhashPainter?.let {
androidx.compose.foundation.Image(
painter = it,
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier.fillMaxSize()
)
}
AsyncImage( AsyncImage(
model = message.mediaUrl, model = message.thumbnailUrl ?: message.mediaUrl,
contentDescription = "影片預覽", contentDescription = "影片預覽",
contentScale = ContentScale.Crop, contentScale = ContentScale.Crop,
onLoading = { isVideoThumbLoading = true }, onLoading = { isVideoThumbLoading = true },

View file

@ -0,0 +1,100 @@
package com.example.login_v3.utils
import android.graphics.Bitmap
import android.graphics.Color
import kotlin.math.cos
import kotlin.math.pow
import kotlin.math.withSign
object BlurHashDecoder {
fun decode(blurHash: String?, width: Int, height: Int, punch: Float = 1f): Bitmap? {
if (blurHash == null || blurHash.length < 6) return null
val numComponents = decode83(blurHash, 0, 1)
val nx = (numComponents % 9) + 1
val ny = (numComponents / 9) + 1
if (blurHash.length != 4 + 2 * nx * ny) return null
val maxAc = (decode83(blurHash, 1, 2) + 1) / 166f
val colors = Array(nx * ny) { i ->
if (i == 0) {
val value = decode83(blurHash, 2, 6)
decodeDc(value)
} else {
val value = decode83(blurHash, 4 + i * 2, 4 + i * 2 + 2)
decodeAc(value, maxAc * punch)
}
}
val pixels = IntArray(width * height)
for (y in 0 until height) {
for (x in 0 until width) {
var r = 0f
var g = 0f
var b = 0f
for (j in 0 until ny) {
for (i in 0 until nx) {
val basis = cos(Math.PI * x * i / width) * cos(Math.PI * y * j / height)
val color = colors[i + j * nx]
r += color[0] * basis.toFloat()
g += color[1] * basis.toFloat()
b += color[2] * basis.toFloat()
}
}
pixels[x + y * width] = Color.rgb(clamp(r), clamp(g), clamp(b))
}
}
return Bitmap.createBitmap(pixels, width, height, Bitmap.Config.ARGB_8888)
}
private fun decode83(str: String, start: Int, end: Int): Int {
var res = 0
for (i in start until end) {
val c = str[i]
val digit = charMap[c] ?: 0
res = res * 83 + digit
}
return res
}
private fun decodeDc(value: Int): FloatArray {
val r = (value shr 16) / 255f
val g = ((value shr 8) and 255) / 255f
val b = (value and 255) / 255f
return floatArrayOf(srgbToLinear(r), srgbToLinear(g), srgbToLinear(b))
}
private fun decodeAc(value: Int, maxAc: Float): FloatArray {
val r = value / (19 * 19)
val g = (value / 19) % 19
val b = value % 19
return floatArrayOf(
signedPow2((r - 9) / 9f) * maxAc,
signedPow2((g - 9) / 9f) * maxAc,
signedPow2((b - 9) / 9f) * maxAc
)
}
private fun srgbToLinear(v: Float): Float {
return if (v <= 0.04045f) v / 12.92f else ((v + 0.055f) / 1.055f).pow(2.4f)
}
private fun linearToSrgb(v: Float): Float {
val f = v.coerceIn(0f, 1f)
return if (f <= 0.0031308f) f * 12.92f else (1.055f * f.pow(1 / 2.4f) - 0.055f)
}
private fun clamp(v: Float): Int {
return (linearToSrgb(v) * 255).toInt()
}
private fun signedPow2(v: Float): Float {
return v.pow(2f).withSign(v)
}
private val charMap = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz#$%*+,-.:;=?@[]^_{|}~"
.mapIndexed { i, c -> c to i }
.toMap()
}