Skip to content

Commit e29bad7

Browse files
authored
CMM-927 support attachments limit (#22338)
* Adding basic UI * Renaming * Some styling * Renaming and dummy data * Using proper "new conversation icon" * Conversation details screen * Creating the reply bottomsheet * Linking to the support screen * bottomsheet fix * Mov navigation form activity to viewmodel * Adding create ticket screen * More screen adjustments * Extracting common code * Margin fix * detekt * Style * New ticket check * Creating tests * Creating repository and load conversations function * Adding createConversation function * Creating loadConversation func * Loading conversations form the viewmodel * Adding loading spinner * Pull to refresh * Proper ionitialization * Adding empty screen * Handling send new conversation * Show loading when sending * New ticket creation fix * Using snackbar for errors * Error handling * Answering conversation * Adding some test to the repository * More tests! * Compile fixes * Similarities improvements * Using snackbar in bots activity * Extracting EmptyConversationsView * Renaming * Extracting VM and UI common code * Extracting navigation common code * Renaming VMs for clarification * More refactor * Capitalise text fields * Updating rs library * Loading conversation UX * Style fix * Fixing scaffolds paddings * userID fix * Fixing the padding problem in bot chat when the keyboard is opened * Apply padding to create ticket screen when the keyboard is opened * Fixing scroll state in reply bottomsheet * Adding tests for the new common viewmodel * Fixing AIBotSupportViewModel tests * detekt * Improvements int he conversation interaction * Adding tests for HE VM * Saving draft state * Properly navigating when a ticket is selected * Error parsing improvement * accessToken suggestion improvements * General suggestions * Send message error UX improvement * Fixing tests * Converting the UI to more AndroidMaterial style * Bots screen renaming * Bots screens renaming * Make NewTicket screen more Android Material theme as well * Adding preview for EmptyConversationsView * Button fix * detekt * Ticket selection change * Supporting markdown text * detekt * Improving MarkdownUtils * Formatting text in the repository layer instead the ui * Renaming * Fixing tests * Support pagination * Triggering in the 4th element * detekt * TODO for debug purposes * Claude PR suggestions Mutex and constant * Put ConversationListView in common between bots and HE * Empty and error state * Skip site capitalization * Adding a11c labels * Adding headings labels * adding accessible labels to chat bubbles * detekt * Fixing tests * PR suggestion about bot chat bubble * Fixing tests * Updating rust * Adding attachments UI * Parsing markdown more exhaustively * New links support * Detekt * Supporting in conversation as well * Keeping the screen when select images * Add attachments to the message data class * Showing attachments in the UI * Downloading attachments * detekt * Support pagination * Triggering in the 4th element * detekt * TODO for debug purposes * Claude PR suggestions Mutex and constant * Detekt * Removing testing code * Updating RS library version * Opening images in fullscreen * Improving full screen image UX * Improving semantics * Extracting strings * Using rs PR fix * Showing attachment preview * Clearing attachments on new ticket screen close * Removing selected images limit * Unifying attachments handling inside the VM * Using a launcher instead of startActivityForResult * Remove unused parameter * Handling temp files inside the VM * Removing files * detekt * Throwing copy file error * Extracting some individual composables from HEConversation screen file * Reducing arguments * Catch file creation error * Using proper file extension * General improvements * Update RS version and some fixes * Extracting temp attachment utils * Adding new tests * Some refactoring * Removing attachments preview to open a dedicated PR * Useless changes * Useless changes * Minor refactor * Showing attachments previews * Typo * String fix * Fixing pan issue * Passing attachments directly instead of searching for then when tapped for full screen * Compile fix * Fixing the send state message * Checking network availability * Saving message state when error * Tests * Reverting non-related commits done by mistake * Checking attachments size * Showing skipped files more intuitively * Detekt * Fixing and adding tests * detekt * PR suggestions * Fixing the incongruence between tests and rejection reason priority * Some refactor * Using a progressbar and retry adding skipped files * Fixing tests
1 parent e95f1e1 commit e29bad7

File tree

10 files changed

+606
-50
lines changed

10 files changed

+606
-50
lines changed
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
package org.wordpress.android.support.he.model
2+
3+
import android.net.Uri
4+
5+
data class AttachmentState(
6+
val acceptedUris: List<Uri> = emptyList(),
7+
val rejectedUris: List<Uri> = emptyList(),
8+
val currentTotalSizeBytes: Long = 0L,
9+
val rejectedTotalSizeBytes: Long = 0L
10+
)
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
package org.wordpress.android.support.he.model
2+
3+
sealed class MessageSendResult {
4+
data object Success : MessageSendResult()
5+
data object Failure : MessageSendResult()
6+
}

WordPress/src/main/java/org/wordpress/android/support/he/ui/HEConversationDetailScreen.kt

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,8 @@ import coil.request.ImageRequest
6060
import coil.request.videoFrameMillis
6161
import org.wordpress.android.R
6262
import org.wordpress.android.support.aibot.util.formatRelativeTime
63+
import org.wordpress.android.support.he.model.AttachmentState
64+
import org.wordpress.android.support.he.model.MessageSendResult
6365
import org.wordpress.android.support.he.model.AttachmentType
6466
import org.wordpress.android.support.he.model.SupportAttachment
6567
import org.wordpress.android.support.he.model.SupportConversation
@@ -77,11 +79,11 @@ fun HEConversationDetailScreen(
7779
conversation: SupportConversation,
7880
isLoading: Boolean = false,
7981
isSendingMessage: Boolean = false,
80-
messageSendResult: HESupportViewModel.MessageSendResult? = null,
82+
messageSendResult: MessageSendResult? = null,
8183
onBackClick: () -> Unit,
8284
onSendMessage: (message: String, includeAppLogs: Boolean) -> Unit,
8385
onClearMessageSendResult: () -> Unit = {},
84-
attachments: List<Uri> = emptyList(),
86+
attachmentState: AttachmentState = AttachmentState(),
8587
attachmentActionsListener: AttachmentActionsListener,
8688
onDownloadAttachment: (SupportAttachment) -> Unit = {},
8789
videoUrlResolver: org.wordpress.android.support.he.util.VideoUrlResolver? = null
@@ -178,7 +180,7 @@ fun HEConversationDetailScreen(
178180
LaunchedEffect(messageSendResult) {
179181
if (messageSendResult != null) {
180182
// Clear draft only on success
181-
if (messageSendResult is HESupportViewModel.MessageSendResult.Success) {
183+
if (messageSendResult is MessageSendResult.Success) {
182184
draftMessageText = ""
183185
draftIncludeAppLogs = false
184186
}
@@ -211,7 +213,13 @@ fun HEConversationDetailScreen(
211213
draftMessageText = message
212214
onSendMessage(message, includeAppLogs)
213215
},
214-
attachments = attachments,
216+
onMessageSentSuccessfully = {
217+
// Clear draft after successful send
218+
draftMessageText = ""
219+
draftIncludeAppLogs = false
220+
onClearMessageSendResult()
221+
},
222+
attachmentState = attachmentState,
215223
attachmentActionsListener = attachmentActionsListener
216224
)
217225
}

WordPress/src/main/java/org/wordpress/android/support/he/ui/HEConversationReplyBottomSheet.kt

Lines changed: 26 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
package org.wordpress.android.support.he.ui
22

3-
import android.net.Uri
43
import androidx.compose.foundation.layout.Arrangement
54
import androidx.compose.foundation.layout.Column
65
import androidx.compose.foundation.layout.Row
@@ -17,6 +16,7 @@ import androidx.compose.material3.ModalBottomSheet
1716
import androidx.compose.material3.Text
1817
import androidx.compose.material3.TextButton
1918
import androidx.compose.runtime.Composable
19+
import androidx.compose.runtime.LaunchedEffect
2020
import androidx.compose.runtime.getValue
2121
import androidx.compose.runtime.mutableStateOf
2222
import androidx.compose.runtime.remember
@@ -29,24 +29,47 @@ import androidx.compose.ui.semantics.semantics
2929
import androidx.compose.ui.text.font.FontWeight
3030
import androidx.compose.ui.unit.dp
3131
import org.wordpress.android.R
32+
import org.wordpress.android.support.he.model.AttachmentState
33+
import org.wordpress.android.support.he.model.MessageSendResult
3234
import org.wordpress.android.support.he.util.AttachmentActionsListener
3335

3436
@OptIn(ExperimentalMaterial3Api::class)
3537
@Composable
3638
fun HEConversationReplyBottomSheet(
3739
sheetState: androidx.compose.material3.SheetState,
3840
isSending: Boolean = false,
41+
messageSendResult: MessageSendResult? = null,
3942
initialMessageText: String = "",
4043
initialIncludeAppLogs: Boolean = false,
4144
onDismiss: (currentMessage: String, currentIncludeAppLogs: Boolean) -> Unit,
4245
onSend: (String, Boolean) -> Unit,
43-
attachments: List<Uri> = emptyList(),
46+
onMessageSentSuccessfully: () -> Unit,
47+
attachmentState: AttachmentState = AttachmentState(),
4448
attachmentActionsListener: AttachmentActionsListener
4549
) {
4650
var messageText by remember { mutableStateOf(initialMessageText) }
4751
var includeAppLogs by remember { mutableStateOf(initialIncludeAppLogs) }
4852
val scrollState = rememberScrollState()
4953

54+
// Close the sheet when sending completes successfully
55+
LaunchedEffect(messageSendResult) {
56+
when (messageSendResult) {
57+
is MessageSendResult.Success -> {
58+
// Message sent successfully, close the sheet and clear draft
59+
onDismiss("", false)
60+
onMessageSentSuccessfully()
61+
}
62+
is MessageSendResult.Failure -> {
63+
// Message failed to send, draft is saved onDismiss
64+
// The error will be shown via snackbar from the Activity
65+
onDismiss("", false)
66+
}
67+
null -> {
68+
// No result yet, do nothing
69+
}
70+
}
71+
}
72+
5073
ModalBottomSheet(
5174
onDismissRequest = { onDismiss(messageText, includeAppLogs) },
5275
sheetState = sheetState
@@ -107,7 +130,7 @@ fun HEConversationReplyBottomSheet(
107130
onMessageChanged = { message -> messageText = message },
108131
onIncludeAppLogsChanged = { checked -> includeAppLogs = checked },
109132
enabled = !isSending,
110-
attachments = attachments,
133+
attachmentState = attachmentState,
111134
attachmentActionsListener = attachmentActionsListener
112135
)
113136
}

WordPress/src/main/java/org/wordpress/android/support/he/ui/HENewTicketScreen.kt

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ import androidx.compose.material3.SnackbarHost
5555
import androidx.compose.material3.SnackbarHostState
5656
import org.wordpress.android.R
5757
import org.wordpress.android.support.common.model.UserInfo
58+
import org.wordpress.android.support.he.model.AttachmentState
5859
import org.wordpress.android.support.he.util.AttachmentActionsListener
5960
import org.wordpress.android.ui.compose.components.MainTopAppBar
6061
import org.wordpress.android.ui.compose.components.NavigationIcons
@@ -74,8 +75,8 @@ fun HENewTicketScreen(
7475
) -> Unit,
7576
userInfo: UserInfo,
7677
isSendingNewConversation: Boolean = false,
77-
attachments: List<Uri> = emptyList(),
78-
attachmentActionsListener: AttachmentActionsListener,
78+
attachmentState: AttachmentState = AttachmentState(),
79+
attachmentActionsListener: AttachmentActionsListener
7980
) {
8081
var selectedCategory by remember { mutableStateOf<SupportCategory?>(null) }
8182
var subject by remember { mutableStateOf("") }
@@ -197,7 +198,7 @@ fun HENewTicketScreen(
197198
includeAppLogs = includeAppLogs,
198199
onMessageChanged = { message -> messageText = message },
199200
onIncludeAppLogsChanged = { checked -> includeAppLogs = checked },
200-
attachments = attachments,
201+
attachmentState = attachmentState,
201202
attachmentActionsListener = attachmentActionsListener
202203
)
203204

WordPress/src/main/java/org/wordpress/android/support/he/ui/HESupportActivity.kt

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -173,7 +173,7 @@ class HESupportActivity : AppCompatActivity() {
173173
val isLoadingConversation by viewModel.isLoadingConversation.collectAsState()
174174
val isSendingMessage by viewModel.isSendingMessage.collectAsState()
175175
val messageSendResult by viewModel.messageSendResult.collectAsState()
176-
val attachments by viewModel.attachments.collectAsState()
176+
val attachmentState by viewModel.attachmentState.collectAsState()
177177

178178
selectedConversation?.let { conversation ->
179179
HEConversationDetailScreen(
@@ -190,7 +190,7 @@ class HESupportActivity : AppCompatActivity() {
190190
)
191191
},
192192
onClearMessageSendResult = { viewModel.clearMessageSendResult() },
193-
attachments = attachments,
193+
attachmentState = attachmentState,
194194
attachmentActionsListener = createAttachmentActionListener(),
195195
onDownloadAttachment = { attachment ->
196196
// Show loading snackbar
@@ -213,7 +213,7 @@ class HESupportActivity : AppCompatActivity() {
213213
composable(route = ConversationScreen.NewTicket.name) {
214214
val userInfo by viewModel.userInfo.collectAsState()
215215
val isSendingNewConversation by viewModel.isSendingMessage.collectAsState()
216-
val attachments by viewModel.attachments.collectAsState()
216+
val attachmentState by viewModel.attachmentState.collectAsState()
217217

218218
// Clear attachments when leaving the new ticket screen
219219
androidx.compose.runtime.DisposableEffect(Unit) {
@@ -234,7 +234,7 @@ class HESupportActivity : AppCompatActivity() {
234234
},
235235
userInfo = userInfo,
236236
isSendingNewConversation = isSendingNewConversation,
237-
attachments = attachments,
237+
attachmentState = attachmentState,
238238
attachmentActionsListener = createAttachmentActionListener()
239239
)
240240
}

WordPress/src/main/java/org/wordpress/android/support/he/ui/HESupportViewModel.kt

Lines changed: 106 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
package org.wordpress.android.support.he.ui
22

3+
import android.app.Application
34
import android.net.Uri
45
import androidx.lifecycle.viewModelScope
56
import dagger.hilt.android.lifecycle.HiltViewModel
@@ -8,10 +9,13 @@ import kotlinx.coroutines.flow.MutableStateFlow
89
import kotlinx.coroutines.flow.StateFlow
910
import kotlinx.coroutines.flow.asStateFlow
1011
import kotlinx.coroutines.launch
12+
import kotlinx.coroutines.withContext
1113
import org.wordpress.android.fluxc.store.AccountStore
1214
import org.wordpress.android.fluxc.utils.AppLogWrapper
1315
import org.wordpress.android.modules.IO_THREAD
1416
import org.wordpress.android.support.common.ui.ConversationsSupportViewModel
17+
import org.wordpress.android.support.he.model.AttachmentState
18+
import org.wordpress.android.support.he.model.MessageSendResult
1519
import org.wordpress.android.support.he.model.SupportConversation
1620
import org.wordpress.android.support.he.repository.CreateConversationResult
1721
import org.wordpress.android.support.he.repository.HESupportRepository
@@ -26,24 +30,23 @@ class HESupportViewModel @Inject constructor(
2630
private val heSupportRepository: HESupportRepository,
2731
@Named(IO_THREAD) private val ioDispatcher: CoroutineDispatcher,
2832
private val tempAttachmentsUtil: TempAttachmentsUtil,
33+
private val application: Application,
2934
accountStore: AccountStore,
3035
appLogWrapper: AppLogWrapper,
3136
networkUtilsWrapper: NetworkUtilsWrapper,
3237
) : ConversationsSupportViewModel<SupportConversation>(accountStore, appLogWrapper, networkUtilsWrapper) {
38+
companion object {
39+
const val MAX_TOTAL_SIZE_BYTES = 20L * 1024 * 1024 // 20MB total
40+
}
3341
private val _isSendingMessage = MutableStateFlow(false)
3442
val isSendingMessage: StateFlow<Boolean> = _isSendingMessage.asStateFlow()
3543

3644
private val _messageSendResult = MutableStateFlow<MessageSendResult?>(null)
3745
val messageSendResult: StateFlow<MessageSendResult?> = _messageSendResult.asStateFlow()
3846

39-
// Attachment state (shared for both Detail and NewTicket screens)
40-
private val _attachments = MutableStateFlow<List<Uri>>(emptyList())
41-
val attachments: StateFlow<List<Uri>> = _attachments.asStateFlow()
42-
43-
sealed class MessageSendResult {
44-
data object Success : MessageSendResult()
45-
data object Failure : MessageSendResult()
46-
}
47+
// Unified attachment state (shared for both Detail and NewTicket screens)
48+
private val _attachmentState = MutableStateFlow(AttachmentState())
49+
val attachmentState: StateFlow<AttachmentState> = _attachmentState.asStateFlow()
4750

4851
override fun initRepository(accessToken: String) {
4952
heSupportRepository.init(accessToken)
@@ -66,7 +69,7 @@ class HESupportViewModel @Inject constructor(
6669

6770
_isSendingMessage.value = true
6871

69-
val files = tempAttachmentsUtil.createTempFilesFrom(_attachments.value)
72+
val files = tempAttachmentsUtil.createTempFilesFrom(_attachmentState.value.acceptedUris)
7073

7174
when (val result = heSupportRepository.createConversation(
7275
subject = subject,
@@ -79,7 +82,7 @@ class HESupportViewModel @Inject constructor(
7982
// update conversations locally
8083
_conversations.value = listOf(newConversation) + _conversations.value
8184
// Clear attachments after successful creation
82-
_attachments.value = emptyList()
85+
_attachmentState.value = AttachmentState()
8386
onBackClick()
8487
}
8588

@@ -126,7 +129,7 @@ class HESupportViewModel @Inject constructor(
126129
}
127130

128131
_isSendingMessage.value = true
129-
val files = tempAttachmentsUtil.createTempFilesFrom(_attachments.value)
132+
val files = tempAttachmentsUtil.createTempFilesFrom(_attachmentState.value.acceptedUris)
130133

131134
when (val result = heSupportRepository.addMessageToConversation(
132135
conversationId = selectedConversation.id,
@@ -137,7 +140,7 @@ class HESupportViewModel @Inject constructor(
137140
_selectedConversation.value = result.conversation
138141
_messageSendResult.value = MessageSendResult.Success
139142
// Clear attachments after successful message send
140-
_attachments.value = emptyList()
143+
_attachmentState.value = AttachmentState()
141144
}
142145

143146
is CreateConversationResult.Error.Forbidden -> {
@@ -170,15 +173,103 @@ class HESupportViewModel @Inject constructor(
170173
}
171174

172175
fun addAttachments(uris: List<Uri>) {
173-
_attachments.value = _attachments.value + uris
176+
viewModelScope.launch(ioDispatcher) {
177+
_attachmentState.value = validateAndCreateAttachmentState(uris)
178+
}
179+
}
180+
181+
@Suppress("LoopWithTooManyJumpStatements")
182+
private suspend fun validateAndCreateAttachmentState(uris: List<Uri>): AttachmentState = withContext(ioDispatcher) {
183+
if (uris.isEmpty()) {
184+
return@withContext _attachmentState.value
185+
}
186+
187+
val validUris = mutableListOf<Uri>()
188+
val skippedUris = mutableListOf<Uri>()
189+
190+
// Calculate current total size
191+
var currentTotalSize = calculateTotalSize(_attachmentState.value.acceptedUris)
192+
193+
// Validate each new attachment
194+
for (uri in uris) {
195+
val fileSize = getFileSize(uri)
196+
197+
// Skip if we can't determine file size we just allow it to be added
198+
if (fileSize != null) {
199+
// Check if adding this file would exceed total size limit
200+
if (currentTotalSize + fileSize > MAX_TOTAL_SIZE_BYTES) {
201+
skippedUris.add(uri)
202+
continue
203+
}
204+
}
205+
206+
// File is valid, add it
207+
validUris.add(uri)
208+
currentTotalSize += fileSize ?: 0
209+
}
210+
211+
// Build the new attachment state
212+
val currentAccepted = _attachmentState.value.acceptedUris
213+
val newAccepted = currentAccepted + validUris
214+
215+
// Calculate rejected total size
216+
val rejectedTotalSize = calculateTotalSize(skippedUris)
217+
218+
AttachmentState(
219+
acceptedUris = newAccepted,
220+
rejectedUris = skippedUris,
221+
currentTotalSizeBytes = currentTotalSize,
222+
rejectedTotalSizeBytes = rejectedTotalSize
223+
)
224+
}
225+
226+
@Suppress("TooGenericExceptionCaught")
227+
private suspend fun getFileSize(uri: Uri): Long? = withContext(ioDispatcher) {
228+
try {
229+
application.contentResolver.openAssetFileDescriptor(uri, "r")?.use { descriptor ->
230+
descriptor.length
231+
}
232+
} catch (e: Exception) {
233+
appLogWrapper.d(AppLog.T.SUPPORT, "Could not determine file size for URI: $uri - ${e.message}")
234+
// Silently return null if we can't get the file size
235+
// This will be handled by the validation logic
236+
null
237+
}
174238
}
175239

240+
/**
241+
* Calculates the total size of all files in the list
242+
* @param uris List of URIs to calculate size for
243+
* @return Total size in bytes
244+
*/
245+
private suspend fun calculateTotalSize(uris: List<Uri>): Long {
246+
var totalSize = 0L
247+
for (uri in uris) {
248+
totalSize += getFileSize(uri) ?: 0L
249+
}
250+
return totalSize
251+
}
252+
253+
/**
254+
* Removes an attachment from the accepted list and attempts to re-include any previously
255+
* skipped files that can now fit within the size limit.
256+
*
257+
* This function removes the specified URI and then re-validates all previously skipped files
258+
* by calling [addAttachments], which ensures consistent validation logic and automatically
259+
* includes files that now fit within the available space.
260+
*/
176261
fun removeAttachment(uri: Uri) {
177-
_attachments.value = _attachments.value.filter { it != uri }
262+
viewModelScope.launch {
263+
// Remove the attachment and re-validate skipped files
264+
val currentState = _attachmentState.value.copy()
265+
val newAcceptedUris = currentState.acceptedUris.filter { it != uri }
266+
_attachmentState.value = currentState.copy(acceptedUris = newAcceptedUris)
267+
addAttachments(currentState.rejectedUris)
268+
}
178269
}
179270

180271
fun clearAttachments() {
181-
_attachments.value = emptyList()
272+
_attachmentState.value = AttachmentState()
182273
}
183274

184275
fun notifyGeneralError() {

0 commit comments

Comments
 (0)