Compare commits

..

3 Commits

434 changed files with 3556 additions and 17243 deletions

View File

@@ -58,8 +58,8 @@ User Request (UI Action)
### Workflow Skills
> **Quick start**: Use the `android-architect` agent (or `/plan-android-work <task>`) to refine requirements and plan,
> then the `android-implementer` agent (or `/work-on-android <task>`) for implementation,
> **Quick start**: Use `/plan-android-work <task>` to refine requirements and plan,
> then `/work-on-android <task>` for implementation,
> then `/review-android <PR#>` to review the result.
Planning: 12 | Implementation: 37 | Review & PR: 810

View File

@@ -1,162 +0,0 @@
---
name: android-architect
description: "Plans, architects, and refines implementation details for Android features in the Bitwarden Android codebase before any code is written. Use at the START of any new feature, significant change, Jira ticket, or when requirements need clarification and gap analysis. Proactively suggest when the user describes a feature, shares a ticket, or asks to plan Android work. Produces a structured, phased implementation plan ready for the android-implementer agent."
model: opus
color: green
tools: Read, Glob, Grep, Write, Edit, Agent, Skill(refining-android-requirements), Skill(planning-android-implementation), Skill(plan-android-work), mcp__plugin_bitwarden-atlassian-tools_bitwarden-atlassian__get_issue, mcp__plugin_bitwarden-atlassian-tools_bitwarden-atlassian__get_issue_comments, mcp__plugin_bitwarden-atlassian-tools_bitwarden-atlassian__search_issues, mcp__plugin_bitwarden-atlassian-tools_bitwarden-atlassian__search_confluence, mcp__plugin_bitwarden-atlassian-tools_bitwarden-atlassian__get_confluence_page
---
You are the Android Architect — an elite software architect and senior Android engineer with deep mastery of the Bitwarden Android codebase. You operate as a planning and design authority, responsible for transforming vague requirements, tickets, or feature ideas into precise, actionable, phased implementation plans before any code is written.
Your primary workflow is `Skill(plan-android-work)`, which encompasses two sequential phases:
1. **`Skill(refining-android-requirements)`** — Gap analysis, ambiguity resolution, and structured specification
2. **`Skill(planning-android-implementation)`** — Architecture design, pattern selection, and phased task breakdown
---
## Core Responsibilities
### Phase 1: Requirements Refinement (`Skill(refining-android-requirements)`)
Before any planning begins, you must fully understand what is being built. You will:
1. **Parse and Extract Intent**: Identify the core feature request, affected modules (`:app`, `:authenticator`, shared), and user-facing vs. internal scope.
2. **Identify Gaps**: Actively look for missing information:
- Ambiguous acceptance criteria
- Undefined edge cases (empty states, error states, loading states, network failure)
- Missing security or zero-knowledge implications
- Unclear UI/UX behavior
- Unspecified API contracts or SDK interactions
- Missing test coverage expectations
3. **Produce Structured Specification**: Output a refined spec with:
- Feature summary (1-2 sentences)
- Affected modules and components
- Functional requirements (numbered list)
- Non-functional requirements (performance, security, accessibility)
- Open questions that MUST be resolved before implementation (ask the user if needed)
- Assumptions being made (document clearly)
### Phase 2: Implementation Planning (`Skill(planning-android-implementation)`)
With a refined spec, produce a comprehensive implementation plan:
1. **Architecture Design**:
- Identify which ViewModel(s), Repository(ies), and data sources are involved
- Define new interfaces and their `...Impl` counterparts
- Map UDF flow: UI Actions → ViewModel → Repository → SDK/Network/Disk → DataState
- Identify required State, Action, and Event sealed class members
- Note any new Hilt modules or injection changes required
2. **Pattern Selection**:
- Identify existing patterns in the codebase that apply
- Flag any cases where a new pattern might be needed (rare — prefer established patterns)
- Reference relevant existing files as implementation guides
3. **Phased Task Breakdown**: Organize work into logical phases:
- Phase 1: Data layer (repositories, data sources, models)
- Phase 2: Domain/business logic (ViewModel, state management)
- Phase 3: UI layer (Compose screens, previews, navigation)
- Phase 4: Tests (unit tests per component, integration where needed)
- Phase 5: Polish (strings, accessibility, edge cases)
4. **Dependency and Risk Analysis**:
- Identify blocking dependencies between tasks
- Flag high-risk areas (security, crypto, SDK interactions)
- Note areas requiring special care (e.g., DataState streaming, coroutine context)
5. **File Manifest**: List all files to be created or modified with brief descriptions.
---
## Bitwarden Android Expertise
You have deep knowledge of this codebase and must apply it in every plan:
### Architecture Constraints
- **No exceptions from data layer**: All suspending functions must return `Result<T>` or sealed classes
- **State hoisting**: All behavior-affecting state lives in ViewModel's state — never in composables
- **Interface-based DI**: Every implementation has an interface counterpart with Hilt injection
- **UDF strictly enforced**: State flows down, actions flow up — no bidirectional data flow
- **Internal actions for coroutines**: Never update state directly inside `launch` blocks; map results to `Internal` actions first
### Zero-Knowledge Security Rules (NON-NEGOTIABLE)
- Never transmit unencrypted vault data or master passwords to the server
- All encryption via Bitwarden SDK — never implement custom crypto
- Use scoped SDK sources (`ScopedVaultSdkSource`) to prevent cross-user context leakage
- On logout, all sensitive data cleared via `UserLogoutManager.logout()`
- Store sensitive data only via Android Keystore or SDK-encrypted storage
### Code Style Requirements
- 100-character line limit
- `camelCase` for vars/functions, `PascalCase` for classes, `SCREAMING_SNAKE_CASE` for constants
- `...Impl` suffix for all implementations
- KDoc required for all public APIs
- Test constants at bottom of file — NO companion objects in tests
- String resources in `:ui` module (`ui/src/main/res/values/strings.xml`) using typographic quotes
---
## Output Format
Your output must always be a structured planning document with these sections:
```
# Implementation Plan: [Feature Name]
## Refined Requirements
### Summary
### Functional Requirements
### Non-Functional Requirements
### Assumptions
### Open Questions (if any — request answers from user before proceeding)
## Architecture Design
### Affected Components
### New Interfaces & Implementations
### UDF Flow Diagram (text-based)
### State / Action / Event Definitions
## Phased Implementation Plan
### Phase 1: [Name] — [Estimated scope]
- Task 1.1: ...
- Task 1.2: ...
### Phase 2: ...
...
## File Manifest
### New Files
### Modified Files
## Risk & Dependency Notes
## Handoff Notes for Implementer
```
---
## Behavioral Guidelines
### DO
- Explore the codebase (via sub-agents) to understand existing patterns before designing — never assume file locations or implementations
- Ask clarifying questions BEFORE producing a plan if critical information is missing
- Reference specific existing files and patterns as implementation guides in your plan
- Apply security considerations proactively — flag any zero-knowledge implications
- Produce plans detailed enough that an implementer needs no additional context
- Note when existing patterns should be reused vs. when genuinely new patterns are warranted
### DON'T
- Write implementation code — your job ends where the implementer's begins
- Assume requirements are complete — always perform gap analysis
- Invent new architectural patterns when established ones exist
- Ignore security implications of any feature touching vault data, credentials, or keys
- Produce vague tasks — every task must be concrete and actionable
- Skip the requirements refinement phase even for seemingly simple requests
### Codebase Exploration Protocol
Before designing any architecture, deploy exploration sub-agents to:
- Locate relevant existing ViewModels, Repositories, and data sources
- Understand current patterns for similar features
- Identify reusable components and shared infrastructure
- Check for existing test patterns to replicate

View File

@@ -15,19 +15,19 @@ Work through each phase sequentially. **Confirm with the user before advancing t
### Phase 1: Implement
Invoke `Skill(implementing-android-code)` to guide your implementation of the task. Understand what needs to be done, explore the relevant code, and write the implementation.
Invoke the `implementing-android-code` skill and use it to guide your implementation of the task. Understand what needs to be done, explore the relevant code, and write the implementation.
**Before advancing**: Summarize what was implemented and confirm the user is ready to move to testing.
### Phase 2: Test
Invoke `Skill(testing-android-code)` to write tests for the changes made in Phase 1. Follow the project's test patterns and conventions.
Invoke the `testing-android-code` skill and use it to write tests for the changes made in Phase 1. Follow the project's test patterns and conventions.
**Before advancing**: Summarize what tests were written and confirm readiness for build verification.
### Phase 3: Build & Verify
Invoke `Skill(build-test-verify)` to run tests, lint, and detekt. Ensure everything passes.
Invoke the `build-test-verify` skill to run tests, lint, and detekt. Ensure everything passes.
**If failures occur**: Fix the issues and re-run verification. Do not advance until all checks pass.
@@ -35,13 +35,13 @@ Invoke `Skill(build-test-verify)` to run tests, lint, and detekt. Ensure everyth
### Phase 4: Self-Review
Invoke `Skill(perform-android-preflight-checklist)` to perform a quality gate check on all changes. Address any issues found.
Invoke the `perform-android-preflight-checklist` skill to perform a quality gate check on all changes. Address any issues found.
**Before advancing**: Share the self-review results and confirm readiness to commit.
### Phase 5: Commit
Invoke `Skill(committing-android-changes)` to stage and commit the changes with a properly formatted commit message.
Invoke the `committing-android-changes` skill to stage and commit the changes with a properly formatted commit message.
**Before advancing**: Confirm the commit was successful and ask if the user wants to proceed to review and PR creation, or stop here.
@@ -56,7 +56,7 @@ Launch a subagent with the `/bitwarden-code-review:code-review-local` command to
### Phase 7: Pull Request
Prompt the user to invoke `Skill(creating-android-pull-request)` to create the pull request with proper description and formatting. **Create as a draft PR by default** unless the user has explicitly requested a ready-for-review PR.
Prompt the user to invoke the `creating-android-pull-request` skill to create the pull request with proper description and formatting. **Create as a draft PR by default** unless the user has explicitly requested a ready-for-review PR.
## Guidelines

View File

@@ -94,16 +94,9 @@ ui/src/testFixtures/ # UI test utilities (BaseViewModelTest, BaseCom
## Lint & Static Analysis
**IMPORTANT**: Prefer running detekt on modified files only — a full project scan is slow and unnecessary during development. The project supports a `-Pprecommit=true` flag that limits detekt to staged files.
**IMPORTANT**: Always pipe detekt output through a filter to capture errors on the first run. Detekt prints violation details to stderr/stdout but Gradle can obscure them. Use the grep pattern below to see violations immediately.
```bash
# Detekt on staged files only (preferred during development)
git add -u && ./gradlew -Pprecommit=true detekt 2>&1 | grep -E "FAILED|BUILD|Line |Rule |Signature|detekt" | head -40
# Detekt on all files (full scan, use sparingly)
./gradlew detekt 2>&1 | grep -E "FAILED|BUILD|Line |Rule |Signature|detekt" | head -40
# Detekt (static analysis)
./gradlew detekt
# Android Lint
./gradlew lint
@@ -112,10 +105,6 @@ git add -u && ./gradlew -Pprecommit=true detekt 2>&1 | grep -E "FAILED|BUILD|Lin
./fastlane check
```
### How `-Pprecommit=true` Works
The root `build.gradle.kts` configures detekt tasks to use `git diff --name-only --cached` when this property is set, limiting analysis to staged files only. This is the same mechanism used by the project's pre-commit hook. Stage your changes with `git add` before running.
---
## Codebase Discovery

View File

@@ -58,21 +58,6 @@ gh pr create --draft --title "[PM-XXXXX] feat: Short summary" --body "<fill in f
---
## AI Review Label
Before running `gh pr create`, **always** use the `AskUserQuestion` tool to ask whether to add an AI review label:
- **Question**: "Would you like to add an AI review label to this PR?"
- **Options**: `ai-review-vnext`, `ai-review`, `No label`
If the user selects a label, include it via the `--label` flag:
```bash
gh pr create --draft --label "ai-review-vnext" --title "..." --body "..."
```
---
## Base Branch
- Default target: `main`

View File

@@ -263,6 +263,7 @@ Common testing mistakes in Bitwarden. **For complete details and examples:** See
- **Null stream testing** - Test null returns from ContentResolver operations
- **bufferedMutableSharedFlow** - Use with `.onSubscription { emit(state) }` in Fakes
- **Test factory methods** - Accept domain state types, not SavedStateHandle
- **@Suppress("MaxLineLength")** - Only add when the `fun` declaration line **actually exceeds 100 chars** — do not copy the pattern blindly
---
@@ -282,10 +283,6 @@ module/src/testFixtures/kotlin/com/bitwarden/.../
└── model/*Util.kt
```
### Test Constants Placement
Declare test constants as top-level `private const val` at the **bottom** of the file, after the class closing brace. Do NOT use `companion object` for test constants.
### Test Naming
- Classes: `*Test.kt`, `*ScreenTest.kt`, `*ViewModelTest.kt`

View File

@@ -9,7 +9,7 @@ runs:
using: 'composite'
steps:
- name: Setup Gradle
uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0
uses: gradle/actions/setup-gradle@f29f5a9d7b09a7c6b29859002d29d24e1674c884 # v5.0.1
- name: Configure Ruby
uses: ruby/setup-ruby@44511735964dcb71245e7e55f72539531f7bc0eb # v1.257.0

View File

@@ -47,7 +47,7 @@ jobs:
uses: bitwarden/gh-actions/azure-logout@main
- name: Generate GH App token
uses: actions/create-github-app-token@f8d387b68d61c58ab83c6c016672934102569859 # v3.0.0
uses: actions/create-github-app-token@a8d616148505b5069dccd32f177bb87d7f39123b # v2.1.1
id: app-token
with:
app-id: ${{ steps.get-kv-secrets.outputs.BW-GHAPP-ID }}

View File

@@ -53,7 +53,7 @@ jobs:
uses: bitwarden/gh-actions/azure-logout@main
- name: Generate GH App token
uses: actions/create-github-app-token@f8d387b68d61c58ab83c6c016672934102569859 # v3.0.0
uses: actions/create-github-app-token@a8d616148505b5069dccd32f177bb87d7f39123b # v2.1.1
id: app-token
with:
app-id: ${{ steps.get-kv-secrets.outputs.BW-GHAPP-ID }}

View File

@@ -89,7 +89,7 @@ jobs:
- name: Upload to codecov.io
if: always() && matrix.group != 'static-analysis' && (github.event_name == 'push' || github.event_name == 'pull_request')
uses: codecov/codecov-action@57e3a136b779b570ffcdbf80b3bdc90e7fab3de2 # v6.0.0
uses: codecov/codecov-action@5a1091511ad55cbe89839c7260b706298ca349f7 # v5.5.1
continue-on-error: true
with:
os: linux
@@ -120,7 +120,7 @@ jobs:
steps:
- name: Notify Codecov that all uploads are complete
id: codecov-notify
uses: codecov/codecov-action@57e3a136b779b570ffcdbf80b3bdc90e7fab3de2 # v6.0.0
uses: codecov/codecov-action@5a1091511ad55cbe89839c7260b706298ca349f7 # v5.5.1
continue-on-error: true
with:
run_command: send-notifications

View File

@@ -3,13 +3,13 @@ GEM
specs:
CFPropertyList (3.0.8)
abbrev (0.1.2)
addressable (2.9.0)
addressable (2.8.9)
public_suffix (>= 2.0.2, < 8.0)
artifactory (3.0.17)
atomos (0.1.3)
aws-eventstream (1.4.0)
aws-partitions (1.1237.0)
aws-sdk-core (3.244.0)
aws-partitions (1.1226.0)
aws-sdk-core (3.243.0)
aws-eventstream (~> 1, >= 1.3.0)
aws-partitions (~> 1, >= 1.992.0)
aws-sigv4 (~> 1.9)
@@ -17,18 +17,18 @@ GEM
bigdecimal
jmespath (~> 1, >= 1.6.1)
logger
aws-sdk-kms (1.123.0)
aws-sdk-core (~> 3, >= 3.244.0)
aws-sdk-kms (1.122.0)
aws-sdk-core (~> 3, >= 3.241.4)
aws-sigv4 (~> 1.5)
aws-sdk-s3 (1.219.0)
aws-sdk-core (~> 3, >= 3.244.0)
aws-sdk-s3 (1.216.0)
aws-sdk-core (~> 3, >= 3.243.0)
aws-sdk-kms (~> 1)
aws-sigv4 (~> 1.5)
aws-sigv4 (1.12.1)
aws-eventstream (~> 1, >= 1.0.2)
babosa (1.0.4)
base64 (0.3.0)
bigdecimal (4.1.1)
bigdecimal (4.0.1)
claide (1.1.0)
colored (1.2)
colored2 (3.1.2)
@@ -148,7 +148,7 @@ GEM
google-cloud-errors (~> 1.0)
google-cloud-env (1.6.0)
faraday (>= 0.17.3, < 3.0)
google-cloud-errors (1.6.0)
google-cloud-errors (1.5.0)
google-cloud-storage (1.47.0)
addressable (~> 2.8)
digest-crc (~> 0.4)
@@ -169,13 +169,13 @@ GEM
httpclient (2.9.0)
mutex_m
jmespath (1.6.2)
json (2.19.3)
json (2.19.1)
jwt (2.10.2)
base64
logger (1.7.0)
mini_magick (4.13.2)
mini_mime (1.1.5)
multi_json (1.20.1)
multi_json (1.19.1)
multipart-post (2.4.1)
mutex_m (0.3.0)
nanaimo (0.4.0)

View File

@@ -8,7 +8,7 @@ import kotlinx.coroutines.flow.StateFlow
/**
* F-Droid implementation of [PlayBillingManager]. Always returns `true` since
* F-Droid users are eligible for the Premium upgrade flow.
* F-Droid users are eligible for the premium upgrade flow.
*/
@OmitFromCoverage
@Suppress("UnusedParameter")

View File

@@ -0,0 +1,14 @@
package com.x8bit.bitwarden.data.platform.manager
import android.content.Context
/**
* F-Droid implementation of [GmsManager]. Always returns `false` since GMS is not available.
*/
@Suppress("UnusedParameter")
class GmsManagerImpl(
context: Context,
) : GmsManager {
override fun isVersionAtLeast(version: Int): Boolean = false
}

View File

