diff --git a/logic/src/commonMain/kotlin/com/wire/kalium/logic/data/analytics/AnalyticsRepository.kt b/logic/src/commonMain/kotlin/com/wire/kalium/logic/data/analytics/AnalyticsRepository.kt new file mode 100644 index 0000000000..841330dc19 --- /dev/null +++ b/logic/src/commonMain/kotlin/com/wire/kalium/logic/data/analytics/AnalyticsRepository.kt @@ -0,0 +1,88 @@ +/* + * Wire + * Copyright (C) 2025 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.kalium.logic.data.analytics + +import com.wire.kalium.common.error.CoreFailure +import com.wire.kalium.common.error.StorageFailure +import com.wire.kalium.common.error.wrapStorageRequest +import com.wire.kalium.common.functional.Either +import com.wire.kalium.common.functional.flatMap +import com.wire.kalium.common.functional.left +import com.wire.kalium.logic.data.id.SelfTeamIdProvider +import com.wire.kalium.logic.data.id.toDao +import com.wire.kalium.logic.data.user.UserId +import com.wire.kalium.persistence.dao.MetadataDAO +import com.wire.kalium.persistence.dao.UserDAO +import kotlinx.datetime.Instant + +interface AnalyticsRepository { + suspend fun getContactsAmountCached(): Either + suspend fun getTeamMembersAmountCached(): Either + suspend fun setContactsAmountCached(amount: Int) + suspend fun setTeamMembersAmountCached(amount: Int) + suspend fun getLastContactsDateUpdateDate(): Either + suspend fun setContactsAmountCachingDate(date: Instant) + suspend fun countContactsAmount(): Either + suspend fun countTeamMembersAmount(): Either +} + +internal class AnalyticsDataSource( + private val userDAO: UserDAO, + private val metadataDAO: MetadataDAO, + private val selfUserId: UserId, + private val selfTeamIdProvider: SelfTeamIdProvider, +) : AnalyticsRepository { + + override suspend fun getContactsAmountCached(): Either = wrapStorageRequest { + metadataDAO.valueByKey(CONTACTS_AMOUNT_KEY)?.toInt() + } + + override suspend fun getTeamMembersAmountCached(): Either = wrapStorageRequest { + metadataDAO.valueByKey(TEAM_MEMBERS_AMOUNT_KEY)?.toInt() + } + + override suspend fun setContactsAmountCached(amount: Int) = + metadataDAO.insertValue(CONTACTS_AMOUNT_KEY, amount.toString()) + + override suspend fun setTeamMembersAmountCached(amount: Int) = + metadataDAO.insertValue(TEAM_MEMBERS_AMOUNT_KEY, amount.toString()) + + override suspend fun getLastContactsDateUpdateDate(): Either = wrapStorageRequest { + metadataDAO.valueByKey(LAST_CONTACTS_UPDATE_KEY)?.let { Instant.parse(it) } + } + + override suspend fun setContactsAmountCachingDate(date: Instant) = + metadataDAO.insertValue(LAST_CONTACTS_UPDATE_KEY, date.toString()) + + override suspend fun countContactsAmount(): Either = wrapStorageRequest { + userDAO.countContactsAmount(selfUserId.toDao()) + } + + override suspend fun countTeamMembersAmount(): Either = selfTeamIdProvider() + .flatMap { teamId -> + teamId?.let { + wrapStorageRequest { userDAO.countTeamMembersAmount(it.value, selfUserId.toDao()) } + } ?: StorageFailure.DataNotFound.left() + } + + companion object { + internal const val CONTACTS_AMOUNT_KEY = "all_contacts_amount" + internal const val TEAM_MEMBERS_AMOUNT_KEY = "team_members_amount" + internal const val LAST_CONTACTS_UPDATE_KEY = "last_contacts_update_date" + } +} diff --git a/logic/src/commonMain/kotlin/com/wire/kalium/logic/data/user/UserRepository.kt b/logic/src/commonMain/kotlin/com/wire/kalium/logic/data/user/UserRepository.kt index 882254bfa6..bd549129c3 100644 --- a/logic/src/commonMain/kotlin/com/wire/kalium/logic/data/user/UserRepository.kt +++ b/logic/src/commonMain/kotlin/com/wire/kalium/logic/data/user/UserRepository.kt @@ -18,10 +18,22 @@ package com.wire.kalium.logic.data.user -import com.wire.kalium.logger.obfuscateDomain import com.wire.kalium.common.error.CoreFailure import com.wire.kalium.common.error.NetworkFailure import com.wire.kalium.common.error.StorageFailure +import com.wire.kalium.common.error.wrapApiRequest +import com.wire.kalium.common.error.wrapStorageRequest +import com.wire.kalium.common.functional.Either +import com.wire.kalium.common.functional.flatMap +import com.wire.kalium.common.functional.flatMapLeft +import com.wire.kalium.common.functional.foldToEitherWhileRight +import com.wire.kalium.common.functional.getOrNull +import com.wire.kalium.common.functional.map +import com.wire.kalium.common.functional.mapRight +import com.wire.kalium.common.functional.onFailure +import com.wire.kalium.common.functional.onSuccess +import com.wire.kalium.common.logger.kaliumLogger +import com.wire.kalium.logger.obfuscateDomain import com.wire.kalium.logic.data.conversation.ClientId import com.wire.kalium.logic.data.conversation.MemberMapper import com.wire.kalium.logic.data.conversation.Recipient @@ -44,19 +56,7 @@ import com.wire.kalium.logic.data.team.TeamMapper import com.wire.kalium.logic.data.user.type.UserEntityTypeMapper import com.wire.kalium.logic.di.MapperProvider import com.wire.kalium.logic.failure.SelfUserDeleted -import com.wire.kalium.common.functional.Either -import com.wire.kalium.common.functional.flatMap -import com.wire.kalium.common.functional.flatMapLeft -import com.wire.kalium.common.functional.foldToEitherWhileRight -import com.wire.kalium.common.functional.getOrNull -import com.wire.kalium.common.functional.map -import com.wire.kalium.common.functional.mapRight -import com.wire.kalium.common.functional.onFailure -import com.wire.kalium.common.functional.onSuccess -import com.wire.kalium.common.logger.kaliumLogger import com.wire.kalium.logic.sync.receiver.handler.legalhold.LegalHoldHandler -import com.wire.kalium.common.error.wrapApiRequest -import com.wire.kalium.common.error.wrapStorageRequest import com.wire.kalium.network.api.authenticated.teams.TeamMemberDTO import com.wire.kalium.network.api.authenticated.teams.TeamMemberIdList import com.wire.kalium.network.api.authenticated.userDetails.ListUserRequest @@ -368,7 +368,7 @@ internal class UserDataSource internal constructor( } override suspend fun observeSelfUser(): Flow = userDAO.observeUserDetailsByQualifiedID(selfUserId.toDao()).filterNotNull() - .map(userMapper::fromUserDetailsEntityToSelfUser) + .map(userMapper::fromUserDetailsEntityToSelfUser) override suspend fun observeSelfUserWithTeam(): Flow> { return userDAO.getUserDetailsWithTeamByQualifiedID(selfUserId.toDao()).filterNotNull() diff --git a/logic/src/commonMain/kotlin/com/wire/kalium/logic/feature/UserSessionScope.kt b/logic/src/commonMain/kotlin/com/wire/kalium/logic/feature/UserSessionScope.kt index 64693654f0..bb5bef954a 100644 --- a/logic/src/commonMain/kotlin/com/wire/kalium/logic/feature/UserSessionScope.kt +++ b/logic/src/commonMain/kotlin/com/wire/kalium/logic/feature/UserSessionScope.kt @@ -39,6 +39,8 @@ import com.wire.kalium.logic.configuration.ClientConfig import com.wire.kalium.logic.configuration.UserConfigDataSource import com.wire.kalium.logic.configuration.UserConfigRepository import com.wire.kalium.logic.configuration.notification.NotificationTokenDataSource +import com.wire.kalium.logic.data.analytics.AnalyticsDataSource +import com.wire.kalium.logic.data.analytics.AnalyticsRepository import com.wire.kalium.logic.data.asset.AssetDataSource import com.wire.kalium.logic.data.asset.AssetRepository import com.wire.kalium.logic.data.asset.DataStoragePaths @@ -164,8 +166,12 @@ import com.wire.kalium.logic.di.PlatformUserStorageProperties import com.wire.kalium.logic.di.RootPathsProvider import com.wire.kalium.logic.di.UserStorageProvider import com.wire.kalium.logic.feature.analytics.AnalyticsIdentifierManager +import com.wire.kalium.logic.feature.analytics.GetAnalyticsContactsDataUseCase +import com.wire.kalium.logic.feature.analytics.GetAnalyticsContactsDataUseCaseImpl import com.wire.kalium.logic.feature.analytics.GetCurrentAnalyticsTrackingIdentifierUseCase import com.wire.kalium.logic.feature.analytics.ObserveAnalyticsTrackingIdentifierStatusUseCase +import com.wire.kalium.logic.feature.analytics.UpdateContactsAmountsCacheUseCase +import com.wire.kalium.logic.feature.analytics.UpdateContactsAmountsCacheUseCaseImpl import com.wire.kalium.logic.feature.applock.AppLockTeamFeatureConfigObserver import com.wire.kalium.logic.feature.applock.AppLockTeamFeatureConfigObserverImpl import com.wire.kalium.logic.feature.applock.MarkTeamAppLockStatusAsNotifiedUseCase @@ -825,7 +831,7 @@ class UserSessionScope internal constructor( sessionRepository = globalScope.sessionRepository, selfUserId = userId, selfTeamIdProvider = selfTeamId, - legalHoldHandler = legalHoldHandler, + legalHoldHandler = legalHoldHandler ) private val accountRepository: AccountRepository @@ -2196,12 +2202,33 @@ class UserSessionScope internal constructor( kaliumLogger = userScopedLogger, ) + private val analyticsRepository: AnalyticsRepository + get() = AnalyticsDataSource( + userDAO = userStorage.database.userDAO, + selfTeamIdProvider = selfTeamId, + selfUserId = userId, + metadataDAO = userStorage.database.metadataDAO + ) + val getTeamUrlUseCase: GetTeamUrlUseCase get() = GetTeamUrlUseCase( userId, authenticationScope.serverConfigRepository, ) + val getAnalyticsContactsData: GetAnalyticsContactsDataUseCase = GetAnalyticsContactsDataUseCaseImpl( + selfTeamIdProvider = selfTeamId, + slowSyncRepository = slowSyncRepository, + analyticsRepository = analyticsRepository, + userConfigRepository = userConfigRepository + ) + + private val updateContactsAmountsCache: UpdateContactsAmountsCacheUseCase = UpdateContactsAmountsCacheUseCaseImpl( + selfTeamIdProvider = selfTeamId, + slowSyncRepository = slowSyncRepository, + analyticsRepository = analyticsRepository, + ) + /** * This will start subscribers of observable work per user session, as long as the user is logged in. * When the user logs out, this work will be canceled. @@ -2258,6 +2285,11 @@ class UserSessionScope internal constructor( launch { messages.confirmationDeliveryHandler.sendPendingConfirmations() } + + launch { + updateContactsAmountsCache() + } + syncExecutor.startAndStopSyncAsNeeded() } } diff --git a/logic/src/commonMain/kotlin/com/wire/kalium/logic/feature/analytics/GetAnalyticsContactsDataUseCase.kt b/logic/src/commonMain/kotlin/com/wire/kalium/logic/feature/analytics/GetAnalyticsContactsDataUseCase.kt new file mode 100644 index 0000000000..faec209528 --- /dev/null +++ b/logic/src/commonMain/kotlin/com/wire/kalium/logic/feature/analytics/GetAnalyticsContactsDataUseCase.kt @@ -0,0 +1,108 @@ +/* + * Wire + * Copyright (C) 2025 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.kalium.logic.feature.analytics + +import com.wire.kalium.common.functional.flatMapLeft +import com.wire.kalium.common.functional.getOrElse +import com.wire.kalium.common.functional.getOrNull +import com.wire.kalium.logic.configuration.UserConfigRepository +import com.wire.kalium.logic.data.analytics.AnalyticsRepository +import com.wire.kalium.logic.data.id.SelfTeamIdProvider +import com.wire.kalium.logic.data.id.TeamId +import com.wire.kalium.logic.data.sync.SlowSyncRepository +import com.wire.kalium.logic.data.sync.SlowSyncStatus +import kotlinx.coroutines.flow.first + +/** + * Use case that combine contacts data necessary for analytics [AnalyticsContactsData]. + * It always get a Cached data and, except case when there is no cache, in that case useCase selects all the data from DB. + */ +interface GetAnalyticsContactsDataUseCase { + suspend operator fun invoke(): AnalyticsContactsData +} + +class GetAnalyticsContactsDataUseCaseImpl internal constructor( + private val selfTeamIdProvider: SelfTeamIdProvider, + private val slowSyncRepository: SlowSyncRepository, + private val analyticsRepository: AnalyticsRepository, + private val userConfigRepository: UserConfigRepository, +) : GetAnalyticsContactsDataUseCase { + + override suspend fun invoke(): AnalyticsContactsData { + slowSyncRepository.slowSyncStatus.first { it is SlowSyncStatus.Complete } + + val teamId = selfTeamIdProvider().getOrNull() + return getAnalyticsContactsData(teamId) + } + + private suspend fun getAnalyticsContactsData(teamId: TeamId?): AnalyticsContactsData = + if (teamId == null) { + val contactsSize = analyticsRepository.getContactsAmountCached() + .flatMapLeft { analyticsRepository.countContactsAmount() } + .getOrNull() + + AnalyticsContactsData( + teamId = null, + teamSize = null, + isEnterprise = null, + contactsSize = contactsSize, + isTeamMember = false + ) + } else { + val teamSize = analyticsRepository.getTeamMembersAmountCached() + .flatMapLeft { analyticsRepository.countTeamMembersAmount() } + .getOrNull() ?: 0 + val isEnterprise = userConfigRepository.isConferenceCallingEnabled().getOrElse { false } + + if (teamSize > SMALL_TEAM_MAX) { + AnalyticsContactsData( + teamId = teamId.value, + teamSize = teamSize, + contactsSize = null, + isEnterprise = isEnterprise, + isTeamMember = true + ) + } else { + // Smaller teams are not tracked due to legal precautions and the potential for user identification. + AnalyticsContactsData( + teamId = null, + teamSize = null, + contactsSize = null, + isEnterprise = isEnterprise, + isTeamMember = true + ) + } + } + + companion object { + private const val SMALL_TEAM_MAX = 5 + } + +} + +/** + * If val is null mean it shouldn't be provided to the analytics. + * More details in task https://wearezeta.atlassian.net/browse/WPB-16121 + */ +data class AnalyticsContactsData( + val teamId: String?, + val contactsSize: Int?, + val teamSize: Int?, + val isEnterprise: Boolean?, + val isTeamMember: Boolean +) diff --git a/logic/src/commonMain/kotlin/com/wire/kalium/logic/feature/analytics/UpdateContactsAmountsCacheUseCase.kt b/logic/src/commonMain/kotlin/com/wire/kalium/logic/feature/analytics/UpdateContactsAmountsCacheUseCase.kt new file mode 100644 index 0000000000..511885df2c --- /dev/null +++ b/logic/src/commonMain/kotlin/com/wire/kalium/logic/feature/analytics/UpdateContactsAmountsCacheUseCase.kt @@ -0,0 +1,68 @@ +/* + * Wire + * Copyright (C) 2025 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.kalium.logic.feature.analytics + +import com.wire.kalium.common.functional.getOrNull +import com.wire.kalium.logic.data.analytics.AnalyticsRepository +import com.wire.kalium.logic.data.id.SelfTeamIdProvider +import com.wire.kalium.logic.data.sync.SlowSyncRepository +import com.wire.kalium.logic.data.sync.SlowSyncStatus +import com.wire.kalium.logic.data.user.UserRepository +import kotlinx.coroutines.flow.first +import kotlinx.datetime.Clock +import kotlin.time.Duration.Companion.days + +/** + * Use case that checks if users ContactsAmount and TeamSize cache are too old and updates it. + * Currently max live period is [UpdateContactsAmountsCacheUseCaseImpl.CACHE_PERIOD] 7 days + */ +interface UpdateContactsAmountsCacheUseCase { + suspend operator fun invoke() +} + +class UpdateContactsAmountsCacheUseCaseImpl internal constructor( + private val selfTeamIdProvider: SelfTeamIdProvider, + private val slowSyncRepository: SlowSyncRepository, + private val analyticsRepository: AnalyticsRepository, +) : UpdateContactsAmountsCacheUseCase { + + override suspend fun invoke() { + slowSyncRepository.slowSyncStatus.first { it is SlowSyncStatus.Complete } + + val nowDate = Clock.System.now() + val updateTime = analyticsRepository.getLastContactsDateUpdateDate().getOrNull() + + if (updateTime != null && nowDate.minus(updateTime) < CACHE_PERIOD) return + + val teamId = selfTeamIdProvider().getOrNull() + + with(analyticsRepository) { + val contactsAmount = countContactsAmount().getOrNull() ?: 0 + val teamAmount = teamId?.let { countTeamMembersAmount().getOrNull() } ?: 0 + + setContactsAmountCached(contactsAmount) + setTeamMembersAmountCached(teamAmount) + setContactsAmountCachingDate(nowDate) + } + } + + companion object { + private val CACHE_PERIOD = 7.days + } + +} diff --git a/logic/src/commonTest/kotlin/com/wire/kalium/logic/data/analytics/AnalyticsRepositoryTest.kt b/logic/src/commonTest/kotlin/com/wire/kalium/logic/data/analytics/AnalyticsRepositoryTest.kt new file mode 100644 index 0000000000..c8e6fb7997 --- /dev/null +++ b/logic/src/commonTest/kotlin/com/wire/kalium/logic/data/analytics/AnalyticsRepositoryTest.kt @@ -0,0 +1,182 @@ +/* + * Wire + * Copyright (C) 2025 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.kalium.logic.data.analytics + +import com.wire.kalium.common.functional.Either +import com.wire.kalium.logic.data.id.SelfTeamIdProvider +import com.wire.kalium.logic.framework.TestTeam +import com.wire.kalium.logic.framework.TestUser +import com.wire.kalium.logic.util.shouldFail +import com.wire.kalium.logic.util.shouldSucceed +import com.wire.kalium.persistence.dao.MetadataDAO +import com.wire.kalium.persistence.dao.UserDAO +import com.wire.kalium.persistence.dao.client.ClientDAO +import io.mockative.Mock +import io.mockative.any +import io.mockative.coEvery +import io.mockative.coVerify +import io.mockative.eq +import io.mockative.mock +import io.mockative.once +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals + +class AnalyticsRepositoryTest { + + + @Test + fun givenCachedContactsAmountAbsent_whenGettingContactsAmountCached_thenShouldPropagateError() = runTest { + // given + val (arrangement, userRepository) = Arrangement() + .withGetMetaDataDaoValue(AnalyticsDataSource.CONTACTS_AMOUNT_KEY, null) + .arrange() + // when + val result = userRepository.getContactsAmountCached() + // then + result.shouldFail() + coVerify { + arrangement.metadataDAO.valueByKey(AnalyticsDataSource.CONTACTS_AMOUNT_KEY) + }.wasInvoked(exactly = once) + } + + @Test + fun givenCachedContactsAmount_whenGettingContactsAmountCached_thenShouldPropagateResult() = runTest { + // given + val (arrangement, userRepository) = Arrangement() + .withGetMetaDataDaoValue(AnalyticsDataSource.CONTACTS_AMOUNT_KEY, "12") + .arrange() + // when + val result = userRepository.getContactsAmountCached() + // then + result.shouldSucceed { assertEquals(12, it) } + coVerify { + arrangement.metadataDAO.valueByKey(AnalyticsDataSource.CONTACTS_AMOUNT_KEY) + }.wasInvoked(exactly = once) + } + + @Test + fun givenCachedTeamSizeAbsent_whenGettingTeamSizeCached_thenShouldPropagateError() = runTest { + // given + val (arrangement, userRepository) = Arrangement() + .withGetMetaDataDaoValue(AnalyticsDataSource.TEAM_MEMBERS_AMOUNT_KEY, null) + .arrange() + // when + val result = userRepository.getTeamMembersAmountCached() + // then + result.shouldFail() + coVerify { + arrangement.metadataDAO.valueByKey(AnalyticsDataSource.TEAM_MEMBERS_AMOUNT_KEY) + }.wasInvoked(exactly = once) + } + + @Test + fun givenCachedTeamSize_whenGettingTeamSizeCached_thenShouldPropagateResult() = runTest { + // given + val (arrangement, userRepository) = Arrangement() + .withGetMetaDataDaoValue(AnalyticsDataSource.TEAM_MEMBERS_AMOUNT_KEY, "12") + .arrange() + // when + val result = userRepository.getTeamMembersAmountCached() + // then + result.shouldSucceed { assertEquals(12, it) } + coVerify { + arrangement.metadataDAO.valueByKey(AnalyticsDataSource.TEAM_MEMBERS_AMOUNT_KEY) + }.wasInvoked(exactly = once) + } + + @Test + fun givenCountingContactsSucceed_whenCountingContactsCalled_thenShouldPropagateResult() = runTest { + // given + val (arrangement, userRepository) = Arrangement() + .withCountContactsAmountResult(12) + .arrange() + // when + val result = userRepository.countContactsAmount() + // then + result.shouldSucceed { assertEquals(12, it) } + coVerify { + arrangement.userDAO.countContactsAmount(any()) + }.wasInvoked(exactly = once) + } + + @Test + fun givenCountingTeamSizeSucceed_whenCountingTeamSize_thenShouldPropagateResult() = runTest { + // given + val (arrangement, userRepository) = Arrangement() + .withCountTeamMembersAmount(12) + .arrange() + // when + val result = userRepository.countTeamMembersAmount() + // then + result.shouldSucceed { assertEquals(12, it) } + coVerify { + arrangement.userDAO.countTeamMembersAmount(any(), any()) + }.wasInvoked(exactly = once) + } + + private class Arrangement { + @Mock + val userDAO = mock(UserDAO::class) + + @Mock + val clientDAO = mock(ClientDAO::class) + + @Mock + val selfTeamIdProvider: SelfTeamIdProvider = mock(SelfTeamIdProvider::class) + + @Mock + val metadataDAO: MetadataDAO = mock(MetadataDAO::class) + + val analyticsRepository: AnalyticsRepository by lazy { + AnalyticsDataSource( + userDAO = userDAO, + selfTeamIdProvider = selfTeamIdProvider, + selfUserId = TestUser.USER_ID, + metadataDAO = metadataDAO, + ) + } + + suspend fun withGetMetaDataDaoValue(key: String, result: String?) = apply { + coEvery { metadataDAO.valueByKey(eq(key)) }.returns(result) + } + + suspend fun withCountContactsAmountResult(result: Int) = apply { + coEvery { userDAO.countContactsAmount(any()) }.returns(result) + } + + suspend fun withCountTeamMembersAmount(result: Int) = apply { + coEvery { userDAO.countTeamMembersAmount(any(), any()) }.returns(result) + } + + suspend inline fun arrange(block: (Arrangement.() -> Unit) = { }): Pair { + coEvery { + userDAO.observeUserDetailsByQualifiedID(any()) + }.returns(flowOf(TestUser.DETAILS_ENTITY)) + + coEvery { + selfTeamIdProvider() + }.returns(Either.Right(TestTeam.TEAM_ID)) + coEvery { metadataDAO.insertValue(any(), any()) }.returns(Unit) + apply(block) + return this to analyticsRepository + } + + } +} diff --git a/logic/src/commonTest/kotlin/com/wire/kalium/logic/data/user/UserRepositoryTest.kt b/logic/src/commonTest/kotlin/com/wire/kalium/logic/data/user/UserRepositoryTest.kt index bd4c1b5d93..304870e948 100644 --- a/logic/src/commonTest/kotlin/com/wire/kalium/logic/data/user/UserRepositoryTest.kt +++ b/logic/src/commonTest/kotlin/com/wire/kalium/logic/data/user/UserRepositoryTest.kt @@ -20,6 +20,8 @@ package com.wire.kalium.logic.data.user import app.cash.turbine.test import com.wire.kalium.common.error.StorageFailure +import com.wire.kalium.common.functional.Either +import com.wire.kalium.common.functional.getOrNull import com.wire.kalium.logic.data.id.QualifiedID import com.wire.kalium.logic.data.id.SelfTeamIdProvider import com.wire.kalium.logic.data.id.TeamId @@ -34,8 +36,6 @@ import com.wire.kalium.logic.framework.TestEvent import com.wire.kalium.logic.framework.TestTeam import com.wire.kalium.logic.framework.TestUser import com.wire.kalium.logic.framework.TestUser.LIST_USERS_DTO -import com.wire.kalium.common.functional.Either -import com.wire.kalium.common.functional.getOrNull import com.wire.kalium.logic.sync.receiver.handler.legalhold.LegalHoldHandler import com.wire.kalium.logic.test_util.TestNetworkException import com.wire.kalium.logic.test_util.TestNetworkException.federationNotEnabled @@ -841,7 +841,7 @@ class UserRepositoryTest { suspend fun withSuccessfulGetMultipleUsers() = apply { coEvery { userDetailsApi.getMultipleUsers(any()) - }.returns(NetworkResponse.Success(value = LIST_USERS_DTO, headers = mapOf(), httpCode = 200) ) + }.returns(NetworkResponse.Success(value = LIST_USERS_DTO, headers = mapOf(), httpCode = 200)) } suspend fun withAllOtherUsersIdSuccess( diff --git a/logic/src/commonTest/kotlin/com/wire/kalium/logic/feature/analytics/GetAnalyticsContactsDataUseCaseTest.kt b/logic/src/commonTest/kotlin/com/wire/kalium/logic/feature/analytics/GetAnalyticsContactsDataUseCaseTest.kt new file mode 100644 index 0000000000..3fc89e3d75 --- /dev/null +++ b/logic/src/commonTest/kotlin/com/wire/kalium/logic/feature/analytics/GetAnalyticsContactsDataUseCaseTest.kt @@ -0,0 +1,193 @@ +/* + * Wire + * Copyright (C) 2025 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.kalium.logic.feature.analytics + +import com.wire.kalium.common.error.StorageFailure +import com.wire.kalium.common.functional.left +import com.wire.kalium.common.functional.right +import com.wire.kalium.logic.data.id.SelfTeamIdProvider +import com.wire.kalium.logic.data.id.TeamId +import com.wire.kalium.logic.data.sync.SlowSyncRepository +import com.wire.kalium.logic.data.sync.SlowSyncStatus +import com.wire.kalium.logic.util.arrangement.repository.AnalyticsRepositoryArrangement +import com.wire.kalium.logic.util.arrangement.repository.AnalyticsRepositoryArrangementImpl +import com.wire.kalium.logic.util.arrangement.repository.UserConfigRepositoryArrangement +import com.wire.kalium.logic.util.arrangement.repository.UserConfigRepositoryArrangementImpl +import io.mockative.Mock +import io.mockative.coEvery +import io.mockative.coVerify +import io.mockative.every +import io.mockative.mock +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals + +class GetAnalyticsContactsDataUseCaseTest { + + @Test + fun givenNoTeamId_whenInvoke_thenOnlyContactsReturned() = runTest { + val expected = AnalyticsContactsData( + teamId = null, + teamSize = null, + contactsSize = 3, + isEnterprise = null, + isTeamMember = false + ) + val (arrangement, useCase) = Arrangement().arrange { + coEvery { selfTeamIdProvider.invoke() }.returns((null as TeamId?).right()) + withContactsAmountCached(expected.contactsSize!!.right()) + } + + val result = useCase() + + assertEquals(expected, result) + coVerify { arrangement.analyticsRepository.countContactsAmount() }.wasNotInvoked() + coVerify { arrangement.analyticsRepository.countTeamMembersAmount() }.wasNotInvoked() + coVerify { arrangement.analyticsRepository.getTeamMembersAmountCached() }.wasNotInvoked() + } + + @Test + fun givenNoTeamIdAndNoCachedContactsAmount_whenInvoke_thenOnlyContactsReturned() = runTest { + val expected = AnalyticsContactsData( + teamId = null, + teamSize = null, + contactsSize = 3, + isEnterprise = null, + isTeamMember = false + ) + val (arrangement, useCase) = Arrangement().arrange { + coEvery { selfTeamIdProvider.invoke() }.returns((null as TeamId?).right()) + withContactsAmountCached(StorageFailure.DataNotFound.left()) + withCountContactsAmount(expected.contactsSize!!.right()) + } + + val result = useCase() + + assertEquals(expected, result) + coVerify { arrangement.analyticsRepository.countContactsAmount() }.wasInvoked(exactly = 1) + coVerify { arrangement.analyticsRepository.countTeamMembersAmount() }.wasNotInvoked() + coVerify { arrangement.analyticsRepository.getTeamMembersAmountCached() }.wasNotInvoked() + } + + @Test + fun givenTeamIdAndTeamIsBig_whenInvoke_thenTeamDataReturned() = runTest { + val expected = AnalyticsContactsData( + teamId = SELF_TEAM_ID.value, + teamSize = 12, + contactsSize = null, + isEnterprise = true, + isTeamMember = true + ) + val (arrangement, useCase) = Arrangement().arrange { + coEvery { selfTeamIdProvider.invoke() }.returns(SELF_TEAM_ID.right()) + withTeamMembersAmountCached(expected.teamSize!!.right()) + withConferenceCallingEnabled(expected.isEnterprise!!) + } + + val result = useCase() + + assertEquals(expected, result) + coVerify { arrangement.analyticsRepository.countContactsAmount() }.wasNotInvoked() + coVerify { arrangement.analyticsRepository.countTeamMembersAmount() }.wasNotInvoked() + coVerify { arrangement.analyticsRepository.getTeamMembersAmountCached() }.wasInvoked(exactly = 1) + coVerify { arrangement.analyticsRepository.countTeamMembersAmount() }.wasNotInvoked() + } + + @Test + fun givenTeamIdAndAndNoTeamSizeCached_whenInvoke_thenTeamDataReturned() = runTest { + val expected = AnalyticsContactsData( + teamId = SELF_TEAM_ID.value, + teamSize = 12, + contactsSize = null, + isEnterprise = true, + isTeamMember = true + ) + val (arrangement, useCase) = Arrangement().arrange { + coEvery { selfTeamIdProvider.invoke() }.returns(SELF_TEAM_ID.right()) + withTeamMembersAmountCached(StorageFailure.DataNotFound.left()) + withCountTeamMembersAmount(expected.teamSize!!.right()) + withConferenceCallingEnabled(expected.isEnterprise!!) + } + + val result = useCase() + + assertEquals(expected, result) + coVerify { arrangement.analyticsRepository.countContactsAmount() }.wasNotInvoked() + coVerify { arrangement.analyticsRepository.getContactsAmountCached() }.wasNotInvoked() + coVerify { arrangement.analyticsRepository.getTeamMembersAmountCached() }.wasInvoked(exactly = 1) + coVerify { arrangement.analyticsRepository.countTeamMembersAmount() }.wasInvoked(exactly = 1) + } + + @Test + fun givenTeamIdAndTeamIsSmall_whenInvoke_thenNoTeamDataReturned() = runTest { + val expected = AnalyticsContactsData( + teamId = null, + teamSize = null, + contactsSize = null, + isEnterprise = true, + isTeamMember = true + ) + val (arrangement, useCase) = Arrangement().arrange { + coEvery { selfTeamIdProvider.invoke() }.returns(SELF_TEAM_ID.right()) + withTeamMembersAmountCached(5.right()) + withConferenceCallingEnabled(expected.isEnterprise!!) + } + + val result = useCase() + + assertEquals(expected, result) + coVerify { arrangement.analyticsRepository.countContactsAmount() }.wasNotInvoked() + coVerify { arrangement.analyticsRepository.countTeamMembersAmount() }.wasNotInvoked() + coVerify { arrangement.analyticsRepository.getTeamMembersAmountCached() }.wasInvoked(exactly = 1) + coVerify { arrangement.analyticsRepository.countTeamMembersAmount() }.wasNotInvoked() + } + + private companion object { + val SELF_TEAM_ID = TeamId("team_id") + } + + private class Arrangement : UserConfigRepositoryArrangement by UserConfigRepositoryArrangementImpl(), + AnalyticsRepositoryArrangement by AnalyticsRepositoryArrangementImpl() { + + @Mock + val slowSyncRepository = mock(SlowSyncRepository::class) + + @Mock + val selfTeamIdProvider = mock(SelfTeamIdProvider::class) + + init { + every { slowSyncRepository.slowSyncStatus } + .returns(MutableStateFlow(SlowSyncStatus.Complete).asStateFlow()) + } + + private val useCase: GetAnalyticsContactsDataUseCase = GetAnalyticsContactsDataUseCaseImpl( + selfTeamIdProvider = selfTeamIdProvider, + userConfigRepository = userConfigRepository, + slowSyncRepository = slowSyncRepository, + analyticsRepository = analyticsRepository, + ) + + fun arrange(block: suspend Arrangement.() -> Unit): Pair { + runBlocking { block() } + return this to useCase + } + } +} diff --git a/logic/src/commonTest/kotlin/com/wire/kalium/logic/feature/analytics/UpdateContactsAmountsCacheUseCaseTest.kt b/logic/src/commonTest/kotlin/com/wire/kalium/logic/feature/analytics/UpdateContactsAmountsCacheUseCaseTest.kt new file mode 100644 index 0000000000..2fb601b59e --- /dev/null +++ b/logic/src/commonTest/kotlin/com/wire/kalium/logic/feature/analytics/UpdateContactsAmountsCacheUseCaseTest.kt @@ -0,0 +1,128 @@ +/* + * Wire + * Copyright (C) 2025 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.kalium.logic.feature.analytics + +import com.wire.kalium.common.error.StorageFailure +import com.wire.kalium.common.functional.left +import com.wire.kalium.common.functional.right +import com.wire.kalium.logic.data.id.SelfTeamIdProvider +import com.wire.kalium.logic.data.id.TeamId +import com.wire.kalium.logic.data.sync.SlowSyncRepository +import com.wire.kalium.logic.data.sync.SlowSyncStatus +import com.wire.kalium.logic.util.arrangement.repository.AnalyticsRepositoryArrangement +import com.wire.kalium.logic.util.arrangement.repository.AnalyticsRepositoryArrangementImpl +import io.mockative.Mock +import io.mockative.any +import io.mockative.coEvery +import io.mockative.coVerify +import io.mockative.every +import io.mockative.mock +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.test.runTest +import kotlinx.datetime.Clock +import kotlin.test.Test +import kotlin.time.Duration.Companion.days + +class UpdateContactsAmountsCacheUseCaseTest { + + @Test + fun givenNoCacheUpdateDate_whenInvoked_thenUpdateCalled() = runTest { + val (arrangement, useCase) = Arrangement().arrange { + coEvery { selfTeamIdProvider.invoke() }.returns(SELF_TEAM_ID.right()) + withLastContactsDateUpdateDate(StorageFailure.DataNotFound.left()) + withCountContactsAmount(112.right()) + withCountTeamMembersAmount(12.right()) + } + + useCase.invoke() + + coVerify { arrangement.analyticsRepository.countContactsAmount() }.wasInvoked(exactly = 1) + coVerify { arrangement.analyticsRepository.countTeamMembersAmount() }.wasInvoked(exactly = 1) + coVerify { arrangement.analyticsRepository.setContactsAmountCached(any()) }.wasInvoked(exactly = 1) + coVerify { arrangement.analyticsRepository.setTeamMembersAmountCached(any()) }.wasInvoked(exactly = 1) + coVerify { arrangement.analyticsRepository.setContactsAmountCachingDate(any()) }.wasInvoked(exactly = 1) + } + + @Test + fun givenCacheUpdateDateLongTimeAgo_whenInvoked_thenUpdateCalled() = runTest { + val (arrangement, useCase) = Arrangement().arrange { + coEvery { selfTeamIdProvider.invoke() }.returns(SELF_TEAM_ID.right()) + withLastContactsDateUpdateDate(Clock.System.now().minus(10.days).right()) + withCountContactsAmount(112.right()) + withCountTeamMembersAmount(12.right()) + } + + useCase.invoke() + + coVerify { arrangement.analyticsRepository.countContactsAmount() }.wasInvoked(exactly = 1) + coVerify { arrangement.analyticsRepository.countTeamMembersAmount() }.wasInvoked(exactly = 1) + coVerify { arrangement.analyticsRepository.setContactsAmountCached(any()) }.wasInvoked(exactly = 1) + coVerify { arrangement.analyticsRepository.setTeamMembersAmountCached(any()) }.wasInvoked(exactly = 1) + coVerify { arrangement.analyticsRepository.setContactsAmountCachingDate(any()) }.wasInvoked(exactly = 1) + } + + @Test + fun givenCacheUpdateDateNotLongTimeAgo_whenInvoked_thenUpdateNotCalled() = runTest { + val (arrangement, useCase) = Arrangement().arrange { + coEvery { selfTeamIdProvider.invoke() }.returns(SELF_TEAM_ID.right()) + withLastContactsDateUpdateDate(Clock.System.now().minus(1.days).right()) + withCountContactsAmount(112.right()) + withCountTeamMembersAmount(12.right()) + } + + useCase.invoke() + + coVerify { arrangement.analyticsRepository.countContactsAmount() }.wasNotInvoked() + coVerify { arrangement.analyticsRepository.countTeamMembersAmount() }.wasNotInvoked() + coVerify { arrangement.analyticsRepository.setContactsAmountCached(any()) }.wasNotInvoked() + coVerify { arrangement.analyticsRepository.setTeamMembersAmountCached(any()) }.wasNotInvoked() + coVerify { arrangement.analyticsRepository.setContactsAmountCachingDate(any()) }.wasNotInvoked() + } + + private companion object { + val SELF_TEAM_ID = TeamId("team_id") + } + + private class Arrangement : + AnalyticsRepositoryArrangement by AnalyticsRepositoryArrangementImpl() { + + @Mock + val slowSyncRepository = mock(SlowSyncRepository::class) + + @Mock + val selfTeamIdProvider = mock(SelfTeamIdProvider::class) + + init { + every { slowSyncRepository.slowSyncStatus } + .returns(MutableStateFlow(SlowSyncStatus.Complete).asStateFlow()) + } + + private val useCase: UpdateContactsAmountsCacheUseCase = UpdateContactsAmountsCacheUseCaseImpl( + selfTeamIdProvider = selfTeamIdProvider, + slowSyncRepository = slowSyncRepository, + analyticsRepository = analyticsRepository, + ) + + fun arrange(block: suspend Arrangement.() -> Unit): Pair { + runBlocking { block() } + return this to useCase + } + } +} diff --git a/logic/src/commonTest/kotlin/com/wire/kalium/logic/util/arrangement/repository/AnalyticsRepositoryArrangement.kt b/logic/src/commonTest/kotlin/com/wire/kalium/logic/util/arrangement/repository/AnalyticsRepositoryArrangement.kt new file mode 100644 index 0000000000..77c79970ec --- /dev/null +++ b/logic/src/commonTest/kotlin/com/wire/kalium/logic/util/arrangement/repository/AnalyticsRepositoryArrangement.kt @@ -0,0 +1,78 @@ +/* + * Wire + * Copyright (C) 2025 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.kalium.logic.util.arrangement.repository + +import com.wire.kalium.common.error.StorageFailure +import com.wire.kalium.common.functional.Either +import com.wire.kalium.logic.data.analytics.AnalyticsRepository +import io.mockative.Mock +import io.mockative.any +import io.mockative.coEvery +import io.mockative.mock +import kotlinx.datetime.Instant + +@Suppress("INAPPLICABLE_JVM_NAME") +internal interface AnalyticsRepositoryArrangement { + val analyticsRepository: AnalyticsRepository + suspend fun withContactsAmountCached(result: Either) + suspend fun withCountContactsAmount(result: Either) + suspend fun withTeamMembersAmountCached(result: Either) + suspend fun withCountTeamMembersAmount(result: Either) + suspend fun withSetContactsAmountCachingDate() + suspend fun withSetTeamMembersAmountCached() + suspend fun withSetContactsAmountCached() + suspend fun withLastContactsDateUpdateDate(result: Either) +} + +@Suppress("INAPPLICABLE_JVM_NAME") +internal open class AnalyticsRepositoryArrangementImpl : AnalyticsRepositoryArrangement { + @Mock + override val analyticsRepository: AnalyticsRepository = mock(AnalyticsRepository::class) + + override suspend fun withContactsAmountCached(result: Either) { + coEvery { analyticsRepository.getContactsAmountCached() }.returns(result) + } + + override suspend fun withCountContactsAmount(result: Either) { + coEvery { analyticsRepository.countContactsAmount() }.returns(result) + } + + override suspend fun withTeamMembersAmountCached(result: Either) { + coEvery { analyticsRepository.getTeamMembersAmountCached() }.returns(result) + } + + override suspend fun withCountTeamMembersAmount(result: Either) { + coEvery { analyticsRepository.countTeamMembersAmount() }.returns(result) + } + + override suspend fun withSetContactsAmountCachingDate() { + coEvery { analyticsRepository.setContactsAmountCachingDate(any()) }.returns(Unit) + } + + override suspend fun withSetTeamMembersAmountCached() { + coEvery { analyticsRepository.setTeamMembersAmountCached(any()) }.returns(Unit) + } + + override suspend fun withSetContactsAmountCached() { + coEvery { analyticsRepository.setContactsAmountCached(any()) }.returns(Unit) + } + + override suspend fun withLastContactsDateUpdateDate(result: Either) { + coEvery { analyticsRepository.getLastContactsDateUpdateDate() }.returns(result) + } +} diff --git a/logic/src/commonTest/kotlin/com/wire/kalium/logic/util/arrangement/repository/UserConfigRepositoryArrangement.kt b/logic/src/commonTest/kotlin/com/wire/kalium/logic/util/arrangement/repository/UserConfigRepositoryArrangement.kt index 30dda67f93..ac4bc9515a 100644 --- a/logic/src/commonTest/kotlin/com/wire/kalium/logic/util/arrangement/repository/UserConfigRepositoryArrangement.kt +++ b/logic/src/commonTest/kotlin/com/wire/kalium/logic/util/arrangement/repository/UserConfigRepositoryArrangement.kt @@ -23,6 +23,7 @@ import com.wire.kalium.logic.data.featureConfig.MLSMigrationModel import com.wire.kalium.logic.data.mls.SupportedCipherSuite import com.wire.kalium.logic.data.user.SupportedProtocol import com.wire.kalium.common.functional.Either +import com.wire.kalium.common.functional.right import io.mockative.Mock import io.mockative.any import io.mockative.coEvery @@ -51,6 +52,7 @@ internal interface UserConfigRepositoryArrangement { suspend fun withDeletePreviousTrackingIdentifier() suspend fun withUpdateNextTimeForCallFeedback() suspend fun withGetNextTimeForCallFeedback(result: Either) + suspend fun withConferenceCallingEnabled(result: Boolean) } internal class UserConfigRepositoryArrangementImpl : UserConfigRepositoryArrangement { @@ -142,4 +144,8 @@ internal class UserConfigRepositoryArrangementImpl : UserConfigRepositoryArrange override suspend fun withUpdateNextTimeForCallFeedback() { coEvery { userConfigRepository.updateNextTimeForCallFeedback(any()) }.returns(Unit) } + + override suspend fun withConferenceCallingEnabled(result: Boolean) { + every { userConfigRepository.isConferenceCallingEnabled() }.returns(result.right()) + } } diff --git a/logic/src/commonTest/kotlin/com/wire/kalium/logic/util/arrangement/repository/UserRepositoryArrangement.kt b/logic/src/commonTest/kotlin/com/wire/kalium/logic/util/arrangement/repository/UserRepositoryArrangement.kt index f7f0fb7e52..ccb0a47a2c 100644 --- a/logic/src/commonTest/kotlin/com/wire/kalium/logic/util/arrangement/repository/UserRepositoryArrangement.kt +++ b/logic/src/commonTest/kotlin/com/wire/kalium/logic/util/arrangement/repository/UserRepositoryArrangement.kt @@ -19,6 +19,7 @@ package com.wire.kalium.logic.util.arrangement.repository import com.wire.kalium.common.error.CoreFailure import com.wire.kalium.common.error.StorageFailure +import com.wire.kalium.common.functional.Either import com.wire.kalium.logic.data.conversation.ClientId import com.wire.kalium.logic.data.conversation.mls.NameAndHandle import com.wire.kalium.logic.data.id.ConversationId @@ -29,7 +30,6 @@ import com.wire.kalium.logic.data.user.User import com.wire.kalium.logic.data.user.UserId import com.wire.kalium.logic.data.user.UserRepository import com.wire.kalium.logic.framework.TestUser -import com.wire.kalium.common.functional.Either import io.mockative.Mock import io.mockative.any import io.mockative.coEvery @@ -241,7 +241,11 @@ internal open class UserRepositoryArrangementImpl : UserRepositoryArrangement { coEvery { userRepository.getNameAndHandle(matches { userId.matches(it) }) }.returns(result) } - override suspend fun withIsClientMlsCapable(result: Either, userId: Matcher, clientId: Matcher) { + override suspend fun withIsClientMlsCapable( + result: Either, + userId: Matcher, + clientId: Matcher + ) { coEvery { userRepository.isClientMlsCapable( userId = matches { userId.matches(it) }, diff --git a/persistence/src/commonMain/db_user/com/wire/kalium/persistence/Users.sq b/persistence/src/commonMain/db_user/com/wire/kalium/persistence/Users.sq index b2e5d09a29..543806f937 100644 --- a/persistence/src/commonMain/db_user/com/wire/kalium/persistence/Users.sq +++ b/persistence/src/commonMain/db_user/com/wire/kalium/persistence/Users.sq @@ -32,6 +32,7 @@ CREATE TABLE User ( ); CREATE INDEX user_team_index ON User(team); CREATE INDEX user_service_id ON User(bot_service); +CREATE INDEX idx_user_connection_deleted_handle ON User (connection_status, deleted, handle); deleteUser: DELETE FROM User WHERE qualified_id = ?; @@ -299,3 +300,9 @@ UPDATE User SET team = ? WHERE qualified_id = ?; selectNameByMessageId: SELECT name FROM User WHERE qualified_id = (SELECT Message.sender_user_id FROM Message WHERE Message.id = :messageId AND Message.conversation_id = :conversationId); + +countContacts: +SELECT COUNT() FROM User WHERE User.connection_status = 'ACCEPTED' AND User.qualified_id != :self_user_id; + +countTeamMembersFromTeam: +SELECT COUNT() FROM User WHERE User.team = :team_id AND User.qualified_id != :self_user_id; diff --git a/persistence/src/commonMain/kotlin/com/wire/kalium/persistence/dao/UserDAO.kt b/persistence/src/commonMain/kotlin/com/wire/kalium/persistence/dao/UserDAO.kt index 858c7428d8..de9255f6ab 100644 --- a/persistence/src/commonMain/kotlin/com/wire/kalium/persistence/dao/UserDAO.kt +++ b/persistence/src/commonMain/kotlin/com/wire/kalium/persistence/dao/UserDAO.kt @@ -317,4 +317,6 @@ interface UserDAO { suspend fun getUsersMinimizedByQualifiedIDs(qualifiedIDs: List): List suspend fun getNameAndHandle(userId: UserIDEntity): NameAndHandleEntity? suspend fun updateTeamId(userId: UserIDEntity, teamId: String) + suspend fun countContactsAmount(selfUserId: QualifiedIDEntity): Int + suspend fun countTeamMembersAmount(teamId: String, selfUserId: QualifiedIDEntity): Int } diff --git a/persistence/src/commonMain/kotlin/com/wire/kalium/persistence/dao/UserDAOImpl.kt b/persistence/src/commonMain/kotlin/com/wire/kalium/persistence/dao/UserDAOImpl.kt index 5f389fa050..2d70d25675 100644 --- a/persistence/src/commonMain/kotlin/com/wire/kalium/persistence/dao/UserDAOImpl.kt +++ b/persistence/src/commonMain/kotlin/com/wire/kalium/persistence/dao/UserDAOImpl.kt @@ -250,13 +250,13 @@ class UserDAOImpl internal constructor( override suspend fun upsertUsers(users: List) = withContext(queriesContext) { userQueries.transaction { val anyInsertedOrModified = users.map { user -> - if (user.deleted) { - // mark as deleted and remove from groups - safeMarkAsDeletedAndRemoveFromGroupConversation(user.id) - } else { - insertUser(user) - } - }.any { it } + if (user.deleted) { + // mark as deleted and remove from groups + safeMarkAsDeletedAndRemoveFromGroupConversation(user.id) + } else { + insertUser(user) + } + }.any { it } if (!anyInsertedOrModified) { // rollback the transaction if no changes were made so that it doesn't notify other queries if not needed this.rollback() @@ -505,4 +505,13 @@ class UserDAOImpl internal constructor( override suspend fun updateTeamId(userId: UserIDEntity, teamId: String) { userQueries.updateTeamId(teamId, userId) } + + override suspend fun countContactsAmount(selfUserId: QualifiedIDEntity): Int = withContext(queriesContext) { + userQueries.countContacts(selfUserId).executeAsOneOrNull()?.toInt() ?: 0 + } + + override suspend fun countTeamMembersAmount(teamId: String, selfUserId: QualifiedIDEntity): Int = withContext(queriesContext) { + userQueries.countTeamMembersFromTeam(teamId, selfUserId).executeAsOneOrNull()?.toInt() ?: 0 + } + } diff --git a/persistence/src/commonTest/kotlin/com/wire/kalium/persistence/dao/UserDAOTest.kt b/persistence/src/commonTest/kotlin/com/wire/kalium/persistence/dao/UserDAOTest.kt index 12eba248eb..d7394accdd 100644 --- a/persistence/src/commonTest/kotlin/com/wire/kalium/persistence/dao/UserDAOTest.kt +++ b/persistence/src/commonTest/kotlin/com/wire/kalium/persistence/dao/UserDAOTest.kt @@ -59,7 +59,7 @@ class UserDAOTest : BaseDatabaseTest() { @Test fun givenUser_whenUpdatingProfileAvatar_thenChangesAreEmittedCorrectly() = runTest(dispatcher) { - //given + // given val updatedUser = PartialUserEntity( id = user1.id, name = "newName", @@ -67,11 +67,11 @@ class UserDAOTest : BaseDatabaseTest() { email = user1.email, accentId = user1.accentId, previewAssetId = UserAssetIdEntity( - value ="newAvatar", + value = "newAvatar", domain = "newAvatarDomain" ), completeAssetId = UserAssetIdEntity( - value ="newAvatar", + value = "newAvatar", domain = "newAvatarDomain" ), supportedProtocols = user1.supportedProtocols @@ -1057,6 +1057,80 @@ class UserDAOTest : BaseDatabaseTest() { assertEquals(updatedUserDetails, result) } + @Test + fun givenUsersInTheSameTeamAsSelf_whenCountTeamMembers_thenAmountCountedCorrectly() = runTest { + // given + val selfUser = newUserEntity(selfUserId) + val users = listOf( + newUserEntity(selfUser.id.copy(value = "other1")), + newUserEntity(selfUser.id.copy(value = "other2")), + newUserEntity(selfUser.id.copy(value = "other3")), + newUserEntity(selfUser.id.copy(value = "other4")), + newUserEntity(selfUser.id.copy(value = "other5")).copy(team = "other_then_${selfUser.team}"), + newUserEntity(selfUser.id.copy(value = "other6")).copy( + team = "other_then_${selfUser.team}", + connectionStatus = ConnectionEntity.State.PENDING + ) + ) + + db.userDAO.upsertUsers(users.plus(selfUser)) + + // when + val result = db.userDAO.countTeamMembersAmount(teamId = selfUser.team!!, selfUserId = selfUserId) + + // then + assertEquals(4, result) + } + + @Test + fun givenUsersWithDiffConnectionStatuses_whenCountContactsAmount_thenAmountCountedCorrectly() = runTest { + // given + val selfUser = newUserEntity(selfUserId) + val users = listOf( + newUserEntity(selfUser.id.copy(value = "other1")), + newUserEntity(selfUser.id.copy(value = "other2")), + newUserEntity(selfUser.id.copy(value = "other3")).copy( + team = "other_then_${selfUser.team}", + ), + newUserEntity(selfUser.id.copy(value = "other4")).copy( + team = "other_then_${selfUser.team}", + connectionStatus = ConnectionEntity.State.NOT_CONNECTED + ), + newUserEntity(selfUser.id.copy(value = "other5")).copy( + team = "other_then_${selfUser.team}", + connectionStatus = ConnectionEntity.State.SENT + ), + newUserEntity(selfUser.id.copy(value = "other6")).copy( + team = "other_then_${selfUser.team}", + connectionStatus = ConnectionEntity.State.PENDING + ), + newUserEntity(selfUser.id.copy(value = "other7")).copy( + team = "other_then_${selfUser.team}", + connectionStatus = ConnectionEntity.State.BLOCKED + ), + newUserEntity(selfUser.id.copy(value = "other8")).copy( + team = "other_then_${selfUser.team}", + connectionStatus = ConnectionEntity.State.IGNORED + ), + newUserEntity(selfUser.id.copy(value = "other9")).copy( + team = "other_then_${selfUser.team}", + connectionStatus = ConnectionEntity.State.CANCELLED + ), + newUserEntity(selfUser.id.copy(value = "other10")).copy( + team = "other_then_${selfUser.team}", + connectionStatus = ConnectionEntity.State.MISSING_LEGALHOLD_CONSENT + ) + ) + + db.userDAO.upsertUsers(users.plus(selfUser)) + + // when + val result = db.userDAO.countContactsAmount(selfUserId) + + // then + assertEquals(3, result) + } + private companion object { val USER_ENTITY_1 = newUserEntity(QualifiedIDEntity("1", "wire.com")) val USER_ENTITY_2 = newUserEntity(QualifiedIDEntity("2", "wire.com"))