mirror of
https://github.com/bitwarden/android.git
synced 2024-11-28 22:08:49 +03:00
BIT-2418: Add the OrganizationEventManager (#3330)
This commit is contained in:
parent
170db5077d
commit
16fce43739
8 changed files with 393 additions and 0 deletions
|
@ -5,6 +5,7 @@ import com.x8bit.bitwarden.data.auth.manager.AuthRequestNotificationManager
|
|||
import com.x8bit.bitwarden.data.platform.annotation.OmitFromCoverage
|
||||
import com.x8bit.bitwarden.data.platform.manager.CrashLogsManager
|
||||
import com.x8bit.bitwarden.data.platform.manager.NetworkConfigManager
|
||||
import com.x8bit.bitwarden.data.platform.manager.event.OrganizationEventManager
|
||||
import dagger.hilt.android.HiltAndroidApp
|
||||
import javax.inject.Inject
|
||||
|
||||
|
@ -24,4 +25,7 @@ class BitwardenApplication : Application() {
|
|||
|
||||
@Inject
|
||||
lateinit var authRequestNotificationManager: AuthRequestNotificationManager
|
||||
|
||||
@Inject
|
||||
lateinit var organizationEventManager: OrganizationEventManager
|
||||
}
|
||||
|
|
|
@ -30,6 +30,7 @@ import com.x8bit.bitwarden.data.auth.repository.util.SsoCallbackResult
|
|||
import com.x8bit.bitwarden.data.auth.repository.util.WebAuthResult
|
||||
import com.x8bit.bitwarden.data.auth.util.YubiKeyResult
|
||||
import com.x8bit.bitwarden.data.platform.datasource.network.authenticator.AuthenticatorProvider
|
||||
import com.x8bit.bitwarden.data.vault.datasource.network.model.SyncResponseJson
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
|
||||
|
@ -123,6 +124,11 @@ interface AuthRepository : AuthenticatorProvider, AuthRequestManager {
|
|||
*/
|
||||
val passwordResetReason: ForcePasswordResetReason?
|
||||
|
||||
/**
|
||||
* The organization for the active user.
|
||||
*/
|
||||
val organizations: List<SyncResponseJson.Profile.Organization>
|
||||
|
||||
/**
|
||||
* Clears the pending deletion state that occurs when the an account is successfully deleted.
|
||||
*/
|
||||
|
|
|
@ -312,6 +312,9 @@ class AuthRepositoryImpl(
|
|||
?.profile
|
||||
?.forcePasswordResetReason
|
||||
|
||||
override val organizations: List<SyncResponseJson.Profile.Organization>
|
||||
get() = activeUserId?.let { authDiskSource.getOrganizations(it) }.orEmpty()
|
||||
|
||||
init {
|
||||
pushManager
|
||||
.syncOrgKeysFlow
|
||||
|
|
|
@ -4,12 +4,14 @@ import android.app.Application
|
|||
import android.content.Context
|
||||
import com.x8bit.bitwarden.data.auth.datasource.disk.AuthDiskSource
|
||||
import com.x8bit.bitwarden.data.auth.repository.AuthRepository
|
||||
import com.x8bit.bitwarden.data.platform.datasource.disk.EventDiskSource
|
||||
import com.x8bit.bitwarden.data.platform.datasource.disk.PushDiskSource
|
||||
import com.x8bit.bitwarden.data.platform.datasource.disk.SettingsDiskSource
|
||||
import com.x8bit.bitwarden.data.platform.datasource.disk.legacy.LegacyAppCenterMigrator
|
||||
import com.x8bit.bitwarden.data.platform.datasource.network.authenticator.RefreshAuthenticator
|
||||
import com.x8bit.bitwarden.data.platform.datasource.network.interceptor.AuthTokenInterceptor
|
||||
import com.x8bit.bitwarden.data.platform.datasource.network.interceptor.BaseUrlInterceptors
|
||||
import com.x8bit.bitwarden.data.platform.datasource.network.service.EventService
|
||||
import com.x8bit.bitwarden.data.platform.datasource.network.service.PushService
|
||||
import com.x8bit.bitwarden.data.platform.manager.AppForegroundManager
|
||||
import com.x8bit.bitwarden.data.platform.manager.AppForegroundManagerImpl
|
||||
|
@ -35,6 +37,8 @@ import com.x8bit.bitwarden.data.platform.manager.clipboard.BitwardenClipboardMan
|
|||
import com.x8bit.bitwarden.data.platform.manager.clipboard.BitwardenClipboardManagerImpl
|
||||
import com.x8bit.bitwarden.data.platform.manager.dispatcher.DispatcherManager
|
||||
import com.x8bit.bitwarden.data.platform.manager.dispatcher.DispatcherManagerImpl
|
||||
import com.x8bit.bitwarden.data.platform.manager.event.OrganizationEventManager
|
||||
import com.x8bit.bitwarden.data.platform.manager.event.OrganizationEventManagerImpl
|
||||
import com.x8bit.bitwarden.data.platform.manager.garbage.GarbageCollectionManager
|
||||
import com.x8bit.bitwarden.data.platform.manager.garbage.GarbageCollectionManagerImpl
|
||||
import com.x8bit.bitwarden.data.platform.repository.EnvironmentRepository
|
||||
|
@ -62,6 +66,24 @@ object PlatformManagerModule {
|
|||
fun provideAppForegroundManager(): AppForegroundManager =
|
||||
AppForegroundManagerImpl()
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideOrganizationEventManager(
|
||||
authRepository: AuthRepository,
|
||||
vaultRepository: VaultRepository,
|
||||
clock: Clock,
|
||||
dispatcherManager: DispatcherManager,
|
||||
eventDiskSource: EventDiskSource,
|
||||
eventService: EventService,
|
||||
): OrganizationEventManager = OrganizationEventManagerImpl(
|
||||
authRepository = authRepository,
|
||||
vaultRepository = vaultRepository,
|
||||
clock = clock,
|
||||
dispatcherManager = dispatcherManager,
|
||||
eventDiskSource = eventDiskSource,
|
||||
eventService = eventService,
|
||||
)
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun providesCipherMatchingManager(
|
||||
|
|
|
@ -0,0 +1,13 @@
|
|||
package com.x8bit.bitwarden.data.platform.manager.event
|
||||
|
||||
import com.x8bit.bitwarden.data.platform.manager.model.OrganizationEventType
|
||||
|
||||
/**
|
||||
* A manager for tracking events.
|
||||
*/
|
||||
interface OrganizationEventManager {
|
||||
/**
|
||||
* Tracks a specific event to be uploaded at a different time.
|
||||
*/
|
||||
suspend fun trackEvent(eventType: OrganizationEventType, cipherId: String? = null)
|
||||
}
|
|
@ -0,0 +1,112 @@
|
|||
package com.x8bit.bitwarden.data.platform.manager.event
|
||||
|
||||
import androidx.lifecycle.DefaultLifecycleObserver
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import androidx.lifecycle.ProcessLifecycleOwner
|
||||
import com.x8bit.bitwarden.data.auth.repository.AuthRepository
|
||||
import com.x8bit.bitwarden.data.auth.repository.model.AuthState
|
||||
import com.x8bit.bitwarden.data.platform.datasource.disk.EventDiskSource
|
||||
import com.x8bit.bitwarden.data.platform.datasource.network.model.OrganizationEvent
|
||||
import com.x8bit.bitwarden.data.platform.datasource.network.service.EventService
|
||||
import com.x8bit.bitwarden.data.platform.manager.dispatcher.DispatcherManager
|
||||
import com.x8bit.bitwarden.data.platform.manager.model.OrganizationEventType
|
||||
import com.x8bit.bitwarden.data.vault.repository.VaultRepository
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
import java.time.Clock
|
||||
import java.time.ZonedDateTime
|
||||
|
||||
/**
|
||||
* The amount of time to delay before attempting the first upload events after the app is
|
||||
* foregrounded.
|
||||
*/
|
||||
private const val UPLOAD_DELAY_INITIAL_MS: Long = 120_000L
|
||||
|
||||
/**
|
||||
* The amount of time to delay before a subsequent attempts to upload events after the first one.
|
||||
*/
|
||||
private const val UPLOAD_DELAY_INTERVAL_MS: Long = 300_000L
|
||||
|
||||
/**
|
||||
* Default implementation of [OrganizationEventManager].
|
||||
*/
|
||||
@Suppress("LongParameterList")
|
||||
class OrganizationEventManagerImpl(
|
||||
private val clock: Clock,
|
||||
private val authRepository: AuthRepository,
|
||||
private val vaultRepository: VaultRepository,
|
||||
private val eventDiskSource: EventDiskSource,
|
||||
private val eventService: EventService,
|
||||
dispatcherManager: DispatcherManager,
|
||||
processLifecycleOwner: LifecycleOwner = ProcessLifecycleOwner.get(),
|
||||
) : OrganizationEventManager {
|
||||
private val ioScope = CoroutineScope(dispatcherManager.io)
|
||||
private var job: Job = Job().apply { complete() }
|
||||
|
||||
init {
|
||||
processLifecycleOwner.lifecycle.addObserver(
|
||||
object : DefaultLifecycleObserver {
|
||||
override fun onStart(owner: LifecycleOwner) = start()
|
||||
|
||||
override fun onStop(owner: LifecycleOwner) = stop()
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@Suppress("ReturnCount")
|
||||
override suspend fun trackEvent(eventType: OrganizationEventType, cipherId: String?) {
|
||||
val userId = authRepository.activeUserId ?: return
|
||||
if (authRepository.authStateFlow.value !is AuthState.Authenticated) return
|
||||
val organizations = authRepository.organizations.filter { it.shouldUseEvents }
|
||||
if (organizations.none()) return
|
||||
cipherId?.let { id ->
|
||||
val cipherOrganizationId = vaultRepository
|
||||
.getVaultItemStateFlow(itemId = id)
|
||||
.first { it.data != null }
|
||||
.data
|
||||
?.organizationId
|
||||
?: return
|
||||
if (organizations.none { it.id == cipherOrganizationId }) return
|
||||
}
|
||||
eventDiskSource.addOrganizationEvent(
|
||||
userId = userId,
|
||||
event = OrganizationEvent(
|
||||
type = eventType,
|
||||
cipherId = cipherId,
|
||||
date = ZonedDateTime.now(clock),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun uploadEvents() {
|
||||
val userId = authRepository.activeUserId ?: return
|
||||
val events = eventDiskSource
|
||||
.getOrganizationEvents(userId = userId)
|
||||
.takeUnless { it.isEmpty() }
|
||||
?: return
|
||||
eventService
|
||||
.sendOrganizationEvents(events = events)
|
||||
.onSuccess { eventDiskSource.deleteOrganizationEvents(userId = userId) }
|
||||
}
|
||||
|
||||
private fun start() {
|
||||
job.cancel()
|
||||
job = ioScope.launch {
|
||||
delay(timeMillis = UPLOAD_DELAY_INITIAL_MS)
|
||||
uploadEvents()
|
||||
while (coroutineContext.isActive) {
|
||||
delay(timeMillis = UPLOAD_DELAY_INTERVAL_MS)
|
||||
uploadEvents()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun stop() {
|
||||
job.cancel()
|
||||
ioScope.launch { uploadEvents() }
|
||||
}
|
||||
}
|
|
@ -571,6 +571,21 @@ class AuthRepositoryTest {
|
|||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `organizations should return an empty list when there is no active user`() = runTest {
|
||||
assertEquals(emptyList<SyncResponseJson.Profile.Organization>(), repository.organizations)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `organizations should pull from the organizations in the AuthDiskSource`() = runTest {
|
||||
fakeAuthDiskSource.userState = SINGLE_USER_STATE_1
|
||||
fakeAuthDiskSource.storeOrganizations(
|
||||
userId = USER_ID_1,
|
||||
organizations = ORGANIZATIONS,
|
||||
)
|
||||
assertEquals(ORGANIZATIONS, repository.organizations)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `clear Pending Account Deletion should unblock userState updates`() = runTest {
|
||||
val masterPassword = "hello world"
|
||||
|
|
|
@ -0,0 +1,218 @@
|
|||
package com.x8bit.bitwarden.data.platform.manager.event
|
||||
|
||||
import com.bitwarden.vault.CipherView
|
||||
import com.x8bit.bitwarden.data.auth.repository.AuthRepository
|
||||
import com.x8bit.bitwarden.data.auth.repository.model.AuthState
|
||||
import com.x8bit.bitwarden.data.platform.base.FakeDispatcherManager
|
||||
import com.x8bit.bitwarden.data.platform.datasource.disk.EventDiskSource
|
||||
import com.x8bit.bitwarden.data.platform.datasource.network.model.OrganizationEvent
|
||||
import com.x8bit.bitwarden.data.platform.datasource.network.service.EventService
|
||||
import com.x8bit.bitwarden.data.platform.manager.model.OrganizationEventType
|
||||
import com.x8bit.bitwarden.data.platform.repository.model.DataState
|
||||
import com.x8bit.bitwarden.data.platform.util.asSuccess
|
||||
import com.x8bit.bitwarden.data.util.FakeLifecycleOwner
|
||||
import com.x8bit.bitwarden.data.util.advanceTimeByAndRunCurrent
|
||||
import com.x8bit.bitwarden.data.vault.datasource.network.model.createMockOrganization
|
||||
import com.x8bit.bitwarden.data.vault.datasource.sdk.model.createMockCipherView
|
||||
import com.x8bit.bitwarden.data.vault.repository.VaultRepository
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.coVerify
|
||||
import io.mockk.every
|
||||
import io.mockk.just
|
||||
import io.mockk.mockk
|
||||
import io.mockk.runs
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.test.StandardTestDispatcher
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.jupiter.api.Test
|
||||
import java.time.Clock
|
||||
import java.time.Instant
|
||||
import java.time.ZoneOffset
|
||||
import java.time.ZonedDateTime
|
||||
|
||||
class OrganizationEventManagerTest {
|
||||
|
||||
private val fakeLifecycleOwner = FakeLifecycleOwner()
|
||||
private val fixedClock: Clock = Clock.fixed(
|
||||
Instant.parse("2023-10-27T12:00:00Z"),
|
||||
ZoneOffset.UTC,
|
||||
)
|
||||
private val dispatcher = StandardTestDispatcher()
|
||||
private val fakeDispatcherManager = FakeDispatcherManager(io = dispatcher)
|
||||
private val mutableAuthStateFlow = MutableStateFlow<AuthState>(value = AuthState.Uninitialized)
|
||||
private val authRepository = mockk<AuthRepository> {
|
||||
every { activeUserId } returns USER_ID
|
||||
every { authStateFlow } returns mutableAuthStateFlow
|
||||
every { organizations } returns emptyList()
|
||||
}
|
||||
private val mutableVaultItemStateFlow = MutableStateFlow<DataState<CipherView?>>(
|
||||
value = DataState.Loading
|
||||
)
|
||||
private val vaultRepository = mockk<VaultRepository> {
|
||||
every { getVaultItemStateFlow(itemId = any()) } returns mutableVaultItemStateFlow
|
||||
}
|
||||
private val eventService = mockk<EventService>()
|
||||
private val eventDiskSource = mockk<EventDiskSource> {
|
||||
coEvery { addOrganizationEvent(userId = any(), event = any()) } just runs
|
||||
}
|
||||
|
||||
private val organizationEventManager: OrganizationEventManager = OrganizationEventManagerImpl(
|
||||
processLifecycleOwner = fakeLifecycleOwner,
|
||||
clock = fixedClock,
|
||||
dispatcherManager = fakeDispatcherManager,
|
||||
authRepository = authRepository,
|
||||
vaultRepository = vaultRepository,
|
||||
eventService = eventService,
|
||||
eventDiskSource = eventDiskSource,
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `onLifecycleStart should upload events after 2 minutes and again after 5 more minutes`() =
|
||||
runTest {
|
||||
val organizationEvent = OrganizationEvent(
|
||||
type = OrganizationEventType.CIPHER_UPDATED,
|
||||
cipherId = CIPHER_ID,
|
||||
date = ZonedDateTime.now(fixedClock),
|
||||
)
|
||||
val events = listOf(organizationEvent)
|
||||
coEvery { eventDiskSource.getOrganizationEvents(userId = USER_ID) } returns events
|
||||
coEvery {
|
||||
eventService.sendOrganizationEvents(events = events)
|
||||
} returns Unit.asSuccess()
|
||||
coEvery { eventDiskSource.deleteOrganizationEvents(userId = USER_ID) } just runs
|
||||
|
||||
fakeLifecycleOwner.lifecycle.dispatchOnStart()
|
||||
|
||||
dispatcher.advanceTimeByAndRunCurrent(delayTimeMillis = 120_000L)
|
||||
coVerify(exactly = 1) {
|
||||
eventDiskSource.getOrganizationEvents(userId = USER_ID)
|
||||
eventService.sendOrganizationEvents(events = events)
|
||||
eventDiskSource.deleteOrganizationEvents(userId = USER_ID)
|
||||
}
|
||||
|
||||
dispatcher.advanceTimeByAndRunCurrent(delayTimeMillis = 300_000L)
|
||||
coVerify(exactly = 2) {
|
||||
eventDiskSource.getOrganizationEvents(userId = USER_ID)
|
||||
eventService.sendOrganizationEvents(events = events)
|
||||
eventDiskSource.deleteOrganizationEvents(userId = USER_ID)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `onLifecycleStop should upload events immediately`() = runTest {
|
||||
val organizationEvent = OrganizationEvent(
|
||||
type = OrganizationEventType.CIPHER_UPDATED,
|
||||
cipherId = CIPHER_ID,
|
||||
date = ZonedDateTime.now(fixedClock),
|
||||
)
|
||||
val events = listOf(organizationEvent)
|
||||
coEvery { eventDiskSource.getOrganizationEvents(userId = USER_ID) } returns events
|
||||
coEvery { eventService.sendOrganizationEvents(events = events) } returns Unit.asSuccess()
|
||||
coEvery { eventDiskSource.deleteOrganizationEvents(userId = USER_ID) } just runs
|
||||
|
||||
fakeLifecycleOwner.lifecycle.dispatchOnStop()
|
||||
|
||||
dispatcher.advanceTimeByAndRunCurrent(delayTimeMillis = 120_000L)
|
||||
coVerify(exactly = 1) {
|
||||
eventDiskSource.getOrganizationEvents(userId = USER_ID)
|
||||
eventService.sendOrganizationEvents(events = events)
|
||||
eventDiskSource.deleteOrganizationEvents(userId = USER_ID)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `trackEvent should do nothing if there is no active user`() = runTest {
|
||||
every { authRepository.activeUserId } returns null
|
||||
|
||||
organizationEventManager.trackEvent(
|
||||
eventType = OrganizationEventType.CIPHER_UPDATED,
|
||||
cipherId = CIPHER_ID,
|
||||
)
|
||||
|
||||
coVerify(exactly = 0) {
|
||||
eventDiskSource.addOrganizationEvent(userId = any(), event = any())
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `trackEvent should do nothing if the active user is not authenticated`() = runTest {
|
||||
organizationEventManager.trackEvent(
|
||||
eventType = OrganizationEventType.CIPHER_UPDATED,
|
||||
cipherId = CIPHER_ID,
|
||||
)
|
||||
|
||||
coVerify(exactly = 0) {
|
||||
eventDiskSource.addOrganizationEvent(userId = any(), event = any())
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `trackEvent should do nothing if the active user has no organizations that use events`() =
|
||||
runTest {
|
||||
mutableAuthStateFlow.value = AuthState.Authenticated(accessToken = "access-token")
|
||||
val organization = createMockOrganization(number = 1)
|
||||
every { authRepository.organizations } returns listOf(organization)
|
||||
|
||||
organizationEventManager.trackEvent(
|
||||
eventType = OrganizationEventType.CIPHER_UPDATED,
|
||||
cipherId = CIPHER_ID,
|
||||
)
|
||||
|
||||
coVerify(exactly = 0) {
|
||||
eventDiskSource.addOrganizationEvent(userId = any(), event = any())
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("MaxLineLength")
|
||||
@Test
|
||||
fun `trackEvent should do nothing if the cipher does not belong to an organization that uses events`() =
|
||||
runTest {
|
||||
mutableAuthStateFlow.value = AuthState.Authenticated(accessToken = "access-token")
|
||||
val organization = createMockOrganization(number = 1).copy(shouldUseEvents = true)
|
||||
every { authRepository.organizations } returns listOf(organization)
|
||||
val cipherView = createMockCipherView(number = 1)
|
||||
mutableVaultItemStateFlow.value = DataState.Loaded(data = cipherView)
|
||||
|
||||
organizationEventManager.trackEvent(
|
||||
eventType = OrganizationEventType.CIPHER_UPDATED,
|
||||
cipherId = CIPHER_ID,
|
||||
)
|
||||
|
||||
coVerify(exactly = 0) {
|
||||
eventDiskSource.addOrganizationEvent(userId = any(), event = any())
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `trackEvent should add the event to disk if the ciphers organization allows it`() =
|
||||
runTest {
|
||||
mutableAuthStateFlow.value = AuthState.Authenticated(accessToken = "access-token")
|
||||
val organization = createMockOrganization(number = 1).copy(
|
||||
id = "mockOrganizationId-1",
|
||||
shouldUseEvents = true,
|
||||
)
|
||||
every { authRepository.organizations } returns listOf(organization)
|
||||
val cipherView = createMockCipherView(number = 1)
|
||||
mutableVaultItemStateFlow.value = DataState.Loaded(data = cipherView)
|
||||
val eventType = OrganizationEventType.CIPHER_UPDATED
|
||||
|
||||
organizationEventManager.trackEvent(
|
||||
eventType = eventType,
|
||||
cipherId = CIPHER_ID,
|
||||
)
|
||||
|
||||
coVerify(exactly = 1) {
|
||||
eventDiskSource.addOrganizationEvent(
|
||||
userId = USER_ID,
|
||||
event = OrganizationEvent(
|
||||
type = eventType,
|
||||
cipherId = CIPHER_ID,
|
||||
date = ZonedDateTime.now(fixedClock),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private const val CIPHER_ID: String = "mockId-1"
|
||||
private const val USER_ID: String = "user-id"
|
Loading…
Reference in a new issue