Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: New user properties for Analytics [WPB-16121] #3312

Open
wants to merge 4 commits into
base: develop
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -72,13 +72,15 @@ import com.wire.kalium.network.api.model.UserProfileDTO
import com.wire.kalium.network.api.model.isTeamMember
import com.wire.kalium.persistence.dao.ConnectionEntity
import com.wire.kalium.persistence.dao.ConversationIDEntity
import com.wire.kalium.persistence.dao.MetadataDAO
import com.wire.kalium.persistence.dao.UserDAO
import com.wire.kalium.persistence.dao.UserIDEntity
import com.wire.kalium.persistence.dao.UserTypeEntity
import com.wire.kalium.persistence.dao.client.ClientDAO
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.map
import kotlinx.datetime.Instant

@Suppress("TooManyFunctions")
interface UserRepository {
Expand Down Expand Up @@ -156,12 +158,21 @@ interface UserRepository {
suspend fun migrateUserToTeam(teamName: String): Either<CoreFailure, CreateUserTeam>
suspend fun updateTeamId(userId: UserId, teamId: TeamId): Either<StorageFailure, Unit>
suspend fun isClientMlsCapable(userId: UserId, clientId: ClientId): Either<StorageFailure, Boolean>
suspend fun getContactsAmountCached(): Either<StorageFailure, Int>
suspend fun getTeamMembersAmountCached(): Either<StorageFailure, Int>
suspend fun setContactsAmountCached(amount: Int)
suspend fun setTeamMembersAmountCached(amount: Int)
suspend fun getLastContactsDateUpdateDate(): Either<StorageFailure, Instant>
suspend fun setContactsAmountCachingDate(date: Instant)
suspend fun countContactsAmount(): Either<StorageFailure, Int>
suspend fun countTeamMembersAmount(): Either<StorageFailure, Int>
}

@Suppress("LongParameterList", "TooManyFunctions")
internal class UserDataSource internal constructor(
private val userDAO: UserDAO,
private val clientDAO: ClientDAO,
private val metadataDAO: MetadataDAO,
private val selfApi: SelfApi,
private val userDetailsApi: UserDetailsApi,
private val upgradePersonalToTeamApi: UpgradePersonalToTeamApi,
Expand Down Expand Up @@ -368,7 +379,7 @@ internal class UserDataSource internal constructor(
}

override suspend fun observeSelfUser(): Flow<SelfUser> = userDAO.observeUserDetailsByQualifiedID(selfUserId.toDao()).filterNotNull()
.map(userMapper::fromUserDetailsEntityToSelfUser)
.map(userMapper::fromUserDetailsEntityToSelfUser)

override suspend fun observeSelfUserWithTeam(): Flow<Pair<SelfUser, Team?>> {
return userDAO.getUserDetailsWithTeamByQualifiedID(selfUserId.toDao()).filterNotNull()
Expand Down Expand Up @@ -600,8 +611,40 @@ internal class UserDataSource internal constructor(
clientDAO.isMLSCapable(userId.toDao(), clientId.value)
}

override suspend fun getContactsAmountCached(): Either<StorageFailure, Int> = wrapStorageRequest {
metadataDAO.valueByKey(CONTACTS_AMOUNT_KEY)?.toInt()
}

override suspend fun getTeamMembersAmountCached(): Either<StorageFailure, Int> = 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<StorageFailure, Instant> = 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<StorageFailure, Int> = wrapStorageRequest {
userDAO.countContactsAmount()
}

override suspend fun countTeamMembersAmount(): Either<StorageFailure, Int> = wrapStorageRequest {
userDAO.countTeamMembersAmount()
}
Comment on lines +614 to +641
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can those be moved out of the UserRepository to their own, maybe a AnalyticsRepo ?


companion object {

internal const val BATCH_SIZE = 500
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"
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -164,8 +164,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
Expand Down Expand Up @@ -826,6 +830,7 @@ class UserSessionScope internal constructor(
selfUserId = userId,
selfTeamIdProvider = selfTeamId,
legalHoldHandler = legalHoldHandler,
metadataDAO = userStorage.database.metadataDAO
)

private val accountRepository: AccountRepository
Expand Down Expand Up @@ -2202,6 +2207,19 @@ class UserSessionScope internal constructor(
authenticationScope.serverConfigRepository,
)

val getAnalyticsContactsData: GetAnalyticsContactsDataUseCase = GetAnalyticsContactsDataUseCaseImpl(
selfTeamIdProvider = selfTeamId,
slowSyncRepository = slowSyncRepository,
userRepository = userRepository,
userConfigRepository = userConfigRepository
)

private val updateContactsAmountsCache: UpdateContactsAmountsCacheUseCase = UpdateContactsAmountsCacheUseCaseImpl(
selfTeamIdProvider = selfTeamId,
slowSyncRepository = slowSyncRepository,
userRepository = userRepository,
)

/**
* 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.
Expand Down Expand Up @@ -2258,6 +2276,11 @@ class UserSessionScope internal constructor(
launch {
messages.confirmationDeliveryHandler.sendPendingConfirmations()
}

launch {
updateContactsAmountsCache()
}

syncExecutor.startAndStopSyncAsNeeded()
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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.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.data.user.UserRepository
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 userRepository: UserRepository,
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 = userRepository.getContactsAmountCached()
.flatMapLeft { userRepository.countContactsAmount() }
.getOrNull()

AnalyticsContactsData(
teamId = null,
teamSize = null,
isEnterprise = null,
contactsSize = contactsSize,
isTeamMember = false
)
} else {
val teamSize = userRepository.getTeamMembersAmountCached()
.flatMapLeft { userRepository.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
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
/*
* 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.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 userRepository: UserRepository,
) : UpdateContactsAmountsCacheUseCase {

override suspend fun invoke() {
slowSyncRepository.slowSyncStatus.first { it is SlowSyncStatus.Complete }

val nowDate = Clock.System.now()
val updateTime = userRepository.getLastContactsDateUpdateDate().getOrNull()

if (updateTime != null && nowDate.minus(updateTime) < CACHE_PERIOD) return

val teamId = selfTeamIdProvider().getOrNull()

with(userRepository) {
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
}

}
Loading
Loading