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 - name: Publish Play Store bundle
if: ${{ matrix.variant == 'prod' && matrix.artifact == 'aab' && (inputs.publish-to-play-store || github.ref_name == 'main') }} 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: publish_fdroid:
name: Publish F-Droid artifacts name: Publish F-Droid artifacts

View File

@@ -6,6 +6,10 @@
# we keep it here. # we keep it here.
-keep class com.bitwarden.** { *; } -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 # 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) { override fun onAccessibilityEvent(event: AccessibilityEvent) {
if (rootInActiveWindow?.packageName != event.packageName) return if (rootInActiveWindow?.packageName != event.packageName) return
processor.processAccessibilityEvent(rootAccessibilityNodeInfo = rootInActiveWindow) processor.processAccessibilityEvent(rootAccessibilityNodeInfo = event.source)
} }
override fun onInterrupt() = Unit override fun onInterrupt() = Unit

View File

@@ -1,12 +1,14 @@
package com.x8bit.bitwarden.data.platform.repository.util package com.x8bit.bitwarden.data.platform.repository.util
import com.x8bit.bitwarden.data.auth.datasource.disk.model.UserStateJson 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.ExperimentalCoroutinesApi
import kotlinx.coroutines.awaitCancellation import kotlinx.coroutines.awaitCancellation
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
@@ -31,3 +33,34 @@ fun <T, R> MutableStateFlow<T>.observeWhenSubscribedAndLoggedIn(
.flatMapLatest { activeUserId -> .flatMapLatest { activeUserId ->
activeUserId?.let(observer) ?: flow { awaitCancellation() } 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.onStart
import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.time.Clock import java.time.Clock
import javax.inject.Singleton import javax.inject.Singleton
@@ -190,7 +191,7 @@ class GeneratorRepositoryImpl(
override suspend fun generateForwardedServiceUsername( override suspend fun generateForwardedServiceUsername(
forwardedServiceGeneratorRequest: UsernameGeneratorRequest.Forwarded, forwardedServiceGeneratorRequest: UsernameGeneratorRequest.Forwarded,
): GeneratedForwardedServiceUsernameResult = ): GeneratedForwardedServiceUsernameResult = withContext(scope.coroutineContext) {
generatorSdkSource.generateForwardedServiceEmail(forwardedServiceGeneratorRequest) generatorSdkSource.generateForwardedServiceEmail(forwardedServiceGeneratorRequest)
.fold( .fold(
onSuccess = { generatedEmail -> onSuccess = { generatedEmail ->
@@ -200,6 +201,7 @@ class GeneratorRepositoryImpl(
GeneratedForwardedServiceUsernameResult.InvalidRequest(it.message) GeneratedForwardedServiceUsernameResult.InvalidRequest(it.message)
}, },
) )
}
override fun getPasscodeGenerationOptions(): PasscodeGenerationOptions? { override fun getPasscodeGenerationOptions(): PasscodeGenerationOptions? {
val userId = authDiskSource.userState?.activeUserId val userId = authDiskSource.userState?.activeUserId

View File

@@ -37,7 +37,7 @@ interface VaultDiskSource {
/** /**
* Retrieves all domains from the data source for a given [userId]. * 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]. * 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 domainsDao
.getDomains(userId) .getDomains(userId)
.map { entity -> .map { entity ->
withContext(dispatcherManager.default) { 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( domainsDao.insertDomains(
domains = DomainsEntity( domains = DomainsEntity(
userId = userId, 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") @Query("SELECT * FROM domains WHERE user_id = :userId")
fun getDomains( fun getDomains(
userId: String, userId: String,
): Flow<DomainsEntity> ): Flow<DomainsEntity?>
/** /**
* Inserts domains into the database. * Inserts domains into the database.

View File

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

View File

@@ -14,5 +14,5 @@ data class DomainsEntity(
val userId: String, val userId: String,
@ColumnInfo(name = "domains_json") @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 package com.x8bit.bitwarden.data.vault.datasource.network.model
import kotlinx.serialization.Contextual import kotlinx.serialization.Contextual
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.SerialName import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import kotlinx.serialization.json.JsonNames
import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.JsonObject
import java.time.ZonedDateTime 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 domains A domains object associated with the vault data.
* @property sends A list of send objects associated with the vault data (nullable). * @property sends A list of send objects associated with the vault data (nullable).
*/ */
@OptIn(ExperimentalSerializationApi::class)
@Serializable @Serializable
data class SyncResponseJson( data class SyncResponseJson(
@SerialName("folders") @SerialName("folders")
@@ -30,6 +33,7 @@ data class SyncResponseJson(
val collections: List<Collection>?, val collections: List<Collection>?,
@SerialName("profile") @SerialName("profile")
@JsonNames("Profile")
val profile: Profile, val profile: Profile,
@SerialName("ciphers") @SerialName("ciphers")
@@ -39,7 +43,8 @@ data class SyncResponseJson(
val policies: List<Policy>?, val policies: List<Policy>?,
@SerialName("domains") @SerialName("domains")
val domains: Domains, @JsonNames("Domains")
val domains: Domains?,
@SerialName("sends") @SerialName("sends")
val sends: List<Send>?, 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.map
import com.x8bit.bitwarden.data.platform.repository.util.mapNullable 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.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.repository.util.updateToPendingOrLoading
import com.x8bit.bitwarden.data.platform.util.asFailure import com.x8bit.bitwarden.data.platform.util.asFailure
import com.x8bit.bitwarden.data.platform.util.asSuccess 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 // 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. // the user switches or the vault is locked for the active user.
merge( 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 vaultLockManager
.vaultUnlockDataStateFlow .vaultUnlockDataStateFlow
.filter { vaultUnlockDataList -> .filter { vaultUnlockDataList ->
@@ -238,7 +245,10 @@ class VaultRepositoryImpl(
// Setup ciphers MutableStateFlow // Setup ciphers MutableStateFlow
mutableCiphersStateFlow mutableCiphersStateFlow
.observeWhenSubscribedAndLoggedIn(authDiskSource.userStateFlow) { activeUserId -> .observeWhenSubscribedAndUnlocked(
userStateFlow = authDiskSource.userStateFlow,
vaultUnlockFlow = vaultUnlockDataStateFlow,
) { activeUserId ->
observeVaultDiskCiphers(activeUserId) observeVaultDiskCiphers(activeUserId)
} }
.launchIn(unconfinedScope) .launchIn(unconfinedScope)
@@ -250,19 +260,28 @@ class VaultRepositoryImpl(
.launchIn(unconfinedScope) .launchIn(unconfinedScope)
// Setup folders MutableStateFlow // Setup folders MutableStateFlow
mutableFoldersStateFlow mutableFoldersStateFlow
.observeWhenSubscribedAndLoggedIn(authDiskSource.userStateFlow) { activeUserId -> .observeWhenSubscribedAndUnlocked(
userStateFlow = authDiskSource.userStateFlow,
vaultUnlockFlow = vaultUnlockDataStateFlow,
) { activeUserId ->
observeVaultDiskFolders(activeUserId) observeVaultDiskFolders(activeUserId)
} }
.launchIn(unconfinedScope) .launchIn(unconfinedScope)
// Setup collections MutableStateFlow // Setup collections MutableStateFlow
mutableCollectionsStateFlow mutableCollectionsStateFlow
.observeWhenSubscribedAndLoggedIn(authDiskSource.userStateFlow) { activeUserId -> .observeWhenSubscribedAndUnlocked(
userStateFlow = authDiskSource.userStateFlow,
vaultUnlockFlow = vaultUnlockDataStateFlow,
) { activeUserId ->
observeVaultDiskCollections(activeUserId) observeVaultDiskCollections(activeUserId)
} }
.launchIn(unconfinedScope) .launchIn(unconfinedScope)
// Setup sends MutableStateFlow // Setup sends MutableStateFlow
mutableSendDataStateFlow mutableSendDataStateFlow
.observeWhenSubscribedAndLoggedIn(authDiskSource.userStateFlow) { activeUserId -> .observeWhenSubscribedAndUnlocked(
userStateFlow = authDiskSource.userStateFlow,
vaultUnlockFlow = vaultUnlockDataStateFlow,
) { activeUserId ->
observeVaultDiskSends(activeUserId) observeVaultDiskSends(activeUserId)
} }
.launchIn(unconfinedScope) .launchIn(unconfinedScope)
@@ -305,7 +324,6 @@ class VaultRepositoryImpl(
private fun clearUnlockedData() { private fun clearUnlockedData() {
mutableCiphersStateFlow.update { DataState.Loading } mutableCiphersStateFlow.update { DataState.Loading }
mutableDomainsStateFlow.update { DataState.Loading }
mutableFoldersStateFlow.update { DataState.Loading } mutableFoldersStateFlow.update { DataState.Loading }
mutableCollectionsStateFlow.update { DataState.Loading } mutableCollectionsStateFlow.update { DataState.Loading }
mutableSendDataStateFlow.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. * Map the API [Domains] model to the internal [DomainsData] model.
*/ */
fun Domains.toDomainsData(): DomainsData { fun Domains?.toDomainsData(): DomainsData {
val globalEquivalentDomains = this val globalEquivalentDomains = this
.globalEquivalentDomains ?.globalEquivalentDomains
?.map { it.toInternalModel() } ?.map { it.toInternalModel() }
.orEmpty() .orEmpty()
return DomainsData( return DomainsData(
equivalentDomains = this.equivalentDomains.orEmpty(), equivalentDomains = this?.equivalentDomains.orEmpty(),
globalEquivalentDomains = globalEquivalentDomains, globalEquivalentDomains = globalEquivalentDomains,
) )
} }

View File

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

View File

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

View File

@@ -2,6 +2,7 @@ package com.x8bit.bitwarden.data.platform.repository.util
import app.cash.turbine.test import app.cash.turbine.test
import com.x8bit.bitwarden.data.auth.datasource.disk.model.UserStateJson 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.every
import io.mockk.mockk import io.mockk.mockk
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
@@ -50,6 +51,74 @@ class StateFlowExtensionsTest {
assertEquals(0, awaitItem()) assertEquals(0, awaitItem())
assertEquals(1, 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.cancel()
// Job is canceled, we should have no more subscribers // Job is canceled, we should have no more subscribers
assertEquals(0, awaitItem()) assertEquals(0, awaitItem())

View File

@@ -248,10 +248,13 @@ class VaultDiskSourceTest {
// We cannot compare the JSON strings directly because of formatting differences // We cannot compare the JSON strings directly because of formatting differences
// So we split that off into its own assertion. // So we split that off into its own assertion.
assertEquals( assertEquals(
DOMAINS_ENTITY.copy(domainsJson = ""), DOMAINS_ENTITY.copy(domainsJson = null),
storedDomainsEntity.copy(domainsJson = ""), storedDomainsEntity.copy(domainsJson = null),
)
assertJsonEquals(
requireNotNull(DOMAINS_ENTITY.domainsJson),
requireNotNull(storedDomainsEntity.domainsJson),
) )
assertJsonEquals(DOMAINS_ENTITY.domainsJson, storedDomainsEntity.domainsJson)
// Verify the folders dao is updated // Verify the folders dao is updated
assertEquals(listOf(FOLDER_ENTITY), foldersDao.storedFolders) 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.platform.repository.util.bufferedMutableSharedFlow
import com.x8bit.bitwarden.data.vault.datasource.disk.entity.DomainsEntity import com.x8bit.bitwarden.data.vault.datasource.disk.entity.DomainsEntity
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.filterNotNull
class FakeDomainsDao : DomainsDao { class FakeDomainsDao : DomainsDao {
var storedDomains: DomainsEntity? = null var storedDomains: DomainsEntity? = null
@@ -18,9 +17,9 @@ class FakeDomainsDao : DomainsDao {
deleteDomainsCalled = true deleteDomainsCalled = true
} }
override fun getDomains(userId: String): Flow<DomainsEntity> { override fun getDomains(userId: String): Flow<DomainsEntity?> {
getDomainsCalled = true getDomainsCalled = true
return mutableDomainsFlow.filterNotNull() return mutableDomainsFlow
} }
override suspend fun insertDomains(domains: DomainsEntity) { override suspend fun insertDomains(domains: DomainsEntity) {

View File

@@ -343,6 +343,12 @@ class VaultRepositoryTest {
) )
setVaultToUnlocked(userId = userId) 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( assertEquals(
DataState.Loaded(listOf(createMockCipherView(number = 1))), DataState.Loaded(listOf(createMockCipherView(number = 1))),
ciphersStateFlow.awaitItem(), ciphersStateFlow.awaitItem(),
@@ -484,7 +490,8 @@ class VaultRepositoryTest {
assertEquals(DataState.Loading, collectionsStateFlow.awaitItem()) assertEquals(DataState.Loading, collectionsStateFlow.awaitItem())
assertEquals(DataState.Loading, foldersStateFlow.awaitItem()) assertEquals(DataState.Loading, foldersStateFlow.awaitItem())
assertEquals(DataState.Loading, sendsStateFlow.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) settingsDiskSource.getLastSyncTime(userId = userId)
} returns clock.instant() } returns clock.instant()
mutableVaultStateFlow.update {
listOf(VaultUnlockData(userId, VaultUnlockData.Status.UNLOCKED))
}
fakeAuthDiskSource.userState = MOCK_USER_STATE fakeAuthDiskSource.userState = MOCK_USER_STATE
setupEmptyDecryptionResults() setupEmptyDecryptionResults()
setupVaultDiskSourceFlows( setupVaultDiskSourceFlows(
@@ -1960,6 +1970,7 @@ class VaultRepositoryTest {
expectNoEvents() expectNoEvents()
setVaultToUnlocked(userId = MOCK_USER_STATE.activeUserId) setVaultToUnlocked(userId = MOCK_USER_STATE.activeUserId)
sendsFlow.tryEmit(emptyList())
assertEquals(DataState.Loaded<SendView?>(null), awaitItem()) assertEquals(DataState.Loaded<SendView?>(null), awaitItem())
sendsFlow.tryEmit(listOf(createMockSend(number = sendId))) sendsFlow.tryEmit(listOf(createMockSend(number = sendId)))
assertEquals(DataState.Loaded<SendView?>(sendView), awaitItem()) assertEquals(DataState.Loaded<SendView?>(sendView), awaitItem())
@@ -4544,6 +4555,14 @@ class VaultRepositoryTest {
*/ */
private fun setVaultToUnlocked(userId: String) { private fun setVaultToUnlocked(userId: String) {
mutableUnlockedUserIdsStateFlow.update { it + userId } mutableUnlockedUserIdsStateFlow.update { it + userId }
mutableVaultStateFlow.tryEmit(
listOf(
VaultUnlockData(
userId,
VaultUnlockData.Status.UNLOCKED,
),
),
)
} }
/** /**

View File

@@ -237,6 +237,17 @@ platform :android do
) )
end 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" desc "Generate release notes"
lane :generateReleaseNotes do |options| lane :generateReleaseNotes do |options|
branchName = `git rev-parse --abbrev-ref HEAD`.chomp() branchName = `git rev-parse --abbrev-ref HEAD`.chomp()