@@ -40,18 +40,6 @@
]
}
},
{
"type": "android",
"info": {
"package_name": "org.calyxos.chromium",
"signatures": [
{
"build": "release",
"cert_fingerprint_sha256": "CB:33:EE:73:84:2F:2F:BD:C3:E3:52:5F:D1:C3:74:07:41:82:6F:33:84:9B:C9:6F:95:4D:76:18:17:D3:00:EB"
}
]
}
},
{
"type": "android",
"info": {

View File

@@ -88,10 +88,6 @@ class MainActivity : AppCompatActivity() {
mainViewModel.trySendAction(MainAction.CookieAcquisitionResult(it))
}
private val premiumCheckoutLauncher = AuthTabIntent.registerActivityResultLauncher(this) {
mainViewModel.trySendAction(MainAction.PremiumCheckoutResult(it))
}
override fun onCreate(savedInstanceState: Bundle?) {
intent = intent.validate()
var shouldShowSplashScreen = true
@@ -119,7 +115,6 @@ class MainActivity : AppCompatActivity() {
sso = ssoLauncher,
webAuthn = webAuthnLauncher,
cookie = cookieLauncher,
premiumCheckout = premiumCheckoutLauncher,
),
) {
ObserveScreenDataEffect(

View File

@@ -27,7 +27,6 @@ import com.x8bit.bitwarden.data.autofill.accessibility.manager.AccessibilitySele
import com.x8bit.bitwarden.data.autofill.manager.AutofillSelectionManager
import com.x8bit.bitwarden.data.autofill.util.getAutofillSaveItemOrNull
import com.x8bit.bitwarden.data.autofill.util.getAutofillSelectionDataOrNull
import com.x8bit.bitwarden.data.billing.util.getPremiumCheckoutCallbackResult
import com.x8bit.bitwarden.data.credentials.manager.CredentialProviderRequestManager
import com.x8bit.bitwarden.data.credentials.manager.model.CredentialProviderRequest
import com.x8bit.bitwarden.data.platform.manager.AppResumeManager
@@ -199,7 +198,6 @@ class MainViewModel @Inject constructor(
is MainAction.SsoResult -> handleSsoResult(action)
is MainAction.WebAuthnResult -> handleWebAuthnResult(action)
is MainAction.CookieAcquisitionResult -> handleCookieAcquisitionResult(action)
is MainAction.PremiumCheckoutResult -> handlePremiumCheckoutResult(action)
is MainAction.Internal -> handleInternalAction(action)
}
}
@@ -249,12 +247,6 @@ class MainViewModel @Inject constructor(
)
}
private fun handlePremiumCheckoutResult(action: MainAction.PremiumCheckoutResult) {
specialCircumstanceManager.specialCircumstance = SpecialCircumstance.PremiumCheckout(
callbackResult = action.authResult.getPremiumCheckoutCallbackResult(),
)
}
private fun handleAppResumeDataUpdated(action: MainAction.ResumeScreenDataReceived) {
when (val data = action.screenResumeData) {
null -> appResumeManager.clearResumeScreen()
@@ -406,9 +398,7 @@ class MainViewModel @Inject constructor(
hasPremiumCheckoutCallback -> {
specialCircumstanceManager.specialCircumstance =
SpecialCircumstance.PremiumCheckout(
callbackResult = intent.data.getPremiumCheckoutCallbackResult(),
)
SpecialCircumstance.PremiumCheckoutResult
}
hasGeneratorShortcut -> {
@@ -565,13 +555,6 @@ sealed class MainAction {
val cookieCallbackResult: AuthTabIntent.AuthResult,
) : MainAction()
/**
* Receive the result from the premium checkout flow.
*/
data class PremiumCheckoutResult(
val authResult: AuthTabIntent.AuthResult,
) : MainAction()
/**
* Receive first Intent by the application.
*/

View File

@@ -42,7 +42,7 @@ data class AccountJson(
* @property name The user's name (if applicable).
* @property stamp The account's security stamp (if applicable).
* @property organizationId The ID of the associated organization (if applicable).
* @property hasPremium True if the user has a Premium account.
* @property hasPremium True if the user has a premium account.
* @property avatarColorHex Hex color value for a user's avatar in the "#AARRGGBB" format.
* @property forcePasswordResetReason Describes the reason for a forced password reset.
* @property kdfType The KDF type.

View File

@@ -1,6 +1,5 @@
package com.x8bit.bitwarden.data.auth.datasource.sdk
import com.bitwarden.auth.KeyConnectorRegistrationResult
import com.bitwarden.core.AuthRequestResponse
import com.bitwarden.core.KeyConnectorResponse
import com.bitwarden.core.MasterPasswordPolicyOptions
@@ -14,16 +13,6 @@ import com.x8bit.bitwarden.data.auth.datasource.sdk.model.PasswordStrength
* Source of authentication information and functionality from the Bitwarden SDK.
*/
interface AuthSdkSource {
/**
* Enrolls the user to key connector unlock.
*/
suspend fun postKeysForKeyConnectorRegistration(
userId: String,
accessToken: String,
keyConnectorUrl: String,
ssoOrganizationIdentifier: String,
): Result<KeyConnectorRegistrationResult>
/**
* Gets the data needed to create a new auth request.
*/

View File

@@ -1,6 +1,5 @@
package com.x8bit.bitwarden.data.auth.datasource.sdk
import com.bitwarden.auth.KeyConnectorRegistrationResult
import com.bitwarden.core.AuthRequestResponse
import com.bitwarden.core.FingerprintRequest
import com.bitwarden.core.KeyConnectorResponse
@@ -25,38 +24,28 @@ class AuthSdkSourceImpl(
) : BaseSdkSource(sdkClientManager = sdkClientManager),
AuthSdkSource {
override suspend fun postKeysForKeyConnectorRegistration(
userId: String,
accessToken: String,
keyConnectorUrl: String,
ssoOrganizationIdentifier: String,
): Result<KeyConnectorRegistrationResult> = runCatchingWithLogs {
useClient(userId = userId, accessToken = accessToken) {
auth().registration().postKeysForKeyConnectorRegistration(
keyConnectorUrl = keyConnectorUrl,
ssoOrgIdentifier = ssoOrganizationIdentifier,
)
}
}
override suspend fun getNewAuthRequest(
email: String,
): Result<AuthRequestResponse> = runCatchingWithLogs {
useClient { auth().newAuthRequest(email = email.lowercase()) }
getClient()
.auth()
.newAuthRequest(
email = email.lowercase(),
)
}
override suspend fun getUserFingerprint(
email: String,
publicKey: String,
): Result<String> = runCatchingWithLogs {
useClient {
platform().fingerprint(
getClient()
.platform()
.fingerprint(
req = FingerprintRequest(
fingerprintMaterial = email.lowercase(),
publicKey = publicKey,
),
)
}
}
override suspend fun hashPassword(
@@ -65,19 +54,21 @@ class AuthSdkSourceImpl(
kdf: Kdf,
purpose: HashPurpose,
): Result<String> = runCatchingWithLogs {
useClient {
auth().hashPassword(
getClient()
.auth()
.hashPassword(
email = email,
password = password,
kdfParams = kdf,
purpose = purpose,
)
}
}
override suspend fun makeKeyConnectorKeys(): Result<KeyConnectorResponse> =
runCatchingWithLogs {
useClient { auth().makeKeyConnectorKeys() }
getClient()
.auth()
.makeKeyConnectorKeys()
}
override suspend fun makeRegisterKeys(
@@ -85,13 +76,13 @@ class AuthSdkSourceImpl(
password: String,
kdf: Kdf,
): Result<RegisterKeyResponse> = runCatchingWithLogs {
useClient {
auth().makeRegisterKeys(
getClient()
.auth()
.makeRegisterKeys(
email = email,
password = password,
kdf = kdf,
)
}
}
override suspend fun makeRegisterTdeKeysAndUnlockVault(
@@ -114,16 +105,15 @@ class AuthSdkSourceImpl(
password: String,
additionalInputs: List<String>,
): Result<PasswordStrength> = runCatchingWithLogs {
useClient {
@Suppress("UnsafeCallOnNullableType")
auth()
.passwordStrength(
password = password,
email = email,
additionalInputs = additionalInputs,
)
.toPasswordStrengthOrNull()!!
}
@Suppress("UnsafeCallOnNullableType")
getClient()
.auth()
.passwordStrength(
password = password,
email = email,
additionalInputs = additionalInputs,
)
.toPasswordStrengthOrNull()!!
}
override suspend fun satisfiesPolicy(
@@ -131,12 +121,12 @@ class AuthSdkSourceImpl(
passwordStrength: PasswordStrength,
policy: MasterPasswordPolicyOptions,
): Result<Boolean> = runCatchingWithLogs {
useClient {
auth().satisfiesPolicy(
getClient()
.auth()
.satisfiesPolicy(
password = password,
strength = passwordStrength.toUByte(),
policy = policy,
)
}
}
}

View File

@@ -1,11 +1,10 @@
package com.x8bit.bitwarden.data.auth.manager
import com.bitwarden.core.KeyConnectorResponse
import com.bitwarden.crypto.Kdf
import com.bitwarden.network.model.AccountKeysJson
import com.bitwarden.network.model.KdfTypeJson
import com.bitwarden.network.model.KeyConnectorMasterKeyResponseJson
import com.x8bit.bitwarden.data.auth.manager.model.MigrateExistingUserToKeyConnectorResult
import com.x8bit.bitwarden.data.auth.manager.model.MigrateNewUserToKeyConnectorResult
/**
* Manager used to interface with a key connector.
@@ -37,8 +36,6 @@ interface KeyConnectorManager {
*/
@Suppress("LongParameterList")
suspend fun migrateNewUserToKeyConnector(
userId: String,
accountKeys: AccountKeysJson?,
url: String,
accessToken: String,
kdfType: KdfTypeJson,
@@ -46,5 +43,5 @@ interface KeyConnectorManager {
kdfMemory: Int?,
kdfParallelism: Int?,
organizationIdentifier: String,
): Result<MigrateNewUserToKeyConnectorResult>
): Result<KeyConnectorResponse>
}

View File

@@ -1,23 +1,16 @@
package com.x8bit.bitwarden.data.auth.manager
import com.bitwarden.core.WrappedAccountCryptographicState
import com.bitwarden.core.data.manager.dispatcher.DispatcherManager
import com.bitwarden.core.data.manager.model.FlagKey
import com.bitwarden.core.KeyConnectorResponse
import com.bitwarden.core.data.util.flatMap
import com.bitwarden.crypto.Kdf
import com.bitwarden.network.model.AccountKeysJson
import com.bitwarden.network.model.KdfTypeJson
import com.bitwarden.network.model.KeyConnectorKeyRequestJson
import com.bitwarden.network.model.KeyConnectorMasterKeyResponseJson
import com.bitwarden.network.service.AccountsService
import com.x8bit.bitwarden.data.auth.datasource.sdk.AuthSdkSource
import com.x8bit.bitwarden.data.auth.manager.model.MigrateExistingUserToKeyConnectorResult
import com.x8bit.bitwarden.data.auth.manager.model.MigrateNewUserToKeyConnectorResult
import com.x8bit.bitwarden.data.auth.repository.util.toAccountCryptographicState
import com.x8bit.bitwarden.data.platform.manager.FeatureFlagManager
import com.x8bit.bitwarden.data.vault.datasource.sdk.VaultSdkSource
import com.x8bit.bitwarden.data.vault.datasource.sdk.model.DeriveKeyConnectorResult
import kotlinx.coroutines.withContext
/**
* The default implementation of the [KeyConnectorManager].
@@ -26,8 +19,6 @@ class KeyConnectorManagerImpl(
private val accountsService: AccountsService,
private val authSdkSource: AuthSdkSource,
private val vaultSdkSource: VaultSdkSource,
private val featureFlagManager: FeatureFlagManager,
private val dispatcherManager: DispatcherManager,
) : KeyConnectorManager {
override suspend fun getMasterKeyFromKeyConnector(
url: String,
@@ -86,8 +77,6 @@ class KeyConnectorManagerImpl(
}
override suspend fun migrateNewUserToKeyConnector(
userId: String,
accountKeys: AccountKeysJson?,
url: String,
accessToken: String,
kdfType: KdfTypeJson,
@@ -95,52 +84,7 @@ class KeyConnectorManagerImpl(
kdfMemory: Int?,
kdfParallelism: Int?,
organizationIdentifier: String,
): Result<MigrateNewUserToKeyConnectorResult> =
if (featureFlagManager.getFeatureFlag(FlagKey.V2EncryptionKeyConnector)) {
withContext(dispatcherManager.io) {
authSdkSource
.postKeysForKeyConnectorRegistration(
userId = userId,
accessToken = accessToken,
keyConnectorUrl = url,
ssoOrganizationIdentifier = organizationIdentifier,
)
.map {
MigrateNewUserToKeyConnectorResult(
masterKey = it.keyConnectorKey,
encryptedUserKey = it.keyConnectorKeyWrappedUserKey,
privateKey = when (val state = it.accountCryptographicState) {
is WrappedAccountCryptographicState.V1 -> state.privateKey
is WrappedAccountCryptographicState.V2 -> state.privateKey
},
accountCryptographicState = it.accountCryptographicState,
)
}
}
} else {
legacyMigrateNewUserToKeyConnector(
accountKeys = accountKeys,
url = url,
accessToken = accessToken,
kdfType = kdfType,
kdfIterations = kdfIterations,
kdfMemory = kdfMemory,
kdfParallelism = kdfParallelism,
organizationIdentifier = organizationIdentifier,
)
}
@Suppress("LongParameterList")
private suspend fun legacyMigrateNewUserToKeyConnector(
accountKeys: AccountKeysJson?,
url: String,
accessToken: String,
kdfType: KdfTypeJson,
kdfIterations: Int?,
kdfMemory: Int?,
kdfParallelism: Int?,
organizationIdentifier: String,
): Result<MigrateNewUserToKeyConnectorResult> =
): Result<KeyConnectorResponse> =
authSdkSource
.makeKeyConnectorKeys()
.flatMap { keyConnectorResponse ->
@@ -167,15 +111,6 @@ class KeyConnectorManagerImpl(
),
)
}
.map {
MigrateNewUserToKeyConnectorResult(
masterKey = keyConnectorResponse.masterKey,
encryptedUserKey = keyConnectorResponse.encryptedUserKey,
privateKey = keyConnectorResponse.keys.private,
accountCryptographicState = accountKeys.toAccountCryptographicState(
privateKey = keyConnectorResponse.keys.private,
),
)
}
.map { keyConnectorResponse }
}
}

View File

@@ -57,6 +57,7 @@ class UserLogoutManagerImpl(
val ableToSwitchToNewAccount = switchUserIfAvailable(
currentUserId = userId,
isSecurityStamp = isSecurityStamp,
removeCurrentUserFromAccounts = true,
)
if (!ableToSwitchToNewAccount) {
@@ -86,6 +87,12 @@ class UserLogoutManagerImpl(
userId = userId,
)
switchUserIfAvailable(
currentUserId = userId,
removeCurrentUserFromAccounts = false,
isSecurityStamp = isSecurityStamp,
)
clearData(userId = userId)
mutableLogoutEventFlow.tryEmit(LogoutEvent(loggedOutUserId = userId))
@@ -128,6 +135,7 @@ class UserLogoutManagerImpl(
private fun switchUserIfAvailable(
currentUserId: String,
removeCurrentUserFromAccounts: Boolean,
isSecurityStamp: Boolean,
): Boolean {
val currentUserState = authDiskSource.userState ?: return false
@@ -135,7 +143,8 @@ class UserLogoutManagerImpl(
val currentAccountsMap = currentUserState.accounts
// Remove the active user from the accounts map
val updatedAccounts = currentAccountsMap.filterKeys { it != currentUserId }
val updatedAccounts = currentAccountsMap
.filterKeys { it != currentUserId }
// Check if there is a new active user
return if (updatedAccounts.isNotEmpty()) {
@@ -154,7 +163,11 @@ class UserLogoutManagerImpl(
// Update the user information and emit an updated token
authDiskSource.userState = currentUserState.copy(
activeUserId = updatedActiveUserId,
accounts = updatedAccounts,
accounts = if (removeCurrentUserFromAccounts) {
updatedAccounts
} else {
currentAccountsMap
},
)
true
} else {

View File

@@ -89,15 +89,11 @@ object AuthManagerModule {
accountsService: AccountsService,
authSdkSource: AuthSdkSource,
vaultSdkSource: VaultSdkSource,
featureFlagManager: FeatureFlagManager,
dispatcherManager: DispatcherManager,
): KeyConnectorManager =
KeyConnectorManagerImpl(
accountsService = accountsService,
authSdkSource = authSdkSource,
vaultSdkSource = vaultSdkSource,
featureFlagManager = featureFlagManager,
dispatcherManager = dispatcherManager,
)
@Provides

View File

@@ -1,13 +0,0 @@
package com.x8bit.bitwarden.data.auth.manager.model
import com.bitwarden.core.WrappedAccountCryptographicState
/**
* Models result of migrating a new user to key connector.
* */
data class MigrateNewUserToKeyConnectorResult(
val masterKey: String,
val encryptedUserKey: String,
val privateKey: String,
val accountCryptographicState: WrappedAccountCryptographicState,
)

View File

@@ -230,10 +230,7 @@ interface AuthRepository :
/**
* Continue the previously halted login attempt.
*/
suspend fun continueKeyConnectorLogin(
orgIdentifier: String,
email: String,
): LoginResult
suspend fun continueKeyConnectorLogin(): LoginResult
/**
* Cancel the previously halted login attempt.

View File

@@ -17,7 +17,6 @@ import com.bitwarden.data.datasource.disk.ConfigDiskSource
import com.bitwarden.data.repository.util.appLinksScheme
import com.bitwarden.data.repository.util.toEnvironmentUrls
import com.bitwarden.data.repository.util.toEnvironmentUrlsOrDefault
import com.bitwarden.network.model.AccountKeysJson
import com.bitwarden.network.model.CreateAccountKeysResponseJson
import com.bitwarden.network.model.DeleteAccountResponseJson
import com.bitwarden.network.model.GetTokenResponseJson
@@ -102,7 +101,6 @@ import com.x8bit.bitwarden.data.auth.repository.util.SsoCallbackResult
import com.x8bit.bitwarden.data.auth.repository.util.WebAuthResult
import com.x8bit.bitwarden.data.auth.repository.util.activeUserIdChangesFlow
import com.x8bit.bitwarden.data.auth.repository.util.policyInformation
import com.x8bit.bitwarden.data.auth.repository.util.toAccountCryptographicState
import com.x8bit.bitwarden.data.auth.repository.util.toOrganizations
import com.x8bit.bitwarden.data.auth.repository.util.toRemovedPasswordUserStateJson
import com.x8bit.bitwarden.data.auth.repository.util.toSdkParams
@@ -125,6 +123,7 @@ import com.x8bit.bitwarden.data.vault.datasource.sdk.VaultSdkSource
import com.x8bit.bitwarden.data.vault.repository.VaultRepository
import com.x8bit.bitwarden.data.vault.repository.model.VaultUnlockError
import com.x8bit.bitwarden.data.vault.repository.model.VaultUnlockResult
import com.x8bit.bitwarden.data.vault.repository.util.createWrappedAccountCryptographicState
import com.x8bit.bitwarden.data.vault.repository.util.toSdkMasterPasswordUnlock
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
@@ -556,6 +555,9 @@ class AuthRepositoryImpl(
errorMessage = null,
error = MissingPropertyException("Private Key"),
)
val signingKey = accountKeys?.signatureKeyPair?.wrappedSigningKey
val securityState = accountKeys?.securityState?.securityState
val signedPublicKey = accountKeys?.publicKeyEncryptionKeyPair?.signedPublicKey
checkForVaultUnlockError(
onVaultUnlockError = { error ->
@@ -563,8 +565,11 @@ class AuthRepositoryImpl(
},
) {
unlockVault(
accountCryptographicState = accountKeys.toAccountCryptographicState(
accountCryptographicState = createWrappedAccountCryptographicState(
privateKey = privateKey,
securityState = securityState,
signingKey = signingKey,
signedPublicKey = signedPublicKey,
),
accountProfile = profile,
initUserCryptoMethod = InitUserCryptoMethod.AuthRequest(
@@ -676,18 +681,15 @@ class AuthRepositoryImpl(
error = MissingPropertyException("Identity Token Auth Model"),
)
override suspend fun continueKeyConnectorLogin(
orgIdentifier: String,
email: String,
): LoginResult {
override suspend fun continueKeyConnectorLogin(): LoginResult {
val response = keyConnectorResponse ?: return LoginResult.Error(
errorMessage = null,
error = MissingPropertyException("Key Connector Response"),
)
return handleLoginCommonSuccess(
loginResponse = response,
email = email,
orgIdentifier = orgIdentifier,
email = rememberedEmailAddress.orEmpty(),
orgIdentifier = rememberedOrgIdentifier,
password = null,
deviceData = null,
userConfirmedKeyConnector = true,
@@ -1674,7 +1676,6 @@ class AuthRepositoryImpl(
checkForVaultUnlockError(
onVaultUnlockError = { vaultUnlockError ->
authDiskSource.storeAccountTokens(userId = profile.userId, accountTokens = null)
return@userStateTransaction vaultUnlockError.toLoginErrorResult()
},
) {
@@ -1697,7 +1698,7 @@ class AuthRepositoryImpl(
val isNewKeyConnectorUser =
loginResponse.userDecryptionOptions?.hasMasterPassword == false &&
loginResponse.key == null &&
loginResponse.privateKeyOrNull() == null
loginResponse.privateKey == null
val isNotConfirmed = !userConfirmedKeyConnector
// If a new KeyConnector user is logging in for the first time,
@@ -1772,7 +1773,7 @@ class AuthRepositoryImpl(
}
// We continue to store the private key for backwards compatibility. Key connector
// conversion still relies on the private key.
loginResponse.privateKeyOrNull()?.let {
loginResponse.privateKey?.let {
// Only set the value if it's present, since we may have set it already
// when we completed the key connector conversion.
authDiskSource.storePrivateKey(userId = userId, privateKey = it)
@@ -1862,7 +1863,7 @@ class AuthRepositoryImpl(
loginResponse: GetTokenResponseJson.Success,
): VaultUnlockResult? {
val key = loginResponse.key
val privateKey = loginResponse.privateKeyOrNull()
val privateKey = loginResponse.privateKey
return if (loginResponse.userDecryptionOptions?.hasMasterPassword != false) {
// This user has a master password, so we skip the key-connector logic as it is not
// setup yet. The user can still unlock the vault with their master password.
@@ -1876,9 +1877,18 @@ class AuthRepositoryImpl(
)
.map {
unlockVault(
accountCryptographicState = loginResponse
.accountKeys
.toAccountCryptographicState(privateKey = privateKey),
accountCryptographicState = createWrappedAccountCryptographicState(
privateKey = privateKey,
securityState = loginResponse.accountKeys
?.securityState
?.securityState,
signingKey = loginResponse.accountKeys
?.signatureKeyPair
?.wrappedSigningKey,
signedPublicKey = loginResponse.accountKeys
?.publicKeyEncryptionKeyPair
?.signedPublicKey,
),
accountProfile = profile,
initUserCryptoMethod = InitUserCryptoMethod.KeyConnector(
masterKey = it.masterKey,
@@ -1892,12 +1902,9 @@ class AuthRepositoryImpl(
onSuccess = { it },
)
} else {
// This is a new user who needs to set up the key connector
val userId = profile.userId
// This is a new user who needs to setup the key connector
keyConnectorManager
.migrateNewUserToKeyConnector(
userId = userId,
accountKeys = loginResponse.accountKeys,
url = keyConnectorUrl,
accessToken = loginResponse.accessToken,
kdfType = loginResponse.kdfType,
@@ -1906,37 +1913,46 @@ class AuthRepositoryImpl(
kdfParallelism = loginResponse.kdfParallelism,
organizationIdentifier = orgIdentifier,
)
.map { keyConnector ->
this
.unlockVault(
accountCryptographicState = keyConnector.accountCryptographicState,
accountProfile = profile,
initUserCryptoMethod = InitUserCryptoMethod.KeyConnector(
masterKey = keyConnector.masterKey,
userKey = keyConnector.encryptedUserKey,
),
.map { keyConnectorResponse ->
val accountKeys = loginResponse.accountKeys
val result = unlockVault(
accountCryptographicState = createWrappedAccountCryptographicState(
privateKey = keyConnectorResponse.keys.private,
securityState = accountKeys
?.securityState
?.securityState,
signingKey = accountKeys
?.signatureKeyPair
?.wrappedSigningKey,
signedPublicKey = accountKeys
?.publicKeyEncryptionKeyPair
?.signedPublicKey,
),
accountProfile = profile,
initUserCryptoMethod = InitUserCryptoMethod.KeyConnector(
masterKey = keyConnectorResponse.masterKey,
userKey = keyConnectorResponse.encryptedUserKey,
),
)
if (result is VaultUnlockResult.Success) {
// We now know that login/unlock was successful, so we store the userKey
// and privateKey we now have since it didn't exist on the loginResponse
authDiskSource.storeUserKey(
userId = profile.userId,
userKey = keyConnectorResponse.encryptedUserKey,
)
.also { result ->
if (result is VaultUnlockResult.Success) {
// We now know that login/unlock was successful, so we store the
// userKey and privateKey we now have since it didn't exist on the
// loginResponse.
authDiskSource.storeUserKey(
userId = userId,
userKey = keyConnector.encryptedUserKey,
)
// We continue to store the private key for backwards compatibility
// since key connector conversion still relies on the private key.
authDiskSource.storePrivateKey(
userId = userId,
privateKey = keyConnector.privateKey,
)
authDiskSource.storeAccountKeys(
userId = userId,
accountKeys = loginResponse.accountKeys,
)
}
}
// We continue to store the private key for backwards compatibility since
// key connector conversion still relies on the private key.
authDiskSource.storePrivateKey(
userId = profile.userId,
privateKey = keyConnectorResponse.keys.private,
)
authDiskSource.storeAccountKeys(
userId = profile.userId,
accountKeys = loginResponse.accountKeys,
)
}
result
}
.fold(
// If the request failed, we want to abort the login process
@@ -1968,8 +1984,17 @@ class AuthRepositoryImpl(
)
return unlockVault(
accountCryptographicState = loginResponse.accountKeys.toAccountCryptographicState(
accountCryptographicState = createWrappedAccountCryptographicState(
privateKey = privateKey,
securityState = loginResponse.accountKeys
?.securityState
?.securityState,
signingKey = loginResponse.accountKeys
?.signatureKeyPair
?.wrappedSigningKey,
signedPublicKey = loginResponse.accountKeys
?.publicKeyEncryptionKeyPair
?.signedPublicKey,
),
accountProfile = profile,
initUserCryptoMethod = initUserCryptoMethod,
@@ -1992,9 +2017,18 @@ class AuthRepositoryImpl(
if (privateKey != null && key != null) {
deviceData?.let { model ->
return unlockVault(
accountCryptographicState = loginResponse
.accountKeys
.toAccountCryptographicState(privateKey = privateKey),
accountCryptographicState = createWrappedAccountCryptographicState(
privateKey = privateKey,
securityState = loginResponse.accountKeys
?.securityState
?.securityState,
signingKey = loginResponse.accountKeys
?.signatureKeyPair
?.wrappedSigningKey,
signedPublicKey = loginResponse.accountKeys
?.publicKeyEncryptionKeyPair
?.signedPublicKey,
),
accountProfile = profile,
initUserCryptoMethod = InitUserCryptoMethod.AuthRequest(
requestPrivateKey = model.privateKey,
@@ -2020,14 +2054,36 @@ class AuthRepositoryImpl(
.userDecryptionOptions
?.trustedDeviceUserDecryptionOptions
?.let { options ->
loginResponse.privateKeyOrNull()?.let { privateKey ->
unlockVaultWithTrustedDeviceUserDecryptionOptionsAndStoreKeys(
options = options,
profile = profile,
privateKey = privateKey,
accountKeys = loginResponse.accountKeys,
)
}
loginResponse.accountKeys
?.let { accountKeys ->
unlockVaultWithTrustedDeviceUserDecryptionOptionsAndStoreKeys(
options = options,
profile = profile,
privateKey = accountKeys
.publicKeyEncryptionKeyPair
.wrappedPrivateKey,
securityState = accountKeys
.securityState
?.securityState,
signedPublicKey = accountKeys
.publicKeyEncryptionKeyPair
.signedPublicKey,
signingKey = accountKeys
.signatureKeyPair
?.wrappedSigningKey,
)
}
?: loginResponse.privateKey
?.let { privateKey ->
unlockVaultWithTrustedDeviceUserDecryptionOptionsAndStoreKeys(
options = options,
profile = profile,
privateKey = privateKey,
securityState = null,
signedPublicKey = null,
signingKey = null,
)
}
}
}
@@ -2039,7 +2095,9 @@ class AuthRepositoryImpl(
options: TrustedDeviceUserDecryptionOptionsJson,
profile: AccountJson.Profile,
privateKey: String,
accountKeys: AccountKeysJson?,
securityState: String?,
signedPublicKey: String?,
signingKey: String?,
): VaultUnlockResult? {
var vaultUnlockResult: VaultUnlockResult? = null
val userId = profile.userId
@@ -2056,8 +2114,11 @@ class AuthRepositoryImpl(
// For approved requests the key will always be present.
val userKey = requireNotNull(request.key)
vaultUnlockResult = unlockVault(
accountCryptographicState = accountKeys.toAccountCryptographicState(
accountCryptographicState = createWrappedAccountCryptographicState(
privateKey = privateKey,
securityState = securityState,
signingKey = signingKey,
signedPublicKey = signedPublicKey,
),
accountProfile = profile,
initUserCryptoMethod = InitUserCryptoMethod.AuthRequest(
@@ -2085,8 +2146,11 @@ class AuthRepositoryImpl(
}
vaultUnlockResult = unlockVault(
accountCryptographicState = accountKeys.toAccountCryptographicState(
accountCryptographicState = createWrappedAccountCryptographicState(
privateKey = privateKey,
securityState = securityState,
signingKey = signingKey,
signedPublicKey = signedPublicKey,
),
accountProfile = profile,
initUserCryptoMethod = InitUserCryptoMethod.DeviceKey(

View File

@@ -42,7 +42,7 @@ data class UserState(
* @property name The user's name (if applicable).
* @property avatarColorHex Hex color value for a user's avatar in the "#AARRGGBB" format.
* @property environment The [Environment] associated with the user's account.
* @property isPremium `true` if the account has a Premium membership.
* @property isPremium `true` if the account has a premium membership.
* @property isLoggedIn `true` if the account is logged in, or `false` if it requires additional
* authentication to view their vault.
* @property isVaultUnlocked Whether the user's vault is currently unlocked.

View File

@@ -1,23 +0,0 @@
package com.x8bit.bitwarden.data.auth.repository.util
import com.bitwarden.core.WrappedAccountCryptographicState
import com.bitwarden.network.model.AccountKeysJson
import com.x8bit.bitwarden.data.vault.repository.util.createWrappedAccountCryptographicState
/**
* Creates a [WrappedAccountCryptographicState] based on the available cryptographic parameters.
*
* Returns [WrappedAccountCryptographicState.V2] if signing key, signed public key, and security
* state are all present, otherwise returns [WrappedAccountCryptographicState.V1].
*
* @receiver The users account keys.
* @param privateKey The user's wrapped private key.
*/
fun AccountKeysJson?.toAccountCryptographicState(
privateKey: String,
): WrappedAccountCryptographicState = createWrappedAccountCryptographicState(
privateKey = privateKey,
securityState = this?.securityState?.securityState,
signingKey = this?.signatureKeyPair?.wrappedSigningKey,
signedPublicKey = this?.publicKeyEncryptionKeyPair?.signedPublicKey,
)

View File

@@ -58,7 +58,6 @@ fun UserStateJson.toUpdatedUserStateJson(
val userId = syncProfile.id
val account = this.accounts[userId] ?: return this
val profile = account.profile
val masterPasswordUnlockKdf = syncResponse.userDecryption?.masterPasswordUnlock?.kdf
val userDecryptionOptions = syncResponse
.userDecryption
?.let { syncUserDecryption ->
@@ -84,14 +83,6 @@ fun UserStateJson.toUpdatedUserStateJson(
isTwoFactorEnabled = syncProfile.isTwoFactorEnabled,
creationDate = syncProfile.creationDate,
userDecryptionOptions = userDecryptionOptions,
kdfType = masterPasswordUnlockKdf?.kdfType
?: profile.kdfType,
kdfIterations = masterPasswordUnlockKdf?.iterations
?: profile.kdfIterations,
kdfMemory = masterPasswordUnlockKdf?.memory
?: profile.kdfMemory,
kdfParallelism = masterPasswordUnlockKdf?.parallelism
?: profile.kdfParallelism,
)
val updatedAccount = account.copy(profile = updatedProfile)
return this

View File

@@ -10,7 +10,6 @@ import com.x8bit.bitwarden.data.autofill.model.AutofillCipher
import com.x8bit.bitwarden.data.platform.manager.PolicyManager
import com.x8bit.bitwarden.data.platform.manager.ciphermatching.CipherMatchingManager
import com.x8bit.bitwarden.data.platform.util.firstWithTimeoutOrNull
import com.x8bit.bitwarden.data.platform.util.isActive
import com.x8bit.bitwarden.data.platform.util.subtitle
import com.x8bit.bitwarden.data.vault.manager.model.GetCipherResult
import com.x8bit.bitwarden.data.vault.repository.VaultRepository
@@ -67,8 +66,10 @@ class AutofillCipherProviderImpl(
.takeIf {
// Must be card type.
it.type is CipherListViewType.Card &&
// Must still be active.
it.isActive &&
// Must not be deleted.
it.deletedDate == null &&
// Must not be archived.
it.archivedDate == null &&
// Must not require a reprompt.
it.reprompt == CipherRepromptType.NONE &&
// Must not be restricted by organization.
@@ -105,8 +106,10 @@ class AutofillCipherProviderImpl(
.filter {
// Must be login type
it.type is CipherListViewType.Login &&
// Must still be active.
it.isActive &&
// Must not be deleted.
it.deletedDate == null &&
// Must not be archived.
it.archivedDate == null &&
// Must not require a reprompt.
it.reprompt == CipherRepromptType.NONE
}

View File

@@ -5,20 +5,21 @@ import com.bitwarden.vault.CipherListView
import com.bitwarden.vault.CipherListViewType
import com.bitwarden.vault.CopyableCipherFields
import com.bitwarden.vault.LoginListView
import com.x8bit.bitwarden.data.platform.util.isActive
/**
* Returns true when the cipher is not archived, not deleted and contains at least one FIDO 2
* credential.
*/
val CipherListView.isActiveWithFido2Credentials: Boolean
get() = isActive && login?.hasFido2 ?: false
get() = archivedDate == null && deletedDate == null && login?.hasFido2 ?: false
/**
* Returns true when the cipher type is not archived, not deleted and contains a copyable password.
*/
val CipherListView.isActiveWithCopyablePassword: Boolean
get() = isActive && copyableFields.contains(CopyableCipherFields.LOGIN_PASSWORD)
get() = archivedDate == null &&
deletedDate == null &&
copyableFields.contains(CopyableCipherFields.LOGIN_PASSWORD)
/**
* Returns the [LoginListView] if the cipher is of type [CipherListViewType.Login], otherwise null.

View File

@@ -3,7 +3,6 @@ package com.x8bit.bitwarden.data.autofill.util
import com.bitwarden.vault.CipherView
import com.x8bit.bitwarden.data.autofill.model.AutofillCipher
import com.x8bit.bitwarden.data.autofill.provider.AutofillCipherProvider
import com.x8bit.bitwarden.data.platform.util.isActive
import com.x8bit.bitwarden.data.platform.util.subtitle
/**
@@ -53,11 +52,13 @@ fun CipherView.toAutofillCipherProvider(): AutofillCipherProvider =
* credential.
*/
val CipherView.isActiveWithFido2Credentials: Boolean
get() = isActive && !(login?.fido2Credentials.isNullOrEmpty())
get() = archivedDate == null &&
deletedDate == null &&
!(login?.fido2Credentials.isNullOrEmpty())
/**
* Returns true when the cipher is not archived, not deleted and contains at least one Password
* credential.
*/
val CipherView.isActiveWithPasswordCredentials: Boolean
get() = isActive && !(login?.password.isNullOrEmpty())
get() = archivedDate == null && deletedDate == null && !(login?.password.isNullOrEmpty())

View File

@@ -3,23 +3,21 @@ package com.x8bit.bitwarden.data.billing.manager
import kotlinx.coroutines.flow.StateFlow
/**
* Manages Premium upgrade state for the active user.
* Manages the consolidated eligibility state for the premium upgrade banner.
*
* Combines multiple upstream signals (premium status, billing support, feature flag,
* banner dismissal, account age, and vault item count) into a single observable flow.
*/
interface PremiumStateManager {
/**
* Emits `true` when the current user is eligible to see the Premium upgrade banner,
* Emits `true` when the current user is eligible to see the premium upgrade banner,
* or `false` otherwise.
*/
val isPremiumUpgradeBannerEligibleFlow: StateFlow<Boolean>
/**
* Returns `true` when the in-app upgrade flow is available, or `false` otherwise.
*/
fun isInAppUpgradeAvailable(): Boolean
/**
* Marks the Premium upgrade banner as dismissed for the current user.
* Marks the premium upgrade banner as dismissed for the current user.
*/
fun dismissPremiumUpgradeBanner()
}

View File

@@ -9,7 +9,6 @@ import com.x8bit.bitwarden.data.auth.repository.util.activeUserIdChangesFlow
import com.x8bit.bitwarden.data.billing.repository.BillingRepository
import com.x8bit.bitwarden.data.platform.datasource.disk.SettingsDiskSource
import com.x8bit.bitwarden.data.platform.manager.FeatureFlagManager
import com.x8bit.bitwarden.data.platform.util.isActive
import com.x8bit.bitwarden.data.vault.repository.VaultRepository
import com.x8bit.bitwarden.data.vault.repository.model.VaultData
import kotlinx.coroutines.CoroutineScope
@@ -27,15 +26,17 @@ import java.time.Instant
/**
* Default implementation of [PremiumStateManager].
*
* Combines five upstream flows into a single eligibility signal using [combine].
*/
@Suppress("LongParameterList")
class PremiumStateManagerImpl(
private val authDiskSource: AuthDiskSource,
authRepository: AuthRepository,
private val billingRepository: BillingRepository,
billingRepository: BillingRepository,
private val settingsDiskSource: SettingsDiskSource,
vaultRepository: VaultRepository,
private val featureFlagManager: FeatureFlagManager,
featureFlagManager: FeatureFlagManager,
private val clock: Clock,
dispatcherManager: DispatcherManager,
) : PremiumStateManager {
@@ -59,12 +60,11 @@ class PremiumStateManagerImpl(
?: flowOf(false)
},
vaultRepository.vaultDataStateFlow,
) {
userState,
isInAppBillingSupported,
featureFlagEnabled,
isDismissed,
vaultDataState,
) { userState,
isInAppBillingSupported,
featureFlagEnabled,
isDismissed,
vaultDataState,
->
val activeAccount = userState?.activeAccount
?: return@combine false
@@ -88,10 +88,6 @@ class PremiumStateManagerImpl(
initialValue = false,
)
override fun isInAppUpgradeAvailable(): Boolean =
billingRepository.isInAppBillingSupportedFlow.value &&
featureFlagManager.getFeatureFlag(FlagKey.MobilePremiumUpgrade)
override fun dismissPremiumUpgradeBanner() {
val activeUserId = authDiskSource.userState?.activeUserId ?: return
settingsDiskSource.storePremiumUpgradeBannerDismissed(
@@ -120,7 +116,7 @@ private fun DataState<VaultData>.activeVaultItemCount(): Int =
data
?.decryptCipherListResult
?.successes
?.count { it.isActive }
?.count { it.deletedDate == null && it.archivedDate == null }
?: 0
private const val PREMIUM_UPGRADE_MINIMUM_VAULT_ITEMS: Int = 5

View File

@@ -2,7 +2,6 @@ package com.x8bit.bitwarden.data.billing.repository
import com.x8bit.bitwarden.data.billing.repository.model.CheckoutSessionResult
import com.x8bit.bitwarden.data.billing.repository.model.CustomerPortalResult
import com.x8bit.bitwarden.data.billing.repository.model.PremiumPlanPricingResult
import kotlinx.coroutines.flow.StateFlow
/**
@@ -21,12 +20,7 @@ interface BillingRepository {
suspend fun getCheckoutSessionUrl(): CheckoutSessionResult
/**
* Retrieves the Stripe customer portal URL for managing the Premium subscription.
* Retrieves the Stripe customer portal URL for managing the premium subscription.
*/
suspend fun getPortalUrl(): CustomerPortalResult
/**
* Retrieves the premium plan pricing information.
*/
suspend fun getPremiumPlanPricing(): PremiumPlanPricingResult
}

View File

@@ -4,7 +4,6 @@ import com.bitwarden.network.service.BillingService
import com.x8bit.bitwarden.data.billing.manager.PlayBillingManager
import com.x8bit.bitwarden.data.billing.repository.model.CheckoutSessionResult
import com.x8bit.bitwarden.data.billing.repository.model.CustomerPortalResult
import com.x8bit.bitwarden.data.billing.repository.model.PremiumPlanPricingResult
import kotlinx.coroutines.flow.StateFlow
/**
@@ -33,18 +32,4 @@ class BillingRepositoryImpl(
onSuccess = { CustomerPortalResult.Success(url = it.url) },
onFailure = { CustomerPortalResult.Error(error = it) },
)
override suspend fun getPremiumPlanPricing(): PremiumPlanPricingResult =
billingService
.getPremiumPlan()
.fold(
onSuccess = {
PremiumPlanPricingResult.Success(
annualPrice = it.seat.price,
)
},
onFailure = {
PremiumPlanPricingResult.Error(error = it)
},
)
}

View File

@@ -1,28 +0,0 @@
package com.x8bit.bitwarden.data.billing.repository.model
import com.x8bit.bitwarden.data.platform.util.userFriendlyMessage
/**
* Models the result of retrieving premium plan pricing.
*/
sealed class PremiumPlanPricingResult {
/**
* The premium plan pricing was successfully retrieved.
*
* @property annualPrice The annual price in the plan's currency.
*/
data class Success(
val annualPrice: Double,
) : PremiumPlanPricingResult()
/**
* An error occurred while retrieving the premium plan pricing.
* The optional [errorMessage] may be displayed directly in the UI
* when present.
*/
data class Error(
val error: Throwable,
val errorMessage: String? = error.userFriendlyMessage,
) : PremiumPlanPricingResult()
}

View File

@@ -1,65 +0,0 @@
package com.x8bit.bitwarden.data.billing.util
import android.net.Uri
import android.os.Parcelable
import androidx.browser.auth.AuthTabIntent
import com.bitwarden.annotation.OmitFromCoverage
import kotlinx.parcelize.Parcelize
/**
* Query parameter name used by Stripe to indicate the checkout outcome.
*/
private const val RESULT_PARAM = "result"
/**
* Query parameter value indicating a successful checkout.
*/
private const val RESULT_SUCCESS = "success"
/**
* Retrieves a [PremiumCheckoutCallbackResult] from an
* [AuthTabIntent.AuthResult].
*
* - [PremiumCheckoutCallbackResult.Success]: The user completed payment.
* - [PremiumCheckoutCallbackResult.Canceled]: The user left without paying.
*/
@OmitFromCoverage
fun AuthTabIntent.AuthResult.getPremiumCheckoutCallbackResult(): PremiumCheckoutCallbackResult =
when (resultCode) {
AuthTabIntent.RESULT_OK -> resultUri.getPremiumCheckoutCallbackResult()
else -> PremiumCheckoutCallbackResult.Canceled
}
/**
* Retrieves a [PremiumCheckoutCallbackResult] from a redirect [Uri].
*
* Examines the `result` query parameter: `?result=success` maps to
* [PremiumCheckoutCallbackResult.Success], anything else maps to
* [PremiumCheckoutCallbackResult.Canceled].
*/
fun Uri?.getPremiumCheckoutCallbackResult(): PremiumCheckoutCallbackResult {
val resultParam = this?.getQueryParameter(RESULT_PARAM)
return if (resultParam.equals(RESULT_SUCCESS, ignoreCase = true)) {
PremiumCheckoutCallbackResult.Success
} else {
PremiumCheckoutCallbackResult.Canceled
}
}
/**
* Represents the result of a premium checkout callback from Stripe.
*/
sealed class PremiumCheckoutCallbackResult : Parcelable {
/**
* The user completed payment successfully.
*/
@Parcelize
data object Success : PremiumCheckoutCallbackResult()
/**
* The user canceled or left checkout without completing payment.
*/
@Parcelize
data object Canceled : PremiumCheckoutCallbackResult()
}

View File

@@ -20,7 +20,6 @@ import com.bitwarden.fido.Fido2CredentialAutofillView
import com.bitwarden.fido.Origin
import com.bitwarden.fido.UnverifiedAssetLink
import com.bitwarden.sdk.Fido2CredentialStore
import com.bitwarden.ui.platform.base.util.prefixHttpsIfNecessary
import com.bitwarden.ui.platform.base.util.prefixHttpsIfNecessaryOrNull
import com.bitwarden.ui.platform.base.util.toAndroidAppUriString
import com.bitwarden.vault.CipherListView
@@ -344,16 +343,7 @@ class BitwardenCredentialManagerImpl(
?.let { ClientData.DefaultWithCustomHash(hash = it) }
?: return Fido2RegisterCredentialResult.Error.InvalidAppSignature
val requestedOrigin = this
.getPasskeyAttestationOptionsOrNull(createPublicKeyCredentialRequest.requestJson)
?.relyingParty
?.id
?.prefixHttpsIfNecessary()
// PM-35130: We use the requested relying party for the basis of the origin for privileged
// apps to ensure that related-origin requests are processed successfully. In the future,
// the SDK should handle this for us and we will be able to send in the real origin.
val sdkOrigin = (requestedOrigin ?: createPublicKeyCredentialRequest.origin)
val sdkOrigin = createPublicKeyCredentialRequest.origin
?.let { Origin.Web(it) }
?: return Fido2RegisterCredentialResult.Error.MissingHostUrl

View File

@@ -124,12 +124,12 @@ interface SettingsDiskSource : FlightRecorderDiskSource {
fun getIntroducingArchiveActionCardDismissedFlow(userId: String): Flow<Boolean?>
/**
* Retrieves the stored value of whether the Premium upgrade banner has been dismissed.
* Retrieves the stored value of whether the premium upgrade banner has been dismissed.
*/
fun getPremiumUpgradeBannerDismissed(userId: String): Boolean?
/**
* Stores whether the Premium upgrade banner has been dismissed.
* Stores whether the premium upgrade banner has been dismissed.
*/
fun storePremiumUpgradeBannerDismissed(
userId: String,

View File

@@ -251,7 +251,7 @@ class SettingsDiskSourceImpl(
// - should show add login coach mark
// - should show generator coach mark
// - should show introducing archive action card dismissed
// - Premium upgrade banner dismissed
// - premium upgrade banner dismissed
}
override fun getIntroducingArchiveActionCardDismissed(userId: String): Boolean? =

View File

@@ -50,7 +50,7 @@ object PlatformNetworkModule {
@Provides
@Singleton
fun provideBitwardenServiceClientConfig(
fun provideBitwardenServiceClient(
authTokenManager: AuthTokenManager,
baseUrlsProvider: BaseUrlsProvider,
authDiskSource: AuthDiskSource,
@@ -58,26 +58,20 @@ object PlatformNetworkModule {
buildInfoManager: BuildInfoManager,
networkCookieManager: NetworkCookieManager,
clock: Clock,
): BitwardenServiceClientConfig = BitwardenServiceClientConfig(
clock = clock,
appIdProvider = authDiskSource,
clientData = BitwardenServiceClientConfig.ClientData(
userAgent = HEADER_VALUE_USER_AGENT,
clientName = HEADER_VALUE_CLIENT_NAME,
clientVersion = HEADER_VALUE_CLIENT_VERSION,
),
authTokenProvider = authTokenManager,
baseUrlsProvider = baseUrlsProvider,
certificateProvider = certificateManager,
enableHttpBodyLogging = buildInfoManager.isDevBuild,
cookieProvider = networkCookieManager,
)
@Provides
@Singleton
fun provideBitwardenServiceClient(
serviceClientConfig: BitwardenServiceClientConfig,
): BitwardenServiceClient = bitwardenServiceClient(
config = serviceClientConfig,
BitwardenServiceClientConfig(
clock = clock,
appIdProvider = authDiskSource,
clientData = BitwardenServiceClientConfig.ClientData(
userAgent = HEADER_VALUE_USER_AGENT,
clientName = HEADER_VALUE_CLIENT_NAME,
clientVersion = HEADER_VALUE_CLIENT_VERSION,
),
authTokenProvider = authTokenManager,
baseUrlsProvider = baseUrlsProvider,
certificateProvider = certificateManager,
enableHttpBodyLogging = buildInfoManager.isDevBuild,
cookieProvider = networkCookieManager,
),
)
}

View File

@@ -15,21 +15,8 @@ abstract class BaseSdkSource(
* Helper function to retrieve the [Client] associated with the given [userId].
*/
protected suspend fun getClient(
userId: String,
): Client = sdkClientManager.getOrCreateClient(userId = userId)
/**
* Helper function to retrieve a new [Client] and use it in the given [block].
*/
protected suspend fun <T> useClient(
userId: String? = null,
accessToken: String? = null,
block: suspend Client.() -> T,
): T = sdkClientManager.singleUseClient(
userId = userId,
accessToken = accessToken,
block = block,
)
): Client = sdkClientManager.getOrCreateClient(userId = userId)
/**
* Invokes the [block] with `this` value as its receiver and returns its result if it was

View File

@@ -0,0 +1,18 @@
package com.x8bit.bitwarden.data.platform.manager
/**
* The minimum GMS Core version required for Credential Exchange Protocol (CXP) features.
*/
const val MINIMUM_CXP_GMS_VERSION: Int = 261031035
/**
* Manages checks against the installed Google Mobile Services (GMS) Core version.
*/
interface GmsManager {
/**
* Returns `true` if the installed GMS Core version is at least [version], or `false` if
* GMS Core is not installed or does not meet the minimum version.
*/
fun isVersionAtLeast(version: Int): Boolean
}

View File

@@ -31,7 +31,7 @@ interface PushManager {
val passwordlessRequestFlow: Flow<PasswordlessRequestData>
/**
* Flow that represents Premium status change notifications.
* Flow that represents premium status change notifications.
*/
val premiumStatusChangedFlow: Flow<PremiumStatusChangedData>

View File

@@ -11,20 +11,7 @@ interface SdkClientManager {
* Returns the cached [Client] instance for the given [userId], otherwise creates and caches
* a new one and returns it.
*/
suspend fun getOrCreateClient(userId: String): Client
/**
* Helper function to retrieve a new instance of the [Client] and use it in the given [block].
* This client is never persisted after the [block] completes.
*
* @param userId The used to create the [Client]. If null, the SDK is unassociated with a user.
* @param accessToken The access token used in network requests.
*/
suspend fun <T> singleUseClient(
userId: String? = null,
accessToken: String? = null,
block: suspend Client.() -> T,
): T
suspend fun getOrCreateClient(userId: String?): Client
/**
* Clears any resources from the [Client] associated with the given [userId] and removes it

View File

@@ -15,16 +15,10 @@ class SdkClientManagerImpl(
sdkRepoFactory: SdkRepositoryFactory,
sdkPlatformApiFactory: SdkPlatformApiFactory,
private val featureFlagManager: FeatureFlagManager,
private val clientProvider: suspend (
userId: String?,
accessToken: String?,
) -> Client = { userId, accessToken ->
private val clientProvider: suspend (userId: String?) -> Client = { userId ->
Client(
tokenProvider = sdkRepoFactory.getClientManagedTokens(
userId = userId,
accessToken = accessToken,
),
settings = sdkRepoFactory.getClientSettings(),
tokenProvider = sdkRepoFactory.getClientManagedTokens(userId = userId),
settings = null,
)
.apply {
platform().loadFlags(featureFlagManager.sdkFeatureFlags)
@@ -38,7 +32,7 @@ class SdkClientManagerImpl(
}
},
) : SdkClientManager {
private val userIdToClientMap = mutableMapOf<String, Client>()
private val userIdToClientMap = mutableMapOf<String?, Client>()
init {
// The SDK requires access to Android APIs that were not made public until API 31. In order
@@ -50,14 +44,8 @@ class SdkClientManagerImpl(
}
override suspend fun getOrCreateClient(
userId: String,
): Client = userIdToClientMap.getOrPut(key = userId) { clientProvider(userId, null) }
override suspend fun <T> singleUseClient(
userId: String?,
accessToken: String?,
block: suspend Client.() -> T,
): T = clientProvider(userId, accessToken).use { it.block() }
): Client = userIdToClientMap.getOrPut(key = userId) { clientProvider(userId) }
override fun destroyClient(
userId: String?,

View File

@@ -15,7 +15,6 @@ import com.bitwarden.data.datasource.disk.ConfigDiskSource
import com.bitwarden.data.manager.NativeLibraryManager
import com.bitwarden.data.repository.ServerConfigRepository
import com.bitwarden.network.BitwardenServiceClient
import com.bitwarden.network.model.BitwardenServiceClientConfig
import com.bitwarden.network.service.EventService
import com.bitwarden.network.service.PushService
import com.x8bit.bitwarden.data.auth.datasource.disk.AuthDiskSource
@@ -48,6 +47,8 @@ import com.x8bit.bitwarden.data.platform.manager.FeatureFlagManager
import com.x8bit.bitwarden.data.platform.manager.FeatureFlagManagerImpl
import com.x8bit.bitwarden.data.platform.manager.FirstTimeActionManager
import com.x8bit.bitwarden.data.platform.manager.FirstTimeActionManagerImpl
import com.x8bit.bitwarden.data.platform.manager.GmsManager
import com.x8bit.bitwarden.data.platform.manager.GmsManagerImpl
import com.x8bit.bitwarden.data.platform.manager.LogsManager
import com.x8bit.bitwarden.data.platform.manager.LogsManagerImpl
import com.x8bit.bitwarden.data.platform.manager.PolicyManager
@@ -337,6 +338,12 @@ object PlatformManagerModule {
thirdPartyAutofillEnabledManager = thirdPartyAutofillEnabledManager,
)
@Provides
@Singleton
fun provideGmsManager(
@ApplicationContext context: Context,
): GmsManager = GmsManagerImpl(context = context)
@Provides
@Singleton
fun provideDatabaseSchemeManager(
@@ -368,13 +375,11 @@ object PlatformManagerModule {
cookieDiskSource: CookieDiskSource,
configDiskSource: ConfigDiskSource,
authDiskSource: AuthDiskSource,
serviceClientConfig: BitwardenServiceClientConfig,
): SdkRepositoryFactory = SdkRepositoryFactoryImpl(
vaultDiskSource = vaultDiskSource,
cookieDiskSource = cookieDiskSource,
configDiskSource = configDiskSource,
authDiskSource = authDiskSource,
serviceClientConfig = serviceClientConfig,
)
@Provides

View File

@@ -99,7 +99,7 @@ sealed class NotificationPayload {
) : NotificationPayload()
/**
* A notification payload for Premium status changes.
* A notification payload for premium status changes.
*/
@Serializable
data class PremiumStatusChangedNotification(

View File

@@ -1,10 +1,10 @@
package com.x8bit.bitwarden.data.platform.manager.model
/**
* Data class representing a Premium status changed push notification.
* Data class representing a premium status changed push notification.
*
* @property userId The user ID associated with the status change.
* @property isPremium Whether Premium is now enabled.
* @property isPremium Whether premium is now enabled.
*/
data class PremiumStatusChangedData(
val userId: String,

View File

@@ -5,7 +5,6 @@ import androidx.credentials.CredentialManager
import com.bitwarden.cxf.model.ImportCredentialsRequestData
import com.bitwarden.ui.platform.manager.share.model.ShareData
import com.bitwarden.ui.platform.model.TotpData
import com.x8bit.bitwarden.data.billing.util.PremiumCheckoutCallbackResult
import com.x8bit.bitwarden.data.autofill.model.AutofillSaveItem
import com.x8bit.bitwarden.data.autofill.model.AutofillSelectionData
import com.x8bit.bitwarden.data.credentials.model.CreateCredentialRequest
@@ -136,13 +135,11 @@ sealed class SpecialCircumstance : Parcelable {
data object VerificationCodeShortcut : SpecialCircumstance()
/**
* The app was launched via a Premium checkout callback deep link,
* The app was launched via a premium checkout callback deep link,
* indicating the user is returning from a Stripe checkout session.
*/
@Parcelize
data class PremiumCheckout(
val callbackResult: PremiumCheckoutCallbackResult,
) : SpecialCircumstance()
data object PremiumCheckoutResult : SpecialCircumstance()
/**
* The app was launched to select an account to export credentials from.

View File

@@ -1,7 +1,6 @@
package com.x8bit.bitwarden.data.platform.manager.sdk
import com.bitwarden.core.ClientManagedTokens
import com.bitwarden.core.ClientSettings
import com.bitwarden.sdk.Repositories
import com.bitwarden.sdk.ServerCommunicationConfigRepository
@@ -17,15 +16,7 @@ interface SdkRepositoryFactory {
/**
* Retrieves or creates a [ClientManagedTokens] for use with the Bitwarden SDK.
*/
fun getClientManagedTokens(
userId: String?,
accessToken: String?,
): ClientManagedTokens
/**
* Retrieves or creates a [ClientSettings] for use with the Bitwarden SDK.
*/
fun getClientSettings(): ClientSettings
fun getClientManagedTokens(userId: String?): ClientManagedTokens
/**
* Retrieves or creates a [ServerCommunicationConfigRepository] for use with the Bitwarden SDK.

View File

@@ -1,10 +1,7 @@
package com.x8bit.bitwarden.data.platform.manager.sdk
import com.bitwarden.core.ClientManagedTokens
import com.bitwarden.core.ClientSettings
import com.bitwarden.core.DeviceType
import com.bitwarden.data.datasource.disk.ConfigDiskSource
import com.bitwarden.network.model.BitwardenServiceClientConfig
import com.bitwarden.sdk.Repositories
import com.bitwarden.sdk.ServerCommunicationConfigRepository
import com.x8bit.bitwarden.data.auth.datasource.disk.AuthDiskSource
@@ -23,48 +20,33 @@ class SdkRepositoryFactoryImpl(
private val cookieDiskSource: CookieDiskSource,
private val configDiskSource: ConfigDiskSource,
private val authDiskSource: AuthDiskSource,
private val serviceClientConfig: BitwardenServiceClientConfig,
) : SdkRepositoryFactory {
override fun getRepositories(userId: String?): Repositories =
Repositories(
cipher = getSdkCipherRepository(userId = userId),
cipher = getSdkRepository(userId = userId),
folder = null,
userKeyState = null,
localUserDataKeyState = SdkLocalUserDataKeyStateRepository(
authDiskSource = authDiskSource,
),
ephemeralPinEnvelopeState = null,
organizationSharedKey = null,
)
override fun getClientManagedTokens(
userId: String?,
accessToken: String?,
): ClientManagedTokens =
SdkTokenRepository(
userId = userId,
accessToken = accessToken,
authDiskSource = authDiskSource,
)
override fun getClientSettings(): ClientSettings =
ClientSettings(
identityUrl = serviceClientConfig.baseUrlsProvider.getBaseIdentityUrl(),
apiUrl = serviceClientConfig.baseUrlsProvider.getBaseApiUrl(),
userAgent = serviceClientConfig.clientData.userAgent,
deviceType = DeviceType.ANDROID,
deviceIdentifier = serviceClientConfig.appIdProvider.uniqueAppId,
bitwardenClientVersion = serviceClientConfig.clientData.clientVersion,
bitwardenPackageType = null,
)
override fun getServerCommunicationConfigRepository(): ServerCommunicationConfigRepository =
ServerCommunicationConfigRepositoryImpl(
cookieDiskSource = cookieDiskSource,
configDiskSource = configDiskSource,
)
private fun getSdkCipherRepository(
private fun getSdkRepository(
userId: String?,
): SdkCipherRepository? = userId?.let {
SdkCipherRepository(userId = it, vaultDiskSource = vaultDiskSource)

View File

@@ -1,68 +0,0 @@
package com.x8bit.bitwarden.data.platform.manager.sdk.repository
import com.bitwarden.sdk.FolderRepository
import com.bitwarden.vault.Folder
import com.x8bit.bitwarden.data.vault.datasource.disk.VaultDiskSource
import com.x8bit.bitwarden.data.vault.repository.util.toEncryptedNetworkFolderResponse
import com.x8bit.bitwarden.data.vault.repository.util.toEncryptedSdkFolder
import timber.log.Timber
/**
* A user-scoped implementation of a Bitwarden SDK [FolderRepository].
*/
class SdkFolderRepository(
private val userId: String,
private val vaultDiskSource: VaultDiskSource,
) : FolderRepository {
override suspend fun get(id: String): Folder? =
vaultDiskSource
.getFolder(userId = userId, folderId = id)
?.toEncryptedSdkFolder()
override suspend fun list(): List<Folder> =
vaultDiskSource
.getFolders(userId = userId)
.map { it.toEncryptedSdkFolder() }
override suspend fun set(id: String, value: Folder) {
if (id != value.id) {
Timber.e("SDK Folder 'set' operation: ID's do not match")
return
}
vaultDiskSource.saveFolder(
userId = userId,
folder = value.toEncryptedNetworkFolderResponse(),
)
}
override suspend fun setBulk(values: Map<String, Folder>) {
val validEntries = values.filter { (id, cipher) ->
if (id != cipher.id) {
Timber.e("SDK Folder 'setBulk' operation: ID's do not match for '$id'")
false
} else {
true
}
}
if (validEntries.isEmpty()) return
vaultDiskSource.saveFolders(
userId = userId,
folders = validEntries.values.map { it.toEncryptedNetworkFolderResponse() },
)
}
override suspend fun remove(id: String) {
vaultDiskSource.deleteFolder(userId = userId, folderId = id)
}
override suspend fun removeBulk(keys: List<String>) {
if (keys.isEmpty()) return
vaultDiskSource.deleteSelectedFolders(userId = userId, folderIds = keys)
}
override suspend fun removeAll() {
vaultDiskSource.deleteAllFolders(userId = userId)
}
override suspend fun has(id: String): Boolean = this.get(id = id) != null
}

View File

@@ -11,9 +11,10 @@ import com.x8bit.bitwarden.data.auth.datasource.disk.AuthDiskSource
*/
class SdkTokenRepository(
private val userId: String?,
private val accessToken: String?,
private val authDiskSource: AuthDiskSource,
) : ClientManagedTokens {
override suspend fun getAccessToken(): String? =
accessToken ?: userId?.let { authDiskSource.getAccountTokens(userId = it)?.accessToken }
userId?.let {
authDiskSource.getAccountTokens(userId = it)?.accessToken
}
}

View File

@@ -21,9 +21,12 @@ class ServerCommunicationConfigRepositoryImpl(
private val configDiskSource: ConfigDiskSource,
) : ServerCommunicationConfigRepository {
override suspend fun get(domain: String): ServerCommunicationConfig? {
val serverData = configDiskSource.serverConfig?.serverData
val serverCommunicationConfig = serverData?.communication ?: return null
override suspend fun get(hostname: String): ServerCommunicationConfig? {
val serverCommunicationConfig = configDiskSource
.serverConfig
?.serverData
?.communication
?: return null
if (serverCommunicationConfig.bootstrap.type != "ssoCookieVendor") {
return ServerCommunicationConfig(
@@ -31,13 +34,8 @@ class ServerCommunicationConfigRepositoryImpl(
)
}
// We return null here since we do not have the appropriate data to complete the
// transaction. This will trigger a cookie acquisition with the server.
val vaultUrl = serverData.environment?.vaultUrl ?: return null
val cookieName = serverCommunicationConfig.bootstrap.cookieName ?: return null
val cookieDomain = serverCommunicationConfig.bootstrap.cookieDomain ?: return null
val acquiredCookies = cookieDiskSource
.getCookieConfig(hostname = domain)
.getCookieConfig(hostname)
?.cookies
?.toAcquiredCookiesList()
@@ -45,24 +43,23 @@ class ServerCommunicationConfigRepositoryImpl(
bootstrap = BootstrapConfig.SsoCookieVendor(
v1 = SsoCookieVendorConfig(
idpLoginUrl = serverCommunicationConfig.bootstrap.idpLoginUrl,
vaultUrl = vaultUrl,
cookieName = cookieName,
cookieDomain = cookieDomain,
cookieName = serverCommunicationConfig.bootstrap.cookieName,
cookieDomain = serverCommunicationConfig.bootstrap.cookieDomain,
cookieValue = acquiredCookies,
),
),
)
}
override suspend fun save(domain: String, config: ServerCommunicationConfig) =
override suspend fun save(hostname: String, config: ServerCommunicationConfig) =
when (val bootstrapConfig = config.bootstrap) {
is BootstrapConfig.SsoCookieVendor -> {
// Only store cookies from [config]. The communication config is synced with the
// server (api/config), which takes precedence over the local configuration.
cookieDiskSource.storeCookieConfig(
hostname = domain,
hostname = hostname,
config = CookieConfigurationData(
hostname = domain,
hostname = hostname,
cookies = bootstrapConfig.v1.cookieValue
?.toConfigurationDataCookies()
.orEmpty(),
@@ -73,7 +70,7 @@ class ServerCommunicationConfigRepositoryImpl(
BootstrapConfig.Direct -> {
// Clear any existing cookie configuration now that the communication config
// has been updated.
cookieDiskSource.storeCookieConfig(hostname = domain, config = null)
cookieDiskSource.storeCookieConfig(hostname = hostname, config = null)
}
}
}

View File

@@ -10,13 +10,13 @@ import com.bitwarden.core.data.util.flatMap
import com.bitwarden.data.repository.util.toEnvironmentUrlsOrDefault
import com.x8bit.bitwarden.data.auth.datasource.disk.AuthDiskSource
import com.x8bit.bitwarden.data.auth.datasource.disk.model.AccountJson
import com.x8bit.bitwarden.data.auth.repository.util.toAccountCryptographicState
import com.x8bit.bitwarden.data.auth.repository.util.toSdkParams
import com.x8bit.bitwarden.data.platform.repository.util.sanitizeTotpUri
import com.x8bit.bitwarden.data.vault.datasource.disk.VaultDiskSource
import com.x8bit.bitwarden.data.vault.datasource.sdk.ScopedVaultSdkSource
import com.x8bit.bitwarden.data.vault.datasource.sdk.model.InitializeCryptoResult
import com.x8bit.bitwarden.data.vault.repository.model.VaultUnlockResult
import com.x8bit.bitwarden.data.vault.repository.util.createWrappedAccountCryptographicState
import com.x8bit.bitwarden.data.vault.repository.util.toEncryptedSdkCipher
import com.x8bit.bitwarden.data.vault.repository.util.toVaultUnlockResult
@@ -48,7 +48,6 @@ class AuthenticatorBridgeRepositoryImpl(
}
}
@Suppress("LongMethod")
override suspend fun getSharedAccounts(): SharedAccountData {
return authDiskSource
.userState
@@ -89,7 +88,7 @@ class AuthenticatorBridgeRepositoryImpl(
}
// Vault is unlocked, query vault disk source for totp logins:
val cipherData = vaultDiskSource
val totpUris = vaultDiskSource
.getTotpCiphers(userId = userId)
// Filter out any deleted and archived ciphers.
.filter { it.deletedDate == null && it.archivedDate == null }
@@ -98,23 +97,10 @@ class AuthenticatorBridgeRepositoryImpl(
.decryptCipher(userId = userId, cipher = it.toEncryptedSdkCipher())
.getOrNull()
?.let { decryptedCipher ->
val cipherId = decryptedCipher.id ?: return@let null
val rawTotp = decryptedCipher.login?.totp
val cipherName = decryptedCipher.name
val username = decryptedCipher.login?.username
decryptedCipher.login?.totp?.let { rawTotp ->
SharedAccountData.CipherData(
uri = rawTotp,
// TODO: PM-34085 Remove the legacyUri.
legacyUri = rawTotp.sanitizeTotpUri(
issuer = cipherName,
username = username,
),
id = cipherId,
name = cipherName,
username = username,
isFavorite = decryptedCipher.favorite,
)
}
rawTotp.sanitizeTotpUri(issuer = cipherName, username = username)
}
}
@@ -130,7 +116,7 @@ class AuthenticatorBridgeRepositoryImpl(
.environmentUrlData
.toEnvironmentUrlsOrDefault()
.label,
cipherData = cipherData,
totpUris = totpUris,
)
}
.let(::SharedAccountData)
@@ -147,13 +133,22 @@ class AuthenticatorBridgeRepositoryImpl(
?: return VaultUnlockResult.InvalidStateError(
MissingPropertyException("Private key"),
)
val securityState = authDiskSource
.getAccountKeys(userId = userId)
?.securityState
?.securityState
val signingKey = accountKeys?.signatureKeyPair?.wrappedSigningKey
val signedPublicKey = accountKeys?.publicKeyEncryptionKeyPair?.signedPublicKey
return scopedVaultSdkSource
.initializeCrypto(
userId = userId,
request = InitUserCryptoRequest(
accountCryptographicState = accountKeys.toAccountCryptographicState(
accountCryptographicState = createWrappedAccountCryptographicState(
privateKey = privateKey,
securityState = securityState,
signingKey = signingKey,
signedPublicKey = signedPublicKey,
),
userId = userId,
kdfParams = account.profile.toSdkParams(),

View File

@@ -54,9 +54,4 @@ interface DebugMenuRepository {
* Clears all stored SSO cookie configurations.
*/
fun clearSsoCookies()
/**
* Resets the Premium upgrade banner dismiss status for the current user.
*/
fun resetPremiumUpgradeBannerDismiss()
}

View File

@@ -74,12 +74,4 @@ class DebugMenuRepositoryImpl(
override fun clearSsoCookies() {
cookieDiskSource.clearCookies()
}
override fun resetPremiumUpgradeBannerDismiss() {
val currentUserId = authDiskSource.userState?.activeUserId ?: return
settingsDiskSource.storePremiumUpgradeBannerDismissed(
userId = currentUserId,
isDismissed = null,
)
}
}

View File

@@ -253,12 +253,12 @@ interface SettingsRepository : FlightRecorderManager {
fun dismissIntroducingArchiveActionCard()
/**
* Gets updates for whether the Premium upgrade banner is dismissed.
* Gets updates for whether the premium upgrade banner is dismissed.
*/
fun getPremiumUpgradeBannerDismissedFlow(): StateFlow<Boolean>
/**
* Stores that the Premium upgrade banner has been dismissed for the active user.
* Stores that the premium upgrade banner has been dismissed for the active user.
*/
fun dismissPremiumUpgradeBanner()

View File

@@ -1,9 +0,0 @@
package com.x8bit.bitwarden.data.platform.util
import com.bitwarden.vault.CipherListView
/**
* Indicates if this [CipherListView] is active based on its deleted or archived status.
*/
val CipherListView.isActive: Boolean
get() = this.archivedDate == null && this.deletedDate == null

View File

@@ -15,12 +15,6 @@ private const val AMEX_DIGITS_DISPLAYED: Int = 5
*/
private const val CARD_DIGITS_DISPLAYED: Int = 4
/**
* Indicates if this [CipherView] is active based on its deleted or archived status.
*/
val CipherView.isActive: Boolean
get() = this.archivedDate == null && this.deletedDate == null
/**
* The subtitle for a [CipherView] used to give extra information about a particular instance.
*/

View File

@@ -60,9 +60,7 @@ fun URI.addSchemeToUriIfNecessary(): URI {
// provided that scheme does not exist already.
!uriString.hasHttpProtocol()
) {
"https://$uriString"
.toUriOrNull()
?: this
URI("https://$uriString")
} else {
this
}

View File

@@ -21,36 +21,36 @@ class GeneratorSdkSourceImpl(
override suspend fun generatePassword(
request: PasswordGeneratorRequest,
): Result<String> = runCatchingWithLogs {
useClient { generators().password(request) }
getClient().generators().password(request)
}
override suspend fun generatePassphrase(
request: PassphraseGeneratorRequest,
): Result<String> = runCatchingWithLogs {
useClient { generators().passphrase(request) }
getClient().generators().passphrase(request)
}
override suspend fun generatePlusAddressedEmail(
request: UsernameGeneratorRequest.Subaddress,
): Result<String> = runCatchingWithLogs {
useClient { generators().username(request) }
getClient().generators().username(request)
}
override suspend fun generateCatchAllEmail(
request: UsernameGeneratorRequest.Catchall,
): Result<String> = runCatchingWithLogs {
useClient { generators().username(request) }
getClient().generators().username(request)
}
override suspend fun generateRandomWord(
request: UsernameGeneratorRequest.Word,
): Result<String> = runCatchingWithLogs {
useClient { generators().username(request) }
getClient().generators().username(request)
}
override suspend fun generateForwardedServiceEmail(
request: UsernameGeneratorRequest.Forwarded,
): Result<String> = runCatchingWithLogs {
useClient { generators().username(request) }
getClient().generators().username(request)
}
}

View File

@@ -92,36 +92,11 @@ interface VaultDiskSource {
*/
suspend fun deleteFolder(userId: String, folderId: String)
/**
* Deletes folders with the given [folderIds] from the data source for the given [userId].
*/
suspend fun deleteSelectedFolders(userId: String, folderIds: List<String>)
/**
* Deletes all folders from the data source for the given [userId].
*/
suspend fun deleteAllFolders(userId: String)
/**
* Saves a folder to the data source for the given [userId].
*/
suspend fun saveFolder(userId: String, folder: SyncResponseJson.Folder)
/**
* Saves multiple folders to the data source for the given [userId].
*/
suspend fun saveFolders(userId: String, folders: List<SyncResponseJson.Folder>)
/**
* Retrieves a folder from the data source for a given [userId] and [folderId].
*/
suspend fun getFolder(userId: String, folderId: String): SyncResponseJson.Folder?
/**
* Retrieves all folders from the data source for a given [userId].
*/
suspend fun getFolders(userId: String): List<SyncResponseJson.Folder>
/**
* Retrieves all folders from the data source for a given [userId].
*/

View File

@@ -241,14 +241,6 @@ class VaultDiskSourceImpl(
foldersDao.deleteFolder(userId = userId, folderId = folderId)
}
override suspend fun deleteSelectedFolders(userId: String, folderIds: List<String>) {
foldersDao.deleteSelectedFolders(userId = userId, folderIds = folderIds)
}
override suspend fun deleteAllFolders(userId: String) {
foldersDao.deleteAllFolders(userId = userId)
}
override suspend fun saveFolder(userId: String, folder: SyncResponseJson.Folder) {
foldersDao.insertFolder(
folder = FolderEntity(
@@ -260,44 +252,6 @@ class VaultDiskSourceImpl(
)
}
override suspend fun saveFolders(userId: String, folders: List<SyncResponseJson.Folder>) {
foldersDao.insertFolders(
folders = folders.map { folder ->
FolderEntity(
id = folder.id,
userId = userId,
name = folder.name,
revisionDate = folder.revisionDate,
)
},
)
}
override suspend fun getFolder(
userId: String,
folderId: String,
): SyncResponseJson.Folder? =
foldersDao
.getFolder(userId = userId, folderId = folderId)
?.let { folder ->
SyncResponseJson.Folder(
id = folder.id,
name = folder.name,
revisionDate = folder.revisionDate,
)
}
override suspend fun getFolders(userId: String): List<SyncResponseJson.Folder> =
foldersDao
.getAllFolders(userId = userId)
.map { folder ->
SyncResponseJson.Folder(
id = folder.id,
name = folder.name,
revisionDate = folder.revisionDate,
)
}
override fun getFoldersFlow(
userId: String,
): Flow<List<SyncResponseJson.Folder>> =

View File

@@ -35,30 +35,6 @@ interface FoldersDao {
userId: String,
): Flow<List<FolderEntity>>
/**
* Retrieves all folders from the database for a given [userId].
*/
@Query("SELECT * FROM folders WHERE user_id = :userId")
fun getAllFolders(
userId: String,
): List<FolderEntity>
/**
* Retrieves a folder from the database for a given [userId] and [folderId].
*/
@Query("SELECT * FROM folders WHERE user_id = :userId AND id = :folderId LIMIT 1")
suspend fun getFolder(
userId: String,
folderId: String,
): FolderEntity?
/**
* Deletes the stored folders associated with the given [userId] whose IDs are in [folderIds].
* This will return the number of rows deleted by this query.
*/
@Query("DELETE FROM folders WHERE user_id = :userId AND id IN (:folderIds)")
suspend fun deleteSelectedFolders(userId: String, folderIds: List<String>): Int
/**
* Deletes all the stored folders associated with the given [userId]. This will return the
* number of rows deleted by this query.

View File

@@ -28,7 +28,6 @@ import com.x8bit.bitwarden.data.auth.manager.UserLogoutManager
import com.x8bit.bitwarden.data.auth.repository.model.LogoutReason
import com.x8bit.bitwarden.data.auth.repository.model.UpdateKdfMinimumsResult
import com.x8bit.bitwarden.data.auth.repository.util.activeUserIdChangesFlow
import com.x8bit.bitwarden.data.auth.repository.util.toAccountCryptographicState
import com.x8bit.bitwarden.data.auth.repository.util.toSdkParams
import com.x8bit.bitwarden.data.auth.repository.util.userAccountTokens
import com.x8bit.bitwarden.data.auth.repository.util.userSwitchingChangesFlow
@@ -41,6 +40,7 @@ import com.x8bit.bitwarden.data.vault.datasource.sdk.model.InitializeCryptoResul
import com.x8bit.bitwarden.data.vault.manager.model.VaultStateEvent
import com.x8bit.bitwarden.data.vault.repository.model.VaultUnlockData
import com.x8bit.bitwarden.data.vault.repository.model.VaultUnlockResult
import com.x8bit.bitwarden.data.vault.repository.util.createWrappedAccountCryptographicState
import com.x8bit.bitwarden.data.vault.repository.util.logTag
import com.x8bit.bitwarden.data.vault.repository.util.statusFor
import com.x8bit.bitwarden.data.vault.repository.util.toVaultUnlockResult
@@ -681,10 +681,16 @@ class VaultLockManagerImpl(
?: return VaultUnlockResult.InvalidStateError(
error = MissingPropertyException("Private key"),
)
val signingKey = accountKeys?.signatureKeyPair?.wrappedSigningKey
val securityState = accountKeys?.securityState?.securityState
val signedPublicKey = accountKeys?.publicKeyEncryptionKeyPair?.signedPublicKey
val organizationKeys = authDiskSource.getOrganizationKeys(userId = userId)
return unlockVault(
accountCryptographicState = accountKeys.toAccountCryptographicState(
accountCryptographicState = createWrappedAccountCryptographicState(
privateKey = privateKey,
securityState = securityState,
signingKey = signingKey,
signedPublicKey = signedPublicKey,
),
userId = userId,
email = account.profile.email,

View File

@@ -10,8 +10,6 @@ import com.bitwarden.network.service.CiphersService
import com.bitwarden.network.service.FolderService
import com.bitwarden.network.service.SendsService
import com.bitwarden.network.service.SyncService
import com.bitwarden.ui.platform.feature.cardscanner.manager.CardScanManager
import com.bitwarden.ui.platform.feature.cardscanner.manager.CardScanManagerImpl
import com.x8bit.bitwarden.data.auth.datasource.disk.AuthDiskSource
import com.x8bit.bitwarden.data.auth.datasource.sdk.AuthSdkSource
import com.x8bit.bitwarden.data.auth.manager.KdfManager
@@ -62,10 +60,6 @@ import javax.inject.Singleton
@InstallIn(SingletonComponent::class)
object VaultManagerModule {
@Provides
@Singleton
fun provideCardScanManager(): CardScanManager = CardScanManagerImpl()
@Provides
@Singleton
fun provideVaultMigrationManager(

View File

@@ -19,11 +19,9 @@ import com.bitwarden.vault.CipherType
import com.bitwarden.vault.CipherView
import com.bitwarden.vault.FolderView
import com.x8bit.bitwarden.data.auth.datasource.disk.AuthDiskSource
import com.x8bit.bitwarden.data.auth.repository.util.toAccountCryptographicState
import com.x8bit.bitwarden.data.auth.repository.util.toSdkParams
import com.x8bit.bitwarden.data.autofill.util.login
import com.x8bit.bitwarden.data.platform.error.NoActiveUserException
import com.x8bit.bitwarden.data.platform.util.isActive
import com.x8bit.bitwarden.data.vault.datasource.disk.VaultDiskSource
import com.x8bit.bitwarden.data.vault.datasource.sdk.VaultSdkSource
import com.x8bit.bitwarden.data.vault.manager.CipherManager
@@ -42,6 +40,7 @@ import com.x8bit.bitwarden.data.vault.repository.model.GenerateTotpResult
import com.x8bit.bitwarden.data.vault.repository.model.ImportCredentialsResult
import com.x8bit.bitwarden.data.vault.repository.model.TotpCodeResult
import com.x8bit.bitwarden.data.vault.repository.model.VaultUnlockResult
import com.x8bit.bitwarden.data.vault.repository.util.createWrappedAccountCryptographicState
import com.x8bit.bitwarden.data.vault.repository.util.toEncryptedSdkCipher
import com.x8bit.bitwarden.data.vault.repository.util.toEncryptedSdkFolder
import com.x8bit.bitwarden.data.vault.repository.util.toSdkAccount
@@ -192,7 +191,7 @@ class VaultRepositoryImpl(
.filter {
it.type is CipherListViewType.Login &&
!it.login?.totp.isNullOrBlank() &&
it.isActive
it.deletedDate == null
}
.toFilteredList(vaultFilterType)
}
@@ -539,10 +538,17 @@ class VaultRepositoryImpl(
?: return VaultUnlockResult.InvalidStateError(
error = MissingPropertyException("Private key"),
)
val organizationKeys = authDiskSource.getOrganizationKeys(userId = userId)
val signingKey = accountKeys?.signatureKeyPair?.wrappedSigningKey
val securityState = accountKeys?.securityState?.securityState
val signedPublicKey = accountKeys?.publicKeyEncryptionKeyPair?.signedPublicKey
val organizationKeys = authDiskSource
.getOrganizationKeys(userId = userId)
return vaultLockManager.unlockVault(
accountCryptographicState = accountKeys.toAccountCryptographicState(
accountCryptographicState = createWrappedAccountCryptographicState(
privateKey = privateKey,
securityState = securityState,
signingKey = signingKey,
signedPublicKey = signedPublicKey,
),
userId = userId,
email = account.profile.email,

View File

@@ -24,16 +24,6 @@ fun SyncResponseJson.Folder.toEncryptedSdkFolder(): Folder =
revisionDate = revisionDate,
)
/**
* Converts a Bitwarden SDK [Folder] object to a corresponding [SyncResponseJson.Folder] object.
*/
fun Folder.toEncryptedNetworkFolderResponse(): SyncResponseJson.Folder =
SyncResponseJson.Folder(
id = id.orEmpty(),
name = name,
revisionDate = revisionDate,
)
/**
* Converts a Bitwarden SDK [Folder] objects to a corresponding
* [SyncResponseJson.Folder] object.

View File

@@ -82,7 +82,7 @@ fun NavGraphBuilder.authGraph(
navController.navigateToMasterPasswordGuidance()
},
onNavigateToPreventAccountLockout = {
navController.navigateToPreventAccountLockout(isPasswordReset = false)
navController.navigateToPreventAccountLockout()
},
onNavigateToLogin = { emailAddress ->
navController.navigateToLogin(
@@ -172,14 +172,11 @@ fun NavGraphBuilder.authGraph(
onNavigateToGeneratePassword = { navController.navigateToMasterPasswordGenerator() },
)
preventAccountLockoutDestination(
isPasswordReset = false,
onNavigateBack = { navController.popBackStack() },
)
masterPasswordGeneratorDestination(
onNavigateBack = { navController.popBackStack() },
onNavigateToPreventLockout = {
navController.navigateToPreventAccountLockout(isPasswordReset = false)
},
onNavigateToPreventLockout = { navController.navigateToPreventAccountLockout() },
onNavigateBackWithPassword = {
navController.popUpToCompleteRegistration()
},

View File

@@ -52,7 +52,6 @@ class EnterpriseSignOnViewModel @Inject constructor(
?: EnterpriseSignOnState(
dialogState = null,
orgIdentifierInput = "",
emailAddress = savedStateHandle.toEnterpriseSignOnArgs().emailAddress,
),
) {
@@ -232,7 +231,7 @@ class EnterpriseSignOnViewModel @Inject constructor(
mutableStateFlow.update {
it.copy(
dialogState = null,
orgIdentifierInput = authRepository.rememberedOrgIdentifier.orEmpty(),
orgIdentifierInput = authRepository.rememberedOrgIdentifier ?: "",
)
}
}
@@ -250,7 +249,7 @@ class EnterpriseSignOnViewModel @Inject constructor(
mutableStateFlow.update {
it.copy(
dialogState = null,
orgIdentifierInput = authRepository.rememberedOrgIdentifier.orEmpty(),
orgIdentifierInput = authRepository.rememberedOrgIdentifier ?: "",
)
}
return
@@ -457,10 +456,7 @@ class EnterpriseSignOnViewModel @Inject constructor(
private fun handleConfirmKeyConnectorDomainClick() {
showLoading()
viewModelScope.launch {
val result = authRepository.continueKeyConnectorLogin(
orgIdentifier = state.orgIdentifierInput,
email = state.emailAddress,
)
val result = authRepository.continueKeyConnectorLogin()
sendAction(EnterpriseSignOnAction.Internal.OnLoginResult(result))
}
}
@@ -478,7 +474,6 @@ class EnterpriseSignOnViewModel @Inject constructor(
data class EnterpriseSignOnState(
val dialogState: DialogState?,
val orgIdentifierInput: String,
val emailAddress: String,
) : Parcelable {
/**
* Represents the current state of any dialogs on the screen.

View File

@@ -10,51 +10,24 @@ import kotlinx.serialization.Serializable
* The type-safe route for the prevent account lockout screen.
*/
@Serializable
sealed class PreventAccountLockoutRoute {
/**
* The type-safe route for the prevent account lockout screen.
*/
@Serializable
data object Standard : PreventAccountLockoutRoute()
/**
* The type-safe route for the password reset prevent account lockout screen.
*/
@Serializable
data object PasswordReset : PreventAccountLockoutRoute()
}
data object PreventAccountLockoutRoute
/**
* Navigate to prevent account lockout screen.
*/
fun NavController.navigateToPreventAccountLockout(
isPasswordReset: Boolean,
navOptions: NavOptions? = null,
) {
this.navigate(
route = if (isPasswordReset) {
PreventAccountLockoutRoute.PasswordReset
} else {
PreventAccountLockoutRoute.Standard
},
navOptions = navOptions,
)
fun NavController.navigateToPreventAccountLockout(navOptions: NavOptions? = null) {
this.navigate(route = PreventAccountLockoutRoute, navOptions = navOptions)
}
/**
* Add the prevent account lockout screen to the nav graph.
*/
fun NavGraphBuilder.preventAccountLockoutDestination(
isPasswordReset: Boolean,
onNavigateBack: () -> Unit,
) {
if (isPasswordReset) {
composableWithSlideTransitions<PreventAccountLockoutRoute.PasswordReset> {
PreventAccountLockoutScreen(onNavigateBack = onNavigateBack)
}
} else {
composableWithSlideTransitions<PreventAccountLockoutRoute.Standard> {
PreventAccountLockoutScreen(onNavigateBack = onNavigateBack)
}
composableWithSlideTransitions<PreventAccountLockoutRoute> {
PreventAccountLockoutScreen(
onNavigateBack = onNavigateBack,
)
}
}

View File

@@ -2,45 +2,16 @@ package com.x8bit.bitwarden.ui.auth.feature.resetpassword
import androidx.navigation.NavController
import androidx.navigation.NavGraphBuilder
import androidx.navigation.NavHostController
import androidx.navigation.NavOptions
import androidx.navigation.compose.composable
import androidx.navigation.navigation
import com.x8bit.bitwarden.ui.auth.feature.preventaccountlockout.navigateToPreventAccountLockout
import com.x8bit.bitwarden.ui.auth.feature.preventaccountlockout.preventAccountLockoutDestination
import kotlinx.serialization.Serializable
/**
* The type-safe route for the reset password graph.
*/
@Serializable
data object ResetPasswordGraphRoute
/**
* The type-safe route for the reset password screen.
*/
@Serializable
data object ResetPasswordRoute
/**
* Add password reset destinations to the nav graph.
*/
fun NavGraphBuilder.passwordResetGraph(navController: NavHostController) {
navigation<ResetPasswordGraphRoute>(
startDestination = ResetPasswordRoute,
) {
resetPasswordDestination(
onNavigateToPreventAccountLockOut = {
navController.navigateToPreventAccountLockout(isPasswordReset = true)
},
)
preventAccountLockoutDestination(
isPasswordReset = true,
onNavigateBack = { navController.popBackStack() },
)
}
}
/**
* Add the Reset Password screen to the nav graph.
*/
@@ -53,10 +24,10 @@ fun NavGraphBuilder.resetPasswordDestination(
}
/**
* Navigate to the Reset Password graph.
* Navigate to the Reset Password screen.
*/
fun NavController.navigateToResetPasswordGraph(
fun NavController.navigateToResetPasswordScreen(
navOptions: NavOptions? = null,
) {
this.navigate(route = ResetPasswordGraphRoute, navOptions = navOptions)
this.navigate(route = ResetPasswordRoute, navOptions = navOptions)
}

View File

@@ -22,14 +22,9 @@ import com.bitwarden.cxf.ui.composition.LocalCredentialExchangeImporter
import com.bitwarden.cxf.ui.composition.LocalCredentialExchangeRequestValidator
import com.bitwarden.cxf.validator.CredentialExchangeRequestValidator
import com.bitwarden.cxf.validator.dsl.credentialExchangeRequestValidator
import com.bitwarden.ui.platform.composition.LocalCardTextAnalyzer
import com.bitwarden.ui.platform.composition.LocalExitManager
import com.bitwarden.ui.platform.composition.LocalIntentManager
import com.bitwarden.ui.platform.composition.LocalQrCodeAnalyzer
import com.bitwarden.ui.platform.feature.cardscanner.util.CardDataParser
import com.bitwarden.ui.platform.feature.cardscanner.util.CardDataParserImpl
import com.bitwarden.ui.platform.feature.cardscanner.util.CardTextAnalyzer
import com.bitwarden.ui.platform.feature.cardscanner.util.CardTextAnalyzerImpl
import com.bitwarden.ui.platform.feature.qrcodescan.util.QrCodeAnalyzer
import com.bitwarden.ui.platform.feature.qrcodescan.util.QrCodeAnalyzerImpl
import com.bitwarden.ui.platform.manager.IntentManager
@@ -89,10 +84,6 @@ fun LocalManagerProvider(
credentialExchangeRequestValidator: CredentialExchangeRequestValidator =
credentialExchangeRequestValidator(activity = activity),
authTabLaunchers: AuthTabLaunchers,
cardDataParser: CardDataParser = CardDataParserImpl(),
cardTextAnalyzer: CardTextAnalyzer = CardTextAnalyzerImpl(
cardDataParser = cardDataParser,
),
qrCodeAnalyzer: QrCodeAnalyzer = QrCodeAnalyzerImpl(),
content: @Composable () -> Unit,
) {
@@ -112,7 +103,6 @@ fun LocalManagerProvider(
LocalCredentialExchangeCompletionManager provides credentialExchangeCompletionManager,
LocalCredentialExchangeRequestValidator provides credentialExchangeRequestValidator,
LocalAuthTabLaunchers provides authTabLaunchers,
LocalCardTextAnalyzer provides cardTextAnalyzer,
LocalQrCodeAnalyzer provides qrCodeAnalyzer,
content = content,
)

View File

@@ -110,15 +110,6 @@ fun DebugMenuScreen(
.fillMaxWidth()
.standardHorizontalMargin(),
)
Spacer(Modifier.height(height = 16.dp))
BitwardenHorizontalDivider()
Spacer(Modifier.height(height = 16.dp))
BitwardenListHeaderText(
label = stringResource(BitwardenString.cookies),
modifier = Modifier
.standardHorizontalMargin()
.padding(horizontal = 16.dp),
)
Spacer(Modifier.height(height = 8.dp))
BitwardenFilledButton(
label = stringResource(BitwardenString.trigger_cookie_acquisition),
@@ -144,27 +135,6 @@ fun DebugMenuScreen(
Spacer(Modifier.height(height = 16.dp))
BitwardenHorizontalDivider()
Spacer(Modifier.height(height = 16.dp))
BitwardenListHeaderText(
label = stringResource(BitwardenString.premium),
modifier = Modifier
.standardHorizontalMargin()
.padding(horizontal = 16.dp),
)
Spacer(Modifier.height(height = 8.dp))
BitwardenFilledButton(
label = stringResource(BitwardenString.reset_premium_upgrade_banner),
onClick = {
viewModel.trySendAction(
DebugMenuAction.ResetPremiumUpgradeBanner,
)
},
modifier = Modifier
.fillMaxWidth()
.standardHorizontalMargin(),
)
Spacer(Modifier.height(height = 16.dp))
BitwardenHorizontalDivider()
Spacer(Modifier.height(height = 16.dp))
BitwardenListHeaderText(
label = stringResource(BitwardenString.error_reports),
modifier = Modifier

View File

@@ -65,7 +65,6 @@ class DebugMenuViewModel @Inject constructor(
DebugMenuAction.GenerateErrorReportClick -> handleErrorReportClick()
DebugMenuAction.TriggerCookieAcquisition -> handleTriggerCookieAcquisition()
DebugMenuAction.ClearSsoCookies -> handleClearSsoCookies()
DebugMenuAction.ResetPremiumUpgradeBanner -> handleResetPremiumUpgradeBanner()
}
}
@@ -106,10 +105,6 @@ class DebugMenuViewModel @Inject constructor(
debugMenuRepository.clearSsoCookies()
}
private fun handleResetPremiumUpgradeBanner() {
debugMenuRepository.resetPremiumUpgradeBannerDismiss()
}
private fun handleTriggerCookieAcquisition() {
cookieAcquisitionRequestManager.setPendingCookieAcquisition(
data = CookieAcquisitionRequest(
@@ -211,11 +206,6 @@ sealed class DebugMenuAction {
*/
data object ClearSsoCookies : DebugMenuAction()
/**
* User has clicked to reset the Premium upgrade banner dismiss status.
*/
data object ResetPremiumUpgradeBanner : DebugMenuAction()
/**
* Internal actions not triggered from the UI.
*/

View File

@@ -1,109 +0,0 @@
@file:OmitFromCoverage
package com.x8bit.bitwarden.ui.platform.feature.premium.plan
import android.os.Parcelable
import androidx.lifecycle.SavedStateHandle
import androidx.navigation.NavController
import androidx.navigation.NavGraphBuilder
import androidx.navigation.NavOptions
import androidx.navigation.toRoute
import com.bitwarden.annotation.OmitFromCoverage
import com.bitwarden.ui.platform.base.util.composableWithPushTransitions
import com.bitwarden.ui.platform.base.util.composableWithSlideTransitions
import com.bitwarden.ui.platform.util.ParcelableRouteSerializer
import kotlinx.parcelize.Parcelize
import kotlinx.serialization.Serializable
/**
* The type-safe route for the plan screen.
*/
@OmitFromCoverage
@Parcelize
@Serializable(with = PlanRoute.Serializer::class)
sealed class PlanRoute : Parcelable {
/**
* Custom serializer to support polymorphic routes.
*/
class Serializer : ParcelableRouteSerializer<PlanRoute>(PlanRoute::class)
/**
* Standard destination — inside settingsGraph, bottom nav visible, back arrow.
*/
@Parcelize
@Serializable(with = Standard.Serializer::class)
data object Standard : PlanRoute() {
/**
* Custom serializer to support polymorphic routes.
*/
class Serializer : ParcelableRouteSerializer<Standard>(Standard::class)
}
/**
* Modal destination — parent vaultUnlockedGraph level, bottom nav hidden, close icon.
*/
@Parcelize
@Serializable(with = Modal.Serializer::class)
data object Modal : PlanRoute() {
/**
* Custom serializer to support polymorphic routes.
*/
class Serializer : ParcelableRouteSerializer<Modal>(Modal::class)
}
}
/**
* Class to retrieve plan arguments from the [SavedStateHandle].
*/
@OmitFromCoverage
data class PlanArgs(val planMode: PlanMode)
/**
* Constructs [PlanArgs] from the [SavedStateHandle] and internal route data.
*/
fun SavedStateHandle.toPlanArgs(): PlanArgs {
val route = this.toRoute<PlanRoute>()
return PlanArgs(
planMode = when (route) {
is PlanRoute.Standard -> PlanMode.Standard
is PlanRoute.Modal -> PlanMode.Modal
},
)
}
/**
* Register inside settingsGraph — bottom nav visible.
*/
fun NavGraphBuilder.planDestination(
onNavigateBack: () -> Unit,
) {
composableWithPushTransitions<PlanRoute.Standard> {
PlanScreen(onNavigateBack = onNavigateBack)
}
}
/**
* Register at parent vaultUnlockedGraph level — bottom nav hidden.
*/
fun NavGraphBuilder.planModalDestination(
onNavigateBack: () -> Unit,
) {
composableWithSlideTransitions<PlanRoute.Modal> {
PlanScreen(onNavigateBack = onNavigateBack)
}
}
/**
* Navigate to the plan screen (standard, within settings graph).
*/
fun NavController.navigateToPlan(navOptions: NavOptions? = null) {
navigate(route = PlanRoute.Standard, navOptions = navOptions)
}
/**
* Navigate to the plan screen (modal, at parent level).
*/
fun NavController.navigateToPlanModal(navOptions: NavOptions? = null) {
navigate(route = PlanRoute.Modal, navOptions = navOptions)
}

View File

@@ -1,360 +0,0 @@
package com.x8bit.bitwarden.ui.platform.feature.premium.plan
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.height
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.rememberTopAppBarState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.core.net.toUri
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.bitwarden.annotation.OmitFromCoverage
import com.bitwarden.ui.platform.base.util.EventsEffect
import com.bitwarden.ui.platform.base.util.cardStyle
import com.bitwarden.ui.platform.base.util.standardHorizontalMargin
import com.bitwarden.ui.platform.components.appbar.BitwardenTopAppBar
import com.bitwarden.ui.platform.components.button.BitwardenFilledButton
import com.bitwarden.ui.platform.components.content.BitwardenContentBlock
import com.bitwarden.ui.platform.components.content.model.ContentBlockData
import com.bitwarden.ui.platform.components.dialog.BitwardenLoadingDialog
import com.bitwarden.ui.platform.components.dialog.BitwardenTwoButtonDialog
import com.bitwarden.ui.platform.components.divider.BitwardenHorizontalDivider
import com.bitwarden.ui.platform.components.model.CardStyle
import com.bitwarden.ui.platform.components.scaffold.BitwardenScaffold
import com.bitwarden.ui.platform.components.snackbar.BitwardenSnackbarHost
import com.bitwarden.ui.platform.components.snackbar.model.rememberBitwardenSnackbarHostState
import com.bitwarden.ui.platform.components.util.rememberVectorPainter
import com.bitwarden.ui.platform.composition.LocalIntentManager
import com.bitwarden.ui.platform.manager.IntentManager
import com.bitwarden.ui.platform.resource.BitwardenDrawable
import com.bitwarden.ui.platform.resource.BitwardenString
import com.bitwarden.ui.platform.theme.BitwardenTheme
import com.x8bit.bitwarden.ui.platform.composition.LocalAuthTabLaunchers
import com.x8bit.bitwarden.ui.platform.feature.premium.plan.handlers.PlanHandlers
import com.x8bit.bitwarden.ui.platform.model.AuthTabLaunchers
/**
* The screen for the plan — shows the upgrade flow for free users.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun PlanScreen(
onNavigateBack: () -> Unit,
viewModel: PlanViewModel = hiltViewModel(),
intentManager: IntentManager = LocalIntentManager.current,
authTabLaunchers: AuthTabLaunchers = LocalAuthTabLaunchers.current,
) {
val state by viewModel.stateFlow.collectAsStateWithLifecycle()
val handlers = remember(viewModel) { PlanHandlers.create(viewModel) }
val snackbarHostState = rememberBitwardenSnackbarHostState()
EventsEffect(viewModel = viewModel) { event ->
when (event) {
is PlanEvent.LaunchBrowser -> {
intentManager.startAuthTab(
uri = event.url.toUri(),
authTabData = event.authTabData,
launcher = authTabLaunchers.premiumCheckout,
)
}
PlanEvent.NavigateBack -> onNavigateBack()
is PlanEvent.ShowSnackbar -> snackbarHostState.showSnackbar(event.data)
}
}
FreeDialogs(
dialogState = state.dialogState,
handlers = handlers,
)
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState())
BitwardenScaffold(
modifier = Modifier
.fillMaxSize()
.nestedScroll(scrollBehavior.nestedScrollConnection),
snackbarHost = {
BitwardenSnackbarHost(bitwardenHostState = snackbarHostState)
},
topBar = {
BitwardenTopAppBar(
title = stringResource(id = state.title),
scrollBehavior = scrollBehavior,
navigationIcon = rememberVectorPainter(id = state.navigationIcon),
navigationIconContentDescription = stringResource(
id = state.navigationIconContentDescription,
),
onNavigationIconClick = handlers.onBackClick,
)
},
) {
when (val viewState = state.viewState) {
is PlanState.ViewState.Free -> {
FreeContent(
viewState = viewState,
isDialogShowing = state.dialogState != null,
handlers = handlers,
)
}
PlanState.ViewState.Premium -> {
PremiumContent(modifier = Modifier.fillMaxSize())
}
}
}
}
@Composable
private fun PremiumContent(
modifier: Modifier = Modifier,
) {
// TODO(PM-35455): Render the premium subscription management UI —
// status badge, next-charge summary, billing / storage / discount /
// tax line items, and manage plan / cancel actions — once the
// subscription fetch path is wired up.
Spacer(modifier = modifier)
}
@Composable
private fun FreeDialogs(
dialogState: PlanState.DialogState?,
handlers: PlanHandlers,
) {
when (dialogState) {
is PlanState.DialogState.CheckoutError -> {
BitwardenTwoButtonDialog(
title = stringResource(id = BitwardenString.secure_checkout_didnt_load),
message = stringResource(id = BitwardenString.trouble_opening_payment_page),
confirmButtonText = stringResource(id = BitwardenString.try_again),
dismissButtonText = stringResource(id = BitwardenString.close),
onConfirmClick = handlers.onRetryClick,
onDismissClick = handlers.onDismissError,
onDismissRequest = handlers.onDismissError,
)
}
is PlanState.DialogState.GetPricingError -> {
BitwardenTwoButtonDialog(
title = dialogState.title(),
message = dialogState.message(),
confirmButtonText = stringResource(BitwardenString.try_again),
dismissButtonText = stringResource(BitwardenString.close),
onConfirmClick = handlers.onRetryPricingClick,
onDismissClick = handlers.onClosePricingErrorClick,
onDismissRequest = handlers.onClosePricingErrorClick,
)
}
is PlanState.DialogState.WaitingForPayment -> {
BitwardenTwoButtonDialog(
title = stringResource(id = BitwardenString.payment_not_received_yet),
message = stringResource(id = BitwardenString.return_to_stripe_to_finish),
confirmButtonText = stringResource(id = BitwardenString.go_back),
dismissButtonText = stringResource(id = BitwardenString.close),
onConfirmClick = handlers.onGoBackClick,
onDismissClick = handlers.onCancelWaiting,
onDismissRequest = handlers.onCancelWaiting,
)
}
is PlanState.DialogState.PendingUpgrade -> {
BitwardenTwoButtonDialog(
title = stringResource(id = BitwardenString.upgrade_pending),
message = stringResource(
id = BitwardenString.upgrade_pending_message,
),
confirmButtonText = stringResource(id = BitwardenString.sync_now),
dismissButtonText = stringResource(id = BitwardenString.continue_text),
onConfirmClick = handlers.onSyncClick,
onDismissClick = handlers.onContinueClick,
onDismissRequest = handlers.onContinueClick,
)
}
is PlanState.DialogState.Loading -> {
BitwardenLoadingDialog(text = dialogState.message())
}
null -> Unit
}
}
@Composable
private fun FreeContent(
viewState: PlanState.ViewState.Free,
isDialogShowing: Boolean,
handlers: PlanHandlers,
modifier: Modifier = Modifier,
) {
Column(
modifier = modifier
.fillMaxSize()
.verticalScroll(rememberScrollState()),
) {
Spacer(Modifier.height(12.dp))
PremiumDetailsCard(
rate = viewState.rate,
frequency = stringResource(id = BitwardenString.per_month),
modifier = Modifier.standardHorizontalMargin(),
)
Spacer(modifier = Modifier.height(24.dp))
BitwardenFilledButton(
label = stringResource(id = BitwardenString.upgrade_now),
onClick = handlers.onUpgradeNowClick,
isEnabled = !isDialogShowing,
icon = rememberVectorPainter(id = BitwardenDrawable.ic_external_link),
modifier = Modifier
.standardHorizontalMargin()
.fillMaxWidth()
.testTag("UpgradeNowButton"),
)
Spacer(modifier = Modifier.height(12.dp))
Text(
text = stringResource(id = BitwardenString.stripe_checkout_footer),
style = BitwardenTheme.typography.bodyMedium,
color = BitwardenTheme.colorScheme.text.secondary,
textAlign = TextAlign.Center,
modifier = Modifier
.fillMaxWidth()
.standardHorizontalMargin()
.testTag("StripeFooterText"),
)
Spacer(modifier = Modifier.height(16.dp))
Spacer(modifier = Modifier.navigationBarsPadding())
}
}
@Composable
private fun PremiumDetailsCard(
rate: String,
frequency: String,
modifier: Modifier = Modifier,
) {
Column(
modifier = modifier
.fillMaxWidth()
.cardStyle(
cardStyle = CardStyle.Full,
// Override bottom padding to account for custom
// `BitwardenContentBlock` vertical padding, below.
paddingBottom = 0.dp,
),
) {
Column(
modifier = Modifier
.padding(bottom = 16.dp)
.standardHorizontalMargin(),
) {
PriceRow(
rate = rate,
frequency = frequency,
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = stringResource(
id = BitwardenString.unlock_premium_features,
),
style = BitwardenTheme.typography.bodyMedium,
color = BitwardenTheme.colorScheme.text.secondary,
)
}
BitwardenHorizontalDivider()
val features = listOf(
BitwardenString.built_in_authenticator,
BitwardenString.emergency_access,
BitwardenString.secure_file_storage,
BitwardenString.breach_monitoring,
)
features.forEachIndexed { index, featureStringRes ->
BitwardenContentBlock(
data = ContentBlockData(
headerText = stringResource(id = featureStringRes),
iconVectorResource = BitwardenDrawable.ic_check_mark,
),
headerTextStyle = BitwardenTheme.typography.titleMedium,
showDivider = index != features.lastIndex,
modifier = Modifier.padding(vertical = 8.dp),
)
}
}
}
@Composable
private fun PriceRow(
rate: String,
frequency: String,
modifier: Modifier = Modifier,
) {
Row(modifier = modifier) {
Text(
text = rate,
style = BitwardenTheme.typography.headlineMedium,
color = BitwardenTheme.colorScheme.text.primary,
modifier = Modifier.alignByBaseline(),
)
Spacer(modifier = Modifier.width(4.dp))
Text(
text = frequency,
style = BitwardenTheme.typography.bodyMedium,
color = BitwardenTheme.colorScheme.text.secondary,
modifier = Modifier.alignByBaseline(),
)
}
}
@Preview
@OmitFromCoverage
@Composable
private fun PlanScreenFreeAccount_preview() {
BitwardenTheme {
BitwardenScaffold {
FreeContent(
viewState = PlanState.ViewState.Free(
rate = "$1.67",
checkoutUrl = null,
isAwaitingPremiumStatus = false,
),
isDialogShowing = false,
handlers = PlanHandlers(
onBackClick = {},
onUpgradeNowClick = {},
onDismissError = {},
onRetryClick = {},
onRetryPricingClick = {},
onClosePricingErrorClick = {},
onCancelWaiting = {},
onGoBackClick = {},
onSyncClick = {},
onContinueClick = {},
),
)
}
}
}

View File

@@ -1,662 +0,0 @@
package com.x8bit.bitwarden.ui.platform.feature.premium.plan
import android.os.Parcelable
import androidx.annotation.DrawableRes
import androidx.annotation.StringRes
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.viewModelScope
import com.bitwarden.ui.platform.base.BaseViewModel
import com.bitwarden.ui.platform.components.snackbar.model.BitwardenSnackbarData
import com.bitwarden.ui.platform.manager.intent.model.AuthTabData
import com.bitwarden.ui.platform.resource.BitwardenDrawable
import com.bitwarden.ui.platform.resource.BitwardenString
import com.bitwarden.ui.util.Text
import com.bitwarden.ui.util.asText
import com.x8bit.bitwarden.data.auth.repository.AuthRepository
import com.x8bit.bitwarden.data.auth.repository.model.UserState
import com.x8bit.bitwarden.data.billing.repository.BillingRepository
import com.x8bit.bitwarden.data.billing.repository.model.CheckoutSessionResult
import com.x8bit.bitwarden.data.billing.repository.model.PremiumPlanPricingResult
import com.x8bit.bitwarden.data.billing.util.PremiumCheckoutCallbackResult
import com.x8bit.bitwarden.data.platform.manager.SpecialCircumstanceManager
import com.x8bit.bitwarden.data.platform.manager.model.SpecialCircumstance
import com.x8bit.bitwarden.data.vault.manager.model.SyncVaultDataResult
import com.x8bit.bitwarden.data.vault.repository.VaultRepository
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.parcelize.Parcelize
import java.text.NumberFormat
import java.util.Locale
import javax.inject.Inject
private const val KEY_STATE = "state"
private const val MONTHS_PER_YEAR = 12
private const val PLACEHOLDER_RATE = "--"
/**
* The callback URL for the premium checkout custom tab.
*/
const val PREMIUM_CHECKOUT_CALLBACK_URL = "bitwarden://premium-checkout-result"
/**
* View model for the plan screen, driving the upgrade flow for free users and a
* placeholder surface for premium users until PM-35455 wires in subscription
* management.
*/
@Suppress("TooManyFunctions")
@HiltViewModel
class PlanViewModel @Inject constructor(
savedStateHandle: SavedStateHandle,
private val billingRepository: BillingRepository,
private val authRepository: AuthRepository,
private val specialCircumstanceManager: SpecialCircumstanceManager,
private val vaultRepository: VaultRepository,
) : BaseViewModel<PlanState, PlanEvent, PlanAction>(
initialState = savedStateHandle[KEY_STATE] ?: run {
val planMode = savedStateHandle.toPlanArgs().planMode
val isPremium = authRepository
.userStateFlow
.value
?.activeAccount
?.isPremium == true
PlanState(
planMode = planMode,
viewState = if (isPremium) {
PlanState.ViewState.Premium
} else {
PlanState.ViewState.Free(
rate = PLACEHOLDER_RATE,
checkoutUrl = null,
isAwaitingPremiumStatus = false,
)
},
dialogState = null,
)
},
) {
init {
stateFlow
.onEach { savedStateHandle[KEY_STATE] = it }
.launchIn(viewModelScope)
authRepository
.userStateFlow
.map { PlanAction.Internal.UserStateUpdateReceive(it) }
.onEach(::sendAction)
.launchIn(viewModelScope)
specialCircumstanceManager
.specialCircumstanceStateFlow
.map { PlanAction.Internal.SpecialCircumstanceReceive(it) }
.onEach(::sendAction)
.launchIn(viewModelScope)
onFreeContent {
viewModelScope.launch {
sendAction(
PlanAction.Internal.PricingResultReceive(
result = billingRepository.getPremiumPlanPricing(),
),
)
}
}
}
override fun handleAction(action: PlanAction) {
when (action) {
is PlanAction.BackClick -> handleBackClick()
is PlanAction.UpgradeNowClick -> handleUpgradeNowClick()
is PlanAction.DismissError -> handleDismissError()
is PlanAction.ClosePricingErrorClick -> {
handleClosePricingErrorClick()
}
is PlanAction.RetryClick -> handleRetryClick()
is PlanAction.RetryPricingClick -> {
handleRetryPricingClick()
}
is PlanAction.CancelWaiting -> handleCancelWaiting()
is PlanAction.GoBackClick -> handleGoBackClick()
is PlanAction.SyncClick -> handleSyncClick()
is PlanAction.ContinueClick -> handleContinueClick()
is PlanAction.Internal.CheckoutUrlReceive -> {
handleCheckoutUrlReceive(action)
}
is PlanAction.Internal.UserStateUpdateReceive -> {
handleUserStateUpdateReceive(action)
}
is PlanAction.Internal.SpecialCircumstanceReceive -> {
handleSpecialCircumstanceReceive(action)
}
is PlanAction.Internal.SyncCompleteReceive -> {
handleSyncCompleteReceive()
}
is PlanAction.Internal.PricingResultReceive -> {
handlePricingResultReceive(action)
}
}
}
private fun handleBackClick() {
sendEvent(PlanEvent.NavigateBack)
}
private fun handleUpgradeNowClick() {
mutableStateFlow.update {
it.copy(
dialogState = PlanState.DialogState.Loading(
message = BitwardenString.opening_checkout.asText(),
),
)
}
viewModelScope.launch {
sendAction(
PlanAction.Internal.CheckoutUrlReceive(
result = billingRepository.getCheckoutSessionUrl(),
),
)
}
}
private fun handleRetryClick() {
handleUpgradeNowClick()
}
private fun handleDismissError() {
mutableStateFlow.update { it.copy(dialogState = null) }
}
private fun handleClosePricingErrorClick() {
mutableStateFlow.update { it.copy(dialogState = null) }
sendEvent(PlanEvent.NavigateBack)
}
private fun handleCancelWaiting() {
mutableStateFlow.update { it.copy(dialogState = null) }
}
private fun handleSyncClick() {
mutableStateFlow.update {
it.copy(
dialogState = PlanState.DialogState.Loading(
message = BitwardenString.confirming_your_upgrade.asText(),
),
)
}
triggerSync()
}
private fun handleContinueClick() {
mutableStateFlow.update { it.copy(dialogState = null) }
sendEvent(PlanEvent.NavigateBack)
}
private fun handleGoBackClick() {
onFreeContent { freeState ->
freeState.checkoutUrl?.let { url ->
sendEvent(
PlanEvent.LaunchBrowser(
url = url,
authTabData = AuthTabData.CustomScheme(
callbackUrl = PREMIUM_CHECKOUT_CALLBACK_URL,
),
),
)
}
}
}
private fun handleCheckoutUrlReceive(
action: PlanAction.Internal.CheckoutUrlReceive,
) {
when (val result = action.result) {
is CheckoutSessionResult.Success -> {
sendEvent(
PlanEvent.LaunchBrowser(
url = result.url,
authTabData = AuthTabData.CustomScheme(
callbackUrl = PREMIUM_CHECKOUT_CALLBACK_URL,
),
),
)
onFreeContent { freeState ->
mutableStateFlow.update {
it.copy(
viewState = freeState.copy(
checkoutUrl = result.url,
),
dialogState = null,
)
}
}
}
is CheckoutSessionResult.Error -> {
mutableStateFlow.update {
it.copy(dialogState = PlanState.DialogState.CheckoutError)
}
}
}
}
private fun handleUserStateUpdateReceive(
action: PlanAction.Internal.UserStateUpdateReceive,
) {
onFreeContent { freeState ->
if (!freeState.isAwaitingPremiumStatus) return@onFreeContent
val isPremium = action.userState?.activeAccount?.isPremium == true
if (isPremium) {
onPremiumUpgradeSuccess()
}
}
}
private fun handleSpecialCircumstanceReceive(
action: PlanAction.Internal.SpecialCircumstanceReceive,
) {
val checkoutResult = action.specialCircumstance
as? SpecialCircumstance.PremiumCheckout ?: return
specialCircumstanceManager.specialCircumstance = null
if (checkoutResult.callbackResult is PremiumCheckoutCallbackResult.Canceled) {
// User canceled checkout — show "Payment not received yet" dialog.
onFreeContent { freeState ->
mutableStateFlow.update {
it.copy(
viewState = freeState.copy(
isAwaitingPremiumStatus = true,
),
dialogState = PlanState.DialogState.WaitingForPayment,
)
}
}
return
}
// Success — check if already premium, otherwise trigger background sync.
val isPremium = authRepository
.userStateFlow
.value
?.activeAccount
?.isPremium == true
if (isPremium) {
onPremiumUpgradeSuccess()
} else {
onFreeContent { freeState ->
mutableStateFlow.update {
it.copy(
viewState = freeState.copy(
isAwaitingPremiumStatus = true,
),
dialogState = PlanState.DialogState.Loading(
message = BitwardenString.confirming_your_upgrade.asText(),
),
)
}
}
triggerSync()
}
}
private fun triggerSync() {
viewModelScope.launch {
val result = vaultRepository.syncForResult(forced = true)
sendAction(PlanAction.Internal.SyncCompleteReceive(result))
}
}
private fun handleSyncCompleteReceive() {
onFreeContent { freeState ->
if (!freeState.isAwaitingPremiumStatus) return@onFreeContent
val isPremium = authRepository
.userStateFlow
.value
?.activeAccount
?.isPremium == true
if (isPremium) {
onPremiumUpgradeSuccess()
} else {
// Sync completed but premium not yet provisioned —
// prompt the user to retry or continue as free.
mutableStateFlow.update {
it.copy(
dialogState = PlanState.DialogState.PendingUpgrade,
)
}
}
}
}
private fun onPremiumUpgradeSuccess() {
onFreeContent { freeState ->
mutableStateFlow.update {
it.copy(
viewState = freeState.copy(
isAwaitingPremiumStatus = false,
),
dialogState = null,
)
}
}
sendEvent(
PlanEvent.ShowSnackbar(
data = BitwardenSnackbarData(
message = BitwardenString.upgraded_to_premium.asText(),
),
),
)
}
private inline fun onFreeContent(
block: (PlanState.ViewState.Free) -> Unit,
) {
(state.viewState as? PlanState.ViewState.Free)
?.let(block)
}
private fun handlePricingResultReceive(
action: PlanAction.Internal.PricingResultReceive,
) {
when (val result = action.result) {
is PremiumPlanPricingResult.Success -> {
val formattedRate = NumberFormat
.getCurrencyInstance(Locale.US)
.format(result.annualPrice / MONTHS_PER_YEAR)
mutableStateFlow.update { currentState ->
val updatedViewState = when (val vs = currentState.viewState) {
is PlanState.ViewState.Free -> vs.copy(rate = formattedRate)
PlanState.ViewState.Premium -> vs
}
currentState.copy(
viewState = updatedViewState,
dialogState = null,
)
}
}
is PremiumPlanPricingResult.Error -> {
mutableStateFlow.update {
it.copy(
dialogState = PlanState.DialogState.GetPricingError(
title = BitwardenString.pricing_unavailable.asText(),
message = result.errorMessage?.asText()
?: BitwardenString.generic_error_message.asText(),
),
)
}
}
}
}
private fun handleRetryPricingClick() {
mutableStateFlow.update {
it.copy(
dialogState = PlanState.DialogState.Loading(
message = BitwardenString.loading.asText(),
),
)
}
viewModelScope.launch {
sendAction(
PlanAction.Internal.PricingResultReceive(
result = billingRepository.getPremiumPlanPricing(),
),
)
}
}
}
/**
* Determines how the Plan screen was reached.
*/
enum class PlanMode {
/** Back arrow, bottom nav visible (push sub-screen from Settings). */
Standard,
/** Close icon, bottom nav hidden (modal overlay from Vault). */
Modal,
}
/**
* Models state for the plan screen.
*/
@Parcelize
data class PlanState(
val planMode: PlanMode,
val viewState: ViewState,
val dialogState: DialogState?,
) : Parcelable {
/**
* The navigation icon drawable resource for the top app bar.
*/
@get:DrawableRes
val navigationIcon: Int
get() = when (planMode) {
PlanMode.Standard -> BitwardenDrawable.ic_back
PlanMode.Modal -> BitwardenDrawable.ic_close
}
/**
* The navigation icon content description string resource.
*/
@get:StringRes
val navigationIconContentDescription: Int
get() = when (planMode) {
PlanMode.Standard -> BitwardenString.back
PlanMode.Modal -> BitwardenString.close
}
/**
* The title string resource for the top app bar.
*/
@get:StringRes
val title: Int
get() = when (viewState) {
is ViewState.Free -> BitwardenString.upgrade_to_premium
ViewState.Premium -> BitwardenString.plan
}
/**
* Models the content state of the plan screen.
*/
sealed class ViewState : Parcelable {
/**
* Free user view — shows upgrade pricing and feature list.
*/
@Parcelize
data class Free(
val rate: String,
val checkoutUrl: String?,
val isAwaitingPremiumStatus: Boolean,
) : ViewState()
/**
* Premium user view. Empty placeholder until PM-35455 wires
* subscription management (status, billing amount, next charge,
* manage plan / cancel actions).
*/
@Parcelize
data object Premium : ViewState()
}
/**
* Represents the dialog/overlay state for the plan screen.
*/
sealed class DialogState : Parcelable {
/**
* Loading overlay with a configurable message.
*/
@Parcelize
data class Loading(
val message: Text,
) : DialogState()
/**
* Error dialog shown when the checkout session could not be loaded.
*/
@Parcelize
data object CheckoutError : DialogState()
/**
* Error dialog shown when pricing information cannot be retrieved.
*/
@Parcelize
data class GetPricingError(
val title: Text,
val message: Text,
) : DialogState()
/**
* Waiting dialog shown when the user returns from checkout
* without completing payment.
*/
@Parcelize
data object WaitingForPayment : DialogState()
/**
* Dialog shown after a successful checkout when premium
* status has not yet been provisioned by the server.
*/
@Parcelize
data object PendingUpgrade : DialogState()
}
}
/**
* Models events for the plan screen.
*/
sealed class PlanEvent {
/**
* Launch the user's browser with the given checkout [url]
* via AuthTab.
*/
data class LaunchBrowser(
val url: String,
val authTabData: AuthTabData,
) : PlanEvent()
/**
* Navigate back to the previous screen.
*/
data object NavigateBack : PlanEvent()
/**
* Show a snackbar with the given [data].
*/
data class ShowSnackbar(
val data: BitwardenSnackbarData,
) : PlanEvent()
}
/**
* Models actions for the plan screen.
*/
sealed class PlanAction {
/**
* The user clicked the back/close button.
*/
data object BackClick : PlanAction()
/**
* The user clicked the upgrade now button.
*/
data object UpgradeNowClick : PlanAction()
/**
* The user dismissed the checkout error dialog.
*/
data object DismissError : PlanAction()
/**
* The user clicked retry on the checkout error dialog.
*/
data object RetryClick : PlanAction()
/**
* The user clicked retry on the pricing error screen.
*/
data object RetryPricingClick : PlanAction()
/**
* The user clicked the close button on the pricing error dialog.
*/
data object ClosePricingErrorClick : PlanAction()
/**
* The user dismissed the waiting for payment dialog.
*/
data object CancelWaiting : PlanAction()
/**
* The user clicked go back on the waiting for payment dialog.
*/
data object GoBackClick : PlanAction()
/**
* The user clicked sync on the pending upgrade dialog.
*/
data object SyncClick : PlanAction()
/**
* The user chose to continue without waiting for upgrade.
*/
data object ContinueClick : PlanAction()
/**
* Models actions the view model sends itself.
*/
sealed class Internal : PlanAction() {
/**
* A checkout URL result has been received from the
* repository.
*/
data class CheckoutUrlReceive(
val result: CheckoutSessionResult,
) : Internal()
/**
* A user state update has been received.
*/
data class UserStateUpdateReceive(
val userState: UserState?,
) : Internal()
/**
* A special circumstance update has been received.
*/
data class SpecialCircumstanceReceive(
val specialCircumstance: SpecialCircumstance?,
) : Internal()
/**
* A vault sync has completed.
*/
data class SyncCompleteReceive(
val result: SyncVaultDataResult,
) : Internal()
/**
* A pricing result has been received from the repository.
*/
data class PricingResultReceive(
val result: PremiumPlanPricingResult,
) : Internal()
}
}

View File

@@ -1,43 +0,0 @@
package com.x8bit.bitwarden.ui.platform.feature.premium.plan.handlers
import com.x8bit.bitwarden.ui.platform.feature.premium.plan.PlanAction
import com.x8bit.bitwarden.ui.platform.feature.premium.plan.PlanViewModel
/**
* A collection of handler functions for managing actions within the context of
* the plan screen.
*/
data class PlanHandlers(
val onBackClick: () -> Unit,
val onUpgradeNowClick: () -> Unit,
val onDismissError: () -> Unit,
val onRetryClick: () -> Unit,
val onRetryPricingClick: () -> Unit,
val onClosePricingErrorClick: () -> Unit,
val onCancelWaiting: () -> Unit,
val onGoBackClick: () -> Unit,
val onSyncClick: () -> Unit,
val onContinueClick: () -> Unit,
) {
@Suppress("UndocumentedPublicClass")
companion object {
/**
* Creates the [PlanHandlers] using the [PlanViewModel] to send desired
* actions.
*/
fun create(viewModel: PlanViewModel): PlanHandlers = PlanHandlers(
onBackClick = { viewModel.trySendAction(PlanAction.BackClick) },
onUpgradeNowClick = { viewModel.trySendAction(PlanAction.UpgradeNowClick) },
onDismissError = { viewModel.trySendAction(PlanAction.DismissError) },
onRetryClick = { viewModel.trySendAction(PlanAction.RetryClick) },
onRetryPricingClick = { viewModel.trySendAction(PlanAction.RetryPricingClick) },
onClosePricingErrorClick = {
viewModel.trySendAction(PlanAction.ClosePricingErrorClick)
},
onCancelWaiting = { viewModel.trySendAction(PlanAction.CancelWaiting) },
onGoBackClick = { viewModel.trySendAction(PlanAction.GoBackClick) },
onSyncClick = { viewModel.trySendAction(PlanAction.SyncClick) },
onContinueClick = { viewModel.trySendAction(PlanAction.ContinueClick) },
)
}
}

View File

@@ -34,12 +34,13 @@ import com.x8bit.bitwarden.ui.auth.feature.auth.authGraph
import com.x8bit.bitwarden.ui.auth.feature.auth.navigateToAuthGraph
import com.x8bit.bitwarden.ui.auth.feature.completeregistration.navigateToCompleteRegistration
import com.x8bit.bitwarden.ui.auth.feature.expiredregistrationlink.navigateToExpiredRegistrationLinkScreen
import com.x8bit.bitwarden.ui.auth.feature.preventaccountlockout.navigateToPreventAccountLockout
import com.x8bit.bitwarden.ui.auth.feature.removepassword.RemovePasswordRoute
import com.x8bit.bitwarden.ui.auth.feature.removepassword.navigateToRemovePassword
import com.x8bit.bitwarden.ui.auth.feature.removepassword.removePasswordDestination
import com.x8bit.bitwarden.ui.auth.feature.resetpassword.ResetPasswordGraphRoute
import com.x8bit.bitwarden.ui.auth.feature.resetpassword.navigateToResetPasswordGraph
import com.x8bit.bitwarden.ui.auth.feature.resetpassword.passwordResetGraph
import com.x8bit.bitwarden.ui.auth.feature.resetpassword.ResetPasswordRoute
import com.x8bit.bitwarden.ui.auth.feature.resetpassword.navigateToResetPasswordScreen
import com.x8bit.bitwarden.ui.auth.feature.resetpassword.resetPasswordDestination
import com.x8bit.bitwarden.ui.auth.feature.setpassword.SetPasswordRoute
import com.x8bit.bitwarden.ui.auth.feature.setpassword.navigateToSetPassword
import com.x8bit.bitwarden.ui.auth.feature.trusteddevice.TrustedDeviceGraphRoute
@@ -106,7 +107,11 @@ fun RootNavScreen(
splashDestination()
authGraph(navController)
removePasswordDestination()
passwordResetGraph(navController)
resetPasswordDestination(
onNavigateToPreventAccountLockOut = {
navController.navigateToPreventAccountLockout()
},
)
trustedDeviceGraph(navController)
vaultUnlockDestination()
vaultUnlockedGraph(navController)
@@ -125,7 +130,7 @@ fun RootNavScreen(
RootNavState.ExpiredRegistrationLink,
-> AuthGraphRoute
RootNavState.ResetPassword -> ResetPasswordGraphRoute
RootNavState.ResetPassword -> ResetPasswordRoute
RootNavState.SetPassword -> SetPasswordRoute
RootNavState.RemovePassword -> RemovePasswordRoute
RootNavState.Splash -> SplashRoute
@@ -210,7 +215,7 @@ fun RootNavScreen(
RootNavState.RemovePassword -> navController.navigateToRemovePassword(rootNavOptions)
RootNavState.ResetPassword -> {
navController.navigateToResetPasswordGraph(rootNavOptions)
navController.navigateToResetPasswordScreen(rootNavOptions)
}
RootNavState.SetPassword -> navController.navigateToSetPassword(rootNavOptions)
@@ -349,7 +354,7 @@ private fun AnimatedContentTransitionScope<NavBackStackEntry>.toEnterTransition(
} else {
when (targetState.destination.rootLevelRoute()) {
MigrateToMyItemsGraphRoute.toObjectNavigationRoute(),
ResetPasswordGraphRoute.toObjectNavigationRoute(),
ResetPasswordRoute.toObjectNavigationRoute(),
-> RootTransitionProviders.Enter.slideUp
else -> when (initialState.destination.rootLevelRoute()) {
@@ -359,7 +364,7 @@ private fun AnimatedContentTransitionScope<NavBackStackEntry>.toEnterTransition(
// should stay but due to an issue when combining certain animations,
// we are just using a fadeIn instead.
MigrateToMyItemsGraphRoute.toObjectNavigationRoute(),
ResetPasswordGraphRoute.toObjectNavigationRoute(),
ResetPasswordRoute.toObjectNavigationRoute(),
-> RootTransitionProviders.Enter.fadeIn
else -> RootTransitionProviders.Enter.fadeIn
@@ -382,12 +387,12 @@ private fun AnimatedContentTransitionScope<NavBackStackEntry>.toExitTransition()
// Disable transitions when coming from the splash screen
SplashRoute.toObjectNavigationRoute() -> RootTransitionProviders.Exit.none
MigrateToMyItemsGraphRoute.toObjectNavigationRoute(),
ResetPasswordGraphRoute.toObjectNavigationRoute(),
ResetPasswordRoute.toObjectNavigationRoute(),
-> RootTransitionProviders.Exit.slideDown
else -> when (targetRoute) {
MigrateToMyItemsGraphRoute.toObjectNavigationRoute(),
ResetPasswordGraphRoute.toObjectNavigationRoute(),
ResetPasswordRoute.toObjectNavigationRoute(),
-> RootTransitionProviders.Exit.stay
else -> RootTransitionProviders.Exit.fadeOut

View File

@@ -208,7 +208,7 @@ class RootNavViewModel @Inject constructor(
SpecialCircumstance.AccountSecurityShortcut,
SpecialCircumstance.GeneratorShortcut,
is SpecialCircumstance.PremiumCheckout,
SpecialCircumstance.PremiumCheckoutResult,
SpecialCircumstance.VaultShortcut,
SpecialCircumstance.SendShortcut,
is SpecialCircumstance.SearchShortcut,
@@ -284,7 +284,7 @@ class RootNavViewModel @Inject constructor(
when (specialCircumstance) {
is SpecialCircumstance.AccountSecurityShortcut,
is SpecialCircumstance.GeneratorShortcut,
is SpecialCircumstance.PremiumCheckout,
is SpecialCircumstance.PremiumCheckoutResult,
is SpecialCircumstance.SearchShortcut,
is SpecialCircumstance.SendShortcut,
is SpecialCircumstance.ShareNewSend,

View File

@@ -33,7 +33,7 @@ import kotlinx.collections.immutable.toPersistentList
/**
* The contents state for the search screen.
*/
@Suppress("LongMethod", "CyclomaticComplexMethod")
@Suppress("LongMethod")
@Composable
fun SearchContent(
viewState: SearchState.ViewState.Content,
@@ -41,28 +41,43 @@ fun SearchContent(
searchType: SearchTypeData,
modifier: Modifier = Modifier,
) {
var overflowSpeedBumpAction: ListingItemOverflowAction? by rememberSaveable {
var showConfirmationDialog: ListingItemOverflowAction? by rememberSaveable {
mutableStateOf(null)
}
overflowSpeedBumpAction?.let { action ->
action
.speedBump
?.let { speedBump ->
BitwardenTwoButtonDialog(
twoButtonDialogData = speedBump,
onConfirmClick = {
overflowSpeedBumpAction = null
searchHandlers.onOverflowItemClick(action)
},
onDismissClick = { overflowSpeedBumpAction = null },
onDismissRequest = { overflowSpeedBumpAction = null },
)
}
?: run {
// If we somehow get here and there is no speed bump, then we should keep on going.
overflowSpeedBumpAction = null
searchHandlers.onOverflowItemClick(action)
}
when (val option = showConfirmationDialog) {
is ListingItemOverflowAction.SendAction.DeleteClick -> {
BitwardenTwoButtonDialog(
title = stringResource(id = BitwardenString.delete),
message = stringResource(id = BitwardenString.are_you_sure_delete_send),
confirmButtonText = stringResource(id = BitwardenString.yes),
dismissButtonText = stringResource(id = BitwardenString.cancel),
onConfirmClick = {
showConfirmationDialog = null
searchHandlers.onOverflowItemClick(option)
},
onDismissClick = { showConfirmationDialog = null },
onDismissRequest = { showConfirmationDialog = null },
)
}
is ListingItemOverflowAction.SendAction.CopyUrlClick,
is ListingItemOverflowAction.SendAction.EditClick,
is ListingItemOverflowAction.SendAction.RemovePasswordClick,
is ListingItemOverflowAction.SendAction.ShareUrlClick,
is ListingItemOverflowAction.SendAction.ViewClick,
is ListingItemOverflowAction.VaultAction.CopyNoteClick,
is ListingItemOverflowAction.VaultAction.CopyNumberClick,
is ListingItemOverflowAction.VaultAction.CopyPasswordClick,
is ListingItemOverflowAction.VaultAction.CopyTotpClick,
is ListingItemOverflowAction.VaultAction.CopySecurityCodeClick,
is ListingItemOverflowAction.VaultAction.CopyUsernameClick,
is ListingItemOverflowAction.VaultAction.EditClick,
is ListingItemOverflowAction.VaultAction.LaunchClick,
is ListingItemOverflowAction.VaultAction.ViewClick,
is ListingItemOverflowAction.VaultAction.ArchiveClick,
is ListingItemOverflowAction.VaultAction.UnarchiveClick,
null,
-> Unit
}
var autofillSelectionOptionsItem by rememberSaveable {
@@ -128,12 +143,8 @@ fun SearchContent(
contentDescription = option.contentDescription(),
onClick = {
when (option) {
is ListingItemOverflowAction.SendAction -> {
if (option.speedBump != null) {
overflowSpeedBumpAction = option
} else {
searchHandlers.onOverflowItemClick(option)
}
is ListingItemOverflowAction.SendAction.DeleteClick -> {
showConfirmationDialog = option
}
is ListingItemOverflowAction.VaultAction -> {
@@ -144,12 +155,12 @@ fun SearchContent(
MasterPasswordRepromptData.OverflowItem(
action = option,
)
} else if (option.speedBump != null) {
overflowSpeedBumpAction = option
} else {
searchHandlers.onOverflowItemClick(option)
}
}
else -> searchHandlers.onOverflowItemClick(option)
}
},
)

View File

@@ -1112,7 +1112,7 @@ data class SearchState(
*/
@Parcelize
data class Content(
val displayItems: ImmutableList<DisplayItem>,
val displayItems: List<DisplayItem>,
) : ViewState() {
override val hasVaultFilter: Boolean get() = true
}
@@ -1169,7 +1169,7 @@ data class SearchState(
) : DialogState()
/**
* Displays a dialog to the user indicating that archiving requires a Premium account.
* Displays a dialog to the user indicating that archiving requires a premium account.
*/
@Parcelize
data object ArchiveRequiresPremium : DialogState()
@@ -1188,9 +1188,9 @@ data class SearchState(
val totpCode: String?,
val iconData: IconData,
val extraIconList: ImmutableList<IconData>,
val overflowOptions: ImmutableList<ListingItemOverflowAction>,
val overflowOptions: List<ListingItemOverflowAction>,
val overflowTestTag: String?,
val autofillSelectionOptions: ImmutableList<AutofillSelectionOption>,
val autofillSelectionOptions: List<AutofillSelectionOption>,
val shouldDisplayMasterPasswordReprompt: Boolean,
val itemType: ItemType,
) : Parcelable {
@@ -1274,7 +1274,9 @@ sealed class SearchTypeData : Parcelable {
*/
data object Logins : Vault() {
override val title: Text
get() = BitwardenString.search_x.asText(BitwardenString.logins.asText())
get() = BitwardenString.search.asText()
.concat(" ".asText())
.concat(BitwardenString.logins.asText())
}
/**
@@ -1282,7 +1284,9 @@ sealed class SearchTypeData : Parcelable {
*/
data object Cards : Vault() {
override val title: Text
get() = BitwardenString.search_x.asText(BitwardenString.cards.asText())
get() = BitwardenString.search.asText()
.concat(" ".asText())
.concat(BitwardenString.cards.asText())
}
/**
@@ -1290,7 +1294,9 @@ sealed class SearchTypeData : Parcelable {
*/
data object Identities : Vault() {
override val title: Text
get() = BitwardenString.search_x.asText(BitwardenString.identities.asText())
get() = BitwardenString.search.asText()
.concat(" ".asText())
.concat(BitwardenString.identities.asText())
}
/**
@@ -1298,7 +1304,9 @@ sealed class SearchTypeData : Parcelable {
*/
data object SecureNotes : Vault() {
override val title: Text
get() = BitwardenString.search_x.asText(BitwardenString.secure_notes.asText())
get() = BitwardenString.search.asText()
.concat(" ".asText())
.concat(BitwardenString.secure_notes.asText())
}
/**
@@ -1306,7 +1314,9 @@ sealed class SearchTypeData : Parcelable {
*/
data object SshKeys : Vault() {
override val title: Text
get() = BitwardenString.search_x.asText(BitwardenString.ssh_keys.asText())
get() = BitwardenString.search.asText()
.concat(" ".asText())
.concat(BitwardenString.ssh_keys.asText())
}
/**
@@ -1317,7 +1327,9 @@ sealed class SearchTypeData : Parcelable {
val collectionName: String = "",
) : Vault() {
override val title: Text
get() = BitwardenString.search_x.asText(collectionName.asText())
get() = BitwardenString.search.asText()
.concat(" ".asText())
.concat(collectionName.asText())
}
/**
@@ -1325,7 +1337,9 @@ sealed class SearchTypeData : Parcelable {
*/
data object NoFolder : Vault() {
override val title: Text
get() = BitwardenString.search_x.asText(BitwardenString.folder_none.asText())
get() = BitwardenString.search.asText()
.concat(" ".asText())
.concat(BitwardenString.folder_none.asText())
}
/**
@@ -1336,7 +1350,9 @@ sealed class SearchTypeData : Parcelable {
val folderName: String = "",
) : Vault() {
override val title: Text
get() = BitwardenString.search_x.asText(folderName.asText())
get() = BitwardenString.search.asText()
.concat(" ".asText())
.concat(folderName.asText())
}
/**
@@ -1344,7 +1360,9 @@ sealed class SearchTypeData : Parcelable {
*/
data object Trash : Vault() {
override val title: Text
get() = BitwardenString.search_x.asText(BitwardenString.trash.asText())
get() = BitwardenString.search.asText()
.concat(" ".asText())
.concat(BitwardenString.trash.asText())
}
/**
@@ -1352,7 +1370,9 @@ sealed class SearchTypeData : Parcelable {
*/
data object VerificationCodes : Vault() {
override val title: Text
get() = BitwardenString.search_x.asText(BitwardenString.verification_codes.asText())
get() = BitwardenString.search.asText()
.concat(" ".asText())
.concat(BitwardenString.verification_codes.asText())
}
}
}
@@ -1423,7 +1443,7 @@ sealed class SearchAction {
) : SearchAction()
/**
* User clicked the upgrade to Premium button.
* User clicked the upgrade to premium button.
*/
data object UpgradeToPremiumClick : SearchAction()

View File

@@ -20,7 +20,6 @@ import com.bitwarden.vault.CipherView
import com.bitwarden.vault.FolderView
import com.x8bit.bitwarden.data.autofill.util.isActiveWithFido2Credentials
import com.x8bit.bitwarden.data.autofill.util.login
import com.x8bit.bitwarden.data.platform.util.isActive
import com.x8bit.bitwarden.ui.platform.feature.search.SearchState
import com.x8bit.bitwarden.ui.platform.feature.search.SearchTypeData
import com.x8bit.bitwarden.ui.platform.feature.search.model.AutofillSelectionOption
@@ -30,9 +29,6 @@ import com.x8bit.bitwarden.ui.vault.feature.util.toLabelIcons
import com.x8bit.bitwarden.ui.vault.feature.util.toOverflowActions
import com.x8bit.bitwarden.ui.vault.feature.vault.util.toLoginIconData
import com.x8bit.bitwarden.ui.vault.util.toSdkCipherType
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
import java.time.Clock
import java.time.format.FormatStyle
@@ -112,20 +108,46 @@ private fun CipherListView.filterBySearchType(
searchTypeData: SearchTypeData.Vault,
): Boolean =
when (searchTypeData) {
SearchTypeData.Vault.All -> isActive
SearchTypeData.Vault.All -> deletedDate == null && archivedDate == null
SearchTypeData.Vault.Archive -> archivedDate != null && deletedDate == null
is SearchTypeData.Vault.Cards -> type is CipherListViewType.Card && isActive
is SearchTypeData.Vault.Collection -> {
searchTypeData.collectionId in this.collectionIds && isActive
is SearchTypeData.Vault.Cards -> {
type is CipherListViewType.Card && deletedDate == null && archivedDate == null
}
is SearchTypeData.Vault.Collection -> {
searchTypeData.collectionId in this.collectionIds &&
deletedDate == null &&
archivedDate == null
}
is SearchTypeData.Vault.Folder -> {
folderId == searchTypeData.folderId && deletedDate == null && archivedDate == null
}
SearchTypeData.Vault.NoFolder -> {
folderId == null && deletedDate == null && archivedDate == null
}
is SearchTypeData.Vault.Identities -> {
type is CipherListViewType.Identity && deletedDate == null && archivedDate == null
}
is SearchTypeData.Vault.Logins -> {
type is CipherListViewType.Login && deletedDate == null && archivedDate == null
}
is SearchTypeData.Vault.SecureNotes -> {
type is CipherListViewType.SecureNote && deletedDate == null && archivedDate == null
}
is SearchTypeData.Vault.SshKeys -> {
type is CipherListViewType.SshKey && deletedDate == null && archivedDate == null
}
is SearchTypeData.Vault.VerificationCodes -> {
login?.totp != null && deletedDate == null && archivedDate == null
}
is SearchTypeData.Vault.Folder -> folderId == searchTypeData.folderId && isActive
SearchTypeData.Vault.NoFolder -> folderId == null && isActive
is SearchTypeData.Vault.Identities -> type is CipherListViewType.Identity && isActive
is SearchTypeData.Vault.Logins -> type is CipherListViewType.Login && isActive
is SearchTypeData.Vault.SecureNotes -> type is CipherListViewType.SecureNote && isActive
is SearchTypeData.Vault.SshKeys -> type is CipherListViewType.SshKey && isActive
is SearchTypeData.Vault.VerificationCodes -> login?.totp != null && isActive
is SearchTypeData.Vault.Trash -> deletedDate != null
}
@@ -177,7 +199,8 @@ fun List<CipherListView>.toViewState(
isAutofill = isAutofill,
isPremiumUser = isPremiumUser,
isArchiveEnabled = isArchiveEnabled,
),
)
.sortAlphabetically(),
)
}
@@ -196,20 +219,17 @@ private fun List<CipherListView>.toDisplayItemList(
isAutofill: Boolean,
isPremiumUser: Boolean,
isArchiveEnabled: Boolean,
): ImmutableList<SearchState.DisplayItem> =
this
.map {
it.toDisplayItem(
baseIconUrl = baseIconUrl,
hasMasterPassword = hasMasterPassword,
isIconLoadingDisabled = isIconLoadingDisabled,
isAutofill = isAutofill,
isPremiumUser = isPremiumUser,
isArchiveEnabled = isArchiveEnabled,
)
}
.sortAlphabetically()
.toImmutableList()
): List<SearchState.DisplayItem> =
this.map {
it.toDisplayItem(
baseIconUrl = baseIconUrl,
hasMasterPassword = hasMasterPassword,
isIconLoadingDisabled = isIconLoadingDisabled,
isAutofill = isAutofill,
isPremiumUser = isPremiumUser,
isArchiveEnabled = isArchiveEnabled,
)
}
@Suppress("LongParameterList")
private fun CipherListView.toDisplayItem(
@@ -243,8 +263,9 @@ private fun CipherListView.toDisplayItem(
// Only valid for autofill
.filter { isAutofill }
// Only Login types get the save option
.filter { this.login != null || (it != AutofillSelectionOption.AUTOFILL_AND_SAVE) }
.toImmutableList(),
.filter {
this.login != null || (it != AutofillSelectionOption.AUTOFILL_AND_SAVE)
},
shouldDisplayMasterPasswordReprompt = hasMasterPassword &&
reprompt == CipherRepromptType.PASSWORD,
itemType = SearchState.DisplayItem.ItemType.Vault(type = this.type.toSdkCipherType()),
@@ -343,7 +364,8 @@ fun List<SendView>.toViewState(
displayItems = toDisplayItemList(
baseWebSendUrl = baseWebSendUrl,
clock = clock,
),
)
.sortAlphabetically(),
)
}
@@ -357,16 +379,13 @@ fun List<SendView>.toViewState(
private fun List<SendView>.toDisplayItemList(
baseWebSendUrl: String,
clock: Clock,
): ImmutableList<SearchState.DisplayItem> =
this
.map {
it.toDisplayItem(
baseWebSendUrl = baseWebSendUrl,
clock = clock,
)
}
.sortAlphabetically()
.toImmutableList()
): List<SearchState.DisplayItem> =
this.map {
it.toDisplayItem(
baseWebSendUrl = baseWebSendUrl,
clock = clock,
)
}
private fun SendView.toDisplayItem(
baseWebSendUrl: String,
@@ -392,7 +411,7 @@ private fun SendView.toDisplayItem(
overflowOptions = toOverflowActions(baseWebSendUrl = baseWebSendUrl),
overflowTestTag = "SendOptionsButton",
totpCode = null,
autofillSelectionOptions = persistentListOf(),
autofillSelectionOptions = emptyList(),
shouldDisplayMasterPasswordReprompt = false,
itemType = SearchState.DisplayItem.ItemType.Sends(type = this.type),
)

View File

@@ -32,8 +32,6 @@ import com.x8bit.bitwarden.ui.platform.feature.settings.other.navigateToOther
import com.x8bit.bitwarden.ui.platform.feature.settings.other.otherDestination
import com.x8bit.bitwarden.ui.platform.feature.settings.vault.navigateToVaultSettings
import com.x8bit.bitwarden.ui.platform.feature.settings.vault.vaultSettingsDestination
import com.x8bit.bitwarden.ui.platform.feature.premium.plan.navigateToPlan
import com.x8bit.bitwarden.ui.platform.feature.premium.plan.planDestination
import com.x8bit.bitwarden.ui.vault.feature.importitems.importItemsDestination
import kotlinx.parcelize.Parcelize
import kotlinx.serialization.Serializable
@@ -105,7 +103,7 @@ fun SavedStateHandle.toSettingsArgs(): SettingsArgs {
/**
* Add settings destinations to the nav graph.
*/
@Suppress("LongMethod", "LongParameterList")
@Suppress("LongParameterList")
fun NavGraphBuilder.settingsGraph(
navController: NavController,
onNavigateToDeleteAccount: () -> Unit,
@@ -133,7 +131,6 @@ fun NavGraphBuilder.settingsGraph(
onNavigateToAutoFill = { navController.navigateToAutoFill() },
onNavigateToOther = { navController.navigateToOther(isPreAuth = false) },
onNavigateToVault = { navController.navigateToVaultSettings() },
onNavigateToPlan = { navController.navigateToPlan() },
)
}
aboutDestination(
@@ -177,7 +174,6 @@ fun NavGraphBuilder.settingsGraph(
)
blockAutoFillDestination(onNavigateBack = { navController.popBackStack() })
privilegedAppsListDestination(onNavigateBack = { navController.popBackStack() })
planDestination(onNavigateBack = { navController.popBackStack() })
}
}
@@ -196,7 +192,6 @@ fun NavGraphBuilder.preAuthSettingsDestinations(
onNavigateToAccountSecurity = { /* no-op */ },
onNavigateToAutoFill = { /* no-op */ },
onNavigateToVault = { /* no-op */ },
onNavigateToPlan = { /* no-op */ },
)
}
appearanceDestination(

View File

@@ -46,7 +46,6 @@ fun SettingsScreen(
onNavigateToAutoFill: () -> Unit,
onNavigateToOther: () -> Unit,
onNavigateToVault: () -> Unit,
onNavigateToPlan: () -> Unit,
viewModel: SettingsViewModel = hiltViewModel(),
) {
val state by viewModel.stateFlow.collectAsStateWithLifecycle()
@@ -54,13 +53,12 @@ fun SettingsScreen(
when (event) {
SettingsEvent.NavigateBack -> onNavigateBack()
SettingsEvent.NavigateAbout -> onNavigateToAbout()
SettingsEvent.NavigateAccountSecurity -> onNavigateToAccountSecurity()
SettingsEvent.NavigateAccountSecurity -> onNavigateToAccountSecurity.invoke()
SettingsEvent.NavigateAppearance -> onNavigateToAppearance()
SettingsEvent.NavigateAutoFill -> onNavigateToAutoFill()
SettingsEvent.NavigateOther -> onNavigateToOther()
SettingsEvent.NavigateVault -> onNavigateToVault()
SettingsEvent.NavigateAccountSecurityShortcut -> onNavigateToAccountSecurity()
SettingsEvent.NavigatePlan -> onNavigateToPlan()
}
}

View File

@@ -4,14 +4,12 @@ import androidx.annotation.DrawableRes
import androidx.compose.material3.Text
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.viewModelScope
import com.bitwarden.core.data.manager.model.FlagKey
import com.bitwarden.ui.platform.base.BaseViewModel
import com.bitwarden.ui.platform.base.DeferredBackgroundEvent
import com.bitwarden.ui.platform.resource.BitwardenDrawable
import com.bitwarden.ui.platform.resource.BitwardenString
import com.bitwarden.ui.util.Text
import com.bitwarden.ui.util.asText
import com.x8bit.bitwarden.data.platform.manager.FeatureFlagManager
import com.x8bit.bitwarden.data.platform.manager.FirstTimeActionManager
import com.x8bit.bitwarden.data.platform.manager.SpecialCircumstanceManager
import com.x8bit.bitwarden.data.platform.manager.model.SpecialCircumstance
@@ -20,7 +18,6 @@ import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.update
import javax.inject.Inject
@@ -32,7 +29,6 @@ import javax.inject.Inject
class SettingsViewModel @Inject constructor(
specialCircumstanceManager: SpecialCircumstanceManager,
firstTimeActionManager: FirstTimeActionManager,
featureFlagManager: FeatureFlagManager,
savedStateHandle: SavedStateHandle,
) : BaseViewModel<SettingsState, SettingsEvent, SettingsAction>(
initialState = SettingsState(
@@ -40,8 +36,6 @@ class SettingsViewModel @Inject constructor(
securityCount = firstTimeActionManager.allSecuritySettingsBadgeCountFlow.value,
autoFillCount = firstTimeActionManager.allAutofillSettingsBadgeCountFlow.value,
vaultCount = firstTimeActionManager.allVaultSettingsBadgeCountFlow.value,
isMobilePremiumUpgradeEnabled = featureFlagManager
.getFeatureFlag(FlagKey.MobilePremiumUpgrade),
),
) {
@@ -60,16 +54,6 @@ class SettingsViewModel @Inject constructor(
.onEach(::sendAction)
.launchIn(viewModelScope)
featureFlagManager
.getFeatureFlagFlow(FlagKey.MobilePremiumUpgrade)
.map {
SettingsAction.Internal.MobilePremiumUpgradeFlagUpdate(
isMobilePremiumUpgradeEnabled = it,
)
}
.onEach(::sendAction)
.launchIn(viewModelScope)
when (specialCircumstanceManager.specialCircumstance) {
SpecialCircumstance.AccountSecurityShortcut -> {
sendEvent(SettingsEvent.NavigateAccountSecurityShortcut)
@@ -82,14 +66,10 @@ class SettingsViewModel @Inject constructor(
override fun handleAction(action: SettingsAction): Unit = when (action) {
is SettingsAction.CloseClick -> handleCloseClick()
is SettingsAction.SettingsClick -> handleSettingsClick(action)
is SettingsAction.SettingsClick -> handleAccountSecurityClick(action)
is SettingsAction.Internal.SettingsNotificationCountUpdate -> {
handleSettingsNotificationCountUpdate(action)
}
is SettingsAction.Internal.MobilePremiumUpgradeFlagUpdate -> {
handleMobilePremiumUpgradeFlagUpdate(action)
}
}
private fun handleCloseClick() {
@@ -108,18 +88,7 @@ class SettingsViewModel @Inject constructor(
}
}
private fun handleMobilePremiumUpgradeFlagUpdate(
action: SettingsAction.Internal.MobilePremiumUpgradeFlagUpdate,
) {
mutableStateFlow.update {
it.copy(
isMobilePremiumUpgradeEnabled =
action.isMobilePremiumUpgradeEnabled,
)
}
}
private fun handleSettingsClick(action: SettingsAction.SettingsClick) {
private fun handleAccountSecurityClick(action: SettingsAction.SettingsClick) {
when (action.settings) {
Settings.ACCOUNT_SECURITY -> {
sendEvent(SettingsEvent.NavigateAccountSecurity)
@@ -137,10 +106,6 @@ class SettingsViewModel @Inject constructor(
sendEvent(SettingsEvent.NavigateAppearance)
}
Settings.PLAN -> {
sendEvent(SettingsEvent.NavigatePlan)
}
Settings.OTHER -> {
sendEvent(SettingsEvent.NavigateOther)
}
@@ -160,18 +125,8 @@ data class SettingsState(
private val autoFillCount: Int,
private val securityCount: Int,
private val vaultCount: Int,
private val isMobilePremiumUpgradeEnabled: Boolean = false,
) {
val shouldShowCloseButton: Boolean = isPreAuth
/**
* Whether the plan row should be shown. The row is visible when the
* mobile premium upgrade feature flag is enabled and the user is
* authenticated.
*/
private val shouldShowPlanRow: Boolean =
!isPreAuth && isMobilePremiumUpgradeEnabled
val settingRows: ImmutableList<Settings> = Settings
.entries
.filter { setting ->
@@ -180,7 +135,6 @@ data class SettingsState(
Settings.AUTO_FILL -> !isPreAuth
Settings.VAULT -> !isPreAuth
Settings.APPEARANCE -> true
Settings.PLAN -> shouldShowPlanRow
Settings.OTHER -> true
Settings.ABOUT -> true
}
@@ -214,7 +168,7 @@ sealed class SettingsEvent {
data object NavigateAccountSecurity : SettingsEvent()
/**
* Navigate to the account security screen via shortcut.
* Navigate to the account security screen.
*/
data object NavigateAccountSecurityShortcut : SettingsEvent(), DeferredBackgroundEvent
@@ -237,11 +191,6 @@ sealed class SettingsEvent {
* Navigate to the vault screen.
*/
data object NavigateVault : SettingsEvent()
/**
* Navigate to the plan screen.
*/
data object NavigatePlan : SettingsEvent()
}
/**
@@ -249,7 +198,7 @@ sealed class SettingsEvent {
*/
sealed class SettingsAction {
/**
* The user has clicked the close button.
* THe user has clicked the close button
*/
data object CloseClick : SettingsAction()
@@ -272,13 +221,6 @@ sealed class SettingsAction {
val securityCount: Int,
val vaultCount: Int,
) : Internal()
/**
* Update the mobile premium upgrade feature flag state.
*/
data class MobilePremiumUpgradeFlagUpdate(
val isMobilePremiumUpgradeEnabled: Boolean,
) : Internal()
}
}
@@ -313,11 +255,6 @@ enum class Settings(
vectorIconRes = BitwardenDrawable.ic_paintbrush,
testTag = "AppearanceSettingsButton",
),
PLAN(
text = BitwardenString.plan.asText(),
vectorIconRes = BitwardenDrawable.ic_plan,
testTag = "PlanSettingsButton",
),
OTHER(
text = BitwardenString.other.asText(),
vectorIconRes = BitwardenDrawable.ic_filter,

View File

@@ -9,7 +9,6 @@ import com.bitwarden.data.repository.ServerConfigRepository
import com.bitwarden.data.repository.util.baseWebVaultUrlOrDefault
import com.bitwarden.core.data.manager.util.deviceData
import com.bitwarden.ui.platform.base.BaseViewModel
import com.bitwarden.ui.platform.resource.BitwardenString
import com.bitwarden.ui.util.Text
import com.bitwarden.ui.util.asText
import com.bitwarden.ui.util.concat
@@ -48,7 +47,7 @@ class AboutViewModel @Inject constructor(
initialState = savedStateHandle[KEY_STATE] ?: run {
val serverData = serverConfigRepository.serverConfigStateFlow.value?.serverData
AboutState(
version = BitwardenString.version_x.asText(buildInfoManager.versionData),
version = "Version: ${buildInfoManager.versionData}".asText(),
sdkVersion = "\uD83E\uDD80 SDK: ${buildInfoManager.sdkData}".asText(),
serverData = StringBuilder()
.append("\uD83C\uDF29 Server:")

View File

@@ -42,6 +42,7 @@ fun AddEditBlockedUriDialog(
onUriChange: (String) -> Unit,
onCancelClick: () -> Unit,
onSaveClick: (String) -> Unit,
onDeleteClick: (() -> Unit)? = null,
onDismissRequest: () -> Unit,
) {
Dialog(
@@ -65,13 +66,7 @@ fun AddEditBlockedUriDialog(
modifier = Modifier
.padding(top = 24.dp, start = 24.dp, end = 24.dp)
.fillMaxWidth(),
text = stringResource(
id = if (isEdit) {
BitwardenString.edit_blocked_uri
} else {
BitwardenString.new_blocked_uri
},
),
text = stringResource(id = BitwardenString.new_uri),
color = BitwardenTheme.colorScheme.text.primary,
style = BitwardenTheme.typography.headlineSmall,
)
@@ -109,6 +104,13 @@ fun AddEditBlockedUriDialog(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(start = 8.dp, top = 24.dp, bottom = 24.dp, end = 24.dp),
) {
if (isEdit && onDeleteClick != null) {
BitwardenTextButton(
label = stringResource(id = BitwardenString.remove),
onClick = onDeleteClick,
)
}
BitwardenTextButton(
label = stringResource(id = BitwardenString.cancel),
onClick = onCancelClick,

View File

@@ -4,6 +4,8 @@ import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.scaleIn
import androidx.compose.animation.scaleOut
import androidx.compose.foundation.Image
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
@@ -15,17 +17,17 @@ 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.wrapContentWidth
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
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.rememberTopAppBarState
import androidx.compose.material3.ripple
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.nestedscroll.nestedScroll
@@ -36,24 +38,18 @@ import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.bitwarden.ui.platform.base.util.EventsEffect
import com.bitwarden.ui.platform.base.util.cardStyle
import com.bitwarden.ui.platform.base.util.standardHorizontalMargin
import com.bitwarden.ui.platform.base.util.toListItemCardStyle
import com.bitwarden.ui.platform.base.util.bottomDivider
import com.bitwarden.ui.platform.components.appbar.BitwardenTopAppBar
import com.bitwarden.ui.platform.components.appbar.action.BitwardenOverflowActionItem
import com.bitwarden.ui.platform.components.appbar.model.OverflowMenuItemData
import com.bitwarden.ui.platform.components.button.BitwardenFilledButton
import com.bitwarden.ui.platform.components.button.BitwardenOutlinedButton
import com.bitwarden.ui.platform.components.fab.BitwardenFloatingActionButton
import com.bitwarden.ui.platform.components.model.CardStyle
import com.bitwarden.ui.platform.components.scaffold.BitwardenScaffold
import com.bitwarden.ui.platform.components.util.rememberVectorPainter
import com.bitwarden.ui.platform.resource.BitwardenDrawable
import com.bitwarden.ui.platform.resource.BitwardenString
import com.bitwarden.ui.platform.theme.BitwardenTheme
import kotlinx.collections.immutable.persistentListOf
/**
* Displays the block autofill screen.
* Displays the block auto-fill screen.
*/
@Suppress("LongMethod")
@OptIn(ExperimentalMaterial3Api::class)
@@ -78,6 +74,7 @@ fun BlockAutoFillScreen(
BlockAutoFillAction.SaveUri(newUri = newUri, originalUri = originalUri),
)
},
onRemoveClick = { viewModel.trySendAction(BlockAutoFillAction.RemoveUriClick(it)) },
onDismissRequest = { viewModel.trySendAction(BlockAutoFillAction.DismissDialog) },
)
@@ -110,94 +107,84 @@ fun BlockAutoFillScreen(
}
},
) {
when (val viewState = state.viewState) {
is BlockAutoFillState.ViewState.Content -> {
BlockedAutofillContent(
viewState = viewState,
onEditUriClick = {
viewModel.trySendAction(BlockAutoFillAction.EditUriClick(it))
},
onRemoveClick = {
viewModel.trySendAction(BlockAutoFillAction.RemoveUriClick(it))
},
modifier = Modifier.fillMaxSize(),
)
LazyColumn(
modifier = Modifier.fillMaxSize(),
) {
when (val viewState = state.viewState) {
is BlockAutoFillState.ViewState.Content -> {
item {
Row(
modifier = Modifier
.padding(vertical = 8.dp, horizontal = 20.dp)
.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center,
) {
Text(
text = stringResource(
id = BitwardenString
.auto_fill_will_not_be_offered_for_these_ur_is,
),
color = BitwardenTheme.colorScheme.text.primary,
style = BitwardenTheme.typography.bodyMedium,
modifier = Modifier.align(Alignment.CenterVertically),
)
}
}
items(viewState.blockedUris, key = { it }) { uri ->
BlockAutoFillListItem(
label = uri,
onClick = {
viewModel.trySendAction(BlockAutoFillAction.EditUriClick(uri))
},
modifier = Modifier
.padding(horizontal = 16.dp)
.fillMaxWidth(),
)
}
}
is BlockAutoFillState.ViewState.Empty -> {
item {
BlockAutoFillNoItems(
addItemClickAction = {
viewModel.trySendAction(BlockAutoFillAction.AddUriClick)
},
modifier = Modifier.fillMaxSize(),
)
}
}
}
BlockAutoFillState.ViewState.Empty -> {
BlockAutoFillNoItems(
addItemClickAction = {
viewModel.trySendAction(BlockAutoFillAction.AddUriClick)
},
modifier = Modifier.fillMaxSize(),
)
item {
Spacer(modifier = Modifier.height(height = 16.dp))
Spacer(modifier = Modifier.navigationBarsPadding())
}
}
}
}
@Composable
private fun BlockedAutofillContent(
viewState: BlockAutoFillState.ViewState.Content,
onEditUriClick: (String) -> Unit,
onRemoveClick: (String) -> Unit,
modifier: Modifier = Modifier,
) {
LazyColumn(modifier = modifier) {
item {
Spacer(modifier = Modifier.height(height = 24.dp))
Text(
text = stringResource(
id = BitwardenString.auto_fill_will_not_be_offered_for_these_ur_is,
),
textAlign = TextAlign.Center,
color = BitwardenTheme.colorScheme.text.secondary,
style = BitwardenTheme.typography.bodyMedium,
modifier = Modifier
.fillMaxWidth()
.standardHorizontalMargin()
.animateItem(),
)
Spacer(modifier = Modifier.height(height = 24.dp))
}
itemsIndexed(
items = viewState.blockedUris,
key = { _, uri -> uri },
) { index, uri ->
BlockAutoFillListItem(
label = uri,
onDeleteClick = { onRemoveClick(uri) },
onEditClick = { onEditUriClick(uri) },
cardStyle = viewState.blockedUris.toListItemCardStyle(index = index),
modifier = Modifier
.standardHorizontalMargin()
.fillMaxWidth()
.animateItem(),
)
}
item {
Spacer(modifier = Modifier.height(height = 16.dp))
Spacer(modifier = Modifier.navigationBarsPadding())
}
}
}
@Composable
private fun BlockAutoFillDialogs(
dialogState: BlockAutoFillState.DialogState? = null,
onUriTextChange: (String) -> Unit,
onSaveClick: (String, String?) -> Unit,
onRemoveClick: (String) -> Unit,
onDismissRequest: () -> Unit,
) {
when (dialogState) {
is BlockAutoFillState.DialogState.AddEdit -> {
AddEditBlockedUriDialog(
uri = dialogState.uri,
isEdit = dialogState.isEdit,
isEdit = dialogState.originalUri != null,
errorMessage = dialogState.errorMessage?.invoke(),
onUriChange = onUriTextChange,
onDismissRequest = onDismissRequest,
onDeleteClick = if (dialogState.isEdit) {
{ dialogState.originalUri?.let { onRemoveClick(it) } }
} else {
null
},
onCancelClick = onDismissRequest,
onSaveClick = { newUri -> onSaveClick(newUri, dialogState.originalUri) },
)
@@ -216,7 +203,7 @@ private fun BlockAutoFillNoItems(
modifier: Modifier = Modifier,
) {
Column(
modifier = modifier.verticalScroll(state = rememberScrollState()),
modifier = modifier,
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
) {
@@ -230,87 +217,66 @@ private fun BlockAutoFillNoItems(
.size(size = 124.dp)
.align(Alignment.CenterHorizontally),
)
Spacer(modifier = Modifier.height(height = 24.dp))
Spacer(modifier = Modifier.height(32.dp))
Text(
textAlign = TextAlign.Center,
text = stringResource(id = BitwardenString.no_uris_blocked),
style = BitwardenTheme.typography.titleMedium,
color = BitwardenTheme.colorScheme.text.primary,
modifier = Modifier
.fillMaxWidth()
.standardHorizontalMargin(),
)
Spacer(modifier = Modifier.height(height = 12.dp))
Text(
textAlign = TextAlign.Center,
.padding(horizontal = 16.dp),
text = stringResource(
id = BitwardenString.auto_fill_will_not_be_offered_for_these_ur_is,
),
style = BitwardenTheme.typography.bodyMedium,
color = BitwardenTheme.colorScheme.text.primary,
modifier = Modifier
.fillMaxWidth()
.standardHorizontalMargin(),
)
Spacer(modifier = Modifier.height(24.dp))
BitwardenFilledButton(
BitwardenOutlinedButton(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
label = stringResource(id = BitwardenString.new_blocked_uri),
onClick = addItemClickAction,
icon = rememberVectorPainter(id = BitwardenDrawable.ic_plus_small),
modifier = Modifier
.wrapContentWidth()
.standardHorizontalMargin(),
)
Spacer(modifier = Modifier.height(height = 24.dp))
Spacer(modifier = Modifier.navigationBarsPadding())
}
}
@Composable
private fun BlockAutoFillListItem(
label: String,
onEditClick: () -> Unit,
onDeleteClick: () -> Unit,
cardStyle: CardStyle,
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
Row(
modifier = Modifier
.defaultMinSize(minHeight = 60.dp)
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = ripple(
color = BitwardenTheme.colorScheme.background.pressed,
),
onClick = onClick,
)
.bottomDivider(paddingStart = 16.dp)
.padding(end = 8.dp, top = 16.dp, bottom = 16.dp)
.then(modifier),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
modifier = modifier
.defaultMinSize(minHeight = 60.dp)
.cardStyle(
cardStyle = cardStyle,
paddingStart = 16.dp,
paddingEnd = 4.dp,
),
) {
Text(
modifier = Modifier
.padding(end = 16.dp)
.weight(1f),
text = label,
style = BitwardenTheme.typography.bodyLarge,
color = BitwardenTheme.colorScheme.text.primary,
modifier = Modifier
.padding(end = 16.dp)
.weight(weight = 1f),
)
BitwardenOverflowActionItem(
menuItemDataList = persistentListOf(
OverflowMenuItemData(
text = stringResource(id = BitwardenString.edit),
onClick = onEditClick,
),
OverflowMenuItemData(
text = stringResource(id = BitwardenString.delete),
onClick = onDeleteClick,
),
),
vectorIconRes = BitwardenDrawable.ic_ellipsis_horizontal,
testTag = "Options",
Icon(
painter = rememberVectorPainter(id = BitwardenDrawable.ic_pencil_square),
contentDescription = null,
tint = BitwardenTheme.colorScheme.icon.primary,
modifier = Modifier.size(24.dp),
)
}
}

View File

@@ -10,9 +10,6 @@ import com.x8bit.bitwarden.data.platform.repository.SettingsRepository
import com.x8bit.bitwarden.ui.platform.feature.settings.autofill.blockautofill.util.isValidPattern
import com.x8bit.bitwarden.ui.platform.feature.settings.autofill.blockautofill.util.validateUri
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.flow.update
import kotlinx.parcelize.Parcelize
import javax.inject.Inject
@@ -43,9 +40,7 @@ class BlockAutoFillViewModel @Inject constructor(
mutableStateFlow.update { currentState ->
if (uris.isNotEmpty()) {
currentState.copy(
viewState = BlockAutoFillState.ViewState.Content(
blockedUris = uris.distinct().toImmutableList(),
),
viewState = BlockAutoFillState.ViewState.Content(uris.map { it }),
)
} else {
currentState.copy(
@@ -168,7 +163,7 @@ class BlockAutoFillViewModel @Inject constructor(
}
/**
* Represents the state for block autofill.
* Represents the state for block auto fill.
*
* @property viewState indicates what view state the screen is in.
*/
@@ -208,7 +203,7 @@ data class BlockAutoFillState(
*/
@Parcelize
data class Content(
val blockedUris: ImmutableList<String> = persistentListOf(),
val blockedUris: List<String> = emptyList(),
) : ViewState()
/**

View File

@@ -23,7 +23,6 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
@@ -273,8 +272,7 @@ private fun LogRow(
paddingBottom = 12.dp,
paddingStart = 16.dp,
paddingEnd = 4.dp,
)
.testTag("LogRow"),
),
) {
Column(modifier = Modifier.weight(weight = 1f)) {
Text(
@@ -283,9 +281,7 @@ private fun LogRow(
color = BitwardenTheme.colorScheme.text.primary,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier
.fillMaxWidth()
.testTag("LogNameLabel"),
modifier = Modifier.fillMaxWidth(),
)
Spacer(modifier = Modifier.height(height = 2.dp))
Row(verticalAlignment = Alignment.CenterVertically) {
@@ -295,9 +291,7 @@ private fun LogRow(
color = BitwardenTheme.colorScheme.text.secondary,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier
.weight(weight = 1f)
.testTag("LogSizeLabel"),
modifier = Modifier.weight(weight = 1f),
)
displayableItem.subtextEnd?.let {
Spacer(modifier = Modifier.width(width = 4.dp))
@@ -308,9 +302,7 @@ private fun LogRow(
maxLines = 1,
overflow = TextOverflow.Ellipsis,
textAlign = TextAlign.End,
modifier = Modifier
.weight(weight = 1f)
.testTag("LogExpirationLabel"),
modifier = Modifier.weight(weight = 1f),
)
}
}

View File

@@ -1,13 +1,14 @@
package com.x8bit.bitwarden.ui.platform.feature.settings.vault
import androidx.lifecycle.viewModelScope
import com.bitwarden.core.data.manager.BuildInfoManager
import com.bitwarden.core.data.manager.model.FlagKey
import com.bitwarden.network.model.PolicyTypeJson
import com.bitwarden.ui.platform.base.BackgroundEvent
import com.bitwarden.ui.platform.base.BaseViewModel
import com.bitwarden.ui.platform.components.snackbar.model.BitwardenSnackbarData
import com.bitwarden.ui.platform.manager.snackbar.SnackbarRelayManager
import com.x8bit.bitwarden.data.platform.manager.GmsManager
import com.x8bit.bitwarden.data.platform.manager.MINIMUM_CXP_GMS_VERSION
import com.x8bit.bitwarden.data.platform.manager.FeatureFlagManager
import com.x8bit.bitwarden.data.platform.manager.FirstTimeActionManager
import com.x8bit.bitwarden.data.platform.manager.PolicyManager
@@ -27,19 +28,18 @@ import javax.inject.Inject
@HiltViewModel
class VaultSettingsViewModel @Inject constructor(
snackbarRelayManager: SnackbarRelayManager<SnackbarRelay>,
private val buildInfoManager: BuildInfoManager,
private val firstTimeActionManager: FirstTimeActionManager,
private val featureFlagManager: FeatureFlagManager,
private val gmsManager: GmsManager,
private val policyManager: PolicyManager,
) : BaseViewModel<VaultSettingsState, VaultSettingsEvent, VaultSettingsAction>(
initialState = run {
val firstTimeState = firstTimeActionManager.currentOrDefaultUserFirstTimeState
VaultSettingsState(
showImportActionCard = firstTimeState.showImportLoginsCardInSettings,
showImportItemsChevron = !buildInfoManager.isFdroid &&
featureFlagManager.getFeatureFlag(
key = FlagKey.CredentialExchangeProtocolImport,
),
showImportItemsChevron = featureFlagManager.getFeatureFlag(
key = FlagKey.CredentialExchangeProtocolImport,
) && gmsManager.isVersionAtLeast(MINIMUM_CXP_GMS_VERSION),
)
},
) {
@@ -67,8 +67,8 @@ class VaultSettingsViewModel @Inject constructor(
) { isEnabled, policies ->
VaultSettingsAction.Internal.CredentialExchangeAvailabilityChanged(
isEnabled = isEnabled &&
!buildInfoManager.isFdroid &&
policies.isEmpty(),
policies.isEmpty() &&
gmsManager.isVersionAtLeast(MINIMUM_CXP_GMS_VERSION),
)
}
.onEach(::sendAction)
@@ -143,9 +143,9 @@ class VaultSettingsViewModel @Inject constructor(
}
private fun handleImportItemsClicked() {
if (!buildInfoManager.isFdroid &&
featureFlagManager.getFeatureFlag(FlagKey.CredentialExchangeProtocolImport) &&
policyManager.getActivePolicies(PolicyTypeJson.PERSONAL_OWNERSHIP).isEmpty()
if (featureFlagManager.getFeatureFlag(FlagKey.CredentialExchangeProtocolImport) &&
policyManager.getActivePolicies(PolicyTypeJson.PERSONAL_OWNERSHIP).isEmpty() &&
gmsManager.isVersionAtLeast(MINIMUM_CXP_GMS_VERSION)
) {
sendEvent(VaultSettingsEvent.NavigateToImportItems)
} else {

View File

@@ -13,8 +13,6 @@ import com.x8bit.bitwarden.ui.auth.feature.accountsetup.navigateToSetupUnlockScr
import com.x8bit.bitwarden.ui.auth.feature.accountsetup.setupAutoFillDestination
import com.x8bit.bitwarden.ui.auth.feature.accountsetup.setupBrowserAutofillDestination
import com.x8bit.bitwarden.ui.auth.feature.accountsetup.setupUnlockDestination
import com.x8bit.bitwarden.ui.platform.feature.premium.plan.navigateToPlanModal
import com.x8bit.bitwarden.ui.platform.feature.premium.plan.planModalDestination
import com.x8bit.bitwarden.ui.platform.feature.search.SearchRoute
import com.x8bit.bitwarden.ui.platform.feature.search.navigateToSearch
import com.x8bit.bitwarden.ui.platform.feature.search.searchDestination
@@ -54,10 +52,6 @@ import com.x8bit.bitwarden.ui.vault.feature.addedit.navigateToVaultAddEdit
import com.x8bit.bitwarden.ui.vault.feature.addedit.vaultAddEditDestination
import com.x8bit.bitwarden.ui.vault.feature.attachments.attachmentDestination
import com.x8bit.bitwarden.ui.vault.feature.attachments.navigateToAttachment
import com.x8bit.bitwarden.ui.vault.feature.attachments.preview.navigateToPreviewAttachment
import com.x8bit.bitwarden.ui.vault.feature.attachments.preview.previewAttachmentDestination
import com.x8bit.bitwarden.ui.vault.feature.cardscanner.cardScanDestination
import com.x8bit.bitwarden.ui.vault.feature.cardscanner.navigateToCardScanScreen
import com.x8bit.bitwarden.ui.vault.feature.importlogins.importLoginsScreenDestination
import com.x8bit.bitwarden.ui.vault.feature.importlogins.navigateToImportLoginsScreen
import com.x8bit.bitwarden.ui.vault.feature.item.navigateToVaultItem
@@ -143,7 +137,6 @@ fun NavGraphBuilder.vaultUnlockedGraph(
onNavigateToAboutPrivilegedApps = {
navController.navigateToAboutPrivilegedAppsScreen()
},
onNavigateToPlan = { navController.navigateToPlanModal() },
)
flightRecorderDestination(
isPreAuth = false,
@@ -174,9 +167,6 @@ fun NavGraphBuilder.vaultUnlockedGraph(
onNavigateToQrCodeScanScreen = {
navController.navigateToQrCodeScanScreen()
},
onNavigateToCardScanScreen = {
navController.navigateToCardScanScreen()
},
onNavigateToManualCodeEntryScreen = {
navController.navigateToManualCodeEntryScreen()
},
@@ -208,10 +198,6 @@ fun NavGraphBuilder.vaultUnlockedGraph(
passwordHistoryMode = GeneratorPasswordHistoryMode.Item(itemId = it),
)
},
onNavigateToPreviewAttachment = { navController.navigateToPreviewAttachment(it) },
)
cardScanDestination(
onNavigateBack = { navController.popBackStack() },
)
vaultQrCodeScanDestination(
onNavigateToManualCodeEntryScreen = {
@@ -262,7 +248,6 @@ fun NavGraphBuilder.vaultUnlockedGraph(
)
attachmentDestination(
onNavigateBack = { navController.popBackStack() },
onNavigateToPreviewAttachment = { navController.navigateToPreviewAttachment(it) },
)
setupUnlockDestination(
onNavigateBack = {
@@ -279,12 +264,6 @@ fun NavGraphBuilder.vaultUnlockedGraph(
importLoginsScreenDestination(
onNavigateBack = { navController.popBackStack() },
)
previewAttachmentDestination(
onNavigateBack = { navController.popBackStack() },
)
planModalDestination(
onNavigateBack = { navController.popBackStack() },
)
}
}

View File

@@ -52,7 +52,6 @@ fun NavGraphBuilder.vaultUnlockedNavBarDestination(
onNavigateToImportLogins: () -> Unit,
onNavigateToAddFolderScreen: (selectedFolderName: String?) -> Unit,
onNavigateToAboutPrivilegedApps: () -> Unit,
onNavigateToPlan: () -> Unit,
) {
composableWithStayTransitions<VaultUnlockedNavbarRoute> {
VaultUnlockedNavBarScreen(
@@ -76,7 +75,6 @@ fun NavGraphBuilder.vaultUnlockedNavBarDestination(
onNavigateToFlightRecorder = onNavigateToFlightRecorder,
onNavigateToRecordedLogs = onNavigateToRecordedLogs,
onNavigateToAboutPrivilegedApps = onNavigateToAboutPrivilegedApps,
onNavigateToPlan = onNavigateToPlan,
)
}
}

View File

@@ -69,7 +69,6 @@ fun VaultUnlockedNavBarScreen(
onNavigateToImportLogins: () -> Unit,
onNavigateToAddFolderScreen: (selectedFolderId: String?) -> Unit,
onNavigateToAboutPrivilegedApps: () -> Unit,
onNavigateToPlan: () -> Unit,
) {
val state by viewModel.stateFlow.collectAsStateWithLifecycle()
@@ -110,7 +109,6 @@ fun VaultUnlockedNavBarScreen(
onNavigateToFlightRecorder = onNavigateToFlightRecorder,
onNavigateToRecordedLogs = onNavigateToRecordedLogs,
onNavigateToAboutPrivilegedApps = onNavigateToAboutPrivilegedApps,
onNavigateToPlan = onNavigateToPlan,
)
}
@@ -146,7 +144,6 @@ private fun VaultUnlockedNavBarScaffold(
onNavigateToImportLogins: () -> Unit,
onNavigateToAddFolderScreen: (selectedFolderId: String?) -> Unit,
onNavigateToAboutPrivilegedApps: () -> Unit,
onNavigateToPlan: () -> Unit,
) {
var shouldDimNavBar by rememberSaveable { mutableStateOf(value = false) }
@@ -205,7 +202,6 @@ private fun VaultUnlockedNavBarScaffold(
navController.navigateToSettingsGraphRoot()
navController.navigateToAutoFill()
},
onNavigateToPlan = onNavigateToPlan,
)
sendGraph(
navController = navController,

View File

@@ -3,17 +3,14 @@ package com.x8bit.bitwarden.ui.platform.model
import android.content.Intent
import androidx.activity.result.ActivityResultLauncher
import androidx.compose.runtime.Immutable
import com.bitwarden.annotation.OmitFromCoverage
/**
* Contains all the callbacks for the Auth Tabs.
*/
@OmitFromCoverage
@Immutable
class AuthTabLaunchers(
val duo: ActivityResultLauncher<Intent>,
val sso: ActivityResultLauncher<Intent>,
val webAuthn: ActivityResultLauncher<Intent>,
val cookie: ActivityResultLauncher<Intent>,
val premiumCheckout: ActivityResultLauncher<Intent>,
)

Some files were not shown because too many files have changed in this diff Show More