diff --git a/WordPress/src/main/java/org/wordpress/android/support/he/model/AttachmentState.kt b/WordPress/src/main/java/org/wordpress/android/support/he/model/AttachmentState.kt new file mode 100644 index 000000000000..c0e915cd19e2 --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/support/he/model/AttachmentState.kt @@ -0,0 +1,10 @@ +package org.wordpress.android.support.he.model + +import android.net.Uri + +data class AttachmentState( + val acceptedUris: List = emptyList(), + val rejectedUris: List = emptyList(), + val currentTotalSizeBytes: Long = 0L, + val rejectedTotalSizeBytes: Long = 0L +) diff --git a/WordPress/src/main/java/org/wordpress/android/support/he/model/MessageSendResult.kt b/WordPress/src/main/java/org/wordpress/android/support/he/model/MessageSendResult.kt new file mode 100644 index 000000000000..f19acf6af7a6 --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/support/he/model/MessageSendResult.kt @@ -0,0 +1,6 @@ +package org.wordpress.android.support.he.model + +sealed class MessageSendResult { + data object Success : MessageSendResult() + data object Failure : MessageSendResult() +} diff --git a/WordPress/src/main/java/org/wordpress/android/support/he/ui/HEConversationDetailScreen.kt b/WordPress/src/main/java/org/wordpress/android/support/he/ui/HEConversationDetailScreen.kt index f70b8d7760d4..61cbab50d7b6 100644 --- a/WordPress/src/main/java/org/wordpress/android/support/he/ui/HEConversationDetailScreen.kt +++ b/WordPress/src/main/java/org/wordpress/android/support/he/ui/HEConversationDetailScreen.kt @@ -60,6 +60,8 @@ import coil.request.ImageRequest import coil.request.videoFrameMillis import org.wordpress.android.R import org.wordpress.android.support.aibot.util.formatRelativeTime +import org.wordpress.android.support.he.model.AttachmentState +import org.wordpress.android.support.he.model.MessageSendResult import org.wordpress.android.support.he.model.AttachmentType import org.wordpress.android.support.he.model.SupportAttachment import org.wordpress.android.support.he.model.SupportConversation @@ -77,11 +79,11 @@ fun HEConversationDetailScreen( conversation: SupportConversation, isLoading: Boolean = false, isSendingMessage: Boolean = false, - messageSendResult: HESupportViewModel.MessageSendResult? = null, + messageSendResult: MessageSendResult? = null, onBackClick: () -> Unit, onSendMessage: (message: String, includeAppLogs: Boolean) -> Unit, onClearMessageSendResult: () -> Unit = {}, - attachments: List = emptyList(), + attachmentState: AttachmentState = AttachmentState(), attachmentActionsListener: AttachmentActionsListener, onDownloadAttachment: (SupportAttachment) -> Unit = {}, videoUrlResolver: org.wordpress.android.support.he.util.VideoUrlResolver? = null @@ -178,7 +180,7 @@ fun HEConversationDetailScreen( LaunchedEffect(messageSendResult) { if (messageSendResult != null) { // Clear draft only on success - if (messageSendResult is HESupportViewModel.MessageSendResult.Success) { + if (messageSendResult is MessageSendResult.Success) { draftMessageText = "" draftIncludeAppLogs = false } @@ -211,7 +213,13 @@ fun HEConversationDetailScreen( draftMessageText = message onSendMessage(message, includeAppLogs) }, - attachments = attachments, + onMessageSentSuccessfully = { + // Clear draft after successful send + draftMessageText = "" + draftIncludeAppLogs = false + onClearMessageSendResult() + }, + attachmentState = attachmentState, attachmentActionsListener = attachmentActionsListener ) } diff --git a/WordPress/src/main/java/org/wordpress/android/support/he/ui/HEConversationReplyBottomSheet.kt b/WordPress/src/main/java/org/wordpress/android/support/he/ui/HEConversationReplyBottomSheet.kt index a57b11e0ea8a..efe68b6a1acf 100644 --- a/WordPress/src/main/java/org/wordpress/android/support/he/ui/HEConversationReplyBottomSheet.kt +++ b/WordPress/src/main/java/org/wordpress/android/support/he/ui/HEConversationReplyBottomSheet.kt @@ -1,6 +1,5 @@ package org.wordpress.android.support.he.ui -import android.net.Uri import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -17,6 +16,7 @@ import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -29,6 +29,8 @@ import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import org.wordpress.android.R +import org.wordpress.android.support.he.model.AttachmentState +import org.wordpress.android.support.he.model.MessageSendResult import org.wordpress.android.support.he.util.AttachmentActionsListener @OptIn(ExperimentalMaterial3Api::class) @@ -36,17 +38,38 @@ import org.wordpress.android.support.he.util.AttachmentActionsListener fun HEConversationReplyBottomSheet( sheetState: androidx.compose.material3.SheetState, isSending: Boolean = false, + messageSendResult: MessageSendResult? = null, initialMessageText: String = "", initialIncludeAppLogs: Boolean = false, onDismiss: (currentMessage: String, currentIncludeAppLogs: Boolean) -> Unit, onSend: (String, Boolean) -> Unit, - attachments: List = emptyList(), + onMessageSentSuccessfully: () -> Unit, + attachmentState: AttachmentState = AttachmentState(), attachmentActionsListener: AttachmentActionsListener ) { var messageText by remember { mutableStateOf(initialMessageText) } var includeAppLogs by remember { mutableStateOf(initialIncludeAppLogs) } val scrollState = rememberScrollState() + // Close the sheet when sending completes successfully + LaunchedEffect(messageSendResult) { + when (messageSendResult) { + is MessageSendResult.Success -> { + // Message sent successfully, close the sheet and clear draft + onDismiss("", false) + onMessageSentSuccessfully() + } + is MessageSendResult.Failure -> { + // Message failed to send, draft is saved onDismiss + // The error will be shown via snackbar from the Activity + onDismiss("", false) + } + null -> { + // No result yet, do nothing + } + } + } + ModalBottomSheet( onDismissRequest = { onDismiss(messageText, includeAppLogs) }, sheetState = sheetState @@ -107,7 +130,7 @@ fun HEConversationReplyBottomSheet( onMessageChanged = { message -> messageText = message }, onIncludeAppLogsChanged = { checked -> includeAppLogs = checked }, enabled = !isSending, - attachments = attachments, + attachmentState = attachmentState, attachmentActionsListener = attachmentActionsListener ) } diff --git a/WordPress/src/main/java/org/wordpress/android/support/he/ui/HENewTicketScreen.kt b/WordPress/src/main/java/org/wordpress/android/support/he/ui/HENewTicketScreen.kt index 413d2897ec0d..e9d4b0da8aab 100644 --- a/WordPress/src/main/java/org/wordpress/android/support/he/ui/HENewTicketScreen.kt +++ b/WordPress/src/main/java/org/wordpress/android/support/he/ui/HENewTicketScreen.kt @@ -55,6 +55,7 @@ import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState import org.wordpress.android.R import org.wordpress.android.support.common.model.UserInfo +import org.wordpress.android.support.he.model.AttachmentState import org.wordpress.android.support.he.util.AttachmentActionsListener import org.wordpress.android.ui.compose.components.MainTopAppBar import org.wordpress.android.ui.compose.components.NavigationIcons @@ -74,8 +75,8 @@ fun HENewTicketScreen( ) -> Unit, userInfo: UserInfo, isSendingNewConversation: Boolean = false, - attachments: List = emptyList(), - attachmentActionsListener: AttachmentActionsListener, + attachmentState: AttachmentState = AttachmentState(), + attachmentActionsListener: AttachmentActionsListener ) { var selectedCategory by remember { mutableStateOf(null) } var subject by remember { mutableStateOf("") } @@ -197,7 +198,7 @@ fun HENewTicketScreen( includeAppLogs = includeAppLogs, onMessageChanged = { message -> messageText = message }, onIncludeAppLogsChanged = { checked -> includeAppLogs = checked }, - attachments = attachments, + attachmentState = attachmentState, attachmentActionsListener = attachmentActionsListener ) diff --git a/WordPress/src/main/java/org/wordpress/android/support/he/ui/HESupportActivity.kt b/WordPress/src/main/java/org/wordpress/android/support/he/ui/HESupportActivity.kt index b6b89b272815..9cf20c3691e0 100644 --- a/WordPress/src/main/java/org/wordpress/android/support/he/ui/HESupportActivity.kt +++ b/WordPress/src/main/java/org/wordpress/android/support/he/ui/HESupportActivity.kt @@ -173,7 +173,7 @@ class HESupportActivity : AppCompatActivity() { val isLoadingConversation by viewModel.isLoadingConversation.collectAsState() val isSendingMessage by viewModel.isSendingMessage.collectAsState() val messageSendResult by viewModel.messageSendResult.collectAsState() - val attachments by viewModel.attachments.collectAsState() + val attachmentState by viewModel.attachmentState.collectAsState() selectedConversation?.let { conversation -> HEConversationDetailScreen( @@ -190,7 +190,7 @@ class HESupportActivity : AppCompatActivity() { ) }, onClearMessageSendResult = { viewModel.clearMessageSendResult() }, - attachments = attachments, + attachmentState = attachmentState, attachmentActionsListener = createAttachmentActionListener(), onDownloadAttachment = { attachment -> // Show loading snackbar @@ -213,7 +213,7 @@ class HESupportActivity : AppCompatActivity() { composable(route = ConversationScreen.NewTicket.name) { val userInfo by viewModel.userInfo.collectAsState() val isSendingNewConversation by viewModel.isSendingMessage.collectAsState() - val attachments by viewModel.attachments.collectAsState() + val attachmentState by viewModel.attachmentState.collectAsState() // Clear attachments when leaving the new ticket screen androidx.compose.runtime.DisposableEffect(Unit) { @@ -234,7 +234,7 @@ class HESupportActivity : AppCompatActivity() { }, userInfo = userInfo, isSendingNewConversation = isSendingNewConversation, - attachments = attachments, + attachmentState = attachmentState, attachmentActionsListener = createAttachmentActionListener() ) } diff --git a/WordPress/src/main/java/org/wordpress/android/support/he/ui/HESupportViewModel.kt b/WordPress/src/main/java/org/wordpress/android/support/he/ui/HESupportViewModel.kt index 39612f657acb..7f684fa45a8e 100644 --- a/WordPress/src/main/java/org/wordpress/android/support/he/ui/HESupportViewModel.kt +++ b/WordPress/src/main/java/org/wordpress/android/support/he/ui/HESupportViewModel.kt @@ -1,5 +1,6 @@ package org.wordpress.android.support.he.ui +import android.app.Application import android.net.Uri import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel @@ -8,10 +9,13 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import org.wordpress.android.fluxc.store.AccountStore import org.wordpress.android.fluxc.utils.AppLogWrapper import org.wordpress.android.modules.IO_THREAD import org.wordpress.android.support.common.ui.ConversationsSupportViewModel +import org.wordpress.android.support.he.model.AttachmentState +import org.wordpress.android.support.he.model.MessageSendResult import org.wordpress.android.support.he.model.SupportConversation import org.wordpress.android.support.he.repository.CreateConversationResult import org.wordpress.android.support.he.repository.HESupportRepository @@ -26,24 +30,23 @@ class HESupportViewModel @Inject constructor( private val heSupportRepository: HESupportRepository, @Named(IO_THREAD) private val ioDispatcher: CoroutineDispatcher, private val tempAttachmentsUtil: TempAttachmentsUtil, + private val application: Application, accountStore: AccountStore, appLogWrapper: AppLogWrapper, networkUtilsWrapper: NetworkUtilsWrapper, ) : ConversationsSupportViewModel(accountStore, appLogWrapper, networkUtilsWrapper) { + companion object { + const val MAX_TOTAL_SIZE_BYTES = 20L * 1024 * 1024 // 20MB total + } private val _isSendingMessage = MutableStateFlow(false) val isSendingMessage: StateFlow = _isSendingMessage.asStateFlow() private val _messageSendResult = MutableStateFlow(null) val messageSendResult: StateFlow = _messageSendResult.asStateFlow() - // Attachment state (shared for both Detail and NewTicket screens) - private val _attachments = MutableStateFlow>(emptyList()) - val attachments: StateFlow> = _attachments.asStateFlow() - - sealed class MessageSendResult { - data object Success : MessageSendResult() - data object Failure : MessageSendResult() - } + // Unified attachment state (shared for both Detail and NewTicket screens) + private val _attachmentState = MutableStateFlow(AttachmentState()) + val attachmentState: StateFlow = _attachmentState.asStateFlow() override fun initRepository(accessToken: String) { heSupportRepository.init(accessToken) @@ -66,7 +69,7 @@ class HESupportViewModel @Inject constructor( _isSendingMessage.value = true - val files = tempAttachmentsUtil.createTempFilesFrom(_attachments.value) + val files = tempAttachmentsUtil.createTempFilesFrom(_attachmentState.value.acceptedUris) when (val result = heSupportRepository.createConversation( subject = subject, @@ -79,7 +82,7 @@ class HESupportViewModel @Inject constructor( // update conversations locally _conversations.value = listOf(newConversation) + _conversations.value // Clear attachments after successful creation - _attachments.value = emptyList() + _attachmentState.value = AttachmentState() onBackClick() } @@ -126,7 +129,7 @@ class HESupportViewModel @Inject constructor( } _isSendingMessage.value = true - val files = tempAttachmentsUtil.createTempFilesFrom(_attachments.value) + val files = tempAttachmentsUtil.createTempFilesFrom(_attachmentState.value.acceptedUris) when (val result = heSupportRepository.addMessageToConversation( conversationId = selectedConversation.id, @@ -137,7 +140,7 @@ class HESupportViewModel @Inject constructor( _selectedConversation.value = result.conversation _messageSendResult.value = MessageSendResult.Success // Clear attachments after successful message send - _attachments.value = emptyList() + _attachmentState.value = AttachmentState() } is CreateConversationResult.Error.Forbidden -> { @@ -170,15 +173,103 @@ class HESupportViewModel @Inject constructor( } fun addAttachments(uris: List) { - _attachments.value = _attachments.value + uris + viewModelScope.launch(ioDispatcher) { + _attachmentState.value = validateAndCreateAttachmentState(uris) + } + } + + @Suppress("LoopWithTooManyJumpStatements") + private suspend fun validateAndCreateAttachmentState(uris: List): AttachmentState = withContext(ioDispatcher) { + if (uris.isEmpty()) { + return@withContext _attachmentState.value + } + + val validUris = mutableListOf() + val skippedUris = mutableListOf() + + // Calculate current total size + var currentTotalSize = calculateTotalSize(_attachmentState.value.acceptedUris) + + // Validate each new attachment + for (uri in uris) { + val fileSize = getFileSize(uri) + + // Skip if we can't determine file size we just allow it to be added + if (fileSize != null) { + // Check if adding this file would exceed total size limit + if (currentTotalSize + fileSize > MAX_TOTAL_SIZE_BYTES) { + skippedUris.add(uri) + continue + } + } + + // File is valid, add it + validUris.add(uri) + currentTotalSize += fileSize ?: 0 + } + + // Build the new attachment state + val currentAccepted = _attachmentState.value.acceptedUris + val newAccepted = currentAccepted + validUris + + // Calculate rejected total size + val rejectedTotalSize = calculateTotalSize(skippedUris) + + AttachmentState( + acceptedUris = newAccepted, + rejectedUris = skippedUris, + currentTotalSizeBytes = currentTotalSize, + rejectedTotalSizeBytes = rejectedTotalSize + ) + } + + @Suppress("TooGenericExceptionCaught") + private suspend fun getFileSize(uri: Uri): Long? = withContext(ioDispatcher) { + try { + application.contentResolver.openAssetFileDescriptor(uri, "r")?.use { descriptor -> + descriptor.length + } + } catch (e: Exception) { + appLogWrapper.d(AppLog.T.SUPPORT, "Could not determine file size for URI: $uri - ${e.message}") + // Silently return null if we can't get the file size + // This will be handled by the validation logic + null + } } + /** + * Calculates the total size of all files in the list + * @param uris List of URIs to calculate size for + * @return Total size in bytes + */ + private suspend fun calculateTotalSize(uris: List): Long { + var totalSize = 0L + for (uri in uris) { + totalSize += getFileSize(uri) ?: 0L + } + return totalSize + } + + /** + * Removes an attachment from the accepted list and attempts to re-include any previously + * skipped files that can now fit within the size limit. + * + * This function removes the specified URI and then re-validates all previously skipped files + * by calling [addAttachments], which ensures consistent validation logic and automatically + * includes files that now fit within the available space. + */ fun removeAttachment(uri: Uri) { - _attachments.value = _attachments.value.filter { it != uri } + viewModelScope.launch { + // Remove the attachment and re-validate skipped files + val currentState = _attachmentState.value.copy() + val newAcceptedUris = currentState.acceptedUris.filter { it != uri } + _attachmentState.value = currentState.copy(acceptedUris = newAcceptedUris) + addAttachments(currentState.rejectedUris) + } } fun clearAttachments() { - _attachments.value = emptyList() + _attachmentState.value = AttachmentState() } fun notifyGeneralError() { diff --git a/WordPress/src/main/java/org/wordpress/android/support/he/ui/TicketMainContentView.kt b/WordPress/src/main/java/org/wordpress/android/support/he/ui/TicketMainContentView.kt index 0f099e12d4fa..dda8fa733f3b 100644 --- a/WordPress/src/main/java/org/wordpress/android/support/he/ui/TicketMainContentView.kt +++ b/WordPress/src/main/java/org/wordpress/android/support/he/ui/TicketMainContentView.kt @@ -2,6 +2,7 @@ package org.wordpress.android.support.he.ui import android.content.res.Configuration.UI_MODE_NIGHT_YES import android.net.Uri +import androidx.compose.foundation.background import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -17,6 +18,7 @@ import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material3.Card +import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.CardDefaults import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon @@ -32,6 +34,7 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.heading import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.KeyboardCapitalization import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp @@ -43,10 +46,20 @@ import androidx.compose.material3.OutlinedButton import androidx.compose.material3.Surface import androidx.compose.ui.draw.clip import androidx.compose.ui.layout.ContentScale +import androidx.core.net.toUri import coil.compose.AsyncImage import org.wordpress.android.R +import org.wordpress.android.support.he.model.AttachmentState import org.wordpress.android.support.he.util.AttachmentActionsListener import org.wordpress.android.ui.compose.theme.AppThemeM3 +import java.util.Locale +import kotlin.math.roundToInt + +private const val MAX_TOTAL_SIZE_BYTES = 20L * 1024 * 1024 // 20MB +private const val BYTES_IN_KB = 1024 +private const val BYTES_IN_MB = 1024 * 1024 +private const val PROGRESS_WARNING_THRESHOLD = 0.9f // Show warning color at 90% +private const val PROGRESS_PERCENTAGE_MULTIPLIER = 100 @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -56,7 +69,7 @@ fun TicketMainContentView( onMessageChanged: (String) -> Unit, onIncludeAppLogsChanged: (Boolean) -> Unit, enabled: Boolean = true, - attachments: List = emptyList(), + attachmentState: AttachmentState = AttachmentState(), attachmentActionsListener: AttachmentActionsListener ) { Column( @@ -107,7 +120,7 @@ fun TicketMainContentView( modifier = Modifier.padding(bottom = 12.dp) ) - if (attachments.isNotEmpty()) { + if (attachmentState.acceptedUris.isNotEmpty()) { FlowRow( modifier = Modifier .fillMaxWidth() @@ -115,7 +128,7 @@ fun TicketMainContentView( horizontalArrangement = Arrangement.spacedBy(12.dp), verticalArrangement = Arrangement.spacedBy(12.dp) ) { - attachments.forEach { imageUri -> + attachmentState.acceptedUris.forEach { imageUri -> ImagePreviewItem( imageUri = imageUri, onRemove = { attachmentActionsListener.onRemoveImage(imageUri) }, @@ -155,6 +168,24 @@ fun TicketMainContentView( ) } + // Show attachment size progress bar if there are any attachments + if (attachmentState.acceptedUris.isNotEmpty()) { + Spacer(modifier = Modifier.height(12.dp)) + AttachmentSizeProgressBar( + currentSizeBytes = attachmentState.currentTotalSizeBytes, + maxSizeBytes = MAX_TOTAL_SIZE_BYTES + ) + } + + // Show rejected attachments with thumbnails + if (attachmentState.rejectedUris.isNotEmpty()) { + Spacer(modifier = Modifier.height(16.dp)) + RejectedAttachmentsSection( + skippedUris = attachmentState.rejectedUris, + rejectedTotalSizeBytes = attachmentState.rejectedTotalSizeBytes + ) + } + Spacer(modifier = Modifier.height(24.dp)) Text( @@ -272,6 +303,169 @@ private fun ImagePreviewItem( } } +@Composable +private fun RejectedAttachmentsSection( + skippedUris: List, + rejectedTotalSizeBytes: Long +) { + val rejectedSizeFormatted = formatFileSize(rejectedTotalSizeBytes) + + Column( + modifier = Modifier.fillMaxWidth() + ) { + // Section header + Text( + text = stringResource(R.string.he_support_skipped_files_header, rejectedSizeFormatted), + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.error, + fontWeight = FontWeight.SemiBold, + modifier = Modifier.padding(bottom = 12.dp) + ) + + // Thumbnails of rejected files + FlowRow( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + skippedUris.forEach { uri -> + RejectedImagePreviewItem(imageUri = uri) + } + } + } +} + +@Composable +private fun RejectedImagePreviewItem( + imageUri: Uri +) { + Box( + modifier = Modifier.size(100.dp) + ) { + Card( + modifier = Modifier.size(100.dp), + shape = RoundedCornerShape(12.dp), + elevation = CardDefaults.cardElevation( + defaultElevation = 2.dp + ) + ) { + Box { + AsyncImage( + model = imageUri, + contentDescription = stringResource(R.string.he_support_screenshot_preview), + modifier = Modifier + .size(100.dp) + .clip(RoundedCornerShape(12.dp)), + contentScale = ContentScale.Crop + ) + // Semi-transparent red overlay to indicate rejection + Box( + modifier = Modifier + .size(100.dp) + .background( + MaterialTheme.colorScheme.error.copy(alpha = 0.3f), + RoundedCornerShape(12.dp) + ) + ) + } + } + + // Error icon in the center + Surface( + modifier = Modifier + .align(Alignment.Center) + .size(32.dp), + shape = CircleShape, + color = MaterialTheme.colorScheme.error, + shadowElevation = 4.dp + ) { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier.size(32.dp) + ) { + Icon( + imageVector = Icons.Default.Close, + contentDescription = null, + tint = MaterialTheme.colorScheme.onError, + modifier = Modifier.size(20.dp) + ) + } + } + } +} + +@Composable +private fun AttachmentSizeProgressBar( + currentSizeBytes: Long, + maxSizeBytes: Long +) { + val progress = (currentSizeBytes.toFloat() / maxSizeBytes.toFloat()).coerceIn(0f, 1f) + val currentSizeFormatted = formatFileSize(currentSizeBytes) + val maxSizeFormatted = formatFileSize(maxSizeBytes) + val progressDescription = stringResource( + R.string.he_support_attachment_size_label, + currentSizeFormatted, + maxSizeFormatted + ) + + Column( + modifier = Modifier.fillMaxWidth() + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 4.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = stringResource( + R.string.he_support_attachment_size_label, + currentSizeFormatted, + maxSizeFormatted + ), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Text( + text = "${(progress * PROGRESS_PERCENTAGE_MULTIPLIER).roundToInt()}%", + style = MaterialTheme.typography.bodySmall, + fontWeight = FontWeight.SemiBold, + color = if (progress >= PROGRESS_WARNING_THRESHOLD) { + MaterialTheme.colorScheme.error + } else { + MaterialTheme.colorScheme.onSurfaceVariant + } + ) + } + + LinearProgressIndicator( + progress = { progress }, + modifier = Modifier + .fillMaxWidth() + .height(6.dp) + .clip(RoundedCornerShape(3.dp)) + .semantics { + contentDescription = progressDescription + }, + color = when { + progress >= 1.0f -> MaterialTheme.colorScheme.error + progress >= PROGRESS_WARNING_THRESHOLD -> MaterialTheme.colorScheme.error.copy(alpha = 0.8f) + else -> MaterialTheme.colorScheme.primary + }, + trackColor = MaterialTheme.colorScheme.surfaceVariant, + ) + } +} + +private fun formatFileSize(bytes: Long): String { + return when { + bytes < BYTES_IN_KB -> "$bytes B" + bytes < BYTES_IN_MB -> String.format(Locale.US, "%.1f KB", bytes / BYTES_IN_KB.toDouble()) + else -> String.format(Locale.US, "%.1f MB", bytes / BYTES_IN_MB.toDouble()) + } +} + @Preview(showBackground = true, name = "HE main ticket content") @Suppress("EmptyFunctionBlock") @@ -308,3 +502,25 @@ private fun TicketMainContentViewPreviewDark() { ) } } + +@Preview(showBackground = true, name = "HE main ticket content - With Attachments") +@Suppress("EmptyFunctionBlock") +@Composable +private fun TicketMainContentViewPreviewWithAttachments() { + AppThemeM3(isDarkTheme = false) { + TicketMainContentView( + messageText = "I'm having trouble with my site", + includeAppLogs = true, + onMessageChanged = { }, + onIncludeAppLogsChanged = { }, + attachmentState = AttachmentState( + acceptedUris = listOf("content://test1".toUri(), "content://test2".toUri()), + currentTotalSizeBytes = 15L * 1024 * 1024 // 15MB + ), + attachmentActionsListener = object : AttachmentActionsListener { + override fun onAddImageClick() { } + override fun onRemoveImage(uri: Uri) { } + } + ) + } +} diff --git a/WordPress/src/main/res/values/strings.xml b/WordPress/src/main/res/values/strings.xml index eea48b5b0145..2e88ae646f6b 100644 --- a/WordPress/src/main/res/values/strings.xml +++ b/WordPress/src/main/res/values/strings.xml @@ -5175,6 +5175,8 @@ translators: %s: Select control option value e.g: "Auto, 25%". --> Add Screenshots Screenshot preview Remove screenshot + Attachment limit: %1$s of %2$s + Skipped Files: %1$s Application Logs (Optional) Include application logs Including logs can help our team investigate issues. Logs may contain recent app activity. diff --git a/WordPress/src/test/java/org/wordpress/android/support/he/ui/HESupportViewModelTest.kt b/WordPress/src/test/java/org/wordpress/android/support/he/ui/HESupportViewModelTest.kt index 46359d635bed..72181efc3fb1 100644 --- a/WordPress/src/test/java/org/wordpress/android/support/he/ui/HESupportViewModelTest.kt +++ b/WordPress/src/test/java/org/wordpress/android/support/he/ui/HESupportViewModelTest.kt @@ -1,5 +1,8 @@ package org.wordpress.android.support.he.ui +import android.app.Application +import android.content.ContentResolver +import android.content.res.AssetFileDescriptor import android.net.Uri import androidx.compose.ui.text.AnnotatedString import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -20,6 +23,7 @@ import org.wordpress.android.fluxc.model.AccountModel import org.wordpress.android.fluxc.store.AccountStore import org.wordpress.android.fluxc.utils.AppLogWrapper import org.wordpress.android.support.common.ui.ConversationsSupportViewModel +import org.wordpress.android.support.he.model.MessageSendResult import org.wordpress.android.support.he.model.SupportConversation import org.wordpress.android.support.he.model.SupportMessage import org.wordpress.android.support.he.repository.CreateConversationResult @@ -29,6 +33,7 @@ import org.wordpress.android.util.NetworkUtilsWrapper import java.util.Date @ExperimentalCoroutinesApi +@Suppress("LargeClass") class HESupportViewModelTest : BaseUnitTest() { @Mock private lateinit var accountStore: AccountStore @@ -45,6 +50,12 @@ class HESupportViewModelTest : BaseUnitTest() { @Mock private lateinit var tempAttachmentsUtil: TempAttachmentsUtil + @Mock + private lateinit var application: Application + + @Mock + private lateinit var contentResolver: ContentResolver + private lateinit var viewModel: HESupportViewModel private val testAccessToken = "test_access_token" @@ -71,10 +82,17 @@ class HESupportViewModelTest : BaseUnitTest() { whenever(tempAttachmentsUtil.removeTempFiles(any())).thenReturn(Unit) } + // Mock ContentResolver to return file sizes + whenever(application.contentResolver).thenReturn(contentResolver) + val assetFileDescriptor = mock() + whenever(assetFileDescriptor.length).thenReturn(1024L * 1024L) // 1MB by default + whenever(contentResolver.openAssetFileDescriptor(any(), any())).thenReturn(assetFileDescriptor) + viewModel = HESupportViewModel( heSupportRepository = heSupportRepository, ioDispatcher = UnconfinedTestDispatcher(), tempAttachmentsUtil = tempAttachmentsUtil, + application = application, accountStore = accountStore, appLogWrapper = appLogWrapper, networkUtilsWrapper = networkUtilsWrapper, @@ -415,7 +433,7 @@ class HESupportViewModelTest : BaseUnitTest() { ) advanceUntilIdle() - assertThat(viewModel.messageSendResult.value).isEqualTo(HESupportViewModel.MessageSendResult.Failure) + assertThat(viewModel.messageSendResult.value).isEqualTo(MessageSendResult.Failure) } @Test @@ -443,65 +461,242 @@ class HESupportViewModelTest : BaseUnitTest() { // region Attachment management tests @Test - fun `addAttachments adds URIs to attachments list`() { + fun `addAttachments adds URIs to attachment state`() = test { val uri1 = mock() val uri2 = mock() viewModel.addAttachments(listOf(uri1, uri2)) + advanceUntilIdle() - assertThat(viewModel.attachments.value).containsExactly(uri1, uri2) + assertThat(viewModel.attachmentState.value.acceptedUris).containsExactly(uri1, uri2) + assertThat(viewModel.attachmentState.value.rejectedUris).isEmpty() } @Test - fun `addAttachments appends to existing attachments`() { + fun `addAttachments appends to existing attachments`() = test { val uri1 = mock() val uri2 = mock() val uri3 = mock() viewModel.addAttachments(listOf(uri1)) + advanceUntilIdle() viewModel.addAttachments(listOf(uri2, uri3)) + advanceUntilIdle() - assertThat(viewModel.attachments.value).containsExactly(uri1, uri2, uri3) + assertThat(viewModel.attachmentState.value.acceptedUris).containsExactly(uri1, uri2, uri3) } @Test - fun `removeAttachment removes specific URI from attachments list`() { + fun `removeAttachment removes specific URI from attachments list`() = test { val uri1 = mock() val uri2 = mock() val uri3 = mock() viewModel.addAttachments(listOf(uri1, uri2, uri3)) + advanceUntilIdle() viewModel.removeAttachment(uri2) + advanceUntilIdle() - assertThat(viewModel.attachments.value).containsExactly(uri1, uri3) + assertThat(viewModel.attachmentState.value.acceptedUris).containsExactly(uri1, uri3) } @Test - fun `removeAttachment does nothing when URI not in list`() { + fun `removeAttachment does nothing when URI not in list`() = test { val uri1 = mock() val uri2 = mock() val uri3 = mock() viewModel.addAttachments(listOf(uri1, uri2)) + advanceUntilIdle() viewModel.removeAttachment(uri3) + advanceUntilIdle() - assertThat(viewModel.attachments.value).containsExactly(uri1, uri2) + assertThat(viewModel.attachmentState.value.acceptedUris).containsExactly(uri1, uri2) } @Test - fun `clearAttachments removes all attachments`() { + fun `clearAttachments removes all attachments`() = test { val uri1 = mock() val uri2 = mock() viewModel.addAttachments(listOf(uri1, uri2)) + advanceUntilIdle() viewModel.clearAttachments() - assertThat(viewModel.attachments.value).isEmpty() + assertThat(viewModel.attachmentState.value.acceptedUris).isEmpty() + assertThat(viewModel.attachmentState.value.rejectedUris).isEmpty() } @Test fun `attachments list is empty initially`() { - assertThat(viewModel.attachments.value).isEmpty() + assertThat(viewModel.attachmentState.value.acceptedUris).isEmpty() + } + + @Test + fun `addAttachments rejects file larger than 20MB`() = test { + val uri1 = mock() + val assetFileDescriptor = mock() + whenever(assetFileDescriptor.length).thenReturn(21L * 1024L * 1024L) // 21MB + whenever(contentResolver.openAssetFileDescriptor(eq(uri1), any())).thenReturn(assetFileDescriptor) + + viewModel.addAttachments(listOf(uri1)) + advanceUntilIdle() + + assertThat(viewModel.attachmentState.value.acceptedUris).isEmpty() + assertThat(viewModel.attachmentState.value.rejectedUris).containsExactly(uri1) + assertThat(viewModel.attachmentState.value.rejectedTotalSizeBytes).isEqualTo(21L * 1024L * 1024L) + } + + @Test + fun `addAttachments accepts file smaller than 20MB`() = test { + val uri1 = mock() + val assetFileDescriptor = mock() + whenever(assetFileDescriptor.length).thenReturn(10L * 1024L * 1024L) // 10MB + whenever(contentResolver.openAssetFileDescriptor(eq(uri1), any())).thenReturn(assetFileDescriptor) + + viewModel.addAttachments(listOf(uri1)) + advanceUntilIdle() + + assertThat(viewModel.attachmentState.value.acceptedUris).containsExactly(uri1) + assertThat(viewModel.attachmentState.value.rejectedUris).isEmpty() + assertThat(viewModel.attachmentState.value.currentTotalSizeBytes).isEqualTo(10L * 1024L * 1024L) + } + + @Test + fun `addAttachments rejects files when total size exceeds 20MB`() = test { + val uri1 = mock() + val uri2 = mock() + val uri3 = mock() + val descriptor1 = mock() + val descriptor2 = mock() + val descriptor3 = mock() + + // Start with 12MB, then try to add 10MB (exceeds limit) and 3MB (fits) + whenever(descriptor1.length).thenReturn(12L * 1024L * 1024L) + whenever(descriptor2.length).thenReturn(10L * 1024L * 1024L) + whenever(descriptor3.length).thenReturn(3L * 1024L * 1024L) + whenever(contentResolver.openAssetFileDescriptor(eq(uri1), any())).thenReturn(descriptor1) + whenever(contentResolver.openAssetFileDescriptor(eq(uri2), any())).thenReturn(descriptor2) + whenever(contentResolver.openAssetFileDescriptor(eq(uri3), any())).thenReturn(descriptor3) + + viewModel.addAttachments(listOf(uri1)) + advanceUntilIdle() + viewModel.addAttachments(listOf(uri2, uri3)) + advanceUntilIdle() + + // uri1 (12MB) accepted, uri2 (10MB) rejected (12+10=22 exceeds 20MB), uri3 (3MB) accepted (12+3=15MB) + assertThat(viewModel.attachmentState.value.acceptedUris).containsExactly(uri1, uri3) + assertThat(viewModel.attachmentState.value.rejectedUris).containsExactly(uri2) + assertThat(viewModel.attachmentState.value.currentTotalSizeBytes).isEqualTo(15L * 1024L * 1024L) + assertThat(viewModel.attachmentState.value.rejectedTotalSizeBytes).isEqualTo(10L * 1024L * 1024L) + } + + @Test + fun `addAttachments accepts multiple files within total size limit`() = test { + val uri1 = mock() + val uri2 = mock() + val descriptor1 = mock() + val descriptor2 = mock() + + // 12MB + 7MB = 19MB (within limit) + whenever(descriptor1.length).thenReturn(12L * 1024L * 1024L) + whenever(descriptor2.length).thenReturn(7L * 1024L * 1024L) + whenever(contentResolver.openAssetFileDescriptor(eq(uri1), any())).thenReturn(descriptor1) + whenever(contentResolver.openAssetFileDescriptor(eq(uri2), any())).thenReturn(descriptor2) + + viewModel.addAttachments(listOf(uri1)) + advanceUntilIdle() + viewModel.addAttachments(listOf(uri2)) + advanceUntilIdle() + + assertThat(viewModel.attachmentState.value.acceptedUris).containsExactly(uri1, uri2) + assertThat(viewModel.attachmentState.value.rejectedUris).isEmpty() + assertThat(viewModel.attachmentState.value.currentTotalSizeBytes).isEqualTo(19L * 1024L * 1024L) + } + + @Test + fun `addAttachments accepts file exactly at 20MB limit`() = test { + val uri1 = mock() + val assetFileDescriptor = mock() + whenever(assetFileDescriptor.length).thenReturn(20L * 1024L * 1024L) // Exactly 20MB + whenever(contentResolver.openAssetFileDescriptor(eq(uri1), any())).thenReturn(assetFileDescriptor) + + viewModel.addAttachments(listOf(uri1)) + advanceUntilIdle() + + assertThat(viewModel.attachmentState.value.acceptedUris).containsExactly(uri1) + assertThat(viewModel.attachmentState.value.rejectedUris).isEmpty() + assertThat(viewModel.attachmentState.value.currentTotalSizeBytes).isEqualTo(20L * 1024L * 1024L) + } + + @Test + fun `addAttachments accepts files when total is exactly 20MB`() = test { + val uri1 = mock() + val uri2 = mock() + val descriptor1 = mock() + val descriptor2 = mock() + + // 10MB + 10MB = exactly 20MB (at limit) + whenever(descriptor1.length).thenReturn(10L * 1024L * 1024L) + whenever(descriptor2.length).thenReturn(10L * 1024L * 1024L) + whenever(contentResolver.openAssetFileDescriptor(eq(uri1), any())).thenReturn(descriptor1) + whenever(contentResolver.openAssetFileDescriptor(eq(uri2), any())).thenReturn(descriptor2) + + viewModel.addAttachments(listOf(uri1)) + advanceUntilIdle() + viewModel.addAttachments(listOf(uri2)) + advanceUntilIdle() + + assertThat(viewModel.attachmentState.value.acceptedUris).containsExactly(uri1, uri2) + assertThat(viewModel.attachmentState.value.rejectedUris).isEmpty() + assertThat(viewModel.attachmentState.value.currentTotalSizeBytes).isEqualTo(20L * 1024L * 1024L) + } + + @Test + fun `addAttachments accepts file when size cannot be determined`() = test { + val uri1 = mock() + whenever(contentResolver.openAssetFileDescriptor(eq(uri1), any())).thenReturn(null) + + viewModel.addAttachments(listOf(uri1)) + advanceUntilIdle() + + // File should be accepted since we can't determine size (fail open approach) + assertThat(viewModel.attachmentState.value.acceptedUris).containsExactly(uri1) + assertThat(viewModel.attachmentState.value.rejectedUris).isEmpty() + assertThat(viewModel.attachmentState.value.currentTotalSizeBytes).isEqualTo(0L) + } + + @Test + fun `addAttachments rejects files that exceed total size even when individual files are valid`() = test { + val uri1 = mock() + val uri2 = mock() + val uri3 = mock() + val uri4 = mock() + val descriptor1 = mock() + val descriptor2 = mock() + val descriptor3 = mock() + val descriptor4 = mock() + + // uri1: 5MB (accepted), uri2: 12MB (accepted), uri3: 8MB (rejected - would exceed total) + whenever(descriptor1.length).thenReturn(5L * 1024L * 1024L) + whenever(descriptor2.length).thenReturn(12L * 1024L * 1024L) + whenever(descriptor3.length).thenReturn(8L * 1024L * 1024L) + whenever(descriptor4.length).thenReturn(2L * 1024L * 1024L) + whenever(contentResolver.openAssetFileDescriptor(eq(uri1), any())).thenReturn(descriptor1) + whenever(contentResolver.openAssetFileDescriptor(eq(uri2), any())).thenReturn(descriptor2) + whenever(contentResolver.openAssetFileDescriptor(eq(uri3), any())).thenReturn(descriptor3) + whenever(contentResolver.openAssetFileDescriptor(eq(uri4), any())).thenReturn(descriptor4) + + viewModel.addAttachments(listOf(uri1)) + advanceUntilIdle() + viewModel.addAttachments(listOf(uri2, uri3, uri4)) + advanceUntilIdle() + + // uri1 accepted (5MB), uri2 accepted (5+12=17 < 20), uri3 rejected (17+8=25 > 20), uri4 accepted (17+2=19 < 20) + assertThat(viewModel.attachmentState.value.acceptedUris).containsExactly(uri1, uri2, uri4) + assertThat(viewModel.attachmentState.value.rejectedUris).containsExactly(uri3) + assertThat(viewModel.attachmentState.value.currentTotalSizeBytes).isEqualTo(19L * 1024L * 1024L) + assertThat(viewModel.attachmentState.value.rejectedTotalSizeBytes).isEqualTo(8L * 1024L * 1024L) } // endregion @@ -553,7 +748,8 @@ class HESupportViewModelTest : BaseUnitTest() { )).thenReturn(CreateConversationResult.Success(newConversation)) viewModel.addAttachments(listOf(uri1)) - assertThat(viewModel.attachments.value).containsExactly(uri1) + advanceUntilIdle() + assertThat(viewModel.attachmentState.value.acceptedUris).containsExactly(uri1) viewModel.onSendNewConversation( subject = "Test Subject", @@ -562,7 +758,7 @@ class HESupportViewModelTest : BaseUnitTest() { ) advanceUntilIdle() - assertThat(viewModel.attachments.value).isEmpty() + assertThat(viewModel.attachmentState.value.acceptedUris).isEmpty() } @Test @@ -574,6 +770,7 @@ class HESupportViewModelTest : BaseUnitTest() { )).thenReturn(CreateConversationResult.Error.GeneralError) viewModel.addAttachments(listOf(uri1)) + advanceUntilIdle() viewModel.onSendNewConversation( subject = "Test Subject", @@ -582,7 +779,7 @@ class HESupportViewModelTest : BaseUnitTest() { ) advanceUntilIdle() - assertThat(viewModel.attachments.value).containsExactly(uri1) + assertThat(viewModel.attachmentState.value.acceptedUris).containsExactly(uri1) } @Test @@ -658,14 +855,15 @@ class HESupportViewModelTest : BaseUnitTest() { advanceUntilIdle() viewModel.addAttachments(listOf(uri1)) - assertThat(viewModel.attachments.value).containsExactly(uri1) + advanceUntilIdle() + assertThat(viewModel.attachmentState.value.acceptedUris).containsExactly(uri1) viewModel.onAddMessageToConversation( message = "Test message" ) advanceUntilIdle() - assertThat(viewModel.attachments.value).isEmpty() + assertThat(viewModel.attachmentState.value.acceptedUris).isEmpty() } @Test @@ -682,13 +880,14 @@ class HESupportViewModelTest : BaseUnitTest() { advanceUntilIdle() viewModel.addAttachments(listOf(uri1)) + advanceUntilIdle() viewModel.onAddMessageToConversation( message = "Test message" ) advanceUntilIdle() - assertThat(viewModel.attachments.value).containsExactly(uri1) + assertThat(viewModel.attachmentState.value.acceptedUris).containsExactly(uri1) } @Test