From afff839122226fc646c97b8661a75f72bac2aa2f Mon Sep 17 00:00:00 2001 From: eric Date: Tue, 4 Aug 2026 15:09:53 +0800 Subject: [PATCH] add delta sync for dm-v10 --- .../login_v3/data/api/api_class/DM_Basic.kt | 7 +- .../example/login_v3/data/di/TypeConverter.kt | 17 +++ .../data/local/entities/MessageEntity.kt | 5 +- .../data/repository/dm/DM_Basic_Repository.kt | 19 +++- .../home/Message/UI/Detail/MessageUiModel.kt | 14 ++- .../Message/UI/Detail/Message_Messaging.kt | 75 ++++++++++--- .../example/login_v3/utils/BlurHashDecoder.kt | 100 ++++++++++++++++++ 7 files changed, 216 insertions(+), 21 deletions(-) create mode 100644 app/src/main/java/com/example/login_v3/utils/BlurHashDecoder.kt diff --git a/app/src/main/java/com/example/login_v3/data/api/api_class/DM_Basic.kt b/app/src/main/java/com/example/login_v3/data/api/api_class/DM_Basic.kt index 38074cf..44447ec 100644 --- a/app/src/main/java/com/example/login_v3/data/api/api_class/DM_Basic.kt +++ b/app/src/main/java/com/example/login_v3/data/api/api_class/DM_Basic.kt @@ -121,8 +121,10 @@ data class Message( val status: MessageStatus = MessageStatus.SUCCESS, @kotlin.jvm.Transient - var callLogInfo: CallLogInfo? = null + var aspectRatio: Float? = null, + @kotlin.jvm.Transient + var callLogInfo: CallLogInfo? = null ) { init { if (type == "system" || type.startsWith("call")) { @@ -182,7 +184,8 @@ data class WebSocketMessagePayload( @Json(name = "is_edited") val isEdited: Boolean, @Json(name = "is_deleted") val isDeleted: Boolean, @Json(name = "reply_to_id") val replyToId: String? = null, - @Json(name = "reactions") val reactions: List? = emptyList() + @Json(name = "reactions") val reactions: List? = emptyList(), + @Json(name = "attachment") val attachment: Attachment? = null ) //send message diff --git a/app/src/main/java/com/example/login_v3/data/di/TypeConverter.kt b/app/src/main/java/com/example/login_v3/data/di/TypeConverter.kt index 68b1b4f..e7da5d3 100644 --- a/app/src/main/java/com/example/login_v3/data/di/TypeConverter.kt +++ b/app/src/main/java/com/example/login_v3/data/di/TypeConverter.kt @@ -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.Partner import com.example.login_v3.data.api.api_class.Reaction +import com.example.login_v3.data.api.api_class.Attachment class RoomConverters { private val moshi = Moshi.Builder() @@ -17,6 +18,7 @@ class RoomConverters { private val reactionAdapter = moshi.adapter>(reactionListType) private val partnerAdapter = moshi.adapter(Partner::class.java) private val lastMessageAdapter = moshi.adapter(LastMessage::class.java) + private val attachmentAdapter = moshi.adapter(Attachment::class.java) @TypeConverter fun fromReactionList(reactions: List?): String? { @@ -62,4 +64,19 @@ class RoomConverters { 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 + } + } } diff --git a/app/src/main/java/com/example/login_v3/data/local/entities/MessageEntity.kt b/app/src/main/java/com/example/login_v3/data/local/entities/MessageEntity.kt index 1086026..a776225 100644 --- a/app/src/main/java/com/example/login_v3/data/local/entities/MessageEntity.kt +++ b/app/src/main/java/com/example/login_v3/data/local/entities/MessageEntity.kt @@ -3,6 +3,7 @@ package com.example.login_v3.data.local.entities import androidx.room.Entity import androidx.room.PrimaryKey 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 } @@ -18,5 +19,7 @@ data class MessageEntity( val isDeleted: Boolean, val replyToId: String?, val status: MessageStatus, - val reactions: List? + val reactions: List?, + val attachment: Attachment? = null, + val aspectRatio: Float? = null ) diff --git a/app/src/main/java/com/example/login_v3/data/repository/dm/DM_Basic_Repository.kt b/app/src/main/java/com/example/login_v3/data/repository/dm/DM_Basic_Repository.kt index e9b2939..73e80db 100644 --- a/app/src/main/java/com/example/login_v3/data/repository/dm/DM_Basic_Repository.kt +++ b/app/src/main/java/com/example/login_v3/data/repository/dm/DM_Basic_Repository.kt @@ -115,7 +115,9 @@ class ChatRoomsRepository @Inject constructor( isDeleted = payload.isDeleted, replyToId = payload.replyToId, 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. 寫入本地資料庫 @@ -185,8 +187,11 @@ class ChatRoomsRepository @Inject constructor( isDeleted = entity.isDeleted, replyToId = entity.replyToId, 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, replyToId = networkMessage.replyToId, 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, replyToId = body.replyToId, 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 一口氣替換! diff --git a/app/src/main/java/com/example/login_v3/home/Message/UI/Detail/MessageUiModel.kt b/app/src/main/java/com/example/login_v3/home/Message/UI/Detail/MessageUiModel.kt index 6195c96..fd31303 100644 --- a/app/src/main/java/com/example/login_v3/home/Message/UI/Detail/MessageUiModel.kt +++ b/app/src/main/java/com/example/login_v3/home/Message/UI/Detail/MessageUiModel.kt @@ -28,10 +28,21 @@ data class MessageUiModel( val isAudio: Boolean, val isFile: Boolean, val mediaUrl: String, + val thumbnailUrl: String? = null, val aspectRatio: Float? = null ) 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( id = id, senderId = senderId, @@ -52,6 +63,7 @@ fun Message.toUiModel(): MessageUiModel { isAudio = isAudio, isFile = isFile, mediaUrl = mediaUrl, - aspectRatio = null // Future: parse from metadata if available + thumbnailUrl = finalThumbnailUrl, + aspectRatio = finalAspectRatio ) } diff --git a/app/src/main/java/com/example/login_v3/home/Message/UI/Detail/Message_Messaging.kt b/app/src/main/java/com/example/login_v3/home/Message/UI/Detail/Message_Messaging.kt index ef8e6c3..d936f3d 100644 --- a/app/src/main/java/com/example/login_v3/home/Message/UI/Detail/Message_Messaging.kt +++ b/app/src/main/java/com/example/login_v3/home/Message/UI/Detail/Message_Messaging.kt @@ -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.VideoLightbox 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 @@ -810,14 +813,25 @@ private fun RepliedMessagePreview( .height(22.dp) .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 = 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), maxLines = 1, 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) } + // BlurHash Placeholder + val blurhashPainter = remember(message.attachment?.blurhash) { + message.attachment?.blurhash?.let { hash -> + BlurHashDecoder.decode(hash, 32, 32)?.let { bitmap -> + BitmapPainter(bitmap.asImageBitmap()) + } + } + } + Box( modifier = Modifier .padding(top = 4.dp) - .widthIn(max = 240.dp) + .sizeIn(maxWidth = 240.dp, maxHeight = 320.dp) // 🎯 限制最大尺寸 .then( if (message.aspectRatio != null) { Modifier.aspectRatio(message.aspectRatio) } else { - Modifier.height(180.dp) + Modifier.height(180.dp).width(240.dp) } ) .clip(RoundedCornerShape(12.dp)) .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( - model = message.mediaUrl, + model = message.thumbnailUrl ?: message.mediaUrl, contentDescription = if (message.isGif) "動態 GIF" else "聊天圖片", - contentScale = ContentScale.Fit, + contentScale = ContentScale.Crop, // 🎯 改為 Crop 確保填滿 onLoading = { isImageLoading = true }, - onSuccess = { isImageLoading = false }, onError = { errorState -> isImageLoading = false Log.e("ChatImageError", "原因: ${errorState.result.throwable}") @@ -900,16 +932,25 @@ private fun VideoMessageContent( 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( modifier = Modifier .padding(top = 4.dp) - .width(240.dp) + .sizeIn(maxWidth = 240.dp, maxHeight = 320.dp) // 🎯 限制最大尺寸 .then( if (message.aspectRatio != null) { Modifier.aspectRatio(message.aspectRatio) } else { - Modifier.height(160.dp) + Modifier.height(160.dp).width(240.dp) } ) .clip(RoundedCornerShape(12.dp)) @@ -919,11 +960,21 @@ private fun VideoMessageContent( color = Color.LightGray.copy(alpha = 0.4f), shape = RoundedCornerShape(12.dp) ) - .shimmer(visible = isVideoThumbLoading), + .shimmer(visible = isVideoThumbLoading && blurhashPainter == null), contentAlignment = Alignment.Center ) { + // 如果有 BlurHash,先顯示它 + blurhashPainter?.let { + androidx.compose.foundation.Image( + painter = it, + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier.fillMaxSize() + ) + } + AsyncImage( - model = message.mediaUrl, + model = message.thumbnailUrl ?: message.mediaUrl, contentDescription = "影片預覽", contentScale = ContentScale.Crop, onLoading = { isVideoThumbLoading = true }, diff --git a/app/src/main/java/com/example/login_v3/utils/BlurHashDecoder.kt b/app/src/main/java/com/example/login_v3/utils/BlurHashDecoder.kt new file mode 100644 index 0000000..00e7686 --- /dev/null +++ b/app/src/main/java/com/example/login_v3/utils/BlurHashDecoder.kt @@ -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() +}