Compare commits

...

10 Commits

Author SHA1 Message Date
Patrick Honkonen
b5752c10ed Bump VaultDatabase to version 5 (#4253) 2024-11-07 10:52:32 -05:00
David Perez
ec85e7af61 PM-11753: Vault flows should listen to vault onlock state to ensure d… (#4248) 2024-11-06 15:16:51 -06:00
Patrick Honkonen
816b9769a1 [PM-14526] Add JsonNames annotation to SyncResponseJson (#4247) 2024-11-06 15:38:00 -05:00
David Perez
25097cbae1 PM-14433: Null domain data (#4243) 2024-11-06 10:59:20 -06:00
Dave Severns
5a4b8d64ab PM-14433 update flow type to nullable so we can handle gracefully and avoid crash. (#4231) 2024-11-04 20:23:36 -05:00
Patrick Honkonen
5523d99400 [PM-14346] Run alias generation on the IO dispatcher (#4215)
(cherry picked from commit 8f2d55c146)
2024-11-01 12:04:28 -04:00
David Perez
9f8d21cb95 PM-14255: Remove accessibility logic to improve overall performance (#4206)
(cherry picked from commit 4831750ffd)
2024-11-01 12:04:28 -04:00
Patrick Honkonen
75fc9fe210 [PM-14254] Keep Android verifier for JNI usage (#4197)
(cherry picked from commit fab018782c)
2024-11-01 12:04:27 -04:00
Álison Fernandes
42671aadfb [PM-14224] Automate Play Store prod variant publishing (#4183) 2024-10-30 14:13:04 +00:00
aj-rosado
d71389ab02 [PM-14241] Checking if Timber tree has been added before trying to remove it (#4194) 2024-10-30 13:12:20 +01:00
22 changed files with 694 additions and 29 deletions

View File

@@ -382,7 +382,9 @@ jobs:
- name: Publish Play Store bundle
if: ${{ matrix.variant == 'prod' && matrix.artifact == 'aab' && (inputs.publish-to-play-store || github.ref_name == 'main') }}
run: bundle exec fastlane publishBetaToPlayStore
run: |
bundle exec fastlane publishProdToPlayStore
bundle exec fastlane publishBetaToPlayStore
publish_fdroid:
name: Publish F-Droid artifacts

View File

@@ -6,6 +6,10 @@
# we keep it here.
-keep class com.bitwarden.** { *; }
# The Android Verifier component must be kept because it looks like dead code. Proguard is unable to
# see any JNI usage, so our rules must manually opt into keeping it.
-keep, includedescriptorclasses class org.rustls.platformverifier.** { *; }
################################################################################
# Bitwarden Models
################################################################################

View File

@@ -0,0 +1,250 @@
{
"formatVersion": 1,
"database": {
"version": 4,
"identityHash": "f28200334a5c94feed1d9712e04ff01b",
"entities": [
{
"tableName": "ciphers",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `user_id` TEXT NOT NULL, `cipher_type` TEXT NOT NULL, `cipher_json` TEXT NOT NULL, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "userId",
"columnName": "user_id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "cipherType",
"columnName": "cipher_type",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "cipherJson",
"columnName": "cipher_json",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_ciphers_user_id",
"unique": false,
"columnNames": [
"user_id"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_ciphers_user_id` ON `${TABLE_NAME}` (`user_id`)"
}
],
"foreignKeys": []
},
{
"tableName": "collections",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `user_id` TEXT NOT NULL, `organization_id` TEXT NOT NULL, `should_hide_passwords` INTEGER NOT NULL, `name` TEXT NOT NULL, `external_id` TEXT, `read_only` INTEGER NOT NULL, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "userId",
"columnName": "user_id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "organizationId",
"columnName": "organization_id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "shouldHidePasswords",
"columnName": "should_hide_passwords",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "externalId",
"columnName": "external_id",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "isReadOnly",
"columnName": "read_only",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_collections_user_id",
"unique": false,
"columnNames": [
"user_id"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_collections_user_id` ON `${TABLE_NAME}` (`user_id`)"
}
],
"foreignKeys": []
},
{
"tableName": "domains",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`user_id` TEXT NOT NULL, `domains_json` TEXT, PRIMARY KEY(`user_id`))",
"fields": [
{
"fieldPath": "userId",
"columnName": "user_id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "domainsJson",
"columnName": "domains_json",
"affinity": "TEXT",
"notNull": false
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"user_id"
]
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "folders",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `user_id` TEXT NOT NULL, `name` TEXT, `revision_date` INTEGER NOT NULL, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "userId",
"columnName": "user_id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "revisionDate",
"columnName": "revision_date",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_folders_user_id",
"unique": false,
"columnNames": [
"user_id"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_folders_user_id` ON `${TABLE_NAME}` (`user_id`)"
}
],
"foreignKeys": []
},
{
"tableName": "sends",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `user_id` TEXT NOT NULL, `send_type` TEXT NOT NULL, `send_json` TEXT NOT NULL, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "userId",
"columnName": "user_id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "sendType",
"columnName": "send_type",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "sendJson",
"columnName": "send_json",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_sends_user_id",
"unique": false,
"columnNames": [
"user_id"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_sends_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, 'f28200334a5c94feed1d9712e04ff01b')"
]
}
}

View File

@@ -0,0 +1,250 @@
{
"formatVersion": 1,
"database": {
"version": 5,
"identityHash": "f28200334a5c94feed1d9712e04ff01b",
"entities": [
{
"tableName": "ciphers",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `user_id` TEXT NOT NULL, `cipher_type` TEXT NOT NULL, `cipher_json` TEXT NOT NULL, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "userId",
"columnName": "user_id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "cipherType",
"columnName": "cipher_type",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "cipherJson",
"columnName": "cipher_json",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_ciphers_user_id",
"unique": false,
"columnNames": [
"user_id"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_ciphers_user_id` ON `${TABLE_NAME}` (`user_id`)"
}
],
"foreignKeys": []
},
{
"tableName": "collections",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `user_id` TEXT NOT NULL, `organization_id` TEXT NOT NULL, `should_hide_passwords` INTEGER NOT NULL, `name` TEXT NOT NULL, `external_id` TEXT, `read_only` INTEGER NOT NULL, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "userId",
"columnName": "user_id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "organizationId",
"columnName": "organization_id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "shouldHidePasswords",
"columnName": "should_hide_passwords",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "externalId",
"columnName": "external_id",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "isReadOnly",
"columnName": "read_only",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_collections_user_id",
"unique": false,
"columnNames": [
"user_id"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_collections_user_id` ON `${TABLE_NAME}` (`user_id`)"
}
],
"foreignKeys": []
},
{
"tableName": "domains",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`user_id` TEXT NOT NULL, `domains_json` TEXT, PRIMARY KEY(`user_id`))",
"fields": [
{
"fieldPath": "userId",
"columnName": "user_id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "domainsJson",
"columnName": "domains_json",
"affinity": "TEXT",
"notNull": false
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"user_id"
]
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "folders",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `user_id` TEXT NOT NULL, `name` TEXT, `revision_date` INTEGER NOT NULL, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "userId",
"columnName": "user_id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "revisionDate",
"columnName": "revision_date",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_folders_user_id",
"unique": false,
"columnNames": [
"user_id"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_folders_user_id` ON `${TABLE_NAME}` (`user_id`)"
}
],
"foreignKeys": []
},
{
"tableName": "sends",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `user_id` TEXT NOT NULL, `send_type` TEXT NOT NULL, `send_json` TEXT NOT NULL, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "userId",
"columnName": "user_id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "sendType",
"columnName": "send_type",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "sendJson",
"columnName": "send_json",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_sends_user_id",
"unique": false,
"columnNames": [
"user_id"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_sends_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, 'f28200334a5c94feed1d9712e04ff01b')"
]
}
}

View File

@@ -23,7 +23,7 @@ class BitwardenAccessibilityService : AccessibilityService() {
override fun onAccessibilityEvent(event: AccessibilityEvent) {
if (rootInActiveWindow?.packageName != event.packageName) return
processor.processAccessibilityEvent(rootAccessibilityNodeInfo = rootInActiveWindow)
processor.processAccessibilityEvent(rootAccessibilityNodeInfo = event.source)
}
override fun onInterrupt() = Unit

View File

@@ -1,12 +1,14 @@
package com.x8bit.bitwarden.data.platform.repository.util
import com.x8bit.bitwarden.data.auth.datasource.disk.model.UserStateJson
import com.x8bit.bitwarden.data.vault.repository.model.VaultUnlockData
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.awaitCancellation
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.map
@@ -31,3 +33,34 @@ fun <T, R> MutableStateFlow<T>.observeWhenSubscribedAndLoggedIn(
.flatMapLatest { activeUserId ->
activeUserId?.let(observer) ?: flow { awaitCancellation() }
}
/**
* Invokes the [observer] callback whenever the user is logged in, the active user changes, the
* vault for the user changes, and there are subscribers to the [MutableStateFlow]. The flow from
* all previous calls to the `observer` is canceled whenever the `observer` is re-invoked, there
* is no active user (logged-out), there are no subscribers to the [MutableStateFlow], or the vault
* is not unlocked.
*/
@OptIn(ExperimentalCoroutinesApi::class)
fun <T, R> MutableStateFlow<T>.observeWhenSubscribedAndUnlocked(
userStateFlow: Flow<UserStateJson?>,
vaultUnlockFlow: Flow<List<VaultUnlockData>>,
observer: (activeUserId: String) -> Flow<R>,
): Flow<R> =
combine(
this.subscriptionCount.map { it > 0 }.distinctUntilChanged(),
userStateFlow.map { it?.activeUserId }.distinctUntilChanged(),
userStateFlow.map { it?.activeUserId }
.distinctUntilChanged()
.filterNotNull()
.flatMapLatest { activeUserId ->
vaultUnlockFlow
.map { it.any { it.userId == activeUserId } }
.distinctUntilChanged()
},
) { isSubscribed, activeUserId, isUnlocked ->
activeUserId.takeIf { isSubscribed && isUnlocked }
}
.flatMapLatest { activeUserId ->
activeUserId?.let(observer) ?: flow { awaitCancellation() }
}

View File

@@ -38,6 +38,7 @@ import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.time.Clock
import javax.inject.Singleton
@@ -190,7 +191,7 @@ class GeneratorRepositoryImpl(
override suspend fun generateForwardedServiceUsername(
forwardedServiceGeneratorRequest: UsernameGeneratorRequest.Forwarded,
): GeneratedForwardedServiceUsernameResult =
): GeneratedForwardedServiceUsernameResult = withContext(scope.coroutineContext) {
generatorSdkSource.generateForwardedServiceEmail(forwardedServiceGeneratorRequest)
.fold(
onSuccess = { generatedEmail ->
@@ -200,6 +201,7 @@ class GeneratorRepositoryImpl(
GeneratedForwardedServiceUsernameResult.InvalidRequest(it.message)
},
)
}
override fun getPasscodeGenerationOptions(): PasscodeGenerationOptions? {
val userId = authDiskSource.userState?.activeUserId

View File

@@ -37,7 +37,7 @@ interface VaultDiskSource {
/**
* Retrieves all domains from the data source for a given [userId].
*/
fun getDomains(userId: String): Flow<SyncResponseJson.Domains>
fun getDomains(userId: String): Flow<SyncResponseJson.Domains?>
/**
* Deletes a folder from the data source for the given [userId] and [folderId].

View File

@@ -119,12 +119,12 @@ class VaultDiskSourceImpl(
},
)
override fun getDomains(userId: String): Flow<SyncResponseJson.Domains> =
override fun getDomains(userId: String): Flow<SyncResponseJson.Domains?> =
domainsDao
.getDomains(userId)
.map { entity ->
withContext(dispatcherManager.default) {
json.decodeFromString<SyncResponseJson.Domains>(entity.domainsJson)
entity?.domainsJson?.let { json.decodeFromString<SyncResponseJson.Domains>(it) }
}
}
@@ -237,7 +237,7 @@ class VaultDiskSourceImpl(
domainsDao.insertDomains(
domains = DomainsEntity(
userId = userId,
domainsJson = json.encodeToString(vault.domains),
domainsJson = vault.domains?.let { json.encodeToString(it) },
),
)
}

View File

@@ -25,7 +25,7 @@ interface DomainsDao {
@Query("SELECT * FROM domains WHERE user_id = :userId")
fun getDomains(
userId: String,
): Flow<DomainsEntity>
): Flow<DomainsEntity?>
/**
* Inserts domains into the database.

View File

@@ -26,7 +26,7 @@ import com.x8bit.bitwarden.data.vault.datasource.disk.entity.SendEntity
FolderEntity::class,
SendEntity::class,
],
version = 3,
version = 5,
exportSchema = true,
)
@TypeConverters(ZonedDateTimeTypeConverter::class)

View File

@@ -14,5 +14,5 @@ data class DomainsEntity(
val userId: String,
@ColumnInfo(name = "domains_json")
val domainsJson: String,
val domainsJson: String?,
)

View File

@@ -1,8 +1,10 @@
package com.x8bit.bitwarden.data.vault.datasource.network.model
import kotlinx.serialization.Contextual
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.JsonNames
import kotlinx.serialization.json.JsonObject
import java.time.ZonedDateTime
@@ -21,6 +23,7 @@ private const val DEFAULT_FIDO_2_KEY_CURVE = "P-256"
* @property domains A domains object associated with the vault data.
* @property sends A list of send objects associated with the vault data (nullable).
*/
@OptIn(ExperimentalSerializationApi::class)
@Serializable
data class SyncResponseJson(
@SerialName("folders")
@@ -30,6 +33,7 @@ data class SyncResponseJson(
val collections: List<Collection>?,
@SerialName("profile")
@JsonNames("Profile")
val profile: Profile,
@SerialName("ciphers")
@@ -39,7 +43,8 @@ data class SyncResponseJson(
val policies: List<Policy>?,
@SerialName("domains")
val domains: Domains,
@JsonNames("Domains")
val domains: Domains?,
@SerialName("sends")
val sends: List<Send>?,

View File

@@ -36,6 +36,7 @@ import com.x8bit.bitwarden.data.platform.repository.util.combineDataStates
import com.x8bit.bitwarden.data.platform.repository.util.map
import com.x8bit.bitwarden.data.platform.repository.util.mapNullable
import com.x8bit.bitwarden.data.platform.repository.util.observeWhenSubscribedAndLoggedIn
import com.x8bit.bitwarden.data.platform.repository.util.observeWhenSubscribedAndUnlocked
import com.x8bit.bitwarden.data.platform.repository.util.updateToPendingOrLoading
import com.x8bit.bitwarden.data.platform.util.asFailure
import com.x8bit.bitwarden.data.platform.util.asSuccess
@@ -222,7 +223,13 @@ class VaultRepositoryImpl(
// Cancel any ongoing sync request and clear the vault data in memory every time
// the user switches or the vault is locked for the active user.
merge(
authDiskSource.userSwitchingChangesFlow,
authDiskSource
.userSwitchingChangesFlow
.onEach {
// DomainState is not part of the locked data but should still be cleared
// when the user changes
mutableDomainsStateFlow.update { DataState.Loading }
},
vaultLockManager
.vaultUnlockDataStateFlow
.filter { vaultUnlockDataList ->
@@ -238,7 +245,10 @@ class VaultRepositoryImpl(
// Setup ciphers MutableStateFlow
mutableCiphersStateFlow
.observeWhenSubscribedAndLoggedIn(authDiskSource.userStateFlow) { activeUserId ->
.observeWhenSubscribedAndUnlocked(
userStateFlow = authDiskSource.userStateFlow,
vaultUnlockFlow = vaultUnlockDataStateFlow,
) { activeUserId ->
observeVaultDiskCiphers(activeUserId)
}
.launchIn(unconfinedScope)
@@ -250,19 +260,28 @@ class VaultRepositoryImpl(
.launchIn(unconfinedScope)
// Setup folders MutableStateFlow
mutableFoldersStateFlow
.observeWhenSubscribedAndLoggedIn(authDiskSource.userStateFlow) { activeUserId ->
.observeWhenSubscribedAndUnlocked(
userStateFlow = authDiskSource.userStateFlow,
vaultUnlockFlow = vaultUnlockDataStateFlow,
) { activeUserId ->
observeVaultDiskFolders(activeUserId)
}
.launchIn(unconfinedScope)
// Setup collections MutableStateFlow
mutableCollectionsStateFlow
.observeWhenSubscribedAndLoggedIn(authDiskSource.userStateFlow) { activeUserId ->
.observeWhenSubscribedAndUnlocked(
userStateFlow = authDiskSource.userStateFlow,
vaultUnlockFlow = vaultUnlockDataStateFlow,
) { activeUserId ->
observeVaultDiskCollections(activeUserId)
}
.launchIn(unconfinedScope)
// Setup sends MutableStateFlow
mutableSendDataStateFlow
.observeWhenSubscribedAndLoggedIn(authDiskSource.userStateFlow) { activeUserId ->
.observeWhenSubscribedAndUnlocked(
userStateFlow = authDiskSource.userStateFlow,
vaultUnlockFlow = vaultUnlockDataStateFlow,
) { activeUserId ->
observeVaultDiskSends(activeUserId)
}
.launchIn(unconfinedScope)
@@ -305,7 +324,6 @@ class VaultRepositoryImpl(
private fun clearUnlockedData() {
mutableCiphersStateFlow.update { DataState.Loading }
mutableDomainsStateFlow.update { DataState.Loading }
mutableFoldersStateFlow.update { DataState.Loading }
mutableCollectionsStateFlow.update { DataState.Loading }
mutableSendDataStateFlow.update { DataState.Loading }

View File

@@ -6,14 +6,14 @@ import com.x8bit.bitwarden.data.vault.repository.model.DomainsData
/**
* Map the API [Domains] model to the internal [DomainsData] model.
*/
fun Domains.toDomainsData(): DomainsData {
fun Domains?.toDomainsData(): DomainsData {
val globalEquivalentDomains = this
.globalEquivalentDomains
?.globalEquivalentDomains
?.map { it.toInternalModel() }
.orEmpty()
return DomainsData(
equivalentDomains = this.equivalentDomains.orEmpty(),
equivalentDomains = this?.equivalentDomains.orEmpty(),
globalEquivalentDomains = globalEquivalentDomains,
)
}

View File

@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="utf-8" ?>
<accessibility-service xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:accessibilityEventTypes="typeWindowStateChanged|typeWindowContentChanged"
android:accessibilityEventTypes="typeWindowStateChanged"
android:accessibilityFeedbackType="feedbackGeneric"
android:accessibilityFlags="flagReportViewIds|flagRetrieveInteractiveWindows"
android:canRetrieveWindowContent="true"

View File

@@ -30,7 +30,7 @@ class LogsManagerImpl(
}
if (value) {
Timber.plant(nonfatalErrorTree)
} else {
} else if (Timber.forest().contains(nonfatalErrorTree)) {
Timber.uproot(nonfatalErrorTree)
}
}

View File

@@ -2,6 +2,7 @@ package com.x8bit.bitwarden.data.platform.repository.util
import app.cash.turbine.test
import com.x8bit.bitwarden.data.auth.datasource.disk.model.UserStateJson
import com.x8bit.bitwarden.data.vault.repository.model.VaultUnlockData
import io.mockk.every
import io.mockk.mockk
import kotlinx.coroutines.flow.MutableStateFlow
@@ -50,6 +51,74 @@ class StateFlowExtensionsTest {
assertEquals(0, awaitItem())
assertEquals(1, awaitItem())
job.cancel()
// Job is canceled, we should have no more subscribers
assertEquals(0, awaitItem())
}
}
@Suppress("MaxLineLength")
@Test
fun `observeWhenSubscribedAndUnlocked should observe the given flow depending on the state of the source user and vault unlock flow`() =
runTest {
val userStateFlow = MutableStateFlow<UserStateJson?>(null)
val vaultUnlockFlow = MutableStateFlow<List<VaultUnlockData>>(emptyList())
val observerStateFlow = MutableStateFlow("")
val sourceMutableStateFlow = MutableStateFlow(Unit)
assertEquals(0, observerStateFlow.subscriptionCount.value)
sourceMutableStateFlow
.observeWhenSubscribedAndUnlocked(
userStateFlow = userStateFlow,
vaultUnlockFlow = vaultUnlockFlow,
observer = { observerStateFlow },
)
.launchIn(backgroundScope)
observerStateFlow.subscriptionCount.test {
// No subscriber to start
assertEquals(0, awaitItem())
userStateFlow.value = mockk<UserStateJson> {
every { activeUserId } returns "user_id_1234"
}
// Still none, since no one has subscribed to the testMutableStateFlow
expectNoEvents()
vaultUnlockFlow.value = listOf(
VaultUnlockData(
userId = "user_id_1234",
status = VaultUnlockData.Status.UNLOCKED,
),
)
// Still none, since no one has subscribed to the testMutableStateFlow
expectNoEvents()
val job = sourceMutableStateFlow.launchIn(backgroundScope)
// Now we subscribe to the observer flow since have a active user and a listener
assertEquals(1, awaitItem())
userStateFlow.value = mockk<UserStateJson> {
every { activeUserId } returns "user_id_4321"
}
// The user changed, so we clear the previous observer but then resubscribe
// with the new user ID
assertEquals(0, awaitItem())
assertEquals(1, awaitItem())
vaultUnlockFlow.value = listOf(
VaultUnlockData(
userId = "user_id_4321",
status = VaultUnlockData.Status.UNLOCKED,
),
)
// The VaultUnlockData changed, so we clear the previous observer but then resubscribe
// with the new data
assertEquals(0, awaitItem())
assertEquals(1, awaitItem())
job.cancel()
// Job is canceled, we should have no more subscribers
assertEquals(0, awaitItem())

View File

@@ -248,10 +248,13 @@ class VaultDiskSourceTest {
// We cannot compare the JSON strings directly because of formatting differences
// So we split that off into its own assertion.
assertEquals(
DOMAINS_ENTITY.copy(domainsJson = ""),
storedDomainsEntity.copy(domainsJson = ""),
DOMAINS_ENTITY.copy(domainsJson = null),
storedDomainsEntity.copy(domainsJson = null),
)
assertJsonEquals(
requireNotNull(DOMAINS_ENTITY.domainsJson),
requireNotNull(storedDomainsEntity.domainsJson),
)
assertJsonEquals(DOMAINS_ENTITY.domainsJson, storedDomainsEntity.domainsJson)
// Verify the folders dao is updated
assertEquals(listOf(FOLDER_ENTITY), foldersDao.storedFolders)

View File

@@ -3,7 +3,6 @@ package com.x8bit.bitwarden.data.vault.datasource.disk.dao
import com.x8bit.bitwarden.data.platform.repository.util.bufferedMutableSharedFlow
import com.x8bit.bitwarden.data.vault.datasource.disk.entity.DomainsEntity
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.filterNotNull
class FakeDomainsDao : DomainsDao {
var storedDomains: DomainsEntity? = null
@@ -18,9 +17,9 @@ class FakeDomainsDao : DomainsDao {
deleteDomainsCalled = true
}
override fun getDomains(userId: String): Flow<DomainsEntity> {
override fun getDomains(userId: String): Flow<DomainsEntity?> {
getDomainsCalled = true
return mutableDomainsFlow.filterNotNull()
return mutableDomainsFlow
}
override suspend fun insertDomains(domains: DomainsEntity) {

View File

@@ -343,6 +343,12 @@ class VaultRepositoryTest {
)
setVaultToUnlocked(userId = userId)
ciphersFlow.tryEmit(listOf(createMockCipher(number = 1)))
collectionsFlow.tryEmit(listOf(createMockCollection(number = 1)))
foldersFlow.tryEmit(listOf(createMockFolder(number = 1)))
sendsFlow.tryEmit(listOf(createMockSend(number = 1)))
domainsFlow.tryEmit(createMockDomains(number = 1))
assertEquals(
DataState.Loaded(listOf(createMockCipherView(number = 1))),
ciphersStateFlow.awaitItem(),
@@ -484,7 +490,8 @@ class VaultRepositoryTest {
assertEquals(DataState.Loading, collectionsStateFlow.awaitItem())
assertEquals(DataState.Loading, foldersStateFlow.awaitItem())
assertEquals(DataState.Loading, sendsStateFlow.awaitItem())
assertEquals(DataState.Loading, domainsStateFlow.awaitItem())
// We already have the domain data
domainsStateFlow.expectNoEvents()
}
}
@@ -1804,6 +1811,9 @@ class VaultRepositoryTest {
settingsDiskSource.getLastSyncTime(userId = userId)
} returns clock.instant()
mutableVaultStateFlow.update {
listOf(VaultUnlockData(userId, VaultUnlockData.Status.UNLOCKED))
}
fakeAuthDiskSource.userState = MOCK_USER_STATE
setupEmptyDecryptionResults()
setupVaultDiskSourceFlows(
@@ -1960,6 +1970,7 @@ class VaultRepositoryTest {
expectNoEvents()
setVaultToUnlocked(userId = MOCK_USER_STATE.activeUserId)
sendsFlow.tryEmit(emptyList())
assertEquals(DataState.Loaded<SendView?>(null), awaitItem())
sendsFlow.tryEmit(listOf(createMockSend(number = sendId)))
assertEquals(DataState.Loaded<SendView?>(sendView), awaitItem())
@@ -4544,6 +4555,14 @@ class VaultRepositoryTest {
*/
private fun setVaultToUnlocked(userId: String) {
mutableUnlockedUserIdsStateFlow.update { it + userId }
mutableVaultStateFlow.tryEmit(
listOf(
VaultUnlockData(
userId,
VaultUnlockData.Status.UNLOCKED,
),
),
)
}
/**

View File

@@ -237,6 +237,17 @@ platform :android do
)
end
desc "Publish Play Store Beta bundle to Google Play Store"
lane :publishProdToPlayStore do
upload_to_play_store(
package_name: "com.x8bit.bitwarden",
track: "internal",
release_status: "completed",
rollout: "1",
aab: "app/build/outputs/bundle/standardRelease/com.x8bit.bitwarden-standard-release.aab",
)
end
desc "Generate release notes"
lane :generateReleaseNotes do |options|
branchName = `git rev-parse --abbrev-ref HEAD`.chomp()