diff --git a/app/src/main/kotlin/com/wire/android/di/accountScoped/MeetingModule.kt b/app/src/main/kotlin/com/wire/android/di/accountScoped/MeetingModule.kt index 6c04c1fd6d4..6831282845d 100644 --- a/app/src/main/kotlin/com/wire/android/di/accountScoped/MeetingModule.kt +++ b/app/src/main/kotlin/com/wire/android/di/accountScoped/MeetingModule.kt @@ -22,6 +22,7 @@ import com.wire.android.di.KaliumCoreLogic import com.wire.kalium.logic.CoreLogic import com.wire.kalium.logic.data.user.UserId import com.wire.kalium.logic.feature.meeting.CreateNewMeetingUseCase +import com.wire.kalium.logic.feature.meeting.EnsureMeetingIsMLSEstablishedUseCase import com.wire.kalium.logic.feature.meeting.GetPaginatedMeetingOccurrencesUseCase import com.wire.kalium.logic.feature.meeting.MeetingScope import com.wire.kalium.logic.feature.meeting.ObserveMeetingOccurrenceUseCase @@ -49,6 +50,10 @@ class MeetingModule { fun provideDeleteMeetingUseCase(meetingScope: MeetingScope): com.wire.kalium.logic.feature.meeting.DeleteMeetingUseCase = meetingScope.deleteMeeting + @Provides + fun ensureMeetingIsMLSEstablishedUseCase(meetingScope: MeetingScope): EnsureMeetingIsMLSEstablishedUseCase = + meetingScope.ensureMeetingIsMLSEstablished + @Provides fun provideCreateNewMeetingUseCase(meetingScope: MeetingScope): CreateNewMeetingUseCase = meetingScope.createNewMeeting diff --git a/app/src/main/kotlin/com/wire/android/ui/calling/CallingViewModelFactory.kt b/app/src/main/kotlin/com/wire/android/ui/calling/CallingViewModelFactory.kt index 09567bd9b73..0772d9e804b 100644 --- a/app/src/main/kotlin/com/wire/android/ui/calling/CallingViewModelFactory.kt +++ b/app/src/main/kotlin/com/wire/android/ui/calling/CallingViewModelFactory.kt @@ -77,6 +77,7 @@ import com.wire.kalium.logic.feature.conversation.ObserveConversationDetailsUseC import com.wire.kalium.logic.feature.conversation.ObserveDegradedConversationNotifiedUseCase import com.wire.kalium.logic.feature.conversation.SetUserInformedAboutVerificationUseCase import com.wire.kalium.logic.feature.incallreaction.SendInCallReactionUseCase +import com.wire.kalium.logic.feature.meeting.EnsureMeetingIsMLSEstablishedUseCase import com.wire.kalium.logic.feature.session.CurrentSessionFlowUseCase import com.wire.kalium.logic.feature.session.CurrentSessionUseCase import com.wire.kalium.logic.feature.user.ObserveSelfUserUseCase @@ -140,6 +141,7 @@ class CallingViewModelFactory @Inject constructor( private val observeDegradedConversationNotified: ObserveDegradedConversationNotifiedUseCase, private val observeConferenceCallingEnabled: ObserveConferenceCallingEnabledUseCase, private val observeSelf: ObserveSelfUserUseCase, + private val ensureMeetingIsMLSEstablished: EnsureMeetingIsMLSEstablishedUseCase, ) { fun callActivityViewModel() = CallActivityViewModel( dispatchers = dispatchers, @@ -244,6 +246,7 @@ class CallingViewModelFactory @Inject constructor( setUserInformedAboutVerification = setUserInformedAboutVerification, observeDegradedConversationNotified = observeDegradedConversationNotified, observeSelf = observeSelf, + ensureMeetingIsMLSEstablished = ensureMeetingIsMLSEstablished, ) fun conversationListCallViewModel() = ConversationListCallViewModelImpl( diff --git a/app/src/main/kotlin/com/wire/android/ui/home/meetings/MeetingsCallViewModel.kt b/app/src/main/kotlin/com/wire/android/ui/home/meetings/MeetingsCallViewModel.kt index aee02cde020..bed2b36e2d7 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/meetings/MeetingsCallViewModel.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/meetings/MeetingsCallViewModel.kt @@ -20,8 +20,11 @@ package com.wire.android.ui.home.meetings import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.wire.android.ui.common.visbility.VisibilityState import com.wire.android.ui.home.conversations.call.JoinOrStartCallManager import com.wire.android.ui.home.conversations.details.participants.usecase.ObserveParticipantsForConversationUseCase +import com.wire.kalium.logic.data.conversation.Conversation +import com.wire.kalium.logic.data.id.ConversationId import com.wire.kalium.logic.data.user.UserId import com.wire.kalium.logic.feature.call.usecase.AnswerCallUseCase import com.wire.kalium.logic.feature.call.usecase.EndCallUseCase @@ -29,8 +32,10 @@ import com.wire.kalium.logic.feature.call.usecase.IsEligibleToStartCallUseCase import com.wire.kalium.logic.feature.call.usecase.ObserveEstablishedCallsUseCase import com.wire.kalium.logic.feature.conversation.ObserveDegradedConversationNotifiedUseCase import com.wire.kalium.logic.feature.conversation.SetUserInformedAboutVerificationUseCase +import com.wire.kalium.logic.feature.meeting.EnsureMeetingIsMLSEstablishedUseCase import com.wire.kalium.logic.feature.user.ObserveSelfUserUseCase import com.wire.kalium.logic.sync.ObserveSyncStateUseCase +import kotlinx.coroutines.launch @Suppress("LongParameterList", "TooManyFunctions") class MeetingsCallViewModel( @@ -43,8 +48,11 @@ class MeetingsCallViewModel( private val isConferenceCallingEnabled: IsEligibleToStartCallUseCase, private val setUserInformedAboutVerification: SetUserInformedAboutVerificationUseCase, private val observeDegradedConversationNotified: ObserveDegradedConversationNotifiedUseCase, - private val observeSelf: ObserveSelfUserUseCase + private val observeSelf: ObserveSelfUserUseCase, + private val ensureMeetingIsMLSEstablished: EnsureMeetingIsMLSEstablishedUseCase, ) : ViewModel() { + val notEstablishedDialogState: VisibilityState = VisibilityState() + val callManager = JoinOrStartCallManager( scope = viewModelScope, currentAccount = currentAccount, @@ -58,4 +66,31 @@ class MeetingsCallViewModel( observeDegradedConversationNotified = observeDegradedConversationNotified, observeSelf = observeSelf, ) + + fun joinOngoingCall(conversationId: ConversationId) { + viewModelScope.launch { + ensureMLSEstablished(conversationId) { + callManager.joinOngoingCall(conversationId) + } + } + } + + fun startCallIfPossible(conversationId: ConversationId) { + viewModelScope.launch { + ensureMLSEstablished(conversationId) { + callManager.startCallIfPossible( + conversationId = conversationId, + conversationType = Conversation.Type.Group.Meeting, + shouldCheckParticipantCount = false, // since this is a meeting, we don't need to check participant count + ) + } + } + } + + private suspend fun ensureMLSEstablished(conversationId: ConversationId, actionIfEstablished: suspend () -> Unit) = + if (ensureMeetingIsMLSEstablished(conversationId)) { + actionIfEstablished() + } else { + notEstablishedDialogState.show(Unit) + } } diff --git a/app/src/main/kotlin/com/wire/android/ui/home/meetings/MeetingsScreen.kt b/app/src/main/kotlin/com/wire/android/ui/home/meetings/MeetingsScreen.kt index 31b3c849f60..33914eec030 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/meetings/MeetingsScreen.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/meetings/MeetingsScreen.kt @@ -20,20 +20,28 @@ package com.wire.android.ui.home.meetings import androidx.compose.foundation.layout.PaddingValues import androidx.compose.runtime.Composable import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource import com.ramcosta.composedestinations.generated.meetings.destinations.NewMeetingScreenDestination import com.wire.android.feature.meetings.ui.AllMeetingsScreen import com.wire.android.feature.meetings.ui.NewMeetingBottomSheet -import com.wire.android.navigation.HomeDestination -import com.wire.android.navigation.annotation.app.WireHomeDestination -import com.wire.android.ui.common.dimensions import com.wire.android.feature.meetings.ui.create.NewMeetingType +import com.wire.android.navigation.HomeDestination import com.wire.android.navigation.NavigationCommand +import com.wire.android.navigation.annotation.app.WireHomeDestination import com.wire.android.ui.calling.meetingsCallViewModel import com.wire.android.ui.calling.ongoing.getOngoingCallIntent +import com.wire.android.ui.common.WireDialog +import com.wire.android.ui.common.WireDialogButtonProperties +import com.wire.android.ui.common.WireDialogButtonType +import com.wire.android.ui.common.VisibilityState +import com.wire.android.ui.common.button.WireButtonState +import com.wire.android.ui.common.dimensions +import com.wire.android.ui.common.visbility.VisibilityState import com.wire.android.ui.home.HomeStateHolder import com.wire.android.ui.home.conversations.call.HandleActions import com.wire.android.ui.home.conversations.call.HandleJoinOrStartCallScreenDialogs -import com.wire.kalium.logic.data.conversation.Conversation +import com.wire.android.feature.meetings.R as meetingsR +import com.wire.android.ui.common.R as commonR @WireHomeDestination @Composable @@ -46,14 +54,10 @@ fun MeetingsScreen( lazyListState = homeStateHolder.lazyListStateFor(HomeDestination.Meetings), contentPadding = PaddingValues(bottom = dimensions().spacing80x), // to ensure last item is not obscured by FAB startCall = { conversationId -> - viewModel.callManager.startCallIfPossible( - conversationId = conversationId, - conversationType = Conversation.Type.Group.Regular, - shouldCheckParticipantCount = false, // since this is a meeting, we don't need to check participant count - ) + viewModel.startCallIfPossible(conversationId = conversationId) }, joinCall = { conversationId -> - viewModel.callManager.joinOngoingCall(conversationId = conversationId) + viewModel.joinOngoingCall(conversationId = conversationId) }, returnToCall = { conversationId -> context.startActivity( @@ -69,6 +73,8 @@ fun MeetingsScreen( viewModel.callManager.actions.HandleActions() viewModel.callManager.HandleJoinOrStartCallScreenDialogs() + NotEstablishedDialog(dialogState = viewModel.notEstablishedDialogState) + NewMeetingBottomSheet( sheetState = homeStateHolder.newMeetingBottomSheetState, onMeetNowClick = { @@ -83,3 +89,21 @@ fun MeetingsScreen( } ) } + +@Composable +private fun NotEstablishedDialog(dialogState: VisibilityState) { + VisibilityState(dialogState) { + WireDialog( + title = stringResource(meetingsR.string.meeting_join_failure_title), + text = stringResource(id = meetingsR.string.meeting_join_failure_description), + buttonsHorizontalAlignment = true, + onDismiss = dialogState::dismiss, + optionButton1Properties = WireDialogButtonProperties( + onClick = dialogState::dismiss, + text = stringResource(id = commonR.string.label_ok), + type = WireDialogButtonType.Primary, + state = WireButtonState.Default, + ) + ) + } +} diff --git a/app/src/test/kotlin/com/wire/android/ui/home/meetings/MeetingsCallViewModelTest.kt b/app/src/test/kotlin/com/wire/android/ui/home/meetings/MeetingsCallViewModelTest.kt new file mode 100644 index 00000000000..9f1f5930cbe --- /dev/null +++ b/app/src/test/kotlin/com/wire/android/ui/home/meetings/MeetingsCallViewModelTest.kt @@ -0,0 +1,195 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ + +package com.wire.android.ui.home.meetings + +import app.cash.turbine.test +import com.wire.android.config.CoroutineTestExtension +import com.wire.android.framework.TestConversation +import com.wire.android.framework.TestUser +import com.wire.android.ui.home.conversations.call.JoinOrStartCallViewActions +import com.wire.android.ui.home.conversations.details.participants.model.ConversationParticipantsData +import com.wire.android.ui.home.conversations.details.participants.usecase.ObserveParticipantsForConversationUseCase +import com.wire.kalium.logic.data.conversation.Conversation +import com.wire.kalium.logic.data.sync.SyncState +import com.wire.kalium.logic.data.user.type.UserType +import com.wire.kalium.logic.data.user.type.UserTypeInfo +import com.wire.kalium.logic.feature.call.usecase.AnswerCallUseCase +import com.wire.kalium.logic.feature.call.usecase.ConferenceCallingResult +import com.wire.kalium.logic.feature.call.usecase.EndCallUseCase +import com.wire.kalium.logic.feature.call.usecase.IsEligibleToStartCallUseCase +import com.wire.kalium.logic.feature.call.usecase.ObserveEstablishedCallsUseCase +import com.wire.kalium.logic.feature.conversation.ObserveDegradedConversationNotifiedUseCase +import com.wire.kalium.logic.feature.conversation.SetUserInformedAboutVerificationUseCase +import com.wire.kalium.logic.feature.meeting.EnsureMeetingIsMLSEstablishedUseCase +import com.wire.kalium.logic.feature.user.ObserveSelfUserUseCase +import com.wire.kalium.logic.sync.ObserveSyncStateUseCase +import io.mockk.MockKAnnotations +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.impl.annotations.MockK +import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith + +@ExtendWith(CoroutineTestExtension::class) +class MeetingsCallViewModelTest { + + @Suppress("UnusedFlow") + @Test + fun givenMeetingMLSIsEstablished_whenStartingCall_thenEnsureMLSAndInitiateMeetingCall() = runTest { + val (arrangement, viewModel) = Arrangement() + .withMeetingMLSEstablished() + .arrange() + + viewModel.callManager.actions.test { + viewModel.startCallIfPossible(TestConversation.ID) + advanceUntilIdle() + + assertEquals(JoinOrStartCallViewActions.InitiatedCall(TestConversation.ID, TestUser.SELF_USER_ID), awaitItem()) + assertFalse(viewModel.notEstablishedDialogState.isVisible) + coVerify(exactly = 1) { arrangement.ensureMeetingIsMLSEstablished(TestConversation.ID) } + coVerify(exactly = 1) { arrangement.isConferenceCallingEnabled(TestConversation.ID, Conversation.Type.Group.Meeting) } + coVerify(exactly = 0) { arrangement.observeParticipantsForConversation(any()) } + } + } + + @Test + fun givenMeetingMLSIsNotEstablished_whenStartingCall_thenShowFailureDialogAndDoNotInitiateCall() = runTest { + val (arrangement, viewModel) = Arrangement() + .withMeetingMLSNotEstablished() + .arrange() + + viewModel.callManager.actions.test { + viewModel.startCallIfPossible(TestConversation.ID) + advanceUntilIdle() + + expectNoEvents() + assertTrue(viewModel.notEstablishedDialogState.isVisible) + coVerify(exactly = 1) { arrangement.ensureMeetingIsMLSEstablished(TestConversation.ID) } + coVerify(exactly = 0) { arrangement.isConferenceCallingEnabled(any(), any()) } + } + } + + @Test + fun givenMeetingMLSIsEstablished_whenJoiningCall_thenEnsureMLSAndJoinCall() = runTest { + val (arrangement, viewModel) = Arrangement() + .withMeetingMLSEstablished() + .arrange() + + viewModel.callManager.actions.test { + viewModel.joinOngoingCall(TestConversation.ID) + advanceUntilIdle() + + assertEquals(JoinOrStartCallViewActions.JoinedCall(TestConversation.ID, TestUser.SELF_USER_ID), awaitItem()) + assertFalse(viewModel.notEstablishedDialogState.isVisible) + coVerify(exactly = 1) { arrangement.ensureMeetingIsMLSEstablished(TestConversation.ID) } + coVerify(exactly = 1) { arrangement.answerCall(conversationId = TestConversation.ID) } + } + } + + @Test + fun givenMeetingMLSIsNotEstablished_whenJoiningCall_thenShowFailureDialogAndDoNotJoinCall() = runTest { + val (arrangement, viewModel) = Arrangement() + .withMeetingMLSNotEstablished() + .arrange() + + viewModel.callManager.actions.test { + viewModel.joinOngoingCall(TestConversation.ID) + advanceUntilIdle() + + expectNoEvents() + assertTrue(viewModel.notEstablishedDialogState.isVisible) + coVerify(exactly = 1) { arrangement.ensureMeetingIsMLSEstablished(TestConversation.ID) } + coVerify(exactly = 0) { arrangement.answerCall(conversationId = any()) } + } + } + + inner class Arrangement { + @MockK + lateinit var observeEstablishedCalls: ObserveEstablishedCallsUseCase + + @MockK + lateinit var observeParticipantsForConversation: ObserveParticipantsForConversationUseCase + + @MockK + lateinit var answerCall: AnswerCallUseCase + + @MockK + lateinit var endCall: EndCallUseCase + + @MockK + lateinit var observeSyncState: ObserveSyncStateUseCase + + @MockK + lateinit var isConferenceCallingEnabled: IsEligibleToStartCallUseCase + + @MockK + lateinit var setUserInformedAboutVerification: SetUserInformedAboutVerificationUseCase + + @MockK + lateinit var observeDegradedConversationNotified: ObserveDegradedConversationNotifiedUseCase + + @MockK + lateinit var observeSelfUser: ObserveSelfUserUseCase + + @MockK + lateinit var ensureMeetingIsMLSEstablished: EnsureMeetingIsMLSEstablishedUseCase + + init { + MockKAnnotations.init(this, relaxUnitFun = true) + every { observeEstablishedCalls() } returns emptyFlow() + coEvery { observeParticipantsForConversation(any()) } returns flowOf(ConversationParticipantsData()) + coEvery { answerCall(conversationId = any()) } returns Unit + coEvery { endCall(any()) } returns Unit + every { observeSyncState() } returns flowOf(SyncState.Live) + coEvery { isConferenceCallingEnabled(any(), any()) } returns ConferenceCallingResult.Enabled + coEvery { setUserInformedAboutVerification(any()) } returns Unit + every { observeDegradedConversationNotified(any()) } returns flowOf(true) + coEvery { observeSelfUser() } returns flowOf(TestUser.SELF_USER.copy(userType = UserTypeInfo.Regular(UserType.GUEST))) + coEvery { ensureMeetingIsMLSEstablished(any()) } returns true + } + + fun withMeetingMLSEstablished() = apply { + coEvery { ensureMeetingIsMLSEstablished(any()) } returns true + } + fun withMeetingMLSNotEstablished() = apply { + coEvery { ensureMeetingIsMLSEstablished(any()) } returns false + } + fun arrange() = this to MeetingsCallViewModel( + currentAccount = TestUser.SELF_USER_ID, + observeEstablishedCalls = observeEstablishedCalls, + observeParticipantsForConversation = observeParticipantsForConversation, + answerCall = answerCall, + endCall = endCall, + observeSyncState = observeSyncState, + isConferenceCallingEnabled = isConferenceCallingEnabled, + setUserInformedAboutVerification = setUserInformedAboutVerification, + observeDegradedConversationNotified = observeDegradedConversationNotified, + observeSelf = observeSelfUser, + ensureMeetingIsMLSEstablished = ensureMeetingIsMLSEstablished, + ) + } +} diff --git a/app/stability/app-devDebug.stability b/app/stability/app-devDebug.stability index d313c9b67b5..dc16964e935 100644 --- a/app/stability/app-devDebug.stability +++ b/app/stability/app-devDebug.stability @@ -8033,6 +8033,13 @@ public fun com.wire.android.ui.home.meetings.MeetingsScreen(homeStateHolder: com - homeStateHolder: UNSTABLE (has mutable properties or unstable members) - viewModel: UNSTABLE (has mutable properties or unstable members) +@Composable +private fun com.wire.android.ui.home.meetings.NotEstablishedDialog(dialogState: com.wire.android.ui.common.visbility.VisibilityState): kotlin.Unit + skippable: false + restartable: true + params: + - dialogState: UNSTABLE (has mutable properties or unstable members) + @Composable public fun com.wire.android.ui.home.messagecomposer.ActiveMessageComposerInput(conversationId: com.wire.kalium.logic.data.id.QualifiedID, messageComposition: com.wire.android.ui.home.messagecomposer.model.MessageComposition, messageTextState: androidx.compose.foundation.text.input.TextFieldState, isTextExpanded: kotlin.Boolean, inputType: com.wire.android.ui.home.messagecomposer.state.InputType, focusRequester: androidx.compose.ui.focus.FocusRequester, keyboardOptions: androidx.compose.foundation.text.KeyboardOptions, onKeyboardAction: androidx.compose.foundation.text.input.KeyboardActionHandler?, canSendMessage: kotlin.Boolean, onSendButtonClicked: kotlin.Function0, onEditButtonClicked: kotlin.Function0, onChangeSelfDeletionClicked: kotlin.Function1<@[ParameterName(name = \, onToggleInputSize: kotlin.Function0, onCancelReply: kotlin.Function0, onCancelEdit: kotlin.Function0, onFocused: kotlin.Function0, onSelectedLineIndexChanged: kotlin.Function1, onLineBottomYCoordinateChanged: kotlin.Function1, showOptions: kotlin.Boolean, optionsSelected: kotlin.Boolean, onPlusClick: kotlin.Function0, modifier: androidx.compose.ui.Modifier): kotlin.Unit skippable: false diff --git a/features/meetings/src/main/res/values/strings.xml b/features/meetings/src/main/res/values/strings.xml index 3a5cfab61f9..45fc45a351b 100644 --- a/features/meetings/src/main/res/values/strings.xml +++ b/features/meetings/src/main/res/values/strings.xml @@ -83,6 +83,8 @@ Something went wrong while starting the meeting. Please try again. Could not schedule meeting Something went wrong while scheduling the meeting. Please try again. + Could not join meeting + Something went wrong while joining the meeting. Please try again. +%1$d more diff --git a/kalium b/kalium index adf8ca3692c..24033ed5fcd 160000 --- a/kalium +++ b/kalium @@ -1 +1 @@ -Subproject commit adf8ca3692cda94f4239c8ea64ec5d32094df4f0 +Subproject commit 24033ed5fcdcda7e4dbecb3fed9a0ff5e7aab815