Compare commits

...

27 Commits

Author SHA1 Message Date
Hinton
30531a40d3 Add toast 2024-10-24 15:41:44 -07:00
Matt Gibson
d4d980b576 Do not overlay offline ciphers with merge conflicts 2024-10-24 15:22:51 -07:00
Hinton
ee61c83409 Add support for conflicts 2024-10-24 15:21:18 -07:00
Matt Gibson
b30e52245f overlay offline edits with existing 2024-10-24 15:08:15 -07:00
Hinton
d7b935031e Fix create id 2024-10-24 14:54:23 -07:00
Hinton
e44cfe16d5 Fix 2024-10-24 14:29:30 -07:00
Hinton
2e0f07e138 Maybe working 2024-10-24 14:20:45 -07:00
Matt Gibson
028d382a5b wip 2024-10-24 12:55:13 -07:00
Hinton
425b085a96 Fix build 2024-10-24 11:27:30 -07:00
Matt Gibson
cee686f92e wip 2024-10-24 11:20:49 -07:00
Hinton
5c62290a3d Add flow support to NetworkConnectionManager 2024-10-24 11:18:56 -07:00
Hinton
a325e92184 tmp 2024-10-24 08:01:49 -07:00
Hinton
3dce7838b5 Fix compile 2024-10-23 09:30:04 -07:00
Hinton
bc6c21463b Merge branches 'poc/offline-editing' and 'poc/offline-editing' of github.com:bitwarden/android into poc/offline-editing 2024-10-23 09:09:18 -07:00
Matt Gibson
991c82b277 wip 2024-10-23 09:09:06 -07:00
Hinton
dc7fca0311 Fix scroll 2024-10-23 09:09:04 -07:00
Hinton
c88157fbce Merge branch 'poc/offline-editing' of github.com:bitwarden/android into poc/offline-editing 2024-10-22 14:50:21 -07:00
Hinton
3bed0f0804 Wire up button 2024-10-22 14:50:15 -07:00
Matt Gibson
cf605ceb4b wip 2024-10-22 14:44:19 -07:00
Matt Gibson
b0194028a8 wip 2024-10-22 14:43:29 -07:00
Matt Gibson
53c0c7d2ad wip 2024-10-22 14:33:42 -07:00
Matt Gibson
563427c99f wip 2024-10-22 13:50:42 -07:00
Matt Gibson
810fd2f428 wip 2024-10-22 13:31:23 -07:00
Matt Gibson
89dceb2485 wip 2024-10-21 19:17:25 -07:00
Hinton
36c07bdad7 Fix migration 2024-10-21 16:05:54 -07:00
Hinton
9cb8f414ba Introduce OfflineCiphersDao/Entity 2024-10-21 15:40:08 -07:00
Matt Gibson
c7d7ed6cb9 initial scaffolding 2024-10-21 14:50:52 -07:00
35 changed files with 1686 additions and 27 deletions

View File

@@ -247,4 +247,4 @@
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '64bb48a6bc6a544d168b0b4f4862cbcd')"
]
}
}
}

View File

