diff --git a/app/src/main/kotlin/com/wire/android/di/accountScoped/CallsModule.kt b/app/src/main/kotlin/com/wire/android/di/accountScoped/CallsModule.kt index 516dc982bfd..803e627db87 100644 --- a/app/src/main/kotlin/com/wire/android/di/accountScoped/CallsModule.kt +++ b/app/src/main/kotlin/com/wire/android/di/accountScoped/CallsModule.kt @@ -197,4 +197,9 @@ class CallsModule { @Provides fun provideIsEligibleToStartCall(callsScope: CallsScope) = callsScope.isEligibleToStartCall + + @ViewModelScoped + @Provides + fun provideObserveConferenceCallingEnabledUseCase(callsScope: CallsScope) = + callsScope.observeConferenceCallingEnabled } diff --git a/app/src/main/kotlin/com/wire/android/navigation/HomeDestination.kt b/app/src/main/kotlin/com/wire/android/navigation/HomeDestination.kt index 42aed03d34a..24c30a17b74 100644 --- a/app/src/main/kotlin/com/wire/android/navigation/HomeDestination.kt +++ b/app/src/main/kotlin/com/wire/android/navigation/HomeDestination.kt @@ -19,22 +19,14 @@ package com.wire.android.navigation import androidx.annotation.DrawableRes -import androidx.navigation.NavBackStackEntry import com.ramcosta.composedestinations.spec.Direction import com.wire.android.R import com.wire.android.ui.destinations.AllConversationsScreenDestination import com.wire.android.ui.destinations.ArchiveScreenDestination -import com.wire.android.ui.destinations.FavoritesConversationsScreenDestination -import com.wire.android.ui.destinations.FolderConversationsScreenDestination -import com.wire.android.ui.destinations.GroupConversationsScreenDestination -import com.wire.android.ui.destinations.OneOnOneConversationsScreenDestination import com.wire.android.ui.destinations.SettingsScreenDestination import com.wire.android.ui.destinations.VaultScreenDestination import com.wire.android.ui.destinations.WhatsNewScreenDestination import com.wire.android.util.ui.UIText -import com.wire.kalium.logic.data.conversation.ConversationFilter -import kotlinx.collections.immutable.PersistentList -import kotlinx.collections.immutable.persistentListOf @Suppress("LongParameterList") sealed class HomeDestination( @@ -45,10 +37,6 @@ sealed class HomeDestination( val withUserAvatar: Boolean = true, val direction: Direction ) { - - internal fun NavBackStackEntry.baseRouteMatches(): Boolean = direction.route.getBaseRoute() == destination.route?.getBaseRoute() - open fun entryMatches(entry: NavBackStackEntry): Boolean = entry.baseRouteMatches() - data object Conversations : HomeDestination( title = UIText.StringResource(R.string.conversations_screen_title), icon = R.drawable.ic_conversation, @@ -57,43 +45,6 @@ sealed class HomeDestination( direction = AllConversationsScreenDestination ) - data object Favorites : HomeDestination( - title = UIText.StringResource(R.string.label_filter_favorites), - icon = R.drawable.ic_conversation, - isSearchable = true, - withNewConversationFab = true, - direction = FavoritesConversationsScreenDestination - ) - - data class Folder( - val folderNavArgs: FolderNavArgs - ) : HomeDestination( - title = UIText.DynamicString(folderNavArgs.folderName), - icon = R.drawable.ic_conversation, - isSearchable = true, - withNewConversationFab = true, - direction = FolderConversationsScreenDestination(folderNavArgs) - ) { - override fun entryMatches(entry: NavBackStackEntry): Boolean = - entry.baseRouteMatches() && FolderConversationsScreenDestination.argsFrom(entry).folderId == folderNavArgs.folderId - } - - data object Group : HomeDestination( - title = UIText.StringResource(R.string.label_filter_group), - icon = R.drawable.ic_conversation, - isSearchable = true, - withNewConversationFab = true, - direction = GroupConversationsScreenDestination - ) - - data object OneOnOne : HomeDestination( - title = UIText.StringResource(R.string.label_filter_one_on_one), - icon = R.drawable.ic_conversation, - isSearchable = true, - withNewConversationFab = true, - direction = OneOnOneConversationsScreenDestination - ) - data object Settings : HomeDestination( title = UIText.StringResource(R.string.settings_screen_title), icon = R.drawable.ic_settings, @@ -130,32 +81,11 @@ sealed class HomeDestination( companion object { private const val ITEM_NAME_PREFIX = "HomeNavigationItem." - fun values(): PersistentList = - persistentListOf(Conversations, Favorites, Group, OneOnOne, Settings, Vault, Archive, Support, WhatsNew) - } -} -fun HomeDestination.currentFilter(): ConversationFilter { - return when (this) { - HomeDestination.Conversations -> ConversationFilter.All - HomeDestination.Favorites -> ConversationFilter.Favorites - HomeDestination.Group -> ConversationFilter.Groups - HomeDestination.OneOnOne -> ConversationFilter.OneOnOne - is HomeDestination.Folder -> ConversationFilter.Folder(folderName = folderNavArgs.folderName, folderId = folderNavArgs.folderId) - HomeDestination.Archive, - HomeDestination.Settings, - HomeDestination.Support, - HomeDestination.Vault, - HomeDestination.WhatsNew -> ConversationFilter.All - } -} + fun fromRoute(fullRoute: String): HomeDestination? = + values().find { it.direction.route.getBaseRoute() == fullRoute.getBaseRoute() } -fun ConversationFilter.toDestination(): HomeDestination { - return when (this) { - ConversationFilter.All -> HomeDestination.Conversations - ConversationFilter.Favorites -> HomeDestination.Favorites - ConversationFilter.Groups -> HomeDestination.Group - ConversationFilter.OneOnOne -> HomeDestination.OneOnOne - is ConversationFilter.Folder -> HomeDestination.Folder(FolderNavArgs(folderId, folderName)) + fun values(): Array = + arrayOf(Conversations, Settings, Vault, Archive, Support, WhatsNew) } } diff --git a/app/src/main/kotlin/com/wire/android/navigation/NavigationUtils.kt b/app/src/main/kotlin/com/wire/android/navigation/NavigationUtils.kt index 24944b89d3e..9ba8c69233c 100644 --- a/app/src/main/kotlin/com/wire/android/navigation/NavigationUtils.kt +++ b/app/src/main/kotlin/com/wire/android/navigation/NavigationUtils.kt @@ -45,7 +45,7 @@ internal fun NavController.navigateToItem(command: NavigationCommand) { fun lastDestinationFromOtherGraph(graph: NavGraphSpec) = currentBackStack.value.lastOrNull { it.navGraph() != graph } - appLogger.d("[$TAG] -> command: ${command.destination.route.obfuscateId()}") + appLogger.d("[$TAG] -> command: ${command.destination.route.obfuscateId()} backStackMode:${command.backStackMode}") navigate(command.destination) { when (command.backStackMode) { BackStackMode.CLEAR_WHOLE, BackStackMode.CLEAR_TILL_START -> { diff --git a/app/src/main/kotlin/com/wire/android/ui/WireActivity.kt b/app/src/main/kotlin/com/wire/android/ui/WireActivity.kt index a95911f1af0..54d9e7ff6d6 100644 --- a/app/src/main/kotlin/com/wire/android/ui/WireActivity.kt +++ b/app/src/main/kotlin/com/wire/android/ui/WireActivity.kt @@ -517,16 +517,14 @@ class WireActivity : AppCompatActivity() { ) CustomBackendDialog( viewModel.globalAppState, - viewModel::dismissCustomBackendDialog - ) { - viewModel.customBackendDialogProceedButtonClicked { - navigate( - NavigationCommand( - WelcomeScreenDestination - ) - ) - } - } + viewModel::dismissCustomBackendDialog, + onConfirm = { + viewModel.customBackendDialogProceedButtonClicked { + navigate(NavigationCommand(WelcomeScreenDestination)) + } + }, + onTryAgain = viewModel::onCustomServerConfig + ) MaxAccountDialog( shouldShow = viewModel.globalAppState.maxAccountDialog, onConfirm = { diff --git a/app/src/main/kotlin/com/wire/android/ui/WireActivityDialogs.kt b/app/src/main/kotlin/com/wire/android/ui/WireActivityDialogs.kt index 8b8ee6e66b3..564950de627 100644 --- a/app/src/main/kotlin/com/wire/android/ui/WireActivityDialogs.kt +++ b/app/src/main/kotlin/com/wire/android/ui/WireActivityDialogs.kt @@ -57,8 +57,8 @@ import com.wire.android.ui.common.button.WireButtonState import com.wire.android.ui.common.button.WireSecondaryButton import com.wire.android.ui.common.dialogs.CustomServerDetailsDialog import com.wire.android.ui.common.dialogs.CustomServerDetailsDialogState -import com.wire.android.ui.common.dialogs.CustomServerInvalidJsonDialog -import com.wire.android.ui.common.dialogs.CustomServerInvalidJsonDialogState +import com.wire.android.ui.common.dialogs.CustomServerNoNetworkDialog +import com.wire.android.ui.common.dialogs.CustomServerNoNetworkDialogState import com.wire.android.ui.common.dialogs.MaxAccountAllowedDialogContent import com.wire.android.ui.common.dimensions import com.wire.android.ui.common.wireDialogPropertiesBuilder @@ -244,7 +244,8 @@ fun JoinConversationDialog( fun CustomBackendDialog( globalAppState: GlobalAppState, onDismiss: () -> Unit, - onConfirm: () -> Unit + onConfirm: () -> Unit, + onTryAgain: (String) -> Unit ) { when (globalAppState.customBackendDialog) { is CustomServerDetailsDialogState -> { @@ -255,8 +256,9 @@ fun CustomBackendDialog( ) } - is CustomServerInvalidJsonDialogState -> { - CustomServerInvalidJsonDialog( + is CustomServerNoNetworkDialogState -> { + CustomServerNoNetworkDialog( + onTryAgain = { onTryAgain(globalAppState.customBackendDialog.customServerUrl) }, onDismiss = onDismiss ) } @@ -581,6 +583,7 @@ fun PreviewCustomBackendDialog() { ) ), {}, + {}, {} ) } diff --git a/app/src/main/kotlin/com/wire/android/ui/WireActivityViewModel.kt b/app/src/main/kotlin/com/wire/android/ui/WireActivityViewModel.kt index d4ee4acfd2b..fc173d079cb 100644 --- a/app/src/main/kotlin/com/wire/android/ui/WireActivityViewModel.kt +++ b/app/src/main/kotlin/com/wire/android/ui/WireActivityViewModel.kt @@ -42,7 +42,7 @@ import com.wire.android.services.ServicesManager import com.wire.android.ui.authentication.devices.model.displayName import com.wire.android.ui.common.dialogs.CustomServerDetailsDialogState import com.wire.android.ui.common.dialogs.CustomServerDialogState -import com.wire.android.ui.common.dialogs.CustomServerInvalidJsonDialogState +import com.wire.android.ui.common.dialogs.CustomServerNoNetworkDialogState import com.wire.android.ui.joinConversation.JoinConversationViaCodeState import com.wire.android.ui.theme.ThemeOption import com.wire.android.util.CurrentScreen @@ -320,7 +320,7 @@ class WireActivityViewModel @Inject constructor( when (val result = deepLinkProcessor.get().invoke(intent?.data, isSharingIntent)) { DeepLinkResult.AuthorizationNeeded -> onAuthorizationNeeded() is DeepLinkResult.SSOLogin -> onSSOLogin(result) - is DeepLinkResult.CustomServerConfig -> onCustomServerConfig(result) + is DeepLinkResult.CustomServerConfig -> onCustomServerConfig(result.url) is DeepLinkResult.Failure.OngoingCall -> onCannotLoginDuringACall() is DeepLinkResult.Failure.Unknown -> appLogger.e("unknown deeplink failure") is DeepLinkResult.JoinConversation -> onConversationInviteDeepLink( @@ -429,13 +429,16 @@ class WireActivityViewModel @Inject constructor( } } - private suspend fun onCustomServerConfig(result: DeepLinkResult.CustomServerConfig) { - val customBackendDialogData = loadServerConfig(result.url)?.let { serverLinks -> - CustomServerDetailsDialogState(serverLinks = serverLinks) - } ?: CustomServerInvalidJsonDialogState - globalAppState = globalAppState.copy( - customBackendDialog = customBackendDialogData - ) + fun onCustomServerConfig(customServerUrl: String) { + viewModelScope.launch(dispatchers.io()) { + val customBackendDialogData = loadServerConfig(customServerUrl) + ?.let { serverLinks -> CustomServerDetailsDialogState(serverLinks = serverLinks) } + ?: CustomServerNoNetworkDialogState(customServerUrl) + + globalAppState = globalAppState.copy( + customBackendDialog = customBackendDialogData + ) + } } private suspend fun onConversationInviteDeepLink( diff --git a/app/src/main/kotlin/com/wire/android/ui/calling/CallActivityViewModel.kt b/app/src/main/kotlin/com/wire/android/ui/calling/CallActivityViewModel.kt index 0dd6344b588..004667bb057 100644 --- a/app/src/main/kotlin/com/wire/android/ui/calling/CallActivityViewModel.kt +++ b/app/src/main/kotlin/com/wire/android/ui/calling/CallActivityViewModel.kt @@ -30,7 +30,6 @@ import com.wire.kalium.logic.feature.session.CurrentSessionUseCase import com.wire.kalium.logic.feature.user.screenshotCensoring.ObserveScreenshotCensoringConfigResult import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Deferred -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.async import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.map @@ -61,7 +60,7 @@ class CallActivityViewModel @Inject constructor( } fun switchAccountIfNeeded(userId: UserId, actions: SwitchAccountActions) { - viewModelScope.launch(Dispatchers.IO) { + viewModelScope.launch(dispatchers.io()) { val shouldSwitchAccount = when (val result = currentSession()) { is CurrentSessionResult.Failure.Generic -> true CurrentSessionResult.Failure.SessionNotFound -> true diff --git a/app/src/main/kotlin/com/wire/android/ui/common/dialogs/CustomServerInvalidJsonDialog.kt b/app/src/main/kotlin/com/wire/android/ui/common/dialogs/CustomServerNoNetworkDialog.kt similarity index 59% rename from app/src/main/kotlin/com/wire/android/ui/common/dialogs/CustomServerInvalidJsonDialog.kt rename to app/src/main/kotlin/com/wire/android/ui/common/dialogs/CustomServerNoNetworkDialog.kt index 356d9b12f17..1ad8d752f0b 100644 --- a/app/src/main/kotlin/com/wire/android/ui/common/dialogs/CustomServerInvalidJsonDialog.kt +++ b/app/src/main/kotlin/com/wire/android/ui/common/dialogs/CustomServerNoNetworkDialog.kt @@ -28,29 +28,40 @@ import com.wire.android.ui.theme.WireTheme import com.wire.android.util.ui.PreviewMultipleThemes @Composable -internal fun CustomServerInvalidJsonDialog( +internal fun CustomServerNoNetworkDialog( + onTryAgain: () -> Unit, onDismiss: () -> Unit ) { WireDialog( - title = stringResource(R.string.custom_backend_invalid_deeplink_data_title), - text = stringResource(R.string.custom_backend_invalid_deeplink_data_body), + title = stringResource(R.string.custom_backend_error_title), + text = stringResource(R.string.custom_backend_error_no_internet_connection_body), onDismiss = onDismiss, + buttonsHorizontalAlignment = false, optionButton1Properties = WireDialogButtonProperties( - onClick = onDismiss, - text = stringResource(id = R.string.label_ok), + onClick = { + onTryAgain() + onDismiss() + }, + text = stringResource(id = R.string.custom_backend_error_no_internet_connection_try_again), type = WireDialogButtonType.Primary, - state = - WireButtonState.Default + state = WireButtonState.Default ), + optionButton2Properties = WireDialogButtonProperties( + onClick = onDismiss, + text = stringResource(id = R.string.label_cancel), + type = WireDialogButtonType.Secondary, + state = WireButtonState.Default + ) ) } -data object CustomServerInvalidJsonDialogState : CustomServerDialogState() +data class CustomServerNoNetworkDialogState(val customServerUrl: String) : CustomServerDialogState() @PreviewMultipleThemes @Composable -fun PreviewCustomServerInvalidJsonDialog() = WireTheme { - CustomServerInvalidJsonDialog( - onDismiss = { } +fun PreviewCustomServerNoNetworkDialog() = WireTheme { + CustomServerNoNetworkDialog( + onTryAgain = {}, + onDismiss = {} ) } diff --git a/app/src/main/kotlin/com/wire/android/ui/common/dialogs/calling/CallingFeatureActivatedDialog.kt b/app/src/main/kotlin/com/wire/android/ui/common/dialogs/calling/CallingFeatureActivatedDialog.kt new file mode 100644 index 00000000000..370f24eb6ca --- /dev/null +++ b/app/src/main/kotlin/com/wire/android/ui/common/dialogs/calling/CallingFeatureActivatedDialog.kt @@ -0,0 +1,44 @@ +/* + * Wire + * Copyright (C) 2024 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.common.dialogs.calling + +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.stringResource +import com.wire.android.R +import com.wire.android.ui.common.DialogTextSuffixLink +import com.wire.android.ui.common.WireDialog +import com.wire.android.ui.common.WireDialogButtonProperties +import com.wire.android.ui.common.WireDialogButtonType + +@Composable +fun CallingFeatureActivatedDialog(onDialogDismiss: () -> Unit) { + WireDialog( + title = stringResource(id = R.string.calling_feature_enabled_title_alert), + text = stringResource(id = R.string.calling_feature_enabled_message_alert), + onDismiss = onDialogDismiss, + textSuffixLink = DialogTextSuffixLink( + linkText = stringResource(R.string.calling_feature_enabled_message_link_alert), + linkUrl = stringResource(R.string.url_wire_enterprise) + ), + optionButton1Properties = WireDialogButtonProperties( + onClick = onDialogDismiss, + text = stringResource(id = R.string.label_ok), + type = WireDialogButtonType.Primary + ) + ) +} diff --git a/app/src/main/kotlin/com/wire/android/ui/common/dialogs/calling/CallingFeatureUnavailableDialog.kt b/app/src/main/kotlin/com/wire/android/ui/common/dialogs/calling/CallingFeatureUnavailableDialog.kt index 21864ebd840..55926a5503b 100644 --- a/app/src/main/kotlin/com/wire/android/ui/common/dialogs/calling/CallingFeatureUnavailableDialog.kt +++ b/app/src/main/kotlin/com/wire/android/ui/common/dialogs/calling/CallingFeatureUnavailableDialog.kt @@ -21,6 +21,7 @@ package com.wire.android.ui.common.dialogs.calling import androidx.compose.runtime.Composable import androidx.compose.ui.res.stringResource import com.wire.android.R +import com.wire.android.ui.common.DialogTextSuffixLink import com.wire.android.ui.common.WireDialog import com.wire.android.ui.common.WireDialogButtonProperties import com.wire.android.ui.common.WireDialogButtonType @@ -38,3 +39,47 @@ fun CallingFeatureUnavailableDialog(onDialogDismiss: () -> Unit) { ) ) } + +@Composable +fun CallingFeatureUnavailableTeamMemberDialog(onDialogDismiss: () -> Unit) { + WireDialog( + title = stringResource(id = R.string.calling_feature_unavailable_title_alert), + text = stringResource(id = R.string.calling_feature_unavailable_team_member_message_alert), + onDismiss = onDialogDismiss, + optionButton1Properties = WireDialogButtonProperties( + onClick = onDialogDismiss, + text = stringResource(id = R.string.label_ok), + type = WireDialogButtonType.Primary + ) + ) +} + +@Composable +fun CallingFeatureUnavailableTeamAdminDialog( + onUpgradeAction: (String) -> Unit, + onDialogDismiss: () -> Unit +) { + val upgradeLink = stringResource(R.string.url_team_management_login) + WireDialog( + title = stringResource(id = R.string.calling_feature_unavailable_team_admin_title_alert), + text = stringResource(id = R.string.calling_feature_unavailable_team_admin_message_alert), + onDismiss = onDialogDismiss, + textSuffixLink = DialogTextSuffixLink( + linkText = stringResource(R.string.calling_feature_unavailable_team_admin_message_link_alert), + linkUrl = stringResource(R.string.url_team_management_login) + ), + optionButton2Properties = WireDialogButtonProperties( + onClick = { + onUpgradeAction(upgradeLink) + onDialogDismiss() + }, + text = stringResource(id = R.string.calling_feature_unavailable_team_admin_upgrade_action_alert), + type = WireDialogButtonType.Primary + ), + optionButton1Properties = WireDialogButtonProperties( + onClick = onDialogDismiss, + text = stringResource(id = R.string.label_cancel), + type = WireDialogButtonType.Secondary + ) + ) +} diff --git a/app/src/main/kotlin/com/wire/android/ui/common/topappbar/ConversationFilterState.kt b/app/src/main/kotlin/com/wire/android/ui/common/topappbar/ConversationFilterState.kt new file mode 100644 index 00000000000..f9bb13e28fc --- /dev/null +++ b/app/src/main/kotlin/com/wire/android/ui/common/topappbar/ConversationFilterState.kt @@ -0,0 +1,53 @@ +/* + * Wire + * Copyright (C) 2024 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.common.topappbar + +import android.os.Bundle +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.Saver +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import com.wire.kalium.logic.data.conversation.ConversationFilter +import dev.ahmedmourad.bundlizer.Bundlizer + +@Composable +fun rememberConversationFilterState(): ConversationFilterState = rememberSaveable(saver = ConversationFilterState.saver()) { + ConversationFilterState() +} + +class ConversationFilterState(initialValue: ConversationFilter = ConversationFilter.All) { + var filter: ConversationFilter by mutableStateOf(initialValue) + private set + + fun changeFilter(newFilter: ConversationFilter) { + filter = newFilter + } + + companion object { + fun saver(): Saver = Saver( + save = { + Bundlizer.bundle(ConversationFilter.serializer(), it.filter) + }, + restore = { + ConversationFilterState(Bundlizer.unbundle(ConversationFilter.serializer(), it)) + } + ) + } +} diff --git a/app/src/main/kotlin/com/wire/android/ui/home/HomeScreen.kt b/app/src/main/kotlin/com/wire/android/ui/home/HomeScreen.kt index 32907047c6e..3b48dd13629 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/HomeScreen.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/HomeScreen.kt @@ -38,7 +38,6 @@ import androidx.compose.material3.ModalNavigationDrawer import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.ColorFilter @@ -64,13 +63,11 @@ import com.ramcosta.composedestinations.result.ResultRecipient import com.wire.android.R import com.wire.android.appLogger import com.wire.android.di.hiltViewModelScoped -import com.wire.android.navigation.FolderNavArgs import com.wire.android.navigation.HomeDestination import com.wire.android.navigation.NavigationCommand import com.wire.android.navigation.Navigator import com.wire.android.navigation.WireDestination import com.wire.android.navigation.handleNavigation -import com.wire.android.navigation.toDestination import com.wire.android.ui.NavGraphs import com.wire.android.ui.analytics.AnalyticsUsageViewModel import com.wire.android.ui.common.CollapsingTopBarScaffold @@ -121,14 +118,8 @@ fun HomeScreen( ) ) { homeViewModel.checkRequirements { it.navigate(navigator::navigate) } - val homeDestinations = remember(foldersViewModel.state().folders) { - HomeDestination.values() - .plus( - foldersViewModel.state().folders.map { HomeDestination.Folder(FolderNavArgs(it.id, it.name)) } - ) - } - val homeScreenState = rememberHomeScreenState(navigator, homeDestinations = homeDestinations) + val homeScreenState = rememberHomeScreenState(navigator) val notificationsPermissionDeniedDialogState = rememberVisibilityState() val showNotificationsPermissionDeniedDialog = { notificationsPermissionDeniedDialogState.show( @@ -318,6 +309,8 @@ fun HomeContent( exit = shrinkVertically() + fadeOut(), ) { HomeTopBar( + title = currentTitle.asString(), + currentFilter = currentConversationFilter, navigationItem = currentNavigationItem, userAvatarData = homeState.userAvatarData, elevation = dimensions().spacing0x, // CollapsingTopBarScaffold manages applied elevation @@ -347,7 +340,7 @@ fun HomeContent( } }, collapsingEnabled = !searchBarState.isSearchActive, - contentLazyListState = homeStateHolder.nullAbleLazyListStateFor(currentNavigationItem), + contentLazyListState = homeStateHolder.lazyListStateFor(currentNavigationItem), content = { /** * This "if" is a workaround, otherwise it can crash because of the SubcomposeLayout's nature. @@ -407,11 +400,7 @@ fun HomeContent( ConversationFilterSheetContent( onChangeFilter = { filter -> filterSheetState.hide() - openHomeDestination(filter.toDestination()) - }, - onChangeFolder = { - filterSheetState.hide() - openHomeDestination(it.toDestination()) + homeStateHolder.changeFilter(filter) }, filterSheetState = sheetContentState ) diff --git a/app/src/main/kotlin/com/wire/android/ui/home/HomeStateHolder.kt b/app/src/main/kotlin/com/wire/android/ui/home/HomeStateHolder.kt index f6e802ce309..ed39cb797a8 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/HomeStateHolder.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/HomeStateHolder.kt @@ -19,7 +19,6 @@ package com.wire.android.ui.home import androidx.compose.foundation.lazy.LazyListState -import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material3.DrawerState import androidx.compose.material3.DrawerValue import androidx.compose.material3.rememberDrawerState @@ -34,10 +33,13 @@ import androidx.navigation.compose.currentBackStackEntryAsState import com.wire.android.navigation.HomeDestination import com.wire.android.navigation.HomeDestination.Conversations import com.wire.android.navigation.Navigator -import com.wire.android.navigation.getBaseRoute import com.wire.android.navigation.rememberTrackingAnimatedNavController +import com.wire.android.ui.common.topappbar.ConversationFilterState +import com.wire.android.ui.common.topappbar.rememberConversationFilterState import com.wire.android.ui.common.topappbar.search.SearchBarState import com.wire.android.ui.common.topappbar.search.rememberSearchbarState +import com.wire.android.ui.home.conversationslist.filter.uiText +import com.wire.kalium.logic.data.conversation.ConversationFilter import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch @@ -49,18 +51,34 @@ class HomeStateHolder( val searchBarState: SearchBarState, val navigator: Navigator, private val currentNavigationItemState: State, - private val lazyListStates: Map, + private val conversationFilterState: ConversationFilterState, ) { val currentNavigationItem get() = currentNavigationItemState.value - fun lazyListStateFor(destination: HomeDestination): LazyListState { - return lazyListStates[destination] ?: error("No LazyListState found for $destination") - } + val currentConversationFilter + get() = conversationFilterState.filter - fun nullAbleLazyListStateFor(destination: HomeDestination): LazyListState? { - return lazyListStates[destination] - } + val currentTitle + get() = when (currentNavigationItemState.value) { + Conversations -> conversationFilterState.filter.uiText() + else -> currentNavigationItemState.value.title + } + + private val lazyListStatesMap = mutableMapOf() + + fun lazyListStateFor( + destination: HomeDestination, + conversationFilter: ConversationFilter = ConversationFilter.All, + ): LazyListState = + lazyListStatesMap.getOrPut( + key = destination.itemName + when (destination) { + Conversations -> ":$conversationFilter" // each filter has its own scroll state + else -> "" // other destinations shouldn't care about the conversation filter + } + ) { + LazyListState() + } fun closeDrawer() { coroutineScope.launch { @@ -73,15 +91,16 @@ class HomeStateHolder( drawerState.open() } } + + fun changeFilter(filter: ConversationFilter) = conversationFilterState.changeFilter(filter) } @Composable fun rememberHomeScreenState( navigator: Navigator, - homeDestinations: List, coroutineScope: CoroutineScope = rememberCoroutineScope(), - navController: NavHostController = rememberTrackingAnimatedNavController { route -> - homeDestinations.find { it.direction.route.getBaseRoute() == route }?.itemName + navController: NavHostController = rememberTrackingAnimatedNavController { + HomeDestination.fromRoute(it)?.itemName }, drawerState: DrawerState = rememberDrawerState(DrawerValue.Closed) ): HomeStateHolder { @@ -89,14 +108,14 @@ fun rememberHomeScreenState( val searchBarState = rememberSearchbarState() val navBackStackEntry by navController.currentBackStackEntryAsState() - val currentNavigationItemState = remember(homeDestinations) { + val currentNavigationItemState = remember { derivedStateOf { - navBackStackEntry?.let { entry -> homeDestinations.find { it.entryMatches(entry) } } ?: Conversations + navBackStackEntry?.destination?.route?.let { HomeDestination.fromRoute(it) } ?: Conversations } } - val lazyListStates = homeDestinations.associateWith { rememberLazyListState() } + val conversationFilterState = rememberConversationFilterState() - return remember(homeDestinations) { + return remember { HomeStateHolder( coroutineScope = coroutineScope, navController = navController, @@ -104,7 +123,7 @@ fun rememberHomeScreenState( searchBarState = searchBarState, navigator = navigator, currentNavigationItemState = currentNavigationItemState, - lazyListStates = lazyListStates + conversationFilterState = conversationFilterState, ) } } diff --git a/app/src/main/kotlin/com/wire/android/ui/home/HomeTopBar.kt b/app/src/main/kotlin/com/wire/android/ui/home/HomeTopBar.kt index 28039c3ea12..cc0ea36b60d 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/HomeTopBar.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/HomeTopBar.kt @@ -28,7 +28,6 @@ import com.wire.android.model.Clickable import com.wire.android.model.NameBasedAvatar import com.wire.android.model.UserAvatarData import com.wire.android.navigation.HomeDestination -import com.wire.android.navigation.currentFilter import com.wire.android.ui.common.avatar.UserProfileAvatar import com.wire.android.ui.common.avatar.UserProfileAvatarType import com.wire.android.ui.common.button.WireButtonState @@ -42,6 +41,8 @@ import com.wire.kalium.logic.data.user.UserAvailabilityStatus @Composable fun HomeTopBar( + title: String, + currentFilter: ConversationFilter, navigationItem: HomeDestination, userAvatarData: UserAvatarData, elevation: Dp, @@ -52,7 +53,7 @@ fun HomeTopBar( onOpenConversationFilter: (filter: ConversationFilter) -> Unit ) { WireCenterAlignedTopAppBar( - title = navigationItem.title.asString(), + title = title, onNavigationPressed = onHamburgerMenuClick, navigationIconType = NavigationIconType.Menu, actions = { @@ -60,12 +61,12 @@ fun HomeTopBar( WireTertiaryIconButton( iconResource = R.drawable.ic_filter, contentDescription = R.string.label_filter_conversations, - state = if (navigationItem.currentFilter() == ConversationFilter.All) { + state = if (currentFilter == ConversationFilter.All) { WireButtonState.Default } else { WireButtonState.Selected }, - onButtonClicked = { onOpenConversationFilter(navigationItem.currentFilter()) } + onButtonClicked = { onOpenConversationFilter(currentFilter) } ) } if (navigationItem.withUserAvatar) { @@ -100,7 +101,9 @@ fun HomeTopBar( fun PreviewTopBar() { WireTheme { HomeTopBar( + title = "Conversations", navigationItem = HomeDestination.Conversations, + currentFilter = ConversationFilter.All, userAvatarData = UserAvatarData(null, UserAvailabilityStatus.AVAILABLE), elevation = 0.dp, withLegalHoldIndicator = false, @@ -117,7 +120,9 @@ fun PreviewTopBar() { fun PreviewSettingsTopBarWithoutAvatar() { WireTheme { HomeTopBar( + title = "Settings", navigationItem = HomeDestination.Settings, + currentFilter = ConversationFilter.All, userAvatarData = UserAvatarData(null, UserAvailabilityStatus.AVAILABLE), elevation = 0.dp, withLegalHoldIndicator = false, @@ -134,7 +139,9 @@ fun PreviewSettingsTopBarWithoutAvatar() { fun PreviewTopBarWithNameBasedAvatar() { WireTheme { HomeTopBar( + title = "Conversations", navigationItem = HomeDestination.Conversations, + currentFilter = ConversationFilter.All, userAvatarData = UserAvatarData( asset = null, availabilityStatus = UserAvailabilityStatus.AVAILABLE, @@ -155,7 +162,9 @@ fun PreviewTopBarWithNameBasedAvatar() { fun PreviewTopBarWithLegalHold() { WireTheme { HomeTopBar( + title = "Archive", navigationItem = HomeDestination.Archive, + currentFilter = ConversationFilter.All, userAvatarData = UserAvatarData(null, UserAvailabilityStatus.AVAILABLE), elevation = 0.dp, withLegalHoldIndicator = true, diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/ConversationScreen.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/ConversationScreen.kt index c8979838185..560914f6d6d 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/ConversationScreen.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/ConversationScreen.kt @@ -107,7 +107,10 @@ import com.wire.android.ui.common.dialogs.InvalidLinkDialog import com.wire.android.ui.common.dialogs.PermissionPermanentlyDeniedDialog import com.wire.android.ui.common.dialogs.SureAboutMessagingInDegradedConversationDialog import com.wire.android.ui.common.dialogs.VisitLinkDialog +import com.wire.android.ui.common.dialogs.calling.CallingFeatureActivatedDialog import com.wire.android.ui.common.dialogs.calling.CallingFeatureUnavailableDialog +import com.wire.android.ui.common.dialogs.calling.CallingFeatureUnavailableTeamAdminDialog +import com.wire.android.ui.common.dialogs.calling.CallingFeatureUnavailableTeamMemberDialog import com.wire.android.ui.common.dialogs.calling.ConfirmStartCallDialog import com.wire.android.ui.common.dialogs.calling.JoinAnywayDialog import com.wire.android.ui.common.dialogs.calling.OngoingActiveCallDialog @@ -183,6 +186,7 @@ import com.wire.kalium.logic.data.id.ConversationId import com.wire.kalium.logic.data.message.MessageAssetStatus import com.wire.kalium.logic.data.message.SelfDeletionTimer import com.wire.kalium.logic.data.user.UserId +import com.wire.kalium.logic.data.user.type.UserType import com.wire.kalium.logic.feature.call.usecase.ConferenceCallingResult import kotlinx.collections.immutable.PersistentMap import kotlinx.coroutines.CoroutineScope @@ -309,6 +313,12 @@ fun ConversationScreen( } } + LaunchedEffect(Unit) { + conversationListCallViewModel.callingEnabled.collect { + showDialog.value = ConversationScreenDialogType.CALLING_FEATURE_ACTIVATED + } + } + conversationMigrationViewModel.migratedConversationId?.let { migratedConversationId -> navigator.navigate( NavigationCommand( @@ -397,9 +407,30 @@ fun ConversationScreen( } ConversationScreenDialogType.CALLING_FEATURE_UNAVAILABLE -> { - CallingFeatureUnavailableDialog(onDialogDismiss = { + CallingFeatureUnavailableDialog { showDialog.value = ConversationScreenDialogType.NONE - }) + } + } + + ConversationScreenDialogType.CALLING_FEATURE_UNAVAILABLE_TEAM_MEMBER -> { + CallingFeatureUnavailableTeamMemberDialog { + showDialog.value = ConversationScreenDialogType.NONE + } + } + + ConversationScreenDialogType.CALLING_FEATURE_UNAVAILABLE_TEAM_ADMIN -> { + CallingFeatureUnavailableTeamAdminDialog( + onUpgradeAction = uriHandler::openUri, + onDialogDismiss = { + showDialog.value = ConversationScreenDialogType.NONE + } + ) + } + + ConversationScreenDialogType.CALLING_FEATURE_ACTIVATED -> { + CallingFeatureActivatedDialog { + showDialog.value = ConversationScreenDialogType.NONE + } } ConversationScreenDialogType.VERIFICATION_DEGRADED -> { @@ -782,7 +813,16 @@ private fun startCallIfPossible( } ConferenceCallingResult.Disabled.OngoingCall -> ConversationScreenDialogType.ONGOING_ACTIVE_CALL - ConferenceCallingResult.Disabled.Unavailable -> ConversationScreenDialogType.CALLING_FEATURE_UNAVAILABLE + ConferenceCallingResult.Disabled.Unavailable -> { + when (conversationListCallViewModel.selfTeamRole.value) { + UserType.INTERNAL -> ConversationScreenDialogType.CALLING_FEATURE_UNAVAILABLE_TEAM_MEMBER + UserType.OWNER, + UserType.ADMIN -> ConversationScreenDialogType.CALLING_FEATURE_UNAVAILABLE_TEAM_ADMIN + + else -> ConversationScreenDialogType.CALLING_FEATURE_UNAVAILABLE + } + } + else -> ConversationScreenDialogType.NONE } showDialog.value = dialogValue diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/ConversationScreenDialogType.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/ConversationScreenDialogType.kt index 8cada4a4eeb..28b81774094 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/ConversationScreenDialogType.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/ConversationScreenDialogType.kt @@ -25,5 +25,8 @@ enum class ConversationScreenDialogType { CALL_CONFIRMATION, PING_CONFIRMATION, CALLING_FEATURE_UNAVAILABLE, + CALLING_FEATURE_UNAVAILABLE_TEAM_MEMBER, + CALLING_FEATURE_UNAVAILABLE_TEAM_ADMIN, + CALLING_FEATURE_ACTIVATED, NONE } diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/call/ConversationListCallViewModel.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/call/ConversationListCallViewModel.kt index 2f533ac6044..26c7fb86589 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/call/ConversationListCallViewModel.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/call/ConversationListCallViewModel.kt @@ -32,18 +32,22 @@ import com.wire.kalium.logic.data.conversation.ConversationDetails import com.wire.kalium.logic.data.id.ConversationId import com.wire.kalium.logic.data.id.QualifiedID import com.wire.kalium.logic.data.sync.SyncState +import com.wire.kalium.logic.data.user.type.UserType 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.ObserveConferenceCallingEnabledUseCase import com.wire.kalium.logic.feature.call.usecase.ObserveEstablishedCallsUseCase import com.wire.kalium.logic.feature.call.usecase.ObserveOngoingCallsUseCase import com.wire.kalium.logic.feature.conversation.ObserveConversationDetailsUseCase import com.wire.kalium.logic.feature.conversation.ObserveDegradedConversationNotifiedUseCase import com.wire.kalium.logic.feature.conversation.SetUserInformedAboutVerificationUseCase +import com.wire.kalium.logic.feature.user.GetSelfUserUseCase import com.wire.kalium.logic.sync.ObserveSyncStateUseCase import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged @@ -64,7 +68,9 @@ class ConversationListCallViewModel @Inject constructor( private val isConferenceCallingEnabled: IsEligibleToStartCallUseCase, private val observeConversationDetails: ObserveConversationDetailsUseCase, private val setUserInformedAboutVerification: SetUserInformedAboutVerificationUseCase, - private val observeDegradedConversationNotified: ObserveDegradedConversationNotifiedUseCase + private val observeDegradedConversationNotified: ObserveDegradedConversationNotifiedUseCase, + private val observeConferenceCallingEnabled: ObserveConferenceCallingEnabledUseCase, + private val getSelf: GetSelfUserUseCase ) : SavedStateViewModel(savedStateHandle) { private val conversationNavArgs: ConversationNavArgs = savedStateHandle.navArgs() @@ -72,6 +78,8 @@ class ConversationListCallViewModel @Inject constructor( var conversationCallViewState by mutableStateOf(ConversationCallViewState()) val shouldInformAboutVerification = mutableStateOf(false) + val selfTeamRole = mutableStateOf(UserType.GUEST) + val callingEnabled = MutableSharedFlow(replay = 1) var establishedCallConversationId: QualifiedID? = null @@ -80,6 +88,15 @@ class ConversationListCallViewModel @Inject constructor( observeEstablishedCall() observeParticipantsForConversation() observeInformedAboutDegradedVerification() + observeSelfTeamRole() + observeCallingActivatedEvent() + } + + private fun observeCallingActivatedEvent() { + viewModelScope.launch { + observeConferenceCallingEnabled() + .collectLatest { callingEnabled.emit(Unit) } + } } private fun observeParticipantsForConversation() { @@ -91,6 +108,14 @@ class ConversationListCallViewModel @Inject constructor( } } + private fun observeSelfTeamRole() { + viewModelScope.launch { + getSelf().collectLatest { self -> + selfTeamRole.value = self.userType + } + } + } + private fun observeInformedAboutDegradedVerification() = viewModelScope.launch { observeDegradedConversationNotified(conversationId).collect { shouldInformAboutVerification.value = !it } } diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/ConversationListViewModel.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/ConversationListViewModel.kt index 0814e5f74e6..070abed799e 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/ConversationListViewModel.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/ConversationListViewModel.kt @@ -214,16 +214,13 @@ class ConversationListViewModelImpl @AssistedInject constructor( .flowOn(dispatcher.io()) .cachedIn(viewModelScope) - private var notPaginatedConversationListState by mutableStateOf(ConversationListState.NotPaginated()) - override val conversationListState: ConversationListState = - if (usePagination) { - ConversationListState.Paginated( - conversations = conversationsPaginatedFlow, - domain = currentAccount.domain - ) - } else { - notPaginatedConversationListState + override var conversationListState by mutableStateOf( + when (usePagination) { + true -> ConversationListState.Paginated(conversations = conversationsPaginatedFlow, domain = currentAccount.domain) + false -> ConversationListState.NotPaginated() } + ) + private set init { if (!usePagination) { @@ -258,7 +255,7 @@ class ConversationListViewModelImpl @AssistedInject constructor( } .flowOn(dispatcher.io()) .collect { - notPaginatedConversationListState = notPaginatedConversationListState.copy( + conversationListState = ConversationListState.NotPaginated( isLoading = false, conversations = it, domain = currentAccount.domain diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/all/AllConversationsScreen.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/all/AllConversationsScreen.kt index 03318926dc3..3f9ff8fe23a 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/all/AllConversationsScreen.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/all/AllConversationsScreen.kt @@ -18,9 +18,9 @@ package com.wire.android.ui.home.conversationslist.all +import androidx.compose.animation.Crossfade import androidx.compose.foundation.text.input.TextFieldState import androidx.compose.runtime.Composable -import com.wire.android.navigation.FolderNavArgs import com.wire.android.navigation.HomeDestination import com.wire.android.navigation.HomeNavGraph import com.wire.android.navigation.WireDestination @@ -41,72 +41,24 @@ import kotlinx.coroutines.flow.flowOf @Composable fun AllConversationsScreen(homeStateHolder: HomeStateHolder) { with(homeStateHolder) { - ConversationsScreenContent( - navigator = navigator, - searchBarState = searchBarState, - conversationsSource = ConversationsSource.MAIN, - lazyListState = lazyListStateFor(HomeDestination.Conversations), - emptyListContent = { ConversationsEmptyContent(filter = ConversationFilter.All) } - ) - } -} - -@HomeNavGraph -@WireDestination -@Composable -fun FavoritesConversationsScreen(homeStateHolder: HomeStateHolder) { - with(homeStateHolder) { - ConversationsScreenContent( - navigator = navigator, - searchBarState = searchBarState, - conversationsSource = ConversationsSource.FAVORITES, - lazyListState = lazyListStateFor(HomeDestination.Favorites), - emptyListContent = { ConversationsEmptyContent(filter = ConversationFilter.Favorites) } - ) - } -} - -@HomeNavGraph -@WireDestination(navArgsDelegate = FolderNavArgs::class) -@Composable -fun FolderConversationsScreen(homeStateHolder: HomeStateHolder, args: FolderNavArgs) { - with(homeStateHolder) { - ConversationsScreenContent( - navigator = navigator, - searchBarState = searchBarState, - conversationsSource = ConversationsSource.FOLDER(args.folderId, args.folderName), - emptyListContent = { ConversationsEmptyContent(filter = ConversationFilter.Folder(args.folderId, args.folderName)) } - ) - } -} - -@HomeNavGraph -@WireDestination -@Composable -fun GroupConversationsScreen(homeStateHolder: HomeStateHolder) { - with(homeStateHolder) { - ConversationsScreenContent( - navigator = navigator, - searchBarState = searchBarState, - conversationsSource = ConversationsSource.GROUPS, - lazyListState = lazyListStateFor(HomeDestination.Group), - emptyListContent = { ConversationsEmptyContent(filter = ConversationFilter.Groups) } - ) - } -} - -@HomeNavGraph -@WireDestination -@Composable -fun OneOnOneConversationsScreen(homeStateHolder: HomeStateHolder) { - with(homeStateHolder) { - ConversationsScreenContent( - navigator = navigator, - searchBarState = searchBarState, - conversationsSource = ConversationsSource.ONE_ON_ONE, - lazyListState = lazyListStateFor(HomeDestination.OneOnOne), - emptyListContent = { ConversationsEmptyContent(filter = ConversationFilter.OneOnOne, domain = it) } - ) + Crossfade( + targetState = homeStateHolder.currentConversationFilter, + label = "Conversation filter change animation", + ) { filter -> + ConversationsScreenContent( + navigator = navigator, + searchBarState = searchBarState, + conversationsSource = when (filter) { + is ConversationFilter.All -> ConversationsSource.MAIN + is ConversationFilter.Favorites -> ConversationsSource.FAVORITES + is ConversationFilter.Groups -> ConversationsSource.GROUPS + is ConversationFilter.OneOnOne -> ConversationsSource.ONE_ON_ONE + is ConversationFilter.Folder -> ConversationsSource.FOLDER(filter.folderId, filter.folderName) + }, + lazyListState = lazyListStateFor(HomeDestination.Conversations, filter), + emptyListContent = { ConversationsEmptyContent(filter = ConversationFilter.All) } + ) + } } } diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/filter/ConversationFilterSheetContent.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/filter/ConversationFilterSheetContent.kt index 0dcb4da607f..4ae6fca2f91 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/filter/ConversationFilterSheetContent.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/filter/ConversationFilterSheetContent.kt @@ -28,7 +28,6 @@ import com.wire.kalium.logic.data.conversation.ConversationFilter fun ConversationFilterSheetContent( filterSheetState: ConversationFilterSheetState, onChangeFilter: (ConversationFilter) -> Unit, - onChangeFolder: (ConversationFilter.Folder) -> Unit, isBottomSheetVisible: () -> Boolean = { true } ) { when (filterSheetState.currentData.tab) { @@ -45,7 +44,7 @@ fun ConversationFilterSheetContent( FilterTab.FOLDERS -> { ConversationFoldersSheetContent( sheetData = filterSheetState.currentData, - onChangeFolder = onChangeFolder, + onChangeFolder = onChangeFilter, onBackClick = { filterSheetState.toFilters() } @@ -77,5 +76,5 @@ fun ConversationFilter.uiText(): UIText = when (this) { ConversationFilter.Favorites -> UIText.StringResource(R.string.label_filter_favorites) ConversationFilter.Groups -> UIText.StringResource(R.string.label_filter_group) ConversationFilter.OneOnOne -> UIText.StringResource(R.string.label_filter_one_on_one) - is ConversationFilter.Folder -> UIText.StringResource(R.string.label_filter_folders, this.folderName) + is ConversationFilter.Folder -> UIText.DynamicString(this.folderName) } diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/model/ConversationsSource.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/model/ConversationsSource.kt index 45a7aad4391..4efe537856a 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/model/ConversationsSource.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/model/ConversationsSource.kt @@ -22,11 +22,21 @@ import kotlinx.serialization.Serializable @Serializable sealed class ConversationsSource { + @Serializable data object MAIN : ConversationsSource() + + @Serializable data object ARCHIVE : ConversationsSource() + + @Serializable data object FAVORITES : ConversationsSource() + + @Serializable data object GROUPS : ConversationsSource() + + @Serializable data object ONE_ON_ONE : ConversationsSource() + @Serializable data class FOLDER(val folderId: String, val folderName: String) : ConversationsSource() } diff --git a/app/src/main/kotlin/com/wire/android/ui/settings/devices/DeviceDetailsScreen.kt b/app/src/main/kotlin/com/wire/android/ui/settings/devices/DeviceDetailsScreen.kt index 422d9647472..7a8894fe512 100644 --- a/app/src/main/kotlin/com/wire/android/ui/settings/devices/DeviceDetailsScreen.kt +++ b/app/src/main/kotlin/com/wire/android/ui/settings/devices/DeviceDetailsScreen.kt @@ -204,6 +204,12 @@ fun DeviceDetailsContent( ) { state.device.mlsClientIdentity?.let { identity -> item { + FolderHeader( + name = stringResource(id = R.string.label_mls_signature, state.mlsCipherSuiteSignature.orEmpty()).uppercase(), + modifier = Modifier + .background(MaterialTheme.wireColorScheme.background) + .fillMaxWidth() + ) DeviceMLSSignatureItem(identity.thumbprint, screenState::copyMessage) HorizontalDivider(color = MaterialTheme.wireColorScheme.background) } diff --git a/app/src/main/kotlin/com/wire/android/ui/settings/devices/DeviceDetailsViewModel.kt b/app/src/main/kotlin/com/wire/android/ui/settings/devices/DeviceDetailsViewModel.kt index f6d06b76b68..826154cc6b6 100644 --- a/app/src/main/kotlin/com/wire/android/ui/settings/devices/DeviceDetailsViewModel.kt +++ b/app/src/main/kotlin/com/wire/android/ui/settings/devices/DeviceDetailsViewModel.kt @@ -37,6 +37,7 @@ import com.wire.kalium.logic.CoreFailure import com.wire.kalium.logic.data.client.ClientType import com.wire.kalium.logic.data.client.DeleteClientParam import com.wire.kalium.logic.data.conversation.ClientId +import com.wire.kalium.logic.data.mlspublickeys.MLSPublicKeyType import com.wire.kalium.logic.data.user.UserId import com.wire.kalium.logic.feature.client.ClientFingerprintUseCase import com.wire.kalium.logic.feature.client.DeleteClientResult @@ -198,6 +199,9 @@ class DeviceDetailsViewModel @Inject constructor( isCurrentDevice = result.isCurrentClient, removeDeviceDialogState = RemoveDeviceDialogState.Hidden, canBeRemoved = !result.isCurrentClient && isSelfClient && result.client.type != ClientType.LegalHold, + mlsCipherSuiteSignature = MLSPublicKeyType.from( + result.client.mlsPublicKeys?.keys?.firstOrNull().orEmpty() + ).value.toString() ) } } diff --git a/app/src/main/kotlin/com/wire/android/ui/settings/devices/model/DeviceDetailsState.kt b/app/src/main/kotlin/com/wire/android/ui/settings/devices/model/DeviceDetailsState.kt index b68745619e9..96216d33068 100644 --- a/app/src/main/kotlin/com/wire/android/ui/settings/devices/model/DeviceDetailsState.kt +++ b/app/src/main/kotlin/com/wire/android/ui/settings/devices/model/DeviceDetailsState.kt @@ -37,5 +37,6 @@ data class DeviceDetailsState( val isE2EICertificateEnrollSuccess: Boolean = false, val isE2EICertificateEnrollError: Boolean = false, val isE2EIEnabled: Boolean = false, - val startGettingE2EICertificate: Boolean = false + val startGettingE2EICertificate: Boolean = false, + val mlsCipherSuiteSignature: String? = null, ) diff --git a/app/src/main/res/values-hu/strings.xml b/app/src/main/res/values-hu/strings.xml index ff5e1b7e98a..57ed2e7001d 100644 --- a/app/src/main/res/values-hu/strings.xml +++ b/app/src/main/res/values-hu/strings.xml @@ -1133,8 +1133,7 @@ Ez a beállítás az összes beszélgetésre érvényes ezen az eszközön.Fiókok URL: Honlap URL: Kiszolgáló WSURL: - Hiba történt - A saját kiszolgálóra történő átirányítás nem volt lehetséges, mivel a JSON fájl érvénytelen beállítást tartalmazott.\n\nLépjen kapcsolatba a rendszergazdával, vagy ellenőrizze a mélylinket, ami ide vezette. + Hiba történt Új üzenetek lekérdezése Szöveg a vágólapra másolva Naplók diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index 8dfcce63cb6..99ecf9a8a1b 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -1171,8 +1171,7 @@ URL аккаунта: URL веб-сайта: WSURL бэкэнда: - Произошла ошибка - Перенаправление на локальный бэкэнд было неудачным, поскольку в JSON-файле была неверная конфигурация.\n\nСвяжитесь с администратором или проверьте ссылку, которая привела вас сюда. + Произошла ошибка Получение новых сообщений Текст скопирован в буфер обмена Журналы diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 469b347d4f7..3e269a70f3c 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -282,6 +282,8 @@ https://support.wire.com/hc/articles/360002855817 https://wire.com/pricing https://teams.wire.com/ + https://teams.wire.com/login + https://wire.com/en/enterprise Vault Archive @@ -983,6 +985,14 @@ Double tap to go back Feature unavailable The option to initiate a conference call is only available in the paid version of Wire. + To start a conference call, your team needs to upgrade to the Enterprise plan. + Upgrade to Enterprise + Your team is currently on the free Basic plan. Upgrade to Enterprise for access to features such as starting conferences and more. + Learn more about Wire\'s pricing + Upgrade now + Wire Enterprise + Your team was upgraded to Wire Enterprise, which gives you access to features such as conference calls and more. + Learn more about Wire Enterprise Connecting… Start a call Are you sure you want to call %1$s people? @@ -1175,8 +1185,9 @@ In group conversations, the group admin can overwrite this setting. Accounts URL: Website URL: Backend WSURL: - An error occurred - Redirecting to an on-premises backend was not possible, as there was an invalid configuration in the JSON file.\n\nContact your admin or check the deeplink that brought you here. + An error occurred + Redirecting to an on-premises backend was not possible, you don’t seem to be connected to the internet.\n\nEstablish an internet connection and try again. + Try again Receiving new messages Text copied to clipboard Logs diff --git a/app/src/test/kotlin/com/wire/android/ui/WireActivityViewModelTest.kt b/app/src/test/kotlin/com/wire/android/ui/WireActivityViewModelTest.kt index 6169a00d52f..f7070a642bf 100644 --- a/app/src/test/kotlin/com/wire/android/ui/WireActivityViewModelTest.kt +++ b/app/src/test/kotlin/com/wire/android/ui/WireActivityViewModelTest.kt @@ -38,7 +38,7 @@ import com.wire.android.framework.TestUser import com.wire.android.migration.MigrationManager import com.wire.android.services.ServicesManager import com.wire.android.ui.common.dialogs.CustomServerDetailsDialogState -import com.wire.android.ui.common.dialogs.CustomServerInvalidJsonDialogState +import com.wire.android.ui.common.dialogs.CustomServerNoNetworkDialogState import com.wire.android.ui.common.topappbar.CommonTopAppBarViewModelTest import com.wire.android.ui.joinConversation.JoinConversationViaCodeState import com.wire.android.ui.theme.ThemeOption @@ -141,13 +141,13 @@ class WireActivityViewModelTest { } @Test - fun `given Intent with malformed ServerConfig json, when currentSessions is present, then initialAppState is LOGGED_IN and customBackEndInvalidJson dialog is shown`() = + fun `given intent with correct ServerConfig json, when no network is present, then initialAppState is LOGGED_IN and no network dialog is shown`() = runTest { val result = DeepLinkResult.CustomServerConfig("url") val (arrangement, viewModel) = Arrangement() .withSomeCurrentSession() .withDeepLinkResult(result) - .withMalformedServerJson() + .withNoNetworkConnectionWhenGettingServerConfig() .withNoOngoingCall() .arrange() @@ -155,17 +155,17 @@ class WireActivityViewModelTest { assertEquals(InitialAppState.LOGGED_IN, viewModel.initialAppState()) verify(exactly = 0) { arrangement.onDeepLinkResult(any()) } - assertInstanceOf(CustomServerInvalidJsonDialogState::class.java, viewModel.globalAppState.customBackendDialog) + assertInstanceOf(CustomServerNoNetworkDialogState::class.java, viewModel.globalAppState.customBackendDialog) } @Test - fun `given Intent with malformed ServerConfig json, when currentSessions is present, then initialAppState is NOT_LOGGED_IN and customBackEndInvalidJson dialog is shown`() = + fun `given Intent with malformed ServerConfig json, when currentSessions is absent, then initialAppState is NOT_LOGGED_IN and no network dialog is shown`() = runTest { val result = DeepLinkResult.CustomServerConfig("url") val (arrangement, viewModel) = Arrangement() .withNoCurrentSession() .withDeepLinkResult(result) - .withMalformedServerJson() + .withNoNetworkConnectionWhenGettingServerConfig() .withNoOngoingCall() .arrange() @@ -173,7 +173,7 @@ class WireActivityViewModelTest { assertEquals(InitialAppState.NOT_LOGGED_IN, viewModel.initialAppState()) verify(exactly = 0) { arrangement.onDeepLinkResult(any()) } - assertInstanceOf(CustomServerInvalidJsonDialogState::class.java, viewModel.globalAppState.customBackendDialog) + assertInstanceOf(CustomServerNoNetworkDialogState::class.java, viewModel.globalAppState.customBackendDialog) } @Test @@ -919,7 +919,7 @@ class WireActivityViewModelTest { coEvery { coreLogic.getSessionScope(TEST_ACCOUNT_INFO.userId).observeIfE2EIRequiredDuringLogin() } returns flowOf(false) } - fun withMalformedServerJson() = apply { + fun withNoNetworkConnectionWhenGettingServerConfig() = apply { coEvery { getServerConfigUseCase(any()) } returns GetServerConfigResult.Failure.Generic(NetworkFailure.NoNetworkConnection(null)) } diff --git a/app/src/test/kotlin/com/wire/android/ui/home/conversations/call/ConversationListCallViewModelTest.kt b/app/src/test/kotlin/com/wire/android/ui/home/conversations/call/ConversationListCallViewModelTest.kt index 183101b7bd6..272326163e7 100644 --- a/app/src/test/kotlin/com/wire/android/ui/home/conversations/call/ConversationListCallViewModelTest.kt +++ b/app/src/test/kotlin/com/wire/android/ui/home/conversations/call/ConversationListCallViewModelTest.kt @@ -18,20 +18,25 @@ package com.wire.android.ui.home.conversations.call import androidx.lifecycle.SavedStateHandle +import app.cash.turbine.test import com.wire.android.config.CoroutineTestExtension import com.wire.android.config.NavigationTestExtension +import com.wire.android.framework.TestUser import com.wire.android.ui.home.conversations.ConversationNavArgs import com.wire.android.ui.home.conversations.details.participants.usecase.ObserveParticipantsForConversationUseCase import com.wire.android.ui.navArgs import com.wire.kalium.logic.data.id.ConversationId +import com.wire.kalium.logic.data.user.type.UserType import com.wire.kalium.logic.feature.call.usecase.AnswerCallUseCase 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.ObserveConferenceCallingEnabledUseCase import com.wire.kalium.logic.feature.call.usecase.ObserveEstablishedCallsUseCase import com.wire.kalium.logic.feature.call.usecase.ObserveOngoingCallsUseCase import com.wire.kalium.logic.feature.conversation.ObserveConversationDetailsUseCase import com.wire.kalium.logic.feature.conversation.ObserveDegradedConversationNotifiedUseCase import com.wire.kalium.logic.feature.conversation.SetUserInformedAboutVerificationUseCase +import com.wire.kalium.logic.feature.user.GetSelfUserUseCase import com.wire.kalium.logic.sync.ObserveSyncStateUseCase import io.mockk.MockKAnnotations import io.mockk.coEvery @@ -40,125 +45,200 @@ import io.mockk.every import io.mockk.impl.annotations.MockK import kotlinx.coroutines.flow.emptyFlow import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.runTest import org.amshove.kluent.internal.assertEquals -import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test import org.junit.jupiter.api.extension.ExtendWith -@ExtendWith(CoroutineTestExtension::class) -@ExtendWith(NavigationTestExtension::class) +@ExtendWith(CoroutineTestExtension::class, NavigationTestExtension::class) class ConversationListCallViewModelTest { - @MockK - private lateinit var savedStateHandle: SavedStateHandle - - @MockK - private lateinit var observeOngoingCalls: ObserveOngoingCallsUseCase + @Test + fun `given join dialog displayed, when user dismiss it, then hide it`() = runTest { + val (_, viewModel) = Arrangement() + .withDefaultAnswers() + .arrange() + viewModel.conversationCallViewState = viewModel.conversationCallViewState.copy( + shouldShowJoinAnywayDialog = true + ) - @MockK - private lateinit var observeEstablishedCalls: ObserveEstablishedCallsUseCase + viewModel.dismissJoinCallAnywayDialog() - @MockK - private lateinit var joinCall: AnswerCallUseCase + assertEquals(false, viewModel.conversationCallViewState.shouldShowJoinAnywayDialog) + } - @MockK - private lateinit var endCall: EndCallUseCase + @Test + fun `given no ongoing call, when user tries to join a call, then invoke answerCall call use case`() = runTest { + val (arrangement, viewModel) = Arrangement() + .withDefaultAnswers() + .withJoinCallResponse() + .arrange() + viewModel.conversationCallViewState = viewModel.conversationCallViewState.copy(hasEstablishedCall = false) + + viewModel.joinOngoingCall(arrangement.onAnswered) + + coVerify(exactly = 1) { arrangement.joinCall(conversationId = any()) } + coVerify(exactly = 1) { arrangement.onAnswered(any()) } + assertEquals(false, viewModel.conversationCallViewState.shouldShowJoinAnywayDialog) + } - @MockK - private lateinit var observeSyncState: ObserveSyncStateUseCase + @Test + fun `given an ongoing call, when user tries to join a call, then show JoinCallAnywayDialog`() = runTest { + val (arrangement, viewModel) = Arrangement() + .withDefaultAnswers() + .arrange() + viewModel.conversationCallViewState = viewModel.conversationCallViewState.copy(hasEstablishedCall = true) - @MockK - private lateinit var isConferenceCallingEnabled: IsEligibleToStartCallUseCase + viewModel.joinOngoingCall(arrangement.onAnswered) - @MockK(relaxed = true) - private lateinit var onAnswered: (conversationId: ConversationId) -> Unit + assertEquals(true, viewModel.conversationCallViewState.shouldShowJoinAnywayDialog) + coVerify(inverse = true) { arrangement.joinCall(conversationId = any()) } + } - @MockK - private lateinit var observeConversationDetails: ObserveConversationDetailsUseCase + @Test + fun `given an ongoing call, when user confirms dialog to join a call, then end current call and join the newer one`() = runTest { + val (arrangement, viewModel) = Arrangement() + .withDefaultAnswers() + .withEndCallResponse() + .arrange() + viewModel.conversationCallViewState = + viewModel.conversationCallViewState.copy(hasEstablishedCall = true) + viewModel.establishedCallConversationId = ConversationId("value", "Domain") + + viewModel.joinAnyway(arrangement.onAnswered) + + coVerify(exactly = 1) { arrangement.endCall(any()) } + } - @MockK - private lateinit var observeParticipantsForConversation: ObserveParticipantsForConversationUseCase + @Test + fun `given self team role as admin in conversation, when we observe own role, then its properly propagated`() = runTest { + val (_, viewModel) = Arrangement() + .withDefaultAnswers() + .withSelfAsAdmin() + .arrange() - @MockK - lateinit var setUserInformedAboutVerificationUseCase: SetUserInformedAboutVerificationUseCase + val role = viewModel.selfTeamRole - @MockK - lateinit var observeDegradedConversationNotifiedUseCase: ObserveDegradedConversationNotifiedUseCase + assertEquals(UserType.ADMIN, role.value) + } - private lateinit var conversationListCallViewModel: ConversationListCallViewModel + @Test + fun `given calling enabled event, when we observe it, then its properly propagated`() = runTest { + val (_, viewModel) = Arrangement() + .withDefaultAnswers() + .withConferenceCallingEnabledResponse() + .arrange() - @BeforeEach - fun setUp() { - MockKAnnotations.init(this) - val conversationId = ConversationId("some-dummy-value", "some.dummy.domain") - every { savedStateHandle.navArgs() } returns ConversationNavArgs(conversationId = conversationId) - coEvery { observeEstablishedCalls.invoke() } returns emptyFlow() - coEvery { observeOngoingCalls.invoke() } returns emptyFlow() - coEvery { observeConversationDetails(any()) } returns flowOf() - coEvery { observeParticipantsForConversation(any()) } returns flowOf() - coEvery { setUserInformedAboutVerificationUseCase(any()) } returns Unit - coEvery { observeDegradedConversationNotifiedUseCase(any()) } returns flowOf(false) + val callingEnabled = viewModel.callingEnabled - conversationListCallViewModel = ConversationListCallViewModel( - savedStateHandle = savedStateHandle, - observeOngoingCalls = observeOngoingCalls, - observeEstablishedCalls = observeEstablishedCalls, - answerCall = joinCall, - endCall = endCall, - observeSyncState = observeSyncState, - isConferenceCallingEnabled = isConferenceCallingEnabled, - observeConversationDetails = observeConversationDetails, - observeParticipantsForConversation = observeParticipantsForConversation, - setUserInformedAboutVerification = setUserInformedAboutVerificationUseCase, - observeDegradedConversationNotified = observeDegradedConversationNotifiedUseCase - ) + callingEnabled.test { + assertEquals(Unit, awaitItem()) + } } @Test - fun `given join dialog displayed, when user dismiss it, then hide it`() { - conversationListCallViewModel.conversationCallViewState = conversationListCallViewModel.conversationCallViewState.copy( - shouldShowJoinAnywayDialog = true - ) + fun `given no calling enabled event, when we observe it, then there are no events propagated`() = runTest { + val (_, viewModel) = Arrangement() + .withDefaultAnswers() + .arrange() - conversationListCallViewModel.dismissJoinCallAnywayDialog() + val callingEnabled = viewModel.callingEnabled - assertEquals(false, conversationListCallViewModel.conversationCallViewState.shouldShowJoinAnywayDialog) + callingEnabled.test { + expectNoEvents() + } } - @Test - fun `given no ongoing call, when user tries to join a call, then invoke answerCall call use case`() { - conversationListCallViewModel.conversationCallViewState = - conversationListCallViewModel.conversationCallViewState.copy(hasEstablishedCall = false) + private class Arrangement { + @MockK + private lateinit var savedStateHandle: SavedStateHandle - coEvery { joinCall(conversationId = any()) } returns Unit + @MockK + private lateinit var observeOngoingCalls: ObserveOngoingCallsUseCase - conversationListCallViewModel.joinOngoingCall(onAnswered) + @MockK + private lateinit var observeEstablishedCalls: ObserveEstablishedCallsUseCase - coVerify(exactly = 1) { joinCall(conversationId = any()) } - coVerify(exactly = 1) { onAnswered(any()) } - assertEquals(false, conversationListCallViewModel.conversationCallViewState.shouldShowJoinAnywayDialog) - } + @MockK + lateinit var joinCall: AnswerCallUseCase - @Test - fun `given an ongoing call, when user tries to join a call, then show JoinCallAnywayDialog`() { - conversationListCallViewModel.conversationCallViewState = - conversationListCallViewModel.conversationCallViewState.copy(hasEstablishedCall = true) + @MockK + lateinit var endCall: EndCallUseCase - conversationListCallViewModel.joinOngoingCall(onAnswered) + @MockK + private lateinit var observeSyncState: ObserveSyncStateUseCase - assertEquals(true, conversationListCallViewModel.conversationCallViewState.shouldShowJoinAnywayDialog) - coVerify(inverse = true) { joinCall(conversationId = any()) } - } + @MockK + private lateinit var isConferenceCallingEnabled: IsEligibleToStartCallUseCase - @Test - fun `given an ongoing call, when user confirms dialog to join a call, then end current call and join the newer one`() { - conversationListCallViewModel.conversationCallViewState = - conversationListCallViewModel.conversationCallViewState.copy(hasEstablishedCall = true) - conversationListCallViewModel.establishedCallConversationId = ConversationId("value", "Domain") - coEvery { endCall(any()) } returns Unit + @MockK(relaxed = true) + lateinit var onAnswered: (conversationId: ConversationId) -> Unit + + @MockK + private lateinit var observeConversationDetails: ObserveConversationDetailsUseCase + + @MockK + private lateinit var observeParticipantsForConversation: ObserveParticipantsForConversationUseCase + + @MockK + lateinit var setUserInformedAboutVerificationUseCase: SetUserInformedAboutVerificationUseCase + + @MockK + lateinit var observeDegradedConversationNotifiedUseCase: ObserveDegradedConversationNotifiedUseCase + + @MockK + lateinit var getSelfUserUseCase: GetSelfUserUseCase + + @MockK + lateinit var observeConferenceCallingEnabled: ObserveConferenceCallingEnabledUseCase + + init { + MockKAnnotations.init(this) + } + + suspend fun withDefaultAnswers() = apply { + val conversationId = ConversationId("some-dummy-value", "some.dummy.domain") + every { savedStateHandle.navArgs() } returns ConversationNavArgs(conversationId = conversationId) + coEvery { observeEstablishedCalls.invoke() } returns emptyFlow() + coEvery { observeOngoingCalls.invoke() } returns emptyFlow() + coEvery { observeConversationDetails(any()) } returns flowOf() + coEvery { observeParticipantsForConversation(any()) } returns flowOf() + coEvery { setUserInformedAboutVerificationUseCase(any()) } returns Unit + coEvery { observeDegradedConversationNotifiedUseCase(any()) } returns flowOf(false) + coEvery { getSelfUserUseCase() } returns flowOf() + coEvery { observeConferenceCallingEnabled() } returns flowOf() + } + + suspend fun withSelfAsAdmin() = apply { + coEvery { getSelfUserUseCase.invoke() } returns flowOf(TestUser.SELF_USER.copy(userType = UserType.ADMIN)) + } - conversationListCallViewModel.joinAnyway(onAnswered) + suspend fun withConferenceCallingEnabledResponse() = apply { + coEvery { observeConferenceCallingEnabled() } returns flowOf(Unit) + } - coVerify(exactly = 1) { endCall(any()) } + suspend fun withJoinCallResponse() = apply { + coEvery { joinCall(conversationId = any()) } returns Unit + } + + suspend fun withEndCallResponse() = apply { + coEvery { endCall(any()) } returns Unit + } + + fun arrange(): Pair = this to ConversationListCallViewModel( + savedStateHandle = savedStateHandle, + observeOngoingCalls = observeOngoingCalls, + observeEstablishedCalls = observeEstablishedCalls, + answerCall = joinCall, + endCall = endCall, + observeSyncState = observeSyncState, + isConferenceCallingEnabled = isConferenceCallingEnabled, + observeConversationDetails = observeConversationDetails, + observeParticipantsForConversation = observeParticipantsForConversation, + setUserInformedAboutVerification = setUserInformedAboutVerificationUseCase, + observeDegradedConversationNotified = observeDegradedConversationNotifiedUseCase, + observeConferenceCallingEnabled = observeConferenceCallingEnabled, + getSelf = getSelfUserUseCase + ) } } diff --git a/core/analytics-enabled/src/main/kotlin/com/wire/android/feature/analytics/AnonymousAnalyticsRecorderImpl.kt b/core/analytics-enabled/src/main/kotlin/com/wire/android/feature/analytics/AnonymousAnalyticsRecorderImpl.kt index 0bb72c5545d..71fe3723667 100644 --- a/core/analytics-enabled/src/main/kotlin/com/wire/android/feature/analytics/AnonymousAnalyticsRecorderImpl.kt +++ b/core/analytics-enabled/src/main/kotlin/com/wire/android/feature/analytics/AnonymousAnalyticsRecorderImpl.kt @@ -20,6 +20,7 @@ package com.wire.android.feature.analytics import android.app.Activity import android.app.Application import android.content.Context +import android.util.Log import com.wire.android.feature.analytics.model.AnalyticsEvent import com.wire.android.feature.analytics.model.AnalyticsEventConstants import com.wire.android.feature.analytics.model.AnalyticsSettings @@ -34,8 +35,8 @@ class AnonymousAnalyticsRecorderImpl : AnonymousAnalyticsRecorder { override fun configure( context: Context, analyticsSettings: AnalyticsSettings - ) { - if (isConfigured) return + ) = wrapCountlyRequest { + if (isConfigured) return@wrapCountlyRequest val countlyConfig = CountlyConfig( context, @@ -54,24 +55,24 @@ class AnonymousAnalyticsRecorderImpl : AnonymousAnalyticsRecorder { } } - Countly.sharedInstance().init(countlyConfig) - Countly.sharedInstance().consent().giveConsent(arrayOf("apm")) + Countly.sharedInstance()?.init(countlyConfig) + Countly.sharedInstance()?.consent()?.giveConsent(arrayOf("apm")) val packageInfo = context.packageManager.getPackageInfo(context.packageName, 0) val globalSegmentations = mapOf( AnalyticsEventConstants.APP_NAME to AnalyticsEventConstants.APP_NAME_ANDROID, AnalyticsEventConstants.APP_VERSION to packageInfo.versionName ) - Countly.sharedInstance().views().setGlobalViewSegmentation(globalSegmentations) + Countly.sharedInstance()?.views()?.setGlobalViewSegmentation(globalSegmentations) isConfigured = true } - override fun onStart(activity: Activity) { - Countly.sharedInstance().onStart(activity) + override fun onStart(activity: Activity) = wrapCountlyRequest { + Countly.sharedInstance()?.onStart(activity) } - override fun onStop() { - Countly.sharedInstance().onStop() + override fun onStop() = wrapCountlyRequest { + Countly.sharedInstance()?.onStop() } /** @@ -79,11 +80,11 @@ class AnonymousAnalyticsRecorderImpl : AnonymousAnalyticsRecorder { * Countly is doing additional operations on it. * See [UtilsInternalLimits.removeUnsupportedDataTypes] */ - override fun sendEvent(event: AnalyticsEvent) { - Countly.sharedInstance().events().recordEvent(event.key, event.toSegmentation().toMutableMap()) + override fun sendEvent(event: AnalyticsEvent) = wrapCountlyRequest { + Countly.sharedInstance()?.events()?.recordEvent(event.key, event.toSegmentation().toMutableMap()) } - override fun halt() { + override fun halt() = wrapCountlyRequest { isConfigured = false Countly.sharedInstance().consent().removeConsentAll() } @@ -93,7 +94,9 @@ class AnonymousAnalyticsRecorderImpl : AnonymousAnalyticsRecorder { isTeamMember: Boolean, migrationComplete: suspend () -> Unit ) { - Countly.sharedInstance().deviceId().changeWithMerge(identifier).also { + wrapCountlyRequest { + Countly.sharedInstance()?.deviceId()?.changeWithMerge(identifier) + }.also { migrationComplete() } @@ -106,7 +109,9 @@ class AnonymousAnalyticsRecorderImpl : AnonymousAnalyticsRecorder { isTeamMember: Boolean, propagateIdentifier: suspend () -> Unit ) { - Countly.sharedInstance().deviceId().changeWithoutMerge(identifier) + wrapCountlyRequest { + Countly.sharedInstance()?.deviceId()?.changeWithoutMerge(identifier) + } setUserProfileProperties(isTeamMember = isTeamMember) @@ -115,27 +120,42 @@ class AnonymousAnalyticsRecorderImpl : AnonymousAnalyticsRecorder { } } - private fun setUserProfileProperties(isTeamMember: Boolean) { - Countly.sharedInstance().userProfile().setProperty( + private fun setUserProfileProperties(isTeamMember: Boolean) = wrapCountlyRequest { + Countly.sharedInstance()?.userProfile()?.setProperty( AnalyticsEventConstants.TEAM_IS_TEAM, isTeamMember ) - Countly.sharedInstance().userProfile().save() + Countly.sharedInstance()?.userProfile()?.save() } override fun isAnalyticsInitialized(): Boolean = Countly.sharedInstance().isInitialized - override fun applicationOnCreate() { - if (isConfigured) return + override fun applicationOnCreate() = wrapCountlyRequest { + if (isConfigured) return@wrapCountlyRequest Countly.applicationOnCreate() } - override fun recordView(screen: String) { - Countly.sharedInstance().views().startAutoStoppedView(screen) + override fun recordView(screen: String) = wrapCountlyRequest { + Countly.sharedInstance()?.views()?.startAutoStoppedView(screen) + } + + override fun stopView(screen: String) = wrapCountlyRequest { + Countly.sharedInstance()?.views()?.stopViewWithName(screen) + } + + @Suppress("TooGenericExceptionCaught") + private fun wrapCountlyRequest(block: () -> Unit) { + try { + block() + } catch (e: Exception) { + // Countly SDK throws exceptions on some cases, just log it + // We don't want to crash the app because of that. + Log.wtf(TAG, "Countly SDK request failed", e) + } } - override fun stopView(screen: String) { - Countly.sharedInstance().views().stopViewWithName(screen) + companion object { + private const val TAG = "AnonymousAnalyticsRecorderImpl" } }