Compare commits

...

17 Commits

Author SHA1 Message Date
David Perez
966a521e0b PM-14458: Fix notifications prompt on first use (#4274) 2024-11-08 17:01:33 -06:00
Patrick Honkonen
4fb67825d3 [PM-14656] Add default value to BaseEnumeratedIntSerializer (#4273) 2024-11-08 17:46:28 -05:00
David Perez
c0c97af177 PM-14411: Allow accessibility autofill to run when app is already in … (#4267) 2024-11-08 15:04:51 -06:00
David Perez
22efdc23a2 PM-14644: Segmented control should be conditionally displayed for modal generator screen (#4265) 2024-11-08 12:51:09 -06:00
David Perez
c977dfc877 PM-14458: Notifications permission for hotfix (#4264) 2024-11-08 12:40:55 -06:00
David Perez
afa1b598ad PM-14480: Update IntentManager to be able to launch apps (#4266) 2024-11-08 12:39:37 -06:00
Patrick Honkonen
3c77933b3d [PM-14596] Sync on database scheme change (#4257) 2024-11-08 09:29:48 -05:00
Patrick Honkonen
b5752c10ed Bump VaultDatabase to version 5 (#4253) 2024-11-07 10:52:32 -05:00
David Perez
ec85e7af61 PM-11753: Vault flows should listen to vault onlock state to ensure d… (#4248) 2024-11-06 15:16:51 -06:00
Patrick Honkonen
816b9769a1 [PM-14526] Add JsonNames annotation to SyncResponseJson (#4247) 2024-11-06 15:38:00 -05:00
David Perez
25097cbae1 PM-14433: Null domain data (#4243) 2024-11-06 10:59:20 -06:00
Dave Severns
5a4b8d64ab PM-14433 update flow type to nullable so we can handle gracefully and avoid crash. (#4231) 2024-11-04 20:23:36 -05:00
Patrick Honkonen
5523d99400 [PM-14346] Run alias generation on the IO dispatcher (#4215)
(cherry picked from commit 8f2d55c146)
2024-11-01 12:04:28 -04:00
David Perez
9f8d21cb95 PM-14255: Remove accessibility logic to improve overall performance (#4206)
(cherry picked from commit 4831750ffd)
2024-11-01 12:04:28 -04:00
Patrick Honkonen
75fc9fe210 [PM-14254] Keep Android verifier for JNI usage (#4197)
(cherry picked from commit fab018782c)
2024-11-01 12:04:27 -04:00
Álison Fernandes
42671aadfb [PM-14224] Automate Play Store prod variant publishing (#4183) 2024-10-30 14:13:04 +00:00
aj-rosado
d71389ab02 [PM-14241] Checking if Timber tree has been added before trying to remove it (#4194) 2024-10-30 13:12:20 +01:00
54 changed files with 1295 additions and 161 deletions

View File

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

View File

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

View File

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

View File

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

View File

@@ -190,12 +190,14 @@ class MainViewModel @Inject constructor(
private fun handleAccessibilitySelectionReceive(
action: MainAction.Internal.AccessibilitySelectionReceive,
) {
specialCircumstanceManager.specialCircumstance = null
sendEvent(MainEvent.CompleteAccessibilityAutofill(cipherView = action.cipherView))
}
private fun handleAutofillSelectionReceive(
action: MainAction.Internal.AutofillSelectionReceive,
) {
specialCircumstanceManager.specialCircumstance = null
sendEvent(MainEvent.CompleteAutofill(cipherView = action.cipherView))
}

View File

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

View File

@@ -26,18 +26,18 @@ class AccessibilityCompletionManagerImpl(
.intent
?.getAutofillSelectionDataOrNull()
?: run {
activity.finish()
activity.finishAndRemoveTask()
return
}
if (autofillSelectionData.framework != AutofillSelectionData.Framework.ACCESSIBILITY) {
activity.finish()
activity.finishAndRemoveTask()
return
}
val uri = autofillSelectionData
.uri
?.toUriOrNull()
?: run {
activity.finish()
activity.finishAndRemoveTask()
return
}
@@ -47,7 +47,7 @@ class AccessibilityCompletionManagerImpl(
)
mainScope.launch {
totpManager.tryCopyTotpToClipboard(cipherView = cipherView)
activity.finish()
}
activity.finishAndRemoveTask()
}
}

View File

@@ -38,7 +38,7 @@ fun createAutofillSelectionIntent(
.apply {
// This helps prevent a crash when using the accessibility framework
if (framework == AutofillSelectionData.Framework.ACCESSIBILITY) {
setFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
setFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_REORDER_TO_FRONT)
}
putExtra(
AUTOFILL_BUNDLE_KEY,

View File

@@ -74,6 +74,11 @@ interface SettingsDiskSource {
*/
var lastDatabaseSchemeChangeInstant: Instant?
/**
* Emits updates that track [lastDatabaseSchemeChangeInstant].
*/
val lastDatabaseSchemeChangeInstantFlow: Flow<Instant?>
/**
* Clears all the settings data for the given user.
*/

View File

@@ -71,6 +71,8 @@ class SettingsDiskSourceImpl(
private val mutableHasUserLoggedInOrCreatedAccountFlow = bufferedMutableSharedFlow<Boolean?>()
private val mutableLastDatabaseSchemeChangeInstantFlow = bufferedMutableSharedFlow<Instant?>()
private val mutableScreenCaptureAllowedFlowMap =
mutableMapOf<String, MutableSharedFlow<Boolean?>>()
@@ -154,7 +156,14 @@ class SettingsDiskSourceImpl(
override var lastDatabaseSchemeChangeInstant: Instant?
get() = getLong(LAST_SCHEME_CHANGE_INSTANT)?.let { Instant.ofEpochMilli(it) }
set(value) = putLong(LAST_SCHEME_CHANGE_INSTANT, value?.toEpochMilli())
set(value) {
putLong(LAST_SCHEME_CHANGE_INSTANT, value?.toEpochMilli())
mutableLastDatabaseSchemeChangeInstantFlow.tryEmit(value)
}
override val lastDatabaseSchemeChangeInstantFlow: Flow<Instant?>
get() = mutableLastDatabaseSchemeChangeInstantFlow
.onSubscription { emit(lastDatabaseSchemeChangeInstant) }
override fun clearData(userId: String) {
storeVaultTimeoutInMinutes(userId = userId, vaultTimeoutInMinutes = null)

View File

@@ -10,11 +10,13 @@ import kotlinx.serialization.encoding.Encoder
/**
* Base [KSerializer] for mapping an [Enum] with possible values given by [values] to/from integer
* values, which should be specified using [SerialName].
* values, which should be specified using [SerialName]. If a [default] value is provided, it will
* be used when an unknown value is provided.
*/
@Suppress("UnnecessaryAbstractClass")
abstract class BaseEnumeratedIntSerializer<T : Enum<T>>(
private val values: Array<T>,
private val default: T? = null,
) : KSerializer<T> {
override val descriptor: SerialDescriptor
@@ -25,7 +27,9 @@ abstract class BaseEnumeratedIntSerializer<T : Enum<T>>(
override fun deserialize(decoder: Decoder): T {
val decodedValue = decoder.decodeInt().toString()
return values.first { it.serialNameAnnotation?.value == decodedValue }
return values.firstOrNull { it.serialNameAnnotation?.value == decodedValue }
?: default
?: throw IllegalArgumentException("Unknown value $decodedValue")
}
override fun serialize(encoder: Encoder, value: T) {

View File

@@ -1,5 +1,6 @@
package com.x8bit.bitwarden.data.platform.manager
import kotlinx.coroutines.flow.Flow
import java.time.Instant
/**
@@ -14,4 +15,9 @@ interface DatabaseSchemeManager {
* that a scheme change to any database will update this value and trigger a sync.
*/
var lastDatabaseSchemeChangeInstant: Instant?
/**
* A flow of the last database schema change instant.
*/
val lastDatabaseSchemeChangeInstantFlow: Flow<Instant?>
}

View File

@@ -1,6 +1,10 @@
package com.x8bit.bitwarden.data.platform.manager
import com.x8bit.bitwarden.data.platform.datasource.disk.SettingsDiskSource
import com.x8bit.bitwarden.data.platform.manager.dispatcher.DispatcherManager
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.stateIn
import java.time.Instant
/**
@@ -8,10 +12,23 @@ import java.time.Instant
*/
class DatabaseSchemeManagerImpl(
val settingsDiskSource: SettingsDiskSource,
val dispatcherManager: DispatcherManager,
) : DatabaseSchemeManager {
private val unconfinedScope = CoroutineScope(dispatcherManager.unconfined)
override var lastDatabaseSchemeChangeInstant: Instant?
get() = settingsDiskSource.lastDatabaseSchemeChangeInstant
set(value) {
settingsDiskSource.lastDatabaseSchemeChangeInstant = value
}
override val lastDatabaseSchemeChangeInstantFlow =
settingsDiskSource
.lastDatabaseSchemeChangeInstantFlow
.stateIn(
scope = unconfinedScope,
started = SharingStarted.Eagerly,
initialValue = settingsDiskSource.lastDatabaseSchemeChangeInstant,
)
}

View File

@@ -304,7 +304,9 @@ object PlatformManagerModule {
@Singleton
fun provideDatabaseSchemeManager(
settingsDiskSource: SettingsDiskSource,
dispatcherManager: DispatcherManager,
): DatabaseSchemeManager = DatabaseSchemeManagerImpl(
settingsDiskSource = settingsDiskSource,
dispatcherManager = dispatcherManager,
)
}

View File

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

View File

@@ -44,7 +44,7 @@ class BitwardenAutofillTileService : TileService() {
}
accessibilityAutofillManager.accessibilityAction = AccessibilityAction.AttemptParseUri
val intent = Intent(applicationContext, AccessibilityActivity::class.java)
.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK)
if (isBuildVersionBelow(Build.VERSION_CODES.UPSIDE_DOWN_CAKE)) {
@Suppress("DEPRECATION")
startActivityAndCollapse(intent)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -81,8 +81,19 @@ enum class PolicyTypeJson {
*/
@SerialName("11")
ACTIVATE_AUTOFILL,
/**
* Represents an unknown policy type.
*
* This is used for forward compatibility to handle new policy types that the client doesn't yet
* understand.
*/
@SerialName("-1")
UNKNOWN,
}
@Keep
private class PolicyTypeSerializer :
BaseEnumeratedIntSerializer<PolicyTypeJson>(PolicyTypeJson.entries.toTypedArray())
private class PolicyTypeSerializer : BaseEnumeratedIntSerializer<PolicyTypeJson>(
values = PolicyTypeJson.entries.toTypedArray(),
default = PolicyTypeJson.UNKNOWN,
)

View File

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

View File

@@ -36,6 +36,7 @@ import com.x8bit.bitwarden.data.platform.repository.util.combineDataStates
import com.x8bit.bitwarden.data.platform.repository.util.map
import com.x8bit.bitwarden.data.platform.repository.util.mapNullable
import com.x8bit.bitwarden.data.platform.repository.util.observeWhenSubscribedAndLoggedIn
import com.x8bit.bitwarden.data.platform.repository.util.observeWhenSubscribedAndUnlocked
import com.x8bit.bitwarden.data.platform.repository.util.updateToPendingOrLoading
import com.x8bit.bitwarden.data.platform.util.asFailure
import com.x8bit.bitwarden.data.platform.util.asSuccess
@@ -98,6 +99,7 @@ import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.flow.flatMapLatest
@@ -222,7 +224,13 @@ class VaultRepositoryImpl(
// Cancel any ongoing sync request and clear the vault data in memory every time
// the user switches or the vault is locked for the active user.
merge(
authDiskSource.userSwitchingChangesFlow,
authDiskSource
.userSwitchingChangesFlow
.onEach {
// DomainState is not part of the locked data but should still be cleared
// when the user changes
mutableDomainsStateFlow.update { DataState.Loading }
},
vaultLockManager
.vaultUnlockDataStateFlow
.filter { vaultUnlockDataList ->
@@ -238,7 +246,10 @@ class VaultRepositoryImpl(
// Setup ciphers MutableStateFlow
mutableCiphersStateFlow
.observeWhenSubscribedAndLoggedIn(authDiskSource.userStateFlow) { activeUserId ->
.observeWhenSubscribedAndUnlocked(
userStateFlow = authDiskSource.userStateFlow,
vaultUnlockFlow = vaultUnlockDataStateFlow,
) { activeUserId ->
observeVaultDiskCiphers(activeUserId)
}
.launchIn(unconfinedScope)
@@ -250,19 +261,28 @@ class VaultRepositoryImpl(
.launchIn(unconfinedScope)
// Setup folders MutableStateFlow
mutableFoldersStateFlow
.observeWhenSubscribedAndLoggedIn(authDiskSource.userStateFlow) { activeUserId ->
.observeWhenSubscribedAndUnlocked(
userStateFlow = authDiskSource.userStateFlow,
vaultUnlockFlow = vaultUnlockDataStateFlow,
) { activeUserId ->
observeVaultDiskFolders(activeUserId)
}
.launchIn(unconfinedScope)
// Setup collections MutableStateFlow
mutableCollectionsStateFlow
.observeWhenSubscribedAndLoggedIn(authDiskSource.userStateFlow) { activeUserId ->
.observeWhenSubscribedAndUnlocked(
userStateFlow = authDiskSource.userStateFlow,
vaultUnlockFlow = vaultUnlockDataStateFlow,
) { activeUserId ->
observeVaultDiskCollections(activeUserId)
}
.launchIn(unconfinedScope)
// Setup sends MutableStateFlow
mutableSendDataStateFlow
.observeWhenSubscribedAndLoggedIn(authDiskSource.userStateFlow) { activeUserId ->
.observeWhenSubscribedAndUnlocked(
userStateFlow = authDiskSource.userStateFlow,
vaultUnlockFlow = vaultUnlockDataStateFlow,
) { activeUserId ->
observeVaultDiskSends(activeUserId)
}
.launchIn(unconfinedScope)
@@ -301,11 +321,16 @@ class VaultRepositoryImpl(
.syncFolderUpsertFlow
.onEach(::syncFolderIfNecessary)
.launchIn(ioScope)
databaseSchemeManager
.lastDatabaseSchemeChangeInstantFlow
.filterNotNull()
.onEach { sync() }
.launchIn(ioScope)
}
private fun clearUnlockedData() {
mutableCiphersStateFlow.update { DataState.Loading }
mutableDomainsStateFlow.update { DataState.Loading }
mutableFoldersStateFlow.update { DataState.Loading }
mutableCollectionsStateFlow.update { DataState.Loading }
mutableSendDataStateFlow.update { DataState.Loading }

View File

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

View File

@@ -9,6 +9,7 @@ import androidx.compose.material3.SheetState
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.res.stringResource
@@ -19,6 +20,7 @@ import com.x8bit.bitwarden.ui.platform.components.appbar.NavigationIcon
import com.x8bit.bitwarden.ui.platform.components.scaffold.BitwardenScaffold
import com.x8bit.bitwarden.ui.platform.components.util.rememberVectorPainter
import com.x8bit.bitwarden.ui.platform.theme.BitwardenTheme
import kotlinx.coroutines.launch
/**
* A reusable modal bottom sheet that applies provides a bottom sheet layout with the
@@ -28,11 +30,12 @@ import com.x8bit.bitwarden.ui.platform.theme.BitwardenTheme
* @param sheetTitle The title to display in the [BitwardenTopAppBar]
* @param onDismiss The action to perform when the bottom sheet is dismissed will also be performed
* when the "close" icon is clicked, caller must handle any desired animation or hiding of the
* bottom sheet.
* bottom sheet. This will be invoked _after_ the sheet has been animated away.
* @param showBottomSheet Whether or not to show the bottom sheet, by default this is true assuming
* the showing/hiding will be handled by the caller.
* @param sheetContent Content to display in the bottom sheet. The content is passed the padding
* from the containing [BitwardenScaffold].
* from the containing [BitwardenScaffold] and a `onDismiss` lambda to be used for manual dismissal
* that will include the dismissal animation.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -42,7 +45,10 @@ fun BitwardenModalBottomSheet(
modifier: Modifier = Modifier,
showBottomSheet: Boolean = true,
sheetState: SheetState = rememberModalBottomSheetState(),
sheetContent: @Composable (PaddingValues) -> Unit,
sheetContent: @Composable (
paddingValues: PaddingValues,
animatedOnDismiss: () -> Unit,
) -> Unit,
) {
if (!showBottomSheet) return
ModalBottomSheet(
@@ -56,13 +62,14 @@ fun BitwardenModalBottomSheet(
shape = BitwardenTheme.shapes.bottomSheet,
) {
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
val animatedOnDismiss = sheetState.createAnimatedDismissAction(onDismiss = onDismiss)
BitwardenScaffold(
topBar = {
BitwardenTopAppBar(
title = sheetTitle,
navigationIcon = NavigationIcon(
navigationIcon = rememberVectorPainter(R.drawable.ic_close),
onNavigationIconClick = onDismiss,
onNavigationIconClick = animatedOnDismiss,
navigationIconContentDescription = stringResource(R.string.close),
),
scrollBehavior = scrollBehavior,
@@ -73,7 +80,18 @@ fun BitwardenModalBottomSheet(
.nestedScroll(scrollBehavior.nestedScrollConnection)
.fillMaxSize(),
) { paddingValues ->
sheetContent(paddingValues)
sheetContent(paddingValues, animatedOnDismiss)
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun SheetState.createAnimatedDismissAction(onDismiss: () -> Unit): () -> Unit {
val scope = rememberCoroutineScope()
return {
scope
.launch { this@createAnimatedDismissAction.hide() }
.invokeOnCompletion { onDismiss() }
}
}

View File

@@ -29,6 +29,7 @@ fun BitwardenSegmentedButton(
options: ImmutableList<SegmentedButtonState>,
modifier: Modifier = Modifier,
) {
if (options.isEmpty()) return
Box(
modifier = modifier
.background(color = BitwardenTheme.colorScheme.background.secondary)

View File

@@ -1,5 +1,8 @@
package com.x8bit.bitwarden.ui.platform.feature.settings.accountsecurity.pendingrequests
import android.Manifest
import android.annotation.SuppressLint
import android.os.Build
import android.widget.Toast
import androidx.compose.foundation.Image
import androidx.compose.foundation.clickable
@@ -14,6 +17,7 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.rememberScrollState
@@ -21,6 +25,7 @@ import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.material3.rememberTopAppBarState
import androidx.compose.material3.ripple
import androidx.compose.runtime.Composable
@@ -40,11 +45,16 @@ import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.x8bit.bitwarden.R
import com.x8bit.bitwarden.data.platform.util.isBuildVersionBelow
import com.x8bit.bitwarden.data.platform.util.isFdroid
import com.x8bit.bitwarden.ui.platform.base.util.EventsEffect
import com.x8bit.bitwarden.ui.platform.base.util.LivecycleEventEffect
import com.x8bit.bitwarden.ui.platform.base.util.bottomDivider
import com.x8bit.bitwarden.ui.platform.base.util.standardHorizontalMargin
import com.x8bit.bitwarden.ui.platform.components.appbar.BitwardenTopAppBar
import com.x8bit.bitwarden.ui.platform.components.bottomsheet.BitwardenModalBottomSheet
import com.x8bit.bitwarden.ui.platform.components.button.BitwardenFilledButton
import com.x8bit.bitwarden.ui.platform.components.button.BitwardenOutlinedButton
import com.x8bit.bitwarden.ui.platform.components.button.BitwardenOutlinedButtonWithIcon
import com.x8bit.bitwarden.ui.platform.components.content.BitwardenErrorContent
import com.x8bit.bitwarden.ui.platform.components.content.BitwardenLoadingContent
@@ -52,6 +62,8 @@ import com.x8bit.bitwarden.ui.platform.components.dialog.BitwardenTwoButtonDialo
import com.x8bit.bitwarden.ui.platform.components.scaffold.BitwardenScaffold
import com.x8bit.bitwarden.ui.platform.components.scaffold.rememberBitwardenPullToRefreshState
import com.x8bit.bitwarden.ui.platform.components.util.rememberVectorPainter
import com.x8bit.bitwarden.ui.platform.composition.LocalPermissionsManager
import com.x8bit.bitwarden.ui.platform.manager.permissions.PermissionsManager
import com.x8bit.bitwarden.ui.platform.theme.BitwardenTheme
/**
@@ -62,6 +74,7 @@ import com.x8bit.bitwarden.ui.platform.theme.BitwardenTheme
@Composable
fun PendingRequestsScreen(
viewModel: PendingRequestsViewModel = hiltViewModel(),
permissionsManager: PermissionsManager = LocalPermissionsManager.current,
onNavigateBack: () -> Unit,
onNavigateToLoginApproval: (fingerprint: String) -> Unit,
) {
@@ -98,6 +111,29 @@ fun PendingRequestsScreen(
}
}
val hideBottomSheet = state.hideBottomSheet ||
isFdroid ||
isBuildVersionBelow(Build.VERSION_CODES.TIRAMISU) ||
permissionsManager.checkPermission(Manifest.permission.POST_NOTIFICATIONS) ||
permissionsManager.shouldShowRequestPermissionRationale(
permission = Manifest.permission.POST_NOTIFICATIONS,
)
BitwardenModalBottomSheet(
showBottomSheet = !hideBottomSheet,
sheetTitle = stringResource(R.string.enable_notifications),
onDismiss = remember(viewModel) {
{ viewModel.trySendAction(PendingRequestsAction.HideBottomSheet) }
},
sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true),
modifier = Modifier.statusBarsPadding(),
) { paddingValues, animatedOnDismiss ->
PendingRequestsBottomSheetContent(
modifier = Modifier.padding(paddingValues),
permissionsManager = permissionsManager,
onDismiss = animatedOnDismiss,
)
}
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState())
BitwardenScaffold(
modifier = Modifier
@@ -338,3 +374,68 @@ private fun PendingRequestsEmpty(
Spacer(modifier = Modifier.height(64.dp))
}
}
@Composable
private fun PendingRequestsBottomSheetContent(
permissionsManager: PermissionsManager,
onDismiss: () -> Unit,
modifier: Modifier = Modifier,
) {
val notificationPermissionLauncher = permissionsManager.getLauncher {
onDismiss()
}
Column(modifier = modifier.verticalScroll(rememberScrollState())) {
Spacer(modifier = Modifier.height(height = 24.dp))
Image(
painter = rememberVectorPainter(id = R.drawable.img_2fa),
contentDescription = null,
modifier = Modifier
.standardHorizontalMargin()
.size(size = 132.dp)
.align(alignment = Alignment.CenterHorizontally),
)
Spacer(modifier = Modifier.height(height = 24.dp))
Text(
text = stringResource(id = R.string.log_in_quickly_and_easily_across_devices),
style = BitwardenTheme.typography.titleMedium,
color = BitwardenTheme.colorScheme.text.primary,
textAlign = TextAlign.Center,
modifier = Modifier
.fillMaxWidth()
.standardHorizontalMargin(),
)
Spacer(modifier = Modifier.height(height = 12.dp))
@Suppress("MaxLineLength")
Text(
text = stringResource(
id = R.string.bitwarden_can_notify_you_each_time_you_receive_a_new_login_request_from_another_device,
),
style = BitwardenTheme.typography.bodyMedium,
color = BitwardenTheme.colorScheme.text.primary,
textAlign = TextAlign.Center,
modifier = Modifier
.fillMaxWidth()
.standardHorizontalMargin(),
)
Spacer(modifier = Modifier.height(height = 24.dp))
BitwardenFilledButton(
label = stringResource(id = R.string.enable_notifications),
onClick = {
@SuppressLint("InlinedApi")
notificationPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS)
},
modifier = Modifier
.fillMaxWidth()
.standardHorizontalMargin(),
)
Spacer(modifier = Modifier.height(height = 12.dp))
BitwardenOutlinedButton(
label = stringResource(id = R.string.skip_for_now),
onClick = onDismiss,
modifier = Modifier
.fillMaxWidth()
.standardHorizontalMargin(),
)
Spacer(modifier = Modifier.navigationBarsPadding())
}
}

View File

@@ -27,6 +27,7 @@ private const val KEY_STATE = "state"
/**
* View model for the pending login requests screen.
*/
@Suppress("TooManyFunctions")
@HiltViewModel
class PendingRequestsViewModel @Inject constructor(
private val clock: Clock,
@@ -39,6 +40,7 @@ class PendingRequestsViewModel @Inject constructor(
viewState = PendingRequestsState.ViewState.Loading,
isPullToRefreshSettingEnabled = settingsRepository.getPullToRefreshEnabledFlow().value,
isRefreshing = false,
hideBottomSheet = false,
),
) {
private var authJob: Job = Job().apply { complete() }
@@ -56,6 +58,7 @@ class PendingRequestsViewModel @Inject constructor(
when (action) {
PendingRequestsAction.CloseClick -> handleCloseClicked()
PendingRequestsAction.DeclineAllRequestsConfirm -> handleDeclineAllRequestsConfirmed()
PendingRequestsAction.HideBottomSheet -> handleHideBottomSheet()
PendingRequestsAction.LifecycleResume -> handleOnLifecycleResumed()
PendingRequestsAction.RefreshPull -> handleRefreshPull()
is PendingRequestsAction.PendingRequestRowClick -> {
@@ -89,6 +92,10 @@ class PendingRequestsViewModel @Inject constructor(
}
}
private fun handleHideBottomSheet() {
mutableStateFlow.update { it.copy(hideBottomSheet = true) }
}
private fun handleOnLifecycleResumed() {
updateAuthRequestList()
}
@@ -193,6 +200,7 @@ data class PendingRequestsState(
val viewState: ViewState,
private val isPullToRefreshSettingEnabled: Boolean,
val isRefreshing: Boolean,
val hideBottomSheet: Boolean,
) : Parcelable {
/**
* Indicates that the pull-to-refresh should be enabled in the UI.
@@ -297,6 +305,11 @@ sealed class PendingRequestsAction {
*/
data object DeclineAllRequestsConfirm : PendingRequestsAction()
/**
* The user has dismissed the bottom sheet.
*/
data object HideBottomSheet : PendingRequestsAction()
/**
* The screen has been re-opened and should be updated.
*/

View File

@@ -6,6 +6,7 @@ import android.content.ActivityNotFoundException
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.content.IntentSender
import android.content.pm.PackageManager
import android.net.Uri
import android.os.Build
@@ -27,6 +28,7 @@ import com.x8bit.bitwarden.MainActivity
import com.x8bit.bitwarden.R
import com.x8bit.bitwarden.data.autofill.util.toPendingIntentMutabilityFlag
import com.x8bit.bitwarden.data.platform.annotation.OmitFromCoverage
import com.x8bit.bitwarden.data.platform.util.isBuildVersionBelow
import com.x8bit.bitwarden.ui.platform.util.toFormattedPattern
import java.io.File
import java.time.Clock
@@ -82,7 +84,7 @@ class IntentManagerImpl(
override fun startActivity(intent: Intent) {
try {
context.startActivity(intent)
} catch (e: ActivityNotFoundException) {
} catch (_: ActivityNotFoundException) {
// no-op
}
}
@@ -115,7 +117,7 @@ class IntentManagerImpl(
}
context.startActivity(intent)
true
} catch (e: ActivityNotFoundException) {
} catch (_: ActivityNotFoundException) {
false
}
@@ -132,12 +134,28 @@ class IntentManagerImpl(
}
override fun launchUri(uri: Uri) {
val newUri = if (uri.scheme == null) {
uri.buildUpon().scheme("https").build()
if (uri.scheme.equals(other = "androidapp", ignoreCase = true)) {
val packageName = uri.toString().removePrefix(prefix = "androidapp://")
if (isBuildVersionBelow(Build.VERSION_CODES.TIRAMISU)) {
startActivity(createPlayStoreIntent(packageName))
} else {
try {
context
.packageManager
.getLaunchIntentSenderForPackage(packageName)
.sendIntent(context, Activity.RESULT_OK, null, null, null)
} catch (_: IntentSender.SendIntentException) {
startActivity(createPlayStoreIntent(packageName))
}
}
} else {
uri.normalizeScheme()
val newUri = if (uri.scheme == null) {
uri.buildUpon().scheme("https").build()
} else {
uri.normalizeScheme()
}
startActivity(Intent(Intent.ACTION_VIEW, newUri))
}
startActivity(Intent(Intent.ACTION_VIEW, newUri))
}
override fun shareText(text: String) {
@@ -301,6 +319,15 @@ class IntentManagerImpl(
startActivity(intent)
}
private fun createPlayStoreIntent(packageName: String): Intent {
val playStoreUri = "https://play.google.com/store/apps/details"
.toUri()
.buildUpon()
.appendQueryParameter("id", packageName)
.build()
return Intent(Intent.ACTION_VIEW, playStoreUri)
}
private fun getCameraFileData(): IntentManager.FileData {
val tmpDir = File(context.filesDir, TEMP_CAMERA_IMAGE_DIR)
val file = File(tmpDir, TEMP_CAMERA_IMAGE_NAME)

View File

@@ -202,16 +202,14 @@ fun GeneratorScreen(
modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
) { innerPadding ->
Column(modifier = Modifier.padding(innerPadding)) {
if (state.generatorMode == GeneratorMode.Default) {
MainStateOptionsItem(
selectedType = state.selectedType,
passcodePolicyOverride = state.passcodePolicyOverride,
possibleMainStates = state.typeOptions.toImmutableList(),
onMainStateOptionClicked = onMainStateOptionClicked,
modifier = Modifier
.scrolledContainerBottomDivider(topAppBarScrollBehavior = scrollBehavior),
)
}
MainStateOptionsItem(
selectedType = state.selectedType,
passcodePolicyOverride = state.passcodePolicyOverride,
possibleMainStates = state.typeOptions.toImmutableList(),
onMainStateOptionClicked = onMainStateOptionClicked,
modifier = Modifier
.scrolledContainerBottomDivider(topAppBarScrollBehavior = scrollBehavior),
)
ScrollContent(
state = state,
onRegenerateClick = onRegenerateClick,

View File

@@ -53,6 +53,7 @@ import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.parcelize.Parcelize
import javax.inject.Inject
import kotlin.collections.filter
import kotlin.math.max
private const val KEY_STATE = "state"
@@ -1726,10 +1727,17 @@ data class GeneratorState(
) : Parcelable {
/**
* Provides a list of available main types for the generator.
* Provides a list of available main types for the generator based on the [GeneratorMode].
*/
val typeOptions: List<MainTypeOption>
get() = MainTypeOption.entries.toList()
get() = when (generatorMode) {
GeneratorMode.Default -> MainTypeOption.entries.toList()
GeneratorMode.Modal.Password -> MainTypeOption
.entries
.filter { it != MainTypeOption.USERNAME }
is GeneratorMode.Modal.Username -> emptyList()
}
/**
* Enum representing the main type options for the generator, such as PASSWORD PASSPHRASE, and

View File

@@ -23,7 +23,6 @@ import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.material3.rememberTopAppBarState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.nestedscroll.nestedScroll
@@ -64,7 +63,6 @@ import com.x8bit.bitwarden.ui.vault.feature.importlogins.handlers.ImportLoginHan
import com.x8bit.bitwarden.ui.vault.feature.importlogins.handlers.rememberImportLoginHandler
import com.x8bit.bitwarden.ui.vault.feature.importlogins.model.InstructionStep
import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.launch
private const val IMPORT_HELP_URL = "https://bitwarden.com/help/import-data/"
@@ -99,27 +97,15 @@ fun ImportLoginsScreen(
}
}
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
val scope = rememberCoroutineScope()
val hideSheetAndExecuteCompleteImportLogins: () -> Unit = {
// This pattern mirrors the onDismissRequest handling in the material ModalBottomSheet
scope
.launch {
sheetState.hide()
}
.invokeOnCompletion {
handler.onSuccessfulSyncAcknowledged()
}
}
BitwardenModalBottomSheet(
showBottomSheet = state.showBottomSheet,
sheetTitle = stringResource(R.string.bitwarden_tools),
onDismiss = hideSheetAndExecuteCompleteImportLogins,
onDismiss = handler.onSuccessfulSyncAcknowledged,
sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true),
modifier = Modifier.statusBarsPadding(),
) { paddingValues ->
) { paddingValues, animatedOnDismiss ->
ImportLoginsSuccessBottomSheetContent(
onCompleteImportLogins = hideSheetAndExecuteCompleteImportLogins,
onCompleteImportLogins = animatedOnDismiss,
modifier = Modifier.padding(paddingValues),
)
}

View File

@@ -1,7 +1,5 @@
package com.x8bit.bitwarden.ui.vault.feature.vault
import android.Manifest
import android.annotation.SuppressLint
import android.widget.Toast
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.scaleIn
@@ -15,7 +13,6 @@ import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.rememberTopAppBarState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
@@ -58,11 +55,9 @@ import com.x8bit.bitwarden.ui.platform.components.scaffold.rememberBitwardenPull
import com.x8bit.bitwarden.ui.platform.components.util.rememberVectorPainter
import com.x8bit.bitwarden.ui.platform.composition.LocalExitManager
import com.x8bit.bitwarden.ui.platform.composition.LocalIntentManager
import com.x8bit.bitwarden.ui.platform.composition.LocalPermissionsManager
import com.x8bit.bitwarden.ui.platform.feature.search.model.SearchType
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.vault.handlers.VaultHandlers
import com.x8bit.bitwarden.ui.vault.model.VaultItemListingType
@@ -86,7 +81,6 @@ fun VaultScreen(
onNavigateToImportLogins: () -> Unit,
exitManager: ExitManager = LocalExitManager.current,
intentManager: IntentManager = LocalIntentManager.current,
permissionsManager: PermissionsManager = LocalPermissionsManager.current,
) {
val state by viewModel.stateFlow.collectAsStateWithLifecycle()
val context = LocalContext.current
@@ -128,10 +122,6 @@ fun VaultScreen(
}
}
val vaultHandlers = remember(viewModel) { VaultHandlers.create(viewModel) }
VaultScreenPushNotifications(
hideNotificationsDialog = state.hideNotificationsDialog,
permissionsManager = permissionsManager,
)
VaultScreenScaffold(
state = state,
pullToRefreshState = pullToRefreshState,
@@ -140,28 +130,6 @@ fun VaultScreen(
)
}
/**
* Handles the notifications permission request.
*/
@Composable
private fun VaultScreenPushNotifications(
hideNotificationsDialog: Boolean,
permissionsManager: PermissionsManager,
) {
if (hideNotificationsDialog) return
val launcher = permissionsManager.getLauncher {
// We do not actually care what the response is, we just need
// to give the user a chance to give us the permission.
}
LaunchedEffect(key1 = Unit) {
@SuppressLint("InlinedApi")
// We check the version code as part of the 'hideNotificationsDialog' property.
if (!permissionsManager.checkPermission(Manifest.permission.POST_NOTIFICATIONS)) {
launcher.launch(Manifest.permission.POST_NOTIFICATIONS)
}
}
}
/**
* Scaffold for the [VaultScreen]
*/

View File

@@ -1,6 +1,5 @@
package com.x8bit.bitwarden.ui.vault.feature.vault
import android.os.Build
import android.os.Parcelable
import androidx.compose.ui.graphics.Color
import androidx.lifecycle.viewModelScope
@@ -18,8 +17,6 @@ import com.x8bit.bitwarden.data.platform.manager.model.OrganizationEvent
import com.x8bit.bitwarden.data.platform.repository.SettingsRepository
import com.x8bit.bitwarden.data.platform.repository.model.DataState
import com.x8bit.bitwarden.data.platform.repository.util.baseIconUrl
import com.x8bit.bitwarden.data.platform.util.isBuildVersionBelow
import com.x8bit.bitwarden.data.platform.util.isFdroid
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
@@ -94,7 +91,6 @@ class VaultViewModel @Inject constructor(
isPullToRefreshSettingEnabled = settingsRepository.getPullToRefreshEnabledFlow().value,
baseIconUrl = userState.activeAccount.environment.environmentUrlData.baseIconUrl,
hasMasterPassword = userState.activeAccount.hasMasterPassword,
hideNotificationsDialog = isBuildVersionBelow(Build.VERSION_CODES.TIRAMISU) || isFdroid,
isRefreshing = false,
showImportActionCard = false,
)
@@ -665,7 +661,6 @@ data class VaultState(
private val isPullToRefreshSettingEnabled: Boolean,
val baseIconUrl: String,
val isIconLoadingDisabled: Boolean,
val hideNotificationsDialog: Boolean,
val isRefreshing: Boolean,
val showImportActionCard: Boolean,
) : Parcelable {

View File

@@ -0,0 +1,73 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="200dp"
android:height="201dp"
android:viewportWidth="200"
android:viewportHeight="201">
<path
android:fillColor="#AAC3EF"
android:pathData="M0,38.17C0,31.26 5.6,25.67 12.5,25.67H125C131.9,25.67 137.5,31.26 137.5,38.17V117.33C137.5,124.24 131.9,129.83 125,129.83H12.5C5.6,129.83 0,124.24 0,117.33V38.17Z" />
<path
android:fillColor="#175DDC"
android:fillType="evenOdd"
android:pathData="M125,29.83H12.5C7.9,29.83 4.17,33.56 4.17,38.17V117.33C4.17,121.94 7.9,125.67 12.5,125.67H125C129.6,125.67 133.33,121.94 133.33,117.33V38.17C133.33,33.56 129.6,29.83 125,29.83ZM12.5,25.67C5.6,25.67 0,31.26 0,38.17V117.33C0,124.24 5.6,129.83 12.5,129.83H125C131.9,129.83 137.5,124.24 137.5,117.33V38.17C137.5,31.26 131.9,25.67 125,25.67H12.5Z" />
<path
android:fillColor="#79A1E9"
android:pathData="M47.92,75.67C47.92,72.21 50.71,69.42 54.17,69.42H83.33C86.78,69.42 89.58,72.21 89.58,75.67V96.5C89.58,99.95 86.78,102.75 83.33,102.75H54.17C50.71,102.75 47.92,99.95 47.92,96.5V75.67Z" />
<path
android:fillColor="#175DDC"
android:fillType="evenOdd"
android:pathData="M83.33,73.58H54.17C53.02,73.58 52.08,74.52 52.08,75.67V96.5C52.08,97.65 53.02,98.58 54.17,98.58H83.33C84.48,98.58 85.42,97.65 85.42,96.5V75.67C85.42,74.52 84.48,73.58 83.33,73.58ZM54.17,69.42C50.71,69.42 47.92,72.21 47.92,75.67V96.5C47.92,99.95 50.71,102.75 54.17,102.75H83.33C86.78,102.75 89.58,99.95 89.58,96.5V75.67C89.58,72.21 86.78,69.42 83.33,69.42H54.17Z" />
<path
android:fillColor="#175DDC"
android:pathData="M66.67,81.92C66.67,80.77 67.6,79.83 68.75,79.83C69.9,79.83 70.83,80.77 70.83,81.92V90.25C70.83,91.4 69.9,92.33 68.75,92.33C67.6,92.33 66.67,91.4 66.67,90.25V81.92Z" />
<path
android:fillColor="#175DDC"
android:fillType="evenOdd"
android:pathData="M58.33,67.33C58.33,61.58 63,56.92 68.75,56.92C74.5,56.92 79.17,61.58 79.17,67.33V69.42H75V67.33C75,63.88 72.2,61.08 68.75,61.08C65.3,61.08 62.5,63.88 62.5,67.33V69.42H58.33V67.33Z" />
<path
android:fillColor="#175DDC"
android:fillType="evenOdd"
android:pathData="M135.42,50.67H2.08V46.5H135.42V50.67Z" />
<path
android:fillColor="#175DDC"
android:pathData="M129.17,38.17C129.17,40.47 127.3,42.33 125,42.33C122.7,42.33 120.83,40.47 120.83,38.17C120.83,35.87 122.7,34 125,34C127.3,34 129.17,35.87 129.17,38.17Z" />
<path
android:fillColor="#175DDC"
android:pathData="M116.67,38.17C116.67,40.47 114.8,42.33 112.5,42.33C110.2,42.33 108.33,40.47 108.33,38.17C108.33,35.87 110.2,34 112.5,34C114.8,34 116.67,35.87 116.67,38.17Z" />
<path
android:fillColor="#175DDC"
android:pathData="M104.17,38.17C104.17,40.47 102.3,42.33 100,42.33C97.7,42.33 95.83,40.47 95.83,38.17C95.83,35.87 97.7,34 100,34C102.3,34 104.17,35.87 104.17,38.17Z" />
<path
android:fillColor="#F3F6F9"
android:pathData="M170.83,88.17C170.83,104.28 157.77,117.33 141.67,117.33C125.56,117.33 112.5,104.28 112.5,88.17C112.5,72.06 125.56,59 141.67,59C157.77,59 170.83,72.06 170.83,88.17Z" />
<path
android:fillColor="#175DDC"
android:fillType="evenOdd"
android:pathData="M141.67,113.17C155.47,113.17 166.67,101.97 166.67,88.17C166.67,74.36 155.47,63.17 141.67,63.17C127.86,63.17 116.67,74.36 116.67,88.17C116.67,101.97 127.86,113.17 141.67,113.17ZM141.67,117.33C157.77,117.33 170.83,104.28 170.83,88.17C170.83,72.06 157.77,59 141.67,59C125.56,59 112.5,72.06 112.5,88.17C112.5,104.28 125.56,117.33 141.67,117.33Z" />
<path
android:fillColor="#F3F6F9"
android:pathData="M195.64,150.62C198.52,157.57 200,165.02 200,172.54C200,174.27 198.6,175.67 196.87,175.67H88.54C86.82,175.67 85.42,174.27 85.42,172.54C85.42,165.02 86.9,157.57 89.78,150.62C92.66,143.67 96.88,137.35 102.2,132.03C107.52,126.71 113.83,122.49 120.78,119.61C127.73,116.73 135.18,115.25 142.71,115.25C150.23,115.25 157.68,116.73 164.63,119.61C171.58,122.49 177.9,126.71 183.22,132.03C188.54,137.35 192.76,143.67 195.64,150.62Z" />
<path
android:fillColor="#175DDC"
android:fillType="evenOdd"
android:pathData="M195.82,171.5C195.69,164.88 194.33,158.34 191.79,152.21C189.12,145.77 185.21,139.91 180.27,134.98C175.34,130.04 169.48,126.13 163.04,123.46C156.59,120.79 149.68,119.42 142.71,119.42C135.73,119.42 128.82,120.79 122.38,123.46C115.93,126.13 110.08,130.04 105.14,134.98C100.21,139.91 96.3,145.77 93.63,152.21C91.09,158.34 89.72,164.88 89.59,171.5H195.82ZM200,172.54C200,165.02 198.52,157.57 195.64,150.62C192.76,143.67 188.54,137.35 183.22,132.03C177.9,126.71 171.58,122.49 164.63,119.61C157.68,116.73 150.23,115.25 142.71,115.25C135.18,115.25 127.73,116.73 120.78,119.61C113.83,122.49 107.52,126.71 102.2,132.03C96.88,137.35 92.66,143.67 89.78,150.62C86.9,157.57 85.42,165.02 85.42,172.54C85.42,174.27 86.82,175.67 88.54,175.67H196.87C198.6,175.67 200,174.27 200,172.54Z" />
<path
android:fillColor="#FFBF00"
android:pathData="M22.92,127.75C22.92,118.54 30.38,111.08 39.58,111.08H95.83C105.04,111.08 112.5,118.54 112.5,127.75C112.5,136.95 105.04,144.42 95.83,144.42H39.58C30.38,144.42 22.92,136.95 22.92,127.75Z" />
<path
android:fillColor="#175DDC"
android:fillType="evenOdd"
android:pathData="M95.83,115.25H39.58C32.68,115.25 27.08,120.85 27.08,127.75C27.08,134.65 32.68,140.25 39.58,140.25H95.83C102.74,140.25 108.33,134.65 108.33,127.75C108.33,120.85 102.74,115.25 95.83,115.25ZM39.58,111.08C30.38,111.08 22.92,118.54 22.92,127.75C22.92,136.95 30.38,144.42 39.58,144.42H95.83C105.04,144.42 112.5,136.95 112.5,127.75C112.5,118.54 105.04,111.08 95.83,111.08H39.58Z" />
<path
android:fillColor="#175DDC"
android:fillType="evenOdd"
android:pathData="M41.68,120.61C42.83,120.61 43.76,121.54 43.76,122.69V125.61L46.49,124.71C47.59,124.36 48.76,124.95 49.12,126.05C49.48,127.14 48.88,128.32 47.79,128.67L45.02,129.58L46.76,132.01C47.42,132.95 47.21,134.25 46.27,134.92C45.33,135.59 44.03,135.37 43.36,134.43L41.68,132.07L39.99,134.43C39.32,135.37 38.02,135.59 37.08,134.92C36.15,134.25 35.93,132.95 36.6,132.01L38.33,129.58L35.56,128.67C34.47,128.32 33.87,127.14 34.23,126.05C34.59,124.95 35.77,124.36 36.86,124.71L39.59,125.61V122.69C39.59,121.54 40.53,120.61 41.68,120.61ZM60.43,120.61C61.58,120.61 62.51,121.54 62.51,122.69V125.61L65.24,124.71C66.34,124.36 67.51,124.95 67.87,126.05C68.23,127.14 67.63,128.32 66.54,128.67L63.77,129.58L65.51,132.01C66.17,132.95 65.96,134.25 65.02,134.92C64.08,135.59 62.78,135.37 62.11,134.43L60.43,132.07L58.74,134.43C58.07,135.37 56.77,135.59 55.83,134.92C54.9,134.25 54.68,132.95 55.35,132.01L57.08,129.58L54.31,128.67C53.22,128.32 52.62,127.14 52.98,126.05C53.34,124.95 54.52,124.36 55.61,124.71L58.34,125.61V122.69C58.34,121.54 59.28,120.61 60.43,120.61Z" />
<path
android:fillColor="#175DDC"
android:fillType="evenOdd"
android:pathData="M72.92,131.92C72.92,130.77 73.85,129.83 75,129.83L83.33,129.83C84.48,129.83 85.42,130.77 85.42,131.92C85.42,133.07 84.48,134 83.33,134L75,134C73.85,134 72.92,133.07 72.92,131.92Z" />
<path
android:fillColor="#175DDC"
android:fillType="evenOdd"
android:pathData="M89.58,131.92C89.58,130.77 90.52,129.83 91.67,129.83L100,129.83C101.15,129.83 102.08,130.77 102.08,131.92C102.08,133.07 101.15,134 100,134L91.67,134C90.52,134 89.58,133.07 89.58,131.92Z" />
</vector>

View File

@@ -0,0 +1,73 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="200dp"
android:height="201dp"
android:viewportWidth="200"
android:viewportHeight="201">
<path
android:fillColor="#DBE5F6"
android:pathData="M0,38.17C0,31.26 5.6,25.67 12.5,25.67H125C131.9,25.67 137.5,31.26 137.5,38.17V117.33C137.5,124.24 131.9,129.83 125,129.83H12.5C5.6,129.83 0,124.24 0,117.33V38.17Z" />
<path
android:fillColor="#020F66"
android:fillType="evenOdd"
android:pathData="M125,29.83H12.5C7.9,29.83 4.17,33.56 4.17,38.17V117.33C4.17,121.94 7.9,125.67 12.5,125.67H125C129.6,125.67 133.33,121.94 133.33,117.33V38.17C133.33,33.56 129.6,29.83 125,29.83ZM12.5,25.67C5.6,25.67 0,31.26 0,38.17V117.33C0,124.24 5.6,129.83 12.5,129.83H125C131.9,129.83 137.5,124.24 137.5,117.33V38.17C137.5,31.26 131.9,25.67 125,25.67H12.5Z" />
<path
android:fillColor="#AAC3EF"
android:pathData="M47.92,75.67C47.92,72.21 50.71,69.42 54.17,69.42H83.33C86.78,69.42 89.58,72.21 89.58,75.67V96.5C89.58,99.95 86.78,102.75 83.33,102.75H54.17C50.71,102.75 47.92,99.95 47.92,96.5V75.67Z" />
<path
android:fillColor="#020F66"
android:fillType="evenOdd"
android:pathData="M83.33,73.58H54.17C53.02,73.58 52.08,74.52 52.08,75.67V96.5C52.08,97.65 53.02,98.58 54.17,98.58H83.33C84.48,98.58 85.42,97.65 85.42,96.5V75.67C85.42,74.52 84.48,73.58 83.33,73.58ZM54.17,69.42C50.71,69.42 47.92,72.21 47.92,75.67V96.5C47.92,99.95 50.71,102.75 54.17,102.75H83.33C86.78,102.75 89.58,99.95 89.58,96.5V75.67C89.58,72.21 86.78,69.42 83.33,69.42H54.17Z" />
<path
android:fillColor="#020F66"
android:pathData="M66.67,81.92C66.67,80.77 67.6,79.83 68.75,79.83C69.9,79.83 70.83,80.77 70.83,81.92V90.25C70.83,91.4 69.9,92.33 68.75,92.33C67.6,92.33 66.67,91.4 66.67,90.25V81.92Z" />
<path
android:fillColor="#020F66"
android:fillType="evenOdd"
android:pathData="M58.33,67.33C58.33,61.58 63,56.92 68.75,56.92C74.5,56.92 79.17,61.58 79.17,67.33V69.42H75V67.33C75,63.88 72.2,61.08 68.75,61.08C65.3,61.08 62.5,63.88 62.5,67.33V69.42H58.33V67.33Z" />
<path
android:fillColor="#020F66"
android:fillType="evenOdd"
android:pathData="M135.42,50.67H2.08V46.5H135.42V50.67Z" />
<path
android:fillColor="#020F66"
android:pathData="M129.17,38.17C129.17,40.47 127.3,42.33 125,42.33C122.7,42.33 120.83,40.47 120.83,38.17C120.83,35.87 122.7,34 125,34C127.3,34 129.17,35.87 129.17,38.17Z" />
<path
android:fillColor="#020F66"
android:pathData="M116.67,38.17C116.67,40.47 114.8,42.33 112.5,42.33C110.2,42.33 108.33,40.47 108.33,38.17C108.33,35.87 110.2,34 112.5,34C114.8,34 116.67,35.87 116.67,38.17Z" />
<path
android:fillColor="#020F66"
android:pathData="M104.17,38.17C104.17,40.47 102.3,42.33 100,42.33C97.7,42.33 95.83,40.47 95.83,38.17C95.83,35.87 97.7,34 100,34C102.3,34 104.17,35.87 104.17,38.17Z" />
<path
android:fillColor="#ffffff"
android:pathData="M170.83,88.17C170.83,104.28 157.77,117.33 141.67,117.33C125.56,117.33 112.5,104.28 112.5,88.17C112.5,72.06 125.56,59 141.67,59C157.77,59 170.83,72.06 170.83,88.17Z" />
<path
android:fillColor="#020F66"
android:fillType="evenOdd"
android:pathData="M141.67,113.17C155.47,113.17 166.67,101.97 166.67,88.17C166.67,74.36 155.47,63.17 141.67,63.17C127.86,63.17 116.67,74.36 116.67,88.17C116.67,101.97 127.86,113.17 141.67,113.17ZM141.67,117.33C157.77,117.33 170.83,104.28 170.83,88.17C170.83,72.06 157.77,59 141.67,59C125.56,59 112.5,72.06 112.5,88.17C112.5,104.28 125.56,117.33 141.67,117.33Z" />
<path
android:fillColor="#ffffff"
android:pathData="M195.64,150.62C198.52,157.57 200,165.02 200,172.54C200,174.27 198.6,175.67 196.87,175.67H88.54C86.82,175.67 85.42,174.27 85.42,172.54C85.42,165.02 86.9,157.57 89.78,150.62C92.66,143.67 96.88,137.35 102.2,132.03C107.52,126.71 113.83,122.49 120.78,119.61C127.73,116.73 135.18,115.25 142.71,115.25C150.23,115.25 157.68,116.73 164.63,119.61C171.58,122.49 177.9,126.71 183.22,132.03C188.54,137.35 192.76,143.67 195.64,150.62Z" />
<path
android:fillColor="#020F66"
android:fillType="evenOdd"
android:pathData="M195.82,171.5C195.69,164.88 194.33,158.34 191.79,152.21C189.12,145.77 185.21,139.91 180.27,134.98C175.34,130.04 169.48,126.13 163.04,123.46C156.59,120.79 149.68,119.42 142.71,119.42C135.73,119.42 128.82,120.79 122.38,123.46C115.93,126.13 110.08,130.04 105.14,134.98C100.21,139.91 96.3,145.77 93.63,152.21C91.09,158.34 89.72,164.88 89.59,171.5H195.82ZM200,172.54C200,165.02 198.52,157.57 195.64,150.62C192.76,143.67 188.54,137.35 183.22,132.03C177.9,126.71 171.58,122.49 164.63,119.61C157.68,116.73 150.23,115.25 142.71,115.25C135.18,115.25 127.73,116.73 120.78,119.61C113.83,122.49 107.52,126.71 102.2,132.03C96.88,137.35 92.66,143.67 89.78,150.62C86.9,157.57 85.42,165.02 85.42,172.54C85.42,174.27 86.82,175.67 88.54,175.67H196.87C198.6,175.67 200,174.27 200,172.54Z" />
<path
android:fillColor="#FFBF00"
android:pathData="M22.92,127.75C22.92,118.54 30.38,111.08 39.58,111.08H95.83C105.04,111.08 112.5,118.54 112.5,127.75C112.5,136.95 105.04,144.42 95.83,144.42H39.58C30.38,144.42 22.92,136.95 22.92,127.75Z" />
<path
android:fillColor="#020F66"
android:fillType="evenOdd"
android:pathData="M95.83,115.25H39.58C32.68,115.25 27.08,120.85 27.08,127.75C27.08,134.65 32.68,140.25 39.58,140.25H95.83C102.74,140.25 108.33,134.65 108.33,127.75C108.33,120.85 102.74,115.25 95.83,115.25ZM39.58,111.08C30.38,111.08 22.92,118.54 22.92,127.75C22.92,136.95 30.38,144.42 39.58,144.42H95.83C105.04,144.42 112.5,136.95 112.5,127.75C112.5,118.54 105.04,111.08 95.83,111.08H39.58Z" />
<path
android:fillColor="#020F66"
android:fillType="evenOdd"
android:pathData="M41.67,120.61C42.83,120.61 43.76,121.54 43.76,122.69V125.61L46.49,124.71C47.58,124.36 48.76,124.95 49.12,126.05C49.48,127.14 48.88,128.32 47.79,128.67L45.02,129.58L46.75,132.01C47.42,132.95 47.2,134.25 46.27,134.92C45.33,135.59 44.03,135.37 43.36,134.43L41.67,132.07L39.99,134.43C39.32,135.37 38.02,135.59 37.08,134.92C36.14,134.25 35.93,132.95 36.6,132.01L38.33,129.58L35.56,128.67C34.47,128.32 33.87,127.14 34.23,126.05C34.59,124.95 35.76,124.36 36.86,124.71L39.59,125.61V122.69C39.59,121.54 40.52,120.61 41.67,120.61ZM60.42,120.61C61.58,120.61 62.51,121.54 62.51,122.69V125.61L65.24,124.71C66.33,124.36 67.51,124.95 67.87,126.05C68.23,127.14 67.63,128.32 66.54,128.67L63.77,129.58L65.5,132.01C66.17,132.95 65.95,134.25 65.02,134.92C64.08,135.59 62.78,135.37 62.11,134.43L60.42,132.07L58.74,134.43C58.07,135.37 56.77,135.59 55.83,134.92C54.89,134.25 54.68,132.95 55.35,132.01L57.08,129.58L54.31,128.67C53.22,128.32 52.62,127.14 52.98,126.05C53.34,124.95 54.51,124.36 55.61,124.71L58.34,125.61V122.69C58.34,121.54 59.27,120.61 60.42,120.61Z" />
<path
android:fillColor="#020F66"
android:fillType="evenOdd"
android:pathData="M72.92,131.92C72.92,130.77 73.85,129.83 75,129.83L83.33,129.83C84.48,129.83 85.42,130.77 85.42,131.92C85.42,133.07 84.48,134 83.33,134L75,134C73.85,134 72.92,133.07 72.92,131.92Z" />
<path
android:fillColor="#020F66"
android:fillType="evenOdd"
android:pathData="M89.58,131.92C89.58,130.77 90.52,129.83 91.67,129.83L100,129.83C101.15,129.83 102.08,130.77 102.08,131.92C102.08,133.07 101.15,134 100,134L91.67,134C90.52,134 89.58,133.07 89.58,131.92Z" />
</vector>

View File

@@ -1072,4 +1072,8 @@ Do you want to switch to this account?</string>
<string name="manage_your_logins_from_anywhere_with_bitwarden_tools">Manage your logins from anywhere with Bitwarden tools for web and desktop.</string>
<string name="bitwarden_tools">Bitwarden Tools</string>
<string name="got_it">Got it</string>
<string name="enable_notifications">Enable notifications</string>
<string name="log_in_quickly_and_easily_across_devices">Log in quickly and easily across devices</string>
<string name="bitwarden_can_notify_you_each_time_you_receive_a_new_login_request_from_another_device">Bitwarden can notify you each time you receive a new login request from another device.</string>
<string name="skip_for_now">Skip for now</string>
</resources>

View File

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

View File

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

View File

@@ -26,7 +26,7 @@ import org.junit.jupiter.api.Test
class AccessibilityCompletionManagerTest {
private val activity: Activity = mockk {
every { finish() } just runs
every { finishAndRemoveTask() } just runs
}
private val accessibilityAutofillManager: AccessibilityAutofillManager = mockk()
private val totpManager: AutofillTotpManager = mockk()
@@ -68,7 +68,7 @@ class AccessibilityCompletionManagerTest {
verify(exactly = 1) {
activity.intent
activity.finish()
activity.finishAndRemoveTask()
}
}
@@ -87,7 +87,7 @@ class AccessibilityCompletionManagerTest {
verify(exactly = 1) {
activity.intent
mockIntent.getAutofillSelectionDataOrNull()
activity.finish()
activity.finishAndRemoveTask()
}
}
@@ -111,7 +111,7 @@ class AccessibilityCompletionManagerTest {
verify(exactly = 1) {
activity.intent
mockIntent.getAutofillSelectionDataOrNull()
activity.finish()
activity.finishAndRemoveTask()
}
}
@@ -135,7 +135,7 @@ class AccessibilityCompletionManagerTest {
verify(exactly = 1) {
activity.intent
mockIntent.getAutofillSelectionDataOrNull()
activity.finish()
activity.finishAndRemoveTask()
}
}
@@ -162,7 +162,7 @@ class AccessibilityCompletionManagerTest {
verify(exactly = 1) {
activity.intent
mockIntent.getAutofillSelectionDataOrNull()
activity.finish()
activity.finishAndRemoveTask()
}
}
@@ -201,7 +201,7 @@ class AccessibilityCompletionManagerTest {
cipherView = cipherView,
uri = uri,
)
activity.finish()
activity.finishAndRemoveTask()
}
coVerify(exactly = 1) {
totpManager.tryCopyTotpToClipboard(cipherView = cipherView)

View File

@@ -42,6 +42,9 @@ class FakeSettingsDiskSource : SettingsDiskSource {
private val mutableScreenCaptureAllowedFlowMap =
mutableMapOf<String, MutableSharedFlow<Boolean?>>()
private val mutableLastDatabaseSchemeChangeInstant =
bufferedMutableSharedFlow<Instant?>()
private var storedAppTheme: AppTheme = AppTheme.DEFAULT
private val storedLastSyncTime = mutableMapOf<String, Instant?>()
private val storedVaultTimeoutActions = mutableMapOf<String, VaultTimeoutAction?>()
@@ -137,6 +140,11 @@ class FakeSettingsDiskSource : SettingsDiskSource {
get() = storedLastDatabaseSchemeChangeInstant
set(value) { storedLastDatabaseSchemeChangeInstant = value }
override val lastDatabaseSchemeChangeInstantFlow: Flow<Instant?>
get() = mutableLastDatabaseSchemeChangeInstant.onSubscription {
emit(lastDatabaseSchemeChangeInstant)
}
override fun getAccountBiometricIntegrityValidity(
userId: String,
systemBioIntegrityState: String,

View File

@@ -35,6 +35,18 @@ class BaseEnumeratedIntSerializerTest {
),
)
}
@Test
fun `properly returns default value when unknown value is provided`() {
assertEquals(
TestEnum.UNKNOWN,
json.decodeFromString<TestEnum>(
"""
-1
""",
),
)
}
}
@Serializable(TestEnumSerializer::class)
@@ -44,7 +56,12 @@ private enum class TestEnum {
@SerialName("2")
CASE_2,
@SerialName("-1")
UNKNOWN,
}
private class TestEnumSerializer :
BaseEnumeratedIntSerializer<TestEnum>(values = TestEnum.entries.toTypedArray())
private class TestEnumSerializer : BaseEnumeratedIntSerializer<TestEnum>(
values = TestEnum.entries.toTypedArray(),
default = TestEnum.UNKNOWN,
)

View File

@@ -1,24 +1,37 @@
package com.x8bit.bitwarden.data.platform.manager
import app.cash.turbine.test
import com.x8bit.bitwarden.data.platform.base.FakeDispatcherManager
import com.x8bit.bitwarden.data.platform.datasource.disk.SettingsDiskSource
import io.mockk.every
import io.mockk.just
import io.mockk.mockk
import io.mockk.runs
import io.mockk.verify
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.test.runTest
import org.junit.Test
import org.junit.jupiter.api.Assertions.assertEquals
import java.time.Clock
import java.time.Instant
import java.time.ZoneOffset
class DatabaseSchemeManagerTest {
private val mutableLastDatabaseSchemeChangeInstantFlow = MutableStateFlow<Instant?>(null)
private val mockSettingsDiskSource: SettingsDiskSource = mockk {
every { lastDatabaseSchemeChangeInstant } returns null
every { lastDatabaseSchemeChangeInstant = any() } just runs
every {
lastDatabaseSchemeChangeInstant
} returns mutableLastDatabaseSchemeChangeInstantFlow.value
every { lastDatabaseSchemeChangeInstant = any() } answers {
mutableLastDatabaseSchemeChangeInstantFlow.value = firstArg()
}
every {
lastDatabaseSchemeChangeInstantFlow
} returns mutableLastDatabaseSchemeChangeInstantFlow
}
private val dispatcherManager = FakeDispatcherManager()
private val databaseSchemeManager = DatabaseSchemeManagerImpl(
settingsDiskSource = mockSettingsDiskSource,
dispatcherManager = dispatcherManager,
)
@Suppress("MaxLineLength")
@@ -30,6 +43,23 @@ class DatabaseSchemeManagerTest {
}
}
@Test
fun `setLastDatabaseSchemeChangeInstant does emit value`() = runTest {
databaseSchemeManager.lastDatabaseSchemeChangeInstantFlow.test {
// Assert the value is initialized to null
assertEquals(
null,
awaitItem(),
)
// Assert the new value is emitted
databaseSchemeManager.lastDatabaseSchemeChangeInstant = FIXED_CLOCK.instant()
assertEquals(
FIXED_CLOCK.instant(),
awaitItem(),
)
}
}
@Test
fun `getLastDatabaseSchemeChangeInstant retrieves stored value from settingsDiskSource`() {
databaseSchemeManager.lastDatabaseSchemeChangeInstant

View File

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

View File

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

View File

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

View File

@@ -190,8 +190,14 @@ class VaultRepositoryTest {
mutableUnlockedUserIdsStateFlow.first { userId in it }
}
}
private val mutableLastDatabaseSchemeChangeInstantFlow = MutableStateFlow<Instant?>(null)
private val databaseSchemeManager: DatabaseSchemeManager = mockk {
every { lastDatabaseSchemeChangeInstant } returns null
every {
lastDatabaseSchemeChangeInstant
} returns mutableLastDatabaseSchemeChangeInstantFlow.value
every {
lastDatabaseSchemeChangeInstantFlow
} returns mutableLastDatabaseSchemeChangeInstantFlow
}
private val mutableFullSyncFlow = bufferedMutableSharedFlow<Unit>()
@@ -343,6 +349,12 @@ class VaultRepositoryTest {
)
setVaultToUnlocked(userId = userId)
ciphersFlow.tryEmit(listOf(createMockCipher(number = 1)))
collectionsFlow.tryEmit(listOf(createMockCollection(number = 1)))
foldersFlow.tryEmit(listOf(createMockFolder(number = 1)))
sendsFlow.tryEmit(listOf(createMockSend(number = 1)))
domainsFlow.tryEmit(createMockDomains(number = 1))
assertEquals(
DataState.Loaded(listOf(createMockCipherView(number = 1))),
ciphersStateFlow.awaitItem(),
@@ -484,7 +496,8 @@ class VaultRepositoryTest {
assertEquals(DataState.Loading, collectionsStateFlow.awaitItem())
assertEquals(DataState.Loading, foldersStateFlow.awaitItem())
assertEquals(DataState.Loading, sendsStateFlow.awaitItem())
assertEquals(DataState.Loading, domainsStateFlow.awaitItem())
// We already have the domain data
domainsStateFlow.expectNoEvents()
}
}
@@ -770,6 +783,36 @@ class VaultRepositoryTest {
}
}
@Test
fun `lastDatabaseSchemeChangeInstantFlow should trigger sync when new value is not null`() =
runTest {
fakeAuthDiskSource.userState = MOCK_USER_STATE
every {
databaseSchemeManager.lastDatabaseSchemeChangeInstant
} returns mutableLastDatabaseSchemeChangeInstantFlow.value
coEvery { syncService.sync() } just awaits
mutableLastDatabaseSchemeChangeInstantFlow.value = clock.instant()
coVerify(exactly = 1) { syncService.sync() }
}
@Test
fun `lastDatabaseSchemeChangeInstantFlow should not sync when new value is null`() =
runTest {
fakeAuthDiskSource.userState = MOCK_USER_STATE
every {
databaseSchemeManager.lastDatabaseSchemeChangeInstant
} returns mutableLastDatabaseSchemeChangeInstantFlow.value
coEvery { syncService.sync() } just awaits
mutableLastDatabaseSchemeChangeInstantFlow.value = null
coVerify(exactly = 0) { syncService.sync() }
}
@Suppress("MaxLineLength")
@Test
fun `sync with syncService Success should unlock the vault for orgs if necessary and update AuthDiskSource and VaultDiskSource`() =
@@ -1804,6 +1847,9 @@ class VaultRepositoryTest {
settingsDiskSource.getLastSyncTime(userId = userId)
} returns clock.instant()
mutableVaultStateFlow.update {
listOf(VaultUnlockData(userId, VaultUnlockData.Status.UNLOCKED))
}
fakeAuthDiskSource.userState = MOCK_USER_STATE
setupEmptyDecryptionResults()
setupVaultDiskSourceFlows(
@@ -1960,6 +2006,7 @@ class VaultRepositoryTest {
expectNoEvents()
setVaultToUnlocked(userId = MOCK_USER_STATE.activeUserId)
sendsFlow.tryEmit(emptyList())
assertEquals(DataState.Loaded<SendView?>(null), awaitItem())
sendsFlow.tryEmit(listOf(createMockSend(number = sendId)))
assertEquals(DataState.Loaded<SendView?>(sendView), awaitItem())
@@ -4544,6 +4591,14 @@ class VaultRepositoryTest {
*/
private fun setVaultToUnlocked(userId: String) {
mutableUnlockedUserIdsStateFlow.update { it + userId }
mutableVaultStateFlow.tryEmit(
listOf(
VaultUnlockData(
userId,
VaultUnlockData.Status.UNLOCKED,
),
),
)
}
/**

View File

@@ -1,18 +1,33 @@
package com.x8bit.bitwarden.ui.platform.feature.settings.accountsecurity.pendingrequests
import androidx.compose.ui.semantics.SemanticsActions
import androidx.compose.ui.test.assert
import androidx.compose.ui.test.filterToOne
import androidx.compose.ui.test.hasAnyAncestor
import androidx.compose.ui.test.hasClickAction
import androidx.compose.ui.test.isDialog
import androidx.compose.ui.test.onAllNodesWithText
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performScrollTo
import androidx.compose.ui.test.performSemanticsAction
import com.x8bit.bitwarden.data.platform.repository.util.bufferedMutableSharedFlow
import com.x8bit.bitwarden.data.platform.util.isBuildVersionBelow
import com.x8bit.bitwarden.data.platform.util.isFdroid
import com.x8bit.bitwarden.data.util.advanceTimeByAndRunCurrent
import com.x8bit.bitwarden.ui.platform.base.BaseComposeTest
import com.x8bit.bitwarden.ui.platform.manager.permissions.FakePermissionManager
import com.x8bit.bitwarden.ui.util.assertNoDialogExists
import io.mockk.every
import io.mockk.just
import io.mockk.mockk
import io.mockk.mockkStatic
import io.mockk.runs
import io.mockk.unmockkStatic
import io.mockk.verify
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.test.runTest
import org.junit.After
import org.junit.Before
import org.junit.Test
import org.junit.jupiter.api.Assertions.assertTrue
@@ -24,22 +39,38 @@ class PendingRequestsScreenTest : BaseComposeTest() {
private val mutableEventFlow = bufferedMutableSharedFlow<PendingRequestsEvent>()
private val mutableStateFlow = MutableStateFlow(DEFAULT_STATE)
private val viewModel = mockk<PendingRequestsViewModel>(relaxed = true) {
private val viewModel = mockk<PendingRequestsViewModel> {
every { eventFlow } returns mutableEventFlow
every { stateFlow } returns mutableStateFlow
every { trySendAction(any()) } just runs
}
private val permissionsManager = FakePermissionManager().apply {
checkPermissionResult = false
shouldShowRequestRationale = false
}
@Before
fun setUp() {
mockkStatic(::isFdroid)
mockkStatic(::isBuildVersionBelow)
every { isFdroid } returns false
every { isBuildVersionBelow(any()) } returns false
composeTestRule.setContent {
PendingRequestsScreen(
onNavigateBack = { onNavigateBackCalled = true },
onNavigateToLoginApproval = { _ -> onNavigateToLoginApprovalCalled = true },
viewModel = viewModel,
permissionsManager = permissionsManager,
)
}
}
@After
fun tearDown() {
unmockkStatic(::isFdroid)
unmockkStatic(::isBuildVersionBelow)
}
@Test
fun `on NavigateBack should call onNavigateBack`() {
mutableEventFlow.tryEmit(PendingRequestsEvent.NavigateBack)
@@ -70,6 +101,7 @@ class PendingRequestsScreenTest : BaseComposeTest() {
),
),
),
hideBottomSheet = true,
)
composeTestRule.onNodeWithText("Decline all requests").performClick()
composeTestRule
@@ -101,6 +133,7 @@ class PendingRequestsScreenTest : BaseComposeTest() {
),
),
),
hideBottomSheet = true,
)
composeTestRule.onNodeWithText("Decline all requests").performClick()
composeTestRule
@@ -114,12 +147,36 @@ class PendingRequestsScreenTest : BaseComposeTest() {
}
}
companion object {
val DEFAULT_STATE: PendingRequestsState = PendingRequestsState(
authRequests = emptyList(),
viewState = PendingRequestsState.ViewState.Loading,
isPullToRefreshSettingEnabled = false,
isRefreshing = false,
)
@Test
fun `on skip for now click should emit HideBottomSheet`() {
composeTestRule
.onNodeWithText(text = "Skip for now")
.performScrollTo()
.performSemanticsAction(SemanticsActions.OnClick)
dispatcher.advanceTimeByAndRunCurrent(delayTimeMillis = 1000L)
verify(exactly = 1) {
viewModel.trySendAction(PendingRequestsAction.HideBottomSheet)
}
}
@Test
fun `on Enable notifications click should emit HideBottomSheet`() {
composeTestRule
.onAllNodesWithText(text = "Enable notifications")
.filterToOne(hasClickAction())
.performScrollTo()
.performSemanticsAction(SemanticsActions.OnClick)
dispatcher.advanceTimeByAndRunCurrent(delayTimeMillis = 1000L)
verify(exactly = 1) {
viewModel.trySendAction(PendingRequestsAction.HideBottomSheet)
}
}
}
private val DEFAULT_STATE: PendingRequestsState = PendingRequestsState(
authRequests = emptyList(),
viewState = PendingRequestsState.ViewState.Loading,
isPullToRefreshSettingEnabled = false,
isRefreshing = false,
hideBottomSheet = false,
)

View File

@@ -165,6 +165,13 @@ class PendingRequestsViewModelTest : BaseViewModelTest() {
}
}
@Test
fun `on HideBottomSheet should make hideBottomSheet true`() {
val viewModel = createViewModel()
viewModel.trySendAction(PendingRequestsAction.HideBottomSheet)
assertEquals(DEFAULT_STATE.copy(hideBottomSheet = true), viewModel.stateFlow.value)
}
@Test
fun `on RefreshPull should make auth request`() = runTest {
val viewModel = createViewModel()
@@ -370,13 +377,12 @@ class PendingRequestsViewModelTest : BaseViewModelTest() {
settingsRepository = settingsRepository,
savedStateHandle = SavedStateHandle().apply { set("state", state) },
)
companion object {
val DEFAULT_STATE: PendingRequestsState = PendingRequestsState(
authRequests = emptyList(),
viewState = PendingRequestsState.ViewState.Empty,
isPullToRefreshSettingEnabled = false,
isRefreshing = false,
)
}
}
private val DEFAULT_STATE: PendingRequestsState = PendingRequestsState(
authRequests = emptyList(),
viewState = PendingRequestsState.ViewState.Empty,
isPullToRefreshSettingEnabled = false,
isRefreshing = false,
hideBottomSheet = false,
)

View File

@@ -26,7 +26,6 @@ import com.x8bit.bitwarden.ui.platform.base.util.asText
import com.x8bit.bitwarden.ui.platform.components.model.AccountSummary
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.FakePermissionManager
import com.x8bit.bitwarden.ui.util.assertLockOrLogoutDialogIsDisplayed
import com.x8bit.bitwarden.ui.util.assertLogoutConfirmationDialogIsDisplayed
import com.x8bit.bitwarden.ui.util.assertNoDialogExists
@@ -70,7 +69,6 @@ class VaultScreenTest : BaseComposeTest() {
private var onNavigateToSearchScreen = false
private val exitManager = mockk<ExitManager>(relaxed = true)
private val intentManager = mockk<IntentManager>(relaxed = true)
private val permissionsManager = FakePermissionManager()
private val mutableEventFlow = bufferedMutableSharedFlow<VaultEvent>()
private val mutableStateFlow = MutableStateFlow(DEFAULT_STATE)
@@ -94,7 +92,6 @@ class VaultScreenTest : BaseComposeTest() {
onNavigateToImportLogins = { onNavigateToImportLoginsCalled = true },
exitManager = exitManager,
intentManager = intentManager,
permissionsManager = permissionsManager,
)
}
}
@@ -1135,14 +1132,6 @@ class VaultScreenTest : BaseComposeTest() {
}
}
@Test
fun `permissionManager is invoked for notifications based on state`() {
assertFalse(permissionsManager.hasGetLauncherBeenCalled)
mutableStateFlow.update { it.copy(hideNotificationsDialog = false) }
composeTestRule.waitForIdle()
assertTrue(permissionsManager.hasGetLauncherBeenCalled)
}
@Test
fun `action card for importing logins should show based on state`() {
mutableStateFlow.update {
@@ -1253,7 +1242,6 @@ private val DEFAULT_STATE: VaultState = VaultState(
baseIconUrl = Environment.Us.environmentUrlData.baseIconUrl,
isIconLoadingDisabled = false,
hasMasterPassword = true,
hideNotificationsDialog = true,
isRefreshing = false,
showImportActionCard = false,
)

View File

@@ -1726,7 +1726,6 @@ private fun createMockVaultState(
baseIconUrl = Environment.Us.environmentUrlData.baseIconUrl,
isIconLoadingDisabled = false,
hasMasterPassword = true,
hideNotificationsDialog = true,
showImportActionCard = true,
isRefreshing = false,
)

View File

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