diff --git a/.github/workflows/build-prod-app.yml b/.github/workflows/build-prod-app.yml index b42866dfe69..f682e36fc1f 100644 --- a/.github/workflows/build-prod-app.yml +++ b/.github/workflows/build-prod-app.yml @@ -114,7 +114,7 @@ jobs: build-flavour: prod build-variant: compatrelease - name: Attach APK and version file to release - uses: softprops/action-gh-release@v2.1.0 + uses: softprops/action-gh-release@v2.2.0 with: files: | app/build/outputs/apk/prodCompatrelease/*.apk diff --git a/.github/workflows/generate-changelog.yml b/.github/workflows/generate-changelog.yml index a239d508807..05955c37a68 100644 --- a/.github/workflows/generate-changelog.yml +++ b/.github/workflows/generate-changelog.yml @@ -48,7 +48,7 @@ jobs: npx generate-changelog@1.8.0 -t "$PREVIOUS_TAG...$CURRENT_TAG" - name: 'Attach changelog to tag' - uses: softprops/action-gh-release@v2.1.0 + uses: softprops/action-gh-release@v2.2.0 env: GITHUB_TOKEN: ${{ secrets.ANDROID_BOB_GH_TOKEN }} with: diff --git a/app/src/main/kotlin/com/wire/android/WireApplication.kt b/app/src/main/kotlin/com/wire/android/WireApplication.kt index 9b2d2823d17..d67de8e4c97 100644 --- a/app/src/main/kotlin/com/wire/android/WireApplication.kt +++ b/app/src/main/kotlin/com/wire/android/WireApplication.kt @@ -29,6 +29,7 @@ import co.touchlab.kermit.platformLogWriter import com.wire.android.analytics.ObserveCurrentSessionAnalyticsUseCase import com.wire.android.datastore.GlobalDataStore import com.wire.android.datastore.UserDataStoreProvider +import com.wire.android.debug.DatabaseProfilingManager import com.wire.android.di.ApplicationScope import com.wire.android.di.KaliumCoreLogic import com.wire.android.feature.analytics.AnonymousAnalyticsManagerImpl @@ -89,6 +90,9 @@ class WireApplication : BaseApp() { @Inject lateinit var currentScreenManager: CurrentScreenManager + @Inject + lateinit var databaseProfilingManager: DatabaseProfilingManager + override val workManagerConfiguration: Configuration get() = Configuration.Builder() .setWorkerFactory(wireWorkerFactory.get()) @@ -183,6 +187,10 @@ class WireApplication : BaseApp() { logDeviceInformation() // 5. Verify if we can initialize Anonymous Analytics initializeAnonymousAnalytics() + // 6. Observe and update profiling when needed + globalAppScope.launch { + databaseProfilingManager.observeAndUpdateProfiling() + } } private fun initializeAnonymousAnalytics() { diff --git a/app/src/main/kotlin/com/wire/android/debug/DatabaseProfilingManager.kt b/app/src/main/kotlin/com/wire/android/debug/DatabaseProfilingManager.kt new file mode 100644 index 00000000000..e6fcc1aa28c --- /dev/null +++ b/app/src/main/kotlin/com/wire/android/debug/DatabaseProfilingManager.kt @@ -0,0 +1,56 @@ +/* + * 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.debug + +import com.wire.android.datastore.GlobalDataStore +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.functional.mapToRightOr +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.scan +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class DatabaseProfilingManager @Inject constructor( + @KaliumCoreLogic private val coreLogic: CoreLogic, + private val globalDataStore: GlobalDataStore, +) { + + suspend fun observeAndUpdateProfiling() { + globalDataStore.isLoggingEnabled() + .flatMapLatest { isLoggingEnabled -> + coreLogic.getGlobalScope().sessionRepository.allValidSessionsFlow() + .mapToRightOr(emptyList()) + .map { it.map { it.userId } } + .scan(emptyList()) { previousList, currentList -> currentList - previousList.toSet() } + .map { userIds -> isLoggingEnabled to userIds } + } + .filter { (_, userIds) -> userIds.isNotEmpty() } + .distinctUntilChanged() + .collect { (isLoggingEnabled, userIds) -> + userIds.forEach { userId -> + coreLogic.getSessionScope(userId).debug.changeProfiling(isLoggingEnabled) + } + } + } +} 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/mapper/ConversationMapper.kt b/app/src/main/kotlin/com/wire/android/mapper/ConversationMapper.kt index 4098405e5c8..abda5b931af 100644 --- a/app/src/main/kotlin/com/wire/android/mapper/ConversationMapper.kt +++ b/app/src/main/kotlin/com/wire/android/mapper/ConversationMapper.kt @@ -96,6 +96,7 @@ fun ConversationDetailsWithEvents.toConversationItem( ), userId = conversationDetails.otherUser.id, blockingState = conversationDetails.otherUser.BlockState, + isUserDeleted = conversationDetails.otherUser.deleted, teamId = conversationDetails.otherUser.teamId, isArchived = conversationDetails.conversation.archived, mlsVerificationStatus = conversationDetails.conversation.mlsVerificationStatus, 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/bottomsheet/conversation/ConversationSheetContent.kt b/app/src/main/kotlin/com/wire/android/ui/common/bottomsheet/conversation/ConversationSheetContent.kt index 4d43febe51e..36ca87da01e 100644 --- a/app/src/main/kotlin/com/wire/android/ui/common/bottomsheet/conversation/ConversationSheetContent.kt +++ b/app/src/main/kotlin/com/wire/android/ui/common/bottomsheet/conversation/ConversationSheetContent.kt @@ -103,7 +103,8 @@ sealed class ConversationTypeDetail { data class Private( val avatarAsset: UserAvatarAsset?, val userId: UserId, - val blockingState: BlockingState + val blockingState: BlockingState, + val isUserDeleted: Boolean ) : ConversationTypeDetail() data class Connection(val avatarAsset: UserAvatarAsset?) : ConversationTypeDetail() @@ -131,7 +132,8 @@ data class ConversationSheetContent( fun canEditNotifications(): Boolean = isSelfUserMember && ((conversationTypeDetail is ConversationTypeDetail.Private - && (conversationTypeDetail.blockingState != BlockingState.BLOCKED)) + && (conversationTypeDetail.blockingState != BlockingState.BLOCKED) + && !conversationTypeDetail.isUserDeleted) || conversationTypeDetail is ConversationTypeDetail.Group) fun canDeleteGroup(): Boolean { @@ -142,8 +144,11 @@ data class ConversationSheetContent( fun canLeaveTheGroup(): Boolean = conversationTypeDetail is ConversationTypeDetail.Group && isSelfUserMember - fun canBlockUser(): Boolean = - conversationTypeDetail is ConversationTypeDetail.Private && conversationTypeDetail.blockingState == BlockingState.NOT_BLOCKED + fun canBlockUser(): Boolean { + return conversationTypeDetail is ConversationTypeDetail.Private + && conversationTypeDetail.blockingState == BlockingState.NOT_BLOCKED + && !conversationTypeDetail.isUserDeleted + } fun canUnblockUser(): Boolean = conversationTypeDetail is ConversationTypeDetail.Private && conversationTypeDetail.blockingState == BlockingState.BLOCKED diff --git a/app/src/main/kotlin/com/wire/android/ui/common/bottomsheet/conversation/ConversationSheetState.kt b/app/src/main/kotlin/com/wire/android/ui/common/bottomsheet/conversation/ConversationSheetState.kt index f5bd110bc79..250e5c7439b 100644 --- a/app/src/main/kotlin/com/wire/android/ui/common/bottomsheet/conversation/ConversationSheetState.kt +++ b/app/src/main/kotlin/com/wire/android/ui/common/bottomsheet/conversation/ConversationSheetState.kt @@ -93,9 +93,10 @@ fun rememberConversationSheetState( } else conversationInfo.name, mutingConversationState = mutedStatus, conversationTypeDetail = ConversationTypeDetail.Private( - userAvatarData.asset, - userId, - blockingState + avatarAsset = userAvatarData.asset, + userId = userId, + blockingState = blockingState, + isUserDeleted = isUserDeleted ), isTeamConversation = isTeamConversation, selfRole = Conversation.Member.Role.Member, 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/debug/DebugDataOptions.kt b/app/src/main/kotlin/com/wire/android/ui/debug/DebugDataOptions.kt index 756669e1442..165da415254 100644 --- a/app/src/main/kotlin/com/wire/android/ui/debug/DebugDataOptions.kt +++ b/app/src/main/kotlin/com/wire/android/ui/debug/DebugDataOptions.kt @@ -161,6 +161,21 @@ fun DebugDataOptionsContent( ) ) + Column { + SettingsItem( + title = stringResource(R.string.debug_federation_enabled), + text = state.isFederationEnabled.toString(), + ) + SettingsItem( + title = stringResource(R.string.debug_default_backend_protocol), + text = state.defaultProtocol, + ) + SettingsItem( + title = stringResource(R.string.debug_current_api_version), + text = state.currentApiVersion, + ) + } + if (BuildConfig.DEBUG) { GetE2EICertificateSwitch( enrollE2EI = enrollE2EICertificate diff --git a/app/src/main/kotlin/com/wire/android/ui/debug/DebugDataOptionsState.kt b/app/src/main/kotlin/com/wire/android/ui/debug/DebugDataOptionsState.kt index acc93712f82..3f7387e53d3 100644 --- a/app/src/main/kotlin/com/wire/android/ui/debug/DebugDataOptionsState.kt +++ b/app/src/main/kotlin/com/wire/android/ui/debug/DebugDataOptionsState.kt @@ -29,5 +29,8 @@ data class DebugDataOptionsState( val certificate: String = "null", val showCertificate: Boolean = false, val startGettingE2EICertificate: Boolean = false, - val analyticsTrackingId: String = "null" + val analyticsTrackingId: String = "null", + val isFederationEnabled: Boolean = false, + val currentApiVersion: String = "null", + val defaultProtocol: String = "null", ) diff --git a/app/src/main/kotlin/com/wire/android/ui/debug/DebugDataOptionsViewModel.kt b/app/src/main/kotlin/com/wire/android/ui/debug/DebugDataOptionsViewModel.kt index b23e0d65238..e98c20eb903 100644 --- a/app/src/main/kotlin/com/wire/android/ui/debug/DebugDataOptionsViewModel.kt +++ b/app/src/main/kotlin/com/wire/android/ui/debug/DebugDataOptionsViewModel.kt @@ -34,6 +34,8 @@ import com.wire.android.util.getGitBuildId import com.wire.android.util.ui.UIText import com.wire.kalium.logic.CoreFailure import com.wire.kalium.logic.E2EIFailure +import com.wire.kalium.logic.configuration.server.CommonApiVersionType +import com.wire.kalium.logic.data.user.SupportedProtocol import com.wire.kalium.logic.data.user.UserId import com.wire.kalium.logic.feature.analytics.GetCurrentAnalyticsTrackingIdentifierUseCase import com.wire.kalium.logic.feature.e2ei.CheckCrlRevocationListUseCase @@ -42,6 +44,8 @@ import com.wire.kalium.logic.feature.keypackage.MLSKeyPackageCountResult import com.wire.kalium.logic.feature.keypackage.MLSKeyPackageCountUseCase import com.wire.kalium.logic.feature.notificationToken.SendFCMTokenError import com.wire.kalium.logic.feature.notificationToken.SendFCMTokenUseCase +import com.wire.kalium.logic.feature.user.GetDefaultProtocolUseCase +import com.wire.kalium.logic.feature.user.SelfServerConfigUseCase import com.wire.kalium.logic.functional.Either import com.wire.kalium.logic.functional.fold import com.wire.kalium.logic.sync.periodic.UpdateApiVersionsScheduler @@ -87,6 +91,8 @@ class DebugDataOptionsViewModelImpl private val getCurrentAnalyticsTrackingIdentifier: GetCurrentAnalyticsTrackingIdentifierUseCase, private val sendFCMToken: SendFCMTokenUseCase, private val dispatcherProvider: DispatcherProvider, + private val selfServerConfigUseCase: SelfServerConfigUseCase, + private val getDefaultProtocolUseCase: GetDefaultProtocolUseCase, ) : ViewModel(), DebugDataOptionsViewModel { var state by mutableStateOf( @@ -102,6 +108,34 @@ class DebugDataOptionsViewModelImpl checkIfCanTriggerManualMigration() setGitHashAndDeviceId() setAnalyticsTrackingId() + setServerConfigData() + setDefaultProtocol() + } + + private fun setDefaultProtocol() { + viewModelScope.launch { + state = state.copy( + defaultProtocol = when (getDefaultProtocolUseCase()) { + SupportedProtocol.PROTEUS -> "Proteus" + SupportedProtocol.MLS -> "MLS" + } + ) + } + } + + private fun setServerConfigData() { + viewModelScope.launch { + val result = selfServerConfigUseCase() + if (result is SelfServerConfigUseCase.Result.Success) { + state = state.copy( + isFederationEnabled = result.serverLinks.metaData.federation, + currentApiVersion = when (result.serverLinks.metaData.commonApiVersion) { + CommonApiVersionType.Unknown -> "Unknown" + else -> result.serverLinks.metaData.commonApiVersion.version.toString() + }, + ) + } + } } private fun setAnalyticsTrackingId() { 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 164aeb80620..061b6c1a1b9 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 @@ -101,7 +101,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 @@ -173,6 +176,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 @@ -299,6 +303,12 @@ fun ConversationScreen( } } + LaunchedEffect(Unit) { + conversationListCallViewModel.callingEnabled.collect { + showDialog.value = ConversationScreenDialogType.CALLING_FEATURE_ACTIVATED + } + } + conversationMigrationViewModel.migratedConversationId?.let { migratedConversationId -> navigator.navigate( NavigationCommand( @@ -387,9 +397,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 -> { @@ -771,7 +802,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 466664122da..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 @@ -24,6 +24,7 @@ import androidx.compose.runtime.setValue import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import androidx.paging.PagingData +import androidx.paging.cachedIn import androidx.paging.insertSeparators import androidx.paging.map import com.wire.android.BuildConfig @@ -211,17 +212,15 @@ class ConversationListViewModelImpl @AssistedInject constructor( } } .flowOn(dispatcher.io()) + .cachedIn(viewModelScope) - private var notPaginatedConversationListState by mutableStateOf(ConversationListState.NotPaginated()) - override val conversationListState: ConversationListState - get() = 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) { @@ -256,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/common/ConversationItemFactory.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/common/ConversationItemFactory.kt index 45db07413b1..d462bc90234 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/common/ConversationItemFactory.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/common/ConversationItemFactory.kt @@ -516,7 +516,8 @@ fun PreviewPrivateConversationItemWithBlockedBadge() = WireTheme { isArchived = false, mlsVerificationStatus = Conversation.VerificationStatus.NOT_VERIFIED, proteusVerificationStatus = Conversation.VerificationStatus.NOT_VERIFIED, - isFavorite = false + isFavorite = false, + isUserDeleted = false ), modifier = Modifier, isSelectableItem = false, diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/common/ConversationList.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/common/ConversationList.kt index 730ef25eaf7..ed04d4d72bc 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/common/ConversationList.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/common/ConversationList.kt @@ -224,7 +224,8 @@ fun previewConversationList(count: Int, startIndex: Int = 0, unread: Boolean = f mlsVerificationStatus = Conversation.VerificationStatus.NOT_VERIFIED, proteusVerificationStatus = Conversation.VerificationStatus.NOT_VERIFIED, searchQuery = searchQuery, - isFavorite = false + isFavorite = false, + isUserDeleted = false ) ) } 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/ConversationItem.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/model/ConversationItem.kt index d5ae38f7625..66bced8c774 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/model/ConversationItem.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/model/ConversationItem.kt @@ -75,6 +75,7 @@ sealed class ConversationItem : ConversationFolderItem { val conversationInfo: ConversationInfo, val userId: UserId, val blockingState: BlockingState, + val isUserDeleted: Boolean, override val conversationId: ConversationId, override val mutedStatus: MutedConversationStatus, override val showLegalHoldIndicator: Boolean = false, 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/home/messagecomposer/state/MessageComposerStateHolder.kt b/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/state/MessageComposerStateHolder.kt index 0ea6534dc7d..02b0152216f 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/state/MessageComposerStateHolder.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/state/MessageComposerStateHolder.kt @@ -55,15 +55,6 @@ fun rememberMessageComposerStateHolder( val messageTextFieldValue = remember { mutableStateOf(TextFieldValue()) } - LaunchedEffect(draftMessageComposition.draftText) { - if (draftMessageComposition.draftText.isNotBlank()) { - messageTextFieldValue.value = messageTextFieldValue.value.copy( - text = draftMessageComposition.draftText, - selection = TextRange(draftMessageComposition.draftText.length) // Place cursor at the end of the new text - ) - } - } - val messageCompositionHolder = remember { mutableStateOf( MessageCompositionHolder( @@ -77,6 +68,23 @@ fun rememberMessageComposerStateHolder( ) ) } + + LaunchedEffect(draftMessageComposition.draftText) { + if (draftMessageComposition.draftText.isNotBlank()) { + messageTextFieldValue.value = messageTextFieldValue.value.copy( + text = draftMessageComposition.draftText, + selection = TextRange(draftMessageComposition.draftText.length) // Place cursor at the end of the new text + ) + } + + if (draftMessageComposition.selectedMentions.isNotEmpty()) { + messageCompositionHolder.value.setMentions( + draftMessageComposition.draftText, + draftMessageComposition.selectedMentions.map { it.intoMessageMention() } + ) + } + } + LaunchedEffect(Unit) { messageCompositionHolder.value.handleMessageTextUpdates() } diff --git a/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/state/MessageCompositionHolder.kt b/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/state/MessageCompositionHolder.kt index 9c0b29b89fb..9b5590244c3 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/state/MessageCompositionHolder.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/state/MessageCompositionHolder.kt @@ -241,13 +241,20 @@ class MessageCompositionHolder( ) messageComposition.update { it.copy( - selectedMentions = mentions.mapNotNull { it.toUiMention(editMessageText) }, + selectedMentions = mentions.mapNotNull { mention -> mention.toUiMention(editMessageText) }, editMessageId = messageId ) } onSaveDraft(messageComposition.value.toDraft(editMessageText)) } + fun setMentions(editMessageText: String, mentions: List) { + messageComposition.update { + it.copy(selectedMentions = mentions.mapNotNull { mention -> mention.toUiMention(editMessageText) }) + } + onSaveDraft(messageComposition.value.toDraft(editMessageText)) + } + fun addOrRemoveMessageMarkdown( markdown: RichTextMarkdown, ) { 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/kotlin/com/wire/android/ui/userprofile/other/OtherUserProfileScreen.kt b/app/src/main/kotlin/com/wire/android/ui/userprofile/other/OtherUserProfileScreen.kt index 98d5a77978d..6a869b01690 100644 --- a/app/src/main/kotlin/com/wire/android/ui/userprofile/other/OtherUserProfileScreen.kt +++ b/app/src/main/kotlin/com/wire/android/ui/userprofile/other/OtherUserProfileScreen.kt @@ -598,7 +598,8 @@ fun ContentFooter( exit = fadeOut(), ) { // TODO show open conversation button for service bots after AR-2135 - if (!state.isMetadataEmpty() && state.membership != Membership.Service && !state.isTemporaryUser()) { + val isNotTemporaryAndNotDeleted = !state.isTemporaryUser() && !state.isDeletedUser + if (!state.isMetadataEmpty() && state.membership != Membership.Service && isNotTemporaryAndNotDeleted) { Surface( shadowElevation = dimensions().bottomNavigationShadowElevation, color = MaterialTheme.wireColorScheme.background diff --git a/app/src/main/kotlin/com/wire/android/ui/userprofile/other/OtherUserProfileScreenViewModel.kt b/app/src/main/kotlin/com/wire/android/ui/userprofile/other/OtherUserProfileScreenViewModel.kt index 9f809c80afc..69effb5e0db 100644 --- a/app/src/main/kotlin/com/wire/android/ui/userprofile/other/OtherUserProfileScreenViewModel.kt +++ b/app/src/main/kotlin/com/wire/android/ui/userprofile/other/OtherUserProfileScreenViewModel.kt @@ -398,15 +398,17 @@ class OtherUserProfileScreenViewModel @Inject constructor( isUnderLegalHold = otherUser.isUnderLegalHold, expiresAt = otherUser.expiresAt, accentId = otherUser.accentId, + isDeletedUser = otherUser.deleted, conversationSheetContent = conversation?.let { ConversationSheetContent( title = otherUser.name.orEmpty(), conversationId = conversation.id, mutingConversationState = conversation.mutedStatus, conversationTypeDetail = ConversationTypeDetail.Private( - userAvatarAsset, - userId, - otherUser.BlockState + avatarAsset = userAvatarAsset, + userId = userId, + blockingState = otherUser.BlockState, + isUserDeleted = otherUser.deleted ), isTeamConversation = conversation.isTeamGroup(), selfRole = Conversation.Member.Role.Member, diff --git a/app/src/main/kotlin/com/wire/android/ui/userprofile/other/OtherUserProfileState.kt b/app/src/main/kotlin/com/wire/android/ui/userprofile/other/OtherUserProfileState.kt index 491108acc7f..672093c0b55 100644 --- a/app/src/main/kotlin/com/wire/android/ui/userprofile/other/OtherUserProfileState.kt +++ b/app/src/main/kotlin/com/wire/android/ui/userprofile/other/OtherUserProfileState.kt @@ -55,7 +55,8 @@ data class OtherUserProfileState( val isConversationStarted: Boolean = false, val expiresAt: Instant? = null, val accentId: Int = -1, - val errorLoadingUser: ErrorLoadingUser? = null + val errorLoadingUser: ErrorLoadingUser? = null, + val isDeletedUser: Boolean = false ) { fun updateMuteStatus(status: MutedConversationStatus): OtherUserProfileState { return conversationSheetContent?.let { 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 b0790543e47..d2f4a999966 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -23,6 +23,11 @@ Analytics Tracking Identifier + Federation Enabled + + Default Backend Protocol + + Current API Version New Login OK @@ -975,6 +980,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? @@ -1167,8 +1180,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/debug/DatabaseProfilingManagerTest.kt b/app/src/test/kotlin/com/wire/android/debug/DatabaseProfilingManagerTest.kt new file mode 100644 index 00000000000..b0dc851cc0d --- /dev/null +++ b/app/src/test/kotlin/com/wire/android/debug/DatabaseProfilingManagerTest.kt @@ -0,0 +1,187 @@ +/* + * 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.debug + +import com.wire.android.config.CoroutineTestExtension +import com.wire.android.datastore.GlobalDataStore +import com.wire.kalium.logic.CoreLogic +import com.wire.kalium.logic.StorageFailure +import com.wire.kalium.logic.data.auth.AccountInfo +import com.wire.kalium.logic.data.user.UserId +import com.wire.kalium.logic.functional.Either +import io.mockk.MockKAnnotations +import io.mockk.coEvery +import io.mockk.impl.annotations.MockK +import io.mockk.mockk +import kotlinx.collections.immutable.PersistentMap +import kotlinx.collections.immutable.persistentMapOf +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.amshove.kluent.internal.assertEquals +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith + +@ExtendWith(CoroutineTestExtension::class) +class DatabaseProfilingManagerTest { + + @Test + fun `given valid session and logging enabled, when observing, then profiling should be enabled`() = + runTest { + // given + val account = AccountInfo.Valid(UserId("user", "domain")) + val (arrangement, databaseProfilingManager) = Arrangement() + .withAllValidSessions(flowOf(Either.Right(listOf(account)))) + .withIsLoggingEnabled(flowOf(true)) + .arrange() + + // when + val job = launch { + databaseProfilingManager.observeAndUpdateProfiling() + } + advanceUntilIdle() + // then + assertEquals(true, arrangement.profilingValues[account.userId]) + job.cancel() + } + + @Test + fun `given valid session and logging disabled, when observing, then profiling is disabled`() = + runTest { + // given + val account = AccountInfo.Valid(UserId("user", "domain")) + val (arrangement, databaseProfilingManager) = Arrangement() + .withAllValidSessions(flowOf(Either.Right(listOf(account)))) + .withIsLoggingEnabled(flowOf(false)) + .arrange() + // when + val job = launch { + databaseProfilingManager.observeAndUpdateProfiling() + } + advanceUntilIdle() + // then + assertEquals(false, arrangement.profilingValues[account.userId]) + job.cancel() + } + + @Test + fun `given valid session, when observing and logging changes from disabled to enabled, then profiling is enabled`() = + runTest { + // given + val account = AccountInfo.Valid(UserId("user", "domain")) + val (arrangement, databaseProfilingManager) = Arrangement() + .withAllValidSessions(flowOf(Either.Right(listOf(account)))) + .withIsLoggingEnabled(flowOf(false)) + .arrange() + // when + val job = launch { + databaseProfilingManager.observeAndUpdateProfiling() + } + arrangement.withIsLoggingEnabled(flowOf(true)) + advanceUntilIdle() + // then + assertEquals(true, arrangement.profilingValues[account.userId]) + job.cancel() + } + + @Test + fun `given two valid sessions, when observing and logging changes from disabled to enabled, then profiling is enabled for both`() = + runTest { + // given + val account1 = AccountInfo.Valid(UserId("user1", "domain")) + val account2 = AccountInfo.Valid(UserId("user2", "domain")) + val (arrangement, databaseProfilingManager) = Arrangement() + .withAllValidSessions(flowOf(Either.Right(listOf(account1, account2)))) + .withIsLoggingEnabled(flowOf(false)) + .arrange() + // when + val job = launch { + databaseProfilingManager.observeAndUpdateProfiling() + } + arrangement.withIsLoggingEnabled(flowOf(true)) + advanceUntilIdle() + // then + assertEquals(true, arrangement.profilingValues[account1.userId]) + assertEquals(true, arrangement.profilingValues[account2.userId]) + job.cancel() + } + + @Test + fun `given valid session and logging enabled, when observing and new session appears, then profiling is enabled for both`() = + runTest { + // given + val account1 = AccountInfo.Valid(UserId("user1", "domain")) + val account2 = AccountInfo.Valid(UserId("user2", "domain")) + val validSessionsFlow = MutableStateFlow(Either.Right(listOf(account1))) + val (arrangement, databaseProfilingManager) = Arrangement() + .withAllValidSessions(validSessionsFlow) + .withIsLoggingEnabled(flowOf(true)) + .arrange() + // when + val job = launch { + databaseProfilingManager.observeAndUpdateProfiling() + } + validSessionsFlow.value = Either.Right(listOf(account1, account2)) + advanceUntilIdle() + // then + assertEquals(true, arrangement.profilingValues[account1.userId]) + assertEquals(true, arrangement.profilingValues[account2.userId]) + job.cancel() + } + + private class Arrangement { + + @MockK + lateinit var coreLogic: CoreLogic + + @MockK + private lateinit var globalDataStore: GlobalDataStore + + var profilingValues: PersistentMap = persistentMapOf() + private set + + init { + MockKAnnotations.init(this, relaxed = true, relaxUnitFun = true) + coEvery { coreLogic.getSessionScope(any()).debug.changeProfiling(any()) } answers { + profilingValues = profilingValues.put(firstArg(), secondArg()) + } + coEvery { coreLogic.getSessionScope(any()) } answers { + val userId = firstArg() + mockk { + coEvery { debug.changeProfiling(any()) } answers { + val profilingValue = firstArg() + profilingValues = profilingValues.put(userId, profilingValue) + } + } + } + } + + fun withIsLoggingEnabled(isLoggingEnabledFlow: Flow) = apply { + coEvery { globalDataStore.isLoggingEnabled() } returns isLoggingEnabledFlow + } + + fun withAllValidSessions(allValidSessionsFlow: Flow>>) = apply { + coEvery { coreLogic.getGlobalScope().sessionRepository.allValidSessionsFlow() } returns allValidSessionsFlow + } + + fun arrange() = this to DatabaseProfilingManager(coreLogic, globalDataStore) + } +} diff --git a/app/src/test/kotlin/com/wire/android/framework/TestConversationItem.kt b/app/src/test/kotlin/com/wire/android/framework/TestConversationItem.kt index 0d95340a56a..848fb683703 100644 --- a/app/src/test/kotlin/com/wire/android/framework/TestConversationItem.kt +++ b/app/src/test/kotlin/com/wire/android/framework/TestConversationItem.kt @@ -45,7 +45,8 @@ object TestConversationItem { isArchived = false, mlsVerificationStatus = Conversation.VerificationStatus.NOT_VERIFIED, proteusVerificationStatus = Conversation.VerificationStatus.NOT_VERIFIED, - isFavorite = false + isFavorite = false, + isUserDeleted = false ) val GROUP = ConversationItem.GroupConversation( diff --git a/app/src/test/kotlin/com/wire/android/ui/CallActivityViewModelTest.kt b/app/src/test/kotlin/com/wire/android/ui/CallActivityViewModelTest.kt index 1c7345672a2..448d1f987fb 100644 --- a/app/src/test/kotlin/com/wire/android/ui/CallActivityViewModelTest.kt +++ b/app/src/test/kotlin/com/wire/android/ui/CallActivityViewModelTest.kt @@ -117,6 +117,7 @@ class CallActivityViewModelTest { .arrange() viewModel.switchAccountIfNeeded(userId, arrangement.switchAccountActions) + advanceUntilIdle() coVerify(inverse = true) { arrangement.accountSwitch(any()) } } @@ -132,6 +133,7 @@ class CallActivityViewModelTest { .arrange() viewModel.switchAccountIfNeeded(UserId("anotherUser", "domain"), arrangement.switchAccountActions) + advanceUntilIdle() coVerify(exactly = if (switchedToAnotherAccountCalled) 1 else 0) { arrangement.switchAccountActions.switchedToAnotherAccount() 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/common/bottomsheet/conversation/ConversationSheetContentTest.kt b/app/src/test/kotlin/com/wire/android/ui/common/bottomsheet/conversation/ConversationSheetContentTest.kt index a95c20a4985..35cd7180406 100644 --- a/app/src/test/kotlin/com/wire/android/ui/common/bottomsheet/conversation/ConversationSheetContentTest.kt +++ b/app/src/test/kotlin/com/wire/android/ui/common/bottomsheet/conversation/ConversationSheetContentTest.kt @@ -17,33 +17,22 @@ */ package com.wire.android.ui.common.bottomsheet.conversation +import com.wire.android.framework.TestUser import com.wire.android.ui.home.conversations.details.GroupConversationDetailsViewModelTest.Companion.testGroup +import com.wire.android.ui.home.conversationslist.model.BlockingState import com.wire.kalium.logic.data.conversation.Conversation import com.wire.kalium.logic.data.id.TeamId import kotlinx.coroutines.test.runTest import org.amshove.kluent.internal.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.Test class ConversationSheetContentTest { @Test fun givenTitleIsEmptyAndTheGroupSizeIsOne_whenCallingIsTheGroupAbandoned_returnsTrue() = runTest { - val details = testGroup.copy(conversation = testGroup.conversation.copy(teamId = TeamId("team_id"))) - - val givenConversationSheetContent = ConversationSheetContent( - title = "", - conversationId = details.conversation.id, - mutingConversationState = details.conversation.mutedStatus, - conversationTypeDetail = ConversationTypeDetail.Group(details.conversation.id, false), - selfRole = Conversation.Member.Role.Member, - isTeamConversation = details.conversation.isTeamGroup(), - isArchived = false, - protocol = Conversation.ProtocolInfo.Proteus, - mlsVerificationStatus = Conversation.VerificationStatus.NOT_VERIFIED, - proteusVerificationStatus = Conversation.VerificationStatus.NOT_VERIFIED, - isUnderLegalHold = false, - isFavorite = false - ) + val givenConversationSheetContent = createGroupSheetContent("") val givenParticipantsCount = 1 assertEquals(true, givenConversationSheetContent.isAbandonedOneOnOneConversation(givenParticipantsCount)) @@ -51,13 +40,137 @@ class ConversationSheetContentTest { @Test fun givenTitleIsEmptyAndTheGroupSizeIsGtOne_whenCallingIsTheGroupAbandoned_returnsFalse() = runTest { - val details = testGroup.copy(conversation = testGroup.conversation.copy(teamId = TeamId("team_id"))) + val givenConversationSheetContent = createGroupSheetContent("") + val givenParticipantsCount = 3 + + assertEquals(false, givenConversationSheetContent.isAbandonedOneOnOneConversation(givenParticipantsCount)) + } + + @Test + fun givenTitleIsNotEmptyAndTheGroupSizeIsOne_whenCallingIsTheGroupAbandoned_returnsFalse() = runTest { + val givenConversationSheetContent = createGroupSheetContent("notEmpty") + val givenParticipantsCount = 3 + + assertEquals(false, givenConversationSheetContent.isAbandonedOneOnOneConversation(givenParticipantsCount)) + } + + @Test + fun givenPrivateConversationWithoutBlockedAndNotDeletedUser_whenCanDeleteUserIsInvoked_thenReturnsTrue() = runTest { + // given + val conversationSheetContent = + createPrivateSheetContent(blockingState = BlockingState.NOT_BLOCKED, isUserDeleted = false) + + // when + val canBlockUser = conversationSheetContent.canBlockUser() + + // then + assertTrue(canBlockUser) + } + + @Test + fun givenGroupConversation_whenCanDeleteUserIsInvoked_thenReturnsFalse() = runTest { + // given + val conversationSheetContent = createGroupSheetContent("") + + // when + val canBlockUser = conversationSheetContent.canBlockUser() + + // then + assertFalse(canBlockUser) + } + + @Test + fun givenPrivateConversationWithBlockedAndNotDeletedUser_whenCanDeleteUserIsInvoked_thenReturnsFalse() = runTest { + // given + val conversationSheetContent = + createPrivateSheetContent(blockingState = BlockingState.BLOCKED, isUserDeleted = false) + + // when + val canBlockUser = conversationSheetContent.canBlockUser() - val givenConversationSheetContent = ConversationSheetContent( - title = "", + // then + assertFalse(canBlockUser) + } + + @Test + fun givenPrivateConversationWithoutBlockedAndDeletedUser_whenCanDeleteUserIsInvoked_thenReturnsFalse() = runTest { + // given + val conversationSheetContent = + createPrivateSheetContent(blockingState = BlockingState.NOT_BLOCKED, isUserDeleted = true) + + // when + val canBlockUser = conversationSheetContent.canBlockUser() + + // then + assertFalse(canBlockUser) + } + + @Test + fun givenPrivateConversationWithoutBlockedAndNotDeletedUser_whenCanEditNotificationsIsInvoked_thenReturnsTrue() = runTest { + // given + val conversationSheetContent = + createPrivateSheetContent(blockingState = BlockingState.NOT_BLOCKED, isUserDeleted = false) + + // when + val canEditNotifications = conversationSheetContent.canEditNotifications() + + // then + assertTrue(canEditNotifications) + } + + @Test + fun givenGroupConversation_whenCanEditNotificationsIsInvoked_thenReturnsTrue() = runTest { + // given + val conversationSheetContent = createGroupSheetContent("") + + // when + val canEditNotifications = conversationSheetContent.canEditNotifications() + + // then + assertTrue(canEditNotifications) + } + + @Test + fun givenPrivateConversationWithBlockedAndNotDeletedUser_whenCanEditNotificationsIsInvoked_thenReturnsFalse() = runTest { + // given + val conversationSheetContent = + createPrivateSheetContent(blockingState = BlockingState.BLOCKED, isUserDeleted = false) + + // when + val canEditNotifications = conversationSheetContent.canEditNotifications() + + // then + assertFalse(canEditNotifications) + } + + @Test + fun givenPrivateConversationWithoutBlockedAndDeletedUser_whenCanEditNotificationsIsInvoked_thenReturnsFalse() = runTest { + // given + val conversationSheetContent = + createPrivateSheetContent(blockingState = BlockingState.BLOCKED, isUserDeleted = false) + + // when + val canEditNotifications = conversationSheetContent.canEditNotifications() + + // then + assertFalse(canEditNotifications) + } + + private fun createPrivateSheetContent( + blockingState: BlockingState, + isUserDeleted: Boolean + ): ConversationSheetContent { + val details = testGroup.copy(conversation = testGroup.conversation.copy(teamId = TeamId("team_id"))) + return ConversationSheetContent( + title = "notEmpty", conversationId = details.conversation.id, mutingConversationState = details.conversation.mutedStatus, - conversationTypeDetail = ConversationTypeDetail.Group(details.conversation.id, false), + conversationTypeDetail = ConversationTypeDetail.Private( + avatarAsset = null, + userId = TestUser.USER_ID, + blockingState = blockingState, + isUserDeleted = isUserDeleted + ), selfRole = Conversation.Member.Role.Member, isTeamConversation = details.conversation.isTeamGroup(), isArchived = false, @@ -67,17 +180,15 @@ class ConversationSheetContentTest { isUnderLegalHold = false, isFavorite = false ) - val givenParticipantsCount = 3 - - assertEquals(false, givenConversationSheetContent.isAbandonedOneOnOneConversation(givenParticipantsCount)) } - @Test - fun givenTitleIsNotEmptyAndTheGroupSizeIsOne_whenCallingIsTheGroupAbandoned_returnsFalse() = runTest { + private fun createGroupSheetContent( + title: String + ): ConversationSheetContent { val details = testGroup.copy(conversation = testGroup.conversation.copy(teamId = TeamId("team_id"))) - val givenConversationSheetContent = ConversationSheetContent( - title = "notEmpty", + return ConversationSheetContent( + title = title, conversationId = details.conversation.id, mutingConversationState = details.conversation.mutedStatus, conversationTypeDetail = ConversationTypeDetail.Group(details.conversation.id, false), @@ -90,8 +201,5 @@ class ConversationSheetContentTest { isUnderLegalHold = false, isFavorite = false ) - val givenParticipantsCount = 3 - - assertEquals(false, givenConversationSheetContent.isAbandonedOneOnOneConversation(givenParticipantsCount)) } } 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/app/src/test/kotlin/com/wire/android/ui/settings/debug/DebugDataOptionsViewModelTest.kt b/app/src/test/kotlin/com/wire/android/ui/settings/debug/DebugDataOptionsViewModelTest.kt index f4476ca976c..b14eb1d1cdf 100644 --- a/app/src/test/kotlin/com/wire/android/ui/settings/debug/DebugDataOptionsViewModelTest.kt +++ b/app/src/test/kotlin/com/wire/android/ui/settings/debug/DebugDataOptionsViewModelTest.kt @@ -31,7 +31,11 @@ import com.wire.android.ui.debug.DebugDataOptionsViewModelImpl import com.wire.android.util.getDeviceIdString import com.wire.android.util.getGitBuildId import com.wire.android.util.ui.UIText +import com.wire.kalium.logic.CoreFailure +import com.wire.kalium.logic.configuration.server.CommonApiVersionType +import com.wire.kalium.logic.configuration.server.ServerConfig import com.wire.kalium.logic.data.conversation.ClientId +import com.wire.kalium.logic.data.user.SupportedProtocol import com.wire.kalium.logic.data.user.UserId import com.wire.kalium.logic.feature.analytics.GetCurrentAnalyticsTrackingIdentifierUseCase import com.wire.kalium.logic.feature.e2ei.CheckCrlRevocationListUseCase @@ -39,6 +43,8 @@ import com.wire.kalium.logic.feature.keypackage.MLSKeyPackageCountResult import com.wire.kalium.logic.feature.keypackage.MLSKeyPackageCountUseCase import com.wire.kalium.logic.feature.notificationToken.SendFCMTokenError import com.wire.kalium.logic.feature.notificationToken.SendFCMTokenUseCase +import com.wire.kalium.logic.feature.user.GetDefaultProtocolUseCase +import com.wire.kalium.logic.feature.user.SelfServerConfigUseCase import com.wire.kalium.logic.functional.Either import com.wire.kalium.logic.sync.periodic.UpdateApiVersionsScheduler import com.wire.kalium.logic.sync.slow.RestartSlowSyncProcessForRecoveryUseCase @@ -46,6 +52,7 @@ import io.mockk.MockKAnnotations import io.mockk.coEvery import io.mockk.every import io.mockk.impl.annotations.MockK +import io.mockk.mockk import io.mockk.mockkStatic import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -128,6 +135,77 @@ class DebugDataOptionsViewModelTest { assertEquals(UIText.DynamicString("Can't register token, error: error message"), result) } } + + @Test + fun `given that Proteus protocol is used, view state should have Proteus protocol name`() = runTest { + // given + val (_, viewModel) = DebugDataOptionsHiltArrangement() + .withProteusProtocolSetup() + .arrange() + + assertEquals("Proteus", viewModel.state.defaultProtocol) + } + + @Test + fun `given that Mls protocol is used, view state should have proteus Mls name`() = runTest { + // given + val (_, viewModel) = DebugDataOptionsHiltArrangement() + .withMlsProtocolSetup() + .arrange() + + assertEquals("MLS", viewModel.state.defaultProtocol) + } + + @Test + fun `given that federation is disabled, view state should have federation value of false`() = runTest { + // given + val (_, viewModel) = DebugDataOptionsHiltArrangement() + .withFederationDisabled() + .arrange() + + assertEquals(false, viewModel.state.isFederationEnabled) + } + + @Test + fun `given that federation is enabled, view state should have federation value of true`() = runTest { + // given + val (_, viewModel) = DebugDataOptionsHiltArrangement() + .withFederationEnabled() + .arrange() + + assertEquals(true, viewModel.state.isFederationEnabled) + } + + @Test + fun `given that api version is unknown, view state should have api version unknown`() = runTest { + // given + val (_, viewModel) = DebugDataOptionsHiltArrangement() + .withApiVersionUnknown() + .arrange() + + assertEquals("Unknown", viewModel.state.currentApiVersion) + } + + @Test + fun `given that api version is set, view state should have api version set`() = runTest { + // given + val (_, viewModel) = DebugDataOptionsHiltArrangement() + .withApiVersionSet(7) + .arrange() + + assertEquals("7", viewModel.state.currentApiVersion) + } + + @Test + fun `given server config failure, view state should have default values`() = runTest { + // given + val (_, viewModel) = DebugDataOptionsHiltArrangement() + .withServerConfigError() + .arrange() + + assertEquals("null", viewModel.state.currentApiVersion) + assertEquals(false, viewModel.state.isFederationEnabled) + } } internal class DebugDataOptionsHiltArrangement { @@ -155,6 +233,12 @@ internal class DebugDataOptionsHiltArrangement { @MockK lateinit var getCurrentAnalyticsTrackingIdentifier: GetCurrentAnalyticsTrackingIdentifierUseCase + @MockK + lateinit var selfServerConfigUseCase: SelfServerConfigUseCase + + @MockK + lateinit var getDefaultProtocolUseCase: GetDefaultProtocolUseCase + @MockK lateinit var sendFCMToken: SendFCMTokenUseCase @@ -170,6 +254,8 @@ internal class DebugDataOptionsHiltArrangement { getCurrentAnalyticsTrackingIdentifier = getCurrentAnalyticsTrackingIdentifier, sendFCMToken = sendFCMToken, dispatcherProvider = TestDispatcherProvider(), + selfServerConfigUseCase = selfServerConfigUseCase, + getDefaultProtocolUseCase = getDefaultProtocolUseCase, ) } @@ -196,6 +282,22 @@ internal class DebugDataOptionsHiltArrangement { coEvery { globalDataStore.getUserMigrationStatus(TestUser.SELF_USER_ID.value) } returns flowOf(UserMigrationStatus.NoNeed) + coEvery { + selfServerConfigUseCase() + } returns SelfServerConfigUseCase.Result.Success( + ServerConfig( + id = "id", + links = mockk(), + metaData = ServerConfig.MetaData( + federation = true, + commonApiVersion = CommonApiVersionType.Unknown, + domain = null, + ) + ) + ) + every { + getDefaultProtocolUseCase() + } returns SupportedProtocol.PROTEUS } fun arrange() = this to viewModel @@ -223,4 +325,88 @@ internal class DebugDataOptionsHiltArrangement { sendFCMToken() } returns Either.Left(SendFCMTokenError(SendFCMTokenError.Reason.CANT_REGISTER_TOKEN, "error message")) } + + fun withProteusProtocolSetup() = apply { + every { + getDefaultProtocolUseCase() + } returns SupportedProtocol.PROTEUS + } + + fun withMlsProtocolSetup() = apply { + every { + getDefaultProtocolUseCase() + } returns SupportedProtocol.MLS + } + + fun withFederationEnabled() = apply { + coEvery { + selfServerConfigUseCase() + } returns SelfServerConfigUseCase.Result.Success( + ServerConfig( + id = "id", + links = mockk(), + metaData = ServerConfig.MetaData( + federation = true, + commonApiVersion = CommonApiVersionType.Unknown, + domain = null, + ) + ) + ) + } + + fun withFederationDisabled() = apply { + coEvery { + selfServerConfigUseCase() + } returns SelfServerConfigUseCase.Result.Success( + ServerConfig( + id = "id", + links = mockk(), + metaData = ServerConfig.MetaData( + federation = false, + commonApiVersion = CommonApiVersionType.Unknown, + domain = null, + ) + ) + ) + } + + fun withApiVersionUnknown() = apply { + coEvery { + selfServerConfigUseCase() + } returns SelfServerConfigUseCase.Result.Success( + ServerConfig( + id = "id", + links = mockk(), + metaData = ServerConfig.MetaData( + federation = true, + commonApiVersion = CommonApiVersionType.Unknown, + domain = null, + ) + ) + ) + } + + fun withApiVersionSet(version: Int) = apply { + coEvery { + selfServerConfigUseCase() + } returns SelfServerConfigUseCase.Result.Success( + ServerConfig( + id = "id", + links = mockk(), + metaData = ServerConfig.MetaData( + federation = true, + commonApiVersion = CommonApiVersionType.Valid(version), + domain = null, + ) + ) + ) + } + + fun withServerConfigError() = apply { + coEvery { + selfServerConfigUseCase() + } returns SelfServerConfigUseCase.Result.Failure( + CoreFailure.Unknown(IllegalStateException()) + ) + } } diff --git a/core/analytics-enabled/src/main/kotlin/com/wire/android/feature/analytics/AnonymousAnalyticsManagerImpl.kt b/core/analytics-enabled/src/main/kotlin/com/wire/android/feature/analytics/AnonymousAnalyticsManagerImpl.kt index 7e78362974f..0eba82a61ae 100644 --- a/core/analytics-enabled/src/main/kotlin/com/wire/android/feature/analytics/AnonymousAnalyticsManagerImpl.kt +++ b/core/analytics-enabled/src/main/kotlin/com/wire/android/feature/analytics/AnonymousAnalyticsManagerImpl.kt @@ -42,6 +42,9 @@ object AnonymousAnalyticsManagerImpl : AnonymousAnalyticsManager { private val mutex = Mutex() private lateinit var coroutineScope: CoroutineScope + // TODO: Sync with product, when we want to enable view tracking, var for testing purposes + internal var VIEW_TRACKING_ENABLED: Boolean = false + override fun init( context: Context, analyticsSettings: AnalyticsSettings, @@ -172,6 +175,10 @@ object AnonymousAnalyticsManagerImpl : AnonymousAnalyticsManager { } override fun recordView(screen: String) { + if (!VIEW_TRACKING_ENABLED) { + Log.d(TAG, "View tracking is disabled for this build.") + return + } coroutineScope.launch { mutex.withLock { if (!isAnonymousUsageDataEnabled) return@withLock @@ -181,6 +188,10 @@ object AnonymousAnalyticsManagerImpl : AnonymousAnalyticsManager { } override fun stopView(screen: String) { + if (!VIEW_TRACKING_ENABLED) { + Log.d(TAG, "View tracking is disabled for this build.") + return + } coroutineScope.launch { mutex.withLock { if (!isAnonymousUsageDataEnabled) return@withLock 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 bee5ab38855..6baadc5b295 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,13 +80,13 @@ 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().halt() + Countly.sharedInstance()?.consent()?.removeConsentAll() } override suspend fun setTrackingIdentifierWithMerge( @@ -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" } } diff --git a/core/analytics-enabled/src/test/kotlin/com/wire/android/feature/analytics/AnonymousAnalyticsManagerTest.kt b/core/analytics-enabled/src/test/kotlin/com/wire/android/feature/analytics/AnonymousAnalyticsManagerTest.kt index ec45685727f..691810ab5a3 100644 --- a/core/analytics-enabled/src/test/kotlin/com/wire/android/feature/analytics/AnonymousAnalyticsManagerTest.kt +++ b/core/analytics-enabled/src/test/kotlin/com/wire/android/feature/analytics/AnonymousAnalyticsManagerTest.kt @@ -294,6 +294,37 @@ class AnonymousAnalyticsManagerTest { } } + @Test + fun givenManagerInitialized_whenRecordingViewAndFlagDisabled_thenScreenIsNOTRecorded() = runTest(dispatcher) { + // given + val (arrangement, manager) = Arrangement() + .withAnonymousAnalyticsRecorderConfigure() + .arrange(shouldTrackViews = false) + + val screen = "screen" + arrangement.withAnalyticsResult(Arrangement.existingIdentifierResult) + + // when + manager.init( + context = arrangement.context, + analyticsSettings = Arrangement.analyticsSettings, + analyticsResultFlow = arrangement.analyticsResultChannel.consumeAsFlow(), + anonymousAnalyticsRecorder = arrangement.anonymousAnalyticsRecorder, + migrationHandler = arrangement.migrationHandler, + propagationHandler = arrangement.propagationHandler, + dispatcher = dispatcher + ) + advanceUntilIdle() + + manager.recordView(screen) + advanceUntilIdle() + + // then + verify(exactly = 0) { + arrangement.anonymousAnalyticsRecorder.recordView(eq(screen)) + } + } + @Test fun givenManagerInitialized_whenStoppingView_thenScreenIsStoppedToRecord() = runTest(dispatcher) { // given @@ -387,7 +418,7 @@ class AnonymousAnalyticsManagerTest { AnonymousAnalyticsManagerImpl } - fun arrange() = this to manager + fun arrange(shouldTrackViews: Boolean = true) = this to manager.apply { VIEW_TRACKING_ENABLED = shouldTrackViews } fun withAnonymousAnalyticsRecorderConfigure() = apply { every { anonymousAnalyticsRecorder.configure(any(), any()) } returns Unit diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 557c6558a97..6dbdf6c2991 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -67,7 +67,7 @@ coil = "2.7.0" commonmark = "0.24.0" # Countly -countly = "24.4.0" +countly = "24.7.7" # RSS rss-parser = "6.0.7"