mirror of
https://github.com/bitwarden/android.git
synced 2026-05-09 13:29:18 -05:00
Compare commits
27 Commits
temp-test
...
poc/offlin
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
30531a40d3 | ||
|
|
d4d980b576 | ||
|
|
ee61c83409 | ||
|
|
b30e52245f | ||
|
|
d7b935031e | ||
|
|
e44cfe16d5 | ||
|
|
2e0f07e138 | ||
|
|
028d382a5b | ||
|
|
425b085a96 | ||
|
|
cee686f92e | ||
|
|
5c62290a3d | ||
|
|
a325e92184 | ||
|
|
3dce7838b5 | ||
|
|
bc6c21463b | ||
|
|
991c82b277 | ||
|
|
dc7fca0311 | ||
|
|
c88157fbce | ||
|
|
3bed0f0804 | ||
|
|
cf605ceb4b | ||
|
|
b0194028a8 | ||
|
|
53c0c7d2ad | ||
|
|
563427c99f | ||
|
|
810fd2f428 | ||
|
|
89dceb2485 | ||
|
|
36c07bdad7 | ||
|
|
9cb8f414ba | ||
|
|
c7d7ed6cb9 |
@@ -247,4 +247,4 @@
|
||||
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '64bb48a6bc6a544d168b0b4f4862cbcd')"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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')"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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].
|
||||
*
|
||||
|
||||
@@ -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].
|
||||
*/
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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
|
||||
|
||||
) {
|
||||
}
|
||||
@@ -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?,
|
||||
|
||||
|
||||
@@ -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
|
||||
)
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
)
|
||||
@@ -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>,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 },
|
||||
),
|
||||
)
|
||||
}
|
||||
@@ -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 = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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 } }
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -1729,4 +1729,5 @@ private fun createMockVaultState(
|
||||
hideNotificationsDialog = true,
|
||||
showImportActionCard = true,
|
||||
isRefreshing = false,
|
||||
notificationSummaries = listOf()
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user