diff --git a/WordPress/src/main/java/org/wordpress/android/support/aibot/ui/AIBotSupportActivity.kt b/WordPress/src/main/java/org/wordpress/android/support/aibot/ui/AIBotSupportActivity.kt index 10adaf9963fb..843180b87b63 100644 --- a/WordPress/src/main/java/org/wordpress/android/support/aibot/ui/AIBotSupportActivity.kt +++ b/WordPress/src/main/java/org/wordpress/android/support/aibot/ui/AIBotSupportActivity.kt @@ -93,6 +93,7 @@ class AIBotSupportActivity : AppCompatActivity() { val message = when (errorType) { ConversationsSupportViewModel.ErrorType.GENERAL -> getString(R.string.ai_bot_generic_error) ConversationsSupportViewModel.ErrorType.FORBIDDEN -> getString(R.string.he_support_forbidden_error) + ConversationsSupportViewModel.ErrorType.OFFLINE -> getString(R.string.no_network_title) } scope.launch { snackbarHostState.showSnackbar( diff --git a/WordPress/src/main/java/org/wordpress/android/support/common/ui/ConversationsSupportViewModel.kt b/WordPress/src/main/java/org/wordpress/android/support/common/ui/ConversationsSupportViewModel.kt index 1b3f613e2061..96e3dda5f677 100644 --- a/WordPress/src/main/java/org/wordpress/android/support/common/ui/ConversationsSupportViewModel.kt +++ b/WordPress/src/main/java/org/wordpress/android/support/common/ui/ConversationsSupportViewModel.kt @@ -19,7 +19,7 @@ import org.wordpress.android.util.NetworkUtilsWrapper abstract class ConversationsSupportViewModel( protected val accountStore: AccountStore, protected val appLogWrapper: AppLogWrapper, - private val networkUtilsWrapper: NetworkUtilsWrapper, + protected val networkUtilsWrapper: NetworkUtilsWrapper, ) : ViewModel() { sealed class NavigationEvent { data object NavigateToConversationDetail : NavigationEvent() @@ -140,6 +140,11 @@ abstract class ConversationsSupportViewModel( fun onConversationClick(conversation: ConversationType) { viewModelScope.launch { try { + if (!networkUtilsWrapper.isNetworkAvailable()) { + _errorMessage.value = ErrorType.OFFLINE + return@launch + } + _isLoadingConversation.value = true _selectedConversation.value = conversation _navigationEvents.emit(NavigationEvent.NavigateToConversationDetail) @@ -173,6 +178,10 @@ abstract class ConversationsSupportViewModel( fun onCreateNewConversationClick() { viewModelScope.launch { + if (!networkUtilsWrapper.isNetworkAvailable()) { + _errorMessage.value = ErrorType.OFFLINE + return@launch + } _navigationEvents.emit(NavigationEvent.NavigateToNewConversation) } } @@ -182,5 +191,6 @@ abstract class ConversationsSupportViewModel( enum class ErrorType { GENERAL, FORBIDDEN, + OFFLINE, } } 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 57c241f8c6e2..c0460f788c55 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 @@ -169,14 +169,31 @@ fun HEConversationDetailScreen( } if (showBottomSheet) { + // Close the sheet when sending completes + LaunchedEffect(messageSendResult) { + if (messageSendResult != null) { + // Clear draft only on success + if (messageSendResult is HESupportViewModel.MessageSendResult.Success) { + draftMessageText = "" + draftIncludeAppLogs = false + } + + // Dismiss sheet and clear result for both success and failure + onClearMessageSendResult() + scope.launch { + sheetState.hide() + }.invokeOnCompletion { + showBottomSheet = false + } + } + } + HEConversationReplyBottomSheet( sheetState = sheetState, isSending = isSendingMessage, - messageSendResult = messageSendResult, initialMessageText = draftMessageText, initialIncludeAppLogs = draftIncludeAppLogs, onDismiss = { currentMessage, currentIncludeAppLogs -> - // Save draft message when closing without sending draftMessageText = currentMessage draftIncludeAppLogs = currentIncludeAppLogs scope.launch { @@ -186,14 +203,9 @@ fun HEConversationDetailScreen( } }, onSend = { message, includeAppLogs -> + draftMessageText = message onSendMessage(message, includeAppLogs) }, - onMessageSentSuccessfully = { - // Clear draft after successful send - draftMessageText = "" - draftIncludeAppLogs = false - onClearMessageSendResult() - }, attachments = attachments, 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 15e0c1cfb6ea..a57b11e0ea8a 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 @@ -17,7 +17,6 @@ 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 @@ -37,12 +36,10 @@ import org.wordpress.android.support.he.util.AttachmentActionsListener fun HEConversationReplyBottomSheet( sheetState: androidx.compose.material3.SheetState, isSending: Boolean = false, - messageSendResult: HESupportViewModel.MessageSendResult? = null, initialMessageText: String = "", initialIncludeAppLogs: Boolean = false, onDismiss: (currentMessage: String, currentIncludeAppLogs: Boolean) -> Unit, onSend: (String, Boolean) -> Unit, - onMessageSentSuccessfully: () -> Unit, attachments: List = emptyList(), attachmentActionsListener: AttachmentActionsListener ) { @@ -50,25 +47,6 @@ fun HEConversationReplyBottomSheet( var includeAppLogs by remember { mutableStateOf(initialIncludeAppLogs) } val scrollState = rememberScrollState() - // Close the sheet when sending completes successfully - LaunchedEffect(messageSendResult) { - when (messageSendResult) { - is HESupportViewModel.MessageSendResult.Success -> { - // Message sent successfully, close the sheet and clear draft - onDismiss("", false) - onMessageSentSuccessfully() - } - is HESupportViewModel.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 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 e6c2f9861f68..bdaa22bd6160 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 @@ -123,6 +123,7 @@ class HESupportActivity : AppCompatActivity() { val message = when (errorType) { ConversationsSupportViewModel.ErrorType.GENERAL -> getString(R.string.he_support_generic_error) ConversationsSupportViewModel.ErrorType.FORBIDDEN -> getString(R.string.he_support_forbidden_error) + ConversationsSupportViewModel.ErrorType.OFFLINE -> getString(R.string.no_network_title) } scope.launch { snackbarHostState.showSnackbar( 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 c7a906fc1c72..39612f657acb 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 @@ -59,6 +59,11 @@ class HESupportViewModel @Inject constructor( ) { viewModelScope.launch(ioDispatcher) { try { + if (!networkUtilsWrapper.isNetworkAvailable()) { + _errorMessage.value = ErrorType.OFFLINE + return@launch + } + _isSendingMessage.value = true val files = tempAttachmentsUtil.createTempFilesFrom(_attachments.value) @@ -108,6 +113,12 @@ class HESupportViewModel @Inject constructor( fun onAddMessageToConversation(message: String) { viewModelScope.launch(ioDispatcher) { try { + if (!networkUtilsWrapper.isNetworkAvailable()) { + _messageSendResult.value = MessageSendResult.Failure + _errorMessage.value = ErrorType.OFFLINE + return@launch + } + val selectedConversation = _selectedConversation.value if (selectedConversation == null) { appLogWrapper.e(AppLog.T.SUPPORT, "Error answering a conversation: no conversation selected") diff --git a/WordPress/src/test/java/org/wordpress/android/support/common/ui/ConversationsSupportViewModelTest.kt b/WordPress/src/test/java/org/wordpress/android/support/common/ui/ConversationsSupportViewModelTest.kt index 03f47f2791e9..209c02cc2a8d 100644 --- a/WordPress/src/test/java/org/wordpress/android/support/common/ui/ConversationsSupportViewModelTest.kt +++ b/WordPress/src/test/java/org/wordpress/android/support/common/ui/ConversationsSupportViewModelTest.kt @@ -148,6 +148,17 @@ class ConversationsSupportViewModelTest : BaseUnitTest() { verify(appLogWrapper).e(any(), any()) } + @Test + fun `init sets NoNetwork state when network is not available`() = test { + whenever(networkUtilsWrapper.isNetworkAvailable()).thenReturn(false) + + viewModel.init() + advanceUntilIdle() + + assertThat(viewModel.conversationsState.value).isInstanceOf(ConversationsState.NoNetwork.javaClass) + assertThat(viewModel.conversations.value).isEmpty() + } + // Refresh Conversations Tests @Test @@ -180,6 +191,22 @@ class ConversationsSupportViewModelTest : BaseUnitTest() { assertThat(viewModel.conversationsState.value).isInstanceOf(ConversationsState.Error.javaClass) } + @Test + fun `refreshConversations sets NoNetwork state when network is not available`() = test { + val initialConversations = createTestConversations(count = 2) + viewModel.setConversationsToReturn(initialConversations) + viewModel.init() + advanceUntilIdle() + + whenever(networkUtilsWrapper.isNetworkAvailable()).thenReturn(false) + viewModel.refreshConversations() + advanceUntilIdle() + + assertThat(viewModel.conversationsState.value).isInstanceOf(ConversationsState.NoNetwork.javaClass) + // Conversations should remain unchanged from previous load + assertThat(viewModel.conversations.value).isEqualTo(initialConversations) + } + // Clear Error Tests @Test @@ -275,6 +302,37 @@ class ConversationsSupportViewModelTest : BaseUnitTest() { verify(appLogWrapper).e(any(), any()) } + @Test + fun `onConversationClick sets OFFLINE error when network is not available`() = test { + val conversation = createTestConversation(1) + whenever(networkUtilsWrapper.isNetworkAvailable()).thenReturn(false) + + viewModel.onConversationClick(conversation) + advanceUntilIdle() + + assertThat(viewModel.errorMessage.value).isEqualTo(ConversationsSupportViewModel.ErrorType.OFFLINE) + assertThat(viewModel.isLoadingConversation.value).isFalse + } + + @Test + fun `onConversationClick does not navigate when network is not available`() = test { + val conversation = createTestConversation(1) + whenever(networkUtilsWrapper.isNetworkAvailable()).thenReturn(false) + + var emittedEvent: ConversationsSupportViewModel.NavigationEvent? = null + val job = launch { + viewModel.navigationEvents.collect { event -> + emittedEvent = event + } + } + + viewModel.onConversationClick(conversation) + advanceUntilIdle() + + assertThat(emittedEvent).isNull() + job.cancel() + } + @Test fun `onBackFromDetailClick clears selected conversation`() = test { val conversation = createTestConversation(1) @@ -322,6 +380,34 @@ class ConversationsSupportViewModelTest : BaseUnitTest() { job.cancel() } + @Test + fun `onCreateNewConversationClick sets OFFLINE error when network is not available`() = test { + whenever(networkUtilsWrapper.isNetworkAvailable()).thenReturn(false) + + viewModel.onCreateNewConversationClick() + advanceUntilIdle() + + assertThat(viewModel.errorMessage.value).isEqualTo(ConversationsSupportViewModel.ErrorType.OFFLINE) + } + + @Test + fun `onCreateNewConversationClick does not navigate when network is not available`() = test { + whenever(networkUtilsWrapper.isNetworkAvailable()).thenReturn(false) + + var emittedEvent: ConversationsSupportViewModel.NavigationEvent? = null + val job = launch { + viewModel.navigationEvents.collect { event -> + emittedEvent = event + } + } + + viewModel.onCreateNewConversationClick() + advanceUntilIdle() + + assertThat(emittedEvent).isNull() + job.cancel() + } + @Test fun `setNewConversation sets selected conversation and emits navigation event`() = test { val conversation = createTestConversation(1) 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 01575e0371a0..46359d635bed 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 @@ -12,6 +12,7 @@ import org.mockito.Mock import org.mockito.kotlin.any import org.mockito.kotlin.eq import org.mockito.kotlin.mock +import org.mockito.kotlin.never import org.mockito.kotlin.verify import org.mockito.kotlin.whenever import org.wordpress.android.BaseUnitTest @@ -202,6 +203,35 @@ class HESupportViewModelTest : BaseUnitTest() { assertThat(viewModel.isSendingMessage.value).isFalse } + @Test + fun `onSendNewConversation sets OFFLINE error when network is not available`() = test { + whenever(networkUtilsWrapper.isNetworkAvailable()).thenReturn(false) + + viewModel.onSendNewConversation( + subject = "Test Subject", + message = "Test Message", + tags = listOf("tag1") + ) + advanceUntilIdle() + + assertThat(viewModel.errorMessage.value).isEqualTo(ConversationsSupportViewModel.ErrorType.OFFLINE) + assertThat(viewModel.isSendingMessage.value).isFalse + } + + @Test + fun `onSendNewConversation does not call repository when network is not available`() = test { + whenever(networkUtilsWrapper.isNetworkAvailable()).thenReturn(false) + + viewModel.onSendNewConversation( + subject = "Test Subject", + message = "Test Message", + tags = listOf("tag1") + ) + advanceUntilIdle() + + verify(heSupportRepository, never()).createConversation(any(), any(), any(), any()) + } + // endregion // region getConversation() override tests @@ -347,6 +377,67 @@ class HESupportViewModelTest : BaseUnitTest() { assertThat(viewModel.isSendingMessage.value).isFalse } + @Test + fun `onAddMessageToConversation sets OFFLINE error when network is not available`() = test { + val existingConversation = createTestConversation(1) + whenever(heSupportRepository.loadConversation(1L)).thenReturn(existingConversation) + + // Network available when loading conversation + viewModel.onConversationClick(existingConversation) + advanceUntilIdle() + + // Network unavailable when sending message + whenever(networkUtilsWrapper.isNetworkAvailable()).thenReturn(false) + + viewModel.onAddMessageToConversation( + message = "Test message" + ) + advanceUntilIdle() + + assertThat(viewModel.errorMessage.value).isEqualTo(ConversationsSupportViewModel.ErrorType.OFFLINE) + assertThat(viewModel.isSendingMessage.value).isFalse + } + + @Test + fun `onAddMessageToConversation sets Failure result when network is not available`() = test { + val existingConversation = createTestConversation(1) + whenever(heSupportRepository.loadConversation(1L)).thenReturn(existingConversation) + + // Network available when loading conversation + viewModel.onConversationClick(existingConversation) + advanceUntilIdle() + + // Network unavailable when sending message + whenever(networkUtilsWrapper.isNetworkAvailable()).thenReturn(false) + + viewModel.onAddMessageToConversation( + message = "Test message" + ) + advanceUntilIdle() + + assertThat(viewModel.messageSendResult.value).isEqualTo(HESupportViewModel.MessageSendResult.Failure) + } + + @Test + fun `onAddMessageToConversation does not call repository when network is not available`() = test { + val existingConversation = createTestConversation(1) + whenever(heSupportRepository.loadConversation(1L)).thenReturn(existingConversation) + + // Network available when loading conversation + viewModel.onConversationClick(existingConversation) + advanceUntilIdle() + + // Network unavailable when sending message + whenever(networkUtilsWrapper.isNetworkAvailable()).thenReturn(false) + + viewModel.onAddMessageToConversation( + message = "Test message" + ) + advanceUntilIdle() + + verify(heSupportRepository, never()).addMessageToConversation(any(), any(), any()) + } + // endregion // region Attachment management tests