@@ -0,0 +1,298 @@
{
"formatVersion": 1,
"database": {
"version": 4,
"identityHash": "67b7550b79460fd815162804f4a00c2e",
"entities": [
{
"tableName": "offline_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_offline_ciphers_user_id",
"unique": false,
"columnNames": [
"user_id"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_offline_ciphers_user_id` ON `${TABLE_NAME}` (`user_id`)"
}
],
"foreignKeys": []
},
{
"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 NOT NULL, PRIMARY KEY(`user_id`))",
"fields": [
{
"fieldPath": "userId",
"columnName": "user_id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "domainsJson",
"columnName": "domains_json",
"affinity": "TEXT",
"notNull": true
}
],
"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, '67b7550b79460fd815162804f4a00c2e')"
]
}
}

View File

@@ -1,5 +1,7 @@
package com.x8bit.bitwarden.data.platform.manager
import kotlinx.coroutines.flow.StateFlow
/**
* Manager to detect and handle changes to network connectivity.
*/
@@ -9,4 +11,6 @@ interface NetworkConnectionManager {
* available.
*/
val isNetworkConnected: Boolean
val isNetworkConnectedFlow: StateFlow<Boolean>
}

View File

@@ -2,13 +2,21 @@ package com.x8bit.bitwarden.data.platform.manager
import android.content.Context
import android.net.ConnectivityManager
import android.net.Network
import android.net.NetworkCapabilities
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.stateIn
/**
* Primary implementation of [NetworkConnectionManager].
*/
class NetworkConnectionManagerImpl(
context: Context,
private val externalScope: CoroutineScope
) : NetworkConnectionManager {
private val connectivityManager: ConnectivityManager = context
.applicationContext
@@ -19,4 +27,31 @@ class NetworkConnectionManagerImpl(
.getNetworkCapabilities(connectivityManager.activeNetwork)
?.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
?: false
override val isNetworkConnectedFlow: StateFlow<Boolean>
get() = _connectedFlow
.stateIn(
scope = externalScope,
started = SharingStarted.WhileSubscribed(5000),
initialValue = isNetworkConnected
)
private val _connectedFlow = callbackFlow {
val networkCallback = object : ConnectivityManager.NetworkCallback() {
override fun onLost(network : Network) {
trySend(false)
}
override fun onCapabilitiesChanged(network : Network, networkCapabilities : NetworkCapabilities) {
if (networkCapabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
&& networkCapabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED)) {
trySend(true)
}
}
}
connectivityManager.registerDefaultNetworkCallback(networkCallback)
awaitClose {
connectivityManager.unregisterNetworkCallback(networkCallback)
}
}
}

View File

@@ -64,6 +64,7 @@ import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import kotlinx.coroutines.CoroutineScope
import kotlinx.serialization.json.Json
import java.time.Clock
import javax.inject.Singleton
@@ -206,8 +207,10 @@ object PlatformManagerModule {
@Singleton
fun provideNetworkConnectionManager(
application: Application,
dispatcherManager: DispatcherManager,
): NetworkConnectionManager = NetworkConnectionManagerImpl(
context = application.applicationContext,
externalScope = CoroutineScope(dispatcherManager.main)
)
@Provides

View File

@@ -164,6 +164,34 @@ fun <T1, T2, T3, T4, R> combineDataStates(
transform(t1t2t3Triple.first, t1t2t3Triple.second, t1t2t3Triple.third, t3)
}
/**
* Combines the [dataState1], [dataState2], [dataState3], and [dataState4] [DataState]s together
* using the provided [transform].
*
* See [combineDataStates] for details.
*
* I'm not proud of this...
*/
@OmitFromCoverage
fun <T1, T2, T3, T4, T5, R> combineDataStates(
dataState1: DataState<T1>,
dataState2: DataState<T2>,
dataState3: DataState<T3>,
dataState4: DataState<T4>,
dataState5: DataState<T5>,
transform: (t1: T1, t2: T2, t3: T3, t4: T4, t5: T5) -> R,
): DataState<R> =
dataState1
.combineDataStatesWith(dataState2) { t1, t2 -> t1 to t2 }
.combineDataStatesWith(dataState3) { t1t2Pair, t3 ->
Triple(t1t2Pair.first, t1t2Pair.second, t3)
}
.combineDataStatesWith(dataState4) { t1t2t3Triple, t4 -> t1t2t3Triple to t4 }
.combineDataStatesWith(dataState5) { t1t2t3Triplet4Pair, t5 ->
transform(t1t2t3Triplet4Pair.first.first, t1t2t3Triplet4Pair.first.second, t1t2t3Triplet4Pair.first.third, t1t2t3Triplet4Pair.second, t5)
}
/**
* Combines [dataState2] with the given [DataState] using the provided [transform].
*

View File

@@ -1,6 +1,9 @@
package com.x8bit.bitwarden.data.vault.datasource.disk
import com.bitwarden.vault.Cipher
import com.x8bit.bitwarden.data.vault.datasource.network.model.OfflineCipherJson
import com.x8bit.bitwarden.data.vault.datasource.network.model.SyncResponseJson
import com.x8bit.bitwarden.data.vault.datasource.sdk.model.OfflineCipher
import kotlinx.coroutines.flow.Flow
/**
@@ -9,11 +12,31 @@ import kotlinx.coroutines.flow.Flow
@Suppress("TooManyFunctions")
interface VaultDiskSource {
/**
* Saves a cipher to the offline data source for the given [userId].
*/
suspend fun saveOfflineCipher(userId: String, cipher: Cipher)
/**
* Saves an offline cipher to the offline data source for the given [userId].
*/
suspend fun updateOfflineCipher(userId: String, cipher: OfflineCipher)
/**
* Saves a cipher to the data source for the given [userId].
*/
suspend fun saveCipher(userId: String, cipher: SyncResponseJson.Cipher)
/**
* Retrieves all ciphers from the offline cache for a given [userId]
*/
fun getOfflineCiphers(userId: String): Flow<List<OfflineCipherJson>>
/**
* Deletes an offline cipher from the data source for the given [userId] and [cipherId].
*/
suspend fun deleteOfflineCipher(userId: String, cipherId: String)
/**
* Retrieves all ciphers from the data source for a given [userId].
*/

View File

@@ -1,22 +1,31 @@
package com.x8bit.bitwarden.data.vault.datasource.disk
import com.bitwarden.vault.Cipher
import com.x8bit.bitwarden.data.platform.manager.dispatcher.DispatcherManager
import com.x8bit.bitwarden.data.platform.repository.util.bufferedMutableSharedFlow
import com.x8bit.bitwarden.data.vault.datasource.disk.dao.CiphersDao
import com.x8bit.bitwarden.data.vault.datasource.disk.dao.CollectionsDao
import com.x8bit.bitwarden.data.vault.datasource.disk.dao.DomainsDao
import com.x8bit.bitwarden.data.vault.datasource.disk.dao.FoldersDao
import com.x8bit.bitwarden.data.vault.datasource.disk.dao.OfflineCiphersDao
import com.x8bit.bitwarden.data.vault.datasource.disk.dao.SendsDao
import com.x8bit.bitwarden.data.vault.datasource.disk.entity.CipherEntity
import com.x8bit.bitwarden.data.vault.datasource.disk.entity.CollectionEntity
import com.x8bit.bitwarden.data.vault.datasource.disk.entity.DomainsEntity
import com.x8bit.bitwarden.data.vault.datasource.disk.entity.FolderEntity
import com.x8bit.bitwarden.data.vault.datasource.disk.entity.OfflineCipherEntity
import com.x8bit.bitwarden.data.vault.datasource.disk.entity.SendEntity
import com.x8bit.bitwarden.data.vault.datasource.network.model.OfflineCipherJson
import com.x8bit.bitwarden.data.vault.datasource.network.model.SyncResponseJson
import com.x8bit.bitwarden.data.vault.datasource.sdk.model.OfflineCipher
import com.x8bit.bitwarden.data.vault.repository.util.toOfflineCipher
import com.x8bit.bitwarden.data.vault.repository.util.toOfflineCipherJson
import com.x8bit.bitwarden.data.vault.repository.util.toSdkCipherJson
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.merge
@@ -24,12 +33,14 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import java.util.UUID
/**
* Default implementation of [VaultDiskSource].
*/
@Suppress("TooManyFunctions", "LongParameterList")
class VaultDiskSourceImpl(
private val offlineCiphersDao: OfflineCiphersDao,
private val ciphersDao: CiphersDao,
private val collectionsDao: CollectionsDao,
private val domainsDao: DomainsDao,
@@ -39,12 +50,44 @@ class VaultDiskSourceImpl(
private val dispatcherManager: DispatcherManager,
) : VaultDiskSource {
private val forceOfflineCiphersFlow = bufferedMutableSharedFlow<List<OfflineCipherJson>>()
private val forceCiphersFlow = bufferedMutableSharedFlow<List<SyncResponseJson.Cipher>>()
private val forceCollectionsFlow =
bufferedMutableSharedFlow<List<SyncResponseJson.Collection>>()
private val forceFolderFlow = bufferedMutableSharedFlow<List<SyncResponseJson.Folder>>()
private val forceSendFlow = bufferedMutableSharedFlow<List<SyncResponseJson.Send>>()
override suspend fun saveOfflineCipher(userId: String, cipher: Cipher) {
val id = cipher.id ?: "create_${UUID.randomUUID()}"
offlineCiphersDao.insertCiphers(
ciphers = listOf(
OfflineCipherEntity(
id = id,
userId = userId,
cipherType = json.encodeToString(cipher.type),
cipherJson = json.encodeToString(
cipher.toOfflineCipher().toOfflineCipherJson(id)
),
),
),
)
}
override suspend fun updateOfflineCipher(userId: String, cipher: OfflineCipher) {
offlineCiphersDao.insertCiphers(
ciphers = listOf(
OfflineCipherEntity(
id = cipher.id!!,
userId = userId,
cipherType = json.encodeToString(cipher.type),
cipherJson = json.encodeToString(
cipher.toOfflineCipherJson(cipher.id)
),
),
),
)
}
override suspend fun saveCipher(userId: String, cipher: SyncResponseJson.Cipher) {
ciphersDao.insertCiphers(
ciphers = listOf(
@@ -58,6 +101,32 @@ class VaultDiskSourceImpl(
)
}
override fun getOfflineCiphers(
userId: String,
): Flow<List<OfflineCipherJson>> =
merge(
forceOfflineCiphersFlow,
offlineCiphersDao
.getAllCiphers(userId = userId)
.map { entities ->
withContext(context = dispatcherManager.default) {
entities
.map { entity ->
async {
json.decodeFromString<OfflineCipherJson>(
string = entity.cipherJson,
)
}
}
.awaitAll()
}
},
)
override suspend fun deleteOfflineCipher(userId: String, cipherId: String) {
offlineCiphersDao.deleteCipher(userId, cipherId)
}
override fun getCiphers(
userId: String,
): Flow<List<SyncResponseJson.Cipher>> =
@@ -78,6 +147,17 @@ class VaultDiskSourceImpl(
.awaitAll()
}
},
).combine(
getOfflineCiphers(userId),
{ ciphers, offlineCiphers ->
val overlaid = ciphers.map { cipher ->
// only overlay ciphers that have not had a merge conflict
offlineCiphers.filter { !it.mergeConflict }.find { it.id == cipher.id }?.toSdkCipherJson() ?: cipher
}
// TODO add new offline items to the vault list
// val newOffline = offlineCiphers.filter { it.id.startsWith("create") }.map { it.toSdkCipherJson() }
overlaid
}
)
override suspend fun deleteCipher(userId: String, cipherId: String) {

View File

@@ -0,0 +1,57 @@
package com.x8bit.bitwarden.data.vault.datasource.disk.dao
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import androidx.room.Transaction
import com.x8bit.bitwarden.data.vault.datasource.disk.entity.OfflineCipherEntity
import kotlinx.coroutines.flow.Flow
/**
* Provides methods for inserting, retrieving, and deleting ciphers from the database using the
* [OfflineCipherEntity].
*/
@Dao
interface OfflineCiphersDao {
/**
* Inserts multiple ciphers into the database.
*/
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertCiphers(ciphers: List<OfflineCipherEntity>)
/**
* Retrieves all ciphers from the database for a given [userId].
*/
@Query("SELECT * FROM offline_ciphers WHERE user_id = :userId")
fun getAllCiphers(
userId: String,
): Flow<List<OfflineCipherEntity>>
/**
* Deletes all the stored ciphers associated with the given [userId]. This will return the
* number of rows deleted by this query.
*/
@Query("DELETE FROM offline_ciphers WHERE user_id = :userId")
suspend fun deleteAllCiphers(userId: String): Int
/**
* Deletes the specified cipher associated with the given [userId] and [cipherId]. This will
* return the number of rows deleted by this query.
*/
@Query("DELETE FROM offline_ciphers WHERE user_id = :userId AND id = :cipherId")
suspend fun deleteCipher(userId: String, cipherId: String): Int
/**
* Deletes all the stored ciphers associated with the given [userId] and then add all new
* [ciphers] to the database. This will return `true` if any changes were made to the database
* and `false` otherwise.
*/
@Transaction
suspend fun replaceAllCiphers(userId: String, ciphers: List<OfflineCipherEntity>): Boolean {
val deletedCiphersCount = deleteAllCiphers(userId)
insertCiphers(ciphers)
return deletedCiphersCount > 0 || ciphers.isNotEmpty()
}
}

View File

@@ -1,5 +1,6 @@
package com.x8bit.bitwarden.data.vault.datasource.disk.database
import androidx.room.AutoMigration
import androidx.room.Database
import androidx.room.RoomDatabase
import androidx.room.TypeConverters
@@ -8,11 +9,13 @@ import com.x8bit.bitwarden.data.vault.datasource.disk.dao.CiphersDao
import com.x8bit.bitwarden.data.vault.datasource.disk.dao.CollectionsDao
import com.x8bit.bitwarden.data.vault.datasource.disk.dao.DomainsDao
import com.x8bit.bitwarden.data.vault.datasource.disk.dao.FoldersDao
import com.x8bit.bitwarden.data.vault.datasource.disk.dao.OfflineCiphersDao
import com.x8bit.bitwarden.data.vault.datasource.disk.dao.SendsDao
import com.x8bit.bitwarden.data.vault.datasource.disk.entity.CipherEntity
import com.x8bit.bitwarden.data.vault.datasource.disk.entity.CollectionEntity
import com.x8bit.bitwarden.data.vault.datasource.disk.entity.DomainsEntity
import com.x8bit.bitwarden.data.vault.datasource.disk.entity.FolderEntity
import com.x8bit.bitwarden.data.vault.datasource.disk.entity.OfflineCipherEntity
import com.x8bit.bitwarden.data.vault.datasource.disk.entity.SendEntity
/**
@@ -20,18 +23,27 @@ import com.x8bit.bitwarden.data.vault.datasource.disk.entity.SendEntity
*/
@Database(
entities = [
OfflineCipherEntity::class,
CipherEntity::class,
CollectionEntity::class,
DomainsEntity::class,
FolderEntity::class,
SendEntity::class,
],
version = 3,
version = 4,
autoMigrations = [
AutoMigration (from = 3, to = 4)
],
exportSchema = true,
)
@TypeConverters(ZonedDateTimeTypeConverter::class)
abstract class VaultDatabase : RoomDatabase() {
/**
* Provides the DAO for accessing cipher data.
*/
abstract fun offlineCipherDao(): OfflineCiphersDao
/**
* Provides the DAO for accessing cipher data.
*/

View File

@@ -10,6 +10,7 @@ import com.x8bit.bitwarden.data.vault.datasource.disk.dao.CiphersDao
import com.x8bit.bitwarden.data.vault.datasource.disk.dao.CollectionsDao
import com.x8bit.bitwarden.data.vault.datasource.disk.dao.DomainsDao
import com.x8bit.bitwarden.data.vault.datasource.disk.dao.FoldersDao
import com.x8bit.bitwarden.data.vault.datasource.disk.dao.OfflineCiphersDao
import com.x8bit.bitwarden.data.vault.datasource.disk.dao.SendsDao
import com.x8bit.bitwarden.data.vault.datasource.disk.database.VaultDatabase
import dagger.Module
@@ -39,6 +40,10 @@ class VaultDiskModule {
.addTypeConverter(ZonedDateTimeTypeConverter())
.build()
@Provides
@Singleton
fun provideOfflineCipherDao(database: VaultDatabase): OfflineCiphersDao = database.offlineCipherDao()
@Provides
@Singleton
fun provideCipherDao(database: VaultDatabase): CiphersDao = database.cipherDao()
@@ -62,6 +67,7 @@ class VaultDiskModule {
@Provides
@Singleton
fun provideVaultDiskSource(
offlineCiphersDao: OfflineCiphersDao,
ciphersDao: CiphersDao,
collectionsDao: CollectionsDao,
domainsDao: DomainsDao,
@@ -70,6 +76,7 @@ class VaultDiskModule {
json: Json,
dispatcherManager: DispatcherManager,
): VaultDiskSource = VaultDiskSourceImpl(
offlineCiphersDao = offlineCiphersDao,
ciphersDao = ciphersDao,
collectionsDao = collectionsDao,
domainsDao = domainsDao,

View File

@@ -0,0 +1,25 @@
package com.x8bit.bitwarden.data.vault.datasource.disk.entity
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.PrimaryKey
import com.bitwarden.core.Uuid
/**
* Entity representing a cipher in the database.
*/
@Entity(tableName = "offline_ciphers")
data class OfflineCipherEntity(
@PrimaryKey(autoGenerate = false)
@ColumnInfo(name = "id")
val id: String,
@ColumnInfo(name = "user_id", index = true)
val userId: String,
@ColumnInfo(name = "cipher_type")
val cipherType: String,
@ColumnInfo(name = "cipher_json")
val cipherJson: String,
)

View File

@@ -0,0 +1,91 @@
package com.x8bit.bitwarden.data.vault.datasource.network.model
import com.bitwarden.core.DateTime
import kotlinx.serialization.Contextual
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.JsonObject
import java.time.ZonedDateTime
/**
* Represents the response model for vault data fetched from the server.
*
* @property folders A list of folders associated with the vault data (nullable).
* @property collections A list of collections associated with the vault data (nullable).
* @property profile The profile associated with the vault data.
* @property ciphers A list of ciphers associated with the vault data (nullable).
* @property policies A list of policies associated with the vault data (nullable).
* @property domains A domains object associated with the vault data.
* @property sends A list of send objects associated with the vault data (nullable).
*/
@Serializable
data class OfflineCipherJson(
@SerialName("id")
val id: String,
@SerialName("organizationId")
val organizationId: String?,
@SerialName("folderId")
val folderId: String?,
@SerialName("collectionIds")
val collectionIds: List<String>,
@SerialName("key")
val key: String?,
@SerialName("name")
val name: String,
@SerialName("notes")
val notes: String?,
@SerialName("login")
val login: SyncResponseJson.Cipher.Login?,
@SerialName("identity")
val identity: SyncResponseJson.Cipher.Identity?,
@SerialName("card")
val card: SyncResponseJson.Cipher.Card?,
@SerialName("secureNote")
val secureNote: SyncResponseJson.Cipher.SecureNote?,
@SerialName("favorite")
val favorite: Boolean,
@SerialName("reprompt")
val reprompt: CipherRepromptTypeJson,
@SerialName("attachments")
val attachments: List<SyncResponseJson.Cipher.Attachment>?,
@SerialName("fields")
val fields: List<SyncResponseJson.Cipher.Field>?,
@SerialName("passwordHistory")
val passwordHistory: List<SyncResponseJson.Cipher.PasswordHistory>?,
@SerialName("creationDate")
@Contextual
val creationDate: ZonedDateTime,
@SerialName("deletionDate")
@Contextual
val deletedDate: ZonedDateTime?,
@SerialName("revisionDate")
@Contextual
val revisionDate: ZonedDateTime,
@SerialName("type")
@Contextual
val type: CipherTypeJson,
@SerialName("mergeConflict")
val mergeConflict: Boolean
) {
}

View File

@@ -6,9 +6,9 @@ import kotlinx.serialization.Serializable
import kotlinx.serialization.json.JsonObject
import java.time.ZonedDateTime
private const val DEFAULT_FIDO_2_KEY_TYPE = "public-key"
private const val DEFAULT_FIDO_2_KEY_ALGORITHM = "ECDSA"
private const val DEFAULT_FIDO_2_KEY_CURVE = "P-256"
const val DEFAULT_FIDO_2_KEY_TYPE = "public-key"
const val DEFAULT_FIDO_2_KEY_ALGORITHM = "ECDSA"
const val DEFAULT_FIDO_2_KEY_CURVE = "P-256"
/**
* Represents the response model for vault data fetched from the server.
@@ -423,7 +423,7 @@ data class SyncResponseJson(
* @property card The card of the cipher.
*/
@Serializable
data class Cipher(
data class Cipher(
@SerialName("notes")
val notes: String?,

View File

@@ -0,0 +1,55 @@
package com.x8bit.bitwarden.data.vault.datasource.sdk.model
import com.bitwarden.core.DateTime
import com.bitwarden.core.Uuid
import com.bitwarden.crypto.EncString
import com.bitwarden.vault.Attachment
import com.bitwarden.vault.Card
import com.bitwarden.vault.CipherRepromptType
import com.bitwarden.vault.CipherType
import com.bitwarden.vault.Field
import com.bitwarden.vault.Identity
import com.bitwarden.vault.LocalData
import com.bitwarden.vault.Login
import com.bitwarden.vault.PasswordHistory
import com.bitwarden.vault.SecureNote
import com.x8bit.bitwarden.data.vault.datasource.network.model.CipherRepromptTypeJson
import com.x8bit.bitwarden.data.vault.datasource.network.model.CipherTypeJson
import com.x8bit.bitwarden.data.vault.datasource.network.model.SyncResponseJson
import kotlinx.serialization.Contextual
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import java.time.ZonedDateTime
/**
* Represents a vault item that has failed to be uploaded and must be stored locally
*
*
*/
data class OfflineCipher (
val id: Uuid?,
val organizationId: Uuid?,
val folderId: Uuid?,
val collectionIds: List<Uuid>,
/**
* More recent ciphers uses individual encryption keys to encrypt the other fields of the
* Cipher.
*/
val key: EncString?,
val name: EncString,
val notes: EncString?,
val type: CipherType,
val login: Login?,
val identity: Identity?,
val card: Card?,
val secureNote: SecureNote?,
val favorite: kotlin.Boolean,
val reprompt: CipherRepromptType,
val attachments: List<Attachment>?,
val fields: List<Field>?,
val passwordHistory: List<PasswordHistory>?,
val creationDate: DateTime,
val deletedDate: DateTime?,
val revisionDate: DateTime,
val mergeConflict: Boolean
)

View File

@@ -16,6 +16,13 @@ import com.x8bit.bitwarden.data.vault.repository.model.UpdateCipherResult
*/
@Suppress("TooManyFunctions")
interface CipherManager {
/**
* Attempt to create a cipher in the offline repository
*/
suspend fun createOfflineCipher(
cipherView: CipherView
): CreateCipherResult
/**
* Attempt to create a cipher.
*/
@@ -93,6 +100,11 @@ interface CipherManager {
collectionIds: List<String>,
): ShareCipherResult
suspend fun updateOfflineCipher(
cipherId: String,
cipherView: CipherView,
): UpdateCipherResult
/**
* Attempt to update a cipher.
*/

View File

@@ -1,16 +1,20 @@
package com.x8bit.bitwarden.data.vault.manager
import android.content.Context
import android.net.Uri
import android.widget.Toast
import androidx.core.net.toUri
import com.bitwarden.vault.AttachmentView
import com.bitwarden.vault.Cipher
import com.bitwarden.vault.CipherView
import com.x8bit.bitwarden.data.auth.datasource.disk.AuthDiskSource
import com.x8bit.bitwarden.data.platform.manager.NetworkConnectionManager
import com.x8bit.bitwarden.data.platform.util.asFailure
import com.x8bit.bitwarden.data.platform.util.asSuccess
import com.x8bit.bitwarden.data.platform.util.flatMap
import com.x8bit.bitwarden.data.vault.datasource.disk.VaultDiskSource
import com.x8bit.bitwarden.data.vault.datasource.network.model.CreateCipherInOrganizationJsonRequest
import com.x8bit.bitwarden.data.vault.datasource.network.model.OfflineCipherJson
import com.x8bit.bitwarden.data.vault.datasource.network.model.ShareCipherJsonRequest
import com.x8bit.bitwarden.data.vault.datasource.network.model.UpdateCipherCollectionsJsonRequest
import com.x8bit.bitwarden.data.vault.datasource.network.model.UpdateCipherResponseJson
@@ -25,27 +29,127 @@ import com.x8bit.bitwarden.data.vault.repository.model.DownloadAttachmentResult
import com.x8bit.bitwarden.data.vault.repository.model.RestoreCipherResult
import com.x8bit.bitwarden.data.vault.repository.model.ShareCipherResult
import com.x8bit.bitwarden.data.vault.repository.model.UpdateCipherResult
import com.x8bit.bitwarden.data.vault.repository.util.toCipher
import com.x8bit.bitwarden.data.vault.repository.util.toEncryptedNetworkCipher
import com.x8bit.bitwarden.data.vault.repository.util.toEncryptedNetworkCipherResponse
import com.x8bit.bitwarden.data.vault.repository.util.toEncryptedSdkCipher
import com.x8bit.bitwarden.data.vault.repository.util.toNetworkAttachmentRequest
import com.x8bit.bitwarden.data.vault.repository.util.toOfflineCipher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.flattenConcat
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import java.io.File
import java.time.Clock
/**
* The default implementation of the [CipherManager].
*/
@OptIn(ExperimentalCoroutinesApi::class)
@Suppress("TooManyFunctions")
class CipherManagerImpl(
private val context: Context,
private val fileManager: FileManager,
private val authDiskSource: AuthDiskSource,
private val ciphersService: CiphersService,
private val vaultDiskSource: VaultDiskSource,
private val vaultSdkSource: VaultSdkSource,
private val clock: Clock,
private val networkConnectionManager: NetworkConnectionManager,
private val externalScope: CoroutineScope,
) : CipherManager {
private val activeUserId: String? get() = authDiskSource.userState?.activeUserId
init {
externalScope.launch {
networkConnectionManager.isNetworkConnectedFlow
.map {
if (it && activeUserId != null) {
// Device went online
// TODO: We need to add support for non active users!
vaultDiskSource.getOfflineCiphers(activeUserId!!).map { it.filter { it.mergeConflict == false } }
} else {
flowOf(listOf<OfflineCipherJson>())
}
}.flattenConcat().collect {
if (activeUserId == null) {
return@collect
}
val userId = activeUserId!!
val hasItems = it.isNotEmpty()
it.map { c ->
val cipher = c.toOfflineCipher().toCipher()
when (cipher.id) {
null -> ciphersService.createCipher(body = cipher.toEncryptedNetworkCipher())
.onSuccess {
vaultDiskSource.saveCipher(userId = userId, cipher = it)
vaultDiskSource.deleteOfflineCipher(userId = userId, cipherId = c.id)
}
.fold(
onFailure = { vaultDiskSource.updateOfflineCipher(userId = userId, c.toOfflineCipher().copy(mergeConflict = true)) },
onSuccess = { CreateCipherResult.Success },
)
else -> ciphersService.updateCipher(
cipherId = cipher.id!!,
body = cipher.toEncryptedNetworkCipher()
).map { response ->
when (response) {
is UpdateCipherResponseJson.Invalid -> {
UpdateCipherResult.Error(errorMessage = response.message)
}
is UpdateCipherResponseJson.Success -> {
vaultDiskSource.saveCipher(
userId = userId,
// TODO: Why are we doing this?
cipher = response.cipher.copy(collectionIds = cipher.collectionIds),
)
vaultDiskSource.deleteOfflineCipher(userId = userId, cipherId = c.id)
UpdateCipherResult.Success
}
}
}
.fold(
onFailure = { vaultDiskSource.updateOfflineCipher(userId = userId, c.toOfflineCipher().copy(mergeConflict = true)) },
onSuccess = { it },
)
}
}
if (hasItems) {
externalScope.launch {
Toast.makeText(
context,
"Offline items uploaded",
Toast.LENGTH_SHORT
).show()
}
}
}
}
}
override suspend fun createOfflineCipher(cipherView: CipherView): CreateCipherResult {
val userId = activeUserId ?: return CreateCipherResult.Error
return vaultSdkSource.encryptCipher(
userId = userId,
cipherView = cipherView
)
.map { vaultDiskSource.saveOfflineCipher(userId = userId, cipher = it) }
.fold(
onFailure = { CreateCipherResult.Error },
onSuccess = { CreateCipherResult.Success }
)
}
override suspend fun createCipher(cipherView: CipherView): CreateCipherResult {
val userId = activeUserId ?: return CreateCipherResult.Error
return vaultSdkSource
@@ -194,6 +298,26 @@ class CipherManagerImpl(
)
}
override suspend fun updateOfflineCipher(
cipherId: String,
cipherView: CipherView,
): UpdateCipherResult {
val userId = activeUserId ?: return UpdateCipherResult.Error(errorMessage = null)
return vaultSdkSource.encryptCipher(
userId = userId,
cipherView = cipherView
)
.map {
vaultDiskSource.saveOfflineCipher(userId = userId, cipher = it)
UpdateCipherResult.Success
}
.fold(
onFailure = { UpdateCipherResult.Error(errorMessage = null) },
onSuccess = { it },
)
}
override suspend fun updateCipher(
cipherId: String,
cipherView: CipherView,

View File

@@ -6,6 +6,7 @@ import com.x8bit.bitwarden.data.auth.datasource.sdk.AuthSdkSource
import com.x8bit.bitwarden.data.auth.manager.TrustedDeviceManager
import com.x8bit.bitwarden.data.auth.manager.UserLogoutManager
import com.x8bit.bitwarden.data.platform.manager.AppForegroundManager
import com.x8bit.bitwarden.data.platform.manager.NetworkConnectionManager
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.VaultDiskSource
@@ -25,6 +26,7 @@ import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import kotlinx.coroutines.CoroutineScope
import java.time.Clock
import javax.inject.Singleton
@@ -38,19 +40,25 @@ object VaultManagerModule {
@Provides
@Singleton
fun provideCipherManager(
@ApplicationContext context: Context,
ciphersService: CiphersService,
vaultDiskSource: VaultDiskSource,
vaultSdkSource: VaultSdkSource,
authDiskSource: AuthDiskSource,
fileManager: FileManager,
clock: Clock,
networkConnectionManager: NetworkConnectionManager,
dispatcherManager: DispatcherManager
): CipherManager = CipherManagerImpl(
context = context,
fileManager = fileManager,
authDiskSource = authDiskSource,
ciphersService = ciphersService,
vaultDiskSource = vaultDiskSource,
vaultSdkSource = vaultSdkSource,
clock = clock,
networkConnectionManager = networkConnectionManager,
externalScope = CoroutineScope(dispatcherManager.main)
)
@Provides

View File

@@ -22,6 +22,7 @@ import com.x8bit.bitwarden.data.vault.repository.model.DeleteSendResult
import com.x8bit.bitwarden.data.vault.repository.model.DomainsData
import com.x8bit.bitwarden.data.vault.repository.model.ExportVaultDataResult
import com.x8bit.bitwarden.data.vault.repository.model.GenerateTotpResult
import com.x8bit.bitwarden.data.vault.repository.model.OfflineCipherView
import com.x8bit.bitwarden.data.vault.repository.model.RemovePasswordSendResult
import com.x8bit.bitwarden.data.vault.repository.model.SendData
import com.x8bit.bitwarden.data.vault.repository.model.SyncVaultDataResult
@@ -56,6 +57,13 @@ interface VaultRepository : CipherManager, VaultLockManager {
*/
val vaultDataStateFlow: StateFlow<DataState<VaultData>>
/**
* Flow that represents all ciphers stored in the offline cache for the active user.
*
* Note that the [StateFlow.value] will return the last known value but the [StateFlow] itself
* must be collected in order to trigger state changes.
*/
val offlineCiphersStateFlow: StateFlow<DataState<List<OfflineCipherView>>>
/**
* Flow that represents all ciphers for the active user.
*

View File

@@ -63,6 +63,7 @@ import com.x8bit.bitwarden.data.vault.repository.model.DeleteSendResult
import com.x8bit.bitwarden.data.vault.repository.model.DomainsData
import com.x8bit.bitwarden.data.vault.repository.model.ExportVaultDataResult
import com.x8bit.bitwarden.data.vault.repository.model.GenerateTotpResult
import com.x8bit.bitwarden.data.vault.repository.model.OfflineCipherView
import com.x8bit.bitwarden.data.vault.repository.model.RemovePasswordSendResult
import com.x8bit.bitwarden.data.vault.repository.model.SendData
import com.x8bit.bitwarden.data.vault.repository.model.SyncVaultDataResult
@@ -72,6 +73,7 @@ import com.x8bit.bitwarden.data.vault.repository.model.UpdateSendResult
import com.x8bit.bitwarden.data.vault.repository.model.VaultData
import com.x8bit.bitwarden.data.vault.repository.model.VaultUnlockResult
import com.x8bit.bitwarden.data.vault.repository.util.sortAlphabetically
import com.x8bit.bitwarden.data.vault.repository.util.toCipherList
import com.x8bit.bitwarden.data.vault.repository.util.toDomainsData
import com.x8bit.bitwarden.data.vault.repository.util.toEncryptedNetworkFolder
import com.x8bit.bitwarden.data.vault.repository.util.toEncryptedNetworkSend
@@ -82,6 +84,8 @@ import com.x8bit.bitwarden.data.vault.repository.util.toEncryptedSdkFolder
import com.x8bit.bitwarden.data.vault.repository.util.toEncryptedSdkFolderList
import com.x8bit.bitwarden.data.vault.repository.util.toEncryptedSdkSend
import com.x8bit.bitwarden.data.vault.repository.util.toEncryptedSdkSendList
import com.x8bit.bitwarden.data.vault.repository.util.toOfflineCipher
import com.x8bit.bitwarden.data.vault.repository.util.toOfflineCipherView
import com.x8bit.bitwarden.ui.vault.feature.vault.model.VaultFilterType
import com.x8bit.bitwarden.ui.vault.feature.vault.util.toFilteredList
import kotlinx.coroutines.CancellationException
@@ -156,6 +160,9 @@ class VaultRepositoryImpl(
private val mutableSendDataStateFlow = MutableStateFlow<DataState<SendData>>(DataState.Loading)
private val mutableOfflineCiphersStateFlow =
MutableStateFlow<DataState<List<OfflineCipherView>>>(DataState.Loading)
private val mutableCiphersStateFlow =
MutableStateFlow<DataState<List<CipherView>>>(DataState.Loading)
@@ -172,18 +179,21 @@ class VaultRepositoryImpl(
override val vaultDataStateFlow: StateFlow<DataState<VaultData>> =
combine(
offlineCiphersStateFlow,
ciphersStateFlow,
foldersStateFlow,
collectionsStateFlow,
sendDataStateFlow,
) { ciphersDataState, foldersDataState, collectionsDataState, sendsDataState ->
) { offlineCiphersDataState, ciphersDataState, foldersDataState, collectionsDataState, sendsDataState ->
combineDataStates(
offlineCiphersDataState,
ciphersDataState,
foldersDataState,
collectionsDataState,
sendsDataState,
) { ciphersData, foldersData, collectionsData, sendsData ->
) { offlineCiphersData, ciphersData, foldersData, collectionsData, sendsData ->
VaultData(
offlineCipherViewList = offlineCiphersData,
cipherViewList = ciphersData,
fido2CredentialAutofillViewList = null,
folderViewList = foldersData,
@@ -201,6 +211,9 @@ class VaultRepositoryImpl(
override val totpCodeFlow: Flow<TotpCodeResult>
get() = mutableTotpCodeResultFlow.asSharedFlow()
override val offlineCiphersStateFlow: StateFlow<DataState<List<OfflineCipherView>>>
get() = mutableOfflineCiphersStateFlow.asStateFlow()
override val ciphersStateFlow: StateFlow<DataState<List<CipherView>>>
get() = mutableCiphersStateFlow.asStateFlow()
@@ -234,6 +247,11 @@ class VaultRepositoryImpl(
}
.launchIn(unconfinedScope)
// Setup offline ciphers MutableStateFlow
mutableOfflineCiphersStateFlow
.observeWhenSubscribedAndLoggedIn(authDiskSource.userStateFlow) { activeUserId ->
observeVaultDiskOfflineCiphers(activeUserId)
}.launchIn(unconfinedScope)
// Setup ciphers MutableStateFlow
mutableCiphersStateFlow
.observeWhenSubscribedAndLoggedIn(authDiskSource.userStateFlow) { activeUserId ->
@@ -302,6 +320,7 @@ class VaultRepositoryImpl(
}
private fun clearUnlockedData() {
mutableOfflineCiphersStateFlow.update { DataState.Loading }
mutableCiphersStateFlow.update { DataState.Loading }
mutableDomainsStateFlow.update { DataState.Loading }
mutableFoldersStateFlow.update { DataState.Loading }
@@ -318,6 +337,7 @@ class VaultRepositoryImpl(
override fun sync() {
val userId = activeUserId ?: return
if (!syncJob.isCompleted) return
mutableOfflineCiphersStateFlow.updateToPendingOrLoading()
mutableCiphersStateFlow.updateToPendingOrLoading()
mutableDomainsStateFlow.updateToPendingOrLoading()
mutableFoldersStateFlow.updateToPendingOrLoading()
@@ -926,6 +946,28 @@ class VaultRepositoryImpl(
)
}
private fun observeVaultDiskOfflineCiphers(
userId: String,
): Flow<DataState<List<OfflineCipherView>>> =
vaultDiskSource.getOfflineCiphers(userId = userId)
.onStart { mutableOfflineCiphersStateFlow.updateToPendingOrLoading() }
.map { ciphers ->
waitUntilUnlocked(userId = userId)
vaultSdkSource
.decryptCipherList(
userId = userId,
cipherList = ciphers.toCipherList(),
)
.map {
it.zip(ciphers).map { (cipher, offlineJson) -> cipher.toOfflineCipherView(offlineJson.toOfflineCipher()) }
}
.fold(
onSuccess = { views -> DataState.Loaded(views) },
onFailure = { throwable -> DataState.Error(throwable) },
)
}
.onEach { mutableOfflineCiphersStateFlow.value = it }
private fun observeVaultDiskCiphers(
userId: String,
): Flow<DataState<List<CipherView>>> =
@@ -1029,6 +1071,11 @@ class VaultRepositoryImpl(
.onEach { mutableSendDataStateFlow.value = it }
private fun updateVaultStateFlowsToError(throwable: Throwable) {
mutableOfflineCiphersStateFlow.update { currentState ->
throwable.toNetworkOrErrorState(
data = currentState.data,
)
}
mutableCiphersStateFlow.update { currentState ->
throwable.toNetworkOrErrorState(
data = currentState.data,

View File

@@ -0,0 +1,46 @@
package com.x8bit.bitwarden.data.vault.repository.model
import com.bitwarden.core.DateTime
import com.bitwarden.core.Uuid
import com.bitwarden.crypto.EncString
import com.bitwarden.vault.AttachmentView
import com.bitwarden.vault.CardView
import com.bitwarden.vault.CipherRepromptType
import com.bitwarden.vault.CipherType
import com.bitwarden.vault.FieldView
import com.bitwarden.vault.IdentityView
import com.bitwarden.vault.LocalDataView
import com.bitwarden.vault.LoginView
import com.bitwarden.vault.PasswordHistoryView
import com.bitwarden.vault.SecureNoteView
data class OfflineCipherView (
val id: Uuid?,
val organizationId: Uuid?,
val folderId: Uuid?,
val collectionIds: List<Uuid>,
/**
* Temporary, required to support re-encrypting existing items.
*/
val key: EncString?,
val name: String,
val notes: String?,
val type: CipherType,
val login: LoginView?,
val identity: IdentityView?,
val card: CardView?,
val secureNote: SecureNoteView?,
val favorite: Boolean,
val reprompt: CipherRepromptType,
val organizationUseTotp: Boolean,
val edit: Boolean,
val viewPassword: Boolean,
val localData: LocalDataView?,
val attachments: List<AttachmentView>?,
val fields: List<FieldView>?,
val passwordHistory: List<PasswordHistoryView>?,
val creationDate: DateTime,
val deletedDate: DateTime?,
val revisionDate: DateTime,
val mergeConflict: Boolean
)

View File

@@ -9,6 +9,7 @@ import com.bitwarden.vault.FolderView
/**
* Represents decrypted vault data.
*
* @param offlineCipherViewList List of decrypted ciphers from offline cache.
* @param cipherViewList List of decrypted ciphers.
* @param collectionViewList List of decrypted collections.
* @param folderViewList List of decrypted folders.
@@ -16,6 +17,7 @@ import com.bitwarden.vault.FolderView
* @param fido2CredentialAutofillViewList List of decrypted fido 2 credentials.
*/
data class VaultData(
val offlineCipherViewList: List<OfflineCipherView>,
val cipherViewList: List<CipherView>,
val collectionViewList: List<CollectionView>,
val folderViewList: List<FolderView>,

View File

@@ -2,8 +2,11 @@
package com.x8bit.bitwarden.data.vault.repository.util
import com.bitwarden.core.DateTime
import com.bitwarden.vault.Attachment
import com.bitwarden.vault.AttachmentView
import com.bitwarden.vault.Card
import com.bitwarden.vault.CardView
import com.bitwarden.vault.Cipher
import com.bitwarden.vault.CipherRepromptType
import com.bitwarden.vault.CipherType
@@ -11,25 +14,38 @@ import com.bitwarden.vault.CipherView
import com.bitwarden.vault.Fido2Credential
import com.bitwarden.vault.Field
import com.bitwarden.vault.FieldType
import com.bitwarden.vault.FieldView
import com.bitwarden.vault.Identity
import com.bitwarden.vault.IdentityView
import com.bitwarden.vault.LocalDataView
import com.bitwarden.vault.Login
import com.bitwarden.vault.LoginUri
import com.bitwarden.vault.LoginView
import com.bitwarden.vault.PasswordHistory
import com.bitwarden.vault.PasswordHistoryView
import com.bitwarden.vault.SecureNote
import com.bitwarden.vault.SecureNoteType
import com.bitwarden.vault.SecureNoteView
import com.bitwarden.vault.UriMatchType
import com.x8bit.bitwarden.data.platform.util.SpecialCharWithPrecedenceComparator
import com.x8bit.bitwarden.data.platform.util.isFdroid
import com.x8bit.bitwarden.data.vault.datasource.network.model.AttachmentJsonRequest
import com.x8bit.bitwarden.data.vault.datasource.network.model.CipherJsonRequest
import com.x8bit.bitwarden.data.vault.datasource.network.model.CipherRepromptTypeJson
import com.x8bit.bitwarden.data.vault.datasource.network.model.CipherTypeJson
import com.x8bit.bitwarden.data.vault.datasource.network.model.FieldTypeJson
import com.x8bit.bitwarden.data.vault.datasource.network.model.LinkedIdTypeJson
import com.x8bit.bitwarden.data.vault.datasource.network.model.OfflineCipherJson
import com.x8bit.bitwarden.data.vault.datasource.network.model.SecureNoteTypeJson
import com.x8bit.bitwarden.data.vault.datasource.network.model.SyncResponseJson
import com.x8bit.bitwarden.data.vault.datasource.network.model.UriMatchTypeJson
import com.x8bit.bitwarden.data.vault.datasource.sdk.model.OfflineCipher
import com.x8bit.bitwarden.data.vault.repository.model.OfflineCipherView
import com.x8bit.bitwarden.ui.vault.feature.vault.model.NotificationSummary
import kotlinx.coroutines.flow.merge
import java.time.ZoneOffset
import java.time.ZonedDateTime
import java.util.UUID
/**
* Converts a Bitwarden SDK [Cipher] object to a corresponding
@@ -57,6 +73,203 @@ fun Cipher.toEncryptedNetworkCipher(): CipherJsonRequest =
key = key,
)
fun Cipher.toOfflineCipher(): OfflineCipher =
OfflineCipher(
id = id,
organizationId = organizationId,
folderId = folderId,
collectionIds = collectionIds,
key = key,
name = name,
notes = notes,
type = type,
login = login,
identity = identity,
card = card,
secureNote = secureNote,
favorite = favorite,
reprompt = reprompt,
attachments = attachments,
fields = fields,
passwordHistory = passwordHistory,
creationDate = creationDate,
deletedDate = deletedDate,
revisionDate = revisionDate,
mergeConflict = false,
)
fun OfflineCipher.toOfflineCipherJson(id: String): OfflineCipherJson =
OfflineCipherJson(
id = id,
organizationId = organizationId,
folderId = folderId,
collectionIds = collectionIds.orEmpty(),
key = key,
name = name.orEmpty(),
notes = notes,
type = type.toNetworkCipherType(),
login = login?.toEncryptedNetworkLogin(),
identity = identity?.toEncryptedNetworkIdentity(),
card = card?.toEncryptedNetworkCard(),
secureNote = secureNote?.toEncryptedNetworkSecureNote(),
favorite = favorite,
reprompt = reprompt.toNetworkRepromptType(),
attachments = attachments?.toNetworkAttachmentList(),
fields = fields?.toEncryptedNetworkFieldList(),
passwordHistory = passwordHistory?.toEncryptedNetworkPasswordHistoryList(),
creationDate = ZonedDateTime.ofInstant(creationDate, ZoneOffset.UTC),
deletedDate = deletedDate?.let { ZonedDateTime.ofInstant(deletedDate, ZoneOffset.UTC) },
revisionDate = ZonedDateTime.ofInstant(creationDate, ZoneOffset.UTC),
mergeConflict = mergeConflict,
)
fun OfflineCipherView.toNotificationSummary(): NotificationSummary =
NotificationSummary(
title = name,
subtitle = "edited on $revisionDate. Has Merge Conflict: $mergeConflict",
)
fun CipherView.toOfflineCipherView(offlineCipher: OfflineCipher) =
OfflineCipherView(
id = id,
organizationId = organizationId,
folderId = folderId,
collectionIds = collectionIds,
/**
* Temporary, required to support re-encrypting existing items.
*/
key = key,
name = name,
notes = notes,
type = type,
login = login,
identity = identity,
card = card,
secureNote = secureNote,
favorite = favorite,
reprompt = reprompt,
organizationUseTotp = organizationUseTotp,
edit = edit,
viewPassword = viewPassword,
localData = localData,
attachments = attachments,
fields = fields,
passwordHistory = passwordHistory,
creationDate = creationDate,
deletedDate = deletedDate,
revisionDate = revisionDate,
mergeConflict = offlineCipher.mergeConflict
)
fun SyncResponseJson.Cipher.overlayOfflineCipherJson(offlineCipherJson: OfflineCipherJson) =
SyncResponseJson.Cipher(
notes = offlineCipherJson.notes,
attachments = offlineCipherJson.attachments,
shouldOrganizationUseTotp = shouldOrganizationUseTotp,
reprompt = offlineCipherJson.reprompt,
shouldEdit = shouldEdit,
passwordHistory = offlineCipherJson.passwordHistory,
revisionDate = offlineCipherJson.revisionDate,
type = offlineCipherJson.type,
login = offlineCipherJson.login,
creationDate = offlineCipherJson.creationDate,
secureNote = offlineCipherJson.secureNote,
folderId = offlineCipherJson.folderId,
organizationId = offlineCipherJson.organizationId,
deletedDate = offlineCipherJson.deletedDate,
identity = offlineCipherJson.identity,
collectionIds = offlineCipherJson.collectionIds,
name = offlineCipherJson.name,
id = id,
fields = offlineCipherJson.fields,
shouldViewPassword = shouldViewPassword,
isFavorite = offlineCipherJson.favorite,
card = offlineCipherJson.card,
key = offlineCipherJson.key
)
fun OfflineCipherJson.toSdkCipherJson(): SyncResponseJson.Cipher =
SyncResponseJson.Cipher(
id = id, // TODO, the "create_..." id is invalid, but it's not clear what's better
notes = notes,
attachments = attachments,
shouldOrganizationUseTotp = false, // TODO
reprompt = reprompt,
shouldEdit = false, // TODO
passwordHistory = passwordHistory,
revisionDate = revisionDate,
type = type,
login = login,
creationDate = creationDate,
secureNote = secureNote,
folderId = folderId,
organizationId = organizationId,
deletedDate = deletedDate,
identity = identity,
collectionIds = collectionIds,
name = name,
fields = fields,
shouldViewPassword = false, // TODO
isFavorite = favorite,
card = card,
key = key,
)
fun OfflineCipherJson.toOfflineCipher(): OfflineCipher =
OfflineCipher(
id = if (id.startsWith("create")) null else id,
organizationId = organizationId,
folderId = folderId,
collectionIds = collectionIds.orEmpty(),
key = key,
name = name.orEmpty(),
notes = notes,
type = type.toSdkCipherType(),
login = login?.toSdkLogin(),
identity = identity?.toSdkIdentity(),
card = card?.toSdkCard(),
secureNote = secureNote?.toSdkSecureNote(),
favorite = favorite,
reprompt = reprompt.toSdkRepromptType(),
attachments = attachments?.toSdkAttachmentList(),
fields = fields?.toSdkFieldList(),
passwordHistory = passwordHistory?.toSdkPasswordHistoryList(),
creationDate = creationDate?.toInstant() ?: DateTime.now(),
deletedDate = deletedDate?.toInstant(),
revisionDate = revisionDate?.toInstant() ?: DateTime.now(),
mergeConflict = mergeConflict
)
fun OfflineCipher.toCipher(): Cipher =
Cipher(
id = id,
organizationId = organizationId,
folderId = folderId,
collectionIds = collectionIds.orEmpty(),
key = key,
name = name.orEmpty(),
notes = notes,
type = type,
login = login,
identity = identity,
card = card,
secureNote = secureNote,
favorite = favorite,
reprompt = reprompt,
attachments = attachments,
fields = fields,
passwordHistory = passwordHistory,
creationDate = creationDate,
deletedDate = deletedDate,
revisionDate = revisionDate,
// TODO: how to get real values here
organizationUseTotp = true,
edit = true,
viewPassword = true,
localData = null,
// TODO
)
/**
* Converts a Bitwarden SDK [Cipher] object to a corresponding
* [SyncResponseJson.Cipher] object.
@@ -318,6 +531,13 @@ private fun CipherType.toNetworkCipherType(): CipherTypeJson =
fun List<SyncResponseJson.Cipher>.toEncryptedSdkCipherList(): List<Cipher> =
map { it.toEncryptedSdkCipher() }
/**
* Converts a list of [OfflineCipherJson] objects to a list of corresponding
* Bitwarden SDK [Cipher] objects.
*/
fun List<OfflineCipherJson>.toCipherList(): List<Cipher> =
map { it.toOfflineCipher().toCipher() }
/**
* Converts a [SyncResponseJson.Cipher] object to a corresponding
* Bitwarden SDK [Cipher] object.

View File

@@ -412,10 +412,16 @@ class VaultAddEditViewModel @Inject constructor(
}
is VaultAddEditType.EditItem -> {
val result = vaultRepository.updateCipher(
var result = vaultRepository.updateCipher(
cipherId = vaultAddEditType.vaultItemId,
cipherView = content.toCipherView(),
)
if (result is UpdateCipherResult.Error) {
// TODO: Ask for permission to store locally
result = vaultRepository.updateOfflineCipher(cipherId = vaultAddEditType.vaultItemId, cipherView = content.toCipherView())
}
sendAction(VaultAddEditAction.Internal.UpdateCipherResultReceive(result))
}
@@ -1858,7 +1864,7 @@ class VaultAddEditViewModel @Inject constructor(
@Suppress("MaxLineLength")
private suspend fun VaultAddEditState.ViewState.Content.createCipherForAddAndCloneItemStates(): CreateCipherResult {
return common.selectedOwner?.collections
var result = common.selectedOwner?.collections
?.filter { it.isSelected }
?.map { it.id }
?.let {
@@ -1868,6 +1874,13 @@ class VaultAddEditViewModel @Inject constructor(
)
}
?: vaultRepository.createCipher(cipherView = toCipherView())
if (result is CreateCipherResult.Error) {
// TODO: Ask for permission to store locally
result = vaultRepository.createOfflineCipher(cipherView = toCipherView())
}
return result;
}
private fun List<VaultAddEditState.Owner>.toUpdatedOwners(

View File

@@ -496,19 +496,6 @@ private fun VaultItemListingScaffold(
}
}
BitwardenAccountSwitcher(
isVisible = isAccountMenuVisible,
accountSummaries = state.accountSummaries.toImmutableList(),
onSwitchAccountClick = vaultItemListingHandlers.switchAccountClick,
onLockAccountClick = vaultItemListingHandlers.lockAccountClick,
onLogoutAccountClick = vaultItemListingHandlers.logoutAccountClick,
onAddAccountClick = {
// Not available
},
onDismissRequest = { isAccountMenuVisible = false },
isAddAccountAvailable = false,
topAppBarScrollBehavior = scrollBehavior,
modifier = modifier,
)
}
}

View File

@@ -0,0 +1,334 @@
package com.x8bit.bitwarden.ui.vault.feature.unsyncedvaultitem
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.ExperimentalAnimationApi
import androidx.compose.animation.core.updateTransition
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.TopAppBarScrollBehavior
import androidx.compose.material3.rememberTopAppBarState
import androidx.compose.material3.ripple
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.clearAndSetSemantics
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.x8bit.bitwarden.R
import com.x8bit.bitwarden.ui.platform.base.util.scrolledContainerBackground
import com.x8bit.bitwarden.ui.platform.base.util.toSafeOverlayColor
import com.x8bit.bitwarden.ui.platform.base.util.toUnscaledTextUnit
import com.x8bit.bitwarden.ui.platform.components.dialog.BitwardenSelectionDialog
import com.x8bit.bitwarden.ui.platform.components.dialog.row.BitwardenBasicDialogRow
import com.x8bit.bitwarden.ui.platform.components.divider.BitwardenHorizontalDivider
import com.x8bit.bitwarden.ui.platform.components.model.AccountSummary
import com.x8bit.bitwarden.ui.platform.components.scrim.BitwardenAnimatedScrim
import com.x8bit.bitwarden.ui.platform.components.util.rememberVectorPainter
import com.x8bit.bitwarden.ui.platform.theme.BitwardenTheme
import com.x8bit.bitwarden.ui.vault.feature.vault.model.NotificationSummary
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
/**
* The maximum number of accounts before the "Add account" button will be hidden to prevent the user
* from adding any more.
*/
private const val MAXIMUM_ACCOUNT_LIMIT = 5
/**
* An account switcher that will slide down inside whatever parent is it placed in and add a
* a scrim via a [BitwardenAnimatedScrim] to all content below it (but not above it). Additional
* [BitwardenAnimatedScrim] may be manually placed over other components that might not be covered
* by the internal one.
*
* Note that this is intended to be used in conjunction with screens containing a top app bar but
* should be placed with the screen's content and not with the bar itself.
*
* @param isVisible Whether or not this component is visible. Changing this value will animate the
* component in or out of view.
* @param onDismissRequest A callback when the component requests to be dismissed. This is triggered
* whenever the user clicks on the scrim or any of the switcher items.
* @param modifier A [Modifier] for the composable.
* @param topAppBarScrollBehavior Used to derive the background color of the content and keep it in
* sync with the associated app bar.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Suppress("LongMethod")
@Composable
fun NotificationCenter(
isVisible: Boolean,
notificationSummaries: ImmutableList<NotificationSummary>,
onNotificationClick: (NotificationSummary) -> Unit,
onDismissRequest: () -> Unit,
modifier: Modifier = Modifier,
topAppBarScrollBehavior: TopAppBarScrollBehavior,
) {
// Track the actual visibility (according to the internal transitions) so that we know when we
// can safely show dialogs.
var isVisibleActual by remember { mutableStateOf(isVisible) }
Box(modifier = modifier) {
BitwardenAnimatedScrim(
isVisible = isVisible,
onClick = onDismissRequest,
modifier = Modifier
.fillMaxSize(),
)
AnimatedNotificationCenter(
isVisible = isVisible,
notificationSummaries = notificationSummaries,
onNotificationClick = {
onDismissRequest()
onNotificationClick(it)
},
topAppBarScrollBehavior = topAppBarScrollBehavior,
currentAnimationState = { isVisibleActual = it },
modifier = Modifier
.fillMaxWidth(),
)
}
}
@OptIn(
ExperimentalMaterial3Api::class,
ExperimentalAnimationApi::class,
)
@Composable
private fun AnimatedNotificationCenter(
isVisible: Boolean,
notificationSummaries: ImmutableList<NotificationSummary>,
onNotificationClick: (NotificationSummary) -> Unit,
modifier: Modifier = Modifier,
topAppBarScrollBehavior: TopAppBarScrollBehavior,
currentAnimationState: (isVisible: Boolean) -> Unit,
) {
val transition = updateTransition(
targetState = isVisible,
label = "AnimatedAccountSwitcher",
)
.also { currentAnimationState(it.currentState) }
transition.AnimatedVisibility(
visible = { it },
enter = slideInVertically { -it },
exit = slideOutVertically { -it },
) {
LazyColumn(
modifier = modifier
.testTag("AccountListView")
// To prevent going all the way up to the bottom of the screen, we'll add some small
// bottom padding.
.padding(bottom = 24.dp)
// Match the color of the switcher the different states of the app bar.
.scrolledContainerBackground(topAppBarScrollBehavior),
) {
items(notificationSummaries) { notificationSummary ->
NotificationSummaryItem(
notificationSummary = notificationSummary,
onNotificationClick = onNotificationClick,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
)
BitwardenHorizontalDivider()
}
}
}
}
@Suppress("LongMethod")
@OptIn(ExperimentalFoundationApi::class)
@Composable
private fun NotificationSummaryItem(
notificationSummary: NotificationSummary,
onNotificationClick: (NotificationSummary) -> Unit,
modifier: Modifier = Modifier,
) {
Row(
horizontalArrangement = Arrangement.Start,
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.testTag("AccountCell")
.combinedClickable(
interactionSource = remember { MutableInteractionSource() },
indication = ripple(
color = BitwardenTheme.colorScheme.background.pressed,
),
onClick = { onNotificationClick(notificationSummary) },
)
.padding(vertical = 8.dp)
.then(modifier),
) {
Box(
contentAlignment = Alignment.Center,
) {
Icon(
painter = rememberVectorPainter(id = R.drawable.ic_account_initials_container),
tint = BitwardenTheme.colorScheme.filledButton.background,
contentDescription = null,
modifier = Modifier.size(40.dp),
)
Text(
text = notificationSummary.title.substring(0, 2),
style = BitwardenTheme.typography.titleMedium
// Do not allow scaling
.copy(fontSize = 16.dp.toUnscaledTextUnit()),
color = BitwardenTheme.colorScheme.filledButton.background.toSafeOverlayColor(),
modifier = Modifier.clearAndSetSemantics { },
)
}
Spacer(modifier = Modifier.width(16.dp))
Column(
modifier = Modifier.weight(1f),
) {
Text(
text = notificationSummary.title,
style = BitwardenTheme.typography.bodyLarge,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.testTag("AccountEmailLabel"),
)
Text(
text = notificationSummary.subtitle,
style = BitwardenTheme.typography.bodyMedium,
color = BitwardenTheme.colorScheme.text.secondary,
modifier = Modifier.testTag("AccountEnvironmentLabel"),
)
}
Spacer(modifier = Modifier.width(16.dp))
Spacer(modifier = Modifier.width(8.dp))
}
}
@Composable
private fun LockOrLogoutDialog(
accountSummary: AccountSummary,
onDismissRequest: () -> Unit,
onLockAccountClick: (AccountSummary) -> Unit,
onLogoutAccountClick: (AccountSummary) -> Unit,
onRemoveAccountClick: (AccountSummary) -> Unit,
) {
BitwardenSelectionDialog(
title = "${accountSummary.email}\n${accountSummary.environmentLabel}",
onDismissRequest = onDismissRequest,
selectionItems = {
if (accountSummary.isVaultUnlocked) {
BitwardenBasicDialogRow(
text = stringResource(id = R.string.lock),
onClick = {
onLockAccountClick(accountSummary)
},
)
}
if (accountSummary.isLoggedIn) {
BitwardenBasicDialogRow(
text = stringResource(id = R.string.log_out),
onClick = {
onLogoutAccountClick(accountSummary)
},
)
} else {
BitwardenBasicDialogRow(
text = stringResource(id = R.string.remove_account),
onClick = {
onRemoveAccountClick(accountSummary)
},
)
}
},
)
}
@Composable
private fun AddAccountItem(
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
Row(
horizontalArrangement = Arrangement.Start,
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = ripple(
color = BitwardenTheme.colorScheme.background.pressed,
),
onClick = onClick,
)
.padding(vertical = 8.dp)
.then(modifier),
) {
Icon(
painter = rememberVectorPainter(id = R.drawable.ic_plus_small),
contentDescription = null,
tint = BitwardenTheme.colorScheme.icon.secondary,
modifier = Modifier
.padding(vertical = 8.dp)
.size(24.dp),
)
Spacer(modifier = Modifier.width(16.dp))
Text(
text = stringResource(id = R.string.add_account),
style = BitwardenTheme.typography.bodyLarge,
color = BitwardenTheme.colorScheme.text.interaction,
)
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Preview(showBackground = true)
@Composable
private fun NotificationCenter_preview() {
NotificationCenter(
isVisible = true,
notificationSummaries = listOf(
NotificationSummary(
title = "The title",
subtitle = "The subtitle"
),
)
.toImmutableList(),
onNotificationClick = {},
onDismissRequest = {},
topAppBarScrollBehavior =
TopAppBarDefaults.exitUntilCollapsedScrollBehavior(
state = rememberTopAppBarState(),
canScroll = { false },
),
)
}

View File

@@ -0,0 +1,39 @@
package com.x8bit.bitwarden.ui.vault.feature.unsyncedvaultitem
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import com.x8bit.bitwarden.R
import com.x8bit.bitwarden.ui.platform.components.button.BitwardenStandardIconButton
import com.x8bit.bitwarden.ui.platform.theme.BitwardenTheme
/**
* Displays an icon representing the Notification Center
*
* @param onClick An action to be invoked when the icon is clicked.
*/
@Composable
fun NotificationCenterActionItem(
onClick: () -> Unit,
) {
val contentDescription = stringResource(id = R.string.account)
BitwardenStandardIconButton(
vectorIconRes = R.drawable.ic_clock,
contentDescription = contentDescription,
onClick = onClick,
modifier = Modifier.testTag(tag = "NotificationCenter"),
)
}
@Preview(showBackground = true)
@Composable
private fun NotificationCenterActionItem_preview() {
BitwardenTheme {
NotificationCenterActionItem (
onClick = {},
)
}
}

View File

@@ -64,7 +64,10 @@ import com.x8bit.bitwarden.ui.platform.manager.exit.ExitManager
import com.x8bit.bitwarden.ui.platform.manager.intent.IntentManager
import com.x8bit.bitwarden.ui.platform.manager.permissions.PermissionsManager
import com.x8bit.bitwarden.ui.vault.feature.itemlisting.model.ListingItemOverflowAction
import com.x8bit.bitwarden.ui.vault.feature.unsyncedvaultitem.NotificationCenter
import com.x8bit.bitwarden.ui.vault.feature.unsyncedvaultitem.NotificationCenterActionItem
import com.x8bit.bitwarden.ui.vault.feature.vault.handlers.VaultHandlers
import com.x8bit.bitwarden.ui.vault.feature.vault.model.NotificationSummary
import com.x8bit.bitwarden.ui.vault.model.VaultItemListingType
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
@@ -181,10 +184,17 @@ private fun VaultScreenScaffold(
accountMenuVisible = shouldShowMenu
onDimBottomNavBarRequest(shouldShowMenu)
}
var notificationMenuVisible by rememberSaveable {
mutableStateOf(false)
}
val updateNotificationMenuVisibility = { shouldShowMenu: Boolean ->
notificationMenuVisible = shouldShowMenu
onDimBottomNavBarRequest(shouldShowMenu)
}
var shouldShowExitConfirmationDialog by rememberSaveable { mutableStateOf(false) }
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(
state = rememberTopAppBarState(),
canScroll = { !accountMenuVisible },
canScroll = { !accountMenuVisible && !notificationMenuVisible },
)
// Dynamic dialogs
@@ -231,6 +241,11 @@ private fun VaultScreenScaffold(
?.let { TopAppBarDividerStyle.STATIC }
?: TopAppBarDividerStyle.ON_SCROLL,
actions = {
NotificationCenterActionItem(
onClick = {
updateNotificationMenuVisibility(!notificationMenuVisible)
},
)
BitwardenAccountActionItem(
initials = state.initials,
color = state.avatarColor,
@@ -263,7 +278,7 @@ private fun VaultScreenScaffold(
},
floatingActionButton = {
AnimatedVisibility(
visible = state.viewState.hasFab && !accountMenuVisible,
visible = state.viewState.hasFab && !accountMenuVisible && !notificationMenuVisible,
enter = scaleIn(),
exit = scaleOut(),
) {
@@ -360,6 +375,15 @@ private fun VaultScreenScaffold(
topAppBarScrollBehavior = scrollBehavior,
modifier = outerModifier,
)
NotificationCenter(
isVisible = notificationMenuVisible,
notificationSummaries = state.notificationSummaries.toImmutableList(),
onNotificationClick = {},
onDismissRequest = { updateNotificationMenuVisibility(false) },
topAppBarScrollBehavior = scrollBehavior,
modifier = outerModifier,
)
}
}
}

View File

@@ -4,6 +4,7 @@ import android.os.Build
import android.os.Parcelable
import androidx.compose.ui.graphics.Color
import androidx.lifecycle.viewModelScope
import com.bitwarden.vault.CipherView
import com.x8bit.bitwarden.R
import com.x8bit.bitwarden.data.auth.repository.AuthRepository
import com.x8bit.bitwarden.data.auth.repository.model.SwitchAccountResult
@@ -24,6 +25,7 @@ import com.x8bit.bitwarden.data.vault.datasource.network.model.PolicyTypeJson
import com.x8bit.bitwarden.data.vault.repository.VaultRepository
import com.x8bit.bitwarden.data.vault.repository.model.GenerateTotpResult
import com.x8bit.bitwarden.data.vault.repository.model.VaultData
import com.x8bit.bitwarden.data.vault.repository.util.toNotificationSummary
import com.x8bit.bitwarden.ui.platform.base.BaseViewModel
import com.x8bit.bitwarden.ui.platform.base.util.Text
import com.x8bit.bitwarden.ui.platform.base.util.asText
@@ -33,6 +35,7 @@ import com.x8bit.bitwarden.ui.platform.components.model.AccountSummary
import com.x8bit.bitwarden.ui.platform.components.model.IconData
import com.x8bit.bitwarden.ui.platform.components.model.IconRes
import com.x8bit.bitwarden.ui.vault.feature.itemlisting.model.ListingItemOverflowAction
import com.x8bit.bitwarden.ui.vault.feature.vault.model.NotificationSummary
import com.x8bit.bitwarden.ui.vault.feature.vault.model.VaultFilterData
import com.x8bit.bitwarden.ui.vault.feature.vault.model.VaultFilterType
import com.x8bit.bitwarden.ui.vault.feature.vault.util.initials
@@ -75,6 +78,7 @@ class VaultViewModel @Inject constructor(
initialState = run {
val userState = requireNotNull(authRepository.userStateFlow.value)
val accountSummaries = userState.toAccountSummaries()
val notificationSummaries = listOf<NotificationSummary>()
val activeAccountSummary = userState.toActiveAccountSummary()
val vaultFilterData = userState.activeAccount.toVaultFilterData(
isIndividualVaultDisabled = policyManager
@@ -87,6 +91,7 @@ class VaultViewModel @Inject constructor(
initials = activeAccountSummary.initials,
avatarColorString = activeAccountSummary.avatarColorHex,
accountSummaries = accountSummaries,
notificationSummaries = notificationSummaries,
vaultFilterData = vaultFilterData,
viewState = VaultState.ViewState.Loading,
isIconLoadingDisabled = settingsRepository.isIconLoadingDisabled,
@@ -550,8 +555,9 @@ class VaultViewModel @Inject constructor(
),
)
}
mutableStateFlow.update {
mutableStateFlow.update { it ->
it.copy(
notificationSummaries = vaultData.data.offlineCipherViewList.map { view -> view.toNotificationSummary()},
viewState = vaultData.data.toViewState(
baseIconUrl = state.baseIconUrl,
isIconLoadingDisabled = state.isIconLoadingDisabled,
@@ -655,6 +661,7 @@ data class VaultState(
private val avatarColorString: String,
val initials: String,
val accountSummaries: List<AccountSummary>,
val notificationSummaries: List<NotificationSummary>,
val vaultFilterData: VaultFilterData? = null,
val viewState: ViewState,
val dialog: DialogState? = null,

View File

@@ -0,0 +1,17 @@
package com.x8bit.bitwarden.ui.vault.feature.vault.model
import android.os.Parcelable
import androidx.compose.ui.graphics.Color
import com.x8bit.bitwarden.ui.platform.base.util.hexToColor
import kotlinx.parcelize.Parcelize
/**
* Summary information about a user's account.
*
* @property
*/
@Parcelize
data class NotificationSummary(
val title: String,
val subtitle: String,
) : Parcelable

View File

@@ -8,6 +8,7 @@ import com.x8bit.bitwarden.data.vault.datasource.disk.dao.FakeCiphersDao
import com.x8bit.bitwarden.data.vault.datasource.disk.dao.FakeCollectionsDao
import com.x8bit.bitwarden.data.vault.datasource.disk.dao.FakeDomainsDao
import com.x8bit.bitwarden.data.vault.datasource.disk.dao.FakeFoldersDao
import com.x8bit.bitwarden.data.vault.datasource.disk.dao.FakeOfflineCiphersDao
import com.x8bit.bitwarden.data.vault.datasource.disk.dao.FakeSendsDao
import com.x8bit.bitwarden.data.vault.datasource.disk.entity.CipherEntity
import com.x8bit.bitwarden.data.vault.datasource.disk.entity.CollectionEntity
@@ -37,6 +38,7 @@ class VaultDiskSourceTest {
private val json = PlatformNetworkModule.providesJson()
private val dispatcherManager: FakeDispatcherManager = FakeDispatcherManager()
private lateinit var ciphersDao: FakeCiphersDao
private lateinit var offlineCiphersDao: FakeOfflineCiphersDao
private lateinit var collectionsDao: FakeCollectionsDao
private lateinit var domainsDao: FakeDomainsDao
private lateinit var foldersDao: FakeFoldersDao
@@ -47,6 +49,7 @@ class VaultDiskSourceTest {
@BeforeEach
fun setup() {
ciphersDao = FakeCiphersDao()
offlineCiphersDao = FakeOfflineCiphersDao()
collectionsDao = FakeCollectionsDao()
domainsDao = FakeDomainsDao()
foldersDao = FakeFoldersDao()
@@ -57,6 +60,7 @@ class VaultDiskSourceTest {
domainsDao = domainsDao,
foldersDao = foldersDao,
sendsDao = sendsDao,
offlineCiphersDao = offlineCiphersDao,
json = json,
dispatcherManager = dispatcherManager,
)

View File

@@ -0,0 +1,46 @@
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.OfflineCipherEntity
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
class FakeOfflineCiphersDao : OfflineCiphersDao {
val storedCiphers = mutableListOf<OfflineCipherEntity>()
var deleteCipherCalled: Boolean = false
var deleteCiphersCalled: Boolean = false
var insertCiphersCalled: Boolean = false
private val ciphersFlow = bufferedMutableSharedFlow<List<OfflineCipherEntity>>(replay = 1)
init {
ciphersFlow.tryEmit(emptyList())
}
override suspend fun deleteAllCiphers(userId: String): Int {
deleteCiphersCalled = true
val count = storedCiphers.count { it.userId == userId }
storedCiphers.removeAll { it.userId == userId }
ciphersFlow.tryEmit(storedCiphers.toList())
return count
}
override suspend fun deleteCipher(userId: String, cipherId: String): Int {
deleteCipherCalled = true
val count = storedCiphers.count { it.userId == userId && it.id == cipherId }
storedCiphers.removeAll { it.userId == userId && it.id == cipherId }
ciphersFlow.tryEmit(storedCiphers.toList())
return count
}
override suspend fun insertCiphers(ciphers: List<OfflineCipherEntity>) {
storedCiphers.addAll(ciphers)
ciphersFlow.tryEmit(ciphers.toList())
insertCiphersCalled = true
}
override fun getAllCiphers(userId: String): Flow<List<OfflineCipherEntity>> =
ciphersFlow.map { ciphers -> ciphers.filter { it.userId == userId } }
}

View File

@@ -43,6 +43,7 @@ import com.x8bit.bitwarden.ui.util.performLockAccountClick
import com.x8bit.bitwarden.ui.util.performLogoutAccountClick
import com.x8bit.bitwarden.ui.util.performRemoveAccountClick
import com.x8bit.bitwarden.ui.util.performYesDialogButtonClick
import com.x8bit.bitwarden.ui.vault.feature.vault.model.NotificationSummary
import com.x8bit.bitwarden.ui.vault.feature.vault.model.VaultFilterData
import com.x8bit.bitwarden.ui.vault.feature.vault.model.VaultFilterType
import com.x8bit.bitwarden.ui.vault.model.VaultItemListingType
@@ -1256,6 +1257,7 @@ private val DEFAULT_STATE: VaultState = VaultState(
hideNotificationsDialog = true,
isRefreshing = false,
showImportActionCard = false,
notificationSummaries = listOf()
)
private val DEFAULT_CONTENT_VIEW_STATE: VaultState.ViewState.Content = VaultState.ViewState.Content(

View File

@@ -1729,4 +1729,5 @@ private fun createMockVaultState(
hideNotificationsDialog = true,
showImportActionCard = true,
isRefreshing = false,
notificationSummaries = listOf()
)