android: add media upload repository and chat attachment send flow
Some checks are pending
CI / test (push) Has started running
Some checks are pending
CI / test (push) Has started running
This commit is contained in:
@@ -97,3 +97,10 @@
|
|||||||
- Added DAO test for message scoped replace behavior in Room.
|
- Added DAO test for message scoped replace behavior in Room.
|
||||||
- Expanded realtime parser tests with rich `receive_message` mapping coverage.
|
- Expanded realtime parser tests with rich `receive_message` mapping coverage.
|
||||||
- Updated `docs/android-checklist.md` for completed message-core items.
|
- Updated `docs/android-checklist.md` for completed message-core items.
|
||||||
|
|
||||||
|
### Step 16 - Sprint B / 1-2) Media data layer + chat integration
|
||||||
|
- Added media API/DTO layer for upload URL and attachment creation.
|
||||||
|
- Added `MediaRepository` + `UploadAndAttachMediaUseCase` and network implementation with presigned PUT upload.
|
||||||
|
- Extended `MessageRepository` with media send flow (`sendMediaMessage`) and optimistic local update behavior.
|
||||||
|
- Wired media API/repository through Hilt modules.
|
||||||
|
- Integrated file picking and media sending into Android `ChatScreen`/`ChatViewModel` with upload state handling.
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
package ru.daemonlord.messenger.data.media.api
|
||||||
|
|
||||||
|
import retrofit2.http.Body
|
||||||
|
import retrofit2.http.POST
|
||||||
|
import ru.daemonlord.messenger.data.media.dto.AttachmentCreateRequestDto
|
||||||
|
import ru.daemonlord.messenger.data.media.dto.UploadUrlRequestDto
|
||||||
|
import ru.daemonlord.messenger.data.media.dto.UploadUrlResponseDto
|
||||||
|
|
||||||
|
interface MediaApiService {
|
||||||
|
@POST("/api/v1/media/upload-url")
|
||||||
|
suspend fun requestUploadUrl(
|
||||||
|
@Body request: UploadUrlRequestDto,
|
||||||
|
): UploadUrlResponseDto
|
||||||
|
|
||||||
|
@POST("/api/v1/media/attachments")
|
||||||
|
suspend fun createAttachment(
|
||||||
|
@Body request: AttachmentCreateRequestDto,
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
package ru.daemonlord.messenger.data.media.dto
|
||||||
|
|
||||||
|
import kotlinx.serialization.SerialName
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class UploadUrlRequestDto(
|
||||||
|
@SerialName("file_name")
|
||||||
|
val fileName: String,
|
||||||
|
@SerialName("file_type")
|
||||||
|
val fileType: String,
|
||||||
|
@SerialName("file_size")
|
||||||
|
val fileSize: Long,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class UploadUrlResponseDto(
|
||||||
|
@SerialName("upload_url")
|
||||||
|
val uploadUrl: String,
|
||||||
|
@SerialName("file_url")
|
||||||
|
val fileUrl: String,
|
||||||
|
@SerialName("object_key")
|
||||||
|
val objectKey: String,
|
||||||
|
@SerialName("expires_in")
|
||||||
|
val expiresIn: Int,
|
||||||
|
@SerialName("required_headers")
|
||||||
|
val requiredHeaders: Map<String, String> = emptyMap(),
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class AttachmentCreateRequestDto(
|
||||||
|
@SerialName("message_id")
|
||||||
|
val messageId: Long,
|
||||||
|
@SerialName("file_url")
|
||||||
|
val fileUrl: String,
|
||||||
|
@SerialName("file_type")
|
||||||
|
val fileType: String,
|
||||||
|
@SerialName("file_size")
|
||||||
|
val fileSize: Long,
|
||||||
|
)
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
package ru.daemonlord.messenger.data.media.repository
|
||||||
|
|
||||||
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import okhttp3.MediaType.Companion.toMediaTypeOrNull
|
||||||
|
import okhttp3.OkHttpClient
|
||||||
|
import okhttp3.Request
|
||||||
|
import okhttp3.RequestBody.Companion.toRequestBody
|
||||||
|
import retrofit2.HttpException
|
||||||
|
import ru.daemonlord.messenger.data.media.api.MediaApiService
|
||||||
|
import ru.daemonlord.messenger.data.media.dto.AttachmentCreateRequestDto
|
||||||
|
import ru.daemonlord.messenger.data.media.dto.UploadUrlRequestDto
|
||||||
|
import ru.daemonlord.messenger.di.IoDispatcher
|
||||||
|
import ru.daemonlord.messenger.di.RefreshClient
|
||||||
|
import ru.daemonlord.messenger.domain.common.AppError
|
||||||
|
import ru.daemonlord.messenger.domain.common.AppResult
|
||||||
|
import ru.daemonlord.messenger.domain.media.repository.MediaRepository
|
||||||
|
import java.io.IOException
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
@Singleton
|
||||||
|
class NetworkMediaRepository @Inject constructor(
|
||||||
|
private val mediaApiService: MediaApiService,
|
||||||
|
@RefreshClient private val uploadClient: OkHttpClient,
|
||||||
|
@IoDispatcher private val ioDispatcher: CoroutineDispatcher,
|
||||||
|
) : MediaRepository {
|
||||||
|
|
||||||
|
override suspend fun uploadAndAttach(
|
||||||
|
messageId: Long,
|
||||||
|
fileName: String,
|
||||||
|
mimeType: String,
|
||||||
|
bytes: ByteArray,
|
||||||
|
): AppResult<Unit> = withContext(ioDispatcher) {
|
||||||
|
try {
|
||||||
|
val uploadInfo = mediaApiService.requestUploadUrl(
|
||||||
|
request = UploadUrlRequestDto(
|
||||||
|
fileName = fileName,
|
||||||
|
fileType = mimeType,
|
||||||
|
fileSize = bytes.size.toLong(),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
val body = bytes.toRequestBody(mimeType.toMediaTypeOrNull())
|
||||||
|
val uploadRequestBuilder = Request.Builder()
|
||||||
|
.url(uploadInfo.uploadUrl)
|
||||||
|
.put(body)
|
||||||
|
|
||||||
|
uploadInfo.requiredHeaders.forEach { (key, value) ->
|
||||||
|
uploadRequestBuilder.header(key, value)
|
||||||
|
}
|
||||||
|
|
||||||
|
uploadClient.newCall(uploadRequestBuilder.build()).execute().use { response ->
|
||||||
|
if (!response.isSuccessful) {
|
||||||
|
return@withContext AppResult.Error(
|
||||||
|
AppError.Server(message = "Upload failed: HTTP ${response.code}")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
mediaApiService.createAttachment(
|
||||||
|
request = AttachmentCreateRequestDto(
|
||||||
|
messageId = messageId,
|
||||||
|
fileUrl = uploadInfo.fileUrl,
|
||||||
|
fileType = mimeType,
|
||||||
|
fileSize = bytes.size.toLong(),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
AppResult.Success(Unit)
|
||||||
|
} catch (error: Throwable) {
|
||||||
|
AppResult.Error(error.toAppError())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun Throwable.toAppError(): AppError {
|
||||||
|
return when (this) {
|
||||||
|
is IOException -> AppError.Network
|
||||||
|
is HttpException -> if (code() == 401 || code() == 403) {
|
||||||
|
AppError.Unauthorized
|
||||||
|
} else {
|
||||||
|
AppError.Server(message = message())
|
||||||
|
}
|
||||||
|
else -> AppError.Unknown(cause = this)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -27,7 +27,7 @@ data class MessageCreateRequestDto(
|
|||||||
@SerialName("chat_id")
|
@SerialName("chat_id")
|
||||||
val chatId: Long,
|
val chatId: Long,
|
||||||
val type: String,
|
val type: String,
|
||||||
val text: String,
|
val text: String? = null,
|
||||||
@SerialName("client_message_id")
|
@SerialName("client_message_id")
|
||||||
val clientMessageId: String,
|
val clientMessageId: String,
|
||||||
@SerialName("reply_to_message_id")
|
@SerialName("reply_to_message_id")
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import ru.daemonlord.messenger.data.message.mapper.toEntity
|
|||||||
import ru.daemonlord.messenger.di.IoDispatcher
|
import ru.daemonlord.messenger.di.IoDispatcher
|
||||||
import ru.daemonlord.messenger.domain.common.AppError
|
import ru.daemonlord.messenger.domain.common.AppError
|
||||||
import ru.daemonlord.messenger.domain.common.AppResult
|
import ru.daemonlord.messenger.domain.common.AppResult
|
||||||
|
import ru.daemonlord.messenger.domain.media.repository.MediaRepository
|
||||||
import ru.daemonlord.messenger.domain.message.model.MessageItem
|
import ru.daemonlord.messenger.domain.message.model.MessageItem
|
||||||
import ru.daemonlord.messenger.domain.message.repository.MessageRepository
|
import ru.daemonlord.messenger.domain.message.repository.MessageRepository
|
||||||
import ru.daemonlord.messenger.data.message.dto.MessageCreateRequestDto
|
import ru.daemonlord.messenger.data.message.dto.MessageCreateRequestDto
|
||||||
@@ -28,6 +29,7 @@ class NetworkMessageRepository @Inject constructor(
|
|||||||
private val messageApiService: MessageApiService,
|
private val messageApiService: MessageApiService,
|
||||||
private val messageDao: MessageDao,
|
private val messageDao: MessageDao,
|
||||||
private val chatDao: ChatDao,
|
private val chatDao: ChatDao,
|
||||||
|
private val mediaRepository: MediaRepository,
|
||||||
@IoDispatcher private val ioDispatcher: CoroutineDispatcher,
|
@IoDispatcher private val ioDispatcher: CoroutineDispatcher,
|
||||||
) : MessageRepository {
|
) : MessageRepository {
|
||||||
|
|
||||||
@@ -149,6 +151,88 @@ class NetworkMessageRepository @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override suspend fun sendMediaMessage(
|
||||||
|
chatId: Long,
|
||||||
|
fileName: String,
|
||||||
|
mimeType: String,
|
||||||
|
bytes: ByteArray,
|
||||||
|
caption: String?,
|
||||||
|
replyToMessageId: Long?,
|
||||||
|
): AppResult<Unit> = withContext(ioDispatcher) {
|
||||||
|
val messageType = mapMimeToMessageType(mimeType)
|
||||||
|
val tempId = -System.currentTimeMillis()
|
||||||
|
val tempMessage = MessageEntity(
|
||||||
|
id = tempId,
|
||||||
|
chatId = chatId,
|
||||||
|
senderId = currentUserId ?: 0L,
|
||||||
|
senderDisplayName = null,
|
||||||
|
senderUsername = null,
|
||||||
|
senderAvatarUrl = null,
|
||||||
|
replyToMessageId = replyToMessageId,
|
||||||
|
type = messageType,
|
||||||
|
text = caption,
|
||||||
|
status = "pending",
|
||||||
|
createdAt = java.time.Instant.now().toString(),
|
||||||
|
updatedAt = null,
|
||||||
|
)
|
||||||
|
messageDao.upsertMessages(listOf(tempMessage))
|
||||||
|
chatDao.updateLastMessage(
|
||||||
|
chatId = chatId,
|
||||||
|
lastMessageText = caption,
|
||||||
|
lastMessageType = messageType,
|
||||||
|
lastMessageCreatedAt = tempMessage.createdAt,
|
||||||
|
updatedSortAt = tempMessage.createdAt,
|
||||||
|
)
|
||||||
|
try {
|
||||||
|
val created = messageApiService.sendMessage(
|
||||||
|
request = MessageCreateRequestDto(
|
||||||
|
chatId = chatId,
|
||||||
|
type = messageType,
|
||||||
|
text = caption,
|
||||||
|
clientMessageId = UUID.randomUUID().toString(),
|
||||||
|
replyToMessageId = replyToMessageId,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
when (val mediaResult = mediaRepository.uploadAndAttach(
|
||||||
|
messageId = created.id,
|
||||||
|
fileName = fileName,
|
||||||
|
mimeType = mimeType,
|
||||||
|
bytes = bytes,
|
||||||
|
)) {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
is AppResult.Error -> {
|
||||||
|
messageDao.deleteMessage(tempId)
|
||||||
|
AppResult.Error(mediaResult.reason)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error: Throwable) {
|
||||||
|
messageDao.deleteMessage(tempId)
|
||||||
|
AppResult.Error(error.toAppError())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun mapMimeToMessageType(mimeType: String): String {
|
||||||
|
return when {
|
||||||
|
mimeType.startsWith("image/") -> "image"
|
||||||
|
mimeType.startsWith("video/") -> "video"
|
||||||
|
mimeType.startsWith("audio/") -> "audio"
|
||||||
|
else -> "file"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun Throwable.toAppError(): AppError {
|
private fun Throwable.toAppError(): AppError {
|
||||||
return when (this) {
|
return when (this) {
|
||||||
is IOException -> AppError.Network
|
is IOException -> AppError.Network
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import ru.daemonlord.messenger.core.network.AuthHeaderInterceptor
|
|||||||
import ru.daemonlord.messenger.core.network.TokenRefreshAuthenticator
|
import ru.daemonlord.messenger.core.network.TokenRefreshAuthenticator
|
||||||
import ru.daemonlord.messenger.data.auth.api.AuthApiService
|
import ru.daemonlord.messenger.data.auth.api.AuthApiService
|
||||||
import ru.daemonlord.messenger.data.chat.api.ChatApiService
|
import ru.daemonlord.messenger.data.chat.api.ChatApiService
|
||||||
|
import ru.daemonlord.messenger.data.media.api.MediaApiService
|
||||||
import ru.daemonlord.messenger.data.message.api.MessageApiService
|
import ru.daemonlord.messenger.data.message.api.MessageApiService
|
||||||
import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory
|
import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory
|
||||||
import java.util.concurrent.TimeUnit
|
import java.util.concurrent.TimeUnit
|
||||||
@@ -130,4 +131,10 @@ object NetworkModule {
|
|||||||
fun provideMessageApiService(retrofit: Retrofit): MessageApiService {
|
fun provideMessageApiService(retrofit: Retrofit): MessageApiService {
|
||||||
return retrofit.create(MessageApiService::class.java)
|
return retrofit.create(MessageApiService::class.java)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
@Singleton
|
||||||
|
fun provideMediaApiService(retrofit: Retrofit): MediaApiService {
|
||||||
|
return retrofit.create(MediaApiService::class.java)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,9 +6,11 @@ import dagger.hilt.InstallIn
|
|||||||
import dagger.hilt.components.SingletonComponent
|
import dagger.hilt.components.SingletonComponent
|
||||||
import ru.daemonlord.messenger.data.auth.repository.NetworkAuthRepository
|
import ru.daemonlord.messenger.data.auth.repository.NetworkAuthRepository
|
||||||
import ru.daemonlord.messenger.data.chat.repository.NetworkChatRepository
|
import ru.daemonlord.messenger.data.chat.repository.NetworkChatRepository
|
||||||
|
import ru.daemonlord.messenger.data.media.repository.NetworkMediaRepository
|
||||||
import ru.daemonlord.messenger.data.message.repository.NetworkMessageRepository
|
import ru.daemonlord.messenger.data.message.repository.NetworkMessageRepository
|
||||||
import ru.daemonlord.messenger.domain.auth.repository.AuthRepository
|
import ru.daemonlord.messenger.domain.auth.repository.AuthRepository
|
||||||
import ru.daemonlord.messenger.domain.chat.repository.ChatRepository
|
import ru.daemonlord.messenger.domain.chat.repository.ChatRepository
|
||||||
|
import ru.daemonlord.messenger.domain.media.repository.MediaRepository
|
||||||
import ru.daemonlord.messenger.domain.message.repository.MessageRepository
|
import ru.daemonlord.messenger.domain.message.repository.MessageRepository
|
||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
|
|
||||||
@@ -33,4 +35,10 @@ abstract class RepositoryModule {
|
|||||||
abstract fun bindMessageRepository(
|
abstract fun bindMessageRepository(
|
||||||
repository: NetworkMessageRepository,
|
repository: NetworkMessageRepository,
|
||||||
): MessageRepository
|
): MessageRepository
|
||||||
|
|
||||||
|
@Binds
|
||||||
|
@Singleton
|
||||||
|
abstract fun bindMediaRepository(
|
||||||
|
repository: NetworkMediaRepository,
|
||||||
|
): MediaRepository
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
package ru.daemonlord.messenger.domain.media.repository
|
||||||
|
|
||||||
|
import ru.daemonlord.messenger.domain.common.AppResult
|
||||||
|
|
||||||
|
interface MediaRepository {
|
||||||
|
suspend fun uploadAndAttach(
|
||||||
|
messageId: Long,
|
||||||
|
fileName: String,
|
||||||
|
mimeType: String,
|
||||||
|
bytes: ByteArray,
|
||||||
|
): AppResult<Unit>
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
package ru.daemonlord.messenger.domain.media.usecase
|
||||||
|
|
||||||
|
import ru.daemonlord.messenger.domain.common.AppResult
|
||||||
|
import ru.daemonlord.messenger.domain.media.repository.MediaRepository
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
class UploadAndAttachMediaUseCase @Inject constructor(
|
||||||
|
private val mediaRepository: MediaRepository,
|
||||||
|
) {
|
||||||
|
suspend operator fun invoke(
|
||||||
|
messageId: Long,
|
||||||
|
fileName: String,
|
||||||
|
mimeType: String,
|
||||||
|
bytes: ByteArray,
|
||||||
|
): AppResult<Unit> {
|
||||||
|
return mediaRepository.uploadAndAttach(
|
||||||
|
messageId = messageId,
|
||||||
|
fileName = fileName,
|
||||||
|
mimeType = mimeType,
|
||||||
|
bytes = bytes,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,6 +9,14 @@ interface MessageRepository {
|
|||||||
suspend fun syncRecentMessages(chatId: Long): AppResult<Unit>
|
suspend fun syncRecentMessages(chatId: Long): AppResult<Unit>
|
||||||
suspend fun loadMoreMessages(chatId: Long, beforeMessageId: Long): AppResult<Unit>
|
suspend fun loadMoreMessages(chatId: Long, beforeMessageId: Long): AppResult<Unit>
|
||||||
suspend fun sendTextMessage(chatId: Long, text: String, replyToMessageId: Long? = null): AppResult<Unit>
|
suspend fun sendTextMessage(chatId: Long, text: String, replyToMessageId: Long? = null): AppResult<Unit>
|
||||||
|
suspend fun sendMediaMessage(
|
||||||
|
chatId: Long,
|
||||||
|
fileName: String,
|
||||||
|
mimeType: String,
|
||||||
|
bytes: ByteArray,
|
||||||
|
caption: String? = null,
|
||||||
|
replyToMessageId: Long? = null,
|
||||||
|
): AppResult<Unit>
|
||||||
suspend fun editMessage(messageId: Long, newText: String): AppResult<Unit>
|
suspend fun editMessage(messageId: Long, newText: String): AppResult<Unit>
|
||||||
suspend fun deleteMessage(messageId: Long, forAll: Boolean): AppResult<Unit>
|
suspend fun deleteMessage(messageId: Long, forAll: Boolean): AppResult<Unit>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
package ru.daemonlord.messenger.domain.message.usecase
|
||||||
|
|
||||||
|
import ru.daemonlord.messenger.domain.common.AppResult
|
||||||
|
import ru.daemonlord.messenger.domain.message.repository.MessageRepository
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
class SendMediaMessageUseCase @Inject constructor(
|
||||||
|
private val messageRepository: MessageRepository,
|
||||||
|
) {
|
||||||
|
suspend operator fun invoke(
|
||||||
|
chatId: Long,
|
||||||
|
fileName: String,
|
||||||
|
mimeType: String,
|
||||||
|
bytes: ByteArray,
|
||||||
|
caption: String? = null,
|
||||||
|
replyToMessageId: Long? = null,
|
||||||
|
): AppResult<Unit> {
|
||||||
|
return messageRepository.sendMediaMessage(
|
||||||
|
chatId = chatId,
|
||||||
|
fileName = fileName,
|
||||||
|
mimeType = mimeType,
|
||||||
|
bytes = bytes,
|
||||||
|
caption = caption,
|
||||||
|
replyToMessageId = replyToMessageId,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,10 @@
|
|||||||
package ru.daemonlord.messenger.ui.chat
|
package ru.daemonlord.messenger.ui.chat
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
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.background
|
||||||
import androidx.compose.foundation.combinedClickable
|
import androidx.compose.foundation.combinedClickable
|
||||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||||
@@ -19,6 +24,7 @@ import androidx.compose.material3.OutlinedTextField
|
|||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
@@ -33,6 +39,18 @@ fun ChatRoute(
|
|||||||
viewModel: ChatViewModel = hiltViewModel(),
|
viewModel: ChatViewModel = hiltViewModel(),
|
||||||
) {
|
) {
|
||||||
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
||||||
|
val context = LocalContext.current
|
||||||
|
val pickMediaLauncher = rememberLauncherForActivityResult(
|
||||||
|
contract = ActivityResultContracts.GetContent(),
|
||||||
|
) { uri ->
|
||||||
|
val picked = uri?.readMediaPayload(context) ?: return@rememberLauncherForActivityResult
|
||||||
|
viewModel.onMediaPicked(
|
||||||
|
fileName = picked.fileName,
|
||||||
|
mimeType = picked.mimeType,
|
||||||
|
bytes = picked.bytes,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
ChatScreen(
|
ChatScreen(
|
||||||
state = state,
|
state = state,
|
||||||
onBack = onBack,
|
onBack = onBack,
|
||||||
@@ -44,6 +62,7 @@ fun ChatRoute(
|
|||||||
onDeleteSelected = viewModel::onDeleteSelected,
|
onDeleteSelected = viewModel::onDeleteSelected,
|
||||||
onCancelComposeAction = viewModel::onCancelComposeAction,
|
onCancelComposeAction = viewModel::onCancelComposeAction,
|
||||||
onLoadMore = viewModel::loadMore,
|
onLoadMore = viewModel::loadMore,
|
||||||
|
onPickMedia = { pickMediaLauncher.launch("*/*") },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -59,6 +78,7 @@ fun ChatScreen(
|
|||||||
onDeleteSelected: (Boolean) -> Unit,
|
onDeleteSelected: (Boolean) -> Unit,
|
||||||
onCancelComposeAction: () -> Unit,
|
onCancelComposeAction: () -> Unit,
|
||||||
onLoadMore: () -> Unit,
|
onLoadMore: () -> Unit,
|
||||||
|
onPickMedia: () -> Unit,
|
||||||
) {
|
) {
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
@@ -147,6 +167,12 @@ fun ChatScreen(
|
|||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
) {
|
) {
|
||||||
|
Button(
|
||||||
|
onClick = onPickMedia,
|
||||||
|
enabled = !state.isUploadingMedia,
|
||||||
|
) {
|
||||||
|
Text(if (state.isUploadingMedia) "..." else "Attach")
|
||||||
|
}
|
||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
value = state.inputText,
|
value = state.inputText,
|
||||||
onValueChange = onInputChanged,
|
onValueChange = onInputChanged,
|
||||||
@@ -156,7 +182,7 @@ fun ChatScreen(
|
|||||||
)
|
)
|
||||||
Button(
|
Button(
|
||||||
onClick = onSendClick,
|
onClick = onSendClick,
|
||||||
enabled = !state.isSending && state.inputText.isNotBlank(),
|
enabled = !state.isSending && !state.isUploadingMedia && state.inputText.isNotBlank(),
|
||||||
) {
|
) {
|
||||||
Text(if (state.isSending) "..." else "Send")
|
Text(if (state.isSending) "..." else "Send")
|
||||||
}
|
}
|
||||||
@@ -172,6 +198,34 @@ fun ChatScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private data class PickedMediaPayload(
|
||||||
|
val fileName: String,
|
||||||
|
val mimeType: String,
|
||||||
|
val bytes: ByteArray,
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun Uri.readMediaPayload(context: Context): PickedMediaPayload? {
|
||||||
|
val resolver = context.contentResolver
|
||||||
|
val mime = resolver.getType(this)?.ifBlank { null } ?: "application/octet-stream"
|
||||||
|
var name = "attachment"
|
||||||
|
resolver.query(this, null, null, null, null)?.use { cursor ->
|
||||||
|
val nameIndex = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME)
|
||||||
|
if (nameIndex >= 0 && cursor.moveToFirst()) {
|
||||||
|
val raw = cursor.getString(nameIndex)
|
||||||
|
if (!raw.isNullOrBlank()) {
|
||||||
|
name = raw
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val bytes = resolver.openInputStream(this)?.use { stream -> stream.readBytes() } ?: return null
|
||||||
|
if (bytes.isEmpty()) return null
|
||||||
|
return PickedMediaPayload(
|
||||||
|
fileName = name,
|
||||||
|
mimeType = mime,
|
||||||
|
bytes = bytes,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@OptIn(ExperimentalFoundationApi::class)
|
@OptIn(ExperimentalFoundationApi::class)
|
||||||
private fun MessageBubble(
|
private fun MessageBubble(
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import ru.daemonlord.messenger.domain.message.usecase.DeleteMessageUseCase
|
|||||||
import ru.daemonlord.messenger.domain.message.usecase.EditMessageUseCase
|
import ru.daemonlord.messenger.domain.message.usecase.EditMessageUseCase
|
||||||
import ru.daemonlord.messenger.domain.message.usecase.LoadMoreMessagesUseCase
|
import ru.daemonlord.messenger.domain.message.usecase.LoadMoreMessagesUseCase
|
||||||
import ru.daemonlord.messenger.domain.message.usecase.ObserveMessagesUseCase
|
import ru.daemonlord.messenger.domain.message.usecase.ObserveMessagesUseCase
|
||||||
|
import ru.daemonlord.messenger.domain.message.usecase.SendMediaMessageUseCase
|
||||||
import ru.daemonlord.messenger.domain.message.usecase.SendTextMessageUseCase
|
import ru.daemonlord.messenger.domain.message.usecase.SendTextMessageUseCase
|
||||||
import ru.daemonlord.messenger.domain.message.usecase.SyncRecentMessagesUseCase
|
import ru.daemonlord.messenger.domain.message.usecase.SyncRecentMessagesUseCase
|
||||||
import ru.daemonlord.messenger.domain.realtime.usecase.HandleRealtimeEventsUseCase
|
import ru.daemonlord.messenger.domain.realtime.usecase.HandleRealtimeEventsUseCase
|
||||||
@@ -29,6 +30,7 @@ class ChatViewModel @Inject constructor(
|
|||||||
private val syncRecentMessagesUseCase: SyncRecentMessagesUseCase,
|
private val syncRecentMessagesUseCase: SyncRecentMessagesUseCase,
|
||||||
private val loadMoreMessagesUseCase: LoadMoreMessagesUseCase,
|
private val loadMoreMessagesUseCase: LoadMoreMessagesUseCase,
|
||||||
private val sendTextMessageUseCase: SendTextMessageUseCase,
|
private val sendTextMessageUseCase: SendTextMessageUseCase,
|
||||||
|
private val sendMediaMessageUseCase: SendMediaMessageUseCase,
|
||||||
private val editMessageUseCase: EditMessageUseCase,
|
private val editMessageUseCase: EditMessageUseCase,
|
||||||
private val deleteMessageUseCase: DeleteMessageUseCase,
|
private val deleteMessageUseCase: DeleteMessageUseCase,
|
||||||
private val handleRealtimeEventsUseCase: HandleRealtimeEventsUseCase,
|
private val handleRealtimeEventsUseCase: HandleRealtimeEventsUseCase,
|
||||||
@@ -133,6 +135,44 @@ class ChatViewModel @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun onMediaPicked(
|
||||||
|
fileName: String,
|
||||||
|
mimeType: String,
|
||||||
|
bytes: ByteArray,
|
||||||
|
) {
|
||||||
|
if (bytes.isEmpty()) return
|
||||||
|
viewModelScope.launch {
|
||||||
|
_uiState.update { it.copy(isUploadingMedia = true, errorMessage = null) }
|
||||||
|
val caption = uiState.value.inputText.trim().ifBlank { null }
|
||||||
|
when (
|
||||||
|
val result = sendMediaMessageUseCase(
|
||||||
|
chatId = chatId,
|
||||||
|
fileName = fileName,
|
||||||
|
mimeType = mimeType,
|
||||||
|
bytes = bytes,
|
||||||
|
caption = caption,
|
||||||
|
replyToMessageId = uiState.value.replyToMessage?.id,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
is AppResult.Success -> _uiState.update {
|
||||||
|
it.copy(
|
||||||
|
isUploadingMedia = false,
|
||||||
|
inputText = "",
|
||||||
|
replyToMessage = null,
|
||||||
|
editingMessage = null,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
is AppResult.Error -> _uiState.update {
|
||||||
|
it.copy(
|
||||||
|
isUploadingMedia = false,
|
||||||
|
errorMessage = result.reason.toUiMessage(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun loadMore() {
|
fun loadMore() {
|
||||||
val oldest = uiState.value.messages.firstOrNull() ?: return
|
val oldest = uiState.value.messages.firstOrNull() ?: return
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ data class MessageUiState(
|
|||||||
val isLoading: Boolean = true,
|
val isLoading: Boolean = true,
|
||||||
val isLoadingMore: Boolean = false,
|
val isLoadingMore: Boolean = false,
|
||||||
val isSending: Boolean = false,
|
val isSending: Boolean = false,
|
||||||
|
val isUploadingMedia: Boolean = false,
|
||||||
val errorMessage: String? = null,
|
val errorMessage: String? = null,
|
||||||
val messages: List<MessageItem> = emptyList(),
|
val messages: List<MessageItem> = emptyList(),
|
||||||
val inputText: String = "",
|
val inputText: String = "",
|
||||||
|
|||||||
Reference in New Issue
Block a user