mirror of
https://github.com/bitwarden/android.git
synced 2026-05-11 02:15:43 -05:00
Merge branch 'main' into pm-6701/email-verification-selfhost-registration
# Conflicts: # .github/workflows/scan.yml # app/build.gradle.kts # app/src/main/java/com/x8bit/bitwarden/BitwardenApplication.kt # app/src/main/java/com/x8bit/bitwarden/data/auth/repository/AuthRepository.kt # app/src/main/java/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryImpl.kt # app/src/main/java/com/x8bit/bitwarden/data/platform/datasource/disk/di/PlatformDiskModule.kt # app/src/main/java/com/x8bit/bitwarden/data/platform/datasource/network/di/PlatformNetworkModule.kt # app/src/main/java/com/x8bit/bitwarden/data/platform/datasource/network/retrofit/Retrofits.kt # app/src/main/java/com/x8bit/bitwarden/data/platform/datasource/network/retrofit/RetrofitsImpl.kt # app/src/main/java/com/x8bit/bitwarden/data/platform/manager/di/PlatformManagerModule.kt # app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/auth/AuthNavigation.kt # app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/createaccount/CreateAccountScreen.kt # app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/createaccount/CreateAccountViewModel.kt # app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/landing/LandingNavigation.kt # app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/landing/LandingScreen.kt # app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/landing/LandingViewModel.kt # app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/exportvault/ExportVaultScreen.kt # app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/exportvault/ExportVaultViewModel.kt # app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/qrcodescan/QrCodeScanViewModel.kt # app/src/main/res/values/strings.xml # app/src/test/java/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryTest.kt # app/src/test/java/com/x8bit/bitwarden/data/platform/datasource/network/retrofit/RetrofitsTest.kt # app/src/test/java/com/x8bit/bitwarden/data/platform/manager/AppForegroundManagerTest.kt # app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/landing/LandingScreenTest.kt # app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/qrcodescan/QrCodeScanViewModelTest.kt # build.gradle.kts # gradle/libs.versions.toml
This commit is contained in:
34
.github/workflows/scan.yml
vendored
34
.github/workflows/scan.yml
vendored
@@ -40,3 +40,37 @@ jobs:
|
||||
base_uri: https://ast.checkmarx.net/
|
||||
cx_client_id: ${{ secrets.CHECKMARX_CLIENT_ID }}
|
||||
cx_client_secret: ${{ secrets.CHECKMARX_SECRET }}
|
||||
additional_params: |
|
||||
--report-format sarif \
|
||||
--filter "state=TO_VERIFY;PROPOSED_NOT_EXPLOITABLE;CONFIRMED;URGENT" \
|
||||
--output-path . ${{ env.INCREMENTAL }}
|
||||
|
||||
- name: Upload Checkmarx results to GitHub
|
||||
uses: github/codeql-action/upload-sarif@9fdb3e49720b44c48891d036bb502feb25684276 # v3.25.6
|
||||
with:
|
||||
sarif_file: cx_result.sarif
|
||||
|
||||
quality:
|
||||
name: Quality scan
|
||||
runs-on: ubuntu-22.04
|
||||
needs: check-run
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
|
||||
steps:
|
||||
- name: Check out repo
|
||||
uses: actions/checkout@a5ac7e51b41094c92402da3b24376905380afc29 # v4.1.6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
ref: ${{ github.event.pull_request.head.sha }}
|
||||
|
||||
- name: Scan with SonarCloud
|
||||
uses: sonarsource/sonarcloud-github-action@4006f663ecaf1f8093e8e4abb9227f6041f52216 # v2.2.0
|
||||
env:
|
||||
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
args: >
|
||||
-Dsonar.organization=${{ github.repository_owner }}
|
||||
-Dsonar.projectKey=${{ github.repository_owner }}_${{ github.event.repository.name }}
|
||||
|
||||
@@ -17,6 +17,7 @@ plugins {
|
||||
alias(libs.plugins.kotlinx.kover)
|
||||
alias(libs.plugins.ksp)
|
||||
alias(libs.plugins.google.services)
|
||||
alias(libs.plugins.sonarqube)
|
||||
}
|
||||
|
||||
android {
|
||||
@@ -276,3 +277,19 @@ afterEvaluate {
|
||||
.filter { it.name.contains("Fdroid") }
|
||||
.forEach { it.enabled = false }
|
||||
}
|
||||
|
||||
sonar {
|
||||
properties {
|
||||
property("sonar.projectKey", "bitwarden_android")
|
||||
property("sonar.organization", "bitwarden")
|
||||
property("sonar.host.url", "https://sonarcloud.io")
|
||||
property("sonar.sources", "app/src/main/,app/src/standard/,app/src/fdroid/")
|
||||
property("sonar.tests", "app/src/test/")
|
||||
}
|
||||
}
|
||||
|
||||
tasks {
|
||||
getByName("sonar") {
|
||||
dependsOn("check")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
{
|
||||
"formatVersion": 1,
|
||||
"database": {
|
||||
"version": 1,
|
||||
"identityHash": "f40d7a933b2f353d8d5b5ca619f28e24",
|
||||
"entities": [
|
||||
{
|
||||
"tableName": "organization_events",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `user_id` TEXT NOT NULL, `organization_event_type` TEXT NOT NULL, `cipher_id` TEXT, `date` INTEGER NOT NULL)",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "userId",
|
||||
"columnName": "user_id",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "organizationEventType",
|
||||
"columnName": "organization_event_type",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "cipherId",
|
||||
"columnName": "cipher_id",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "date",
|
||||
"columnName": "date",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": true,
|
||||
"columnNames": [
|
||||
"id"
|
||||
]
|
||||
},
|
||||
"indices": [
|
||||
{
|
||||
"name": "index_organization_events_user_id",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"user_id"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_organization_events_user_id` ON `${TABLE_NAME}` (`user_id`)"
|
||||
}
|
||||
],
|
||||
"foreignKeys": []
|
||||
}
|
||||
],
|
||||
"views": [],
|
||||
"setupQueries": [
|
||||
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
|
||||
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'f40d7a933b2f353d8d5b5ca619f28e24')"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
package com.x8bit.bitwarden.data.platform.datasource.disk
|
||||
|
||||
import com.x8bit.bitwarden.data.platform.datasource.network.model.OrganizationEvent
|
||||
|
||||
/**
|
||||
* Primary access point for disk information related to event data.
|
||||
*/
|
||||
interface EventDiskSource {
|
||||
/**
|
||||
* Deletes all organization events associated with the given [userId].
|
||||
*/
|
||||
suspend fun deleteOrganizationEvents(userId: String)
|
||||
|
||||
/**
|
||||
* Adds a new organization event associated with the given [userId].
|
||||
*/
|
||||
suspend fun addOrganizationEvent(userId: String, event: OrganizationEvent)
|
||||
|
||||
/**
|
||||
* Retrieves all organization events associated with the given [userId].
|
||||
*/
|
||||
suspend fun getOrganizationEvents(userId: String): List<OrganizationEvent>
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package com.x8bit.bitwarden.data.platform.datasource.disk
|
||||
|
||||
import com.x8bit.bitwarden.data.platform.datasource.disk.dao.OrganizationEventDao
|
||||
import com.x8bit.bitwarden.data.platform.datasource.disk.entity.OrganizationEventEntity
|
||||
import com.x8bit.bitwarden.data.platform.datasource.network.model.OrganizationEvent
|
||||
import com.x8bit.bitwarden.data.platform.manager.dispatcher.DispatcherManager
|
||||
import com.x8bit.bitwarden.data.platform.manager.model.OrganizationEventType
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
|
||||
/**
|
||||
* The default implementation of [EventDiskSource].
|
||||
*/
|
||||
class EventDiskSourceImpl(
|
||||
private val organizationEventDao: OrganizationEventDao,
|
||||
private val dispatcherManager: DispatcherManager,
|
||||
private val json: Json,
|
||||
) : EventDiskSource {
|
||||
|
||||
override suspend fun deleteOrganizationEvents(userId: String) {
|
||||
organizationEventDao.deleteOrganizationEvents(userId = userId)
|
||||
}
|
||||
|
||||
override suspend fun addOrganizationEvent(userId: String, event: OrganizationEvent) {
|
||||
organizationEventDao.insertOrganizationEvent(
|
||||
event = OrganizationEventEntity(
|
||||
userId = userId,
|
||||
organizationEventType = withContext(context = dispatcherManager.default) {
|
||||
json.encodeToString(value = event.type)
|
||||
},
|
||||
cipherId = event.cipherId,
|
||||
date = event.date,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
override suspend fun getOrganizationEvents(
|
||||
userId: String,
|
||||
): List<OrganizationEvent> =
|
||||
organizationEventDao
|
||||
.getOrganizationEvents(userId = userId)
|
||||
.map {
|
||||
OrganizationEvent(
|
||||
type = withContext(context = dispatcherManager.default) {
|
||||
json.decodeFromString<OrganizationEventType>(
|
||||
string = it.organizationEventType,
|
||||
)
|
||||
},
|
||||
cipherId = it.cipherId,
|
||||
date = it.date,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package com.x8bit.bitwarden.data.platform.datasource.disk.dao
|
||||
|
||||
import androidx.room.Dao
|
||||
import androidx.room.Insert
|
||||
import androidx.room.OnConflictStrategy
|
||||
import androidx.room.Query
|
||||
import com.x8bit.bitwarden.data.platform.datasource.disk.entity.OrganizationEventEntity
|
||||
|
||||
/**
|
||||
* Provides methods for inserting, retrieving, and deleting events from the database using the
|
||||
* [OrganizationEventEntity].
|
||||
*/
|
||||
@Dao
|
||||
interface OrganizationEventDao {
|
||||
/**
|
||||
* Deletes all the stored events associated with the given [userId]. This will return the
|
||||
* number of rows deleted by this query.
|
||||
*/
|
||||
@Query("DELETE FROM organization_events WHERE user_id = :userId")
|
||||
suspend fun deleteOrganizationEvents(userId: String): Int
|
||||
|
||||
/**
|
||||
* Retrieves all events from the database for a given [userId].
|
||||
*/
|
||||
@Query("SELECT * FROM organization_events WHERE user_id = :userId")
|
||||
suspend fun getOrganizationEvents(userId: String): List<OrganizationEventEntity>
|
||||
|
||||
/**
|
||||
* Inserts an event into the database.
|
||||
*/
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
suspend fun insertOrganizationEvent(event: OrganizationEventEntity)
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package com.x8bit.bitwarden.data.platform.datasource.disk.database
|
||||
|
||||
import androidx.room.Database
|
||||
import androidx.room.RoomDatabase
|
||||
import androidx.room.TypeConverters
|
||||
import com.x8bit.bitwarden.data.platform.datasource.disk.dao.OrganizationEventDao
|
||||
import com.x8bit.bitwarden.data.platform.datasource.disk.entity.OrganizationEventEntity
|
||||
import com.x8bit.bitwarden.data.vault.datasource.disk.convertor.ZonedDateTimeTypeConverter
|
||||
|
||||
/**
|
||||
* Room database for storing any persisted data for platform data.
|
||||
*/
|
||||
@Database(
|
||||
entities = [
|
||||
OrganizationEventEntity::class,
|
||||
],
|
||||
version = 1,
|
||||
exportSchema = true,
|
||||
)
|
||||
@TypeConverters(ZonedDateTimeTypeConverter::class)
|
||||
abstract class PlatformDatabase : RoomDatabase() {
|
||||
/**
|
||||
* Provides the DAO for accessing organization event data.
|
||||
*/
|
||||
abstract fun organizationEventDao(): OrganizationEventDao
|
||||
}
|
||||
@@ -3,21 +3,28 @@ package com.x8bit.bitwarden.data.platform.datasource.disk.di
|
||||
import android.app.Application
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import androidx.room.Room
|
||||
import com.x8bit.bitwarden.data.platform.datasource.di.EncryptedPreferences
|
||||
import com.x8bit.bitwarden.data.platform.datasource.di.UnencryptedPreferences
|
||||
import com.x8bit.bitwarden.data.platform.datasource.disk.EnvironmentDiskSource
|
||||
import com.x8bit.bitwarden.data.platform.datasource.disk.EnvironmentDiskSourceImpl
|
||||
import com.x8bit.bitwarden.data.platform.datasource.disk.EventDiskSource
|
||||
import com.x8bit.bitwarden.data.platform.datasource.disk.EventDiskSourceImpl
|
||||
import com.x8bit.bitwarden.data.platform.datasource.disk.PushDiskSource
|
||||
import com.x8bit.bitwarden.data.platform.datasource.disk.PushDiskSourceImpl
|
||||
import com.x8bit.bitwarden.data.platform.datasource.disk.SettingsDiskSource
|
||||
import com.x8bit.bitwarden.data.platform.datasource.disk.SettingsDiskSourceImpl
|
||||
import com.x8bit.bitwarden.data.platform.datasource.disk.dao.OrganizationEventDao
|
||||
import com.x8bit.bitwarden.data.platform.datasource.disk.database.PlatformDatabase
|
||||
import com.x8bit.bitwarden.data.platform.datasource.disk.legacy.LegacyAppCenterMigrator
|
||||
import com.x8bit.bitwarden.data.platform.datasource.disk.legacy.LegacyAppCenterMigratorImpl
|
||||
import com.x8bit.bitwarden.data.platform.datasource.disk.legacy.LegacySecureStorage
|
||||
import com.x8bit.bitwarden.data.platform.datasource.disk.legacy.LegacySecureStorageImpl
|
||||
import com.x8bit.bitwarden.data.platform.datasource.disk.legacy.LegacySecureStorageMigrator
|
||||
import com.x8bit.bitwarden.data.platform.datasource.disk.legacy.LegacySecureStorageMigratorImpl
|
||||
import com.x8bit.bitwarden.data.platform.manager.dispatcher.DispatcherManager
|
||||
import com.x8bit.bitwarden.data.platform.repository.SettingsRepository
|
||||
import com.x8bit.bitwarden.data.vault.datasource.disk.convertor.ZonedDateTimeTypeConverter
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import dagger.hilt.InstallIn
|
||||
@@ -44,6 +51,38 @@ object PlatformDiskModule {
|
||||
json = json,
|
||||
)
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideEventDatabase(app: Application): PlatformDatabase =
|
||||
Room
|
||||
.databaseBuilder(
|
||||
context = app,
|
||||
klass = PlatformDatabase::class.java,
|
||||
name = "platform_database",
|
||||
)
|
||||
.fallbackToDestructiveMigration()
|
||||
.addTypeConverter(ZonedDateTimeTypeConverter())
|
||||
.build()
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideOrganizationEventDao(
|
||||
database: PlatformDatabase,
|
||||
): OrganizationEventDao = database.organizationEventDao()
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideEventDiskSource(
|
||||
organizationEventDao: OrganizationEventDao,
|
||||
dispatcherManager: DispatcherManager,
|
||||
json: Json,
|
||||
): EventDiskSource =
|
||||
EventDiskSourceImpl(
|
||||
organizationEventDao = organizationEventDao,
|
||||
dispatcherManager = dispatcherManager,
|
||||
json = json,
|
||||
)
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideLegacySecureStorage(
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
package com.x8bit.bitwarden.data.platform.datasource.disk.entity
|
||||
|
||||
import androidx.room.ColumnInfo
|
||||
import androidx.room.Entity
|
||||
import androidx.room.PrimaryKey
|
||||
import java.time.ZonedDateTime
|
||||
|
||||
/**
|
||||
* Entity representing an organization event in the database.
|
||||
*/
|
||||
@Entity(tableName = "organization_events")
|
||||
data class OrganizationEventEntity(
|
||||
@PrimaryKey(autoGenerate = true)
|
||||
@ColumnInfo(name = "id")
|
||||
val id: Int = 0,
|
||||
|
||||
@ColumnInfo(name = "user_id", index = true)
|
||||
val userId: String,
|
||||
|
||||
@ColumnInfo(name = "organization_event_type")
|
||||
val organizationEventType: String,
|
||||
|
||||
@ColumnInfo(name = "cipher_id")
|
||||
val cipherId: String?,
|
||||
|
||||
@ColumnInfo(name = "date")
|
||||
val date: ZonedDateTime,
|
||||
)
|
||||
@@ -0,0 +1,13 @@
|
||||
package com.x8bit.bitwarden.data.platform.datasource.network.api
|
||||
|
||||
import com.x8bit.bitwarden.data.platform.datasource.network.model.OrganizationEvent
|
||||
import retrofit2.http.Body
|
||||
import retrofit2.http.POST
|
||||
|
||||
/**
|
||||
* This interface defines the API service for posting event data.
|
||||
*/
|
||||
interface EventApi {
|
||||
@POST("/collect")
|
||||
suspend fun collectOrganizationEvents(@Body events: List<OrganizationEvent>): Result<Unit>
|
||||
}
|
||||
@@ -10,6 +10,8 @@ import com.x8bit.bitwarden.data.platform.datasource.network.retrofit.RetrofitsIm
|
||||
import com.x8bit.bitwarden.data.platform.datasource.network.serializer.ZonedDateTimeSerializer
|
||||
import com.x8bit.bitwarden.data.platform.datasource.network.service.ConfigService
|
||||
import com.x8bit.bitwarden.data.platform.datasource.network.service.ConfigServiceImpl
|
||||
import com.x8bit.bitwarden.data.platform.datasource.network.service.EventService
|
||||
import com.x8bit.bitwarden.data.platform.datasource.network.service.EventServiceImpl
|
||||
import com.x8bit.bitwarden.data.platform.datasource.network.service.PushService
|
||||
import com.x8bit.bitwarden.data.platform.datasource.network.service.PushServiceImpl
|
||||
import dagger.Module
|
||||
@@ -36,6 +38,14 @@ object PlatformNetworkModule {
|
||||
retrofits: Retrofits,
|
||||
): ConfigService = ConfigServiceImpl(retrofits.unauthenticatedApiRetrofit.create())
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun providesEventService(
|
||||
retrofits: Retrofits,
|
||||
): EventService = EventServiceImpl(
|
||||
eventApi = retrofits.authenticatedEventsRetrofit.create(),
|
||||
)
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun providePushService(
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
package com.x8bit.bitwarden.data.platform.datasource.network.model
|
||||
|
||||
import com.x8bit.bitwarden.data.platform.manager.model.OrganizationEventType
|
||||
import kotlinx.serialization.Contextual
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
import java.time.ZonedDateTime
|
||||
|
||||
/**
|
||||
* Represents an individual organization event including the type and time.
|
||||
*/
|
||||
@Serializable
|
||||
data class OrganizationEvent(
|
||||
@SerialName("type") val type: OrganizationEventType,
|
||||
@SerialName("cipherId") val cipherId: String?,
|
||||
@SerialName("date") @Contextual val date: ZonedDateTime,
|
||||
)
|
||||
@@ -14,6 +14,13 @@ interface Retrofits {
|
||||
*/
|
||||
val authenticatedApiRetrofit: Retrofit
|
||||
|
||||
/**
|
||||
* Allows access to "/events" calls that must be authenticated.
|
||||
*
|
||||
* The base URL can be dynamically determined via the [BaseUrlInterceptors].
|
||||
*/
|
||||
val authenticatedEventsRetrofit: Retrofit
|
||||
|
||||
/**
|
||||
* Allows access to "/api" calls that do not require authentication.
|
||||
*
|
||||
|
||||
@@ -36,6 +36,12 @@ class RetrofitsImpl(
|
||||
)
|
||||
}
|
||||
|
||||
override val authenticatedEventsRetrofit: Retrofit by lazy {
|
||||
createAuthenticatedRetrofit(
|
||||
baseUrlInterceptor = baseUrlInterceptors.eventsInterceptor,
|
||||
)
|
||||
}
|
||||
|
||||
//endregion Authenticated Retrofits
|
||||
|
||||
//region Unauthenticated Retrofits
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
package com.x8bit.bitwarden.data.platform.datasource.network.service
|
||||
|
||||
import com.x8bit.bitwarden.data.platform.datasource.network.model.OrganizationEvent
|
||||
|
||||
/**
|
||||
* Provides an API for submitting events.
|
||||
*/
|
||||
interface EventService {
|
||||
/**
|
||||
* Attempts to submit all of the given organizations events.
|
||||
*/
|
||||
suspend fun sendOrganizationEvents(events: List<OrganizationEvent>): Result<Unit>
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package com.x8bit.bitwarden.data.platform.datasource.network.service
|
||||
|
||||
import com.x8bit.bitwarden.data.platform.datasource.network.api.EventApi
|
||||
import com.x8bit.bitwarden.data.platform.datasource.network.model.OrganizationEvent
|
||||
|
||||
/**
|
||||
* The default implementation of the [EventService].
|
||||
*/
|
||||
class EventServiceImpl(
|
||||
private val eventApi: EventApi,
|
||||
) : EventService {
|
||||
override suspend fun sendOrganizationEvents(
|
||||
events: List<OrganizationEvent>,
|
||||
): Result<Unit> = eventApi.collectOrganizationEvents(events = events)
|
||||
}
|
||||
@@ -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.
|
||||
*/
|
||||
fun trackEvent(eventType: OrganizationEventType, cipherId: String? = null)
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
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 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
|
||||
|
||||
ioScope.launch {
|
||||
cipherId?.let { id ->
|
||||
val cipherOrganizationId = vaultRepository
|
||||
.getVaultItemStateFlow(itemId = id)
|
||||
.first { it.data != null }
|
||||
.data
|
||||
?.organizationId
|
||||
?: return@launch
|
||||
if (organizations.none { it.id == cipherOrganizationId }) return@launch
|
||||
}
|
||||
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() }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
package com.x8bit.bitwarden.data.platform.manager.model
|
||||
|
||||
import androidx.annotation.Keep
|
||||
import com.x8bit.bitwarden.data.platform.datasource.network.serializer.BaseEnumeratedIntSerializer
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/**
|
||||
* Representation of events used for organization tracking.
|
||||
*/
|
||||
@Serializable(OrganizationEventTypeSerializer::class)
|
||||
enum class OrganizationEventType {
|
||||
@SerialName("1000")
|
||||
USER_LOGGED_IN,
|
||||
|
||||
@SerialName("1001")
|
||||
USER_CHANGED_PASSWORD,
|
||||
|
||||
@SerialName("1002")
|
||||
USER_UPDATED_2FA,
|
||||
|
||||
@SerialName("1003")
|
||||
USER_DISABLED_2FA,
|
||||
|
||||
@SerialName("1004")
|
||||
USER_RECOVERED_2FA,
|
||||
|
||||
@SerialName("1005")
|
||||
USER_FAILED_LOGIN,
|
||||
|
||||
@SerialName("1006")
|
||||
USER_FAILED_LOGIN_2FA,
|
||||
|
||||
@SerialName("1007")
|
||||
USER_CLIENT_EXPORTED_VAULT,
|
||||
|
||||
@SerialName("1100")
|
||||
CIPHER_CREATED,
|
||||
|
||||
@SerialName("1101")
|
||||
CIPHER_UPDATED,
|
||||
|
||||
@SerialName("1102")
|
||||
CIPHER_DELETED,
|
||||
|
||||
@SerialName("1103")
|
||||
CIPHER_ATTACHMENT_CREATED,
|
||||
|
||||
@SerialName("1104")
|
||||
CIPHER_ATTACHMENT_DELETED,
|
||||
|
||||
@SerialName("1105")
|
||||
CIPHER_SHARED,
|
||||
|
||||
@SerialName("1106")
|
||||
CIPHER_UPDATED_COLLECTIONS,
|
||||
|
||||
@SerialName("1107")
|
||||
CIPHER_CLIENT_VIEWED,
|
||||
|
||||
@SerialName("1108")
|
||||
CIPHER_CLIENT_TOGGLED_PASSWORD_VISIBLE,
|
||||
|
||||
@SerialName("1109")
|
||||
CIPHER_CLIENT_TOGGLED_HIDDEN_FIELD_VISIBLE,
|
||||
|
||||
@SerialName("1110")
|
||||
CIPHER_CLIENT_TOGGLED_CARD_CODE_VISIBLE,
|
||||
|
||||
@SerialName("1111")
|
||||
CIPHER_CLIENT_COPIED_PASSWORD,
|
||||
|
||||
@SerialName("1112")
|
||||
CIPHER_CLIENT_COPIED_HIDDEN_FIELD,
|
||||
|
||||
@SerialName("1113")
|
||||
CIPHER_CLIENT_COPIED_CARD_CODE,
|
||||
|
||||
@SerialName("1114")
|
||||
CIPHER_CLIENT_AUTO_FILLED,
|
||||
|
||||
@SerialName("1115")
|
||||
CIPHER_SOFT_DELETED,
|
||||
|
||||
@SerialName("1116")
|
||||
CIPHER_RESTORED,
|
||||
|
||||
@SerialName("1117")
|
||||
CIPHER_CLIENT_TOGGLED_CARD_NUMBER_VISIBLE,
|
||||
|
||||
@SerialName("1300")
|
||||
COLLECTION_CREATED,
|
||||
|
||||
@SerialName("1301")
|
||||
COLLECTION_UPDATED,
|
||||
|
||||
@SerialName("1302")
|
||||
COLLECTION_DELETED,
|
||||
|
||||
@SerialName("1400")
|
||||
GROUP_CREATED,
|
||||
|
||||
@SerialName("1401")
|
||||
GROUP_UPDATED,
|
||||
|
||||
@SerialName("1402")
|
||||
GROUP_DELETED,
|
||||
|
||||
@SerialName("1500")
|
||||
ORGANIZATION_USER_INVITED,
|
||||
|
||||
@SerialName("1501")
|
||||
ORGANIZATION_USER_CONFIRMED,
|
||||
|
||||
@SerialName("1502")
|
||||
ORGANIZATION_USER_UPDATED,
|
||||
|
||||
@SerialName("1503")
|
||||
ORGANIZATION_USER_REMOVED,
|
||||
|
||||
@SerialName("1504")
|
||||
ORGANIZATION_USER_UPDATED_GROUPS,
|
||||
|
||||
@SerialName("1600")
|
||||
ORGANIZATION_UPDATED,
|
||||
|
||||
@SerialName("1601")
|
||||
ORGANIZATION_PURGED_VAULT,
|
||||
}
|
||||
|
||||
@Keep
|
||||
private class OrganizationEventTypeSerializer : BaseEnumeratedIntSerializer<OrganizationEventType>(
|
||||
values = OrganizationEventType.entries.toTypedArray(),
|
||||
)
|
||||
@@ -0,0 +1,106 @@
|
||||
package com.x8bit.bitwarden.ui.auth.feature.createaccount
|
||||
|
||||
import androidx.compose.animation.animateColorAsState
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.drawBehind
|
||||
import androidx.compose.ui.graphics.TransformOrigin
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.x8bit.bitwarden.R
|
||||
import com.x8bit.bitwarden.ui.platform.base.util.asText
|
||||
import com.x8bit.bitwarden.ui.platform.theme.LocalNonMaterialColors
|
||||
|
||||
/**
|
||||
* Draws a password indicator that displays password strength based on the given [state].
|
||||
*/
|
||||
@Suppress("LongMethod", "CyclomaticComplexMethod", "MagicNumber")
|
||||
@Composable
|
||||
fun PasswordStrengthIndicator(
|
||||
modifier: Modifier = Modifier,
|
||||
state: PasswordStrengthState,
|
||||
) {
|
||||
val widthPercent by animateFloatAsState(
|
||||
targetValue = when (state) {
|
||||
PasswordStrengthState.NONE -> 0f
|
||||
PasswordStrengthState.WEAK_1 -> .25f
|
||||
PasswordStrengthState.WEAK_2 -> .5f
|
||||
PasswordStrengthState.WEAK_3 -> .66f
|
||||
PasswordStrengthState.GOOD -> .82f
|
||||
PasswordStrengthState.STRONG -> 1f
|
||||
},
|
||||
label = "Width Percent State",
|
||||
)
|
||||
val indicatorColor = when (state) {
|
||||
PasswordStrengthState.NONE -> MaterialTheme.colorScheme.error
|
||||
PasswordStrengthState.WEAK_1 -> MaterialTheme.colorScheme.error
|
||||
PasswordStrengthState.WEAK_2 -> MaterialTheme.colorScheme.error
|
||||
PasswordStrengthState.WEAK_3 -> LocalNonMaterialColors.current.passwordWeak
|
||||
PasswordStrengthState.GOOD -> MaterialTheme.colorScheme.primary
|
||||
PasswordStrengthState.STRONG -> LocalNonMaterialColors.current.passwordStrong
|
||||
}
|
||||
val animatedIndicatorColor by animateColorAsState(
|
||||
targetValue = indicatorColor,
|
||||
label = "Indicator Color State",
|
||||
)
|
||||
val label = when (state) {
|
||||
PasswordStrengthState.NONE -> "".asText()
|
||||
PasswordStrengthState.WEAK_1 -> R.string.weak.asText()
|
||||
PasswordStrengthState.WEAK_2 -> R.string.weak.asText()
|
||||
PasswordStrengthState.WEAK_3 -> R.string.weak.asText()
|
||||
PasswordStrengthState.GOOD -> R.string.good.asText()
|
||||
PasswordStrengthState.STRONG -> R.string.strong.asText()
|
||||
}
|
||||
Column(
|
||||
modifier = modifier,
|
||||
) {
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.height(4.dp)
|
||||
.background(MaterialTheme.colorScheme.surfaceContainerHigh),
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxHeight()
|
||||
.fillMaxWidth()
|
||||
.graphicsLayer {
|
||||
transformOrigin = TransformOrigin(pivotFractionX = 0f, pivotFractionY = 0f)
|
||||
scaleX = widthPercent
|
||||
}
|
||||
.drawBehind {
|
||||
drawRect(animatedIndicatorColor)
|
||||
},
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.height(4.dp))
|
||||
Text(
|
||||
text = label(),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = indicatorColor,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Models various levels of password strength that can be displayed by [PasswordStrengthIndicator].
|
||||
*/
|
||||
enum class PasswordStrengthState {
|
||||
NONE,
|
||||
WEAK_1,
|
||||
WEAK_2,
|
||||
WEAK_3,
|
||||
GOOD,
|
||||
STRONG,
|
||||
}
|
||||
@@ -165,6 +165,6 @@ sealed class QrCodeScanAction {
|
||||
* Checks if a string is using base32 digits.
|
||||
*/
|
||||
private fun String.isBase32(): Boolean {
|
||||
val regex = ("^[A-Z2-7]+=*$").toRegex()
|
||||
val regex = ("^[A-Za-z2-7]+=*$").toRegex()
|
||||
return regex.matches(this)
|
||||
}
|
||||
|
||||
@@ -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,142 @@
|
||||
package com.x8bit.bitwarden.data.platform.datasource.disk
|
||||
|
||||
import com.x8bit.bitwarden.data.platform.base.FakeDispatcherManager
|
||||
import com.x8bit.bitwarden.data.platform.datasource.disk.dao.FakeOrganizationEventDao
|
||||
import com.x8bit.bitwarden.data.platform.datasource.disk.entity.OrganizationEventEntity
|
||||
import com.x8bit.bitwarden.data.platform.datasource.network.di.PlatformNetworkModule
|
||||
import com.x8bit.bitwarden.data.platform.datasource.network.model.OrganizationEvent
|
||||
import com.x8bit.bitwarden.data.platform.manager.model.OrganizationEventType
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertFalse
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
import org.junit.jupiter.api.Test
|
||||
import java.time.Clock
|
||||
import java.time.Instant
|
||||
import java.time.ZoneOffset
|
||||
import java.time.ZonedDateTime
|
||||
|
||||
class EventDiskSourceTest {
|
||||
private val fixedClock: Clock = Clock.fixed(
|
||||
Instant.parse("2023-10-27T12:00:00Z"),
|
||||
ZoneOffset.UTC,
|
||||
)
|
||||
|
||||
private val fakeOrganizationEventDao = FakeOrganizationEventDao()
|
||||
private val fakeDispatcherManager = FakeDispatcherManager()
|
||||
private val json = PlatformNetworkModule.providesJson()
|
||||
|
||||
private val eventDiskSource: EventDiskSource = EventDiskSourceImpl(
|
||||
organizationEventDao = fakeOrganizationEventDao,
|
||||
dispatcherManager = fakeDispatcherManager,
|
||||
json = json,
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `addOrganizationEvent should insert a new organization event`() = runTest {
|
||||
val userId = "userId-1"
|
||||
val organizationEvent = OrganizationEvent(
|
||||
type = OrganizationEventType.CIPHER_DELETED,
|
||||
cipherId = "cipherId-1",
|
||||
date = ZonedDateTime.now(fixedClock),
|
||||
)
|
||||
|
||||
eventDiskSource.addOrganizationEvent(
|
||||
userId = userId,
|
||||
event = organizationEvent,
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
listOf(
|
||||
OrganizationEventEntity(
|
||||
id = 0,
|
||||
userId = userId,
|
||||
organizationEventType = "1102",
|
||||
cipherId = "cipherId-1",
|
||||
date = ZonedDateTime.now(fixedClock),
|
||||
),
|
||||
),
|
||||
fakeOrganizationEventDao.storedEvents,
|
||||
)
|
||||
assertFalse(fakeOrganizationEventDao.isDeleteCalled)
|
||||
assertTrue(fakeOrganizationEventDao.isInsertCalled)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `deleteOrganizationEvents should delete all organization events`() = runTest {
|
||||
val userId = "userId-1"
|
||||
fakeOrganizationEventDao.storedEvents.addAll(
|
||||
listOf(
|
||||
OrganizationEventEntity(
|
||||
id = 1,
|
||||
userId = userId,
|
||||
organizationEventType = "1102",
|
||||
cipherId = "cipherId-1",
|
||||
date = ZonedDateTime.now(fixedClock),
|
||||
),
|
||||
OrganizationEventEntity(
|
||||
id = 2,
|
||||
userId = "userId-2",
|
||||
organizationEventType = "1102",
|
||||
cipherId = "cipherId-2",
|
||||
date = ZonedDateTime.now(fixedClock),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
eventDiskSource.deleteOrganizationEvents(userId = userId)
|
||||
|
||||
assertEquals(
|
||||
listOf(
|
||||
OrganizationEventEntity(
|
||||
id = 2,
|
||||
userId = "userId-2",
|
||||
organizationEventType = "1102",
|
||||
cipherId = "cipherId-2",
|
||||
date = ZonedDateTime.now(fixedClock),
|
||||
),
|
||||
),
|
||||
fakeOrganizationEventDao.storedEvents,
|
||||
)
|
||||
assertTrue(fakeOrganizationEventDao.isDeleteCalled)
|
||||
assertFalse(fakeOrganizationEventDao.isInsertCalled)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `getOrganizationEvents should retrieve the correct organization events`() = runTest {
|
||||
val userId = "userId-1"
|
||||
fakeOrganizationEventDao.storedEvents.addAll(
|
||||
listOf(
|
||||
OrganizationEventEntity(
|
||||
id = 1,
|
||||
userId = userId,
|
||||
organizationEventType = "1102",
|
||||
cipherId = "cipherId-1",
|
||||
date = ZonedDateTime.now(fixedClock),
|
||||
),
|
||||
OrganizationEventEntity(
|
||||
id = 2,
|
||||
userId = "userId-2",
|
||||
organizationEventType = "1102",
|
||||
cipherId = "cipherId-2",
|
||||
date = ZonedDateTime.now(fixedClock),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
val result = eventDiskSource.getOrganizationEvents(userId = userId)
|
||||
|
||||
assertEquals(
|
||||
listOf(
|
||||
OrganizationEvent(
|
||||
type = OrganizationEventType.CIPHER_DELETED,
|
||||
cipherId = "cipherId-1",
|
||||
date = ZonedDateTime.now(fixedClock),
|
||||
),
|
||||
),
|
||||
result,
|
||||
)
|
||||
assertFalse(fakeOrganizationEventDao.isDeleteCalled)
|
||||
assertFalse(fakeOrganizationEventDao.isInsertCalled)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package com.x8bit.bitwarden.data.platform.datasource.disk.dao
|
||||
|
||||
import com.x8bit.bitwarden.data.platform.datasource.disk.entity.OrganizationEventEntity
|
||||
|
||||
class FakeOrganizationEventDao : OrganizationEventDao {
|
||||
val storedEvents = mutableListOf<OrganizationEventEntity>()
|
||||
|
||||
var isDeleteCalled = false
|
||||
var isInsertCalled = false
|
||||
|
||||
override suspend fun deleteOrganizationEvents(userId: String): Int {
|
||||
val count = storedEvents.count { it.userId == userId }
|
||||
storedEvents.removeAll { it.userId == userId }
|
||||
isDeleteCalled = true
|
||||
return count
|
||||
}
|
||||
|
||||
override suspend fun getOrganizationEvents(
|
||||
userId: String,
|
||||
): List<OrganizationEventEntity> = storedEvents.filter { it.userId == userId }
|
||||
|
||||
override suspend fun insertOrganizationEvent(event: OrganizationEventEntity) {
|
||||
storedEvents.add(event)
|
||||
isInsertCalled = true
|
||||
}
|
||||
}
|
||||
@@ -101,6 +101,36 @@ class RetrofitsTest {
|
||||
assertTrue(isRefreshAuthenticatorCalled)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `authenticatedEventsRetrofit should not invoke the RefreshAuthenticator on success`() =
|
||||
runBlocking {
|
||||
val testApi = retrofits
|
||||
.authenticatedEventsRetrofit
|
||||
.createMockRetrofit()
|
||||
.create<TestApi>()
|
||||
|
||||
server.enqueue(MockResponse().setBody("""{}"""))
|
||||
|
||||
testApi.test()
|
||||
|
||||
assertFalse(isRefreshAuthenticatorCalled)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `authenticatedEventsRetrofit should invoke the RefreshAuthenticator on 401`() =
|
||||
runBlocking {
|
||||
val testApi = retrofits
|
||||
.authenticatedEventsRetrofit
|
||||
.createMockRetrofit()
|
||||
.create<TestApi>()
|
||||
|
||||
server.enqueue(MockResponse().setResponseCode(401).setBody("""{}"""))
|
||||
|
||||
testApi.test()
|
||||
|
||||
assertTrue(isRefreshAuthenticatorCalled)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `unauthenticatedApiRetrofit should not invoke the RefreshAuthenticator`() = runBlocking {
|
||||
val testApi = retrofits
|
||||
@@ -133,6 +163,24 @@ class RetrofitsTest {
|
||||
assertFalse(isEventsInterceptorCalled)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `authenticatedEventsRetrofit should invoke the correct interceptors`() = runBlocking {
|
||||
val testApi = retrofits
|
||||
.authenticatedEventsRetrofit
|
||||
.createMockRetrofit()
|
||||
.create<TestApi>()
|
||||
|
||||
server.enqueue(MockResponse().setBody("""{}"""))
|
||||
|
||||
testApi.test()
|
||||
|
||||
assertTrue(isAuthInterceptorCalled)
|
||||
assertFalse(isApiInterceptorCalled)
|
||||
assertTrue(isheadersInterceptorCalled)
|
||||
assertFalse(isIdentityInterceptorCalled)
|
||||
assertTrue(isEventsInterceptorCalled)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `unauthenticatedApiRetrofit should invoke the correct interceptors`() = runBlocking {
|
||||
val testApi = retrofits
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
package com.x8bit.bitwarden.data.platform.datasource.network.service
|
||||
|
||||
import com.x8bit.bitwarden.data.platform.base.BaseServiceTest
|
||||
import com.x8bit.bitwarden.data.platform.datasource.network.api.EventApi
|
||||
import com.x8bit.bitwarden.data.platform.datasource.network.model.OrganizationEvent
|
||||
import com.x8bit.bitwarden.data.platform.manager.model.OrganizationEventType
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import okhttp3.mockwebserver.MockResponse
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Test
|
||||
import retrofit2.create
|
||||
import java.time.Clock
|
||||
import java.time.Instant
|
||||
import java.time.ZoneOffset
|
||||
import java.time.ZonedDateTime
|
||||
|
||||
class EventServiceTest : BaseServiceTest() {
|
||||
private val fixedClock: Clock = Clock.fixed(
|
||||
Instant.parse("2023-10-27T12:00:00Z"),
|
||||
ZoneOffset.UTC,
|
||||
)
|
||||
|
||||
private val eventApi: EventApi = retrofit.create()
|
||||
|
||||
private val eventService: EventService = EventServiceImpl(
|
||||
eventApi = eventApi,
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `sendOrganizationEvents should return the correct response`() = runTest {
|
||||
server.enqueue(MockResponse())
|
||||
val result = eventService.sendOrganizationEvents(
|
||||
events = listOf(
|
||||
OrganizationEvent(
|
||||
type = OrganizationEventType.CIPHER_CREATED,
|
||||
cipherId = "cipher-id",
|
||||
date = ZonedDateTime.now(fixedClock),
|
||||
),
|
||||
),
|
||||
)
|
||||
assertEquals(Unit, result.getOrThrow())
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,8 @@
|
||||
package com.x8bit.bitwarden.data.platform.manager
|
||||
|
||||
import androidx.lifecycle.DefaultLifecycleObserver
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleObserver
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import app.cash.turbine.test
|
||||
import com.x8bit.bitwarden.data.platform.manager.model.AppForegroundState
|
||||
import com.x8bit.bitwarden.data.util.FakeLifecycleOwner
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Test
|
||||
@@ -45,43 +42,3 @@ class AppForegroundManagerTest {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private class FakeLifecycle(
|
||||
private val lifecycleOwner: LifecycleOwner,
|
||||
) : Lifecycle() {
|
||||
private val observers = mutableSetOf<DefaultLifecycleObserver>()
|
||||
|
||||
override var currentState: State = State.INITIALIZED
|
||||
|
||||
override fun addObserver(observer: LifecycleObserver) {
|
||||
observers += (observer as DefaultLifecycleObserver)
|
||||
}
|
||||
|
||||
override fun removeObserver(observer: LifecycleObserver) {
|
||||
observers -= (observer as DefaultLifecycleObserver)
|
||||
}
|
||||
|
||||
/**
|
||||
* Triggers [DefaultLifecycleObserver.onStart] calls for each registered observer.
|
||||
*/
|
||||
fun dispatchOnStart() {
|
||||
currentState = State.STARTED
|
||||
observers.forEach { observer ->
|
||||
observer.onStart(lifecycleOwner)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Triggers [DefaultLifecycleObserver.onStop] calls for each registered observer.
|
||||
*/
|
||||
fun dispatchOnStop() {
|
||||
currentState = State.CREATED
|
||||
observers.forEach { observer ->
|
||||
observer.onStop(lifecycleOwner)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private class FakeLifecycleOwner : LifecycleOwner {
|
||||
override val lifecycle: FakeLifecycle = FakeLifecycle(this)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,216 @@
|
||||
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`() {
|
||||
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`() {
|
||||
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`() {
|
||||
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`() {
|
||||
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`() {
|
||||
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,
|
||||
)
|
||||
|
||||
dispatcher.scheduler.runCurrent()
|
||||
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"
|
||||
@@ -0,0 +1,45 @@
|
||||
package com.x8bit.bitwarden.data.util
|
||||
|
||||
import androidx.lifecycle.DefaultLifecycleObserver
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleObserver
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
|
||||
/**
|
||||
* A fake implementation of [LifecycleOwner] and [Lifecycle] for testing purposes.
|
||||
*/
|
||||
class FakeLifecycle(
|
||||
private val lifecycleOwner: LifecycleOwner,
|
||||
) : Lifecycle() {
|
||||
private val observers = mutableSetOf<DefaultLifecycleObserver>()
|
||||
|
||||
override var currentState: State = State.INITIALIZED
|
||||
|
||||
override fun addObserver(observer: LifecycleObserver) {
|
||||
observers += (observer as DefaultLifecycleObserver)
|
||||
}
|
||||
|
||||
override fun removeObserver(observer: LifecycleObserver) {
|
||||
observers -= (observer as DefaultLifecycleObserver)
|
||||
}
|
||||
|
||||
/**
|
||||
* Triggers [DefaultLifecycleObserver.onStart] calls for each registered observer.
|
||||
*/
|
||||
fun dispatchOnStart() {
|
||||
currentState = State.STARTED
|
||||
observers.forEach { observer ->
|
||||
observer.onStart(lifecycleOwner)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Triggers [DefaultLifecycleObserver.onStop] calls for each registered observer.
|
||||
*/
|
||||
fun dispatchOnStop() {
|
||||
currentState = State.CREATED
|
||||
observers.forEach { observer ->
|
||||
observer.onStop(lifecycleOwner)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package com.x8bit.bitwarden.data.util
|
||||
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
|
||||
/**
|
||||
* A fake implementation of [LifecycleOwner] for testing purposes.
|
||||
*/
|
||||
class FakeLifecycleOwner : LifecycleOwner {
|
||||
override val lifecycle: FakeLifecycle = FakeLifecycle(lifecycleOwner = this)
|
||||
}
|
||||
@@ -81,7 +81,7 @@ class QrCodeScanViewModelTest : BaseViewModelTest() {
|
||||
setupMockUri()
|
||||
|
||||
val validCode =
|
||||
"otpauth://totp/Test:me?secret=JBSWY3DPEHPK3PXP&algorithm=sha256&digits=8&period=60"
|
||||
"otpauth://totp/Test:me?secret=JBSWY3dpeHPK3PXP&algorithm=sha256&digits=8&period=60"
|
||||
val viewModel = createViewModel()
|
||||
val result = TotpCodeResult.Success(validCode)
|
||||
|
||||
@@ -99,8 +99,7 @@ class QrCodeScanViewModelTest : BaseViewModelTest() {
|
||||
queryParameterNames = setOf(SECRET),
|
||||
)
|
||||
|
||||
val validCode =
|
||||
"otpauth://totp/Test:me?secret=JBSWY3DPEHPK3PXP"
|
||||
val validCode = "otpauth://totp/Test:me?secret=JBSWY3dpeHPK3PXP"
|
||||
val viewModel = createViewModel()
|
||||
val result = TotpCodeResult.Success(validCode)
|
||||
|
||||
@@ -119,8 +118,7 @@ class QrCodeScanViewModelTest : BaseViewModelTest() {
|
||||
|
||||
val viewModel = createViewModel()
|
||||
val result = TotpCodeResult.CodeScanningError
|
||||
val invalidCode =
|
||||
"otpauth://totp/Test:me?secret=JBSWY3DPEHPK3PXP&algorithm=sha224"
|
||||
val invalidCode = "otpauth://totp/Test:me?secret=JBSWY3dpeHPK3PXP&algorithm=sha224"
|
||||
|
||||
viewModel.eventFlow.test {
|
||||
viewModel.trySendAction(QrCodeScanAction.QrCodeScanReceive(invalidCode))
|
||||
@@ -136,8 +134,7 @@ class QrCodeScanViewModelTest : BaseViewModelTest() {
|
||||
|
||||
val viewModel = createViewModel()
|
||||
val result = TotpCodeResult.CodeScanningError
|
||||
val invalidCode =
|
||||
"otpauth://totp/Test:me?secret=JBSWY3DPEHPK3PXP&digits=11"
|
||||
val invalidCode = "otpauth://totp/Test:me?secret=JBSWY3dpeHPK3PXP&digits=11"
|
||||
|
||||
viewModel.eventFlow.test {
|
||||
viewModel.trySendAction(QrCodeScanAction.QrCodeScanReceive(invalidCode))
|
||||
@@ -153,8 +150,7 @@ class QrCodeScanViewModelTest : BaseViewModelTest() {
|
||||
|
||||
val viewModel = createViewModel()
|
||||
val result = TotpCodeResult.CodeScanningError
|
||||
val invalidCode =
|
||||
"otpauth://totp/Test:me?secret=JBSWY3DPEHPK3PXP&period=0"
|
||||
val invalidCode = "otpauth://totp/Test:me?secret=JBSWY3dpeHPK3PXP&period=0"
|
||||
|
||||
viewModel.eventFlow.test {
|
||||
viewModel.trySendAction(QrCodeScanAction.QrCodeScanReceive(invalidCode))
|
||||
@@ -169,8 +165,7 @@ class QrCodeScanViewModelTest : BaseViewModelTest() {
|
||||
|
||||
val viewModel = createViewModel()
|
||||
val result = TotpCodeResult.CodeScanningError
|
||||
val invalidCode =
|
||||
"nototpauth://totp/Test:me?secret=JBSWY3DPEHPK3PXP"
|
||||
val invalidCode = "nototpauth://totp/Test:me?secret=JBSWY3dpeHPK3PXP"
|
||||
|
||||
viewModel.eventFlow.test {
|
||||
viewModel.trySendAction(QrCodeScanAction.QrCodeScanReceive(invalidCode))
|
||||
@@ -182,12 +177,11 @@ class QrCodeScanViewModelTest : BaseViewModelTest() {
|
||||
|
||||
@Test
|
||||
fun `QrCodeScan should emit failure result with non base32 secret`() = runTest {
|
||||
setupMockUri(secret = "JBSWY3DPEHPK3PXP1")
|
||||
setupMockUri(secret = "JBSWY3dpeHPK3PXP1")
|
||||
|
||||
val viewModel = createViewModel()
|
||||
val result = TotpCodeResult.CodeScanningError
|
||||
val invalidCode =
|
||||
"otpauth://totp/Test:me?secret=JBSWY3DPEHPK3PXP1"
|
||||
val invalidCode = "otpauth://totp/Test:me?secret=JBSWY3dpeHPK3PXP1"
|
||||
|
||||
viewModel.eventFlow.test {
|
||||
viewModel.trySendAction(QrCodeScanAction.QrCodeScanReceive(invalidCode))
|
||||
@@ -244,7 +238,7 @@ class QrCodeScanViewModelTest : BaseViewModelTest() {
|
||||
}
|
||||
|
||||
private fun setupMockUri(
|
||||
secret: String? = "JBSWY3DPEHPK3PXP",
|
||||
secret: String? = "JBSWY3dpeHPK3PXP",
|
||||
algorithm: String = "SHA256",
|
||||
digits: String = "8",
|
||||
period: String = "60",
|
||||
|
||||
@@ -7,4 +7,5 @@ plugins {
|
||||
alias(libs.plugins.kotlinx.kover) apply false
|
||||
alias(libs.plugins.ksp) apply false
|
||||
alias(libs.plugins.google.services) apply false
|
||||
alias(libs.plugins.sonarqube) apply false
|
||||
}
|
||||
|
||||
@@ -44,6 +44,7 @@ mockk = "1.13.11"
|
||||
okhttp = "4.12.0"
|
||||
retrofitBom = "2.11.0"
|
||||
roboelectric = "4.12.2"
|
||||
sonarqube = "5.0.0.4638"
|
||||
turbine = "1.1.0"
|
||||
zxcvbn4j = "1.9.0"
|
||||
zxing = "3.5.3"
|
||||
@@ -122,3 +123,4 @@ kotlin-parcelize = { id = "org.jetbrains.kotlin.plugin.parcelize", version.ref =
|
||||
kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
|
||||
kotlinx-kover = { id = "org.jetbrains.kotlinx.kover", version.ref = "kotlinxKover" }
|
||||
ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }
|
||||
sonarqube = { id = "org.sonarqube", version.ref = "sonarqube" }
|
||||
|
||||
Reference in New Issue
Block a user