From e4a83b4b2b07fa1ba83be64167bf856bc56cc71c Mon Sep 17 00:00:00 2001 From: migulyaev Date: Fri, 29 Sep 2023 15:46:08 +0200 Subject: [PATCH 1/2] attachments base ui in the message composing screen --- .../tech/relaycorp/letro/di/AndroidModule.kt | 21 +++ .../tech/relaycorp/letro/di/FileModule.kt | 27 ++++ .../compose/ComposeNewMessageScreen.kt | 56 +++++++- .../compose/ComposeNewMessageViewModel.kt | 44 +++++- .../messages/filepicker/FileConverter.kt | 60 ++++++++ .../letro/messages/filepicker/model/File.kt | 30 ++++ .../filepicker/model/FileExtension.kt | 23 ++++ .../relaycorp/letro/messages/ui/Attachment.kt | 129 ++++++++++++++++++ .../ui/utils/AttachmentInfoConverter.kt | 59 ++++++++ .../letro/ui/navigation/LetroNavHost.kt | 14 +- .../relaycorp/letro/ui/navigation/Route.kt | 4 +- .../relaycorp/letro/utils/files/FileSize.kt | 10 ++ .../main/res/drawable/attachment_audio.xml | 13 ++ .../main/res/drawable/attachment_default.xml | 13 ++ .../main/res/drawable/attachment_image.xml | 13 ++ app/src/main/res/drawable/attachment_pdf.xml | 13 ++ .../main/res/drawable/attachment_video.xml | 13 ++ .../res/drawable/ic_delete_attachment.xml | 13 ++ app/src/main/res/values/strings.xml | 6 + 19 files changed, 543 insertions(+), 18 deletions(-) create mode 100644 app/src/main/java/tech/relaycorp/letro/di/AndroidModule.kt create mode 100644 app/src/main/java/tech/relaycorp/letro/di/FileModule.kt create mode 100644 app/src/main/java/tech/relaycorp/letro/messages/filepicker/FileConverter.kt create mode 100644 app/src/main/java/tech/relaycorp/letro/messages/filepicker/model/File.kt create mode 100644 app/src/main/java/tech/relaycorp/letro/messages/filepicker/model/FileExtension.kt create mode 100644 app/src/main/java/tech/relaycorp/letro/messages/ui/Attachment.kt create mode 100644 app/src/main/java/tech/relaycorp/letro/messages/ui/utils/AttachmentInfoConverter.kt create mode 100644 app/src/main/java/tech/relaycorp/letro/utils/files/FileSize.kt create mode 100644 app/src/main/res/drawable/attachment_audio.xml create mode 100644 app/src/main/res/drawable/attachment_default.xml create mode 100644 app/src/main/res/drawable/attachment_image.xml create mode 100644 app/src/main/res/drawable/attachment_pdf.xml create mode 100644 app/src/main/res/drawable/attachment_video.xml create mode 100644 app/src/main/res/drawable/ic_delete_attachment.xml diff --git a/app/src/main/java/tech/relaycorp/letro/di/AndroidModule.kt b/app/src/main/java/tech/relaycorp/letro/di/AndroidModule.kt new file mode 100644 index 00000000..5217e1a9 --- /dev/null +++ b/app/src/main/java/tech/relaycorp/letro/di/AndroidModule.kt @@ -0,0 +1,21 @@ +package tech.relaycorp.letro.di + +import android.content.ContentResolver +import android.content.Context +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent + +@Module +@InstallIn(SingletonComponent::class) +object AndroidModule { + + @Provides + fun provideContentResolver( + @ApplicationContext context: Context, + ): ContentResolver { + return context.contentResolver + } +} diff --git a/app/src/main/java/tech/relaycorp/letro/di/FileModule.kt b/app/src/main/java/tech/relaycorp/letro/di/FileModule.kt new file mode 100644 index 00000000..269c9348 --- /dev/null +++ b/app/src/main/java/tech/relaycorp/letro/di/FileModule.kt @@ -0,0 +1,27 @@ +package tech.relaycorp.letro.di + +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.android.components.ViewModelComponent +import dagger.hilt.android.scopes.ViewModelScoped +import tech.relaycorp.letro.messages.filepicker.FileConverter +import tech.relaycorp.letro.messages.filepicker.FileConverterImpl +import tech.relaycorp.letro.messages.ui.utils.AttachmentInfoConverter +import tech.relaycorp.letro.messages.ui.utils.AttachmentInfoConverterImpl + +@Module +@InstallIn(ViewModelComponent::class) +interface FileModule { + + @Binds + @ViewModelScoped + fun bindFileConverter( + impl: FileConverterImpl, + ): FileConverter + + @Binds + fun bindAttachmentInfoConverter( + impl: AttachmentInfoConverterImpl, + ): AttachmentInfoConverter +} diff --git a/app/src/main/java/tech/relaycorp/letro/messages/compose/ComposeNewMessageScreen.kt b/app/src/main/java/tech/relaycorp/letro/messages/compose/ComposeNewMessageScreen.kt index b03c0ff7..bfd956d0 100644 --- a/app/src/main/java/tech/relaycorp/letro/messages/compose/ComposeNewMessageScreen.kt +++ b/app/src/main/java/tech/relaycorp/letro/messages/compose/ComposeNewMessageScreen.kt @@ -1,5 +1,7 @@ package tech.relaycorp.letro.messages.compose +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.layout.Box @@ -45,6 +47,8 @@ import androidx.hilt.navigation.compose.hiltViewModel import tech.relaycorp.letro.R import tech.relaycorp.letro.contacts.model.Contact import tech.relaycorp.letro.contacts.ui.ContactView +import tech.relaycorp.letro.messages.ui.Attachment +import tech.relaycorp.letro.messages.ui.AttachmentInfo import tech.relaycorp.letro.ui.common.LetroButton import tech.relaycorp.letro.ui.common.LetroTextField import tech.relaycorp.letro.ui.theme.Elevation2 @@ -53,14 +57,21 @@ import tech.relaycorp.letro.ui.utils.ConversationsStringsProvider import tech.relaycorp.letro.utils.ext.applyIf @Composable -fun CreateNewMessageScreen( +fun ComposeNewMessageScreen( conversationsStringsProvider: ConversationsStringsProvider, onBackClicked: () -> Unit, onMessageSent: () -> Unit, - viewModel: CreateNewMessageViewModel = hiltViewModel(), + viewModel: ComposeNewMessageViewModel = hiltViewModel(), ) { val uiState by viewModel.uiState.collectAsState() + val documentPickerLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.GetContent(), + onResult = { + viewModel.onFilePickerResult(it) + }, + ) + var recipientTextFieldValueState by remember { mutableStateOf( TextFieldValue(), @@ -120,7 +131,7 @@ fun CreateNewMessageScreen( modifier = Modifier.weight(1f), ) IconButton( - onClick = { }, + onClick = { documentPickerLauncher.launch("*/*") }, ) { Icon( painter = painterResource(id = R.drawable.attachment), @@ -295,11 +306,21 @@ fun CreateNewMessageScreen( singleLine = false, placeholderColor = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier - .defaultMinSize( - minHeight = 500.dp, - ) + .then(Modifier) + .applyIf(uiState.attachments.isEmpty()) { + defaultMinSize( + minHeight = 500.dp, + ) + } .onFocusChanged { viewModel.onMessageTextFieldFocused(it.isFocused) }, ) + if (uiState.attachments.isNotEmpty()) { + attachments( + lazyListScope = this@LazyColumn, + attachments = uiState.attachments, + onAttachmentDeleteClick = { viewModel.onAttachmentDeleteClick(it) }, + ) + } } } } @@ -371,3 +392,26 @@ private fun RecipientChipView( } } } + +private fun attachments( + lazyListScope: LazyListScope, + attachments: List, + onAttachmentDeleteClick: (AttachmentInfo) -> Unit, +) { + with(lazyListScope) { + items(attachments.size) { + Column { + if (it != 0) { + Spacer(modifier = Modifier.height(8.dp)) + } + Attachment( + attachment = attachments[it], + modifier = Modifier + .padding(horizontal = 16.dp) + .fillMaxWidth(fraction = 0.87F), + onDeleteClick = { onAttachmentDeleteClick(attachments[it]) }, + ) + } + } + } +} diff --git a/app/src/main/java/tech/relaycorp/letro/messages/compose/ComposeNewMessageViewModel.kt b/app/src/main/java/tech/relaycorp/letro/messages/compose/ComposeNewMessageViewModel.kt index 3be1a82a..f425496d 100644 --- a/app/src/main/java/tech/relaycorp/letro/messages/compose/ComposeNewMessageViewModel.kt +++ b/app/src/main/java/tech/relaycorp/letro/messages/compose/ComposeNewMessageViewModel.kt @@ -1,10 +1,12 @@ package tech.relaycorp.letro.messages.compose +import android.net.Uri import androidx.annotation.IntDef import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharedFlow @@ -15,10 +17,14 @@ import tech.relaycorp.letro.account.storage.AccountRepository import tech.relaycorp.letro.contacts.model.Contact import tech.relaycorp.letro.contacts.model.ContactPairingStatus import tech.relaycorp.letro.contacts.storage.ContactsRepository -import tech.relaycorp.letro.messages.compose.CreateNewMessageViewModel.ScreenType.Companion.NEW_CONVERSATION -import tech.relaycorp.letro.messages.compose.CreateNewMessageViewModel.ScreenType.Companion.REPLY_TO_EXISTING_CONVERSATION +import tech.relaycorp.letro.messages.compose.ComposeNewMessageViewModel.ScreenType.Companion.NEW_CONVERSATION +import tech.relaycorp.letro.messages.compose.ComposeNewMessageViewModel.ScreenType.Companion.REPLY_TO_EXISTING_CONVERSATION +import tech.relaycorp.letro.messages.filepicker.FileConverter +import tech.relaycorp.letro.messages.filepicker.model.File import tech.relaycorp.letro.messages.model.ExtendedConversation import tech.relaycorp.letro.messages.repository.ConversationsRepository +import tech.relaycorp.letro.messages.ui.AttachmentInfo +import tech.relaycorp.letro.messages.ui.utils.AttachmentInfoConverter import tech.relaycorp.letro.ui.navigation.Route import tech.relaycorp.letro.utils.ext.emitOn import tech.relaycorp.letro.utils.ext.isEmptyOrBlank @@ -26,10 +32,12 @@ import tech.relaycorp.letro.utils.ext.isNotEmptyOrBlank import javax.inject.Inject @HiltViewModel -class CreateNewMessageViewModel @Inject constructor( +class ComposeNewMessageViewModel @Inject constructor( private val accountRepository: AccountRepository, private val contactsRepository: ContactsRepository, private val conversationsRepository: ConversationsRepository, + private val fileConverter: FileConverter, + private val attachmentInfoConverter: AttachmentInfoConverter, private val savedStateHandle: SavedStateHandle, ) : ViewModel() { @@ -48,6 +56,7 @@ class CreateNewMessageViewModel @Inject constructor( showNoSubjectText = conversation != null && conversation.subject.isNullOrEmpty(), showRecipientAsChip = conversation != null, isOnlyTextEditale = conversation != null, + attachments = emptyList(), ), ) val uiState: StateFlow @@ -59,6 +68,8 @@ class CreateNewMessageViewModel @Inject constructor( val messageSentSignal: SharedFlow get() = _messageSentSignal + private val attachments = arrayListOf() + init { viewModelScope.launch { accountRepository.currentAccount.collect { @@ -74,6 +85,32 @@ class CreateNewMessageViewModel @Inject constructor( } } + fun onFilePickerResult(uri: Uri?) { + uri ?: return + viewModelScope.launch(Dispatchers.IO) { + val file = fileConverter.getFile(uri) ?: return@launch + attachments.add(file) + _uiState.update { + it.copy( + attachments = ArrayList(it.attachments).apply { + add(attachmentInfoConverter.convert(file)) + }, + ) + } + } + } + + fun onAttachmentDeleteClick(attachmentInfo: AttachmentInfo) { + viewModelScope.launch { + attachments.removeAll { it.id == attachmentInfo.fileId } + _uiState.update { + it.copy( + attachments = it.attachments.filter { it.fileId != attachmentInfo.fileId }, + ) + } + } + } + fun onRecipientTextChanged(text: String) { if (text == _uiState.value.recipientDisplayedText) { return @@ -233,4 +270,5 @@ data class NewMessageUiState( val isOnlyTextEditale: Boolean = false, val showNoSubjectText: Boolean = false, val suggestedContacts: List? = null, + val attachments: List = emptyList(), ) diff --git a/app/src/main/java/tech/relaycorp/letro/messages/filepicker/FileConverter.kt b/app/src/main/java/tech/relaycorp/letro/messages/filepicker/FileConverter.kt new file mode 100644 index 00000000..b1a14f65 --- /dev/null +++ b/app/src/main/java/tech/relaycorp/letro/messages/filepicker/FileConverter.kt @@ -0,0 +1,60 @@ +package tech.relaycorp.letro.messages.filepicker + +import android.content.ContentResolver +import android.net.Uri +import android.provider.OpenableColumns +import android.webkit.MimeTypeMap +import tech.relaycorp.letro.messages.filepicker.model.File +import tech.relaycorp.letro.messages.filepicker.model.FileExtension +import javax.inject.Inject +import kotlin.random.Random + +interface FileConverter { + suspend fun getFile(uri: Uri): File? +} + +class FileConverterImpl @Inject constructor( + private val contentResolver: ContentResolver, +) : FileConverter { + + override suspend fun getFile(uri: Uri): File? { + contentResolver.openInputStream(uri).use { + it ?: return null // TODO: log error? + val bytes = it.readBytes() + val extension = getFileExtension(uri) + val fileName = getFileName(uri) ?: UNKNOWN_FILE_NAME + return File( + id = Random.nextInt(), + name = fileName, + extension = extension, + content = bytes, + ) + } + } + + private suspend fun getFileName(uri: Uri): String? { + if (uri.scheme.equals("content")) { + contentResolver.query(uri, null, null, null, null)?.use { + val nameColumnIndex = it.getColumnIndex(OpenableColumns.DISPLAY_NAME) + if (it.moveToFirst()) { + return it.getString(nameColumnIndex) + } + } + } + // TODO: log error? + return null + } + + private suspend fun getFileExtension(uri: Uri): FileExtension = + when (MimeTypeMap.getSingleton().getExtensionFromMimeType(contentResolver.getType(uri))) { + "pdf" -> FileExtension.Pdf + "png", "jpg", "jpeg", "webp", "gif" -> FileExtension.Image + "mp3", "wav", "aac", "pcm" -> FileExtension.Audio + "mp4", "mov", "wmv", "avi" -> FileExtension.Video + else -> FileExtension.Other + } + + private companion object { + private const val UNKNOWN_FILE_NAME = "Unknown" + } +} diff --git a/app/src/main/java/tech/relaycorp/letro/messages/filepicker/model/File.kt b/app/src/main/java/tech/relaycorp/letro/messages/filepicker/model/File.kt new file mode 100644 index 00000000..ba80d8af --- /dev/null +++ b/app/src/main/java/tech/relaycorp/letro/messages/filepicker/model/File.kt @@ -0,0 +1,30 @@ +package tech.relaycorp.letro.messages.filepicker.model + +data class File( + val id: Int, + val name: String, + val extension: FileExtension, + val content: ByteArray, +) { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as File + + if (id != other.id) return false + if (name != other.name) return false + if (extension != other.extension) return false + if (!content.contentEquals(other.content)) return false + + return true + } + + override fun hashCode(): Int { + var result = id + result = 31 * result + name.hashCode() + result = 31 * result + extension.hashCode() + result = 31 * result + content.contentHashCode() + return result + } +} diff --git a/app/src/main/java/tech/relaycorp/letro/messages/filepicker/model/FileExtension.kt b/app/src/main/java/tech/relaycorp/letro/messages/filepicker/model/FileExtension.kt new file mode 100644 index 00000000..eb85ebda --- /dev/null +++ b/app/src/main/java/tech/relaycorp/letro/messages/filepicker/model/FileExtension.kt @@ -0,0 +1,23 @@ +package tech.relaycorp.letro.messages.filepicker.model + +sealed class FileExtension( + val name: String, +) { + data object Pdf : FileExtension(PDF) + + data object Image : FileExtension(IMAGE) + + data object Video : FileExtension(VIDEO) + + data object Audio : FileExtension(AUDIO) + + data object Other : FileExtension(OTHER) + + private companion object { + private const val PDF = "pdf" + private const val IMAGE = "image" + private const val VIDEO = "video" + private const val AUDIO = "audio" + private const val OTHER = "other" + } +} diff --git a/app/src/main/java/tech/relaycorp/letro/messages/ui/Attachment.kt b/app/src/main/java/tech/relaycorp/letro/messages/ui/Attachment.kt new file mode 100644 index 00000000..ca667b7a --- /dev/null +++ b/app/src/main/java/tech/relaycorp/letro/messages/ui/Attachment.kt @@ -0,0 +1,129 @@ +package tech.relaycorp.letro.messages.ui + +import androidx.annotation.DrawableRes +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import tech.relaycorp.letro.R +import tech.relaycorp.letro.ui.theme.LetroColor +import tech.relaycorp.letro.utils.ext.applyIf + +data class AttachmentInfo( + val fileId: Int, + val name: String, + val size: String, + @DrawableRes val icon: Int, +) + +@Composable +fun Attachment( + modifier: Modifier = Modifier, + attachment: AttachmentInfo, + onDeleteClick: (() -> Unit)? = null, +) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = modifier + .background(LetroColor.SurfaceContainer, RoundedCornerShape(6.dp)) + .padding( + horizontal = 16.dp, + vertical = 8.dp, + ), + ) { + Icon( + painter = painterResource(id = attachment.icon), + contentDescription = null, + ) + Spacer(modifier = Modifier.width(16.dp)) + Column( + modifier = Modifier + .then(Modifier) + .applyIf(onDeleteClick != null) { + weight(1f) + }, + ) { + Text( + text = attachment.name, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurface, + overflow = TextOverflow.Ellipsis, + maxLines = 1, + ) + Text( + text = attachment.size, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + overflow = TextOverflow.Ellipsis, + maxLines = 1, + ) + } + if (onDeleteClick != null) { + Icon( + painter = painterResource(id = R.drawable.ic_delete_attachment), + contentDescription = stringResource(id = R.string.content_description_delete_attachment), + modifier = Modifier.clickable { onDeleteClick() }, + ) + } + } +} + +@Preview(showBackground = true) +@Composable +private fun Attachment_Preview() { + Column { + Attachment( + attachment = AttachmentInfo( + name = "Short_name.pdf", + size = "126 KB", + icon = R.drawable.attachment_pdf, + fileId = 0, + ), + modifier = Modifier + .padding(horizontal = 16.dp) + .fillMaxWidth(fraction = 0.87F), + ) + Spacer(modifier = Modifier.height(8.dp)) + Attachment( + attachment = AttachmentInfo( + name = "Very long name of the file, that it can barely fit on the screen.img", + size = "126 KB", + icon = R.drawable.attachment_image, + fileId = 0, + ), + modifier = Modifier + .padding(horizontal = 16.dp) + .fillMaxWidth(fraction = 0.87F), + ) + Spacer(modifier = Modifier.height(8.dp)) + Attachment( + attachment = AttachmentInfo( + name = "Deleteable attachment with very long name of the file, that it can barely fit on the screen.img", + size = "126 KB", + icon = R.drawable.attachment_image, + fileId = 0, + ), + onDeleteClick = {}, + modifier = Modifier + .padding(horizontal = 16.dp) + .fillMaxWidth(fraction = 0.87F), + ) + } +} diff --git a/app/src/main/java/tech/relaycorp/letro/messages/ui/utils/AttachmentInfoConverter.kt b/app/src/main/java/tech/relaycorp/letro/messages/ui/utils/AttachmentInfoConverter.kt new file mode 100644 index 00000000..d93d1924 --- /dev/null +++ b/app/src/main/java/tech/relaycorp/letro/messages/ui/utils/AttachmentInfoConverter.kt @@ -0,0 +1,59 @@ +package tech.relaycorp.letro.messages.ui.utils + +import android.content.Context +import androidx.annotation.DrawableRes +import dagger.hilt.android.qualifiers.ApplicationContext +import tech.relaycorp.letro.R +import tech.relaycorp.letro.messages.filepicker.model.File +import tech.relaycorp.letro.messages.filepicker.model.FileExtension +import tech.relaycorp.letro.messages.ui.AttachmentInfo +import tech.relaycorp.letro.utils.files.bytesToKb +import tech.relaycorp.letro.utils.files.bytesToMb +import tech.relaycorp.letro.utils.files.isMoreThanKilobyte +import tech.relaycorp.letro.utils.files.isMoreThanMegabyte +import javax.inject.Inject + +interface AttachmentInfoConverter { + fun convert(file: File): AttachmentInfo +} + +class AttachmentInfoConverterImpl @Inject constructor( + @ApplicationContext private val context: Context, +) : AttachmentInfoConverter { + + override fun convert(file: File): AttachmentInfo { + return AttachmentInfo( + fileId = file.id, + name = file.name, + size = getDisplayedSize(file), + icon = getIcon(file), + ) + } + + private fun getDisplayedSize(file: File): String { + return when { + file.content.isMoreThanMegabyte() -> { + val size = file.content.size.bytesToMb() + context.getString(R.string.file_size_megabytes, String.format("%.2f", size)) + } + + file.content.isMoreThanKilobyte() -> { + val size = file.content.size.bytesToKb() + context.getString(R.string.file_size_kilobytes, String.format("%.2f", size)) + } + + else -> { + context.getString(R.string.file_size_bytes, file.content.size.toString()) + } + } + } + + @DrawableRes + private fun getIcon(file: File): Int = when (file.extension) { + FileExtension.Pdf -> R.drawable.attachment_pdf + FileExtension.Image -> R.drawable.attachment_image + FileExtension.Video -> R.drawable.attachment_video + FileExtension.Audio -> R.drawable.attachment_audio + FileExtension.Other -> R.drawable.attachment_default + } +} diff --git a/app/src/main/java/tech/relaycorp/letro/ui/navigation/LetroNavHost.kt b/app/src/main/java/tech/relaycorp/letro/ui/navigation/LetroNavHost.kt index 143eb213..43e62c50 100644 --- a/app/src/main/java/tech/relaycorp/letro/ui/navigation/LetroNavHost.kt +++ b/app/src/main/java/tech/relaycorp/letro/ui/navigation/LetroNavHost.kt @@ -42,8 +42,8 @@ import tech.relaycorp.letro.home.HomeScreen import tech.relaycorp.letro.home.HomeViewModel import tech.relaycorp.letro.home.TAB_CONTACTS import tech.relaycorp.letro.main.MainViewModel -import tech.relaycorp.letro.messages.compose.CreateNewMessageScreen -import tech.relaycorp.letro.messages.compose.CreateNewMessageViewModel +import tech.relaycorp.letro.messages.compose.ComposeNewMessageScreen +import tech.relaycorp.letro.messages.compose.ComposeNewMessageViewModel import tech.relaycorp.letro.messages.viewing.ConversationScreen import tech.relaycorp.letro.notification.NotificationClickAction import tech.relaycorp.letro.onboarding.actionTaking.ActionTakingScreen @@ -97,7 +97,7 @@ fun LetroNavHost( LaunchedEffect(Unit) { homeViewModel.createNewConversationSignal.collect { - navController.navigate(Route.CreateNewMessage.getRouteName(CreateNewMessageViewModel.ScreenType.NEW_CONVERSATION)) + navController.navigate(Route.CreateNewMessage.getRouteName(ComposeNewMessageViewModel.ScreenType.NEW_CONVERSATION)) } } @@ -285,18 +285,18 @@ fun LetroNavHost( ), ) { val screenType = it.arguments?.getInt(Route.CreateNewMessage.KEY_SCREEN_TYPE) - CreateNewMessageScreen( + ComposeNewMessageScreen( conversationsStringsProvider = stringsProvider.conversations, onBackClicked = { navController.popBackStack() }, onMessageSent = { when (screenType) { - CreateNewMessageViewModel.ScreenType.REPLY_TO_EXISTING_CONVERSATION -> { + ComposeNewMessageViewModel.ScreenType.REPLY_TO_EXISTING_CONVERSATION -> { navController.popBackStack( route = Route.Home.name, inclusive = false, ) } - CreateNewMessageViewModel.ScreenType.NEW_CONVERSATION -> { + ComposeNewMessageViewModel.ScreenType.NEW_CONVERSATION -> { navController.popBackStack() } } @@ -327,7 +327,7 @@ fun LetroNavHost( onReplyClick = { navController.navigate( route = Route.CreateNewMessage.getRouteName( - screenType = CreateNewMessageViewModel.ScreenType.REPLY_TO_EXISTING_CONVERSATION, + screenType = ComposeNewMessageViewModel.ScreenType.REPLY_TO_EXISTING_CONVERSATION, conversationId = conversationId, ), ) diff --git a/app/src/main/java/tech/relaycorp/letro/ui/navigation/Route.kt b/app/src/main/java/tech/relaycorp/letro/ui/navigation/Route.kt index 7c011f2c..429c2999 100644 --- a/app/src/main/java/tech/relaycorp/letro/ui/navigation/Route.kt +++ b/app/src/main/java/tech/relaycorp/letro/ui/navigation/Route.kt @@ -1,7 +1,7 @@ package tech.relaycorp.letro.ui.navigation import tech.relaycorp.letro.contacts.ManageContactViewModel -import tech.relaycorp.letro.messages.compose.CreateNewMessageViewModel +import tech.relaycorp.letro.messages.compose.ComposeNewMessageViewModel /** * Class which contains all possible routes @@ -87,7 +87,7 @@ sealed class Route( const val KEY_CONVERSATION_ID = "conversation_id" fun getRouteName( - @CreateNewMessageViewModel.ScreenType screenType: Int, + @ComposeNewMessageViewModel.ScreenType screenType: Int, conversationId: String? = null, ) = "${CreateNewMessage.name}?" + diff --git a/app/src/main/java/tech/relaycorp/letro/utils/files/FileSize.kt b/app/src/main/java/tech/relaycorp/letro/utils/files/FileSize.kt new file mode 100644 index 00000000..aff6e3dc --- /dev/null +++ b/app/src/main/java/tech/relaycorp/letro/utils/files/FileSize.kt @@ -0,0 +1,10 @@ +package tech.relaycorp.letro.utils.files + +fun Int.bytesToMb() = this / 1024f / 1024f +fun Int.bytesToKb() = this / 1024f + +fun ByteArray.isMoreThanMegabyte(): Boolean = + this.size.bytesToMb() >= 1 + +fun ByteArray.isMoreThanKilobyte(): Boolean = + this.size.bytesToKb() >= 1 diff --git a/app/src/main/res/drawable/attachment_audio.xml b/app/src/main/res/drawable/attachment_audio.xml new file mode 100644 index 00000000..ad5de4ee --- /dev/null +++ b/app/src/main/res/drawable/attachment_audio.xml @@ -0,0 +1,13 @@ + + + + + + diff --git a/app/src/main/res/drawable/attachment_default.xml b/app/src/main/res/drawable/attachment_default.xml new file mode 100644 index 00000000..05e9b65d --- /dev/null +++ b/app/src/main/res/drawable/attachment_default.xml @@ -0,0 +1,13 @@ + + + + + + diff --git a/app/src/main/res/drawable/attachment_image.xml b/app/src/main/res/drawable/attachment_image.xml new file mode 100644 index 00000000..a272c89e --- /dev/null +++ b/app/src/main/res/drawable/attachment_image.xml @@ -0,0 +1,13 @@ + + + + + + diff --git a/app/src/main/res/drawable/attachment_pdf.xml b/app/src/main/res/drawable/attachment_pdf.xml new file mode 100644 index 00000000..d66fc9c9 --- /dev/null +++ b/app/src/main/res/drawable/attachment_pdf.xml @@ -0,0 +1,13 @@ + + + + + + diff --git a/app/src/main/res/drawable/attachment_video.xml b/app/src/main/res/drawable/attachment_video.xml new file mode 100644 index 00000000..00b4eebf --- /dev/null +++ b/app/src/main/res/drawable/attachment_video.xml @@ -0,0 +1,13 @@ + + + + + + diff --git a/app/src/main/res/drawable/ic_delete_attachment.xml b/app/src/main/res/drawable/ic_delete_attachment.xml new file mode 100644 index 00000000..bc0c986c --- /dev/null +++ b/app/src/main/res/drawable/ic_delete_attachment.xml @@ -0,0 +1,13 @@ + + + + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index eba930ec..fc568157 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -135,8 +135,14 @@ %d new notifications + Delete attachment + james.bond@mi6.gov.uk James Bond + + %s MB + %s KB + %s B https://letro.app/en/terms \ No newline at end of file From b3b72043db8c14631216585f4fe2a0fe76ad235b Mon Sep 17 00:00:00 2001 From: migulyaev Date: Sun, 1 Oct 2023 00:01:55 +0200 Subject: [PATCH 2/2] attachments saving in a local database, display them in message view mode + open attachments --- .../1.json | 54 +++++++++++- app/src/main/AndroidManifest.xml | 11 +++ .../relaycorp/letro/di/ConversationsModule.kt | 14 +++ .../tech/relaycorp/letro/di/FileModule.kt | 14 +-- .../relaycorp/letro/main/MainViewModel.kt | 23 +++++ .../attachments/AttachmentsRepository.kt | 42 +++++++++ .../compose/ComposeNewMessageScreen.kt | 26 +++--- .../compose/ComposeNewMessageViewModel.kt | 29 +++---- .../ExtendedConversationConverter.kt | 30 ++++--- .../messages/filepicker/FileConverter.kt | 46 +++++++--- .../letro/messages/filepicker/FileSaver.kt | 33 +++++++ .../letro/messages/filepicker/model/File.kt | 85 +++++++++++++++---- .../filepicker/model/FileExtension.kt | 43 +++++++--- .../letro/messages/model/ExtendedMessage.kt | 2 + .../repository/ConversationsRepository.kt | 36 ++++++-- .../letro/messages/storage/AttachmentsDao.kt | 23 +++++ .../messages/storage/entity/Attachment.kt | 27 ++++++ .../relaycorp/letro/messages/ui/Attachment.kt | 13 ++- .../ui/utils/AttachmentInfoConverter.kt | 20 ++--- .../messages/viewing/ConversationScreen.kt | 22 +++++ .../relaycorp/letro/storage/LetroDatabase.kt | 4 + .../tech/relaycorp/letro/ui/MainActivity.kt | 21 +++++ .../letro/ui/navigation/LetroNavHost.kt | 3 + .../relaycorp/letro/utils/files/FileSize.kt | 12 +-- app/src/main/res/values/strings.xml | 1 + app/src/main/res/xml/file_paths.xml | 4 + 26 files changed, 527 insertions(+), 111 deletions(-) create mode 100644 app/src/main/java/tech/relaycorp/letro/messages/attachments/AttachmentsRepository.kt create mode 100644 app/src/main/java/tech/relaycorp/letro/messages/filepicker/FileSaver.kt create mode 100644 app/src/main/java/tech/relaycorp/letro/messages/storage/AttachmentsDao.kt create mode 100644 app/src/main/java/tech/relaycorp/letro/messages/storage/entity/Attachment.kt create mode 100644 app/src/main/res/xml/file_paths.xml diff --git a/app/schemas/tech.relaycorp.letro.storage.LetroDatabase/1.json b/app/schemas/tech.relaycorp.letro.storage.LetroDatabase/1.json index f3c68e42..7e38a1c8 100644 --- a/app/schemas/tech.relaycorp.letro.storage.LetroDatabase/1.json +++ b/app/schemas/tech.relaycorp.letro.storage.LetroDatabase/1.json @@ -2,7 +2,7 @@ "formatVersion": 1, "database": { "version": 1, - "identityHash": "1c40ad8823e534038e8cc251f7b87ade", + "identityHash": "e714cb3de1eddf34851bc19c7da0dd6f", "entities": [ { "tableName": "account", @@ -361,12 +361,62 @@ ] } ] + }, + { + "tableName": "attachments", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `fileId` BLOB NOT NULL, `path` TEXT NOT NULL, `messageId` INTEGER NOT NULL, FOREIGN KEY(`messageId`) REFERENCES `messages`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "fileId", + "columnName": "fileId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "path", + "columnName": "path", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageId", + "columnName": "messageId", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "messages", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "messageId" + ], + "referencedColumns": [ + "id" + ] + } + ] } ], "views": [], "setupQueries": [ "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", - "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '1c40ad8823e534038e8cc251f7b87ade')" + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'e714cb3de1eddf34851bc19c7da0dd6f')" ] } } \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index c98cb461..79a0e3e8 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -23,6 +23,17 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/java/tech/relaycorp/letro/di/ConversationsModule.kt b/app/src/main/java/tech/relaycorp/letro/di/ConversationsModule.kt index dc723327..de3a100d 100644 --- a/app/src/main/java/tech/relaycorp/letro/di/ConversationsModule.kt +++ b/app/src/main/java/tech/relaycorp/letro/di/ConversationsModule.kt @@ -5,6 +5,8 @@ import dagger.Module import dagger.Provides import dagger.hilt.InstallIn import dagger.hilt.components.SingletonComponent +import tech.relaycorp.letro.messages.attachments.AttachmentsRepository +import tech.relaycorp.letro.messages.attachments.AttachmentsRepositoryImpl import tech.relaycorp.letro.messages.converter.ExtendedConversationConverter import tech.relaycorp.letro.messages.converter.ExtendedConversationConverterImpl import tech.relaycorp.letro.messages.converter.MessageTimestampFormatter @@ -23,6 +25,7 @@ import tech.relaycorp.letro.messages.processor.NewMessageProcessor import tech.relaycorp.letro.messages.processor.NewMessageProcessorImpl import tech.relaycorp.letro.messages.repository.ConversationsRepository import tech.relaycorp.letro.messages.repository.ConversationsRepositoryImpl +import tech.relaycorp.letro.messages.storage.AttachmentsDao import tech.relaycorp.letro.messages.storage.ConversationsDao import tech.relaycorp.letro.messages.storage.MessagesDao import tech.relaycorp.letro.storage.LetroDatabase @@ -42,6 +45,11 @@ object ConversationsModule { letroDatabase: LetroDatabase, ): MessagesDao = letroDatabase.messagesDao() + @Provides + fun provideAttachmentsDao( + letroDatabase: LetroDatabase, + ): AttachmentsDao = letroDatabase.attachmentsDao() + @Module @InstallIn(SingletonComponent::class) interface Bindings { @@ -91,5 +99,11 @@ object ConversationsModule { fun bindOnboardingMessageManager( impl: ConversationsOnboardingManagerImpl, ): ConversationsOnboardingManager + + @Binds + @Singleton + fun bindAttachmentsRepository( + impl: AttachmentsRepositoryImpl, + ): AttachmentsRepository } } diff --git a/app/src/main/java/tech/relaycorp/letro/di/FileModule.kt b/app/src/main/java/tech/relaycorp/letro/di/FileModule.kt index 269c9348..cdcba22b 100644 --- a/app/src/main/java/tech/relaycorp/letro/di/FileModule.kt +++ b/app/src/main/java/tech/relaycorp/letro/di/FileModule.kt @@ -3,19 +3,18 @@ package tech.relaycorp.letro.di import dagger.Binds import dagger.Module import dagger.hilt.InstallIn -import dagger.hilt.android.components.ViewModelComponent -import dagger.hilt.android.scopes.ViewModelScoped +import dagger.hilt.components.SingletonComponent import tech.relaycorp.letro.messages.filepicker.FileConverter import tech.relaycorp.letro.messages.filepicker.FileConverterImpl +import tech.relaycorp.letro.messages.filepicker.FileSaver +import tech.relaycorp.letro.messages.filepicker.FileSaverImpl import tech.relaycorp.letro.messages.ui.utils.AttachmentInfoConverter import tech.relaycorp.letro.messages.ui.utils.AttachmentInfoConverterImpl @Module -@InstallIn(ViewModelComponent::class) +@InstallIn(SingletonComponent::class) interface FileModule { - @Binds - @ViewModelScoped fun bindFileConverter( impl: FileConverterImpl, ): FileConverter @@ -24,4 +23,9 @@ interface FileModule { fun bindAttachmentInfoConverter( impl: AttachmentInfoConverterImpl, ): AttachmentInfoConverter + + @Binds + fun bindFileSaver( + impl: FileSaverImpl, + ): FileSaver } diff --git a/app/src/main/java/tech/relaycorp/letro/main/MainViewModel.kt b/app/src/main/java/tech/relaycorp/letro/main/MainViewModel.kt index 46ac7d47..479a62a6 100644 --- a/app/src/main/java/tech/relaycorp/letro/main/MainViewModel.kt +++ b/app/src/main/java/tech/relaycorp/letro/main/MainViewModel.kt @@ -21,9 +21,14 @@ import tech.relaycorp.letro.account.storage.AccountRepository import tech.relaycorp.letro.awala.AwalaInitializationState import tech.relaycorp.letro.awala.AwalaManager import tech.relaycorp.letro.contacts.storage.ContactsRepository +import tech.relaycorp.letro.messages.attachments.AttachmentsRepository +import tech.relaycorp.letro.messages.filepicker.FileConverter +import tech.relaycorp.letro.messages.filepicker.model.File import tech.relaycorp.letro.push.model.PushAction import tech.relaycorp.letro.ui.navigation.RootNavigationScreen +import tech.relaycorp.letro.utils.ext.emitOn import tech.relaycorp.letro.utils.ext.sendOn +import java.util.UUID import javax.inject.Inject @HiltViewModel @@ -31,6 +36,8 @@ class MainViewModel @Inject constructor( private val awalaManager: AwalaManager, private val accountRepository: AccountRepository, private val contactsRepository: ContactsRepository, + private val attachmentsRepository: AttachmentsRepository, + private val fileConverter: FileConverter, ) : ViewModel() { private val _uiState = MutableStateFlow(MainUiState()) @@ -45,6 +52,10 @@ class MainViewModel @Inject constructor( val joinMeOnLetroSignal: SharedFlow get() = _joinMeOnLetroSignal + private val _openFileSignal = MutableSharedFlow() + val openFileSignal: SharedFlow + get() = _openFileSignal + private val _rootNavigationScreen: MutableStateFlow = MutableStateFlow(RootNavigationScreen.Splash) val rootNavigationScreen: StateFlow get() = _rootNavigationScreen @@ -118,6 +129,18 @@ class MainViewModel @Inject constructor( } } + fun onAttachmentClick(fileId: UUID) { + viewModelScope.launch { + attachmentsRepository.getById(fileId)?.let { attachment -> + fileConverter.getFile(attachment)?.let { file -> + if (file.exists()) { + _openFileSignal.emitOn(file, viewModelScope) + } + } + } + } + } + private fun getJoinMeLink(accountId: String) = "$JOIN_ME_ON_LETRO_COMMON_PART_OF_LINK$accountId" companion object { diff --git a/app/src/main/java/tech/relaycorp/letro/messages/attachments/AttachmentsRepository.kt b/app/src/main/java/tech/relaycorp/letro/messages/attachments/AttachmentsRepository.kt new file mode 100644 index 00000000..e0da96c5 --- /dev/null +++ b/app/src/main/java/tech/relaycorp/letro/messages/attachments/AttachmentsRepository.kt @@ -0,0 +1,42 @@ +package tech.relaycorp.letro.messages.attachments + +import kotlinx.coroutines.flow.Flow +import tech.relaycorp.letro.messages.filepicker.FileSaver +import tech.relaycorp.letro.messages.filepicker.model.File +import tech.relaycorp.letro.messages.storage.AttachmentsDao +import tech.relaycorp.letro.messages.storage.entity.Attachment +import java.util.UUID +import javax.inject.Inject + +interface AttachmentsRepository { + val attachments: Flow> + suspend fun saveAttachments(messageId: Long, attachments: List) + suspend fun getById(id: UUID): Attachment? +} + +class AttachmentsRepositoryImpl @Inject constructor( + private val attachmentsDao: AttachmentsDao, + private val fileSaver: FileSaver, +) : AttachmentsRepository { + + override val attachments: Flow> + get() = attachmentsDao.getAll() + + override suspend fun saveAttachments(messageId: Long, attachments: List) { + attachmentsDao.insert( + attachments + .map { file -> + val path = fileSaver.save(file) + Attachment( + fileId = file.id, + path = path, + messageId = messageId, + ) + }, + ) + } + + override suspend fun getById(id: UUID): Attachment? { + return attachmentsDao.getById(id) + } +} diff --git a/app/src/main/java/tech/relaycorp/letro/messages/compose/ComposeNewMessageScreen.kt b/app/src/main/java/tech/relaycorp/letro/messages/compose/ComposeNewMessageScreen.kt index bfd956d0..eb9768ae 100644 --- a/app/src/main/java/tech/relaycorp/letro/messages/compose/ComposeNewMessageScreen.kt +++ b/app/src/main/java/tech/relaycorp/letro/messages/compose/ComposeNewMessageScreen.kt @@ -64,6 +64,7 @@ fun ComposeNewMessageScreen( viewModel: ComposeNewMessageViewModel = hiltViewModel(), ) { val uiState by viewModel.uiState.collectAsState() + val attachments by viewModel.attachments.collectAsState() val documentPickerLauncher = rememberLauncherForActivityResult( contract = ActivityResultContracts.GetContent(), @@ -158,7 +159,7 @@ fun ComposeNewMessageScreen( .weight(1f), state = scrollState, ) { - items(1) { + item { Row( modifier = Modifier .fillMaxWidth() @@ -261,7 +262,7 @@ fun ComposeNewMessageScreen( } else -> { - items(1) { + item { Column { if (!uiState.isOnlyTextEditale) { LetroTextField( @@ -307,22 +308,22 @@ fun ComposeNewMessageScreen( placeholderColor = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier .then(Modifier) - .applyIf(uiState.attachments.isEmpty()) { + .applyIf(attachments.isEmpty()) { defaultMinSize( minHeight = 500.dp, ) } .onFocusChanged { viewModel.onMessageTextFieldFocused(it.isFocused) }, ) - if (uiState.attachments.isNotEmpty()) { - attachments( - lazyListScope = this@LazyColumn, - attachments = uiState.attachments, - onAttachmentDeleteClick = { viewModel.onAttachmentDeleteClick(it) }, - ) - } } } + if (attachments.isNotEmpty()) { + attachments( + lazyListScope = this@LazyColumn, + attachments = attachments, + onAttachmentDeleteClick = { viewModel.onAttachmentDeleteClick(it) }, + ) + } } } } @@ -399,7 +400,10 @@ private fun attachments( onAttachmentDeleteClick: (AttachmentInfo) -> Unit, ) { with(lazyListScope) { - items(attachments.size) { + items( + count = attachments.size, + key = { attachments[it].fileId }, + ) { Column { if (it != 0) { Spacer(modifier = Modifier.height(8.dp)) diff --git a/app/src/main/java/tech/relaycorp/letro/messages/compose/ComposeNewMessageViewModel.kt b/app/src/main/java/tech/relaycorp/letro/messages/compose/ComposeNewMessageViewModel.kt index f425496d..6505eaab 100644 --- a/app/src/main/java/tech/relaycorp/letro/messages/compose/ComposeNewMessageViewModel.kt +++ b/app/src/main/java/tech/relaycorp/letro/messages/compose/ComposeNewMessageViewModel.kt @@ -56,7 +56,6 @@ class ComposeNewMessageViewModel @Inject constructor( showNoSubjectText = conversation != null && conversation.subject.isNullOrEmpty(), showRecipientAsChip = conversation != null, isOnlyTextEditale = conversation != null, - attachments = emptyList(), ), ) val uiState: StateFlow @@ -68,7 +67,10 @@ class ComposeNewMessageViewModel @Inject constructor( val messageSentSignal: SharedFlow get() = _messageSentSignal - private val attachments = arrayListOf() + private val attachedFiles = arrayListOf() + private val _attachments: MutableStateFlow> = MutableStateFlow(emptyList()) + val attachments: StateFlow> + get() = _attachments init { viewModelScope.launch { @@ -89,24 +91,20 @@ class ComposeNewMessageViewModel @Inject constructor( uri ?: return viewModelScope.launch(Dispatchers.IO) { val file = fileConverter.getFile(uri) ?: return@launch - attachments.add(file) - _uiState.update { - it.copy( - attachments = ArrayList(it.attachments).apply { - add(attachmentInfoConverter.convert(file)) - }, - ) + attachedFiles.add(file) + _attachments.update { + ArrayList(it).apply { + add(attachmentInfoConverter.convert(file)) + } } } } fun onAttachmentDeleteClick(attachmentInfo: AttachmentInfo) { viewModelScope.launch { - attachments.removeAll { it.id == attachmentInfo.fileId } - _uiState.update { - it.copy( - attachments = it.attachments.filter { it.fileId != attachmentInfo.fileId }, - ) + attachedFiles.removeAll { it.id == attachmentInfo.fileId } + _attachments.update { + it.filter { it.fileId != attachmentInfo.fileId } } } } @@ -203,6 +201,7 @@ class ComposeNewMessageViewModel @Inject constructor( recipient = contact, messageText = uiState.value.messageText, subject = uiState.value.subject, + attachments = attachedFiles, ) } REPLY_TO_EXISTING_CONVERSATION -> { @@ -210,6 +209,7 @@ class ComposeNewMessageViewModel @Inject constructor( conversationsRepository.reply( conversationId = conversation.conversationId, messageText = uiState.value.messageText, + attachments = attachedFiles, ) } else -> { @@ -270,5 +270,4 @@ data class NewMessageUiState( val isOnlyTextEditale: Boolean = false, val showNoSubjectText: Boolean = false, val suggestedContacts: List? = null, - val attachments: List = emptyList(), ) diff --git a/app/src/main/java/tech/relaycorp/letro/messages/converter/ExtendedConversationConverter.kt b/app/src/main/java/tech/relaycorp/letro/messages/converter/ExtendedConversationConverter.kt index 8175cc38..f370606a 100644 --- a/app/src/main/java/tech/relaycorp/letro/messages/converter/ExtendedConversationConverter.kt +++ b/app/src/main/java/tech/relaycorp/letro/messages/converter/ExtendedConversationConverter.kt @@ -1,32 +1,39 @@ package tech.relaycorp.letro.messages.converter import tech.relaycorp.letro.contacts.model.Contact +import tech.relaycorp.letro.messages.filepicker.FileConverter import tech.relaycorp.letro.messages.model.ExtendedConversation import tech.relaycorp.letro.messages.model.ExtendedMessage +import tech.relaycorp.letro.messages.storage.entity.Attachment import tech.relaycorp.letro.messages.storage.entity.Conversation import tech.relaycorp.letro.messages.storage.entity.Message +import tech.relaycorp.letro.messages.ui.utils.AttachmentInfoConverter import java.sql.Timestamp import java.time.ZoneOffset import java.util.UUID import javax.inject.Inject interface ExtendedConversationConverter { - fun convert( + suspend fun convert( conversations: List, messages: List, contacts: List, + attachments: List, ownerVeraId: String, ): List } class ExtendedConversationConverterImpl @Inject constructor( private val messageTimestampFormatter: MessageTimestampFormatter, + private val fileConverter: FileConverter, + private val attachmentInfoConverter: AttachmentInfoConverter, ) : ExtendedConversationConverter { - override fun convert( + override suspend fun convert( conversations: List, messages: List, contacts: List, + attachments: List, ownerVeraId: String, ): List { val conversationsMap = hashMapOf() @@ -52,19 +59,20 @@ class ExtendedConversationConverterImpl @Inject constructor( val lastMessage = messagesToConversation[conversation.conversationId]!!.last() val extendedMessagesList = sortedMessages .filter { it.conversationId == conversation.conversationId } - .map { - val isOutgoing = ownerVeraId == it.senderVeraId + .map { message -> + val isOutgoing = ownerVeraId == message.senderVeraId ExtendedMessage( conversationId = conversation.conversationId, - senderVeraId = it.senderVeraId, - recipientVeraId = it.recipientVeraId, - senderDisplayName = if (isOutgoing) it.ownerVeraId else contactDisplayName, - recipientDisplayName = if (isOutgoing) contactDisplayName else it.ownerVeraId, + senderVeraId = message.senderVeraId, + recipientVeraId = message.recipientVeraId, + senderDisplayName = if (isOutgoing) message.ownerVeraId else contactDisplayName, + recipientDisplayName = if (isOutgoing) contactDisplayName else message.ownerVeraId, isOutgoing = isOutgoing, contactDisplayName = contactDisplayName, - text = it.text, - sentAtBriefFormatted = messageTimestampFormatter.formatBrief(it.sentAt), - sentAtDetailedFormatted = messageTimestampFormatter.formatDetailed(it.sentAt), + text = message.text, + sentAtBriefFormatted = messageTimestampFormatter.formatBrief(message.sentAt), + sentAtDetailedFormatted = messageTimestampFormatter.formatDetailed(message.sentAt), + attachments = attachments.filter { it.messageId == message.id }.mapNotNull { fileConverter.getFile(it)?.let { attachmentInfoConverter.convert(it) } }, ) } ExtendedConversation( diff --git a/app/src/main/java/tech/relaycorp/letro/messages/filepicker/FileConverter.kt b/app/src/main/java/tech/relaycorp/letro/messages/filepicker/FileConverter.kt index b1a14f65..d97d4415 100644 --- a/app/src/main/java/tech/relaycorp/letro/messages/filepicker/FileConverter.kt +++ b/app/src/main/java/tech/relaycorp/letro/messages/filepicker/FileConverter.kt @@ -4,34 +4,52 @@ import android.content.ContentResolver import android.net.Uri import android.provider.OpenableColumns import android.webkit.MimeTypeMap +import androidx.core.net.toUri import tech.relaycorp.letro.messages.filepicker.model.File import tech.relaycorp.letro.messages.filepicker.model.FileExtension +import tech.relaycorp.letro.messages.storage.entity.Attachment +import java.util.UUID import javax.inject.Inject -import kotlin.random.Random interface FileConverter { - suspend fun getFile(uri: Uri): File? + suspend fun getFile(uri: Uri): File.FileWithContent? + suspend fun getFile(attachment: Attachment): File.FileWithoutContent? } class FileConverterImpl @Inject constructor( private val contentResolver: ContentResolver, ) : FileConverter { - override suspend fun getFile(uri: Uri): File? { + override suspend fun getFile(uri: Uri): File.FileWithContent? { contentResolver.openInputStream(uri).use { it ?: return null // TODO: log error? val bytes = it.readBytes() val extension = getFileExtension(uri) val fileName = getFileName(uri) ?: UNKNOWN_FILE_NAME - return File( - id = Random.nextInt(), + return File.FileWithContent( + id = UUID.randomUUID(), name = fileName, extension = extension, + size = bytes.size.toLong(), content = bytes, ) } } + override suspend fun getFile(attachment: Attachment): File.FileWithoutContent? { + val file = java.io.File(attachment.path) + if (!file.exists()) { + return null + } + return File.FileWithoutContent( + id = attachment.fileId, + name = file.name, + extension = getFileExtension(file.toUri()), + size = file.length(), + path = file.absolutePath, + ) + } + private suspend fun getFileName(uri: Uri): String? { if (uri.scheme.equals("content")) { contentResolver.query(uri, null, null, null, null)?.use { @@ -45,14 +63,18 @@ class FileConverterImpl @Inject constructor( return null } - private suspend fun getFileExtension(uri: Uri): FileExtension = - when (MimeTypeMap.getSingleton().getExtensionFromMimeType(contentResolver.getType(uri))) { - "pdf" -> FileExtension.Pdf - "png", "jpg", "jpeg", "webp", "gif" -> FileExtension.Image - "mp3", "wav", "aac", "pcm" -> FileExtension.Audio - "mp4", "mov", "wmv", "avi" -> FileExtension.Video - else -> FileExtension.Other + private fun getFileExtension(uri: Uri): FileExtension { + return when (MimeTypeMap.getSingleton().getExtensionFromMimeType(contentResolver.getType(uri))) { + "pdf" -> FileExtension.Pdf() + "png", "jpg", "jpeg", "webp", "gif" -> FileExtension.Image() + "mp3", "wav", "aac", "pcm" -> FileExtension.Audio() + "mp4", "mov", "wmv", "avi" -> FileExtension.Video() + else -> { + val extension = MimeTypeMap.getFileExtensionFromUrl(uri.toString()) + FileExtension.fromMimeType(MimeTypeMap.getSingleton().getMimeTypeFromExtension(extension)) + } } + } private companion object { private const val UNKNOWN_FILE_NAME = "Unknown" diff --git a/app/src/main/java/tech/relaycorp/letro/messages/filepicker/FileSaver.kt b/app/src/main/java/tech/relaycorp/letro/messages/filepicker/FileSaver.kt new file mode 100644 index 00000000..d96918c5 --- /dev/null +++ b/app/src/main/java/tech/relaycorp/letro/messages/filepicker/FileSaver.kt @@ -0,0 +1,33 @@ +package tech.relaycorp.letro.messages.filepicker + +import android.content.Context +import dagger.hilt.android.qualifiers.ApplicationContext +import tech.relaycorp.letro.messages.filepicker.model.File +import javax.inject.Inject + +interface FileSaver { + fun save(file: File.FileWithContent): String +} + +class FileSaverImpl @Inject constructor( + @ApplicationContext private val context: Context, +) : FileSaver { + + override fun save(file: File.FileWithContent): String { + val fileOutput = getFileOutput(file) + fileOutput.writeBytes(file.content) + return fileOutput.absolutePath + } + + private fun getFileOutput(file: File.FileWithContent): java.io.File { + var fileName = file.name + var duplicates = 1 + while (java.io.File(context.filesDir, fileName).exists()) { + fileName = "${duplicates}_${file.name}" + duplicates++ + } + return java.io.File(context.filesDir, fileName).apply { + createNewFile() + } + } +} diff --git a/app/src/main/java/tech/relaycorp/letro/messages/filepicker/model/File.kt b/app/src/main/java/tech/relaycorp/letro/messages/filepicker/model/File.kt index ba80d8af..5f3357c7 100644 --- a/app/src/main/java/tech/relaycorp/letro/messages/filepicker/model/File.kt +++ b/app/src/main/java/tech/relaycorp/letro/messages/filepicker/model/File.kt @@ -1,30 +1,79 @@ package tech.relaycorp.letro.messages.filepicker.model -data class File( - val id: Int, +import java.util.UUID + +sealed class File( + val id: UUID, val name: String, val extension: FileExtension, - val content: ByteArray, + val size: Long, ) { - override fun equals(other: Any?): Boolean { - if (this === other) return true - if (javaClass != other?.javaClass) return false - other as File + class FileWithContent( + id: UUID, + name: String, + extension: FileExtension, + size: Long, + val content: ByteArray, + ) : File(id, name, extension, size) { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as FileWithContent + + if (id != other.id) return false + if (name != other.name) return false + if (extension != other.extension) return false + if (size != other.size) return false + if (!content.contentEquals(other.content)) return false - if (id != other.id) return false - if (name != other.name) return false - if (extension != other.extension) return false - if (!content.contentEquals(other.content)) return false + return true + } - return true + override fun hashCode(): Int { + var result = id.hashCode() + result = 31 * result + name.hashCode() + result = 31 * result + extension.hashCode() + result = 31 * result + size.hashCode() + result = 31 * result + content.contentHashCode() + return result + } } - override fun hashCode(): Int { - var result = id - result = 31 * result + name.hashCode() - result = 31 * result + extension.hashCode() - result = 31 * result + content.contentHashCode() - return result + class FileWithoutContent( + id: UUID, + name: String, + extension: FileExtension, + size: Long, + val path: String, + ) : File(id, name, extension, size) { + + fun exists() = toFile().exists() + fun toFile() = java.io.File(path) + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as FileWithoutContent + + if (id != other.id) return false + if (name != other.name) return false + if (extension != other.extension) return false + if (size != other.size) return false + if (path != other.path) return false + + return true + } + + override fun hashCode(): Int { + var result = id.hashCode() + result = 31 * result + name.hashCode() + result = 31 * result + extension.hashCode() + result = 31 * result + size.hashCode() + result = 31 * result + path.hashCode() + return result + } } } diff --git a/app/src/main/java/tech/relaycorp/letro/messages/filepicker/model/FileExtension.kt b/app/src/main/java/tech/relaycorp/letro/messages/filepicker/model/FileExtension.kt index eb85ebda..ff167a2c 100644 --- a/app/src/main/java/tech/relaycorp/letro/messages/filepicker/model/FileExtension.kt +++ b/app/src/main/java/tech/relaycorp/letro/messages/filepicker/model/FileExtension.kt @@ -1,23 +1,42 @@ package tech.relaycorp.letro.messages.filepicker.model sealed class FileExtension( - val name: String, + val mimeType: String, ) { - data object Pdf : FileExtension(PDF) + class Pdf( + mimeType: String = PDF, + ) : FileExtension(mimeType) - data object Image : FileExtension(IMAGE) + class Image( + mimeType: String = IMAGE, + ) : FileExtension(mimeType) - data object Video : FileExtension(VIDEO) + class Video( + mimeType: String = VIDEO, + ) : FileExtension(mimeType) - data object Audio : FileExtension(AUDIO) + class Audio( + mimeType: String = AUDIO, + ) : FileExtension(mimeType) - data object Other : FileExtension(OTHER) + class Other( + mimeType: String = OTHER, + ) : FileExtension(mimeType) - private companion object { - private const val PDF = "pdf" - private const val IMAGE = "image" - private const val VIDEO = "video" - private const val AUDIO = "audio" - private const val OTHER = "other" + companion object { + private const val PDF = "application/pdf" + private const val IMAGE = "image/*" + private const val VIDEO = "video/*" + private const val AUDIO = "audio/*" + private const val OTHER = "*/*" + + fun fromMimeType(mimeType: String?) = when { + mimeType == null -> Other() + mimeType.startsWith("application/pdf") -> Pdf(mimeType) + mimeType.startsWith("image/") -> Image(mimeType) + mimeType.startsWith("video/") -> Video(mimeType) + mimeType.startsWith("audio/") -> Audio(mimeType) + else -> Other(mimeType) + } } } diff --git a/app/src/main/java/tech/relaycorp/letro/messages/model/ExtendedMessage.kt b/app/src/main/java/tech/relaycorp/letro/messages/model/ExtendedMessage.kt index 8b78eaf4..d07d8d9c 100644 --- a/app/src/main/java/tech/relaycorp/letro/messages/model/ExtendedMessage.kt +++ b/app/src/main/java/tech/relaycorp/letro/messages/model/ExtendedMessage.kt @@ -1,5 +1,6 @@ package tech.relaycorp.letro.messages.model +import tech.relaycorp.letro.messages.ui.AttachmentInfo import java.util.UUID data class ExtendedMessage( @@ -13,4 +14,5 @@ data class ExtendedMessage( val text: String, val sentAtBriefFormatted: String, val sentAtDetailedFormatted: String, + val attachments: List = emptyList(), ) diff --git a/app/src/main/java/tech/relaycorp/letro/messages/repository/ConversationsRepository.kt b/app/src/main/java/tech/relaycorp/letro/messages/repository/ConversationsRepository.kt index 2163f69c..a259b9af 100644 --- a/app/src/main/java/tech/relaycorp/letro/messages/repository/ConversationsRepository.kt +++ b/app/src/main/java/tech/relaycorp/letro/messages/repository/ConversationsRepository.kt @@ -19,7 +19,9 @@ import tech.relaycorp.letro.awala.message.MessageRecipient import tech.relaycorp.letro.awala.message.MessageType import tech.relaycorp.letro.contacts.model.Contact import tech.relaycorp.letro.contacts.storage.ContactsRepository +import tech.relaycorp.letro.messages.attachments.AttachmentsRepository import tech.relaycorp.letro.messages.converter.ExtendedConversationConverter +import tech.relaycorp.letro.messages.filepicker.model.File import tech.relaycorp.letro.messages.model.ExtendedConversation import tech.relaycorp.letro.messages.parser.OutgoingMessageMessageEncoder import tech.relaycorp.letro.messages.storage.ConversationsDao @@ -37,10 +39,12 @@ interface ConversationsRepository { recipient: Contact, messageText: String, subject: String? = null, + attachments: List = emptyList(), ) fun reply( conversationId: UUID, messageText: String, + attachments: List = emptyList(), ) fun getConversation(id: String): ExtendedConversation? fun getConversationFlow(scope: CoroutineScope, id: String): StateFlow @@ -55,6 +59,7 @@ interface ConversationsRepository { class ConversationsRepositoryImpl @Inject constructor( private val conversationsDao: ConversationsDao, private val messagesDao: MessagesDao, + private val attachmentsRepository: AttachmentsRepository, private val contactsRepository: ContactsRepository, private val accountRepository: AccountRepository, private val conversationsConverter: ExtendedConversationConverter, @@ -109,6 +114,7 @@ class ConversationsRepositoryImpl @Inject constructor( recipient: Contact, messageText: String, subject: String?, + attachments: List, ) { val recipientNodeId = recipient.contactEndpointId ?: return scope.launch { @@ -127,7 +133,11 @@ class ConversationsRepositoryImpl @Inject constructor( sentAt = LocalDateTime.now(), ) conversationsDao.createNewConversation(conversation) - messagesDao.insert(message) + val messageId = messagesDao.insert(message) + + if (attachments.isNotEmpty()) { + attachmentsRepository.saveAttachments(messageId, attachments) + } awalaManager.sendMessage( outgoingMessage = AwalaOutgoingMessage( @@ -144,7 +154,11 @@ class ConversationsRepositoryImpl @Inject constructor( } } - override fun reply(conversationId: UUID, messageText: String) { + override fun reply( + conversationId: UUID, + messageText: String, + attachments: List, + ) { scope.launch { val conversation = _conversations.value .find { it.conversationId == conversationId } ?: return@launch @@ -159,7 +173,12 @@ class ConversationsRepositoryImpl @Inject constructor( recipientVeraId = conversation.contactVeraId, sentAt = LocalDateTime.now(), ) - messagesDao.insert(message) + + val messageId = messagesDao.insert(message) + if (attachments.isNotEmpty()) { + attachmentsRepository.saveAttachments(messageId, attachments) + } + awalaManager.sendMessage( outgoingMessage = AwalaOutgoingMessage( type = MessageType.NewMessage, @@ -222,14 +241,21 @@ class ConversationsRepositoryImpl @Inject constructor( combine( conversationsDao.getAll(), messagesDao.getAll(), + attachmentsRepository.attachments, contacts, - ) { conversations, messages, contacts -> + ) { conversations, messages, attachments, contacts -> + _conversations.emit(conversations) + + val messagesOfCurrentAccount = messages.filter { it.ownerVeraId == account.accountId } + val messageIdsOfCurrentAccount = messagesOfCurrentAccount.map { it.id }.toSet() + _extendedConversations.emit( conversationsConverter.convert( conversations = conversations.filter { it.ownerVeraId == account.accountId }, - messages = messages.filter { it.ownerVeraId == account.accountId }, + messages = messagesOfCurrentAccount, contacts = contacts.filter { it.ownerVeraId == account.accountId }, + attachments = attachments.filter { messageIdsOfCurrentAccount.contains(it.messageId) }, ownerVeraId = account.accountId, ), ) diff --git a/app/src/main/java/tech/relaycorp/letro/messages/storage/AttachmentsDao.kt b/app/src/main/java/tech/relaycorp/letro/messages/storage/AttachmentsDao.kt new file mode 100644 index 00000000..87bea78d --- /dev/null +++ b/app/src/main/java/tech/relaycorp/letro/messages/storage/AttachmentsDao.kt @@ -0,0 +1,23 @@ +package tech.relaycorp.letro.messages.storage + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import kotlinx.coroutines.flow.Flow +import tech.relaycorp.letro.messages.storage.entity.Attachment +import tech.relaycorp.letro.messages.storage.entity.TABLE_NAME_ATTACHMENTS +import java.util.UUID + +@Dao +interface AttachmentsDao { + + @Query("SELECT * FROM $TABLE_NAME_ATTACHMENTS") + fun getAll(): Flow> + + @Insert(onConflict = OnConflictStrategy.REPLACE) + fun insert(attachments: List) + + @Query("SELECT * FROM $TABLE_NAME_ATTACHMENTS where fileId=:id") + suspend fun getById(id: UUID): Attachment? +} diff --git a/app/src/main/java/tech/relaycorp/letro/messages/storage/entity/Attachment.kt b/app/src/main/java/tech/relaycorp/letro/messages/storage/entity/Attachment.kt new file mode 100644 index 00000000..7209b3be --- /dev/null +++ b/app/src/main/java/tech/relaycorp/letro/messages/storage/entity/Attachment.kt @@ -0,0 +1,27 @@ +package tech.relaycorp.letro.messages.storage.entity + +import androidx.room.Entity +import androidx.room.ForeignKey +import androidx.room.PrimaryKey +import java.util.UUID + +const val TABLE_NAME_ATTACHMENTS = "attachments" + +@Entity( + tableName = TABLE_NAME_ATTACHMENTS, + foreignKeys = [ + ForeignKey( + entity = Message::class, + parentColumns = ["id"], + childColumns = ["messageId"], + onDelete = ForeignKey.CASCADE, + ), + ], +) +data class Attachment( + @PrimaryKey(autoGenerate = true) + val id: Long = 0L, + val fileId: UUID, + val path: String, + val messageId: Long, +) diff --git a/app/src/main/java/tech/relaycorp/letro/messages/ui/Attachment.kt b/app/src/main/java/tech/relaycorp/letro/messages/ui/Attachment.kt index ca667b7a..9f2a03fc 100644 --- a/app/src/main/java/tech/relaycorp/letro/messages/ui/Attachment.kt +++ b/app/src/main/java/tech/relaycorp/letro/messages/ui/Attachment.kt @@ -25,9 +25,10 @@ import androidx.compose.ui.unit.dp import tech.relaycorp.letro.R import tech.relaycorp.letro.ui.theme.LetroColor import tech.relaycorp.letro.utils.ext.applyIf +import java.util.UUID data class AttachmentInfo( - val fileId: Int, + val fileId: UUID, val name: String, val size: String, @DrawableRes val icon: Int, @@ -37,12 +38,16 @@ data class AttachmentInfo( fun Attachment( modifier: Modifier = Modifier, attachment: AttachmentInfo, + onAttachmentClick: (() -> Unit)? = null, onDeleteClick: (() -> Unit)? = null, ) { Row( verticalAlignment = Alignment.CenterVertically, modifier = modifier .background(LetroColor.SurfaceContainer, RoundedCornerShape(6.dp)) + .applyIf(onAttachmentClick != null) { + clickable { onAttachmentClick?.invoke() } + } .padding( horizontal = 16.dp, vertical = 8.dp, @@ -94,7 +99,7 @@ private fun Attachment_Preview() { name = "Short_name.pdf", size = "126 KB", icon = R.drawable.attachment_pdf, - fileId = 0, + fileId = UUID.randomUUID(), ), modifier = Modifier .padding(horizontal = 16.dp) @@ -106,7 +111,7 @@ private fun Attachment_Preview() { name = "Very long name of the file, that it can barely fit on the screen.img", size = "126 KB", icon = R.drawable.attachment_image, - fileId = 0, + fileId = UUID.randomUUID(), ), modifier = Modifier .padding(horizontal = 16.dp) @@ -118,7 +123,7 @@ private fun Attachment_Preview() { name = "Deleteable attachment with very long name of the file, that it can barely fit on the screen.img", size = "126 KB", icon = R.drawable.attachment_image, - fileId = 0, + fileId = UUID.randomUUID(), ), onDeleteClick = {}, modifier = Modifier diff --git a/app/src/main/java/tech/relaycorp/letro/messages/ui/utils/AttachmentInfoConverter.kt b/app/src/main/java/tech/relaycorp/letro/messages/ui/utils/AttachmentInfoConverter.kt index d93d1924..ff5d38fb 100644 --- a/app/src/main/java/tech/relaycorp/letro/messages/ui/utils/AttachmentInfoConverter.kt +++ b/app/src/main/java/tech/relaycorp/letro/messages/ui/utils/AttachmentInfoConverter.kt @@ -32,28 +32,28 @@ class AttachmentInfoConverterImpl @Inject constructor( private fun getDisplayedSize(file: File): String { return when { - file.content.isMoreThanMegabyte() -> { - val size = file.content.size.bytesToMb() + file.size.isMoreThanMegabyte() -> { + val size = file.size.bytesToMb() context.getString(R.string.file_size_megabytes, String.format("%.2f", size)) } - file.content.isMoreThanKilobyte() -> { - val size = file.content.size.bytesToKb() + file.size.isMoreThanKilobyte() -> { + val size = file.size.bytesToKb() context.getString(R.string.file_size_kilobytes, String.format("%.2f", size)) } else -> { - context.getString(R.string.file_size_bytes, file.content.size.toString()) + context.getString(R.string.file_size_bytes, file.size.toString()) } } } @DrawableRes private fun getIcon(file: File): Int = when (file.extension) { - FileExtension.Pdf -> R.drawable.attachment_pdf - FileExtension.Image -> R.drawable.attachment_image - FileExtension.Video -> R.drawable.attachment_video - FileExtension.Audio -> R.drawable.attachment_audio - FileExtension.Other -> R.drawable.attachment_default + is FileExtension.Pdf -> R.drawable.attachment_pdf + is FileExtension.Image -> R.drawable.attachment_image + is FileExtension.Video -> R.drawable.attachment_video + is FileExtension.Audio -> R.drawable.attachment_audio + is FileExtension.Other -> R.drawable.attachment_default } } diff --git a/app/src/main/java/tech/relaycorp/letro/messages/viewing/ConversationScreen.kt b/app/src/main/java/tech/relaycorp/letro/messages/viewing/ConversationScreen.kt index 1f50a652..bbd64427 100644 --- a/app/src/main/java/tech/relaycorp/letro/messages/viewing/ConversationScreen.kt +++ b/app/src/main/java/tech/relaycorp/letro/messages/viewing/ConversationScreen.kt @@ -40,6 +40,8 @@ import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import tech.relaycorp.letro.R import tech.relaycorp.letro.messages.model.ExtendedMessage +import tech.relaycorp.letro.messages.ui.Attachment +import tech.relaycorp.letro.messages.ui.AttachmentInfo import tech.relaycorp.letro.ui.common.LetroButton import tech.relaycorp.letro.ui.theme.Elevation2 import tech.relaycorp.letro.ui.theme.LabelLargeProminent @@ -55,6 +57,7 @@ fun ConversationScreen( onConversationDeleted: () -> Unit, onConversationArchived: (Boolean) -> Unit, onBackClicked: () -> Unit, + onAttachmentClick: (UUID) -> Unit, viewModel: ConversationViewModel = hiltViewModel(), ) { val scrollState = rememberLazyListState() @@ -119,6 +122,7 @@ fun ConversationScreen( message = message, isCollapsable = conversation.messages.size > 1, isLastMessage = isLastMessage, + onAttachmentClick = { onAttachmentClick(it.fileId) }, ) if (!isLastMessage) { Divider( @@ -140,6 +144,7 @@ private fun Message( message: ExtendedMessage, isCollapsable: Boolean, isLastMessage: Boolean, + onAttachmentClick: (AttachmentInfo) -> Unit, ) { var isCollapsed: Boolean by remember { mutableStateOf(!isLastMessage) } var isDetailsCollapsed: Boolean by remember { mutableStateOf(true) } @@ -213,6 +218,23 @@ private fun Message( horizontal = 16.dp, ), ) + if (!isCollapsed) { + Column { + Spacer(modifier = Modifier.height(16.dp)) + message.attachments.forEachIndexed { index, attachment -> + if (index != 0) { + Spacer(modifier = Modifier.height(8.dp)) + } + Attachment( + modifier = Modifier + .padding(horizontal = 16.dp) + .fillMaxWidth(fraction = 0.73F), + attachment = attachment, + onAttachmentClick = { onAttachmentClick(attachment) }, + ) + } + } + } } } diff --git a/app/src/main/java/tech/relaycorp/letro/storage/LetroDatabase.kt b/app/src/main/java/tech/relaycorp/letro/storage/LetroDatabase.kt index bebc5254..924ff208 100644 --- a/app/src/main/java/tech/relaycorp/letro/storage/LetroDatabase.kt +++ b/app/src/main/java/tech/relaycorp/letro/storage/LetroDatabase.kt @@ -7,8 +7,10 @@ import tech.relaycorp.letro.account.model.Account import tech.relaycorp.letro.account.storage.AccountDao import tech.relaycorp.letro.contacts.model.Contact import tech.relaycorp.letro.contacts.storage.ContactsDao +import tech.relaycorp.letro.messages.storage.AttachmentsDao import tech.relaycorp.letro.messages.storage.ConversationsDao import tech.relaycorp.letro.messages.storage.MessagesDao +import tech.relaycorp.letro.messages.storage.entity.Attachment import tech.relaycorp.letro.messages.storage.entity.Conversation import tech.relaycorp.letro.messages.storage.entity.Message import tech.relaycorp.letro.notification.storage.dao.NotificationsDao @@ -22,6 +24,7 @@ import tech.relaycorp.letro.storage.converter.LocalDateTimeConverter Conversation::class, Message::class, Notification::class, + Attachment::class, ], version = 1, exportSchema = true, @@ -35,4 +38,5 @@ abstract class LetroDatabase : RoomDatabase() { abstract fun conversationsDao(): ConversationsDao abstract fun messagesDao(): MessagesDao abstract fun notificationsDao(): NotificationsDao + abstract fun attachmentsDao(): AttachmentsDao } diff --git a/app/src/main/java/tech/relaycorp/letro/ui/MainActivity.kt b/app/src/main/java/tech/relaycorp/letro/ui/MainActivity.kt index 199f2950..b133a4e3 100644 --- a/app/src/main/java/tech/relaycorp/letro/ui/MainActivity.kt +++ b/app/src/main/java/tech/relaycorp/letro/ui/MainActivity.kt @@ -13,6 +13,7 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.ui.Modifier +import androidx.core.content.FileProvider import androidx.lifecycle.lifecycleScope import androidx.navigation.compose.rememberNavController import dagger.hilt.android.AndroidEntryPoint @@ -119,5 +120,25 @@ class MainActivity : ComponentActivity() { } } } + lifecycleScope.launch(Dispatchers.Main) { + viewModel.openFileSignal.collect { file -> + try { + startActivity( + Intent(Intent.ACTION_VIEW).apply { + flags = Intent.FLAG_GRANT_READ_URI_PERMISSION + setDataAndType(FileProvider.getUriForFile(this@MainActivity, AUTHORITY, file.toFile()), file.extension.mimeType) + }, + ) + } catch (e: ActivityNotFoundException) { + Toast + .makeText(this@MainActivity, R.string.no_app_to_open_file, Toast.LENGTH_SHORT) + .show() + } + } + } + } + + private companion object { + private const val AUTHORITY = "tech.relaycorp.letro.provider" } } diff --git a/app/src/main/java/tech/relaycorp/letro/ui/navigation/LetroNavHost.kt b/app/src/main/java/tech/relaycorp/letro/ui/navigation/LetroNavHost.kt index 43e62c50..6fc3b04e 100644 --- a/app/src/main/java/tech/relaycorp/letro/ui/navigation/LetroNavHost.kt +++ b/app/src/main/java/tech/relaycorp/letro/ui/navigation/LetroNavHost.kt @@ -335,6 +335,9 @@ fun LetroNavHost( onBackClicked = { navController.popBackStack() }, + onAttachmentClick = { fileId -> + mainViewModel.onAttachmentClick(fileId) + }, ) } } diff --git a/app/src/main/java/tech/relaycorp/letro/utils/files/FileSize.kt b/app/src/main/java/tech/relaycorp/letro/utils/files/FileSize.kt index aff6e3dc..a5c60608 100644 --- a/app/src/main/java/tech/relaycorp/letro/utils/files/FileSize.kt +++ b/app/src/main/java/tech/relaycorp/letro/utils/files/FileSize.kt @@ -1,10 +1,10 @@ package tech.relaycorp.letro.utils.files -fun Int.bytesToMb() = this / 1024f / 1024f -fun Int.bytesToKb() = this / 1024f +fun Long.bytesToMb() = this / 1024f / 1024f +fun Long.bytesToKb() = this / 1024f -fun ByteArray.isMoreThanMegabyte(): Boolean = - this.size.bytesToMb() >= 1 +fun Long.isMoreThanMegabyte(): Boolean = + bytesToMb() >= 1 -fun ByteArray.isMoreThanKilobyte(): Boolean = - this.size.bytesToKb() >= 1 +fun Long.isMoreThanKilobyte(): Boolean = + bytesToKb() >= 1 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index fc568157..539e258f 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -42,6 +42,7 @@ There is no app to install Awala There is no app to share your id + There is no app to open the file You already tried to connect with them, but we can send another request if you want. You two are already connected. diff --git a/app/src/main/res/xml/file_paths.xml b/app/src/main/res/xml/file_paths.xml new file mode 100644 index 00000000..d3866710 --- /dev/null +++ b/app/src/main/res/xml/file_paths.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file