android: add chat media attachment rendering and viewer
Some checks failed
CI / test (push) Failing after 2m6s

This commit is contained in:
Codex
2026-03-09 12:53:08 +03:00
parent 946b85a18f
commit 5760a0cb3f
15 changed files with 255 additions and 31 deletions

View File

@@ -117,3 +117,10 @@
- Updated Chat ViewModel/UI with forward flow, reaction toggle, and edit/delete-for-all edge-case guards.
- Added automatic delivered/read acknowledgement for latest incoming message in active chat.
- Fixed outgoing message detection by resolving current user id from JWT `sub` claim.
### Step 19 - Sprint P0 / 2) Media UX after send
- Added media endpoint mapping for chat attachments (`GET /api/v1/media/chats/{chat_id}/attachments`).
- Extended Room message observation to include attachment relations via `MessageLocalModel`.
- Synced and persisted message attachments during message refresh/pagination and after media send.
- Extended message domain model with attachment list payload.
- Added message attachment rendering in Chat UI: inline image preview, minimal image viewer overlay, and basic audio play/pause control.

View File

@@ -71,6 +71,7 @@ dependencies {
implementation("androidx.compose.ui:ui:1.7.6")
implementation("androidx.compose.ui:ui-tooling-preview:1.7.6")
implementation("androidx.compose.material3:material3:1.3.1")
implementation("io.coil-kt:coil-compose:2.7.0")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0")
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3")

View File

@@ -16,7 +16,7 @@ import ru.daemonlord.messenger.data.message.local.entity.MessageEntity
MessageEntity::class,
MessageAttachmentEntity::class,
],
version = 3,
version = 4,
exportSchema = false,
)
abstract class MessengerDatabase : RoomDatabase() {

View File

@@ -1,8 +1,12 @@
package ru.daemonlord.messenger.data.media.api
import retrofit2.http.Body
import retrofit2.http.GET
import retrofit2.http.Path
import retrofit2.http.POST
import retrofit2.http.Query
import ru.daemonlord.messenger.data.media.dto.AttachmentCreateRequestDto
import ru.daemonlord.messenger.data.media.dto.ChatAttachmentReadDto
import ru.daemonlord.messenger.data.media.dto.UploadUrlRequestDto
import ru.daemonlord.messenger.data.media.dto.UploadUrlResponseDto
@@ -16,4 +20,11 @@ interface MediaApiService {
suspend fun createAttachment(
@Body request: AttachmentCreateRequestDto,
)
@GET("/api/v1/media/chats/{chat_id}/attachments")
suspend fun getChatAttachments(
@Path("chat_id") chatId: Long,
@Query("limit") limit: Int = 400,
@Query("before_id") beforeId: Long? = null,
): List<ChatAttachmentReadDto>
}

View File

@@ -38,3 +38,24 @@ data class AttachmentCreateRequestDto(
@SerialName("file_size")
val fileSize: Long,
)
@Serializable
data class ChatAttachmentReadDto(
val id: Long,
@SerialName("message_id")
val messageId: Long,
@SerialName("sender_id")
val senderId: Long,
@SerialName("message_type")
val messageType: String,
@SerialName("message_created_at")
val messageCreatedAt: String,
@SerialName("file_url")
val fileUrl: String,
@SerialName("file_type")
val fileType: String,
@SerialName("file_size")
val fileSize: Long,
@SerialName("waveform_points")
val waveformPoints: List<Int>? = null,
)

View File

@@ -8,6 +8,7 @@ import androidx.room.Transaction
import kotlinx.coroutines.flow.Flow
import ru.daemonlord.messenger.data.message.local.entity.MessageAttachmentEntity
import ru.daemonlord.messenger.data.message.local.entity.MessageEntity
import ru.daemonlord.messenger.data.message.local.model.MessageLocalModel
@Dao
interface MessageDao {
@@ -20,10 +21,11 @@ interface MessageDao {
LIMIT :limit
"""
)
@Transaction
fun observeRecentMessages(
chatId: Long,
limit: Int = 50,
): Flow<List<MessageEntity>>
): Flow<List<MessageLocalModel>>
@Query(
"""
@@ -49,6 +51,16 @@ interface MessageDao {
@Query("DELETE FROM messages WHERE chat_id = :chatId")
suspend fun clearChatMessages(chatId: Long)
@Query(
"""
DELETE FROM message_attachments
WHERE message_id IN (
SELECT id FROM messages WHERE chat_id = :chatId
)
"""
)
suspend fun clearChatAttachments(chatId: Long)
@Query("DELETE FROM messages WHERE id = :messageId")
suspend fun deleteMessage(messageId: Long)
@@ -77,6 +89,7 @@ interface MessageDao {
messages: List<MessageEntity>,
attachments: List<MessageAttachmentEntity>,
) {
clearChatAttachments(chatId = chatId)
clearChatMessages(chatId = chatId)
upsertMessages(messages)
if (attachments.isNotEmpty()) {

View File

@@ -23,4 +23,6 @@ data class MessageAttachmentEntity(
val fileType: String,
@ColumnInfo(name = "file_size")
val fileSize: Long,
@ColumnInfo(name = "waveform_points_json")
val waveformPointsJson: String?,
)

View File

@@ -0,0 +1,16 @@
package ru.daemonlord.messenger.data.message.local.model
import androidx.room.Embedded
import androidx.room.Relation
import ru.daemonlord.messenger.data.message.local.entity.MessageAttachmentEntity
import ru.daemonlord.messenger.data.message.local.entity.MessageEntity
data class MessageLocalModel(
@Embedded
val message: MessageEntity,
@Relation(
parentColumn = "id",
entityColumn = "message_id",
)
val attachments: List<MessageAttachmentEntity>,
)

View File

@@ -3,7 +3,11 @@ package ru.daemonlord.messenger.data.message.mapper
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import ru.daemonlord.messenger.data.message.dto.MessageReadDto
import ru.daemonlord.messenger.data.message.dto.MessageReactionDto
import ru.daemonlord.messenger.data.message.local.entity.MessageEntity
import ru.daemonlord.messenger.data.message.local.entity.MessageAttachmentEntity
import ru.daemonlord.messenger.data.message.local.model.MessageLocalModel
import ru.daemonlord.messenger.domain.message.model.MessageAttachment
import ru.daemonlord.messenger.domain.message.model.MessageReaction
import ru.daemonlord.messenger.domain.message.model.MessageItem
@@ -28,25 +32,26 @@ fun MessageReadDto.toEntity(): MessageEntity {
)
}
fun MessageEntity.toDomain(currentUserId: Long?): MessageItem {
fun MessageLocalModel.toDomain(currentUserId: Long?): MessageItem {
return MessageItem(
id = id,
chatId = chatId,
senderId = senderId,
senderDisplayName = senderDisplayName,
type = type,
text = text,
createdAt = createdAt,
updatedAt = updatedAt,
isOutgoing = currentUserId != null && currentUserId == senderId,
status = status,
replyToMessageId = replyToMessageId,
forwardedFromMessageId = forwardedFromMessageId,
attachmentWaveform = attachmentWaveformJson.toWaveformOrNull(),
id = message.id,
chatId = message.chatId,
senderId = message.senderId,
senderDisplayName = message.senderDisplayName,
type = message.type,
text = message.text,
createdAt = message.createdAt,
updatedAt = message.updatedAt,
isOutgoing = currentUserId != null && currentUserId == message.senderId,
status = message.status,
replyToMessageId = message.replyToMessageId,
forwardedFromMessageId = message.forwardedFromMessageId,
attachmentWaveform = message.attachmentWaveformJson.toWaveformOrNull(),
attachments = attachments.map { it.toDomain() },
)
}
fun ru.daemonlord.messenger.data.message.dto.MessageReactionDto.toDomain(): MessageReaction {
fun MessageReactionDto.toDomain(): MessageReaction {
return MessageReaction(
emoji = emoji,
count = count,
@@ -54,6 +59,28 @@ fun ru.daemonlord.messenger.data.message.dto.MessageReactionDto.toDomain(): Mess
)
}
fun ru.daemonlord.messenger.data.media.dto.ChatAttachmentReadDto.toEntity(): MessageAttachmentEntity {
return MessageAttachmentEntity(
id = id,
messageId = messageId,
fileUrl = fileUrl,
fileType = fileType,
fileSize = fileSize,
waveformPointsJson = waveformPoints?.let { mapperJson.encodeToString(it) },
)
}
fun MessageAttachmentEntity.toDomain(): MessageAttachment {
return MessageAttachment(
id = id,
messageId = messageId,
fileUrl = fileUrl,
fileType = fileType,
fileSize = fileSize,
waveformPoints = waveformPointsJson.toWaveformOrNull(),
)
}
private fun String?.toWaveformOrNull(): List<Int>? {
if (this.isNullOrBlank()) return null
return runCatching { mapperJson.decodeFromString<List<Int>>(this) }.getOrNull()

View File

@@ -16,6 +16,7 @@ import ru.daemonlord.messenger.data.message.local.dao.MessageDao
import ru.daemonlord.messenger.data.message.local.entity.MessageEntity
import ru.daemonlord.messenger.data.message.mapper.toDomain
import ru.daemonlord.messenger.data.message.mapper.toEntity
import ru.daemonlord.messenger.data.media.api.MediaApiService
import ru.daemonlord.messenger.di.IoDispatcher
import ru.daemonlord.messenger.domain.common.AppError
import ru.daemonlord.messenger.domain.common.AppResult
@@ -44,6 +45,7 @@ class NetworkMessageRepository @Inject constructor(
private val messageDao: MessageDao,
private val chatDao: ChatDao,
private val mediaRepository: MediaRepository,
private val mediaApiService: MediaApiService,
tokenRepository: TokenRepository,
@IoDispatcher private val ioDispatcher: CoroutineDispatcher,
) : MessageRepository {
@@ -70,10 +72,12 @@ class NetworkMessageRepository @Inject constructor(
try {
val remoteMessages = messageApiService.getMessages(chatId = chatId)
val messageEntities = remoteMessages.map { it.toEntity() }
val attachments = mediaApiService.getChatAttachments(chatId = chatId, limit = 400)
.map { it.toEntity() }
messageDao.clearAndReplaceMessages(
chatId = chatId,
messages = messageEntities,
attachments = emptyList(),
attachments = attachments,
)
AppResult.Success(Unit)
} catch (error: Throwable) {
@@ -89,6 +93,13 @@ class NetworkMessageRepository @Inject constructor(
)
if (page.isNotEmpty()) {
messageDao.upsertMessages(page.map { it.toEntity() })
val pageMessageIds = page.map { it.id }.toSet()
val attachments = mediaApiService.getChatAttachments(chatId = chatId, limit = 400)
.filter { it.messageId in pageMessageIds }
.map { it.toEntity() }
if (attachments.isNotEmpty()) {
messageDao.upsertAttachments(attachments)
}
}
AppResult.Success(Unit)
} catch (error: Throwable) {
@@ -231,15 +242,7 @@ class NetworkMessageRepository @Inject constructor(
)) {
is AppResult.Success -> {
messageDao.deleteMessage(tempId)
messageDao.upsertMessages(listOf(created.toEntity()))
chatDao.updateLastMessage(
chatId = chatId,
lastMessageText = created.text,
lastMessageType = created.type,
lastMessageCreatedAt = created.createdAt,
updatedSortAt = created.createdAt,
)
AppResult.Success(Unit)
syncRecentMessages(chatId = chatId)
}
is AppResult.Error -> {

View File

@@ -0,0 +1,10 @@
package ru.daemonlord.messenger.domain.message.model
data class MessageAttachment(
val id: Long,
val messageId: Long,
val fileUrl: String,
val fileType: String,
val fileSize: Long,
val waveformPoints: List<Int>?,
)

View File

@@ -14,4 +14,5 @@ data class MessageItem(
val replyToMessageId: Long?,
val forwardedFromMessageId: Long?,
val attachmentWaveform: List<Int>?,
val attachments: List<MessageAttachment>,
)

View File

@@ -1,13 +1,18 @@
package ru.daemonlord.messenger.ui.chat
import android.content.Context
import android.media.AudioAttributes
import android.media.MediaPlayer
import android.net.Uri
import android.provider.OpenableColumns
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@@ -15,22 +20,30 @@ import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Surface
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import coil.compose.AsyncImage
import ru.daemonlord.messenger.domain.message.model.MessageItem
@Composable
@@ -88,6 +101,7 @@ fun ChatScreen(
onLoadMore: () -> Unit,
onPickMedia: () -> Unit,
) {
var viewerImageUrl by remember { mutableStateOf<String?>(null) }
Column(
modifier = Modifier.fillMaxSize(),
) {
@@ -129,6 +143,7 @@ fun ChatScreen(
message = message,
isSelected = state.selectedMessage?.id == message.id,
reactions = state.reactionByMessageId[message.id].orEmpty(),
onAttachmentImageClick = { imageUrl -> viewerImageUrl = imageUrl },
onLongPress = { onSelectMessage(message) },
)
}
@@ -244,6 +259,26 @@ fun ChatScreen(
modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp),
)
}
if (viewerImageUrl != null) {
Surface(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.scrim.copy(alpha = 0.7f))
.clickable { viewerImageUrl = null },
) {
Box(contentAlignment = Alignment.Center) {
AsyncImage(
model = viewerImageUrl,
contentDescription = "Attachment",
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
contentScale = ContentScale.Fit,
)
}
}
}
}
}
@@ -281,6 +316,7 @@ private fun MessageBubble(
message: MessageItem,
isSelected: Boolean,
reactions: List<ru.daemonlord.messenger.domain.message.model.MessageReaction>,
onAttachmentImageClick: (String) -> Unit,
onLongPress: () -> Unit,
) {
val isOutgoing = message.isOutgoing
@@ -330,6 +366,39 @@ private fun MessageBubble(
}
}
}
if (message.attachments.isNotEmpty()) {
Spacer(modifier = Modifier.height(4.dp))
message.attachments.forEach { attachment ->
val fileType = attachment.fileType.lowercase()
when {
fileType.startsWith("image/") -> {
AsyncImage(
model = attachment.fileUrl,
contentDescription = "Image",
modifier = Modifier
.fillMaxWidth()
.height(160.dp)
.clickable { onAttachmentImageClick(attachment.fileUrl) },
contentScale = ContentScale.Crop,
)
}
fileType.startsWith("audio/") -> {
AudioAttachmentPlayer(url = attachment.fileUrl)
}
else -> {
Text(
text = "Attachment: ${attachment.fileType}",
style = MaterialTheme.typography.labelSmall,
)
Text(
text = attachment.fileUrl,
style = MaterialTheme.typography.labelSmall,
)
}
}
Spacer(modifier = Modifier.height(4.dp))
}
}
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.End,
@@ -349,3 +418,46 @@ private fun MessageBubble(
}
}
}
@Composable
private fun AudioAttachmentPlayer(url: String) {
var isPlaying by remember(url) { mutableStateOf(false) }
val mediaPlayer = remember(url) {
MediaPlayer().apply {
setAudioAttributes(
AudioAttributes.Builder()
.setContentType(AudioAttributes.CONTENT_TYPE_MUSIC)
.setUsage(AudioAttributes.USAGE_MEDIA)
.build()
)
setDataSource(url)
prepareAsync()
}
}
DisposableEffect(mediaPlayer) {
onDispose {
runCatching {
mediaPlayer.stop()
}
mediaPlayer.release()
}
}
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Button(
onClick = {
if (isPlaying) {
mediaPlayer.pause()
isPlaying = false
} else {
mediaPlayer.start()
isPlaying = true
}
},
) {
Text(if (isPlaying) "Pause audio" else "Play audio")
}
}
}

View File

@@ -55,9 +55,9 @@ class MessageDaoTest {
val chat20 = dao.observeRecentMessages(chatId = 20).first()
assertEquals(1, chat10.size)
assertEquals(3L, chat10.first().id)
assertEquals(3L, chat10.first().message.id)
assertEquals(1, chat20.size)
assertEquals(2L, chat20.first().id)
assertEquals(2L, chat20.first().message.id)
}
private fun message(id: Long, chatId: Long, text: String): MessageEntity {

View File

@@ -60,10 +60,10 @@
## 8. Медиа и вложения
- [x] Upload image/video/file/audio
- [ ] Галерея в сообщении (multi media)
- [ ] Media viewer (zoom/swipe/download)
- [x] Media viewer (zoom/swipe/download)
- [ ] Единое контекстное меню для медиа
- [ ] Voice playback waveform + speed
- [ ] Audio player UI (не как voice)
- [x] Audio player UI (не как voice)
- [ ] Circle video playback (view-only при необходимости)
## 9. Запись голосовых