From 990b69e1ab4f7b09ce12114e53a6582c2935db58 Mon Sep 17 00:00:00 2001 From: Sergei Bakhtiarov Date: Thu, 27 Aug 2026 15:07:05 +0200 Subject: [PATCH] feat: fetch apps and collaborators (WPB-25747) --- .../android/di/accountScoped/AppsModule.kt | 5 ++ .../search/SearchUsersAndAppsScreen.kt | 18 +++--- .../android/search/apps/AppsContentState.kt | 1 - ...mptySearchDisabledByConversationContent.kt | 14 ++--- .../android/search/apps/SearchAppsScreen.kt | 19 +------ .../search/apps/SearchAppsViewModel.kt | 57 ++++++++++++------- .../search/apps/SearchAppsViewModelTest.kt | 35 +++++++++++- kalium | 2 +- 8 files changed, 92 insertions(+), 59 deletions(-) diff --git a/app/src/main/kotlin/com/wire/android/di/accountScoped/AppsModule.kt b/app/src/main/kotlin/com/wire/android/di/accountScoped/AppsModule.kt index db0fcab860c..345ab1757ef 100644 --- a/app/src/main/kotlin/com/wire/android/di/accountScoped/AppsModule.kt +++ b/app/src/main/kotlin/com/wire/android/di/accountScoped/AppsModule.kt @@ -26,6 +26,7 @@ import com.wire.kalium.logic.feature.app.GetAppByIdUseCase import com.wire.kalium.logic.feature.app.ObserveAllAppsUseCase import com.wire.kalium.logic.feature.app.ObserveIsAppMemberUseCase import com.wire.kalium.logic.feature.app.SearchAppsByNameUseCase +import com.wire.kalium.logic.feature.app.SyncAppsUseCase import dev.zacsweers.metro.BindingContainer import dev.zacsweers.metro.Provides @@ -53,4 +54,8 @@ class AppsModule { @Provides fun provideObserveAllAppsUseCase(appScope: AppScope): ObserveAllAppsUseCase = appScope.observeAllApps + + @Provides + fun provideSyncAppsUseCase(appScope: AppScope): SyncAppsUseCase = + appScope.syncApps } diff --git a/core/search/src/main/kotlin/com/wire/android/search/SearchUsersAndAppsScreen.kt b/core/search/src/main/kotlin/com/wire/android/search/SearchUsersAndAppsScreen.kt index 10ce5301906..c0f81f27b38 100644 --- a/core/search/src/main/kotlin/com/wire/android/search/SearchUsersAndAppsScreen.kt +++ b/core/search/src/main/kotlin/com/wire/android/search/SearchUsersAndAppsScreen.kt @@ -58,6 +58,7 @@ import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.res.stringResource import com.wire.android.model.Contact import com.wire.android.model.ItemActionType +import com.wire.android.search.apps.EmptySearchDisabledByConversationContent import com.wire.android.search.apps.SearchAppsScreen import com.wire.android.search.users.SearchAllPeopleScreen import com.wire.android.search.users.SearchUserViewModel @@ -229,13 +230,16 @@ fun SearchUsersAndAppsScreen( } SearchPeopleTabItem.SERVICES -> { - SearchAppsScreen( - protocolInfo = conversationProtocol, - searchQuery = searchBarState.searchQueryTextState.text.toString(), - onServiceClicked = onAppClicked, - lazyListState = lazyListStates[pageIndex], - isConversationAppsEnabled = isConversationAppsEnabled, - ) + if (isConversationAppsEnabled) { + SearchAppsScreen( + protocolInfo = conversationProtocol, + searchQuery = searchBarState.searchQueryTextState.text.toString(), + onServiceClicked = onAppClicked, + lazyListState = lazyListStates[pageIndex], + ) + } else { + EmptySearchDisabledByConversationContent() + } } } } diff --git a/core/search/src/main/kotlin/com/wire/android/search/apps/AppsContentState.kt b/core/search/src/main/kotlin/com/wire/android/search/apps/AppsContentState.kt index 16d5292c5fa..ab6f55b4854 100644 --- a/core/search/src/main/kotlin/com/wire/android/search/apps/AppsContentState.kt +++ b/core/search/src/main/kotlin/com/wire/android/search/apps/AppsContentState.kt @@ -26,5 +26,4 @@ enum class AppsContentState { EMPTY_INITIAL, EMPTY_SEARCH, SHOW_RESULTS, - APPS_NOT_ENABLED_FOR_CONVERSATION } diff --git a/core/search/src/main/kotlin/com/wire/android/search/apps/EmptySearchDisabledByConversationContent.kt b/core/search/src/main/kotlin/com/wire/android/search/apps/EmptySearchDisabledByConversationContent.kt index acd379a40b0..f5501b49fec 100644 --- a/core/search/src/main/kotlin/com/wire/android/search/apps/EmptySearchDisabledByConversationContent.kt +++ b/core/search/src/main/kotlin/com/wire/android/search/apps/EmptySearchDisabledByConversationContent.kt @@ -17,15 +17,12 @@ */ package com.wire.android.search.apps -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.wrapContentHeight import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign @@ -35,13 +32,10 @@ import com.wire.android.ui.theme.wireTypography @Composable fun EmptySearchDisabledByConversationContent(modifier: Modifier = Modifier) { - Column( + Box( modifier = modifier - .fillMaxWidth() - .wrapContentHeight() + .fillMaxSize() .padding(dimensions().spacing16x), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center, ) { Text( text = stringResource(R.string.search_results_apps_empty_description_disabled_for_conversation), diff --git a/core/search/src/main/kotlin/com/wire/android/search/apps/SearchAppsScreen.kt b/core/search/src/main/kotlin/com/wire/android/search/apps/SearchAppsScreen.kt index 43ea9ec8562..05aa62acb93 100644 --- a/core/search/src/main/kotlin/com/wire/android/search/apps/SearchAppsScreen.kt +++ b/core/search/src/main/kotlin/com/wire/android/search/apps/SearchAppsScreen.kt @@ -66,7 +66,6 @@ fun SearchAppsScreen( protocolInfo: Conversation.ProtocolInfo?, searchQuery: String, onServiceClicked: (Contact) -> Unit, - isConversationAppsEnabled: Boolean, searchAppsViewModel: SearchAppsViewModel = searchAppsViewModel(protocolInfo), lazyListState: LazyListState = rememberLazyListState() ) { @@ -83,7 +82,6 @@ fun SearchAppsScreen( appsAllowedResult = state.isTeamAllowedToUseApps, isSelfATeamAdmin = state.isSelfATeamAdmin, lazyListState = lazyListState, - isConversationAppsEnabled = isConversationAppsEnabled ) } } @@ -96,11 +94,9 @@ private fun SearchAllAppsContent( onServiceClicked: (Contact) -> Unit, appsAllowedResult: AppsAllowedResult, isSelfATeamAdmin: Boolean, - isConversationAppsEnabled: Boolean, lazyListState: LazyListState = rememberLazyListState() ) { val appsContentState by rememberAppsContentState( - isConversationAppsEnabled = isConversationAppsEnabled, isLoading = isLoading, appsAllowedResult = appsAllowedResult, searchQuery = searchQuery, @@ -125,10 +121,6 @@ private fun SearchAllAppsContent( CenteredCircularProgressBarIndicator() } - AppsContentState.APPS_NOT_ENABLED_FOR_CONVERSATION -> { - EmptySearchDisabledByConversationContent() - } - AppsContentState.TEAM_NOT_ALLOWED -> { UpgradeToGetAppsBanner() } @@ -156,16 +148,14 @@ private fun SearchAllAppsContent( @Composable private fun rememberAppsContentState( - isConversationAppsEnabled: Boolean, isLoading: Boolean, appsAllowedResult: AppsAllowedResult, searchQuery: String, result: ImmutableList -): State = remember(isConversationAppsEnabled, isLoading, appsAllowedResult, searchQuery, result) { +): State = remember(isLoading, appsAllowedResult, searchQuery, result) { derivedStateOf { if (isLoading) return@derivedStateOf AppsContentState.LOADING if (appsAllowedResult is AppsAllowedResult.Disabled) return@derivedStateOf AppsContentState.TEAM_NOT_ALLOWED - if (!isConversationAppsEnabled) return@derivedStateOf AppsContentState.APPS_NOT_ENABLED_FOR_CONVERSATION when { searchQuery.isBlank() && result.isEmpty() -> AppsContentState.EMPTY_SEARCH @@ -239,7 +229,6 @@ fun PreviewSearchAllServicesScreen_TeamNotEnabledForApps() = WireTheme { onServiceClicked = {}, appsAllowedResult = AppsAllowedResult.Disabled, isSelfATeamAdmin = true, - isConversationAppsEnabled = true ) } @@ -253,7 +242,6 @@ fun PreviewSearchAllServicesScreen_InitialResults() = WireTheme { onServiceClicked = {}, appsAllowedResult = AppsAllowedResult.Enabled(protocol = AppsAllowedProtocol.MLS), isSelfATeamAdmin = true, - isConversationAppsEnabled = true ) } @@ -267,7 +255,6 @@ fun PreviewSearchAllServicesScreen_EmptyInitialResults_TeamAdmin() = WireTheme { onServiceClicked = {}, appsAllowedResult = AppsAllowedResult.Enabled(protocol = AppsAllowedProtocol.MLS), isSelfATeamAdmin = true, - isConversationAppsEnabled = true ) } @@ -281,7 +268,6 @@ fun PreviewSearchAllServicesScreen_EmptyInitialResults_NonTeamAdmin() = WireThem onServiceClicked = {}, appsAllowedResult = AppsAllowedResult.Enabled(protocol = AppsAllowedProtocol.MLS), isSelfATeamAdmin = false, - isConversationAppsEnabled = true ) } @@ -295,7 +281,6 @@ fun PreviewSearchAllServicesScreen_SearchResults() = WireTheme { onServiceClicked = {}, appsAllowedResult = AppsAllowedResult.Enabled(protocol = AppsAllowedProtocol.MLS), isSelfATeamAdmin = true, - isConversationAppsEnabled = true ) } @@ -309,7 +294,6 @@ fun PreviewSearchAllServicesScreen_EmptySearchResults() = WireTheme { onServiceClicked = {}, appsAllowedResult = AppsAllowedResult.Enabled(protocol = AppsAllowedProtocol.MLS), isSelfATeamAdmin = true, - isConversationAppsEnabled = true ) } @@ -323,7 +307,6 @@ fun PreviewSearchAllServicesScreen_EmptySearchResultsDisabledInConversation() = onServiceClicked = {}, appsAllowedResult = AppsAllowedResult.Enabled(protocol = AppsAllowedProtocol.MLS), isSelfATeamAdmin = true, - isConversationAppsEnabled = false ) } diff --git a/core/search/src/main/kotlin/com/wire/android/search/apps/SearchAppsViewModel.kt b/core/search/src/main/kotlin/com/wire/android/search/apps/SearchAppsViewModel.kt index bedc855e28d..8f950af5931 100644 --- a/core/search/src/main/kotlin/com/wire/android/search/apps/SearchAppsViewModel.kt +++ b/core/search/src/main/kotlin/com/wire/android/search/apps/SearchAppsViewModel.kt @@ -22,15 +22,19 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.wire.android.di.metro.WireAssistedViewModelBinding import com.wire.android.mapper.ContactMapper import com.wire.android.model.Contact +import com.wire.android.search.SearchManualViewModelFactoryGroup import com.wire.android.ui.common.DEFAULT_SEARCH_QUERY_DEBOUNCE import com.wire.android.util.AppsUtil import com.wire.android.util.EMPTY +import com.wire.kalium.common.logger.kaliumLogger import com.wire.kalium.logic.data.conversation.Conversation import com.wire.kalium.logic.data.user.type.isTeamAdmin import com.wire.kalium.logic.feature.app.ObserveAllAppsUseCase import com.wire.kalium.logic.feature.app.SearchAppsByNameUseCase +import com.wire.kalium.logic.feature.app.SyncAppsUseCase import com.wire.kalium.logic.feature.featureConfig.AppsAllowedResult import com.wire.kalium.logic.feature.featureConfig.ObserveIsAppsAllowedForUsageUseCase import com.wire.kalium.logic.feature.service.ObserveAllServicesUseCase @@ -43,6 +47,8 @@ import dev.zacsweers.metro.AssistedInject import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.Deferred +import kotlinx.coroutines.async import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.combine @@ -50,14 +56,14 @@ import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.launch -import com.wire.android.di.metro.WireAssistedViewModelBinding -import com.wire.android.search.SearchManualViewModelFactoryGroup +@Suppress("LongParameterList") @WireAssistedViewModelBinding(SearchManualViewModelFactoryGroup::class) class SearchAppsViewModel @AssistedInject constructor( @Assisted private val protocolInfo: Conversation.ProtocolInfo?, private val getAllServices: ObserveAllServicesUseCase, private val syncServices: SyncServicesUseCase, + private val syncApps: SyncAppsUseCase, private val getAllApps: ObserveAllAppsUseCase, private val contactMapper: ContactMapper, private val searchServicesByName: SearchServicesByNameUseCase, @@ -71,6 +77,7 @@ class SearchAppsViewModel @AssistedInject constructor( } private val searchQueryTextFlow = MutableStateFlow(String.EMPTY) private var servicesSynced = false + private var appsSync: Deferred? = null var state: SearchServicesState by mutableStateOf(SearchServicesState(isLoading = true)) private set @@ -111,29 +118,37 @@ class SearchAppsViewModel @AssistedInject constructor( } } - private fun search(query: String, appsAllowedResult: AppsAllowedResult.Enabled) { - viewModelScope.launch { - val showNewApps = AppsUtil.isAppsAllowed( - appsAllowedResult = appsAllowedResult, - conversationProtocol = protocolInfo - ) + private suspend fun search(query: String, appsAllowedResult: AppsAllowedResult.Enabled) { + val showNewApps = AppsUtil.isAppsAllowed( + appsAllowedResult = appsAllowedResult, + conversationProtocol = protocolInfo + ) - val result = if (showNewApps) { - if (query.isEmpty()) getAllApps() else searchAppsByName(query) - } else { - if (!servicesSynced) { - servicesSynced = true - launch { syncServices() } + val result = if (showNewApps) { + if (query.isEmpty() && appsSync == null) { + appsSync = viewModelScope.async { + syncApps().also { syncResult -> + if (syncResult is SyncAppsUseCase.Result.Failure) { + kaliumLogger.w("Failed to refresh apps; using the local cache: ${syncResult.error}") + } + } } - if (query.isEmpty()) getAllServices() else searchServicesByName(query) } - - state = state.copy( - isLoading = false, - searchQuery = query, - result = result.first().map(contactMapper::fromService).toImmutableList() - ) + appsSync?.await() + if (query.isEmpty()) getAllApps() else searchAppsByName(query) + } else { + if (!servicesSynced) { + servicesSynced = true + viewModelScope.launch { syncServices() } + } + if (query.isEmpty()) getAllServices() else searchServicesByName(query) } + + state = state.copy( + isLoading = false, + searchQuery = query, + result = result.first().map(contactMapper::fromService).toImmutableList() + ) } } diff --git a/core/search/src/test/kotlin/com/wire/android/search/apps/SearchAppsViewModelTest.kt b/core/search/src/test/kotlin/com/wire/android/search/apps/SearchAppsViewModelTest.kt index d4f44ce1d27..c1571d86f98 100644 --- a/core/search/src/test/kotlin/com/wire/android/search/apps/SearchAppsViewModelTest.kt +++ b/core/search/src/test/kotlin/com/wire/android/search/apps/SearchAppsViewModelTest.kt @@ -35,6 +35,7 @@ import com.wire.kalium.logic.data.user.ConnectionState import com.wire.kalium.logic.data.user.SupportedProtocol import com.wire.kalium.logic.feature.app.ObserveAllAppsUseCase import com.wire.kalium.logic.feature.app.SearchAppsByNameUseCase +import com.wire.kalium.logic.feature.app.SyncAppsUseCase import com.wire.kalium.logic.feature.featureConfig.AppsAllowedProtocol import com.wire.kalium.logic.feature.featureConfig.AppsAllowedResult import com.wire.kalium.logic.feature.featureConfig.ObserveIsAppsAllowedForUsageUseCase @@ -46,6 +47,7 @@ import com.wire.kalium.logic.feature.user.ObserveSelfUserUseCase import io.mockk.MockKAnnotations import io.mockk.coEvery import io.mockk.coVerify +import io.mockk.coVerifyOrder import io.mockk.every import io.mockk.impl.annotations.MockK import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -66,7 +68,7 @@ class SearchAppsViewModelTest { fun `given apps feature flag is disabled, when init view model, then loading is finished and result is empty`() = runTest { // given - val (_, viewModel) = Arrangement() + val (arrangement, viewModel) = Arrangement() .arrange(protocolInfo = null) // when @@ -76,6 +78,7 @@ class SearchAppsViewModelTest { // then assertTrue(viewModel.state.result.isEmpty()) assertFalse(viewModel.state.isLoading) + coVerify(exactly = 0) { arrangement.syncApps() } } @Test @@ -93,6 +96,11 @@ class SearchAppsViewModelTest { // then coVerify(exactly = 1) { + arrangement.syncApps() + arrangement.getAllApps() + } + coVerifyOrder { + arrangement.syncApps() arrangement.getAllApps() } assertEquals(1, viewModel.state.result.size) @@ -115,6 +123,7 @@ class SearchAppsViewModelTest { coVerify(exactly = 1) { arrangement.getAllServices() } + coVerify(exactly = 0) { arrangement.syncApps() } assertEquals(1, viewModel.state.result.size) } @@ -264,10 +273,25 @@ class SearchAppsViewModelTest { // then coVerify(exactly = 1) { arrangement.searchAppsByName(query) + arrangement.syncApps() } assertEquals(1, viewModel.state.result.size) } + @Test + fun `given app refresh fails, when loading Apps, then cached Apps are still emitted`() = runTest { + val (_, viewModel) = Arrangement() + .withAppsAllowedForUsage(AppsAllowedResult.Enabled(AppsAllowedProtocol.MLS)) + .withGetAllApps(listOf(SERVICE_DETAILS)) + .withSyncAppsFailing() + .arrange(protocolInfo = null) + + advanceUntilIdle() + + assertEquals(1, viewModel.state.result.size) + assertFalse(viewModel.state.isLoading) + } + @Test fun `given services branch is used across multiple searches, when init view model, then syncServices is called exactly once`() = runTest { @@ -405,6 +429,9 @@ class SearchAppsViewModelTest { @MockK lateinit var syncServices: SyncServicesUseCase + @MockK + lateinit var syncApps: SyncAppsUseCase + @MockK lateinit var getAllApps: ObserveAllAppsUseCase @@ -428,6 +455,7 @@ class SearchAppsViewModelTest { coEvery { getAllServices() } returns flowOf(emptyList()) coEvery { syncServices() } returns SyncServicesUseCase.Result.Success + coEvery { syncApps() } returns SyncAppsUseCase.Result.Success coEvery { getAllApps() } returns flowOf(emptyList()) coEvery { searchServicesByName(any()) } returns flowOf(emptyList()) coEvery { searchAppsByName(any()) } returns flowOf(emptyList()) @@ -442,6 +470,7 @@ class SearchAppsViewModelTest { protocolInfo = protocolInfo, getAllServices = getAllServices, syncServices = syncServices, + syncApps = syncApps, getAllApps = getAllApps, contactMapper = contactMapper, searchServicesByName = searchServicesByName, @@ -473,5 +502,9 @@ class SearchAppsViewModelTest { fun withSyncServicesFailing() = apply { coEvery { syncServices() } returns SyncServicesUseCase.Result.Failure(NetworkFailure.NoNetworkConnection(cause = null)) } + + fun withSyncAppsFailing() = apply { + coEvery { syncApps() } returns SyncAppsUseCase.Result.Failure(NetworkFailure.NoNetworkConnection(cause = null)) + } } } diff --git a/kalium b/kalium index 860840cce6f..8b689379fcc 160000 --- a/kalium +++ b/kalium @@ -1 +1 @@ -Subproject commit 860840cce6f25666d026f15d97aba341d6f2dcb5 +Subproject commit 8b689379fcc4a9bf8c1b5abc2ddaa8f9e7492d23