mirror of
https://github.com/bitwarden/android.git
synced 2026-05-11 10:38:43 -05:00
Compare commits
3 Commits
renovate/g
...
v2026.3.1-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c2ef20e1ed | ||
|
|
d3f5621f40 | ||
|
|
5ce64c3a47 |
@@ -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: 1–2 | Implementation: 3–7 | Review & PR: 8–10
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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
|
||||
|
||||
2
.github/workflows/crowdin-pull.yml
vendored
2
.github/workflows/crowdin-pull.yml
vendored
@@ -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 }}
|
||||
|
||||
2
.github/workflows/sdlc-sdk-update.yml
vendored
2
.github/workflows/sdlc-sdk-update.yml
vendored
@@ -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 }}
|
||||
|
||||
4
.github/workflows/test.yml
vendored
4
.github/workflows/test.yml
vendored
@@ -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
|
||||
|
||||
22
Gemfile.lock
22
Gemfile.lock
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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": {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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.
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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? =
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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?,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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].
|
||||
*/
|
||||
|
||||
@@ -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>> =
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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()
|
||||
},
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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 = {},
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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) },
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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:")
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
/**
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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() },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user